Skip to content

Latest commit

 

History

History
690 lines (496 loc) · 29.5 KB

37-55.md

File metadata and controls

690 lines (496 loc) · 29.5 KB

디커플링

메서드(method)

값 리시버와 포인터 리시버를 이용한 호출(Value and Pointer Receiver Call)

type user struct {
    name  string
    email string
}

notify는 값 리시버를 가지는 메서드(method)이다. uuser타입으로, Go에서는 함수가 리시버와 함께 선언된다면 이를 메서드라고 한다. 리시버는 파라미터와 비슷하게 보이지만, 이는 자신만의 역할이 있다. 값 리시버를 사용하면, 메서드는 자신을 호출한 변수를 복사하고, 그 복사본을 가지고 동작한다.

func (u user) notify() {
    fmt.Printf("Sending User Email To %s<%s>\n", u.name, u.email)
}

changeEmail은 포인터 리시버를 가지는 메서드이다: uuser의 포인터 타입으로, 포인터 리시버를 이용하면 메서드를 호출한 변수를 공유하면서 바로 접근이 가능하다.

func (u *user) changeEmail(email string) {
    u.email = email
    fmt.Printf("Changed User Email To %s\n", email)
}

위의 두 메서드들은 값 리시버와 포인터 리시버의 차이를 이해하기 위해서 같이 사용되었다. 하지만 실제 개발에서는 하나의 리시버를 사용하는 것을 권장한다. 이에 대해서는 나중에 다시 살펴볼 것이다.

값 리시버와 포인터 리시버를 이용한 호출

user 타입의 변수는 값 리시버와 포인터 리시버를 사용하는 모든 메서드를 호출할 수 있다.

bill := user{"Bill", "bill@email.com"}
bill.notify()
bill.changeEmail("bill@hotmail.com")
Sending User Email To Bill<bill@email.com>
Changed User Email To bill@hotmail.com

user의 포인터 타입 변수 역시 값 리시버와 포인터 리시버를 사용하는 모든 메서드를 호출할 수 있다.

hoanh := &user{"Hoanh", "hoanhan@email.com"}
hoanh.notify()
hoanh.changeEmail("hoanhan101@gmail.com")
Sending User Email To Hoanh<hoanhan@email.com>
Changed User Email To hoanhan101@gmail.com

이 예제에서 hoanhuser 타입을 가리키는 포인터 변수이다. 하지만 값 리시버로 구현된 notify를 호출할 수 있다. user 타입의 변수로 메서드를 호출하지만, Go는 내부적으로 이를 (*hoanh).notify()로 호출한다. Go는 hoanh이 가리키는 값을 찾고, 이 값을 복사하여 notify를 값에 의한 호출(value semantic)이 가능하도록 한다. 이와 유사하게 billuser 타입의 변수이지만, 포인터 리시버로 구현된 changeEmail을 호출할 수 있다. Go는 bill의 주소를 찾고, 내부적으로 (&bill).changeEmail()을 호출한다.

두 개의 user 타입 원소를 가진 슬라이스를 만들자.

users := []user{
    {"bill", "bill@email.com"},
    {"hoanh", "hoanh@email.com"},
}

이 슬라이스에 for ... range를 사용하면, 각 원소의 복사본을 만들고, notify 호출을 위해 또 다른 복사본을 만들게 된다.

for _, u := range users {
    u.notify()
}
Sending User Email To bill<bill@email.com>
Sending User Email To Hoanh<hoanhan@email.com>

포인터 리시버를 사용하는 changeEmailfor ... range 안에서 사용해보자. 이는 복사본의 값을 변경하는 것으로 이렇게 사용하면 안 된다.

for _, u := range users {
    u.changeEmail("it@wontmatter.com")
}
Changed User Email To it@wontmatter.com
Changed User Email To it@wontmatter.com

값에 의한 호출과 참조에 의한 호출(Value and Pointer Semantics)

