본문으로 건너뛰기

제네릭 기초

이 챕터에서 다루는 것

4-5는 표를 하나 남기고 끝났다. "동작이 다르면 인터페이스, 타입만 다르면 제네릭." 이 문장이 Part 4에 세 번 나오는데, 정작 제네릭이 무엇인지는 한 번도 설명하지 않았다. 이 챕터가 그 빚을 갚는다.

타입 파라미터 문법, 타입 추론, 그리고 제네릭을 쓰면 안 되는 경우를 다룬다. 제약(constraint)은 개념만 잡고, 설계는 5-2에서 한다.

문제 — 타입만 다른 코드가 복사된다

[]int에서 최댓값을 찾는 함수를 썼다고 하자. 다음 주에 []float64가 필요해진다. 그다음에는 []string이 필요해진다. 세 함수의 본문은 글자 하나도 다르지 않다. 다른 것은 타입 이름뿐이다.

Go에는 2022년(1.18)까지 이 문제의 답이 없었다. 있던 우회로는 두 가지다.

  1. 복사한다. 표준 라이브러리의 stringsbytes 패키지가 그 흔적이다. 거의 같은 함수가 두 벌씩 있다.
  2. any로 받는다. 타입 정보를 버리고, 쓸 때마다 타입 단언으로 되찾는다. 4-3에서 "여러 타입을 받고 싶다면 제네릭이 답"이라고 미뤄 둔 그 자리다.

세 방식을 나란히 놓고 보자.

examples/05-generics-and-advanced/01-why-generics/main.go
package main

import (
"errors"
"fmt"
)

// ---- 1. 제네릭 이전: 타입마다 같은 코드를 복사한다 ----

func maxInt(xs []int) (int, error) {
if len(xs) == 0 {
return 0, errors.New("빈 슬라이스")
}
m := xs[0]
for _, x := range xs[1:] {
if x > m {
m = x
}
}
return m, nil
}

func maxFloat64(xs []float64) (float64, error) {
if len(xs) == 0 {
return 0, errors.New("빈 슬라이스")
}
m := xs[0]
for _, x := range xs[1:] {
if x > m {
m = x
}
}
return m, nil
}

// ---- 2. any로 우회하기: 컴파일은 되지만 대가가 크다 ----

func maxAny(xs []any) (any, error) {
if len(xs) == 0 {
return nil, errors.New("빈 슬라이스")
}
m := xs[0]
for _, x := range xs[1:] {
switch v := x.(type) {
case int:
if v > m.(int) { // m이 int가 아니면 여기서 패닉
m = v
}
case float64:
if v > m.(float64) {
m = v
}
default:
return nil, fmt.Errorf("지원하지 않는 타입: %T", x)
}
}
return m, nil
}

// ---- 3. 제네릭: 코드 하나, 타입 검사는 컴파일 시점에 ----

// Ordered는 > 연산자를 쓸 수 있는 타입들의 집합이다.
// 표준 라이브러리의 cmp.Ordered와 같은 모양이며, 5-2에서 문법을 뜯어본다.
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 | ~string
}

func Max[T Ordered](xs []T) (T, error) {
var zero T
if len(xs) == 0 {
return zero, errors.New("빈 슬라이스")
}
m := xs[0]
for _, x := range xs[1:] {
if x > m {
m = x
}
}
return m, nil
}

func main() {
mi, err := maxInt([]int{3, 9, 2})
if err != nil {
fmt.Println("maxInt:", err)
}
mf, err := maxFloat64([]float64{1.5, 0.25})
if err != nil {
fmt.Println("maxFloat64:", err)
}
fmt.Println("복사한 버전:", mi, mf)

ma, err := maxAny([]any{3, 9, 2})
if err != nil {
fmt.Println("maxAny:", err)
}
fmt.Println("any 버전:", ma)

// any 버전은 섞인 입력을 컴파일 시점에 막지 못한다.
if _, err := maxAny([]any{3, "아홉"}); err != nil {
fmt.Println("any 버전 실패:", err)
}

// 타입이 섞였는데 default에도 안 걸리면 단언에서 패닉이 난다.
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("any 버전 패닉:", rec)
}
}()
if _, err := maxAny([]any{3, 9.5}); err != nil {
fmt.Println("maxAny:", err)
}
}()

