Reference
- 내용전반(한글) : Rhyno's DevLife Log
- 내용전반(영어) : Static vs Dynamic Dispatch in Swift: A decisive choice
- 내용전반(영어, 가장 자세함) : Method Dispatch in Swift
- Swift 타입별 Dispatch 방식 정리 : 소들이님 블로그
- Table/Message Dispatch : Rhyno's DevLife Log
- Method Swizzling : Rhyno's DevLife Log
- Why can't override in extension : stackoverflow
Remind
- 메서드가 초기선언에 정의되느냐, extension에 정의되느냐에 따라 Dispatch 방식에 차이가 있다
- 프로토콜 extension의 기본구현 메서드는, 프로토콜 초기선언에서 요구사항으로도 요구하느냐에 따라 Dynamic/Static이 나뉜다
- 요구사항이 아닌 프로토콜 기본구현 메서드를 concrete 타입이 재정의하면 Static을 사용한다
- Swift에서 Message Dispatch를 사용하려면 Objective-C 런타임의 도움이 필요하다
- 부모 메서드를 자식 extension에서 override ->
@objc dynamic
- 부모 extension 메서드를 자식 초기선언에서 override ->
@objc
Method Dispatch란, 어떤 인스턴스의 메서드 호출구문에서 실제로 호출될 메서드를 선택하는 것을 말합니다. 이게 선택의 문제인가? 당연히 자기 타입 선언부에 정의된 메서드를 호출하는 것 아닌가? 하고 생각될 수 있지만, 아래의 코드를 살펴봅시다
class Parent {
func sayHello() {
print("Hello Parent")
}
}
class Child: Parent {
override func sayHello() {
print("Hello Child")
}
}
let person: Parent = Child()
person.sayHello() // print "Hello Parent? Child?"
위 코드에서 person
변수의 명목상 타입이 Parent
더라도 Child
인스턴스가 할당될 수 있으며, sayHello()
메서드를 호출하면 변수의 실제 타입인 Child의 sayHello가 호출됩니다. 이는 객체지향언어의 다형성
개념에 의한 것입니다. 위 예시처럼 어떤 메서드를 호출할지 모호한 경우들에선, "호출될 메서드를 선택"하는 것을 컴파일 타임에 결정할 수 없으므로 런타임에 하도록 넘기게 됩니다
▪️ Static / Dynamic Dispatch
이처럼 객체지향언어의 "다형성"을 지원하기 위해 Method Dispatch를 런타임에 할 수도 있어야 합니다. Method Dispatch는 컴파일 타임에 가능하냐, 런타임에서야 가능하냐에 따라 크게 2가지 타입으로 구분됩니다 (이 외에 Inline, Virtual Dispatch가 있지만 이번 포스팅에선 다루지 않습니다)
특징
▪️ 변수의 명목상 타입에 맞춰 메서드를 호출합니다
▪️ 메서드 선택이 모호하지 않아 컴파일 타임에 결정할 수 있습니다
▪️ 다만, 다형성을 활용하기 어렵게 됩니다.
▪️ 어느 메서드가 호출될지 이미 알고 있으므로, Dynamic에 비해 성능 상 유리합니다
▪️ Dynamic에 비해 최적화의 기회가 많습니다 (ex. inlining)
❗️ inlining이란?
메서드 호출부를 실제 구현체로 치환하여, 메서드 구현체 주소값으로 점프하거나 Stack push/pop과 같은 관련 동작을 생략할 수 있습니다. inlining은 함수 내에서 또 다른 함수를 호출하는 체인의 규모가 크면 클수록 큰 성능차이를 보입니다
특징
▪️ 변수의 실제 타입에 맞춰 메서드를 호출합니다
▪️ 메서드 선택이 코드상으로 드러나지 않기에 컴파일 타임이 아닌 런타임에 결정됩니다
▪️ 다형성을 활용할 수 있습니다
▪️ 런타임에 실제로 참조할 메서드를 찾는 과정이 필요하여, static에 비해 성능 상 불리합니다
▪️ static에 비해 최적화의 기회가 적습니다
Dynamic Dispatch는 메서드 리스트를 관리함으로써 런타임에 실제로 호출할 메서드를 결정하게 됩니다. 이러한 과정으로 인한 런타임 오버헤드를 감수하는 대신 언어 표현력을 향상시킵니다
Dynamic Dispatch에는 메서드 리스트를 유지하고 검색하는 방법에 따라 2가지 타입이 있습니다
Dynamic 동작의 가장 기본적인 구현 형태입니다. class에 선언된 모든
메서드의 함수포인터를 배열 형태로 유지합니다. 이런 Table 기반의 Dispatch 메커니즘은 다양하게 많이 종류가 있으며 Swift에선 class는 V-Table
/ protocol은 PWT
를 사용합니다. 이번 포스팅에선 V-Table을 기준으로 설명하고 PWT에 대해선 WWDC를 참고바랍니다
Table Dispatch에서는 모든 class가 Table에 등록되며, 각 class별로 메서드 주소값 배열을 갖습니다. 부모로부터 상속받은 메서드는 같은 주소값을 유지하고, 오버라이드하게 되면 이를 덮어씁니다. 그리고 자신만의 메서드를 추가하면 배열 끝에 append합니다
class ParentClass {
func method1() {}
func method2() {}
}
class ChildClass: ParentClass {
override func method2() {}
func method3() {}
}
let person: ParentClass = ChildClass()
person.method2() // Parent's one? : Child's one?
ParentClass
이지만 class는 상속이 가능하므로 누구의 method2()
를 호출해야할지 컴파일 타임에 알 수 없으므로 Dynamic Dispatch를 사용해야 합니다method2()
가 호출되면 실제 타입이 누구인지 확인합니다ChildClass
의 method2()
주소값을 확인하여 호출합니다method2()
가 override되었다면 0x222로, 되지 않았다면 0x122로 가게 됩니다)Table Lookup 방식은 간단하고 구현이 쉽고 성능이 예측가능합니다만, 여전히 static에 비해 느립니다. 위 예제처럼 (1) 테이블에서 0xB00를 읽고, (2) method2가 ChildClass의 몇번 index에 있는지 읽고, (3) 0x222로 점프하는 추가 동작이 필요하여 오버헤드가 됩니다. 또, 컴파일 타임에 알 수 있는 정보가 적어 컴파일러가 최적화하기 어렵다는 단점이 있습니다
이런 배열 기반 구현의 단점은 extension이 Dispatch Table을 확장할 수 없게 만듭니다. subclass는 새로운 메서드들을 dispatch table의 끝에 append하므로, extension이 안전하게 함수포인터를 추가할 index가 없습니다.
✅ 왜 extension에선 override가 불가능할까?
(이 부분은 정확한 답을 찾지 못해 "이렇지 않을까"하는 추측을 정리해보았습니다)
Swift Language Guide에 따르면 extension은 기능을 "추가"하기 위한 용도임을 명시하고 있습니다. 이는 replacing/overriding을 하길 원치 않는다는 표현으로 해석될 수 있을 것 같습니다. 이렇게 "추가"만 가능하도록 설계한 이유는 Swift의 목표 중 하나가 최대한 Static Dispatch를 사용하도록 하는 것이기 때문이 아닐까 싶습니다
또한, extension은 일부 코드 영역에만 적용할 수 있다는 특징 때문에 Dispatch 메커니즘 구현 복잡도 문제가 있을 것 같습니다 (override된 메서드에 대해선 Table의 해당 메서드 주소값을 치환하는데, 코드위치마다 치환여부가 다르다면 결국, extension하는 곳마다 별도의 Table을 둬야 함)
message dispatch는 (override한 메서드 / 자신만의 메서드)에 대한 함수포인터만 유지합니다. 대신 부모 타입으로의 포인터를 가지고 있어서(super) 일반 상속 메서드는 부모 타입 포인터를 타고 올라가서 찾습니다
이러한 방식은 굉장히 유연해서 런타임에 메서드의 동작을 수정하거나 새로운 메서드나 프로퍼티를 만들고, 아예 클래스를 동적으로 만드는 것도 가능합니다 (Method swizzling, isa-swizzling). 이를 활용한 예로 KVO
/UIAppearance
/Core Data
등이 있습니다. 다만, 이런 동작은 런타임 라이브러리가 필요한데 Swift는 자체적으로 갖고 있지 않아 Obj-c 런타임을 통해 수행할 수 있습니다
- Method swizzling : 런타임에 어떤 메서드 구현체를 동적으로 다른 메서드로 변경하는 행위
- ISA swizzling : 런타임에 어떤 객체의 타입을 변경하는 행위
✅ 왜 Message Dipatch에서만 swizzling이 가능할까?
(이 부분은 정확한 답을 찾지 못해 "이렇지 않을까"하는 추측을 정리해보았습니다)
Table 방식은 상속관계에 있는 메서드 주소값을 부모와 자식들 모두가copy
를 가지고 있고 이들은 값은 같아야 할 것입니다. 만약 여기서 swizzling하여 메서드 주소값을 변경하면 해당 메서드를 갖고 있는 모든 부모/자식 table의 copy 주소값도 동기화를 위해 변경해주는 작업이 필요할 것입니다
반면, Message 방식은 table처럼 어떤 메서드에 대한 주소값을 여러 타입들이 copy로 가지고 있지 않고, 해당 메서드를 정의한 타입에게만유일
하게 존재합니다. 따라서 원본만 swizzling 해주면 파급효과가 자동으로 전역에 퍼지게 됩니다
message가 dispatch되면, 런타임이 어느 메서드를 호출할지 결정하기 위해 class 계층을 탐색합니다. 그래서 느립니다. 하지만, cache layer를 통해 table dispatch만큼 빨라질 여지는 있습니다.
▪️ 초기선언 / extension
값타입은 상속을 지원하지 않으므로 기본적으로 Static Dispatch를 사용하며 이는 extension에서 정의된 메서드도 동일합니다
▪️ 초기선언
class는 기본적으로 상속의 여지가 있으므로 Table Dispatch를 사용합니다. 단, final
/private
등의 처리로 Static으로의 변경 여지가 있습니다
▪️ extension
class extension에서는 모든 override 관련 행위가 금지되어 있습니다 (extension에서 정의된 메서드를 override하는 것도, 메서드를 extension에서 override하는 것도). 따라서 extension에 정의된 메서드는 마치 final 처리된 같게 됩니다. Static Dispatch를 사용합니다
▪️ @objc dynamic
한 가지 예외 케이스가 있는데 @objc dynamic
키워드를 붙이는 경우입니다. @objc
로 선언된 메서드는 Objective-C 런타임에 노출되어 dynamic
키워드와 함께 사용하면 Message Dispatch를 사용하게 됩니다
▪️ 초기선언
프로토콜 타입 변수는 기본적으로 PWT(Protocol Witness Table)이란 메커니즘을 사용합니다 (PWT에 대해선 WWDC에서 자세하게 소개되었습니다). PWT는 테이블기반 Dynamic Dispatch의 일종입니다
▪️ 초기선언 + extension
프로토콜은 초기선언과 extension이 성질이 매우 다른 특별한 타입입니다. 초기선언에 요구사항으로 정의된 메서드는 extension에서 기본구현을 제공하더라도 PWT를 통한 Dynamic Dispatch를 사용하여 concrete type의 메서드 구현체가 호출됩니다
▪️ only extension
초기선언에서 요구하지 않고 extension에서 기본구현으로만 제공되는 메서드에 대해선 concrete type에 동일한 메서드를 구현했던말던 Static Dispatch를 사용합니다. 따라서, 명목상 타입에 정의된 메서드가 호출됩니다
class는 기본적으로 Table Dispatch를 사용하나, final
로 선언된 class 내에서 새로이 정의된 메서드 혹은 final로 새로이 정의된 메서드는 더 이상 상속의 여지가 없어 Static Dispatch를 사용합니다
final class MyClass1: ParentClass {
override func sayHello() { } //Table
func sayGoodbye() { } //Static
}
class MyClass2: ParentClass {
final sayGoodBye() { } //Static
}
class 메서드에 private
을 지정하고 접근가능한 곳들에서 override가 없다면, 컴파일러가 자동으로 final
로 추론하여 Static Dispatch를 사용합니다
WMO 최적화를 사용하는 경우, 모듈 단위로 final 추론을 시도합니다. 즉, 모듈 외부로 공개되지 않는 internal
이하로 선언된 메서드에 대해서 모듈 전체에 override가 없다면 final
로 추론하여 Static Dispatch를 사용합니다
Swift class는 기본적으로 Message Dispatch 능력이 없으므로 Objective-C의 힘을 빌려야 합니다. 따라서 Objective-C 런타임과의 연결(@objc
)이 필요하고 초기선언이냐/extension이냐에 따라 dynamic
키워드가 추가로 필요합니다
@objc
키워드를 통해 Objective-C 런타임과의 연결성을 확보합니다dynamic
키워드까지 붙혀 Message Dispatch를 사용하도록 유도합니다class MyClass {
@objc dynamic func useMessage1() {...}
}
extension MyClass {
@objc func useMessage2() {...}
}
그렇다면 어떤 경우에 Message Dispatch를 사용하는 것이 필요할까요?
▪️ 1. extension에서 부모메서드 override하려는 경우
기본적으로 Swift에서는 extension에서의 override를 금지하고 있지만, 위와 같이 @objc dynamic
처리를 통해 Message Dispatch를 하도록 하면 가능합니다
(반대로, 부모의 extension에 정의된 메서드를 자식의 초기선언에서 override하려면 @objc
만 있으면 됩니다)
▪️ 2. Objective-C 런타임에 의해 제공되는 기능 중 일부를 다루는 경우
KVO
/UIAppearance
/Core Data
등은 Objective-C 런타임을 통해서만 제공되는 기능이므로 조치가 필요합니다
▪️ 3. NSObject의 subclass 일부를 다루는 경우
NSObject의 근본적인 특성상 Objective-C 런타임에 의존하는 경우가 많기 때문