고 고급 #3 제네릭 — type parameter와 constraint

5 분 소요

#2 sync 패키지 다음, 이번엔 다른 결의 도구. 제네릭.

Go 1.18(2022)에서 도입됐습니다. 그 전에는 interface{} (지금의 any) + 타입 단언으로 우회하던 것들을 — 이제는 타입 안전하게 표현할 수 있습니다.

제네릭이 어울리는 경우 #

모든 타입에 동작하는 Min
func Min[T int | float64 | string](a, b T) T {
	if a < b {
		return a
	}
	return b
}

Min(1, 2)         // 1
Min(1.5, 2.5)     // 1.5
Min("a", "b")     // "a"

[T int | float64 | string]타입 매개변수. T는 호출 시점에 정해집니다.

핵심 두 가지 #

  • type parameter list — 함수 이름 뒤 [T constraint, U constraint, ...]
  • constraint — T가 만족해야 할 조건. 인터페이스로 표현

Constraint — 인터페이스의 확장 #

기존 인터페이스는 메서드 집합만 표현했습니다. 제네릭이 들어오면서 — 인터페이스가 타입 집합도 표현하게 됐습니다.

타입 집합으로 constraint
type Number interface {
	int | float64
}

func Sum[T Number](nums []T) T {
	var sum T
	for _, n := range nums {
		sum += n
	}
	return sum
}

Number 인터페이스는 — int 또는 float64 인 타입만 만족. 메서드가 없어도 인터페이스로 쓰임.

~ 토큰 — 기본 타입까지 포함 #

~ 토큰
type Number interface {
	~int | ~float64
}

type Celsius float64
Sum([]Celsius{1, 2, 3})    // OK — Celsius의 기본 타입이 float64

~int는 — 기본 타입(underlying type)이 int 인 모든 타입. type ID int 같은 정의된 타입까지 포함합니다.

~ 없이 그냥 int | float64 면 — Celsius 같은 정의된 타입은 사용 불가.

comparable — 비교 가능한 타입 #

== != 비교가 가능한 타입의 집합으로, map의 key 타입을 지정할 때도 나타납니다.

comparable
func Index[T comparable](s []T, target T) int {
	for i, v := range s {
		if v == target {
			return i
		}
	}
	return -1
}

comparable은 — 미리 정의된 constraint. int, string, struct (필드가 모두 comparable이면) 등 다 포함하지만 slice, map, function은 비교 불가라 제외됩니다.

any — 제약 없음 #

any constraint
func First[T any](s []T) T {
	return s[0]
}

any는 — interface{}의 별칭. 어떤 타입이든 받습니다. 단, 타입에 가하는 연산이 거의 없을 때만 유용합니다(인덱싱, 대입 정도).

golang.org/x/exp/constraints — 표준 constraint 들 #

자주 쓰이는 constraint는 별도 패키지에 미리 정의돼 있습니다.

exp/constraints
import "golang.org/x/exp/constraints"

func Max[T constraints.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

constraints.Ordered는 — < > <= >=가 가능한 타입(숫자 + string).

제네릭 자료구조 #

가장 자연스러운 경우입니다. 자료구조는 — 들어가는 타입과 무관하게 동작하는 게 보통입니다.

제네릭 Stack
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, bool) {
	if len(s.items) == 0 {
		var zero T
		return zero, false
	}
	last := len(s.items) - 1
	v := s.items[last]
	s.items = s.items[:last]
	return v, true
}

func main() {
	s := Stack[int]{}
	s.Push(1)
	s.Push(2)
	v, _ := s.Pop()    // 2
	_ = v
}

var zero T — 제네릭 타입의 zero value를 얻는 표준 패턴.

제네릭 함수의 타입 추론 #

타입 추론
Sum([]int{1, 2, 3})           // T는 int로 추론
Sum[int]([]int{1, 2, 3})      // 명시적

stack := Stack[int]{}          // 타입 인자 명시

함수 호출은 — 인자 타입에서 자동 추론. 자료구조나 메서드 호출은 — 명시가 필요한 경우가 많습니다.

표준 라이브러리의 활용 — slices, maps #

Go 1.21에서 — 제네릭 기반의 표준 패키지가 들어왔습니다.

slices 패키지
import "slices"

slices.Contains([]int{1, 2, 3}, 2)         // true
slices.Sort([]string{"b", "a", "c"})        // 정렬
slices.Index([]int{1, 2, 3}, 2)            // 1
slices.Reverse([]int{1, 2, 3})              // [3 2 1]

