호댕의 iOS 개발

[Swift] Opaque Type 본문

Software Engineering/iOS

[Swift] Opaque Type

호르댕댕댕 2023. 9. 13. 17:17

SwiftUI를 사용하다보면 꼭 보게 되는 것이 존재한다. 

import SwiftUI

struct ContentView: View {
    var body: some View {
    	Text("Hello World")
    }
}

View 프로토콜을 채택하게 되면 반드시 var body를 선언해야 하고 이는 연산 프로퍼티이며 some View 타입이다. 

 

흠... 그런데 View면 View지 여기서 some View를 사용한다. 

이유가 뭘까?

 

일단 some View로 선언한 타입 내에선 다양한 뷰를 나열하게 되면 뷰가 잘 그려진다. 

import SwiftUI

struct ContentView: View {
    var body: some View {
    	ZStack {
            Color(.black)
            Text("Hello World")
        }
    }
}

 

그럼 여러 가지 View를 내보낸다는 것인가 생각했다... (하지만 이건 아님)

이는 body에는 암시적으로 @ViewBuilder로 선언되어 있으며 이를 통해 Closure에서 다양한 Child View를 구성했기 때문이다. 

 

그럼 대체 some View는 뭘 의미하는 것일까?

 

공식문서에선 이렇게 설명한다. 

hide details about a value’s type

 

자세한 Value의 타입을 숨기기 위해 사용한다고?

조금 더 읽어보자. 

 

Instead of providing a concrete type as the function’s return type, the return value is described in terms of the protocols it supports.

 

반환 타입을 구체적인 타입이 아니라 프로토콜로 반환 타입을 설명하는 것이다. 

여기까지 읽었을 때 대충 감은 왔지만 아직 정확히 모르겠다. 

 

예제 코드를 살펴보자 

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

그러면 이렇게 제네릭을 활용해 이를 뒤집어서 그려주는 구조체를 만들 수도 있다. 

이를 수직으로 합쳐주는 구조체도 추가로 만들 수 있다. 

 

이때 Shape를 채택한 두 개의 구조체를 받아서 이를 합쳐야 하기 때문에 제네릭도 2개가 들어간다. 

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

 

하지만 여기서의 문제점은 제네릭으로 사용해야 하는 정확한 타입이 노출이 된다는 것이다. 

(FlippedShape와 Triangle이 노출됨)

 

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

그래서 some Shape를 반환하여 함수 내부에선 정확히 어떤 타입을 사용하는지 외부에선 알 수 없도록 감출 수 있다. 

 

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
}

하지만 Shape를 채택한 Square 타입과 FlippedShape를 반환하더라도 동일한 타입을 반환해야지 이렇게 다른 타입을 반환하면 컴파일 에러가 발생한다. 

 

컴파일러가 정확히 어떤 반환 타입을 가져야 하는지 알 수 없기 때문이다. 

여기까지 봤을 때 Opaque Type이란 반환하는 프로토콜을 채택하는 어떠한 타입이든 반환이 가능하며 그 타입은 반드시 하나여야 한다고 이해가 됐다. 

 

흠... 그런데 이 예제만 보고선 Opaque Type을 정확히 언제 사용하는 것이 좋은지 / 왜 이런 타입이 나오게 된 것인지 사실 이해가 잘 가지 않았다. 

 

그래서 찾다보니 SyncSwift 2022 컨퍼런스에서 관련 내용에 대한 발표가 있었다. 

여기선 다형성부터 이야기가 시작된다. 

 

Swift는 객체지향 언어인 만큼 상속을 통해 다형성의 구현이 가능하다. 

class Transportation {
	func move() {
    	print("움직인다")
    }
}

final class Car: Transportation {
	override func move() {
    	print("부릉부릉 움직인다")
    }
}

final class Ship: Transportation {
	override func move() {
    	print("뿌뿌 움직인다")
    }
}

var transportation: Transportation = Car()
transportation.move() // "부릉부릉 움직인다"
transportation = Ship() // 이렇게 다른 하위 클래스를 할당할 수도 있음
transportation.move() // "뿌뿌 움직인다"

하지만 상속은 아래와 같은 단점을 가진다. 

  • 다중 상속이 불가능하다. 
  • 구조체에선 사용이 불가능하다. 

 

그래서 Swift에선 Protocol이 등장했다. 이는 다른 언어의 Interface와 거의 동일한 개념이다. 

 

protocol Transportation {
    associatedtype Fuel
    
    var fuel: Fuel { get }
    var name: String { get }
    
    func move()
}

struct Gasoline { }
struct Diesel { }

struct MyCar: Transportation {
    var fuel: Gasoline = Gasoline()
    var name: String
    
    func move() {
    	print("부릉부릉 움직인다")
    }	
}

이렇게 하게 되면 어떤 타입이든 Fuel로 들어갈 수 있게 된다. 

그래서 이렇게 또 Protocol을 만든다고 가정해보자. 

 

protocol Transportation {
    associatedtype Fuel: UsableFuel
    
    var fuel: Fuel { get }
    var name: String { get }
    
    func move()
}

protocol UsableFuel {
	var name: String { get }
}

struct Gasoline: UsableFuel {
	let name = "가솔린 A"
}
struct Diesel: UsableFuel { 
	let name = "경유 A"
}

하지만 이렇게 associatedtype을 두게 되면 이런 에러가 발생하게 된다. 

any Type이라는 Boxed Protocol Type(existential type)을 쓰라고 하고 있다. 이를 사용하면 UsableFuel을 채택한 모든 타입을 다 사용할 수 있게 되며 이는 런타임에서 매칭을 시키게 된다. 

 

하지만 여기서 Opaque Type을 사용해 정확한 타입을 감춰서 해결이 가능하다. 

 

즉, Transportation 프로토콜을 채택하는 어떤 타입이 올지는 모르지만 무조건 이 프로토콜을 채택한 하나의 타입이 들어올 것임을 보장해주는 것이다. 

하지만 Opaque Type의 경우 하나의 타입이 들어올 것임을 보장하기 때문에 다른 타입을 할당하려하면 컴파일 에러가 발생한다. 

 

이렇게 Protocol 내부에 associatedtype을 사용해서 다양한 타입이 들어갈 수 있는 프로퍼티나 함수가 생겼다면 이럴 때 Opaque Type을 사용할 수 있는 것이다. 

 

이제서야 왜 Opaque라는 이름이 붙여졌는지 이해가 됐다. 

 

타입을 완전 구체적으로 노출하는 것이 아니라 특정 프로토콜을 채택하는 하나의 타입이 들어갈 거다! 하지만 정확히 어떤 타입이 올지는 노출시키지 않을 때 Opaque Type을 사용할 수 있는 것이다. 

 

참고 자료

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

 

Documentation

 

docs.swift.org

https://www.youtube.com/watch?v=3sfrqRaLuWk 

https://developer.apple.com/documentation/swiftui/viewbuilder

 

ViewBuilder | Apple Developer Documentation

A custom parameter attribute that constructs views from closures.

developer.apple.com

 

Comments