Eine Einführung in die protokollorientierte Programmierung in Swift

Veröffentlicht: 2022-03-11

Das Protokoll ist eine sehr mächtige Funktion der Programmiersprache Swift.

Protokolle werden verwendet, um einen „Entwurf von Methoden, Eigenschaften und anderen Anforderungen zu definieren, die für eine bestimmte Aufgabe oder Funktionalität geeignet sind“.

Swift prüft zur Kompilierzeit auf Probleme mit der Protokollkonformität, sodass Entwickler einige schwerwiegende Fehler im Code entdecken können, noch bevor sie das Programm ausführen. Protokolle ermöglichen es Entwicklern, flexiblen und erweiterbaren Code in Swift zu schreiben, ohne die Ausdruckskraft der Sprache beeinträchtigen zu müssen.

Swift geht noch einen Schritt weiter mit der Bequemlichkeit der Verwendung von Protokollen, indem es Problemumgehungen für einige der häufigsten Macken und Einschränkungen von Schnittstellen bietet, die viele andere Programmiersprachen plagen.

Eine Einführung in die protokollorientierte Programmierung in Swift

Schreiben Sie flexiblen und erweiterbaren Code in Swift mit protokollorientierter Programmierung.
Twittern

In früheren Versionen von Swift war es nur möglich, Klassen, Strukturen und Aufzählungen zu erweitern, wie es in vielen modernen Programmiersprachen der Fall ist. Seit Version 2 von Swift ist es jedoch auch möglich, Protokolle zu erweitern.

Dieser Artikel untersucht, wie Protokolle in Swift verwendet werden können, um wiederverwendbaren und wartbaren Code zu schreiben, und wie Änderungen an einer großen protokollorientierten Codebasis durch die Verwendung von Protokollerweiterungen an einem einzigen Ort konsolidiert werden können.

Protokolle

Was ist ein Protokoll?

In seiner einfachsten Form ist ein Protokoll eine Schnittstelle, die einige Eigenschaften und Methoden beschreibt. Jeder Typ, der einem Protokoll entspricht, sollte die im Protokoll definierten spezifischen Eigenschaften mit geeigneten Werten füllen und die erforderlichen Methoden implementieren. Zum Beispiel:

 protocol Queue { var count: Int { get } mutating func push(_ element: Int) mutating func pop() -> Int }

Das Queue-Protokoll beschreibt eine Warteschlange, die ganzzahlige Elemente enthält. Die Syntax ist recht einfach.

Wenn wir innerhalb des Protokollblocks eine Eigenschaft beschreiben, müssen wir angeben, ob die Eigenschaft nur gettable { get } oder sowohl gettable als auch settable { get set } . In unserem Fall ist nur die Variable Count (vom Typ Int ) abrufbar.

Wenn ein Protokoll erfordert, dass eine Eigenschaft abgerufen und eingestellt werden kann, kann diese Anforderung nicht durch eine konstant gespeicherte Eigenschaft oder eine berechnete Eigenschaft mit Schreibschutz erfüllt werden.

Wenn das Protokoll nur verlangt, dass eine Eigenschaft abgerufen werden kann, kann die Anforderung durch jede Art von Eigenschaft erfüllt werden, und es gilt, dass die Eigenschaft auch einstellbar sein muss, wenn dies für Ihren eigenen Code nützlich ist.

Für Funktionen, die in einem Protokoll definiert sind, ist es wichtig anzugeben, ob die Funktion den Inhalt mit dem Schlüsselwort mutating ändern wird. Ansonsten genügt als Definition die Signatur einer Funktion.

Um einem Protokoll zu entsprechen, muss ein Typ alle Instanzeigenschaften bereitstellen und alle im Protokoll beschriebenen Methoden implementieren. Unten sehen Sie zum Beispiel einen Struct- Container , der unserem Queue entspricht. Die Struktur speichert im Wesentlichen gepushte Int s in einem privaten Array items .

 struct Container: Queue { private var items: [Int] = [] var count: Int { return items.count } mutating func push(_ element: Int) { items.append(element) } mutating func pop() -> Int { return items.removeFirst() } }

Unser aktuelles Warteschlangenprotokoll hat jedoch einen großen Nachteil.

Nur Container, die mit Int s umgehen, können diesem Protokoll entsprechen.

Wir können diese Einschränkung aufheben, indem wir die Funktion „zugeordnete Typen“ verwenden. Assoziierte Typen funktionieren wie Generika. Um dies zu demonstrieren, ändern wir das Warteschlangenprotokoll, um zugehörige Typen zu verwenden:

 protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }

Jetzt ermöglicht das Warteschlangenprotokoll die Speicherung aller Arten von Elementen.

Bei der Implementierung der Container bestimmt der Compiler den zugehörigen Typ aus dem Kontext (dh Methodenrückgabetyp und Parametertypen). Dieser Ansatz ermöglicht es uns, eine Container mit einem generischen Artikeltyp zu erstellen. Zum Beispiel:

 class Container<Item>: Queue { private var items: [Item] = [] var count: Int { return items.count } func push(_ element: Item) { items.append(element) } func pop() -> Item { return items.removeFirst() } }

Die Verwendung von Protokollen vereinfacht das Schreiben von Code in vielen Fällen.

Beispielsweise kann jedes Objekt, das einen Fehler darstellt, dem Error -Protokoll (oder LocalizedError , falls wir lokalisierte Beschreibungen bereitstellen möchten) entsprechen.

Dieselbe Fehlerbehandlungslogik kann dann im gesamten Code auf jedes dieser Fehlerobjekte angewendet werden. Folglich müssen Sie kein bestimmtes Objekt (wie NSError in Objective-C) verwenden, um Fehler darzustellen, Sie können jeden Typ verwenden, der den Protokollen Error oder LocalizedError entspricht.

Sie können sogar den String-Typ erweitern, damit er mit dem LocalizedError -Protokoll kompatibel ist, und Strings als Fehler ausgeben.

 extension String: LocalizedError { public var errorDescription: String? { Return NSLocalizedString(self, comment:””) } } throw “Unfortunately something went wrong” func handle(error: Error) { print(error.localizedDescription) }

Protokollerweiterungen

Protokollerweiterungen bauen auf der Großartigkeit von Protokollen auf. Sie ermöglichen uns:

  1. Stellen Sie eine Standardimplementierung von Protokollmethoden und Standardwerten von Protokolleigenschaften bereit, wodurch sie „optional“ werden. Typen, die einem Protokoll entsprechen, können ihre eigenen Implementierungen bereitstellen oder die Standardimplementierungen verwenden.

  2. Fügen Sie die Implementierung zusätzlicher Methoden hinzu, die nicht im Protokoll beschrieben sind, und „dekorieren“ Sie alle Typen, die dem Protokoll entsprechen, mit diesen zusätzlichen Methoden. Mit dieser Funktion können wir mehreren Typen, die bereits dem Protokoll entsprechen, bestimmte Methoden hinzufügen, ohne jeden Typ einzeln ändern zu müssen.

Implementierung der Standardmethode

Lassen Sie uns ein weiteres Protokoll erstellen:

 protocol ErrorHandler { func handle(error: Error) }

Dieses Protokoll beschreibt Objekte, die für die Behandlung von Fehlern zuständig sind, die in einer Anwendung auftreten. Zum Beispiel:

 struct Handler: ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }

Hier drucken wir nur die lokalisierte Beschreibung des Fehlers. Mit der Protokollerweiterung können wir diese Implementierung zum Standard machen.

 extension ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }

Dadurch wird die handle Methode optional, indem eine Standardimplementierung bereitgestellt wird.

Die Möglichkeit, ein vorhandenes Protokoll mit Standardverhalten zu erweitern, ist sehr leistungsfähig, sodass Protokolle wachsen und erweitert werden können, ohne sich Gedanken über die Beeinträchtigung der Kompatibilität von vorhandenem Code machen zu müssen.

Bedingte Erweiterungen

Wir haben also eine Standardimplementierung der handle -Methode bereitgestellt, aber das Drucken auf der Konsole ist für den Endbenutzer nicht sehr hilfreich.

Wir würden ihnen wahrscheinlich lieber eine Art Warnungsansicht mit einer lokalisierten Beschreibung zeigen, wenn der Fehlerbehandler ein Ansichtscontroller ist. Dazu können wir das ErrorHandler Protokoll erweitern, aber die Erweiterung so einschränken, dass sie nur für bestimmte Fälle gilt (z. B. wenn der Typ ein View-Controller ist).

Swift ermöglicht es uns, solche Bedingungen zu Protokollerweiterungen hinzuzufügen, indem wir das Schlüsselwort where verwenden.

 extension ErrorHandler where Self: UIViewController { func handle(error: Error) { let alert = UIAlertController(title: nil, message: error.localizedDescription, preferredStyle: .alert) let action = UIAlertAction(title: "OK", style: .cancel, handler: nil) alert.addAction(action) present(alert, animated: true, completion: nil) } }

