24. 제네릭 프로그래밍
24. 제네릭 프로그래밍
✅ 1. 제네릭 프로그래밍 소개
- Go 1.18 버전에 추가된 기능으로, 타입 파라미터를 통해서 하나의 함수나 타입이 여러 타입에 대응해 동작하도록 하는 프로그래밍 기법
✅ 2. 제네릭 함수
1
func funcName[T constraint](p T) {}
- 함수명 뒤에 대괄호를 열고 타입 파라미터(파라미터 이름과 타입 제한)를 적는다
T
: 파라미터 이름constaint
: 타입 제한
- 타입 파라미터는
,
로 구분하여 여러 개 적을 수 있다 - 타입 파라미터에 사용한 타입 파라미터 이름을 특정 타입 대신에 사용할 수 있다
p T
1
2
3
4
5
6
7
8
9
func Print[T any](a, b T) {
fmt.Println(a, b)
}
func main() {
Print(1, 2)
Print("Hello", "World")
Print(1, "Hello") // in call to Print, mismatched types untyped int and untyped string (cannot infer T)compilerCannotInferTypeArgs
}
Print()
함수는 같은 타입 인자 두 개를 받으면 출력하지만, 서로 다른 타입의 인수를 사용하면 에러를 발생시킨다- 여러 타입 파라미터를 정의할 수 있다
2.1 타입 제한(type constraint)
1
2
3
func add[T any](a, b T) T {
return a + b // invalid operation: operator + not defined on a (variable of type T constrained by any)compilerUndefinedOp
}
- 위 함수에서
any
타입T
는 + 연산자가 정의되어 있지 않기 때문에 에러가 발생한다 - 특정 조건을 정의해서 해당 타입이 + 연산자를 지원하고 있음을 알려줘야 한다
2.1.1 타입 제한 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. `|`로 구분
func add1[T int8 | int16 | int32 | int64 | int](a, b T) T {
return a + b
}
// 2. 타입 제한 선언
type Integer interface {
int8 | int16 | int32 | int64 | int
}
func add2[T Integer](a, b T) T {
return a + b
}
T
타입에 여러 타입을|
로 구분하여 넣어줄 수 있다- 타입 제한은 Interface 키워드를 사용해서 정의할 수도 있다
golang.org/x/exp/constraints
패키지는 이미 정의된 몇 가지 타입 제한을 제공한다1 2 3
func add[T constraints.Integer | constraints.Float](a, b T) T { return a + b }
2.2 타입 제한 더 알아보기
1
2
3
type Float interface {
~float32 | ~float64
}
constraints.Float
타입은~
가 붙어있는데, 이는 해당 타입을 기본으로 하는 모든 별칭 타입들까지 포함한다는 뜻이다
2.2.1 별칭 타입을 포함하지 않는 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Integer interface {
int | int32
}
func add[T Integer](a, b T) T {
return a + b
}
type MyInt int
func main() {
var a MyInt = 3
var b MyInt = 4
fmt.Println(add(a, b)) // MyInt does not satisfy Integer (possibly missing ~ for int in Integer)compilerInvalidTypeArg
}
MyInt
는Integer
타입에 포함되어 있지 않아 컴파일 에러가 발생한다- 아래와 같이
~int
로 수정하면 별칭 타입까지 포함되어 에러가 발생하지 않는다1 2 3
type Integer interface { ~int | int32 }
2.3 타입 제한에 메서드 조건 더하기
- 타입 제한 정의가
interface
키워드를 사용하기 때문에 일반 인터페이스처럼 메서드 조건을 더할 수 있다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main
import (
"fmt"
"hash/fnv"
)
type ComparableHasher interface {
comparable
Hash() uint32
}
type MyString string
func (s MyString) Hash() uint32 {
h := fnv.New32a()
h.Write([]byte(s))
return h.Sum32()
}
func Equal[T ComparableHasher](a, b T) bool {
if a == b {
return true
}
return a.Hash() == b.Hash()
}
func main() {
var str1 MyString = "Hello"
var str2 MyString = "World"
fmt.Println(Equal(str1, str2)) // false
}
ComparableHasher
타입 제한을 정의한다comparable
은==
,!=
를 지원하는 타입들을 정의한 Go 내부 타입 제한이다Hash() uint32
메서드를 포함하도록 제한했다- 즉,
ComparableHasher
는==
와!=
를 지원하고Hash() uint32
메서드를 포함한 타입만 가능하다
MyString
이라는 별칭 타입을 정의하고Has() uint32
메서드를 구현한다MyString
은ComparableHasher
타입을 만족한다
ComparableHasher
제한을 사용하는Equal()
함수를 정의한다
타입 제한은 인터페이스 같지만 서로 다른 개념이다. 타입 제한은 제네릭 프로그래밍의 타입 파라미터에서만 사용될 수 있고, 일반 인터페이스처럼 사용하지 못한다
1 2 func Equal(a, b ComparableHasher) bool {} // Error func Equal[T ComparableHasher](a, b T) bool {} // OK
2.4 제네릭 함수 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main
import (
"fmt"
"strconv"
"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() {
// 1. 값을 두 배 증가
doubled := Map([]int{1, 2, 3}, func(v int) int {
return v * 2
})
// 2. 대문자로 변경
uppered := Map([]string{"Hello", "World"}, func(v string) string {
return strings.ToUpper(v)
})
// 3. 문자열로 변경
toString := Map([]int{1, 2, 3}, func(v int) string {
return strconv.Itoa(v)
})
fmt.Println(doubled)
fmt.Println(uppered)
fmt.Println(toString)
}
✅ 3. 제네릭 타입
- 타입 파라미터는 함수뿐 아니라 타입 선언 시에도 사용 가능하다
- 아래 예시에서
Node
구조체는 타입 파라미터를 사용해서val
필드 타입이 어떤 타입이든 가능하도록 정의하고 있다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main
import "fmt"
type Node[T any] struct {
val T
next *Node[T]
}
func NewNode[T any](v T) *Node[T] {
return &Node[T]{val: v}
}
func (n *Node[T]) Push(v T) *Node[T] {
node := NewNode(v)
n.next = node
return node
}
func main() {
node1 := NewNode(1)
node1.Push(2).Push(3).Push(4)
for node1 != nil {
fmt.Print(node1.val, " - ") // 1 - 2 - 3 - 4 -
node1 = node1.next
}
fmt.Println()
node2 := NewNode("Hi")
node2.Push("Hello").Push("Wow")
for node2 != nil {
fmt.Print(node2.val, " - ") // Hi - Hello - Wow -
node2 = node2.next
}
fmt.Println()
}
3.1 인터페이스와 제네릭은 무엇이 다른가?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main
import "fmt"
type Node1 struct {
val any
next *Node1
}
type Node2[T any] struct {
val T
next *Node2[T]
}
func main() {
node1 := &Node1{val: 1}
node2 := &Node2[int]{val: 2}
var v1 int = node1.val.(int)
var v2 int = node2.val
fmt.Println(v1, v2)
}
Node1
은 내부 필드를any
로 정의했고Node2
는 제네릭 타입으로 정의했다- 빈 인터페이스를 이용하면 모든 타입값을 가질 수 있으나 그 값을 사용할 때 실제 타입값으로 Type assertion 수행해야하고, 넣을 때의 타입과 뺼 때의 타입을 정확히 알고 있어야 한다는 문제가 있다.
- 반면 제네릭 타입은 타입 파라미터에 의해 필드 타입이 결정되므로 assertion이 필요하지 않다
3.2 성능 차이
1
2
3
var v1 int = 3
var v2 any = v1 // boxing
var v3 int = v2.(int) // unboxing
- 위와 같이 기본 타입값을 빈 인터페이스 변수에 대입할 때, Go에서는 빈 인터페이스를 만들어서 기본 타입값을 가리키도록 하는데 이를 boxing이라고 하며 값을 다시 꺼내는 것을 unboxing이라고 한다
- 박싱은 값을 감싸는 빈 인터페이스 객체를 생성해야하기 때문에 성능 비용이 발생하지만 제네릭 프로그래밍을 사용하면 타입 파라미터에 의해 타입이 고정되기 때문에 박싱, 언박싱 비용이 발생하지 않는다.
- 그렇다면 제네릭을 사용하느 게 무조건 이득일까? → 꼭 그렇지는 않다
3.2.1 예제
1
2
3
4
5
6
func add[T constraints.Integer | constraints.Float](a, b T) T {
return a + b
}
add(1, 3)
add(1.1, 3.3)
- 위 예제에서 하나의 함수를 서로 다른 타입으로 호출한 것처럼 보이지만, 사실은
add[int](1, 3)
과add[float64](1.1, 3.3)
함수를 각각 호출한 것이다 - 제네릭 함수, 타입은 컴파일 타임에 사용한 타입 파라미터 별로 새로운 함수나 타입을 생성한다. 따라서 제네릭을 많이 사용하면 컴파일 타임에 생성해야 할 함수와 타입 개수가 늘어나며 컴파일 시간이 길어지고 실행 파일 크기가 늘어난다.
✅ 4. 언제 제네릭을 사용해야 하는가?
“동작하는 코드 먼저, 제네릭은 나중에”
- 제네릭을 사용하면 코드 재사용성에 도움이 되지만 가독성이 떨어진다
- 동작하는 코드를 먼저 작성하고, 그 다음에 최적화나 개선을 해도 늦지않다. 일반적인 타입이나 구조체를 사용해서 동작하는 코드에 먼저 집중하고, 나중에 제네릭을 사용해서 도움이 되는 부분이 있다면 그 때 적용해도 늦지 않다.
- 트리, 그래프, 리스트, 맵과 같은 일반적인 타입에 대해서 같은 동작을 보장해야 하는 경우 제네릭이 잘 어울린다.
✅ 5. 제네릭을 사용하는 유용한 기본 패키지
- Go 1.21 버전에서 제네릭을 사용해 만든
slices
와maps
라는 기본 패키지가 추가되었다
5.1 slices
slices
패키지는 슬라이스를 다룰 때 사용할 수 있는 유용한 기능들을 제공한다. 제네릭을 사용해서 만들었기 때문에 타입 제한만 만족한다면 어떤 타입의 slice에서도 사용 가능하다
5.1.1 Binary Search
1
func BinarySearch[S ~[]E, E cmp.Ordered](x S, target E) (int, bool)
BinarySearch()
는 정렬된 슬라이스에서 target값의 위치를 찾는 함수로, target을 찾으면 인덱스와 true를 반환하고 찾지 못했다면 target값이 위치해야 할 index와 false를 반환한다- 제네릭 타입
~[]E
로 슬라이스를 만들고cmp.Ordered
타입으로 대소비교가 가능한E
타입들만 사용하도록 했다.1 2 3 4 5 6
names := []string{"Alice", "Bob", "Vera"} n, found := slices.BinarySearch(names, "Vera") fmt.Println("Vera: ", n, found) // Vera: 2 true n, found = slices.BinarySearch(names, "Bill") fmt.Println("Vera: ", n, found) // Vera: 1 false
1
func BinarySearchFunc[S ~[]E, E, T any](x S, target T, cmp func(E, T) int) (int, bool)
- 만약
cmp.Ordered
타입 조건을 만족하지 못하는 구조체 슬라이스일 때는BinarySearchFunc()
를 이용할 수 있다 []E
타입 슬라이스에서any
타입 T를 찾을 수 있다. 대소 비교는 함수 리터럴을 인수로 받아 처리한다1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
type Person struct { Name string Age int } func main() { people := []Person{ {"Alice", 55}, {"Bob", 24}, {"Gopher", 13}, } n, found := slices.BinarySearchFunc(people, "Bob", func(p Person, s string) int { return cmp.Compare(p.Name, s) }) fmt.Println("Bob: ", n, found) // Bob: 1 true }
5.1.2 Clone
1
2
3
4
5
6
7
func Clone[S ~[]E, E any](s S) S {
if s == nil {
return nil
}
return append(S{}, s...)
}
slices.Clone()
함수는 같은 값을 가지는 다른 슬라이스를 생성하는 함수로 내부에append()
로 구현되어 있다
5.1.3 Compare
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Compare[S ~[]E, E cmp.Ordered](s1, s2 S) int {
for i, v1 := range s1 {
if i >= len(s2) {
return +1
}
v2 := s2[i]
if c := cmp.Compare(v1, v2); c != 0 {
return c
}
}
if len(s1) < len(s2) {
return -1
}
return 0
}
- 앞 요소값부터 비교해서 s1의 원소가 더 작으면 -1, 더 크면 1을 반환한다. 모든 요소가 같다면 길이를 비교해서 s1이 더 작다면 -1을 반환하고 길이도 똑같으면 0을 반환한다
slices.Compare()
는E
가cmp.Ordered
로 타입 제한이 걸려있다.
1
func CompareFunc[S1 ~[]E1, S2 ~[]E2, E1, E2 any](s1 S1, s2 S2, cmp func(E1, E2) int) int
cmp.Ordered
타입이 아닌 구조체 슬라이스는slices.CompareFunc()
를 통해 비교해야 한다
5.1.4 Concat
1
func Concat[S ~[]E, E any](slices ...S) S
- 여러 슬라이스를 하나의 슬라이스로 합치는 함수
5.1.5 Contains
1
func Contains[S ~[]E, E comparable](s S, v E) bool
- 슬라이스에 원소가 포함되어 있는지 여부를 반환하는 함수
==
,!=
이 가능한comparable
타입 제한이 있다
1
func ContainsFunc[S ~[]E, E any](s S, f func(E) bool) bool
- 비교가 되지 않는 타입들도
ContainsFunc
를 통해서 포함 여부를 확인할 수 있다
5.2 maps
- map을 다룰 때 유용한 기능을 제공한다
5.2.1 Clone
1
func Clone[M ~map[K]V, K comparable, V any](m M) M
- map은 내부에 포인터를 가지고 있기 때문에 단순 대입으로는 맵이 복제되지 않는다
5.2.2 Copy
1
func Copy[M1 ~map[K]V, M2 ~map[K]V, K comparable, V any](dst M1, src M2)
- 두 번째 인수인 src의 모든 값을 dst로 복사하며 키가 같을 경우 src값으로 덮어쓰게 된다
5.2.3 Equal
1
2
func Equal[M1, M2 ~map[K]V, K, V comparable](m1 M1, m2 M2) bool
func EqualFunc[M1 ~map[K]V1, M2 ~map[K]V2, K comparable, V1, V2 any](m1 M1, m2 M2, eq func(V1, V2) bool) bool
- 두 맵의 K, V값이 모두 같은지 여부를 판단할 떄
maps.Equal()
함수를 사용한다 V
가comparable
타입이 아니라면EqualFunc()
함수를 사용해서 두 맵을 비교할 수 있다EqualFunc()
는 두 맵의V
의 타입이 다르더라도 비교할 수 있다
This post is licensed under CC BY 4.0 by the author.