Ta strona została przetłumaczona przez Cloud Translation API.
Switch to English

Programowanie zorientowane na protokoły i typy ogólne

Zobacz na TensorFlow.org Wyświetl źródło na GitHub

W tym samouczku omówione zostanie programowanie zorientowane na protokoły i różne przykłady ich wykorzystania z typami ogólnymi w codziennych przykładach.

Protokoły

Dziedziczenie to potężny 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 już znasz jeden z tych sposobów, z innych języków: dziedziczenie klas. Jednak Swift ma inny sposób: protokoły.

W tym samouczku zbadamy protokoły - alternatywę dla podklas, która pozwala osiągnąć podobne cele poprzez różne kompromisy. W Swift protokoły zawierają wielu abstrakcyjnych członków. Klasy, struktury i wyliczenia mogą być zgodne z wieloma protokołami, a związek zgodności można ustanowić z mocą wsteczną. Wszystko to umożliwia tworzenie projektów, których nie da się łatwo wyrazić w Swift za pomocą podklas. Omówimy idiomy, które wspierają 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, które są 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

Spójrzmy teraz na struktury. 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 zobaczmy, jak są 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żyjmy 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 dziedziczenia wielokrotnego) być może utworzono klasy abstrakcyjne Electric i Gas a następnie wykorzystano dziedziczenie klas, aby oba dziedziczenie po Car , a następnie określony model samochodu był klasą podstawową. Jednak tutaj oba są całkowicie oddzielnymi protokołami z zerowym sprzężeniem! Dzięki temu cały system jest bardziej elastyczny w sposobie projektowania.

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)

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

Teraz zdefiniujmy samochód na gaz:

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

Na przykładach widać, że mamy pewną nadmiarowość. Za każdym razem, gdy ładujemy samochód elektryczny, musimy ustawić procentowy poziom naładowania akumulatora na 100. Ponieważ wszystkie samochody elektryczne mają maksymalną pojemność 100%, ale samochody na gaz różnią się w zależności od pojemności zbiornika na gaz, możemy domyślnie ustawić poziom 100 dla samochodów elektrycznych .

W tym miejscu przydatne mogą być rozszerzenia w Swift:

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

Tak więc teraz każdy nowy samochód elektryczny, który stworzymy, ustawi akumulator na 100, gdy go naładujemy. Dlatego właśnie byliśmy w stanie ozdobić klasy, struktury i wyliczenia unikalnym i domyślnym zachowaniem.

Protokół Komiks

Dzięki Rayowi Wenderlichowi za komiks!

Jednak jedna rzecz, na którą należy uważać, jest następująca. W naszej pierwszej implementacji definiujemy foo() jako domyślną implementację w A , ale nie wymagamy tego w protokole. Więc kiedy wywołujemy a.foo() , a.foo() " A default ".

protocol Default {}

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

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

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

Jeśli jednak sprawimy, że foo() wymagane na 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łką statyczną w pierwszym przykładzie a wysyłką statyczną w drugim w protokołach w Swift. Aby uzyskać więcej informacji, zapoznaj się z tym postem Medium .

Zastępowanie domyślnego zachowania

Jeśli jednak chcemy, nadal możemy nadpisać domyślne zachowanie. Należy pamiętać, że nie obsługuje dynamicznego wysyłania .

Powiedzmy, że mamy starszą wersję samochodu elektrycznego, więc stan baterii został obniżony 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
    }
}

Standardowa biblioteka używa protokołów

Teraz, kiedy mamy już pojęcie, jak działają protokoły w Swift, przejdźmy do kilku typowych przykładów użycia standardowych protokołów bibliotecznych.

Rozszerz standardową bibliotekę

Zobaczmy, jak możemy dodać dodatkowe funkcje do typów, które już istnieją w Swift. Ponieważ typy w języku Swift nie są wbudowane, ale są częścią biblioteki standardowej jako struktury, jest to łatwe do zrobienia.

Spróbujmy przeprowadzić wyszukiwanie binarne w 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 mogą być przechodzone wiele razy, w sposób bezpieczny i dostępne przez indeksowany indeks dolny”. Ponieważ tablice mogą być indeksowane przy użyciu notacji z nawiasami kwadratowymi, jest to protokół, który chcemy rozszerzyć.

Podobnie chcemy dodać tę funkcję narzędziową tylko do tablic, których elementy można porównać. To jest powód, dla którego mamy where Element: Comparable .

Klauzula where jest częścią systemu typów Swift, który wkrótce omówimy, ale w skrócie pozwala nam dodać dodatkowe wymagania do pisanego przez nas rozszerzenia, takie jak wymaganie, aby typ implementował protokół, aby wymagać dwóch typów jako to samo lub wymagać od klasy posiadania określonej nadklasy.

Element jest skojarzonym typem elementów w typie zgodnym z Collection . Element jest zdefiniowany w Sequence protokołu, ale ponieważ Collection dziedziczy z Sequence , to dziedziczy Element związany typ.

Comparable to protokół, który definiuje „typ, który można porównać przy użyciu operatorów relacji < , <= , >= i > ”. . Ponieważ wykonujemy wyszukiwanie binarne w posortowanej Collection , to oczywiście musi być prawda, w przeciwnym razie nie wiemy, czy powtarzać / iterować w lewo czy w prawo w wyszukiwaniu binarnym.

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

