Swift에서 프로토콜, 제네릭, 그리고 확장(extension)이 섞인 코드를 보다 보면 뭔가 어려운 듯하면서도, 익숙한 듯하면서도… 이상하게 머리에 잘 안 들어온다.
나도 그랬다. 특히 associatedtype이 처음 등장했을 때는 대체 얘는 왜 필요한 건지부터가 궁금했다.
그럼, 처음부터 차근차근 짚어보자.
제네릭(Generic)은 “타입을 고정하지 않고, 나중에 사용할 때 결정하도록 만드는 프로그래밍 기법”이다.
예를 들어 Array<T>는 어떤 타입이든 저장할 수 있다. Array<Int>, Array<String>처럼.
Swift에서 제네릭은 다음과 같은 곳에 쓰인다.
func swapValues<T>(_ a: inout T, _ b: inout T) {
let temp = a
a = b
b = temp
}
같은 로직을 여러 타입에 대해 재사용할 수 있게 해주니, 유연하고 타입 안정적인 코드를 만들 수 있다.
다음 코드를 봐보자. 역시 아무것도 모르는게 당연하다. 이 코드가 예제가 되어줄거다.
(주석으로 달은건 무시해도 된다. 강의를 보면서 대충 이해한대로 적은 것이니....)
rotocol View {
func addSubView(_ view: View)
}
extension View {
func addSubView(_ view: View) {
}
}
struct Button: View {}
protocol PresentableAsView {
associatedtype ViewType: View // generic 역할
func produceView() -> ViewType // ViewType을 지정해줄 애
func configure(superView: View, thisView: ViewType)
func present(view: ViewType, on superView: View)
}
extension PresentableAsView {
func present(view: ViewType, on superView: View) {
superView.addSubView(view) // 이건 잘 모르겠음
}
}
struct MyButton: PresentableAsView { // MyButton 구조체를 만들어 줌
func produceView() -> Button { Button() } // 구현체로부터 받은 produceView를 Button()으로 초기화 여기서 ViewType 자동추론이 이루어진다.
func configure(superView: View, thisView: Button) {} // 의미 없음
}
extension PresentableAsView where ViewType == Button { // Veiw타입이 Button하고 같으면 아래 함수를 실행
func doSomethingWithButton() {
"This is a button"
}
}
let button = MyButton()
button.doSomethingWithButton()
Swift 프로토콜에서는 왜 직접 제네릭을 못 쓰고 associatedtype을 써야 할까?
Swift에서는 프로토콜에 직접 <T> 같은 제네릭을 붙일 수 없다.
대신 associatedtype을 이용해서 추상적인 타입을 정의한다.
protocol PresentableAsView {
associatedtype ViewType: View
}
이건 “이 프로토콜을 따르는 애는 ViewType이라는 타입을 나중에 정하게 될 건데, 그 타입은 최소한 View를 따라야 해”라는 뜻이다.
즉, 프로토콜은 타입을 알 수 없으니 대충 틀만 잡고,
실제 타입은 나중에 이 프로토콜을 채택하는 쪽에서 정해줘야 한다는 거다.
아래처럼 쓴 present(view: Button, ...)는 과연 프로토콜 요구사항을 만족할까?
extension PresentableAsView {
func present(view: Button, on superView: View) {
superView.addSubView(view)
}
}
아니다 ❌
이건 프로토콜에 정의된 func present(view: ViewType, on:)와는 다른 함수다.
ViewType과 Button은 같은 타입이 아니기 때문이다. 이름만 같아 보여도, Swift는 타입까지 엄격하게 본다.
따라서 정확하게 구현하려면 이렇게 써야 한다:
extension PresentableAsView {
func present(view: ViewType, on superView: View) {
superView.addSubView(view)
}
}
그럼 where ViewType == Button 조건은 언제 만족할까?
extension PresentableAsView where ViewType == Button {
func doSomethingWithButton() {
print("This is a button")
}
}
이건 조건부 확장이다.
말 그대로, ViewType이 Button일 때만 저 확장이 붙는다.
그럼 ViewType은 언제 Button으로 정해질까?
struct MyButton: PresentableAsView {
func produceView() -> Button { Button() } // 여기서 정해진다.
func configure(superView: View, thisView: Button) {} // produceView()가 없었다면 여기서 정해졌을것이다.
}
여기서 Swift는 추론한다:
produceView()의 반환값이 Button이니까 → ViewType = Buttonconfigure()도 thisView: Button이니까 → 역시 ViewType = Button즉, 컴파일러는 "아 너 ViewType = Button이구나" 하고 알아서 추론해준다.
그래서 아래 같은 코드도 실행된다:
let my = MyButton()
my.doSomethingWithButton()
produceView, configure, present는 왜 필요한가?지금 당장 보기엔 아무 일도 안 하는 함수처럼 보일 수 있다.
하지만 이들은 설계상의 인터페이스 역할을 한다.
produceView() → 뷰를 만들고configure() → 뷰를 설정하고present() → 부모 뷰에 뷰를 추가한다이렇게 만들면 어떤 뷰 타입이 오든 같은 흐름으로 처리할 수 있다.
→ 매우 확장성과 재사용성이 높은 구조다.
| 항목 | 설명 |
|---|---|
associatedtype | 프로토콜 내부에서 타입을 일반화하는 키워드. 제네릭처럼 작동함 |
ViewType: View | ViewType은 최소한 View를 따라야 함 |
produceView() -> Button | 이걸 통해 ViewType이 Button으로 추론됨 |
extension where ViewType == Button | 조건부 확장. 오직 Button일 때만 실행 |
present(view: Button, ...) | 이건 ViewType이 Button일 때만 맞는 함수이고, 일반 요구사항과는 별개 |
| 인터페이스 메서드들 | 구조화된 설계를 위해 사용됨. 재사용성과 유연성을 높임 |
이 코드는 Swift 제네릭 프로그래밍, 프로토콜 중심 설계, 타입 추론, 조건부 확장의 정수가 다 들어 있는 예제다.
처음엔 헷갈릴 수밖에 없다.
하지만 흐름을 알고 나면, Swift가 왜 이렇게 설계되었는지 조금씩 이해가 간다.
그리고 이제 나도 이렇게 말할 수 있다:
associatedtype은 프로토콜 속 제네릭이다.