Programmazione orientata al protocollo - generici

Visualizza su TensorFlow.org Esegui in Google Colab Visualizza la fonte su GitHub

Questo tutorial esaminerà la programmazione orientata ai protocolli e diversi esempi di come possono essere utilizzati con i generici negli esempi quotidiani.

Protocolli

L'ereditarietà è un modo efficace per organizzare il codice in linguaggi di programmazione che consente di condividere il codice tra più componenti del programma.

In Swift esistono diversi modi per esprimere l'ereditarietà. Potresti già avere familiarità con uno di questi modi, da altri linguaggi: l'ereditarietà delle classi. Tuttavia, Swift ha un altro modo: i protocolli.

In questo tutorial esploreremo i protocolli, un'alternativa alle sottoclassi che ti consente di raggiungere obiettivi simili attraverso diversi compromessi. In Swift, i protocolli contengono più membri astratti. Classi, strutture ed enumerazioni possono conformarsi a più protocolli e la relazione di conformità può essere stabilita retroattivamente. Tutto ciò consente alcuni progetti che non sono facilmente esprimibili in Swift utilizzando le sottoclassi. Esamineremo gli idiomi che supportano l'uso dei protocolli (estensioni e vincoli dei protocolli), nonché le limitazioni dei protocolli.

I tipi di valore di Swift 💖!

Oltre alle classi che hanno una semantica di riferimento, Swift supporta enumerazioni e strutture passate per valore. Enumerazioni e strutture supportano molte funzionalità fornite dalle classi. Diamo un'occhiata!

Innanzitutto, diamo un'occhiata a come le enumerazioni sono simili alle classi:

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

Ora diamo un'occhiata alle strutture. Si noti che non possiamo ereditare le strutture, ma possiamo invece utilizzare i protocolli:

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

Infine, vediamo come vengono passati i tipi di valore a differenza delle classi:

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

Usiamo i protocolli

Iniziamo creando protocolli per diverse auto:

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 un mondo orientato agli oggetti (senza ereditarietà multipla), potresti aver creato classi astratte Electric e Gas , quindi utilizzare l'ereditarietà delle classi per far sì che entrambe ereditino da Car e quindi avere un modello di auto specifico come classe base. Tuttavia, in questo caso si tratta di protocolli completamente separati con accoppiamento zero ! Ciò rende l'intero sistema più flessibile nel modo in cui lo si progetta.

Definiamo una 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)

Ciò specifica una nuova struttura TeslaModelS che è conforme ad entrambi i protocolli Car ed Electric .

Ora definiamo un'auto alimentata a gas:

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)

Estendi i protocolli con comportamenti predefiniti

Ciò che puoi notare dagli esempi è che abbiamo una certa ridondanza. Ogni volta che ricarichiamo un'auto elettrica, dobbiamo impostare il livello percentuale della batteria su 100. Poiché tutte le auto elettriche hanno una capacità massima del 100%, ma le auto a gas variano in base alla capacità del serbatoio del gas, possiamo impostare il livello predefinito su 100 per le auto elettriche. .

È qui che le estensioni in Swift possono tornare utili:

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

Quindi ora, qualsiasi nuova auto elettrica che creeremo imposterà la batteria su 100 quando la ricarichiamo. Pertanto, siamo appena riusciti a decorare classi, strutture ed enumerazioni con un comportamento unico e predefinito.

Protocollo comico

Grazie a Ray Wenderlich per il fumetto!

Tuttavia, una cosa a cui prestare attenzione è la seguente. Nella nostra prima implementazione, definiamo foo() come implementazione predefinita su A , ma non la rendiamo obbligatoria nel protocollo. Quindi quando chiamiamo a.foo() , otteniamo stampato " 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

Tuttavia, se rendiamo foo() obbligatorio su A , otteniamo " 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

Ciò si verifica a causa di una differenza tra l'invio statico nel primo esempio e l'invio statico nel secondo sui protocolli in Swift. Per maggiori informazioni, fare riferimento a questo post di Medium .

Sostituire il comportamento predefinito

Tuttavia, se lo desideriamo, possiamo comunque sovrascrivere il comportamento predefinito. Una cosa importante da notare è che questo non supporta l'invio dinamico .

Supponiamo di avere una versione precedente di un'auto elettrica, quindi la salute della batteria è stata ridotta al 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
    }
}

Usi dei protocolli da parte della libreria standard

Ora che abbiamo un'idea di come funzionano i protocolli in Swift, esaminiamo alcuni esempi tipici di utilizzo dei protocolli della libreria standard.

Estendi la libreria standard

