Dynamic Dispatch는 어떻게 이루어지는가? 클래스 VS 프로토콜
사실 이게 첫번째 글이 아니에요. 프로토콜은 어떻게 동작하고 채택당한놈은 어떻게 알고 필수 메서드들을 구현하라고 컴파일 에러가 뜨는지 궁금했던 저는 더 나아가 프로토콜을 채택한 다양한 타입들이 같은 배열에 들어갔을 때, 어떻게 각각 올바른 메서드를 호출할 수 있을까?. 꼬꼬무하다보니 여기까지 왔습니다..쫌 어려웠어여. 주에 한번씩 복습할예정!!
들어가보자~잇!
Dynmaic Dispatch
정적 디스패치와 달리 런타임에 호출할 메서드가 결정되는 것입니다
소스코드를 파싱하여 AST트리를 생성하고 의미분석하여 타입검사하고 중간언어인 SIL로 변환되는데 여기서 정적/동적 디스패치가 결정
Virtual Table
final 키워드가 붙지 않은 클래스는 상속이 가능하기에 dynmaic Dispatch가 이루어진다.. 런타임에 실제 객체의 타입을 확인하고 적절한 메서드를 호출하기 위해서이다. 여기까지는 다 알고 있는 사실!일껄요?
이를 가능하게 하는것이 바로 Virtual Table(V-Table)입니다.
그래서 각 Class마다 본인의 Virtual Table을 가지고 있고 메서드 호출 시 이 테이블의 주소값을 참조하여 실행합니다.
자 애플 공식영상의 코드와 이미지를 보면서 더 이해해보자. drawables라는 배열안에는 Drawable 클래스를 상속받은 Point, Line이 올수 있다. 뭐 Drawable자체가 올수도 있고, 배열의 요소들은 각각 힙에 있는 객체들을 가리키고 있죠 클래스니까!
그래서 draw함수를 호출할때 어떤 draw를 호출하는 것인가? 컴파일 타임에는 모르는거죠 이때 V-table이 활용된다.
V-table은 정적메모리(정확히는 Data영역의 클래스 메타데이터)에 저장되며, 런타임에 이 테이블을 조회하여 올바른 메서드를 호출할수 있어요. 각 클래스 인스턴스는 자신의 맞는 V-table을 가리키는 포인터를 가지고 있다!!!
Protocol Witness Table(PWT)
이제 프로토콜에서는 어떻게 작동하는지 살펴보겠습니다. 클래스와 달리 구조체나 열거형은 상속이 불가능하므로, V-table을 사용할
수 없죠!
새로운 예시 등장!! 이전과 비슷하지만 Drawable이 class->Protocol, Line과 Point는 Class->Struct로 바뀌고 프로토콜을 채택하고 있어요
Point와 Line은 구조체. 우선 명심해야 할것은 일반적으로 struct가 프로토콜을 채택해도 정적 디스패치다. 하지만 이 경우 컴파일 타임때는 drawables배열에 어떤 draw를 불러야할지 모르기에 동적 디스패치이다. 그렇다면 어떻게 컴파일러가 올바른 draw를 부를수 있을까? 여기서 Protocol Witness Table이 나온다.
테이블의 엔트리는 해당 타입의 구현에 연결이 되고 두번째 사진과 같이 메서드를 찾을수 있다.
먼저 보면 Line의 프로퍼티는 4개, Point는 2개이다. 그런데 같은 배열에 넣고 있다. 배열은 고정된 offset을 저장하려 하는데 어떻게 저장하는 것일까? 또 새로운 개념이 등장한ㄷ ㄷㄷㄷㄷ
Existential Container
Existential Container는 프로토콜 타입의 값을 저장하기 위한 컨테이너로 총 5Words(Word는 CPU가 한번에 다루는 데이터의 단위)컨테이너로 이루어져 있습니다. 3칸의 valueBuffer와 1칸의 Value Witness Table 포인터, 1칸의 protocol Witness Table 포인터로 이루어져있죠. value Buffer에 우리는 값을 저장하는거죠. 보면 2가지 프로퍼티가 있는 Point는 잘 담긴다. 하지만 4개인 Line은 ?
힙에 메모리를 할당하고 해당 메모리에 대한 포인터를 Existential Container에 담는다. 흠 벌써 같은 프로토콜로 이루저인 구조체인데 하나는 힙을 사용한다. 그러면 이 컨테이너는 어떻게 관리하는걸까?
Value Witness Table
이번엔 Value Witness Table을 알아야한다. Value의 수명을 관리하며 타입마다 각각의 Value Witness Table이 있다.
먼저 라인VWT를 보자 Line은 3개보다 더 많은 프로퍼티를 가지고 있어서 힙에 메모리 할당을 해야했다. 이 allocate함수는 힙에 메모리를 할당하고, 해당 메모리에 대한 포인터를 Existential Container의 valueBuffer내에 저장한다.
그 다음은 Swift의 로컬변수를 초기화하는 assignment소스에서 Existentail Container로 값을 복사해야한다. Point는 딱 valueBuffer에 맞기에 힙영역을 할당하는 것도 안했다. 마찬가지로 copy에서는 이 ValueBuffer에 값을 복사할것이다. 라인은 heap에 값들을 복사한다.
사용을 다하여 로컬변수의 수명이 끝났다. 그러면 Swift는 Value Witness Table에서 Destruct 엔트리 호출한다. 이게 값에 대한 레퍼런스 카운트를 감소시킴. 그리고 나서 Deallocate를 통해 Line은 힙메모리 할당 해제한다
자 아까의 질문을 다시 돌아가보자 어떻게 배열의 요소가 각각에 맞는 메서드를 호출할 수 있는걸까 Vtable로 주소 공유하는 것도 아닌데
각각의 Existential Container가 만들어지고 vwt와 pwt에 대한 레퍼런스를 저장하는데 vwt는 저장프로퍼티를 관리하고, pwt에서는 프로토콜 메서드를 관리한다.
저 위에껏만 보면 swift는 컴파일타임에는 어떤 draw를 호출할지 모를것이다. 그래서 아래에 있는 코드가 자동 생성된다고 한다.
이 코드는 언제생기는걸까?
위에서 우리가 그림으로 만났던 3개의 valueBuffer와 ValueWitnessTable과 프로토콜이름+PWT가 exeistential Container구조체가 만들어진다.
매개변수에 대한 로컬변수가 만들어지고 argument가 할당된다. drawACopy를 실행하면 argument로 existetnial Container구조체를 넘긴다. 그래서 스택에 만든다. 그런다음 vwt와 pwt를 읽고 local 필드 초기화해준다. 뭐 라인이면 이전에 설명했던것처럼 3칸넘어가니 힙에 저장.
다시 보면 vwt, pwt는 힙에 저장되나보다.
자 마지막 Line을 보면 arugment의 값을 local ValueBuffer로 복사하고 있다. Point가 왼쪽. 힙에 저장하는 라인이오른쪽
draw를 호출하는 순간 Swift는 existential container 필드에서 pwt조회하고 해당 table의 fixed offset에 있는 메서드 조회하여 구현으로 이동한다. 와 이걸 찾으려 우리가 빙빙 공부
결국 정리해보자.
1. 프로토콜을 채택한 무언가가 선언될때 Existential Container가 생성된다.
2. 여기에는 총 5Words로 이루어져 있다. 3Words(3칸): Value Buffer / 1Words(1칸): Value Witness Table / 1Words(1칸): Protocol Witness Table
3. 해당 구조체의 프로퍼티가 3개보다
- 많다? VWT의 Allocate method활용해 힙영역에 메모리 할당하고 Value Buffer에는 해당 포인터를 저장한다
- 적다? Value Buffer에 저장
4. 메서드 호출하면 PWT조회하여 PWT의 고정된 오프셋에서 draw()메서드 실제 구현을 찾는다. 실제 구현으로 점프하여 메서드 실행
- VWT를 통해 인스턴스가 생성되고 메모리에서 해제되기까지의 과정을 함
- Allocate: 인스턴스 값들의 메모리 할당. 이전에 말했던 것처럼 3 Words를 초과해? 힙에 메모리 할당하고 포인터만 버퍼가 가지고 그 이하면 valueBuffer에 메로리를 할당한다.
- Copy: 실제로 값 할당한다. 초과하면 힙영역에 값 복사하고, 그 이하면 valuBuffer에 값 복사
- destruct: 해당 인스턴스가 더이상 쓰이는 곳이 없을때 레퍼런스 카운트를 감소
- deallocate: 힙 메모리 해제
- PWT를 통해 프로토콜에 해당하는 메서드를 호출한다.
- 실제 VWT와 PWT는 힙 영역에 저장되어 있다.
5. 메모리 해제: VMT에 의해 해제하고 힙 메모리도 해제
그러면 프로토콜을 채택한 타입이 필수 메서드 구현하지 않으면 어떻게 컴파일 에러가 뜨는걸까?
저 Protocol Witness Table과 관련된 코드 생성이 컴파일 타임에 생긴다. 그런데 컴파일러가 Protocol Witness Table 생성할때 프로토콜의 각 요구사항에 대응하는 구현을 찾아야 하는데, 없으면 PWT가 완성되지 않아 에러생김.
Extension을 통해 만들어놓은 공통 구현은?
이건 두가지 케이스가 있다. 1. 프로토콜의 필수메서드를 구현해놓은경우 요구조건이기 때문에 PWT가 생기고 Dynamic Dispatch이다.
2. 필수메서드말고 새로운 메서드를 구현해놓은 경우 이건 Static Dispatch이다. 컴파일시 다 알기에
그러면 프로토콜 상속은 어떻게 이루어지는걸까?
protocol Drawable {
func draw()
}
protocol AnimatableDrawable: Drawable {
func animate()
}
struct SimpleShape: Drawable {
func draw() { print("Drawing simple shape") }
}
struct AnimatedShape: AnimatableDrawable {
func draw() { print("Drawing animated shape") }
func animate() { print("Animating shape") }
}
// 사용 예시
func renderDrawable(_ drawable: Drawable) {
drawable.draw()
// 런타임에 AnimatableDrawable인지 확인
if let animatable = drawable as? AnimatableDrawable {
animatable.animate()
}
}
let simple: Drawable = SimpleShape()
let animated: Drawable = AnimatedShape()
renderDrawable(simple) // "Drawing simple shape"
renderDrawable(animated) // "Drawing animated shape", "Animating shape"
PWT_Drawable {
draw: implementation_ptr
}
// AnimatableDrawable PWT (Drawable 상속)
PWT_AnimatableDrawable {
// 부모 프로토콜의 요구사항
draw: implementation_ptr,
// 추가된 요구사항
animate: implementation_ptr
}
struct Shape: AnimatableDrawable {
func draw() { print("Drawing shape") }
func animate() { print("Animating shape") }
}
- AnimatedShape는 Drawable_PWT와 AnimatableDrawable_PWT를 각각 가짐.
- animated: Drawable = AnimatedShape()로 저장되면 Drawable_PWT만 사용됨.
- if let animatable = drawable as? AnimatableDrawable 시, 실제 타입(AnimatedShape)을 검사하고 AnimatableDrawable_PWT를 가져옴.
- Existential Container는 값과 PWT를 함께 저장하여 동작.
결론적으로, animated의 경우
PWT는 Drawable과 AnimatableDrawable 각각 존재하지만,
변수가 Drawable로 선언될 경우 초기에는 Drawable_PWT만 사용되며,
다운캐스팅을 통해 AnimatableDrawable_PWT를 동적으로 참조한다.