Un'introduzione alla programmazione orientata al protocollo in Swift
Pubblicato: 2022-03-11Il protocollo è una caratteristica molto potente del linguaggio di programmazione Swift.
I protocolli vengono utilizzati per definire un "progetto di metodi, proprietà e altri requisiti che si adattano a una particolare attività o funzionalità".
Swift controlla i problemi di conformità del protocollo in fase di compilazione, consentendo agli sviluppatori di scoprire alcuni bug fatali nel codice anche prima di eseguire il programma. I protocolli consentono agli sviluppatori di scrivere codice flessibile ed estensibile in Swift senza dover compromettere l'espressività del linguaggio.
Swift fa un ulteriore passo avanti nella comodità dell'utilizzo dei protocolli, fornendo soluzioni alternative ad alcune delle stranezze e limitazioni più comuni delle interfacce che affliggono molti altri linguaggi di programmazione.
Nelle versioni precedenti di Swift, era possibile estendere solo classi, strutture ed enumerazioni, come è vero in molti linguaggi di programmazione moderni. Tuttavia, dalla versione 2 di Swift, è stato possibile estendere anche i protocolli.
Questo articolo esamina come i protocolli in Swift possono essere utilizzati per scrivere codice riutilizzabile e gestibile e come le modifiche a una base di codice orientata al protocollo di grandi dimensioni possono essere consolidate in un unico luogo tramite l'uso di estensioni di protocollo.
Protocolli
Che cos'è un protocollo?
Nella sua forma più semplice, un protocollo è un'interfaccia che descrive alcune proprietà e metodi. Qualsiasi tipo conforme a un protocollo deve compilare le proprietà specifiche definite nel protocollo con valori appropriati e implementare i metodi richiesti. Per esempio:
protocol Queue { var count: Int { get } mutating func push(_ element: Int) mutating func pop() -> Int }
Il protocollo Queue descrive una coda che contiene elementi interi. La sintassi è abbastanza semplice.
All'interno del blocco del protocollo, quando descriviamo una proprietà, dobbiamo specificare se la proprietà è solo gettable { get }
o sia gettable che settable { get set }
. Nel nostro caso, la variabile Count (di tipo Int
) è solo gettable.
Se un protocollo richiede che una proprietà sia recuperabile e impostabile, tale requisito non può essere soddisfatto da una proprietà memorizzata costante o da una proprietà calcolata di sola lettura.
Se il protocollo richiede solo che una proprietà sia recuperabile, il requisito può essere soddisfatto da qualsiasi tipo di proprietà, ed è valido che la proprietà sia anche impostabile, se questo è utile per il proprio codice.
Per le funzioni definite in un protocollo, è importante indicare se la funzione cambierà il contenuto con la parola chiave mutating
. A parte questo, la firma di una funzione è sufficiente come definizione.
Per essere conforme a un protocollo, un tipo deve fornire tutte le proprietà dell'istanza e implementare tutti i metodi descritti nel protocollo. Di seguito, ad esempio, è riportato uno struct Container
conforme al nostro protocollo Queue
. La struttura essenzialmente memorizza gli Int
s inseriti in un array privato di 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() } }
Il nostro attuale protocollo di coda, tuttavia, presenta un grave svantaggio.
Solo i contenitori che gestiscono Int
s possono essere conformi a questo protocollo.
Possiamo rimuovere questa limitazione utilizzando la funzione "tipi associati". I tipi associati funzionano come i generici. Per dimostrare, cambiamo il protocollo della coda per utilizzare i tipi associati:
protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }
Ora il protocollo Queue consente la memorizzazione di qualsiasi tipo di articolo.
Nell'implementazione della struttura Container
, il compilatore determina il tipo associato dal contesto (ad esempio, tipo restituito dal metodo e tipi di parametro). Questo approccio ci consente di creare una struttura Container
con un tipo di elementi generico. Per esempio:
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() } }
L'uso dei protocolli semplifica la scrittura del codice in molti casi.
Ad esempio, qualsiasi oggetto che rappresenta un errore può essere conforme al protocollo Error
(o LocalizedError
, nel caso si desideri fornire descrizioni localizzate).
La stessa logica di gestione degli errori può quindi essere applicata a uno qualsiasi di questi oggetti di errore in tutto il codice. Di conseguenza, non è necessario utilizzare alcun oggetto specifico (come NSError in Objective-C) per rappresentare gli errori, è possibile utilizzare qualsiasi tipo conforme ai protocolli Error
o LocalizedError
.
Puoi anche estendere il tipo String per renderlo conforme al protocollo LocalizedError
e generare stringhe come errori.
extension String: LocalizedError { public var errorDescription: String? { Return NSLocalizedString(self, comment:””) } } throw “Unfortunately something went wrong” func handle(error: Error) { print(error.localizedDescription) }
Estensioni del protocollo
Le estensioni del protocollo si basano sulla bellezza dei protocolli. Ci consentono di:
Fornire l'implementazione predefinita dei metodi del protocollo e i valori predefiniti delle proprietà del protocollo, rendendoli così "facoltativi". I tipi conformi a un protocollo possono fornire le proprie implementazioni o utilizzare quelle predefinite.
Aggiungi l'implementazione di metodi aggiuntivi non descritti nel protocollo e "decora" qualsiasi tipo conforme al protocollo con questi metodi aggiuntivi. Questa caratteristica ci consente di aggiungere metodi specifici a più tipi già conformi al protocollo senza dover modificare ogni tipo singolarmente.
Implementazione del metodo predefinito
Creiamo un altro protocollo:
protocol ErrorHandler { func handle(error: Error) }
Questo protocollo descrive gli oggetti che hanno il compito di gestire gli errori che si verificano in un'applicazione. Per esempio:
struct Handler: ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }
Qui stampiamo solo la descrizione localizzata dell'errore. Con l'estensione del protocollo siamo in grado di rendere questa implementazione predefinita.
extension ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }
In questo modo il metodo handle
è facoltativo fornendo un'implementazione predefinita.
La possibilità di estendere un protocollo esistente con comportamenti predefiniti è piuttosto potente, consentendo ai protocolli di crescere ed essere estesi senza doversi preoccupare di interrompere la compatibilità del codice esistente.
Estensioni condizionali
Quindi abbiamo fornito un'implementazione predefinita del metodo handle
, ma la stampa sulla console non è di grande aiuto per l'utente finale.
Probabilmente preferiremmo mostrare loro una sorta di visualizzazione di avviso con una descrizione localizzata nei casi in cui il gestore degli errori è un controller di visualizzazione. Per fare ciò, possiamo estendere il protocollo ErrorHandler
, ma possiamo limitare l'estensione in modo che si applichi solo in determinati casi (ad esempio, quando il tipo è un controller di visualizzazione).
Swift ci consente di aggiungere tali condizioni alle estensioni di protocollo utilizzando la parola chiave 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 (con la "S" maiuscola nel frammento di codice sopra si riferisce al tipo (struttura, classe o enum). Specificando che estendiamo il protocollo solo per i tipi che ereditano da UIViewController
, siamo in grado di utilizzare metodi specifici di UIViewController
(come present(viewControllerToPresnt: animated: completion)
).

Ora, tutti i controller di visualizzazione conformi al protocollo ErrorHandler
hanno la propria implementazione predefinita del metodo handle
che mostra una visualizzazione di avviso con una descrizione localizzata.
Implementazioni di metodi ambigui
Supponiamo che ci siano due protocolli, che hanno entrambi un metodo con la stessa firma.
protocol P1 { func method() //some other methods } protocol P2 { func method() //some other methods }
Entrambi i protocolli hanno un'estensione con un'implementazione predefinita di questo metodo.
extension P1 { func method() { print("Method P1") } } extension P2 { func method() { print("Method P2") } }
Supponiamo ora che esista un tipo conforme a entrambi i protocolli.
struct S: P1, P2 { }
In questo caso, abbiamo un problema con l'implementazione ambigua del metodo. Il tipo non indica chiaramente quale implementazione del metodo dovrebbe utilizzare. Di conseguenza, otteniamo un errore di compilazione. Per risolvere questo problema, dobbiamo aggiungere l'implementazione del metodo al tipo.
struct S: P1, P2 { func method() { print("Method S") } }
Molti linguaggi di programmazione orientati agli oggetti sono afflitti da limitazioni che circondano la risoluzione di definizioni di estensioni ambigue. Swift lo gestisce in modo abbastanza elegante attraverso le estensioni del protocollo consentendo al programmatore di assumere il controllo dove il compilatore non è all'altezza.
Aggiunta di nuovi metodi
Diamo un'occhiata al protocollo Queue
ancora una volta.
protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }
Ciascun tipo conforme al protocollo Queue
dispone di una proprietà di istanza di count
che definisce il numero di elementi archiviati. Questo ci consente, tra le altre cose, di confrontare tali tipi per decidere quale è più grande. Possiamo aggiungere questo metodo tramite l'estensione del protocollo.
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 } }
Questo metodo non è descritto nel protocollo Queue
stesso perché non è correlato alla funzionalità della coda.
Non si tratta quindi di un'implementazione predefinita del metodo del protocollo, ma piuttosto di una nuova implementazione del metodo che "decora" tutti i tipi conformi al protocollo della Queue
. Senza le estensioni del protocollo dovremmo aggiungere questo metodo a ciascun tipo separatamente.
Estensioni del protocollo e classi di base
Le estensioni di protocollo possono sembrare abbastanza simili all'utilizzo di una classe base, ma ci sono diversi vantaggi nell'utilizzare le estensioni di protocollo. Questi includono, ma non sono necessariamente limitati a:
Poiché le classi, le strutture e le enumerazioni possono essere conformi a più di un protocollo, possono assumere l'implementazione predefinita di più protocolli. Questo è concettualmente simile all'ereditarietà multipla in altre lingue.
I protocolli possono essere adottati da classi, strutture ed enumerazioni, mentre le classi base e l'ereditarietà sono disponibili solo per le classi.
Estensioni della libreria Swift Standard
Oltre a estendere i tuoi protocolli, puoi estendere i protocolli dalla libreria standard Swift. Ad esempio, se vogliamo trovare la dimensione media della raccolta di code, possiamo farlo estendendo il protocollo di Collection
standard.
Le strutture dei dati di sequenza fornite dalla libreria standard di Swift, i cui elementi possono essere attraversati e accessibili tramite pedice indicizzato, di solito sono conformi al protocollo Collection
. Attraverso l'estensione del protocollo, è possibile estendere tutte queste strutture di dati di libreria standard o estenderne alcune in modo selettivo.
Nota: il protocollo precedentemente noto come
CollectionType
in Swift 2.x è stato rinominatoCollection
in 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()))) } }
Ora possiamo calcolare la dimensione media di qualsiasi raccolta di code ( Array
, Set
, ecc.). Senza le estensioni del protocollo, avremmo dovuto aggiungere questo metodo a ciascun tipo di raccolta separatamente.
Nella libreria standard Swift, le estensioni del protocollo vengono utilizzate per implementare, ad esempio, metodi come map
, filter
, reduce
, ecc.
extension Collection { public func map<T>(_ transform: (Self.Iterator.Element) throws -> T) rethrows -> [T] { } }
Estensioni del protocollo e polimorfismo
Come ho detto in precedenza, le estensioni del protocollo ci consentono di aggiungere implementazioni predefinite di alcuni metodi e aggiungere anche nuove implementazioni di metodi. Ma qual è la differenza tra queste due caratteristiche? Torniamo al gestore degli errori e scopriamolo.
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)
Il risultato è un errore fatale.
Ora rimuovi la dichiarazione del metodo handle(error: Error)
dal protocollo.
protocol ErrorHandler { }
Il risultato è lo stesso: un errore fatale.
Significa che non c'è differenza tra l'aggiunta di un'implementazione predefinita del metodo del protocollo e l'aggiunta di una nuova implementazione del metodo al protocollo?
No! Esiste una differenza e puoi vederla modificando il tipo del handler
della variabile da Handler
a ErrorHandler
.
let handler: ErrorHandler = Handler()
Ora l'output sulla console è: Impossibile completare l'operazione. (ApplicationError errore 0.)
Ma se restituiamo la dichiarazione del metodo handle(error: Error) al protocollo, il risultato tornerà all'errore fatale.
protocol ErrorHandler { func handle(error: Error) }
Diamo un'occhiata all'ordine di ciò che accade in ciascun caso.
Quando la dichiarazione del metodo esiste nel protocollo:
Il protocollo dichiara il metodo handle(error: Error)
e fornisce un'implementazione predefinita. Il metodo viene sovrascritto nell'implementazione del Handler
. Quindi, la corretta implementazione del metodo viene invocata in fase di esecuzione, indipendentemente dal tipo di variabile.
Quando la dichiarazione del metodo non esiste nel protocollo:
Poiché il metodo non è dichiarato nel protocollo, il tipo non è in grado di sovrascriverlo. Ecco perché l'implementazione di un metodo chiamato dipende dal tipo di variabile.
Se la variabile è di tipo Handler
, viene richiamata l'implementazione del metodo dal tipo. Nel caso in cui la variabile sia di tipo ErrorHandler
, viene richiamata l'implementazione del metodo dall'estensione del protocollo.
Codice orientato al protocollo: sicuro ma espressivo
In questo articolo, abbiamo dimostrato parte della potenza delle estensioni di protocollo in Swift.
A differenza di altri linguaggi di programmazione con interfacce, Swift non limita i protocolli con limitazioni non necessarie. Swift risolve le stranezze comuni di quei linguaggi di programmazione consentendo allo sviluppatore di risolvere l'ambiguità se necessario.
Con i protocolli Swift e le estensioni di protocollo, il codice che scrivi può essere espressivo come la maggior parte dei linguaggi di programmazione dinamici ed essere comunque sicuro dai tipi al momento della compilazione. Ciò ti consente di garantire la riutilizzabilità e la manutenibilità del tuo codice e di apportare modifiche alla base di codice dell'app Swift con maggiore sicurezza.
Ci auguriamo che questo articolo ti sia utile e accogliamo con favore qualsiasi feedback o ulteriore approfondimento.