Engineering
Go 제네릭
2022년 4월 15일
원문에서 보기 ↗안녕하세요. 최근에 맡은 프로젝트에서 Go 언어를 사용하여 개발을 진행하고 있습니다. Go 언어의 장점은 간결한 문법, 컨벤션 통일, 가벼운 고루틴(Java의 쓰레드) 등이 있고, Docker 역시 Go 언어로 개발되어 있을 정도로 많은 곳에서 사용 중입니다. 2022년 3월에 Go 1.18이 릴리즈되면서 제네릭이 새롭게 추가된다고 하여 예제를 중심으로 간략하게 정리해 보았습니다.
Go 제네릭
- Go 언어 v1.18부터 제네릭이 들어간다고 한다. (벌써 나왔네요.)
- 이번 제네릭을 지원하기 위해 새로운 문법이 추가되었다.
- 제네릭에 이용할 타입 파라미터
- 타입들을 묶는 인터페이스
- 제네릭에서의 타입 추론
- 최대한 예제 코드를 중심으로 Go 제네릭을 어떻게 사용해야 하는지 알아보자.
타입 파라미터
- 타입 파라미터는 아래와 같은 형식으로 되어 있다.
['식별자' '타입 제한자', '식별자' '타입 제한자']
- '식별자'는 함수 혹은 구조체 내에 쓸 타입의 식별자를 의미한다. '타입 제한자'는 제네릭에서 새롭게 추가된 요소로, 들어갈 타입의 범위를 제한하는 역할을 한다.
Go 제네릭 함수
- Go 언어에는 제네릭이 없었다.
- 그래서 동일한 기능을 하지만 타입이 다른 경우 여러 함수를 만들었다.
package main
import "fmt"
func min(a, b int) int {
if a < b {
return a
}
return b
}
func minInt16(a, b int16) int16 {
if a < b {
return a
}
return b
}
func main() {
var (
a int = 10
b int = 20
)
fmt.Println(min(a, b))
var (
c int16 = 10
d int16 = 20
)
fmt.Println(minInt16(c, d))
}
- 소스 링크 - go.dev/play with Go dev branch - 제네릭이 없는 경우
- min 함수는 int형의 파라미터만 처리할 수 있기 때문에 int16을 처리하기 위해서는 다른 함수를 만들어야만 한다.
- 제네릭을 사용하면 동일한 기능을 하는 여러 타입에 대해서 한 함수로 적용할 수 있다.
package main
import "fmt"
func print[T any](a T) { // func name['식별자' '타입 제한자']
fmt.Println(a)
}
func main() {
var (
a int = 10
b float32 = 3.14
c string = "hello"
)
print(a)
print(b)
print(c)
}
- go.dev/play with Go dev branch - 제네릭 any 이용
- Java와 C++에서는
<,>keyword를 통해 제네릭을 표현하였지만, Go 에서는[,]keyword를 통해 제네릭을 표현한다. - Go 언어는 강타입 언어이기 때문에 타입에 대한 제약이 강하다. 위 예제에서 보았듯이 동일한 로직이지만 int 타입인지, float 타입인지에 따라 각 함수를 만들었어야 했다. 하지만 이번 제네릭 도입을 통해 강타입 체크로 인해 불편했던 중복 코드 개발이 약간은 해소될 것으로 여겨진다.
any와 빈 인터페이스와의 차이
- 제네릭에서의 타입 추론
- Go 제네릭을 사용하지 않고도
빈 인터페이스를 사용해 비슷하게 사용할 수는 있다.
package main
import "fmt"
func Print(s interface{}) {
fmt.Print(s)
}
func main() {
Print("hello")
}
- 빈 인터페이스를 사용하면 타입의 제한 없이 파라미터를 전달 할 수 있다.
- 하지만 아래 예제와 같이 빈 인터페이스가 가지는 한계가 존재하며 제네릭만의 장점이 있다.
package main
import "fmt"
func foo1(a interface{}) interface{} {
return a
}
func foo2[T any](a T) T {
return a
}
func main() {
var (
a int = 10
b int = 20
c int
)
c = foo1(a).(int) // 리턴 타입이 interface{} 이다.
fmt.Println(c)
c = foo2(b) // 리턴 타입이 int이다.
fmt.Println(c)
}
- foo1 함수는 빈 인터페이스를 반환하기 때문에 이를 int 타입에 넣기 위해서는 형변환이 필요하다.
- foo2 함수는 파라미터와 반환 타입이 any로 되어 있고, foo2 함수에 int 타입의 파라미터가 전달되면 자동으로 반환도 int 타입으로 되어 형변환이 필요 없다.
- 제네릭은 타입 추론이 된다. foot2 함수의 경우는 전달된 파라미터 타입을 통해 반환할 타입이 추론된다. 이는 컴파일 시점에 정해지며 작성된 타입별로 내부의 코드가 생산되는 것이다. foo2 함수에 int 타입의 파라미터를 전달하면 반환되는 파라미터도 int 타입이고, 파라미터 타입이 string 이라면 반환되는 타입도 string이 되는 것이다.
- 형변환이 없으니 약간의 성능상의 이점도 있다.
타입 제한자
- Go 제네릭은 인터페이스와 유사하게 타입 제한자로서 기능을 제공한다.
any는 Go 1.18에 새로 추가된 keyword로 빈 인터페이스의 alias이다. 빈 인터페이스는 어떠한 타입이든 허용하기 때문에 가장 보편적인 기능만 지원한다. 만약 보편적이지 않은 기능을 필요로 하다면 해당 기능을 가진 타입으로 지정해야만 한다.
package main
import "fmt"
func min[T any](a, b T) T {
if a < b { // 문법 오류가 발생. any는 < 연산을 지원하지 않는다.
return a
}
return b
}
func main() {
var (
a int = 10
b b = 20
)
fmt.Println(min(a, b))
}
- go.dev/play with Go dev branch - 타입 제한자-1 any
- 비교를 위해 사용한
빈 인터페이스의 alias인 any는<연산자가 없다.<연산자를 가지는 타입을 타입 제한자로 사용하면 min 함수를 완성할 수 있다.
package main
import "fmt"
func min[T int | int16 | int32 | int64 | float32 | float64](a, b T) T {
if a < b { // 위 타입들이 < 연산자를 지원하기 때문에 문법 오류가 없다.
return a
}
return b
}
func main() {
var (
a int = 10
b int = 20
c int16 = 10
d int16 = 20
e float32 = 3.14
f float32 = 1.14
)
fmt.Println(min(a, b))
fmt.Println(min(c, d))
fmt.Println(min(e, f))
}
- go.dev/play with Go dev branch - 타입 제한자-2
<연산자를 가진 여러 개의 타입을 파이프(|) 연산자로 합쳤다.- 타입 제한자는 파이프 연산자로 여러 개를 쉽게 추가가 가능하다.
타입 제한자 선언
- 매번 타입 제한자를 만드는 것은 비효율적이므로 타입 제한자를
interface키워드로 선언하여 사용이 가능하다.
package main
import "fmt"
type ComparableNumbers interface {
int | int16 | int32 | int64 | float32 | float64
}
func min[T ComparableNumbers](a, b T) T {
if a < b { // 위 타입들이 < 연산자를 지원하기 때문에 문법 오류가 없다.
return a
}
return b
}
func main() {
var (
a int = 10
b int = 20
c int16 = 10
d int16 = 20
e float32 = 3.14
f float32 = 1.14
)
fmt.Println(min(a, b))
fmt.Println(min(c, d))
fmt.Println(min(e, f))
}
- go.dev/play with Go dev branch - 타입 제한자 선언-1
- 반복되는 타입 제한자 묶음을 하나로 만들어 재사용이 가능하고 가독성도 높게 되었다.
- 타입 제한자끼리 합치는 것도 가능하다.
package main
import "fmt"
type Integer interface {
int | int16 | int32 | int64
}
type Float interface {
float32 | float64
}
type ComparableNumbers interface {
Integer | Float
}
func min[T ComparableNumbers](a, b T) T {
if a < b {
return a
}
return b
}
func main() {
var (
a int = 10
b int = 20
c int16 = 10
d int16 = 20
e float32 = 3.14
f float32 = 1.14
)
fmt.Println(min(a, b))
fmt.Println(min(c, d))
fmt.Println(min(e, f))
}
- go.dev/play with Go dev branch - 타입 제한자 선언-2
- 타입 제한자 Integer와 Float를 합쳐서 ComparableNumbers 타입 제한자를 만들었다.
타입 제한자와 인터페이스
- 인터페이스는 타입 제한자로 사용이 가능하지만, 타입 제한자는 인터페이스로 사용이 불가능하다.
- 타입 제한자와 인터페이스를 합치는 것도 가능하다.
- 타입 제한자와 인터페이스를 합치면
타입 제한자로만 사용할 수 있다.
package main
import (
"fmt"
)
// 인터페이스. 타입 제한자로 사용 가능
type ToString interface {
String() string
}
func PrintCat[T ToString](a, b T) {
fmt.Printf("%s-%s", a.String(), b.String())
}
// 타입 제한자. 인터페이스로 사용 불가능
type Integer interface {
~int8 | ~int16 | ~int32 | ~int64 | ~int
}
// 문법 오류 발생
func PrintMin1(a, b Integer) {
if a < b {
fmt.Println(a.String())
} else {
fmt.Println(b.String())
}
}
// 타입 제한자+인터페이스 ==> 타입 제한자. 인터페이스로 사용 불가능
type Stringer interface {
Integer
ToString
}
func PrintMin2[T Stringer](a, b T) {
if a < b {
fmt.Println(a.String())
} else {
fmt.Println(b.String())
}
}
// 문법 오류 발생
func PrintMin3(a, b Stringer) {
if a < b {
fmt.Println(a.String())
} else {
fmt.Println(b.String())
}
}
type MyInt int
func (m MyInt) String() string {
return fmt.Sprintf("%d", m)
}
func main() {
var a MyInt = 10
var b MyInt = 100
PrintMin(a, b)
PrintCat(a, b)
}
- go.dev/play with Go dev branch - 타입 제한자와 인터페이스
- 타입 제한자도 인터페이스도 뒤에
interface라는 키워드를 통해 선언한다. - 얼핏 보아서는 헷갈릴 수밖에 없다. 새로운 키워드를 만들어내지 않는 이유는 Go 언어의 철학 때문이라고 생각되는데 Go 언어는 최대한 간결한 문법을 원한다. 그래서 새로운 키워드의 도입이 아닌 기존에 사용하던
interface키워드를 같이 사용한 것으로 생각된다. - 타입 제한자가 인터페이스 키워드를 같이 사용하기보다는 인터페이스가 타입 제한자의 역할도 같이 하기 때문에 인터페이스의 확장이라고 보는 것이 좋을 것 같다.
constraints 패키지
- Go 1.18 Release Notes에 보면 새롭게 추가된 몇 가지가 나온다. 릴리즈 노트 전체를 확인하는 것은 이 문서의 범위를 벗어나므로 constraints 패키지 중 일부만 살펴보겠다.
type Ordered
- 순서가 있는 타입들을 선언한 타입 제한자
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
func min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
func main() {
var (
a int = 10
b int = 20
c int16 = 10
d int16 = 20
e float32 = 3.14
f float32 = 1.14
)
fmt.Println(min(a, b))
fmt.Println(min(c, d))
fmt.Println(min(e, f))
var (
h = "Hello"
i = "World"
)
fmt.Println(min(h, i))
}
- go.dev/play with Go dev branch - 타입 제한자-Ordered
- constraints.Ordered 은 크기 비교가 가능한 타입 제한자입니다.
- 새로 추가된
comparable키워드는 == 또는 != 연산이 가능한 타입 제한자입니다. - 그리고 특이한 문법이 있습니다.
type Float interface {
~float32 | ~float64
}
- 공식문서: go constraints > Float
- float32 앞에
~(틸트)가 붙어 있습니다. 이 토큰도 Go 1.18에서 새로 추가된 문법으로 "확장된" 이란 의미를 가집니다.
package main
import (
"fmt"
)
type Integer interface {
int | int8 | int16 | int32 | int64
}
type MyInt int
func min[T Integer](a, b T) T {
if a < b {
return a
}
return b
}
func main() {
var (
a int = 10
b int = 20
)
fmt.Println(min(a, b))
var (
c MyInt = 10
d MyInt = 20
)
fmt.Println(min(c, d)) // possibly missing ~ for int in constraint Integer
// type Integer 안에 'int' 앞에 틸트(~)를 붙여워야 에러가 안난다. '~int'
}
- go.dev/play with Go dev branch - 타입 제한자-틸트
- MyInt는 int를 확장한 타입입니다. Integer 타입 제한자에서 이를 수용하기 위해서는 int 앞에 ~(틸트)를 붙여줘야 합니다.
package main
import (
"fmt"
)
type Integer interface {
~int | int8 | int16 | int32 | int64
}
type MyInt int
func min[T Integer](a, b T) T {
if a < b {
return a
}
return b
}
func main() {
var (
a int = 10
b int = 20
)
fmt.Println(min(a, b))
var (
c MyInt = 10
d MyInt = 20
)
fmt.Println(min(c, d))
}
- go.dev/play with Go dev branch - 타입 제한자-틸트 2
- 변수 c, d는 MyInt를 사용하고 Integer 인터페이스에는 ~int 가 있어서 int를 확장한 MyInt를 사용할 수 있다.
제네릭 타입
- Go 제네릭은 함수와 구조체에서 사용이 가능하다. 아직 메소드에서 사용은 불가능하다.
package main
import "fmt"
type Node[T any] struct {
val T // struct 의 value 타입을 T로 사용한다.
next *Node[T]
}
func NewNode[T any](v T) *Node[T] { // 새로운 Node를 만들 때도 제네릭이 필요하다.
return &Node[T]{val: v}
}
/*
Node의 메소드인 Push에 제네릭 T가 포함된다. 하지만 이곳에서 새로운 다른 제네릭을 선언하거나 사용하는 것은 문법 오류이다.
문법오류: func (n *Node[T]) Push[F any](f F) * Node[T]
*/
func (n *Node[T]) Push(v T) *Node[T] {
node := NewNode(v)
n.next = node
return node
}
func main() {
node1 := NewNode(1) // *Node[int]
node1.Push(2).Push(3).Push(4)
for node1 != nil {
fmt.Println(node1.val)
node1 = node1.next
}
node2 := NewNode("hello") // *Node[string]
node2.Push("how").Push("are").Push("you").Push("?")
for node2 != nil {
fmt.Println(node2.val)
node2 = node2.next
}
}
/*
1
2
3
4
hello
how
are
you
?
*/
- go.dev/play with Go dev branch - 제네릭 타입 - 링크드 리스트
- Node 구조체는 제네릭 변수를 가지고 있다.
- NewNode 함수에 전달된 파리미터 타입에 따라 컴파일 시점에 Node 구조체의 타입이 결정되며 타입별로 내부 코드가 생성되는 것이다. 작성자 입장에서는 1개의 Node 구조체이지만 컴파일된 코드는 타입별로 만들어진다.
- 그래서, main()의 NewNode 함수에 전달되는
int파라미터에 의해 Node[int] 구조체가 만들어지고,string파라미터에 의해 Node[string] 구조체가 만들어진다. - 타입 추론이 되므로 Push로 Node를 추가할 때 동일한 타입만 받는다.
제네릭 함수
- 위 예제들에서 보았드시 함수에서 제네릭 사용이 가능하다.
package main
import (
"fmt"
"strings"
)
func Map[F, T any](s []F, f func(F) T) []T {
rst := make([]T, len(s))
for i, v := range s {
rst[i] = f(v)
}
return rst
}
func main() {
doubled := Map([]int{1, 2, 3}, func(i int) int {
return i * 2
})
fmt.Println(doubled)
uppered := Map([]string{"Hello", "world", "abc"}, func(s string) string {
return strings.ToUpper(s)
})
fmt.Println(uppered)
toString := Map([]int{1, 2, 3}, func(i int) string {
return fmt.Sprintf("str%d", i)
})
fmt.Println(toString)
}
/*
[2 4 6]
[HELLO WORLD ABC]
[str1 str2 str3]
*/
- go.dev/play with Go dev branch - 제네릭 타입 - Map
- Map 함수는 값을 변환시켜 배열로 반환하는 함수이다.
요약
- 제네릭 프로그래밍은 타입 파라미터를 통해서 하나의 함수나 타입이 여러 타입에 대해서 동작할 수 있도록 해준다.
- '타입 제한자'를 이용해 타입 파라미터로 사용되는 타입을 제한한다.
- 인터페이스도 타입 제한자로 사용이 가능하다.
- 적용할 수 있는 곳
- 함수
- 타입 제한자
- 제네릭 타입(구조체)
- 적용하면 좋은 곳
- 자료구조. 타입에 관계없는 슬라이스, 맵, 채널
- 다양한 타입에 대해서 다양한 동작을 하는 경우