import "maps"
maps.Keys(m)                                 // map의 모든 key

이전에 손으로 짜던 헬퍼들이 — 이제 표준에 다 있습니다.

함정 — 메서드는 type parameter를 가질 수 없다 #

자주 만나는 함정
type Box[T any] struct{ v T }

// 메서드는 새 type parameter 정의 불가
func (b Box[T]) Map[U any](f func(T) U) Box[U] {    // ✗ 컴파일 에러
	// ...
}

메서드는 — receiver의 type parameter (T)만 사용 가능하고, 새 type parameter도입 불가. 이런 변환이 필요하면 함수로 짜야 합니다.

함수로
func MapBox[T, U any](b Box[T], f func(T) U) Box[U] {
	return Box[U]{v: f(b.v)}
}

함정 — 너무 광범위한 constraint #

과한 일반화
func Process[T any](v T) {
	// any 인데 v로 할 수 있는 게 거의 없음
}

constraint가 넓을수록 — T에 가할 수 있는 연산이 줄어듭니다. 산술이 필요하면 Number, 비교가 필요하면 comparable, 정렬이 필요하면 Ordered — 필요한 만큼만.

함정 — 인터페이스를 그대로 쓰는 게 더 나은 경우도 #

제네릭 vs 인터페이스
// 제네릭
func Print[T fmt.Stringer](v T) {
	fmt.Println(v.String())
}

// 인터페이스
func Print(v fmt.Stringer) {
	fmt.Println(v.String())
}

이 경우에는 — 인터페이스가 더 자연스럽습니다. 동적 디스패치가 필요한 경우(여러 다른 타입이 한 슬라이스에 섞임)는 — 제네릭이 아니라 인터페이스가 어울립니다.

단순 가이드라인 #

  • 타입 안전성과 성능이 중요한 자료구조 → 제네릭
  • 다형성이 본질(여러 다른 타입을 한 컨테이너에) → 인터페이스
  • 단일 함수에 여러 타입을 받고 싶음 → 둘 다 가능, 가독성으로 결정

패턴 — Map / Filter / Reduce #

Go는 일부러 표준에 안 넣었지만, 제네릭으로 자연스럽게 짤 수 있습니다.

Map / Filter / Reduce
func Map[T, U any](s []T, f func(T) U) []U {
	out := make([]U, len(s))
	for i, v := range s {
		out[i] = f(v)
	}
	return out
}

func Filter[T any](s []T, pred func(T) bool) []T {
	var out []T
	for _, v := range s {
		if pred(v) {
			out = append(out, v)
		}
	}
	return out
}

func Reduce[T, U any](s []T, init U, f func(U, T) U) U {
	acc := init
	for _, v := range s {
		acc = f(acc, v)
	}
	return acc
}

자주 쓰면 헬퍼 패키지로 묶어 두면 편합니다. 다만 — Go의 일반 스타일은 for 루프를 명시적으로 쓰는 쪽이 더 흔합니다(가독성이 좋다고 보기 때문).

어디까지 추상화할 것인가 #

제네릭이 들어왔다고 해서 — 모든 코드를 제네릭으로 바꾸는 건 좋지 않습니다. 세 번 이상 같은 모양이 보일 때 일반화를 검토하는 정도가 적절합니다.

“Don’t be afraid to write a little code.” — Rob Pike

Go가 의도적으로 단순함을 지키는 언어인 만큼 — 제네릭도 같은 정신으로. 굳이 일반화하지 않아도 되는 것을 일반화하면 가독성이 떨어지는 경우가 많습니다.

마무리 #

이번 글에서 정리한 내용:

  • type parameter — 함수/타입 이름 뒤 [T constraint]
  • constraint — 메서드 + 타입 집합을 표현하는 인터페이스
  • ~T — 기본 타입 기반 타입까지 포함
  • comparable== 비교 가능
  • any — 제약 없음 (= interface{})
  • constraints.Ordered — 정렬 가능한 타입
  • 자료구조 — 제네릭이 가장 잘 어울리는 경우
  • 메서드는 새 type parameter도입 불가
  • 다형성이 본질이면 — 인터페이스가 자연스러움
  • 추상화는 세 번 이상 반복 보일 때

다음 글(#4 reflect 패키지)에서는 — 런타임에 타입을 다루는 reflect. 직렬화 라이브러리 안쪽에서 보이는 도구를 정리합니다.

X