불투명 타입과 박스형 프로토콜 타입
불투명 타입과 박스형 프로토콜 타입
값의 타입에 관한 구현 세부사항을 감춥니다.
Swift에서는 값의 타입에 대한 세부사항을 숨기는 방법이 두 가지 있어요. 바로 **불투명 타입(opaque type)**과 **박스형 프로토콜 타입(boxed protocol type)**이에요. 타입 정보를 숨기는 것은 모듈과, 모듈을 호출하는 코드 사이의 경계에서 특히 유용해요. 반환 값의 실제 타입을 비공개로 유지할 수 있기 때문이죠.
불투명 타입을 반환하는 함수나 메서드는 반환 값의 타입 정보를 숨겨요. 함수의 반환 타입으로 구체적인 타입을 제공하는 대신, 반환 값을 그것이 지원하는 프로토콜을 기준으로 설명해요. 불투명 타입은 타입 정체성(type identity)을 보존해요. 컴파일러는 타입 정보에 접근할 수 있지만, 모듈의 클라이언트는 접근할 수 없어요.
반면 박스형 프로토콜 타입은 주어진 프로토콜을 준수하는 어떤 타입의 인스턴스든 저장할 수 있어요. 박스형 프로토콜 타입은 타입 정체성을 보존하지 않아요. 값의 구체적인 타입은 런타임이 되어서야 알 수 있고, 서로 다른 값이 저장되면서 시간에 따라 바뀔 수도 있어요.
본문
불투명 타입이 해결하는 문제
예를 들어 ASCII 아트 도형을 그리는 모듈을 작성한다고 생각해 볼게요. ASCII 아트 도형의 기본 특징은 그 도형의 문자열 표현을 반환하는 draw() 함수예요. 이 함수를 Shape 프로토콜의 요구사항으로 사용할 수 있어요.
protocol Shape {
func draw() -> String
}
struct Triangle: Shape {
var size: Int
func draw() -> String {
var result: [String] = []
for length in 1...size {
result.append(String(repeating: "*", count: length))
}
return result.joined(separator: "\n")
}
}
let smallTriangle = Triangle(size: 3)
print(smallTriangle.draw())
// *
// **
// ***
도형을 세로로 뒤집는 것 같은 연산은 제네릭으로 구현할 수 있어요. 아래 코드가 그 예시죠. 그런데 이 방식에는 중요한 한계가 하나 있어요. 뒤집힌 결과가, 그것을 만드는 데 사용된 정확한 제네릭 타입을 그대로 드러낸다는 점이에요.
struct FlippedShape<T: Shape>: Shape {
var shape: T
func draw() -> String {
let lines = shape.draw().split(separator: "\n")
return lines.reversed().joined(separator: "\n")
}
}
let flippedTriangle = FlippedShape(shape: smallTriangle)
print(flippedTriangle.draw())
// ***
// **
// *
두 도형을 세로로 이어 붙이는 JoinedShape<T: Shape, U: Shape> 구조체를 정의하는 방식은 아래 코드처럼 돼요. 이 방식은 삼각형과 뒤집힌 삼각형을 이어 붙일 때 JoinedShape<Triangle, FlippedShape<Triangle>> 같은 타입을 만들어 내요.
struct JoinedShape<T: Shape, U: Shape>: Shape {
var top: T
var bottom: U
func draw() -> String {
return top.draw() + "\n" + bottom.draw()
}
}
let joinedTriangles = JoinedShape(top: smallTriangle, bottom: flippedTriangle)
print(joinedTriangles.draw())
// *
// **
// ***
// ***
// **
// *
도형이 만들어지는 과정에 관한 상세한 정보를 드러내면, ASCII 아트 모듈의 공개 인터페이스에 속할 의도가 없는 타입이 새어 나가요. 전체 반환 타입을 명시해야 하기 때문이죠. 모듈 안의 코드는 같은 도형을 여러 가지 방식으로 만들어 낼 수 있지만, 모듈 밖에서 그 도형을 사용하는 다른 코드가 변환 목록의 구현 세부사항까지 신경 써야 할 이유는 없어요. JoinedShape나 FlippedShape 같은 래퍼 타입은 모듈 사용자에게 중요하지 않고, 보여서도 안 돼요. 모듈의 공개 인터페이스는 도형을 이어 붙이고 뒤집는 같은 연산으로 이루어지며, 그 연산들은 다시 Shape 값을 반환해요.
불투명 타입 반환하기
불투명 타입은 제네릭 타입의 반대라고 생각하면 돼요. 제네릭 타입은 함수를 호출하는 코드가 그 함수의 매개변수와 반환 값의 타입을, 함수 구현에서 추상화된 방식으로 고르게 해 줘요. 예를 들어 아래 코드의 함수는 호출자에 의존하는 타입을 반환해요.
func max<T>(_ x: T, _ y: T) -> T where T: Comparable { ... }
max(_:_:)를 호출하는 코드가 x와 y의 값을 고르고, 그 값들의 타입이 T의 구체적인 타입을 결정해요. 호출 코드는 Comparable 프로토콜을 준수하는 어떤 타입이든 쓸 수 있어요. 함수 내부의 코드는 호출자가 제공하는 어떤 타입이든 처리할 수 있도록 일반적인 방식으로 작성돼요. max(_:_:)의 구현은 모든 Comparable 타입이 공유하는 기능만 사용해요.
불투명 반환 타입을 가진 함수에서는 그 역할이 뒤집혀요. 불투명 타입은 함수 구현이, 함수를 호출하는 코드에서 추상화된 방식으로 자신이 반환하는 값의 타입을 고르게 해 줘요. 예를 들어 아래 예시의 함수는 사다리꼴을 반환하면서도 그 도형의 실제 타입은 드러내지 않아요.
struct Square: Shape {
var size: Int
func draw() -> String {
let line = String(repeating: "*", count: size)
let result = Array<String>(repeating: line, count: size)
return result.joined(separator: "\n")
}
}
func makeTrapezoid() -> some Shape {
let top = Triangle(size: 2)
let middle = Square(size: 2)
let bottom = FlippedShape(shape: top)
let trapezoid = JoinedShape(
top: top,
bottom: JoinedShape(top: middle, bottom: bottom)
)
return trapezoid
}
let trapezoid = makeTrapezoid()
print(trapezoid.draw())
// *
// **
// **
// **
// **
// *
이 예시의 makeTrapezoid() 함수는 반환 타입을 some Shape으로 선언해요. 그 결과 함수는 어떤 특정한 구체 타입을 지정하지 않고, Shape 프로토콜을 준수하는 어떤 타입의 값을 반환해요. makeTrapezoid()를 이렇게 작성하면 공개 인터페이스의 본질적인 면, 즉 반환하는 값이 도형이라는 점을 표현할 수 있어요. 도형을 구성하는 구체적인 타입까지 공개 인터페이스의 일부로 만들지 않고요. 이 구현은 삼각형 두 개와 정사각형 하나를 사용하지만, 반환 타입을 바꾸지 않으면서도 사다리꼴을 그리는 다른 다양한 방식으로 함수를 다시 작성할 수 있어요.
이 예시는 불투명 반환 타입이 어떻게 제네릭 타입의 반대인지를 잘 보여 줘요. makeTrapezoid() 내부의 코드는 제네릭 함수의 호출 코드처럼, Shape 프로토콜을 준수하기만 하면 필요한 어떤 타입이든 반환할 수 있어요. 반대로 함수를 호출하는 코드는 제네릭 함수의 구현처럼 일반적인 방식으로 작성돼서, makeTrapezoid()가 반환하는 어떤 Shape 값이든 다룰 수 있어야 해요.
불투명 반환 타입은 제네릭과 결합할 수도 있어요. 아래 코드의 두 함수는 모두 Shape 프로토콜을 준수하는 어떤 타입의 값을 반환해요.
func flip<T: Shape>(_ shape: T) -> some Shape {
return FlippedShape(shape: shape)
}
func join<T: Shape, U: Shape>(_ top: T, _ bottom: U) -> some Shape {
JoinedShape(top: top, bottom: bottom)
}
let opaqueJoinedTriangles = join(smallTriangle, flip(smallTriangle))
print(opaqueJoinedTriangles.draw())
// *
// **
// ***
// ***
// **
// *
이 예시에서 opaqueJoinedTriangles의 값은 이전 장의 "불투명 타입이 해결하는 문제" 절에 나온 제네릭 예시의 joinedTriangles와 같아요. 다만 그 예시와 달리, flip(_:)과 join(_:_:)은 제네릭 도형 연산이 반환하는 기반 타입을 불투명 반환 타입으로 감싸서, 그 타입들이 보이지 않게 해요. 두 함수 모두 의존하는 타입이 제네릭이라서 제네릭 함수예요. 함수의 타입 매개변수는 FlippedShape와 JoinedShape가 필요한 타입 정보를 전달해요.
불투명 반환 타입을 가진 함수가 여러 곳에서 반환한다면, 가능한 모든 반환 값이 같은 타입이어야 해요. 제네릭 함수의 경우 그 반환 타입이 함수의 제네릭 타입 매개변수를 쓸 수 있지만, 그래도 여전히 단일 타입이어야 해요. 예를 들어 정사각형에 대한 특별한 경우를 포함한, 도형 뒤집기 함수의 잘못된 버전은 이렇게 생겼어요.
func invalidFlip<T: Shape>(_ shape: T) -> some Shape {
if shape is Square {
return shape // Error: Return types don't match.
}
return FlippedShape(shape: shape) // Error: Return types don't match.
}
이 함수를 Square와 함께 호출하면 Square를 반환하고, 그 외에는 FlippedShape를 반환해요. 이렇게 하면 단 하나의 타입만 반환해야 한다는 요구사항을 위반해서 invalidFlip(_:)은 잘못된 코드가 돼요. invalidFlip(_:)을 고치는 방법 중 하나는 정사각형에 대한 특별한 경우를 FlippedShape의 구현으로 옮기는 거예요. 그러면 이 함수는 항상 FlippedShape 값을 반환할 수 있어요.
struct FlippedShape<T: Shape>: Shape {
var shape: T
func draw() -> String {
if shape is Square {
return shape.draw()
}
let lines = shape.draw().split(separator: "\n")
return lines.reversed().joined(separator: "\n")
}
}
항상 단일 타입을 반환해야 한다는 요구사항이, 불투명 반환 타입에서 제네릭을 쓰는 것을 막지는 않아요. 타입 매개변수를 반환 값의 기반 타입에 포함시키는 함수의 예시를 볼게요.
func `repeat`<T: Shape>(shape: T, count: Int) -> some Collection {
return Array<T>(repeating: shape, count: count)
}
이 경우 반환 값의 기반 타입은 T에 따라 달라져요. 어떤 도형이 들어오든 repeat(shape:count:)는 그 도형의 배열을 만들어 반환해요. 그럼에도 반환 값은 항상 [T]라는 같은 기반 타입이기 때문에, 불투명 반환 타입을 가진 함수는 단일 타입의 값만 반환해야 한다는 요구사항을 지켜요.
박스형 프로토콜 타입
박스형 프로토콜 타입은 **존재 타입(existential type)**이라고 부르기도 해요. "타입 T가 존재해서 T가 프로토콜을 준수한다"라는 표현에서 나온 이름이죠. 박스형 프로토콜 타입을 만들려면 프로토콜 이름 앞에 any를 쓰면 돼요. 예시를 볼게요.
struct VerticalShapes: Shape {
var shapes: [any Shape]
func draw() -> String {
return shapes.map { $0.draw() }.joined(separator: "\n\n")
}
}
let largeTriangle = Triangle(size: 5)
let largeSquare = Square(size: 5)
let vertical = VerticalShapes(shapes: [largeTriangle, largeSquare])
print(vertical.draw())
위 예시에서 VerticalShapes는 shapes의 타입을 [any Shape], 즉 박스형 Shape 요소들의 배열로 선언해요. 배열의 각 요소는 서로 다른 타입일 수 있고, 그 타입들은 각각 Shape 프로토콜을 준수해야 해요. 이런 런타임의 유연성을 지원하기 위해 Swift는 필요할 때 간접 참조(indirection) 한 단계를 추가해요. 이 간접 참조를 **박스(box)**라고 부르고, 거기에는 성능 비용이 따라와요.
VerticalShapes 타입 안에서 코드는 Shape 프로토콜이 요구하는 메서드, 프로퍼티, 서브스크립트를 사용할 수 있어요. 예를 들어 VerticalShapes의 draw() 메서드는 배열의 각 요소에서 draw() 메서드를 호출해요. Shape가 draw() 메서드를 요구하기 때문에 이 메서드를 쓸 수 있는 거예요. 반대로 삼각형의 size 프로퍼티나, Shape가 요구하지 않는 다른 프로퍼티·메서드에 접근하려 하면 오류가 나요.
shapes에 쓸 수 있는 세 가지 타입을 비교해 볼게요.
- 제네릭을 쓰는 경우,
struct VerticalShapes<S: Shape>와var shapes: [S]라고 작성하면 어떤 특정한 도형 타입의 요소들로 이루어진 배열이 돼요. 그 특정 타입의 정체성은 배열과 상호작용하는 어떤 코드에게든 보여요. - 불투명 타입을 쓰는 경우,
var shapes: [some Shape]라고 작성하면 어떤 특정한 도형 타입의 요소들로 이루어진 배열이 되고, 그 특정 타입의 정체성은 숨겨져요. - 박스형 프로토콜 타입을 쓰는 경우,
var shapes: [any Shape]라고 작성하면 서로 다른 타입의 요소들을 저장할 수 있는 배열이 되고, 그 타입들의 정체성은 숨겨져요.
이 경우 VerticalShapes의 호출자가 서로 다른 종류의 도형을 섞을 수 있게 해 주는 방법은 박스형 프로토콜 타입뿐이에요.
박스형 값의 기반 타입을 알 때는 as 캐스팅을 쓸 수 있어요. 예를 들면 이렇게요.
if let downcastTriangle = vertical.shapes[0] as? Triangle {
print(downcastTriangle.size)
}
// Prints "5".
더 자세한 내용은 타입 캐스팅의 "다운캐스팅" 절을 참고하세요.
불투명 타입과 박스형 프로토콜 타입의 차이
불투명 타입을 반환하는 것은 함수의 반환 타입으로 박스형 프로토콜 타입을 쓰는 것과 겉보기에는 매우 비슷해요. 하지만 이 두 종류의 반환 타입은 타입 정체성을 보존하는지의 여부에서 갈라져요. 불투명 타입은 하나의 특정 타입을 가리켜요. 함수의 호출자가 어느 타입인지 볼 수는 없지만요. 박스형 프로토콜 타입은 프로토콜을 준수하는 어떤 타입이든 가리킬 수 있어요. 일반적으로 말하면 박스형 프로토콜 타입은 저장하는 값의 기반 타입에 대해 더 큰 유연성을 주고, 불투명 타입은 그 기반 타입에 대해 더 강한 보장을 해 줘요.
예를 들어 반환 타입으로 불투명 타입 대신 박스형 프로토콜 타입을 쓰는 flip(_:) 버전을 볼게요.
func protoFlip<T: Shape>(_ shape: T) -> Shape {
return FlippedShape(shape: shape)
}
이 protoFlip(_:) 버전은 flip(_:)과 같은 본문을 가지고 있고, 항상 같은 타입의 값을 반환해요. flip(_:)과 달리 protoFlip(_:)이 반환하는 값은 항상 같은 타입일 필요는 없어요. Shape 프로토콜을 준수하기만 하면 되죠. 다시 말하면 protoFlip(_:)은 flip(_:)보다 호출자와 훨씬 느슨한 API 계약을 맺어요. 여러 타입의 값을 반환할 수 있는 유연성을 남겨 두는 거예요.
func protoFlip<T: Shape>(_ shape: T) -> Shape {
if shape is Square {
return shape
}
return FlippedShape(shape: shape)
}
수정된 버전의 코드는 어떤 도형이 들어오느냐에 따라 Square 인스턴스나 FlippedShape 인스턴스를 반환해요. 이 함수가 반환하는 두 개의 뒤집힌 도형은 완전히 다른 타입일 수도 있어요. 이 함수의 다른 유효한 버전은 같은 도형의 여러 인스턴스를 뒤집을 때 서로 다른 타입의 값을 반환할 수도 있죠. protoFlip(_:)의 반환 타입 정보가 덜 구체적이라는 것은, 타입 정보에 의존하는 많은 연산이 반환된 값에 적용될 수 없다는 뜻이에요. 예를 들어 이 함수가 반환한 결과들을 비교하는 == 연산은 작성할 수 없어요.
let protoFlippedTriangle = protoFlip(smallTriangle)
let sameThing = protoFlip(smallTriangle)
protoFlippedTriangle == sameThing // Error
이 예시의 마지막 줄에서 오류가 나는 데는 여러 이유가 있어요. 가장 직접적인 문제는 Shape가 프로토콜 요구사항으로 == 연산자를 포함하지 않는다는 거예요. == 연산자를 추가하려 해도 다음으로 마주치는 문제는, == 연산자가 왼쪽·오른쪽 인자의 타입을 알아야 한다는 거예요. 이런 종류의 연산자는 보통 프로토콜을 채택하는 구체 타입에 맞추어 Self 타입의 인자를 받아요. 그런데 프로토콜에 Self 요구사항을 추가하면, 프로토콜을 타입으로 쓸 때 일어나는 타입 이레이저(type erasure)를 허용하지 않아요.
함수의 반환 타입으로 박스형 프로토콜 타입을 쓰면 프로토콜을 준수하는 어떤 타입이든 반환할 수 있는 유연성을 얻어요. 하지만 그 유연성의 대가로, 반환된 값에 대해 불가능한 연산들이 생겨요. 예시가 보여 주듯 == 연산자를 쓸 수 없어요. 그것은 박스형 프로토콜 타입을 쓰면 보존되지 않는 구체적인 타입 정보에 의존하기 때문이에요.
이 방식의 또 다른 문제는 도형 변환이 중첩되지 않는다는 거예요. 삼각형을 뒤집은 결과는 Shape 타입의 값이고, protoFlip(_:) 함수는 Shape 프로토콜을 준수하는 어떤 타입의 인자를 받아요. 그런데 박스형 프로토콜 타입의 값은 그 프로토콜을 준수하지 않아요. protoFlip(_:)이 반환한 값은 Shape를 준수하지 않죠. 이 말은 여러 변환을 적용하는 protoFlip(protoFlip(smallTriangle)) 같은 코드는, 뒤집힌 도형이 protoFlip(_:)의 유효한 인자가 아니기 때문에 잘못됐다는 뜻이에요.
반대로 불투명 타입은 기반 타입의 정체성을 보존해요. Swift가 연관 타입을 추론할 수 있어서, 박스형 프로토콜 타입을 반환 값으로 쓸 수 없는 곳에서 불투명 반환 값을 쓸 수 있어요. 예를 들어 제네릭 장의 Container 프로토콜 버전을 볼게요.
protocol Container {
associatedtype Item
var count: Int { get }
subscript(i: Int) -> Item { get }
}
extension Array: Container { }
Container는 연관 타입을 가지고 있기 때문에 함수의 반환 타입으로 쓸 수 없어요. 제네릭 반환 타입의 제약으로도 쓸 수 없어요. 함수 본문 밖에는 제네릭 타입이 무엇이어야 하는지를 추론할 정보가 없기 때문이에요.
// Error: Protocol with associated types can't be used as a return type.
func makeProtocolContainer<T>(item: T) -> Container {
return [item]
}
// Error: Not enough information to infer C.
func makeProtocolContainer<T, C: Container>(item: T) -> C {
return [item]
}
불투명 타입 some Container를 반환 타입으로 쓰면 원하는 API 계약을 표현할 수 있어요. 함수가 컨테이너를 반환한다는 뜻이지만 컨테이너의 타입은 명시하지 않는 거죠.
func makeOpaqueContainer<T>(item: T) -> some Container {
return [item]
}
let opaqueContainer = makeOpaqueContainer(item: 12)
let twelve = opaqueContainer[0]
print(type(of: twelve))
// Prints "Int".
twelve의 타입이 Int로 추론되는데, 이는 타입 추론이 불투명 타입에서도 동작한다는 사실을 보여 줘요. makeOpaqueContainer(item:)의 구현에서 불투명 컨테이너의 기반 타입은 [T]예요. 이 경우 T가 Int이므로 반환 값은 정수 배열이고, Item 연관 타입은 Int로 추론돼요. Container의 서브스크립트는 Item을 반환하므로 twelve의 타입도 Int로 추론되는 거예요.
불투명 매개변수 타입
불투명 타입을 반환하기 위해 some을 쓰는 것 외에도, 함수·서브스크립트·이니셜라이저의 매개변수 타입에도 some을 쓸 수 있어요. 다만 매개변수 타입에 some을 쓰는 것은 불투명 타입이 아니라 제네릭의 짧은 문법이에요. 예를 들어 아래 두 함수는 동등해요.
func drawTwiceGeneric<SomeShape: Shape>(_ shape: SomeShape) -> String {
let drawn = shape.draw()
return drawn + "\n" + drawn
}
func drawTwiceSome(_ shape: some Shape) -> String {
let drawn = shape.draw()
return drawn + "\n" + drawn
}
drawTwiceGeneric(_:) 함수는 SomeShape라는 이름의 제네릭 타입 매개변수를 선언하고, SomeShape가 Shape 프로토콜을 준수해야 한다는 제약을 붙여요. drawTwiceSome(_:) 함수는 인자 타입으로 some Shape를 사용해요. 이렇게 하면 그 함수에 대해 새롭고 이름 없는 제네릭 타입 매개변수가 생기고, 그 타입이 Shape 프로토콜을 준수해야 한다는 제약이 붙어요. 제네릭 타입에 이름이 없으므로, 그 타입을 함수 안의 다른 곳에서는 가리킬 수 없어요.
매개변수 타입이 두 개 이상일 때 각각 앞에 some을 쓰면, 각 제네릭 타입은 서로 독립적이에요. 예를 들면 이렇게요.
func combine(shape s1: some Shape, with s2: some Shape) -> String {
return s1.draw() + "\n" + s2.draw()
}
combine(smallTriangle, trapezoid)
combine(shape:with:) 함수에서 첫 번째와 두 번째 매개변수의 타입은 모두 Shape 프로토콜을 준수해야 하지만, 둘이 같은 타입이어야 한다는 제약은 없어요. combine(shape:with:)을 호출할 때 서로 다른 두 도형을 넘길 수 있어요. 이 경우에는 삼각형 하나와 사다리꼴 하나를 넘기죠.
제네릭 장에서 설명하는 이름 있는 제네릭 타입 매개변수의 문법과 달리, 이 가벼운 문법은 제네릭 where 절이나 같은 타입(==) 제약을 포함할 수 없어요. 게다가 아주 복잡한 제약에 가벼운 문법을 쓰면 읽기 어려울 수 있어요.