Opaque 리턴타입(Swift 5.1)

이 글을 다음 문서를 부분 번역한 것입니다.
https://docs.swift.org/swift-book/LanguageGuide/OpaqueTypes.html

Opaque 리턴 타입이 있는 함수나 메소드는 리턴 값에 대한 정보를 숨깁니다. 함수의 리턴 타입에 대한 구체적인 타입 정보를 제시하는 대신에, 리턴 값은 그 것이 따르는 프로토콜만으로 기술됩니다. 타입 정보를 숨기는 것은 모듈이 외부에 내놓는 코드에서 실제 리턴값의 타입은 내부에서만 유지관리될 수 있게 만들기 때문에 유용하게 사용될 수 있습니다. 리턴 값의 타입이 프로토콜 타입인 것과는 달리 Opaque 타입은 동일성을 유지합니다. 그리고 이때 컴파일러는 타입 정보에 액세스할 수 있지만, 모듈의 클라이언트는 그렇게 할 수 없습니다.

The Problem That Opaque Types Solve

예를 들어 아스키 아트 도형을 그리는 모듈을 작성한다고 하면, 이들 도형은 공통적으로 draw()라는 함수를 가지고 있고, 도형을 표현하는 문자열을 리턴한다고 해보겠습니다. 이는 모든 도형이 가지는 특성이므로 Shape라는 프로토콜을 통해 요건으로 정의할 수 있습니다.

protocol Shape {
    func draw() -> String
}

struct Triangle: Shape {
    var size: Int
    func draw() -> String {
      return (1...size).map{ String(repeating: "*", count: $0) }
              .joined(separator: "\n")
  }
}

let smallTriangle = Triangle(size: 3)
print(smallTriangle.draw())

도형을 세로로 뒤집는 연산을 구현하기 위해 제네릭을 사용할 수 있을 것입니다. 하지만 이 제네릭을 사용하는 경우, 뒤집힌 결과는 이를 만들 때 사용한 제네릭 타입을 그대로 노출한다는 단점이 있습니다. 아래 예에서 flipedTriangle의 타입은 FlippedShape<Triangle>이 됩니다.

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도 생각할 수 있습니다. 여기에 삼각형과 뒤집힌 삼각형을 적용해서 큰 삼각형을 만들어봅시다. joinedTriangle의 타입은 어떻게 될까요?

struct JoinedShape<T: Shape, U: Shape> : Shape {
  var top: T
  var bottom: T
  func draw() -> String {
    return top.draw() + "\n" + bottom.draw()
  }
}

let joinedTriangles = JoinedShape(top: smallTriangle, bottom: flippedTriangle)
// \--> JoinedShape<Triangle, FlippedShape<Triangle>>

joinedTriangles의 타입은 무려 JoinedShape<Triangle, FlippedShape<Triangle>> 이 됩니다. 만약 이러한 연산들이 모듈의 내부에서 이루어졌다면, 불필요하게 내부의 세부사항을 모듈 외부로 노출하게 된 것입니다. 모듈 내부에서 하나의 도형은 다양한 방식으로 만들어질 수 있으며, 모듈 외부에 공개되는 코드는 변형 목록에 대한 세부 사항을 설명하지 않아야 합니다. 이 코드들에서 JoinedShapeFlippedShape와 같은 래퍼 타입은 모듈 사용자에게는 중요하지 않으며, 표시되어서도 안됩니다. 모듈의 공용 인터페이스는 각 도형들에 대해서 결합 및 뒤집기와 같은 연산을 조합할 수 있고, 이러한 작업은 매 조합마다 다른 Shape 값을 반환하게 될 것이지만, 그저 “Shape중 하나”인 것으로 보이면 충분하고 그것이 옳은 방법입니다.

Returning an Opaque Type

이 상황은 마치 거꾸로 된 제네릭 타입을 요구하는 것 같습니다. 먼저 제네릭 타입을 사용하는 함수를 생각해봅니다. 함수를 작성하는 시점에는 구체적인 타입이 결정되지 않습니다. 따라서 함수 내부 코드에서는 추상화된 레벨에서 결정되지 않은 타입의 값을 리턴합니다. 그리고 이 타입들은 함수를 호출하는 코드에서 결정합니다. 이러한 함수에는 max(_:, _:)가 있습니다.

