Opaque Type

oto·2023년 3월 2일
0

Opaque Type은 프로토콜을 사용하여 특정 타입의 세부 정보를 감추고, 해당 타입의 인스턴스에 대한 참조를 반환하여 타입을 추상화한다.

SwiftUI를 쓰면서 some이라는 의문의 키워드가 있길래 궁금증이 생겼다.

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/opaquetypes

해당 문서를 참고하여 이해를 하였다.


The Problem That Opaque Types Solve

아래에 String을 반환하는 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())
// *
// **
// ***

그리고 아래에는 Generic을 사용하여 도형을 수직으로 뒤집는 것과 같은 작업 구현 코드가 있다. 이 코드는 결과적으로 generic type을 노출한다.

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

아래 첨부된 이미지처럼 FlippedShape<Triangle>과 같이 type이 노출된다.

shape을 만드는 것에 대한 자세한 정보를 노출하면 전체 반환 유형을 명시해야하기 때문에 위에서처럼 공용 인터페이스가 아닌 type들이 누출될 수 있다. 구현 세부 정보는 모듈 사용자에게 중요하지도 않고 표시되지 않아야 한다.

Returning an Opaque Type

Opaque type은 Generic type의 정반대로 생각할 수 있다. Generic type은 함수를 호출하는 코드가 해당 함수의 파라미터와 반환값의 type을 함수 구현에서 추상화된 방식으로 선택하게 한다. 예시로 다음 코드의 함수는 호출자에 따라 달라지는 type을 반환한다.

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

max(::)를 호출하는 코드는 x와 y에 대한 값을 선택하고 이러한 값의 유형은 T의 구체적인 유형을 결정한다. 호출 코드는 Comparable 프로토콜을 준수하는 모든 유형을 사용할 수 있다. 함수 내부의 코드는 호출자가 제공하는 모든 형식을 처리할 수 있도록 일반적인 방식으로 작성된다. max(::) 구현은 모든 Comparable 유형이 공유하는 기능만 사용한다.

이러한 역할은 opaque type이 있는 함수의 경우 반전된다. opaque type은 함수 구현부가 함수를 호출하는 코드로부터 추상화된 방식으로 반환값의 type을 선택할 수 있게 해준다. 이렇게 함으로써, 함수를 호출하는 코드는 반환되는 값의 타입을 신경쓰지 않아도 되며, 함수 구현부에서 내부 로직을 변경하더라도 호출하는 코드에는 영향을 끼치지 않는다. 아래 코드는 해당 도형의 내부 타입을 노출하지 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 프로토콜을 준수하는 구체적인 타입을 지정하지 않아도 해당 타입의 값을 반환한다. 이렇게 함수는 반환되는 값이 Shape임을 나타낼 수 있다. 구체적인 타입은 함수의 공개 인터페이스의 일부로 드러나지 않는다. 이 구현 방식은 두 개의 삼각형과 한 개의 사각형으로 사다리꼴을 그리지만, 함수의 반환 타입을 변경하지 않고도 사다리꼴을 그리는 다양한 방법으로 다시 작성될 수 있다.

이 예시에서는 opaque return type이 generic type의 반대와 같은 방식으로 작동하는 것을 보여준다. makeTrapezoid() 함수 내부 코드는 호출하는 코드가 제네릭 함수를 사용하는 것과 마찬가지로, 반환되는 타입이 Shape 프로토콜을 준수하면 필요한 어떤 타입이던지 반환할 수 있다. 함수를 호출하는 코드는 makeTrapezoid()에서 반환하는 모든 Shape 값을 다룰 수 있도록 제네릭 함수를 구현하는 것과 같은 일반적인 방식으로 작성되어야한다.

추가적으로, 아래 코드에서는 opaque return type과 generic을 함께 사용한다.

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에 필요한 유형 정보를 전달하기 때문에 두 함수 모두 제네릭입니다.

반환 유형이 불투명한 함수가 여러 위치에서 반환되는 경우 가능한 모든 반환 값의 유형이 동일해야 합니다. 일반 함수의 경우 해당 반환 유형은 함수의 일반 유형 매개변수를 사용할 수 있지만 여전히 단일 유형이어야 합니다. 예를 들어, 다음은 사각형에 대한 특수한 경우를 포함하는 모양 뒤집기 함수의 유효하지 않은 버전입니다.

