[Go] Interface 구현과 Reflection Package

[Go] Interface 구현과  Reflection Package
Photo by Scott Graham / Unsplash

[요약]

  • Go의 interface는 내부적으로 한 쌍의 word로 구성된다. 하나는 itable을, 하나는 data를 가리킨다.
  • Go의 컴파일러는 concrete 타입과 interface 타입마다 메소드 리스트를 작성한다. 런타임 때 이 둘을 조합하여 itable을 생성한다.
  • Golang의 reflect 패키지는 interface 타입 객체로부터 concrete 타입 및 값을 다룰 수 있도록 도와준다.  
  • Interface와 reflect 패키지를 활용하면 인자의 타입과 상관 없이 처리할 수 있는 함수를 생성하기 용이하다.

목차

  1. Interface
  2. Reflect
  3. 실제 사용 예제 살펴보기: binary.Read

Interface

언어가 메소드를 다루는 방법은 크게 두 가지가 있다.

  • 호출할 메소드를 담은 테이블을 static하게 준비 (C++, JAVA)
  • 호출할 때마다 메소드를 찾고 캐싱 (Js, Python)

Golang이 interface를 사용하여 메소드를 다루는 방법은 위 두 방법 사이의 어딘가 즈음이다. 메소드 테이블을 가지고 있지만 runtime에 계산하여 사용하기 때문이다.

type Stringer interface {
    String() string
}

type Binary uint64

func (i Binary) String() string {
    return strconv.Uitob64(i.Get(), 2)
}

func (i Binary) Get() uint64 {
    return uint64(i)
}
https://research.swtch.com/interfaces

위 코드를 예시로 살펴보자. Stringer 인터페이스와 Binary 타입이 정의되어 있다.

b := Binary(200)
s := Stringer(b)

위처럼 변수들을 선언했을 때 s의 내부 구현을 그림으로 나타내면 아래와 같다.

https://research.swtch.com/interfaces

Binary는 uint64이기 때문에 2개의 word로 되어 있다(32bit machine 가정).

Inteface는 두 word의 쌍으로 구성된다. 하나는 interface에 저장된 타입에 메타 데이터를 담고 있는 table을 가리키는 포인터(tab), 나머지 하나는 데이터를 가리키는 포인터(data)이다. tab이 가리키는 table을 itable이라고 한다.

itable의 첫번째 항목에는 concrete 타입(Binary)이 저장된다. itable에는 타입에 대한 메타데이터가 담겨있을 뿐만 아니라 메소드를 가리키는 함수 포인터들이 저장되어 있다. itable은 concrete 타입이 아니라 inteface 타입과 대응되기 때문에 inteface를 만족하는 메소드들만 가지고 있다. Binary가 Stringer가 가지고 있지 않은 메소드들을 가지고 있어도 itable에는 존재하지 않는다.

데이터를 가리키는 포인터의 대상은 복사로 값을 저장한다. 즉, s의 값을 변경하여도 b의 값은 변하지 않는다.

itable은 (interface 타입, concrete 타입) 페어마다 생성되기 때문에 컴파일러가 미리 계산한 후 사용하는 것은 비효율적이다. 그래서 Go 컴파일러는 페어마다 itable을 생성하는 것이 아니라 interface 타입과 concrete 타입마다 테이블을 생성한 후 runtime에 계산하는 방식을 택했다.

Go 컴파일러는 concrete 타입마다 type description structure를 작성한다. type description structure은 해당 타입에 의해 구현된 메소드 리스트를 가진다. 비슷한 방식으로 interface 타입마다 메소드 리스트를 생성한다. 컴파일러의 역할은 여기까지고 interface 런타임에서 interface 타입 메소드 테이블과 concrete 타입 메소드 테이블을 계산해 itable을 생성한다. 캐싱을 사용하기 때문에 interface를 사용할 때마다 계산하지 않고 단 한번만 계산 과정을 수행한다.

Reflect

Reflect 패키지는 interface 객체의 concrete 타입과 값을 다룰 때 유용하다.

package main

import (
	"fmt"
    "reflect"
)

func main() {
	var x float64 = 3.4
    fmt.Println("type:", reflect.TypeOf(x))
    fmt.Println("value:", reflect.ValueOf(x).String())
}

---
출력
type: float64
value: <float64 Value>

