Swift 5.1 에서 추가된 Opaque Types 의 Apple Docs 번역글입니다. 제 블로그에서 Swift 번역글이 스테디하게 인기가 좋길래 새로운 피처가 추가된 기념으로 한번 해보았습니다 :) 제 번역 포스트는 원문 그대로의 직역이 아닌 이해를 돕기 위한 의역, 부연설명이 되어 있으니 참고해주세요. 이번 문서는 최대한 이해에 도움이 되게끔 문장을 몇 번이나 고쳐썼습니다. 오역 발견 시 꼭 제보해주시면 감사하겠습니다.
* 이 문서는 Swift 에서의 Protocol, Generic 에 대한 이해가 선행되어야 이해하실 수 있습니다.
Opaque Types
Opaque 리턴 타입(Opaque return type)을 가지는 함수/메소드는 자신의 리턴 타입에 대한 정보를 드러내지 않습니다. 함수의 리턴 타입으로 구체적인 타입을 제공하는 대신, 함수가 지원하는 프로토콜의 관점에서 리턴 타입을 기술합니다. 타입 정보를 숨기는 것은 모듈과 그 모듈을 호출하는 코드 사이의 경계에서 유용합니다. 리턴 값의 실제 타입(underlying type. 내부에서 실제로 사용하는 타입)을 은닉할 수 있기 때문입니다. 프로토콜 타입을 리턴하는 경우와 다르게, opaque 타입을 리턴하는 경우에는 opaque 타입의 아이덴티티—컴파일러는 타입 정보에 접근할 수 있지만, 모듈을 사용하는 클라이언트는 그것이 제한된다는 특성—가 유지됩니다.
Opaque 타입으로 해결할 수 있는 문제
아스키 아트(ASCII Art)를 그리는 모듈을 작성해야 한다고 가정해봅시다. 아스키 아트의 기본이 되는 특징은 draw() 함수이며, 이 함수는 도형(아트)으로 보이기 위한 문자열 표현을 리턴합니다. 이 함수를 다음과 같이 Shape 프로토콜의 요구사항으로 정의할 수 있습니다.
도형을 수직으로 뒤집는 것을 구현하기 위해서는 아래의 코드처럼 제네릭을 사용할 수 있습니다. 그러나 이와 같은 방식에는 중대한 한계가 있습니다. 수직으로 뒤집은 결과가, 그것을 만드는 데에 사용되었던 제네릭의 정확한 타입을 드러내고 있다는 것입니다. (역주: 아래 코드를 보시면, 뒤집은 삼각형의 타입이 FlippedShape<Triangle>이라는 것을 FlippedShape의 구현부(모듈 내부)만이 아니라 호출부(모듈 외부)에서도 알 수 있는 상황입니다.)
이번에는 두 도형을 수직으로 결합하기 위한 JoinedShape<T: Shape, U: Shape> 구조체를 정의해보았습니다. 아래 코드를 살펴보세요. 이런 접근 방식에서는 뒤집힌 삼각형과 다른 삼각형을 결합한 결과로써 JoinedShape<FlippedShape<Triangle>, Triangle> 같은 타입이 나오게 됩니다.
도형의 생성에 관련한 디테일한 정보를 노출하게 되면 아스키 아트 모듈의 퍼블릭 인터페이스에 포함하려 하지 않았던 타입 정보까지 노출하게 됩니다. 본래 노출하려던 의도는 없었으나, 위 예제에서 본 것처럼 정확한 리턴 타입이 드러나기 때문입니다. 모듈의 구현부(내부 코드)에서는 다양한 방식으로 동일한 형태의 도형을 만들어낼 수 있으며, 그렇게 만들어진 도형을 이용하는 모듈 외부에서는 그런 과정에서의 디테일한 정보를 알아야 할 필요가 없습니다. JoinedShape, FlippedShape 등의 Wrapper 타입은 모듈을 사용하는 입장에서는 관심없는 정보이며, 애초에 제공되지 말아야 할 정보입니다. 모듈의 퍼블릭 인터페이스는 도형을 결합하거나 뒤집는 오퍼레이션들로 구성되며 이런 오퍼레이션은 또 다른 Shape 값을 리턴해야 합니다. (역주: 우리가 살펴본 예제에서는 Shape 프로토콜을 구현한 JoinedShape, FlippedShape, Triangle 같은 구체적인 타입들을 모듈 외부에서도 알고 있는데, 이것을 외부에서는 Shape 프로토콜 타입만 알아도 동작할 수 있도록 바꿔보고자 하는 것입니다.)
불투명한 타입을 리턴하기
(*Opaque 타입을 리턴하기. Opaque 의 뜻이 ‘불투명한'입니다.)
제네릭 타입이 하는 일을 반대로 생각해서, 불투명한 타입(Opaque type)을 리턴해보는 건 어떨까요? 제네릭 타입은 함수의 호출부에서 함수의 파라미터와 리턴 값이 될 타입을 선택하게 합니다. 함수의 구현부로부터 타입을 추상화시키는 방식으로 그것을 가능하게 합니다(함수의 구현부에서는 추상화된 타입을 사용한다는 뜻). 예를 들면, 아래의 함수에서 리턴하는 타입은 이 함수를 부르는 코드에 따라 달라집니다.
func max<T>(_ x: T, _ y: T) -> T where T: Comparable { ... }
max(_:_:)를 호출하는 코드는 x와 y의 값을 정하고, 이 값들의 타입이 곧 T의 실제 타입으로 정해집니다. 호출부에서는 Comparable 프로토콜을 만족하는 어떤 타입이라도 선택할 수 있습니다. 제너럴 함수는 어떤 타입이 오더라도 동작할 수 있도록 제너럴하게 작성되어 있습니다(범용적으로 작성되어 있습니다). max(_:_:) 함수의 경우에는 모든 Comparable 타입에 통용되는 기능만을 사용하도록 작성되어 있습니다.
Opaque 리턴 타입(Opaque return type)을 사용하는 함수에서는 이 역할이 반대가 됩니다. Opaque 타입은 함수의 구현부에서 그 함수가 리턴하는 값의 타입을 결정하도록 합니다. 함수를 호출하는 코드로부터 타입을 추상화시키는 방식으로 그것을 가능하게 합니다(함수의 호출부에서는 추상화된 타입을 사용한다는 뜻). 예를 들면, 아래의 함수는 내부에서 사용하는 도형(Shape)의 실제 타입(underlying type)을 노출하지 않고도 사다리꼴 도형을 리턴합니다. (역주: 여기서 말하는 underlying type은 Triangle, Square, FlippsedShape, JoinedShape 등을 가리킵니다.)
예제에서의 makeTrapezoid() 함수는 리턴 타입을 some Shape로 선언하였으며, 특정한(구체적인) 타입 정보를 따로 명시하지 않고도 Shape 프로토콜을 준수하는 어떤 타입의 값(예제에서는 JoinedShape 타입의 값이네요)을 리턴하고 있습니다. 이런 식으로 makeTrapezoid() 함수를 작성하면, 함수의 근본적인 부분—리턴 값이 도형(Shape)이라는 것—을 구체적인 타입(JoinedShape 등)의 노출 없이도 퍼블릭 인터페이스로 표현할 수 있습니다. 현재는 함수 내에서 두 개의 삼각형과 한 개의 사각형을 통해 사다리꼴을 만들었지만, 그것과 다른 다양한 방식으로 사다리꼴을 만들도록 함수를 수정할 수 있습니다. 리턴 타입을 변경하지 않고도요.
이 예제는 opaque 리턴 타입이 제네릭 타입의 반대처럼 동작하는 것을 강조하고 있습니다. makeTrapezoid() 함수 내부에서는 어떤 타입이라도 리턴할 수 있습니다. 그 타입이 Shape 프로토콜을 만족하기만 한다면요. 제네릭 함수의 경우에는 함수를 부르는 쪽(함수 외부)에서 그런 식으로 동작하지요. 여기서 유의할 점이 또 있습니다. 앞서 말한 것처럼 제네릭 함수는 제네럴하게(범용적으로) 작성되어야 합니다. 따라서, Opaque 리턴 타입을 사용하는 함수의 경우에는 반대로 함수의 호출 코드가 제네럴하게 작성되어야 합니다. 그래야 makeTrapezoid() 함수가 리턴하는 어떤 Shape 값이라도 다룰 수 있습니다.
Opaque 리턴 타입을 제네릭과 함께 사용할 수도 있습니다. 아래의 두 함수는 모두 'Shape 프로토콜을 만족하는 어떤 타입의 값'(value of some type that conforms to the Shape Protocol)을 리턴하고 있습니다. (역주: 여기서 여러분은 그냥 Shape 타입을 반환해도 되는 거 아냐? 라고 생각하실 수 있습니다. 어떤 점에서 some Shape가 더 유리한지는 문서를 계속 읽어보시면 아실 수 있습니다.)
예제에서의 opaqueJoinedTriangles는 이 챕터의 첫 섹션('Opaque 타입으로 해결할 수 있는 문제’)에 나왔던 예제의 joinedTriangles과 동일합니다. (둘 다 draw() 해보면 같은 모양을 그립니다.) 그러나 그 예제와는 다르게, flip(_:)과 join(_:_:)에서는 FlippedShape, JoinedShape 등의 실제 타입(underlying type)을 Opaque 리턴 타입으로 한 겹 감싸서 외부에 드러나지 않도록 하고 있습니다. 두 함수 모두 제네릭 함수입니다. 두 함수는 제네릭한 타입에 의존하고 있으며, 함수의 타입 파라미터가 FlippedShape와 JoinedShape가 필요로 하는 타입 정보를 전달하기 때문입니다.
Opaque 리턴 타입을 가지는 하나의 함수가 여러 곳에서 사용될 경우, 함수가 반환할 수 있는 모든 리턴 값은 반드시 같은 타입이어야 합니다. 제네릭을 결합한 경우라도, 함수의 제네릭 타입 파라미터가 리턴 타입이 될 수는 있지만, 리턴 타입이 오직 하나라는 것은 마찬가지입니다. 예를 들어, 아래 코드를 봅시다. 도형을 뒤집어주는 함수가 있습니다. 이 함수는 오로지 정사각형에만 유효한, 잘못된(invalid) 함수입니다.
만약 이 함수를 Square에 대해 호출한다면 Square가 반환될 것입니다. Square 외의 다른 타입에 대해 호출한다면 FlippedShape가 반환될 것입니다. 오직 하나의 타입의 값만을 반환해야 한다는 요구사항을 위반하고 있으며, 따라서 invalidFlip(_:) 함수는 유효하지 않은 코드입니다. 이 함수를 고치는 방법 중 하나는 아래처럼 정사각형 케이스를 처리하는 코드를 FlippedShape의 구현부로 옮기는 것입니다. 그렇게 하면 이 함수는 항상 FlippedShape 타입의 값만을 반환할 수 있습니다.
Opaque 리턴 타입에서 제네릭을 사용할 때, 항상 하나의 타입을 리턴해야 한다는 요구사항은 별 문제가 되지 않습니다. 여기에 하나의 예가 있습니다. 리턴 값의 실제 타입(underlying type)에 타입 파라미터를 통합한 이 함수를 보세요.
이 케이스에서 리턴 값의 실제 타입(underlying type)은 T에 따라 달라지며, (Shape 프로토콜을 따르는) 어떤 도형이 이 파라미터로 넘어오건 repeat(shape:count:) 함수는 해당 도형의 배열을 만들어 리턴합니다. 그럼에도 불구하고 리턴 값은 항상 같은 타입인 [T]가 되기 때문에, opaque 리턴 타입을 가지는 함수가 항상 하나의 타입을 반환해야 한다는 요구조건을 만족시킬 수 있습니다.
Opaque 타입과 프로토콜 타입의 차이
Opaque 타입을 리턴하는 것은 프로토콜 타입을 리턴 타입으로 사용하는 것과 매우 유사해보일지도 모릅니다. 이 둘의 차이는 타입의 아이덴티티를 보존할 수 있는가에 달려있습니다. Opaque 타입을 리턴 타입으로 쓸 때는 특정한 하나의 타입을 가리킬 수 있으며, 함수의 호출부에서는 그 타입을 특정할 수 없게 만들 수 있습니다. (말이 좀 어려운데, 앞에서 계속 예제로 확인한 내용입니다. Underlying type 을 외부에 숨길 수 있습니다.) 프로토콜 타입에는 해당 프로토콜을 준수하는 모든 타입을 사용할 수 있습니다.(예를 들면 Collection 프로토콜을 리턴 타입으로 쓰는 함수에서는 Array, Dictionary 등을 리턴할 수 있겠죠?) 정리해보면, 프로토콜 타입은 값의 실제 타입(underlying type)에 대한 유연성을 높여주는 역할을 하며, opaque 타입을 통해 그 실제 타입(underlying type)을 더 강력하게 보장할 수 있습니다.
예를 들어볼까요. 여기에 opaque 리턴 타입 대신 프로토콜 타입의 값을 리턴하는 버전의 flip(_:) 함수를 만들어보았습니다.
protoFlip(_:)은 flip(_:)과 같은 내용으로 구성되어있고, 언제나 같은 타입의 값을 리턴합니다. flip(_:)과 달리 protoFlip(_:)의 리턴값은 항상 같은 타입일 필요가 없습니다. 단순히 Shape 프로토콜을 만족하기만 하면 되죠. 다시 말해서 protoFlip(_:)은 flip(_:)에 비해서 호출부와 좀 더 느슨한 관계를 맺고 있으며, 다양한 타입의 값들을 리턴할 수 있는 유연성을 보입니다.
위와 같이 수정된 버전의 protoFlip(_:)에서는 Square 또는 FlippedShape의 인스턴스를 반환하고 있습니다. 둘 중 어떤 것을 반환할지는, 넘어온 도형이 무엇이냐에 따라 다릅니다. 따라서 이 함수에서 반환된 두 개의 뒤집힌 도형은 서로 완전히 다른 타입을 가지고 있을 수도 있습니다. 이렇듯 protoFlip(_:)의 리턴 타입은 덜 구체적이고, 그렇기 때문에 이 리턴 값으로는 타입 정보에 의존하는 많은 작업들을 할 수 없게 됩니다. 예를 들면, 이 함수에서 리턴된 값으로는 == 연산자를 사용할 수 없습니다.
예제 마지막 줄에서 에러가 발생하는 이유에는 몇 가지가 있습니다. 우선 Shape 프로토콜의 요구조건에는 == 연산자가 포함되어 있지 않습니다. 만약 == 연산자를 추가하려고 하면, 이번에는 또 다른 문제를 만나게 됩니다. == 연산자는 자신의 왼쪽과 오른쪽에 오는 값들 각각의 타입을 알아야 한다는 것입니다. 이러한 종류의 연산자는 보통 Self의 타입을 인자로 받습니다. (예를 들면, 커스텀 클래스 Animal의 경우에는 == 연산자 구현부의 파라미터가 lhs: Animal, rhs: Animal 이 될 것입니다.) 그러나 Self를 프로토콜의 요구조건에 추가하게 되면, 프로토콜을 타입으로 사용할 때의 Type erasure를 허용하지 않는 것이 됩니다. (Self 또는 associated type을 프로토콜 요구조건에 추가하면 프로토콜을 리턴 타입 등으로 사용하는 것이 불가합니다. 오직 제네릭 constraint로만 사용될 수 있습니다. Type erasure라는 용어가 낯설다면 이 아티클을 한번 읽어보세요.)
프로토콜 타입을 함수의 리턴 타입으로 사용하면 프로토콜을 따르는 어떤 타입이라도 반환할 수 있는 유연성을 얻을 수 있습니다. 그러나 이런 유연성을 위해 리턴 값으로 할 수 있는 몇몇 작업들을 포기해야 합니다. 우리는 위 예제에서 왜 == 연산자를 사용할 수 없는지 보았습니다. 프로토콜 타입을 사용했기 때문에 구체적인 타입 정보가 보존되지 않아서 그랬죠.
이 방식이 가진 또 다른 문제는 도형의 변환을 중첩(nest)할 수 없다는 것입니다. 삼각형을 뒤집은 결과는 Shape 타입의 값이며, protoFlip(_:) 함수는 Shape 프로토콜을 따르는 타입을 인자로 받습니다. 그러나 프로토콜 타입의 값은 그 프로토콜을 따르지 않습니다. (protoFlip(_:)에서 반환한 값(Shape 프로토콜 타입)은 Shape 프로토콜을 따르지 않는 것으로 취급됩니다.) 따라서 protoFlip(protoFlip(smallTriange))처럼 여러 번의 변환을 하는 코드는 작성할 수 없습니다. 뒤집힌 도형(protoFlip(smallTriange)의 결과)은 protoFlip(_:)의 인자가 될 수 없기 때문입니다.
이와 반대로 Opaque 타입은 실제 타입(underlying type)의 아이덴티티를 보존합니다. 스위프트는 associated type을 추론할 수 있으며, 따라서 프로토콜 타입을 리턴값으로 쓸 수 없을 때 그 대신 opaque 리턴값을 사용할 수 있습니다. 예를 들어, Generics 가이드에서 보았던 Container 프로토콜을 봅시다.
프로토콜이 associated type을 가지고 있기 때문에 Container를 함수의 리턴 타입으로 사용할 수 없는 상황입니다. 또한 제네릭 타입이 어떤 것이 되어야 할지 추론할 수 있는 정보가 함수 외부에는 없기 때문에, 제네릭 리턴 타입의 constraint로 사용할 수도 없습니다.
리턴 타입으로 some Container라는 Opaque 타입을 사용하면 바람직한 API 를 만들 수 있습니다. 함수는 ‘어떤 컨테이너 값’을 리턴하되, 컨테이너의 타입이 정확히 무엇인지는 지정하지 않습니다.
위 예제에서 twelve의 타입은 Int로 추론되고 있습니다. 이는 유형 추론이 opaque 타입에 잘 먹힌다는 것을 보여줍니다. makeOpaqueContainer(item:)의 구현에서 opaque container의 실제 타입(underlying type)은 [T]입니다. 이 케이스에서 T는 Int이기 때문에 리턴값은 정수형 배열이며 associated type(Item)은 Int로 추론되게 됩니다. Container의 서브스크립트가 Item을 리턴하는 것은 twelve의 타입 역시 Int로 추론된다는 것을 보여줍니다.
이번 가이드의 요약
- 오페이크 타입은 제네릭의 반대. 제네릭은 함수의 구현에서 추상을 이용하고 호출부에서 타입을 구체적으로 지정한다면, 오페이크 타입은 함수의 구현에서 구체적인 타입을 이용하고 호출부에서 추상화된 프로토콜 타입을 이용.
- 따라서 외부에 underlying type 을 숨길 수 있게 됨 (encapsulated)
- 그럼 오페이크 타입 대신 프로토콜 타입을 리턴하면 되지 않느냐? > 프로토콜 타입을 리턴하게 되면, 그 리턴값으로는 각종 동작이 불가능함 (ex) == 비교, 중첩해서 같은 함수를 부르는 것 등(리턴값의 타입이 ‘프로토콜 타입’이기 때문에, 리턴된 값을 실제 그 프로토콜을 따르는 값처럼 사용할 수가 없다. 불편함) + 프로토콜에 associated type 을 지정한 경우에는 리턴 타입으로 쓰는 것 자체가 불가능
- 제네릭과 오페이크 타입을 같이 쓸 수도 있다
- 오페이크 타입을 리턴하는 함수의 제약: 언제나 같은 타입을 리턴해야 한다
'Swift 공식 가이드 > Swift 5.1' 카테고리의 다른 글
Property Wrapper (2) | 2020.02.10 |
---|