Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

불투명 타입 #60

Open
simoniful opened this issue Nov 4, 2022 · 0 comments
Open

불투명 타입 #60

simoniful opened this issue Nov 4, 2022 · 0 comments

Comments

@simoniful
Copy link
Owner

불투명 (opaque) 반환 타입을 가진 함수나 메서드는 자신의 반환 값 타입 정보를 숨긴다
함수의 반환 타입으로 고정 타입을 제공하는 대신, 반환 값이 지원하는 프로토콜로 관점에서 설명된다
타입 정보를 감추는 건 모듈과 그 모듈 안을 호출하는 코드 경계선 상에서 유용한데,
반환 값의 실제 타입이 private으로 유지될 수 있기 때문이다 - 스위프트의 ‘개체 (entity)’ 에 대한 ‘접근 수준’ 이 private 인 것을 말한다
타입이 프로토콜 타입인 값을 반환하는 것과 달리, 불투명 타입은 타입 정체성을 보존한다
불투명 타입을 사용하면 한 특정한 타입이 계속 유지된다 의미로 프로토콜은 그 프로토콜을 준수하는 어떤 타입이든 모두 그 프로토콜 타입이기 때문에 타입 정체성을 보존할 수 없다
컴파일러는 타입 정보에 접근할 수 있지만, 모듈 사용자는 접근할 수 없다

불투명 타입이 풀어내는 문제 (The Problem That Opaque Types Solve)

예를 들어, 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())
// *
// **
// ***

제네릭을 사용하면, 아래 코드에서 보듯, 도형을 수직으로 뒤집는 연산도 구현할 수도 있다
하지만, 이 접근법에는 중요한 한계가 있는데: 뒤집은 결과를 생성하는데 사용한 제네릭 타입이 정확하게 드러난다
예제에서, flippedTriangle 은 FlippedShape 타입으로
모듈 안에 있어야 할 FlippedShape 타입이 모듈 밖으로 드러난다는 의미다

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> 같은 타입이 되버린다

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 같은 wrapper 타입은 모듈 사용자에겐 중요하지 않으며, 보이지 않는게 좋다
모듈의 공용 인터페이스는 도형 맞붙이기(joining) 와 뒤집기(flipping) 같은 연산들로 구성하며, 이러한 연산은 또 다른 Shape 값을 반환한다

wrapper 타입 에 대한 더 자세한 내용은, Attributes페이지를 참고하면 된다

Returning an Opaque Type (불투명 타입 반환하기)

불투명 타입은 제네릭 타입의 역방향이라고 생각할 수 있다
제네릭 타입은 그 함수의 매개 변수와 반환 값 타입을
함수를 호출하는 코드가 고르게 해서 함수 구현을 추상화하는 방식이다
예를 들어, 다음 코드에 있는 함수의 반환 타입은 자신을 호출한 쪽에 달려있다

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

max(_:_:) 를 호출한 코드가 x 와 y의 값을 선택하며,
해당 값들의 타입이 T 라는 고정 타입을 결정한다
호출 코드는 Comparable 프로토콜을 준수한 어떤 타입이든 사용할 수 있다
함수 안의 코드는 제네릭 방식으로 작성하므로 호출한 쪽이 무슨 타입을 제공하든 처리할 수 있다
max(::) 구현부는 모든 Comparable 타입이 공유하는 기능만을 사용한다

