Go언어에서의 제네릭 미리보기
프로그래밍 언어

Go언어에서의 제네릭 미리보기

이번 Gophercon 2021의 첫번째 세션은 내년 2월에 예정된 메이저 업데이트(v1.18)에 추가될 제네릭에 대한 소개였다. 아마 많은 Gopher(Go언어 이용자)들이 기대한 내용이 아닐까 한다.

아래 영상의 28:40 부근부터 제네릭에 대한 설명이 나온다.

Gophercon 2021 Day 1

이번 글에서는 영상에서 발표된 제네릭 관련 내용을 간략하게 정리하고자 한다.

문법

Go1.18의 새로운 기능

이번 제네릭을 지원하면서 이를 위한 새로운 문법이 추가되었다.

  • 제네릭에 이용할 타입 파라미터
  • 타입들을 묶는 인터페이스
  • 제네릭에서의 타입 추론

각각의 문법에 대해서 알아보자.

타입 파라미터

타입 파라미터는 아래와 같은 형식으로 되어 있다.

[식별자 제약, 식별자 제약]

식별자는 함수 혹은 구조체 내에 쓸 타입의 식별자를 의미한다. 제약은 제네릭에서 새롭게 추가된 요소로, 들어갈 타입의 범위를 제한하는 역할을 한다.

기본 라이브러리에 constraints 패키지에 추가될 예정이다. 해당 패키지에는 다양한 제약이 포함되어 있다. 영상에서는 이 중 constraints.Ordered를 예로 들었다. '<의 피연산자가 될 수 있어야 한다'는 제약으로 min, sort 등 두 수의 비교가 필요한 함수 혹은 메소드에 이용될 것이다.

타입 파라미터를 실제로 사용하면 이런 모습이다.

import "fmt"

type Tree[T interface{}] struct {
    left, right *Tree[T]
    data T
}

func (t *Tree[T]) Lookup(x T) *Tree[T] {
    ...
}

var t tree[string]

//심지어 두 타입이 연계될 수도 있다.
func Scale[S ~[]E, E constraints.Integer](s S, sc E) S {
    r := make(S, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

func min[T constraints.Ordered](x, y T) T {
    if x < y {
    	return x
    }
    return y
}

func main() {
    x := min[int](3, 2)
    fmt.Println(x)
}

 Tree 구조체의 제약에 빈 인터페이스가 나오는데 이는 바로 다음에 소개할 문법과 관련이 있다.

타입들을 묶는 인터페이스

제네릭의 도입으로 인터페이스에 새로운 용도가 생겼다. 기존 인터페이스가 함수의 묶음으로만 쓰였다면 이제 인터페이스는 타입의 묶음으로도 쓸 수 있다. 문법은 기존의 인터페이스의 것과 살짝 다르다.

type Ordered interface {
    Integer|Float|~string
}

타입을 묶는 인터페이스는 or 연산자로도 쓰이는 |를 이용해 타입을 나열한다. ~ 연산자가 새롭게 추가되었는데, ~T는 T 타입에서 파생된 모든 타입을 의미한다.(원문: ~T means the set of all types with underlying type T. ) 이렇게 타입들을 묶는 인터페이스는 위의 타입 파라미터 제약에 넣을 수 있다.

한편, 빈 인터페이스도 타입들을 묶는 인터페이스로 쓸 수 있는데 이는 기존 용법과 마찬가지로 어떤 제약도 가지지 않음을 뜻한다. 즉, 어떤 타입이 와도 상관 없음을 나타낸다. 여기서 interface{}를 대신해 사용할 수 있는 any라는 새로운 키워드가 추가되었다. 필자는 any 키워드의 추가는 키워드의 최소화를 목표로 하는 Go언어에 있어서 제네릭의 추가보다 더 이례적인 일이라고 생각한다.

제네릭에서의 타입 추론

위에서 잠깐 보았다시피 제네릭으로 정의된 구조체/함수/메소드를 이용할 때는 명시적으로 타입을 지정해 쓸 수 있다. 하지만 이렇게 호출을 하게 되면 코드가 난잡해지게 되는데 이는 Go언어의 철학과 상충한다. 이러한 문제점을 해결하기 위해  타입 추론이 등장한다.

제네릭에서의 타입 추론은 호출할 때 받은 매개변수의 타입으로 함수/메소드에 넣는 것을 말한다. 장황하게 설명했지만 함수/메소드를 호출할 때 []로 둘러싸인 부분을 생략하기만 하면 된다. 위의 min에 타입 추론을 쓰면 다음과 같은 모습이 된다.

...

func main() {
    fmt.Println(min(3,5))
    fmt.Println(min(3.1,8.9))
    fmt.Println(min("Hello", "World"))
}

주의점

제약이 없을수록 타입의 의미도 퇴색된다.

타입 파라미터를 정의할 때 되도록 제약을 풀고 싶은 욕구가 생길 것이다. 하지만 위의 사진에 나와있듯 제약이 없을수록 타입의 의미도 퇴색된다. 이런 점에서 함수를 묶는 인터페이스와 비슷한 부분이 있다. SOLID 원칙을 떠올려보자.

제네릭은 강력한 도구이지만 코드를 난잡하게 만들 위험 역시 내재하고 있다. 영상에서는 타입을 설계하지 말고 코드를 작성하라고 한다.(원문: Write code, don't design types.)

제네릭이 유용한 경우

영상에서는 다음과 같은 상황일 때 제네릭을 쓰라고 한다.

  • 타입에 관계 없는 슬라이스, 맵, 채널을 이용하는 경우
  • 일반적인 목적의 자료구조를 설계하는 경우 - C++의 STL을 떠올리면 된다.
  • 메소드/함수가 모든 데이터 타입에 똑같이 동작하는 경우

물론 위에 나열한 상황 외에도 필요한 경우가 있다고 생각한다. 필자는 기존에 type assertion을 썼거나 reflect 패키지를 썼던 부분에는 제네릭을 시도해볼만한 가치가 있다고 생각한다.

마치며...

제네릭의 부재는 Go언어의 아킬레스건으로 여겨지곤 했다. 아마 제네릭을 기다렸던 Gopher들(필자 포함)이라면 이번 발표가 매우 만족스러웠으리라 생각한다.(추가: 언어가 약간 난잡해져서 아쉽다는 의견도 있다.) 아마 기존의 기본 라이브러리에 있는 여러 패키지들(영상에서도 언급한 sort를 포함하여) 역시 제네릭을 이용하여 좀 더 유연하고 사용하기 쉽게 다듬어질 것이라 생각한다.