Method Dispatch in Swift

J.Noma·2022년 1월 14일
0

Swift : 중요한 주제

목록 보기
2/5

Reference


Remind

  • 메서드가 초기선언에 정의되느냐, extension에 정의되느냐에 따라 Dispatch 방식에 차이가 있다
  • 프로토콜 extension의 기본구현 메서드는, 프로토콜 초기선언에서 요구사항으로도 요구하느냐에 따라 Dynamic/Static이 나뉜다
  • 요구사항이 아닌 프로토콜 기본구현 메서드를 concrete 타입이 재정의하면 Static을 사용한다
  • Swift에서 Message Dispatch를 사용하려면 Objective-C 런타임의 도움이 필요하다
  • 부모 메서드를 자식 extension에서 override -> @objc dynamic
  • 부모 extension 메서드를 자식 초기선언에서 override -> @objc

🌀 Method Dispatch란?

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가 있지만 이번 포스팅에선 다루지 않습니다)


🌀 Static Dispatch

특징
▪️ 변수의 명목상 타입에 맞춰 메서드를 호출합니다
▪️ 메서드 선택이 모호하지 않아 컴파일 타임에 결정할 수 있습니다
▪️ 다만, 다형성을 활용하기 어렵게 됩니다.
▪️ 어느 메서드가 호출될지 이미 알고 있으므로, Dynamic에 비해 성능 상 유리합니다
▪️ Dynamic에 비해 최적화의 기회가 많습니다 (ex. inlining)

❗️ inlining이란?
메서드 호출부를 실제 구현체로 치환하여, 메서드 구현체 주소값으로 점프하거나 Stack push/pop과 같은 관련 동작을 생략할 수 있습니다. inlining은 함수 내에서 또 다른 함수를 호출하는 체인의 규모가 크면 클수록 큰 성능차이를 보입니다


🌀 Dynamic Dispatch

특징
▪️ 변수의 실제 타입에 맞춰 메서드를 호출합니다
▪️ 메서드 선택이 코드상으로 드러나지 않기에 컴파일 타임이 아닌 런타임에 결정됩니다
▪️ 다형성을 활용할 수 있습니다
▪️ 런타임에 실제로 참조할 메서드를 찾는 과정이 필요하여, static에 비해 성능 상 불리합니다
▪️ static에 비해 최적화의 기회가 적습니다

Dynamic Dispatch는 메서드 리스트를 관리함으로써 런타임에 실제로 호출할 메서드를 결정하게 됩니다. 이러한 과정으로 인한 런타임 오버헤드를 감수하는 대신 언어 표현력을 향상시킵니다

Dynamic Dispatch에는 메서드 리스트를 유지하고 검색하는 방법에 따라 2가지 타입이 있습니다

🔸 1. Table Dispatch

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? 
  1. 우선, 명목상 타입이 ParentClass이지만 class는 상속이 가능하므로 누구의 method2()를 호출해야할지 컴파일 타임에 알 수 없으므로 Dynamic Dispatch를 사용해야 합니다
  2. 런타임에 method2()가 호출되면 실제 타입이 누구인지 확인합니다
  3. 실제 타입의 Table을 참조하여 ChildClassmethod2() 주소값을 확인하여 호출합니다
    (이 시점에 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을 둬야 함)

🔸 2. Message Dispatch

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 해주면 파급효과가 자동으로 전역에 퍼지게 됩니다

▪️ Table Dispatch보다 느리지만 캐싱 여지가 있다

message가 dispatch되면, 런타임이 어느 메서드를 호출할지 결정하기 위해 class 계층을 탐색합니다. 그래서 느립니다. 하지만, cache layer를 통해 table dispatch만큼 빨라질 여지는 있습니다.


🌀 Swift Dispatch

🔸 Struct / Enum

▪️ 초기선언 / extension
값타입은 상속을 지원하지 않으므로 기본적으로 Static Dispatch를 사용하며 이는 extension에서 정의된 메서드도 동일합니다

🔸 Class

▪️ 초기선언
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를 사용하게 됩니다

🔸 Protocol

▪️ 초기선언
프로토콜 타입 변수는 기본적으로 PWT(Protocol Witness Table)이란 메커니즘을 사용합니다 (PWT에 대해선 WWDC에서 자세하게 소개되었습니다). PWT는 테이블기반 Dynamic Dispatch의 일종입니다

▪️ 초기선언 + extension
프로토콜은 초기선언과 extension이 성질이 매우 다른 특별한 타입입니다. 초기선언에 요구사항으로 정의된 메서드는 extension에서 기본구현을 제공하더라도 PWT를 통한 Dynamic Dispatch를 사용하여 concrete type의 메서드 구현체가 호출됩니다

▪️ only extension
초기선언에서 요구하지 않고 extension에서 기본구현으로만 제공되는 메서드에 대해선 concrete type에 동일한 메서드를 구현했던말던 Static Dispatch를 사용합니다. 따라서, 명목상 타입에 정의된 메서드가 호출됩니다


🌀 사용될 Dispatch를 바꾸는 방법

🔸 Dynamic -> Static

▪️ 1. final 키워드 활용

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
}

▪️ 2. private으로 추론시키기

class 메서드에 private을 지정하고 접근가능한 곳들에서 override가 없다면, 컴파일러가 자동으로 final로 추론하여 Static Dispatch를 사용합니다

▪️ 3. WMO(Whole Module Optimization)

WMO 최적화를 사용하는 경우, 모듈 단위로 final 추론을 시도합니다. 즉, 모듈 외부로 공개되지 않는 internal 이하로 선언된 메서드에 대해서 모듈 전체에 override가 없다면 final로 추론하여 Static Dispatch를 사용합니다

🔸 Table -> Message

Swift class는 기본적으로 Message Dispatch 능력이 없으므로 Objective-C의 힘을 빌려야 합니다. 따라서 Objective-C 런타임과의 연결(@objc)이 필요하고 초기선언이냐/extension이냐에 따라 dynamic 키워드가 추가로 필요합니다

  1. 먼저 class 메서드에 @objc 키워드를 통해 Objective-C 런타임과의 연결성을 확보합니다
  2. 추가로 dynamic 키워드까지 붙혀 Message Dispatch를 사용하도록 유도합니다
    (단, extension에선 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 런타임에 의존하는 경우가 많기 때문

profile
노션으로 이사갑니다 https://tungsten-run-778.notion.site/Study-Archive-98e51c3793684d428070695d5722d1fe

0개의 댓글