숫자, 문자열, 불과 같은 기본 타입을 사용하는 경우, 값에 의한 호출을 사용하는 것을 권장한다. 만약 정수 또는 불 타입 변수의 주소 값을 사용해야 한다면 주의해야 한다. 상황에 따라, 이 방법이 맞을 수도 있고, 아닐 수도 있기 때문이다. 하지만 일반적으로, 메모리 누수의 위험이 있는 힙 메모리 영역에 이 변수들을 만들 필요는 없다. 그래서 이 타입의 변수들은 stack에 생성하는 것을 더 권장한다. 모든 것에는 예외가 있을 수 있지만, 그 예외를 적용하는 것이 적합하다고 판단하기 전에는 규칙을 따를 필요가 있다.

슬라이스, 맵, 채널, 인터페이스와 같이 참조 타입의 변수들 역시 기본적으로 값에 의한 호출을 사용하는 것을 권장한다. 다만 변수의 주소 값을 파라미터로 받는 Unmarshal같은 함수를 사용하기 위한 경우라면, 이 타입들의 주소 값을 사용해야 한다.

아래 예제들은 실제 Go의 표준 라이브러리에서 사용하는 코드들이다. 이들을 공부해보면, 값에 의한 호출과 참조에 의한 호출(pointer semantic) 중 하나를 일관되게 사용하는 것이 얼마나 중요한지 알 수 있다. 따라서 변수의 타입을 정할 때, 다음의 질문에 스스로 답해보자.

  • 이 타입은 값에 의한 호출과 참조에 의한 호출 중 어느 것이 더 적합한가?
  • 만약 이 변수의 값을 변경해야 한다면, 새로운 값을 가지는 복사본을 만드는 것과, 다른 곳에서도 이 변경된 값을 확인할 수 있게 이 변수의 값을 직접 변경하는 것 중 어느 것이 더 적합한가?

가장 중요한 것은 일관성이다. 처음에 한 결정이 잘못되었다고 판단되면, 그때 이를 변경하면 된다.

package main

import (
    "sync/atomic"
    "syscall"
)

값에 의한 호출

Go의 net 패키지는 IPIPMask 타입을 제공하는데, 이들은 바이트 형 슬라이스이다. 아래의 예제들은 이 참조 타입들을 값에 의한 호출을 통해 사용하는 것을 보여준다.

type IP []byte
type IPMask []byte

MaskIP 타입의 값 리시버를 사용하며, IP 타입의 값을 반환한다. 이 메서드는 IP 타입에 대해서 값에 의한 호출을 사용하는 것이다.

func (ip IP) Mask(mask IPMask) IP {
    if len(mask) == IPv6len && len(ip) == IPv4len && allFF(mask[:12]) {
        mask = mask[12:]
    }
    if len(mask) == IPv4len && len(ip) == IPv6len &&
        bytesEqual(ip[:12], v4InV6Prefix) {
        ip = ip[12:]
    }
    n := len(ip)
    if n != len(mask) {
        return nil
    }
    out := make(IP, n)
    for i := 0; i < n; i++ {
        out[i] = ip[i] & mask[i]
    }
    return out
}

ipEmptyStringIP 타입의 값을 파라미터로 받고, 문자열 타입의 값을 반환한다. 이 함수는 IP 타입에 대해서 값에 의한 호출을 사용하는 것이다.

func ipEmptyString(ip IP) string {
    if len(ip) == 0 {
        return ""
    }
    return ip.String()
}

참조에 의한 호출

Time 타입은 값에 의한 호출과 참조에 의한 호출 중 어떤 것을 사용해야 할까? 만약 Time 타입 변수의 값을 변경해야 한다면, 이 값을 직접 변경해야 할까? 아니면 복사본을 만들어서 값을 변경하는 것이 좋을까?

type Time struct {
    sec  int64
    nsec int32
    loc  *Location
}

