Swift/Swift 가이드

[Swift] 공식 문서 - Opaque Types

유정주 2022. 8. 1. 17:50
반응형

새로 배운 점

  • Opaque 리턴 타입을 갖는 함수나 메서드는 리턴 값의 타입 정보를 숨깁니다.
  • 함수의 리턴 타입으로 프로토콜 타입을 사용하는 것은 프로토콜을 준수하는 어떠한 타입을 리턴할 수 있다는 유연성을 제공합니다.
  • 너무 어렵다...

 

Opaque Types

오페이크(Opaque) 리턴 타입을 갖는 함수나 메서드는 리턴 값의 타입 정보를 숨깁니다.

함수의 리턴 타입으로 구체적인 타입을 제공하는 대신에,

리턴 타입은 프로토콜이 제공하는 것으로 묘사될 수 있습니다.

 

리턴 값의 내부 타입이 private로 남아있을 수 있기 때문에 타입 정보를 숨기는 것은

모듈과 모듈을 호출하는 코드 사이의 경계에서 유용합니다.

타입이 프로토콜인 값을 리턴하는 것과는 달리, opaque 타입은 타입 identity를 유지합니다.

따라서 컴파일러는 타입 정보에 액세스할 수 있지만, 그 모듈의 클라이언트는 액세스할 수 없습니다.

 

The Problem That Opaque Types Solve

예를 들어, ASCII art shape를 그리는 모듈을 작성한다고 가정해봅시다.

ASCII art shape은 draw()를 가지고 있는데, 이 함수는 shape를 표현하는 문자열을 리턴합니다.

따라서 이러한 특징을 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())
// *
// **
// ***

그리고 아래 코드처럼 제네릭을 사용하여 수직으로 뒤집힌 shape를 그리는 모듈을 작성할 수 있습니다.

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())
// ***
// **
// *

이 방법에는 큰 한계가 있는데,

FilppedShape에서 사용되는 제네릭 타입이 노출된다는 점입니다.

아래 코드처럼 두 개의 Shape를 수직으로 합치는 JoinedShape 구조체를 정의하기 위한 방법은

JoinedShape<FlippedShape<Triangle>, 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())
// *
// **
// ***
// ***
// **
// *

shape 생성에 대한 자세한 정보를 노출하면,

전체 리턴 타입을 명시해야 하기 때문에 ASCII art 모듈의 public 인터페이스가 일부가 아닌 타입이

노출된다는 것을 의미합니다.

모듈의 내부 코드는 다양한 방법으로 동일한 shape를 만들 수 있으며,

모듈 외부의 다른 코드는 이러한 shape의 세부구현 정보를 알 필요가 없고,

draw()만 사용하면 됩니다.

 

JoinedShape나 FlippedShape와 같은 wrapper 타입은

모듈을 사용하는 유저 입장에서는 중요하지 않으며 드러나면 안 됩니다.

모듈의 public 인터페이스는 shape를 합치거나 뒤집는 것과 같은 동작으로 구성되고

그 동작은 다른 shape 값을 리턴합니다.

 

Returning an Opaque Type

Opaque 타입은 제네릭 타입의 반대로 생각하면 쉽습니다.

제네릭 타입은 함수를 호출하는 코드가 함수 구현으로부터

추상적인 방법으로 함수의 파라미터와 리턴 값에 대한 타입을 선택합니다.

예를 들어, 다음 코드에서 리턴 타입은 이를 호출하는 쪽에 의해 결정됩니다.

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

max()를 호출하는 코드는 x와 y의 값을 선택하고 그 값의 타입은 T의 타입이 됩니다.

호출하는 쪽에서 Comparable 프로토콜을 준수하는 타입이라면 아무 타입이나 사용할 수 있습니다.

함수 내부에서 코드는 일반적인 방법으로 작성되며,

호출하는 쪽에서는 제공하는 어떤 타입이라도 처리할 수 있습니다.

max() 구현은 모든 Comparable 타입들이 공유하는 기능만 사용합니다.

 

함수에서 opaque 리턴 타입은 반대의 역할을 합니다.

