Programowanie zorientowane na protokół & leki generyczne

Zobacz na TensorFlow.org Zobacz źródło w GitHub

W tym samouczku omówimy programowanie zorientowane na protokoły i różne przykłady ich użycia z rodzajami generycznymi w codziennych przykładach.

Protokoły

Dziedziczenie to skuteczny sposób organizowania kodu w językach programowania, który umożliwia współdzielenie kodu między wieloma komponentami programu.

W języku Swift istnieją różne sposoby wyrażania dziedziczenia. Być może znasz już jeden z tych sposobów z innych języków: dziedziczenie klas. Jednak Swift ma inny sposób: protokoły.

W tym samouczku omówimy protokoły — alternatywę dla podklas, która pozwala osiągnąć podobne cele za pomocą różnych kompromisów. W Swift protokoły zawierają wiele abstrakcyjnych elementów. Klasy, struktury i wyliczenia mogą być zgodne z wieloma protokołami, a relację zgodności można ustalić z mocą wsteczną. Wszystko to umożliwia tworzenie projektów, których nie można łatwo wyrazić w języku Swift za pomocą podklas. Omówimy idiomy wspierające użycie protokołów (rozszerzenia i ograniczenia protokołów), a także ograniczenia protokołów.

Typy wartości Swift 💖!

Oprócz klas, które mają semantykę referencyjną, Swift obsługuje wyliczenia i struktury przekazywane przez wartość. Wyliczenia i struktury obsługują wiele funkcji udostępnianych przez klasy. Spójrzmy!

Najpierw przyjrzyjmy się, jak wyliczenia są podobne do klas:

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

Przyjrzyjmy się teraz strukturom. Zauważ, że nie możemy dziedziczyć struktur, ale zamiast tego możemy używać protokołów:

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

Na koniec przyjrzyjmy się, w jaki sposób są one przekazywane przez typy wartości w przeciwieństwie do klas:

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

Używajmy protokołów

Zacznijmy od stworzenia protokołów dla różnych samochodów:

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

W świecie zorientowanym obiektowo (bez wielokrotnego dziedziczenia) mogłeś utworzyć abstrakcyjne klasy Electric i Gas , a następnie użyć dziedziczenia klas, aby obie dziedziczyły z Car , a następnie konkretny model samochodu był klasą bazową. Jednak tutaj oba są całkowicie odrębnymi protokołami z zerowym sprzężeniem! Dzięki temu cały system jest bardziej elastyczny w projektowaniu.

Zdefiniujmy Teslę:

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)

Określa nową strukturę TeslaModelS , która jest zgodna z protokołami Car i Electric .

Zdefiniujmy teraz samochód zasilany gazem:

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)

Rozszerz protokoły o domyślne zachowania

Z przykładów można zauważyć, że mamy pewną redundancję. Za każdym razem, gdy ładujemy samochód elektryczny, musimy ustawić poziom procentowy akumulatora na 100. Ponieważ wszystkie samochody elektryczne mają maksymalną pojemność 100%, ale samochody zasilane gazem różnią się w zależności od pojemności zbiornika na benzynę, możemy ustawić domyślny poziom na 100 dla samochodów elektrycznych .

Tutaj mogą przydać się rozszerzenia w Swift:

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

Zatem teraz każdy nowy samochód elektryczny, który stworzymy, po naładowaniu naładuje akumulator na 100. W ten sposób właśnie mogliśmy ozdobić klasy, struktury i wyliczenia unikalnym i domyślnym zachowaniem.

Protokół Komiks

Dziękuję Rayowi Wenderlichowi za komiks!

Jednak należy zwrócić uwagę na następującą rzecz. W naszej pierwszej implementacji definiujemy foo() jako domyślną implementację w A , ale nie wymagamy tego w protokole. Kiedy więc wywołamy a.foo() , otrzymamy wydruk „ 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

Jeśli jednak sprawimy, że foo() będzie wymagane w A , otrzymamy „ 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

Dzieje się tak z powodu różnicy między wysyłaniem statycznym w pierwszym przykładzie i wysyłaniem statycznym w drugim przypadku protokołów w Swift. Więcej informacji znajdziesz w tym poście na Medium .

Zastępowanie zachowania domyślnego

Jeśli jednak chcemy, nadal możemy zastąpić zachowanie domyślne. Należy pamiętać, że nie obsługuje to dynamicznej wysyłki .

Załóżmy, że mamy starszą wersję samochodu elektrycznego, więc kondycja akumulatora spadła do 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
    }
}

Standardowe zastosowania protokołów w bibliotece

Teraz, gdy mamy już pojęcie, jak działają protokoły w Swift, przejrzyjmy kilka typowych przykładów użycia standardowych protokołów bibliotecznych.

Rozszerz bibliotekę standardową

Zobaczmy, jak możemy dodać dodatkową funkcjonalność do typów, które już istnieją w Swift. Ponieważ typy w Swift nie są wbudowane, ale stanowią część standardowej biblioteki jako struktury, jest to łatwe do wykonania.