타입에 대해 어떤 호출을 사용할지를 결정하는 가장 좋은 방법은 타입의 생성 함수를 확인하는 것이다. 이 생성 함수는 어떤 호출을 사용해야 하는지 알려준다. 이 예제에서, Now 함수는 Time 타입의 값을 반환한다. Time 타입의 값은 한번 복사가 이루어지고, 이 복사된 값은 이 함수를 호출한 곳으로 반환된다. 즉, 이 Time 타입의 값은 스택(stack)에 저장된다. 따라서 값에 의한 호출을 사용하는 것이 좋다.

func Now() Time {
    sec, nsec := now()
    return Time{sec + unixToInternal, nsec, Local}
}

Add는 기존의 Time 타입의 값과는 다른 값을 얻기 위한 메서드다. 만약 값을 변경할 때는 무조건 참조에 의한 호출을 사용하고, 그렇지 않을 때는 값에 의한 호출을 사용해야 한다면 이 Add의 구현은 잘못되었다고 생각할 수 있다. 하지만, 타입은 어떤 호출을 사용할지를 책임지는 것이지, 메서드의 구현을 책임지는 것은 아니다. 메서드는 반드시 선택된 호출을 따라야 하고, 그래서 Add의 구현은 틀리지 않았다.

Add는 값 리시버를 사용하고, Time 타입의 값을 반환한다. 즉, 이 메서드는 실제로 Time 타입 변수의 복사본을 변경하며, 완전히 새로운 값을 반환하는 것이다.

func (t Time) Add(d Duration) Time {
    t.sec += int64(d / 1e9)
    nsec := int32(t.nsec) + int32(d%1e9)
    if nsec >= 1e9 {
        t.sec++
        nsec -= 1e9
    } else if nsec <  0  {
        t.sec--
        nsec += 1e9
    }
    t.nsec = nsec
    return t
}

divTime 타입의 파라미터를 받고, 기본 타입의 값들을 반환한다. 이 함수는 Time 타입에 대해 값에 의한 호출을 사용하는 것이다.

func div(t Time, d Duration) (qmod2 int, r Duration) {}

Time 타입에 대한 참조에 의한 호출은, 오직 주어진 데이터를 Time 타입으로 변환해 이 메서드를 호출한 변수를 수정할 할 때만 사용한다:

func (t *Time) UnmarshalBinary(data []byte) error {}
func (t *Time) GobDecode(data []byte) error {}
func (t *Time) UnmarshalJSON(data []byte) error {}
func (t *Time) UnmarshalText(data []byte) error {}

대부분의 구조체(struct) 타입들은 값에 의한 호출을 잘 사용하지 않는다. 이는 다른 코드에서 함께 공유하거나 또는 공유하면 좋은 데이터들이기 때문이다. User 타입이 대표적인 예이다. User 타입의 변수를 복사하는 것은 가능은 하지만, 이는 실제로 좋은 구현이 아니다.

다른 예제를 살펴보자:

앞서 이야기했듯이, 생성 함수는 어떤 호출을 사용해야 하는지를 알려준다. Open 함수는 File 타입 데이터의 주소 값, 즉 File 타입 포인터를 반환한다. 이는 File 타입에 대해, 참조에 의한 호출을 사용해서 이 File 타입의 값을 공유할 수 있다는 것을 뜻한다.

func Open(name string) (file *File, err error) {
    return OpenFile(name, O_RDONLY, 0)
}

ChdirFile 타입의 포인터 리시버를 사용한다. 즉, 이 메서드는 File 타입에 대해 참조에 의한 호출을 사용하는 것이다.

func (f *File) Chdir() error {
    if f == nil {
        return ErrInvalid
    }
    if e := syscall.Fchdir(f.fd); e != nil {
        return &PathError{"chdir", f.name, e}
    }
    return nil
}

epipecheckFile 타입의 포인터를 파라미터로 받는다. 따라서 이 함수는 File 타입에 대해 참조에 의한 호출을 사용하는 것이다.