func max<T>(_ x: T, _ y: T) -> T where T: Comparable { ... }

max(_:_:)를 호출하는 코드가 x, y의 값을 선택하며 이 값들의 타입에 따라 리턴 값의 타입이 결정됩니다. 호출 코드는 Comparable 프로토콜을 준수하는 모든 타입을 사용할 수 있습니다. 함수 내부의 코드는 제네릭한 방식으로 작성되어 호출자가 전달하는 모든 타입을 처리하게 됩니다. 이때 max(_:_:)의 구현은 모든 Comparable 타입이 공유하는 특성만을 사용합니다.

이러한 역할들은 불투명한 리턴 타입이 있는 함수에서 그대로 반전됩니다. 작성하고자 하는 함수에서는 구현하는 코드가 구체적인 리턴 값의 타입을 알고, 결정합니다. 반대로 호출하는 측의 코드는 추상화된 타입을 받으며, 실제 세부 사항을 알 수 없습니다. 다음 예에서 우리는 Square라는 새로운 타입을 추가하고 이를 사용해서 사다리꼴을 만드는 함수를 정의할 것입니다. 이 함수는 생성된 사다리꼴의 세부 타입을 만들지만, 외부에서는 실제 타입을 알 수 없습니다. (이 함수의 실제 리턴값은 JoinedShape<Triangle, JoinedShape<Square, FlippedShape<Triangle>>>입니다.)

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 Shpae로 선언했습니다. 그 결과 이 함수는 특정한 구체적인 타입을 명시하지 않고 Shape 프로토콜을 준수하는 어떤 타입의 값을 반환하며, 구체적인 값을 외부에 알려주지 않습니다. 이런 식으로 makeTrapezoid()를 작성하면 공개된 인터페이스에 적용하기가 유리합니다. – 함수가 반환하는 값은 ‘어떤 모양’일 뿐입니다. 이 구현에서는 두 개의 삼각형과 사각형을 이용했지만, 함수의 타입 시그니처를 수정하지 않고도 내부 구현을 조정해도 됩니다. (세 개의 삼각형만 사용하거나, 네 개의 삼각형을 사용해서도 사다리꼴은 만들 수 있으니까요)

이 예제는 불투명 리턴 타입이 제네릭의 반대와 같다는 것을 강조했습니다. makeTrapezoid()의 내부 코드는 필요한 어떤 타입이든, Shape 프로토콜을 따르기만 한다면 리턴할 수 있습니다. 이것은 마치 제네릭 함수를 호출하는 것과 비슷합니다. 역으로 밖에서는 이 함수를 호출하는 코드가 제네릭한 방식으로 작성될 필요가 있습니다. 따라서 호출 코드는 makeTrapezoid()에 의해 반환되는 값이 어떤 Shape 타입이든 상관없이 동작할 수 있어야 합니다.

불투명 리턴 타입은 다시 제네릭 타입과 결합하여 사용될 수 있습니다. 아래 예제의 함수들은 모두 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 {
  JonedShape(top: top, bottom: bottom)
}

let opaqueJoindTriangle = join(smallTriangle, flip(smallTriangle))

opaqueJoinedTriangle의 실질적인 타입은 JoinedShape<Triangle, FlippedShape<Triangle>>일 것입니다. 하지만 그와 달리 flip(_:)join(_:_:)은 반환하는 기본적인 타입을 불투명 타입으로 감싸므로 실제 세부 유형이 표시되지 않습니다. 동시에 두 함수는 의존하는 인자의 유형이 제네릭이므로 제네릭 함수입니다.

