Go의 제네릭
원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기
Go 프로그래밍 언어는 2009년에 처음 공개되었으며, 1.0 버전은 2012년 3월에 출시되었다. 1.0 출시 전부터 일부 개발자들은 이 언어가 지나치게 단순하다고 비판했는데, 그 이유 중 하나는 사용자가 정의하는 제네릭 타입과 타입으로 매개변수화된 함수가 없다는 점이었다. 이러한 기능이 없음에도 불구하고 Go는 널리 사용되고 있으며, 추정치에 따르면 전 세계적으로 약 100만~200만 명의 개발자가 사용하고 있다. 수년에 걸쳐 언어에 어떤 형태로든 제네릭을 추가하자는 제안이 여러 차례 있었지만, 핵심 개발자인 Ian Lance Taylor와 Robert Griesemer가 작성한 최근 제안은 향후 Go 버전에 포함될 가능성이 높아 보인다.
배경
Go는 정적 타입 언어이므로 타입은 소스 코드에 명시되거나(또는 소스 코드로부터 추론되어) 컴파일러에 의해 검사된다. 컴파일러는 최적화된 머신 코드를 생성하므로, CPU 집약적인 코드는 바이트코드 컴파일러를 사용하고 가상 머신에서 실행되는 Python이나 Ruby 같은 언어보다 훨씬 효율적으로 동작한다.
제네릭은 “매개변수화된 타입” 또는 “매개변수 다형성”이라고도 하며, 모든 데이터 타입에 대해 동작하는 코드나 자료구조를 작성하는 방법이다. 코드나 자료구조를 각기 다른 데이터 타입을 처리하도록 인스턴스화할 수 있어 코드를 중복할 필요가 없다. 정렬이나 탐색 같은 일반화된 알고리즘을 작성할 때나 트리, 스레드 안전한 맵 등과 같이 타입에 독립적인 자료구조를 만들 때 유용하다. 예를 들어 개발자는 모든 정수 및 부동소수점 타입에서 동작하는 제네릭 min() 함수를 작성하거나, 키 타입을 값 타입에 연결하고 문자열, 정수 또는 사용자 정의 타입에서도 동작하는 이진 트리를 만들 수 있다. 제네릭을 이용하면 이러한 코드를 중복 없이 작성하면서도 컴파일러가 여전히 타입을 정적으로 검사하도록 할 수 있다.
초기 버전의 Java와 마찬가지로 Go는 사용자가 정의하는 제네릭을 제공하지 않는다. Go FAQ에서 언급한 바와 같이 제네릭은 “언젠가는 추가될 수도 있다
”고 하며, 이를 제외한 것이 의도적인 절충이었다고 설명한다:
제네릭은 편리하지만 타입 시스템과 런타임의 복잡성이라는 비용이 따른다. 아직은 복잡성에 비례하는 가치를 제공하는 설계를 찾지 못했지만, 계속 고민하고 있다. 한편 Go의 내장 맵과 슬라이스, 그리고 빈 인터페이스를 이용해 컨테이너를 구성할 수 있는 기능(명시적 언박싱 포함) 덕분에 많은 경우 제네릭이 가능하게 할 일을 다소 매끄럽지 않더라도 수행하는 코드를 작성할 수 있다.
실제 Go 사용자들이 제네릭 부재에 대해 크게 불만을 제기하지 않는 이유 중 하나는 Go가 내장 컨테이너 타입에 대해서는 제네릭을 제공하기 때문이다. 구체적으로는 슬라이스(Go의 가변 길이 배열 타입), 맵(해시 테이블), 채널(스레드 안전한 통신 큐)이 이에 해당한다. 예를 들어 블로그 소프트웨어를 작성하는 개발자는 게시글 목록이나 작성자 ID에서 작성자 정보로의 매핑을 가져오는 함수를 다음과 같이 작성할 수 있다:
// takes ID, returns "slice of Article" (compiler checks types)
func GetLatestArticles(num int) []Article {
...
}
// takes "slice of int" of IDs, returns "map of int IDs to Author"
func GetAuthors(authorIDs []int) map[int]Author {
...
}len()나 append() 같은 내장 함수는 이러한 컨테이너 타입에서 동작하지만, 개발자가 이러한 제네릭 내장 함수와 동등한 함수를 직접 정의할 방법은 없다. 많은 Go 개발자들이 증언하듯, 타입으로 매개변수화된 가변 길이 배열과 맵의 내장 버전을 갖추는 것만으로도 사용자 정의 제네릭 타입이 없더라도 상당한 도움이 된다.
또한 Go는 제네릭을 대신하거나 제네릭 부재를 우회하기 위해 자주 사용되는 두 가지 기능, 즉 인터페이스와 클로저를 지원한다. 예를 들어 Go에서 정렬은 세 가지 메서드를 요구하는 인터페이스인 sort.Interface 타입을 이용해 수행된다:
type Interface interface {
Len() int // length of this collection
Less(i, j int) bool // true if i'th element < j'th element
Swap(i, j int) // swap i'th and j'th elements
}사용자 정의 컬렉션이 이 인터페이스를 구현하면 표준 라이브러리의 sort.Sort() 함수를 이용해 정렬할 수 있다. Go 1.8에서 sort.Slice()가 추가된 이후로는 전체 정렬 인터페이스를 구현하는 대신 이 함수에 “less-than 클로저”를 전달할 수 있다. 예를 들면 다음과 같다:
// declare a struct for names and ages and a slice of those structs with four entries
people := []struct {
Name string
Age int
}{
{"Gopher", 7},
{"Alice", 55},
{"Vera", 24},
{"Bob", 75},
}
// sort people using the "less-than closure" specified in the call
sort.Slice(
people,
func(i, j int) bool { // i and j are the two slice indices
return people[i].Name < people[j].Name
},
)Go의 제네릭 부재를 우회하는 다른 방법으로는 interface{}(“빈 인터페이스”)를 사용하는 컨테이너 타입을 만드는 것이 있다. 이는 컬렉션에 삽입되는 모든 값을 사실상 박싱하고 런타임 타입 단언을 요구하므로 특별히 효율적이지도, 타입 안전하지도 않다. 하지만 동작은 하며, sync.Map 같은 일부 표준 라이브러리 타입도 이 방식을 사용한다.
일부 개발자는 제네릭이 너무 많은 복잡성을 가져올 것이라며 Go에 아예 추가해서는 안 된다고까지 주장한다. 예를 들어 Greg Hall은 바란다 “Go에 제네릭이 절대 생기지 않기를 바라거나, 생긴다면 설계자들이 Java 제네릭과 C++ 템플릿에서 본 복잡성과 어려움을 피할 방법을 찾기를 바란다
”고 했다.
Go 팀은 복잡성 문제를 심각하게 받아들인다. 핵심 개발자인 Russ Cox는 2009년 글 “The Generic Dilemma”에서 다음과 같이 말했다:
제네릭에 대한 접근 방식은 크게 세 가지가 있는 것으로 보인다:
- (C 언어 방식.) 제네릭을 제외한다. 프로그래머의 속도가 느려진다. 하지만 언어에 복잡성을 추가하지 않는다.
- (C++ 방식.) 컴파일 타임 특수화 또는 매크로 확장. 컴파일이 느려진다. 많은 코드가 생성되며, 그 중 상당수는 중복되고, 중복 복사본을 제거하려면 우수한 링커가 필요하다. [...]
- (Java 방식.) 모든 것을 암시적으로 박싱한다. 실행 속도가 느려진다. [...]
제네릭 딜레마는 이것이다: 느린 프로그래머, 느린 컴파일러와 비대해진 바이너리, 혹은 느린 실행 시간 중 무엇을 원하는가?
그럼에도 많은 Go 개발자가 제네릭을 요구하고 있으며, Go다운 방식으로 제네릭을 추가하는 최선의 방법에 대해 수년간 엄청난 논의가 있었다. 여러 개발자가 Go 사용 경험을 담은 “경험 보고서”에서 사려 깊은 근거를 제시했다. 공식 Go 블로그에 Taylor가 기고한 “Why Generics?”에서는 제네릭 추가가 Go에 가져올 이점을 자세히 설명하고, Go 팀이 제네릭을 추가할 때 따르는 지침을 나열한다:
가장 중요한 것은 오늘날 Go가 단순한 언어라는 점이다. Go 프로그램은 보통 명확하고 이해하기 쉽다. 우리가 오랫동안 이 영역을 탐색해 온 과정의 큰 부분은 이러한 명확성과 단순성을 유지하면서 제네릭을 추가하는 방법을 이해하려는 시도였다. 기존 언어에 잘 들어맞으면서 언어를 전혀 다른 것으로 바꾸지 않는 메커니즘을 찾아야 한다.
이러한 지침은 Go의 모든 제네릭 구현에 적용되어야 한다. 이것이 오늘 여러분에게 남기고 싶은 가장 중요한 메시지다: 제네릭은 언어에 상당한 이점을 가져올 수 있지만, Go가 여전히 Go답게 느껴질 때만 그만한 가치가 있다.
최근 제안
특히 Taylor는 Go에 제네릭을 추가하는 주제에 대해 다작했으며, 여섯 개 이상의 제안을 작성했다. 2010년부터 2013년까지 작성된 처음 네 개는 그의 문서 “Go should have generics” 하단에 나열되어 있다. 그는 이에 대해 “모두 여러 면에서 결함이 있다
”고 언급했다. 2019년 7월에는 앞서 언급한 “Why Generics?” 블로그 글을 게시했는데, 이 글은 Taylor와 Griesemer가 “contracts”에 기반한 제네릭 버전에 대해 작성한 방대한 2019년 제안으로 연결된다. 거의 1년 뒤인 2020년 6월, Taylor와 Griesemer는 contracts 추가를 피한 현재 제안을 발표했다. Taylor의 말에 따르면:
제네릭의 이전 초안 설계에서는 contracts라는 새로운 언어 구성을 이용해 제약 조건을 구현했다. 타입 목록은 인터페이스 타입이 아니라 contracts에만 등장했다. 그러나 많은 사람들이 contracts와 인터페이스 타입의 차이를 이해하는 데 어려움을 겪었다. 또한 contracts는 대응되는 인터페이스들의 집합으로 표현될 수 있음이 밝혀졌고, 따라서 contracts 없이도 표현력이 손실되지 않았다. 우리는 인터페이스 타입만을 사용하는 방식으로 접근법을 단순화하기로 결정했다.
contracts의 제거는 부분적으로 Philip Wadler와 그의 공동 연구자들이 2020년 5월 논문 “Featherweight Go [PDF]”(영상 발표)에서 수행한 연구에 기반한다. Wadler는 Haskell 설계에 기여한 타입 이론가로, 2004년 Java에 제네릭을 추가하는 작업에도 참여했다. Go의 창시자 중 한 명인 Rob Pike는 Wadler에게 Go의 향후 버전을 위해 “다형성을 올바르게 구현하는 것을 돕는 데 관심이 있는지(그리고 ‘올바르다’는 것이 무엇을 의미하는지 함께 고민하는 데 관심이 있는지)
” 물었고, 이 논문은 Pike의 요청에 대한 응답이다.
2020년 제안은 함수와 타입에 선택적인 타입 매개변수를 추가하여 각각 제네릭 알고리즘과 제네릭 컨테이너 타입을 가능하게 할 것을 제안한다. 이 제안 하에서 제네릭 함수가 어떻게 생겼는지에 대한 예시는 다음과 같다:
// Stringify calls the String method on each element of s,
// and returns the results.
func Stringify(type T Stringer)(s []T) []string {
var ret []string
for _, v := range s {
ret = append(ret, v.String())
}
return ret
}
// Stringer is a type constraint that requires the type argument to have
// a String method and permits the generic function to call String.
// The String method should return a string representation of the value.
type Stringer interface {
String() string
}타입 매개변수는 T(임의의 이름)이며, 함수 이름 뒤의 추가 괄호 안에 Stringer 제약 조건과 함께 지정된다: type T Stringer. 함수의 실제 인자는 두 번째 괄호 안에 있는 s []T이다. 이와 같은 함수를 작성하는 것은 현재 Go에서는 불가능하다. 구체 타입의 슬라이스를 인터페이스 타입(예: Stringer)의 슬라이스를 받는 함수에 전달하는 것을 허용하지 않기 때문이다.
제네릭 함수 외에도 새로운 제안은 이진 트리나 그래프 자료구조 등과 같은 타입 안전한 컬렉션을 지원하기 위해 타입의 매개변수화도 지원한다. 제네릭 Vector 타입이 어떻게 생길 수 있는지에 대한 예는 다음과 같다:
// Vector is a name for a slice of any element type.
type Vector(type T) []T
// Push adds a value to the end of a vector.
func (v *Vector(T)) Push(x T) {
*v = append(*v, x)
}
// v is a Vector of Authors
var v Vector(Author)
v.Push(Author{Name: "Ben Hoyt"})Go는 연산자 오버로딩을 지원하지 않고 연산자를 메서드 관점에서 정의하지도 않기 때문에, 인터페이스 제약 조건을 이용해 특정 타입이 < 연산자를 지원해야 한다고 지정할 방법이 없다(예를 들어). 제안에서는 이를 “타입 목록”이라는 새로운 기능을 이용해 수행하며, 그 예는 아래와 같다:
// Ordered is a type constraint that matches any ordered type.
// An ordered type is one that supports the <, <=, >, and >= operators.
type Ordered interface {
type int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64, uintptr,
float32, float64,
string
}실제로는 Ordered 같은 공통 제약 조건을 미리 정의해 둔 constraints 패키지가 표준 라이브러리에 추가될 가능성이 높다. 타입 목록을 이용하면 개발자는 내장 연산자를 사용하는 제네릭 함수를 작성할 수 있다:
// Smallest returns the smallest element in a slice of "Ordered" values.
func Smallest(type T Ordered)(s []T) T {
r := s[0]
for _, v := range s[1:] {
if v < r { // works due to the "Ordered" constraint
r = v
}
}
return r
}타입 목록으로 작성할 수 없는 유일한 제약 조건은 ==와 != 연산자에 대한 제약 조건인데, Go에서는 구조체, 배열, 인터페이스 타입의 동등성을 비교할 수 있기 때문이다. 이를 해결하기 위해 제안에서는 동등성 연산자를 허용하는 내장 comparable 제약 조건을 추가할 것을 제시한다. 이는 예를 들어 슬라이스나 배열에서 값의 인덱스를 찾는 함수에 유용할 것이다:
// Index returns the index of x in s, or -1 if not found.
func Index(type T comparable)(s []T, x T) int {
for i, v := range s {
// v and x are type T, which has the comparable
// constraint, so we can use == here.
if v == x {
return i
}
}
return -1
}Taylor와 Griesemer는 이 제안에 명시된 Go 코드를 일반 Go 코드로 변환하는 실험용 도구(go2go 브랜치)를 개발하여, 개발자들이 오늘날 제네릭 코드를 컴파일하고 실행할 수 있게 했다. 이 제안에 따라 작성된 코드를 온라인에서 공유하고 실행할 수 있는 Go 플레이그라운드 버전도 있다. 예를 들어 위 Stringify() 함수의 동작 예제는 다음과 같다.
Go 팀은 개발자들에게 제네릭 실험 도구를 이용해 자신의 문제를 해결해 보고, 다음 질문들에 대해 상세한 피드백을 보내 달라고 요청하고 있다:
첫째, 제네릭 코드가 이해가 되는가? Go답게 느껴지는가? 사람들은 어떤 예상치 못한 점을 마주하는가? 오류 메시지는 유용한가?
둘째, 많은 사람들이 Go에 제네릭이 필요하다고 말해 왔지만, 그것이 정확히 무엇을 의미하는지는 반드시 알지 못한다. 이 초안 설계가 문제를 유용한 방식으로 해결하는가? “Go에 제네릭이 있다면 이 문제를 해결할 수 있을 것 같다”고 생각하게 만드는 문제가 있다면, 이 도구를 사용할 때 그 문제를 해결할 수 있는가?
논의
최근 제안이 공개된 이후로 주요 golang-nuts 메일링 리스트와 Hacker News, reddit.com/r/golang 스레드에서 제네릭에 대한 공개 논의가 활발하게 이루어졌다.
Pike가 작년 말했다 [YouTube] “구문은 문제가 아니며, 적어도 아직은 그렇다
”지만, 메일링 리스트의 많은 스레드에서는 곧바로 구문에 대한 비판이 제기되었다. 인정하건대 구문은 이례적이며, 이미 괄호가 많기로 알려진 Go에 (소)괄호를 한 쌍 더 추가한다(예를 들어 Go의 메서드 정의는 메서드의 리시버 타입에 한 쌍, 메서드 인수에 또 한 쌍의 괄호를 사용한다). 제안서는 꺾쇠 괄호 대신 소괄호를 선택한 이유에 대한 설명으로 구문에 대한 사소한 논쟁을 미리 차단하려 한다:
함수 내부의 코드(예: v := F<T>)를 파싱할 때, <를 보는 시점에서 그것이 타입 인스턴스화인지 < 연산자를 사용하는 표현식인지 모호하다. 이를 해결하려면 사실상 무제한의 선행 탐색이 필요하다. 일반적으로 우리는 Go 파서를 효율적으로 유지하려고 노력한다.
메일링 리스트의 응답자 대부분은 C++, Java, C#처럼 꺾쇠 괄호를 사용하자고 제안하며, 예를 들어 List<T> 대신 List(T)를 사용하자고 한다. Taylor는 새로운 제안의 의미론이 타당한지에 훨씬 더 관심이 있지만, 이러한 구문 관련 스레드 각각에 다음과 같은 취지로 인내심 있게 답하고 있다:
대안을 걱정하기 전에 제안된 구문으로 실제 코드가 어떻게 보이는지 살펴보자. 감사합니다.
이러한 일이 너무 자주 반복되자 메일링 리스트 기여자 중 한 명인 Tyler Compton이 구문 관련 스레드를 모두 정리한 유용한 목록을 만들었다.
제네릭은 여러 타입에 대해 반복되는 타입과 함수를 없애는 데 도움이 될 것이다. 예를 들어 sort 패키지의 sort.Ints, sort.Float64s, sort.Strings가 그렇다. Hacker News의 댓글에서 Kyle Conroy는 표준 라이브러리의 다양한 sql.Null* 타입을 “네 줄로 대체하는 방법
”을 보여주었다:
type Null(type T) struct {
Val T
Valid bool // Valid is true if Val is not NULL
}메일링 리스트 기여자 Pee Jai는 타입을 구조체만 허용하도록 제한할 방법이 있는지 궁금해했지만, Taylor는 그것이 불가능하다고 답하며 “제네릭이 모든 문제를 해결하지는 않는다
”고 언급했다. Robert Engels는 어차피 이 경우에는 reflect 패키지가 여전히 필요할 것이라고 말했다.
한 스레드에서 “i3dmaster”가 커스텀 맵 타입에 대해 몇 가지 질문을 했고, Taylor는 “커스텀 컨테이너 타입은 len()이나 range를 지원하지 않을 것
”이라고 명확히 했다. 컬렉션 타입을 만드는 이들은 이러한 특수 구문을 사용할 수 없으며, 자체 Len() 메서드와 컬렉션을 순회하는 자체 방식을 정의해야 한다.
Go 핵심 기여자 Bryan Mills는 여러 스레드에 통찰력 있는 답변들을 게시했다. 그는 또한 제네릭 실험에서 얻은 다양한 메모와 코드 예제를 담은 자신만의 저장소를 만들었는데, 여기에는 타입 목록이 왜 이상적이지 않다고 생각하는지에 대한 설명도 포함되어 있다. 이 저장소에는 제안된 제네릭을 이용해 append() 내장 함수를 재구현하려는 여러 시도도 담겨 있다.
타임라인
Taylor와 Griesemer는 최근 블로그 글에서 언어에 제네릭을 추가하는 것이 빠른 과정이 아닐 것임을 분명히 했다. 그들은 제대로 해내고 싶어 하며 커뮤니티 피드백을 반영하려 한다:
우리는 Go 커뮤니티로부터 모은 피드백을 이용해 앞으로 어떻게 나아갈지 결정할 것이다. 초안 설계가 호평을 받고 중대한 변경이 필요하지 않다면, 다음 단계는 공식적인 언어 변경 제안이 될 것이다. 기대치를 설정하자면, 모두가 설계 초안에 완전히 만족하고 추가 조정이 전혀 필요하지 않다면 제네릭이 Go에 추가될 수 있는 가장 이른 시점은 2021년 8월로 예정된 Go 1.17 릴리스가 될 것이다. 물론 실제로는 예상치 못한 문제가 있을 수 있으므로 이는 낙관적인 일정이며, 확정적으로 예측할 수는 없다.
개인적인 추측으로는 이 정도 규모의 기능에 대해 2021년 8월(1년 남짓 남은 시점)은 낙관적이다. 피드백을 모으고, 설계를 반복하며, 현재의 Go-to-Go 변환기를 사용하는 대신 프로덕션 수준으로 제네릭을 구현하는 데는 꽤 오랜 시간이 걸릴 것이다. 하지만 지금까지의 제안 수와 피드백 양을 고려하면, 제네릭은 언제 도입되든 분명 많이 사용되고(그리고 바라건대 남용은 적게 되는) 기능이 될 것이다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기