본문 바로가기
프로그래밍/Swift

[Swift] Opaque Types

by 별준 2022. 3. 9.

References

Contents

  • Opaque Types (불분명한 타입)

Opaque 리턴 타입을 갖는 함수나 메소드는 리턴 값의 타입 정보를 숨깁니다. 함수의 리턴 타입으로 구체적인 타입을 제공하는 대신, 리턴 타입은 프로토콜이 제공하는 것으로 묘사될 수 있습니다. 리턴 값의 내부 타입이 private으로 남아있을 수 있기 때문에 타입 정보를 숨기는 것은 모듈과 모듈을 호출하는 코드 사이의 경계(boundaries)에서 유용합니다. 타입이 프로토콜인 값을 리턴하는 것과는 달리, 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<T: Shape, U:Shape> 구조체를 정의하기 위한 이 접근 방법은 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 타입은 제네릭(generic) 타입의 반대라고 생각하면 이해하기 쉽습니다. 제네릭 타입은 함수를 호출하는 코드(code that calls a function)함수 구현(function implementation)으로부터 추상적인 방법으로 함수의 파라미터와 리턴 값에 대한 타입을 선택합니다. 예를 들어, 다음 코드에서 리턴 타입은 이를 호출하는 쪽에 의해 결정됩니다.

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

max(_:_:)를 호출하는 코드는 x와 y의 값을 선택하고 그 값의 타입은 T의 타입을 결정합니다. 호출하는 쪽에서는 Comparable 프로토콜을 준수하는 타입이라면 아무 타입이나 사용할 수 있습니다. 함수 내부에서 코드는 일반적인 방법으로 작성되며, 호출하는 측에서 제공하는 어떤 타입이라도 처리할 수 있습니다. max(_:_:)의 구현은 모든 Comparable 타입들이 공유하는 기능만 사용합니다.

 

함수에서 opaque 리턴 타입은 반대의 역할을 합니다. opaque 타입은 함수를 호출하는 코드(code that calls the function)로부터 추상적인 방법으로 리턴되는 값의 타입을 함수 구현(function implementation)이 선택하도록 합니다. 예를 들어, 아래의 예제 코드는 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())
// *
// **
// **
// **
// **
// *

makeTrapezoid() 함수는 리턴 타입이 some Shape;이며, 결과적으로 함수는 Shape 프로토콜을 준수하는 어떤 타입의 값을 리턴하게 됩니다. 여기서 특정한 구체적인 타입을 지정하지 않았습니다. makeTrapezoid() 함수가 작성된 방법을 살펴보면, Shape의 public 인터페이스의 기본 측면만 표현합니다. 즉 리턴되는 값은 shape 이며, 구체적인 타입을 표시하지 않았습니다. 이 함수 내부에서 2개의 triangle과 하나의 square를 사용하도록 변경할 수 있지만, trapezoid를 그리는 것만 변경될 뿐 리턴 타입이 변경되지는 않습니다.

 

위 예제 코드는 opaque 리턴 타입이 제네릭 타입의 반대라는 것을 잘 보여줍니다. 제네릭 함수에서 호출하는 코드가 그러하듯, makeTrapezoid()의 내부 코드에서 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와 달리, flip(_:)과 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
}

만약 이 함수를 Squre로 호출하면, Squre를 리턴하게 됩니다. 그렇지 않다면 이 함수는 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 타입의 값이고, protoFlip 함수는 Shape 프로토콜을 준수하는 어떤 타입을 인수로 받습니다. 그러나 프로토콜 타입의 값은 그 프로토콜을 준수하지 않습니다. 즉, protoFlip에서 리턴되는 값은 Shape를 준수하지 않습니다. 이는 protoFlip(protoFlip(smallTriangle))과 같은 코드가 유효하지 않다는 것을 의미합니다.

 

반면, opaque는 타입의 identity를 보존합니다. Swift는 연관 타입을 추론할 수 있고, 이는 프로토콜 타입이 리턴 값으로 사용될 수 없는 곳에서 opaque 리턴 값을 사용할 수 있도록 합니다.

예를 들어, 제네릭 포스팅(Generics (제네릭))에서 살펴본 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로 추론된다는 것을 의미합니다.

'프로그래밍 > Swift' 카테고리의 다른 글

[Swift] Memory Safety  (0) 2022.03.11
[Swift] Automatic Reference Counting (ARC)  (0) 2022.03.10
[Swift] Error Handling  (0) 2022.03.08
[Swift] Generics (제네릭)  (0) 2022.03.07
[Swift] Protocols (2)  (0) 2022.02.05

댓글