Go 제네릭: 깊게 들어가기
Grace Collins
Solutions Engineer · Leapcell

1. Go 제네릭 없이
제네릭 도입 이전에는 다양한 데이터 유형을 지원하는 제네릭 함수를 구현하는 데 몇 가지 접근 방식이 있었습니다.
접근 방식 1: 각 데이터 유형에 대한 함수 구현 이 접근 방식은 극도로 중복된 코드와 높은 유지 관리 비용을 초래합니다. 모든 수정 사항은 모든 함수에서 동일한 작업을 수행해야 합니다. 또한 Go 언어는 동일한 이름으로 함수 오버로딩을 지원하지 않으므로 이러한 함수를 외부 모듈 호출에 노출하는 것도 불편합니다.
접근 방식 2: 가장 큰 범위의 데이터 유형 사용
코드 중복을 피하기 위해 다른 방법은 가장 큰 범위의 데이터 유형, 즉 접근 방식 2를 사용하는 것입니다. 일반적인 예는 두 숫자 중 더 큰 값을 반환하는 math.Max
입니다. 다양한 데이터 유형의 데이터를 비교할 수 있도록 math.Max
는 Go의 숫자 유형 중 가장 큰 범위의 데이터 유형인 float64
를 입력 및 출력 매개변수로 사용하여 정밀도 손실을 방지합니다. 이것은 코드 중복 문제를 어느 정도 해결하지만 모든 유형의 데이터를 먼저 float64
유형으로 변환해야 합니다. 예를 들어 int
와 int
를 비교할 때 유형 캐스팅이 여전히 필요하며, 이는 성능을 저하시킬 뿐만 아니라 부자연스러워 보입니다.
접근 방식 3: interface{}
유형 사용
interface{}
유형을 사용하면 위의 문제를 효과적으로 해결할 수 있습니다. 그러나 interface{}
유형은 런타임에 유형 어설션 또는 유형 판단이 필요하므로 특정 런타임 오버헤드를 발생시켜 일부 성능 저하를 초래할 수 있습니다. 또한 interface{}
유형을 사용할 때 컴파일러는 정적 유형 검사를 수행할 수 없으므로 일부 유형 오류는 런타임에만 발견될 수 있습니다.
2. 제네릭의 장점
Go 1.18은 Go 언어의 오픈 소싱 이후 중요한 변화인 제네릭에 대한 지원을 도입했습니다. 제네릭은 프로그래밍 언어의 기능입니다. 프로그래머가 프로그래밍에서 실제 유형 대신 제네릭 유형을 사용할 수 있도록 합니다. 그런 다음 실제 호출 중에 명시적 전달 또는 자동 추론을 통해 제네릭 유형이 대체되어 코드 재사용의 목적을 달성합니다. 제네릭을 사용하는 과정에서 작동할 데이터 유형이 매개변수로 지정됩니다. 이러한 매개변수 유형은 각각 클래스, 인터페이스 및 메서드에서 제네릭 클래스, 제네릭 인터페이스 및 제네릭 메서드라고 합니다. 제네릭의 주요 장점은 코드 재사용성과 유형 안전성을 향상시키는 것입니다. 기존 정식 매개변수와 비교할 때 제네릭은 범용 코드 작성을 더 간결하고 유연하게 만들어 다양한 유형의 데이터를 처리하는 기능을 제공하고 Go 언어의 표현력과 재사용성을 더욱 향상시킵니다. 동시에 제네릭의 특정 유형은 컴파일 시간에 결정되므로 유형 검사를 제공하여 유형 변환 오류를 방지할 수 있습니다.
3. 제네릭과 interface{}
의 차이점
Go 언어에서 interface{}
와 제네릭은 모두 여러 데이터 유형을 처리하기 위한 도구입니다. 그 차이점을 논의하기 위해 먼저 interface{}
와 제네릭의 구현 원리를 살펴봅시다.
3.1 interface{}
구현 원리
interface{}
는 인터페이스 유형에 메서드가 없는 빈 인터페이스입니다. 모든 유형이 interface{}
를 구현하므로 모든 유형을 수용할 수 있는 함수, 메서드 또는 데이터 구조를 만드는 데 사용할 수 있습니다. 런타임 시 interface{}
의 기본 구조는 아래와 같이 표시된 eface
로 표시되며 주로 두 개의 필드 _type
과 data
를 포함합니다.
type eface struct { _type *_type data unsafe.Pointer } type type struct { Size uintptr PtrBytes uintptr // number of (prefix) bytes in the type that can contain pointers Hash uint32 // hash of type; avoids computation in hash tables TFlag TFlag // extra type information flags Align_ uint8 // alignment of variable with this type FieldAlign_ uint8 // alignment of struct field with this type Kind_ uint8 // enumeration for C // function for comparing objects of this type // (ptr to object A, ptr to object B) -> ==? Equal func(unsafe.Pointer, unsafe.Pointer) bool // GCData stores the GC type data for the garbage collector. // If the KindGCProg bit is set in kind, GCData is a GC program. // Otherwise it is a ptrmask bitmap. See mbitmap.go for details. GCData *byte Str NameOff // string form PtrToThis TypeOff // type for pointer to this type, may be zero }
_type
은 _type
구조체에 대한 포인터이며 실제 값의 크기, 종류, 해시 함수 및 문자열 표현과 같은 정보를 포함합니다. data
는 실제 데이터에 대한 포인터입니다. 실제 데이터의 크기가 포인터 크기보다 작거나 같으면 데이터가 data
필드에 직접 저장됩니다. 그렇지 않으면 data
필드에 실제 데이터에 대한 포인터가 저장됩니다.
특정 유형의 객체가 interface{}
유형의 변수에 할당되면 Go 언어는 암시적으로 eface
의 박싱 작업을 수행하여 _type
필드를 값의 유형으로 설정하고 data
필드를 값의 데이
터로 설정합니다. 예를 들어 var i interface{} = 123
문이 실행되면 Go는 eface
구조체를 만들고, 여기서 _type
필드는 int
유형을 나타내고 data
필드는 값 123을 나타냅니다.
interface{}
에서 저장된 값을 검색할 때 언박싱 프로세스가 발생합니다. 즉, 유형 어설션 또는 유형 판단입니다. 이 프로세스에서는 예상 유형을 명시적으로 지정해야 합니다. interface{}
에 저장된 값의 유형이 예상 유형과 일치하면 유형 어설션이 성공하고 값을 검색할 수 있습니다. 그렇지 않으면 유형 어설션이 실패하고 이 상황에 대한 추가 처리가 필요합니다.
var i interface{} = "hello" s, ok := i.(string) if ok { fmt.Println(s) // Output "hello" } else { fmt.Println("not a string") }
interface{}
는 런타임 시 박싱 및 언박싱 작업을 통해 여러 데이터 유형에 대한 작업을 지원한다는 것을 알 수 있습니다.
3.2 제네릭 구현 원리
Go 핵심 팀은 Go 제네릭의 구현 체계를 평가할 때 매우 신중했습니다. 총 3개의 구현 체계가 제출되었습니다.
- 스텐실링 체계
- 딕셔너리 체계
- GC Shape 스텐실링 체계
스텐실링 체계는 C++ 및 Rust와 같은 언어가 제네릭을 구현하기 위해 채택한 구현 체계이기도 합니다. 이 체계의 구현 원리는 컴파일 기간 동안 제네릭 함수가 호출되거나 제약 조건의 유형 요소가 제네릭 함수의 각 유형 인수에 대한 별도의 구현을 생성하여 유형 안전성과 최적의 성능을 보장하는 경우의 특정 유형 매개변수에 따릅니다. 그러나 이 방법은 컴파일러 속도를 저하시킵니다. 호출되는 데이터 유형이 많으면 제네릭 함수가 각 데이터 유형에 대해 독립적인 함수를 생성해야 하므로 컴파일된 파일이 매우 커질 수 있기 때문입니다. 동시에 CPU 캐시 미스와 명령어 분기 예측과 같은 문제로 인해 생성된 코드가 효율적으로 실행되지 않을 수 있습니다.
딕셔너리 체계는 제네릭 함수에 대해 하나의 함수 논리만 생성하지만 함수에 dict
매개변수를 첫 번째 매개변수로 추가합니다. dict
매개변수는 제네릭 함수가 호출될 때 유형 인수의 유형 관련 정보를 저장하고 함수 호출 중에 AX 레지스터(AMD)를 사용하여 딕셔너리 정보를 전달합니다. 이 체계의 장점은 컴파일 단계 오버헤드를 줄이고 바이너리 파일의 크기를 늘리지 않는다는 것입니다. 그러나 런타임 오버헤드를 늘리고 컴파일 단계에서 함수 최적화를 수행할 수 없으며 딕셔너리 재귀와 같은 문제가 있습니다.
type Op interface{ int|float } func Add[T Op](m, n T) T { return m + n } // After generation => const dict = map[type] typeInfo{ int : intInfo{ newFunc, lessFucn, //...... }, float : floatInfo } func Add(dict[T], m, n T) T{}
Go는 마침내 위의 두 체계를 통합하고 제네릭 구현에 대한 GC Shape 스텐실링 체계를 제안했습니다. 유형의 GC Shape 단위로 함수 코드를 생성합니다. 동일한 GC Shape를 가진 유형은 동일한 코드를 재사용합니다(유형의 GC Shape는 Go 메모리 할당자/가비지 수집기에서 해당 유형의 표현을 나타냅니다). 모든 포인터 유형은 *uint8
유형을 재사용합니다. 동일한 GC Shape를 가진 유형의 경우 공유 인스턴스화된 함수 코드가 사용됩니다. 이 체계는 또한 동일한 GC Shape를 가진 다른 유형을 구별하기 위해 각 인스턴스화된 함수 코드에 dict
매개변수를 자동으로 추가합니다.
type V interface{ int|float|*int|*float } func F[T V](m, n T) {} // 1. Generate templates for regular types int/float func F[go.shape.int_0](m, n int){} func F[go.shape.float_0](m, n int){} // 2. Pointer types reuse the same template func F[go.shape.*uint8_0](m, n int){} // 3. Add dictionary passing during the call const dict = map[type] typeInfo{ int : intInfo{}, float : floatInfo{} } func F[go.shape.int_0](dict[int],m, n int){}
3.3 차이점
interface{}
와 제네릭의 기본 구현 원리에서 우리는 그들 사이의 주요 차이점이 interface{}
가 런타임 중에 다른 데이터 유형 처리를 지원하는 반면 제네릭은 컴파일 단계에서 정적으로 다른 데이터 유형 처리를 지원한다는 것을 알 수 있습니다. 실제 사용에는 주로 다음과 같은 차이점이 있습니다.
(1) 성능 차이: 다른 유형의 데이터가 interface{}
에 할당되거나 검색될 때 수행되는 박싱 및 언박싱 작업은 비용이 많이 들고 추가 오버헤드를 발생시킵니다. 대조적으로 제네릭은 박싱 및 언박싱 작업이 필요하지 않으며 제네릭에 의해 생성된 코드는 특정 유형에 대해 최적화되어 런타임 성능 오버헤드를 피합니다.
(2) 유형 안전성: interface{}
유형을 사용할 때 컴파일러는 정적 유형 검사를 수행할 수 없으며 런타임에만 유형 어설션을 수행할 수 있습니다. 따라서 일부 유형 오류는 런타임에만 발견될 수 있습니다. 대조적으로 Go의 제네릭 코드는 컴파일 시간에 생성되므로 제네릭 코드는 컴파일 시간에 유형 정보를 얻어 유형 안전성을 보장할 수 있습니다.
4. 제네릭 시나리오
4.1 적용 가능한 시나리오
- 일반 데이터 구조를 구현할 때: 제네릭을 사용하면 코드를 한 번 작성하고 다른 데이터 유형에서 재사용할 수 있습니다. 이렇게 하면 코드 중복이 줄어들고 코드 유지 관리성 및 확장성이 향상됩니다.
- Go에서 기본 컨테이너 유형을 작동할 때: 함수가 슬라이스, 맵 또는 채널과 같은 Go 기본 제공 컨테이너 유형의 매개변수를 사용하고 함수 코드가 컨테이너의 element 유형에 대해 특정 가정을 하지 않는 경우 제네릭을 사용하면 컨테이너 알고리즘을 컨테이너의 element 유형에서 완전히 분리할 수 있습니다. 제네릭 구문을 사용할 수 있기 전에 reflection은 일반적으로 구현에 사용되었지만 reflection은 코드를 읽기 어렵게 만들고 정적 유형 검사를 수행할 수 없으며 프로그램의 런타임 오버헤드를 크게 증가시킵니다.
- 다른 데이터 유형에 대해 구현된 메서드의 논리가 동일한 경우: 다른 데이터 유형의 메서드가 동일한 함수 논리를 가지고 유일한 차이점은 입력 매개변수의 데이터 유형인 경우 제네릭을 사용하여 코드 중복을 줄일 수 있습니다.
4.2 적용 불가능한 시나리오
- 인터페이스 유형을 type 매개변수로 바꾸지 마십시오: 인터페이스는 특정 의미에서 제네릭 프로그래밍을 지원합니다. 특정 유형 변수에 대한 작업이 해당 유형의 메서드만 호출하는 경우 제네릭을 사용하지 않고 인터페이스 유형을 직접 사용하십시오. 예를 들어
io.Reader
는 인터페이스를 사용하여 파일 및 난수 생성기에서 다양한 유형의 데이터를 읽습니다.io.Reader
는 코드 관점에서 읽기 쉽고 효율성이 높으며 함수 실행 효율성에 거의 차이가 없으므로 type 매개변수를 사용할 필요가 없습니다. - 다른 데이터 유형에 대한 메서드의 구현 세부 정보가 다른 경우: 각 유형에 대한 메서드 구현이 다른 경우 제네릭 대신 인터페이스 유형을 사용해야 합니다.
- 강력한 런타임 동적 특성이 있는 시나리오에서: 예를 들어
switch
를 사용하여 type 판단을 수행하는 시나리오에서는interface{}
를 직접 사용하면 더 나은 결과를 얻을 수 있습니다.
5. 제네릭의 함정
5.1 nil
비교
Go 언어에서는 type 매개변수가 컴파일 시간에 type 검사를 받는 반면 nil
은 런타임 시 특수한 값이기 때문에 type 매개변수를 nil
과 직접 비교할 수 없습니다. type 매개변수의 기본 유형을 컴파일 시간에 알 수 없으므로 컴파일러는 type 매개변수의 기본 유형이 nil
과의 비교를 지원하는지 여부를 확인할 수 없습니다. 따라서 type 안전성을 유지하고 잠재적인 런타임 오류를 피하기 위해 Go 언어는 type 매개변수와 nil
간의 직접 비교를 허용하지 않습니다.
// 잘못된 예 func ZeroValue0[T any](v T) bool { return v == nil } // 올바른 예 1 func Zero1[T any]() T { return *new(T) } // 올바른 예 2 func Zero2[T any]() T { var t T return t } // 올바른 예 3 func Zero3[T any]() (t T) { return }
5.2 잘못된 기본 element
기본 element의 type T
는 기본 type이어야 하며 인터페이스 type일 수 없습니다.
// 잘못된 정의! type MyInt int type I0 interface { ~MyInt // 잘못됨! MyInt는 기본 type이 아니고 int는 기본 type입니다. ~error // 잘못됨! error는 인터페이스입니다. }
5.3 잘못된 Union type element
Union type element는 type 매개변수가 될 수 없으며 인터페이스가 아닌 element는 쌍별로 분리되어야 합니다. element가 두 개 이상인 경우 비어 있지 않은 메서드가 있는 인터페이스 type을 포함할 수 없으며 comparable
이거나 comparable
을 포함할 수 없습니다.
func I1[K any, V interface{ K }]() { // 잘못됨, interface{ K }의 K는 type 매개변수입니다. } type MyInt int func I5[K any, V interface{ int | MyInt }]() { // 올바름 } func I6[K any, V interface{ int | ~MyInt }]() { // 잘못됨! int와 ~MyInt의 교차점은 int입니다. } type MyInt2 = int func I7[K any, V interface{ int | MyInt2 }]() { // 잘못됨! int와 MyInt2는 동일한 type이며 교차합니다. } // 잘못됨! Union element가 두 개 이상 있고 comparable할 수 없기 때문입니다. func I13[K comparable | int]() { } // 잘못됨! Union element가 두 개 이상 있고 element가 comparable을 포함할 수 없기 때문입니다. func I14[K interface{ comparable } | int]() { }
5.4 인터페이스 type을 재귀적으로 포함할 수 없습니다.
// 잘못됨! 자체적으로 포함할 수 없습니다. type Node interface { Node } // 잘못됨! Tree는 TreeNode를 통해 자체적으로 포함할 수 없습니다. type Tree interface { TreeNode } type TreeNode interface { Tree }
6. 모범 사례
제네릭을 잘 사용하려면 사용 중에 다음 사항에 유의해야 합니다.
- 과도한 일반화는 피하십시오.
제네릭은 모든 시나리오에 적합하지 않으며 어떤 시나리오에 적합한지 신중하게 고려해야 합니다. reflection은 적절한 경우 사용할 수 있습니다. Go에는 런타임 reflection이 있습니다. Reflection 메커니즘은 특정 의미에서 제네릭 프로그래밍을 지원합니다. 특정 작업이 다음 시나리오를 지원해야 하는 경우 reflection을 고려할 수 있습니다.
(1) 메서드가 없는 type에서 작동하는 경우 인터페이스 type을 적용할 수 없습니다.
(2) 각 type에 대한 작업 논리가 다른 경우 제네릭을 적용할 수 없습니다. 예는
encoding/json
패키지의 구현입니다. 인코딩할 각 type이MarshalJson
메서드를 구현하는 것이 바람직하지 않으므로 인터페이스 type을 사용할 수 없습니다. 또한 다른 type에 대한 인코딩 논리가 다르기 때문에 제네릭을 사용해서는 안 됩니다. T
가 포인터 type, 슬라이스 또는 맵을 나타내지 않도록*T
,[]T
및map[T1]T2
를 명확하게 사용하십시오. C++의 type 매개변수가 자리 표시자이고 실제 type으로 대체된다는 사실과 달리 Go의 type 매개변수T
의 type은 type 매개변수 자체입니다. 따라서 포인터, 슬라이스, 맵 및 기타 데이터 type으로 표현하면 아래와 같이 사용하는 동안 많은 예기치 않은 상황이 발생합니다.
func Set[T *int|*uint](ptr T) { *ptr = 1 } func main() { i := 0 Set(&i) fmt.Println(i) // 오류 보고: 잘못된 작업 }
위의 코드는 오류: ptr의 포인터(type *int | *uint로 제한된 T type 변수)는 동일한 기본 type을 가져야 합니다.
라는 오류를 보고합니다. 이 오류의 이유는 T
가 type 매개변수이고 type 매개변수는 포인터가 아니며 역참조 작업을 지원하지 않기 때문입니다. 이는 정의를 다음과 같이 변경하여 해결할 수 있습니다.
func Set[T int|uint](ptr *T) { *ptr = 1 }
요약
전반적으로 제네릭의 장점은 세 가지 측면으로 요약할 수 있습니다.
- type은 컴파일 기간에 결정되어 type 안전성을 보장합니다. 넣은 것이 꺼낸 것입니다.
- 가독성이 향상되었습니다. 실제 데이터 type은 코딩 단계에서 명시적으로 알려져 있습니다.
- 제네릭은 동일한 type에 대한 처리 코드를 병합하여 코드 재사용률을 높이고 프로그램의 전반적인 유연성을 높입니다. 그러나 제네릭은 일반 데이터 type에 필수적인 것은 아닙니다. 실제 사용 상황에 따라 제네릭 사용 여부를 신중하게 고려해야 합니다.
Leapcell: Go 웹 호스팅, 비동기 작업 및 Redis를 위한 고급 플랫폼
마지막으로 Go 서비스를 배포하는 데 가장 적합한 플랫폼인 Leapcell을 소개합니다.
1. 다국어 지원
- JavaScript, Python, Go 또는 Rust로 개발하십시오.
2. 무료로 무제한 프로젝트 배포
- 사용량에 대해서만 지불하십시오. 요청도 없고 요금도 없습니다.
3. 최고의 비용 효율성
- 유휴 요금 없이 사용량에 따라 지불하십시오.
- 예: $25는 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
4. 간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
5. 손쉬운 확장성 및 고성능
- 고도의 동시성을 쉽게 처리할 수 있도록 자동 확장합니다.
- 제로 운영 오버헤드. 구축에만 집중하십시오.
문서!에서 자세히 알아보십시오.
Leapcell 트위터: https://x.com/LeapcellHQ