Uma Introdução à Programação Orientada a Protocolos em Swift
Publicados: 2022-03-11O protocolo é um recurso muito poderoso da linguagem de programação Swift.
Os protocolos são usados para definir um “plano de métodos, propriedades e outros requisitos que se adequam a uma tarefa ou funcionalidade específica”.
O Swift verifica problemas de conformidade de protocolo em tempo de compilação, permitindo que os desenvolvedores descubram alguns erros fatais no código antes mesmo de executar o programa. Os protocolos permitem que os desenvolvedores escrevam códigos flexíveis e extensíveis em Swift sem comprometer a expressividade da linguagem.
O Swift leva a conveniência de usar protocolos um passo adiante, fornecendo soluções alternativas para algumas das peculiaridades e limitações mais comuns de interfaces que afetam muitas outras linguagens de programação.
Nas versões anteriores do Swift, era possível apenas estender classes, estruturas e enums, como acontece em muitas linguagens de programação modernas. No entanto, desde a versão 2 do Swift, tornou-se possível estender protocolos também.
Este artigo examina como os protocolos em Swift podem ser usados para escrever código reutilizável e sustentável e como as alterações em uma grande base de código orientada a protocolos podem ser consolidadas em um único local por meio do uso de extensões de protocolo.
Protocolos
O que é um protocolo?
Em sua forma mais simples, um protocolo é uma interface que descreve algumas propriedades e métodos. Qualquer tipo que esteja em conformidade com um protocolo deve preencher as propriedades específicas definidas no protocolo com valores apropriados e implementar seus métodos necessários. Por exemplo:
protocol Queue { var count: Int { get } mutating func push(_ element: Int) mutating func pop() -> Int }
O protocolo Queue descreve uma fila, que contém itens inteiros. A sintaxe é bastante direta.
Dentro do bloco de protocolo, quando descrevemos uma propriedade, devemos especificar se a propriedade é apenas gettable { get }
ou tanto gettable quanto settable { get set }
. No nosso caso, a variável Count (do tipo Int
) é apenas gettable.
Se um protocolo exigir que uma propriedade possa ser obtida e configurável, esse requisito não pode ser atendido por uma propriedade armazenada constante ou uma propriedade computada somente leitura.
Se o protocolo requer apenas que uma propriedade seja gettable, o requisito pode ser satisfeito por qualquer tipo de propriedade, e é válido que a propriedade também seja configurável, se isso for útil para seu próprio código.
Para funções definidas em um protocolo, é importante indicar se a função alterará o conteúdo com a palavra-chave mutating
. Fora isso, a assinatura de uma função é suficiente como definição.
Para estar em conformidade com um protocolo, um tipo deve fornecer todas as propriedades de instância e implementar todos os métodos descritos no protocolo. Abaixo, por exemplo, está um struct Container
que está em conformidade com nosso protocolo Queue
. O struct armazena essencialmente Int
s empurrados em um array privado de 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() } }
Nosso protocolo de fila atual, no entanto, tem uma grande desvantagem.
Somente containers que lidam com Int
s podem estar em conformidade com este protocolo.
Podemos remover essa limitação usando o recurso “tipos associados”. Tipos associados funcionam como genéricos. Para demonstrar, vamos alterar o protocolo Queue para utilizar tipos associados:
protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }
Agora o protocolo Queue permite o armazenamento de qualquer tipo de item.
Na implementação da estrutura Container
, o compilador determina o tipo associado a partir do contexto (ou seja, tipo de retorno de método e tipos de parâmetro). Essa abordagem nos permite criar uma estrutura Container
com um tipo de item genérico. Por exemplo:
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() } }
O uso de protocolos simplifica a escrita de código em muitos casos.
Por exemplo, qualquer objeto que represente um erro pode estar em conformidade com o protocolo Error
(ou LocalizedError
, caso queiramos fornecer descrições localizadas).
A mesma lógica de tratamento de erros pode ser aplicada a qualquer um desses objetos de erro em todo o seu código. Conseqüentemente, você não precisa usar nenhum objeto específico (como NSError em Objective-C) para representar erros, você pode usar qualquer tipo que esteja em conformidade com os protocolos Error
ou LocalizedError
.
Você pode até estender o tipo String para torná-lo compatível com o protocolo LocalizedError
e lançar strings como erros.
extension String: LocalizedError { public var errorDescription: String? { Return NSLocalizedString(self, comment:””) } } throw “Unfortunately something went wrong” func handle(error: Error) { print(error.localizedDescription) }
Extensões de protocolo
As extensões de protocolo se baseiam na grandiosidade dos protocolos. Eles nos permitem:
Fornecer implementação padrão de métodos de protocolo e valores padrão de propriedades de protocolo, tornando-os “opcionais”. Os tipos que estão em conformidade com um protocolo podem fornecer suas próprias implementações ou usar as padrão.
Adicione a implementação de métodos adicionais não descritos no protocolo e “decore” quaisquer tipos que estejam em conformidade com o protocolo com esses métodos adicionais. Esse recurso nos permite adicionar métodos específicos a vários tipos que já estão em conformidade com o protocolo sem precisar modificar cada tipo individualmente.
Implementação do método padrão
Vamos criar mais um protocolo:
protocol ErrorHandler { func handle(error: Error) }
Este protocolo descreve objetos que se encarregam de tratar os erros que ocorrem em uma aplicação. Por exemplo:
struct Handler: ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }
Aqui, apenas imprimimos a descrição localizada do erro. Com a extensão do protocolo, podemos fazer com que essa implementação seja o padrão.
extension ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }
Isso torna o método handle
opcional, fornecendo uma implementação padrão.
A capacidade de estender um protocolo existente com comportamentos padrão é bastante poderosa, permitindo que os protocolos cresçam e sejam estendidos sem ter que se preocupar em quebrar a compatibilidade do código existente.
Extensões condicionais
Portanto, fornecemos uma implementação padrão do método handle
, mas imprimir no console não é muito útil para o usuário final.
Provavelmente, preferiríamos mostrar a eles algum tipo de exibição de alerta com uma descrição localizada nos casos em que o manipulador de erros é um controlador de exibição. Para fazer isso, podemos estender o protocolo ErrorHandler
, mas podemos limitar a extensão para aplicar apenas em determinados casos (ou seja, quando o tipo é um controlador de exibição).
Swift nos permite adicionar tais condições a extensões de protocolo usando a palavra-chave 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 (com “S” maiúsculo) no trecho de código acima se refere ao tipo (estrutura, classe ou enumeração). Ao especificar que apenas estendemos o protocolo para tipos que herdam de UIViewController
, podemos usar métodos específicos de UIViewController
(como present(viewControllerToPresnt: animated: completion)
).