func epipecheck(file *File, e error) {
    if e == syscall.EPIPE {
        if atomic.AddInt32(&file.nepipe, 1) >= 10 {
            sigpipe()
        }
    } else {
        atomic.StoreInt32(&file.nepipe, 0)
    }
}

메서드는 단지 함수일 뿐이다

메서드는 특수한 기능이 아니라 문법적인 필요에 의해 만들어진 것이다. 메서드는 데이터와 관련된 일부 기능을 외부에서 사용할 수 있는 것처럼 믿게 만든다. 객체지향 프로그래밍에서도 이러한 설계와 기능을 권장한다. 하지만 Go가 객체지향 프로그래밍를 추구하는 것은 아니다. 다만 데이터와 동작이 필요하기 때문에 이들이 있는 것이다.[재방문]

어떤 때는 데이터가 일부 기능을 노출할 수 있지만 특정 목적을 위해 API를 설계하는 것은 아니다. 메서드는 함수와 거의 동일하다.[재방문]

typedatastruct​ {
    namestring
    ageint
}

displayNamedata타입 d 변수의 name을 포함한 문자열을 출력한다. 이 메서드는 data를 값 리시버로 사용한다.

func (d data) displayName() {
    fmt.Println("My Name Is", d.name)
}

setAgedata타입 dage를 수정하고, 이 값을 name과 함께 출력한다. 이 메서드는 data를 포인터 리시버로 사용한다.

func (d *data) setAge(age int) {
    d.age = age
    fmt.Println(d.name, "Is Age", d.age)
}

메서드는 단지 함수일 뿐이다

data 타입의 변수를 선언해보자.

d := data{
    name: "Hoanh",
}
fmt.Println("Proper Calls to Methods:")

다음과 같이 메서드를 호출할 수 있다.

d.displayName()
d.setAge(21)

fmt.Println("\nWhat the Compiler is Doing:")

다음 예제를 통해 Go가 내부적으로 어떻게 동작하는지 알 수 있다. d.displayName()을 호출하면, 컴파일러는 data.displayName을 먼저 호출해서 data 타입의 값 리시버를 사용하는 것을 보여주고, d를 첫번째 파라미터로 전달한다. func (d data) displayName()을 다시 자세히 보면, 리시버가 정말 파라미터임을 알 수 있다. 즉, 이 리시버는 displayName의 첫번째 파라미터이다.

d.setAge(21) 역시 이와 비슷하다. Go는 포인터 리시버를 사용하는 함수를 호출하고, d를 함수의 파라미터로 넘긴다. 추가로 d의 주소 값을 이용하기 위하여 약간의 조정이 필요하다.

data.displayName(d)
(*data).setAge(&d, 21)
Proper Calls to Methods:
My Name Is Hoanh
Hoanh Is Age 21

What the Compiler is Doing:
My Name Is Hoanh
Hoanh Is Age 21

함수형 변수

fmt.Println("\nCall Value Receiver Methods with Variable:")

함수형 변수를 선언하고, 이 변수에 d 변수의 메서드를 설정해보자. 이 메서드, displayName,은 값 리시버를 사용하기 때문에, 함수형 변수 f1d의 독립적인 복사본을 가진다. 함수형 변수 f1은 참조 타입으로 포인터, 즉 주소 값을 저장한다. displayName의 뒤에 ()을 붙이지 않았으므로, 이 메서드의 반환 값을 저장한 것이 아니다.

f1 := d.displayName

이 변수를 이용해서 메서드를 호출해보자.

f1은 포인터로, 이는 2개의 워드를 가지는 특별한 자료 구조를 가리킨다. 첫 번째 워드는 실행 대상인 메서드를 가리키는데, 이 예제에서는 displayName이다. 이 displayName는 값 리시버를 사용하므로 실행하기 위해서는 data 타입의 값이 필요하다. 따라서 두 번째 워드는 이 값의 복사본을 가리킨다. displayNamef1에 저장을 하면, 자동으로 d의 복사본이 만들어진다.

