Wprowadzenie do programowania zorientowanego na protokoły w Swift
Opublikowany: 2022-03-11Protokół to bardzo potężna funkcja języka programowania Swift.
Protokoły służą do definiowania „planu metod, właściwości i innych wymagań, które pasują do konkretnego zadania lub funkcji”.
Swift sprawdza problemy ze zgodnością protokołu w czasie kompilacji, umożliwiając programistom wykrycie krytycznych błędów w kodzie jeszcze przed uruchomieniem programu. Protokoły umożliwiają programistom pisanie elastycznego i rozszerzalnego kodu w Swift bez konieczności narażania wyrazistości języka.
Swift przenosi wygodę korzystania z protokołów o krok dalej, zapewniając obejście niektórych z najczęstszych dziwactw i ograniczeń interfejsów, które nękają wiele innych języków programowania.
We wcześniejszych wersjach Swift możliwe było tylko rozszerzanie klas, struktur i wyliczeń, jak to ma miejsce w wielu nowoczesnych językach programowania. Jednak od wersji 2 Swift możliwe stało się również rozszerzanie protokołów.
W tym artykule omówiono, w jaki sposób protokoły w języku Swift mogą być używane do pisania kodu wielokrotnego użytku i konserwacji oraz jak zmiany w dużej bazie kodu zorientowanej na protokoły mogą być konsolidowane w jednym miejscu za pomocą rozszerzeń protokołu.
Protokoły
Co to jest protokół?
W najprostszej postaci protokół jest interfejsem opisującym niektóre właściwości i metody. Każdy typ zgodny z protokołem powinien wypełnić określone właściwości zdefiniowane w protokole odpowiednimi wartościami i wdrożyć wymagane metody. Na przykład:
protocol Queue { var count: Int { get } mutating func push(_ element: Int) mutating func pop() -> Int }
Protokół Queue opisuje kolejkę, która zawiera pozycje całkowite. Składnia jest dość prosta.
Wewnątrz bloku protokołu, kiedy opisujemy właściwość, musimy określić, czy właściwość jest tylko gettable { get }
, czy też gettable i settable { get set }
. W naszym przypadku zmienna Count (typu Int
) jest dostępna tylko do pobrania.
Jeśli protokół wymaga, aby właściwość była dostępna do pobrania i ustawienia, to wymaganie nie może zostać spełnione przez stałą przechowywaną właściwość lub właściwość obliczaną tylko do odczytu.
Jeśli protokół wymaga tylko, aby właściwość była dostępna do pobrania, wymaganie to może być spełnione przez dowolny rodzaj właściwości i jest ważne, aby właściwość była również ustawialna, jeśli jest to przydatne dla własnego kodu.
W przypadku funkcji zdefiniowanych w protokole ważne jest, aby wskazać, czy funkcja zmieni zawartość za pomocą słowa kluczowego mutating
. Poza tym sygnatura funkcji wystarcza jako definicja.
Aby zapewnić zgodność z protokołem, typ musi udostępniać wszystkie właściwości wystąpienia i implementować wszystkie metody opisane w protokole. Poniżej, na przykład, znajduje się struct Container
, który jest zgodny z naszym protokołem Queue
. Struktura zasadniczo przechowuje wypychane elementy Int
w prywatnej tablicy 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() } }
Nasz obecny protokół kolejki ma jednak poważną wadę.
Tylko kontenery obsługujące Int
mogą być zgodne z tym protokołem.
Możemy usunąć to ograniczenie, korzystając z funkcji „skojarzone typy”. Skojarzone typy działają jak generyki. Aby to zademonstrować, zmieńmy protokół kolejki, aby wykorzystywał skojarzone typy:
protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }
Teraz protokół kolejki umożliwia przechowywanie dowolnego rodzaju elementów.
W implementacji struktury Container
kompilator określa skojarzony typ z kontekstu (tj. typ zwracanej metody i typy parametrów). Takie podejście pozwala nam stworzyć strukturę Container
z ogólnym typem elementów. Na przykład:
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() } }
Korzystanie z protokołów upraszcza pisanie kodu w wielu przypadkach.
Na przykład każdy obiekt, który reprezentuje błąd, może być zgodny z protokołem Error
(lub LocalizedError
, jeśli chcemy udostępnić zlokalizowane opisy).
Tę samą logikę obsługi błędów można następnie zastosować do dowolnego z tych obiektów błędów w całym kodzie. W związku z tym nie musisz używać żadnego konkretnego obiektu (takiego jak NSError w Objective-C) do reprezentowania błędów, możesz użyć dowolnego typu, który jest zgodny z protokołami Error
lub LocalizedError
.
Możesz nawet rozszerzyć typ String, aby był zgodny z protokołem LocalizedError
i zgłaszać ciągi jako błędy.
extension String: LocalizedError { public var errorDescription: String? { Return NSLocalizedString(self, comment:””) } } throw “Unfortunately something went wrong” func handle(error: Error) { print(error.localizedDescription) }
Rozszerzenia protokołu
Rozszerzenia protokołów opierają się na wspaniałości protokołów. Pozwalają nam:
Podaj domyślną implementację metod protokołu i domyślne wartości właściwości protokołu, czyniąc je „opcjonalnymi”. Typy zgodne z protokołem mogą dostarczać własne implementacje lub używać domyślnych.
Dodaj implementację dodatkowych metod nieopisanych w protokole i „udekoruj” wszystkie typy zgodne z protokołem tymi dodatkowymi metodami. Ta funkcja pozwala nam dodawać określone metody do wielu typów, które są już zgodne z protokołem, bez konieczności indywidualnej modyfikacji każdego typu.
Implementacja metody domyślnej
Stwórzmy jeszcze jeden protokół:
protocol ErrorHandler { func handle(error: Error) }
Ten protokół opisuje obiekty odpowiedzialne za obsługę błędów występujących w aplikacji. Na przykład:
struct Handler: ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }
Tutaj po prostu drukujemy zlokalizowany opis błędu. Dzięki rozszerzeniu protokołu jesteśmy w stanie uczynić tę implementację domyślną.
extension ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }
W ten sposób metoda handle
staje się opcjonalna, zapewniając domyślną implementację.
Możliwość rozszerzenia istniejącego protokołu o domyślne zachowania jest dość potężna, pozwalając protokołom na rozwój i rozszerzanie bez martwienia się o złamanie kompatybilności istniejącego kodu.
Rozszerzenia warunkowe
Dlatego udostępniliśmy domyślną implementację metody handle
, ale drukowanie do konsoli nie jest zbyt pomocne dla użytkownika końcowego.
Prawdopodobnie wolelibyśmy pokazać im jakiś widok alertu ze zlokalizowanym opisem w przypadkach, gdy moduł obsługi błędów jest kontrolerem widoku. Aby to zrobić, możemy rozszerzyć protokół ErrorHandler
, ale możemy ograniczyć rozszerzenie do zastosowania tylko w określonych przypadkach (tj. gdy typem jest kontroler widoku).
Swift pozwala nam dodawać takie warunki do rozszerzeń protokołu za pomocą słowa kluczowego where
.
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 (przez duże „S”) w powyższym fragmencie kodu odnosi się do typu (struktury, klasy lub wyliczenia). Określając, że rozszerzamy protokół tylko dla typów, które dziedziczą z UIViewController
, jesteśmy w stanie użyć specyficznych metod UIViewController
(takich jak present(viewControllerToPresnt: animated: completion)
).

