제네릭 - 타입 매개변수와 제약
이 장에서 배우는 것
기본서에서 만든 중계 서비스는 주문, 가게, 배달원 같은 구체적인 타입으로 짜여 있었다. 서비스가 커지면 "주문 목록에서 조건에 맞는 것만 고르기", "가게별로 묶기", "대기열에 넣고 순서대로 꺼내기" 같은 동작이 타입만 바꿔 가며 반복된다. 이 장에서는 그 반복을 타입 매개변수(type parameter)로 한 번만 쓰는 방법과, 제네릭(generics)을 쓰지 않는 편이 나은 경우를 다룬다.
- 타입 매개변수를 가진 함수를 선언하고 호출하며, 타입 인자가 추론되는 조건을 설명할 수 있다.
any,comparable,cmp.Ordered가 각각 어떤 연산을 허용하는지 구분할 수 있다.- 제약 인터페이스와
~근사 타입으로Won같은 이름 있는 타입을 받는 함수를 만들 수 있다. - 타입 매개변수를 가진 구조체와 메서드로 제네릭 큐를 구현할 수 있다.
- 제네릭이 이득이 없거나 오히려 해가 되는 경우를 알아본다.
문제 상황
중계 서비스 백엔드에 주문 목록이 있다. 배달료 합계를 구하는 함수를 []int 용으로 만들었는데, 얼마 뒤 금액을 Won 이라는 타입으로 감싸자는 의견이 나와 []Won 용 함수가 하나 더 생겼다. 정산 쪽에서는 float64 를 쓰므로 세 번째 복사본이 생긴다. 주문 ID 목록에서 위치 찾기, 가게 이름으로 묶기, 대기열 만들기도 같은 일을 겪는다. 본문은 같고 타입 이름만 다른 함수가 늘어난다.
제네릭 이전에는 두 가지 방법이 있었다. 하나는 복사해서 붙이는 것이고, 다른 하나는 any(빈 인터페이스)로 받고 꺼낼 때 타입 단언을 하는 것이다. 앞의 방법은 수정할 곳이 늘어나고, 뒤의 방법은 컴파일러가 잘못된 타입을 걸러 주지 못한다. 대기열에 주문만 넣을 생각이었는데 문자열이 들어가도 실행 중에야 알게 된다.
타입 매개변수는 "어떤 타입이든 좋지만 한 호출 안에서는 같은 타입"이라는 관계를 컴파일러에게 알려 준다. 함수나 타입을 쓰는 쪽은 구체적인 타입으로 결과를 받고, 쓰는 도중 타입이 어긋나면 컴파일 단계에서 오류가 난다.
타입 매개변수와 제약
선언과 호출
함수 이름 뒤 대괄호 안에 타입 매개변수와 제약(constraint)을 적는다. 제약은 "이 타입 매개변수에 들어올 수 있는 타입의 집합"이자 "함수 본문에서 쓸 수 있는 연산의 목록"이다.
func Map[T, U any](xs []T, f func(T) U) []U
위 선언에서 T 와 U 는 타입 매개변수이고 둘 다 any 제약을 가진다. 호출할 때는 보통 타입 인자를 적지 않는다. 컴파일러가 인자의 타입에서 T 를 알아내는 타입 추론(type inference)을 하기 때문이다. Map(orders, func(o Order) Won { ... }) 에서는 첫 인자로 T = Order, 함수 인자의 반환형으로 U = Won 이 정해진다. 인자만으로 정해지지 않는 경우에만 Zero[int]() 처럼 직접 적는다.
any 와 comparable
any 는 interface{} 의 별칭이다. 제약으로 쓰면 모든 타입을 받지만, 본문에서 할 수 있는 일은 값을 복사하고, 슬라이스에 담고, 다른 함수에 넘기는 정도로 한정된다. ==, +, < 는 쓸 수 없다. 모든 타입이 그 연산을 지원하지는 않기 때문이다.
comparable 은 == 와 != 를 쓸 수 있는 타입의 집합이다. 숫자, 문자열, 불리언, 포인터, 채널, 이런 타입으로만 이루어진 구조체와 배열이 속한다. 슬라이스, 맵, 함수는 비교할 수 없으므로 속하지 않는다. 맵의 키 타입도 같은 이유로 comparable 이어야 한다. 인터페이스 타입은 comparable 을 만족하지만, 담긴 동적 값이 비교 불가 타입이면 실행 중에 패닉이 난다는 점을 기억해 두자.
순서 비교가 필요하면 표준 라이브러리 cmp 패키지의 cmp.Ordered 를 쓴다. 정수, 부동소수점, 문자열 계열을 모두 포함하는 제약이다.
| 제약 | 들어올 수 있는 타입 | 본문에서 쓸 수 있는 연산 | 용도 예 |
|---|---|---|---|
any | 모든 타입 | 대입, 복사, 슬라이스에 담기 | Map, Filter, 큐 |
comparable | 비교 가능한 타입 | ==, !=, 맵 키 | Index, GroupBy 의 키 |
cmp.Ordered | 정수, 실수, 문자열 계열 | <, >, 정렬 | SortedKeys |
| 직접 만든 합집합 | 나열한 타입 계열 | 나열한 타입이 공통으로 지원하는 연산 | Sum |
제약 인터페이스와 ~ 근사 타입
제약은 인터페이스로 쓴다. 메서드 목록 대신 타입 목록을 적을 수 있고, | 로 합집합을 만든다.
type Number interface {
~int | ~int64 | ~float64
}
여기서 ~int 는 "기반 타입(underlying type)이 int 인 모든 타입"을 뜻한다. type Won int 로 정의한 Won 의 기반 타입이 int 이므로 ~int 에 속한다. 물결표 없이 int 만 적으면 정확히 int 하나만 허용하므로 Won 은 거부된다. 도메인 타입을 이름 있는 타입으로 감싸는 코드에서는 물결표를 붙이는 쪽이 일반적이다.
컴파일러는 호출 때 이 검사를 한다. 아래 그림은 Sum(fees) 가 처리되는 순서를 보여 준다.
합집합 제약에 속한 타입이 모두 지원하는 연산만 본문에서 쓸 수 있다. Number 의 세 계열은 모두 += 를 지원하므로 Sum 본문에서 합산이 허용된다. 반대로 제약에 타입 목록이 있는 인터페이스는 일반 변수의 타입으로는 쓸 수 없고, 타입 매개변수의 제약으로만 쓴다.
| 인자 타입 | int 만 적은 제약 | ~int 를 적은 제약 | 이유 |
|---|---|---|---|
[]int | 허용 | 허용 | int 자체 |
[]Won | 컴파일 오류 | 허용 | Won 의 기반 타입이 int |
[]float64 | 컴파일 오류 | float64 가 목록에 있으면 허용 | 목록에 적힌 계열만 통과 |
[]string | 컴파일 오류 | 컴파일 오류 | 목록에 없음 |
제네릭 자료구조: 큐
타입 매개변수는 함수뿐 아니라 타입 선언에도 붙는다. 배달원에게 넘기기 전 주문을 먼저 들어온 순서대로 쌓아 두는 큐를 만들어 보자. 슬라이스 하나로 충분하다.
type Queue[T any] struct {
items []T
}
메서드는 리시버에서 타입 매개변수 이름을 다시 적는다. func (q *Queue[T]) Push(v T) 처럼 쓰며, 이때 T 는 새로 선언하는 것이 아니라 타입 선언의 T 를 받는 이름이다. 메서드 자체에는 새 타입 매개변수를 달 수 없다. 이 제한은 뒤의 "자주 틀리는 것"에서 다시 본다.
Pop 은 빈 큐일 때 무엇을 돌려줄지 정해야 한다. T 의 값을 만들어 낼 방법이 없으므로 var zero T 로 영값을 얻고, 두 번째 반환값 bool 로 성공 여부를 알린다. 기본서에서 본 맵 조회의 v, ok 관용과 같은 모양이다. 또한 꺼낸 칸을 영값으로 덮은 뒤 슬라이스의 시작점을 옮긴다. T 가 포인터나 큰 구조체일 때 앞칸이 계속 값을 붙잡아 두는 일을 막기 위해서다.
이 구현은 고루틴 여러 개가 동시에 쓰기에는 안전하지 않다. 동시 접근을 보호하는 방법은 경쟁 상태를 다루는 장에서 다룬다. 이 장의 큐는 한 고루틴이 쓰는 자료구조로 본다.
제네릭을 쓰지 말아야 할 때
타입 매개변수는 코드를 읽는 사람에게 부담을 준다. 다음 경우에는 쓰지 않는 편이 낫다.
- 메서드 호출만 필요한 경우. 본문이 값의 메서드만 호출한다면 인터페이스 매개변수로 충분하다. 이 주제는 다음 장에서 이어서 다룬다.
- 타입이 하나뿐인 경우. 호출하는 곳이
Order하나뿐이라면 제네릭으로 만들어 얻는 것이 없다. 실제로 두 번째 타입이 필요해졌을 때 바꿔도 늦지 않다. - 타입마다 본문이 달라지는 경우. 타입 스위치로 분기해야 한다면 그 함수는 타입 매개변수가 표현하는 "같은 동작"이 아니다.
- 한 번 쓰고 마는 짧은 반복문. 세 줄짜리
for는Filter를 부르는 것보다 읽기 쉬울 때가 많다.
기준은 단순하다. 같은 알고리즘이 서로 다른 타입의 값에 대해 반복되고, 그 타입이 결과 타입에도 드러나야 할 때 제네릭이 맞다. 슬라이스 조작, 맵 키 추출, 자료구조가 그 전형이다. 표준 라이브러리의 slices, maps 패키지가 이미 이런 함수를 제공하므로, 직접 만들기 전에 거기 있는지 먼저 확인한다. 이 장에서는 개념을 보이기 위해 Map, Filter 를 직접 쓴다.
정확한 문법은 언어 명세의 타입 매개변수 선언 항목에서 확인할 수 있다.
완성 코드
아래 코드를 main.go 하나에 저장한다. 표준 라이브러리만 쓴다.
package main
import (
"cmp"
"fmt"
"slices"
"strings"
)
type Won int
type OrderID string
type Order struct {
ID OrderID
Store string
Fee Won
}
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
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
}
func Index[T comparable](xs []T, target T) int {
for i, x := range xs {
if x == target {
return i
}
}
return -1
}
func GroupBy[K comparable, V any](xs []V, key func(V) K) map[K][]V {
groups := make(map[K][]V)
for _, x := range xs {
k := key(x)
groups[k] = append(groups[k], x)
}
return groups
}
func SortedKeys[K cmp.Ordered, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
return keys
}
type Queue[T any] struct {
items []T
}
func (q *Queue[T]) Push(v T) {
q.items = append(q.items, v)
}
func (q *Queue[T]) Pop() (T, bool) {
var zero T
if len(q.items) == 0 {
return zero, false
}
v := q.items[0]
q.items[0] = zero
q.items = q.items[1:]
return v, true
}
func (q *Queue[T]) Len() int {
return len(q.items)
}
func main() {
orders := []Order{
{"A-101", "한빛분식", 3000},
{"A-102", "봄날카페", 2500},
{"A-103", "한빛분식", 3500},
{"A-104", "새벽빵집", 4000},
}
fees := Map(orders, func(o Order) Won { return o.Fee })
ids := Map(orders, func(o Order) OrderID { return o.ID })
fmt.Println("배달료 합계:", Sum(fees))
big := Filter(orders, func(o Order) bool { return o.Fee >= 3500 })
names := Map(big, func(o Order) string { return string(o.ID) })
fmt.Println("3500원 이상:", strings.Join(names, ", "))
fmt.Println("A-103 위치:", Index(ids, "A-103"))
fmt.Println("Z-999 위치:", Index(ids, "Z-999"))
groups := GroupBy(orders, func(o Order) string { return o.Store })
for _, store := range SortedKeys(groups) {
fmt.Printf("%s: %d건\n", store, len(groups[store]))
}
q := &Queue[Order]{}
for _, o := range orders {
q.Push(o)
}
fmt.Println("대기 주문:", q.Len())
for q.Len() > 0 {
o, _ := q.Pop()
fmt.Printf("배달원에게 전달: %s (%s)\n", o.ID, o.Store)
}
var empty Queue[int]
v, ok := empty.Pop()
fmt.Println("빈 큐 Pop:", v, ok)
}
줄별 해설
type Won int,type OrderID string: 도메인 의미를 담은 이름 있는 타입이다. 기반 타입은 각각int,string이므로~int제약과comparable을 모두 만족한다.Number:~int | ~int64 | ~float64로 숫자 계열을 모은 제약이다. 이름 있는 타입도 받기 위해 모든 항목에~를 붙였다.Sum:var total T는T의 영값(숫자라면 0)으로 시작한다.+=는Number의 모든 타입이 지원하므로 허용된다.Map[T, U any]: 결과 길이를 알고 있으므로make에 용량을 줘서 재할당을 피한다.Filter는 결과 길이를 모르므로nil슬라이스에서 시작한다.Index[T comparable]: 본문의x == target은comparable제약이 보장하기 때문에 컴파일된다.any였다면 이 줄에서 오류가 난다.GroupBy[K comparable, V any]: 맵 키K는 비교 가능해야 하므로comparable, 값V는 아무 타입이나 된다. 두 타입 매개변수가 호출에서key함수의 시그니처로 모두 추론된다.SortedKeys: 맵 순회 순서는 정해져 있지 않으므로 키를 모아slices.Sort로 정렬한다. 정렬에<가 필요해서cmp.Ordered를 쓴다. 한글 문자열은 코드 포인트 순으로 정렬되므로 봄날카페, 새벽빵집, 한빛분식 순이 된다.Queue[T any]: 슬라이스 하나를 감싼 구조체다. 메서드의 리시버는*Queue[T]로 적어 값이 복사되지 않게 한다.Pop: 비었으면zero, false. 아니면 앞의 값을 꺼내고 그 칸을 영값으로 덮은 뒤q.items[1:]로 시작점을 옮긴다.main의Map(orders, func(o Order) Won {...}): 타입 인자를 적지 않았다.T=Order,U=Won이 인자에서 추론된다.Index(ids, "A-103"):ids가[]OrderID이므로T=OrderID이고, 형이 정해지지 않은 문자열 상수"A-103"은OrderID로 변환된다.var empty Queue[int]: 타입 매개변수가 있는 타입은 쓸 때 타입 인자를 반드시 적는다. 비어 있으므로Pop은0, false를 돌려준다.
실행 결과
$ go run main.go
배달료 합계: 13000
3500원 이상: A-103, A-104
A-103 위치: 2
Z-999 위치: -1
봄날카페: 1건
새벽빵집: 1건
한빛분식: 2건
대기 주문: 4
배달원에게 전달: A-101 (한빛분식)
배달원에게 전달: A-102 (봄날카페)
배달원에게 전달: A-103 (한빛분식)
배달원에게 전달: A-104 (새벽빵집)
빈 큐 Pop: 0 false
실무에서 자주 틀리는 것
any 로 받고 연산자를 쓴다
모든 타입을 받는다는 이유로 any 를 적었다가 본문에서 연산자를 쓰면 컴파일 오류가 난다.
// 틀린 코드
func Index[T any](xs []T, target T) int {
for i, x := range xs {
if x == target { // 오류: T 는 비교 가능하다고 보장되지 않는다
return i
}
}
return -1
}
// 고친 코드
func Index[T comparable](xs []T, target T) int {
for i, x := range xs {
if x == target {
return i
}
}
return -1
}
본문에서 쓰는 연산에 맞춰 제약을 고른다. 합산이면 숫자 제약, 순서 비교면 cmp.Ordered, 동등 비교면 comparable 이다.
~ 를 빠뜨려 이름 있는 타입이 거부된다
// 틀린 코드
type Number interface {
int | int64 | float64
}
fees := []Won{3000, 2500}
_ = Sum(fees) // 오류: Won 은 Number 를 만족하지 않는다
// 고친 코드
type Number interface {
~int | ~int64 | ~float64
}
fees := []Won{3000, 2500}
_ = Sum(fees)
오류 메시지에 "Won does not satisfy Number" 와 비슷한 문장이 나오면 물결표를 확인한다. 도메인 타입을 자주 정의하는 프로그램에서 흔히 겪는다.
메서드에 타입 매개변수를 달려고 한다
타입 매개변수는 타입 선언과 함수에만 붙는다. 메서드가 자기만의 타입 매개변수를 선언하는 것은 허용되지 않는다.
// 틀린 코드
func (q *Queue[T]) Map[U any](f func(T) U) *Queue[U] { // 문법 오류
// ...
}
// 고친 코드: 패키지 수준 함수로 뺀다
func MapQueue[T, U any](q *Queue[T], f func(T) U) *Queue[U] {
out := &Queue[U]{}
for _, v := range q.items {
out.Push(f(v))
}
return out
}
리시버가 가진 T 는 쓸 수 있지만 새 타입 매개변수 U 는 메서드에서 도입할 수 없다. 이런 변환은 함수로 쓴다.
인터페이스로 충분한 곳에 제네릭을 쓴다
// 틀린 코드: 메서드 하나만 호출하는데 타입 매개변수를 쓴다
func Describe[T fmt.Stringer](x T) string {
return x.String()
}
// 고친 코드
func Describe(x fmt.Stringer) string {
return x.String()
}
두 함수는 같은 일을 하고 호출하는 모양도 같다. 제네릭 쪽은 선언이 길고, 타입마다 인스턴스가 만들어져 읽는 사람이 따라갈 것이 늘어난다. 타입 매개변수가 반환 타입이나 다른 인자와 연결되지 않는다면 인터페이스를 먼저 고려한다.
한눈에 보기
| 주제 | 문법 | 기억할 점 | 쓰는 곳 |
|---|---|---|---|
| 타입 매개변수 함수 | func F[T any](x T) | 호출 때 인자에서 추론된다 | Map, Filter |
| 비교 가능 제약 | [K comparable] | 맵 키와 == 에 필요하다 | Index, GroupBy |
| 순서 제약 | [K cmp.Ordered] | 정렬과 대소 비교 | SortedKeys |
| 근사 타입 | ~int | 기반 타입이 같은 이름 있는 타입 포함 | Won 같은 도메인 타입 |
| 제네릭 타입 | type Queue[T any] struct | 사용할 때 타입 인자를 적는다 | 큐, 스택 |
| 메서드 | func (q *Queue[T]) Push | 새 타입 매개변수는 못 단다 | 변환은 함수로 |
| 상황 | 선택 | 이유 | 예 |
|---|---|---|---|
| 메서드만 호출한다 | 인터페이스 | 간단하고 동적 디스패치로 충분 | Describe |
| 타입이 결과에 드러나야 한다 | 제네릭 | 반환 타입이 입력과 연결된다 | Map, Pop |
| 슬라이스·맵 조작 | 제네릭 (표준 패키지 우선) | 원소 타입 유지 | slices, maps |
| 타입마다 본문이 다르다 | 인터페이스 또는 별도 함수 | 같은 알고리즘이 아니다 | 직렬화 분기 |
연습 문제
Number제약과Sum을 이용해 평균을float64로 돌려주는Average[T Number](xs []T) float64를 쓰시오. 빈 슬라이스면 0 을 돌려준다. 변환에서 막히는 지점이 있다면 그 이유를 설명하시오.Queue[T]에 맨 앞 값을 꺼내지 않고 보기만 하는Peek() (T, bool)메서드를 추가하시오.- 다음 함수가 컴파일되지 않는 이유를 설명하고 고치시오.
func Contains[T any](xs []T, target T) bool { for _, x := range xs { if x == target { return true } } return false } Unique[T comparable](xs []T) []T를 쓰시오. 처음 나온 순서를 유지하며 중복을 제거한다.[]OrderID로 호출하는 예를 한 줄 덧붙이시오.
정답과 해설
-
func Average[T Number](xs []T) float64 { if len(xs) == 0 { return 0 } return float64(Sum(xs)) / float64(len(xs)) }Number의 모든 타입은 숫자이므로float64(...)변환이 가능하다. 제약이 숫자 계열로 한정되어 있기 때문에 허용되는 것이며,any였다면 변환할 수 없다.int를float64로 바꿔 나누므로 정수 나눗셈으로 소수 부분이 잘리는 일을 피한다. -
func (q *Queue[T]) Peek() (T, bool) { var zero T if len(q.items) == 0 { return zero, false } return q.items[0], true }값을 꺼내지 않으므로 슬라이스를 바꾸지 않는다. 빈 큐 처리는
Pop과 같은 방식이다. -
any제약은==를 보장하지 않는다. 슬라이스나 맵처럼 비교할 수 없는 타입도T로 들어올 수 있기 때문이다. 제약을comparable로 바꾸면 된다.func Contains[T comparable](xs []T, target T) bool { for _, x := range xs { if x == target { return true } } return false }표준 라이브러리의
slices.Contains가 같은 일을 하므로 실무에서는 그것을 쓴다. -
func Unique[T comparable](xs []T) []T { seen := make(map[T]struct{}, len(xs)) var out []T for _, x := range xs { if _, ok := seen[x]; ok { continue } seen[x] = struct{}{} out = append(out, x) } return out } // 호출 예 // uniq := Unique([]OrderID{"A-101", "A-102", "A-101"}) // A-101, A-102맵 키로
T를 쓰므로comparable이 필요하다. 값이 필요 없는 집합이므로struct{}를 쓴다. 순서를 지키기 위해 맵 순회 대신 입력 슬라이스를 순회하면서 결과에 추가한다.