Programmation orientée protocole - génériques

Voir sur TensorFlow.org Exécuter dans Google Colab Afficher la source sur GitHub

Ce didacticiel passera en revue la programmation orientée protocole et différents exemples de la façon dont ils peuvent être utilisés avec des génériques dans des exemples quotidiens.

Protocoles

L'héritage est un moyen puissant d'organiser le code dans des langages de programmation qui vous permet de partager du code entre plusieurs composants du programme.

Dans Swift, il existe différentes manières d'exprimer l'héritage. Vous connaissez peut-être déjà l'une de ces méthodes, issue d'autres langages : l'héritage de classe. Cependant, Swift a un autre moyen : les protocoles.

Dans ce didacticiel, nous explorerons les protocoles, une alternative au sous-classement qui vous permet d'atteindre des objectifs similaires grâce à différents compromis. Dans Swift, les protocoles contiennent plusieurs membres abstraits. Les classes, les structures et les énumérations peuvent être conformes à plusieurs protocoles et la relation de conformité peut être établie rétroactivement. Tout cela permet certaines conceptions qui ne sont pas facilement exprimables dans Swift à l'aide du sous-classement. Nous passerons en revue les idiomes qui prennent en charge l'utilisation des protocoles (extensions et contraintes de protocole), ainsi que les limitations des protocoles.

Les types de valeur de Swift 💖 !

En plus des classes qui ont une sémantique de référence, Swift prend en charge les énumérations et les structures transmises par valeur. Les énumérations et les structures prennent en charge de nombreuses fonctionnalités fournies par les classes. Nous allons jeter un coup d'oeil!

Tout d’abord, regardons en quoi les énumérations sont similaires aux classes :

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

Maintenant, regardons les structures. Notez que nous ne pouvons pas hériter de structures, mais que nous pouvons utiliser des protocoles :

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

Enfin, voyons comment ils sont transmis par types valeur contrairement aux classes :

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

Utilisons des protocoles

Commençons par créer des protocoles pour différentes voitures :

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

Dans un monde orienté objet (sans héritage multiple), vous avez peut-être créé des classes abstraites Electric et Gas , puis utilisé l'héritage de classe pour que les deux héritent de Car , puis avoir un modèle de voiture spécifique comme classe de base. Cependant, ici, les deux sont des protocoles complètement distincts avec un couplage nul ! Cela rend l’ensemble du système plus flexible dans la façon dont vous le concevez.

Définissons une 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)

Ceci spécifie une nouvelle structure TeslaModelS conforme aux deux protocoles Car et Electric .

Définissons maintenant une voiture à essence :

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)

Étendre les protocoles avec des comportements par défaut

Ce que vous pouvez remarquer à partir des exemples, c'est que nous avons une certaine redondance. Chaque fois que nous rechargeons une voiture électrique, nous devons régler le niveau de pourcentage de batterie à 100. Étant donné que toutes les voitures électriques ont une capacité maximale de 100 %, mais que les voitures à essence varient selon la capacité du réservoir d'essence, nous pouvons par défaut le niveau à 100 pour les voitures électriques. .

C’est là que les extensions de Swift peuvent s’avérer utiles :

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

Alors maintenant, toute nouvelle voiture électrique que nous créons réglera la batterie à 100 lorsque nous la rechargerons. Ainsi, nous venons de pouvoir décorer les classes, les structures et les énumérations avec un comportement unique et par défaut.

Bande dessinée de protocole

Merci à Ray Wenderlich pour la bande dessinée !

Cependant, une chose à surveiller est la suivante. Dans notre première implémentation, nous définissons foo() comme implémentation par défaut sur A , mais nous ne la rendons pas obligatoire dans le protocole. Ainsi, lorsque nous appelons a.foo() , nous obtenons " A default " imprimé.

protocol Default {}

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

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

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

Cependant, si nous rendons foo() obligatoire sur A , nous obtenons " 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

Cela se produit en raison d'une différence entre la répartition statique dans le premier exemple et la répartition statique dans le second sur les protocoles dans Swift. Pour plus d'informations, reportez-vous à cet article Medium .

Remplacement du comportement par défaut

Cependant, si nous le souhaitons, nous pouvons toujours remplacer le comportement par défaut. Une chose importante à noter est que cela ne prend pas en charge la répartition dynamique .

Disons que nous avons une ancienne version d'une voiture électrique, donc la santé de la batterie a été réduite à 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
    }
}

Utilisations des protocoles par la bibliothèque standard

Maintenant que nous avons une idée du fonctionnement des protocoles dans Swift, passons en revue quelques exemples typiques d'utilisation des protocoles de bibliothèque standard.