// 제네릭 버전은 호출부에 타입을 적지 않아도 된다(타입 추론).
gi, err := Max([]int{3, 9, 2})
if err != nil {
fmt.Println("Max[int]:", err)
}
gs, err := Max([]string{"나", "가", "다"})
if err != nil {
fmt.Println("Max[string]:", err)
}
fmt.Println("제네릭 버전:", gi, gs)

// 빈 슬라이스일 때 돌아오는 것은 T의 제로값이다.
empty, err := Max([]string{})
fmt.Printf("빈 입력: 값=%q 에러=%v\n", empty, err)

// Max([]any{3, "아홉"})은 아예 컴파일되지 않는다.
// any does not satisfy Ordered
}
go run ./01-why-generics
복사한 버전: 9 1.5
any 버전: 9
any 버전 실패: 지원하지 않는 타입: string
any 버전 패닉: interface conversion: interface {} is int, not float64
제네릭 버전: 9 다
빈 입력: 값="" 에러=빈 슬라이스

출력에서 읽어야 할 것

any 버전은 실패를 런타임까지 미룬다. []any{3, "아홉"}default 가지에 걸려 에러가 됐지만, []any{3, 9.5}case float64에 들어가서 m.(int) 단언에서 패닉이 났다. 세 줄짜리 함수에 이미 버그가 하나 들어 있다는 뜻이다. 타입 정보를 버리면 그 자리를 검사 코드로 메워야 하고, 검사 코드에는 빠진 경우가 생긴다.

제네릭 버전은 그 실수 자체가 존재할 수 없다. Max([]any{...})는 컴파일되지 않는다. anyOrdered를 만족하지 않기 때문이다.

Max([]string{"나", "가", "다"})"다"를 준 것은 문자열 비교가 유니코드 코드 포인트 순서라서다. 한글 음절은 가나다 순으로 배치되어 있으므로 결과가 직관과 맞지만, 한자나 옛한글이 섞이면 사전 순과 다르다.

빈 입력에서 ""가 나온 것이 제네릭 코드의 특징적인 문제다. T가 무엇인지 모르니 return 0return ""도 쓸 수 없고, var zero T제로값을 만들어 돌려준다. 이 관용구는 제네릭 코드 어디에나 나온다.

문법 — 대괄호가 하는 일

func Max[T Ordered](xs []T) (T, error)
// └────┬────┘
// 타입 파라미터 목록

[T Ordered]"이 함수는 타입 하나를 인자로 더 받는다. 그 타입의 이름은 T이고, Ordered를 만족해야 한다" 는 뜻이다. T는 함수 본문 어디서나 타입으로 쓸 수 있다.

Part 4까지 []int, map[string]int처럼 대괄호를 봐 왔지만 그것은 슬라이스와 맵 문법이었다. 타입 파라미터의 대괄호는 그것들과 무관한, 별개의 문법이다. 다른 언어의 <T>에 해당한다. Go가 꺾쇠 대신 대괄호를 고른 이유는 파싱 모호성 때문이다 — a < b, c > (d)가 비교식인지 인스턴스화인지 구분하려면 컴파일러가 무한 선행 탐색을 해야 한다.

제약(constraint)은 인터페이스다. [T Ordered]Ordered는 위 예제에서 interface {...}로 선언했다. 즉 Go는 새 문법 범주를 만들지 않고 인터페이스를 재활용했다. 다만 제약으로 쓰이는 인터페이스는 메서드 목록 대신 타입 집합을 적을 수 있다(~int | ~string). 그 문법은 5-2의 주제다.

지금 알아야 할 제약은 세 가지뿐이다.

