제네릭
제네릭, 또는 더 학술적인 이름인 매개변수화 다형성 (Parameterized Polymorphism) 은 타입 매개변수화를 통해 코드의 재사용성과 유연성을 달성하는 것을 말합니다. 많은 프로그래밍 언어에서 매개변수화 다형성은 중요한 개념으로, 함수나 데이터 구조가 각 타입마다 별도의 코드를 작성하지 않고도 다양한 타입의 데이터를 처리할 수 있게 해줍니다. 초기 Go 에는 제네릭이라는 개념이 없었지만, 탄생 이후 커뮤니티에서 Go 에 가장 많이 요청한 것이 제네릭 추가였으며, 마침내 Go 언어는 2022 년 1.18 버전에서 제네릭 지원을 추가했습니다.
설계
Go 언어는当初제네릭을 설계할 때 다음과 같은 방안을 고려했습니다.
- stenciling: 모노모피제이션 (monomorphization) 으로, C++, Rust 와 같은 전형적인 방식으로, 사용되는 각 타입마다 템플릿 코드를 생성합니다. 이 방식의 성능은 매우 우수하여 런타임 오버헤드가 전혀 없으며, 성능은 직접 호출하는 것과 동일합니다. 단점은 컴파일 속도를 크게 저하시킨다는 점 (Go 자체에 비해) 이며, 각 타입마다 코드를 생성하므로 컴파일된 바이너리 파일 크기가 팽창한다는 점입니다.
- dictionaries: 이 방식은 하나의 코드만 생성하며, 컴파일 시점에 모든 사용될 타입 정보를 저장하는 타입 딕셔너리를 읽기 전용 데이터 세그먼트에 생성합니다. 함수 호출 시에는 딕셔너리를 조회하여 타입 정보를 가져옵니다. 이 방식은 컴파일 속도를 저하시키지 않으며 파일 크기도 팽창하지 않지만, 큰 런타임 오버헤드를 발생시켜 제네릭 성능이 매우 나빠집니다.
위 두 방법은 두 가지 극단을 나타내며, Go 언어가 최종적으로 선택한 구현 방안은 Gcshape stenciling 입니다. 이는 절충안으로, 동일한 메모리 쉐이프 (쉐이프는 메모리 할당자가 결정) 를 가진 타입에는 모노모피제이션을 사용하여 동일한 코드를 생성합니다. 예를 들어 type Int int 와 int 는 실제로 동일한 타입이므로 하나의 코드를 공유합니다. 하지만 포인터의 경우, 모든 포인터 타입이 동일한 메모리 쉐이프를 가지지만 (예: *int, *Person 모두 동일한 메모리 쉐이프), 역참조 시 대상 타입의 메모리 레이아웃이 완전히 다르므로 코드를 공유할 수 없습니다. 이를 위해 Go 는 런타임에 딕셔너리를 사용하여 타입 정보를 가져오므로, Go 의 제네릭에도 런타임 오버헤드가 존재합니다.
서론
간단한 예제부터 살펴보겠습니다.
func Sum(a, b int) int {
return a + b
}이는 매우 간단한 함수로, 두 개의 int 타입 정수를 더하여 결과를 반환합니다. 만약 두 개의 float64 타입 부동소수점을 전달하여 합계를 구하려면, 타입이 일치하지 않으므로 사용할 수 없습니다. 한 가지 해결 방법은 다음과 같이 새로운 함수를 정의하는 것입니다.
func SumFloat64(a, b float64) float64 {
return a + b
}그렇다면 문제가 발생합니다. 수학 도구 패키지를 개발하여 모든 숫자 타입의 두 수 합계를 계산하려면, 각 타입마다 함수를 작성해야 할까요? 분명히 그럴 수는 없으며, 또는 any 타입과 리플렉션을 사용하여 판단할 수 있습니다.
func SumAny(a, b any) (any, error) {
tA, tB := reflect.ValueOf(a), reflect.ValueOf(b)
if tA.Kind() != tB.Kind() {
return nil, errors.New("타입 불일치")
}
switch tA.Kind() {
case reflect.Int:
case reflect.Int32:
...
}
}하지만 이렇게 작성하면 번거롭고 성능도 낮습니다. Sum 함수의 로직은 모두 동일하며, 단지 두 수를 더할 뿐입니다. 이때 제네릭이 필요합니다. 그래서 왜 제네릭이 필요한지, 제네릭은 타입과 무관한 실행 로직을 해결하기 위한 것입니다. 이러한 문제는 주어진 타입이 무엇인지 신경 쓰지 않으며, 해당 작업만 완료하면 됩니다.
문법
제네릭 작성 방법은 다음과 같습니다.
func Sum[T int | float64](a, b T) T {
return a + b
}타입 형식 매개변수: T 는 타입 형식 매개변수로, 구체적인 타입은 전달된 타입에 따라 결정됩니다.
타입 제약: int | float64 는 타입 제약을 구성하며, 이 타입 제약은 어떤 타입이 허용되는지 규정하고 타입 형식 매개변수의 타입 범위를 제한합니다.
타입 실제 매개변수: Sum[int](1,2), int 타입을 수동으로 지정하며, int 가 타입 실제 매개변수입니다.
첫 번째 사용법은 명시적으로 사용할 타입을 지정하는 것입니다.
Sum[int](2012, 2022)두 번째 사용법은 타입을 지정하지 않고 컴파일러가 추론하도록 하는 것입니다.
Sum(3.1415926, 1.114514)이는 제네릭 슬라이스로, 타입 제약은 int | int32 | int64 입니다.
type GenericSlice[T int | int32 | int64] []T사용 시 타입 실제 매개변수를 생략할 수 없습니다.
GenericSlice[int]{1, 2, 3}이는 제네릭 해시 테이블로, 키의 타입은 반드시 비교 가능해야 하므로 comparable 인터페이스를 사용하며, 값의 타입 제약은 V int | string | byte 입니다.
type GenericMap[K comparable, V int | string | byte] map[K]V사용
gmap1 := GenericMap[int, string]{1: "hello world"}
gmap2 := make(GenericMap[string, byte], 0)이는 제네릭 구조체로, 타입 제약은 T int | string 입니다.
type GenericStruct[T int | string] struct {
Name string
Id T
}사용
GenericStruct[int]{
Name: "jack",
Id: 1024,
}
GenericStruct[string]{
Name: "Mike",
Id: "1024",
}이는 제네릭 슬라이스 매개변수 예제입니다.
type Company[T int | string, S []T] struct {
Name string
Id T
Stuff S
}
//또는 다음과 같이
type Company[T int | string, S []int | []string] struct {
Name string
Id T
Stuff S
}사용
Company[int, []int]{
Name: "lili",
Id: 1,
Stuff: []int{1},
}TIP
제네릭 구조체에서는 다음과 같은 작성을 권장합니다.
type Company[T int | string, S int | string] struct {
Name string
Id T
Stuff []S
}SayAble 은 제네릭 인터페이스이며, Person 이 해당 인터페이스를 구현했습니다.
type SayAble[T int | string] interface {
Say() T
}
type Person[T int | string] struct {
msg T
}
func (p Person[T]) Say() T {
return p.msg
}
func main() {
var s SayAble[string]
s = Person[string]{"hello world"}
fmt.Println(s.Say())
}제네릭 인터페이스
제네릭 인터페이스는 더 나은 추상화 제약 능력을 제공할 수 있습니다. 아래는 예제입니다.
func PrintObj[T fmt.Stringer](s T) {
fmt.Println(s.String())
}
type Person struct {
Name string
}
func (p Person) String() string {
return fmt.Sprintf("Person: %s", p.Name)
}
func main() {
PrintObj(Person{Name: "Alice"})
}비제네릭 인터페이스를 제네릭의 타입 형식 매개변수로 사용할 수도 있습니다.
func Write[W io.Writer](w W, bs []byte) (int, error) {
return w.Write(bs)
}제네릭 타입 단언
제네릭을 사용하여 any 타입에 대한 타입 단언을 수행할 수 있습니다. 예를 들어 다음 함수는 모든 타입을 단언할 수 있습니다.
func Assert[T any](v any) (bool, T) {
var av T
if v == nil {
return false, av
}
av, ok := v.(T)
return ok, av
}타입 세트
1.18 이후, 인터페이스 정의는 타입 세트 (type set) 로 변경되었으며, 타입 세트를 포함한 인터페이스를 General interfaces 즉 일반 인터페이스라고 합니다.
An interface type defines a type set
타입 세트는 제네릭의 타입 제약에만 사용할 수 있으며, 타입 선언, 타입 변환, 타입 단언에는 사용할 수 없습니다. 타입 세트는 집합이므로 공집합, 합집합, 교집합이 있으며,接下来이 세 가지 상황을 설명하겠습니다.
합집합
인터페이스 타입 SignedInt 는 타입 세트로, 부호 있는 정수 타입의 합집합은 SignedInt 이며, 반대로 SignedInt 는 이들의 슈퍼세트입니다.
type SignedInt interface {
int8 | int16 | int | int32 | int64
}기본 데이터 타입도 마찬가지이며, 다른 일반 인터페이스도 마찬가지입니다.
type SignedInt interface {
int8 | int16 | int | int32 | int64
}
type UnSignedInt interface {
uint8 | uint16 | uint32 | uint64
}
type Integer interface {
SignedInt | UnSignedInt
}교집합
비어 있지 않은 인터페이스의 타입 세트는 해당 요소의 모든 타입 세트의 교집합입니다. 다시 말해, 인터페이스가 여러 개의 비어 있지 않은 타입 세트를 포함하면, 해당 인터페이스는 이러한 타입 세트의 교집합입니다. 예제는 다음과 같습니다.
type SignedInt interface {
int8 | int16 | int | int32 | int64
}
type Integer interface {
int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64
}
type Number interface {
SignedInt
Integer
}예제의 교집합은 분명히 SignedInt 입니다.
func Do[T Number](n T) T {
return n
}
Do[int](2)
DO[uint](2) //컴파일 통과 불가공집합
공집합은 교집합이 없는 경우입니다. 아래 예제에서 Integer 는 타입 공집합입니다.
type SignedInt interface {
int8 | int16 | int | int32 | int64
}
type UnsignedInt interface {
uint8 | uint16 | uint | uint32 | uint64
}
type Integer interface {
SignedInt
UnsignedInt
}부호 없는 정수와 부호 있는 정수는 분명히 교집합이 없으므로 교집합은 공집합입니다. 아래 예제에서는 어떤 타입을 전달해도 컴파일을 통과할 수 없습니다.
Do[Integer](1)
Do[Integer](-100)빈 인터페이스
빈 인터페이스는 공집합과 다르며, 빈 인터페이스는 모든 타입 세트의 집합으로, 즉 모든 타입을 포함합니다.
func Do[T interface{}](n T) T {
return n
}
func main() {
Do[struct{}](struct{}{})
Do[any]("abc")
}하지만 일반적으로 제네릭 형식 매개변수로는 any 를 사용하며, interface{} 는 보기 좋지 않기 때문입니다.
기저 타입
type 키워드를 사용하여 새로운 타입을 선언한 경우, 기저 타입이 타입 세트 내에 포함되어 있더라도 전달 시 컴파일을 통과할 수 없습니다.
type Int interface {
int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64
}
type TinyInt int8
func Do[T Int](n T) T {
return n
}
func main() {
Do[TinyInt](1) // 컴파일 통과 불가, 기저 타입이 Int 타입 세트 범위에 속하더라도
}두 가지 해결 방법이 있습니다. 첫 번째는 타입 세트에 해당 타입을 합집합하는 것이지만, 이는 무의미합니다. TinyInt 와 int8 의 기저 타입은 동일하기 때문입니다. 따라서 두 번째 해결 방법이 있습니다.
type Int interface {
int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64 | TinyInt
}~ 기호를 사용하여 기저 타입을 나타냅니다. 타입의 기저 타입이 해당 타입 세트에 속하면, 해당 타입은 이 타입 세트에 속합니다. 다음과 같습니다.
type Int interface {
~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64
}수정 후 컴파일을 통과할 수 있습니다.
func main() {
Do[TinyInt](1) // 컴파일 통과 가능, TinyInt 가 Int 타입 세트 내에 있으므로
}주의점
제네릭은 타입의 기본 타입으로 사용할 수 없음
다음 작성은 잘못되었습니다. 제네릭 형식 매개변수 T 는 기본 타입으로 사용할 수 없습니다.
type GenericType[T int | int32 | int64] T다음 작성은 허용되지만 무의미하며 수치 오버플로우 문제를 일으킬 수 있으므로 권장하지 않습니다.
type GenericType[T int | int32 | int64] int제네릭 타입은 타입 단언을 사용할 수 없음
제네릭 타입에 타입 단언을 사용하면 컴파일을 통과할 수 없습니다. 제네릭이 해결하려는 문제는 타입 무관이며, 만약 문제가 다른 타입에 따라 다른 로직을 만들어야 한다면 제네릭을 사용해서는 안 되며 interface{} 또는 any 를 사용해야 합니다.
func Sum[T int | float64](a, b T) T {
ints,ok := a.(int) // 허용되지 않음
switch a.(type) { // 허용되지 않음
case int:
case bool:
...
}
return a + b
}익명 구조체는 제네릭을 지원하지 않음
익명 구조체는 제네릭을 지원하지 않으며, 다음 코드는 컴파일을 통과할 수 없습니다.
testStruct := struct[T int | string] {
Name string
Id T
}[int]{
Name: "jack",
Id: 1
}익명 함수는 사용자 정의 제네릭을 지원하지 않음
다음 두 가지 작성은 모두 컴파일을 통과할 수 없습니다.
var sum[T int | string] func (a, b T) T
sum := func[T int | string](a,b T) T{
...
}하지만 기존 제네릭 타입을 사용할 수는 있습니다. 예를 들어 클로저에서 다음과 같습니다.
func Sum[T int | float64](a, b T) T {
sub := func(c, d T) T {
return c - d
}
return sub(a,b) + a + b
}제네릭 메서드를 지원하지 않음
메서드는 제네릭 형식 매개변수를 가질 수 없지만, receiver 는 제네릭 형식 매개변수를 가질 수 있습니다. 다음 코드는 컴파일을 통과할 수 없습니다.
type GenericStruct[T int | string] struct {
Name string
Id T
}
func (g GenericStruct[T]) name[S int | float64](a S) S {
return a
}타입 세트는 타입 실제 매개변수로 사용할 수 없음
타입 세트를 포함한 인터페이스는 타입 실제 매개변수로 사용할 수 없습니다.
type SignedInt interface {
int8 | int16 | int | int32 | int64
}
func Do[T SignedInt](n T) T {
return n
}
func main() {
Do[SignedInt](1) // 컴파일 통과 불가
}타입 세트의 교집합 문제
비인터페이스 타입의 경우, 타입 합집합에는 교집합이 있을 수 없습니다. 아래 예제의 TinyInt 와 ~int8 은 교집합이 있습니다.
type Int interface {
~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64 | TinyInt // 컴파일 통과 불가
}
type TinyInt int8하지만 인터페이스 타입의 경우 교집합이 허용됩니다. 아래 예제와 같습니다.
type Int interface {
~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64 | TinyInt // 컴파일 통과 가능
}
type TinyInt interface {
int8
}타입 세트는 직접적 또는 간접적으로 자신을 합집합할 수 없음
다음 예제에서, Floats 는 직접적으로 자신을 합집합하며, Double 은 다시 Floats 를 합집합하므로 간접적으로 자신을 합집합합니다.
type Floats interface { // 코드 컴파일 통과 불가
Floats | Double
}
type Double interface {
Floats
}comparable 인터페이스는 타입 세트에 합집합할 수 없음
마찬가지로 타입 제약에도 합집합할 수 없으므로 기본적으로 별도로 사용합니다.
func Do[T comparable | Integer](n T) T { //컴파일 통과 불가
return n
}
type Number interface { // 컴파일 통과 불가
Integer | comparable
}
type Comparable interface { // 컴파일 통과 가능하지만 무의미
comparable
}메서드 세트는 타입 세트에 합집합할 수 없음
메서드를 포함한 인터페이스는 타입 집합에 합집합할 수 없습니다.
type I interface {
int | fmt.Stringer // cannot use fmt.Stringer in union (fmt.Stringer contains methods)
}하지만 교집합은 할 수 있으나, 이는 무의미합니다.
type I interface {
int
fmt.Stringer
}사용
데이터 구조는 제네릭의 가장 일반적인 사용 사례로, 두 가지 데이터 구조를 통해 제네릭 사용 방법을 살펴보겠습니다.
큐
다음은 제네릭을 사용하여 간단한 큐를 구현한 것입니다. 먼저 큐 타입을 선언하며, 큐의 요소 타입은 임의적이므로 타입 제약은 any 입니다.
type Queue[T any] []T총 네 가지 메서드 Pop, Peek, Push, Size 가 있습니다. 코드는 다음과 같습니다.
type Queue[T any] []T
func (q *Queue[T]) Push(e T) {
*q = append(*q, e)
}
func (q *Queue[T]) Pop(e T) (_ T) {
if q.Size() > 0 {
res := q.Peek()
*q = (*q)[1:]
return res
}
return
}
func (q *Queue[T]) Peek() (_ T) {
if q.Size() > 0 {
return (*q)[0]
}
return
}
func (q *Queue[T]) Size() int {
return len(*q)
}Pop 과 Peek 메서드에서 반환 값이 _ T 인 것을 볼 수 있습니다. 이는 명명된 반환 값 사용 방식이지만, 밑줄 _ 를 사용하여 익명임을 나타냅니다. 이는 불필요한 것이 아니라 제네릭 제로 값을 나타내기 위한 것입니다. 제네릭을 사용했으므로 큐가 비었을 때 제로 값을 반환해야 하지만, 타입이 알려지지 않았으므로 구체적인 타입을 반환할 수 없습니다. 위의 방식을 통해 제네릭 제로 값을 반환할 수 있습니다. 또는 제네릭 변수를 선언하여 제로 값을 해결할 수도 있습니다. 제네릭 변수의 경우, 기본 값이 해당 타입의 제로 값입니다. 다음과 같습니다.
func (q *Queue[T]) Pop(e T) T {
var res T
if q.Size() > 0 {
res = q.Peek()
*q = (*q)[1:]
return res
}
return res
}힙
위 큐의 예제는 요소에 아무런 요구사항이 없으므로 타입 제약이 any 입니다. 하지만 힙은 다릅니다. 힙은 특별한 데이터 구조로, O(1) 시간 내에 최대값 또는 최소값을 판단할 수 있으므로 요소에 정렬 가능한 타입이어야 한다는 요구사항이 있습니다. 하지만 내장 정렬 가능 타입은 숫자와 문자열뿐이므로, 힙 초기화 시 사용자 정의 비교기를 전달해야 합니다. 비교기는 호출자가 제공하며 비교기도 제네릭을 사용해야 합니다. 다음과 같습니다.
type Comparator[T any] func(a, b T) int다음은 간단한 이진 힙 구현으로, 먼저 제네릭 구조체를 선언하며, 여전히 any 를 사용하여 제약하므로 임의 타입을 저장할 수 있습니다.
type Comparator[T any] func(a, b T) int
type BinaryHeap[T any] struct {
s []T
c Comparator[T]
}여러 메서드 구현
func (heap *BinaryHeap[T]) Peek() (_ T) {
if heap.Size() > 0 {
return heap.s[0]
}
return
}
func (heap *BinaryHeap[T]) Pop() (_ T) {
size := heap.Size()
if size > 0 {
res := heap.s[0]
heap.s[0], heap.s[size-1] = heap.s[size-1], heap.s[0]
heap.s = heap.s[:size-1]
heap.down(0)
return res
}
return
}
func (heap *BinaryHeap[T]) Push(e T) {
heap.s = append(heap.s, e)
heap.up(heap.Size() - 1)
}
func (heap *BinaryHeap[T]) up(i int) {
if heap.Size() == 0 || i < 0 || i >= heap.Size() {
return
}
for parentIndex := i>>1 - 1; parentIndex >= 0; parentIndex = i>>1 - 1 {
// greater than or equal to
if heap.compare(heap.s[i], heap.s[parentIndex]) >= 0 {
break
}
heap.s[i], heap.s[parentIndex] = heap.s[parentIndex], heap.s[i]
i = parentIndex
}
}
func (heap *BinaryHeap[T]) down(i int) {
if heap.Size() == 0 || i < 0 || i >= heap.Size() {
return
}
size := heap.Size()
for lsonIndex := i<<1 + 1; lsonIndex < size; lsonIndex = i<<1 + 1 {
rsonIndex := lsonIndex + 1
if rsonIndex < size && heap.compare(heap.s[rsonIndex], heap.s[lsonIndex]) < 0 {
lsonIndex = rsonIndex
}
// less than or equal to
if heap.compare(heap.s[i], heap.s[lsonIndex]) <= 0 {
break
}
heap.s[i], heap.s[lsonIndex] = heap.s[lsonIndex], heap.s[i]
i = lsonIndex
}
}
func (heap *BinaryHeap[T]) Size() int {
return len(heap.s)
}
func NewHeap[T any](n int, c Comparator[T]) BinaryHeap[T] {
var heap BinaryHeap[T]
heap.s = make([]T, 0, n)
heap.Comparator = c
return heap
}사용 방법은 다음과 같습니다.
type Person struct {
Age int
Name string
}
func main() {
heap := NewHeap[Person](10, func(a, b Person) int {
return cmp.Compare(a.Age, b.Age)
})
heap.Push(Person{Age: 10, Name: "John"})
heap.Push(Person{Age: 18, Name: "mike"})
heap.Push(Person{Age: 9, Name: "lili"})
heap.Push(Person{Age: 32, Name: "miki"})
fmt.Println(heap.Peek())
fmt.Println(heap.Pop())
fmt.Println(heap.Peek())
}출력
{9 lili}
{9 lili}
{10 John}제네릭의 지원으로 원래 정렬할 수 없던 타입도 비교기를 전달하면 힙을 사용할 수 있게 되었습니다. 이렇게 하면 이전에 interface{} 를 사용하여 타입 변환과 단언을 수행하는 것보다 훨씬 우아하고 편리합니다.
객체 풀
원래 객체 풀은 any 타입만 사용할 수 있어,每次 가져올 때마다 타입 단언을 수행해야 했지만, 제네릭으로 간단히 개선하면 이 작업을 생략할 수 있습니다.
package main
import (
"bytes"
"fmt"
"sync"
)
func NewPool[T any](newFn func() T) *Pool[T] {
return &Pool[T]{
pool: &sync.Pool{
New: func() interface{} {
return newFn()
},
},
}
}
type Pool[T any] struct {
pool *sync.Pool
}
func (p *Pool[T]) Put(v T) {
p.pool.Put(v)
}
func (p *Pool[T]) Get() T {
var v T
get := p.pool.Get()
if get != nil {
v, _ = get.(T)
}
return v
}
func main() {
bufferPool := NewPool(func() *bytes.Buffer {
return bytes.NewBuffer(nil)
})
for range 100 {
buffer := bufferPool.Get()
buffer.WriteString("Hello, World!")
fmt.Println(buffer.String())
buffer.Reset()
bufferPool.Put(buffer)
}
}##小结
Go 의 큰 특징 중 하나는 컴파일 속도가 매우 빠르다는 점입니다. 컴파일이 빠른 이유는 컴파일 시 최적화가 적기 때문입니다. 제네릭 추가는 컴파일러의 작업량을 증가시키고 작업을 더 복잡하게 만들며, 이는 반드시 컴파일 속도 저하로 이어집니다. 사실 Go1.18 이 제네릭을 처음 출시했을 때 실제로 컴파일이 더 느려졌습니다. Go 팀은 제네릭을 추가하고 싶었지만 컴파일 속도를 너무 저하시키고 싶지 않았으며, 개발자가 사용하기 편하면 컴파일러는 괴롭고, 반대로 컴파일러가轻松하면 (가장轻松한 것은 물론 제네릭을 아예 사용하지 않는 것) 개발자가 괴롭습니다. 현재의 제네릭은 이 둘 사이의 타협 산물입니다.
