포스트

타입 ② struct·method·interface — 클래스 없이 사고하기

Go의 struct·method·interface를 클래스 상속의 대체물로 단순화하지 않고, 암묵적 interface 만족·embedding·method set을 각각 다른 규칙으로 정리한다.

타입 ② struct·method·interface — 클래스 없이 사고하기

Go 학습 로드맵② 타입(뼈대) 단계다. 앞 글: ① 문법·패키지·모듈

Go에는 클래스 상속 계층이 없다. 대신 struct, method, interface, embedding을 조합해 타입을 설계한다. C++의 클래스와 비교하면 출발점은 잡기 쉽지만, 상속을 다른 문법으로 옮긴 것이라고 이해하면 금방 어긋난다.

struct와 method

데이터는 보통 struct로 표현하고, 해당 타입에 동작을 연결하려면 receiver가 있는 method를 정의한다.

1
2
3
4
5
6
7
type Rect struct {
    Width, Height float64
}

func (r Rect) Area() float64 {
    return r.Width * r.Height
}

(r Rect)가 receiver다. C++의 암묵적 this를 떠올리면 호출 형태는 익숙하지만, Go method는 클래스 본문 안에 선언하는 구조가 아니다.

단, receiver의 base type은 같은 패키지에서 정의한 non-pointer type이어야 한다. 아무 외부 타입에나 임의로 method를 추가할 수 있는 것은 아니다.

interface — 구현 선언 없이 만족한다

1
2
3
4
5
6
7
8
9
10
11
type Shape interface {
    Area() float64
}

type Rect struct {
    Width, Height float64
}

func (r Rect) Area() float64 {
    return r.Width * r.Height
}

Rect의 method set이 Shape가 요구하는 메서드를 포함하면 Rect는 별도의 implements Shape 선언 없이 interface를 만족한다.

이를 구조적 interface 만족이라고 이해하면 된다. 런타임 duck typing과 결과가 비슷해 보일 수 있지만, Go compiler는 interface assignment와 호출이 유효한지 정적으로 검사한다.

1
2
3
4
5
덕 타이핑
→ 보통 런타임에 "이 메서드가 있나"를 확인하는 동적 모델을 떠올림

Go interface 만족
→ method set을 컴파일 타임에 검사

따라서 Go interface = duck typing이라고 그대로 등치하지 않는다.

Go의 중요한 장점은 구현 타입이 interface를 미리 알 필요가 없다는 것이다. 사용하는 패키지가 자신에게 필요한 작은 interface를 정의할 수 있다.

embedding — 조합과 승격

1
2
3
4
5
6
7
8
9
10
11
12
type Animal struct {
    Name string
}

func (a Animal) Speak() string {
    return a.Name + " makes a sound"
}

type Dog struct {
    Animal
    Breed string
}

embedded field의 field와 method는 selector에서 승격(promote)되어 d.Name, d.Speak()처럼 사용할 수 있다.

하지만 DogAnimal의 subtype이 되는 것은 아니다.

1
2
3
4
5
C++ inheritance
→ 명목적 타입 관계 + 멤버 상속 + virtual dispatch 등이 함께 엮일 수 있음

Go embedding
→ 필드 포함 + selector promotion

다형성이 필요하면 별도의 interface 계약을 사용한다. 따라서 embedding은 상속의 완전한 대체 문법이 아니라 조합을 편하게 만드는 기능이다.

method set — 값과 포인터 타입의 계약

receiver를 값 또는 포인터로 정의할 수 있다.

1
2
func (r Rect) Area() float64 { ... }
func (r *Rect) Scale(f float64) { ... }

method set의 핵심 규칙은 다음과 같다.

메서드 receiverRect의 method set*Rect의 method set
(r Rect)포함포함
(r *Rect)미포함포함

그래서 다음 interface는 *Rect만 만족한다.

1
2
3
4
5
6
7
type Scaler interface {
    Scale(float64)
}

var s Scaler
s = &Rect{Width: 3, Height: 4} // OK
// s = Rect{Width: 3, Height: 4} // compile error

여기서 주의할 점은 일반 method 호출 시 compiler가 addressable value에 대해 자동으로 &/*를 보정해 주는 경우interface method set 만족 규칙을 섞지 않는 것이다.

1
2
r := Rect{3, 4}
r.Scale(2) // r이 addressable하므로 호출 자체는 허용될 수 있음

그러나 Rect 값이 Scaler interface를 만족하는 것은 아니다.

값 receiver와 포인터 receiver를 고르는 기준

“수정하면 pointer, 읽기만 하면 value”는 출발점일 뿐 절대 규칙은 아니다.

포인터 receiver를 고려할 대표 이유:

  • receiver 상태를 수정해야 함
  • struct가 커서 복사를 피하고 싶음
  • 타입의 method set과 의미를 pointer 기준으로 일관되게 유지하고 싶음

값 receiver가 자연스러운 경우:

  • 작은 immutable-like value type
  • 복사 자체가 의미적으로 자연스러움

한 타입에서 두 receiver 형태를 섞지 말라는 식의 절대 규칙보다 타입의 의미와 method set 일관성을 우선한다.

any와 interface value

anyinterface{}의 alias다. 모든 타입이 empty interface를 만족하므로 어떤 값도 담을 수 있다.

1
2
3
4
5
6
var x any = "hello"

s, ok := x.(string)
if ok {
    fmt.Println(s)
}

C++ std::any를 떠올리면 “구체 타입을 지운 값 컨테이너”라는 직관은 잡기 쉽다. 하지만 Go interface value와 C++ std::any, void*는 타입 표현과 메서드 디스패치 의미가 다르므로 같은 타입으로 등치하지 않는다.

가능하면 any보다 필요한 메서드만 가진 작은 interface를 선호한다. 다만 JSON처럼 입력 타입이 본질적으로 동적인 경계에서는 any가 자연스러울 수 있다.

C++을 발판으로 비교

C++에서 떠올릴 것Go경계
member functionreceiver method호출 직관은 비슷하지만 클래스 본문 구조가 아님
abstract base classinterface공통 계약이라는 목적은 비슷하지만 Go는 암묵적·구조적 만족
compositionembedding포함 관계는 비슷하지만 selector promotion이 추가됨
std::anyany구체 타입을 추상적으로 담는 용도는 비슷하지만 interface dispatch 의미는 다름

통과 기준

  • interface 만족 여부가 선언이 아니라 method set으로 결정된다는 것을 설명할 수 있다.
  • Rect*Rect의 method set 차이를 설명할 수 있다.
  • embedding을 inheritance와 같은 타입 관계라고 오해하지 않는다.

다음은 ③ slice·map·string이다.

Reference

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.