7장 타입, 메서드, 인터페이스

2024.03.19

7.1 Go의 타입

type Person struct {
    FirstName string
    LastName string
    Age int
}
type Score int
type Converter func(string)Score
type TeamScores map[string]Score
  • 구조체 리터럴 외에도 기본 타입 또는 복합 타입 리터럴을 사용하여 구체적인 타입을 정의할 수 있다.
  • Go는 패키지 블록에서부터 모든 블록 레벨에서도 타입을 선언할 수 있다. 하지만 타입은 해당 범위 내에서만 접근이 가능하다.

7.2 메서드

  • 타입을 위한 메서드는 패키지 블록 레벨에서 정의된다.
type Person struct {
    FirstName string
    LastName string
    Age int
}

func (p Person) String() string {
    return fmt.Sprintf("%s %s, age %d", p.FirstName, p.LastName, p.Age)
}
  • 메서드 선언은 함수 선언과 비슷한데 추가적으로 리시버(Reciever)를 명시해야 한다.
    • 리시버는 func 키워드와 메서드 이름 사이에 들어간다.
    • 관례적으로 리시버 이름은 타입 이름의 짧은 약어인 첫 문자를 사용한다. thisself 를 사용하는 것은 관용적이지 못하다.
  • 함수와 같이, 메서드 이름은 오버로드 되지 않는다. 다른 타입을 위한 같은 이름의 메서드는 사용할 수 있다.
    • 이름을 재사용하지 않는 것은 코드가 수행하는 작업을 명확히 하는 Go 철학의 일부이다.

7.2.1 포인터 리시버와 값 리시버

  • Go는 포인터 타입의 파라미터를 이용해 파라미터가 함수에서 수정될 수 있다. 메서드 리시버에도 같은 규칙이 적용된다.
  • 각 리시버의 타입 사용을 결정할 때 도움이 될 만한 규칙
    • 메서드가 리시버를 수정한다면, 반드시 포인터 리시버를 사용해야 한다.
    • 메서드가 nil 인스턴스를 처리할 필요가 있다면, 반드시 포인터 리시버를 사용해야 한다.
    • 메서드가 리시버를 수정하지 않는다면, 값 리시버를 사용할 수 있다.
// 하나는 값 리시버를 사용하고 다른 하나는 포인터를 리시버를 사용하는 두 가지 메서드가 있는 타입
type Counter struct {
    total int
    lastUpdated time.Time
}

func (c *Counter) Increment() {
    c.total++
    c.lastUpdated = time.Now()
}

func (c Counter) String() string {
    return fmt.Sprintf("total: %d, last updated: %v", c.total, c.lastUpdated)
}

// 실행하는 코드
var c Counter
fmt.Println(c.String())
c.increament()
fmt.Println(c.String())

// 출력 결과
// total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
// total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC m=+0.000000001
  • 위에서 알 수 있듯이 c 가 값 타입에도 불구하고 포인터 리시버로 메서드를 호출할 수 있다는 것이다.
  • 값 타입인 지역 변수를 포인터 리시버와 함께 사용하면, Go는 자동으로 지역 변수를 포인터 타입으로 변환한다.
    • c.Increment()(&c).Increment()

7.2.2 nil 인스턴스를 위한 메서드 작성

  • 포인터 인스턴스에서 nil 인스턴스로 메서드를 호출하면 패닉이 발생한다.
  • 메서드가 포인터 리시버를 가진다면, 해당 메서드가 nil 인스턴스의 가능성을 처리하면 제대로 동작할 것이다.
  • 포인터 리시버는 포인터 함수 파라미터와 동일하게 동작한다. 메서드로 넘어온 포인터의 복사본으로 수행한다.
  • 함수로 전달된 nil 파라미터와 같이 포인터의 복사본이 변경되면 원본은 변경이 되지 않는다.
    • 이것은 nil 을 처리하고 원본 포인터를 nil 이 아닌 것으로 만드는 포인터 리시버 메서드를 작성할 수 없다는 의미이다.
  • 포인터 리시버를 가진 메서드가 nil 리시버를 처리할 수 없다면, nil을 확인하고 오류를 반환하도록 하자

