제약 설계와 제네릭 자료구조
이 챕터에서 다루는 것
5-1에서 제약은 "인터페이스"라고만 하고 넘어갔다. 여기서는
제약을 직접 설계한다. 타입 집합 문법, comparable의 정확한 경계, 그리고
제네릭 자료구조를 쓸 때 반드시 부딪히는 두 벽 — 메서드에는 타입 파라미터를 붙일 수
없다는 것과 제로값 처리 — 를 다룬다.
문제 — any로는 아무것도 못 한다
func Sum[T any](xs []T) T {
var total T
for _, x := range xs {
total += x // 컴파일 에러: invalid operation: operator + not defined on total (variable of type T)
}
return total
}
any는 "아무 타입이나"라는 뜻이고, 아무 타입이나에는 +가 없다. 제약은 제한이
아니라 허가다. 제약에 무엇을 적느냐가 곧 함수 본문에서 T로 무엇을 할 수 있는지를
정한다.
| 제약 | 본문에서 할 수 있는 것 |
|---|---|
any | 대입, 전달, var zero T, []T/map[K]T에 담기 |
comparable | 위 + ==, !=, 맵의 키로 쓰기 |
cmp.Ordered | 위 + <, >, <=, >= |
~int | ~float64 | 위 + +, -, *, / |
interface{ Close() error } | any + Close() 호출 |
타입 집합 — 유니온과 ~
제약으로 쓰이는 인터페이스에는 메서드 대신 타입을 나열할 수 있다. 이것을 타입 집합(type set) 이라고 한다.
package main
import (
"cmp"
"fmt"
)
// ---- 1. 유니온: 허용할 타입을 | 로 나열한다 ----
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
type Float interface {
~float32 | ~float64
}
// 제약끼리 임베딩해서 합칠 수 있다. 인터페이스 임베딩과 같은 문법이다.
type Number interface {
Integer | Float
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
// ---- 2. ~ 가 없으면 정의된 타입이 걸린다 ----
type Celsius float64
type StrictFloat interface{ float64 } // 정확히 float64만
type LooseFloat interface{ ~float64 } // 근본 타입이 float64인 모든 타입
func Double[T LooseFloat](v T) T { return v * 2 }
// func DoubleStrict[T StrictFloat](v T) T { return v * 2 }
// DoubleStrict(Celsius(1.5))는 컴파일 에러:
// Celsius does not satisfy StrictFloat (possibly missing ~ for float64 in StrictFloat)
// ---- 3. 메서드와 타입 집합을 한 제약에 섞는다 ----
type Named interface {
~int | ~string
fmt.Stringer
}
type UserID int
func (u UserID) String() string { return fmt.Sprintf("U-%03d", int(u)) }
type Tag string
func (t Tag) String() string { return "#" + string(t) }
// Label은 T에 String()이 있다는 것과, T가 int/string 계열이라는 것을 둘 다 안다.
// %v도 String()을 찾아 쓰므로, 원래 값을 보려면 근본 타입으로 변환해야 한다.
func Label[T Named](v T) string {
return fmt.Sprintf("%s (%%v로도 %v)", v.String(), v)
}
// ---- 4. 메서드만 있는 제약은 평범한 인터페이스와 같다 ----
type Resettable interface {
Reset()
}
type Counter struct{ N int }
func (c *Counter) Reset() { c.N = 0 }
func ResetAll[T Resettable](xs []T) {
for _, x := range xs {
x.Reset()
}
}
func main() {
fmt.Println("Sum[int]:", Sum([]int{1, 2, 3}))
fmt.Println("Sum[float64]:", Sum([]float64{1.5, 2.25}))
fmt.Println("Sum[Celsius]:", Sum([]Celsius{36.5, 1.0}))
fmt.Println("Double[Celsius]:", Double(Celsius(1.5)))
fmt.Println("Double[float64]:", Double(1.5))
fmt.Println("Label:", Label(UserID(7)))
fmt.Println("Label:", Label(Tag("go")))
// 제약이 타입 집합을 가지면 연산자를 쓸 수 있다. cmp.Ordered가 표준 답이다.
fmt.Println("cmp.Compare:", cmp.Compare(3, 10), cmp.Compare("b", "a"), cmp.Compare(2.0, 2.0))
// NaN 때문에 부동소수에서는 < 와 cmp.Compare의 결과가 갈린다.
nan := zeroDiv()
fmt.Println("nan < 1:", nan < 1, "/ 1 < nan:", 1 < nan, "/ cmp.Compare(nan, 1):", cmp.Compare(nan, 1))
// 포인터 리시버 메서드를 요구하는 제약에는 포인터를 넣어야 한다. 메서드 집합 규칙 그대로다.
cs := []*Counter{{N: 3}, {N: 9}}
ResetAll(cs)
fmt.Println("ResetAll 후:", cs[0].N, cs[1].N)
// ResetAll([]Counter{{N: 3}})는 컴파일 에러:
// Counter does not satisfy Resettable (method Reset has pointer receiver)
}
// zeroDiv는 상수 0.0/0.0이 컴파일 에러이므로 변수로 우회해 NaN을 만든다.
func zeroDiv() float64 {
z := 0.0
return z / z
}
go run ./02-type-sets
Sum[int]: 6
Sum[float64]: 3.75
Sum[Celsius]: 37.5
Double[Celsius]: 3
Double[float64]: 3
Label: U-007 (%v로도 U-007)
Label: #go (%v로도 #go)
cmp.Compare: -1 1 0
nan < 1: false / 1 < nan: false / cmp.Compare(nan, 1): -1
ResetAll 후: 0 0
~는 왜 필요한가
type Celsius float64는 3-5에서
본 정의된 타입(defined type) 이다. float64와 근본 타입(underlying type)은 같지만
다른 타입이다.
interface{ float64 }→ 정확히float64만.Celsius는 안 된다.interface{ ~float64 }→ 근본 타입이float64인 모든 타입.Celsius도 된다.
컴파일러의 에러 메시지가 친절하게 알려 준다.
Celsius does not satisfy StrictFloat (possibly missing ~ for float64 in StrictFloat)
실무에서는 거의 항상 ~를 붙인다. 도메인 타입(type UserID int,
type Money int64)을 정의하는 것이 Go의 관용구인데, ~가 없으면 그 타입들이
전부 제약에서 튕겨 나간다. 표준 라이브러리의 cmp.Ordered도 전부 ~가 붙어 있다.
~를 붙일 수 있는 것은 근본 타입 자체뿐이다. ~Celsius는 컴파일 에러다
(Celsius가 이미 정의된 타입이라 근본 타입이 아니다). 인터페이스도 유니온 항으로
쓸 수 없다.
메서드와 타입 집합은 한 제약에 섞을 수 있다
Named가 그 예다. ~int | ~string으로 어떤 값인지를 제한하고, fmt.Stringer로
무엇을 할 수 있는지를 요구한다. Label 본문 안에서 T는 두 가지 성질을 다 가진다.
Label(UserID(7))이 %v로도 U-007을 낸 것을 보자. %v는 값이 fmt.Stringer면
String()을 부른다(4-3).
제네릭 함수 안이라고 다르지 않다.
포인터 리시버는 그대로 발목을 잡는다
ResetAll([]Counter{...})는 컴파일되지 않는다.
Counter does not satisfy Resettable (method Reset has pointer receiver)
4-1의 메서드 집합 규칙이 제약에도
똑같이 적용된다. Reset이 포인터 리시버이므로 Counter의 메서드 집합에는 Reset이
없고, *Counter에만 있다. 그래서 []*Counter를 넘겨야 한다.
여기서 자동 주소 변환이 안 통한다는 점이 중요하다. c.Reset()을 직접 쓸 때는
컴파일러가 (&c).Reset()으로 바꿔 주지만, 제약을 만족하는지 판정하는 자리에서는
그런 편의가 없다.
:::info NaN 때문에 <와 cmp.Compare가 갈린다
cmp.Ordered는 <를 쓸 수 있는 타입 집합인데, 부동소수에는 NaN이 있다. NaN은
어떤 값과 비교해도 <, >, ==가 전부 false다. 위 출력에서 nan < 1도
1 < nan도 false인 것이 그 결과다.
cmp.Compare는 NaN을 다른 모든 값보다 작다고 정의해 이 구멍을 메운다. 그래서
cmp.Compare(nan, 1)이 -1이다. 정렬처럼 전순서가 필요한 곳에서는 <가 아니라
cmp.Compare를 쓴다(5-3).
:::
comparable — 정확한 경계
4-4에서 인터페이스 비교가
런타임 패닉을 내는 것을 봤고, 3-4에서 구조체의
비교 가능성 규칙을 봤다. comparable은 그 규칙에 언어 차원의 이름을 붙인 것이다.
package main
import "fmt"
// Index는 == 를 쓰므로 comparable이 필요하다. any였다면 컴파일되지 않는다.
func Index[T comparable](xs []T, target T) int {
for i, x := range xs {
if x == target {
return i
}
}
return -1
}
// Dedup은 맵의 키로 T를 쓴다. 맵 키의 조건이 곧 comparable이다.
func Dedup[T comparable](xs []T) []T {
seen := make(map[T]struct{}, len(xs))
out := make([]T, 0, len(xs))
for _, x := range xs {
if _, ok := seen[x]; ok {
continue
}
seen[x] = struct{}{}
out = append(out, x)
}
return out
}
type Point struct{ X, Y int }
type Box struct{ Items []string } // 슬라이스 필드가 있으므로 비교 불가능
type Pair struct {
P Point
A [2]int // 배열은 원소가 비교 가능하면 비교 가능하다
}
func main() {
fmt.Println("int:", Index([]int{3, 9, 2}, 9))
fmt.Println("string:", Index([]string{"가", "나"}, "나"))
fmt.Println("struct:", Index([]Point{{1, 2}, {3, 4}}, Point{3, 4}))
fmt.Println("배열 필드 포함 struct:", Index([]Pair{{Point{1, 1}, [2]int{1, 2}}}, Pair{Point{1, 1}, [2]int{1, 2}}))
fmt.Println("포인터:", Dedup([]*Point{nil, nil}))
// Index([]Box{{}}, Box{})는 컴파일 에러:
// Box does not satisfy comparable
// 함정: any는 comparable을 "만족"한다. 구현하는 것은 아니지만 인스턴스화는 허용된다.
fmt.Println("any도 통과:", Index([]any{1, "가", true}, "가"))
// 그리고 그 순간 컴파일 시점 보장이 사라진다.
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("any + comparable 패닉:", rec)
}
}()
fmt.Println(Index([]any{[]int{1}}, any([]int{1})))
}()
// 인터페이스 타입 인자도 마찬가지다.
type Stringer interface{ String() string }
fmt.Println("인터페이스 타입 인자:", Index([]Stringer{}, nil))
fmt.Println("Dedup:", Dedup([]string{"a", "b", "a", "c", "b"}))
}
go run ./02-comparable
int: 1
string: 1
struct: 1
배열 필드 포함 struct: 0
포인터: [<nil>]
any도 통과: 1
any + comparable 패닉: runtime error: comparing uncomparable type []int
인터페이스 타입 인자: -1
Dedup: [a b c]
comparable을 만족하는 것과 만족하지 않는 것
| 만족한다 | 만족하지 않는다 |
|---|---|
모든 수치 타입, string, bool | 슬라이스 |
| 포인터, 채널 | 맵 |
| 배열 (원소가 비교 가능하면) | 함수 |
| 구조체 (모든 필드가 비교 가능하면) | 위 셋 중 하나를 필드로 가진 구조체 |
| 인터페이스 타입 — 단, 조건부다 |
Box처럼 슬라이스 필드가 하나라도 있으면 구조체 전체가 비교 불가능해진다.
컴파일러가 Box does not satisfy comparable이라고 잘라 준다. 이것이 comparable의
가치다 — 4-4에서 런타임
패닉이던 것이 컴파일 에러가 됐다.
함정 — 인터페이스 타입은 comparable을 "만족"한다
여기서 Go 명세의 두 단어가 갈린다.
- 구현한다(implement) — 인터페이스의 메서드를 다 가진다.
- 만족한다(satisfy) — 제약으로서 통과한다.
Go 1.20부터 인터페이스 타입은 comparable을 구현하지는 않지만 만족한다. 즉
Index[any]로 인스턴스화하는 것이 허용된다. 위 출력이 그 결과다.
any도 통과: 1
any + comparable 패닉: runtime error: comparing uncomparable type []int
any를 타입 인자로 넣는 순간 컴파일 시점 보장이 사라지고 4-4의 패닉이 돌아온다.
이 예외가 있는 이유는 실용성이다 — map[any]string 같은 코드가 이미 언어에서
합법이고, 제네릭 헬퍼가 그것을 다루지 못하면 쓸모가 줄어든다.
:::warning comparable은 인터페이스 타입 인자를 막아 주지 않는다
T comparable이라고 써 두었다고 안심하면 안 된다. 호출자가 T = any나
T = error로 인스턴스화하면 ==가 런타임에 터질 수 있다. error를 ==로
비교하면 안 되고 errors.Is를 쓰라는
4-7의 규칙이 제네릭 코드에서도
그대로다.
:::
메서드에는 타입 파라미터를 붙일 수 없다
제네릭 자료구조를 쓰다 보면 반드시 이 벽에 부딪힌다.
type Store[T any] struct{ v T }
func (s Store[T]) MapTo[U any](f func(T) U) Store[U] { ... }
syntax error: method must have no type parameters
문법 미비가 아니라 의도된 제약이다. 이유는 두 가지다.
1. 인터페이스 만족 판정이 결정 불가능해진다. 메서드에 타입 파라미터가 있으면, 어떤 타입이 어떤 인터페이스를 구현하는지 판정하는 문제가 무한히 커진다. Go 팀은 이것을 컴파일 시간과 언어 복잡도 양쪽에서 감당할 수 없다고 판단했다.
2. 구현 전략과 충돌한다. Go는 제네릭을 GC 형태 단위로 인스턴스화하는데 (5-1), 메서드에 타입 파라미터가 있으면 어떤 조합이 필요한지를 링크 시점까지 알 수 없다. 인터페이스를 통해 호출되면 아예 알 수 없다.
우회법 — 함수로 뺀다
답은 단순하다. 메서드가 아니라 패키지 수준 함수로 만든다.
// 메서드로는 불가능
func (s *Set[T]) SortedItems() []T // T에 cmp.Ordered를 요구할 방법이 없다
func (s *Set[T]) Map[U any](f func(T) U) // syntax error
// 함수로는 가능
func SortedItems[T cmp.Ordered](s *Set[T]) []T
func MapSet[T, U comparable](s *Set[T], f func(T) U) *Set[U]
이 제약이 Go에 Map/Filter 메서드 체이닝이 없는 이유다. 다른 언어의
list.map(f).filter(g)가 Go에서는 Filter(Map(list, f), g)가 된다. 표준
라이브러리의 slices와 maps가 전부 함수인 것도(5-3)
같은 이유다.
타입 자신의 타입 파라미터보다 좁은 제약이 필요할 때도 함수로 뺀다. Set[T comparable]은
정렬할 수 없다. comparable에는 <가 없기 때문이다. 그렇다고 Set[T cmp.Ordered]로
좁히면 구조체를 담지 못한다. 답은 Set[T comparable]을 유지하고,
SortedItems[T cmp.Ordered]를 함수로 두는 것이다.
제네릭 자료구조 — Set
지금까지의 규칙이 하나에 모인다.
package main
import (
"cmp"
"fmt"
"slices"
)
// Set은 값의 존재 여부만 담는다. 값 타입으로 struct{}를 쓰면 메모리를 0바이트만 쓴다.
type Set[T comparable] struct {
m map[T]struct{}
}
// NewSet은 생성자다. 제네릭 타입에는 타입 추론이 없으므로
// NewSet[string]()처럼 명시하거나, 아래 SetOf처럼 인자에서 추론되게 만든다.
func NewSet[T comparable]() *Set[T] {
return &Set[T]{m: make(map[T]struct{})}
}
func SetOf[T comparable](items ...T) *Set[T] {
s := &Set[T]{m: make(map[T]struct{}, len(items))}
for _, item := range items {
s.Add(item)
}
return s
}
// 리시버에 타입 파라미터를 다시 적지만, 새로 선언하는 것이 아니라 받아 오는 것이다.
func (s *Set[T]) Add(v T) { s.m[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool {
_, ok := s.m[v]
return ok
}
func (s *Set[T]) Delete(v T) { delete(s.m, v) }
func (s *Set[T]) Len() int { return len(s.m) }
// Items의 순서는 맵 순회 순서라서 실행할 때마다 달라진다.
func (s *Set[T]) Items() []T {
out := make([]T, 0, len(s.m))
for v := range s.m {
out = append(out, v)
}
return out
}
func (s *Set[T]) Union(other *Set[T]) *Set[T] {
out := &Set[T]{m: make(map[T]struct{}, len(s.m)+len(other.m))}
for v := range s.m {
out.Add(v)
}
for v := range other.m {
out.Add(v)
}
return out
}
// SortedItems는 메서드가 아니라 함수다.
// T에 cmp.Ordered라는 "더 좁은 제약"을 요구해야 하는데,
// 메서드에는 타입 파라미터를 못 붙이므로 메서드로는 표현할 방법이 없다.
func SortedItems[T cmp.Ordered](s *Set[T]) []T {
out := s.Items()
slices.Sort(out)
return out
}
// MapSet도 같은 이유로 함수다. 결과의 원소 타입 U가 새 타입 파라미터이기 때문이다.
func MapSet[T, U comparable](s *Set[T], f func(T) U) *Set[U] {
out := &Set[U]{m: make(map[U]struct{}, len(s.m))}
for v := range s.m {
out.Add(f(v))
}
return out
}
func main() {
langs := SetOf("go", "rust", "go", "zig")
fmt.Println("크기:", langs.Len())
fmt.Println("has go:", langs.Has("go"), "/ has java:", langs.Has("java"))
langs.Delete("zig")
fmt.Println("정렬된 원소:", SortedItems(langs))
other := SetOf("zig", "go")
fmt.Println("합집합:", SortedItems(langs.Union(other)))
lengths := MapSet(langs, func(s string) int { return len(s) })
fmt.Println("길이 집합:", SortedItems(lengths))
// 비교 불가능한 타입은 애초에 Set에 넣을 수 없다.
// bad := NewSet[[]int]() // 컴파일 에러: []int does not satisfy comparable
// 구조체는 필드가 전부 비교 가능하면 키가 된다.
type Point struct{ X, Y int }
pts := SetOf(Point{1, 2}, Point{1, 2}, Point{3, 4})
fmt.Println("좌표 집합 크기:", pts.Len())
// Point는 Ordered가 아니므로 SortedItems를 못 쓴다.
// SortedItems(pts) // 컴파일 에러: Point does not satisfy cmp.Ordered
items := pts.Items()
slices.SortFunc(items, func(a, b Point) int {
return cmp.Or(cmp.Compare(a.X, b.X), cmp.Compare(a.Y, b.Y))
})
fmt.Println("좌표 정렬:", items)
// 제로값 Set은 쓸 수 없다. nil 맵에 쓰면 패닉이다.
var zero Set[string]
fmt.Println("제로값 Set 조회는 된다:", zero.Has("go"), "크기:", zero.Len())
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("제로값 Set에 Add:", rec)
}
}()
zero.Add("go")
}()
}
go run ./02-set
크기: 3
has go: true / has java: false
정렬된 원소: [go rust]
합집합: [go rust zig]
길이 집합: [2 4]
좌표 집합 크기: 2
좌표 정렬: [{1 2} {3 4}]
제로값 Set 조회는 된다: false 크기: 0
제로값 Set에 Add: assignment to entry in nil map
읽어야 할 것
Items()의 순서는 정하지 않는다. 맵 순회 순서는 Go가 의도적으로 무작위화한다
(3-3). 그래서 예제 출력에는 Items()를 그대로
쓰지 않고 항상 정렬해서 냈다. 제네릭 컨테이너를 만들 때 "순서 없음"을 문서에
명시하지 않으면 사용자는 반드시 순서에 의존하는 코드를 쓴다.
SortedItems와 MapSet이 함수인 이유가 서로 다르다. SortedItems는 더 좁은
제약이 필요해서, MapSet은 새 타입 파라미터가 필요해서다. 둘 다 메서드로는 표현할
수 없다.
제로값 Set[string]은 반쪽만 동작한다. Has와 Len은 nil 맵에서도 안전하지만
Add는 패닉이다. 3-3의 nil 맵 규칙 그대로다.
제로값을 어떻게 다룰 것인가
제네릭 자료구조 설계에서 가장 자주 미끄러지는 부분이다. 선택지는 셋이다.
1. 제로값을 유효한 상태로 만든다 (권장). Set을 슬라이스가 아니라 맵으로 만든 것이
문제였다. sync.Mutex나 strings.Builder처럼 var s Set[string]이 바로 쓸 수 있으면
가장 좋다. Add에서 지연 초기화하면 된다.
func (s *Set[T]) Add(v T) {
if s.m == nil {
s.m = make(map[T]struct{})
}
s.m[v] = struct{}{}
}
2. 생성자를 강제한다. 필드를 비공개로 두고 생성자만 노출한다. 위 예제가 이쪽인데,
Set 자체가 공개 타입이라 var zero Set[string]을 막지 못한다는 한계가 있다.
3. 문서에 적고 포기한다. 표준 라이브러리도 sync.WaitGroup처럼 지연 초기화하는
쪽과 time.Timer처럼 생성자를 강제하는 쪽이 섞여 있다.
원소 타입 T의 제로값은 또 다른 문제다. Pop이 (T, error)를 돌려주는 것
(5-1), Get이 (T, bool)을 돌려주는 것이 표준적인
답이다. T가 int면 제로값 0이 정상적인 값이므로, 제로값만으로 "없음"을
표현할 수 없다.
자기 참조 제약 [A Adder[A]]
"두 값을 더해 같은 타입을 돌려준다"를 제약으로 적으려면 제약이 자기 자신을 참조해야 한다. Go 1.26에서 이 형태가 허용됐다.
package main
import "fmt"
// Adder[A]는 "A를 더해 A를 돌려주는 Add 메서드"를 요구한다.
// A가 제약 자신을 참조한다. Go 1.26 이전에는 컴파일러가 이 형태를 거부했다.
type Adder[A Adder[A]] interface {
Add(A) A
}
type Money int64
func (m Money) Add(o Money) Money { return m + o }
type Vec2 struct{ X, Y float64 }
func (v Vec2) Add(o Vec2) Vec2 { return Vec2{v.X + o.X, v.Y + o.Y} }
// SumAll은 A가 무엇인지 모른다. "A끼리 더하면 A가 나온다"는 것만 안다.
func SumAll[A Adder[A]](xs []A) A {
var acc A // 제로값에서 시작한다. 덧셈의 항등원이라는 가정이 숨어 있다.
for _, x := range xs {
acc = acc.Add(x)
}
return acc
}
// ---- 포인터 리시버가 섞이면 제약이 한 단계 복잡해진다 ----
// Accumulator는 값을 누적한다. Reset이 상태를 바꾸므로 포인터 리시버다.
type Accumulator[T any] interface {
*T // 근본 타입이 *T인 타입만 허용한다
Reset()
Add(int)
Value() int
}
type IntAcc struct{ n int }
func (a *IntAcc) Reset() { a.n = 0 }
func (a *IntAcc) Add(v int) { a.n += v }
func (a *IntAcc) Value() int { return a.n }
func (a IntAcc) String() string { return fmt.Sprintf("IntAcc(%d)", a.n) }
// AccumulateInto는 T의 제로값을 스스로 만들고 *T로 조작한다.
// 타입 파라미터가 두 개인 이유는, *T가 메서드를 가진다는 것을 표현하려면
// T와 *T를 둘 다 이름 붙여야 하기 때문이다.
func AccumulateInto[T any, PT Accumulator[T]](vs []int) T {
var storage T
acc := PT(&storage)
acc.Reset()
for _, v := range vs {
acc.Add(v)
}
return storage
}
func main() {
fmt.Println("Money 합계:", SumAll([]Money{100, 250, 30}))
fmt.Println("Vec2 합계:", SumAll([]Vec2{{1, 2}, {3, 4}}))
// 빈 입력이면 제로값이 나온다. Money(0), Vec2{0,0}이 마침 항등원이라 자연스럽다.
fmt.Println("빈 입력:", SumAll([]Money{}), SumAll([]Vec2{}))
// SumAll([]int{1, 2})는 컴파일 에러 — int에는 Add 메서드가 없다.
// int does not satisfy Adder[int] (missing method Add)
acc := AccumulateInto[IntAcc]([]int{1, 2, 3})
fmt.Println("AccumulateInto:", acc.String(), "값:", acc.n)
}
go run ./02-self-referential
Money 합계: 380
Vec2 합계: {4 6}
빈 입력: 0 {0 0}
AccumulateInto: IntAcc(6) 값: 6
무엇이 새로운가
Go 1.26 이전에는 type Adder[A Adder[A]] interface { Add(A) A }를 컴파일러가
거부했다. 우회하려면 제약을 Adder[A any]로 느슨하게 두고 사용처에서
[A Adder[A]]를 적어야 했는데, 그러면 Money에 Vec2를 더하는 조합을 막지
못했다. 자기 참조가 허용되면서 "결과 타입이 자기 자신"이라는 조건이 제약 안에서
표현된다.
이 패턴은 Java의 class Enum<E extends Enum<E>>이나 Rust의
trait Add<Rhs = Self>와 같은 문제를 푼다. 흔히 F-bounded 다형성이라고 부른다.
:::info 제로값이 항등원이라는 가정
SumAll은 var acc A에서 시작한다. Money(0)과 Vec2{0,0}은 마침 덧셈의
항등원이라 문제가 없지만, 곱셈이라면 제로값이 0이라 답이 항상 0이 된다.
제네릭 코드가 제로값에서 출발할 때는 그 제로값이 무슨 의미인지 항상 확인해야 한다.
안전한 형태는 첫 원소에서 시작하고 빈 입력을 에러로 돌려주는 것이다
(5-1의 Max가 그 형태다).
:::
[T any, PT Accumulator[T]] — 포인터 제약 패턴
AccumulateInto가 타입 파라미터를 둘 쓰는 이유를 보자. 하고 싶은 것은
"T의 제로값을 만들고, *T의 메서드로 조작한다"인데, T와 *T를 한 이름으로는
표현할 수 없다. 그래서 T(저장소 타입)와 PT(그 포인터 타입)를 따로 받고,
PT의 제약에 *T를 유니온 항으로 넣는다.
type Accumulator[T any] interface {
*T // PT의 근본 타입이 *T여야 한다
Reset() // 그리고 이 메서드들을 가져야 한다
Add(int)
Value() int
}
호출부는 AccumulateInto[IntAcc](...)처럼 첫 타입만 적으면 된다. 나머지 PT는
추론된다. 이 패턴은 못생겼지만 제네릭 코드가 값을 새로 만들어야 하면서 그 값의
메서드가 포인터 리시버일 때 피할 방법이 없다. 표준 라이브러리 바깥에서
encoding 계열 제네릭 헬퍼를 쓸 때 자주 만난다.
흔히 하는 실수
1. ~를 빠뜨린다
type MyOrdered interface{ int | string } // 도메인 타입이 전부 튕긴다
type MyOrdered interface{ ~int | ~string } // 이쪽
에러 메시지에 possibly missing ~이 나오면 이것이다. 제약을 새로 쓸 때는 일단
~를 붙이고, 안 붙일 이유가 있는지 나중에 생각한다.
2. 제약을 직접 만든다
type MyOrdered interface{ ~int | ~float64 | ~string } // cmp.Ordered가 이미 있다
cmp.Ordered, comparable이 이미 표준이다. golang.org/x/exp/constraints에
Integer, Float, Signed, Unsigned가 있지만, 표준 라이브러리로 승격되지 않았고
앞으로도 그럴 계획이 없다. 필요하면 자기 패키지에 짧게 정의하는 편이 낫다.
3. 제약을 너무 좁게 잡는다
func Keys[K cmp.Ordered, V any](m map[K]V) []K // 왜 Ordered인가?
func Keys[K comparable, V any](m map[K]V) []K // 맵 키면 충분하다
함수 본문이 실제로 요구하는 만큼만 제약한다. 인터페이스를 작게 유지하라는
4-3의 원칙이 제약에도 그대로다.
정렬이 필요하면 그때 cmp.Ordered를 요구하는 별도 함수를 둔다.
4. 제약을 변수 타입으로 쓴다
var x Number = 3 // 컴파일 에러: cannot use type Number outside a type constraint
타입 집합을 가진 인터페이스는 제약 자리에서만 유효하다. 메서드만 있는 인터페이스는 둘 다 된다.
5. comparable을 만족한다고 안심한다
T = any나 T = error로 인스턴스화되면 ==가 런타임에 터진다. 에러를 담는
컨테이너를 만들 때 특히 위험하다.
6. 제로값을 유효한 상태로 만들지 않는다
var s Set[string]이 Add에서 패닉나는 것이 예제 그대로다. 공개 제네릭 타입을
만든다면 제로값에서 무슨 일이 일어나는지 반드시 정하고 문서에 적는다.
정리
- 제약은 제한이 아니라 허가다. 제약에 적은 것만 본문에서 쓸 수 있다.
- 제약 인터페이스에는 메서드 대신 타입 집합을 적을 수 있다.
|로 나열하고, 제약끼리 임베딩해 합친다. ~T는 "근본 타입이T인 모든 타입" 이다. 도메인 타입을 받으려면 거의 항상 필요하다.- 메서드와 타입 집합을 한 제약에 섞을 수 있다. 포인터 리시버 규칙은 그대로 적용된다.
comparable은==와 맵 키의 조건이고, 3-4/4-4의 규칙에 이름을 붙인 것이다. 다만 인터페이스 타입은comparable을 만족하므로 런타임 패닉 가능성이 남는다.- 메서드에는 타입 파라미터를 붙일 수 없다. 그래서 Go에는 메서드 체이닝 대신 패키지 수준 함수가 있다. 더 좁은 제약이 필요할 때도 함수로 뺀다.
- 제네릭 자료구조는 제로값이 유효한지를 반드시 정해야 한다. 원소 타입의 제로값은
"없음"을 뜻할 수 없으므로
(T, bool)이나(T, error)로 돌려준다. - 자기 참조 제약
[A Adder[A]]로 "결과가 자기 자신인 연산"을 표현한다. 값을 새로 만들면서 포인터 메서드가 필요하면[T any, PT Accumulator[T]]형태를 쓴다.
연습문제
-
Set을 제로값에서 바로 쓸 수 있게 고쳐 보자.Add에서 지연 초기화하면 된다. 그다음,Union이nil리시버에서도 동작하려면 무엇을 더 고쳐야 하는가? 포인터 리시버 메서드를nil포인터에서 부르는 것이 왜 합법인지 4-1을 떠올려 보자. -
Ring[T any]— 고정 크기 순환 버퍼를 만들어 보자.Push(T)는 가득 차면 가장 오래된 것을 덮어쓰고,Items() []T는 오래된 순서로 돌려준다. 힌트: 제로값Ring[int]{}은 용량이 0이다. 이때Push가 어떻게 동작해야 하는지를 먼저 정하고 구현하자. 그리고Ring에서 원소를 지울 때var zero T로 덮어써야 하는 이유는 무엇인가(힌트:T가 포인터라면)? -
Named제약을~int | ~string에서~int만으로 좁히면Label(Tag("go"))가 깨진다. 반대로fmt.Stringer만 남기고 타입 집합을 빼면 무엇이 달라지는가? 각 조합에서Label본문에v * 2를 추가할 수 있는지 시험해 보고, 유니온에 여러 타입이 있을 때 어떤 연산이 허용되는지의 규칙을 스스로 정리해 보자.