이 코드에서 opaqueJoinedTriangles의 값은 The Problem That Opaque Types Solve 섹션에서의 제네릭 예시의 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]를 가지므로 불투명한 반환 유형이 있는 함수는 단일 유형의 값만 반환해야 한다는 요구 사항을 따른다.

Differences Between Opaque Types and Protocol Types

이와 같이 opaque type을 반환하는 것은 함수의 반환 타입으로 프로토콜 타입을 사용하는 것과 매우 유사해 보이지만, 이 두 가지 반환 타입은 타입 ID를 유지하는지 여부에 차이가 있다. opaque type은 호출자가 어떤 타입인지 보지 못하더라도 특정 타입을 가리키는 반면, 프로토콜 타입은 프로토콜을 준수하는 모든 타입을 가리킬 수 있다. 일반적으로, 프로토콜 타입은 저장하는 값의 기본 타입에 대한 더 많은 유연성을 제공하고, opaque type은 그 기본 타입에 대해 더 강력한 보장을 제공한다.

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

이 protoFlip(:) 버전은 flip(:)(이전 섹션에서 소개된 함수)와 동일한 body를 가지고 있으며 항상 동일한 타입의 값을 반환한다. 그러나 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(_:)에서 반환된 덜 구체적인 반환 타입 정보로 인해 타입 정보에 의존하는 많은 작업이 반환된 값에서 사용할 수 없다. 예를 들어, == 연산자를 비교하는 것은 불가능하다.

프로토콜 유형을 반환 유형으로 사용하는 경우, 프로토콜을 채택한 구체적인 유형과 일치하는 Self 타입의 인수를 취하는 == 연산자를 추가하는 것이 문제가 된다. 그러나 프로토콜을 타입으로 사용할 때 발생하는 타입 소거와 일치하는 Self 요구 사항을 추가하는 것은 불가능하다.

반환 유형으로 프로토콜 유형을 사용하면 프로토콜을 채택하는 모든 유형을 반환할 수 있다. 그러나 이러한 유연성은 반환된 값에서 몇 가지 작업이 불가능하다는 비용이 따른다. 이 예제에서는 == 연산자가 사용할 수 없음을 보여주고 있다. 이 연산자는 프로토콜 유형으로 반환되는 값에서 보존되지 않는 특정 유형 정보에 의존하기 때문이다.

이 접근 방식의 또 다른 문제는 모양 변형이 중첩되지 않는다는 것이다. 삼각형을 뒤집은 결과는 Shape 유형의 값이며, protoFlip(:) 함수는 Shape 프로토콜을 준수하는 어떤 유형의 인수도 취할 수 있다. 그러나 프로토콜 유형의 값은 해당 프로토콜을 준수하지 않는다. protoFlip(:)가 반환하는 플립된 모양은 Shape을 준수하지 않으므로 protoFlip(protoFlip(smallTriangle))와 같이 여러 변환을 적용하는 코드는 잘못된 것이다.

반면에 opaque type은 기반 유형의 식별성을 보존한다. Swift는 연관된 타입을 추론할 수 있으므로, 프로토콜 유형으로 반환 값이 사용할 수 없는 경우에도 opaque type을 사용할 수 있다.

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

이 예제의 마지막 줄에서 나타난 오류는 몇 가지 이유로 발생한다. 즉시 문제가 되는 것은 Shape이 프로토콜 요구사항으로 포함하지 않은 == 연산자이다. 이 연산자를 추가하려면 다음 문제가 발생한다. == 연산자는 비교 대상인 좌항과 우항의 타입을 알아야 한다. 이런 종류의 연산자는 일반적으로 프로토콜을 채택하는 구체 타입과 일치하는 Self 타입의 인수를 취하지만, 프로토콜에 Self 요구사항을 추가하면 프로토콜을 타입으로 사용할 때 발생하는 타입 지우기(type erasure)에 대응할 수 없다.