불투명 반환 타입을 가진 함수에선 이 역할들이 역방향이다
불투명 타입은 함수 구현부가 자신의 반환 값 타입을 고르게 해서 함수 호출 코드를 추상화하는 방식이다
예를 들어, 다음 예제의 함수는 사다리꼴 (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() 함수를 이런 식으로 작성하면 공용 인터페이스에 도형의 특정 타입을 만들지 않고도
반환 값이 도형이라는 공용 인터페이스의 기본 측면을 표현하도록 해준다
이 구현은 두 개의 삼각형과 한 개의 정사각형을 사용하지만,
함수의 반환 타입을 바꾸지 않고도 다양한 방식으로 사다리꼴을 그리게 재 작성할 수도 있다

예제는 불투명 반환 타입이 제네릭 타입의 역방향과 같다는 걸 강조한다
makeTrapezoid() 안의 코드는, 제네릭 함수의 호출 코드처럼,
그 타입이 Shape 프로토콜을 준수하는 한, 필요한 어떤 타입이든 반환할 수 있다
함수 호출 코드는, 제네릭 함수의 구현부 같이, 일반적인 방식으로 작성할 필요가 있는데,
그래야 makeTrapezoid() 가 반환한 어떤 Shape와도 작업할 수 있다

불투명 반환 타입과 제네릭을 조합할 수도 있습니다
다음 코드에 있는 함수는 둘 다 Shape 프로토콜을 준수하는 어떠한 (some) 타입의 값을 반환한다

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 값은 The Problem That Opaque Types Solve 부분의 제네릭 예제에 있는 joinedTriangles과 똑같다
하지만, 앞 예제의 값과 달리, flip(_:) 과 join(_:_:) 은 제네릭 도형 연산이 반환할 실제 타입을
불투명 반환 타입 안에 래핑하여 해당 타입들이 보이는 걸 막는다
함수가 제네릭 타입에 의지하기 때문에 둘 다 제네릭 함수이고,
함수의 타입 매개 변수가 FlippedShape 과 JoinedShape 에 필요한 타입 정보를 전달한다

불투명 반환 타입을 가진 함수가 여러 곳에서 반환을 한다면, 가능한 모든 반환 값의 타입은 반드시 똑같아야 한다
제네릭 함수에선, 그 반환 타입으로 함수의 제네릭 타입 매개 변수를 사용할 순 있지만, 반드시 여전히 단일 타입이어야 한다
예를 들어, 정사각형이라는 특수한 경우를 포함한 도형-뒤집기 함수의 무효한(invalid) 버전은 이렇다 - 컴파일 오류 발생

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(
:) 을 고치는 한 방법은, 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")
  }
}

항상 단일한 타입을 반환하라는 요구 사항이 불투명 반환 타입에 제네릭을 사용하는 걸 막는 건 아니다
함수의 타입 매개 변수를 반환 값의 실제 타입 안에 편입하는 예제도 있다

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)

불투명 타입을 반환하는 건 함수 반환 타입으로 프로토콜 타입을 사용하는 것과 매우 비슷해 보이지만,
이 두 종류의 반환 타입은 타입 정체성을 보존하는 여부가 다르다
불투명 타입은 하나의 특정 타입을 참조하지만, 함수를 호출한 쪽이 어느 타입인지 보는게 불가능하며
프로토콜 타입은 프로토콜을 준수한 어떤 타입이든 참조할 수 있다
일반적으로 말해서, 프로토콜 타입이 저장 값의 실제 타입에 대해 더 많은 유연함을 주고,
불투명 타입이 그러한 실제 타입을 더 강하게 보증하도록 한다

예를 들어, 자신의 반환 타입으로 불투명 반환 타입 대신 프로토콜 타입을 사용한 flip(_:) 버전은 이렇다

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

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

이 버전의 protoFlip(_:)은 flip(_:) 과 동일한 본문을 가지며, 항상 동일한 타입의 값을 반환한다
flip(_:) 과는 달리, protoFlip(_:) 이 반환하는 값은 항상 동일한 타입일 걸 요구하지 않는다 - 그냥 Shape 프로토콜을 준수하면 된다
다른 식으로 말하면, protoFlip(_:) 이 자신을 호출한 쪽과 맺는 API 계약은 flip(_:) 보다 더 많이 느슨하다
이는 여러 타입의 값을 반환하는 유연함을 남겨두고 있다

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

    return FlippedShape(shape: shape)
}

뜯어 고친 코드는, 무슨 도형을 전달했는 지에 따라, Square 인스턴스나 FlippedShape 인스턴스를 반환한다
이 함수가 반환할 뒤집은 도형 두 개는 그냥 Shape 프로토콜을 준수하는 서로 완전히 다른 타입일 수도 있다
이 함수의 유효한 버전 중에 어떤 건 여러 개의 동일한 도형 인스턴스를 뒤집을 때 서로 다른 타입의 값을 반환할 수도 있다
protoFlip(_:) 의 반환 타입 정보가 덜 특정하다는 건 타입 정보에 의존하는 수 많은 연산을 반환 값에 사용할 수 없게 된다
예를 들어, == 연산자를 써서 이 함수의 반환 결과를 비교하는 건 불가능하다