Self (mit Großbuchstaben „S“) im obigen Code-Snippet bezieht sich auf den Typ (Struktur, Klasse oder Aufzählung). Indem wir angeben, dass wir das Protokoll nur für Typen erweitern, die von UIViewController erben, können wir UIViewController spezifische Methoden verwenden (z. B. present(viewControllerToPresnt: animated: completion) ).

Jetzt haben alle View-Controller, die dem ErrorHandler Protokoll entsprechen, ihre eigene Standardimplementierung der handle -Methode, die eine Warnungsansicht mit einer lokalisierten Beschreibung anzeigt.

Mehrdeutige Methodenimplementierungen

Nehmen wir an, es gibt zwei Protokolle, die beide eine Methode mit derselben Signatur haben.

 protocol P1 { func method() //some other methods } protocol P2 { func method() //some other methods }

Beide Protokolle haben eine Erweiterung mit einer Standardimplementierung dieser Methode.

 extension P1 { func method() { print("Method P1") } } extension P2 { func method() { print("Method P2") } }

Nehmen wir nun an, dass es einen Typ gibt, der beiden Protokollen entspricht.

 struct S: P1, P2 { }

In diesem Fall haben wir ein Problem mit einer mehrdeutigen Methodenimplementierung. Der Typ gibt nicht eindeutig an, welche Implementierung der Methode verwendet werden soll. Als Ergebnis erhalten wir einen Kompilierungsfehler. Um dies zu beheben, müssen wir die Implementierung der Methode zum Typ hinzufügen.

 struct S: P1, P2 { func method() { print("Method S") } }

Viele objektorientierte Programmiersprachen sind mit Einschränkungen geplagt, die die Auflösung mehrdeutiger Erweiterungsdefinitionen betreffen. Swift handhabt dies recht elegant durch Protokollerweiterungen, indem es dem Programmierer ermöglicht, die Kontrolle zu übernehmen, wo der Compiler zu kurz kommt.

Neue Methoden hinzufügen

Schauen wir uns noch einmal das Queue -Protokoll an.

 protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }

Jeder Typ, der dem Queue entspricht, hat eine count -Instanzeigenschaft, die die Anzahl der gespeicherten Elemente definiert. Dies ermöglicht uns unter anderem, solche Typen zu vergleichen, um zu entscheiden, welcher größer ist. Wir können diese Methode durch eine Protokollerweiterung hinzufügen.

 extension Queue { func compare<Q>(queue: Q) -> ComparisonResult where Q: Queue { if count < queue.count { return .orderedDescending } if count > queue.count { return .orderedAscending } return .orderedSame } }

Diese Methode wird nicht im Queue selbst beschrieben, da sie nichts mit der Warteschlangenfunktionalität zu tun hat.

Es handelt sich also nicht um eine Standardimplementierung der Protokollmethode, sondern um eine neue Methodenimplementierung, die alle Typen „dekoriert“, die dem Queue -Protokoll entsprechen. Ohne Protokollerweiterungen müssten wir diese Methode jedem Typ separat hinzufügen.

Protokollerweiterungen vs. Basisklassen

Protokollerweiterungen scheinen der Verwendung einer Basisklasse recht ähnlich zu sein, die Verwendung von Protokollerweiterungen bietet jedoch mehrere Vorteile. Dazu gehören, sind aber nicht notwendigerweise beschränkt auf:

  1. Da Klassen, Strukturen und Aufzählungen mehr als einem Protokoll entsprechen können, können sie die Standardimplementierung mehrerer Protokolle annehmen. Dies ähnelt konzeptionell der Mehrfachvererbung in anderen Sprachen.

  2. Protokolle können von Klassen, Strukturen und Aufzählungen übernommen werden, während Basisklassen und Vererbung nur für Klassen verfügbar sind.

Erweiterungen der Swift-Standardbibliothek

Zusätzlich zur Erweiterung Ihrer eigenen Protokolle können Sie Protokolle aus der Swift-Standardbibliothek erweitern. Wenn wir beispielsweise die durchschnittliche Größe der Sammlung von Warteschlangen ermitteln möchten, können wir dies tun, indem wir das standardmäßige Collection erweitern.

Sequenzdatenstrukturen, die von der Standardbibliothek von Swift bereitgestellt werden, deren Elemente durchquert und auf die über indizierte Indizes zugegriffen werden kann, entsprechen normalerweise dem Collection -Protokoll. Durch die Protokollerweiterung ist es möglich, alle diese Standardbibliotheksdatenstrukturen zu erweitern oder einige davon selektiv zu erweitern.

