프로토콜 지향 프로그래밍 & 제네릭

TensorFlow.org에서 보기 Google Colab에서 실행 GitHub에서 소스 보기

이 튜토리얼에서는 프로토콜 지향 프로그래밍과 일상적인 예제에서 제네릭과 함께 사용할 수 있는 방법에 대한 다양한 예제를 살펴보겠습니다.

프로토콜

상속은 프로그램의 여러 구성 요소 간에 코드를 공유할 수 있도록 프로그래밍 언어로 코드를 구성하는 강력한 방법입니다.

Swift에는 상속을 표현하는 다양한 방법이 있습니다. 여러분은 이미 다른 언어에서 이러한 방법 중 하나인 클래스 상속에 익숙할 것입니다. 그러나 Swift에는 프로토콜이라는 또 다른 방법이 있습니다.

이 튜토리얼에서는 다양한 트레이드오프를 통해 유사한 목표를 달성할 수 있는 서브클래싱의 대안인 프로토콜을 살펴보겠습니다. Swift에서 프로토콜에는 여러 추상 멤버가 포함되어 있습니다. 클래스, 구조체 및 열거형은 여러 프로토콜을 준수할 수 있으며 적합성 관계는 소급하여 설정할 수 있습니다. 이 모든 것이 서브클래싱을 사용하여 Swift에서 쉽게 표현할 수 없는 일부 디자인을 가능하게 합니다. 프로토콜(확장 및 프로토콜 제약 조건) 사용을 지원하는 관용어와 프로토콜의 제한 사항을 살펴보겠습니다.

Swift 💖의 가치 유형!

참조 의미 체계를 갖는 클래스 외에도 Swift는 값으로 전달되는 열거형 및 구조체를 지원합니다. 열거형과 구조체는 클래스에서 제공하는 많은 기능을 지원합니다. 한 번 보자!

먼저, 열거형이 클래스와 어떻게 유사한지 살펴보겠습니다.

enum Color: String {
    case red = "red"
    case green = "green"
    case blue = "blue"
    // A computed property. Note that enums cannot contain stored properties.
    var hint: String {
        switch self {
            case .red:
                return "Roses are this color."
            case .green:
                return "Grass is this color."
            case .blue:
                return "The ocean is this color."
        }
    }

    // An initializer like for classes.
    init?(color: String) {
        switch color {
        case "red":
            self = .red
        case "green":
            self = .green
        case "blue":
            self = .blue
        default:
            return nil
        }
    }
}

// Can extend the enum as well!
extension Color {
    // A function.
    func hintFunc() -> String {
        return self.hint
    }
}

let c = Color.red
print("Give me a hint for c: \(c.hintFunc())")

let invalidColor = Color(color: "orange")
print("is invalidColor nil: \(invalidColor == nil)")
Give me a hint for c: Roses are this color.
is invalidColor nil: true

이제 구조체를 살펴보겠습니다. 구조체를 상속할 수 없지만 대신 프로토콜을 사용할 수 있습니다.

struct FastCar {
    // Can have variables and constants as stored properties.
    var color: Color
    let horsePower: Int
    // Can have computed properties.
    var watts: Float {
       return Float(horsePower) * 745.7
    }
    // Can have lazy variables like in classes!
    lazy var titleCaseColorString: String = {
        let colorString = color.rawValue
        return colorString.prefix(1).uppercased() + 
               colorString.lowercased().dropFirst()
    }()
    // A function.
    func description() -> String {
        return "This is a \(color) car with \(horsePower) horse power!"
    }
    // Can create a variety of initializers.
    init(color: Color, horsePower: Int) {
        self.color = color
        self.horsePower = horsePower
    }
    // Can define extra initializers other than the default one.
    init?(color: String, horsePower: Int) {
        guard let enumColor = Color(color: color) else {
            return nil
        }
        self.color = enumColor
        self.horsePower = horsePower
    }
}

var car = FastCar(color: .red, horsePower: 250)
print(car.description())
print("Horse power in watts: \(car.watts)")
print(car.titleCaseColorString)
This is a red car with 250 horse power!
Horse power in watts: 186425.0
Red

마지막으로 클래스와 달리 값 유형으로 전달되는 방법을 살펴보겠습니다.