함수의 반환 타입으로 프로토콜 타입을 사용하면 해당 프로토콜을 채택하는 어떤 타입이든 반환할 수 있으므로 유연성이 높아진다. 그러나 이렇게 하면 반환된 값에서 일부 연산이 불가능해진다. 예제에서 보이는 것처럼 == 연산자를 사용할 수 없습니다. == 연산자는 프로토콜을 채택하는 구체 타입에 맞는 Self 타입의 인수를 취하는데, 프로토콜을 타입으로 사용할 때 발생하는 타입 지우기 때문에 이러한 연산자를 구현하는 것이 불가능하다.

이 방식의 또 다른 문제는 도형 변환을 중첩해서 적용할 수 없다는 것이다. 삼각형을 뒤집은 결과는 Shape 타입의 값이며, protoFlip(:) 함수는 Shape 프로토콜을 채택하는 어떤 타입의 인수든 받는다. 그러나 프로토콜 타입의 값은 해당 프로토콜을 채택하지 않는다. 따라서 protoFlip(protoFlip(smallTriangle))와 같이 여러 변환을 적용하는 코드는 잘못된다. 왜냐하면 뒤집힌 도형은 protoFlip(:)의 인수로 사용될 수 없기 때문이다.

반면, opaque 타입은 기존 타입의 정체성을 유지한다. Swift는 연관 타입을 추론할 수 있으므로 프로토콜 타입을 반환 값으로 사용할 수 없는 경우에도 opaque 반환 값을 사용할 수 있다.

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로 추론된다. 이는, 이 예시에서 타입 추론이 opaque types와 함께 작동하는 것을 보여준다. makeOpaqueContainer(item:) 함수의 구현에서, opaque container의 기본 타입은 [T]이다. 이 경우, T는 Int이므로 반환 값은 정수의 배열이 되고, Item 연관 타입은 Int로 추론된다. Container의 subscript는 Item을 반환하므로, twelve의 타입 역시 Int로 추론된다.


결론

opaque type을 사용하면, 캡슐화를 통해 protocol을 준수하는 타입을 반환하는 객체의 타입을 완전히 숨기는 것이 가능하다는 장점이 있다. 이를 통해 사용자는 반환하는 객체가 어떤 타입인지 신경쓰지 않고, 해당 객체가 제공하는 기능에 집중해 사용할 수 있다.

객체의 타입에 따라 다르게 동작해야 하는 경우에는 타입을 명확하게 지정하는 것이 좋겠지만, opaque type을 사용하는 경우는 같은 프로토콜을 채택해 사용하는 경우이기때문에 객체가 제공하는 "기능"이 더 중요하다. 이러한 경우 객체가 제공하는 기능을 중심으로 프로그래밍하는 것이 더 유연하고 확장성 있는 코드를 작성할 수 있다. 객체의 타입을 명확하게 지정하면 해당 객체를 사용하는 코드가 변경되었을 때, 다른 객체 타입으로 교체하기 어려울 수 있다. 반면에, 프로토콜을 준수하는 타입을 반환하면, 해당 프로토콜을 구현하는 다른 객체로 교체하기 쉬워진다.

또한, 코드의 유연성이 높아지며, 구현 세부 사항을 숨길 수 있어 유지보수 측면에서도 이점이 있다. 구현 세부사항을 숨기는 것을 캡슐화라고도 하며, 객체의 내부 구현 세부사항이 변경되더라도 외부에서는 영향을 받지 않도록 한다.

function에서 return value를 protocol type을 쓰지않고 opaque type을 사용하는 이유는 더 강력한 타입 안정성을 줄 수 있기 때문이다.더 강력한 타입 안정성을 가진다는 것은, 런타임 중 타입 일치성을 검증하는 대신, 컴파일 타임에 타입 안정성을 검증한다는 것이다.

예를 들어, protocol type을 사용하면 프로토콜을 구현하지 않은 타입도 전달될 수 있다. 런타임 중에 타입 일치성을 확인하기 때문에 컴파일러에서는 이를 감지할 수 없다. 그러나 opaque type은 구체적인 타입에 대한 정보를 제공하므로, 컴파일 타임에 타입 일치성을 검증할 수 있다. 따라서 opaque type은 더 강력한 타입 안정성을 가진다고 할 수 있다.

profile
iOS Developer

0개의 댓글