Post

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
}
  • MyIntInteger 타입에 포함되어 있지 않아 컴파일 에러가 발생한다
  • 아래와 같이 ~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
}
  1. ComparableHasher 타입 제한을 정의한다
    • comparable==, !=를 지원하는 타입들을 정의한 Go 내부 타입 제한이다
    • Hash() uint32 메서드를 포함하도록 제한했다
    • 즉, ComparableHasher==!=를 지원하고 Hash() uint32 메서드를 포함한 타입만 가능하다
  2. MyString이라는 별칭 타입을 정의하고 Has() uint32 메서드를 구현한다
    • MyStringComparableHasher 타입을 만족한다
  3. 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 버전에서 제네릭을 사용해 만든 slicesmaps라는 기본 패키지가 추가되었다

5.1 slices

  • slices 패키지는 슬라이스를 다룰 때 사용할 수 있는 유용한 기능들을 제공한다. 제네릭을 사용해서 만들었기 때문에 타입 제한만 만족한다면 어떤 타입의 slice에서도 사용 가능하다
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()Ecmp.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() 함수를 사용한다
  • Vcomparable 타입이 아니라면 EqualFunc() 함수를 사용해서 두 맵을 비교할 수 있다
    • EqualFunc()는 두 맵의 V의 타입이 다르더라도 비교할 수 있다
This post is licensed under CC BY 4.0 by the author.