이전 글에서 swift의 세 가지 메서드 실행 방법에 대해 알아보았다.
이번 글에서는 Table Dispatch를 하기 위한 메서드 저장 방식인 Virtual Table, Witness Table에 대해 알아보려고 한다.
WWDC2016에서 Swift의 퍼포먼스에 대한 세션에서 사용했던 코드를 중심으로 차이를 살펴보자.
Understanding Swift Performance
Swift의 메서드 실행 방법 (1) | Direct(Static) Dispatch/ Table(Dynamic) Dispatch/ Message Dispatch
Swift의 메서드 실행 방법 (2) | table 기반 메커니즘 Virtual Table과 Witness Table
Swift의 메서드 실행 방법 (3) | 프로토콜 채택한 구조체 Existential Container / ValueBuffer / VWT / PWT
class의 Virtual Table(VTable)
상속이 가능한 class의 특성상 각 class마다 본인의 VTable이 있고, 메서드 실행 시 테이블의 메서드 포인트(주소값)를 참조하여 실행시킨다.
class Drawable {
func draw() {}
}
class Point: Drawable {
let x, y: Double
override func draw() {
override print("\(x), \(y)")
}
}
class Line: Drawable {
let x1, y1: Double
let x2, y2: Double
override func draw() {
print(" \(x1), \(y1) - \(x2), \(y2)")
}
}
var drawables : [Drawable]
for d in drawables {
d.draw()
}
for문을 돌면서 이 drawables배열 안의 인스턴스에 대해 draw()를 호출하는 코드이다.
실제로 draw를 호출할 때(런타임), 컴파일러는 Drawble타입의 인스턴스의 메서드들의 정보가 저장된 virtual method table이라고 하는 것을 조회한다. 그리고 실행하기에 적합한 draw메서드를 찾고 실행하는 것이다.
그림으로 아주 간단하게 표현하면 이런 식이 될 것 같다.
하지만 모든 class가 이렇게 런타임에 Virtual Table을 조회해서 메서드를 실행하는 Dynamic Dispatch를 사용할 필요는 없다. class를 서브클래싱하지 않으면 final로 명시하면 되며, 컴파일러는 이를 보고 Static Dispatch를 하게 될 것이다.
성능면에서 Dynamic Dispatch보다 Static Dispatch가 유리하기 때문에 상속을 허락하지 않는 클래스의 경우에는 final을 써주면 좋겠죠?
struct의 프로토콜 채택과 Witness Table
Virtual Table은 클래스의 상속이 있을 때 필요하다고 했는데 Witness Table은 언제 사용되는 것일까?
우리는 이 시점에 struct가 protocol을 채택했을 때는 어떻게 메서드를 저장하는지에 대해 알아볼 필요가 있다.
근데 왜 하필 struct + protocol 이렇게 가정하는가!!
Static Dispatch로 메서드가 실행되는 그냥 struct 말고 왜 struct + protocol 경우를 봐야 하는가?
우리는 struct가 상속이 불가능하다는 것을 알고 있다. 그리고 상속이 불가능하거나 다중채택이 필요한 경우를 위해 protocol을 사용할 수 있다는 것도 알고 있다. struct가 상속이 불가능하다는 점을 프로토콜을 채택하는 것으로 극복할 수 있는 것이다.
하지만 struct + protocol 경우도 클래스 상속 코드예시와 마찬가지로 Swift 컴파일러는 어떤 draw를 호출해야 할지 직관적으로 알 수 없을 뿐더러 상속처럼 매 클래스마다 Vtable이 생기는 것이 아니기 때문에 프로토콜을 상속한 구조체 인스턴스의 메서드들은 어떻게 동작하는지 알아볼 필요가 있다. ( → 앞으로 차차 알아가겠지만 VTable이 아니라 Protocol Witness Table(PWT)을 통해 실행하고 싶은 메서드 특정이 가능하다.)
앞서 살펴본 코드와 비슷하지만 클래스 상속이 아닌 구조체가 프로토콜을 채택하는 것에 대해 알아보자.
protocol Drawable {
func draw()
}
struct Point: Drawable {
let x, y: Double
func draw() {
print("Point 구조체의 메서드 실행 => \(x), \(y)")
}
}
struct Line: Drawable {
let x1, y1: Double
let x2, y2: Double
func draw() {
print("Line 구조체의 메서드 실행 => \(x1), \(y1) - \(x2), \(y2)")
}
}
var drawables : [Drawable]
for d in drawables {
d.draw()
}
위에 클래스 예제랑 다른 것은 Drawable이 클래스가 아니라, 프로토콜이라는 점이다. 그리고 drawables라는 배열에는 Drawable 프로토콜 준수하는 타입이라면 모두 들어갈 수 있다.
Point와 Line이 클래스였을 때를 잠깐 다시 생각해 보면, 컴파일러는 d.draw()에서 컴파일타임에 어떤 draw(Point의 draw?, Line의 draw?)를 호출해야 할지 직관적으로 알 수 없기 때문에,런타임에 해당 타입의 정보를 가지고 vtable라는 것을 조회한 뒤, 해당 draw메서드를 찾아서 실행시켰다. 대표적 VTable 방식으로 클래스마다 본인 메서드들의 주소에 대해 테이블을 가지고 있는 것이다.
struct + protocol의 경우에는 상속기반 구조가 아니기 때문에 VTable을 생성하거나 메서드 실행 시 VTable 조회 같은 걸 하지 않는다.
클래스처럼 런타임에 vtable을 조회하는 것도 아니고, Drawable타입으로 저장된 구조체라는 단서만 있기 때문에 컴파일타임에 어떤 구조체인스턴스의 draw가 호출될지 알아내야 하는데 그럼 이렇게 struct + protocol의 경우에는 Swift는 어떻게 메서드를 찾아가서 실행하나?
답은, table기반 메커니즘인 Protocol Witness Table이다.
다음 글에서 더 자세하게 알아보겠지만 간단하게 설명하자면
Swift에서는 struct + protocol의 구조일 경우 구조체의 인스턴스들을 Stack 영역에 Existential Container라는 특수한 저장방법으로 저장하는데 Existential Container내부에 있는 Protocol Witness Table(PWT)의 참조를 따라 Protocol Witness Table(PWT)를 찾아가서 메서드를 실행하는 것이다.
이번 글에서 간단히 언급한 Existential Container와 그 내부의 ValueBuffer, VWP Reference, PWT Reference에 대해서는 다음 글에서 더 자세히 짚고 넘어가겠다!