티스토리 뷰

반응형

개요

 

Russ Cox 인터페이스 글을 기반으로 Interface 대해 깊이 들여다 본다.

링크: https://research.swtch.com/interfaces

 

사용법

 

예제 링크: https://play.golang.org/p/AEHmlYtqkAy

 

1) ReadCloser 라는 인터페이스를 정의했다.

- Read() Close() 라는 메쏘드를 가진 type 이기만 하면

- ReadCloser 인터페이스를 만족한다.

 

- 회사가 구인 광고에 워드와 엑셀을 있는 사람 이라고 올렸을때

- 내가 워드, 엑셀, 파워포인트, 포토샵을 있으면 지원이 가능한 것이다.

 

2) ReadAndClose() 라는 함수는 ReadCloser 인터페이스를 만족하는 타입 파라미터로 받는다.

그리고는 해당 타입의 메쏘드들을 사용한다.

- r.Read(), r.Close()

 

3) 실제로 ReadCloser 인터페이스를 만족하는 타입을 만들어보자.

- 아무 타입이나 정의하고

- 말도 안되는 타입의 메쏘드를 정의해주면 된다.

 

 

 

 

 

 

 

예제 링크: https://play.golang.org/p/2ZSz-qmNIS9

 

예제의 핵심은 interface runtime 에서 dynamic 하게도 type check 가능하다는 것이다.

여기서는 인터페이스가 Stringer 인터페이스 타입인지를 확인한다.

 

1) ToString() interface{}, empty interface type 파라미터로 받는다.

2) main() 함수에서 Binary type 값을 넣어도, empty interface  type 으로 타입 캐스팅 된다.

3) 그리고 다시 Stringer type 인지 type assertion 다음

- 맞다면 String() 메쏘드가 있을 것이니

- String() 불러다 쓴다.

 

* 기존 예제의 몇몇 패키지 함수들이 deprecate 되어 수정하였음

 

 

 

 

Interface Values

 

메쏘드를 가진 프로그래밍 언어는 둘로 나눌 있다.

 

1) 모든 메쏘드 콜의 테이블을 정적으로 준비해두는 녀석들

- C++,  Java

메모리 많이 잡아 먹겠네, 모든 경우의 만들어 둬야 하는 건가?

 

2) 각각에 메쏘드들에 대한 Lookup 가지고, 캐싱을 추가해서 효과적으로 호출을 하는 녀석들

- Smalltalk 이를 흉내낸 언어들 (JavaScript, Python 등등)

-

 

Go 둘의 중간쯤이다.

- 메쏘드 테이블을 가지지만, 그것을 런타임에 계산한다.

 

2021-02-10: 특정 타입이 특정 인터페이스를 만족한다면, 

인터페이스가 요구하는 behavior(=메서드)에 대한 타입의 실제 구현 메서드 코드의 매핑을 실시간으로 한다는 것이다.

이걸 빨리 하려면 어떻게 하면 될까? 아래에서 알아보자  

 

워밍업

 

1) type Binary int64 라고 선언하고,

2) b:= Binary(200) 이라고 정의, 초기화 했다면

3) 32비트 머신에서, 2 word 메모리 공간에 자리잡게 된다.

 

Binary 라는 타입의 변수 하나가 메모리에 어떻게 자리잡는가를 보았다.

 

이번에는 interface value 보자.

 

interface value 개의 word 표현되며, 이녀석들은 포인터이다.

1) 인터페이스에 저장된 타입의 정보를 가리키는 포인터

2) 인터페이스에 저장된 데이터를 가리키는 포인터

 

type Binary int64

b := Binary(200)

s := Stringer(b) 라고 할당하면 어떻게 될까? Binary 타입에서 interface{} 타입으로 타입 캐스팅을 한거다.

 

이때 s 인터페이스 타입이니, 해당 인터페이스 값에 대한 개의 포인터 가지게 것이다.

 

1) 타입정보를 가리키는 포인터 (오른쪽 아래로 가는 화살표)

2) 데이터를 가리키는 포인터

type Binary int64

b := Binary(200)

s := Stringer(b) 라고 할당하면 어떻게 될까? Binary 타입에서 interface{} 타입으로 타입 컨버젼을 한거다.

 

이때 s 인터페이스 타입이니, 해당 인터페이스 값에 대한 개의 포인터 가지게 것이다.

- 물론 Binary 라는 타입이 Stringer 인터페이스 타입을 만족하는 메서드가 구현되어 있어야 한다

 

1) itable 가리키는 포인터 (오른쪽 아래로 가는 화살표)

2) 데이터를 가리키는 포인터

 

