메서드 선언

메서드 선언 (Method declarations)

메서드는 Go에서 타입에 동작을 붙이는 핵심 수단이에요. 함수는 그냥 이름 붙은 코드 덩어리라면, 메서드는 '이 타입에 속한 함수'라는 차이를 가져요. 지금까지 함수 선언과 타입 정의를 봤다면, 이제 그 둘이 만나는 지점을 보게 될 거예요. 기본 타입(point와 같은 정의 타입)을 하나 잡고, 그 타입에 메서들 붙이는 방식으로 전체 흐름을 따라가 볼게요.

출처: Go Specification

본문

메서드는 리시버(receiver)를 가진 함수예요. 메서드 선언은 식별자 하나, 즉 메서드 이름(method name)을 메서드에 바인딩하고, 그 메서드를 리시버의 기본 타입(base type)과 연결해 줘요.

선언 문법은 이렇게 생겼어요.

MethodDecl = "func" Receiver MethodName [ TypeParameters ] Signature [ FunctionBody ] .
Receiver   = Parameters .

리시버는 메서드 이름 앞에 오는 추가적인 파라미터 섹션으로 지정돼요. 그 파라미터 섹션은 가변 인자가 아닌(non-variadic) 파라미터 하나, 곧 리시버를 선언해야 해요. 리시버의 타입은 정의된(defined) 타입 T 또는 정의 타입 T의 포인터여야 하고, 뒤에 대괄호로 감싼 타입 파라미터 이름 목록 [P1, P2, …]이 따라올 수도 있어요. 이때 T를 리시버 기본 타입이라고 불러요. 리시버 기본 타입은 포인터나 인터페이스 타입이 될 수 없고, 반드시 메서드와 같은 패키지에 선언되어 있어야 해요. 이렇게 하면 메서드는 리시버 기본 타입에 바인딩(bound)되었다고 하고, 메서드 이름은 타입 T*T에 대한 셀렉터(selectors) 안에서만 보이게 돼요.

공백이 아닌(non-blank) 리시버 식별자는 메서드 시그니처 안에서 유일(unique)해야 해요. 리시버의 값이 메서드 본문 안에서 참조되지 않는다면, 선언에서 그 식별자를 생략해도 돼요. 이 규칙은 일반적으로 함수와 메서드의 파라미터에도 그대로 적용돼요.

기본 타입에 대해서는, 그 타입에 바인딩된 메서드들의 공백이 아닌 이름이 모두 유일해야 해요. 그리고 기본 타입이 구조체 타입(struct type)이라면, 공백이 아닌 메서드 이름과 필드 이름도 서로 구분되어야 해요.

정의 타입 Point가 있다고 할 때, 아래 선언들을 한번 볼게요.

func (p *Point) Length() float64 {
	return math.Sqrt(p.x * p.x + p.y * p.y)
}

func (p *Point) Scale(factor float64) {
	p.x *= factor
	p.y *= factor
}

이 선언들은 메서드 LengthScale을 리시버 타입 *Point로 해서 기본 타입 Point에 바인딩해 줘요. 리시버가 포인터 타입(*Point)이어서 이 메서드들은 p.x, p.y처럼 필드를 직접 바꿀 수 있는 거예요.

리시버 기본 타입이 제네릭 타입(generic type)이라면, 리시버 명세가 메서드에서 쓸 타입 파라미터들을 꼭 선언해야 해요. 그래야 그 타입 파라미터들이 메서드 안에서 사용 가능해지죠. 문법적으로 이 타입 파라미터 선언은 리시버 기본 타입의 인스턴스화(instantiation)처럼 보이는데, 이때 타입 인자는 선언되는 타입 파라미터를 가리키는 식별자여야 하고, 리시버 기본 타입의 타입 파라미터마다 하나씩 필요해요. 타입 파라미터 이름이 리시버 기본 타입 정의에 나오는 해당 파라미터 이름과 일치할 필요는 없어요. 대신 모든 공백이 아닌 파라미터 이름이 리시버 파라미터 섹션과 메서드 시그니처 안에서 유일해야 해요. 리시버 타입 파라미터의 제약(제약, constraint)은 리시버 기본 타입 정의로부터 따라 나와요. 다시 말해 대응하는 타입 파라미터끼리는 대응하는 제약을 가지게 되는 거죠.

type Pair[A, B any] struct {
	a A
	b B
}

func (p Pair[A, B]) Swap() Pair[B, A]  { … }  // receiver declares A, B
func (p Pair[First, _]) First() First  { … }  // receiver declares First, corresponds to A in Pair

리시버 타입이 별칭(alias)으로 (또는 그 별칭의 포인터로) 나타내진다면, 그 별칭은 제네릭이면 안 되고, 인스턴스화된 제네릭 타입을 가리켜서도 안 돼요. 다른 별칭을 통해서든 직접적으로든, 그리고 포인터 간접 참조가 있든 없든 마찬가지예요.

type GPoint[P any] = Point
type HPoint        = *GPoint[int]
type IPair         = Pair[int, int]

func (*GPoint[P]) Draw(P)   { … }  // illegal: alias must not be generic
func (HPoint) Draw(P)       { … }  // illegal: alias must not denote instantiated type GPoint[int]
func (*IPair) Second() int  { … }  // illegal: alias must not denote instantiated type Pair[int, int]

메서드 선언이 타입 파라미터(type parameters)를 지정하면 (리시버 명세가 이미 선언한 타입 파라미터에 더해서일 수도 있어요), 그 메서드 이름은 Go 1.27부터 제네릭 메서드(generic method)를 나타내요. 제네릭 함수와 마찬가지로, 제네릭 메서드는 호출되거나 값으로 쓰이기 전에 반드시 인스턴스화(instantiated)되어야 해요.

type List[E any] []E

// Apply returns the list obtained from applying f to each element of l.
func (l List[E]) Apply[F any](f func(E) F) List[F] {
	r := make(List[F], len(l))
	for i, x := range l {
		r[i] = f(x)
	}
	return r
}

마지막 예시가 앞에서 본 내용을 한데 모은 모습이에요. List[E]라는 제네릭 타입에 메서드를 붙이면서, 리시버가 쓰는 E와 메서드만의 타입 파라미터 F를 함께 선언했죠. 리시버와 메서드 선언 사이의 타입 파라미터 흐름, 그리고 제네릭 메서드는 인스턴스화를 거쳐야 쓸 수 있다는 점, 이 두 가지만 기억하면 메서드 선언의 큰 그림은 잡힌 거예요.

더 알아보기