opaque 타입은 함수를 호출하는 코드로부터 추상적인 방법으로 리턴되는 값의 타입을

함수 구현이 선택하도록 합니다.

예를 들어, 아래의 예제 코드는 shape의 자세한 타입을 노출하지 않고 trapezoid를 리턴합니다.

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())
// *
// **
// **
// **
// **
// *

makeTrapedzoid()는 리턴 타입이 some Shape이며,

함수는 Shape 프로토콜을 준수하는 어떤 타입의 값을 리턴합니다.

여기서 구체적인 타입을 지정하지 않았습니다.

makeTrapezoid()가 작성된 방법을 살펴보면,

Shape의 public 인터페이스의 기본만 표현합니다.

즉 리턴되는 값은 shape 이며, 구체적인 타입을 표시하지 않았습니다.

이 함수 내부에서 2개의 triangle과 하나의 square를 사용하도록 변경할 수 있지만,

trapezoid를 그리는 것만 변경될 뿐 리턴 타입이 변경되지는 않습니다.

 

위 예제 코드는 opaque 리턴 타입이 제네릭 타입의 반대라는 것을 잘 보여줍니다.

제네릭 함수에서 호출하는 코드가 그러하듯,

makeTrapedzoid() 내부 코드에서

Shape 프로토콜을 준수하는 타입이라면 어떠한 타입도 리턴할 수 있습니다.

또한 호출하는 쪽의 코드로 제네릭 함수의 구현처럼 일반적인 방법으로 작성하면 됩니다.

 

opaque 리턴 타입을 제네릭과 조합하여 사용할 수도 있습니다.

아래 코드의 함수들은 모두 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와 같습니다.

그러나 JoinedTriangles와 달리,

filp(_:)과 join(_:_:)은 제네릭 shape operation이 리턴하는 구체적인 타입을 감싸므로

구체적인 타입이 안 보이도록 합니다.

모두 제네릭으로 작성되어 있기 때문에 두 함수는 제네릭이고, 

타입 파라미터가 FlippedShape와 JoinedShape에 필요한 타입 정보를 전달하기 때문에

두 함수는 모두 제네릭입니다.

 

만약 opaque 리턴 타입인 함수가 내부 코드의 여러 곳에서 리턴한다면,

리턴 가능한 모든 값이 같은 타입이어야 합니다.

제네릭 함수에서 함수의 제네릭 타입 파라미터가 될 수 있지만,

이는 반드시 single 타입이어야 합니다.

예를 들어, 다음 코드는 유효하지 않습니다.

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(_:)를 수정하는 한 가지 방법은

square에 대한 특별한 케이스를 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")
    }
}

항상 한 가지 타입만 리턴해야 된다는 조건은

opaque 리턴 타입에서 제네릭을 사용하지 못하도록 만들지는 않습니다.

다음 코드의 함수는 타입 파라미터를 리턴하는 값의 구체적인 타입으로 사용하고 있습니다.

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

위의 경우 리턴값 타입은 T에 의해 결정됩니다.

shape로 전달되는 것이 무엇이든지 repeat(shape:count:)는 그 shape의 배열을 생성하고 리턴합니다.

그럼에도 리턴값은 항상 [T]라는 타입으로 같으며,

opaque 리턴 타입을 갖는 함수가 항상 하나 이상의 리턴 값이어야 한다는 조건을 만족합니다.

 

Differences Between Opaque Types and Protocol Types

opaque 타입을 리턴하는 것은 함수의 리턴 타입으로 프로토콜 타입을 사용하는 것과 유사하게 보이지만,

이 두 타입은 타입의 identity를 보존한다는 면에서 차이가 있습니다.

opaque 타입은 하나의 특정 타입을 참조하지만,

함수를 호출한 측이 어떤 타입인지 볼 수 없습니다.

 

프로토콜 타입은 프로토콜을 준수하는 모든 타입을 참조할 수 있습니다.

일반적으로 프로토콜 타입은 저장하는 값의 기본 타입에 대해 더 많은 유연성을 제공합니다.