예제: https://play.golang.org/p/NPuLZ3SF0ou

 

관련 없기는 한데 암튼…

Stringer 타입 컨버젼을 하니 Print() 없게 되었다.

 

번째 word (tab word)

 

인터페이스의 번째 워드가 가리키는 것은 interface table (= itable) 이다.

 

itable

1) 타입에 대한 메타정보를 담고

2) 나머지는 function pointer 리스트이다

  itable interface type 의미하지 dynamic type 아님에 주의하자

 

위의 그림으로 보면

1) type Binary 안고있는, Stringer 위한 itable

2) Stringer 만족하는 메쏘드 리스트를 가지고 있다. (*Binary).String 이다

type Binary 가지고 있는 또다른 메쏘드인 Get 리스트에 없다!

번째 word (data word)

 

번째 word 실제 데이터를 가리킨다. 우리가 예에서 본다면 정확히는 b 복사본이다.

따라서 s Stringer = b 라고 assign 할때에 b 엄청나게 크다해도 힙에 할당한 다음,

인터페이스에는 포인터만 저장한다.

- 만약 데이터가 word 안에 들어간다면 힙에 저장할 필요가 없잖아? 이런 최적화는 이따 이야기한다.

 

인터페이스 값이 특정 타입인지 체크하려면

1) 컴파일러는 C 언어에서 s.tab->type 같은 방식으로 포인터를 얻은 다음

2) 확인하려는 type 비교한다.

타입이 같다면 s.data dereferencing 해서 복사할 있다 (type assertion!)

 

메쏘드 호출

1) s.String 호출하려면, 컴파일러는 C 언어에서 s.tab->fun[0] 같은 방식으로 포인터를 구한다

2) 그리고 인터페이스 값의 data word 함수의 첫번째 인자 전달한다.

- s.tab->fun[0](s.data) 같은 식이다.

3) 인터페이스에서 메쏘드를 호출하는 부분에서는 실제 data word 가리키는 값이 얼마나 큰지 알지 못한다.

 

 

Computing the Itable

 

타입은 메쏘드를 가진다.

타입을 특정 인터페이스로 type casting 한다면 해당 인터페이스 타입 만족하는 메쏘드만을 가져다 있게 된다.

그러니 해당하는 메쏘드만의 테이블을 만들어야 하는 것이다. 이것이 Computing Itable 의미이다.

Go 동적 타입 컨버젼이다.

 

type MyType int64 라고 할때에 MyType Read(), Write(), Close() 라는 메쏘드를 가지고 있다면

런타임에서 아래 인터페이스 타입으로 type casting 가능해진다는 말이다.

 

type Reader interface {

Read()

}

type ReadWriter interface {

Read()

Write()

}

type ReadWriteCloser interface {

Read()

Write()

Close()

}

 

그렇기에 모든 가능한 itable 컴파일러나 링크가 미리 계산해둘 없는 것이다.

1) interface type - concrete type 조합은 너무나도 많고,

2) 대부분 사용하지도 않을 그것들을 미리 계산해 둔다는 것은 낭비이다.

MyType Reader, ReadWriter, ReadWriteCloser 타입이 되었을때에 대하여 일일이 Itable 만들 없다는

 

컴파일러의 역할

 

컴파일러는 단지 type descriptoin structure 생성해둘 뿐이다.

- (MyType 같은) concrete type 마다가 가진 메쏘드들의 정보를 만들어둔다는

- 이런 concrete type 들마다의 type description structure 해당 타입의 메쏘드들 리스트를 가지고 있다.

 

컴파일러는 interface type 들에 대해서도 마찬가지로 type description structure 만들어둔다.

- Stringer 라는 interface type 이라면 String() 이라는 메쏘드가 있겠지?

 

런타임에서의 동작

 

이제 런타임에서는 어떻게 될까? interface runtime

1) interface type type description structure 있는 메쏘드 테이블의 메쏘드들을

2) contrete type type descriptoin structure  있는 메쏘드 테이블의 메쏘드들 중에서 찾아

3) itable 계산해낸다.

런타임은 계산해낸 itable 캐싱해두기에 계산은 한번만 수행하면 충분하다

 

이제 예제로 돌아가보면

 

 

1) Stringer 인터페이스는 메쏘드 테이블에 String() 이라는 하나의 메쏘드 밖에 없지만

2) Binary 메쏘드 테이블에 개의 메쏘드가 있다. Get() String()

 

interface type ni 개의 메쏘드가 있고, concrete type nt 개의 메쏘드가 있다면

1) 인터페이스 메쏘드 콘크리트 메쏘드 매핑에는 O(ni * nt) 것이다.

