POP · 디스패치 · 제네릭 복습 (Q&A)
6/3 학습일지의 Protocol Oriented Programming / Existential Container / Witness Table 오답노트를 복습하며 정리한 문답이다. 주인님이 이해한 것 + 질문과 그에 대한 답을 그대로 보존한다.
제네릭과 결합하면 zero-cost 추상화가 가능하다 — 최적화가 일어나면 그렇다는 건가? PWT를 함수 인자로 보내야 하는 상황이면 아니지?
내 이해: zero-cost는 최적화(specialization)가 됐을 때 얘기고, PWT를 인자로 넘겨야 하면 zero-cost가 아니다.
정확하다. zero-cost는 specialization(특수화)이 일어났을 때만 성립한다.
- 최적화 ON →
T를 구체 타입으로 고정한 별도 함수로 복제(monomorphize) + 인라인 → witness table 간접 호출이 사라짐 → zero-cost - 특수화 실패 → PWT(+메타데이터)를 숨은 인자로 전달받아 런타임에 witness table 조회 → zero-cost 아님
→ "PWT를 인자로 보냄 = 특수화 안 됨 = zero-cost 아님"이 맞다.
AnyView를 설명해줘
타입 소거(type erasure)된 SwiftUI 뷰 래퍼. 서로 다른 구체 View 타입을 하나의 AnyView 단일 타입으로 감싸 정적 타입 정보를 지운다.
some View (opaque) | AnyView / any View | |
|---|---|---|
| 타입 | 컴파일타임 단일 타입 유지(정적) | 런타임 박싱(존재 타입) |
| diff/identity | 효율적 | 비효율 — 구조 비교 어려움 → 재생성↑ |
| 용도 | 기본 권장 | 분기마다 타입이 달라 못 묶을 때만 |
오늘 배운 existential container의 SwiftUI 버전. 남용하면 성능 저하 → 가능하면 some View.
프로토콜 기본 + extension의 dispatch 함정 — extension에서 static dispatch가 일어나면 좋은 거 아냐?
내 이해: static dispatch면 빠르니까 좋은 거 아닌가?
함정은 "성능"이 아니라 "엉뚱한 메서드가 불린다(정확성)"이다. static dispatch가 빠른 건 맞지만, 여기선 다형성을 기대했는데 override가 무시되는 버그가 핵심.
| 메서드 종류 | 디스패치 | 결과 |
|---|---|---|
| 프로토콜 requirement + extension 기본 구현 | 동적(witness table) | 채택 타입 override 호출 의도대로 |
| extension에만 있는 메서드 | 정적(정적 타입 기준) | 정적 타입 구현 호출, override 무시 함정 |
→ "빠르니 좋다"가 아니라 빠르지만 틀린 호출이라 함정. (override 안 시킬 내부 헬퍼라면 의도적으로 OK)
associated type과 Self 제약을 설명해줘
둘 다 프로토콜에 구체 타입이 채워져야 할 구멍을 만들어, 그 프로토콜을 그냥 존재 타입으로 쓰기 어렵게 만드는 요소다.
이 둘이 있으면(PAT: Protocol with Associated Type / Self requirement) Swift 5.6까지는 let x: Container처럼 존재 타입으로 못 쓰고 "can only be used as a generic constraint" 에러 → 제네릭 func f<T: Container>(...)로 써야 했다. (5.7+ any Container로 일부 허용, 제약 있음)
아래 코드에서 — requirement에만 있는 메서드는 witness table에 들어가지?
네 — requirement인 메서드는 witness table에 들어간다. 주인님 분석 전부 맞다:
a()= requirement → witness table 등재 → 동적 →p.a()=S.a✅b()= requirement 아님(extension 전용) → witness table 없음 → 정적(타입P) →p.b()=P.b ext only✅
witness table엔 "프로토콜 requirement로 선언된 것"만 들어간다. 다형성을 원하면 반드시 프로토콜 본문에 requirement로 선언해야 한다.
"클래스의 vtable은 객체 안에 isa 포인터로 박혀 있다" — 이게 무슨 말이야?
객체가 vtable을 통째로 들고 있는 게 아니라, 객체 맨 앞 8바이트에 isa 포인터(= 타입 메타데이터 주소)가 박혀 있고, vtable은 그 메타데이터 안에 있다.
- 호출 경로: 인스턴스 → isa → 메타데이터의 vtable → 슬롯의 함수 주소 → 호출
- "박혀 있다" = vtable 자체가 아니라 vtable로 가는 포인터(isa)가 객체 안에 박혀 있다는 뜻. vtable은 클래스당 1개를 메타데이터에 두고 모든 인스턴스가 isa로 공유
isa는 Obj-C 유래 용어. Swift 순수 클래스에선 type metadata 포인터
generic 호출은 specialization으로 디스패치가 사라진다는데 — Drawable·Circle이 모듈A, print<T: Drawable>이 모듈B에 있고 B에서 print를 호출하면, B는 A의 Circle 메서드를 모르니까 PWT를 조회해야겠네?
내 이해: 모듈B는 모듈A의 Circle이 어떤 메서드를 가졌는지 모르니까 PWT를 런타임에 조회할 것이다.
전제에 미묘한 오해가 있다. specialization은 "제네릭 함수의 본문(body)이 호출 지점 옵티마이저에 보이는가"로 결정된다 — 채택 타입의 메서드를 아느냐가 아니다.
이 시나리오(print<T>이 B에 있고 B가 호출):
- B를 컴파일할 때
print본문이 보인다(같은 모듈) - Circle의 Drawable witness table은 모듈A가 emit해 공개 심볼로 접근 가능. B는 A를 import하므로 Circle의 conformance(=PWT)를 안다
- → specialization 가능 → 런타임 PWT 조회 없이 zero-cost
즉 "B는 Circle의 메서드를 모른다"가 틀렸다 — import로 알고 witness table도 접근 가능.
print<T>이 모듈A에 있고@inlinable도 아닌데 모듈B에서 호출 → B 옵티마이저가print본문을 못 봄 → 특수화 불가 → PWT를 런타임 인자로 전달- 또는 library evolution(resilient)로 특수화가 막히는 경우
정리: 제네릭 함수 본문이 보이면(같은 모듈 or @inlinable) → 특수화 → zero-cost. 안 보이면 → PWT 런타임 전달 → 비용 발생. 주인님 예시(print가 B)는 특수화되는 쪽이다.
추가 문답 (이어서)
"분기마다 타입이 달라 some View로 못 묶을 때"가 무슨 말이야?
some View(opaque type)는 모든 경로가 단 하나의 구체 타입을 반환해야 한다. 분기마다 반환 View 타입이 다르면 하나로 못 묶인다.
참고: body처럼 @ViewBuilder가 붙은 곳의 if/else는 컴파일러가 _ConditionalContent<Text, Image>로 자동으로 감싸줘서 된다. 문제는 일반 함수에서 return으로 분기하거나 배열에 이질적 뷰를 담을 때처럼 @ViewBuilder가 못 끼는 상황 → AnyView 필요.
extension A { c() }인데 B: A가 c()를 직접 구현하면 — B 것이 안 먹히고 extension 것이 먹혀?
호출 시점의 "정적 타입"에 따라 갈린다. (c()는 requirement가 아니라 witness table에 없음)
| 호출 | 불리는 것 |
|---|---|
B로 볼 때 (let b: B) | B.c() (B 버전 먹힘) |
A로 볼 때 (let x: A) | A extension의 c() (B.c 무시) |
→ "안 먹힌다"는 프로토콜 타입(A)으로 업캐스트해서 호출할 때만. B로 들고 있으면 B.c가 먹힌다. 항상 B.c가 불리게 하려면 c()를 프로토콜 requirement로 선언해야 한다.
메타데이터는 데이터 영역에 있고 그게 vtable을 갖고, vtable은 코드 영역 주소를 가리키지?
정확하다.
- 메타데이터: 바이너리의 읽기 전용 데이터 영역(
__DATA_CONST/const)에 정적 존재 (힙·스택 아님) - vtable: 그 메타데이터 안의 함수 포인터 배열
- 각 슬롯 = 코드 영역(
__TEXT)의 실제 함수 기계어 주소
→ 인스턴스(힙) → isa → 메타데이터(데이터) → vtable → 코드영역 함수 주소.
print 구현체와 호출이 같은 모듈이면 항상 최적화(특수화)라고 보면 되네?
"항상"은 아니다 — "특수화의 전제조건이 충족된다"가 정확하다. 같은 모듈이면 본문+구체 타입이 다 보이므로 특수화가 가능하지만, 실제로 되려면:
- ① 최적화(-O, Release)가 켜져야 함 → Debug(-Onone)에선 특수화 안 됨
- ② 컴파일러 휴리스틱 — 코드 크기 등 이유로 생략할 수도 있음 (보장 아님)
→ 같은 모듈 + Release → 거의 항상 특수화(직접 구체 타입 호출 시). 단 Debug거나 컴파일러 판단상 생략하면 안 될 수도. 강제하려면 @_specialize/@inlinable.
추가 문답 (2) — some 규칙 · PWT 저장 위치
some View가 하나의 구체 타입을 반환해야 한다는 건 some의 규칙인가?
그렇다 — some(opaque result type, SE-0244)의 핵심 규칙이고, SwiftUI가 아니라 Swift 언어 규칙이다. some View는 그걸 가져다 쓸 뿐.
some P = "P를 채택하는 단 하나의 구체 타입이 컴파일타임에 고정. 호출자는 뭔지 모르지만 컴파일러는 안다." → 모든 return 경로가 같은 타입이어야 함(타입 identity 보존이 목적).
some P (opaque) | any P (existential) | |
|---|---|---|
| 구체 타입 | 1개 고정(컴파일타임) | 여러 개(런타임 박싱) |
| 규칙 | 모든 경로 같은 타입 | 자유 |
struct가 protocol 채택할 때 PWT가 그 stack에 들어가?
아니다. PWT는 인스턴스/값마다 들어가는 게 아니라 바이너리의 정적 읽기전용 데이터에 conformance당 1개만 emit되어 공유된다. struct 값(스택)은 그냥 자기 필드만 갖는다.
PWT 포인터가 등장하는 건 ① existential(any P)로 만들 때(컨테이너에 VWT*+PWT*), ② unspecialized 제네릭 호출 시(숨은 인자)뿐. PWT 자체는 정적 데이터(공유).
클래스가 protocol 채택할 때는 힙에 PWT가 올라가? / requirement 아닌 클래스 메서드는 isa의 vtable에 들어가지?
PWT는 힙에 안 올라간다 — 똑같이 정적 데이터다. 힙의 클래스 인스턴스는 isa 포인터 + 저장 프로퍼티만 갖는다.
requirement 아닌 일반 클래스 메서드(오버라이드 가능) → isa→메타데이터의 vtable에 들어간다(맞음). 클래스는 두 테이블과 엮인다:
| 메서드 종류 | 어디에 | 디스패치 |
|---|---|---|
| requirement 아닌 일반 메서드(오버라이드 가능) | vtable (isa→메타데이터) | 클래스 동적 |
| protocol requirement 메서드 | PWT (non-final이면 엔트리가 vtable 거쳐 실제 구현) | 프로토콜 동적 |
final/private 메서드 | 테이블 없음 | 정적 |
→ PWT는 프로토콜 requirement용, vtable은 클래스 상속용으로 역할이 다르다.
추가 문답 (3) — some/any identity · 이중 디스패치 · 정적 vs 인라인
"타입은 숨지만 identity 보장(some) / 타입 지워짐(any)"이 무슨 말이야?
핵심은 컴파일러가 구체 타입을 아직 아느냐다.
some P: 호출자에겐 타입 이름을 숨기지만 컴파일러는 하나의 고정 타입 T임을 앎 → 타입 identity 보존(같은 함수가 준 두 값은 같은 타입 보장, 정적 디스패치/특수화 가능)any P: 런타임에 아무 채택 타입이나 담는 박스 → 구체 타입 정보 지워짐(같은타입 보장 없음, PWT 동적)
→ some = "이름만 가린 단일 타입(컴파일러는 앎)", any = "타입 자체를 지운 박스(런타임 동적)".
non-final 클래스의 requirement 메서드는 isa·PWT 둘 다 보는가?
그렇다. any P로 non-final 클래스의 requirement를 호출하면 PWT → vtable 2단 간접이 일어난다.
| 채택 타입 | requirement 호출 경로 |
|---|---|
struct / final class | PWT → 직접 구현 (vtable 안 거침) |
| non-final class | PWT → thunk → isa/vtable → 구현 (둘 다) |
non-final은 서브클래스 override 존중을 위해 PWT 엔트리가 vtable을 거친다 → PWT(프로토콜) + vtable(클래스) 이중 디스패치.
final/private는 "테이블 없음·정적"인데 — "인라인"이라고 표현 가능한가?
아니다 — 둘은 다른 개념. 정적 디스패치가 인라인을 가능하게 하지만 같지는 않다.
| 정적 디스패치(static) | 인라인(inlining) | |
|---|---|---|
| 뜻 | 대상이 컴파일타임 확정 → 직접 호출(테이블 X) | 호출을 아예 함수 본문으로 치환(호출 자체가 사라짐) |
| 관계 | 인라인의 전제조건 | 그 위에 얹는 추가 최적화 |
| 보장 | final/private면 항상 정적 | 보장 안 됨(크기·빈도·-O 휴리스틱) |
- 큰
final메서드 → 정적이지만 인라인 안 될 수 있음(그냥 직접 call) - 작은
final메서드 → 정적 + 인라인 잘 됨 - Debug 빌드 → 정적이지만 인라인 거의 안 함
→ "final/private = 정적 디스패치(직접 호출)". 인라인은 그게 깔린 뒤 컴파일러가 선택적으로 하는 별개 최적화. "정적 = 인라인"으로 묶으면 틀림.
추가 문답 (4) — 호출자 vs 컴파일러 · thunk · 이중 디스패치 이유
"호출자에겐 숨김 / 컴파일러는 앎" — 호출자와 컴파일러의 차이가 뭐야?
같은 프로그램을 보는 두 관점이다.
- 호출자(caller) = 그 함수를 부르는 네 코드(소스 레벨 타입 시스템이 보여주는 것).
some View면 "어떤 View다"까지만 → Text 전용 멤버 접근 불가, 구체 타입 이름 못 씀 - 컴파일러 = 내부적으로 실제 타입(Text)을 끝까지 알고 그에 맞춰 기계어 생성(박싱 없이 정적 디스패치·레이아웃 확정)
비유: 봉인된 상자에 "some Drawable" 라벨 → 너(호출자)는 못 열어보고 Drawable 메서드만, 공장(컴파일러)은 Circle인 걸 알고 효율 포장. any는 한 발 더 나가 컴파일러조차 타입을 안 고정(런타임 박스), some은 컴파일러는 알지만 API 표면에서만 가림.
thunk가 뭐야?
컴파일러가 자동 생성하는 짧은 "어댑터 함수". 두 호출 규약을 맞추거나 한 단계 우회(간접)를 끼워 A→B를 연결한다.
이 맥락의 witness thunk: non-final 클래스의 witness table 엔트리는 메서드 자체가 아니라 thunk이고, 그 thunk가 객체 isa → vtable 조회 → 실제 구현 호출(+ 프로토콜 호출 규약 변환)을 한다.
Swift엔 witness thunk 외에 reabstraction thunk, ObjC bridging thunk 등도 있다. 공통점: 규약/간접을 메우는 자동 생성 글루 코드.
non-final 클래스를 any로 쓸 때 PWT 먼저 보고, override 가능하니 vtable도 보는 거야?
정확하다 — 이유까지 맞다.
- PWT를 먼저: 손에 든 게
any A라 "어느 타입이 A를 어떻게 conform하는지"를 PWT로 찾음(프로토콜 레벨) - vtable도: 클래스라 서브클래스 override 가능 → 실제 동적 타입의 구현을 골라야 하므로 vtable로 한 번 더(클래스 레벨)
→ PWT(프로토콜) + vtable(클래스) 이중. final 클래스/struct면 override 불가라 PWT 엔트리가 구현을 직접 가리키고 vtable 안 봄.
추가 문답 (5) — executor vs cooperative thread pool
"global cooperative thread pool" 용어가 맞아?
맞다 — 올바른 서술적 표현. 공식 용어로 나누면 둘이다:
- cooperative thread pool: 런타임이 유지하는 스레드 풀, 폭 ≈ CPU 코어 수. "cooperative" = 스레드 블로킹 없이
await에서 양보 → thread explosion 방지 (WWDC21 공식 용어) - global concurrent executor(=default): 그 풀에 스케줄링하는 executor
| cooperative thread pool | GCD global() | |
|---|---|---|
| 스레드 수 | 코어 수 제한 | 증가 가능(explosion) |
| 블로킹 | await 양보(non-blocking) | 블로킹 가능 |
"Task는 글로벌 스레드"의 "글로벌"은 GCD global queue가 아니라 이 cooperative thread pool. 더 깊은 내용: Actor 프로토콜 · Task Isolation
추가 문답 (6) — @inlinable
@inlinable이 뭐야?
함수의 "본문(body)"을 모듈의 공개 인터페이스로 내보내, 다른 모듈의 옵티마이저가 그 본문을 보고 인라인·특수화할 수 있게 하는 속성. (Q7/Q11의 "같은 모듈 or @inlinable"의 바로 그것)
왜: 보통 함수는 시그니처만 공개되고 본문은 불투명 → cross-module 호출은 인라인·제네릭 특수화 불가 → PWT 런타임 전달. @inlinable은 본문까지 공개 → 호출 모듈이 보고 specialization + 인라인 가능 → 디스패치 제거.
주의(공짜 아님):
- 본문이 ABI/API의 일부가 됨 → 이미 인라인해 간 클라이언트는 옛 본문 보유 → 함부로 못 바꿈(호환성 약속)
@inlinable본문은public또는@usableFromInline심볼만 참조 가능 (internal/private 직접 사용 불가)- 인라인을 가능하게 할 뿐, 실제 여부는 옵티마이저가 결정
- 주 용도 = cross-module(프레임워크/라이브러리, 특히 resilient). 같은 모듈은 whole-module 최적화로 이미 됨
한 줄: "이 함수 본문을 다른 모듈에 공개해 그쪽에서 인라인·제네릭 특수화하게 해줘" → 모듈 경계를 넘는 zero-cost를 가능케 하는 도구.
한 장 정리
| 주제 | 핵심 |
|---|---|
| 제네릭 zero-cost | specialization 됐을 때만. PWT 런타임 전달이면 비용 있음 |
| AnyView | 타입 소거 박싱. diff 비효율 → 남용 금지, some View 우선 |
| extension dispatch 함정 | requirement 아닌 메서드는 정적 바인딩 → override 무시(정확성 버그) |
| associatedtype / Self | PAT → 존재 타입으로 쓰기 제약, 제네릭 constraint로 사용 |
| witness table 등재 | 프로토콜 requirement만 등재 → 동적. extension 전용은 미등재 → 정적 |
| isa / vtable | 객체엔 isa 포인터, vtable은 메타데이터 안. isa로 도달 |
| cross-module 특수화 | 제네릭 함수 본문 가시성이 관건(같은 모듈/@inlinable). 안 보이면 PWT 런타임 |