Ogólne + protokoły = 💥

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

Po pierwsze, spójrz na inny samouczek, A Swift Tour , który pokrótce omawia typy ogólne na końcu książki Colab.

Zakładając, że masz ogólne pojęcie o rodzajach generycznych, przyjrzyjmy się szybko niektórym zaawansowanym zastosowaniom.

Gdy jeden typ ma wiele wymagań, takich jak 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 u góry. To dodaje nazwany alias istniejącego typu do twojego programu. Po zadeklarowaniu aliasu typu nazwa aliasu może być używana zamiast istniejącego typu w całym programie. Aliasy typów nie tworzą nowych typów; po prostu pozwalają nazwie odwołać 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 musi spełniać następujące wymagania dotyczące każdego sprzedawanego przez nas laptopa, aby określić, jak zorganizujemy 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))"
    }
}

Mamy jednak nowy wymóg grupowania 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)]

A co by było, gdybyśmy chcieli filtrować przez coś 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)]

Niesamowite! Teraz możemy filtrować w oparciu o dowolne ograniczenia dotyczące laptopa. Jednak możemy filtrować tylko Laptop .

A co z możliwością filtrowania wszystkiego, co jest w pudełku i ma masę? Może ta hurtownia laptopów będzie również wykorzystywana dla serwerów, 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)]

Teraz byliśmy w stanie przefiltrować tablicę nie tylko według dowolnej właściwości określonej struct , ale także możemy filtrować każdą strukturę, która ma tę właściwość!

Wskazówki dotyczące dobrego projektowania interfejsu API

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

Teraz, gdy rozumiesz, jak zachowują się protokoły, najlepiej jest przejrzeć, kiedy należy ich używać. Choć protokoły mogą być potężne, nie zawsze najlepszym pomysłem jest zanurzenie się w nich i natychmiastowe rozpoczęcie pracy z protokołami.

  • Zacznij od konkretnych przypadków użycia:
    • Najpierw zbadaj przypadek użycia z konkretnymi typami i dowiedz się, jaki to kod, który chcesz udostępnić, a który jest powtarzany. Następnie uwzględnij udostępniony kod za pomocą typów generycznych. Może to oznaczać tworzenie nowych protokołów. Odkryj potrzebę kodu ogólnego.
  • Rozważ utworzenie nowych protokołów z istniejących protokołów zdefiniowanych w bibliotece standardowej. Zapoznaj się z następującą dokumentacją firmy Apple, aby uzyskać dobry przykład.
  • Zamiast protokołu ogólnego rozważ zdefiniowanie typu ogólnego.

Przykład: definiowanie własnego typu wektora

Załóżmy, że chcemy zdefiniować protokół GeometricVector na liczbach zmiennoprzecinkowych do użycia w niektórych tworzonych przez nas aplikacjach do geometrii, który definiuje 3 ważne operacje na wektorach:

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 SIMD 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 na wektorach:

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 z typów, do których 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?

Fakt, że żaden z typów SIMD ma unikalnych implementacji, jest znakiem ostrzegawczym. Więc w tym przypadku protokół tak naprawdę nic nam nie daje.

Definiowanie go w rozszerzeniu SIMD

Jeśli napiszemy 3 operatory w rozszerzeniu protokołu SIMD , może to rozwiązać problem bardziej zwięźle:

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 pokusić się o utworzenie 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 kod będzie szybszy w kompilacji.

Jednak to podejście do rozszerzenia jest świetne, gdy masz kilka metod, które chcesz dodać. Jednak podczas projektowania większego interfejsu API występuje problem ze skalowalnością.

Jest? Ma?

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

Tak więc, być może relacja typu has-a byłaby lepsza, gdybyśmy opakowali 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 być ostrożni i zdefiniować tylko 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 uzyskać 3 poprzednie operatory, które chcieliśmy zaimplementować, które wyglądają 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, byliśmy w stanie zawęzić zachowanie naszych trzech operacji do typu, po prostu używając struktury struct. W przypadku protokołów napotkaliśmy problem pisania powtarzalnych zgodności dla wszystkich wektorów SIMD , a także nie byliśmy w stanie zapobiec dostępności niektórych operatorów, takich jak Scalar + Vector (czego w tym przypadku nie chcieliśmy). W związku z tym pamiętaj, że protokoły nie są rozwiązaniem uniwersalnym. Ale czasami bardziej tradycyjne rozwiązania mogą okazać się potężniejsze.

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 przedstawione za pomocą Swift 2, więc od tego czasu wiele się zmieniło (np. Nazwa protokołów, których używali w prezentacji), ale nadal jest to dobre źródło teorii i zastosowań za nią .
  • Wprowadzenie programowania zorientowanego na protokoły w języku Swift 3 : zostało to napisane w języku Swift 3, więc część kodu może wymagać modyfikacji w celu pomyślnej kompilacji, ale jest to kolejny świetny zasób.
  • WWDC 2019: Modern Swift API Design : omawia różnice między typami wartości i referencji, przypadek użycia, w którym protokoły mogą okazać się gorszym wyborem w projektowaniu API (tak samo jak w sekcji „Wskazówki dotyczące dobrego projektowania interfejsu API” powyżej), klucz wyszukiwanie elementu członkowskiego ścieżki i opakowania właściwości.
  • Generics : własna dokumentacja Swift dla Swift 5 dotyczy wszystkich typów generycznych.