Vediamo come possiamo aggiungere funzionalità aggiuntive ai tipi già esistenti in Swift. Dato che i tipi in Swift non sono integrati, ma fanno parte della libreria standard come strutture, è facile farlo.

Proviamo a eseguire una ricerca binaria su un array di elementi, assicurandoci anche di verificare che l'array sia ordinato:

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

Lo facciamo estendendo il protocollo Collection che definisce "una sequenza i cui elementi possono essere attraversati più volte, in modo non distruttivo, e accessibili tramite un pedice indicizzato". Poiché gli array possono essere indicizzati utilizzando la notazione tra parentesi quadre, questo è il protocollo che vogliamo estendere.

Allo stesso modo, vogliamo aggiungere questa funzione di utilità solo agli array i cui elementi possono essere confrontati. Questo è il motivo per cui abbiamo where Element: Comparable .

La clausola where fa parte del sistema di tipi di Swift, di cui parleremo tra poco, ma in breve ci permette di aggiungere ulteriori requisiti all'estensione che stiamo scrivendo, come richiedere il tipo per implementare un protocollo, richiedere due tipi per essere il stesso, o per richiedere che una classe abbia una particolare superclasse.

Element è il tipo associato degli elementi in un tipo conforme Collection . Element è definito all'interno del protocollo Sequence , ma poiché Collection eredita da Sequence , eredita il tipo associato Element .

Comparable è un protocollo che definisce "un tipo che può essere confrontato utilizzando gli operatori relazionali < , <= , >= e > ". . Poiché stiamo eseguendo una ricerca binaria su una Collection ordinata, questo ovviamente deve essere vero altrimenti non sappiamo se ripetere/iterare a sinistra o a destra nella ricerca binaria.

Come nota a margine sull'implementazione, per ulteriori informazioni sulla funzione index(_:offsetBy:) utilizzata, fare riferimento alla seguente documentazione .

Generici + protocolli = 💥

I generici e i protocolli possono essere uno strumento potente se utilizzati correttamente per evitare codice duplicato.

Innanzitutto, dai un'occhiata a un altro tutorial, A Swift Tour , che tratta brevemente i generici alla fine del libro Colab.

Supponendo che tu abbia un'idea generale sui farmaci generici, diamo rapidamente un'occhiata ad alcuni usi avanzati.

Quando un singolo tipo ha più requisiti, ad esempio un tipo è conforme a più protocolli, hai diverse opzioni a tua disposizione:

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

Notare l'uso dei typealias in alto. Ciò aggiunge un alias denominato di un tipo esistente al tuo programma. Dopo aver dichiarato un alias di tipo, il nome con alias può essere utilizzato al posto del tipo esistente ovunque nel programma. Gli alias di tipo non creano nuovi tipi; consentono semplicemente a un nome di fare riferimento a un tipo esistente.

Ora vediamo come possiamo usare insieme protocolli e generici.

Immaginiamo di essere un negozio di computer con i seguenti requisiti su qualsiasi laptop che vendiamo per determinare come organizzarli nel retro del negozio:

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

Tuttavia, abbiamo una nuova esigenza di raggruppare i nostri Laptop in base alla massa poiché gli scaffali hanno limitazioni di peso.

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

Ma cosa succederebbe se volessimo filtrare per qualcosa di diverso dalla Mass ?

Un'opzione è procedere come segue:

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

Eccezionale! Ora siamo in grado di filtrare in base a qualsiasi vincolo del laptop. Tuttavia, siamo in grado di filtrare solo i Laptop .

Che ne dici di poter filtrare tutto ciò che è in una scatola e ha massa? Forse questo magazzino di laptop verrà utilizzato anche per server che hanno una clientela diversa:

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

Ora siamo in grado di filtrare un array non solo in base a qualsiasi proprietà di una struct specifica, ma anche in grado di filtrare qualsiasi struttura che abbia quella proprietà!

Suggerimenti per una buona progettazione dell'API

Questa sezione è stata tratta dal discorso del WWDC 2019: Modern Swift API Design .

Ora che hai capito come si comportano i protocolli, è meglio spiegare quando dovresti usarli. Per quanto potenti possano essere i protocolli, non è sempre l'idea migliore immergersi e iniziare immediatamente con i protocolli.

  • Inizia con casi d’uso concreti:
    • Per prima cosa esplora il caso d'uso con tipi concreti e capisci quale codice vuoi condividere e scopri che viene ripetuto. Quindi, considera il codice condiviso con i generici. Potrebbe significare creare nuovi protocolli. Scopri la necessità di un codice generico.
  • Considera la possibilità di comporre nuovi protocolli da protocolli esistenti definiti nella libreria standard. Fare riferimento alla seguente documentazione Apple per un buon esempio di ciò.
  • Invece di un protocollo generico, considera invece la definizione di un tipo generico.

