본문으로 건너뛰기

제약 설계와 제네릭 자료구조

이 챕터에서 다루는 것

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) 이라고 한다.

examples/05-generics-and-advanced/02-type-sets/main.go
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 float643-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.StringerString()을 부른다(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 < 11 < nanfalse인 것이 그 결과다.

cmp.Compare는 NaN을 다른 모든 값보다 작다고 정의해 이 구멍을 메운다. 그래서 cmp.Compare(nan, 1)-1이다. 정렬처럼 전순서가 필요한 곳에서는 <가 아니라 cmp.Compare를 쓴다(5-3). :::

comparable — 정확한 경계

4-4에서 인터페이스 비교가 런타임 패닉을 내는 것을 봤고, 3-4에서 구조체의 비교 가능성 규칙을 봤다. comparable은 그 규칙에 언어 차원의 이름을 붙인 것이다.

examples/05-generics-and-advanced/02-comparable/main.go
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 = anyT = 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)가 된다. 표준 라이브러리의 slicesmaps가 전부 함수인 것도(5-3) 같은 이유다.

타입 자신의 타입 파라미터보다 좁은 제약이 필요할 때도 함수로 뺀다. Set[T comparable]은 정렬할 수 없다. comparable에는 <가 없기 때문이다. 그렇다고 Set[T cmp.Ordered]로 좁히면 구조체를 담지 못한다. 답은 Set[T comparable]을 유지하고, SortedItems[T cmp.Ordered]를 함수로 두는 것이다.

제네릭 자료구조 — Set

지금까지의 규칙이 하나에 모인다.

examples/05-generics-and-advanced/02-set/main.go
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()를 그대로 쓰지 않고 항상 정렬해서 냈다. 제네릭 컨테이너를 만들 때 "순서 없음"을 문서에 명시하지 않으면 사용자는 반드시 순서에 의존하는 코드를 쓴다.

SortedItemsMapSet이 함수인 이유가 서로 다르다. SortedItems더 좁은 제약이 필요해서, MapSet새 타입 파라미터가 필요해서다. 둘 다 메서드로는 표현할 수 없다.

제로값 Set[string]은 반쪽만 동작한다. HasLen은 nil 맵에서도 안전하지만 Add는 패닉이다. 3-3의 nil 맵 규칙 그대로다.

제로값을 어떻게 다룰 것인가

제네릭 자료구조 설계에서 가장 자주 미끄러지는 부분이다. 선택지는 셋이다.

1. 제로값을 유효한 상태로 만든다 (권장). Set을 슬라이스가 아니라 맵으로 만든 것이 문제였다. sync.Mutexstrings.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)을 돌려주는 것이 표준적인 답이다. Tint면 제로값 0이 정상적인 값이므로, 제로값만으로 "없음"을 표현할 수 없다.

자기 참조 제약 [A Adder[A]]

"두 값을 더해 같은 타입을 돌려준다"를 제약으로 적으려면 제약이 자기 자신을 참조해야 한다. Go 1.26에서 이 형태가 허용됐다.

examples/05-generics-and-advanced/02-self-referential/main.go
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]]를 적어야 했는데, 그러면 MoneyVec2를 더하는 조합을 막지 못했다. 자기 참조가 허용되면서 "결과 타입이 자기 자신"이라는 조건이 제약 안에서 표현된다.

이 패턴은 Java의 class Enum<E extends Enum<E>>이나 Rust의 trait Add<Rhs = Self>와 같은 문제를 푼다. 흔히 F-bounded 다형성이라고 부른다.

:::info 제로값이 항등원이라는 가정 SumAllvar acc A에서 시작한다. Money(0)Vec2{0,0}은 마침 덧셈의 항등원이라 문제가 없지만, 곱셈이라면 제로값이 0이라 답이 항상 0이 된다. 제네릭 코드가 제로값에서 출발할 때는 그 제로값이 무슨 의미인지 항상 확인해야 한다. 안전한 형태는 첫 원소에서 시작하고 빈 입력을 에러로 돌려주는 것이다 (5-1Max가 그 형태다). :::

[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/constraintsInteger, 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 = anyT = 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]] 형태를 쓴다.

연습문제

  1. Set제로값에서 바로 쓸 수 있게 고쳐 보자. Add에서 지연 초기화하면 된다. 그다음, Unionnil 리시버에서도 동작하려면 무엇을 더 고쳐야 하는가? 포인터 리시버 메서드를 nil 포인터에서 부르는 것이 왜 합법인지 4-1을 떠올려 보자.

  2. Ring[T any] — 고정 크기 순환 버퍼를 만들어 보자. Push(T)는 가득 차면 가장 오래된 것을 덮어쓰고, Items() []T는 오래된 순서로 돌려준다. 힌트: 제로값 Ring[int]{}은 용량이 0이다. 이때 Push가 어떻게 동작해야 하는지를 먼저 정하고 구현하자. 그리고 Ring에서 원소를 지울 때 var zero T로 덮어써야 하는 이유는 무엇인가(힌트: T가 포인터라면)?

  3. Named 제약을 ~int | ~string에서 ~int만으로 좁히면 Label(Tag("go"))가 깨진다. 반대로 fmt.Stringer만 남기고 타입 집합을 빼면 무엇이 달라지는가? 각 조합에서 Label 본문에 v * 2를 추가할 수 있는지 시험해 보고, 유니온에 여러 타입이 있을 때 어떤 연산이 허용되는지의 규칙을 스스로 정리해 보자.