만약 d의 멤버 변수인 name의 값을 "Hoanh An"으로 변경하더라도, f1에는 이 변경이 적용되지 않는다.

d.name = "Hoanh An"

f1을 통해서 메서드를 호출해보자. 앞서 이야기했듯이, 결과는 변하지 않았다.

f1()
Call Value Receiver Methods with Variable:
My Name Is Hoanh
My Name Is Hoanh

하지만 setAge 메서드를 저장한 f2d의 값이 변하면 그 자신의 결과도 변한다.

fmt.Println("\nCall Pointer Receiver Method with Variable:")

함수형 변수를 선언하고, 이 변수에 d 변수의 메서드를 설정해보자. 이 메서드, setAge는 포인터 리시버를 사용하므로, 이 함수형 변수는 d의 주소 값을 가지게 된다.

f2 := d.setAge
d.name = "Hoanh An Dinh"

이 함수형 변수를 이용해서 메서드를 호출해보자. f2는 역시 포인터이고, 2개의 워드를 가지는 자료 구조를 가리킨다. 첫 번째 워드는 setAge 메서드를 가리키지만, 두 번째 워드는 더 이상 복사본이 아니라 원본 d를 가리킨다.

f2(21)
Call Pointer Receiver Method with Variable:
Hoanh An Dinh Is Age 21

인터페이스

값이 없는 타입

reader는 데이터를 읽는 동작을 정의하는 인터페이스이다. 인터페이스는 엄밀히 말해서 값이 없는 타입이다. 이 인터페이스는 어떠한 멤버 변수도 가지지 않으며, 오직 행동에 대한 계약만을 정의한다. Go에서는 이 행동에 대한 계약을 통해서, 다형성을 활용할 수 있다. 인터페이스는 두 개의 워드로 이루어진 자료 구조로, 두 워드 모두 포인터이다. 인터페이스는 참조 타입으로, var r reader구문은 nil 값 가지는 인터페이스 r을 만든다.

type reader interface {
    read(b []byte) (int, error) // (1)
}

read 메서드를 다르게 정의할 수도 있다. 예를 들어 read(i int) ([]byte, error) // (2)처럼, 파라미터로 읽을 바이트 수를 받고, 읽은 데이터를 슬라이스에 담아 에러와 함께 반환할 수도 있다.

그럼 왜 (1)을 선택한 것일까?

(2)는 매번 호출할 때마다, 반환할 슬라이스를 메서드 내부에서 만들어야 한다. 이때 슬라이스를 위한 배열을 힙 메모리에 할당하는 비용이 발생한다. 하지만 (1)의 경우, 이 메서드를 호출하는 쪽에서 슬라이스를 만들 책임이 있다. 따라서 슬라이스를 위한 한 번의 메모리 할당은 피할 수 없지만, 반복적인 메서드 호출에 대해 추가적인 메모리 할당은 발생하지 않는다.

구체적 타입 vs 인터페이스 타입

구체적 타입(concrete type)이란 메서드를 가질 수 있는 모든 타입을 말한다. 오직 사용자 정의 타입만이 메서드를 가질 수 있다.

메서드는 데이터가 인터페이스를 기반으로 기능을 외부에서 사용할 수 있도록 해준다. file은 시스템 파일을 위한 구조체이다.

type file struct {
    name string
}

이는 구체적 타입으로, 이후에 설명할 read 메서드를 가지고 있다. 이는 reader 인터페이스에서 선언한 메서드와 동일하다. 따라서, 구체적 타입인 filereader 인터페이스를 값 리시버를 이용해서 구현(implement) 한 것이다.

구현을 위해서 특별한 문법이 존재하는 것은 아니다. 다만 컴파일러가 자동으로 구현임을 인지한다.

관계

인터페이스 변수에는 구체적 타입의 값을 저장할 수 있다.