Esempio: definizione di un tipo di vettore personalizzato

Diciamo che vogliamo definire un protocollo GeometricVector sui numeri in virgola mobile da utilizzare in alcune app di geometria che stiamo realizzando che definisce 3 importanti operazioni vettoriali:

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

Diciamo che vogliamo memorizzare le dimensioni del vettore, in cui il protocollo SIMD può aiutarci, quindi faremo in modo che il nostro nuovo tipo perfezioni il protocollo SIMD . I vettori SIMD possono essere considerati come vettori di dimensione fissa che sono molto veloci quando li si utilizza per eseguire operazioni sui vettori:

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

Ora, definiamo le implementazioni predefinite delle operazioni di cui sopra:

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

E poi dobbiamo aggiungere una conformità a ciascuno dei tipi a cui vogliamo aggiungere queste abilità:

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

Questo processo in tre fasi che consiste nella definizione del protocollo, nell'assegnazione di un'implementazione predefinita e quindi nell'aggiunta di una conformità a più tipi è abbastanza ripetitivo.

Il protocollo era necessario?

Il fatto che nessuno dei tipi SIMD abbia implementazioni uniche è un segnale di avvertimento. Quindi in questo caso il protocollo non ci fornisce davvero nulla.

Definirlo in un'estensione di SIMD

Se scriviamo i 3 operatori in un'estensione del protocollo SIMD , questo può risolvere il problema in modo più succinto:

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

Utilizzando meno righe di codice, abbiamo aggiunto tutte le implementazioni predefinite a tutti i tipi di SIMD .

A volte potresti essere tentato di creare questa gerarchia di tipi, ma ricorda che non è sempre necessario. Ciò significa anche che la dimensione binaria del programma compilato sarà inferiore e il codice sarà più veloce da compilare.

Tuttavia, questo approccio di estensione è ottimo quando si desidera aggiungere un numero limitato di metodi. Tuttavia, si verifica un problema di scalabilità quando si progetta un'API più grande.

È un? Ha un?

In precedenza abbiamo detto che GeometricVector avrebbe perfezionato SIMD . Ma è questa una relazione? Il problema è che SIMD definisce operazioni che ci permettono di aggiungere uno scalare 1 a un vettore, ma non ha senso definire tale operazione nel contesto della geometria.

Quindi, forse una relazione has-a sarebbe migliore racchiudendo SIMD in un nuovo tipo generico in grado di gestire qualsiasi numero in virgola mobile:

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

Possiamo quindi stare attenti e definire solo le operazioni che hanno senso solo nel contesto della geometria:

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

E possiamo ancora utilizzare estensioni generiche per ottenere i 3 operatori precedenti che volevamo implementare e che sembrano quasi identici a prima:

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

Nel complesso, siamo stati in grado di perfezionare il comportamento delle nostre tre operazioni in un tipo semplicemente utilizzando una struttura. Con i protocolli, abbiamo dovuto affrontare il problema della scrittura di conformità ripetitive per tutti i vettori SIMD e inoltre non siamo stati in grado di impedire la disponibilità di alcuni operatori come Scalar + Vector (cosa che in questo caso non volevamo). Pertanto, ricorda che i protocolli non sono la soluzione universale. Ma a volte le soluzioni più tradizionali possono rivelarsi più potenti.

Più risorse di programmazione orientate al protocollo

Di seguito sono riportate risorse aggiuntive sugli argomenti trattati:

  • WWDC 2015: Programmazione orientata ai protocolli in Swift : è stata presentata utilizzando Swift 2, quindi molto è cambiato da allora (ad esempio il nome dei protocolli utilizzati nella presentazione) ma questa è ancora una buona risorsa per la teoria e gli usi dietro di essa .
  • Presentazione della programmazione orientata al protocollo in Swift 3 : è stata scritta in Swift 3, quindi potrebbe essere necessario modificare parte del codice per poterlo compilare con successo, ma è un'altra grande risorsa.
  • WWDC 2019: Modern Swift API Design : esamina le differenze tra tipi di valore e di riferimento, un caso d'uso in cui i protocolli possono rivelarsi la scelta peggiore nella progettazione delle API (come nella sezione "Suggerimenti per una buona progettazione delle API" sopra), chiave ricerca dei membri del percorso e wrapper delle proprietà.
  • Generici : la documentazione di Swift per Swift 5 è tutta sui generici.