Method Dispatch - Dynamic과 Static
Method Dispatch는 성능 최적화를 말할 때 꼭 나오는 단어 중 하나입니다.
Method Dispatch는 Static Dispatch와 Dynamic Dispatch가 있습니다.
Static Dispatch와 Dynamic Dispatch의 대결 구도는
class와 struct 대결 구도와 비슷한데요.
하나씩 천천히 알아보도록 합시다.
Method Dispatch
일단 Method Dispatch가 무엇인지 알아야 합니다.
Swift를 포함한 객체 지향 언어는
하위 클래스가 상위 클래스의 프로퍼티와 메서드를 오버라이드할 수 있습니다.
오버라이드를 하게되면 프로그램은 호출할 함수가 어떤 것인지 결정하는 과정이 필요한데요.
이때 사용하는 메커니즘을 Method Dispatch 라고 합니다.
예를 들어,
class Person {
func greeting() {
print("사람이 인사한다.")
}
}
class Baby: Person {
override func greeting() {
print("아기가 인사한다.")
}
}
Baby는 Person의 greeting 메서드를 오버라이드했습니다.
let person: Person = Person()
person.greeting()
let baby: Person = Baby()
baby.greeting()
이때 person과 baby는 어떤 greeting()을 호출할까요?
Person 타입이므로 Person의 greeting()이 호출될까요
아니면 Baby 인스턴스이므로 Baby의 greeting()이 호출될까요?
오늘 알아볼 Static Dispatch와 Dynamic Dispatch는
이 결정을 컴파일 타임에 결정할 수 있는냐 없는냐에 따라 갈립니다.
컴파일에 결정하냐, 런타임에 결정하냐에 따라 나뉘는거죠!
Dynamic Dispatch(Indirect Call)
Dynamic Dispatch는 어떤 메서드 구현을 사용할지를
런타임에 결정합니다.
위의 예시를 이어서 보면
baby는 Person 타입이지만 Baby 클래스를 업캐스팅해서 가리키고 있기 때문에
Person이 아니라 Baby의 greeting을 참조해야 합니다.
이렇게 컴파일러는 하위 클래스에서 오버라이딩이 될 경우를 대비해
상위 클래스의 메서드를 참조할지, 하위 클래스의 메서드를 참조할지 결정하는데요.
이 작업을 위해 Swift의 모든 서브클래스는 각자의 vTable이 존재합니다.
vTable에는 서브클래스가 재정의한 모든 메서드에 대해 다른 함수 포인터를 갖고 있는데요.
서브 클래스가 새로운 메서드를 추가하면 해당 메서드에 대한 함수 포인터가 배열 끝에 추가됩니다.
이 vTable을 참조하여 컴파일러가 런타임에 어떤 메서드를 실행할지 결정합니다.
런타임에 vTable을 읽고, 해당 위치로 점프해야 하므로
아래에서 알아볼 Static Dispatch 보다 느립니다.
오해하면 안 될 점은 오버라이딩의 유무와는 상관 없이
"가능성"만 있다면 Dynamic Dispatch로 동작합니다.
그래서 오버라이딩을 하지 않는 class를 Static Dispatch로 동작하게끔 하면
성능 최적화를 할 수 있습니다.
이에 대해서는 조금 더 아래에서 알아보도록 하죠.
Static Dispatch(Direct Call)
Static Dispatch는 컴파일 타임에 어떤 메서드를 사용할지 결정합니다.
상속이 발생하지 않는 경우 Static Dispatch로 동작합니다.
컴파일 타임에 메서드 포인터를 알기 때문에 vTable을 참조하지 않아도 되며,
함수가 호출되면 컴파일러는 수행할 함수의 메모리 주소로 바로 점프할 수 있습니다.
이로 인해 Dynamic Dispatch보다 더 빠릅니다.
struct Person {
func greeting() {
print("사람이 인사한다.")
}
}
struct는 상속이 아예 불가능합니다.
상속이 불가능하므로 오버라이딩이 불가능하고
항상 Person 구조체 안의 greeting()을 참조하겠죠.
따라서 struct의 모든 메서드는 Static Dispatch로 동작합니다.
class보다 struct의 성능이 더 좋은 이유에 대해
하나 더 알게 되었네요👍
Protocol에서의 Dispatch
프로토콜도 Dynamic Dispatch로 동작합니다.
프로토콜은 기본적으로 메서드의 선언부만 제공하므로
런타임에 어떤 곳에서 채택하고 있는지 체크해야 하기 때문입니다.
프로토콜을 채택하는 구조체는 어떨까요?
Swift에서 구조체는 상속은 안 되지만 프로토콜 채택은 가능합니다.
예를 들어,
protocol Greetable {
func greeting()
}
struct Human: Greetable {
func greeting() {
print("인간이 인사한다.")
}
}
여기에서 Human 구조체의 greeting은
Static으로 동작할까요 Dynamic으로 동작할까요?
이 대답은 하나만 생각하면 됩니다.
Greetable 프로토콜이 자신을 채택한 구조체를 알 수 있을까?
결국에는 런타임에 구현부를 찾아 가야겠죠?
그러므로 Dynamic Dispatch로 동작하겠네요.
PWT(Protocol Witness Table)
하지만 class의 Dynamic Dispatch와는 약간의 차이가 있어요.
구조체는 상속이 불가능하므로 부모라는 개념이 없습니다.
따라서 vTable이 아니라 PWT(Protocol Witness Table)를 사용해요.
PWT는 프로토콜을 채택하는 구조체 인스턴스가 각자 하나씩 가지고 있고
특정한 메서드에 대한 실제 구현을 PWT에에 연결시킵니다.
WWDC의 발표 자료입니다.
배열에 담긴 인스턴스들이 내부에 PWT를 가지고 있고
실제 구현에 대한 포인터가 들어갑니다.
결론은,
이렇게 런타임에 포인터를 참조하여 구현부를 찾아가야 하므로
프로토콜을 채택한 struct 메서드는 Dynamic Dispatch로 동작합니다.
더 생각해보기
extension은?
extension은 어떨까요?
예를 들어, 구조체를 extension 해서 오버라이딩 하면!?
정답은 extension은 오버라이딩을 할 수 없다 입니다!
"오버라이딩이 불가능하다"라는 extension의 특징때문에
extension에서 새로 정의한 메서드는 구조체, 클래스, 프로토콜 모두 Static Dispatch로 동작합니다.
Overloading은?
또 하나 오버로딩에 대해서도 궁금할 수 있는데요.
extension Person {
func greeting(name: String) {
print("안녕, \(name)")
}
}
오버라이딩은 하나의 메서드를 재정의하는 것이지만
오버로딩은 아예 새로운 메서드를 생성하는 것이므로
이 부분도 Dynamic Dispatch 와는 상관이 없습니다.
표로 정리하기
좋은 표 이미지가 있어서 가져와봤습니다. (참고의 마지막 링크)
Table이 Dynamic을 의미합니다.
위에서 봤듯이 Extension은 무조건 Static으로 동작한다는 것이 기억하면 좋을 내용같네요.
Dynamic Dispatch 최적화하기
오늘의 가장 큰 목표라고 할 수 있는건 Dynamic Dispatch 최적화입니다.
아무튼 객체 지향에서 다형성의 이유로 클래스의 사용과 오버라이딩은 피할 수가 없습니다.
하지만 상속하지 않아도 되는 클래스는 최적화하면 좋겠죠.
애플에서 안내하는 방법은 총 세 가지입니다.
1. final 사용하기
final은 오버라이딩을 할 수 없도록 제한합니다.
따라서 Dynamic Dispatch를 Static Dispatch로 사용할 수 있게 해줍니다.
Dynamic 예시에서 Person에 final을 붙이면 Baby에서 상속이 안 된다고 컴파일 에러가 발생합니다.
이렇게 클래스 전체에 final을 붙일 수도 있고
메서드 단독으로도 final을 붙일 수 있습니다.
이때는 클래스에는 컴파일 에러가 안 나지만 메서드 오버라이드쪽은 여전히 컴파일 에러가 발생합니다.
2. private 사용
private를 이용해 다른 파일에서는 참조할 수 없다는 것을 명시합니다.
그러면 컴파일러는 private 키워드가 참조될 수 있는 곳에서
오버라이딩이 되는지 알아서 판단할 수 있게 되는데요.
오버라이딩 되는 곳이 없다면 스스로 final 키워드를 추론해서 Static Dispatch로 동작합니다.
(기특한 녀석...)
class Human {
private var name: String = ""
private var alias: String = ""
var age: Int = 0
}
이렇게 private로 명시하면 name과 aliase는 Human 클래스에서만 알 수 있고
상속을 하더라도 알 수가 없으니 당연히 오버라이드도 불가능합니다.
따라서 private를 붙이면 Static Dispatch로 동작하여 성능 최적화가 가능해지는 것입니다.
물론 private를 붙이지 않은 age는
자동완성도 되고 오버라이드도 가능하기 때문에 Dynamic Dispatch로 동작합니다.
3. WMO(Whole Module Optimization) 사용하기
WMO(Whole Module Optimization)란 모듈 전체를 한 번에 컴파일하는 것입니다.
WMO 방식으로 컴파일을 하면 기본 접근제어자인 internal로 선언된 프로퍼티나 메서드는
전체 모듈에서 오버라이딩이 발생하는지 쉽게 파악할 수 있습니다.
컴파일러는 오버라이딩을 안 하는 internal 프로퍼티와 메서드를 final로 추론하기 때문에
Static Dispatch로 동작하게 한다고 합니다.
WMO를 쓰면 외부 모듈에서 접근 가능한 open 접근제어자를 제외하면
모두 컴파일 타임에 오버라이딩 체크를 해서 Static Dispatch로 동작한다고 하네요!
참고
https://heartbeat.fritz.ai/understanding-method-dispatch-in-swift-684801e718bc
https://hyunsikwon.github.io/swift/Swift-MethodDispatch-01/
https://jeonyeohun.tistory.com/340?category=874083
https://babbab2.tistory.com/143
https://www.rightpoint.com/rplabs/switch-method-dispatch-table
아직은 초보 개발자입니다.
더 효율적인 코드 훈수 환영합니다!
공감과 댓글 부탁드립니다.