readfile 타입에 대하여 reader 인터페이스를 구현한다.

func (file) read(b []byte) (int, error) {
    s := "<rss><channel><title>Going Go Programming</title></channel></rss>"
    copy(b, s)
    return len(s), nil
}

pipe는 이름 있는 파이프 네트워크 연결을 위한 구조체이다. 이는 두 번째 구체적 타입으로, 값 리시버를 사용한다. 두 구체적 타입 모두 reader 인터페이스의 행동에 대한 계약을 구현하고 이들을 외부에 제공한다.

type pipe struct {
    name string
}

read는 네트워크 연결을 위한 reader 인터페이스를 구현한다.

func (pipe) read(b []byte) (int, error) {
    s := `{name: "hoanh", title: "developer"}`
    copy(b, s)
    return len(s), nil
}

filepipe 두 타입의 변수를 다음과 같이 만들어보자.

f := file{"data.json"}
p := pipe{"cfg_service"}

이 변수들을 파라미터로 retrieve 함수를 호출해보자. 이 함수는 값을 파라미터로 받으므로, f의 복사본이 함수에서 사용된다.

컴파일러는 다음의 질문을 하게 된다: 이 변수의 타입 filereader 인터페이스를 구현한 것인가? file 타입은 reader 인터페이스의 행동에 대한 계약을 값 리시버를 이용하여 구현하였으므로, 이 질문에 대한 답은 "Yes"이다. 이 예제에서, 인터페이스의 두 번째 워드는 f의 복사본을 가리킨다. 그리고 첫 번째 워드는 iTable이라는 특별한 자료 구조를 가리킨다.

iTable은 다음의 두 가지를 제공한다:

  • 첫 번째 부분은 저장된 데이터의 타입을 나타낸다. 이 예제에서는 file 타입을 말한다.
  • 두 번째 부분은 함수 포인터의 모체로서, 인터페이스를 통해 메서드를 호출할 때 정확한 메서드를 호출하기 위하여 사용된다.

iTable for file

인터페이스를 통해서 read를 호출하면, iTable을 확인해서 이 타입에 맞는 read 함수를 찾고 이를 호출한다. 결과적으로 구체적 타입의 read 메서드를 호출하는 것이다.

retrieve(f)

p도 이와 동일하다. reader 인터페이스의 첫 번째 워드는 pipe 타입을 가리키고, 두번째 워드는 p의 복사본을 가리킨다.

iTable for pipe

데이터가 변경되었기 때문에, 동작도 다르다.

retrieve(p)

앞으로 iTable와 이를 가리키는 포인터를 간략하게 *pipe처럼 나타낼 것이다.

simple representation

다형성 함수

retrieve는 어떤 장치든 다 읽을 수 있고, 어떤 데이터든지 다 처리할 수 있다. 이러한 함수를 다형성 함수라 한다. 이 예제에서 사용된 파라미터는 reader 타입이다. 하지만 이는 인터페이스이므로 값이 없는 타입니다. 즉, 이 함수는 reader 인터페이스의 계약을 구현한 모든 타입을 다 파라미터로 받을 수 있다. 이 함수는 구체적 타입에 대해서는 전혀 알지 못하므로, 이는 완전히 디커플링 되어 있다. 이는 Go에서 할 수 있는 최상위의 디커플링이다. 이러한 구현 방법은 간결하고 효율적이다. 이 방법을 사용하기 위해 필요한 것은 오직 인터페이스를 통한 구체적 타입 데이터로의 간접적 접근뿐이다.[재방문]

func retrieve(r reader) error {
    data := make([]byte, 100)

    len, err := r.read(data)
    if err != nil {
        return err
    }

    fmt.Println(string(data[:len]))
    return nil
}
<rss><channel><title>Going Go Programming</title></channel></rss>
{name: "hoanh", title: "developer"}

포인터 리시버를 이용한 인터페이스

