Language/Golang

Golang (Go언어) 제네릭 프로그래밍(Generic Programming) 3/3

HeeWorld 2025. 1. 1. 16:54

Tucker의 Go 언어 프로그래밍 책과 유튜브를 통해 학습 중입니다.

Golang 마스코트 Gopher(고퍼)

 

제네릭 타입

- 타입을 파라미터를 이용해서 여러 타입에 동작하는 새로운 타입을 만들 수 있음.

type Node[T any] struct {
	val T
    next *Node[T]
}

 

- Node 구조체는 타입 파라미터를 사용해서 val 필드 타입이 어떤 타입이든 가능하도록 정의함.

 

package main

import "fmt"

type Node[T any] struct {
	val  T
	next *Node[T]
}

func NewNode[T any](v T) *Node[T] {	// T타입의 val 필드값을 갖는 객체 생성
	return &Node[T]{val: v}
}

func (n *Node[T]) Push(v T) *Node[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.Print(node1.val, " - ")
		node1 = node1.next
	}
	fmt.Println()

	node2 := NewNode("Hi")	// 객체 생성	*Node[string]
	node2.Push("Hello").Push("How are you")

	for node2 != nil {
		fmt.Print(node2.val, " - ")
		node2 = node2.next
	}
	fmt.Println()
}


// 결과

1 - 2 - 3 - 4 - 
Hi - Hello - How are you -

 

- 제네릭 함수를 사용해 T 타입의 val 빌드 값을 갖는 Node 객체를 생성함.

 

- Node[T] 타입의 메서드를 정의하고, 이렇게 제네릭 타입은 타입명 뒤에 [T]와 같이 붙여 메서드를 추가할 수 있음.

tpye Node[T1 any, T2 any] strucy {
	val1 T1
	val1 T2
	next *Node[T]
}

func (n *Node[T1, T2] Push(val1 T1, val2 T2) *Node[T1, T2] { ... }

 

- 위와 같이 타입 파라미터가 두 개이면 Node[T1, T2]와 같이 필요한 타입 파라미터를 모두 적어서 메서드를 정의함.

 

- NewNode(1)로 새로운 Node 객체를 생성함. 1은 int 타입이므로 생성된 node1 변수 타입은 *Node[int]가 됨.

   따라서 node1 변수의 val 필드 타입은 int가 됨.

- NewNode("Hi")로 새로운 Node 객체를 생성함. "Hi"가 string 타입이므로 이때 생성된 Node2 변수 타입은 *Node[string]이 됨.

   따라서 node2 변수의 val 필드 타입은 string이 됨.

 

 

※ 제네릭 타입 선언할 때 제네릭 타입 파라미터를 넣을 수 있는데, 메서드에는 타입 파라미터를 넣을 수 없음.

package main

import "fmt"

type Node[T any] struct {
	val  T
	next *Node[T]
}

func (n *Node) Push[T any](a T) {
}

 

- 메서드로 Push를 사용하여 위 처럼 사용하는 타입 파라미터를 받는 메서드는 사용할 수 없음! (지원하지 않음)

- 함수는 타입 파라미터를 넣을 수 있음.

 

→ 그러나, 타입 선언할 때는 타입 파라미터를 넣을 수 있음.

 

 

성능차이

- 타입 파라미터를 사용할 때와 인터페이스를 사용할 때는 성능 차이가 발생함.

var v1 int = 3
var v2 interface{} = v1	//boxing
var v3 int = v2.(int)	//unboxing

 

- 기본 타입 값을 빈 인터페이스 변수에 대입할 때 Go에서는 빈 인터페이스를 만들어 기본 타입값을 가리키도록 함.

   = 박스에 넣는 것과 같다고 하여 이를 박싱(Boxing)이라고 함.

      박싱할 때 빈 인터페이스 객체를 사용하게 됨.

- 다시 값을 꺼낼 때는 박스에서 꺼내는 것과 같다고 하여 언박싱(Unboxing)이라고 함.

 

package main

import "fmt"

func main() {
	var v1 int = 3
	var v2 interface{} = &v1  // boxing
	var v3 int = *(v2.(*int)) // unboxing

	fmt.Printf("v1: %x %T\n", &v1, &v1)
	fmt.Printf("v2: %x %T\n", &v2, &v2)
	fmt.Printf("v3: %x %T\n", &v3, &v3)
}


// 결과

v1: c00009a040 *int
v2: c00009c050 *interface {}
v3: c00009a048 *int

 

- 예제와 같이 박싱을 한 v2와 v1이 서로 다른 주소값을 가지고 있음 = 서로 다른 객체

 

✓ 박싱한다는 것은 빈 인터페이스 박스 안에 실젯값인 int 타입 값인 3을 넣는다고 볼 수 있음.

-  값을 감싸는 박스 객체를 만들어야 함.

    이 값을 감싸는 빈 인터페이스 박스는 크기가 매우 작기 때문에 성능상 큰 문제가 되지 않지만 많아지면, 박싱/언박싱을 위해

    임시로 사용되는 박스 수가 늘어나기 때문에 문제가 될 수 있음.

 

→ 제네릭 프로그래밍을 사용하면, 타입 파리머터에 의해 타입이 고정되기 때문에 박싱/언박싱이 필요 없어짐.

    그렇다고, 제네릭을 사용하는게 무조건적인 이득은 아님!

 

func add[T constraints.Integer | constraints.Float](a, b T) T {
	return a + b
}

add(1, 3)
add(3.14, 1.43)

 

- 위 예제에서 add(1, 3)과 add(3.14, 1.43)은 하나의 함수를 서로 다른 타입으로 두 번 호출한 것 처럼 보이지만 그렇지 않음.

→ add(1,3)은 add[int](1,3)을 호출한 것이고, add(3.14, 1.43)은 add[float64](3.14, 1.43) 함수를 호출한 것.

 

- 제네릭 함수나 타입의 경우 하나의 함수나 타입처럼 보이지만, 실제 컴파일 타임에 사용한 타입 파라미터별로 새로운 함수나 타입을 생성해서 사용하게 됨.\

 

→ 제네릭 프로그래밍을 많이 사용하면, 컴파일 타임에 생성해야 할 함수와 타입 개수가 늘어나고 컴파일 시간도 더 걸림.

    또한, 코드양이 증가되어 실행 파일 크기가 늘어나게 됨.

    실행 파일 크기는 일반적인 프로그램에서는 문제가 되지 않지만 용량의 제한이 있는 임베디드 프로그램에서는 문제가 될 수 있음.