Hinweis: Das Protokoll, das früher in Swift 2.x als CollectionType bekannt war, wurde in Swift 3 in Collection umbenannt.

 extension Collection where Iterator.Element: Queue { func avgSize() -> Int { let size = map { $0.count }.reduce(0, +) return Int(round(Double(size) / Double(count.toIntMax()))) } }

Jetzt können wir die durchschnittliche Größe einer beliebigen Sammlung von Warteschlangen ( Array , Set usw.) berechnen. Ohne Protokollerweiterungen hätten wir diese Methode jedem Sammlungstyp separat hinzufügen müssen.

In der Swift-Standardbibliothek werden Protokollerweiterungen verwendet, um beispielsweise Methoden wie map , filter , reduce usw. zu implementieren.

 extension Collection { public func map<T>(_ transform: (Self.Iterator.Element) throws -> T) rethrows -> [T] { } }

Protokollerweiterungen und Polymorphismus

Wie ich bereits sagte, ermöglichen Protokollerweiterungen das Hinzufügen von Standardimplementierungen einiger Methoden und das Hinzufügen neuer Methodenimplementierungen. Aber was ist der Unterschied zwischen diesen beiden Merkmalen? Kehren wir zum Error Handler zurück und finden es heraus.

 protocol ErrorHandler { func handle(error: Error) } extension ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } } struct Handler: ErrorHandler { func handle(error: Error) { fatalError("Unexpected error occurred") } } enum ApplicationError: Error { case other } let handler: Handler = Handler() handler.handle(error: ApplicationError.other)

Das Ergebnis ist ein schwerwiegender Fehler.

Entfernen Sie nun die Methodendeklaration handle(error: Error) aus dem Protokoll.

 protocol ErrorHandler { }

Das Ergebnis ist dasselbe: ein schwerwiegender Fehler.

Bedeutet dies, dass es keinen Unterschied zwischen dem Hinzufügen einer Standardimplementierung der Protokollmethode und dem Hinzufügen einer neuen Methodenimplementierung zum Protokoll gibt?

Nein! Es gibt einen Unterschied, und Sie können ihn sehen, indem Sie den Typ des Variablenhandlers von handler in Handler ErrorHandler .

 let handler: ErrorHandler = Handler()

Die Ausgabe an die Konsole lautet nun: Der Vorgang konnte nicht abgeschlossen werden. (ApplicationError-Fehler 0.)

Aber wenn wir die Deklaration der Methode handle(error: Error) an das Protokoll zurückgeben, ändert sich das Ergebnis wieder in den schwerwiegenden Fehler.

 protocol ErrorHandler { func handle(error: Error) }

Schauen wir uns die Reihenfolge an, was in jedem Fall passiert.

Wenn eine Methodendeklaration im Protokoll vorhanden ist:

Das Protokoll deklariert die Methode handle(error: Error) und stellt eine Standardimplementierung bereit. Die Methode wird in der Handler Implementierung überschrieben. Die korrekte Implementierung der Methode wird also unabhängig vom Typ der Variablen zur Laufzeit aufgerufen.

Wenn die Methodendeklaration im Protokoll nicht vorhanden ist:

Da die Methode nicht im Protokoll deklariert ist, kann der Typ sie nicht überschreiben. Deshalb hängt die Implementierung einer aufgerufenen Methode vom Typ der Variablen ab.

Wenn die Variable vom Typ Handler ist, wird die Methodenimplementierung des Typs aufgerufen. Falls die Variable vom Typ ErrorHandler ist, wird die Methodenimplementierung aus der Protokollerweiterung aufgerufen.

Protokollorientierter Code: Sicher und dennoch ausdrucksstark

In diesem Artikel haben wir einige der Möglichkeiten von Protokollerweiterungen in Swift demonstriert.

Im Gegensatz zu anderen Programmiersprachen mit Schnittstellen schränkt Swift Protokolle nicht mit unnötigen Einschränkungen ein. Swift umgeht gängige Macken dieser Programmiersprachen, indem es dem Entwickler ermöglicht, Mehrdeutigkeiten nach Bedarf aufzulösen.

Mit Swift-Protokollen und Protokollerweiterungen kann der von Ihnen geschriebene Code so ausdrucksstark sein wie die meisten dynamischen Programmiersprachen und dennoch zum Zeitpunkt der Kompilierung typsicher sein. Auf diese Weise können Sie die Wiederverwendbarkeit und Wartbarkeit Ihres Codes sicherstellen und Änderungen an der Codebasis Ihrer Swift-App sicherer vornehmen.

Wir hoffen, dass dieser Artikel für Sie nützlich ist, und freuen uns über Feedback oder weitere Erkenntnisse.