Spróbujmy przeprowadzić wyszukiwanie binarne tablicy elementów, jednocześnie upewniając się, że tablica jest posortowana:

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

Robimy to poprzez rozszerzenie protokołu Collection , który definiuje „sekwencję, której elementy można przechodzić wielokrotnie, w sposób nieniszczący, i do której można uzyskać dostęp za pomocą indeksowanego indeksu dolnego”. Ponieważ tablice można indeksować przy użyciu notacji nawiasów kwadratowych, jest to protokół, który chcemy rozszerzyć.

Podobnie tę funkcję użyteczności chcemy dodać tylko do tablic, których elementy można porównywać. To jest powód, dla którego mamy where Element: Comparable .

Klauzula where jest częścią systemu typów Swifta, który omówimy wkrótce, ale w skrócie pozwala nam dodać dodatkowe wymagania do rozszerzenia, które piszemy, takie jak wymaganie od typu do implementacji protokołu, wymaganie dwóch typów jako to samo lub wymagać, aby klasa miała określoną nadklasę.

Element jest powiązanym typem elementów w typie zgodnym z Collection . Element jest zdefiniowany w protokole Sequence , ale ponieważ Collection dziedziczy z Sequence , dziedziczy typ skojarzony z Element .

Comparable to protokół, który definiuje „typ, który można porównać za pomocą operatorów relacyjnych < , <= , >= i > ”. . Ponieważ przeprowadzamy wyszukiwanie binarne w posortowanej Collection , musi to oczywiście być prawda, w przeciwnym razie nie wiemy, czy w wyszukiwaniu binarnym wykonać powtórzenie/iterację w lewo czy w prawo.

A tak na marginesie na temat implementacji, więcej informacji na temat użytej funkcji index(_:offsetBy:) można znaleźć w poniższej dokumentacji .

Genetyki + protokoły = 💥

Generics i protokoły mogą być potężnym narzędziem, jeśli są używane prawidłowo, aby uniknąć duplikowania kodu.

Najpierw przejrzyj inny samouczek, A Swift Tour , który krótko omawia leki generyczne na końcu książki Colab.

Zakładając, że masz ogólne pojęcie o lekach generycznych, rzućmy okiem na niektóre zaawansowane zastosowania.

Gdy pojedynczy typ ma wiele wymagań, na przykład typ zgodny z kilkoma protokołami, masz do dyspozycji kilka opcji:

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

Zwróć uwagę na użycie typealias na górze. Spowoduje to dodanie nazwanego aliasu istniejącego typu do twojego programu. Po zadeklarowaniu aliasu typu, w całym programie można użyć tej nazwy zamiast istniejącego typu. Aliasy typów nie tworzą nowych typów; po prostu pozwalają nazwie odnosić się do istniejącego typu.

Zobaczmy teraz, jak możemy razem używać protokołów i typów generycznych.

Wyobraźmy sobie, że jesteśmy sklepem komputerowym, który ma następujące wymagania dotyczące każdego sprzedawanego przez nas laptopa w celu ustalenia, w jaki sposób organizujemy je na zapleczu sklepu:

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

Jednakże mamy nowy wymóg grupowania naszych Laptop według masy, ponieważ półki mają ograniczenia wagowe.

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

Co jednak, gdybyśmy chcieli filtrować według czegoś innego niż Mass ?

Jedną z opcji jest wykonanie następujących czynności:

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

Wspaniały! Teraz możemy filtrować w oparciu o dowolne ograniczenia laptopa. Możemy jednak filtrować tylko Laptop .

A co z możliwością filtrowania wszystkiego, co jest w pudełku i ma masę? Być może ten magazyn laptopów będzie służył także serwerom, które mają inną bazę klientów:

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

Udało nam się teraz filtrować tablicę nie tylko według dowolnej właściwości określonej struct , ale także możemy filtrować dowolną strukturę, która ma tę właściwość!

Wskazówki dotyczące dobrego projektowania API

Ta sekcja została zaczerpnięta z wykładu WWDC 2019: Modern Swift API Design .

Teraz, gdy już rozumiesz, jak zachowują się protokoły, najlepiej jest omówić, kiedy należy ich używać. Niezależnie od tego, jak potężne mogą być protokoły, nie zawsze jest najlepszym pomysłem zagłębienie się w szczegóły i natychmiastowe rozpoczęcie ich stosowania.

  • Zacznij od konkretnych przypadków użycia:
    • Najpierw przeanalizuj przypadek użycia konkretnych typów i dowiedz się, jaki kod chcesz udostępnić i który stwierdzisz, że się powtarza. Następnie uwzględnij ten wspólny kod z kodami generycznymi. Może to oznaczać utworzenie nowych protokołów. Odkryj potrzebę ogólnego kodu.
  • Rozważ utworzenie nowych protokołów na podstawie istniejących protokołów zdefiniowanych w bibliotece standardowej. Dobry przykład można znaleźć w poniższej dokumentacji Apple .
  • Zamiast protokołu ogólnego rozważ zdefiniowanie typu ogólnego.

