Sitemap

Protocol and Generic

Dynamic, Static Dispatch

--

프로토콜과 제네릭은 Swift로 개발할 때 필수적이다.
성능적으로 프로토콜과 제네릭이 어떤 차이가 있는지 정리를 해보려고 한다! (Kodeco의 Expert Swift 를 번역한 부분이 있어서 문장이 매끄럽지 못한 부분이 있을 수 있음에 양해를 구합니다.)

Start!

우선 프로토콜에 대해서 알아보자!

Protocol

특징(간략하게만 짚고 넘어가자..)
- 프로토콜은 타입에 대한 정보를 숨겨 여러 타입을 동일한 슈퍼 타입으로 사용할 수 있다.
- 프로토콜에서 상속은 클래스의 상속과 비슷하게 모든 프로토콜의 요구 사항은 자식 프로토콜로 전달된다.
- 프로토콜 메서드에서 mutating 키워드가 사용될 때 struct에서 해당 프로토콜을 채택하는 경우 mutating 키워드를 사용해야 하지만 클래스의 경우는 원래 mutable이 default이기 때문에 mutating키워드가 필요하지 않다.
- struct, class, enum외에도 스위프트의 모든 타입에서는 프로토콜을 채택할 수 있다(다중 채택 가능).
- 프로토콜을 확장 하면서 프로토콜에 대한 기본구현을 할 수 있다.
기본 구현이 된 프로토콜을 채택하면 따로 정의하지 않고 사용 가능하다.

이제 여기부터 중요한 개념이 시작된다!

Method Dispatch(Static and Dynamic dispatch)

런타임에서 스위프트가 함수 이름을 찾으면 해당 함수의 주소로 점프하고 해당 코드를 실행하기 시작함. 그러나 함수의 주소로 점핑하는 것이 항상 간단한 것이 이니다.

함수를 호출하고 저장하는데에 두가지 주요 매커니즘이 있다(static and dynamic dispatch).

static dispatch
함수가 절대 변하지 않을 거라는 것이 확실할 때 발생한다(컴파일 타임에 호출될 함수를 결정).
static dispatch는 final class의 메서드 뿐만 아니라 struct에서 선언된 메서드와 글로벌 함수에 사용된다.
이러한 경우에는 오버라이딩에 대한 걱정이 없기 때문에 컴파일러는 함수의 주소를 하드 코딩하고 함수가 참조될 때마다 해당 주소로 점프할 수 있다.

알아두면 좋을 상식!
@inlining
- 컴파일러 최적화에 사용.
- 인라이닝은 컴파일 타임에 함수 호출을 해당 함수의 전체 본문으로 대체한다.이것은 함수를 호출하는 가장 빠른 방법이지만, static dispatch와 특정 조건에서만 사용할 수 있다.

dynamic dispatch
non-final class 인스턴스에서 호출된 메서드는 여러곳(현재 클래스, 상위 클래스, 확장 또는 프로토콜 확장)에서 선언될 수 있다.
이것은 컴파일러가 함수의 정확한 주소가 무엇인지 미리알 수 없다는 것을 의미한다. 대신 이 경우에는 witness table(virtual table or v-table)을 사용한다.

Kodeco의 Expert Swift 에서 발췌한 v-table

컴파일러가 코드를 빌드할 때 각 클래스의 인스턴스에 대한 테이블(v-table)을 만든다.
클래스의 각 함수는 메모리에 저장된 테이블에 저장된다.
서브클래스는 부모 테이블의 복사본을 얻고 오버라이딩이 된 부분은 오버라이딩된 메스드로 대체된다. 스위프트는 런타임에 v-table을 사용할 수 있다(런타임에 호출될 함수를 결정). 메서드의 호출이 발생하면 스위프트는 테이블의 어떤 오프셋이 그 메서드에 해당하는지 알 수 있다. 테이블에는 함수 주소가 저장됨.

v-table은 다음과 같은 순서로 만들어진다.

  1. 부모 클래스의 v-table을 그대로 복사.
  2. 현재 클래스에서 재정의(override)한 메서드가 있다면, 복사해온 v-table에서 해당 위치의 함수 포인터를 현재 클래스의 메서드 주소로 교체(Override).
  3. 현재 클래스에서 새롭게 정의한 동적 메서드가 있다면, v-table의 맨 끝에 순서대로 추가(Append).

dynamic dispatch는 같은 이름을 가진 메서드의 구현을 동적으로 변경할 수 있으며 상속, 다형성 및 프로토콜과 같은 기능을 하용한다.
하지만 이러한 기능들은 비용이 든다. 테이블에서 함수를 호출하면 각 함수 호출에 대한 오버헤드가 추가된다. 또한 인라인 및 기타 컴파일러의 최적화를 방지하여 동적 디스패치를 정적 디스패치보다 느리게 만든다.

Dispatch in Protocols
상속이 동적 디스패치를 요구함으로써 컴파일러의 삶을 복작하게 만들었다.
프로토콜도 상속을 지원한다. 여러개의 클래스와 구조체가 동일한 프로토콜을 채택하고, 동일한 메서드를 구현할 수 있다.

Get Junyoung’s stories in your inbox

Join Medium for free to get updates from this writer.

