Why This Work Exists
개발자들이 any와 Generic의 차이를 헷갈려서 성능 문제를 만들거나, Witness Table을 동적 디스패치로 오해해서 잘못된 최적화를 시도한다.
이 문서는 Swift의 디스패치 메커니즘을 정확히 이해하고, 상황에 맞는 선택을 할 수 있도록 돕는다.
any / Generic / Witness Table / Existential Container / Boxing 완전 정리 — 언제 뭘 써야 하는가?
"제네릭은 기본적으로 정적 디스패치인데 다른 모듈에서 사용 시 동적 디스패치이다" — 이 말은 틀렸다. Witness Table은 동적 디스패치가 아니다. 이 문서는 이러한 혼동을 정리한다.
개발자들이 any와 Generic의 차이를 헷갈려서 성능 문제를 만들거나, Witness Table을 동적 디스패치로 오해해서 잘못된 최적화를 시도한다.
이 문서는 Swift의 디스패치 메커니즘을 정확히 이해하고, 상황에 맞는 선택을 할 수 있도록 돕는다.
✅ 이번 문서 범위:
❌ 제외:
| 용어 | 정의 | 핵심 포인트 |
|---|---|---|
| Static Dispatch | 컴파일 타임에 호출할 함수가 결정됨 | 가장 빠름, 인라인 최적화 가능 |
| Dynamic Dispatch | 런타임에 타입 확인 후 함수 결정 | vtable 또는 런타임 타입 체킹 필요 |
| Witness Table | Protocol 요구사항 → 구체 타입 구현 매핑 테이블 | 컴파일 타임에 결정됨 (Generic 사용 시) |
| Existential Container | any Protocol 값을 저장하는 구조 (단일 프로토콜 기준 40 bytes) | Value Buffer(24B) + Type Metadata ptr + PWT ptr |
| Boxing | 구체 타입 → Existential Container 변환 | 오버헤드 발생 |
| Unboxing | Existential Container → 구체 타입 추출 | 런타임 타입 체킹 필요 |
| Specialization | 컴파일러가 Generic을 구체 타입 버전으로 최적화 | Static Dispatch 가능 |
| Vtable | Virtual Table - Class의 메서드 주소들을 담은 테이블 | Dynamic Dispatch에 사용 |
| Inline 저장 | 값을 Value Buffer에 직접 저장 (≤ 24 bytes) | 캐시 효율적, 힙 할당 없음 |
❌ 오해 1:
"Generic은 다른 모듈에서 사용하면 동적 디스패치다"
❌ 오해 2:
"Witness Table = Dynamic Dispatch"
❌ 오해 3:
"any를 쓰면 무조건 느리니까 절대 쓰지 마라"
✅ 사실 1:
"Generic은 Witness Table을 사용하지만, 컴파일 타임에 결정된다"
✅ 사실 2:
"Witness Table ≠ Dynamic Dispatch (런타임 타입 체킹 없음)"
✅ 사실 3:
"any는 다양한 타입을 섞어 저장해야 할 때 정당한 선택"
개발 시 의사결정 순서:
1. Struct (Value Type)
struct Dog {
func bark() {
print("멍멍")
}
}
let dog = Dog()
dog.bark() // ← Static Dispatch ✅
2. final class (상속 불가)
final class Cat { // ← final = 상속 불가
func meow() {
print("야옹")
}
}
let cat = Cat()
cat.meow() // ← Static Dispatch ✅
3. Generic (Specialization)
func makeSound<T: Animal>(animal: T) {
animal.sound() // ← Specialization → Static Dispatch ✅
}
let dog = Dog()
makeSound(animal: dog) // 컴파일러가 T = Dog 알고 있음
func process<T: Animal>(animal: T)
func process_Dog(animal: Dog)class MyClass {
private func helper() { // ← private: 이 클래스 안에서만 사용
print("도움말")
}
func publicMethod() {
helper() // ← 컴파일러가 Static Dispatch로 최적화 가능 ✅
}
}
1. Class (상속 가능)
class Animal {
func sound() {
print("...")
}
}
class Dog: Animal {
override func sound() {
print("멍멍")
}
}
let animal: Animal = Dog()
animal.sound() // ← Dynamic Dispatch (vtable 사용) ✅
2. any (Existential Type)
protocol Drawable {
func draw()
}
let drawable: any Drawable = Circle()
drawable.draw() // ← Dynamic Dispatch (PWT 경유 간접 호출, 타입 비교 아님) ✅
class Dog { // ← final 아님, 상속 가능
func bark() { print("멍멍") }
}
let animal: Dog = Dog() // ← 구체 타입으로 선언
animal.bark() // ← 여전히 Dynamic Dispatch (vtable 사용) ❌
| Witness Table (Generic) | Dynamic Dispatch (any) | |
|---|---|---|
| 테이블 결정 시점 | 컴파일 타임 ✅ | 런타임 ❌ |
| 런타임 타입 체킹 | 불필요 ✅ | 필요 ❌ |
| 인라인 가능 | 불가 (간접 호출) | 불가 (간접 호출) |
| 성능 | 빠름 | 느림 |
protocol Drawable { func draw() func erase()}
// Generic 함수
func render<T: Drawable>(shape: T) {
shape.draw()
}
// 컴파일러가 내부적으로 하는 일 (개념):
func render<T: Drawable>(
shape: T,
witnessTable: DrawableWitnessTable // 컴파일 타임에 전달 ✅
) {
witnessTable.draw(shape) // 간접 호출
}
// 호출
let circle = Circle()
render(shape: circle)
// → 컴파일러가 Circle의 Witness Table을 자동으로 전달
// 문제: Protocol 타입으로 다양한 크기의 값을 저장해야 함
protocol Drawable { }
struct Point: Drawable {
var x: Int // 8 bytes
var y: Int // 8 bytes
// 총 16 bytes
}
struct Circle: Drawable {
var center: Point // 16 bytes
var radius: Double // 8 bytes
var color: Color // 4 bytes
// 총 28 bytes
}
// 크기가 다른 값들을 어떻게 하나의 타입으로 저장?
var drawable: any Drawable = Point(x: 0, y: 0)
drawable = Circle(...) // 크기가 달라도 저장 가능해야 함
// 일반적인 경우: Container는 스택에
func example() {
let drawable: any Drawable = Circle(radius: 5.0)
// ↑ Container (40 bytes)는 스택에 저장
}
// 클래스 프로퍼티: Container는 힙에
class ViewModel {
var drawable: any Drawable // ← 클래스는 힙에 있으므로 Container도 힙에
}
// 클로저 캡처: escaping이면 힙으로 이동
let closure = {
let drawable: any Drawable = Circle(radius: 5.0)
// escaping closure면 힙으로 이동
}
작은 값 (≤ 24 bytes) - Inline 저장
struct Point: Drawable {
var x: Int // 8 bytes
var y: Int // 8 bytes
}
var drawable: any Drawable = Point(x: 10, y: 20)
// Container 내부:
// [Value Buffer: x=10, y=20, (unused)] ← 값을 직접 저장 (Inline)
// [VWT pointer]
// [PWT pointer]
큰 값 (> 24 bytes) - 힙 할당
struct BigShape: Drawable {
var center: Point // 16 bytes
var radius: Double // 8 bytes
var color: UInt32 // 4 bytes
var opacity: Double // 8 bytes
// 총 36 bytes > 24 bytes
}
var drawable: any Drawable = BigShape(...)
// Container 내부:
// [Value Buffer: heap pointer → 힙의 BigShape, (unused), (unused)]
// [VWT pointer]
// [PWT pointer]
| 구분 | Inline (≤ 24 bytes) | Non-Inline (> 24 bytes) |
|---|---|---|
| 값 위치 | Value Buffer에 직접 저장 | 힙에 저장, 포인터만 Buffer에 |
| 메모리 레이아웃 | 연속된 메모리 | Container와 값이 멀리 떨어짐 |
| 첫 접근 시 | Container 전체가 캐시에 로드 | Container만 캐시에 로드 (값은 힙에) |
| 값 사용 시 | 이미 캐시에 있음 (캐시 히트) | 다시 힙 메모리 확인 (캐시 미스) |
| 캐시 효율 | 높음 (캐시 히트) | 낮음 (캐시 미스 가능) |
| 힙 할당 | 없음 ✅ | 있음 ❌ |
| 성능 | 빠름 (~1-2 사이클) | 느림 (~100-300 사이클) |
struct ShapeWithArray: Drawable {
var data: [Double]
}
// Array의 내부 구조 (간소화):
// - buffer pointer (8 bytes) ← 힙의 실제 배열 데이터를 가리킴
// - count (8 bytes)
// - capacity (8 bytes)
// 총 약 24 bytes (메타데이터만)
allocate → "Circle을 어떻게 메모리 할당?"copy → "Circle을 어떻게 복사?"destroy → "Circle을 어떻게 파괴?"assignWithCopy → "Circle을 어떻게 대입?"assignWithTake → "Circle을 어떻게 이동?"// 예시: Circle 복사할 때
var drawable1: any Drawable = Circle(radius: 5.0)
var drawable2 = drawable1 // 복사 발생
// 내부 동작:
// 1. drawable1의 VWT pointer 찾기
// 2. VWT에서 copy() 함수 주소 찾기
// 3. copy() 호출해서 Circle 복사
// 4. drawable2의 Container에 복사된 값 저장
draw() → Circle.draw() 함수 주소 (0x1000)erase() → Circle.erase() 함수 주소 (0x1100)// 예시: draw() 호출할 때
let drawable: any Drawable = Circle(radius: 5.0)
drawable.draw()
// 내부 동작:
// 1. drawable의 PWT pointer 찾기
// 2. PWT에서 draw() 함수 주소 찾기 (0x1000)
// 3. Value Buffer에서 Circle 값 꺼내기
// 4. 0x1000 주소로 점프 (Circle.draw() 실행)
| 구분 | VWT (Value Witness Table) | PWT (Protocol Witness Table) |
|---|---|---|
| 목적 | 값 관리 (복사, 삭제 등) | Protocol 메서드 호출 |
| 언제 사용 | 변수 대입, 복사, 해제 시 | Protocol 메서드 호출 시 |
| 함수 예시 | allocate, copy, destroy | draw(), erase(), validate() |
| 누가 정의 | Swift 컴파일러 (자동 생성) | 개발자 (Protocol 구현) |
struct Circle: Drawable {
var radius: Double // 8 bytes
}
let circle = Circle(radius: 5.0) // 스택에 Circle 값 (8 bytes)
// Boxing 발생 ↓
let drawable: any Drawable = circle // Container에 포장 (40 bytes)
// Boxing 비용 발생 시점
let drawable: any Drawable = Circle(radius: 5.0) // ← Boxing ✅
// 배열에 넣을 때도 각각 Boxing
let shapes: [any Drawable] = [
Circle(radius: 5.0), // ← Boxing ✅
Point(x: 10, y: 20), // ← Boxing ✅
Square(side: 15) // ← Boxing ✅
]
// 총 3번의 Boxing 발생
Case 1: 메서드 호출 시 Unboxing
let drawable: any Drawable = Circle(radius: 5.0)
// 메서드 호출 시 Unboxing 발생
drawable.draw()
Case 2: 타입 캐스팅으로 Unboxing
let drawable: any Drawable = Circle(radius: 5.0)
// 타입 캐스팅으로 완전히 Unboxing
if let circle = drawable as? Circle {
// Circle 타입으로 Unboxing 성공
print(circle.radius) // 5.0
// 이제 circle은 일반 Circle 타입 (Container 없음)
}
❌ 나쁜 예: 반복적인 Boxing
func process(shapes: [any Drawable]) {
for shape in shapes {
shape.draw() // 매번 Unboxing
}
}
✅ 좋은 예: Generic 사용
func process<T: Drawable>(shapes: [T]) {
for shape in shapes {
shape.draw() // Witness Table 사용 (Boxing 없음)
}
}
// any 사용
func drawAll(shapes: [any Drawable]) {
for shape in shapes {
shape.draw()
}
}
let shapes: [any Drawable] = [
Circle(),
Square(),
Triangle()
]
drawAll(shapes: shapes)
/*
동작:
1. 각 shape은 Existential Container에 Boxing됨
2. draw() 호출 시마다:
- 런타임 타입 체킹
- Protocol Witness Table 조회
- Unboxing
- 함수 호출
*/
// Generic 사용
func drawAll<T: Drawable>(shapes: [T]) {
for shape in shapes {
shape.draw()
}
}
let circles = [Circle(), Circle(), Circle()]
drawAll(shapes: circles)
/*
동작:
1. 컴파일러가 T = Circle로 Specialization
2. Circle의 Witness Table이 컴파일 타임에 결정됨
3. draw() 호출 시:
- 런타임 타입 체킹 없음 ✅
- Witness Table 직접 사용
- Boxing/Unboxing 없음 ✅
*/
| 항목 | any (Existential) | Generic |
|---|---|---|
| 타입 결정 | 런타임 | 컴파일 타임 |
| Boxing | ✅ 필요 | ❌ 불필요 |
| 런타임 타입 체킹 | ✅ 필요 | ❌ 불필요 |
| Existential Container | ✅ 사용 (40 bytes) | ❌ 사용 안 함 |
| 힙 할당 | 큰 값은 힙 할당 | 스택 사용 |
| 성능 | 느림 | 빠름 |
| 유연성 | 높음 (타입 섞어 저장 가능) | 낮음 (한 타입만) |
// any: 여러 타입을 섞어서 저장
let shapes: [any Drawable] = [
Circle(),
Square(),
Triangle()
]
// Generic: 한 타입만 다룸, 성능 좋음
func process<T: Drawable>(shapes: [T]) {
for shape in shapes {
shape.draw()
}
}
다양한 타입을 섞어 저장하는 정당한 이유가 있는지 확인. 단일 타입이면 Generic으로 변경.
배열 순회 시 any를 사용하면 매번 Unboxing. 성능 병목이면 Generic 고려.
상속이 필요한 경우만 Class. 그 외에는 Struct로 Static Dispatch 활용.
상속하지 않는 Class는 final로 선언해서 Static Dispatch 가능하게.
추측하지 말고 Instruments로 실제 병목 지점 확인 후 최적화.
24 bytes 이하의 작은 Struct는 inline 저장. 큰 값은 힙 할당 고려.
답: any Protocol 타입의 값을 저장하는 특수한 메모리 구조입니다.
• 고정 크기: 40 bytes
• 구조: Value Buffer (24B) + VWT (8B) + PWT (8B)
• 택배 상자처럼 "다양한 크기의 값을 하나의 상자에 담는 방법"
Point는 16 bytes, Circle은 28 bytes인데 any Drawable 변수 하나로 둘 다 저장할 수 있는 이유가 바로 이 Container 덕분입니다.
답:
• VWT (Value Witness Table): "이 값을 어떻게 복사/삭제하나?" (값 관리)
• PWT (Protocol Witness Table): "draw()를 어떻게 호출하나?" (메서드 호출)
비유:
VWT = "취급 주의" 스티커
PWT = "사용 설명서" 스티커
답: 구체 타입 (Circle)을 Existential Container에 포장하는 과정입니다.
과정:
1. Container 할당 (40 bytes)
2. 값 복사 (Value Buffer에)
3. VWT 주소 저장
4. PWT 주소 저장
원래 스택에 8 bytes로 저장될 Circle이 40 bytes Container에 담기게 됩니다.
답: 2가지 경우에 발생합니다.
1. 메서드 호출:
drawable.draw()
→ PWT 조회 → Value Buffer에서 값 꺼내기 → 메서드 실행
2. 타입 캐스팅:
if let circle = drawable as? Circle
→ 런타임 타입 체킹 → 값 추출
모두 런타임에 발생합니다.
답: Boxing을 다르게 표현한 것입니다.
구체 타입: Circle, Point처럼 실제 타입 이름이 있는 것
변환: Circle (8 bytes) → Container (40 bytes)로 형태가 바뀜
마치 책상 위의 책 (Circle)을 택배 상자에 넣는 것과 같습니다.
타입도 Circle → any Drawable로 바뀝니다.
답: 둘 다 맞습니다.
• 컴파일 타임: 컴파일러가 "여기에 Boxing이 필요하다"고 판단하고 코드 삽입
• 런타임: 실제로 Container에 값을 복사하는 실행
컴파일러가 미리 알고 준비해두고, 실제 실행은 런타임에 발생합니다.
답: Boxing/Unboxing 오버헤드 때문입니다.
Generic:
• Boxing 없음
• 런타임 타입 체킹 없음
• Witness Table은 컴파일 타임에 결정
any:
• 매번 Boxing (40 bytes 할당 + 복사)
• Unboxing (PWT 조회 + 값 꺼내기)
• 큰 값은 힙 할당까지
하지만 "여러 타입을 섞어 저장"할 때는 any가 필수입니다.
답: Value Buffer에 직접 저장되기 때문입니다.
작은 값 (≤ 24 bytes):
Container의 Value Buffer에 inline 저장 (힙 할당 없음) ✅
큰 값 (> 24 bytes):
힙에 할당하고 포인터만 Value Buffer에 저장 (힙 할당 오버헤드) ❌
따라서 작은 Struct를 사용하는 게 성능에 유리합니다.
답: 기본적으로 스택에 저장됩니다.
일반 변수:
let drawable: any Drawable = ... → 스택 ✅
클래스 프로퍼티:
class ViewModel { var drawable: any Drawable } → 힙 (클래스가 힙에 있으므로)
escaping 클로저 캡처: → 힙
중요: Container 자체의 위치와, Value Buffer 안의 값이 힙에 있는지는 별개입니다!
답: Value Buffer에 **데이터를 직접** 저장하는 방식입니다. (≤ 24 bytes)
Inline 저장 (메모리 점프 없음):
• Container의 Value Buffer 안에 데이터를 직접 저장
• 연속된 메모리 주소에 값이 나란히 배치
• Container 읽을 때 **메모리 점프 없이** 바로 데이터 접근 ✅
• 예: [x: 10, y: 20] ← Value Buffer에 직접 저장
Non-Inline 저장 (메모리 점프 발생):
• Value Buffer에는 **힙 포인터만** 저장
• 실제 데이터는 힙의 다른 주소에 위치
• 데이터 읽으려면 **메모리 점프 발생** (Container → 힙) ❌
• 예: [heap pointer → 0x7ffeab3c] → 힙의 데이터
핵심: Inline = 연속된 메모리, Non-Inline = 포인터 따라가야 함 (캐시 미스 가능성)
답: CPU 캐시에 데이터가 없어서 느린 메인 메모리까지 가야 하기 때문입니다.
속도 차이:
• 캐시 히트 (데이터가 캐시에 있음): ~1-10 사이클 ✅
• 캐시 미스 (메인 메모리까지 가야 함): ~100-300 사이클 ❌
Inline 저장: 연속된 메모리 → 캐시에 한 번에 로드 → 캐시 히트
Non-Inline: 메모리 점프 (스택 → 힙) → 캐시 미스 가능성 높음
약 10-30배 속도 차이!
답: Array는 특수한 경우입니다.
Array 구조:
• buffer pointer (힙 가리킴) + count + capacity
• 약 24 bytes (메타데이터만)
결과:
• Array 구조체는 Value Buffer에 inline 저장 가능
• 하지만 실제 배열 요소는 항상 힙에 저장됨
• 결국 포인터를 따라가야 하므로 Non-Inline처럼 동작
따라서 Array는 inline 저장되어도 성능상 큰 이득은 없습니다.
답: 아니요! Inline 저장 ≠ Static Dispatch입니다.
Inline 저장 (≤ 24 bytes):
• 메모리 할당 방식 (힙 할당 없음)
• 캐시 효율성에만 영향
• → 메모리 성능 개선 ✅
Static Dispatch:
• 메서드 호출 방식 (컴파일 타임 결정)
• 타입에 따라 결정됨 (Struct, final class, Generic)
• → 메서드 호출 성능 개선 ✅
중요: any Protocol은 값이 inline 저장되어도, 메서드 호출은 여전히 PWT를 통한 Dynamic Dispatch입니다!
let drawable: any Drawable = Point()
→ Point는 inline 저장됨 (메모리 빠름)
→ 하지만 drawable.draw()는 Dynamic Dispatch (메서드 호출 느림)
세 가지 오버헤드:
// Static Dispatch (빠름):
dog.bark()
↓ 컴파일
call 0x3000 // 바로 점프 ✅
// Dynamic Dispatch (느림):
dog.bark()
↓ 런타임
1. dog의 vtable pointer 읽기 // 메모리 접근 1
2. vtable에서 bark() 슬롯 읽기 // 메모리 접근 2
3. 메서드 주소 읽기 (0x5000)
4. 0x5000으로 점프 // 여러 단계 거침 ❌
① 간접 호출 (Indirect Call):
② 인라이닝 최적화 불가:
// Static Dispatch - 인라이닝 가능:
func callSound(dog: Dog) { // Dog 타입 확정
dog.sound()
}
// 컴파일러 최적화 후:
// func callSound(dog: Dog) {
// print("멍멍") // ← 함수 호출 없이 코드 직접 삽입
// }
// Dynamic Dispatch - 인라이닝 불가:
func callSound(animal: Animal) { // 실제 타입 모름
animal.sound() // 런타임까지 모르니까 인라이닝 불가 ❌
}
③ 메모리 접근 추가:
상대적 속도:
| 방식 | 상대 속도 | 비고 |
|---|---|---|
| Static Dispatch | 1x (기준) | 가장 빠름 |
| Dynamic Dispatch (vtable) | 1.5~2x | Class 메서드 |
| Dynamic Dispatch (any + PWT) | 2~3x | Protocol 메서드 |
의미: 연속된 메모리에 데이터를 직접 저장
// Existential Container
let drawable: any Drawable = Point()
Value Buffer에 데이터를
"연속된 메모리에 직접" 저장
→ Inline 저장 (메모리 점프 없음)
영향: 캐시 효율성, 메모리 성능
의미: 함수 호출을 제거하고 코드를 직접 삽입
// 원본:
func add(a: Int, b: Int) -> Int {
return a + b
}
let result = add(a: 5, b: 3)
// 인라이닝 후:
let result = 5 + 3
// 함수 호출 없음!
영향: 함수 호출 오버헤드 제거
vtable의 본질:
// vtable = 함수 주소들을 담은 배열 (개념적)
Dog's vtable = [
sound: 0x3000, // ← Dog.sound() 함수가 있는 주소
eat: 0x2000 // ← Animal.eat() 함수가 있는 주소
]
// vtable 자체는 "데이터" (함수 코드가 아님)
struct Dog {
var name: String
func bark() { }
}
let dog = Dog(name: "Max")
메모리 구조:
스택:
┌─────────────────────┐
│ name (String) │ ← 16 bytes
└─────────────────────┘
vtable pointer 없음!
데이터만 있음!
class Dog {
var name: String
func bark() { }
}
let dog = Dog(name: "Max")
메모리 구조:
힙:
┌─────────────────────┐
│ vtable pointer │ ← 8 bytes
│ reference count │ ← 8 bytes
│ name (String) │ ← 16 bytes
└─────────────────────┘
vtable pointer 있음!
예시:
struct Point {
var x: Double // 8 bytes
var y: Double // 8 bytes
}
class ViewModel {
var point: Point // ← Point가 "포함됨"
}
let vm = ViewModel()
vs Class 프로퍼티:
class Dog {
var name: String
}
class ViewModel {
var dog: Dog // ← Dog는 별도 힙 할당
}
차이점:
Struct 인스턴스 = 데이터만:
struct Dog {
var name: String
func bark() { print("멍멍") }
}
let dog = Dog(name: "Max")
메서드 호출 과정:
dog.bark()
// 컴파일러가 생성하는 코드 (개념적):
Dog.bark(&dog) // ← 전역 함수처럼 호출, dog를 인자로 전달
// 어셈블리 레벨:
lea rdi, [rbp-16] // dog의 주소를 rdi 레지스터에
call 0x3000 // Dog.bark() 코드 주소로 직접 점프
호출 과정 비교:
| 방식 | 호출 과정 |
|---|---|
| Static Dispatch (Struct) |
dog.bark()↓ 컴파일 call 0x3000 ← 코드 세그먼트 주소 직접↓ 실행 바로 코드 세그먼트 0x3000으로 점프 ✅ |
| Dynamic Dispatch (Class) |
dog.bark()↓ 런타임 1. 힙에서 vtable pointer 읽기 2. 데이터 세그먼트에서 vtable의 bark 슬롯 읽기 3. 코드 주소 얻음 (0x5000) 4. 코드 세그먼트 0x5000으로 점프 ↓ 힙 → 데이터 → 코드 (3단계) ❌ |
Existential Container (40 bytes)
┌─────────────────────────────────────┐
│ Value Buffer (24 bytes) │ ← 값 저장 (≤24B: 인라인 / >24B: 힙 포인터)
├─────────────────────────────────────┤
│ VWT Pointer (8 bytes) ─────────────────→ Data Segment: VWT (copy, destroy, size...)
├─────────────────────────────────────┤
│ PWT Pointer (8 bytes) ─────────────────→ Data Segment: PWT (draw→0x1000, erase→0x1100)
└─────────────────────────────────────┘ │
▼
Code Segment: 실제 함수 코드
답: any 타입/프로토콜을 따르는 구현체를 담는 메모리 구조입니다.
구성 (총 40 bytes):
• Value Buffer (24B): 실제 값 저장 (작으면 inline, 크면 힙 포인터)
• VWT pointer (8B): 복사/삭제 등 메모리 관리 방법
• PWT pointer (8B): 프로토콜 메서드들의 주소
┌─────────────────────────────────────┐ │ Value Buffer (24 bytes) │ ├─────────────────────────────────────┤ │ VWT Pointer (8 bytes) │ ├─────────────────────────────────────┤ │ PWT Pointer (8 bytes) │ └─────────────────────────────────────┘
답: 테이블의 개수가 다르게 생성됩니다.
VWT - 타입마다 1개:
"이 타입을 어떻게 복사/삭제하나"만 알면 됨
→ Circle용 VWT 1개, Square용 VWT 1개
PWT - (타입, 프로토콜) 조합마다 1개:
"이 타입이 이 프로토콜 메서드를 어디서 구현했나"
→ Circle+Drawable PWT, Circle+Printable PWT, Square+Drawable PWT
// Circle이 Drawable, Printable 채택 시 Circle용 VWT: 1개 (복사/삭제 방법) Circle+Drawable PWT: 1개 (draw 주소) Circle+Printable PWT: 1개 (print 주소)
답: 네, 맞습니다! ✅
struct Circle: Drawable, Printable { ... }
// PWT 2개 생성됨:
┌─────────────────────────────┐
│ Circle + Drawable PWT │ ← draw() 주소
└─────────────────────────────┘
┌─────────────────────────────┐
│ Circle + Printable PWT │ ← print() 주소
└─────────────────────────────┘
어떤 PWT가 사용되는지는 변수 타입에 따라 결정:
답: Generic 함수 호출 시 PWT가 파라미터로 함께 전달됩니다.
func render<T: Drawable>(_ item: T) {
item.draw()
}
// 컴파일러가 내부적으로 변환:
func render(_ item: T, pwt: Drawable_PWT, vwt: T_VWT) {
pwt.draw(item) // PWT를 통해 draw 호출
}
// 호출 시:
render(Circle()) // Circle + Drawable PWT 전달
render(Square()) // Square + Drawable PWT 전달
Existential Container와 차이:
답: 네, 둘 다 PWT를 사용하지만 Generic이 더 빠릅니다!
any Drawable:
• Value Buffer에 값 복사 (boxing)
• 큰 값 (>24B)은 힙 할당 필요
• 40바이트 컨테이너 전체를 넘김
<T: Drawable> (Generic):
• 값 그대로 전달 (boxing 없음)
• 힙 할당 없음
• Specialization 가능 (인라인)
핵심: PWT 사용 여부가 아니라 Existential Container의 boxing 오버헤드가 성능 차이의 원인!
답: 아니요! 인라인은 컴파일 타임 최적화입니다.
인라인 = 함수 호출을 함수 본문으로 대체
// 원본 코드
func square(_ x: Int) -> Int {
return x * x
}
let result = square(5)
// 컴파일러가 인라인 적용 후
let result = 5 * 5 // 함수 호출 자체가 사라짐
정리:
답: 아니요! PWT는 데이터 영역(Data Segment)에 있습니다.
┌─────────────────────────────────┐ │ Code Segment │ ← 실제 함수 코드 │ Circle.draw: 0x1000 │ ├─────────────────────────────────┤ │ Data Segment │ ← PWT, VWT 테이블들 │ Circle+Drawable PWT: 0x5000 │ │ └─ draw → 0x1000 (포인터) │ ├─────────────────────────────────┤ │ Stack / Heap │ ← 실제 값들 └─────────────────────────────────┘호출 흐름:
답: 맞습니다! 구조가 거의 동일합니다.
공통점:
• 둘 다 Data Segment에 저장
• 둘 다 Code Segment의 함수 주소를 가리킴
• 둘 다 함수 포인터 테이블
차이점:
| VTable | PWT | |
|---|---|---|
| 포인터 위치 | 인스턴스 내부 (힙) | 파라미터 or Container |
| 용도 | 클래스 상속 다형성 | 프로토콜 다형성 |
| 테이블 개수 | 클래스당 1개 | (타입, 프로토콜)당 1개 |
답: 아니요! 함수 본문을 볼 수 있어야 합니다.
Specialization = 함수 복제
컴파일러가 Generic 함수를 타입별로 복사하는 것
// 원본
func render<T: Drawable>(_ item: T) {
item.draw()
}
// Specialization 후 (컴파일러가 복제)
func render_Circle(_ item: Circle) {
item.draw() // ← 본문 복사됨
}
본문을 못 보면 → 복사할 게 없음 → Specialization 불가
답: 기본적으로 Specialization이 안 됩니다.
// 모듈 B
func print<T: Drawable>(_ item: T) {
item.draw()
}
// 모듈 A에서 호출
import ModuleB
print(Circle()) // ❌ Specialization 불가
// → PWT 전달 방식으로 컴파일
모듈 A 컴파일러 입장:
답: 두 가지를 구분해야 합니다!
1. Specialization = "T가 뭔지 아는 것"
T = Circle만 알면 → Circle.draw() 주소로 직접 호출 가능
2. 인라인 = "함수 본문을 복사하는 것"
Circle.draw() 본문까지 복사하려면 그 코드가 보여야 함
// 모듈 B에서 print(Circle()) 호출 시:
Specialization 후 생성되는 코드:
func print_Circle(_ item: Circle) {
item.draw() // Circle.draw() "직접 호출"
// PWT 조회 없음 ✅
// draw() 본문 인라인 ❌ (다른 모듈)
}
결론: Circle 구현 몰라도 Specialization은 가능
답: 다른 모듈에 함수 본문을 공개하는 키워드입니다.
// 모듈 B
@inlinable
public func print<T: Drawable>(_ item: T) {
item.draw()
}
// 모듈 A
import ModuleB
print(Circle()) // ✅ Specialization 가능!
비유:
Specialization이 적용되려면:
| 조건 | Specialization |
|---|---|
| 같은 모듈 내에서 호출 | ✅ 적용 가능 |
| 다른 모듈에서 호출 | ❌ 기본 불가 |
@inlinable 표시된 함수 | ✅ 다른 모듈에서도 가능 |
Specialization 필수 조건: 1. T가 뭔지 알아야 함 ✅ (호출부에서 Circle 전달) 2. 함수 본문을 봐야 함 ✅ (복제할 코드 필요) → 둘 다 충족해야 Specialization 가능
| 영역 | 무엇 |
|---|---|
| Code(text) | 실제 메서드 기계어 |
| Data(읽기전용) | 클래스 메타데이터 + vtable(메서드 주소들) |
| Heap | 인스턴스(맨 앞 isa 포인터 + 프로퍼티) |
인스턴스(힙)는 isa로 메타데이터를 가리키고, 메타데이터 안 vtable로 메서드를 찾는다.
class Animal { func eat() {} }
class Dog: Animal { func bark() {} } // bark는 Dog 고유
let animal: Animal = Dog()
animal.eat() // ✅ Animal에 있음
animal.bark() // ❌ 컴파일 에러 — Animal 타입엔 bark 없음
(animal as? Dog)?.bark() // ✅ 다운캐스팅하면 됨캐시 미스 = CPU가 데이터를 캐시에서 못 찾아 느린 RAM에서 가져오는 것(~100사이클). 박싱/언박싱과는 다른 층위(메모리 접근 비용).
let shapes: any Collection = [a, b, c]
Container(40B):
Value Buffer(24B): [Array 헤더 = buffer 포인터 8B] ← Array는 8B라 inline 직접! box 없음
Type Metadata Ptr(8B) → Array 메타데이터(크기·VWT)
PWT Ptr(8B) → (Array, Collection) witness table
│ Array buffer 포인터
▼
힙: [a, b, c] ← 요소들 (Array가 관리, existential과 무관) [any P]는 별개 — 각 원소가 container)
클래스마다 하나입니다. 인스턴스가 갖는 건 vtable 자체가 아니라, 클래스 metadata를 가리키는 isa 포인터 1개. vtable은 그 metadata 안에 있고, 같은 클래스의 인스턴스 100개는 전부 같은 vtable 하나를 공유합니다.
class Base { func foo() {} func bar() {} }
class Derived: Base { override func foo() {} } // foo만 오버라이드
Base 의 vtable : [ foo → Base.foo , bar → Base.bar ] ← 클래스당 하나
Derived 의 vtable: [ foo → Derived.foo , bar → Base.bar ] ← foo 슬롯만 교체
base1, base2 ─isa→ Base vtable (Base 인스턴스는 전부 공유)
d1, d2, d3 ─isa→ Derived vtable (Derived 인스턴스는 전부 공유)
class Parent { func run() { print("Parent") } }
class Child: Parent {
override func run() { print("Child") }
func onlyChild() {}
}
let x: Parent = Child() // 선언 타입 Parent, 실제 Child
x.run() // "Child" ← dynamic dispatch (vtable 따라감)
x.onlyChild() // ❌ 컴파일 에러 — 가시성 문제
| 축 | 무엇을 결정 | 기준 |
|---|---|---|
| 가시성 | 무엇을 부를 수 있나 | 선언 타입(Parent) — 컴파일 타임 |
| 디스패치 | 부른 것 중 어느 구현이 실행되나 | 실제 인스턴스(Child) — 런타임 vtable |
x로는 Parent에 선언·오버라이드된 것만 호출 가능. 자식 전용 메서드는 x as? Child 다운캐스트 후에야 호출let x: Base = Derived()도 x의 실제 인스턴스가 Derived vtable을 isa로 가리키므로 x.foo()는 Derived.foo