고 고급 #3 제네릭 — type parameter와 constraint
#2 sync 패키지 다음, 이번엔 다른 결의 도구. 제네릭.
Go 1.18(2022)에서 도입됐습니다. 그 전에는 interface{} (지금의 any) + 타입 단언으로 우회하던 것들을 — 이제는 타입 안전하게 표현할 수 있습니다.
제네릭이 어울리는 경우 #
func Min[T int | float64 | string](a, b T) T {
if a < b {
return a
}
return b
}
Min(1, 2) // 1
Min(1.5, 2.5) // 1.5
Min("a", "b") // "a"[T int | float64 | string]가 타입 매개변수. T는 호출 시점에 정해집니다.
핵심 두 가지 #
- type parameter list — 함수 이름 뒤
[T constraint, U constraint, ...] - constraint — T가 만족해야 할 조건. 인터페이스로 표현
Constraint — 인터페이스의 확장 #
기존 인터페이스는 메서드 집합만 표현했습니다. 제네릭이 들어오면서 — 인터페이스가 타입 집합도 표현하게 됐습니다.
type Number interface {
int | float64
}
func Sum[T Number](nums []T) T {
var sum T
for _, n := range nums {
sum += n
}
return sum
}Number 인터페이스는 — int 또는 float64 인 타입만 만족. 메서드가 없어도 인터페이스로 쓰임.
~ 토큰 — 기본 타입까지 포함
#
type Number interface {
~int | ~float64
}
type Celsius float64
Sum([]Celsius{1, 2, 3}) // OK — Celsius의 기본 타입이 float64~int는 — 기본 타입(underlying type)이 int 인 모든 타입. type ID int 같은 정의된 타입까지 포함합니다.
~ 없이 그냥 int | float64 면 — Celsius 같은 정의된 타입은 사용 불가.
comparable — 비교 가능한 타입
#
== != 비교가 가능한 타입의 집합으로, map의 key 타입을 지정할 때도 나타납니다.
func Index[T comparable](s []T, target T) int {
for i, v := range s {
if v == target {
return i
}
}
return -1
}comparable은 — 미리 정의된 constraint. int, string, struct (필드가 모두 comparable이면) 등 다 포함하지만 slice, map, function은 비교 불가라 제외됩니다.
any — 제약 없음
#
func First[T any](s []T) T {
return s[0]
}any는 — interface{}의 별칭. 어떤 타입이든 받습니다. 단, 타입에 가하는 연산이 거의 없을 때만 유용합니다(인덱싱, 대입 정도).
golang.org/x/exp/constraints — 표준 constraint 들
#
자주 쓰이는 constraint는 별도 패키지에 미리 정의돼 있습니다.
import "golang.org/x/exp/constraints"
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}constraints.Ordered는 — < > <= >=가 가능한 타입(숫자 + string).
제네릭 자료구조 #
가장 자연스러운 경우입니다. 자료구조는 — 들어가는 타입과 무관하게 동작하는 게 보통입니다.
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
last := len(s.items) - 1
v := s.items[last]
s.items = s.items[:last]
return v, true
}
func main() {
s := Stack[int]{}
s.Push(1)
s.Push(2)
v, _ := s.Pop() // 2
_ = v
}var zero T — 제네릭 타입의 zero value를 얻는 표준 패턴.
제네릭 함수의 타입 추론 #
Sum([]int{1, 2, 3}) // T는 int로 추론
Sum[int]([]int{1, 2, 3}) // 명시적
stack := Stack[int]{} // 타입 인자 명시함수 호출은 — 인자 타입에서 자동 추론. 자료구조나 메서드 호출은 — 명시가 필요한 경우가 많습니다.
표준 라이브러리의 활용 — slices, maps
#
Go 1.21에서 — 제네릭 기반의 표준 패키지가 들어왔습니다.
import "slices"
slices.Contains([]int{1, 2, 3}, 2) // true
slices.Sort([]string{"b", "a", "c"}) // 정렬
slices.Index([]int{1, 2, 3}, 2) // 1
slices.Reverse([]int{1, 2, 3}) // [3 2 1]
import "maps"
maps.Keys(m) // map의 모든 key이전에 손으로 짜던 헬퍼들이 — 이제 표준에 다 있습니다.
함정 — 메서드는 type parameter를 가질 수 없다 #
type Box[T any] struct{ v T }
// 메서드는 새 type parameter 정의 불가
func (b Box[T]) Map[U any](f func(T) U) Box[U] { // ✗ 컴파일 에러
// ...
}메서드는 — receiver의 type parameter (T)만 사용 가능하고, 새 type parameter도입 불가. 이런 변환이 필요하면 함수로 짜야 합니다.
func MapBox[T, U any](b Box[T], f func(T) U) Box[U] {
return Box[U]{v: f(b.v)}
}함정 — 너무 광범위한 constraint #
func Process[T any](v T) {
// any 인데 v로 할 수 있는 게 거의 없음
}constraint가 넓을수록 — T에 가할 수 있는 연산이 줄어듭니다. 산술이 필요하면 Number, 비교가 필요하면 comparable, 정렬이 필요하면 Ordered — 필요한 만큼만.
함정 — 인터페이스를 그대로 쓰는 게 더 나은 경우도 #
// 제네릭
func Print[T fmt.Stringer](v T) {
fmt.Println(v.String())
}
// 인터페이스
func Print(v fmt.Stringer) {
fmt.Println(v.String())
}이 경우에는 — 인터페이스가 더 자연스럽습니다. 동적 디스패치가 필요한 경우(여러 다른 타입이 한 슬라이스에 섞임)는 — 제네릭이 아니라 인터페이스가 어울립니다.
단순 가이드라인 #
- 타입 안전성과 성능이 중요한 자료구조 → 제네릭
- 다형성이 본질(여러 다른 타입을 한 컨테이너에) → 인터페이스
- 단일 함수에 여러 타입을 받고 싶음 → 둘 다 가능, 가독성으로 결정
패턴 — Map / Filter / Reduce #
Go는 일부러 표준에 안 넣었지만, 제네릭으로 자연스럽게 짤 수 있습니다.
func Map[T, U any](s []T, f func(T) U) []U {
out := make([]U, len(s))
for i, v := range s {
out[i] = f(v)
}
return out
}
func Filter[T any](s []T, pred func(T) bool) []T {
var out []T
for _, v := range s {
if pred(v) {
out = append(out, v)
}
}
return out
}
func Reduce[T, U any](s []T, init U, f func(U, T) U) U {
acc := init
for _, v := range s {
acc = f(acc, v)
}
return acc
}자주 쓰면 헬퍼 패키지로 묶어 두면 편합니다. 다만 — Go의 일반 스타일은 for 루프를 명시적으로 쓰는 쪽이 더 흔합니다(가독성이 좋다고 보기 때문).
어디까지 추상화할 것인가 #
제네릭이 들어왔다고 해서 — 모든 코드를 제네릭으로 바꾸는 건 좋지 않습니다. 세 번 이상 같은 모양이 보일 때 일반화를 검토하는 정도가 적절합니다.
“Don’t be afraid to write a little code.” — Rob Pike
Go가 의도적으로 단순함을 지키는 언어인 만큼 — 제네릭도 같은 정신으로. 굳이 일반화하지 않아도 되는 것을 일반화하면 가독성이 떨어지는 경우가 많습니다.
마무리 #
이번 글에서 정리한 내용:
- type parameter — 함수/타입 이름 뒤
[T constraint] - constraint — 메서드 + 타입 집합을 표현하는 인터페이스
~T— 기본 타입 기반 타입까지 포함comparable—==비교 가능any— 제약 없음 (=interface{})constraints.Ordered— 정렬 가능한 타입- 자료구조 — 제네릭이 가장 잘 어울리는 경우
- 메서드는 새 type parameter도입 불가
- 다형성이 본질이면 — 인터페이스가 자연스러움
- 추상화는 세 번 이상 반복 보일 때
다음 글(#4 reflect 패키지)에서는 — 런타임에 타입을 다루는 reflect. 직렬화 라이브러리 안쪽에서 보이는 도구를 정리합니다.