Agora, quaisquer controladores de exibição que estejam em conformidade com o protocolo ErrorHandler
têm sua própria implementação padrão do método handle
que mostra uma exibição de alerta com uma descrição localizada.
Implementações de métodos ambíguos
Vamos supor que existam dois protocolos, ambos com um método com a mesma assinatura.
protocol P1 { func method() //some other methods } protocol P2 { func method() //some other methods }
Ambos os protocolos têm uma extensão com uma implementação padrão desse método.
extension P1 { func method() { print("Method P1") } } extension P2 { func method() { print("Method P2") } }
Agora vamos supor que existe um tipo que está em conformidade com ambos os protocolos.
struct S: P1, P2 { }
Nesse caso, temos um problema com a implementação de métodos ambíguos. O tipo não indica claramente qual implementação do método deve ser usada. Como resultado, obtemos um erro de compilação. Para corrigir isso, temos que adicionar a implementação do método ao tipo.
struct S: P1, P2 { func method() { print("Method S") } }
Muitas linguagens de programação orientadas a objetos são infestadas de limitações em torno da resolução de definições de extensão ambíguas. O Swift lida com isso de forma bastante elegante por meio de extensões de protocolo, permitindo que o programador assuma o controle onde o compilador falha.
Adicionando novos métodos
Vamos dar uma olhada no protocolo Queue
mais uma vez.
protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }
Cada tipo que está em conformidade com o protocolo Queue
tem uma propriedade de instância de count
que define o número de itens armazenados. Isso nos permite, entre outras coisas, comparar esses tipos para decidir qual é o maior. Podemos adicionar este método através da extensão do protocolo.
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 } }
Esse método não é descrito no próprio protocolo Queue
porque não está relacionado à funcionalidade da fila.
Portanto, não é uma implementação padrão do método de protocolo, mas sim uma nova implementação de método que “decora” todos os tipos que estão em conformidade com o protocolo Queue
. Sem extensões de protocolo, teríamos que adicionar esse método a cada tipo separadamente.
Extensões de protocolo versus classes base
As extensões de protocolo podem parecer bastante semelhantes ao uso de uma classe base, mas há vários benefícios em usar extensões de protocolo. Estes incluem, mas não estão necessariamente limitados a:
Como classes, estruturas e enums podem estar em conformidade com mais de um protocolo, elas podem usar a implementação padrão de vários protocolos. Isso é conceitualmente semelhante à herança múltipla em outras linguagens.
Os protocolos podem ser adotados por classes, estruturas e enums, enquanto classes base e herança estão disponíveis apenas para classes.
Extensões de biblioteca padrão Swift
Além de estender seus próprios protocolos, você pode estender protocolos da biblioteca padrão do Swift. Por exemplo, se quisermos encontrar o tamanho médio da coleção de filas, podemos fazê-lo estendendo o protocolo de Collection
padrão.
Estruturas de dados de sequência fornecidas pela biblioteca padrão do Swift, cujos elementos podem ser percorridos e acessados por meio de subscrito indexado, geralmente em conformidade com o protocolo Collection
. Por meio da extensão de protocolo, é possível estender todas essas estruturas de dados de biblioteca padrão ou estender algumas delas seletivamente.
Observação: o protocolo anteriormente conhecido como
CollectionType
no Swift 2.x foi renomeado paraCollection
no 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()))) } }
Agora podemos calcular o tamanho médio de qualquer coleção de filas ( Array
, Set
, etc.). Sem extensões de protocolo, teríamos que adicionar esse método a cada tipo de coleção separadamente.
Na biblioteca padrão do Swift, extensões de protocolo são usadas para implementar, por exemplo, métodos como map
, filter
, reduce
, etc.
extension Collection { public func map<T>(_ transform: (Self.Iterator.Element) throws -> T) rethrows -> [T] { } }
Extensões de protocolo e polimorfismo
Como eu disse anteriormente, as extensões de protocolo nos permitem adicionar implementações padrão de alguns métodos e adicionar novas implementações de métodos também. Mas qual é a diferença entre essas duas características? Vamos voltar ao manipulador de erros e descobrir.
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)
O resultado é um erro fatal.
Agora remova a declaração do método handle(error: Error)
do protocolo.
protocol ErrorHandler { }
O resultado é o mesmo: um erro fatal.
Isso significa que não há diferença entre adicionar uma implementação padrão do método de protocolo e adicionar uma nova implementação de método ao protocolo?
Não! Existe uma diferença, e você pode vê-la alterando o tipo do handler
de variável de Handler
para ErrorHandler
.
let handler: ErrorHandler = Handler()
Agora a saída para o console é: A operação não pôde ser concluída. (Erro ApplicationError 0.)
Mas se retornarmos a declaração do método handle(error: Error) para o protocolo, o resultado voltará ao erro fatal.
protocol ErrorHandler { func handle(error: Error) }
Vejamos a ordem do que acontece em cada caso.
Quando a declaração do método existe no protocolo:
O protocolo declara o método handle(error: Error)
e fornece uma implementação padrão. O método é substituído na implementação do Handler
. Assim, a implementação correta do método é invocada em tempo de execução, independentemente do tipo da variável.
Quando a declaração do método não existe no protocolo:
Como o método não é declarado no protocolo, o tipo não pode substituí-lo. É por isso que a implementação de um método chamado depende do tipo da variável.
Se a variável for do tipo Handler
, a implementação do método do tipo será invocada. Caso a variável seja do tipo ErrorHandler
, a implementação do método da extensão do protocolo é invocada.
Código orientado a protocolo: seguro, mas expressivo
Neste artigo, demonstramos um pouco do poder das extensões de protocolo no Swift.
Ao contrário de outras linguagens de programação com interfaces, o Swift não restringe protocolos com limitações desnecessárias. Swift trabalha em torno de peculiaridades comuns dessas linguagens de programação, permitindo que o desenvolvedor resolva a ambiguidade conforme necessário.
Com protocolos Swift e extensões de protocolo, o código que você escreve pode ser tão expressivo quanto a maioria das linguagens de programação dinâmicas e ainda ser seguro em tempo de compilação. Isso permite que você garanta a reutilização e a manutenção do seu código e faça alterações na base de código do seu aplicativo Swift com mais confiança.
Esperamos que este artigo seja útil para você e agradecemos qualquer feedback ou insights adicionais.