7.2.3 메서드도 함수이다

  • Go에서 메서드는 함수와 매우 유사하므로 함수 타입의 변수 혹은 파라미터가 있는 어느 때나 함수를 대체하여 메서드를 사용할 수 있다.
type Adder struct {
    start int
}

func (a Adder) AddTo(val int) int {
    return a.start + val
}

// 일반적인 방법으로 인스턴스의 메서드 실행
myAddr := Adder{start: 10}
fmt.Println(myAdder.AddTo(5)) // 15를 출력

// 메서드를 변수에 할당
f1 := myAdder.AddTo
fmt.Println(f1(10)) // 20을 출력
  • 메서드를 변수에 할당하거나 타입이 `func(int)int의 파라미터로 전달할 수 있다. 이것을 메서드 값(method value)이라 부른다
  • 메서드 값은 생성된 인스턴스 항목에 있는 값에 접근할 수 있기 때문에 클로저와 유사하다.
f2 := Adder.AddTo
fmt.Println(f2(myAdder, 15)) // 25를 출력
  • 타입 자체로 함수를 생성하는 것을 메서드 표현(method expression)이라 부른다.
  • 메서드 값과 메서드 표현은 단지 영리한 코너 케이스가 아니라, 의존성 주입을 쉽게 만드는 암묵적 인터페이스이다.

7.2.4 함수와 메서드 비교

  • 함수처럼 메서드를 사용할 수 있기 때문에, 각각 사용처에 대한 구분이 필요하다.
  • 구분을 하는 요소는 함수가 다른 데이터에 의존적인지 여부이다.
  • 로직이 단지 입력 파라미터에 의존적이라면 함수로, 로직이 시작할 때 설정되거나 프로그램이 수행하는 중에 변경되는 값에 의존할 때는 메서드로 구현되어야 한다.

7.2.5 타입 선언은 상속되지 않는다

  • 내장 Go 타입 및 구조체 리터럴을 기반으로 타입을 선언하는 것 외에도 다른 사용자 정의 타입에 기반한 사용자 정의 타입을 선언할 수 있다.
type HighScore Score
type Employee Person
  • ‘객체지향’으로 고려될 수 있는 많은 개념이 있는데, 그 중 두드러지는 한가지는 상속이다. 부모 타입의 상태와 메서드가 자식 타입에서도 사용 가능하도록 선언이 되고 자식 타입의 값이 부모 타입으로 대체될 수 있다. 상속이 있는 언어에서는 자식 인스턴스는 부모 인스턴스가 사용된 곳이면 어디든 사용이 가능하다. 자식 인스턴스는 부모 인스턴스의 모든 메서드와 데이터 구조를 가지고 있다. Go에서는 그렇지 않다.
  • Go에서는 명시적 타입 변경 없이 타입 변수로 할당할 수 없다. 게다가 Score에 정의된 모든 메서드는 HighScore에 정의되지 않는다.
var i int = 300
var s Score = 100
var hs HighScore = 200

hs = s                   // 컴파일 오류!
s = i                    // 컴파일 오류!
s = Score(i)             // ok
hs = HighScore(s)        // ok

7.2.6 타입은 실행가능한 문서이다

  • 개념을 위한 이름을 제공하여 코드를 더 명확하게 만들고 기대되는 데이터의 종류를 기술한다.
  • 메서드가 파라미터로 int 타입 대신에 Percentage 타입을 사용할 때 누군가가 코드를 읽는다면 더 명확할 수 있고, 유효하지 않는 값으로 해당 메서드를 실행하는 것을 어렵게 한다.

7.2.7 열거형을 위한 iota

  • Go는 열거형 타입을 가지고 있지 않는다. 대신에, iota 를 사용하여 증가하는 값을 상수 세트에 할당할 수 있도록 한다.
  • iota 를 사용할 때 먼저 모든 유효한 값을 나타내는 정수 기반의 타입을 정의하는 방법이 ㅣ가장 좋다.
type MailCategory int

const (
    Uncategorized MailCategory = iota
    Personal
    Spam
    Social
    Advertisements
)
  • const 블록에서 첫 번째 상수는 지정된 타이비을 가지고 값으로 iota 로 설정했다. 후속 라인에는 타입이나 값이 지정되지 않았다.
  • Go 컴파일러가 이것을 봤을 때, 블록 내 하위 상수 모두에 타입과 할당을 반복하고 각 라인은 iota 의 값을 증가시킨다.
  • iota 기반의 열거는 값 세트를 구별할 수 있는지를 관리할 수 있을 때만 의미가 있고, 그 뒤에 숨겨진 값이 무엇인지는 특별히 신경 쓰지 않는다. 실제 값이 중요한 경우는 명시적으로 지정해야 한다.

7.3 구성을 위한 임베딩 사용

  • Go는 상속을 가지지 않지만, 구성과 승격을 위한 내장 지원을 통해 코드 재사용을 권장한다.
type Employee sturct {
    Name string
    ID   string
}

func (e Employee) Description() string {
    return fmt.Sprintf("%s (%s)", e.Name, e.ID)
}

type Manager struct {
    Employee
    Reports []Employee
}

func (m Manager) FindNewEmployees() []Employee {
    // do business logic
}
  • ManagerEmployee 타입의 항목을 포함하고 있지만, 해당 항목에 이름이 지정디어 있지 않다.
  • 이것은 Employee임베딩한 것이다. 임베딩된 항목의 선언된 모든 항목이나 메서드는 승격(promotion)되어 구조체를 포함하고 바로 실행도 가능하다.
m := Manager{
    Employee: Employee{
        Name: "Bob Bobson",
        ID:   "12345",
    },
    Reports: []Employee{},
}
fmt.Println(m.ID)             // 12345 출력
fmt.Println(M.Description())  // Bob Bobson (12345) 출력
  • 다른 구조체 뿐만 아니라 어떤 타입이든 구조체로 임베드가 가능하다. 임베딩된 타입의 메서드는 포함하는 구조체로 승격된다.
  • 포함하는 구조체가 임베딩되는 항목과 동일한 이름의 항목이나 메서드를 가지ㅣ면, 임베딩된 항목의 타입을 사용하여 가려진 항목이나 메서드를 참조해야 한다.
type Inner struct {
    X int
}

type Outer struct {
    Inner
    X int
}

o := Outer{
    Inner: Inner{
        X: 10,
    },
    X: 20,
}
fmt.Println(o.X)        // 20
fmt.Println(o.Inner.X)  // 10

7.4 임베딩은 상속이 아니다

  • 상속에 익숙한 많은 개발자는 임베딩을 상속과 같이 취급하여 이해하려고 한다. 그런 방식으로는 이해가 어려울 수 있다.
  • Go에서 구체 타입(concreate type)을 위한 동적 디스패치(dynamic dispatch)는 없다. 임베딩된 항목의 메서드는 자신이 임베드 되었다는 것을 알 길이 없다.

7.5 인터페이스에 대한 간단한 지도

  • Go 디자인의 진정한 스타는 Go의 유일한 추상 타입인 암묵적 인터페이스이다.
  • 인터페이스는 단순하고, 다른 사용자 정의 타입과 같이 type 키워드를 사용한다.
// fmt 패키지에 Stringer 인터페이스의 정의이다.

type Stringer interface {
    String() string
}
  • 인터페이스 선언에서 하나의 인터페이스 리터럴은 인터페이스 타입 이름 뒤에 작성된다.
  • 인터페이스를 만족시키기 위한 구체 타입에서 반드시 구현해야 할 메서드가 나열된다.
  • 인터페이스는 이름의 마지막에 er 을 붙인다.

7.6 인터페이스는 타입에 안정적인 덕 타이핑이다

  • Go의 인터페이스를 특별하게 만드는 것은 암묵적으로 구현이 된다는 것이다. 구체 타입은 구현하는 인터페이스를 선언하지 않는다.
  • 이 암묵적 행동은 인터페이스가 타입 안정성과 디커플링을 가능하게 하여 정적 및 동적언어의 기능을 연결하기 때문에 Go의 타입에 관련된 가장 흥미로운 부분이다.
  • 파이썬, 루비, 자바스크립트와 같은 동적 타입 언어는 인터페이스가 없다. 대신에 개발자들은 “만약 그것이 오리처럼 걷고 오리처럼 운다면 그것은 오리이다”라는 표현에 기반한 덕 타이핑을 사용한다.
  • Go 개발자는 사람들이 코드가 수행되는 작업을 이해하려면, 코드가 어떤 의존성을 가지는지 명시할 필요가 있다고 생각했고, 이것이 암시적 인터페이스가 있는 이유이다.
type LogicProvider struct {}

func (lp LogicProvider) Process(data string) string {
    // business logic
}

type Logic interface {
    Process(data string) string
}

type Client struct {
    L Logic
}

func (c Client) Program() {
    // get data from somewhere
    c.L.Process(data)
}

main() {
    c := Client{
        L: LogicProvider{},
    }
    c.Program()
}

7.7 임베딩과 인터페이스

  • 구조체에 타입을 임베딩 할 수 있는 것처럼, 인터페이스에 인터페이스를 임베딩 할 수 있다.
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Closer interface {
    Close() error
}

type ReadCloser interface {
    Reader
    Closer
}

7.8 인터페이스를 받고 구조체 반환하기

  • 함수로 실행되는 비지니스 로직은 인터페이스를 통해 실행되어야 하는 것이지만, 함수의 출력은 구체 타입이어야 한다.
  • 인터페이스를 반환하는 API를 만든다면, 암묵적 인터페이스의 주요 장점인 디커플링을 잃게 된다.
  • 인터페이스를 반환하는 것을 피하는 또 다른 이유는 버저닝이다. 구체 타입이 반환되면, 기존 코드를 망가뜨리지 않고 새 메서드와 항목을 추가할 수 있다. 인터페이스에 새로운 메서드를 추가하는 것은 인터페이스의 존재하는 모든 구현을 업데이트 해야 하거나 코드를 망가뜨릴 수 있다는 의미이다.
  • 이런 패턴에 하나의 잠재적 단점이 있다. 구조체를 반환하는 것은 힙 할당을 피할 수 있어 좋지만, 인터페이스 타입의 파라미터를 가진 함수를 실행할 때, 힙 할당은 각 인터페이스 파라미터를 위해 발생한다.
  • 더 나은 추상화와 더 나은 성능 간의 기회비용을 파악하는 것은 프로그램 실행동안 수행해야 한다. 가독성 좋고 유지 관리하기 좋은 코드를 작성하자.

7.9 인터페이스와 nil

  • nil 은 인터페이스 인스턴스의 제로 값으로도 사용할 수 있지만, 구체 타입을 위해 사용하는 것보다는 단순하지가 않다.
  • 인터페이스가 nil 이라는 것은 타입과 값 모두 nil 이어야 한다는 것이다.
var s *string
fmt.Println(s == nil) // true
var i interface{}
fmt.Println(i == nil) // true
i = s
fmt.Println(i == nil) // false
  • Go 런타임에서 인터페이스는 기본 타입에 대한 포인터와 기본 값에 대한 포인터 쌍으로 구현되었다. 타입이 nil 이 아닌 한, 인터페이스도 nil 이 아니다.
  • nil 이 아닌 인터페이스 인스턴스는 nil 과 같지 않기 때문에, 타입이 nil 이 아닐 때 인터페이스와 연관된 값이 nil 인지 여부를 말하는 것은 간단치 않다. 반드시 리플렉션을 사용하여 알아봐야 한다.

7.10 빈 인터페이스는 어떤 것도 표현하지 않는다

  • 정적 타입 언언에서 때로는 변수가 어떤 타입의 값이라도 저장할 수 있는 방법이 있어야 한다.
  • Go는 interface{} 를 사용하여 이것을 표현한다.
var i interface{}
i = 20
i = "hello"
i = struct {
    FirstName string
    LastNmae string
} {"Fred", "Fredson"}
  • 빈 인터페이스의 읠반적인 용도 중 하나는 JSON 파일과 같이 외부에서 온 불확실한 스키마의 데이터에 대한 플레이스 홀더로 사용되는 것이다.
  • interface{} 의 다른 용도는 사용자 생성 데이터 구조 내에 값을 저장하는 방법으로 사용한다. Go에서 현재 사용자 정의된 제네릭이 없기 때문이다. 슬라이스, 배열, 맵 이외의 데이터 구조가 필요하고 단일 타입에서만 동작하지 않도록 하려면 해당 값을 들고 있기 위해 interface{} 타입의 항목을 사용해야 한다.
type LinkedList sturct {
    Value interface{}
    Next * LinkedList
}

func (ll *LinkedList) Insert(pos int, val interface{}) *LinkedList {
    if ll == nil || pos == 0 {
        return &LinkedList{
            Value: val,
            Next: ll,
        }
    }
    ll.Next = ll.Next.Insert(pos-1, val)
    return ll
}
  • 빈 인터페이스에 값을 저장해야 하는 상황을 발견한다면 해당 값을 다시 읽는 방법이 궁금할 것이다. 타입 단언(type assertion)과 타입 스위치(type switch)를 살펴볼 필요가 있다.

7.11 타입 단언 및 타입 스위치

  • 타입 단언은 인터페이스를 구현한 구체 타입의 이름을 지정하거나 인터페이스 기반인 구체 타입에 의해 구현된 다른 인터페이스의 이름을 지정한다.
type MyInt int

func main() {
    var i interface{}
    var mine MyInt = 20
    i = mine
    i2 := i.(MyInt)
    fmt.Println(i2 + 1)
}
  • 만약 타입 단언이 잘못되었다면 무슨 일이 일어나는지 궁금할 것이다. 코드는 패닉을 일으킨다.
i2 := i.(string)
fmt.Println(i2)

// panic: interface conversion: interface {} is main.MyInt, not string
  • 두 개의 타입이 기본 타입을 공유하더라도 타입 단언은 기본 값의 타입과 반드시 일치해야 한다.
i2 := i.(string)
fmt.Println(i2 + 1)

// panic: interface conversion: interface {} is main.MyInt, not int
  • 위의 패닉을 피하고자 한다면, 콤마 OK 관용구를 사용할 수 있다.
i2, ok := i.(int)
if !ok {
    return fmt.Errorf("unexpected type for %v", i)
}
fmt.Println(i2 + 1)
  • 타입 단언이 유효하다고 확신이 될 지라도 콤마 OK 관용구를 사용하자. 다른 사람이 해당 코드를 재사용하는 방법을 모를 수도 있기 때문이다.
  • 인터페이스를 여러 가능한 타입 중 하나로 사용할 때, 타입 스위치(type switch)를 사용하자.
func doThings(i interface{}) {
    switch j := i.(type) {
    case nil:
        // i는 nil이다. j는 interface{} 타입이다.
    case int:
        // j는 정수다.
    case MyInt:
        // j는 MyInt 타입이다.
    case io.Reader:
        // j는 io.Reader 타입이다.
    case string:
        // j는 문자열이다.
    case boo, rune:
        // i가 불리언이거나 룬일 수 있기에 j는 interface{} 타입이다.
    default:
        // i가 무슨 타입인지 알 수 없기에 j는 interface{} 타입이다.
    }
}

타입 스위치의 목적은 이미 존재하는 변수를 새로운 변수로 파생시키는 것이기 때문에, 전환되는 변수를 같은 이름의 변수로(i := i.(type) 할당하는 것은 관용적이고 섀도잉이 좋게 쓰이는 몇 안되는 것 중 하나이다.

7.12 타입 단언과 타입 스위치를 아껴 사용하기

  • 타입 단언의 일반적인 사용 중 하나는 인터페이스를 구현한 구체타입이 다른 인터페이스도 구현되어 있는지 확인하는 것이다. 이는 선택적 인터페이스를 지정할 수 있도록 한다.
  • 예를 들어, 표준 라이브러리는 해당 기술을 사용하여 io.Copy 함수를 호출 했을 때, 더 효과적으로 복사할 수 있또록 한다. 이 함수는 io.Writerio.Reader 타입의 파라미터를 받아 해당 작업을 수행하기 위해 io.copyBuffer 함수를 호출한다. io.Reader 파라미터가 io.WriterTo 를 구현했거나 io.Writer 파라미터가 io.ReaderFrom 을 구현했다면 해당 함수의 대부분의 작업은 하지 않고 넘어갈 수 있다.
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
    // reader가 WriteTo 메서드를 가지고 있다면, 복사를 위해 해당 함수를 사용한다.
    // 할당과 복사 방지
    if wt, ok := src.(WriterTo); ok {
        return wt.WriteTo(dst)
    }
    // 비슷하게, writer가 ReadFrom 메서드를 가지고 있다면 복사를 위해 하당 함수를 사용한다.
    if rt, ok := dst.(ReadFrom); ok {
        return rt.ReadFrom(src)
    }
    // 함수 하단 구현부..
}
  • 앞서 인터페이스 구현이 데코레이터 패턴을 사용하여 같은 인터페이스의 다른 구현을 계층 동작에 래핑하는 일반적이라는 것을 알 수 있었다. 래핑된 구현중 하나에 의해 구현된 선택적 인터페이스는 타입 단언이나 타입 스위치로 검출이 안된다는 단점이 있다.
  • 오류는 다른 오류를 감싸서 추가적인 정보를 포함할 수 있다. 타입 스위치나 타입 단언을 통해 래핑된 오류를 검출하거나 일치시킬 수 없다. 반환된 오류의 다른 구체 구현을 처리하도록 하려면, errors.Is와 errors.As 함수를 사용하여 래핑된 오류를 테스트하고 접근한다.

7.13 함수 타입은 인터페이스로의 연결

  • Go는 사용자 정의 함수 타입을 포함하여 모든 사용자 정의 타입에 메서드를 허용한다. 이것은 학술적인 코너 케이스처럼 보이지만 실제로는 매우 유용하다. 함수가 인터페이스를 구현할 수 있도록 한다.
// HTTP 핸들러는 HTTP 서버 요청을 처리하는 것에 대한 인터페이스
type Handler interface {
    ServerHTTP(http.ResponseWriter, *http.Request(
}

// 타입 변환을 사용하여 http.HandlerFunc로 변환하여
// 아래 시그니처를 가지는 모든 함수를 http.Handler로써 사용이 가능하다.
type HandlerFunc func(http.ResposeWriter, *http.Request)

func (f HandlerFunc) serverHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r)
}
  • 단일 함수에 많은 다른 함수나 해당 함수의 입력 파라미터에 지정되지 않은 다른 상태에 의존적인 것 같다면, 인터페이스 파라미터를 사용하고 함수 타입을 선언하여 함수와 인터페이스를 연결하자.
  • 단순한 함수라면 함수 타입의 파라미터가 좋은 선택이 된다.

7.14 Go는 특히 객체지향이 아니다

  • Go에서 타입의 관용적 사용을 살펴보면 Go는 언어의 특정 스타일로 분류하기엔 어렵다는 것을 알았다.
  • 엄격한 절차적 언어가 아니라는 것은 명확하고, Go의 메서드 재정의, 상속 및 객체의 결핍은 특히 객체지향 언어가 아니라는 것을 의미한다.
  • Go는 함수 타입과 클로저를 가지지만 함수형 언어는 아니다.
  • Go의 스타일의 라벨을 붙이자면 가장 적합한 단어는 실용적(practical)이라 할 수 있다. 수년 동안 대규모 팀에서 간단하고, 가독성이 좋으며 유지 관리하기 용이한 언어를 만드는 것을 최우선 목표로 다양한 곳에서 개념을 차용했다.