Étendre la bibliothèque standard

Voyons comment nous pouvons ajouter des fonctionnalités supplémentaires aux types qui existent déjà dans Swift. Étant donné que les types dans Swift ne sont pas intégrés, mais font partie de la bibliothèque standard en tant que structures, cela est facile à faire.

Essayons de faire une recherche binaire sur un tableau d'éléments, tout en veillant également à vérifier que le tableau est trié :

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

Nous faisons cela en étendant le protocole Collection qui définit « une séquence dont les éléments peuvent être parcourus plusieurs fois, de manière non destructive, et accessibles par un indice indexé ». Étant donné que les tableaux peuvent être indexés en utilisant la notation entre crochets, c'est le protocole que nous souhaitons étendre.

De même, nous souhaitons ajouter cette fonction utilitaire uniquement aux tableaux dont les éléments peuvent être comparés. C'est la raison pour laquelle nous avons where Element: Comparable .

La clause where fait partie du système de types de Swift, que nous aborderons bientôt, mais nous permet en bref d'ajouter des exigences supplémentaires à l'extension que nous écrivons, comme exiger que le type implémente un protocole, exiger que deux types soient les même, ou pour exiger qu'une classe ait une superclasse particulière.

Element est le type associé des éléments dans un type conforme à Collection . Element est défini dans le protocole Sequence , mais comme Collection hérite de Sequence , il hérite du type associé à Element .

Comparable est un protocole qui définit « un type qui peut être comparé à l'aide des opérateurs relationnels < , <= , >= et > . . Puisque nous effectuons une recherche binaire sur une Collection triée, cela doit bien sûr être vrai, sinon nous ne savons pas s'il faut récurer/itérer à gauche ou à droite dans la recherche binaire.

En guise de remarque sur l'implémentation, pour plus d'informations sur la fonction index(_:offsetBy:) qui a été utilisée, reportez-vous à la documentation suivante.

Génériques + protocoles = 💥

Les génériques et les protocoles peuvent être un outil puissant s’ils sont utilisés correctement pour éviter la duplication de code.

Tout d'abord, consultez un autre didacticiel, A Swift Tour , qui couvre brièvement les génériques à la fin du livre Colab.

En supposant que vous ayez une idée générale des génériques, examinons rapidement quelques utilisations avancées.

Lorsqu’un même type a plusieurs exigences comme par exemple un type conforme à plusieurs protocoles, vous disposez de plusieurs options :

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

Notez l'utilisation de typealias en haut. Cela ajoute un alias nommé d'un type existant dans votre programme. Une fois qu'un alias de type est déclaré, le nom de l'alias peut être utilisé à la place du type existant partout dans votre programme. Les alias de type ne créent pas de nouveaux types ; ils permettent simplement à un nom de faire référence à un type existant.

Voyons maintenant comment nous pouvons utiliser ensemble les protocoles et les génériques.

Imaginons que nous soyons un magasin d'informatique avec les exigences suivantes sur tous les ordinateurs portables que nous vendons pour déterminer comment nous les organisons à l'arrière du magasin :

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

Cependant, nous avons une nouvelle exigence de regrouper nos Laptop par masse puisque les étagères ont des restrictions de poids.

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

Cependant, et si on voulait filtrer par autre chose que Mass ?

Une option consiste à procéder comme suit :

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

Génial! Nous sommes désormais en mesure de filtrer en fonction de n'importe quelle contrainte d'ordinateur portable. Cependant, nous ne pouvons filtrer que Laptop .

Que diriez-vous de pouvoir filtrer tout ce qui se trouve dans une boîte et qui a une masse ? Peut-être que cet entrepôt d'ordinateurs portables sera également utilisé pour des serveurs ayant une clientèle différente :

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

Nous sommes désormais en mesure de filtrer un tableau non seulement en fonction de n'importe quelle propriété d'une struct spécifique, mais également de filtrer n'importe quelle structure possédant cette propriété !

Conseils pour une bonne conception d'API

Cette section est tirée de la conférence WWDC 2019 : Modern Swift API Design .

Maintenant que vous comprenez comment se comportent les protocoles, il est préférable de déterminer quand utiliser les protocoles. Aussi puissants que puissent être les protocoles, ce n’est pas toujours la meilleure idée de se lancer et de commencer immédiatement par les protocoles.

  • Commencez par des cas d’utilisation concrets :
    • Explorez d'abord le cas d'utilisation avec des types concrets et comprenez quel code vous souhaitez partager et trouver est répété. Ensuite, intégrez ce code partagé aux génériques. Cela pourrait signifier créer de nouveaux protocoles. Découvrez un besoin de code générique.
  • Envisagez de composer de nouveaux protocoles à partir de protocoles existants définis dans la bibliothèque standard. Reportez-vous à la documentation Apple suivante pour un bon exemple.
  • Au lieu d’un protocole générique, envisagez plutôt de définir un type générique.