제약의미
any아무 타입이나. 대입·전달·비교 아님 외에는 아무것도 못 한다
comparable==, !=를 쓸 수 있는 타입. 맵의 키가 될 수 있는 타입과 같다
cmp.Ordered<, >를 쓸 수 있는 타입. 정수·부동소수·문자열

comparable4-4의 인터페이스 비교 패닉과 3-4의 구조체 비교 가능성 규칙이 언어 차원에서 이름을 얻은 것이다. 정확한 경계는 5-2에서 본다.

타입 추론

호출할 때마다 Max[int](...)라고 적어야 한다면 제네릭은 훨씬 덜 쓰였을 것이다. 컴파일러는 인자에서 타입 파라미터를 역산한다.

examples/05-generics-and-advanced/01-type-params/main.go
package main

import (
"fmt"
"strconv"
"strings"
)

// ---- 제네릭 함수 ----

// Map은 []T를 []U로 옮긴다. 타입 파라미터가 두 개다.
func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, 0, len(xs))
for _, x := range xs {
out = append(out, f(x))
}
return out
}

func Filter[T any](xs []T, keep func(T) bool) []T {
var out []T
for _, x := range xs {
if keep(x) {
out = append(out, x)
}
}
return out
}

// Reduce의 누산기 타입 U는 인자 init에서 추론된다.
func Reduce[T, U any](xs []T, init U, f func(U, T) U) U {
acc := init
for _, x := range xs {
acc = f(acc, x)
}
return acc
}

// ---- 제네릭 타입 ----

// Pair는 타입 파라미터를 가진 구조체다. 쓸 때는 반드시 Pair[int, string]처럼 채운다.
type Pair[A, B any] struct {
First A
Second B
}

// 메서드는 리시버에서 타입 파라미터를 받는다. 새로 선언하지는 못한다.
func (p Pair[A, B]) Swap() Pair[B, A] {
return Pair[B, A]{First: p.Second, Second: p.First}
}

func (p Pair[A, B]) String() string {
return fmt.Sprintf("(%v, %v)", p.First, p.Second)
}

// MakePair는 생성자다. 함수라서 타입 추론이 되고, 그래서 타입을 안 적어도 된다.
func MakePair[A, B any](a A, b B) Pair[A, B] {
return Pair[A, B]{First: a, Second: b}
}

func main() {
nums := []int{1, 2, 3, 4, 5}

// 타입 추론: Map[int, string]이라고 안 적어도 된다.
strs := Map(nums, strconv.Itoa)
fmt.Println("Map:", strings.Join(strs, "-"))

// 리터럴 함수를 넘길 때는 인자 타입을 명시해야 추론이 성립한다.
squares := Map(nums, func(n int) int { return n * n })
fmt.Println("Map 제곱:", squares)

evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println("Filter:", evens)

sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
joined := Reduce(nums, "", func(acc string, n int) string { return acc + strconv.Itoa(n) })
fmt.Println("Reduce 합:", sum, "/ Reduce 이어붙이기:", joined)

// 명시적 인스턴스화. 추론이 안 되거나 의도를 드러내고 싶을 때 쓴다.
toFloat := Map[int, float64]
fmt.Println("명시적 인스턴스화:", toFloat(nums, func(n int) float64 { return float64(n) / 2 }))

// 제네릭 타입은 타입 인자를 적어야 한다. 타입 추론은 함수 호출에만 있다.
var p Pair[int, string] = Pair[int, string]{First: 1, Second: "하나"}
fmt.Println("Pair:", p, "→ Swap:", p.Swap())

// 생성자 함수를 두면 호출부가 짧아진다.
q := MakePair("나이", 42)
fmt.Printf("MakePair: %v (타입 %T)\n", q, q)

// Pair[int, string]과 Pair[string, int]는 서로 다른 타입이다.
// p = q // 컴파일 에러: cannot use q (Pair[string, int]) as Pair[int, string]
fmt.Println("서로 다른 타입:", fmt.Sprintf("%T", p) != fmt.Sprintf("%T", q))
}
go run ./01-type-params
Map: 1-2-3-4-5
Map 제곱: [1 4 9 16 25]
Filter: [2 4]
Reduce 합: 15 / Reduce 이어붙이기: 12345
명시적 인스턴스화: [0.5 1 1.5 2 2.5]
Pair: (1, 하나) → Swap: (하나, 1)
MakePair: (나이, 42) (타입 main.Pair[string,int])
서로 다른 타입: true