프로토콜을 인스턴스 타입으로 사용하는 경우 동적 디스패치를 사용한다.
프로토콜 메서드를 디스패치 하는 방법은 클래스의 동작 방식과 비슷하다.
프로토콜을 채택한 모든 타입은 자체 protocol witness table을 갖는다.
프로토콜의 각 구성원(프로토콜에서 선언된 메서드와 변수)은 테이블에 포함된다. 이 테이블은 프로토콜을 채택한 인스턴스와 함께 저장된다. 그런다음 스위프트는 런타임에 protocol witness table(PWT)에서올바른 함수를 찾아서 호출한다. 프로토콜을 사용하는 클래스 인스턴스를 사용하는 경우 스위프트는 클래스 witness table와 프로토콜 witness table 에서 함수를 찾아 동적으로 올바른 구현을 찾을 수 있다.

프로토콜에서 기본 확장 구현을 한 경우에는 프로토콜을 채택한 모든 타입에 복사되어 프로토콜 witness table에 추가된다.(즉, 인터페이스에 정의되어 있고 기본확장 되었거나, 컨크리트 객체가 구현한 경우에 PWT에 추가됨)
프로토콜 자체는 테이블을 가지고 있지 않고 오직 concrete 타입만 테이블을 갖는다. 프로토콜의 기본구현을 채택한 타입에서 프로토콜의 요구 사항을 새로 구현하는 경우에는 프로토콜 witness table에서 기본구현이 새로운 구현으로 대체된다.(클래스에서 함수를 오버라이딩한 경우와 동일하다.)

프로토콜의 정의에는 포함되어 있지는 않지만 기본 확장에 구현된 메서드의 경우에도 프로토콜을 채택한 모든 타입에서 접근이 가능하다.

protocol Greetable {
func greet() -> String
}

extension Greetable {
func greet() -> String {
return "Hello"
}

func leave() -> String {
return "Bye"
}
}

struct AmericaGreeter: Greetable {
func greet() -> String {
return "Hello 2"
}

func leave() -> String {
return "Bye 2"
}
}

let greeter1: Greetable = AmericaGreeter()
print(greeter1.greet()) // Hello 2
print(greeter1.leave()) // Bye

let greeter2: AmericaGreeter = AmericaGreeter()
print(greeter2.greet()) // Hello 2
print(greeter2.leave()) // Bye 2

프로토콜의 정의에는 포함되어 있지는 않지만 기본 확장에 구현된 함수의 호출은 변수로 선언된 타입에 달려 있다. 이것은 다형성이 작동하지 않는 다는 것을 의미한다. 앞에서 동적 디스패치가 다형성을 가능하게 한다고 하였고, 그래서 leave는 정적 디스패치를 사용하여 호출된다.

실제로 (프로토콜의 정의에는 포함되어 있지는 않은)확장 메서드는 전적으로 정적 디스패치에 의존한다. 즉 leave를 호출하는 것과 관련된 테이블은 없다. 스위프트는 이 함수를 정적으로 호출한다.
즉, 프로토콜은 프로토콜 명세에는 없지만 확장에 구현된 메서드를 경우(static dispatch)를 제외하고 dynamic dispatch를 사용하여 함수를 호출한다.

프로토콜에서 확장 메서드를 추가하는 것이 매우 일반적이기 때문에 (명세에는)확장 메서드를 오버라이딩을 하고 싶은 경우에서 프로토콜의 정의로 추가해야 한다는 것을 명심하자. 그렇지 않으면 예상하지 못한일이 발생할 수 있다.

Generic

func replaceNilValues<T>(from array: [T?], with element: T) -> [T] {
array.compactMap {
$0 == nil ? element : $0
}
}

제네릭 함수는 부등호로 감싸서 표현한 타입 파라미터<T> 와 함께 프로토타입을 구현한다. 타입 파라미터는 함수 호출부에서 특정 구체타입으로 치환 되는데 이를 구체화(Specialization)라 한다.

서두가 길었다! 여기서 부터 진짜 진짜 중요!

앞에서 보았듯 프로토콜은 Dynamic Dispatch를 사용하고 있다. Dynamic Dispatch 보다는 Static Dispatch가 성능적으로 더 낫기 때문에 제네릭을 통해 Static Dispatch로 성능을 개선해 보자!

protocol Drawable {
func draw()
}

struct Point: Drawable {
let x, y: Double

func draw() {
print("\(x), \(y)")
}
}

struct Line: Drawable {
let x1, y1: Double
let x2, y2: Double

func draw() {
print("\(x1), \(y1) - \(x2), \(y2)")
}
}

func drawACopy(_ local: Drawable) {
local.draw()
}

func drawACopy<T: Drawable>(_ local: T) {
local.draw()
}

위의 drawACopy 두 메서드는 동일한 결과를 반환하지만 dispatch방식은 다르다.

drawACopy(_:) 메서드는 PWT를 이용한다. 그렇기 때문에 런타임에서 함수의 인자로 전달받은 Drawable 타입 인스턴스의 PWT를 통해Dynamic Dispatch 방식으로 호출한다.

drawACopy<T>(_:) 메서드는 구체화 과정을 거치면서 타입 파라미터 T는 구체 타입으로 치환된다. 그렇기 때문에 PWT를 통해 메서드를 참조하는 상황은 발생하지 않고 Static Dispatch방식으로 호출한다.

프로토콜을 사용하면서 발생한 성능 저하를 제네릭으로 해결할 수 있다!

끝!

부족한 부분이 있다면 댓글로 남겨주세요!

참고

--

--