명확하고 관용적인 Go 코드를 작성하기 위한 팁을 정리한 문서입니다.
URL: https://go.dev/doc/effective_go
Title: Effective Go - The Go Programming Language
| 목차 |
|---|
| 소개예제서식주석이름패키지 이름게터(Getter)인터페이스 이름MixedCaps세미콜론제어 구조If재선언과 재할당ForSwitch타입 스위치함수다중 반환값이름 있는 결과 매개변수Defer데이터new로 할당생성자와 복합 리터럴make로 할당배열슬라이스2차원 슬라이스맵출력Append초기화상수변수init 함수 |
Go는 새로운 언어입니다. 기존 언어들에서 아이디어를 빌려 오긴 했지만, 효과적인 Go 프로그램이 그 친척 언어들로 작성된 프로그램과는 성격이 달라지게 만드는 독특한 속성들이 있습니다. C++이나 Java 프로그램을 Go로 단순히 곧장 번역하는 것은 만족스러운 결과를 내기 어렵습니다—Java 프로그램은 Java로 작성되는 것이지 Go로 작성되는 것이 아니니까요. 반대로, Go의 관점에서 문제를 생각하면 성공적인(하지만 상당히 다른) 프로그램을 만들 수도 있습니다. 다시 말해 Go를 잘 쓰려면 그 속성과 관용구를 이해하는 것이 중요합니다. 또한 다른 Go 프로그래머들이 이해하기 쉬운 프로그램을 작성하려면, 이름 짓기, 서식, 프로그램 구성 등 Go에서 확립된 관례를 아는 것도 중요합니다.
이 문서는 명확하고 관용적인(Go스럽게) Go 코드를 작성하기 위한 팁을 제공합니다. 이는 언어 명세, Tour of Go, How to Write Go Code를 보완합니다. 이 문서들은 먼저 읽어 두어야 합니다.
2022년 1월 추가 참고: 이 문서는 2009년 Go 릴리스를 위해 작성되었으며, 그 이후로 큰 폭의 업데이트가 이뤄지지 않았습니다. 언어 자체의 안정성 덕분에 언어 사용법을 이해하는 데는 여전히 좋은 안내서이지만, 라이브러리에 대한 내용은 거의 없고, 작성 이후 Go 생태계에 있었던 중요한 변화(빌드 시스템, 테스트, 모듈, 다형성 등)에 대해서는 아무것도 다루지 않습니다. 많은 일이 있었고 현대적인 Go 사용법을 설명하는 문서, 블로그, 책들이 이미 풍부하게 존재하므로, 이 문서를 업데이트할 계획은 없습니다. Effective Go는 여전히 유용하지만, 독자는 이것이 결코 완전한 안내서가 아니라는 점을 이해해야 합니다. 맥락은 이슈 28782를 참고하세요.
Go 패키지 소스는 핵심 라이브러리일 뿐만 아니라 언어를 사용하는 방법에 대한 예제 역할도 하도록 의도되었습니다. 또한 많은 패키지에는 go.dev 웹사이트에서 바로 실행해 볼 수 있는, 동작하는 독립 실행형 예제가 포함되어 있습니다. 예를 들어 이 예제처럼요(필요하다면 “Example”이라는 단어를 클릭해 펼치세요). 어떤 문제에 어떻게 접근해야 하는지, 또는 어떤 것이 어떻게 구현될 수 있는지 궁금할 때, 라이브러리의 문서, 코드, 예제는 답, 아이디어, 배경지식을 제공해 줄 수 있습니다.
서식 문제는 가장 논쟁적이지만 가장 중요하지는 않은 주제입니다. 사람들은 다양한 서식 스타일에 적응할 수 있지만, 굳이 그럴 필요가 없다면 더 좋고, 모두가 동일한 스타일을 따르면 이 주제에 쓰는 시간도 줄어듭니다. 문제는 긴 규범적 스타일 가이드 없이 어떻게 이런 이상향에 도달하느냐입니다.
Go에서는 특이한 접근을 택해, 대부분의 서식 문제를 기계가 처리하도록 했습니다. gofmt 프로그램(소스 파일 단위가 아니라 패키지 단위로 동작하는 go fmt로도 이용 가능)은 Go 프로그램을 읽어 들여, 주석을 유지하고 필요하다면 재서식화하면서, 들여쓰기와 세로 정렬을 포함한 표준 스타일로 소스를 출력합니다. 새로운 레이아웃 상황을 어떻게 처리해야 할지 알고 싶다면 gofmt를 실행하세요; 결과가 마음에 들지 않는다면 gofmt를 피해가는 대신 프로그램을 재배열하거나(또는 gofmt에 버그 리포트를 올리세요) 하십시오.
예를 들어, 구조체 필드 주석을 줄맞춤하는 데 시간을 들일 필요가 없습니다. gofmt가 알아서 해줍니다. 다음 선언이 있을 때
gotype T struct { name string // name of the object value int // its value }
gofmt는 열을 맞춥니다:
gotype T struct { name string // name of the object value int // its value }
표준 패키지의 모든 Go 코드는 gofmt로 포매팅되어 있습니다.
서식에 관한 몇 가지 세부 사항은 남아 있습니다. 아주 간단히 말하면:
gofmt는 기본으로 탭을 출력합니다. 꼭 필요한 경우에만 공백을 사용하세요.if, for, switch) 문법에는 괄호가 없습니다. 또한 연산자 우선순위 계층이 더 짧고 명확해서, x<<8 + y<<16은 다른 언어들과 달리 공백이 암시하는 그대로 해석됩니다.Go는 C 스타일의 /* */ 블록 주석과 C++ 스타일의 // 한 줄 주석을 제공합니다. 한 줄 주석이 일반적이며, 블록 주석은 주로 패키지 주석으로 나타나지만, 표현식 내부에서 유용하거나 큰 코드 덩어리를 비활성화하는 데도 쓸 수 있습니다.
최상위 선언 바로 앞에(중간에 빈 줄 없이) 나타나는 주석은 해당 선언 자체를 문서화하는 것으로 간주됩니다. 이런 “문서 주석(doc comment)”이 특정 Go 패키지나 명령의 기본 문서입니다. 문서 주석에 대한 자세한 내용은 “Go Doc Comments”를 참고하세요.
이름은 Go에서도 다른 언어와 마찬가지로 중요합니다. 심지어 의미론적 효과도 있습니다. 패키지 밖에서 이름이 보이는지(공개되는지)는 첫 글자가 대문자인지에 따라 결정됩니다. 따라서 Go 프로그램에서의 이름 짓기 관례에 대해 조금 시간을 들여 이야기할 가치가 있습니다.
패키지를 import하면 패키지 이름은 그 내용에 접근하는 접근자 역할을 합니다. 다음을 한 뒤
goimport "bytes"
import하는 패키지는 bytes.Buffer를 사용할 수 있습니다. 패키지를 사용하는 모두가 동일한 이름으로 그 내용들을 지칭할 수 있으면 유용하므로, 패키지 이름은 좋아야 합니다: 짧고, 간결하며, 연상 가능해야 합니다. 관례적으로 패키지는 소문자 단어 하나로 이름 짓습니다. 밑줄이나 mixedCaps가 필요 없어야 합니다. 패키지를 사용하는 모두가 그 이름을 타이핑할 것이므로, 짧은 쪽을 선택하세요. 그리고 미리 충돌을 걱정하지 마세요. 패키지 이름은 import 시 기본 이름일 뿐이며 전체 소스 코드에서 유일할 필요가 없습니다. 드물게 충돌이 나면 import하는 쪽에서 로컬 이름을 다른 것으로 선택할 수 있습니다. 어쨌든 import 경로에 파일(디렉터리) 이름이 포함되어 어떤 패키지를 쓰는지 결정되므로 혼동은 드뭅니다.
또 다른 관례는 패키지 이름이 소스 디렉터리의 마지막 요소(base name)라는 것입니다. src/encoding/base64의 패키지는 "encoding/base64"로 import하지만 이름은 base64이며, encoding_base64도 encodingBase64도 아닙니다.
패키지를 import하는 쪽은 그 이름으로 내용을 참조하므로, 패키지 내부의 export 이름은 반복을 피하기 위해 이 점을 활용할 수 있습니다. (테스트처럼 패키지 밖에서 실행돼야 하는 경우를 단순화할 때를 제외하고는 import . 표기는 피하세요.) 예를 들어 bufio 패키지의 버퍼드 리더 타입은 BufReader가 아니라 Reader입니다. 사용자는 bufio.Reader로 보게 되므로, 명확하고 간결합니다. 또한 import된 엔터티는 항상 패키지 이름으로 수식되므로 bufio.Reader는 io.Reader와 충돌하지 않습니다. 비슷하게, Go에서 _생성자(constructor)_에 해당하는 ring.Ring의 새 인스턴스 생성 함수는 보통 NewRing이라 부를 수 있겠지만, 이 패키지에서 export되는 타입이 Ring 하나뿐이고 패키지 이름이 ring이므로 그냥 New로 부르며, 사용자는 ring.New로 봅니다. 패키지 구조가 좋은 이름 선택에 도움을 주게 하세요.
또 다른 짧은 예는 once.Do입니다. once.Do(setup)은 읽기 좋고, once.DoOrWaitUntilDone(setup)이라고 써도 더 좋아지지 않습니다. 긴 이름이 자동으로 더 읽기 쉬운 것은 아닙니다. 도움이 되는 문서 주석은 지나치게 긴 이름보다 더 가치 있을 때가 많습니다.
Go는 게터/세터를 자동으로 제공하지 않습니다. 직접 제공하는 것이 잘못은 아니고 종종 적절하지만, 게터 이름에 Get을 붙이는 것은 관용적이지도 필요하지도 않습니다. owner라는 필드(소문자, 비공개)가 있다면 게터 메서드는 GetOwner가 아니라 Owner(대문자, 공개)여야 합니다. 공개를 위해 대문자로 시작하는 이름을 쓰는 것이 필드와 메서드를 구분하는 훅 역할을 합니다. 세터가 필요하다면 보통 SetOwner가 될 것입니다. 둘 다 실제로 읽기 좋습니다:
goowner := obj.Owner() if owner != user { obj.SetOwner(user) }
관례적으로 메서드 하나짜리 인터페이스는 메서드 이름에 -er 접미사를 붙이거나 비슷한 변형을 통해 행위자 명사를 만들며 이름 짓습니다: Reader, Writer, Formatter, CloseNotifier 등.
이런 이름들은 다수 존재하며, 이를 존중하고 그들이 포착하는 함수 이름도 존중하는 것이 생산적입니다. Read, Write, Close, Flush, String 등은 표준(정전, canonical) 시그니처와 의미를 갖습니다. 혼동을 피하기 위해, 시그니처와 의미가 같지 않다면 메서드에 그런 이름을 붙이지 마세요. 반대로, 잘 알려진 타입의 메서드와 의미가 같은 메서드를 여러분의 타입이 구현한다면, 같은 이름과 시그니처를 사용하세요. 문자열 변환 메서드는 ToString이 아니라 String으로 하세요.
마지막으로, Go에서는 여러 단어로 된 이름을 밑줄 대신 MixedCaps 또는 mixedCaps로 쓰는 것이 관례입니다.
C처럼 Go의 형식 문법은 세미콜론으로 문장을 끝내지만, C와 달리 소스에는 세미콜론이 나타나지 않습니다. 대신 렉서가 스캔하면서 간단한 규칙으로 세미콜론을 자동 삽입하므로, 입력 텍스트는 대부분 세미콜론이 없습니다.
규칙은 다음과 같습니다. 줄바꿈(newline) 직전의 마지막 토큰이 식별자(여기에는 int, float64 같은 단어도 포함), 숫자나 문자열 상수 같은 기본 리터럴, 또는 다음 토큰들 중 하나라면
break continue fallthrough return ++ -- ) }
렉서는 항상 그 토큰 뒤에 세미콜론을 삽입합니다. 이는 “문장을 끝낼 수 있는 토큰 뒤에 줄바꿈이 오면 세미콜론을 삽입한다”로 요약할 수 있습니다.
닫는 중괄호 바로 앞의 세미콜론은 생략할 수도 있으므로,
gogo func() { for { dst <- <-src } }()
같은 문장은 세미콜론이 필요 없습니다. 관용적인 Go 프로그램에서 세미콜론은 for 루프 절처럼 초기화, 조건, 후처리 요소를 구분하는 자리에서만 나타납니다. 또한 한 줄에 여러 문장을 쓰고 싶다면(그렇게 쓴다면) 문장 구분을 위해 필요합니다.
세미콜론 삽입 규칙의 한 결과로, 제어 구조(if, for, switch, select)의 여는 중괄호를 다음 줄에 둘 수 없습니다. 그렇게 하면 중괄호 앞에 세미콜론이 삽입되어 원치 않는 효과를 낼 수 있습니다. 다음처럼 쓰세요.
goif i < f() { g() }
다음처럼 쓰지 마세요.
goif i < f() // wrong! { // wrong! g() }
Go의 제어 구조는 C의 것과 관련이 있지만 중요한 차이가 있습니다. do나 while 루프는 없고 약간 일반화된 for만 있습니다. switch는 더 유연합니다. if와 switch는 for처럼 선택적 초기화 문을 받을 수 있습니다. break와 continue는 어떤 것을 break/continue할지 식별하기 위한 선택적 레이블을 받을 수 있습니다. 그리고 타입 스위치와 다중 통신 멀티플렉서 select를 포함한 새로운 제어 구조가 있습니다. 문법도 약간 다릅니다. 괄호가 없고 본문은 항상 중괄호로 둘러싸야 합니다.
Go에서 단순한 if는 다음과 같습니다:
goif x > 0 { return y }
중괄호가 필수이기 때문에 단순한 if도 여러 줄로 쓰게 됩니다. 특히 본문에 return이나 break 같은 제어문이 들어 있다면, 어차피 여러 줄로 쓰는 것이 좋은 스타일입니다.
if와 switch는 초기화 문을 허용하므로 로컬 변수를 준비하는 데 자주 사용합니다.
goif err := file.Chmod(0664); err != nil { log.Print(err) return err }
Go 라이브러리에서는 if의 본문이 다음 문장으로 이어지지 않는 경우(즉 본문이 break, continue, goto, return으로 끝나는 경우) 불필요한 else를 생략하는 것을 볼 수 있습니다.
gof, err := os.Open(name) if err != nil { return err } codeUsing(f)
이는 에러 조건 연쇄를 방어해야 하는 흔한 상황의 예입니다. 성공적인 제어 흐름이 페이지 아래로 쭉 내려가고, 에러 케이스는 나타나는 대로 제거되면 코드는 읽기 좋습니다. 에러 케이스는 대개 return으로 끝나므로, 그 결과 else가 필요 없습니다.
gof, err := os.Open(name) if err != nil { return err } d, err := f.Stat() if err != nil { f.Close() return err } codeUsing(f, d)
덧붙임: 앞 절의 마지막 예시는 := 짧은 선언 형식이 어떻게 동작하는지에 대한 디테일을 보여줍니다. os.Open을 호출하는 선언은 다음과 같습니다.
gof, err := os.Open(name)
이 문장은 f와 err 두 변수를 선언합니다. 몇 줄 뒤 f.Stat 호출은 다음과 같습니다.
god, err := f.Stat()
이것은 d와 err를 선언하는 것처럼 보입니다. 하지만 err가 두 문장 모두에 등장합니다. 이 중복은 합법입니다. err는 첫 번째 문장에서 선언되었고, 두 번째에서는 _재할당_만 됩니다. 즉 f.Stat 호출은 위에서 선언된 기존 err 변수를 사용하며 새 값을 넣을 뿐입니다.
:= 선언에서 변수 v는 이미 선언되어 있어도 다음 조건을 만족하면 다시 나타날 수 있습니다:
v의 기존 선언과 동일 스코프에 있다(만약 v가 바깥 스코프에 이미 선언되어 있다면, 이 선언은 새 변수를 만든다 §).v에 대입 가능하다.이 특이한 성질은 순전히 실용성 때문이며, 예를 들어 긴 if-else 체인에서 err 하나를 계속 쓰기 쉽게 해 줍니다. 자주 보게 될 것입니다.
§ 여기서 주목할 점: Go에서 함수 매개변수와 반환값의 스코프는, 본문을 둘러싸는 중괄호 밖에 어휘적으로 나타나더라도, 함수 본문과 같습니다.
Go의 for 루프는 C와 비슷하지만 동일하지는 않습니다. for와 while을 통합하며 do-while은 없습니다. 세 가지 형태가 있고, 그중 하나만 세미콜론을 가집니다.
go// C의 for처럼 for init; condition; post { } // C의 while처럼 for condition { } // C의 for(;;)처럼 for { }
짧은 선언을 사용하면 인덱스 변수를 루프 안에서 바로 선언하기 쉽습니다.
gosum := 0 for i := 0; i < 10; i++ { sum += i }
배열, 슬라이스, 문자열, 맵을 순회하거나 채널에서 읽을 때는 range 절이 루프를 관리할 수 있습니다.
gofor key, value := range oldMap { newMap[key] = value }
범위(range)에서 첫 번째 항목(키 또는 인덱스)만 필요하다면 두 번째를 생략하세요:
gofor key := range m { if key.expired() { delete(m, key) } }
두 번째 항목(값)만 필요하다면, 첫 번째를 버리기 위해 _빈 식별자_인 밑줄 _을 사용하세요:
gosum := 0 for _, value := range array { sum += value }
빈 식별자는 뒤 절에서 설명하듯 다양한 용도가 있습니다.
문자열에서 range는 UTF-8을 파싱하여 개별 유니코드 코드 포인트로 분해하는 일을 더 해줍니다. 잘못된 인코딩은 1바이트를 소비하고 대체 룬(rune) U+FFFD를 생성합니다. (연관된 내장 타입 이름을 가진 rune은 단일 유니코드 코드 포인트에 대한 Go 용어입니다. 자세한 내용은 언어 명세를 보세요.) 다음 루프는
gofor pos, char := range "日本\x80語" { // \x80 is an illegal UTF-8 encoding fmt.Printf("character %#U starts at byte position %d\n", char, pos) }
다음을 출력합니다.
character U+65E5 '日' starts at byte position 0
character U+672C '本' starts at byte position 3
character U+FFFD '�' starts at byte position 6
character U+8A9E '語' starts at byte position 7
마지막으로, Go에는 콤마 연산자가 없고 ++, --은 표현식이 아니라 문장입니다. 따라서 for에서 여러 변수를 함께 진행시키려면 병렬 대입을 사용해야 합니다(그 경우 ++, --은 쓸 수 없습니다).
go// a를 뒤집기 for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 { a[i], a[j] = a[j], a[i] }
Go의 switch는 C보다 일반적입니다. 표현식이 상수이거나 정수일 필요가 없고, case는 위에서 아래로 평가되어 매치가 발견될 때까지 진행합니다. 또한 switch에 표현식이 없으면 true에 대해 스위칭합니다. 따라서 if-else if-else 체인을 switch로 쓰는 것이 가능하며 관용적입니다.
gofunc unhex(c byte) byte { switch { case '0' <= c && c <= '9': return c - '0' case 'a' <= c && c <= 'f': return c - 'a' + 10 case 'A' <= c && c <= 'F': return c - 'A' + 10 } return 0 }
자동 fallthrough는 없지만, case는 콤마로 구분한 목록으로 제시할 수 있습니다.
gofunc shouldEscape(c byte) bool { switch c { case ' ', '?', '&', '=', '#', '+', '%': return true } return false }
Go에서 break는 다른 C 계열 언어들만큼 흔하지는 않지만 switch를 일찍 종료하기 위해 사용할 수 있습니다. 하지만 때로는 스위치가 아니라 바깥 루프를 빠져나와야 하는데, Go에서는 루프에 레이블을 붙이고 그 레이블로 “break”할 수 있습니다. 이 예시는 두 사용법을 모두 보여줍니다.
goLoop: for n := 0; n < len(src); n += size { switch { case src[n] < sizeOne: if validateOnly { break } size = 1 update(src[n]) case src[n] < sizeTwo: if n+1 >= len(src) { err = errShortInput break Loop } if validateOnly { break } size = 2 update(src[n] + src[n+1]<<shift) } }
물론 continue도 선택적 레이블을 받지만 루프에만 적용됩니다.
이 절을 마치며, 두 개의 switch를 사용하는 바이트 슬라이스 비교 루틴을 보겠습니다:
go// Compare returns an integer comparing the two byte slices, // lexicographically. // The result will be 0 if a == b, -1 if a < b, and +1 if a > b func Compare(a, b []byte) int { for i := 0; i < len(a) && i < len(b); i++ { switch { case a[i] > b[i]: return 1 case a[i] < b[i]: return -1 } } switch { case len(a) > len(b): return 1 case len(a) < len(b): return -1 } return 0 }
switch는 인터페이스 변수의 동적 타입을 알아내는 데도 사용할 수 있습니다. 이런 _타입 스위치_는 괄호 안에 type 키워드를 넣은 타입 단언(type assertion) 문법을 사용합니다. switch가 표현식에서 변수를 선언하면, 그 변수는 각 절에서 해당 타입을 갖습니다. 또한 이런 경우 이름을 재사용하는 것이 관용적이며, 각 case에서 같은 이름이지만 타입이 다른 새 변수를 선언하는 효과가 있습니다.
govar t interface{} t = functionOfSomeType() switch t := t.(type) { default: fmt.Printf("unexpected type %T\n", t) // %T는 t의 타입을 출력 case bool: fmt.Printf("boolean %t\n", t) // t는 bool 타입 case int: fmt.Printf("integer %d\n", t) // t는 int 타입 case *bool: fmt.Printf("pointer to boolean %t\n", *t) // t는 *bool 타입 case *int: fmt.Printf("pointer to integer %d\n", *t) // t는 *int 타입 }
Go의 특이한 기능 중 하나는 함수와 메서드가 여러 값을 반환할 수 있다는 점입니다. 이 형태는 C 프로그램에서의 두 가지 어색한 관용구를 개선하는 데 사용할 수 있습니다. 예를 들어 EOF를 -1로 반환하는 식의 인밴드(in-band) 에러 반환, 그리고 주소로 전달한 인자를 수정하는 방식입니다.
C에서 write 오류는 음수 카운트로 신호하고, 오류 코드는 휘발성 위치에 숨겨둡니다. Go에서는 Write가 카운트 그리고 오류를 반환할 수 있습니다. “일부 바이트는 썼지만 장치를 꽉 채워서 전부는 못 썼다” 같은 상황을 표현할 수 있죠. os 패키지의 파일 Write 메서드 시그니처는 다음과 같습니다:
gofunc (file *File) Write(b []byte) (n int, err error)
문서가 말하듯 n != len(b)일 때 n 바이트를 썼고 error가 nil이 아닌 값을 반환합니다. 이는 흔한 스타일입니다. 더 많은 예시는 오류 처리 절을 보세요.
비슷한 접근은 참조 매개변수를 흉내 내기 위해 반환값 포인터를 넘길 필요를 없애 줍니다. 다음은 바이트 슬라이스의 어떤 위치에서 숫자를 뽑아, 그 숫자와 다음 위치를 반환하는 단순한 함수입니다.
gofunc nextInt(b []byte, i int) (int, int) { for ; i < len(b) && !isDigit(b[i]); i++ { } x := 0 for ; i < len(b) && isDigit(b[i]); i++ { x = x*10 + int(b[i]) - '0' } return x, i }
입력 슬라이스 b의 숫자들을 다음과 같이 스캔할 수 있습니다:
gofor i := 0; i < len(b); { x, i = nextInt(b, i) fmt.Println(x) }
Go 함수의 반환 또는 결과 “매개변수”는 이름을 가질 수 있으며, 들어오는 매개변수처럼 일반 변수로 사용할 수 있습니다. 이름이 붙으면 함수 시작 시 해당 타입의 제로 값으로 초기화됩니다. 함수가 인자 없는 return 문을 실행하면, 결과 매개변수의 현재 값이 반환값으로 사용됩니다.
이름은 필수는 아니지만 코드를 더 짧고 명확하게 만들 수 있습니다. 그 자체로 문서이기 때문입니다. nextInt의 결과에 이름을 붙이면 어떤 int가 어떤 의미인지 명확해집니다.
gofunc nextInt(b []byte, pos int) (value, nextPos int) {
이름 있는 결과는 초기화되고 장식 없는 return과 연결되므로, 명확하게 할 뿐 아니라 단순화할 수도 있습니다. 이를 잘 활용한 io.ReadFull의 버전은 다음과 같습니다:
gofunc ReadFull(r Reader, buf []byte) (n int, err error) { for len(buf) > 0 && err == nil { var nr int nr, err = r.Read(buf) n += nr buf = buf[nr:] } return }
Go의 defer 문은 defer를 실행하는 함수가 반환하기 직전에 실행될 함수 호출( 지연된(deferred) 함수)을 예약합니다. 이는 반환 경로가 어떤 것이든 반드시 해제해야 하는 리소스를 다루는 데 특이하지만 효과적인 방법입니다. 대표 예시는 뮤텍스 언락이나 파일 닫기입니다.
go// Contents returns the file's contents as a string. func Contents(filename string) (string, error) { f, err := os.Open(filename) if err != nil { return "", err } defer f.Close() // f.Close will run when we're finished. var result []byte buf := make([]byte, 100) for { n, err := f.Read(buf[0:]) result = append(result, buf[0:n]...) // append is discussed later. if err != nil { if err == io.EOF { break } return "", err // f will be closed if we return here. } } return string(result), nil // f will be closed if we return here. }
Close 같은 호출을 defer하는 것에는 두 가지 장점이 있습니다. 첫째, 파일 닫기를 절대 잊지 않게 해 줍니다. 이는 나중에 새 반환 경로를 추가하도록 함수를 수정할 때 흔히 저지르기 쉬운 실수입니다. 둘째, close가 open 근처에 위치하게 되어 함수 끝에 두는 것보다 훨씬 명확합니다.
지연된 함수(메서드라면 리시버 포함)의 인자는 호출이 실행될 때가 아니라 defer가 실행될 때 평가됩니다. 이는 함수 실행 중 변수가 바뀌는 것을 걱정하지 않게 해줄 뿐 아니라, 하나의 defer 지점으로 여러 함수 실행을 지연시킬 수도 있다는 뜻입니다. 다음은 우스운 예시입니다.
gofor i := 0; i < 5; i++ { defer fmt.Printf("%d ", i) }
지연 함수는 LIFO 순서로 실행되므로, 이 코드는 함수가 반환될 때 4 3 2 1 0을 출력합니다. 더 그럴듯한 예로, 프로그램 전체에서 함수 실행을 추적하는 간단한 방법이 있습니다. 다음처럼 추적 루틴을 작성할 수 있습니다.
gofunc trace(s string) { fmt.Println("entering:", s) } func untrace(s string) { fmt.Println("leaving:", s) } // Use them like this: func a() { trace("a") defer untrace("a") // do something.... }
하지만 defer 인자가 defer 실행 시점에 평가된다는 점을 활용하면 더 잘할 수 있습니다. 추적 루틴이 언트레이스 루틴의 인자를 준비하도록 만들 수 있습니다. 이 예시는:
gofunc trace(s string) string { fmt.Println("entering:", s) return s } func un(s string) { fmt.Println("leaving:", s) } func a() { defer un(trace("a")) fmt.Println("in a") } func b() { defer un(trace("b")) fmt.Println("in b") a() } func main() { b() }
다음을 출력합니다.
entering: b
in b
entering: a
in a
leaving: a
leaving: b
다른 언어에서 블록 수준 리소스 관리에 익숙한 프로그래머에게 defer는 낯설 수 있지만, 가장 흥미롭고 강력한 응용은 바로 이것이 블록 기반이 아니라 함수 기반이라는 사실에서 나옵니다. panic과 recover 절에서 또 다른 가능성의 예를 보게 될 것입니다.
new로 할당¶Go에는 두 가지 할당 프리미티브가 있습니다. 내장 함수 new와 make입니다. 둘은 서로 다른 일을 하고 서로 다른 타입에 적용되므로 헷갈릴 수 있지만 규칙은 간단합니다. 먼저 new부터 봅시다. new는 메모리를 할당하지만, 일부 다른 언어의 동명 기능과 달리 메모리를 _초기화_하지 않고 _0으로 채우기_만 합니다. 즉 new(T)는 타입 T의 새 항목을 위한 0으로 채워진 저장소를 할당하고, 그 주소, 즉 타입 *T의 값을 반환합니다. Go 용어로는 “타입 T의 새로 할당된 제로 값에 대한 포인터”를 반환합니다.
new가 반환하는 메모리는 0으로 채워지므로, 데이터 구조를 설계할 때 각 타입의 제로 값이 추가 초기화 없이도 유용하게 쓰일 수 있도록 하는 것이 좋습니다. 그러면 사용자는 new로 만들고 바로 사용할 수 있습니다. 예를 들어 bytes.Buffer 문서는 “Buffer의 제로 값은 바로 사용할 수 있는 빈 버퍼다”라고 말합니다. 마찬가지로 sync.Mutex에는 명시적 생성자나 Init 메서드가 없습니다. 대신 sync.Mutex의 제로 값은 잠기지 않은(unlocked) 뮤텍스로 정의됩니다.
“제로 값이 유용하다”는 성질은 전이적으로 작동합니다. 다음 타입 선언을 보세요.
gotype SyncedBuffer struct { lock sync.Mutex buffer bytes.Buffer }
SyncedBuffer 타입의 값 역시 할당되거나 선언되는 즉시 사용할 준비가 되어 있습니다. 다음 스니펫에서 p와 v는 추가 설정 없이도 올바르게 동작합니다.
gop := new(SyncedBuffer) // type *SyncedBuffer var v SyncedBuffer // type SyncedBuffer
때로는 제로 값이 충분하지 않아 초기화 생성자가 필요합니다. 다음은 os 패키지에서 가져온 예시입니다.
gofunc NewFile(fd int, name string) *File { if fd < 0 { return nil } f := new(File) f.fd = fd f.name = name f.dirinfo = nil f.nepipe = 0 return f }
보일러플레이트가 많습니다. _복합 리터럴(composite literal)_을 사용하면 단순화할 수 있습니다. 복합 리터럴은 평가될 때마다 새 인스턴스를 만드는 표현식입니다.
gofunc NewFile(fd int, name string) *File { if fd < 0 { return nil } f := File{fd, name, nil, 0} return &f }
C와 달리 로컬 변수의 주소를 반환하는 것은 완전히 괜찮습니다. 변수와 연관된 저장소는 함수가 반환한 뒤에도 살아남습니다. 사실 복합 리터럴의 주소를 취하면 평가될 때마다 새로운 인스턴스를 할당하므로, 마지막 두 줄을 결합할 수 있습니다.
goreturn &File{fd, name, nil, 0}
복합 리터럴의 필드는 순서대로 배치되며 모두 제공되어야 합니다. 하지만 요소를 필드:값 쌍으로 명시적으로 라벨링하면, 초기화는 어떤 순서로든 나타날 수 있고 누락된 것은 각각의 제로 값으로 남습니다. 따라서 다음처럼 쓸 수도 있습니다.
goreturn &File{fd: fd, name: name}
극단적으로, 필드가 하나도 없는 복합 리터럴은 해당 타입의 제로 값을 만듭니다. new(File)과 &File{}는 동등합니다.
복합 리터럴은 배열, 슬라이스, 맵에도 만들 수 있으며, 필드 라벨은 인덱스 또는 맵 키가 됩니다. 다음 예시에서는 Enone, Eio, Einval의 값이 무엇이든(서로 다르기만 하면) 초기화가 동작합니다.
goa := [...]string {Enone: "no error", Eio: "Eio", Einval: "invalid argument"} s := []string {Enone: "no error", Eio: "Eio", Einval: "invalid argument"} m := map[int]string{Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
make로 할당¶다시 할당 이야기로 돌아가서, 내장 함수 make(T, args)는 new(T)와 다른 목적을 갖습니다. 슬라이스, 맵, 채널만 생성하며, 타입 T의 초기화된(0으로만 채워진 것이 아닌) 값을 반환합니다(*T가 아닙니다). 이 구분이 필요한 이유는 이 세 타입이 내부적으로 사용 전에 초기화되어야 하는 데이터 구조에 대한 참조를 표현하기 때문입니다. 예를 들어 슬라이스는 데이터(배열 내부)에 대한 포인터, 길이, 용량을 담는 3요소 디스크립터이며, 이것들이 초기화되기 전까지 슬라이스는 nil입니다. 슬라이스, 맵, 채널에 대해 make는 내부 데이터 구조를 초기화해 사용 가능한 값으로 준비합니다. 예를 들어,
gomake([]int, 10, 100)
은 100개의 int 배열을 할당하고, 길이 10 용량 100인 슬라이스 구조를 만들어 배열 첫 10개 요소를 가리키게 합니다. (슬라이스를 만들 때 용량은 생략할 수 있습니다. 자세한 내용은 슬라이스 절을 보세요.) 반면 new([]int)는 새로 할당되고 0으로 채워진 슬라이스 구조에 대한 포인터, 즉 nil 슬라이스 값에 대한 포인터를 반환합니다.
다음 예시들이 new와 make의 차이를 보여줍니다.
govar p *[]int = new([]int) // 슬라이스 구조를 할당; *p == nil; 거의 쓸모 없음 var v []int = make([]int, 100) // 슬라이스 v는 이제 100개의 int 새 배열을 참조 // 불필요하게 복잡: var p *[]int = new([]int) *p = make([]int, 100, 100) // 관용적: v := make([]int, 100)
make는 맵, 슬라이스, 채널에만 적용되며 포인터를 반환하지 않는다는 점을 기억하세요. 명시적 포인터가 필요하면 new로 할당하거나 변수의 주소를 명시적으로 취하십시오.
배열은 메모리의 세부 레이아웃을 계획할 때 유용하며 때로는 할당을 피하는 데 도움이 되지만, 주로 다음 절의 주제인 슬라이스를 위한 구성 요소입니다. 기반을 위해 배열에 대해 몇 마디만 하겠습니다.
Go에서 배열이 동작하는 방식은 C와 크게 다릅니다. Go에서는,
[10]int와 [20]int는 서로 다른 타입입니다.값 성질은 유용할 수 있지만 비용이 들 수도 있습니다. C 같은 동작과 효율을 원하면 배열에 대한 포인터를 넘길 수 있습니다.
gofunc Sum(a *[3]float64) (sum float64) { for _, v := range *a { sum += v } return } array := [...]float64{7.0, 8.5, 9.1} x := Sum(&array) // 명시적 주소 연산자에 주목
하지만 이런 스타일도 Go에서는 관용적이지 않습니다. 대신 슬라이스를 사용하세요.
슬라이스는 배열을 감싸 데이터 시퀀스에 대해 더 일반적이고 강력하며 편리한 인터페이스를 제공합니다. 변환 행렬 같은 명시적 차원을 가진 항목을 제외하면, Go에서 대부분의 배열 프로그래밍은 단순 배열보다 슬라이스로 합니다.
슬라이스는 내부 배열에 대한 참조를 들고 있으며, 한 슬라이스를 다른 슬라이스에 할당하면 둘 다 같은 배열을 참조합니다. 함수가 슬라이스 인자를 받으면, 슬라이스 요소에 대한 변경은 호출자에게 보입니다. 이는 내부 배열에 대한 포인터를 넘기는 것과 유사합니다. 따라서 Read 함수는 포인터와 카운트를 받는 대신 슬라이스 인자를 받을 수 있으며, 슬라이스의 길이가 읽을 데이터의 상한을 설정합니다. os 패키지의 File 타입의 Read 메서드 시그니처는 다음과 같습니다:
gofunc (f *File) Read(buf []byte) (n int, err error)
이 메서드는 읽은 바이트 수와 오류(있다면)를 반환합니다. 더 큰 버퍼 buf의 처음 32바이트에 읽고 싶다면 버퍼를 슬라이스 하십시오(여기서는 동사로 사용).
gon, err := f.Read(buf[0:32])
이런 슬라이싱은 흔하고 효율적입니다. 효율은 잠시 제쳐두고, 아래 스니펫도 버퍼의 처음 32바이트를 읽습니다.
govar n int var err error for i := 0; i < 32; i++ { nbytes, e := f.Read(buf[i:i+1]) // 1바이트 읽기. n += nbytes if nbytes == 0 || e != nil { err = e break } }
슬라이스의 길이는 내부 배열의 한도 내에 있는 한 바꿀 수 있습니다. 슬라이스 자신을 다시 슬라이스하여 대입하면 됩니다. 내장 함수 cap으로 접근 가능한 슬라이스의 _용량_은 슬라이스가 가질 수 있는 최대 길이를 보고합니다. 다음은 슬라이스에 데이터를 덧붙이는 함수입니다. 데이터가 용량을 넘으면 슬라이스가 재할당됩니다. 결과 슬라이스를 반환합니다. 이 함수는 nil 슬라이스에 len과 cap을 적용해도 합법이며 0을 반환한다는 사실을 이용합니다.
gofunc Append(slice, data []byte) []byte { l := len(slice) if l + len(data) > cap(slice) { // reallocate // Allocate double what's needed, for future growth. newSlice := make([]byte, (l+len(data))*2) // The copy function is predeclared and works for any slice type. copy(newSlice, slice) slice = newSlice } slice = slice[0:l+len(data)] copy(slice[l:], data) return slice }
Append가 slice의 요소는 수정할 수 있지만, 슬라이스 자체(포인터, 길이, 용량을 들고 있는 런타임 데이터 구조)는 값으로 전달되므로 이후에 슬라이스를 반환해야 합니다.
슬라이스에 덧붙이기는 매우 유용해서 append 내장 함수로 제공됩니다. 다만 그 함수의 설계를 이해하려면 조금 더 정보가 필요하므로, 뒤에서 다시 다루겠습니다.
Go의 배열과 슬라이스는 1차원입니다. 2D 배열/슬라이스에 해당하는 것을 만들려면 다음처럼 배열의 배열 또는 슬라이스의 슬라이스를 정의해야 합니다.
gotype Transform [3][3]float64 // 3x3 배열, 사실 배열의 배열. type LinesOfText [][]byte // 바이트 슬라이스들의 슬라이스.
슬라이스는 가변 길이이므로, 내부 슬라이스마다 길이가 달라질 수 있습니다. 이는 흔한 상황이며 LinesOfText 예제처럼 각 줄은 독립적인 길이를 가집니다.
gotext := LinesOfText{ []byte("Now is the time"), []byte("for all good gophers"), []byte("to bring some fun to the party."), }
때로는 2D 슬라이스를 할당해야 하는데, 예를 들면 픽셀 스캔라인을 처리할 때 그렇습니다. 두 가지 방법이 있습니다. 하나는 각 슬라이스를 독립적으로 할당하는 것이고, 다른 하나는 단일 배열을 할당한 뒤 각 슬라이스가 그 안을 가리키게 하는 것입니다. 어떤 것을 쓸지는 애플리케이션에 따라 다릅니다. 슬라이스가 늘거나 줄 수 있다면 다음 줄을 덮어쓰지 않도록 독립적으로 할당해야 합니다. 그렇지 않다면 단일 할당으로 만드는 것이 더 효율적일 수 있습니다. 참고로 두 방법의 개요는 다음과 같습니다. 먼저 줄 단위:
go// 최상위 슬라이스 할당. picture := make([][]uint8, YSize) // y 단위당 한 행. // 각 행을 순회하며 행 슬라이스 할당. for i := range picture { picture[i] = make([]uint8, XSize) }
이제 단일 할당 후 줄로 슬라이싱:
go// 최상위 슬라이스 할당(이전과 동일). picture := make([][]uint8, YSize) // y 단위당 한 행. // 모든 픽셀을 담을 큰 슬라이스 하나 할당. pixels := make([]uint8, XSize*YSize) // picture가 [][]uint8이지만 타입은 []uint8. // 각 행을 순회하며 pixels의 앞쪽에서 행을 잘라냄. for i := range picture { picture[i], pixels = pixels[:XSize], pixels[XSize:] }
맵은 한 타입( 키 )의 값을 다른 타입( 요소 또는 값 )의 값에 연관시키는 편리하고 강력한 내장 데이터 구조입니다. 키는 정수, 부동소수점/복소수, 문자열, 포인터, 인터페이스(동적 타입이 동등성(equality)을 지원하는 한), 구조체, 배열처럼 동등 연산자가 정의된 어떤 타입도 될 수 있습니다. 슬라이스는 동등성이 정의되지 않으므로 맵 키로 사용할 수 없습니다. 슬라이스처럼 맵도 내부 데이터 구조에 대한 참조를 들고 있습니다. 맵을 함수에 넘겨 그 함수가 맵 내용을 바꾸면, 변경은 호출자에게 보입니다.
맵은 콜론으로 구분한 키-값 쌍을 갖는 일반적인 복합 리터럴 문법으로 만들 수 있어, 초기화 시 쉽게 구성할 수 있습니다.
govar timeZone = map[string]int{ "UTC": 0*60*60, "EST": -5*60*60, "CST": -6*60*60, "MST": -7*60*60, "PST": -8*60*60, }
맵 값의 대입과 조회는 배열/슬라이스와 문법적으로 유사하지만, 인덱스가 정수일 필요는 없습니다.
gooffset := timeZone["EST"]
맵에 없는 키로 값을 가져오려고 하면 맵 엔트리 타입의 제로 값을 반환합니다. 예를 들어 값이 정수면 존재하지 않는 키 조회는 0을 반환합니다. 집합(set)은 값 타입이 bool인 맵으로 구현할 수 있습니다. 값을 집합에 넣기 위해 true로 설정하고, 인덱싱으로 테스트합니다.
goattended := map[string]bool{ "Ann": true, "Joe": true, ... } if attended[person] { // person이 맵에 없으면 false fmt.Println(person, "was at the meeting") }
때로는 누락 엔트리와 제로 값을 구분해야 합니다. "UTC" 엔트리가 있는 걸까요, 아니면 맵에 없어서 0인 걸까요? 다중 대입 형태로 구분할 수 있습니다.
govar seconds int var ok bool seconds, ok = timeZone[tz]
이것은 흔히 “comma ok” 관용구라고 부릅니다. 이 예시에서 tz가 존재하면 seconds는 알맞게 설정되고 ok는 true입니다. 존재하지 않으면 seconds는 0이고 ok는 false입니다. 다음 함수는 이를 좋은 오류 보고와 함께 묶습니다.
gofunc offset(tz string) int { if seconds, ok := timeZone[tz]; ok { return seconds } log.Println("unknown time zone:", tz) return 0 }
실제 값은 신경 쓰지 않고 맵에 키가 존재하는지만 테스트하려면, 값 변수 자리에 빈 식별자 (_)를 사용할 수 있습니다.
go_, present := timeZone[tz]
맵 엔트리를 삭제하려면 내장 함수 delete를 사용합니다. 인자는 맵과 삭제할 키입니다. 키가 이미 없어도 안전합니다.
godelete(timeZone, "PDT") // 이제 Standard Time
Go의 서식 출력은 C의 printf 계열과 비슷하지만 더 풍부하고 일반적입니다. 함수들은 fmt 패키지에 있으며 대문자로 시작하는 이름을 가집니다: fmt.Printf, fmt.Fprintf, fmt.Sprintf 등. 문자열 함수(Sprintf 등)는 제공된 버퍼를 채우는 대신 문자열을 반환합니다.
포맷 문자열을 제공할 필요는 없습니다. Printf, Fprintf, Sprintf 각각에 대해 예를 들어 Print, Println 같은 다른 함수 쌍이 있습니다. 이 함수들은 포맷 문자열을 받지 않고 각 인자에 대해 기본 포맷을 생성합니다. Println 버전은 인자 사이에 공백을 넣고 출력 끝에 줄바꿈을 붙이는 반면, Print 버전은 양쪽 피연산자 중 어느 쪽도 문자열이 아닐 때만 공백을 추가합니다. 다음 예시에서 각 줄은 같은 출력을 만듭니다.
gofmt.Printf("Hello %d\n", 23) fmt.Fprint(os.Stdout, "Hello ", 23, "\n") fmt.Println("Hello", 23) fmt.Println(fmt.Sprint("Hello ", 23))
fmt.Fprint 계열의 첫 번째 인자는 io.Writer 인터페이스를 구현하는 어떤 객체도 될 수 있습니다. os.Stdout, os.Stderr는 익숙한 인스턴스입니다.
여기서부터 C와 달라집니다. 우선 %d 같은 숫자 포맷은 signedness나 크기 플래그를 받지 않습니다. 대신 출력 루틴이 인자의 타입을 보고 이런 속성을 결정합니다.
govar x uint64 = 1<<64 - 1 fmt.Printf("%d %x; %d %x\n", x, x, int64(x), int64(x))
출력:
18446744073709551615 ffffffffffffffff; -1 -1
정수는 10진수처럼 기본 변환이 필요하면 “value”를 뜻하는 포괄 포맷 %v를 사용할 수 있습니다. 결과는 Print/Println이 만드는 것과 정확히 같습니다. 또한 이 포맷은 배열, 슬라이스, 구조체, 맵 등 어떤 값도 출력할 수 있습니다. 앞 절의 timeZone 맵에 대한 출력은 다음과 같습니다.
gofmt.Printf("%v\n", timeZone) // 또는 fmt.Println(timeZone)
출력:
map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]
맵의 경우 Printf 계열은 키를 사전식으로 정렬해 출력합니다.
구조체를 출력할 때 %+v는 필드를 이름과 함께 주석처럼 달아주며, 어떤 값이든 대체 포맷 %#v는 전체 Go 문법 형태로 출력합니다.
gotype T struct { a int b float64 c string } t := &T{ 7, -2.35, "abc\tdef" } fmt.Printf("%v\n", t) fmt.Printf("%+v\n", t) fmt.Printf("%#v\n", t) fmt.Printf("%#v\n", timeZone)
출력:
&{7 -2.35 abc def}
&{a:7 b:-2.35 c:abc def}
&main.T{a:7, b:-2.35, c:"abc\tdef"}
map[string]int{"CST":-21600, "EST":-18000, "MST":-25200, "PST":-28800, "UTC":0}
(앰퍼샌드에 주목하세요.) 인용 문자열 포맷은 string 또는 []byte에 %q를 적용하면 사용할 수 있습니다. 대체 포맷 %#q는 가능하면 백쿼트로 출력합니다. (%q는 정수와 룬에도 적용되며 작은따옴표로 감싼 룬 상수 형태를 생성합니다.) 또한 %x는 정수뿐 아니라 문자열, 바이트 배열/슬라이스에도 동작해 긴 16진 문자열을 생성하며, 포맷에 공백을 넣은 % x는 바이트 사이에 공백을 넣습니다.
또 다른 유용한 포맷은 %T로, 값의 _타입_을 출력합니다.
gofmt.Printf("%T\n", timeZone)
출력:
map[string]int
커스텀 타입의 기본 출력 포맷을 제어하고 싶다면, 타입에 String() string 시그니처의 메서드를 정의하면 됩니다. 단순 타입 T에 대해 다음처럼 쓸 수 있습니다.
gofunc (t *T) String() string { return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c) } fmt.Printf("%v\n", t)
출력 포맷:
7/-2.35/"abc\tdef"
(포인터 *T뿐 아니라 T 값도 출력해야 한다면 String의 리시버는 값 타입이어야 합니다. 이 예시는 구조체 타입에 대해 더 효율적이고 관용적인 포인터 리시버를 사용했습니다. 자세한 내용은 아래의 포인터 vs 값 리시버 절을 보세요.)
이 String 메서드는 출력 루틴들이 완전 재진입(reentrant) 가능하므로 이런 식으로 Sprintf를 감싸도 됩니다. 하지만 중요한 디테일이 하나 있습니다. Sprintf가 String 메서드를 무한 재귀로 다시 호출하게 만드는 방식으로 String을 구성하지 마세요. Sprintf가 리시버를 직접 문자열로 출력하려 시도하면 다시 String을 호출하게 되어 이런 일이 생길 수 있습니다. 흔하고 쉬운 실수입니다.
gotype MyString string func (m MyString) String() string { return fmt.Sprintf("MyString=%s", m) // Error: will recur forever. }
해결은 쉽습니다. 메서드가 없는 기본 string 타입으로 변환하세요.
gotype MyString string func (m MyString) String() string { return fmt.Sprintf("MyString=%s", string(m)) // OK: 변환에 주목. }
초기화 절에서 이 재귀를 피하는 또 다른 기법을 보게 될 것입니다.
또 다른 출력 기법은 한 출력 루틴의 인자들을 다른 출력 루틴에 직접 넘기는 것입니다. Printf의 시그니처는 마지막 인자로 ...interface{} 타입을 사용해 포맷 뒤에 임의 개수(임의 타입)의 매개변수가 올 수 있음을 나타냅니다.
gofunc Printf(format string, v ...interface{}) (n int, err error) {
Printf 내부에서 v는 []interface{} 타입 변수처럼 동작하지만, 또 다른 가변 인자 함수에 전달할 때는 일반 인자 목록처럼 동작합니다. 위에서 사용한 log.Println의 구현은 다음과 같습니다. 실제 포매팅은 fmt.Sprintln에 위임합니다.
go// Println prints to the standard logger in the manner of fmt.Println. func Println(v ...interface{}) { std.Output(2, fmt.Sprintln(v...)) // Output takes parameters (int, string) }
중첩 호출에서 v 뒤에 ...를 써서 컴파일러에게 v를 인자 목록으로 취급하라고 알려야 합니다. 그렇지 않으면 v 슬라이스 자체를 단일 인자로 전달하게 됩니다.
출력에는 여기서 다룬 것보다 더 많은 내용이 있습니다. 자세한 내용은 fmt 패키지의 godoc 문서를 보세요.
참고로 ... 매개변수는 특정 타입일 수도 있습니다. 예를 들어 정수 목록의 최소값을 고르는 min 함수는 ...int를 쓸 수 있습니다.
gofunc Min(a ...int) int { min := int(^uint(0) >> 1) // largest int for _, i := range a { if i < min { min = i } } return min }
이제 append 내장 함수의 설계를 설명하기 위해 필요했던 마지막 조각이 갖춰졌습니다. append의 시그니처는 위의 커스텀 Append 함수와 다릅니다. 개략적으로는 다음과 같습니다.
gofunc append(slice []_T_, elements ..._T_) []_T_
여기서 _T_는 임의의 타입을 위한 자리표시자입니다. 호출자에 의해 타입 T가 결정되는 함수를 Go에서 실제로 작성할 수는 없습니다. append가 내장인 이유가 이것입니다. 컴파일러 지원이 필요합니다.
append는 요소들을 슬라이스 끝에 덧붙이고 결과를 반환합니다. 내부 배열이 바뀔 수 있으므로 결과를 반환해야 합니다. 다음 예시
gox := []int{1,2,3} x = append(x, 4, 5, 6) fmt.Println(x)
는 [1 2 3 4 5 6]을 출력합니다. 따라서 append는 Printf처럼 임의 개수의 인자를 모으는 방식으로 동작합니다.
그런데 슬라이스에 슬라이스를 덧붙이고 싶다면? 간단합니다. 위의 Output 호출에서 했던 것처럼 호출 지점에서 ...를 사용하세요. 아래 스니펫은 위와 같은 출력을 만듭니다.
gox := []int{1,2,3} y := []int{4,5,6} x = append(x, y...) fmt.Println(x)
...가 없으면 타입이 맞지 않아 컴파일되지 않습니다. y는 int 타입이 아니기 때문입니다.
겉보기에는 C나 C++의 초기화와 크게 달라 보이지 않지만, Go의 초기화는 더 강력합니다. 초기화 중에 복잡한 구조를 만들 수 있고, 서로 다른 패키지 간에 초기화된 객체들의 순서 문제도 올바르게 처리됩니다.
Go의 상수는 말 그대로 상수입니다. 함수 안의 로컬로 정의하더라도 컴파일 타임에 생성되며, 숫자, 문자(룬), 문자열, 불리언만 될 수 있습니다. 컴파일 타임 제약 때문에, 상수를 정의하는 표현식은 컴파일러가 평가할 수 있는 상수 표현식이어야 합니다. 예를 들어 1<<3은 상수 표현식이지만, math.Sin(math.Pi/4)는 런타임에 math.Sin 호출이 필요하므로 상수 표현식이 아닙니다.
Go에서는 열거 상수를 iota 열거자로 만듭니다. iota는 표현식의 일부일 수 있고 표현식은 암묵적으로 반복될 수 있으므로, 복잡한 값 집합을 쉽게 만들 수 있습니다.
gotype ByteSize float64 const ( _ = iota KB ByteSize = 1 << (10 * iota) MB GB TB PB EB ZB YB )
사용자 정의 타입에 String 같은 메서드를 붙일 수 있는 능력 덕분에, 임의의 값이 출력 시 자동으로 자기 자신을 포맷하도록 만들 수 있습니다. 흔히 구조체에 적용되는 것을 보지만, ByteSize 같은 스칼라 타입에도 유용합니다.
gofunc (b ByteSize) String() string { switch { case b >= YB: return fmt.Sprintf("%.2fYB", b/YB) case b >= ZB: return fmt.Sprintf("%.2fZB", b/ZB) case b >= EB: return fmt.Sprintf("%.2fEB", b/EB) case b >= PB: return fmt.Sprintf("%.2fPB", b/PB) case b >= TB: return fmt.Sprintf("%.2fTB", b/TB) case b >= GB: return fmt.Sprintf("%.2fGB", b/GB) case b >= MB: return fmt.Sprintf("%.2fMB", b/MB) case b >= KB: return fmt.Sprintf("%.2fKB", b/KB) } return fmt.Sprintf("%.2fB", b) }
YB는 1.00YB로 출력되며, ByteSize(1e13)은 9.09TB로 출력됩니다.
여기서 ByteSize의 String을 구현하기 위해 Sprintf를 쓰는 것이 안전한 이유는 변환 때문이 아니라 %f로 호출하기 때문입니다. %f는 문자열 포맷이 아니므로, Sprintf는 문자열이 필요할 때만 String 메서드를 호출하는데 %f는 부동소수점 값을 원합니다.
변수는 상수와 비슷하게 초기화할 수 있지만, 초기화식은 런타임에 계산되는 일반 표현식이 될 수 있습니다.
govar ( home = os.Getenv("HOME") user = os.Getenv("USER") gopath = os.Getenv("GOPATH") )
마지막으로, 각 소스 파일은 필요한 상태를 설정하기 위한 인자 없는 init 함수를 정의할 수 있습니다. (실제로 각 파일은 여러 개의 init 함수를 가질 수 있습니다.) 그리고 정말로 “마지막”입니다: init은 패키지의 모든 변수 선언이 초기화식을 평가한 뒤 호출되며, 그 초기화식 평가는 import된 모든 패키지가 초기화된 이후에만 이루어집니다.
선언으로 표현할 수 없는 초기화 외에도, init 함수의 흔한 용도는 실제 실행이 시작되기 전에 프로그램 상태의 정확성을 검증하거나 수리하는 것입니다.
gofunc init() { if user == "" { log.Fatal("$USER not set") } if home == "" { home = "/home/" + user } if gopath == "" { gopath = home + "/go" } // gopath may be overridden by --gopath flag on command line. flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH") }
ByteSize에서 보았듯이, 메서드는 포인터나 인터페이스를 제외한 어떤 이름 있는(named) 타입에도 정의할 수 있습니다. 리시버가 구조체일 필요도 없습니다.
위의 슬라이스 논의에서 Append 함수를 작성했습니다. 이를 슬라이스에 대한 메서드로 정의할 수도 있습니다. 그러려면 메서드를 바인딩할 수 있는 이름 있는 타입을 먼저 선언하고, 메서드의 리시버를 그 타입의 값으로 만들면 됩니다.
gotype ByteSlice []byte func (slice ByteSlice) Append(data []byte) []byte { // Body exactly the same as the Append function defined above. }
이 경우에도 업데이트된 슬라이스를 반환해야 합니다. 리시버를 ByteSlice에 대한 _포인터_로 바꾸면, 메서드가 호출자의 슬라이스를 덮어쓸 수 있어 이 어색함을 없앨 수 있습니다.
gofunc (p *ByteSlice) Append(data []byte) { slice := *p // Body as above, without the return. *p = slice }
사실 더 나아갈 수도 있습니다. 이 함수를 표준 Write 메서드처럼 다음과 같이 바꾸면,
gofunc (p *ByteSlice) Write(data []byte) (n int, err error) { slice := *p // Again as above. *p = slice return len(data), nil }
*ByteSlice 타입은 표준 인터페이스 io.Writer를 만족하게 되어 유용합니다. 예를 들어 여기에 출력할 수 있습니다.
govar b ByteSlice fmt.Fprintf(&b, "This hour has %d days\n", 7)
io.Writer를 만족하는 것은 *ByteSlice뿐이므로 ByteSlice의 주소를 넘깁니다. 리시버에서 포인터 vs 값을 선택하는 규칙은 다음과 같습니다. 값 메서드는 포인터와 값 모두에서 호출할 수 있지만, 포인터 메서드는 포인터에서만 호출할 수 있습니다.
이 규칙은 포인터 메서드가 리시버를 수정할 수 있기 때문에 생깁니다. 값을 대상으로 포인터 메서드를 호출하면 메서드는 값의 복사본을 받게 되어 수정이 버려질 것입니다. 그래서 언어가 이를 금지합니다. 다만 편리한 예외가 있습니다. 값이 주소 지정 가능(addressable)하다면, 언어가 흔한 경우를 처리하기 위해 값에서 포인터 메서드를 호출할 때 주소 연산자를 자동으로 삽입해 줍니다. 예시에서 변수 b는 주소 지정 가능이므로 b.Write처럼 호출해도 되며, 컴파일러가 이를 (&b).Write로 재작성해 줍니다.
참고로, 바이트 슬라이스에 Write를 사용하는 아이디어는 bytes.Buffer 구현의 핵심입니다.
Go의 인터페이스는 객체의 동작을 명시하는 방법을 제공합니다. 어떤 것이 이것 을 할 수 있다면, 여기 에서 사용할 수 있습니다. 이미 몇 가지 간단한 예를 보았습니다. String 메서드로 커스텀 프린터를 구현할 수 있고, Fprintf는 Write 메서드가 있는 어떤 곳으로든 출력을 생성할 수 있습니다. 한두 개 메서드만 가진 인터페이스는 Go 코드에서 흔하며, 보통 Write를 구현하는 것을 나타내는 io.Writer처럼 메서드에서 유래한 이름을 가집니다.
한 타입은 여러 인터페이스를 구현할 수 있습니다. 예를 들어 어떤 컬렉션이 sort 패키지의 루틴으로 정렬되려면 sort.Interface(Len(), Less(i, j int) bool, Swap(i, j int) 포함)를 구현하면 됩니다. 또한 커스텀 포매터를 가질 수도 있습니다. 다음 인위적 예시에서 Sequence는 둘 다를 만족합니다.
gotype Sequence []int func (s Sequence) Len() int { return len(s) } func (s Sequence) Less(i, j int) bool { return s[i] < s[j] } func (s Sequence) Swap(i, j int) { s[i], s[j] = s[j], s[i] } func (s Sequence) Copy() Sequence { copy := make(Sequence, 0, len(s)) return append(copy, s...) } func (s Sequence) String() string { s = s.Copy() sort.Sort(s) str := "[" for i, elem := range s { if i > 0 { str += " " } str += fmt.Sprint(elem) } return str + "]" }
Sequence의 String 메서드는 이미 슬라이스에 대해 Sprint가 하는 일을 재구현하고 있습니다(게다가 O(N²)로 좋지 않습니다). Sprint를 호출하기 전에 Sequence를 일반 []int로 변환하면 노력도 공유하고 속도도 올릴 수 있습니다.
gofunc (s Sequence) String() string { s = s.Copy() sort.Sort(s) return fmt.Sprint([]int(s)) }
이 메서드는 String 메서드에서 Sprintf를 안전하게 호출하기 위한 변환 기법의 또 다른 예입니다. 두 타입(Sequence와 []int)은 타입 이름만 무시하면 같으므로 서로 변환하는 것이 합법입니다. 변환은 새 값을 만들지 않고, 기존 값이 잠시 다른 타입을 가진 것처럼 동작하게 합니다. (정수→부동소수점처럼 새 값을 만드는 합법적 변환도 있습니다.)
Go 프로그램에서는 표현식의 타입을 변환해 다른 메서드 집합에 접근하는 것이 관용구입니다. 예를 들어 기존 타입 sort.IntSlice를 사용하면 전체 예시를 다음처럼 줄일 수 있습니다.
gotype Sequence []int // 출력용 메서드 - 출력 전에 요소를 정렬 func (s Sequence) String() string { s = s.Copy() sort.IntSlice(s).Sort() return fmt.Sprint([]int(s)) }
이제 Sequence가 (정렬과 출력이라는) 여러 인터페이스를 구현하는 대신, 데이터 항목이 여러 타입(Sequence, sort.IntSlice, []int)으로 변환될 수 있다는 점을 이용해 각 타입이 일의 일부를 하도록 하고 있습니다. 실무에서는 다소 덜 흔하지만 효과적일 수 있습니다.
타입 스위치는 변환의 한 형태입니다. 인터페이스를 받아 스위치의 각 case에서 어떤 의미로는 해당 case의 타입으로 변환합니다. 아래는 fmt.Printf 내부에서 타입 스위치를 사용해 값을 문자열로 바꾸는 방식의 단순화한 버전입니다. 이미 문자열이면 인터페이스가 들고 있는 실제 문자열 값을 원하고, String 메서드가 있으면 그 호출 결과를 원합니다.
gotype Stringer interface { String() string } var value interface{} // Value provided by caller. switch str := value.(type) { case string: return str case Stringer: return str.String() }
첫 case는 구체 값을 찾고, 두 번째는 인터페이스를 다른 인터페이스로 변환합니다. 이런 식으로 타입을 섞는 것은 완전히 괜찮습니다.
딱 한 타입만 관심 있다면 어떨까요? 값이 string을 담고 있다고 알고 있고 꺼내고만 싶다면? 한 case짜리 타입 스위치도 되지만, _타입 단언(type assertion)_도 됩니다. 타입 단언은 인터페이스 값에서 지정된 명시적 타입의 값을 추출합니다. 문법은 타입 스위치의 절 오프닝에서 빌려오지만 type 키워드 대신 명시적 타입을 씁니다:
value.(typeName)
결과는 정적 타입이 typeName인 새 값입니다. 그 타입은 인터페이스가 담고 있는 구체 타입이거나, 값이 변환될 수 있는 두 번째 인터페이스 타입이어야 합니다. 값 안에 있는 문자열을 꺼내려면 다음처럼 쓸 수 있습니다.
gostr := value.(string)
하지만 값에 문자열이 없으면 프로그램은 런타임 오류로 크래시합니다. 이를 방지하려면 “comma, ok” 관용구로 안전하게 테스트하세요.
gostr, ok := value.(string) if ok { fmt.Printf("string value is: %q\n", str) } else { fmt.Printf("value is not a string\n") }
타입 단언이 실패하면 str은 여전히 string 타입으로 존재하지만 제로 값인 빈 문자열을 갖습니다.
이 기능을 보여주는 예로, 이 절을 연 타입 스위치와 동등한 if-else 문은 다음과 같습니다.
goif str, ok := value.(string); ok { return str } else if str, ok := value.(Stringer); ok { return str.String() }
어떤 타입이 오직 인터페이스를 구현하기 위해 존재하고, 그 인터페이스 외에 공개 메서드를 가질 일이 없다면, 타입 자체를 공개할 필요는 없습니다. 인터페이스만 공개하면 값이 인터페이스에 기술된 것 이상의 흥미로운 동작이 없음을 분명히 합니다. 또한 흔한 메서드의 문서를 모든 인스턴스마다 반복할 필요가 없어집니다.
이런 경우 생성자는 구현 타입이 아니라 인터페이스 값을 반환해야 합니다. 예를 들어 해시 라이브러리에서 crc32.NewIEEE와 adler32.New는 모두 인터페이스 타입 hash.Hash32를 반환합니다. Go 프로그램에서 Adler-32를 CRC-32로 바꾸려면 생성자 호출만 바꾸면 되고, 나머지 코드는 알고리즘 변경의 영향을 받지 않습니다.
비슷한 접근으로 다양한 crypto 패키지의 스트리밍 암호 알고리즘을, 그것들이 체인으로 연결하는 블록 암호와 분리할 수 있습니다. crypto/cipher 패키지의 Block 인터페이스는 단일 블록 데이터의 암호화를 제공하는 블록 암호의 동작을 지정합니다. 그런 다음 bufio 패키지와 유사하게, 이 인터페이스를 구현하는 cipher 패키지들을 사용해 블록 암호 세부를 몰라도 Stream 인터페이스로 표현되는 스트리밍 암호를 구성할 수 있습니다.
crypto/cipher의 인터페이스는 다음과 같습니다:
gotype Block interface { BlockSize() int Encrypt(dst, src []byte) Decrypt(dst, src []byte) } type Stream interface { XORKeyStream(dst, src []byte) }
카운터 모드(CTR) 스트림 정의는 다음과 같습니다. 블록 암호의 세부가 추상화된 점에 주목하세요:
go// NewCTR returns a Stream that encrypts/decrypts using the given Block in // counter mode. The length of iv must be the same as the Block's block size. func NewCTR(block Block, iv []byte) Stream
NewCTR은 특정 암호 알고리즘이나 특정 데이터 소스에만 적용되는 것이 아니라, Block 인터페이스의 어떤 구현과 어떤 Stream에도 적용됩니다. 인터페이스 값을 반환하므로 CTR을 다른 암호 모드로 대체하는 것은 국소적인 변경입니다. 생성자 호출은 수정해야 하지만, 주변 코드는 결과를 Stream으로만 다뤄야 하므로 차이를 느끼지 못합니다.
거의 모든 것에 메서드를 붙일 수 있으므로, 거의 모든 것이 인터페이스를 만족할 수 있습니다. http 패키지의 Handler 인터페이스는 이를 잘 보여줍니다. Handler를 구현하는 어떤 객체도 HTTP 요청을 처리할 수 있습니다.
gotype Handler interface { ServeHTTP(ResponseWriter, *Request) }
ResponseWriter 자체도 클라이언트에 응답을 반환하는 데 필요한 메서드들에 접근할 수 있게 해주는 인터페이스입니다. 여기에는 표준 Write 메서드가 포함되므로 http.ResponseWriter는 io.Writer를 사용할 수 있는 곳이면 어디서든 사용할 수 있습니다. Request는 클라이언트의 요청을 파싱한 표현을 담는 구조체입니다.
간단히 하기 위해 POST는 무시하고 HTTP 요청이 항상 GET이라고 가정하겠습니다. 이런 단순화는 핸들러 설정 방식에는 영향을 주지 않습니다. 다음은 페이지 방문 횟수를 세는 사소한 핸들러 구현입니다.
go// Simple counter server. type Counter struct { n int } func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) { ctr.n++ fmt.Fprintf(w, "counter = %d\n", ctr.n) }
(테마에 맞게 Fprintf가 http.ResponseWriter로 출력할 수 있음을 주목하세요.) 실제 서버라면 ctr.n 접근은 동시 접근으로부터 보호되어야 합니다. 제안은 sync와 atomic 패키지를 보세요.
참고로 URL 트리의 노드에 서버를 붙이는 방법은 다음과 같습니다.
goimport "net/http" ... ctr := new(Counter) http.Handle("/counter", ctr)
그런데 왜 Counter를 구조체로 만들까요? 정수 하나면 충분합니다. (증가가 호출자에게 보이려면 리시버는 포인터여야 합니다.)
go// Simpler counter server. type Counter int func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) { *ctr++ fmt.Fprintf(w, "counter = %d\n", *ctr) }
프로그램에 페이지 방문 사실을 통지받아야 하는 내부 상태가 있다면? 웹 페이지에 채널을 묶으세요.
go// A channel that sends a notification on each visit. // (Probably want the channel to be buffered.) type Chan chan *http.Request func (ch Chan) ServeHTTP(w http.ResponseWriter, req *http.Request) { ch <- req fmt.Fprint(w, "notification sent") }
마지막으로, 서버 바이너리를 실행할 때 사용한 인자들을 /args에 보여주고 싶다고 합시다. 인자들을 출력하는 함수는 쉽게 쓸 수 있습니다.
gofunc ArgServer() { fmt.Println(os.Args) }
이를 어떻게 HTTP 서버로 바꿀까요? 어떤 타입의 메서드로 만들고 값을 무시할 수도 있지만 더 깔끔한 방법이 있습니다. 포인터와 인터페이스를 제외한 어떤 타입에도 메서드를 정의할 수 있으므로, 함수에 대해 메서드를 쓸 수도 있습니다. http 패키지에는 다음 코드가 있습니다:
go// The HandlerFunc type is an adapter to allow the use of // ordinary functions as HTTP handlers. If f is a function // with the appropriate signature, HandlerFunc(f) is a // Handler object that calls f. type HandlerFunc func(ResponseWriter, *Request) // ServeHTTP calls f(w, req). func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) { f(w, req) }
HandlerFunc는 ServeHTTP라는 메서드를 가진 타입이므로, 그 타입의 값은 HTTP 요청을 처리할 수 있습니다. 메서드 구현을 보세요. 리시버는 함수 f이고, 메서드는 f를 호출합니다. 이상해 보일 수 있지만, 리시버가 채널이고 메서드가 채널로 송신하는 것과 크게 다르지 않습니다.
ArgServer를 HTTP 서버로 만들기 위해 먼저 시그니처를 맞춥니다.
go// Argument server. func ArgServer(w http.ResponseWriter, req *http.Request) { fmt.Fprintln(w, os.Args) }
이제 ArgServer는 HandlerFunc와 같은 시그니처를 가지므로, Sequence를 IntSlice로 변환해 메서드에 접근했던 것처럼, 그 타입으로 변환해 메서드에 접근할 수 있습니다. 설정 코드는 간결합니다.
gohttp.Handle("/args", http.HandlerFunc(ArgServer))
누군가 /args 페이지를 방문하면, 그 페이지에 설치된 핸들러는 값 ArgServer와 타입 HandlerFunc를 갖습니다. HTTP 서버는 그 타입의 ServeHTTP 메서드를 호출하고(리시버는 ArgServer), 그 메서드는 다시 ArgServer를 호출합니다(HandlerFunc.ServeHTTP 내부의 f(w, req) 호출을 통해). 그러면 인자들이 표시됩니다.
이 절에서는 구조체, 정수, 채널, 함수로 HTTP 서버를 만들었습니다. 인터페이스는 메서드 집합일 뿐이고, (거의) 모든 타입에 메서드를 정의할 수 있기 때문입니다.
빈 식별자는 for range 루프와 맵 맥락에서 몇 번 언급했습니다. 빈 식별자는 어떤 타입의 어떤 값으로든 대입되거나 선언될 수 있으며, 값은 해롭게 버려집니다. 유닉스의 /dev/null 파일에 쓰는 것과 비슷합니다. 변수가 필요하지만 실제 값은 무관한 곳에서 자리표시자(write-only 값)로 쓰입니다. 지금까지 본 것 이상으로 더 많은 용도가 있습니다.
for range 루프에서 빈 식별자를 쓰는 것은 일반적인 상황, 즉 다중 대입의 특수한 경우입니다.
대입에서 왼쪽에 여러 값이 필요한데, 그중 하나가 프로그램에서 사용되지 않는다면, 왼쪽에 빈 식별자를 두면 더미 변수를 만들 필요가 없고 값이 버려진다는 것이 분명해집니다. 예를 들어 반환값과 오류를 반환하는 함수를 호출할 때, 오류만 중요하다면, 무관한 반환값은 빈 식별자로 버리세요.
goif _, err := os.Stat(path); os.IsNotExist(err) { fmt.Printf("%s does not exist\n", path) }
가끔 오류를 무시하기 위해 오류 값을 버리는 코드를 보게 되는데, 이는 끔찍한 관행입니다. 오류 반환은 이유가 있어 제공됩니다. 항상 확인하세요.
go// Bad! This code will crash if path does not exist. fi, _ := os.Stat(path) if fi.IsDir() { fmt.Printf("%s is a directory\n", path) }
사용하지 않는 패키지를 import하거나 사용하지 않는 변수를 선언하는 것은 오류입니다. 사용되지 않는 import는 프로그램을 비대하게 하고 컴파일을 느리게 하며, 초기화되었지만 사용되지 않는 변수는 최소한 낭비 계산이고 더 큰 버그의 징후일 수 있습니다. 하지만 활발히 개발 중인 프로그램에서는 사용되지 않는 import와 변수가 자주 생기며, 컴파일을 진행시키기 위해 지웠다가 나중에 다시 필요해지는 일이 번거로울 수 있습니다. 빈 식별자는 이를 위한 우회로를 제공합니다.
다음 미완성 프로그램에는 사용되지 않는 import 두 개(fmt, io)와 사용되지 않는 변수 하나(fd)가 있어 컴파일되지 않습니다. 하지만 지금까지의 코드가 맞는지 확인하고 싶을 수 있습니다.
gopackage main import ( "fmt" "io" "log" "os" ) func main() { fd, err := os.Open("test.go") if err != nil { log.Fatal(err) } }
사용되지 않는 import에 대한 불평을 잠재우려면, import한 패키지의 심볼을 빈 식별자로 참조하세요. 마찬가지로 사용하지 않는 변수 fd를 빈 식별자에 대입하면, 사용되지 않는 변수 오류를 잠재울 수 있습니다. 이 버전은 컴파일됩니다.
gopackage main import ( "fmt" "io" "log" "os" ) var _ = fmt.Printf var _ io.Reader func main() { fd, err := os.Open("test.go") if err != nil { log.Fatal(err) } _ = fd }
관례적으로 import 오류를 잠재우는 전역 선언은 import 바로 뒤에 두고 주석을 달아, 찾기 쉽게 하고 나중에 정리해야 한다는 것을 상기시키는 것이 좋습니다.
이전 예시의 fmt나 io 같은 사용되지 않는 import는 결국 사용되거나 제거되어야 합니다. 빈 대입은 코드를 작업 중으로 표시합니다. 하지만 때때로 명시적 사용 없이 부수 효과만을 위해 패키지를 import하는 것이 유용할 수 있습니다. 예를 들어 net/http/pprof 패키지는 init 함수에서 디버깅 정보를 제공하는 HTTP 핸들러를 등록합니다. 이 패키지는 export API를 갖지만 대부분의 클라이언트는 핸들러 등록만 필요하고 웹 페이지로 데이터에 접근합니다. 부수 효과만을 위해 import하려면 패키지 이름을 빈 식별자로 바꾸세요:
goimport _ "net/http/pprof"
이 import 형태는 패키지가 부수 효과를 위해 import되었음을 분명히 합니다. 이 파일에서 패키지는 이름이 없으므로 다른 용도가 있을 수 없습니다. (이름이 있고 그 이름을 사용하지 않으면 컴파일러가 프로그램을 거부합니다.)
인터페이스 논의에서 보았듯이, 타입은 자신이 어떤 인터페이스를 구현한다고 명시적으로 선언할 필요가 없습니다. 대신 인터페이스의 메서드를 구현하기만 하면 인터페이스를 구현합니다. 실무에서 대부분의 인터페이스 변환은 정적이며 컴파일 타임에 검사됩니다. 예를 들어 io.Reader를 기대하는 함수에 *os.File을 넘기는 코드는, *os.File이 io.Reader를 구현하지 않으면 컴파일되지 않습니다.
하지만 일부 인터페이스 검사는 런타임에 일어나기도 합니다. 예를 들면 encoding/json 패키지는 Marshaler 인터페이스를 정의합니다. JSON 인코더가 그 인터페이스를 구현하는 값을 받으면 표준 변환 대신 그 마샬링 메서드를 호출합니다. 인코더는 다음 같은 타입 단언으로 이를 런타임에 검사합니다:
gom, ok := val.(json.Marshaler)
타입이 인터페이스를 구현하는지 여부만 묻고 인터페이스 값 자체는 사용하지 않으려면, 아마 오류 검사 일부로, 타입 단언한 값을 빈 식별자로 무시하세요:
goif _, ok := val.(json.Marshaler); ok { fmt.Printf("value %v of type %T implements json.Marshaler\n", val, val) }
또 다른 상황은 타입을 구현하는 패키지 내부에서, 그 타입이 실제로 인터페이스를 만족함을 보장해야 할 때입니다. 예를 들어 json.RawMessage 같은 타입이 커스텀 JSON 표현이 필요하다면 json.Marshaler를 구현해야 하지만, 컴파일러가 자동으로 이를 검증하는 정적 변환이 코드에 없을 수도 있습니다. 실수로 인터페이스를 만족하지 못하더라도 JSON 인코더는 동작하지만 커스텀 구현을 사용하지 않을 것입니다. 구현이 올바름을 보장하려면, 패키지에 빈 식별자를 사용하는 전역 선언을 둘 수 있습니다.
govar _ json.Marshaler = (*RawMessage)(nil)
이 선언에서 *RawMessage를 Marshaler로 변환하는 대입은 *RawMessage가 Marshaler를 구현해야 한다는 것을 요구하며, 이는 컴파일 타임에 검사됩니다. json.Marshaler 인터페이스가 바뀌면 이 패키지는 더 이상 컴파일되지 않으며 업데이트가 필요함을 알게 됩니다.
이 구성에서 빈 식별자가 등장하는 것은 이 선언이 변수를 만들기 위한 것이 아니라 타입 체크를 위한 것임을 나타냅니다. 하지만 모든 타입에 대해 이런 일을 하지는 마세요. 관례적으로 이런 선언은 코드에 이미 정적 변환이 존재하지 않는 경우에만 사용되며, 이는 드문 일입니다.
Go는 전형적인 타입 중심의 서브클래싱 개념을 제공하지 않지만, 구조체나 인터페이스에 타입을 _임베딩(embedding)_하여 구현의 일부를 “빌려오는” 능력이 있습니다.
인터페이스 임베딩은 아주 간단합니다. io.Reader와 io.Writer 인터페이스를 앞서 언급했는데, 정의는 다음과 같습니다.
gotype Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) }
io 패키지는 이런 메서드들을 여러 개 구현할 수 있는 객체를 지정하는 다른 인터페이스들도 export합니다. 예를 들어 io.ReadWriter는 Read와 Write를 모두 포함하는 인터페이스입니다. 두 메서드를 명시적으로 나열해도 되지만, 두 인터페이스를 임베딩해 새 인터페이스를 만드는 편이 더 쉽고 더 의미를 잘 드러냅니다.
go// ReadWriter is the interface that combines the Reader and Writer interfaces. type ReadWriter interface { Reader Writer }
이는 보이는 그대로입니다. ReadWriter는 Reader가 할 수 있는 것을 그리고 Writer가 할 수 있는 것을 할 수 있습니다. 즉 임베딩된 인터페이스들의 합집합입니다. 인터페이스 안에는 인터페이스만 임베딩할 수 있습니다.
같은 기본 아이디어가 구조체에도 적용되지만, 더 광범위한 의미를 갖습니다. bufio 패키지에는 bufio.Reader와 bufio.Writer 두 구조체 타입이 있고, 물론 둘 다 io 패키지의 대응 인터페이스들을 구현합니다. 또한 bufio는 리더와 라이터를 임베딩으로 하나의 구조체에 결합해 버퍼드 리더/라이터도 구현합니다. 구조체 안에 타입을 나열하지만 필드 이름은 주지 않습니다.
go// ReadWriter stores pointers to a Reader and a Writer. // It implements io.ReadWriter. type ReadWriter struct { *Reader // *bufio.Reader *Writer // *bufio.Writer }
임베딩 요소는 구조체에 대한 포인터이며, 사용 전에 유효한 구조체를 가리키도록 초기화해야 합니다. ReadWriter 구조체는 다음처럼도 쓸 수 있습니다.
gotype ReadWriter struct { reader *Reader writer *Writer }
하지만 그러면 필드의 메서드를 승격(promote)시키고 io 인터페이스들을 만족하려면, 다음처럼 포워딩 메서드도 제공해야 합니다.
gofunc (rw *ReadWriter) Read(p []byte) (n int, err error) { return rw.reader.Read(p) }
구조체를 직접 임베딩하면 이런 부기 작업을 피할 수 있습니다. 임베딩된 타입의 메서드가 공짜로 따라오므로 bufio.ReadWriter는 bufio.Reader와 bufio.Writer의 메서드를 모두 갖고, io.Reader, io.Writer, io.ReadWriter 세 인터페이스를 모두 만족합니다.
임베딩과 서브클래싱의 중요한 차이가 있습니다. 타입을 임베딩하면 그 타입의 메서드는 바깥 타입의 메서드가 되지만, 호출 시 메서드의 리시버는 바깥 타입이 아니라 안쪽 타입입니다. 예시에서 bufio.ReadWriter의 Read 메서드를 호출하면, 위에 적어둔 포워딩 메서드와 정확히 같은 효과가 있습니다. 리시버는 ReadWriter 자신이 아니라 그 내부의 reader 필드입니다.
임베딩은 단순한 편의 기능일 수도 있습니다. 다음 예시는 임베딩 필드와 일반적인 이름 있는 필드를 함께 보여줍니다.
gotype Job struct { Command string *log.Logger }
Job 타입은 이제 *log.Logger의 Print, Printf, Println 등의 메서드를 가집니다. 물론 Logger에 필드 이름을 줄 수도 있지만 그럴 필요가 없습니다. 초기화만 되면 Job에 로그를 남길 수 있습니다:
gojob.Println("starting now...")
Logger는 Job 구조체의 일반 필드이므로, Job의 생성자 내부에서 일반적인 방식으로 초기화할 수 있습니다.
gofunc NewJob(command string, logger *log.Logger) *Job { return &Job{command, logger} }
또는 복합 리터럴로:
gojob := &Job{command, log.New(os.Stderr, "Job: ", log.Ldate)}
임베딩된 필드를 직접 참조해야 한다면, 패키지 한정자를 무시한 타입 이름이 필드 이름 역할을 합니다. 따라서 job 변수의 *log.Logger에 접근하려면 job.Logger라고 씁니다. 이는 Logger 메서드를 더 정교하게 만들고 싶을 때 유용합니다.
gofunc (job *Job) Printf(format string, args ...interface{}) { job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...)) }
임베딩은 이름 충돌 문제를 만들 수 있지만 규칙은 간단합니다. 첫째, 필드나 메서드 X는 더 깊이 중첩된 부분에 있는 다른 X를 가립니다. log.Logger에 Command라는 필드나 메서드가 있다면 Job의 Command 필드가 우선합니다.
둘째, 같은 중첩 수준에 같은 이름이 나타나면 보통 오류입니다. Job에 Logger라는 다른 필드나 메서드가 있다면 log.Logger를 임베딩하는 것은 오류가 됩니다. 다만 중복된 이름이 타입 정의 밖에서 프로그램에서 전혀 언급되지 않는다면 허용됩니다. 이 조건은 외부에서 임베딩한 타입에 변경이 생길 때 어느 정도 보호를 제공합니다. 서로 다른 서브타입에 서로 충돌하는 필드가 추가되더라도 어느 쪽도 사용되지 않는다면 문제가 없습니다.
동시성 프로그래밍은 큰 주제이며, 여기서는 Go 특유의 하이라이트 몇 가지를 다룰 공간만 있습니다.
많은 환경에서 동시성 프로그래밍이 어려운 이유는 공유 변수에 대한 올바른 접근을 구현하기 위해 요구되는 미묘함 때문입니다. Go는 공유 값을 채널로 전달하고, 사실 별도의 실행 스레드들이 적극적으로 공유하지 않는 접근을 장려합니다. 어떤 시점에도 오직 하나의 고루틴만 그 값에 접근합니다. 설계상 데이터 레이스가 발생할 수 없습니다. 이런 사고방식을 장려하기 위해 이를 슬로건으로 줄였습니다:
메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.
이 접근을 너무 과도하게 적용할 수도 있습니다. 예를 들어 참조 카운트는 정수 변수에 뮤텍스를 씌우는 편이 더 나을 수 있습니다. 하지만 고수준 접근으로는, 채널로 접근을 제어하면 명확하고 올바른 프로그램을 더 쉽게 작성할 수 있습니다.
이 모델을 생각하는 한 가지 방법은, 한 CPU에서 실행되는 전형적인 단일 스레드 프로그램을 떠올리는 것입니다. 동기화 프리미티브가 필요 없습니다. 이제 이런 인스턴스를 하나 더 실행해도 역시 동기화가 필요 없습니다. 이제 둘이 통신하게 하세요. 통신이 동기화라면 여전히 다른 동기화는 필요 없습니다. 예를 들어 유닉스 파이프라인은 이 모델에 완벽히 들어맞습니다. Go의 동시성 접근은 Hoare의 CSP(Communicating Sequential Processes)에서 유래했지만, 타입 안전한 유닉스 파이프의 일반화로 볼 수도 있습니다.
기존 용어—스레드, 코루틴, 프로세스 등—는 부정확한 뉘앙스를 주기 때문에 _고루틴(goroutine)_이라 부릅니다. 고루틴은 간단한 모델을 가집니다. 동일한 주소 공간에서 다른 고루틴들과 동시에 실행되는 함수입니다. 스택 공간 할당 정도의 비용만 드는 가벼운 존재입니다. 스택은 작게 시작해 저렴하고, 필요에 따라 힙 저장소를 할당(및 해제)하면서 늘어납니다.
고루틴은 여러 OS 스레드에 멀티플렉싱되므로, 하나가 I/O 대기 등으로 블록되더라도 다른 것들은 계속 실행됩니다. 이 설계는 스레드 생성과 관리의 복잡성을 많이 숨깁니다.
함수/메서드 호출 앞에 go 키워드를 붙이면 새 고루틴에서 호출을 실행합니다. 호출이 끝나면 고루틴은 조용히 종료합니다. (유닉스 셸에서 &로 백그라운드 실행하는 것과 비슷한 효과입니다.)
gogo list.Sort() // list.Sort를 동시에 실행; 기다리지 않음.
고루틴 호출에서는 함수 리터럴이 유용할 수 있습니다.
gofunc Announce(message string, delay time.Duration) { go func() { time.Sleep(delay) fmt.Println(message) }() // 괄호에 주목 - 함수를 호출해야 함. }
Go에서 함수 리터럴은 클로저입니다. 구현은 함수가 참조하는 변수가 활성 상태인 동안 살아남도록 보장합니다.
하지만 이 예시들은 완료를 알릴 방법이 없으므로 실용적이지 않습니다. 이를 위해 채널이 필요합니다.
맵처럼 채널도 make로 할당하며, 결과 값은 내부 데이터 구조에 대한 참조처럼 동작합니다. 선택적 정수 매개변수를 제공하면 채널의 버퍼 크기를 설정합니다. 기본은 0이며, 이는 버퍼가 없는(unbuffered) 또는 동기식(synchronous) 채널입니다.
goci := make(chan int) // int의 버퍼 없는 채널 cj := make(chan int, 0) // int의 버퍼 없는 채널 cs := make(chan *os.File, 100) // *File 포인터의 버퍼 채널
버퍼 없는 채널은 통신(값 교환)과 동기화(두 계산(고루틴)이 알려진 상태에 있음을 보장)를 결합합니다.
채널을 사용하는 좋은 관용구가 많이 있습니다. 시작으로 하나 보겠습니다. 앞 절에서 백그라운드 정렬을 시작했습니다. 채널은 정렬이 끝날 때까지 시작한 고루틴이 기다리도록 해줍니다.
goc := make(chan int) // 채널 할당. // 고루틴에서 정렬을 시작하고, 완료되면 채널에 신호. go func() { list.Sort() c <- 1 // 신호 전송; 값은 중요하지 않음. }() doSomethingForAWhile() <-c // 정렬이 끝날 때까지 대기; 보낸 값은 버림.
수신자는 항상 받을 데이터가 있을 때까지 블록됩니다. 채널이 버퍼가 없으면, 송신자는 수신자가 값을 받을 때까지 블록됩니다. 채널에 버퍼가 있으면, 송신자는 값이 버퍼에 복사될 때까지만 블록됩니다. 버퍼가 가득 찼다면 이는 어떤 수신자가 값을 꺼낼 때까지 기다린다는 뜻입니다.
버퍼 채널은 처리량을 제한하기 위해 세마포어처럼 사용할 수 있습니다. 다음 예시에서 들어오는 요청은 handle로 전달됩니다. handle은 채널로 값을 보내고 요청을 처리한 뒤 채널에서 값을 받아 다음 소비자를 위한 “세마포어”를 준비합니다. 채널 버퍼의 용량이 process의 동시 호출 수를 제한합니다.
govar sem = make(chan int, MaxOutstanding) func handle(r *Request) { sem <- 1 // Wait for active queue to drain. process(r) // May take a long time. <-sem // Done; enable next request to run. } func Serve(queue chan *Request) { for { req := <-queue go handle(req) // Don't wait for handle to finish. } }
MaxOutstanding개의 핸들러가 process를 실행 중이면, 그 이상은 채워진 채널 버퍼로 송신하려다 블록되고, 기존 핸들러 중 하나가 끝나 버퍼에서 수신할 때까지 기다립니다.
하지만 이 설계에는 문제가 있습니다. Serve는 들어오는 모든 요청마다 새 고루틴을 만들지만, 어떤 순간에도 MaxOutstanding개만 실행될 수 있습니다. 결과적으로 요청이 너무 빨리 들어오면 프로그램이 무한정 리소스를 소모할 수 있습니다. 이를 해결하려면 Serve를 바꿔 고루틴 생성 자체를 게이트로 막을 수 있습니다:
gofunc Serve(queue chan *Request) { for req := range queue { sem <- 1 go func() { process(req) <-sem }() } }
(Go 1.22 이전 버전에서는 이 코드에 버그가 있습니다. 루프 변수가 모든 고루틴에서 공유됩니다. 자세한 내용은 Go 위키를 보세요.)
리소스를 잘 관리하는 다른 접근은, 고정된 수의 handle 고루틴을 시작해 모두 요청 채널에서 읽게 하는 것입니다. 고루틴 수가 process의 동시 호출 수를 제한합니다. 다음 Serve 함수는 종료 지시를 받을 채널도 받습니다. 고루틴들을 시작한 뒤, 그 채널에서 수신하며 블록합니다.
gofunc handle(queue chan *Request) { for r := range queue { process(r) } } func Serve(clientRequests chan *Request, quit chan bool) { // Start handlers for i := 0; i < MaxOutstanding; i++ { go handle(clientRequests) } <-quit // Wait to be told to exit. }
Go의 중요한 속성 중 하나는 채널이 일급 값(first-class value)이라서 다른 값처럼 할당하고 전달할 수 있다는 것입니다. 이 속성의 흔한 용도는 안전한 병렬 디멀티플렉싱을 구현하는 것입니다.
앞 절 예시에서 handle은 요청을 처리하는 이상화된 핸들러였지만 처리 대상 타입을 정의하지 않았습니다. 그 타입에 응답을 보낼 채널이 포함되어 있다면, 각 클라이언트는 자신의 응답 경로를 제공할 수 있습니다. 타입 Request의 개략적 정의는 다음과 같습니다.
gotype Request struct { args []int f func([]int) int resultChan chan int }
클라이언트는 함수와 인자, 그리고 요청 객체 안의 응답 채널을 제공합니다.
gofunc sum(a []int) (s int) { for _, v := range a { s += v } return } request := &Request{[]int{3, 4, 5}, sum, make(chan int)} // Send request clientRequests <- request // Wait for response. fmt.Printf("answer: %d\n", <-request.resultChan)
서버 쪽에서는 핸들러 함수만 바뀝니다.
gofunc handle(queue chan *Request) { for req := range queue { req.resultChan <- req.f(req.args) } }
현실적으로 만들려면 더 많은 일이 필요하겠지만, 이 코드는 속도 제한이 있고 병렬이며 논블로킹인 RPC 시스템의 프레임워크이고 뮤텍스는 하나도 없습니다.
이 아이디어의 또 다른 응용은 계산을 여러 CPU 코어에 걸쳐 병렬화하는 것입니다. 계산을 독립적으로 실행 가능한 조각으로 나눌 수 있다면, 각 조각이 끝났음을 알리는 채널을 사용해 병렬화할 수 있습니다.
벡터의 각 항목에 대해 비싼 연산을 수행해야 하고, 각 항목에 대한 연산 값이 서로 독립적이라고 가정합시다. 다음은 이상화된 예시입니다.
gotype Vector []float64 // Apply the operation to v[i], v[i+1] ... up to v[n-1]. func (v Vector) DoSome(i, n int, u Vector, c chan int) { for ; i < n; i++ { v[i] += u.Op(v[i]) } c <- 1 // signal that this piece is done }
루프에서 CPU당 하나씩 조각들을 독립적으로 실행합니다. 완료 순서는 어떤 순서든 상관없고, 고루틴을 모두 시작한 뒤 채널을 비우며 완료 신호를 세기만 하면 됩니다.
goconst numCPU = 4 // number of CPU cores func (v Vector) DoAll(u Vector) { c := make(chan int, numCPU) // Buffering optional but sensible. for i := 0; i < numCPU; i++ { go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c) } // Drain the channel. for i := 0; i < numCPU; i++ { <-c // wait for one task to complete } // All done. }
numCPU를 상수로 만들기보다는 런타임에 적절한 값을 물어볼 수도 있습니다. runtime.NumCPU는 머신의 하드웨어 CPU 코어 수를 반환하므로 다음처럼 쓸 수 있습니다.
govar numCPU = runtime.NumCPU()
또한 runtime.GOMAXPROCS 함수가 있어 Go 프로그램이 동시에 실행할 수 있는 코어 수를 사용자 설정에 따라 보고(또는 설정)합니다. 기본값은 runtime.NumCPU지만, 같은 이름의 셸 환경 변수를 설정하거나 양수를 인자로 호출해 오버라이드할 수 있습니다. 0으로 호출하면 값만 조회합니다. 따라서 사용자 리소스 요청을 존중하려면 다음처럼 써야 합니다.
govar numCPU = runtime.GOMAXPROCS(0)
동시성—프로그램을 독립적으로 실행되는 구성 요소로 구조화하는 것—과 병렬성—여러 CPU에서 효율을 위해 계산을 병렬로 실행하는 것—을 혼동하지 마세요. Go의 동시성 기능은 일부 문제를 병렬 계산으로 구조화하기 쉽게 만들지만, Go는 병렬 언어가 아니라 동시성 언어이며, 모든 병렬화 문제가 Go 모델에 맞는 것은 아닙니다. 이 차이에 대한 논의는 이 블로그 포스트에 인용된 발표를 보세요.
동시성 프로그래밍 도구는 비동시성 아이디어도 더 쉽게 표현하게 만들 수 있습니다. 다음은 RPC 패키지에서 추상화한 예시입니다. 클라이언트 고루틴은 네트워크 같은 소스로부터 데이터를 받는 루프를 돌고, 버퍼를 할당/해제하지 않기 위해 free list를 유지하며, 이를 표현하기 위해 버퍼 채널을 사용합니다. 채널이 비어 있으면 새 버퍼를 할당합니다. 메시지 버퍼가 준비되면 serverChan으로 서버에 보냅니다.
govar freeList = make(chan *Buffer, 100) var serverChan = make(chan *Buffer) func client() { for { var b *Buffer // Grab a buffer if available; allocate if not. select { case b = <-freeList: // Got one; nothing more to do. default: // None free, so allocate a new one. b = new(Buffer) } load(b) // Read next message from the net. serverChan <- b // Send to server. } }
서버 루프는 각 메시지를 받아 처리하고, 버퍼를 free list로 돌려놓습니다.
gofunc server() { for { b := <-serverChan // Wait for work. process(b) // Reuse buffer if there's room. select { case freeList <- b: // Buffer on free list; nothing more to do. default: // Free list full, just carry on. } } }
클라이언트는 freeList에서 버퍼를 가져오려 하며, 없으면 새로 할당합니다. 서버는 freeList로 b를 보내 리스트가 가득 차지 않았다면 free list에 다시 넣습니다. 리스트가 가득 차면 버퍼를 바닥에 떨어뜨려 GC가 회수하게 둡니다. (select의 default 절은 다른 case가 준비되지 않았을 때 실행되므로, 이 select들은 절대 블록하지 않습니다.) 이 구현은 버퍼 채널과 GC를 이용해 몇 줄로 새는 버킷 free list를 구성합니다.
라이브러리 루틴은 종종 호출자에게 어떤 형태로든 오류를 반환해야 합니다. 앞서 언급했듯이 Go의 다중 반환은 정상 반환값과 함께 상세한 오류 설명을 반환하기 쉽게 해 줍니다. 이 기능으로 자세한 오류 정보를 제공하는 것이 좋은 스타일입니다. 예를 들어 os.Open은 실패 시 nil 포인터만 반환하지 않고, 무엇이 잘못됐는지 설명하는 오류 값도 반환합니다.
관례적으로 오류는 error 타입을 가지며, 이는 간단한 내장 인터페이스입니다.
gotype error interface { Error() string }
라이브러리 작성자는 내부적으로 더 풍부한 모델로 이 인터페이스를 구현할 수 있어, 오류를 보는 것뿐 아니라 맥락도 제공할 수 있습니다. 앞서 언급했듯 os.Open은 *os.File 반환값과 함께 오류를 반환합니다. 파일이 성공적으로 열리면 오류는 nil이지만, 문제가 있으면 os.PathError를 담습니다:
go// PathError records an error and the operation and // file path that caused it. type PathError struct { Op string // "open", "unlink", etc. Path string // The associated file. Err error // Returned by the system call. } func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
PathError의 Error는 다음 같은 문자열을 생성합니다.
open /etc/passwx: no such file or directory
문제가 된 파일 이름, 수행한 연산, 운영체제 오류를 포함한 이런 오류는, 원인이 된 호출로부터 멀리 떨어진 곳에서 출력되더라도 유용합니다. 단순한 “no such file or directory”보다 훨씬 정보가 풍부합니다.
가능하다면 오류 문자열은 생성한 연산이나 패키지 이름 같은 출처를 식별할 수 있어야 합니다. 예를 들어 image 패키지에서 알 수 없는 포맷 때문에 생긴 디코딩 오류의 문자열 표현은 “image: unknown format”입니다.
정확한 오류 세부에 관심 있는 호출자는 타입 스위치나 타입 단언으로 특정 오류를 찾고 세부를 추출할 수 있습니다. PathError의 경우 내부 Err 필드를 검사해 복구 가능한 실패인지 볼 수 있습니다.
gofor try := 0; try < 2; try++ { file, err = os.Create(filename) if err == nil { return } if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC { deleteTempFiles() // Recover some space. continue } return }
여기서 두 번째 if는 또 다른 타입 단언입니다. 실패하면 ok는 false이고 e는 nil입니다. 성공하면 ok는 true이며 오류는 *os.PathError 타입이고, 따라서 e도 그 타입이므로 오류 정보를 더 볼 수 있습니다.
호출자에게 오류를 보고하는 보통 방법은 추가 반환값으로 error를 반환하는 것입니다. 대표적 Read 메서드는 바이트 수와 error를 반환합니다. 하지만 오류가 복구 불가능하다면 어떨까요? 때로는 프로그램이 계속 진행할 수 없습니다.
이를 위해 내장 함수 panic이 있습니다. 이는 사실상 프로그램을 멈추는 런타임 오류를 만들어냅니다(다만 다음 절을 보세요). 이 함수는 임의 타입의 단일 인자(종종 문자열)를 받아 프로그램이 죽을 때 출력합니다. 또한 무한 루프에서 빠져나오는 것처럼, 불가능한 일이 일어났음을 나타내는 방법이기도 합니다.
go// A toy implementation of cube root using Newton's method. func CubeRoot(x float64) float64 { z := x/3 // Arbitrary initial value for i := 0; i < 1e6; i++ { prevz := z z -= (z*z*z-x) / (3*z*z) if veryClose(z, prevz) { return z } } // A million iterations has not converged; something is wrong. panic(fmt.Sprintf("CubeRoot(%g) did not converge", x)) }
이는 예시일 뿐이지만, 실제 라이브러리 함수는 panic을 피해야 합니다. 문제를 숨기거나 우회할 수 있다면, 전체 프로그램을 내리는 것보다 계속 실행되도록 하는 편이 항상 낫습니다. 한 가지 가능한 반례는 초기화 시점입니다. 라이브러리가 정말로 스스로를 설정할 수 없다면, 말하자면 panic하는 것이 합리적일 수 있습니다.
govar user = os.Getenv("USER") func init() { if user == "" { panic("no value for $USER") } }
panic이 호출되면(슬라이스 범위 밖 인덱싱이나 타입 단언 실패 같은 런타임 오류에 의해 암묵적으로 호출되는 경우 포함) 즉시 현재 함수 실행을 멈추고, 고루틴의 스택을 언와인딩(unwinding)하기 시작하며, 그 과정에서 지연(defer)된 함수들을 실행합니다. 언와인딩이 고루틴 스택의 꼭대기에 도달하면 프로그램은 죽습니다. 그러나 내장 함수 recover를 사용하면 고루틴의 제어권을 되찾아 정상 실행을 재개할 수 있습니다.
recover 호출은 언와인딩을 멈추고 panic에 전달된 인자를 반환합니다. 언와인딩 중 실행되는 코드는 defer된 함수 내부뿐이므로, recover는 defer된 함수 안에서만 유용합니다.
recover의 응용 중 하나는 서버 안에서 실패한 고루틴 하나를 종료하되, 다른 실행 중인 고루틴들을 죽이지 않는 것입니다.
gofunc server(workChan <-chan *Work) { for work := range workChan { go safelyDo(work) } } func safelyDo(work *Work) { defer func() { if err := recover(); err != nil { log.Println("work failed:", err) } }() do(work) }
이 예시에서 do(work)가 panic하면 결과는 로그로 남고 고루틴은 다른 것들을 방해하지 않고 깔끔히 종료됩니다. defer된 클로저에서 더 할 일은 없습니다. recover 호출이 상황을 완전히 처리합니다.
recover는 defer 함수에서 직접 호출되지 않으면 항상 nil을 반환하므로, defer된 코드는 자신이 panic과 recover를 사용하는 라이브러리 루틴을 호출하더라도 실패하지 않습니다. 예를 들어 safelyDo의 defer 함수가 recover 전에 로깅 함수를 호출하더라도, 그 로깅 코드는 패닉 상태의 영향을 받지 않고 실행됩니다.
이 복구 패턴이 있으면 do 함수(그리고 그가 호출하는 것들)는 어떤 나쁜 상황에서도 panic을 호출해 깔끔히 빠져나올 수 있습니다. 이를 이용해 복잡한 소프트웨어에서 오류 처리를 단순화할 수 있습니다. 파싱 오류를 로컬 오류 타입으로 panic해 보고하는 이상화된 regexp 패키지 버전을 보겠습니다. 다음은 Error 정의, error 메서드, Compile 함수입니다.
go// Error is the type of a parse error; it satisfies the error interface. type Error string func (e Error) Error() string { return string(e) } // error is a method of *Regexp that reports parsing errors by // panicking with an Error. func (regexp *Regexp) error(err string) { panic(Error(err)) } // Compile returns a parsed representation of the regular expression. func Compile(str string) (regexp *Regexp, err error) { regexp = new(Regexp) // doParse will panic if there is a parse error. defer func() { if e := recover(); e != nil { regexp = nil // Clear return value. err = e.(Error) // Will re-panic if not a parse error. } }() return regexp.doParse(str), nil }
doParse가 panic하면, 복구 블록은 반환값을 nil로 설정합니다. defer된 함수는 이름 있는 반환값을 수정할 수 있습니다. 그 다음 err에 대입하는 과정에서, 문제가 로컬 타입 Error인지 타입 단언으로 확인합니다. 그렇지 않으면 타입 단언이 실패해 런타임 오류가 발생하고, 마치 아무 일도 없었던 것처럼 스택 언와인딩이 계속됩니다. 이는 인덱스 범위 오류 같은 예기치 않은 일이 일어나면, 우리가 파싱 오류를 처리하기 위해 panic/recover를 사용하더라도 코드가 실패하게 해줍니다.
오류 처리가 준비되면, error 메서드(타입에 바인딩된 메서드이므로 내장 error 타입과 같은 이름이어도 괜찮고 자연스럽습니다)는 파서 스택을 손으로 풀지 않고도 파싱 오류를 보고하게 해 줍니다:
goif pos == 0 { re.error("'*' illegal at start of expression") }
이 패턴은 유용하지만 패키지 내부에서만 사용해야 합니다. Parse는 내부 panic 호출을 error 값으로 바꾸며, panic을 클라이언트에 노출하지 않습니다. 이는 따라야 할 좋은 규칙입니다.
덧붙여, 이 re-panic 관용구는 실제 오류가 발생했을 때 panic 값을 바꿉니다. 하지만 원래 실패와 새 실패 모두 크래시 리포트에 나타나므로 근본 원인은 여전히 보입니다. 따라서 이 단순 re-panic 접근은 보통 충분합니다—어차피 크래시니까요. 하지만 원래 값만 표시하고 싶다면, 예기치 않은 문제를 필터링하고 원래 오류로 다시 panic하는 코드를 조금 더 작성할 수 있습니다. 이는 독자 연습 문제로 남겨둡니다.
마지막으로 완전한 Go 프로그램, 웹 서버로 끝내겠습니다. 이것은 일종의 웹 “리-서버(re-server)”입니다. Google은 chart.apis.google.com에서 데이터를 차트와 그래프로 자동 포맷팅하는 서비스를 제공합니다. 하지만 데이터를 쿼리로 URL에 넣어야 해서 대화형으로 쓰기 어렵습니다. 여기의 프로그램은 한 형태의 데이터에 대해 더 나은 인터페이스를 제공합니다. 짧은 텍스트를 주면, 차트 서버를 호출해 QR 코드(텍스트를 인코딩한 박스 행렬) 이미지를 만듭니다. 이 이미지는 휴대폰 카메라로 찍어 예를 들어 URL로 해석할 수 있어, 작은 키보드에 URL을 직접 타이핑하는 수고를 덜어줍니다.
다음은 전체 프로그램입니다. 뒤에 설명이 이어집니다.
gopackage main import ( "flag" "html/template" "log" "net/http" ) var addr = flag.String("addr", ":1718", "http service address") var templ = template.Must(template.New("qr").Parse(templateStr)) func main() { flag.Parse() http.Handle("/", http.HandlerFunc(QR)) err := http.ListenAndServe(*addr, nil) if err != nil { log.Fatal("ListenAndServe:", err) } } func QR(w http.ResponseWriter, req *http.Request) { templ.Execute(w, req.FormValue("s")) } const templateStr = ` <html> <head> <title>QR Link Generator</title> </head> <body> {{if .}} <img src="http://chart.apis.google.com/chart?chs=300x300&cht=qr&choe=UTF-8&chl={{.}}" /> <br> {{.}} <br> <br> {{end}} <form action="/" name=f method="GET"> <input maxLength=1024 size=70 name=s value="" title="Text to QR Encode"> <input type=submit value="Show QR" name=qr> </form> </body> </html> `
main까지의 조각들은 따라가기 쉬울 것입니다. 하나의 플래그가 서버의 기본 HTTP 포트를 설정합니다. 템플릿 변수 templ에서 재미있는 일이 일어납니다. 이는 서버가 페이지를 표시하기 위해 실행할 HTML 템플릿을 구성합니다. 잠시 후 더 설명하겠습니다.
main 함수는 플래그를 파싱하고, 앞서 이야기한 메커니즘을 사용해 함수 QR을 서버의 루트 경로에 바인딩합니다. 그 다음 http.ListenAndServe를 호출해 서버를 시작하며, 서버가 실행되는 동안 블록됩니다.
QR은 요청을 받아(여기에 폼 데이터가 들어 있음) s라는 이름의 폼 값에서 데이터를 가져와 템플릿을 실행합니다.
html/template 템플릿 패키지는 강력하며, 이 프로그램은 그 능력의 일부만 건드립니다. 본질적으로, templ.Execute에 전달된 데이터 항목에서 파생된 요소로 치환하여 HTML 텍스트 조각을 실행 중에 재작성합니다. 여기서는 폼 값입니다. 템플릿 텍스트(templateStr)에서 이중 중괄호로 둘러싸인 부분이 템플릿 액션을 나타냅니다. {{if .}}부터 {{end}}까지는 현재 데이터 항목( .(dot)이라고 부름)의 값이 비어 있지 않을 때만 실행됩니다. 즉 문자열이 비어 있으면 이 템플릿 조각은 억제됩니다.
{{.}} 두 곳은 템플릿에 제공된 데이터(쿼리 문자열)를 웹 페이지에 보여 달라는 의미입니다. HTML 템플릿 패키지는 텍스트가 안전하게 표시되도록 적절한 이스케이프를 자동으로 제공합니다.
템플릿 문자열의 나머지는 페이지가 로드될 때 보여줄 HTML입니다. 설명이 너무 빠르다면 템플릿 패키지의 문서를 참고해 더 자세한 논의를 보세요.
이것으로 끝입니다. 코드 몇 줄과 데이터 기반 HTML 텍스트만으로 유용한 웹 서버를 만들었습니다. Go는 몇 줄로 많은 일을 하게 만들 만큼 강력합니다.