Przykład: definiowanie niestandardowego typu wektora

Załóżmy, że chcemy zdefiniować protokół GeometricVector na liczbach zmiennoprzecinkowych do użycia w tworzonej przez nas aplikacji geometrycznej, która definiuje 3 ważne operacje wektorowe:

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

Powiedzmy, że chcemy przechowywać wymiary wektora, w czym może nam pomóc protokół SIMD , więc sprawimy, że nasz nowy typ udoskonali protokół SIMD . Wektory SIMD można traktować jako wektory o stałym rozmiarze, które są bardzo szybkie, gdy używa się ich do wykonywania operacji wektorowych:

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

Teraz zdefiniujmy domyślne implementacje powyższych operacji:

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

Następnie musimy dodać zgodność do każdego typu, do którego chcemy dodać te umiejętności:

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

Ten trzyetapowy proces definiowania protokołu, nadawania mu domyślnej implementacji, a następnie dodawania zgodności do wielu typów, jest dość powtarzalny.

Czy protokół był konieczny?

Sygnałem ostrzegawczym jest fakt, że żaden z typów SIMD nie ma unikalnych implementacji. Zatem w tym przypadku protokół tak naprawdę nic nam nie daje.

Zdefiniowanie go w rozszerzeniu SIMD

Jeśli napiszemy 3 operatory w rozszerzeniu protokołu SIMD , może to rozwiązać problem w bardziej zwięzły sposób:

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

Używając mniejszej liczby linii kodu, dodaliśmy wszystkie domyślne implementacje do wszystkich typów SIMD .

Czasami możesz ulec pokusie utworzenia takiej hierarchii typów, ale pamiętaj, że nie zawsze jest to konieczne. Oznacza to również, że rozmiar binarny skompilowanego programu będzie mniejszy, a kompilacja kodu będzie szybsza.

Jednak to podejście rozszerzające świetnie się sprawdza, gdy masz kilka metod, które chcesz dodać. Jednak podczas projektowania większego interfejsu API pojawia się problem ze skalowalnością.

Jest? Ma?

Wcześniej powiedzieliśmy, że GeometricVector udoskonali SIMD . Ale czy jest to związek? Problem w tym, że SIMD definiuje operacje, które pozwalają dodać do wektora liczbę skalarną, ale definiowanie takiej operacji w kontekście geometrii nie ma sensu.

Może więc relacja ma-a byłaby lepsza, zawijając SIMD w nowy typ ogólny, który może obsłużyć dowolną liczbę zmiennoprzecinkową:

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

Możemy wtedy zachować ostrożność i definiować tylko te operacje, które mają sens tylko w kontekście geometrii:

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

Nadal możemy używać rozszerzeń ogólnych, aby 3 poprzednie operatory, które chcieliśmy zaimplementować, wyglądały prawie tak samo jak poprzednio:

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

Ogólnie rzecz biorąc, udało nam się udoskonalić zachowanie naszych trzech operacji do typu, po prostu używając struktury. W przypadku protokołów stanęliśmy przed problemem zapisywania powtarzalnych zgodności dla wszystkich wektorów SIMD , a także nie mogliśmy uniemożliwić dostępności niektórych operatorów, takich jak Scalar + Vector (czego w tym przypadku nie chcieliśmy). W związku z tym należy pamiętać, że protokoły nie są rozwiązaniem uniwersalnym. Czasami jednak bardziej tradycyjne rozwiązania mogą okazać się skuteczniejsze.

Więcej zasobów programistycznych zorientowanych na protokoły

Oto dodatkowe zasoby dotyczące omawianych tematów:

  • WWDC 2015: Programowanie zorientowane na protokoły w Swift : zostało to zaprezentowane przy użyciu Swift 2, więc od tego czasu wiele się zmieniło (np. nazwy protokołów użytych w prezentacji), ale nadal jest to dobre źródło teorii i zastosowań za nią stojących .
  • Przedstawiamy programowanie zorientowane na protokół w Swift 3 : zostało to napisane w Swift 3, więc część kodu może wymagać modyfikacji, aby mogła się pomyślnie skompilować, ale jest to kolejny świetny zasób.
  • WWDC 2019: Modern Swift API Design : omawia różnice między typami wartościowymi i referencyjnymi, przypadek użycia, w którym protokoły mogą okazać się gorszym wyborem przy projektowaniu API (tak samo jak sekcja „Wskazówki dotyczące dobrego projektowania API” powyżej), klucz wyszukiwanie elementów ścieżki i opakowania właściwości.
  • Generics : Własna dokumentacja Swifta dla Swift 5 dotyczy wyłącznie leków generycznych.