// Notice we have no problem modifying a constant class with 
// variable properties.
class A {
  var a = "a"
}

func foo(_ a: A) {
  a.a = "foo"
}
let a = A()
print(a.a)
foo(a)
print(a.a)

/* 
Uncomment the following code to see how an error is thrown.
Structs are implicitly passed by value, so we cannot modify it.
> "error: cannot assign to property: 'car' is a 'let' constant"
*/

// func modify(car: FastCar, toColor color: Color) -> Void {
//   car.color = color
// }

// car = FastCar(color: .red, horsePower: 250)
// print(car.description())
// modify(car: &car, toColor: .blue)
// print(car.description())
a
foo

프로토콜을 사용해보자

다양한 자동차에 대한 프로토콜을 만드는 것부터 시작해 보겠습니다.

protocol Car {
    var color: Color { get set }
    var price: Int { get }
    func turnOn()
    mutating func drive()
}

protocol Electric {
    mutating func recharge()
    // percentage of the battery level, 0-100%.
    var batteryLevel: Int { get set }
}

protocol Gas {
    mutating func refill()
    // # of liters the car is holding, varies b/w models.
    var gasLevelLiters: Int { get set }
}

다중 상속이 없는 객체 지향 세계에서는 ElectricGas 추상 클래스를 만든 다음 클래스 상속을 사용하여 둘 다 Car 에서 상속하도록 한 다음 특정 자동차 모델을 기본 클래스로 만들 수 있습니다. 그러나 여기서는 둘 다 커플 링이 전혀 없는 완전히 별개의 프로토콜입니다! 이렇게 하면 전체 시스템을 설계하는 방식이 더욱 유연해집니다.

Tesla를 정의해 보겠습니다.

struct TeslaModelS: Car, Electric {
    var color: Color // Needs to be a var since `Car` has a getter and setter.
    let price: Int
    var batteryLevel: Int

    func turnOn() {
        print("Starting all systems!")
    }

    mutating func drive() {
        print("Self driving engaged!")
        batteryLevel -= 8
    }

    mutating func recharge() {
        print("Recharging the battery...")
        batteryLevel = 100
    }
}

var tesla = TeslaModelS(color: .red, price: 110000, batteryLevel: 100)

이는 CarElectric 프로토콜을 모두 준수하는 새로운 구조체 TeslaModelS 지정합니다.

이제 가스 구동 자동차를 정의해 보겠습니다.

struct Mustang: Car, Gas{
    var color: Color
    let price: Int
    var gasLevelLiters: Int

    func turnOn() {
        print("Starting all systems!")
    }

    mutating func drive() {
        print("Time to drive!")
        gasLevelLiters -= 1
    }

    mutating func refill() {
        print("Filling the tank...")
        gasLevelLiters = 25
    }
}

var mustang = Mustang(color: .red, price: 30000, gasLevelLiters: 25)

기본 동작으로 프로토콜 확장

예제에서 알 수 있는 것은 약간의 중복성이 있다는 것입니다. 전기 자동차를 충전할 때마다 배터리 백분율 수준을 100으로 설정해야 합니다. 모든 전기 자동차의 최대 용량은 100%이지만 휘발유 자동차는 주유 탱크 용량에 따라 다르므로 전기 자동차의 경우 기본적으로 수준을 100으로 설정할 수 있습니다. .

Swift의 확장 기능이 유용할 수 있는 곳은 다음과 같습니다.

extension Electric {
    mutating func recharge() {
        print("Recharging the battery...")
        batteryLevel = 100
    }
}

이제 우리가 만드는 새로운 전기 자동차는 재충전할 때 배터리를 100으로 설정합니다. 따라서 우리는 고유한 기본 동작으로 클래스, 구조체 및 열거형을 장식할 수 있게 되었습니다.

프로토콜 코믹

만화를 제작해주신 Ray Wenderlich 에게 감사드립니다!

다만, 한 가지 주의할 점은 다음과 같습니다. 첫 번째 구현에서는 foo() A 의 기본 구현으로 정의했지만 프로토콜에서는 필수로 만들지 않았습니다. 따라서 a.foo() 호출하면 " A default "가 인쇄됩니다.

protocol Default {}

extension Default {
    func foo() { print("A default")}
}

struct DefaultStruct: Default {
    func foo() {
        print("Inst")
    }
}

let a: Default = DefaultStruct()
a.foo()
A default

그러나 Afoo() 필수로 만들면 " Inst "를 얻게 됩니다.

protocol Default {
    func foo()
}

extension Default {
    func foo() { 
        print("A default")
    }
}

struct DefaultStruct: Default {
    func foo() {
        print("Inst")
    }
}

let a: Default = DefaultStruct()
a.foo()
Inst

이는 Swift의 프로토콜에 대한 첫 번째 예의 정적 디스패치와 두 번째 예의 정적 디스패치 간의 차이로 인해 발생합니다. 자세한 내용은 이 중간 게시물을 참조하세요.

기본 동작 재정의

그러나 원한다면 기본 동작을 무시할 수도 있습니다. 주목해야 할 중요한 점은 이것이 동적 디스패치를 ​​지원하지 않는다는 것입니다.

오래된 버전의 전기 자동차가 있어서 배터리 상태가 90%로 감소했다고 가정해 보겠습니다.

struct OldElectric: Car, Electric {
    var color: Color // Needs to be a var since `Car` has a getter and setter.
    let price: Int
    var batteryLevel: Int

    func turnOn() {
        print("Starting all systems!")
    }

    mutating func drive() {
        print("Self driving engaged!")
        batteryLevel -= 8
    }

    mutating func reCharge() {
        print("Recharging the battery...")
        batteryLevel = 90
    }
}

프로토콜의 표준 라이브러리 사용

이제 Swift의 프로토콜이 어떻게 작동하는지 알았으니 표준 라이브러리 프로토콜을 사용하는 몇 가지 일반적인 예를 살펴보겠습니다.

표준 라이브러리 확장

Swift에 이미 존재하는 유형에 추가 기능을 추가하는 방법을 살펴보겠습니다. Swift의 유형은 내장되어 있지 않지만 구조체로 표준 라이브러리의 일부이므로 이 작업은 쉽습니다.

요소 배열에 대해 이진 검색을 시도하고 배열이 정렬되어 있는지도 확인해 보겠습니다.

extension Collection where Element: Comparable {
    // Verify that a `Collection` is sorted.
    func isSorted(_ order: (Element, Element) -> Bool) -> Bool {
        var i = index(startIndex, offsetBy: 1)

        while i < endIndex {
            // The longer way of calling a binary function like `<(_:_:)`, 
            // `<=(_:_:)`, `==(_:_:)`, etc.
            guard order(self[index(i, offsetBy: -1)], self[i]) else {
                return false
            }
            i = index(after: i)
        }
        return true
    }

    // Perform binary search on a `Collection`, verifying it is sorted.
    func binarySearch(_ element: Element) -> Index? {
        guard self.isSorted(<=) else {
            return nil
        }

        var low = startIndex
        var high = endIndex

        while low <= high {
            let mid = index(low, offsetBy: distance(from: low, to: high)/2)

            if self[mid] == element {
                return mid
            } else if self[mid] < element {
                low = index(after: mid)
            } else {
                high = index(mid, offsetBy: -1)
            }
        }

        return nil
    }
}

print([2, 2, 5, 7, 11, 13, 17].binarySearch(5)!)
print(["a", "b", "c", "d"].binarySearch("b")!)
print([1.1, 2.2, 3.3, 4.4, 5.5].binarySearch(3.3)!)
2
1
2

우리는 "요소가 비파괴적으로 여러 번 탐색될 수 있고 색인된 첨자에 의해 액세스될 수 있는 시퀀스"를 정의하는 Collection 프로토콜을 확장하여 이를 수행합니다. 대괄호 표기법을 사용하여 배열을 색인화할 수 있으므로 이것이 우리가 확장하려는 프로토콜입니다.

마찬가지로, 요소를 비교할 수 있는 배열에만 이 유틸리티 함수를 추가하려고 합니다. 이것이 우리 where Element: Comparable 사용하는 이유입니다.

where 절은 우리가 곧 다룰 Swift의 유형 시스템의 일부입니다. 그러나 간단히 말해서 프로토콜을 구현하기 위해 유형을 요구하거나 두 가지 유형이 동일하거나 클래스가 특정 슈퍼클래스를 갖도록 요구합니다.

Element Collection 준수 유형에 있는 요소의 연관된 유형입니다. Element Sequence 프로토콜 내에서 정의되지만 Collection Sequence 에서 상속되므로 Element 관련 유형을 상속합니다.

Comparable "관계 연산자 < , <= , >=> 를 사용하여 비교할 수 있는 유형"을 정의하는 프로토콜입니다. . 정렬된 Collection 에 대해 이진 검색을 수행하고 있으므로 이는 물론 true여야 합니다. 그렇지 않으면 이진 검색에서 왼쪽 또는 오른쪽으로 재귀/반복할지 여부를 알 수 없습니다.

구현에 대한 참고 사항으로 사용된 index(_:offsetBy:) 함수에 대한 자세한 내용은 다음 문서를 참조하세요.

제네릭 + 프로토콜 = 품

중복 코드를 피하기 위해 올바르게 사용된다면 제네릭과 프로토콜은 강력한 도구가 될 수 있습니다.

먼저 Colab 책 마지막 부분에서 제네릭을 간략하게 다루는 또 다른 튜토리얼인 A Swift Tour를 살펴보세요.

제네릭에 대한 일반적인 아이디어가 있다고 가정하고 몇 가지 고급 용도를 빠르게 살펴보겠습니다.

단일 유형에 여러 프로토콜을 준수하는 유형과 같은 여러 요구 사항이 있는 경우 여러 가지 옵션을 사용할 수 있습니다.

typealias ComparableReal = Comparable & FloatingPoint

func foo1<T: ComparableReal>(a: T, b: T) -> Bool {
    return a > b
}

func foo2<T: Comparable & FloatingPoint>(a: T, b: T) -> Bool {
    return a > b
}

func foo3<T>(a: T, b: T) -> Bool where T: ComparableReal {
    return a > b
}

func foo4<T>(a: T, b: T) -> Bool where T: Comparable & FloatingPoint {
    return a > b
}

func foo5<T: FloatingPoint>(a: T, b: T) -> Bool where T: Comparable {
    return a > b
}

print(foo1(a: 1, b: 2))
print(foo2(a: 1, b: 2))
print(foo3(a: 1, b: 2))
print(foo4(a: 1, b: 2))
print(foo5(a: 1, b: 2))
false
false
false
false
false

상단에 typealias 사용된 것을 확인하세요. 그러면 기존 유형의 명명된 별칭이 프로그램에 추가됩니다. 유형 별칭이 선언되면 프로그램의 모든 위치에서 기존 유형 대신 별칭 이름을 사용할 수 있습니다. 유형 별칭은 새 유형을 생성하지 않습니다. 단순히 이름이 기존 유형을 참조하도록 허용합니다.

이제 프로토콜과 제네릭을 함께 사용하는 방법을 살펴보겠습니다.

우리가 판매하는 노트북에 대해 매장 뒤에서 노트북을 정리하는 방법을 결정하기 위해 다음 요구 사항을 충족하는 컴퓨터 매장이라고 가정해 보겠습니다.

enum Box {
    case small
    case medium
    case large
}

enum Mass {
    case light
    case medium
    case heavy
}

// Note: `CustomStringConvertible` protocol lets us pretty-print a `Laptop`.
struct Laptop: CustomStringConvertible {
    var name: String
    var box: Box
    var mass: Mass

    var description: String {
        return "(\(self.name) \(self.box) \(self.mass))"
    }
}

그러나 선반에는 무게 제한이 있으므로 Laptop 을 질량별로 그룹화해야 하는 새로운 요구 사항이 있습니다.

func filtering(_ laptops: [Laptop], by mass: Mass) -> [Laptop] {
    return laptops.filter { $0.mass == mass }
}

let laptops: [Laptop] = [
    Laptop(name: "a", box: .small, mass: .light),
    Laptop(name: "b", box: .large, mass: .medium),
    Laptop(name: "c", box: .medium, mass: .heavy),
    Laptop(name: "d", box: .large, mass: .light)
]

let filteredLaptops = filtering(laptops, by: .light)
print(filteredLaptops)
[(a small light), (d large light)]

그러나 Mass 가 아닌 다른 것으로 필터링하고 싶다면 어떻게 해야 할까요?

한 가지 옵션은 다음을 수행하는 것입니다.

// Define a protocol which will act as our comparator.
protocol DeviceFilterPredicate {
    associatedtype Device
    func shouldKeep(_ item: Device) -> Bool
}

// Define the structs we will use for passing into our filtering function.
struct BoxFilter: DeviceFilterPredicate {
    typealias Device = Laptop
    var box: Box 

    func shouldKeep(_ item: Laptop) -> Bool {
        return item.box == box
    }
}

struct MassFilter: DeviceFilterPredicate {
    typealias Device = Laptop  
    var mass: Mass

    func shouldKeep(_ item: Laptop) -> Bool {
        return item.mass == mass
    }
}

// Make sure our filter conforms to `DeviceFilterPredicate` and that we are 
// filtering `Laptop`s.
func filtering<F: DeviceFilterPredicate>(
    _ laptops: [Laptop], 
    by filter: F
) -> [Laptop] where Laptop == F.Device {
    return laptops.filter { filter.shouldKeep($0) }
}

// Let's test the function out!
print(filtering(laptops, by: BoxFilter(box: .large)))
print(filtering(laptops, by: MassFilter(mass: .heavy)))
[(b large medium), (d large light)]
[(c medium heavy)]

엄청난! 이제 랩톱 제약 조건을 기준으로 필터링할 수 있습니다. 그러나 우리는 Laptop 만 필터링할 수 있습니다.

상자 안에 있고 질량이 있는 모든 것을 필터링할 수 있다면 어떨까요? 어쩌면 이 노트북 창고는 고객 기반이 다른 서버에도 사용될 수도 있습니다.

// Define 2 new protocols so we can filter anything in a box and which has mass.
protocol Weighable {
    var mass: Mass { get }
}

protocol Boxed {
    var box: Box { get }
}

// Define the new Laptop and Server struct which have mass and a box.
struct Laptop: CustomStringConvertible, Boxed, Weighable {
    var name: String
    var box: Box
    var mass: Mass

    var description: String {
        return "(\(self.name) \(self.box) \(self.mass))"
    }
}

struct Server: CustomStringConvertible, Boxed, Weighable {
    var isWorking: Bool
    var name: String
    let box: Box
    let mass: Mass

    var description: String {
        if isWorking {
            return "(working \(self.name) \(self.box) \(self.mass))"
        } else {
            return "(notWorking \(self.name) \(self.box) \(self.mass))"
        }
    }
}

// Define the structs we will use for passing into our filtering function.
struct BoxFilter<T: Boxed>: DeviceFilterPredicate {
    var box: Box 

    func shouldKeep(_ item: T) -> Bool {
        return item.box == box
    }
}

struct MassFilter<T: Weighable>: DeviceFilterPredicate {
    var mass: Mass

    func shouldKeep(_ item: T) -> Bool {
        return item.mass == mass
    }
}

// Define the new filter function.
func filtering<F: DeviceFilterPredicate, T>(
    _ elements: [T], 
    by filter: F
) -> [T] where T == F.Device {
    return elements.filter { filter.shouldKeep($0) }
}


// Let's test the function out!
let servers = [
    Server(isWorking: true, name: "serverA", box: .small, mass: .heavy),
    Server(isWorking: false, name: "serverB", box: .medium, mass: .medium),
    Server(isWorking: true, name: "serverC", box: .large, mass: .light),
    Server(isWorking: false, name: "serverD", box: .medium, mass: .light),
    Server(isWorking: true, name: "serverE", box: .small, mass: .heavy)
]

let products = [
    Laptop(name: "a", box: .small, mass: .light),
    Laptop(name: "b", box: .large, mass: .medium),
    Laptop(name: "c", box: .medium, mass: .heavy),
    Laptop(name: "d", box: .large, mass: .light)
]

print(filtering(servers, by: BoxFilter(box: .small)))
print(filtering(servers, by: MassFilter(mass: .medium)))

print(filtering(products, by: BoxFilter(box: .small)))
print(filtering(products, by: MassFilter(mass: .medium)))
[(working serverA small heavy), (working serverE small heavy)]
[(notWorking serverB medium medium)]
[(a small light)]
[(b large medium)]

이제 특정 struct 의 속성뿐만 아니라 해당 속성이 있는 모든 구조체를 기준으로 배열을 필터링할 수 있습니다!

좋은 API 디자인을 위한 팁

이 섹션은 WWDC 2019: Modern Swift API Design talk에서 발췌한 것입니다.

이제 프로토콜이 어떻게 작동하는지 이해했으므로 언제 프로토콜을 사용해야 하는지 살펴보는 것이 가장 좋습니다. 프로토콜이 강력할 수 있는 만큼, 프로토콜을 시작하고 즉시 시작하는 것이 항상 최선의 아이디어는 아닙니다.

  • 구체적인 사용 사례부터 시작하세요.
    • 먼저 구체적인 유형의 사용 사례를 탐색하고 공유하고 싶은 코드가 무엇인지, 반복되는 코드가 무엇인지 이해하세요. 그런 다음 해당 공유 코드를 제네릭과 함께 제외합니다. 새로운 프로토콜을 만드는 것을 의미할 수도 있습니다. 일반 코드의 필요성을 발견하세요.
  • 표준 라이브러리에 정의된 기존 프로토콜에서 새 프로토콜을 구성하는 것을 고려하세요. 이에 대한 좋은 예는 다음 Apple 설명서를 참조하세요.
  • 일반 프로토콜 대신 일반 유형을 정의하는 것을 고려해 보세요.

예: 사용자 정의 벡터 유형 정의

3가지 중요한 벡터 연산을 정의하는 우리가 만들고 있는 일부 기하학 앱에서 사용할 부동 소수점 숫자에 대한 GeometricVector 프로토콜을 정의한다고 가정해 보겠습니다.

protocol GeometricVector {
    associatedtype Scalar: FloatingPoint
    static func dot(_ a: Self, _ b: Self) -> Scalar
    var length: Scalar { get }
    func distance(to other: Self) -> Scalar
}

SIMD 프로토콜이 우리에게 도움이 될 수 있는 벡터의 차원을 저장하고 싶다고 가정하고 SIMD 프로토콜을 개선하는 새로운 유형을 만들겠습니다. SIMD 벡터는 벡터 연산을 수행하는 데 사용할 때 매우 빠른 고정 크기 벡터로 간주될 수 있습니다.

protocol GeometricVector: SIMD {
    associatedtype Scalar: FloatingPoint
    static func dot(_ a: Self, _ b: Self) -> Scalar
    var length: Scalar { get }
    func distance(to other: Self) -> Scalar
}

이제 위 작업의 기본 구현을 정의해 보겠습니다.

extension GeometricVector {
    static func dot(_ a: Self, _ b: Self) -> Scalar {
        (a * b).sum()
    }

    var length: Scalar {
        Self.dot(self, self).squareRoot()
    }

    func distance(to other: Self) -> Scalar {
        (self - other).length
    }
}

그런 다음 이러한 기능을 추가하려는 각 유형에 적합성을 추가해야 합니다.

extension SIMD2: GeometricVector where Scalar: FloatingPoint { }
extension SIMD3: GeometricVector where Scalar: FloatingPoint { }
extension SIMD4: GeometricVector where Scalar: FloatingPoint { }
extension SIMD8: GeometricVector where Scalar: FloatingPoint { }
extension SIMD16: GeometricVector where Scalar: FloatingPoint { }
extension SIMD32: GeometricVector where Scalar: FloatingPoint { }
extension SIMD64: GeometricVector where Scalar: FloatingPoint { }

프로토콜을 정의하고 기본 구현을 제공한 다음 여러 유형에 적합성을 추가하는 이 3단계 프로세스는 상당히 반복적입니다.

프로토콜이 필요했나요?

SIMD 유형 중 어느 것도 고유한 구현을 가지고 있지 않다는 사실은 경고 신호입니다. 따라서 이 경우 프로토콜은 실제로 우리에게 아무것도 제공하지 않습니다.

SIMD 확장으로 정의

SIMD 프로토콜의 확장에 3개의 연산자를 작성하면 문제를 더 간결하게 해결할 수 있습니다.

extension SIMD where Scalar: FloatingPoint {
    static func dot(_ a: Self, _ b: Self) -> Scalar {
        (a * b).sum()
    }

    var length: Scalar {
        Self.dot(self, self).squareRoot()
    }

    func distance(to other: Self) -> Scalar {
        (self - other).length
    }
}

더 적은 줄의 코드를 사용하여 모든 유형의 SIMD 에 모든 기본 구현을 추가했습니다.

때로는 이러한 유형의 계층 구조를 만들고 싶은 유혹을 느낄 수도 있지만 항상 필요한 것은 아니라는 점을 기억하세요. 이는 또한 컴파일된 프로그램의 바이너리 크기가 더 작아지고 코드 컴파일이 더 빨라진다는 것을 의미합니다.

그러나 이 확장 접근 방식은 추가하려는 메서드가 몇 개 있는 경우에 적합합니다. 그러나 더 큰 API를 설계할 때 확장성 문제가 발생합니다.

이야? 있어요?

앞서 우리는 GeometricVector SIMD 개선할 것이라고 말했습니다. 하지만 이것이 is-a 관계인가요? 문제는 SIMD 벡터에 스칼라 1을 추가할 수 있는 연산을 정의하지만 기하학의 맥락에서 그러한 연산을 정의하는 것은 의미가 없다는 것입니다.

따라서 부동 소수점 숫자를 처리할 수 있는 새로운 일반 유형으로 SIMD 래핑하면 has-a 관계가 더 좋아질 수 있습니다.

// NOTE: `Storage` is the underlying type that is storing the values, 
// just like in a `SIMD` vector.
struct GeometricVector<Storage: SIMD> where Storage.Scalar: FloatingPoint {
    typealias Scalar = Storage.Scalar
    var value: Storage
    init(_ value: Storage) { self.value = value }
}

그런 다음 조심스럽게 기하학의 맥락에서만 의미가 있는 작업만 정의할 수 있습니다.

extension GeometricVector {
    static func + (a: Self, b: Self) -> Self {
        Self(a.value + b.value)
    }

    static func - (a: Self, b: Self) -> Self {
        Self(a.value - b.value)
    }
    static func * (a: Self, b: Scalar) -> Self {
        Self(a.value * b)
    }
}

그리고 우리는 여전히 일반 확장을 사용하여 이전과 거의 동일하게 보이는 구현하고 싶었던 3개의 이전 연산자를 얻을 수 있습니다.

extension GeometricVector {
    static func dot(_ a: Self, _ b: Self) -> Scalar {
        (a.value * b.value).sum()
    }

    var length: Scalar {
        Self.dot(self, self).squareRoot()
    }

    func distance(to other: Self) -> Scalar {
        (self - other).length
    }
}

전반적으로 우리는 단순히 구조체를 사용하여 세 가지 작업의 동작을 특정 유형으로 구체화할 수 있었습니다. 프로토콜을 사용하면 모든 SIMD 벡터에 반복적인 적합성을 작성하는 문제에 직면했으며 Scalar + Vector 와 같은 특정 연산자를 사용할 수 없게 할 수도 없었습니다(이 경우에는 원하지 않았습니다). 따라서 프로토콜은 전부이자 최종적인 솔루션이 아니라는 점을 기억하십시오. 그러나 때로는 보다 전통적인 솔루션이 더 강력할 수도 있습니다.

더 많은 프로토콜 지향 프로그래밍 리소스

논의된 주제에 대한 추가 리소스는 다음과 같습니다.

  • WWDC 2015: Protocol-Oriented 프로그래밍 in Swift : 이것은 Swift 2를 사용하여 발표되었으므로 그 이후로 많은 것이 변경되었습니다(예: 프레젠테이션에서 사용한 프로토콜의 이름). 그러나 이것은 여전히 ​​이론과 그 뒤에 사용되는 좋은 리소스입니다. .
  • Swift 3의 프로토콜 지향 프로그래밍 소개 : 이 내용은 Swift 3으로 작성되었으므로 성공적으로 컴파일하려면 일부 코드를 수정해야 할 수도 있지만 이는 또 다른 훌륭한 리소스입니다.
  • WWDC 2019: 최신 Swift API 디자인 : 값과 참조 유형 간의 차이점, 프로토콜이 API 디자인에서 더 나쁜 선택으로 판명될 수 있는 사용 사례(위의 "좋은 API 디자인을 위한 팁" 섹션과 동일)에 대해 설명합니다. 경로 멤버 조회 및 속성 래퍼.
  • Generics : 제네릭에 관한 Swift 5의 Swift 자체 문서입니다.