만약 불투명 리턴 타입을 가진 함수가 여러 조건 하에서 호출될 때, 모든 가능한 리턴 타입들은 같은 타입이어야 합니다. 제네릭 함수에서는 리턴 타입이 제네릭 타입 파라미터에 의존하기도 하는데, 불투명 리턴 타입에서는 그럴 수 없습니다. ‘동일한 구체적 타입’이어야 합니다. 예를 들어 다음은 정사각형을 예외로 처리하는 잘못된 버전의 모양 뒤집기 함수입니다. (컴파일 단계에서 에러가 납니다.)

func invalidFlip<T: Shape>(_ shape: T) -> some Shape {
  if shape is Square {
    return shape
  }
  return FlippedShape(shape: shape)
}

이 함수에 Square 타입을 넘기면 Squre 타입이, 그외 타입을 넘기면 FlippedShape<T> 타입이 리턴될 것입니다. 이 두 타입은 모두 Shape를 따르기 합니다만, 컴파일러는 이러한 상황에 대해 불평을 터뜨리게 됩니다. 불투명 리턴 타입 함수가 지켜야할 요구사항을 따르지 못했기 때문입니다. 만약 정사각형에 대한 예외적인 처리를 하고 싶다면, 그 코드는 FlippedShape 타입 내부로 이동해야 하며 함수는 항상 FlippedShape을 리턴해야 합니다.

항상 단일 타입의 값을 리턴해야 한다는 제약 사항이 불투명 리턴 타입을 제네릭과 사용하지 못하게 하는 것은 아닙니다. 다음 예에서는 타입 파라미터를 리턴타입의 하부 타입으로 통합하는 방법을 보여줍니다.

func `repeat`<T: Shape>(shape: T, count: Int) -> some Collection {
  return Array<T>(repeating: shape, count: count)
}

이 케이스에서 리턴값의 하부 타입은 T에 의존합니다. 넘겨지는 모양이 무엇이든 간에 repeat(shape:count:)는 어떤 ShaepArray를 만듭니다. 그럼에도 불구하고 리턴값은 항상 동일한 하부 유형인 [T]를 가지므로 불투명 리턴 타입을 갖는 함수가 단일 타입의 값만을 반환해야 한다는 요구 사항은 준수됩니다.

Differences Between Opaque Types and Protocol Types

불투명 타입을 리턴하는 것은 프로토콜 타입을 함수의 리턴 타입으로 사용하는 것과 매우 유사해보이지만, 타입 동일성을 유지하는가에 따라 구분됩니다. 불투명 타입을 호출하는 함수는 호출자(caller)가 리턴값이 실제로 어떤 타입인지를 볼 수 없지만, 모든 호출에서 항상 같은 타입일 것을 가정할 수 있습니다. 프로토콜 타입이 저장된 값의 타입을 느슨하게 취급하여 더 유연성있게 다루려 한다면, 불투명 타입은 실제적인 타입에 대해 더 강한 보증을 제공합니다. 좀 더 구체적인 예를 들어 살펴보겠습니다.

만약에 위에서 예로 들었던 flip(_:)을 불투명 타입이 아닌 프로토콜 타입을 리턴하는 함수로 작성한다면 아래와 같을 것입니다.

func protoFlip<T: Shape>(_ shape: T) -> Shape {
    return FlippedShape(shape: shape)
}

이 함수는 타입 시그니처만 다를 뿐, flip(_:)과 동일한 내부를 가지고 있습니다. 하지만 항상 동일한 타입을 리턴해야할 필요는 없습니다. 언제든 리턴타입은 Shape를 따르기만 하면 됩니다. 달리말해 protoFlip(_:)flip(_:)에 비해 호출코드와 훨씬 더 느슨한 API 계약을 맺습니다. 이 버전은 여러 타입의 값을 리턴할 수 있는 유연성을 가져갑니다.

func protoFlip<T: Shape>(_ shape: T) -> Shape {
  if shape is Square {
    return shape
  }
  return FlippedShape(shape: shape)
}