opaque 타입을 사용하면 이러한 기본 타입을 더 강력하게 보장합니다.

 

다음 예제 코드는 위에서 정의한 flip(_:)에서 opaque 리턴 타입이 아닌

프로토콜 타입을 사용하도록 정의했습니다.

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

protoFlip(_:)은 flip(_:)의 바디와 동일하고 항상 동일한 타입의 값을 리턴합니다.

flip(_:)과는 다르게 protoFlip(_:)이 리턴하는 값은

항상 같은 타입이라는 요구사항을 만족하지는 못하며, 단지 Shape 프로토콜을 준수할 뿐입니다.

다시 말해 protoFlip(_:)은 호출하는 측과 API의 관계를 더욱 느슨하게 하여

여러 타입의 값을 리턴할 수 있는 유연성을 가지게 합니다.

 

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

위와 같이 수정된 protoFlip은 함수로 전달된 shape에 따라

Square의 인스턴스를 리턴하거나 FlippedShape의 인스턴스를 리턴합니다.

이 함수로부터 리턴되는 두 개의 flipped shape는 완전히 다른 타입이 될 수 있습니다.

 

따라서 이 함수는 동일한 shape에 대해 다른 타입의 값을 반환할 수 있습니다.

즉, protoFlip에서 덜 구체적인 리턴 타입 정보는 리턴된 값에서

타입 정보에 의존하는 많은 작업들을 사용할 수 없다는 것을 의미합니다.

 

예를 들어, 이 함수에서 반환된 결과를 비교하는 == 연산자를 사용하지 못할 수도 있습니다.

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

위 코드에서 마지막 줄은 여러 가지 이유로 에러가 발생할 수 있습니다.

Shape가 프로토콜의 요구사항으로 == 연산자를 포함하지 않을 수도 있습니다.

좌항과 우항의 타입을 몰라 에러가 발생할 수도 있습니다.

 

함수의 리턴 타입으로 프로토콜 타입을 사용하는 것은

프로토콜을 준수하는 어떠한 타입을 리턴할 수 있다는 유연성을 제공합니다.

하지만 위 예제 코드에서 == 연산자를 사용하지 못한 것처럼

몇몇 연산들이 리턴된 값에서 동작하지 않을 수 있다는 단점이 있습니다.

 

이러한 방법의 또다른 문제는 shape의 변환이 중첩되지 않는다는 것입니다.

뒤집한 삼각형의 결과는 Shape 타입의 값이고,

protoFilp 함수는 Shape 프로토콜을 준수하는 어떤 타입을 인수로 받습니다.

그러나 프로토콜 타입의 값은 그 프로토콜을 준수하지 않습니다.

즉, protoFlip 에서 리턴되는 값은 Shape를 준수하지 않습니다.

이는 protoFlip(protoFlip(smallTriangle))과 같은 코드가 불가능하다는 것을 의미합니다.

 

반면  opaque는 다입의 identity를 보존합니다.

Swift는 연관 타입을 추론할 수 있고,

이는 프로토콜 타입이 리턴 값으로 사용될 수 없는 곳에서 opaque 리턴 값을 사용할 수 있도록 합니다.

예를 들어 제네릭 포스팅에서 살펴본 Container 프로토콜 예제를 살펴보겠습니다.

protocol Container {
    associatedtype Item
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}
extension Array: Container { }

프로토콜이 연관 타입을 가지고 있기 때문에

함수 리턴 타입으로 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]
}

 

아래처럼 opaque 타입인 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로 추론되며,

이는 opaque 타입엥서 타입 추론이 동작한다는 사실을 보여줍니다.

makeOpaqueContainer(item:)의 구현에서

opaque container의 타입은 [T] 입니다.

이 경우에서 T는 Int 이며, 그래서 리턴 값은 정수 배열이고 연관 타입 Item은 Int로 추론됩니다.

Container에서 서브스크립트는 Item을 리턴하고,

이는 twelve의 타입 또한 Int로 추론된다는 것을 의미합니다.

 

참고

https://docs.swift.org/swift-book/LanguageGuide/OpaqueTypes.html

 

 

반응형