파라미터 Protocol 타입과 제네릭의 차이점

프로토콜은 자기 자신을 채택하고 있지 않다

protocol P {}
struct S: P {}

let arr:[P] = [S()]

func genericFunc<T: P>(a: [T]) -> [T] {
    return a
}

genericFunc(a: arr) // Error: Type 'any P' cannot conform to 'P'

프로토콜은 자기 자신을 채택하고 있지 않다. 따라서 제네릭의 타입 제약을 프로토콜을 채택한 것으로 제약을 걸어 두었을 때, 컴파일 시 오류가 뜬다. 즉 프로토콜 P는 프로토콜 P를 채택하고 있지 않아서 제네릭 제약조건인 P를 채택한 타입에서 걸려서 오류가 발생한다.

protocol P {}
struct S: P {}

let arr:[P] = [S()]

func nonGenericFunc(a: [P]) -> [P] {
    return a
}

nonGenericFunc(a: arr)

하지만 파라미터 자체가 프로토콜 타입인 경우에는 사용 가능하다. 이는 제네릭은 ‘프로토콜을 채택한 타입’이고 파라미터는 ‘프로토콜 타입인 모든 것’이기 때문

Method의 Dispatch

함수를 실행할 때 어떤 코드를 실행할지 선택하는 매커니즘

Static Dispatch

struct A {
	func hello() {
		print("Hello")
	}
}

let a = A()
a.hello()

다음과 같은 코드가 있을 때 컴파일러는 struct A의 hello함수를 실행해야 된다는 것을 알고 있음. 이렇게 컴파일 때 어떤 메서드를 실행시킬지 알 수 있는 것을 static dispatch라고 함.

Dynamic Dispatch

class A {
	func hello() {
		print("Hello")
	}
}

class B: A {
	@override func hello() {
		print("Hello2")
	}
}

func sayHello(obj: A) {
	obj.hello()
}

컴파일 때 어떤 코드를 실행할지 알 수 없고, 런타임 때 어떤 코드가 실행되는지 결정되는 것.

프로토콜 파라미터의 작동 방식

protocol Drawable {
	func draw()
}

struct Point: Drawable {
	func draw() {...}
}

struct Line: Drawable {
	func draw() {...}
}

var drawables: [Drawable]

for d in drawable {
	d.draw()
}

위에서 말한 클래스의 dynamic dispatch와 동일하게 drawables에는 Point와 Line가 들어갈 수 있다. class의 vtable과는 다르게 프로토콜은 PWT(Protocol Witness Table), Existential Container, VWT(Value Witness Table)을 이용하여 런타임 중 실행할 코드를 호출한다.

PWT

프로토콜을 채택한 각 타입마다 PWT가 생성된다. 각 타입별 PWT는 프로토콜의 구현에 연결된다.

existential container

프로토콜 타입으로 선언된 변수나 파라미터에 실제 어떤 타입이 들어가 있는지 추상적으로 관리해 줄 수 있게 해 준다. 3 words를 저장할 수 있는 버퍼와 VWT, PWT의 레퍼런스를 가지고 있고, 만약 저장해야 할 데이터가 3 word보다 크면 해당 데이터를 힙에 할당해서 관리한다.

VWT

existential container가 데이터를 힙이나 스택에 저장하는데, 이를 관리하기 위해 사용. 다음과 같은 작업을 수행한다.

  • allocate: 힙에 메모리를 할당하고, 메모리 포인터를 value Buffer에 할당하는 작업을 수행
  • copy: 데이터를 힙이나 스택에 저장. value buffer내에 저장할 수 있으면 저장하고, 데이터가 커서 저장하지 못할 경우 힙에 데이터를 복사한다.
  • destruct: 지역 변수의 life time이 끝났을 때 호출돼서 데이터에 대한 reference count를 감소시킨다.
  • deallocate: 힙에 할당된 메모리를 해제

과정

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

let val: Drawable = Point()
drawACopy(val)

이런 코드가 있다고 해보자. 파라미터 타입은 Drawable이고 val은 Drawable을 채택한 Point이다.

drawACopy에 val을 전달할 때 swift는 existential container를 전달한다.

drawACopy에 argument로 값이 넘어가면 swift는 스택에 existential container를 생성한 후 매개변수를 초기화한다. 그 후 existential container의 VWT의 copy에 의해 valueBuffer에 값을 할당할 수 있으면 할당을 하고, 아닌 경우 힙에 할당을 한 후 해당 메모리 주소를 value buffer에 넣는다.

value buffer에 값이 모두 넣을 수 있는 Point는 existential container에 값이 할당되었고, 값을 모두 넣을 수 없는 Line은 힙 영역에 할당되고, 그 참조를 value buffer에 할당된 것을 볼 수 있다

그 후 PWT를 조회해 Line의 draw의 구현으로 이동하게 된다.

그 후 실행이 완료되면 VWT의 destruct, deallocate가 호출되면서 메모리에서 해제가 된다.

제네릭의 작동 방식

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

let point = Point()
drawACopy(point)

이번엔 제네릭을 프로토콜로 타입 제약을 했을 경우의 코드를 살펴보자.

drawACopy의 local의 draw 함수를 호출하기 위해 어떤 타입의 draw인지를 알아야 한다. 이때 프로토콜을 파라미터 타입으로 했을 때와는 다르게 existential container를 사용하지 않는다. argument로 Point의 VWT와 PWT를 전달한고 전달받은 VWT를 이용해 힙이나 스택에 데이터를 저장한다.

또한 Specialization of Generic이라고 하는 최적화를 통해 컴파일러 최적화를 한다.

Specialization of Generic

간단하게 말하면 전달하는 파라미터의 타입 버전의 함수를 만들어서 static dispatch를 수행할 수 있다.

이런 식으로 각 타입 버전의 함수를 만들어서 최적화가 가능하다.

또한 T는 같은 타입만 가능하도록 강제한다. Line(), Line()은 가능하지만, Line(), Point()는 불가.

 

제네릭을 통한 타입 제약은 existential container가 필요 없고 (T가 특정 타입으로 바뀌어서) heap 할당 없이 스택에 매개변수를 초기화할 수 있고, PWT를 이용하지 않아도 된다. 이 경우 파라미터 자체가 프로토콜인 경우보다 더 효율적.

 

 

참고: https://developer.apple.com/videos/play/wwdc2016/416/