위와 같이 수정한 버전의 코드에서 리턴되는 값의 실제 타입은 shape로 전달되는 값의 타입에 따라 결정됩니다. 서로 다른 두 개의 도형을 받아서 리턴한 값들은 완전히 다른 타입일 수 있습니다. 또 리턴된 값을 반복적으로 다시 전달하여 얻는 값 역시 매번 다른 타입일 수 있습니다. 하지만 리턴값에 대해서 더 적은 정보를 알려줄수록, 타입 정보에 기반하는 많은 연산들이 사용할 수 없게 됩니다. 예를 들어 == 연산자는 이 함수가 리턴한 두 개의 값에 대해서 사용될 수 없습니다.

let protoFlippedTriangle = protoFlip(smallTriangle)
let sameThing = protoFlip(smallTriangle)
protoFlippedTriangle == sameThing  // Error

이 예제의 마지막 줄에 있는 오류는 여러가지 이유로 발생합니다. 즉각적인 문제는 Shape가 프로토콜 요구사항에 == 연산을 포함하지 않는다는 것입니다. 만약 이 연산을 프로토콜에 추가하려고 하면 그 다음문제가 발생합니다. 우리는 == 연산자에서 왼쪽 및 오른쪽 인수의 정확한 유형을 알아야 한다는 것입니다. 이러한 종류의 연산자는 일반적으로 프로토콜을 채택하는 구체적인 타입과 일치하는 유형 Self의 인수를 취하지만, 프로토콜 요구사항에 Self가 포함되면 타입 지우기가 허용되지 않기 때문에 프로토콜 타입을 개별적인 타입으로 사용할 수 없습니다. (연관타입이 있는 거랑 같은 모양새가 되어 제네릭 한정요소로만 쓸 수 있습니다.)

다시 말해서, 함수의 리턴타입으로 프로토콜 타입을 쓰면 세부 타입을 고려하지 않아도 되는 유연성을 얻습니다. 하지만 이러한 유연성에 대한 비용으로 프로토콜 전체의 기능을 온전히 보장받지 못합니다. 프로토콜 타입을 사용할 때 보존되지 않는 정보의 유형은 각각의 타입이나 상황에 따라 달라지게 됩니다.

프로토콜 타입을 리턴값의 타입으로 하는 접근법의 더 문제는 그 리턴타입의 활용범위가 너무나 좁다는 데 있습니다. 위 protoFlip(_:)의 리턴된 Shape는 다른 변환을 적용할 수 없습니다. 심지어 같은 변환도 할 수 없습니다. 이 함수의 리턴값은 뒤집은 모양은 Shape 타입의 값입니다. 이 값을 다시 뒤집으려고 합니다. 그리고 protoFlip(_:) 함수는 Shape 프로토콜을 따르는 어떤 타입의 값을 인자로 받습니다. 여기서 문제가 발생합니다. protoFlip(_:)의 리턴값은 다시 이 함수의 인자로 들어갈 수 없습니다. 이상하지만 Shape 타입 그 자체는 프로토콜 Shape를 따르지 않습니다.


반면에 불투명 타입은 내제된 타입의 아이덴티티를 보존합니다. Swift는 연관타입을 추론할 수 있고, 그래서 프로토콜 타입이 리턴값으로서 사용될 수 없는 곳에서 불투명 타입을 사용할 수 있습니다. 예를 들어 제네릭을 통한 Container 프로토콜의 버전을 다음과 같이 보일 수 있습니다.

protocol Generic {
    associcatedtype Item
    var count : Int { get }
    subscript(i: Int) -> Item { get }
}

extension Array: Container {  }

Container 자체는 연관 타입이기 때문에 함수의 리턴 타입으로 사용될 수 없습니다. 심지어 제네릭의 제한 조건으로도 사용할 수 없습니다. 왜냐하면 연관된 타입을 함수의 본체 외부에서 추정할 수 있는 정보가 부족하기 때문입니다.

// Error
func makeProtocolContainer<T>(item: T) -> Container {
  return [item]
}

// 
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 = opaqueContaner[0]
print(type(of: twelve))
// -> "Int"

하지만 이 경우에도 불투명 타입에 대한 타입 추론은 실제로 작동하기 때문에 twelve의 타입은 Int로 나오게 됩니다.