티스토리 뷰
개요
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 이 가능해진다는 말이다.
그렇기에 모든 가능한 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 을 캐싱해두기에 이 계산은 한번만 수행하면 충분하다
이제 예제로 돌아가보면
|
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 을 생성해 두었다.
|
다른 동적 언어들
4번 라인과 같이 매번 실제 메쏘드들이 호출될때에 메쏘드 룩업을 생성하거나 캐시에서 찾아내는 작업을 해야 하는 것이다. → 매번 캐싱한걸 찾아야 해서 그렇다는 걸까? |
'golang' 카테고리의 다른 글
go build 의 -ldflags 옵션으로 빌드정보를 프로그램에 담아보자 (0) | 2020.07.22 |
---|---|
Rob Pike의 The Laws of Reflection 블로그 포스팅 분석 (0) | 2020.02.28 |
비밀번호 안전보관: bcrypt 를 알아보자 (2) | 2020.01.28 |
Go 깨알: if err := json.Unmarshal(bytes, &book2); err != nil { (0) | 2020.01.17 |
Go Slice Tricks (0) | 2019.12.27 |
- Total
- Today
- Yesterday
- 2023
- 인텔리제이
- websocket
- github
- 독서후기
- Gin
- strange
- golang
- intellij
- agile
- 잡학툰
- clean agile
- 노션
- 체호프
- 티스토리챌린지
- solid
- folklore
- ChatGPT
- OpenAI
- go
- 제이펍
- notion
- bun
- 엉클 밥
- API
- 영화
- 오블완
- Bug
- 클린 애자일
- 독서
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |