03. 메서드와 인터페이스
타입에 동작을 정의하는 방법과, 구현에 묶이지 않는 코드를 쓰는 방법을 정리합니다.
메서드
함수 이름 앞에 받는 타입을 적으면 메서드가 됩니다.
type Todo struct {
ID int64
Title string
Done bool
}
func (t Todo) Label() string {
if t.Done {
return "[x] " + t.Title
}
return "[ ] " + t.Title
}
todo := Todo{Title: "공부"}
todo.Label() // "[ ] 공부"구조체뿐 아니라 직접 정의한 타입이면 무엇이든 붙일 수 있습니다.
type Status int
func (s Status) String() string {
switch s {
case Todo:
return "할 일"
case Done:
return "완료"
}
return "알 수 없음"
}String() 메서드가 있으면 fmt가 출력할 때 자동으로 씁니다.
값·포인터 리시버
리시버를 값으로 받으면 복사본이, 포인터로 받으면 원본이 전달됩니다.
func (t Todo) CompleteValue() {
t.Done = true // 복사본만 바뀝니다
}
func (t *Todo) Complete() {
t.Done = true // 원본이 바뀝니다
}
todo := Todo{}
todo.CompleteValue()
todo.Done // false
todo.Complete()
todo.Done // truetodo.Complete()처럼 값에 포인터 메서드를 불러도 동작합니다. 컴파일러가 (&todo).Complete()로 바꿔 줍니다. 다만 주소를 얻을 수 있는 값이어야 합니다.
todos := map[string]Todo{"a": {}}
todos["a"].Complete() // 컴파일 오류 — 맵의 값은 주소를 얻을 수 없습니다고르는 기준은 단순합니다.
- 상태를 바꾸는 메서드가 하나라도 있으면 전부 포인터 리시버로 통일합니다
- 구조체가 크면 복사를 피하려고 포인터를 씁니다
- 작고 바뀌지 않는 값 타입이면 값 리시버가 자연스럽습니다
섞어 쓰면 어떤 것이 원본을 바꾸는지 읽는 사람이 매번 확인해야 합니다.
인터페이스
메서드 묶음만 정의합니다. 구현하겠다고 선언하지 않아도, 메서드를 갖추면 자동으로 만족합니다.
type Storage interface {
Load() ([]Todo, error)
Save(todos []Todo) error
}
type JSONStorage struct {
Path string
}
func (s JSONStorage) Load() ([]Todo, error) { ... }
func (s JSONStorage) Save(todos []Todo) error { ... }
// implements 같은 키워드가 없습니다쓰는 쪽은 인터페이스를 받습니다. 저장 방식이 바뀌어도 이 코드는 그대로입니다.
type Service struct {
storage Storage
}
func NewService(storage Storage) *Service {
return &Service{storage: storage}
}
NewService(JSONStorage{Path: "todos.json"})
NewService(MemoryStorage{}) // 테스트에서는 이쪽인터페이스는 쓰는 쪽에 두는 것이 Go의 관례입니다. 구현하는 패키지가 아니라 필요로 하는 패키지에 선언합니다. 그래야 필요한 메서드만 작게 정의할 수 있습니다.
작을수록 좋습니다. 표준 라이브러리의 인터페이스는 대부분 메서드가 하나입니다.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Stringer interface {
String() string
}any는 모든 타입을 받습니다. 꺼낼 때는 타입 단언으로 확인합니다.
func describe(value any) string {
switch v := value.(type) {
case string:
return "문자열: " + v
case int:
return fmt.Sprintf("정수: %d", v)
default:
return "알 수 없음"
}
}
text, ok := value.(string) // ok로 받으면 실패해도 패닉이 나지 않습니다인터페이스 값은 타입과 값 두 가지를 들고 있습니다. 그래서 nil 포인터를 담은 인터페이스는 nil이 아닙니다.
var p *JSONStorage = nil
var s Storage = p
s == nil // false ← 타입 정보가 들어 있습니다오류를 돌려줄 때 자주 부딪히는 함정입니다. error를 반환할 때는 구체 타입 변수를 거치지 말고 nil을 그대로 반환합니다.
합성과 임베딩
Go에는 상속이 없습니다. 필요한 것을 필드로 가집니다.
type Service struct {
storage Storage
logger *slog.Logger
}이름 없이 타입만 적으면 임베딩이 됩니다. 안쪽 타입의 메서드가 바깥으로 올라옵니다.
type Base struct {
ID int64
}
func (b Base) Describe() string {
return fmt.Sprintf("#%d", b.ID)
}
type Todo struct {
Base // 임베딩
Title string
}
todo := Todo{Base: Base{ID: 1}, Title: "공부"}
todo.ID // 1 — todo.Base.ID 를 줄여 쓴 것입니다
todo.Describe() // "#1"상속처럼 보이지만 다릅니다. Todo는 Base가 아닙니다. Base를 받는 함수에 Todo를 넘길 수 없습니다.
func print(b Base) { }
print(todo) // 컴파일 오류
print(todo.Base) // 이렇게 넘깁니다인터페이스도 임베딩할 수 있습니다.
type ReadWriter interface {
Reader
Writer
}구조체에 인터페이스를 임베딩하면, 일부 메서드만 덮어쓰는 구현을 만들 수 있습니다.
type LoggingStorage struct {
Storage // 나머지는 그대로 위임됩니다
logger *slog.Logger
}
func (s LoggingStorage) Save(todos []Todo) error {
s.logger.Info("저장", "count", len(todos))
return s.Storage.Save(todos)
}제네릭
Go 1.18부터 타입 매개변수를 쓸 수 있습니다.
func Map[T, R any](list []T, fn func(T) R) []R {
result := make([]R, 0, len(list))
for _, item := range list {
result = append(result, fn(item))
}
return result
}
titles := Map(todos, func(t Todo) string { return t.Title })제약은 인터페이스로 적습니다.
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](list []T) T {
var total T
for _, item := range list {
total += item
}
return total
}~int는 "기본 타입이 int인 모든 타입"입니다. type Status int처럼 정의한 타입도 포함됩니다.
자주 쓰는 제약은 표준으로 제공됩니다.
import "cmp"
func Max[T cmp.Ordered](list []T) T { ... }타입 매개변수는 함수와 타입에 쓸 수 있지만 메서드에는 쓸 수 없습니다.
func (s Service) Map[T any](...) { } // 컴파일 오류제네릭이 생겼다고 모든 곳에 쓰지는 않습니다. 인터페이스로 충분하면 인터페이스가 읽기 쉽습니다. 여러 타입에 대해 똑같은 코드를 반복하게 될 때가 쓸 자리입니다.