notifier는 인터페이스로 알림을 보내는 타입들의 동작을 정의한다.

type notifier interface {
    notify()
}

printer 역시 인터페이스로, 어떤 정보를 출력하는 동작을 정의한다.

type printer interface {
    print()
}

user는 프로그램 내의 사용자 정보를 담을 타입을 정의한다.

type user struct {
    name  string
    email string
}

printuser 타입의 name과 email 정보를 출력한다.

func (u user) print() {
    fmt.Printf("My name is %s and my email is %s\n", u.name, u.email)
}

notify는 포인터 리시버를 사용해서 notifier 인터페이스를 구현한다.

func (u *user) notify() {
    fmt.Printf("Sending User Email To %s<%s>\n", u.name, u.email)
}

Stringfmt.Stringer 인터페이스를 구현한다. fmt는 지금까지 화면에 정보를 출력하기 위해서 사용한 패키지로, String을 구현하는 데이터가 주어지면 기존 동작이 아닌 새롭게 구현된 동작을 수행한다. 참조에 의한 호출을 사용하기 때문에, 포인터만이 이 인터페이스를 만족한다.

func (u *user) String() string {
    return fmt.Sprintf("My name is %q and my email is %q", u.name, u.email)
}

user 타입의 데이터를 만들어보자.

u := user{"Hoanh", "hoanhan@email.com"}

참조에 의한 호출로 u를 전달하는 다형성 함수를 호출하자: sendNotification(u). 하지만 컴파일러는 이 호출을 허락하지 않고 다음의 에러 메시지를 보여준다: "cannot use u (type user) as type notifier in argument to sendNotification: user does not implement notifier (notify method has pointer receiver)" 이는 무결성을 위반해서 발생한 문제이다.

메서드 집합

메서드 집합의 개념과 관련하여 몇 가지 규칙이 존재한다. 앞서 발생한 에러는 이 규칙을 위반한 것이다.

다음은 그 규칙들이다.

  • 주어진 타입 T의 어떤 값에 대해, 값 리시버로 구현한 메서드들만이 이 타입의 메서드 집합에 속한다.
  • 주어진 타입 *T (타입 T의 포인터)의 어떤 값에 대해, 값 리시버와 포인터 리시버를 사용하는 모든 메서드들이 이 타입의 메서드 집합에 속한다.

즉, 어떤 타입의 포인터를 사용한다면 선언된 모든 메서드들은 모두 이 포인터를 통해 사용이 가능하다. 만약 어떤 타입의 값을 사용한다면 값에 의한 호출을 사용하는 메서드들만이 사용이 가능하다.

앞서 메서드를 공부할 때, 둘 중 어떤 호출을 사용하든 이러한 문제가 발생하지 않았다. 이는 메서드를 구체적 타입의 값을 통해 호출하였기 때문에 Go가 내부적으로 정상 동작이 가능하게끔 하였다. 하지만 여기서는 인터페이스에 구체적 타입의 값을 저장하고 이 인터페이스를 통해서 메서드를 호출하려고 하였다. 하지만 해당 메서드가 포인터 리시버를 사용하기 때문에 계약을 만족시키지 못하는 것이다.

왜 포인터 리시버로는 값을 통해 호출되는 메서드 집합을 사용할 수 없을까? 타입 T의 값에 대해 참조에 의한 호출을 허락하지 않는 무결성 문제란 무엇인가?

인터페이스를 만족하는 모든 값이 주소를 가진다고 100% 보장할 수 없다. 만약 어떤 값이 주소를 가지고 있지 않다면, 이는 공유할 수 없으므로 포인터 리시버를 사용할 수 없다. 다음의 예를 살펴보자.

int와 동일한 duration이라는 타입을 선언하자.

type duration int

포인터 리시버를 사용해서 durationnotify 메서드를 정의하자. 이제 이 타입은 포인터 리시버를 통해 notifier 인터페이스를 구현한 것이다.