추론이 되는 경우와 안 되는 경우

인자에서 추론된다. Map(nums, strconv.Itoa)에서 nums[]int이므로 T = int, strconv.Itoafunc(int) string이므로 U = string이다.

반환 타입에서는 추론되지 않는다. 이것이 다른 언어와 갈리는 지점이다.

func Zero[T any]() T { var z T; return z }

x := Zero() // 컴파일 에러: cannot infer T
y := Zero[int]() // 이렇게 적어야 한다
var z int = Zero() // 이것도 에러다. 대입 대상은 추론에 쓰이지 않는다

제네릭 타입에는 추론이 없다. Pair[int, string]{...}처럼 항상 적어야 한다. 그래서 MakePair 같은 생성자 함수를 같이 두는 관행이 생겼다. 함수에는 추론이 있으므로 MakePair("나이", 42)로 끝난다.

%Tmain.Pair[string,int]로 출력된다. 제네릭 타입은 타입 인자까지 포함해야 타입 하나가 된다. Pair[int, string]Pair[string, int]완전히 다른 타입이고 서로 대입되지 않는다.

:::warning 리터럴 함수의 인자 타입은 생략할 수 없다

Map(nums, func(n) string { return "x" }) // 컴파일 에러 — Go에 이런 문법이 없다
Map(nums, func(n int) string { return "x" }) // 이렇게

Go의 함수 리터럴은 항상 완전한 시그니처를 요구한다. TypeScript나 Kotlin의 람다처럼 문맥에서 인자 타입이 채워지지 않는다. 제네릭 함수에 콜백을 넘길 때 코드가 길어지는 주된 이유다. :::

인터페이스인가 제네릭인가

Part 4가 남긴 문장을 이제 정면으로 본다.

동작이 다르면 인터페이스, 타입만 다르면 제네릭.

두 방향을 각각 하나씩, 그리고 잘못 고른 경우를 같이 본다.

examples/05-generics-and-advanced/01-generics-vs-interfaces/main.go
package main

import (
"errors"
"fmt"
"math"
)

// ---- 방향 1: 동작이 다르다 → 인터페이스 ----

// Circle과 Rect는 넓이를 "다른 방식으로" 구한다. 그래서 메서드가 필요하다.
type Shape interface {
Area() float64
}

type Circle struct{ R float64 }

func (c Circle) Area() float64 { return math.Pi * c.R * c.R }

type Rect struct{ W, H float64 }

func (r Rect) Area() float64 { return r.W * r.H }

// 인터페이스 슬라이스라서 서로 다른 도형을 한 자루에 담을 수 있다.
func TotalArea(shapes []Shape) float64 {
var total float64
for _, s := range shapes {
total += s.Area()
}
return total
}

// 같은 일을 제네릭으로 옮기면 오히려 나빠진다.
// []Circle이나 []Rect는 되지만, 섞인 슬라이스는 못 받는다.
func TotalAreaGeneric[T Shape](shapes []T) float64 {
var total float64
for _, s := range shapes {
total += s.Area()
}
return total
}

// ---- 방향 2: 타입만 다르다 → 제네릭 ----

var errEmptyStack = errors.New("빈 스택")

// any로 만든 스택. 꺼낼 때마다 타입 단언이 필요하고, 잘못 넣어도 컴파일러가 안 막는다.
type AnyStack struct{ items []any }

func (s *AnyStack) Push(v any) { s.items = append(s.items, v) }