‘프로토콜 타입’ 을 사용하면 해당 ‘프로토콜 요구 사항’ 에서 정의한 인터페이스만 사용할 수 있기 때문에
타입 정보가 덜 특정해 질수록 사용할 수 있는 인터페이스가 더 줄어들게 된다

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

예제 마지막 줄 에러는 여러가지 이유로 일어난다
직접적인 문제점은 Shape 의 프로토콜 요구 사항엔 == 연산자가 포함되지 않는다는 거다
이를 추가하려고 하면, 그 다음 마주칠 문제점은 == 연산자가 자신의 왼쪽(lhs) 및 오른쪽(rhs) 인자 타입을 알 필요가 있다
이런 종류의 연산자는 평소에 Self 타입의 인자를 취하여, 무슨 타입이 프로토콜을 채택하든 일치하도록 하지만,
프로토콜에 Self 요구 사항을 추가하면 프로토콜을 타입으로 사용할 때 발생하는 타입 삭제(type erasure)를 허용하지 않게 된다

프로토콜 타입을 함수의 반환 타입으로 사용하면 프로토콜을 준수하는 어떤 타입도 반환할 수 있는 유연함을 준다
하지만, 그 유연함의 대가는 반환 값에 대해서 일부 연산이 불가능하다
예제는 == 연산자가 가능하지 않은 이유를
추상적인 프로토콜 타입의 사용으론 보존되지 않는 구체적인 특정 타입 정보에 의존한다는 것을 기반으로 보여준다

해당 접근법이 가진 또 다른 문제는 도형의 변형을 중첩하지 않는다는 거다
삼각형을 뒤집은 결과는 Shape 타입의 값이고, protoFlip(:) 함수는 Shape 프로토콜을 준수한 어떠한 타입인 인자를 취한다
하지만, 프로토콜 타입의 값은 그 프로토콜을 준수하지 않으며, protoFlip(
:)이 반환한 값은 Shape을 준수하지 않는다

프로토콜을 준수한다는 건 프로토콜의 요구 사항을 모두 구현한다는 의미이다
하지만, 프로토콜 그 자체는 추상 타입이라서 어떤 것도 직접 구현하지 않는다
즉, 어떠한 값이 프로토콜 타입이라면 그 프로토콜을 준수한다고 볼 수 없다

이는 여러 번 변형하는 protoFlip(protoFlip(smallTriangle)) 같은 코드는 무효라는 의미인데
뒤집은 도형은 protoFlip(_:) 의 유효 인자가 아니기 때문이다
시도하게 되면 Value of protocol type 'Shape' cannot conform to 'Shape'; only struct/enum/class types can conform to protocols 컴파일 에러가 발생한다

이와 대조하여, 불투명 타입은 실제 타입의 정체성을 보존한다
Swift가 associated 타입을 추론할 수 있어서,
반환 값으로 프로토콜 타입을 사용할 수 없는 곳에서 불투명 타입 값 을 사용하도록 해준다
예를 들어, 앞선 제네릭 챕터에서의 Container 프로토콜 버전을 보자

associated 타입과 관련해서는 Associated Types 페이지를 통해 더 자세한 정보를 볼 수 있다

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

Container 프로토콜엔 associated 타입이 있기 때문에 함수의 반환 타입으로 이를 사용할 순 없다
제네릭 타입의 제약 조건으로도 사용할 수 없는데, 이는 함수 외부에 제네릭 타입을 추론하는데 필요한 충분한 정보가 없기 때문이다

// 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:) 구현에선, some Container의 실제 타입은 [T]이다
T 는 Int 이므로, 반환 값은 정수 배열이며 associated 타입인 Item 은 Int 라고 추론된다
Container의 서브 스크립트는 Item을 반환하는데, 이는 twelve 의 타입도 Int 라고 추론된다는 의미다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant