RSVP für Ihr lokales TensorFlow Everywhere-Event noch heute!
Diese Seite wurde von der Cloud Translation API übersetzt.
Switch to English

Protokollorientierte Programmierung & Generika

Ansicht auf TensorFlow.org Quelle auf GitHub anzeigen

In diesem Tutorial werden die protokollorientierte Programmierung und verschiedene Beispiele für die Verwendung mit Generika in alltäglichen Beispielen behandelt.

Protokolle

Vererbung ist eine leistungsstarke Methode zum Organisieren von Code in Programmiersprachen, mit der Sie Code zwischen mehreren Programmkomponenten austauschen können.

In Swift gibt es verschiedene Möglichkeiten, die Vererbung auszudrücken. Möglicherweise kennen Sie bereits eine dieser Möglichkeiten aus anderen Sprachen: die Klassenvererbung. Swift hat jedoch einen anderen Weg: Protokolle.

In diesem Tutorial werden wir Protokolle untersuchen - eine Alternative zur Unterklasse, mit der Sie ähnliche Ziele durch unterschiedliche Kompromisse erreichen können. In Swift enthalten Protokolle mehrere abstrakte Elemente. Klassen, Strukturen und Aufzählungen können mehreren Protokollen entsprechen und die Konformitätsbeziehung kann rückwirkend hergestellt werden. All dies ermöglicht einige Designs, die in Swift mithilfe von Unterklassen nicht leicht auszudrücken sind. Wir werden die Redewendungen durchgehen, die die Verwendung von Protokollen (Erweiterungen und Protokollbeschränkungen) sowie die Einschränkungen von Protokollen unterstützen.

Die Werttypen von Swift!

Zusätzlich zu Klassen mit Referenzsemantik unterstützt Swift Aufzählungen und Strukturen, die als Wert übergeben werden. Aufzählungen und Strukturen unterstützen viele Funktionen, die von Klassen bereitgestellt werden. Lass uns mal sehen!

Schauen wir uns zunächst an, wie ähnlich Aufzählungen Klassen sind:

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

Schauen wir uns nun die Strukturen an. Beachten Sie, dass wir keine Strukturen erben können, sondern Protokolle verwenden können:

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

Lassen Sie uns abschließend sehen, wie sie im Gegensatz zu Klassen von Werttypen übergeben werden:

// 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

Verwenden wir Protokolle

Beginnen wir mit der Erstellung von Protokollen für verschiedene Autos:

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 }
}

In einer objektorientierten Welt (ohne Mehrfachvererbung) haben Sie möglicherweise abstrakte Klassen für Electric und Gas erstellt und dann die Klassenvererbung verwendet, um beide von Car erben, und dann ein bestimmtes Automodell als Basisklasse verwendet. Doch hier sind beide völlig getrennte Protokolle mit Null Kopplung! Dies macht das gesamte System flexibler bei der Gestaltung.

Definieren wir einen 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)

Dies gibt eine neue Struktur TeslaModelS , die sowohl den Protokollen Car als auch Electric .

Definieren wir nun ein gasbetriebenes Auto:

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)

Erweitern Sie Protokolle mit Standardverhalten

Was Sie an den Beispielen erkennen können, ist, dass wir eine gewisse Redundanz haben. Jedes Mal, wenn wir ein Elektroauto aufladen, müssen wir den Batterieprozentsatz auf 100 einstellen. Da alle Elektroautos eine maximale Kapazität von 100% haben, Gasautos jedoch zwischen den Gastankkapazitäten variieren, können wir den Stand für Elektroautos standardmäßig auf 100 setzen .

Hier können Erweiterungen in Swift nützlich sein:

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

Jedes neue Elektroauto, das wir entwickeln, setzt die Batterie beim Aufladen auf 100. So konnten wir gerade Klassen, Strukturen und Aufzählungen mit einzigartigem und Standardverhalten dekorieren.

Protokoll Comic

Danke an Ray Wenderlich für den Comic!

Beachten Sie jedoch Folgendes. In unserer ersten Implementierung definieren wir foo() als Standardimplementierung für A , machen dies jedoch im Protokoll nicht erforderlich. Wenn wir also a.foo() aufrufen, wird " A default " gedruckt.

protocol Default {}

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

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

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

Wenn wir jedoch foo() für A erforderlich machen, erhalten wir " 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

Dies liegt an einem Unterschied zwischen dem statischen Versand im ersten Beispiel und dem statischen Versand im zweiten Beispiel für Protokolle in Swift. Weitere Informationen finden Sie in diesem mittleren Beitrag .

Standardverhalten überschreiben

Wenn wir möchten, können wir das Standardverhalten dennoch überschreiben. Eine wichtige Sache zu beachten ist, dass dies keinen dynamischen Versand unterstützt .

Nehmen wir an, wir haben eine ältere Version eines Elektroautos, sodass der Batteriezustand auf 90% reduziert wurde:

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
    }
}

Standardbibliotheksverwendung von Protokollen

Nachdem wir eine Vorstellung davon haben, wie Protokolle in Swift funktionieren, gehen wir einige typische Beispiele für die Verwendung der Standardbibliotheksprotokolle durch.

Erweitern Sie die Standardbibliothek

Mal sehen, wie wir Typen, die bereits in Swift existieren, zusätzliche Funktionen hinzufügen können. Da Typen in Swift nicht integriert sind, sondern als Strukturen Teil der Standardbibliothek sind, ist dies einfach.

Versuchen wir, eine binäre Suche für ein Array von Elementen durchzuführen, und stellen Sie gleichzeitig sicher, dass das Array sortiert ist:

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

Dazu erweitern wir dasCollection Protokoll, das "eine Sequenz definiert , deren Elemente mehrfach zerstörungsfrei durchlaufen werden können und auf die ein indizierter Index zugreifen kann". Da Arrays mit der eckigen Klammer indiziert werden können, ist dies das Protokoll, das wir erweitern möchten.

Ebenso möchten wir diese Dienstprogrammfunktion nur Arrays hinzufügen, deren Elemente verglichen werden können. Dies ist der Grund, warum wir where Element: Comparable .

Die where Klausel ist Teil des Typsystems von Swift, das wir in Kürze behandeln werden. Kurz gesagt, wir können der Erweiterung, die wir schreiben, zusätzliche Anforderungen hinzufügen, z. B. den Typ zur Implementierung eines Protokolls und zwei Typen als gleich, oder von einer Klasse zu verlangen, dass sie eine bestimmte Oberklasse hat.

Element ist der zugeordnete Typ der Elemente in einem Collection konformen Typ. Element wird im Sequence definiert. Da Collection von Sequence erbt, erbt es den mit Element verknüpften Typ.

Comparable ist ein Protokoll, das "einen Typ definiert , der mit den Vergleichsoperatoren < , <= , >= und > verglichen werden kann". . Da wir eine binäre Suche für eine sortierte Collection , muss dies natürlich wahr sein, sonst wissen wir nicht, ob wir bei der binären Suche nach links oder rechts rekursieren / iterieren sollen.

Weitere Informationen zur verwendeten index(_:offsetBy:) in der folgenden Dokumentation .

Generika + Protokolle = 💥

Generika und Protokolle können bei korrekter Verwendung ein leistungsstarkes Werkzeug sein, um doppelten Code zu vermeiden.

Schauen Sie sich zunächst ein weiteres Tutorial an, A Swift Tour , in dem Generika am Ende des Colab-Buches kurz behandelt werden.

Angenommen, Sie haben eine allgemeine Vorstellung von Generika, werfen wir einen kurzen Blick auf einige erweiterte Anwendungen.

Wenn für einen einzelnen Typ mehrere Anforderungen gelten, z. B. ein Typ, der mehreren Protokollen entspricht, stehen Ihnen mehrere Optionen zur Verfügung:

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

Beachten Sie die Verwendung von typealias oben. Dadurch wird Ihrem Programm ein benannter Alias ​​eines vorhandenen Typs hinzugefügt. Nachdem ein Typalias deklariert wurde, kann der Aliasname überall in Ihrem Programm anstelle des vorhandenen Typs verwendet werden. Typ-Aliase erstellen keine neuen Typen. Sie erlauben einfach, dass ein Name auf einen vorhandenen Typ verweist.

Nun wollen wir sehen, wie wir Protokolle und Generika zusammen verwenden können.

Stellen wir uns vor, wir sind ein Computergeschäft mit den folgenden Anforderungen an jeden Laptop, den wir verkaufen, um zu bestimmen, wie wir sie im hinteren Teil des Geschäfts organisieren:

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))"
    }
}

Wir haben jedoch eine neue Anforderung, unsere Laptop nach Masse zu gruppieren, da die Regale Gewichtsbeschränkungen unterliegen.

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)]

Was aber, wenn wir nach etwas anderem als Mass filtern wollten?

Eine Möglichkeit besteht darin, Folgendes zu tun:

// 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)]

Genial! Jetzt können wir basierend auf jeder Laptop-Einschränkung filtern. Wir können jedoch nur Laptop filtern.

Was ist mit der Möglichkeit, alles zu filtern, was sich in einer Box befindet und Masse hat? Vielleicht wird dieses Lager von Laptops auch für Server mit einem anderen Kundenstamm verwendet:

// 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)]

Wir konnten jetzt ein Array nicht nur nach einer Eigenschaft einer bestimmten struct filtern, sondern auch nach einer Struktur, die diese Eigenschaft hat!

Tipps für ein gutes API-Design

Dieser Abschnitt stammt aus dem WWDC 2019: Modern Swift API Design Talk.

Nachdem Sie nun verstanden haben, wie sich Protokolle verhalten, sollten Sie überlegen, wann Sie Protokolle verwenden sollten. So leistungsfähig Protokolle auch sein mögen, es ist nicht immer die beste Idee, in Protokolle einzutauchen und sofort damit zu beginnen.

  • Beginnen Sie mit konkreten Anwendungsfällen:
    • Untersuchen Sie zunächst den Anwendungsfall mit konkreten Typen und verstehen Sie, welcher Code, den Sie freigeben möchten, wiederholt wird. Berücksichtigen Sie dann den gemeinsam genutzten Code mit Generika. Es könnte bedeuten, neue Protokolle zu erstellen. Ermitteln Sie den Bedarf an generischem Code.
  • Erstellen Sie neue Protokolle aus vorhandenen Protokollen, die in der Standardbibliothek definiert sind. Ein gutes Beispiel hierfür finden Sie in der folgenden Apple-Dokumentation .
  • Anstelle eines generischen Protokolls sollten Sie stattdessen einen generischen Typ definieren.

Beispiel: Definieren eines benutzerdefinierten Vektortyps

Angenommen, wir möchten ein GeometricVector Protokoll für Gleitkommazahlen definieren, das in einer von uns erstellten Geometrie-App verwendet wird und drei wichtige Vektoroperationen definiert:

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

Angenommen, wir möchten die Abmessungen des Vektors speichern, bei denen uns das SIMD Protokoll helfen kann, damit unser neuer Typ das SIMD Protokoll verfeinert. SIMD Vektoren können als Vektoren fester Größe betrachtet werden, die sehr schnell sind, wenn Sie sie zur Ausführung von Vektoroperationen verwenden:

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

Definieren wir nun die Standardimplementierungen der obigen Operationen:

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
    }
}

Und dann müssen wir jedem Typ, zu dem wir diese Fähigkeiten hinzufügen möchten, eine Konformität hinzufügen:

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 { }

Dieser dreistufige Prozess zum Definieren des Protokolls, zum Festlegen einer Standardimplementierung und zum Hinzufügen einer Konformität zu mehreren Typen ist ziemlich repetitiv.

War das Protokoll notwendig?

Die Tatsache, dass keiner der SIMD Typen eindeutige Implementierungen aufweist, ist ein Warnzeichen. In diesem Fall gibt uns das Protokoll also nichts.

Definieren in einer Erweiterung von SIMD

Wenn wir die 3 Operatoren in eine Erweiterung des SIMD Protokolls schreiben, kann dies das Problem prägnanter lösen:

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
    }
}

Mit weniger Codezeilen haben wir allen SIMD Typen alle Standardimplementierungen hinzugefügt.

Manchmal könnten Sie versucht sein, diese Hierarchie von Typen zu erstellen, aber denken Sie daran, dass dies nicht immer notwendig ist. Dies bedeutet auch, dass die Binärgröße Ihres kompilierten Programms kleiner ist und Ihr Code schneller kompiliert werden kann.

Dieser Erweiterungsansatz eignet sich jedoch hervorragend, wenn Sie einige Methoden hinzufügen möchten. Beim Entwerfen einer größeren API tritt jedoch ein Skalierbarkeitsproblem auf.

Ist ein? Hat ein?

Früher sagten wir, GeometricVector würde SIMD verfeinern. Aber ist das eine Beziehung? Das Problem ist, dass SIMD Operationen definiert, mit denen wir einem Vektor einen Skalar 1 hinzufügen können. Es ist jedoch nicht sinnvoll, eine solche Operation im Kontext der Geometrie zu definieren.

Vielleicht wäre eine Has-A-Beziehung besser, wenn Sie SIMD in einen neuen generischen Typ einschließen, der jede Gleitkommazahl verarbeiten kann:

// 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 }
}

Wir können dann vorsichtig sein und nur die Operationen definieren, die nur im Kontext der Geometrie sinnvoll sind:

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)
    }
}

Und wir können immer noch generische Erweiterungen verwenden, um die 3 vorherigen Operatoren zu erhalten, die wir implementieren wollten und die fast genauso aussehen wie zuvor:

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
    }
}

Insgesamt konnten wir das Verhalten unserer drei Operationen durch einfache Verwendung einer Struktur auf einen Typ verfeinern. Bei Protokollen hatten wir das Problem, sich wiederholende Konformitäten für alle SIMD Vektoren zu schreiben, und konnten auch nicht verhindern, dass bestimmte Operatoren wie Scalar + Vector verfügbar waren (was wir in diesem Fall nicht wollten). Denken Sie daher daran, dass Protokolle keine A und O-Lösung sind. Aber manchmal können sich traditionellere Lösungen als leistungsfähiger erweisen.

Mehr protokollorientierte Programmierressourcen

Hier finden Sie zusätzliche Ressourcen zu den behandelten Themen:

  • WWDC 2015: Protokollorientierte Programmierung in Swift : Dies wurde mit Swift 2 präsentiert, daher hat sich seitdem viel geändert (z. B. Name der Protokolle, die sie in der Präsentation verwendet haben), aber dies ist immer noch eine gute Ressource für die Theorie und die dahinter stehenden Verwendungen .
  • Einführung in die protokollorientierte Programmierung in Swift 3 : Diese wurde in Swift 3 geschrieben, sodass möglicherweise ein Teil des Codes geändert werden muss, damit er erfolgreich kompiliert werden kann. Dies ist jedoch eine weitere großartige Ressource.
  • WWDC 2019: Modernes Swift-API-Design : Erläutert die Unterschiede zwischen Wert- und Referenztypen. Dies ist ein Anwendungsfall, bei dem sich Protokolle als die schlechtere Wahl im API-Design erweisen können (wie im Abschnitt "Tipps für ein gutes API-Design" oben) Suche nach Pfadmitgliedern und Eigenschafts-Wrapper.
  • Generika : Swifts eigene Dokumentation für Swift 5 rund um Generika.