func (s *AnyStack) Pop() (any, error) {
if len(s.items) == 0 {
return nil, errEmptyStack
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, nil
}

// 제네릭 스택. 넣는 것과 꺼내는 것의 타입이 컴파일 시점에 고정된다.
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, error) {
var zero T
if len(s.items) == 0 {
return zero, errEmptyStack
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, nil
}

func (s *Stack[T]) Len() int { return len(s.items) }

func main() {
shapes := []Shape{Circle{R: 1}, Rect{W: 2, H: 3}}
fmt.Printf("인터페이스 합계: %.4f\n", TotalArea(shapes))

// 제네릭 버전은 동종 슬라이스만 받는다.
fmt.Printf("제네릭 합계(원만): %.4f\n", TotalAreaGeneric([]Circle{{R: 1}, {R: 2}}))
// TotalAreaGeneric(shapes)도 T=Shape로 컴파일은 되지만, 그건 결국 인터페이스 버전이다.
fmt.Printf("제네릭 합계(T=Shape): %.4f\n", TotalAreaGeneric(shapes))

// any 스택: 실수가 런타임까지 살아남는다.
as := &AnyStack{}
as.Push(1)
as.Push("둘") // 컴파일러가 막지 않는다
v, err := as.Pop()
if err != nil {
fmt.Println("Pop:", err)
}
n, ok := v.(int)
fmt.Printf("any 스택에서 int로 꺼내기: n=%d ok=%t\n", n, ok)

// 제네릭 스택: 잘못 넣는 것 자체가 컴파일 에러다.
gs := &Stack[int]{}
gs.Push(1)
gs.Push(2)
// gs.Push("둘") // 컴파일 에러: cannot use "둘" (untyped string) as int
top, err := gs.Pop()
if err != nil {
fmt.Println("Pop:", err)
}
fmt.Println("제네릭 스택 top:", top, "남은 개수:", gs.Len())

// 빈 스택에서 꺼내면 T의 제로값과 에러가 함께 온다.
empty := &Stack[string]{}
s, err := empty.Pop()
fmt.Printf("빈 제네릭 스택: 값=%q 에러=%v\n", s, err)

// ---- 두 방향을 섞는 자리 ----
// 타입은 파라미터로, 동작은 함수 인자로 받는다. slices.SortFunc이 이 모양이다.
fmt.Println("MaxBy:", MaxBy([]Rect{{W: 1, H: 1}, {W: 3, H: 2}}, func(r Rect) float64 { return r.Area() }))
}

// MaxBy는 T를 모른다. "T에서 점수를 뽑는 법"만 인자로 받는다.
func MaxBy[T any](xs []T, score func(T) float64) T {
var best T
bestScore := math.Inf(-1)
for _, x := range xs {
if s := score(x); s > bestScore {
best, bestScore = x, s
}
}
return best
}
go run ./01-generics-vs-interfaces
인터페이스 합계: 9.1416
제네릭 합계(원만): 15.7080
제네릭 합계(T=Shape): 9.1416
any 스택에서 int로 꺼내기: n=0 ok=false
제네릭 스택 top: 2 남은 개수: 1
빈 제네릭 스택: 값="" 에러=빈 스택
MaxBy: {3 2}

방향 1 — 여기서 제네릭은 오답이다

TotalAreaGeneric[T Shape]는 문법적으로 완벽하고 컴파일도 되지만, 인터페이스 버전보다 모든 면에서 나쁘다.

  • 가장 중요한 능력을 잃는다. []Shape에는 원과 사각형을 섞어 담을 수 있다. TotalAreaGeneric[]Circle 아니면 []Rect다. 도형을 모아 넓이를 합산하는 일에서 "섞어 담기"를 못 하면 존재 이유가 사라진다.
  • 얻는 것이 없다. 제네릭이 줄여 주는 것은 타입 단언과 박싱인데, Area()는 어차피 메서드 호출이다.
  • 결국 호출부가 TotalAreaGeneric(shapes)처럼 T = Shape로 인스턴스화하게 되는데, 그러면 처음부터 인터페이스 버전이었던 셈이다.

판별 기준은 이렇게 말할 수 있다. T마다 다른 코드가 실행되어야 하면 인터페이스다. 각 타입이 자기 방식을 알고 있어야 하기 때문이다. 반대로 T가 무엇이든 똑같은 코드가 실행되면 제네릭이다. 코드가 하나뿐인데 타입 때문에 세 벌을 두는 상황이 제네릭의 자리다.

Stack[T]PushTint*User든 슬라이스 끝에 붙이는 같은 동작이다. Shape.Area()는 타입마다 다른 공식이다. 이 차이가 전부다.

방향 3 — 섞는 자리

두 방향이 배타적이지는 않다. MaxBy타입은 파라미터로, 동작은 함수 인자로 받는다. T가 무엇인지는 모르고, "T에서 점수를 뽑는 법"만 밖에서 주입받는다.

표준 라이브러리의 slices.SortFunc, slices.IndexFunc, maps.EqualFunc이 전부 이 모양이다(5-3). Part 4에서 본 XxxFunc 어댑터의 제네릭 버전이라고 봐도 된다.

any는 최후의 수단이다

4-3에서 any를 써도 되는 자리를 셋으로 정리했다. 그중 세 번째가 "그 외에는 거의 없다 — 여러 타입을 받고 싶다면 제네릭"이었다. 이제 그 대안이 손에 있으니, 판단표를 완성할 수 있다.

상황
값을 들여다보지 않고 통과만 시킨다 (fmt.Println(a ...any))any
구조가 실행 시점에야 정해진다 (JSON, 설정 파일)any
타입마다 다른 동작이 필요하다인터페이스
타입만 다르고 코드는 같다제네릭
타입은 하나이고 동작만 주입하면 된다함수 인자

any가 남는 자리는 "타입을 정말로 모를 때"뿐이다. 컴파일 시점에 타입을 알 수 있는데도 any로 받는 것은 컴파일러에게 줄 수 있는 정보를 스스로 버리는 일이다. 위 maxAny의 패닉이 그 대가다.

:::note 제네릭이 성능을 위한 기능은 아니다 Go 컴파일러는 제네릭 함수를 GC 형태(GCShape) 단위로 인스턴스화한다. 포인터 크기 타입들은 하나의 기계어 코드를 공유하고, 딕셔너리라는 숨은 인자로 타입 정보를 넘긴다. 그래서 C++ 템플릿처럼 타입마다 완전히 특화된 코드가 나오지도 않고, Java 제네릭처럼 전부 지워지지도 않는 중간 형태다.

실제로 any 대비 박싱과 할당이 줄어드는 경우가 많지만, 인터페이스 호출보다 반드시 빠르다고 가정하면 안 된다. 제네릭을 고르는 이유는 타입 안전성과 중복 제거이지 성능이 아니다. 성능이 목적이라면 측정해야 한다(Part 12). :::

흔히 하는 실수

1. 제네릭부터 쓴다

가장 흔하고 가장 비싼 실수다. Part 4에서 "인터페이스는 발명이 아니라 발견" 이라고 했는데, 제네릭에는 그 원칙이 더 강하게 적용된다.

Write code, don't design types. — Go 팀의 제네릭 가이드

두 번째 구체 타입이 실제로 필요해질 때까지 제네릭을 쓰지 않는다. []int용 함수 하나면 충분한 코드를 [T Ordered]로 만드는 순간, 읽는 사람은 타입 파라미터가 왜 있는지 추적해야 하고, 제약을 만족하지 않는 타입을 넣으려다 낯선 에러 메시지를 만난다.

2. 타입 파라미터를 한 번만 쓴다

func Print[T any](v T) { fmt.Println(v) } // 의미 없다
func Print(v any) { fmt.Println(v) } // 이거면 된다

타입 파라미터가 시그니처에 딱 한 번만 등장하면 거의 항상 불필요하다. 제네릭의 값은 "여러 자리의 타입이 서로 같음"을 보장하는 데서 나온다. func Append[T any](s []T, v T) []Ts의 원소 타입과 v의 타입이 같음을 강제하므로 정당하다.

3. 제로값을 잊는다

func First[T any](xs []T) T {
return xs[0] // 빈 슬라이스면 패닉
}

T가 무엇인지 모르므로 "없음"을 표현할 방법이 var zero T 아니면 두 번째 반환값밖에 없다. Stack.Pop처럼 (T, error)(T, bool)을 돌려주는 것이 표준적인 형태다. Tint면 제로값이 0이고 이것은 정상적인 값이므로, 제로값만으로 실패를 알릴 수는 없다.

4. 제네릭 메서드를 만들려고 한다

type Store[T any] struct{ ... }

func (s Store[T]) MapTo[U any](f func(T) U) Store[U] { // 컴파일 에러

메서드에는 타입 파라미터를 붙일 수 없다. 이것은 문법 미비가 아니라 의도된 제약이고, 이유와 우회법은 5-2에서 다룬다. 지금은 "메서드에서 MapTo 같은 걸 하려다 막히면 그건 정상"이라고만 알아 두면 된다.

5. 제약을 인터페이스처럼 쓴다

var s Ordered = 3 // 컴파일 에러: cannot use type Ordered outside a type constraint

타입 집합을 가진 인터페이스는 제약 자리에서만 쓸 수 있다. 변수 타입이나 슬라이스 원소 타입으로는 못 쓴다. 메서드만 있는 인터페이스(Shape)는 둘 다 된다.

정리

  • 제네릭은 타입만 다른 코드의 중복을 없애기 위한 기능이다. 그전의 답은 복사 아니면 any였고, any는 타입 검사를 런타임으로 미룬다.
  • func F[T C](...)[T C]타입 파라미터 목록이다. C제약이고, 제약은 인터페이스다.
  • 함수 호출에는 타입 추론이 있다. 인자에서 역산하며, 반환 타입이나 대입 대상에서는 추론되지 않는다. 제네릭 타입에는 추론이 아예 없어서 생성자 함수를 같이 두는 관행이 있다.
  • var zero T가 제네릭 코드의 필수 관용구다.
  • 동작이 다르면 인터페이스, 타입만 다르면 제네릭. 판별 기준은 T마다 다른 코드가 실행되어야 하는가다.
  • 타입은 파라미터로, 동작은 함수 인자로 받는 형태(MaxBy, slices.SortFunc)가 실무에서 가장 자주 쓰인다.
  • any가 남는 자리는 타입을 정말로 모를 때뿐이다.
  • 제네릭은 두 번째 타입이 실제로 필요해진 다음에 도입한다.

연습문제

  1. Filter[]T가 아니라 원소를 하나씩 검사하는 FilterInPlace[T any](xs []T, keep func(T) bool) []T로 바꿔 보자. 백킹 배열을 재사용해 새 할당을 하지 않는 형태다. 힌트: out := xs[:0]으로 시작한다. 3-2의 백킹 배열 공유가 여기서는 왜 문제가 아니라 이득인가? 원래 슬라이스를 계속 쓰려던 호출자에게는 왜 재앙인가?

  2. Pair[A, B]Map 같은 걸 붙이려다 실수 4를 직접 만나 보자. func (p Pair[A, B]) MapFirst[C any](f func(A) C) Pair[C, B]를 써서 컴파일해 보고, 에러 메시지를 그대로 읽어 보자. 그다음 함수로 같은 일을 하는 MapFirst[A, B, C any](p Pair[A, B], f func(A) C) Pair[C, B]를 써 보자. 호출부가 어떻게 달라지는가?

  3. 다음 세 함수 중 제네릭이 정당한 것은 무엇이고 아닌 것은 무엇인가. 각각 이유를 한 줄로 적어 보자.

    func Log[T any](v T)
    func Keys[K comparable, V any](m map[K]V) []K
    func Render[T fmt.Stringer](items []T) string

    힌트: 실수 2의 기준과, TotalAreaGeneric이 잃은 것을 떠올려 보자.