Teraz wszystkie kontrolery widoku, które są zgodne z protokołem ErrorHandler
, mają własną domyślną implementację metody handle
, która pokazuje widok alertu ze zlokalizowanym opisem.
Niejednoznaczne implementacje metod
Załóżmy, że istnieją dwa protokoły, z których oba mają metodę o tej samej sygnaturze.
protocol P1 { func method() //some other methods } protocol P2 { func method() //some other methods }
Oba protokoły mają rozszerzenie z domyślną implementacją tej metody.
extension P1 { func method() { print("Method P1") } } extension P2 { func method() { print("Method P2") } }
Załóżmy teraz, że istnieje typ zgodny z obydwoma protokołami.
struct S: P1, P2 { }
W tym przypadku mamy problem z niejednoznaczną implementacją metody. Typ nie wskazuje wyraźnie, której implementacji metody ma użyć. W rezultacie otrzymujemy błąd kompilacji. Aby to naprawić, musimy dodać implementację metody do typu.
struct S: P1, P2 { func method() { print("Method S") } }
Wiele języków programowania obiektowego jest nękanych ograniczeniami dotyczącymi rozwiązywania niejednoznacznych definicji rozszerzeń. Swift radzi sobie z tym dość elegancko poprzez rozszerzenia protokołu, pozwalając programiście przejąć kontrolę tam, gdzie kompilator zawodzi.
Dodawanie nowych metod
Przyjrzyjmy się jeszcze raz protokołowi Queue
.
protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }
Każdy typ zgodny z protokołem Queue
ma właściwość wystąpienia count
, która definiuje liczbę przechowywanych elementów. Umożliwia nam to między innymi porównywanie takich typów, aby zdecydować, który z nich jest większy. Możemy dodać tę metodę poprzez rozszerzenie protokołu.
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 } }
Ta metoda nie jest opisana w samym protokole Queue
, ponieważ nie jest związana z funkcjonalnością kolejki.
Dlatego nie jest to domyślna implementacja metody protokołu, ale raczej nowa implementacja metody, która „dekoruje” wszystkie typy zgodne z protokołem Queue
. Bez rozszerzeń protokołu musielibyśmy dodać tę metodę do każdego typu osobno.
Rozszerzenia protokołu a klasy podstawowe
Rozszerzenia protokołów mogą wydawać się dość podobne do używania klasy bazowej, ale istnieje kilka korzyści z używania rozszerzeń protokołu. Należą do nich między innymi:
Ponieważ klasy, struktury i wyliczenia mogą być zgodne z więcej niż jednym protokołem, mogą przyjąć domyślną implementację wielu protokołów. Jest to koncepcyjnie podobne do dziedziczenia wielokrotnego w innych językach.
Protokoły mogą być przyjmowane przez klasy, struktury i wyliczenia, podczas gdy klasy bazowe i dziedziczenie są dostępne tylko dla klas.
Rozszerzenia biblioteki standardowej Swift
Oprócz rozszerzania własnych protokołów możesz rozszerzać protokoły ze standardowej biblioteki Swift. Na przykład, jeśli chcemy znaleźć średni rozmiar kolekcji kolejek, możemy to zrobić, rozszerzając standardowy protokół Collection
.
Struktury danych sekwencyjnych dostarczane przez standardową bibliotekę Swift, której elementy można przeglądać i uzyskiwać do nich dostęp za pomocą indeksowanego indeksu dolnego, zwykle są zgodne z protokołem Collection
. Poprzez rozszerzenie protokołu możliwe jest rozszerzenie wszystkich takich standardowych struktur danych bibliotecznych lub rozszerzenie niektórych z nich selektywnie.
Uwaga: Protokół wcześniej znany jako
CollectionType
w Swift 2.x został przemianowany naCollection
w Swift 3.
extension Collection where Iterator.Element: Queue { func avgSize() -> Int { let size = map { $0.count }.reduce(0, +) return Int(round(Double(size) / Double(count.toIntMax()))) } }
Teraz możemy obliczyć średni rozmiar dowolnej kolekcji kolejek ( Array
, Set
, itp.). Bez rozszerzeń protokołu musielibyśmy dodać tę metodę do każdego typu kolekcji osobno.
W standardowej bibliotece Swift rozszerzenia protokołów służą do implementacji np. takich metod jak map
, filter
, reduce
, etc.
extension Collection { public func map<T>(_ transform: (Self.Iterator.Element) throws -> T) rethrows -> [T] { } }
Rozszerzenia protokołu i polimorfizm
Jak powiedziałem wcześniej, rozszerzenia protokołów pozwalają nam dodawać domyślne implementacje niektórych metod, a także dodawać nowe implementacje metod. Ale jaka jest różnica między tymi dwiema cechami? Wróćmy do programu obsługi błędów i dowiedzmy się.
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)
Rezultatem jest błąd krytyczny.
Teraz usuń deklarację metody handle(error: Error)
z protokołu.
protocol ErrorHandler { }
Wynik jest ten sam: błąd krytyczny.
Czy to oznacza, że nie ma różnicy między dodaniem domyślnej implementacji metody protokołu a dodaniem nowej implementacji metody do protokołu?
Nie! Różnica istnieje i można ją zobaczyć, zmieniając typ handler
zmiennej z Handler
na ErrorHandler
.
let handler: ErrorHandler = Handler()
Teraz dane wyjściowe do konsoli to: Operacja nie mogła zostać ukończona. (błąd błędu aplikacji 0.)
Ale jeśli zwrócimy deklarację metody handle(error: Error) do protokołu, wynik zmieni się z powrotem na błąd krytyczny.
protocol ErrorHandler { func handle(error: Error) }
Przyjrzyjmy się kolejności tego, co dzieje się w każdym przypadku.
Gdy deklaracja metody istnieje w protokole:
Protokół deklaruje metodę handle(error: Error)
i zapewnia domyślną implementację. Metoda jest zastępowana w implementacji Handler
. Tak więc prawidłowa implementacja metody jest wywoływana w czasie wykonywania, niezależnie od typu zmiennej.
Gdy deklaracja metody nie istnieje w protokole:
Ponieważ metoda nie jest zadeklarowana w protokole, typ nie może jej zastąpić. Dlatego implementacja wywoływanej metody zależy od typu zmiennej.
Jeśli zmienna jest typu Handler
, wywoływana jest implementacja metody z typu. W przypadku, gdy zmienna jest typu ErrorHandler
, wywoływana jest implementacja metody z rozszerzenia protokołu.
Kod zorientowany na protokół: bezpieczny, ale ekspresyjny
W tym artykule pokazaliśmy część mocy rozszerzeń protokołu w Swift.
W przeciwieństwie do innych języków programowania z interfejsami, Swift nie ogranicza protokołów niepotrzebnymi ograniczeniami. Swift omija typowe dziwactwa tych języków programowania, umożliwiając programiście rozwiązywanie niejasności w razie potrzeby.
Dzięki protokołom Swift i rozszerzeniom protokołów pisany przez Ciebie kod może być tak ekspresyjny, jak większość dynamicznych języków programowania i nadal być bezpiecznym typem w czasie kompilacji. Pozwala to zapewnić możliwość ponownego użycia i konserwacji kodu oraz wprowadzać zmiany w bazie kodu aplikacji Swift z większą pewnością.
Mamy nadzieję, że ten artykuł będzie dla Ciebie przydatny i czekamy na wszelkie opinie lub dalsze spostrzeżenia.