golang은 정적 타입 언어이기 때문에 code가 컴파일되기 이전에 변수 또는 파라미터의 type을 확인한다. 그러나 때로는 변수의 type이 지정되지 않을 때가 있는데, 어떤 함수를 사용할 때 파라미터의 type을 지정하지 않고 그때그때 알맞은 type으로 처리하고 싶을 때가 있다.
이때 사용하는 것이 바로 generic으로, 함수나 map
, slice
, channels
과 같은 자료구조에 특정 type을 변수로 받아, 변수의 type을 동적으로 선언해 처리하는 것이다. 쉽게 설명하자면 type parameters
라고 생각하면 된다.
그렇다면 generic은 어떨 때 쓰일까?? 대표적으로 함수이다. 아래의 divAndRemainder
라는 함수는 두 개의 int
paramter들과 두 개의 int
return value를 가진다.
package main
import "errors"
func divAndRemainder(num, denom int) (int, int, error) {
if denom == 0 {
return 0, 0, errors.New("Cannot divide by zero")
}
return num / denom, num & denom, nil
}
또 다른 generic 사용처는 struct
이다.
type Node struct {
val int
next *Node
}
golang의 특성상 static하게 type을 선언해놓고 쓰는데, 함수의 파라미터, 리턴값, 구조체의 field 타입들을 static한 값으로 쓰지 않고, 나중에 사용될 때 type을 지정하고 싶을 때가 있다. 이 때 사용하는 것이 generic이라는 것이다.
만약 golang에서 generic없이 binary tree를 만든다고 하자. 이 경우 element의 type이 int
, string
이냐에 따라 정렬 결과가 달라지는데, type마다 정렬 방식에 따른 구현 방법을 달리 구현하는 수 밖에 없다.
type마다 정렬 방식을 구현하도록 하기위헤서, 정렬 함수를 구현하도록 interface를 만들어 binary tree의 value type으로 쓰도록 하자.
type Orderable interface {
// Order returns
// a value < 0, Orderable이 주어진 값보다 작을 때
// a value > 0, Orderable이 주어진 값보다 클 때
Order(any) int
}
Orderable
interface은 Order
이라는 함수를 구현해야한다. Orderable
을 만족하는 type이 어떤 것인지 모르기 때문에 Order
에는 any
타입이 사용되는 것이다.
다음으로 Orderable
interface를 사용하여 Tree
를 만들어보도록 하자.
type Tree struct {
val Orderable
letf, right *Tree
}
func (t *Tree) Insert(val Orderable) *Tree {
if t == nil {
return &Tree{val: val}
}
switch comp := val.Order(t.val); {
case comp < 0:
t.letf = t.letf.Insert(val)
case comp > 0:
t.right = t.right.Insert(val)
}
return t
}
Tree
의 element type을 Orderable
로 지정하고, Orderable
의 Order
를 사용하여 정렬할 수 있도록 한다.
이제 이를 이용해 int
type을 Orderable
로 만들어보도록 하자.
type OrderableInt int
func (oi OrderableInt) Order(val any) int {
return int(oi - val.(OrderableInt))
}
func main() {
var it *Tree
it = it.Insert(OrderableInt(5))
it = it.Insert(OrderableInt(3))
}
문제없이 code가 동작하는 것을 볼 수 있읋 것이다. compiler에서도 문제가 발생하지 않을 것이다.
다음으로 OrderableString
type을 만들어보도록 하자.
type OrderableString string
func (os OrderableString) Order(val any) int {
return strings.Compare(string(os), val.(string))
}
string
이라고 해서 int
보다 딱히 특별할 것도 어려울 것도 없다. 그러나 한가지 맘에 걸리는 부분이 있는데, 바로 any
이다. any
는 golang에서 compile의 검사를 받지 않는다. 즉, any
를 사용하는 것은 굉장히 위험한데, golang이 가지는 강한 타입의 결속력을 무시한다는 것이다.
따라서, 다음의 code에서 error가 발생할 수 밖에 없다.
var it *Tree
it = it.Insert(OrderableInt(5))
it = it.Insert(OrderableString("nope"))
위의 code는 tree에 서로 다른 type element를 넣어 문제를 발생시키는 code이다.
compiler는 해당 부분이 별 문제가 없다고 나올 것이다. 이는 앞서 말했듯이 any
를 사용했기 때문이다. any
때문에 golang의 compiler가 type checking기능을 상실한 것과 마찬가지이다. 따라서 runtime중에 발생하는 panic이 발생할 수 밖에 없다.
panic: interface conversion: interface {} is main.OrderableInt, not string
이것은 굉장히 큰 문제이다. golang이 가진 가장 큰 장점은 강한 static type을 통한 runtime error가 적다는 점인데, 이러한 장점이 없어지게 되는 것이다.
이러한 문제를 해결해주는 것이 바로 generic
이다. generic
을 사용하면 하나의 code로 여러 type에 대한 구현을 가능하게 해준다. 또한, golang이 가진 type checking기능이 가능하도록 하여, compile중에 type이 확인되어, type에 대한 정합성 검사를 해준다.
generic
이 등장하기 이전 golang의 생태계에서는 어떤 함수를 구현하는데 있어 많은 문제들이 있었다. 가령 standard library의 경우 math.Min
, math.Max
와 같은 범용 함수들을 잘보면 float64
로 되어있다. 이는 굉장히 큰 범위의 수를 두도록 하여 타입 변환을 하여도 값을 잃지 않도록 하기 위함이다. 그러나 int
, int64
와 같이 더 큰 범위의 수의 경우는 exception이 있으므로, int
버전의 MaxInt
, MinInt
를 따로 만들었다.
이렇게 같은 알고리즘이지만 type이 다르므로 구현을 달리해주어야 하는 경우 golang은 하나하나씩 구현해주는 수 밖에 없었다. code를 복사하고 code가 점점 verbose해지는 것에 개발자들은 점점 지칠 수 밖에 없었다.
사실 golang에 generics가 도입되어야 한다는 목소리는 아주 예에에에에에전부터 있었다. go가 소개되던 2009년에도 이러한 요청이 많았는데, golang code의 지옥의 수문장 Russ cox는 다음의 이유로 이를 거절했다.
이 3가지를 망치는 것이 generics라는 것이 russ cox의 의견이다. (https://research.swtch.com/generic)
그러나, 시간이 흘러 go team은 지난 10년간의 연구 끝에 이러한 딜레마를 해결하면서 generics를 사용할 수 있는 type parameters proposal을 만들게 된다. (https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md)
stack을 만들어보면서 generics를 golang에서 어떻게 사용할 수 있는 지 보도록 하자.
package main
type Stack[T any] struct {
vals []T
}
func (s *Stack[T]) Push(val T) {
s.vals = append(s.vals, val)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.vals) == 0 {
var zero T
return zero, false
}
top := s.vals[len(s.vals)-1]
s.vals = s.vals[:len(s.vals)-1]
return top, true
}
먼저 Stack
구조체를 선언할 때 구조체 이름 옆에 []
으로 두 가지 paramater인 type name
, type constaint
를 선언하는 것을 볼 수 있다.
T
를 썼다.interface
를 사용해 제한할 수 있다.여기서는 type constaint
으로 any
interface를 사용했다. 이는 모든 type들을 받을 수 있다는 것을 나타낸다.
generics로 type parameter를 받았기 때문에 해당 type name을 이용해 변수처럼 사용할 수 있다. 따라서, vals []T
를 사용하는 것이다.
struct의 receiver method는 이전과 별반 다를 바 없다. 단지 Stack
에서 Stack[T]
로 바뀌었을 뿐이다. 이는 해당 struct의 generics type이 T
라는 것을 알려주는 것이다. 그래서 receiver method에서도 T
를 쓸 수 있도록 하기 위한 것이다.
여기서 한 가지 더 흥미로운 점은 generics
type을 통해서 zero variable을 선언할 수 있다는 것이다. var zero T
이 부분이다. 이는 interface
로는 불가능한 부분이다. interface
로는 해당 타입에 대한 새로운 인스턴스를 생성할 수 없다. 그러나 generics
는 Type parameter
를 받기 때문에 Type
을 통해서 새로운 인스턴스를 생성할 수 있다. 이 부분에서는 zero value를 반환하기 위해서 T
type을 통해 새로운 zero value를 가진 인스턴스를 만든 것이다.
이제 generic을 이용하여 Stack
을 만들어보자.
func main() {
var intStack Stack[int]
intStack.Push(10)
intStack.Push(20)
intStack.Push(30)
v, ok := intStack.Pop()
fmt.Println(v, ok) // 30 true
}
사용하는 방법은 딱히 어렵지가 않다. 원래 쓰던 구조체 type에 concrete type을 []
안에 넣어주면 된다. 나머지 사용방법은 generics를 사용하지 않을 때와 동일하다.
Stack[int]
에 type이 지정되어있으므로 int
type이 아닌 다른 type이 들어가면 에러가 발생한다.
intStack.Push("nope")
다음의 compiler 에러가 발생한다.
cannot use "nope" (untyped string constant) as int value in argument to intStack.Push
이는 runtime error가 아니며, compile단계에서 generics을 통헤서 type을 confirm받을 수 있다는 것은 매우 강력한 기능이다.
또 다른 stack method를 만들업도록 하자. stack안에 해당 element가 포함되어 있는 지 확인하는 code이다.
func (s Stack[T]) Contains(val T) bool {
for _, v := range s.vals {
if v == val {
return true
}
}
return false
}
안타깝게도 complie단계에서 error가 발생할 것이다. error를 확인하면 다음과 같다.
invalid operation: v == val (incomparable types in type set)
해당 generic type이 ==
을 사용할 수 있는 타입이냐는 것이다. 만약 우리의 user-defined type이 ==
을 사용할 수 없는 타입이라면 이는 런타임에 에러가 발생할 것이었을텐데, 다행스럽게도 generics에서는 이를 확인해준다.
만약 generics를 사용하지 않고 interface{}
와 any
를 사용하면 이러한 error도 반환하지 않는다. ==
을 사용하기 위해서는 Stack
의 generics
type을 any가 아니라 다른 interface로 써야한다. any
로 쓰면 any
type의 value를 저장하고, 불러오는 것 밖에 못한다.
이를 위해서 사용해야할 interface는 comparable
이다. comparable
은 ==
, !=
, <
, <=
, >
, >=
와 같은 연산을 허용해준다. 다음과 같이 Stack
구조체의 선언을 바꾸어보도록 하자.
type Stack[T comparable] struct {
vals []T
}
참고로 comparable
은 golang built-in type중에 비교가능한 type들만 사용가능하다.
이제 다음의 code를 실행해보도록 하자.
func main() {
var s Stack[int]
s.Push(10)
s.Push(20)
s.Push(30)
fmt.Println(s.Contains(10)) // true
fmt.Println(s.Contains(4)) // false
}
구조체에 generic을 쓰는 것이외에 generic을 함수에도 적용시킬 수 있다. 가장 대표적으로 Map
, Reduce
, Filter
와 같은 함수들이 있다.
다음을 보도록 하자.
// Map turns a []T1 to a []T2 using a mapping function.
// This function has two type parameters, T1 and T2.
// This works with slices of any type.
func Map[T1, T2 any](s []T1, f func(T1) T2) []T2 {
r := make([]T2, len(s))
for i, v := range s {
r[i] = f(v)
}
return r
}
// Reduce reduces a []T1 to a single value using a reduction function.
func Reduce[T1, T2 any](s []T1, initializer T2, f func(T2, T1) T2) T2 {
r := initializer
for _, v := range s {
r = f(r, v)
}
return r
}
// Filter filters values from a slice using a filter function.
// It returns a new slice with only the elements of s
// for which f returned true.
func Filter[T any](s []T, f func(T) bool) []T {
var r []T
for _, v := range s {
if f(v) {
r = append(r, v)
}
}
return r
함수에 generic을 적용할 때는 function 이름 다음에 type parameter
를 넣어주면 된다.
다음은 Map
, Reduce
, Filter
를 실제로 사용하는 예제들이다.
words := []string{"One", "Potato", "Two", "Potato"}
filtered := Filter(words, func(s string) bool {
return s != "Potato"
})
fmt.Println(filtered) // [One Two]
lengths := Map(filtered, func(s string) int {
return len(s)
})
fmt.Println(lengths) // [3, 3]
sum := Reduce(lengths, 0, func(acc int, val int) int {
return acc + val
})
fmt.Println(sum) // 6
함수를 generic으로 사용할 때 재밌는 것은 type을 지정하는 argument를 따로 넣을 필요가 없다는 것이다. 들어가는 함수 argument의 value type이 바로 generic의 type parameter 값이 된다.
interface 역시도 generic의 type으로 사용할 수 있다. 따라서, 단순히 any
, comparable
만 사용하지 않아도 된다.
가령, 하나의 구조체를 만드려고 할 때 해당 구조체의 맴버변수 type이 fmt.Stringer
인터페이스를 구현하는 타입이어야 한다면 다음과 같이 쓸 수 있다.
type Pair[T fmt.Stringer] struct {
Val1 T
Val2 T
}
이렇게 generics을 활용하면 구조체의 인스턴스의 type이 compile단계에 지정되어, fmt.Stringer
를 구현하였지만 원치않은 type이 들어가지 못하도록 할 수 있다.
또한, type parameter들을 가지는 interface를 만들 수도 있다.
type Differ[T any] interface {
fmt.Stringer
Diff(T) float64
}
Differ
interface는 Diff
method를 정의할 때 generic을 이용하여 T
type을 받는다.
Pair
와 Differ
를 활용하여 두 pair중 어느 pair가 더 가까운 pair인지 알려주는 함수륾 만들 수 있다.
func FindCloser[T Differ[T]](pair1, pair2 Pair[T]) Pair[T] {
d1 := pair1.Val1.Diff(pair1.Val2)
d2 := pair2.Val1.Diff(pair2.Val2)
if d1 < d2 {
return pair1
}
return pair2
}
FindCloser
는 Differ
interface를 구현하는 맴버변수를 가지는 Pair
인스턴스를 받는다. 따라서, FindCloser
에 들어가는 T
는 Diff
method를 구현해야한다.
이제 Differ
interface를 만족하는 type들을 정의해보도록 하자.
type Point2D struct {
X, Y int
}
func (p2 Point2D) String() string {
return fmt.Sprintf("{%d,%d}", p2.X, p2.Y)
}
func (p2 Point2D) Diff(from Point2D) float64 {
x := p2.X - from.X
y := p2.Y - from.Y
return math.Sqrt(float64(x*x) + float64(y*y))
}
type Point3D struct {
X, Y, Z int
}
func (p3 Point3D) String() string {
return fmt.Sprintf("{%d,%d,%d}", p3.X, p3.Y, p3.Z)
}
func (p3 Point3D) Diff(from Point3D) float64 {
x := p3.X - from.X
y := p3.Y - from.Y
z := p3.Z - from.Z
return math.Sqrt(float64(x*x) + float64(y*y) + float64(z*z))
}
해당 code를 사용하는 부분은 다음과 같다.
func main() {
pair2Da := Pair[Point2D]{Point2D{1, 1}, Point2D{5, 5}}
pair2Db := Pair[Point2D]{Point2D{10, 10}, Point2D{15, 5}}
closer := FindCloser(pair2Da, pair2Db)
fmt.Println(closer)
pair3Da := Pair[Point3D]{Point3D{1, 1, 10}, Point3D{5, 5, 0}}
pair3Db := Pair[Point3D]{Point3D{10, 10, 10}, Point3D{11, 5, 0}}
closer2 := FindCloser(pair3Da, pair3Db)
fmt.Println(closer2)
}
go generics는 type element를 가지고 interface에 허용되는 type을 지정할 수 있는데, type element는 여러 개의 type term들로 이루어져있다.
type Integer interface {
int | int8 | int16 | int32 | int64 |
uint | uint8 | uint16 | uint32 | uint64 | uintptr
}
type elements는 어떤 type이 type parameter에 할당될 수 있고, 어떤 연산자들이 지원될 수 있는 지 지정해준다. type element는 구체적인 type들을 |
기준으로 나열하면 된다.
한 가지 주의해야할 것은 type element
들은 오직 type constraint
(type parameter)로 쓸 때만 유효하다. 즉, compile-time error 확인용이라는 것이다.
위에서 만든 Integer
interface를 이용하여 divAndRemainer
의 분자, 분모 타입을 사용하도록 하자.
func divAndRemainder[T Integer](num, denom T) (T, T, error) {
if denom == 0 {
return 0, 0, errors.New("cannot divide by zero")
}
return num / denom, num % denom, nil
}
func main() {
var a uint = 18_446_744_073_709_551_615
var b uint = 9_223_372_036_854_775_808
fmt.Println(divAndRemainder(a, b))
}
위의 예제에서는 uint
를 사용하여 Integer
에 호환되도록 하였다.
유념해야할 점은 golang은 강타입 언어이기 때문에 다음과 같이, Integer
interface에 type term으로 정의되어 있지 않지만, 내부는 Integer`를 만족하는 type term인 경우에는 호환되지 않는다.
type MyInt int
var myA MyInt = 10
var myB MyInt = 20
fmt.Println(divAndRemainder(myA, myB))
다음의 error를 발생시키게 된다.
MyInt does not satisfy Integer (possibly missing ~ for int in Integer)
만약, 특정 type의 하부가 Interger
에 넣은 type term 중 일부라면 호환가능하도록 바꾸고 싶다면, type term
앞에 ~
를 넣어주면 된다.
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
MyInt
는 하부가 int
이므로 Integer
의 type term인 int
와 호환된다. 따라서, MyInt
도 Integer
interface를 만족할 수 있는 것이다.
다음은 Ordered
interface로 ==
, !=
, <
, >
, <=
, >=
연산자들을 지원하는 여러 type들을 열거한다.
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}
orderable
type을 표현하여 변수를 지정하는 방식은 go 1.21의 cmp
package에 Ordered
interface로 도입되었다.
하나의 interface에 type element와 method element를 한번에 지정하는 것 또한 가능하다. 가령, int
type를 하부에 가지면서 String() string
method를 구현하는 user defined type를 다음과 같이 만들 수 있다.
type PrintableInt interface {
~int
String() string
}
만약 PrintableInt
에서 ~int
를 int
로 바꾼다면, 이를 만족하는 type은 세상에 없다. 왜냐하면 int
에는 String() string
method를 구현되어있지 않기 때문이다.
이러한 실수들은 생각보다 꽤 있는 일이다. 다행스러운건 golang에서는 이러한 실수를 용서하지 않는 compiler를 가지고 있기 때문에, 어떠한 type도 용납하지 않는다.
type ImpossiblePrintableInt interface {
int
String() string
}
type ImpossibleStruct[T ImpossiblePrintableInt] struct {
val T
}
type MyInt int
func (mi MyInt) String() string {
r
비록 ImpossiblePrintableInt
를 선언하는 것 자체는 compiler에서 문제로 삼지 않지만 실제 사용하려고 할때는 compiler가 문제점을 집어줄 것이다.
s := ImpossibleStruct[int]{10}
s2 := ImpossibleStruct[MyInt]{10}
이는 다음과 같은 error들을 만들어낼 것이다.
int does not implement ImpossiblePrintableInt (missing String method)
MyInt does not implement ImpossiblePrintableInt (possibly missing ~ for
int in constraint ImpossiblePrintableInt)
type term들은 primitive type들 뿐만 아니라, slices, maps, array, channels, structs, functions들도 가능하다. type term을 사용하여 type parameter가 특정 하부 type을 가지고, 여러 method들을 구현하도록 강제할 수 있어 매우 유용하다.
go는 :=
을 사용하여 type inference(추론)이 가능하다. generic 역시도 type 추론이 잘 동작한다. generic이 적용된 함수의 경우에 type parameter를 굳이 써주지 않아도 입력된 parameter를 통해 type이 추론 가능하다면 문제없이 type 추론이 동작한다.
type Integer interface {
int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64
}
func Convert[T1 Integer](in T1) T1 {
return T1(in)
}
func main() {
var a int = 10
b := Convert(a)
fmt.Println(b)
}
그러나 만약 함수의 파라미터로 generic의 type 파라미터를 추론하지 못하는 경우는 불가능하다. 다음과 같이 입력 파라미터로 T2
가 어떤 type parameter인지 추론이 불가능한 경우 error가 발생한다.
type Integer interface {
int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64
}
func Convert[T1, T2 Integer](in T1) T2 {
return T2(in)
}
func main() {
var a int = 10
b := Convert(a) // can't infer the return type
fmt.Println(b)
}
이 경우 b
에 들어갈 type parameter인 T2
가 어떤 type인지 추론이 안되기 때문에 에러가 발생하는 것이다. 이 문제를 해결하기 위해서는 명시적으로 type parameter의 type을 넣어주면 된다.
b := Convert[int, int64](a)
type element는 또한 변수에 들어갈 상수의 유효성 검사를 도와준다. type element안의 type term들을 모두 만족해야 상수가 해당 type element를 가진 변수에 할당이 가능한 것이다.
가령 Integer
interface는 int8
을 포함하고 있기 때문에 1000과 같은 상수값이 할당되지 못한다. 다음의 code는 error가 발생할 것이다.
func PlusOneThousand[T Integer](in T) T {
return in + 1000 // error
}
그러나 100의 경우는 모든 Integer
interface안의 모든 type term들을 만족하므로 가능하다.
func PlusOneThousand[T Integer](in T) T {
return in + 100
}
이진 트리 예제로 돌아와서 여태까지 배웠던 것들을 하나로 묶어보도록 하자. 이진 트리는 노드들이 있고, 비교 함수를 통해 현재 트리에서 어느쪽으로 노드를 추가할 지 결정한다. 따라서, Tree
와 Node
를 다음과 같이 만들 수 있을 것이다.
type Tree[T any] struct {
f OrderableFunc[T]
root *Node[T]
}
type Node[T any] struct {
val T
left, right *Node[T]
}
T
가 바로 Node
가 담고 있는 진짜 value의 type이 되는 것이다. 따라서, 해당 value를 기반으로 비교가 이루어져야 하므로 f
인 OrderableFunc[T]
는 type parameter로 T
를 받는 것이다.
type OrderableFunc [T any] func(t1, t2 T) int
Tree
에 대한 생성자 함수를 다음과 같이 만들 수 있다.
func NewTree[T any](f OrderableFunc[T]) *Tree[T] {
return &Tree[T]{
f: f,
}
}
Tree
가 가져야할 method들은 어차피 맨 위의 Node
인 root
에서부터 알고리즘을 시작해야하기 때문에 사실상 Node
의 method들을 호출하는 것이 전부이다.
func (t *Tree[T]) Add(v T) {
t.root = t.root.Add(t.f, v)
}
func (t *Tree[T]) Contains(v T) bool {
return t.root.Contains(t.f, v)
}
Node
의 Add
와 Contains
메서드는 현재 Node
의 value
와 입력된 v
를 비교하여 좌우 child node로 재귀함수를 호출하는 것이 전부이다.
func (n *Node[T]) Add(f OrderableFunc[T], v T) *Node[T] {
if n == nil {
return &Node[T]{val: v}
}
switch r := f(v, n.val); {
case r <= -1:
n.left = n.left.Add(f, v)
case r >= 1:
n.right = n.right.Add(f, v)
}
return n
}
func (n *Node[T]) Contains(f OrderableFunc[T], v T) bool {
if n == nil {
return false
}
switch r := f(v, n.val); {
case r <= -1:
return n.left.Contains(f, v)
case r >= 1:
return n.right.Contains(f, v)
}
return true
}
여기서 한 가지 재미난 사실은 Node
가 nil
이어도 Add
나 Contains
method를 호출할 수 있다는 것이다. 따라서, n.left
나 n.right
가 nil
이어도 Add
나 Contains
가 호출이 가능하다는 것이다.
이제 OrderedFunc
을 만족하는 compare함수를 만들어야하는데, cmp
package의 Compare
함수와 동일하다. 따라서, 다음과 같이 쓸 수 있다.
t1 := NewTree(cmp.Compare[int])
t1.Add(10)
t1.Add(30)
t1.Add(15)
fmt.Println(t1.Contains(15))
fmt.Println(t1.Contains(40))
정상 동작하는 것을 볼 수 있을 것이다.
이제 generic의 힘을 보도록 하자. Node
에 int
값이 아니라 Person
이라는 구조체를 value type으로 넣어주도록 하자.
type Person struct {
Name string
Age int
}
func OrderPeople(p1, p2 Person) int {
out := cmp.Compare(p1.Name, p2.Name)
if out == 0 {
out = cmp.Compare(p1.Age, p2.Age)
}
return out
}
비교 함수도 따로 만들어주면 된다. 이제 이를 활용하여 사용 code를 만들어보도록 하자.
t2 := NewTree(OrderPeople)
t2.Add(Person{"Bob", 30})
t2.Add(Person{"Maria", 35})
t2.Add(Person{"Bob", 50})
fmt.Println(t2.Contains(Person{"Bob", 30}))
fmt.Println(t2.Contains(Person{"Fred", 25}))
또 다른 방법으로는 Person
구조체에 비교 method
를 만들어 사용하는 방법이다. 이는 마치 method를 함수처럼 쓰는 방법인데, 이전에 Methods Are Functions Too
(메서드도 함수이다)라고 알려줬던 것과 동일하다.
func (p Person) Order(other Person) int {
out := cmp.Compare(p.Name, other.Name)
if out == 0 {
out = cmp.Compare(p.Age, other.Age)
}
return out
}
이 Order
을 Person.Order
로 사용하여 함수처럼 쓸 수 있다는 사실을 잊지말도록 하자.
t3 := NewTree(Person.Order)
t3.Add(Person{"Bob", 30})
t3.Add(Person{"Maria", 35})
t3.Add(Person{"Bob", 50})
fmt.Println(t3.Contains(Person{"Bob", 30}))
fmt.Println(t3.Contains(Person{"Fred", 25}))
이전에 interface는 comparable하다는 것을 알게되었었다. 이는 interface를 사용할 때 ==
와 !=
를 조심해야한다는 것이다. 만약 interface의 기저 type이 comparable하지 않다면 runtime중에는 code panic이 발생한다.
generics
로 comparable
interface를 사용할 때, 이러한 문제가 계속 잠재하게 된다. 다음의 예제를 보도록 하자.
type Thinger interface {
Thing()
}
type ThingerInt int
func (t ThingerInt) Thing() {
fmt.Println("ThingInt:", t)
}
type ThingerSlice []int
func (t ThingerSlice) Thing() {
fmt.Println("ThingSlice:", t)
}
Thinger
interface를 만족하는 ThingerInt
는 comparable한 type이지만 ThingerSlice
는 []int
이기 때문에 comparable 하지않다. 따라서 이 두 type을 같은 Thinger
interface로 비교를 하게되면 runtime중에 panic 문제가 발생할 수 있게되는 것이다.
generic을 사용하면 이 문제를 어느정도 해결할 수 있는데, comparable
을 사용하면 되기 때문이다.
func Comparer[T comparable](t1, t2 T) {
if t1 == t2 {
fmt.Println("equal!")
}
}
int
와 ThingerInt
는 둘다 comparable한 type이기 때문에 Comparer
함수 호출에 문제가 없다.
var a int = 10
var b int = 10
Comparer(a, b) // prints true
var a2 ThingerInt = 20
var b2 ThingerInt = 20
Comparer(a2, b2) // prints true
그러나 ThingerSlice
는 comparable하지 않기 때문에 compile error가 발생하게 된다.
var a3 ThingerSlice = []int{1, 2, 3}
var b3 ThingerSlice = []int{1, 2, 3}
Comparer(a3, b3) // compile fails: "ThingerSlice does not satisfy comparable"
하지만, 완벽하게 이 문제를 해결한 것은 아니다. Thinger
interface로 속이고(?) 돌아가면 compile error를 우회해서 돌아갈 수 있기 때문이다.
먼저 ThingerInt
를 Thinger
에 넣어서 비교해보도록 하자.
var a4 Thinger = a2
var b4 Thinger = b2
Comparer(a4, b4) // prints true
ThingerSlice
를 Thinger
에 넣으면 compile은 되지만 runtime중에 panic이 발생하게 된다.
a4 = a3
b4 = b3
Comparer(a4, b4) // compiles, panics at runtime
compile때는 error가 발생하지 않지만 해당 code를 실행하면 다음과 같이 panic이 발생할 것이다.
panic: runtime error: comparing uncomparable type main.ThingerSlice
따라서, generic으로 comparable type만 오도록 할 수 있어서 어느정도 error들을 잡아낼 수 있지만 interface도 comparable하다는 특징이 문제가 있기 때문에 이를 조심히 사용하는 것이 좋다.
추가적인 정보는 다음의 링크를 확인해보도록 하자. https://go.dev/blog/comparable
generic이 도입되면서 golang에서 관용적으로 사용되던 방법들이 많이 달라지고 있다. 그러나 굳이 현재 동작하고 있는 레거시 code들을 generic을 도입하여 다시 고치거나 만들 필요는 없다. generic은 아직 도입된지 오래되지 않아 어떠한 잠재 문제가 있을 지 모른다. 성능상에 대한 토론도 활발히 이루어지고 있는 상황이라 성능이 더 좋아질지 나빠지질지 확신을 가질 수 없다.
특히, 성능의 상승을 위해 interface parameter를 받는 함수의 parameter를 geneic type parameter로 바꾸지 마라.
type Ager interface {
age() int
}
func doubleAge(a Ager) int {
return a.age() * 2
}
위의 code를 아래와 같이 바꾸는 것이다.
func doubleAgeGeneric[T Ager](a T) int {
return a.age() * 2
}
go 1.20기준으로 generic을 쓴 code가 30% 더 느리다. 이는 다른 언어, 특히 c++과 같은 언어와 비교하면 충격적인 결과가 아닐 수 없다. 왜냐하면 c++은 generic을 컴파일 후에 generic type에 들어갈 수 있는 경우에 수에 비례하게 모든 함수들을 만들어낸다. 가령 int
, string
만 들어갈 수 있다면 int
, string
에 대한 함수 두 개를 만들어내는 것이다. 그렇기 때문에 바이너리 크기는 매우 커지지만 성능에는 전혀 문제가 없는 것이다.
현재의 go compiler는 오직 하나의 unique 함수만을 만들어내어 generic의 기저 type들이 이 함수에 의존하게 된다. go에서는 서로 다른 type들이 하나의 unique한 함수에 들어갈 때, type들을 구별하기 위한 작업이 필요하다. compiler는 추가적인 runtime lookup을 주어 하나의 함수에 여러 type들이 들어갈 때, 어떤 type인지 구별할 수 있는 기준을 주게되는 것이다. 이 type구분 작업이 하나의 overhead가 되어 성능상의 문제가 되는 것이다.
시간이 흐르면 generic의 구현이 성숙해지고 성능도 향상될 것이다. 사실 다른 언어들도 다 이러한 방식으로 발전한 것이지 처음부터 지금의 성능을 내는 언어들은 없다.
go에서 generic을 쓸 때, 한 가지 명심할 것은 generic은 code를 정리하고 가독성 좋게 만들기 사용하기 위함이 1순위이고, 성능의 만족도는 벤치마크를 통해 반드시 확인해봐야할 것이라는 것이다.