reflect.TypeOf는 empty interface 타입을 인자로 받는다. Empty interface에서 타입 정보를 얻기 위해 unpack을 하게 된다. 앞서 살펴보았던 itable의 타입 정보를 가져오는 것이다.

reflect.ValueOf는 interface의 data 포인터가 가리키고 있는 값을 가져온다.

reflect의 ValueOf를 통해 얻은 value는 타입에 따라 변경할 수 있는지 없는지가 결정된다.

var x float64 = 3.4
v := reflect.ValueOf(x)
v.SetFloat(7.1) // panic

---

var x float64 = 3.4
p := reflect.ValueOf(&x) // Note: take the address of x
v := p.Elem()
v.SetFloat(7.1)

Settability는 reflection 객체를 만들 때 사용된 실제 스토리지를 변경할 수 있는지에 따라 달라진다. 즉, Settability는 원본 item을 reflection 객체가 가지고 있는지에 따라 다르다.

그래서 위 예제에서 포인터 값을 reflect한 경우는 SetFloat을 통해 값을 변경할 수 있으나 그냥 float64 값을 가져온 경우는 copy가 되기 때문에 SetFloat 호출시 panic이 일어난다.

실제 사용 예제 살펴보기: binary.Read

실제 go의 패키지에서 어떻게 reflect가 사용되는지 알아보자.

go의 standard library인 encoding/binary의 Read를 보자.

func Read(r io.Reader, order ByteOrder, data any) error {
	// Fast path for basic types and slices.
	if n := intDataSize(data); n != 0 {
		bs := make([]byte, n)
		if _, err := io.ReadFull(r, bs); err != nil {
			return err
		}
		switch data := data.(type) {
		case *bool:
			*data = bs[0] != 0
		case *int8:
			*data = int8(bs[0])
		...
		case []float64:
			for i := range data {
				data[i] = math.Float64frombits(order.Uint64(bs[8*i:]))
			}
		default:
			n = 0 // fast path doesn't apply
		}
		if n != 0 {
			return nil
		}
	}

	// Fallback to reflect-based decoding.
	v := reflect.ValueOf(data)
	size := -1
	switch v.Kind() {
	case reflect.Pointer:
		v = v.Elem()
		size = dataSize(v)
	case reflect.Slice:
		size = dataSize(v)
	}
	if size < 0 {
		return errors.New("binary.Read: invalid type " + reflect.TypeOf(data).String())
	}
	d := &decoder{order: order, buf: make([]byte, size)}
	if _, err := io.ReadFull(r, d.buf); err != nil {
		return err
	}
	d.value(v)
	return nil
}

Read 함수의 인자 data는 타입이 any이다. 어떤 타입의 data도 Read를 사용할 수 있어 범용성이 높다. Read 함수 내부에서 동적으로 타입에 따라 로직을 분리하였기 때문이다. 이 과정에서 reflect 패키지가 사용된다.

Read 함수의 내부는 크게 두 부분으로 나눌 수 있다. n := intDataSize(data)에서 n이 양수가 나와 if문에서 return이 되는 경우와 그렇지 않은 경우다. intDataSize는 bool, int, float 등 빌트인 타입의 크기를 돌려주는 함수다.

첫번째 조건문은 data의 타입이 미리 정의한 빌트인 타입인 경우 Reader로부터 byte 슬라이스로 값을 읽고 data에 복사한다.

data의 타입이 빌트인 타입이 아닌 경우 reflect 패키지를 통해 값과 값의 크기를 가져온다. 포인터 타입인 경우 복사해야할 값이 포인터가 가리키는 값이므로 v = v.Elem()이 존재하는 것을 확인할 수 있다. 마찬가지로 값의 크기만큼 byte 슬라이스를 할당한 이후 Reader로부터 읽어오고 해당 값을 v에 복사한다(d.value).

Decoder의 value 메소드는 내부적으로 reflect의 Set 메소드들을 사용한다. 앞서 Set 메소드를 사용하려면 reflect의 value를 변경하였을 때 원본 값도 변경할 수 있어야 한다고 했었다. Read의 Switch Case 문에서 다루는 타입 종류는 포인터와 슬라이스다. 이 두 타입은 원본 값에 간접적으로 접근할 수 있으므로 reflect의 Set 메소드들을 문제없이 사용할 수 있다.


Reference

Go Data Structures: Interface

The Laws of Reflection