2) 하지만 메쏘드 테이블을 정렬한 동시에 찾아나가면 O(ni + nt) 정도면 된다

 

Memory Optimizations

 

인터페이스는 개의 포인터로 구성되어 있다고 이야기하였다.

하지만 상황에 따라 개선될 부분이 있다.

 

전형적인 인터페이스 인스턴스는 왼쪽과 같다.

최적화 번째. interface type 메쏘드가 없는 경우

 

원래 타입 가리키는 포인터이면 충분하다.

경우 itable 필요없고 바로 원래 타입 가리키게 된다.

 

인터페이스 타입이 정적인 메쏘드를 가지는 경우에는, 아무것도 없거나, 메쏘드를 명시하거나 하면,

컴파일러는 어떤 표현이 프로그램에서 사용될 지를 알게 된다. ( 부분은 정확히 이해가 안됨)

최적화 번째, interface value 하나의 word 들어간다면

 

32비트 머신에서는 4바이트 이내라면, 힙에 저장한 포인터로 가리킬 필요가 없다.

type Binary32 uint32 라고 정의했다면 interface data 영역에 바로 넣을 있는 것이다.

 

진짜 값이 메모리에 저장되고 포인터로 가리킬지,

그냥 interface data 영역에 저장할지는 타입의 크기에 좌우된다.

 

컴파일러는 타입의 메쏘드 테이블의 함수들을 정렬하여 word 전달될 제대로 처리하기 위해 준비한다.

정렬된 메쏘드 테이블의 함수들은 런타임에서 필요시에 itable 복사될 것이다.

 

direct or dereferencing

 

리시버 타입이 워드하나에 들어온다면 바로 사용된다. 그렇지 않으면 dereference 한다

 

왼쪽의 다이어그램이 보여주는게 그거다.

1) (*Binary).String()

2) Binary32.String() 차이인거다.

 

 

인터페이스 (왼쪽 그림)

 

만약 인터페이스 인데다 data 워드 이하의 크기라면 최적화 적용될 것이다.

1) itable 필요없이 타입을 바로 가리키고

2) 값도 바로 넣을 있다.

 

Method Lookup Performance

 

Smalltalk 많은 다이나믹 시스템은

1) 메쏘드 호출때마다, 호출때마다, 호출때마다

2) 메쏘드 lookup 수행한다.

 

속도를 위해 간단한 one-entry cache 사용하는데 멀티쓰레드의 경우에는 조심해서 사용해야 한다.

1) 레이스 컨디션도 조심해야지만

2) 메모리 경합의 원인이 되기도 한다

 

Go 동적 메쏘드 룩업과 함께 정적 타이핑 힌트도 제공하기에

룩업을 호출하는 사이트에서 인터페이스에 값이 저장되는 포인트로 옮길 수도 있다. (이해안됨)

 

예제를 보며 좀더 이해도를 높여보자

 

 

1) 인터페이스 타입인 any 정의하고

2) any 인터페이스 Stringer 타입으로 동적 컨버젼을 해서 s 할당해준다

3) 그리고는 100 동안 출력을 해준다.

 

과연 예제로 무얼 이야기하려는 걸까?

 

Go 경우

 

1) s := any.(Stringer) 라는 코드 부분에서 itable 계산하거나 캐시에서 찾는다.

2) s.String() 이라는 부분에서 메모리 fetch 들과 한번의 간접 콜이 발생한다.

다른 동적 언어들

 

만약 Smalltalk (또는 Python, JavaScript) 같은 동적 언어였다면 어떠했을까?

4번째 라인에서 매번 메쏘드 룩업을 것이다. 캐싱을 하면 그나마 비용이 줄어들겠지만

Go   한번의 indirect call instruction 보다는 여전히 비싸다.

 

나만의 이해를 덧붙여 본다.

Go 경우

 

1) 2 라인에서 이미 s 라는 인터페이스 타입에 대한 itable 생성해 두었다.

2) 그래서 4 라인에서 s.String() 호출하면

- 일단 itable 함수정보를 가리키고

- 다시 함수정보에서 실제 concrete type 메쏘드를 호출 하는 것이다.

- concrete type 메쏘드들은 이미 컴파일시에 만들어져 있는 것이다.

type description structure!

다른 동적 언어들

 

4 라인과 같이 매번 실제 메쏘드들이 호출될때에 메쏘드 룩업을 생성하거나

캐시에서 찾아내는 작업을 해야 하는 것이다.

매번 캐싱한걸 찾아야 해서 그렇다는 걸까?

 

 

반응형
댓글
댓글쓰기 폼