The Go Programming Language - 에이콘출판사 책을 정리한 글 입니다.
The Go Programming
Chapter 5. 함수
함수는 여러 문장을 하나의 단위로 묶어 프로그램 내의 다른 부분에서 수차례 호출 할 수 있다. 함수를 통해 큰 작업을 여러 작은 작업으로 분할할 수 있으며, 함수를 사용하면 사용자에게 구현의 세부 사항을 숨길 수 있다.
5.1 함수 선언
함수는 아래와 같은 형식으로 이루어져 있다.
func 이름(파라미터 목록) (결과 목록) { 본문 }
- 파라미터 목록
- 함수 파라미터의 이름과 타입을 지정
- 이 인자는 함수를 호출하는 호출자가 값이나 인자를 제공한다.
- 결과 목록
- 함수가 반환하는 값의 타입을 지정한다.
- 함수가 한개의 이름없는 결과를 반환하거나 결과를 반환하지 않을 경우엔 괄호를 사용할 필요가 없다.
- 결과목록을 생략하면 아무 값도 반환하지 않으며, 그 동작을 위해서만 호출되는 함수를 선언한다.
package main import( "fmt" "math" )
func hypot(x, y float64) float64 {
return math.Sqrt(x*x + y*y)
}
func main() {
fmt.Println(hypot(3, 4)) // 결과는 5
}
x와 y는 파라미터이고 3과 4는 호출자가 제공하는 인수이며, 이 함수는 float 64 값을 반환한다. 그리고 파라미터와 마찬가지로 결과에도 이름을 붙일 수 있다. 아래 함수는 위의 hypot 함수의 결과에 이름을 붙인 함수다.
func hypot(x, y float64) (r float64) {
r = math.Sqrt(x*x + y*y)
return r
}
함수의 인자는 값으로 전달되므로 함수는 각 인자의 복사본을 전달 받는다. 이 복사본에 대한 변경은 호출자에 아무 영향을 주지 않지만, 만약 인자가 포인터, 슬라이스, 맵, 함수, 채널 등의 참조형인 경우에는 값의 변경이 호출자에 영향을 준다.
5.2 재귀
함수는 스스로를 직접 또는 간접적으로 재귀 호출할 수 있다.
(재귀함수에 대해선 따로 알아보자. 여기선 예시 코드를 보고 넘어가자)
fetch.go
package main import ( "fmt" "io/ioutil" "net/http" "os" )
func main() {
for _, url := range os.Args[1:] {
resp, err := http.Get(url)
if err != nil {
fmt.Fprintf(os.Stderr, "fetch: %v\\n", err) os.Exit(1)
}
b, err := ioutil.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "fetch: reading %s: %v\\n", url, err)
os.Exit(1)
}
fmt.Printf("%s", b)
}
}
outline.go
package main
import (
"fmt"
"golang.org/x/net/html"
"os"
)
func main() {
doc, err := html.Parse(os.Stdin)
// zero value: 초기값을 할당하지 않고 변수를 만들었을 때 해당 변수가 갖는 값
// nil은 포인터, 인터페이스, 맵, 슬라이스, 채널, 함수의 zero value다.
if err != nil {
fmt.Fprintf(os.Stderr, "outline: %v\\n", err)
os.Exit(1)
}
outline(nil, doc)
}
func outline(stack []string, n *html.Node) {
if n.Type == html.ElementNode {
stack = append(stack, n.Data)
fmt.Println(stack)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
outline(stack, c)
}
}
outline.go 프로그램은 HTML 노드 트리에 재귀를 사용해 개략적인 트리 구조를 출력한다. 재귀실행 하면서 마주치는 각각의 요소의 태그를 스택에 넣고 스택을 출력한다. (fetch.go 는 지정된 URL 에서 HTML 정보를 가져오기 위한 코드인데, 해당 재귀 프로그램을 설명하기 위한 도우미 코드다.)
아래 명령어는 실행 코드 와 결과를 보여준다. go는 build 명령어를 통해 바이너리 파일을 생성할 수 있다. 아래 명령어는 fetch.go 를 빌드해서 생성된 fetch 바이너리 파일의 실행 결과를 바이너리 파일 outline 의 입력으로 사용하는 코드다.
$ go build fetch.go $ go build outline.go
$ ./fetch https://golang.org | ./outline2
[html]
[html head]
[html head meta]
[html head meta]
[html head title]
[html head link]
[html head link]
[html head script]
[html head script]
[html body]
[html body header]
[html body header div]
[html body header div a]
[html body header nav]
[html body header nav a]
[html body header nav a img]
[html body header nav button]
...
재귀호출을 통해 HTML 문서의 태그만 출력한다.
5.3 다중 값 반환
함수는 결과를 한 개 이상 반환할 수 있다. 다음 findLinks 함수는 링크 목록과 오류, 2개의 값을 반환하는 함수다.
package main
import (
"fmt"
"net/http"
"golang.org/x/net/html"
"os"
)
func main() {
for _, url := range os.Args[1:] {
links, err := findLinks(url)
if err != nil {
fmt.Fprintf(os.Stderr, "findlinks2: %v\\n", err)
continue
}
for _, link := range links {
fmt.Println(link)
}
}
}
// findLinks 는 url 에 있는 get 요청을 수행하고
// 결과를 html로 파싱한 후 링크를 추출하고 반환한다.
func findLinks(url string) ([]string, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("getting %s: %s", url, resp.Status)
}
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("parsing %s as HTML: %v", url, err)
}
return visit(nil, doc), nil
}
다중 값을 반환하는 함수를 호출할 때는 변수에 명시적으로 값을 할당해야 한다.
links, err := findLinks(url)
반환 값중 일부를 무시하려면 값을 빈 식별자에 할당하면 된다.
links, _ := findLinks(url)
함수의 결과에 이름을 붙이면 반환문에서 피연산자를 생략할 수 있다. 이를 단순반환이라 한다.
func CountWordsAndImages(url string) (words, images int, err error) {
...
return
}
단순 반환은 이름이 있는 변수를 순서대로 반환하는 단축 문법이다. 하지만 코드를 이해하기 쉽게 하는 경우는 거의 없다. 코드 수를 줄일 수 있다는 장점에 비해 단점이 너무 크기에 삼가는 것이 좋다.
5.4 오류
오류 처리 전략의 가장 흔한 방법으로는 오류를 전파해 서부루틴에서의 실패를 호출 루틴의 실패가 되게 하는 전략이 있다. 아래 코드는 http 오류를 호출자에게 반환한다.
resp, err := http.Get(url)
if err != nil {
return nil, err
}
두번째 전략은 지연시간을 두거나 재시도 횟수 또는 재시도 소요시간을 제한하고 실패한 작업을 다시 시도하는 전략이 있다.
// url 로 지정된 서버로 접속을 시도
// 1분간 지수 단위로 백오프 수행
// 모든 시도가 실패하면 오류를 보고
func waitForserver(url string) error {
const timeout = 1 * time.Minute
deadline := time.Now().Add(timeout)
for tries := 0; time.Now().Before(deadline); tries++ {
_, err := http.Head(url)
if err == nil {
return nil
}
log.Printf("server not responding (%s); retrying...", err)
time.Sleep(time.Second << uint(tries)) // 지수 단위 백오프
}
return fmt.Errorf("server %s failed to respond after %s", url, timeout)
}
세번째 전략은 호출자가 오류를 출력하고 프로그램을 종료하는 방법이 있다. 이러한 역할은 보통 main 패키지의 역할이다.
네번째 전략은 오류를 기록하고 필요시 기능을 약간 제한하며 계속 실행하는 경우가 있다.
다섯번째 전략은 오류를 무시하는 방법이 있다.
Go 의 오류처리에는 특유의 리듬이 있다. 오류를 확인한 후 실패를 성공 전에 처리한다. 실패로 인한 함수가 반환돼야 한다면 성공에 대한 로직은 들여쓰기 한 else 블록이 아닌 외부 수준에 작성을 한다.
5.5 함수 값
Go의 함수는 first-class(일급) 값이다. 이것은 함수 값이 다른 값과 마찬가지로 타입이 있고 함수값을 변수에 할당이 가능, 함수값을 함수 파라미터로 전달이 가능, 함수에서 반환이 가능하다는 것이다. 함수값은 임의의 다른 함수처럼 호출할 수 있다.
package main
import (
"fmt"
"strings"
)
func square(n int) int { return n * n }
func main() {
f1 := square
fmt.Println(f1(3)) // 9 출력
var f func(int) int // 함수 타입의 제로 값은 nil 이다.
fmt.Println(f(3)) // panic: runtime error: invalid memory address or nil pointer dereference
}
함수 값을 이용해 함수 값뿐만 아니라 동작도 전달할 수 있다.
func add2(r rune) rune {
return r + 1
}
func main() {
fmt.Println(strings.Map(add2, "VMS")) // WNT 출력
}
5.2 에서 visit 을 사용해서 HTML의 모든 노드를 방문해 출력하는 코드가 있었는데, 이번에는 다르게 만들어보자. 이번엔 함수 값을 사용해 트리 탐색을 위한 로직과 각 노드에 적용할 액션 로직을 분리해서 만들어보자.
package main
import (
"fmt"
"golang.org/x/net/html"
"net/http"
"os"
)
func main() {
for _, url := range os.Args[1:] {
outline2(url)
}
}
func outline2(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
doc, err := html.Parse(resp.Body)
if err != nil {
return err
}
forEachNode(doc, startElement, endElement)
return nil
}
func forEachNode(n *html.Node, pre, post func(n *html.Node)) {
if pre != nil {
pre(n)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
forEachNode(c, pre, post)
}
if post != nil {
post(n)
}
}
var depth int
func startElement(n *html.Node) {
if n.Type == html.ElementNode {
fmt.Printf("%*s<%s>\n", depth*2, "", n.Data)
depth++
}
}
func endElement(n *html.Node) {
if n.Type == html.ElementNode {
depth--
fmt.Printf("%*s</%s>\n", depth*2, "", n.Data)
}
}
forEachNode 함수는 하위 노드를 방문하기 전에 호출할 함수와 방문한 후에 호출함 함수 두개를 각각 인자로 받는다. startElement, endElement 에서 %s 의 * 포매터는 앞에 가변 길이의 공백이 추가된 문자열을 출력한다. 공백의 폭과 문자열로 depth2와 "" 인자를 사용했다.
출력 결과는 아래와 같다 (defer 명령어에 대해선 아래 연기된 함수에 나와있다)
$ go build outline2.go
$ ./outline2
<html>
<head>
<meta>
</meta>
<title>
</title>
<script>
</script>
<link>
</link>
<style>
</style>
</head>
<body>
<table>
<tbody>
<tr>
<td>
<a>
<img>
</img>
...
5.6 익명함수
명명된 함수는 패키지 수준에서만 선언할 수 있지만, 함수 리터럴로 표현식 내의 어디서나 함수 값을 나타낼 수 있다. 함수 리터럴은 함수 선언과 유사하게 작성하지만 func 키워드 뒤에 이름이 없다. 이는 표현식이며, 그 값을 익명 함수라 한다. 예를 들면
strings.Map(func(r rune) rune {return r+1}, "VMS"} // WNT 출력
익명함수는 내부 함수에서 외부 변수를 참조할 수 있다.
package main
import "fmt"
func squares() func() int {
var x int
return func() int {
x++
return x * x
}
}
func main() {
f := squares()
fmt.Println(f()) // 1
fmt.Println(f()) // 4
fmt.Println(f()) // 9
fmt.Println(f()) // 16
}
squares 함수는 func() int 타입의 다른 함수를 반환한다. 익명 내부 함수는 바깥에 있는 square 함수의 지역 변수에 접근하고 값을 갱신할 수 있다. 이러한 숨겨진 변수 참조가 있기 때문에 함수는 참조 타입으로 정의한다. 함수 값은 closure 라는 기술로 구현하며, go 개발자들은 보통 함수 값이라는 용어를 사용한다.
5.7 가변 인자 함수
가변 인자 함수는 다양한 개수의 인자로 호출할 수 있다.
package main
import "fmt"
func sum(vals ...int) int {
total := 0
for _, val := range vals {
total += val
}
return total
}
func main() {
fmt.Println(sum()) // 0
fmt.Println(sum(3)) // 3
fmt.Println(sum(1, 2, 3, 4)) // 10
}
sum 함수는 0개 이상의 int 인자를 받아서 합을 반환한다. 함수 본문 안에서 vals의 타입은 []int 슬라이스다. 함수를 호출하는 호출자는 묵시적으로 배열을 할당하고 배열에 인자를 복사한 후 함수에 전체 배열의 슬라이스를 전달한다.
5.8 연기된 함수 호출
다음 프로그램은 HTML 문서를 가져와 제목을 출력한다. title 함수는 서버 응답에서 Content-Type 헤더를 검사하고 문서가 HTML이 아닐 경우 오류를 반환한다.
package main
import (
"fmt"
"net/http"
"os"
"strings"
"golang.org/x/net/html"
)
func main() {
for _, arg := range os.Args[1:] {
if err := title(arg); err != nil {
fmt.Fprintf(os.Stderr, "title: %v\n", err)
}
}
}
func title(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
// Check Content-Type is HTML (e.g., "text/html; charset=utf-8").
ct := resp.Header.Get("Content-Type")
if ct != "text/html" && !strings.HasPrefix(ct, "text/html;") {
resp.Body.Close()
return fmt.Errorf("%s has type %s, not text/html", url, ct)
}
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return fmt.Errorf("parsing %s as HTML: %v", url, err)
}
visitNode := func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "title" &&
n.FirstChild != nil {
fmt.Println(n.FirstChild.Data)
}
}
forEachNode(doc, visitNode, nil)
return nil
}
func forEachNode(n *html.Node, pre, post func(n *html.Node)) {
if pre != nil {
pre(n)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
forEachNode(c, pre, post)
}
if post != nil {
post(n)
}
}
이 예제를 실행하면 결과는 아래와 같다.
title 에 네트워크 접속을 닫게 하는 resp.Body.Close() 호출이 중복돼 있다. 함수가 커지면서 복잡해지고 많은 오류를 처리해야 할 일이 생길 때는 이런 중복된 정리 로직이 유지보수에 문제가 될 가능성이 있다. go 의 defer 를 사용하면 이런 문제를 해결할 수 있다.
defer 키워드가 붙은 함수는 정상적으로 반환문 또는 끝에 도달하거나 비정상적인 패닉이 일어나서 완료될 때까지 미뤄진다. defer문은 보통 open과 close, connect와 disconnect, lock과 unlock 과 같이 한 쌍의 작업을 해야할 때 함수의 복잡도와 관계없이 확실하게 리소스를 해제하기 위해 자주 사용된다. 리소스를 해제하는 defer 문의 적절한 위치는 리소스가 성공적으로 할당된 직후다. 위의 title 함수에 defer 문을 사용하면 아래와 같은 코드가 된다.
func title2(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
if ct != "text/html" && !strings.HasPrefix(ct, "text/html;") {
return fmt.Errorf("%s has type %s, not text/html", url, ct)
}
doc, err := html.Parse(resp.Body)
if err != nil {
return fmt.Errorf("parsing %s as HTML: %v", url, err)
}
// title 엘리먼트 출력
visitNode := func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "title" &&
n.FirstChild != nil {
fmt.Println(n.FirstChild.Data)
}
}
forEachNode(doc, visitNode, nil)
return nil
}
defer 문은 디버깅할 때 사용하기도 한다.
package main
import (
"log"
"time"
)
func bigSlowOperation() {
defer trace("bigSlowOperation")()
time.Sleep(10 * time.Second)
}
func trace(msg string) func() {
start := time.Now()
log.Println("enter %s", msg)
return func() { log.Printf("exit %s (%s)", msg, time.Since(start)) }
}
func main() {
bigSlowOperation()
}
결과는 아래와 같다.
2021/01/24 02:45:35 enter %s bigSlowOperation
2021/01/24 02:45:45 exit bigSlowOperation (10.001815647s)
bigSlowOperation 함수는 trace 를 호출해 시작 시간에 대한 액션을 수행하고, 이후에 종료 시간 액션을 수행하는 함수 값을 반환한다. 이와 같이 반환되는 함수에 대한 호출을 연기해 함수의 시작 부분과 모든 종료부분을 하나의 문장으로 조작할 수 있다.
5.9 패닉
Go는 컴파일시 많은 실수를 잡아내지만 배열 범위 바깥쪽 참조나 nil 포인터 참조 등의 실행 시 검사가 필요한 경우도 있다. Go 런타임이 이러한 실수를 발견하면 패닉을 발생시킨다.
보통 패닉 상황에서는 정상 실행이 중단되고 고루틴에 있는 모든 연기된 함수가 호출되며, 프로그램 로그 메시지와 함께 비정상 종료된다. 이 로그 메시지에는 패닉값이 들어 있으며, 프로그램을 다시 실행하지 않고도 문제 원인을 파악할 수 있을 만큼의 충분한 정보가 있으므로 패닉을 일으키는 프로그램에 관한 버그 리포트에는 항상 포함되어야 한다. 패닉은 일부 '불가능한 상황', 이를테면 실행 중 논리적으로 일어날 수 없는 경우에 가장 좋은 대응 방법이다. 아래 코드는 card 가 예상된 종류의 카드가 아닐시 패닉을 일으키게 하는 코드다.
switch s := suit(drawCard()); s {
case "Spades": //
case "Hearts": //
case "Diamonds": //
case "Clubs": //
default:
panic(fmt.Sprintf("invalid suit %q", s)) // Joker?
}
패닉이 발생하면 모든 연기된 함수가 스택 맨 위에서 main 까지 역순으로 실행된다.
package main
import "fmt"
func main() {
f(3)
}
func f(x int) {
fmt.Printf("f(%d) \n", x+0/x)
defer fmt.Printf("defer %d\n", x)
f(x - 1)
}
이 함수의 실행결과는 아래와 같다.
f(3)
f(2)
f(1)
defer 1
defer 2
defer 3
panic: runtime error: integer divide by zero
goroutine 1 [running]:
main.f(0x0) /MyWorkspace/go-programming/src/chapter5/defer1.go:10 +0x1e5
main.f(0x1) /MyWorkspace/go-programming/src/chapter5/defer1.go:12 +0x185
main.f(0x2) /MyWorkspace/go-programming/src/chapter5/defer1.go:12 +0x185
main.f(0x3) /MyWorkspace/go-programming/src/chapter5/defer1.go:12 +0x185
main.main() /MyWorkspace/go-programming/src/chapter5/defer1.go:6 +0x2a
f(0) 호출에서 패닉이 발생해 세 개의 연기된 fmt.Printf 호출이 수행된다.
5.10 복구
패닉 발생시 종료하는 것이 일반적이지만, 복구하거나 종료하기 전에 어떤 처리를 하게 할 수 있다. 예를 들어 웹 서버에 예상치 못한 문제가 발생했을 때는 클라이언트와의 연결을 유지하는 대신 종료할 수 있으며, 개발 시에는 클라이언트에게 오류를 보고할 수 있다.
내장된 recover 함수는 연기된 함수(defer) 안에서 호출되며, defer 구문이 들어있는 함수가 패닉을 일으키면 recover 함수가 현재의 패닉 상태를 끝내고 패닉 값을 반환한다. 패닉을 일으킨 함수는 마지막 부분을 계속하지 않고 정상적으로 반환한다. 패닉이 아닐때 recover 를 호출하면 nil 을 반환한다.
복구는 신중하게 해야 한다. 네트워크 접속이 열린 이후 닫히지 않았거나, 락을 걸고 나서 해제하지 않게 되는 문제가 발생할 수도 있다. 또는 비정상 종료를 로그 파일의 한 줄로 교체하는 식의 무차별 복구로 인해 버그를 알아차리지 못하게 되는 경우도 있다. 복구는 일반적으로 '복구될 의도가 있는 드문 경우'에만 복구해야 한다. 복구 대상이면 패닉을 일반 error 로 보고하고, 복구 대상이 아니라면 패닉 상태에서 재개하기 위해 같은 값으로 panic 을 호출한다. 다음 예제는 HTML 문서에 <title> 원소가 여러개 있는 경우 오류를 보고한다. 이 경우 특별한 타입 값 ballout 으로 panic 을 호출해 재귀를 중단한다.
func soleTitle(doc *html.Node) (title string, err error) {
type bailout struct{}
defer func() {
switch p := recover(); p {
case nil:
// 패닉이 아님
case bailout{}:
// 예상된 패닉
err = fmt.Errorf("multiple title elements")
default:
panic(p) // 예상치 못한 패닉, 패닉 지속
}
}()
// 비어있지 않은 title이 두개 이상이면 재귀에서 탈출.
forEachNode(doc, func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "title" &&
n.FirstChild != nil {
if title != "" {
panic(bailout{}) // multiple title elements
}
title = n.FirstChild.Data
}
}, nil)
if title == "" {
return "", fmt.Errorf("no title element")
}
return title, nil
}
지연된 함수가 recover 를 호출해 패닉 값을 확인하고 그 값이 ballout{} 이라면 일반 오류로 보고한다. 그 외의 nil이 아닌 모든 값은 예상치 못한 패닉이므로 핸들러는 그 값으로 panic 을 호출해 recover를 취소하고 패닉의 원래 상태를 재개한다. 복구할 수 없는 경우도 있다. 예를 들어 메모리가 부족하면 Go 런타임이 치명적인 오류와 함께 프로그램을 종료하게 된다.
이상으로 5장 함수를 마칩니다.