제네릭 기초
이 챕터에서 다루는 것
4-5는 표를 하나 남기고 끝났다. "동작이 다르면 인터페이스, 타입만 다르면 제네릭." 이 문장이 Part 4에 세 번 나오는데, 정작 제네릭이 무엇인지는 한 번도 설명하지 않았다. 이 챕터가 그 빚을 갚는다.
타입 파라미터 문법, 타입 추론, 그리고 제네릭을 쓰면 안 되는 경우를 다룬다. 제약(constraint)은 개념만 잡고, 설계는 5-2에서 한다.
문제 — 타입만 다른 코드가 복사된다
[]int에서 최댓값을 찾는 함수를 썼다고 하자. 다음 주에 []float64가 필요해진다.
그다음에는 []string이 필요해진다. 세 함수의 본문은 글자 하나도 다르지 않다.
다른 것은 타입 이름뿐이다.
Go에는 2022년(1.18)까지 이 문제의 답이 없었다. 있던 우회로는 두 가지다.
- 복사한다. 표준 라이브러리의
strings와bytes패키지가 그 흔적이다. 거의 같은 함수가 두 벌씩 있다. any로 받는다. 타입 정보를 버리고, 쓸 때마다 타입 단언으로 되찾는다. 4-3에서 "여러 타입을 받고 싶다면 제네릭이 답"이라고 미뤄 둔 그 자리다.
세 방식을 나란히 놓고 보자.
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{...})는 컴파일되지
않는다. any는 Ordered를 만족하지 않기 때문이다.
Max([]string{"나", "가", "다"})가 "다"를 준 것은 문자열 비교가 유니코드
코드 포인트 순서라서다. 한글 음절은 가나다 순으로 배치되어 있으므로 결과가 직관과
맞지만, 한자나 옛한글이 섞이면 사전 순과 다르다.
빈 입력에서 ""가 나온 것이 제네릭 코드의 특징적인 문제다. T가 무엇인지
모르니 return 0도 return ""도 쓸 수 없고, 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 | <, >를 쓸 수 있는 타입. 정수·부동소수·문자열 |
comparable은 4-4의
인터페이스 비교 패닉과 3-4의 구조체 비교
가능성 규칙이 언어 차원에서 이름을 얻은 것이다. 정확한 경계는
5-2에서 본다.
타입 추론
호출할 때마다 Max[int](...)라고 적어야 한다면 제네릭은 훨씬 덜 쓰였을 것이다.
컴파일러는 인자에서 타입 파라미터를 역산한다.
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.Itoa가 func(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)로 끝난다.
%T가 main.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가 남긴 문장을 이제 정면으로 본다.
동작이 다르면 인터페이스, 타입만 다르면 제네릭.
두 방향을 각각 하나씩, 그리고 잘못 고른 경우를 같이 본다.
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]의 Push는 T가 int든 *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) []T는
s의 원소 타입과 v의 타입이 같음을 강제하므로 정당하다.
3. 제로값을 잊는다
func First[T any](xs []T) T {
return xs[0] // 빈 슬라이스면 패닉
}
T가 무엇인지 모르므로 "없음"을 표현할 방법이 var zero T 아니면 두 번째 반환값밖에
없다. Stack.Pop처럼 (T, error)나 (T, bool)을 돌려주는 것이 표준적인 형태다.
T가 int면 제로값이 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가 남는 자리는 타입을 정말로 모를 때뿐이다.- 제네릭은 두 번째 타입이 실제로 필요해진 다음에 도입한다.
연습문제
-
Filter를[]T가 아니라 원소를 하나씩 검사하는FilterInPlace[T any](xs []T, keep func(T) bool) []T로 바꿔 보자. 백킹 배열을 재사용해 새 할당을 하지 않는 형태다. 힌트:out := xs[:0]으로 시작한다. 3-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]를 써 보자. 호출부가 어떻게 달라지는가? -
다음 세 함수 중 제네릭이 정당한 것은 무엇이고 아닌 것은 무엇인가. 각각 이유를 한 줄로 적어 보자.
func Log[T any](v T)func Keys[K comparable, V any](m map[K]V) []Kfunc Render[T fmt.Stringer](items []T) string힌트: 실수 2의 기준과,
TotalAreaGeneric이 잃은 것을 떠올려 보자.