func (d *duration) notify() {
    fmt.Println("Sending Notification in", *d)
}

42를 받아서, 이를 duration 타입으로 변경 후 notify 메서드를 호출해보자. 이때, 컴파일러는 다음과 같은 에러 메시지를 보여준다.

  • "cannot call pointer method on duration(42)"
  • "cannot take the address of duration(42)"
duration(42).notify()

주소 값을 얻을 수 없는 이유는 42가 변수에 저장된 값이 아니기 때문이다. 이는 여전히 리터럴 값으로 타입을 알 수 없다. 하지만 duration은 여전히 notifier 인터페이스를 구현한다.

다시 본 예제로 돌아와서, 이러한 에러가 발생했다는 것은 값에 의한 호출과 참조에 의한 호출이 혼용되었음을 알 수 있다. u는 포인터 리시버를 통해서 notifier 인터페이스를 구현하였지만, u의 복사본을 통해 해당 메서드를 호출하려고 한 것이다. 이는 일관적이지 못하다.

교훈

포인터 리시버를 이용해서 인터페이스를 구현한다면, 반드시 참조에 의한 호출을 사용해야 한다. 만약 값 리시버를 사용해서 인터페이스를 구현한다면, 값에 의한 호출과 참조에 의한 호출 모두를 사용할 수 있다. 하지만, 일관성을 유지하기 위해서 이 경우에도 값에 의한 호출을 사용하는 것을 권장한다. 다만 Unmarshal과 같은 함수를 위해서는 참조에 의한 호출을 사용해야만 할 수도 있다.

이 문제를 해결하기 위해서는, u 대신 u의 주소 값(&u)를 전달해야 한다. user의 값을 만들고 이 값의 주소 값을 전달하면, 인터페이스는 user 타입의 주소 값을 가지게 되고 원본 값을 가리킬 수 있게 된다.

pointer of u

sendNotification(&u)

sendNotification은 다형성 함수이다. 이 함수는 notifier 인터페이스를 구현하는 타입의 값을 받아 알림을 보낸다. 이 함수는 다음과 같이 말하는 것이다: 나는 notifier 인터페이스를 구현하는 타입의 값 또는 포인터를 받을 것이다. 그리고 나는 인터페이스를 통해서 동작을 호출할 것이다.

func sendNotification(n notifier) {
    n.notify()
}

이와 유사하게, u의 값을 Println에게 전달하면, 기존 형태의 출력을 보게 된다. 하지만 만약 u의 주소 값을 전달하면, 앞서 정의한 String으로 기존 동작을 덮어써서 새로운 형태의 출력을 사용하게 된다.

fmt.Println(u)
fmt.Println(&u)
Sending User Email To Hoanh<hoanhan@email.com>
{Hoanh hoanhan@email.com}
My name is "Hoanh" and my email is "hoanhan@email.com"

인터페이스 타입의 슬라이스

인터페이스를 저장할 수 있는 슬라이스를 만들자. 이 슬라이스에는 printer 인터페이스를 구현하는 모든 타입의 값이나 포인터를 저장할 수 있다.

value and pointer

entities := []printer{

이 슬라이스에 값을 저장하면, 인터페이스의 값은 복사본을 가지게 된다. 따라서 원본 데이터에 변경이 발생하더라도, 이를 확인할 수는 없다.

    u,

슬라이스에 포인터를 저장하면, 인터페이스의 값은 원본 데이터를 가리키는 주소 값을 복사하고 이를 가지게 된다. 따라서 원본 데이터의 변경을 확인할 수 있다.

    &u,
}

unameemail을 변경해보자.

u.name = "Hoanh An"
u.email = "hoanhan101@gmail.com"

슬라이스를 순회하면서 복사된 인터페이스의 값을 통해 print를 호출해보자.

for _, e := range entities {
    e.print()
}
My name is Hoanh and my email is hoanhan@email.com
My name is Hoanh An and my email is hoanhan101@gmail.com