Exemple : définition d'un type de vecteur personnalisé

Disons que nous voulons définir un protocole GeometricVector sur les nombres à virgule flottante à utiliser dans une application de géométrie que nous créons et qui définit 3 opérations vectorielles importantes :

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

Disons que nous voulons stocker les dimensions du vecteur, pour lesquelles le protocole SIMD peut nous aider, nous allons donc faire en sorte que notre nouveau type affine le protocole SIMD . Les vecteurs SIMD peuvent être considérés comme des vecteurs de taille fixe qui sont très rapides lorsque vous les utilisez pour effectuer des opérations vectorielles :

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

Maintenant, définissons les implémentations par défaut des opérations ci-dessus :

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

Et puis nous devons ajouter une conformité à chacun des types auxquels nous souhaitons ajouter ces capacités :

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

Ce processus en trois étapes consistant à définir le protocole, à lui donner une implémentation par défaut, puis à ajouter une conformité à plusieurs types est assez répétitif.

Le protocole était-il nécessaire ?

Le fait qu’aucun des types SIMD n’ait d’implémentation unique est un signe d’avertissement. Donc dans ce cas, le protocole ne nous donne vraiment rien.

Le définir dans une extension de SIMD

Si on écrit les 3 opérateurs dans une extension du protocole SIMD , cela peut résoudre le problème de manière plus succincte :

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

En utilisant moins de lignes de code, nous avons ajouté toutes les implémentations par défaut à tous les types de SIMD .

Parfois, vous pourriez être tenté de créer cette hiérarchie de types, mais n'oubliez pas que ce n'est pas toujours nécessaire. Cela signifie également que la taille binaire de votre programme compilé sera plus petite et que votre code sera plus rapide à compiler.

Cependant, cette approche d’extension est idéale lorsque vous souhaitez ajouter un certain nombre de méthodes. Cependant, cela pose un problème d’évolutivité lorsque vous concevez une API plus grande.

Est un? A un?

Plus tôt, nous avons dit que GeometricVector affinerait SIMD . Mais s’agit-il d’une relation ? Le problème est que SIMD définit des opérations qui permettent d'ajouter un scalaire 1 à un vecteur, mais cela n'a pas de sens de définir une telle opération dans le contexte de la géométrie.

Donc, peut-être qu'une relation has-a serait meilleure en enveloppant SIMD dans un nouveau type générique capable de gérer n'importe quel nombre à virgule flottante :

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

On peut alors être prudent et définir uniquement les opérations qui n'ont de sens que dans le contexte de la géométrie :

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

Et nous pouvons toujours utiliser des extensions génériques pour obtenir les 3 opérateurs précédents que nous voulions implémenter et qui ressemblent presque exactement à avant :

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

Dans l'ensemble, nous avons pu affiner le comportement de nos trois opérations en un type en utilisant simplement une structure. Avec les protocoles, nous avons été confrontés au problème de l'écriture de conformités répétitives pour tous les vecteurs SIMD , et nous n'avons pas non plus pu empêcher certains opérateurs comme Scalar + Vector d'être disponibles (ce que nous ne voulions pas dans ce cas). Par conséquent, n’oubliez pas que les protocoles ne constituent pas une solution universelle. Mais parfois, des solutions plus traditionnelles peuvent s’avérer plus efficaces.

Plus de ressources de programmation orientées protocole

Voici des ressources supplémentaires sur les sujets abordés :

  • WWDC 2015 : Programmation orientée protocole dans Swift : cela a été présenté en utilisant Swift 2, donc beaucoup de choses ont changé depuis (par exemple le nom des protocoles utilisés dans la présentation) mais cela reste une bonne ressource pour la théorie et les utilisations qui la sous-tendent. .
  • Présentation de la programmation orientée protocole dans Swift 3 : cela a été écrit dans Swift 3, donc une partie du code devra peut-être être modifiée pour qu'il soit compilé avec succès, mais c'est une autre excellente ressource.
  • WWDC 2019 : Modern Swift API Design : passe en revue les différences entre les types valeur et référence, un cas d'utilisation dans lequel les protocoles peuvent s'avérer être le pire choix dans la conception d'API (identique à la section "Conseils pour une bonne conception d'API" ci-dessus), clé recherche de membres de chemin et wrappers de propriétés.
  • Génériques : la propre documentation de Swift pour Swift 5 sur les génériques.