Introducción a la programación orientada a protocolos en Swift

Publicado: 2022-03-11

El protocolo es una característica muy poderosa del lenguaje de programación Swift.

Los protocolos se utilizan para definir un "modelo de métodos, propiedades y otros requisitos que se adaptan a una tarea o función en particular".

Swift verifica los problemas de conformidad del protocolo en tiempo de compilación, lo que permite a los desarrolladores descubrir algunos errores fatales en el código incluso antes de ejecutar el programa. Los protocolos permiten a los desarrolladores escribir código flexible y extensible en Swift sin tener que comprometer la expresividad del lenguaje.

Swift lleva la conveniencia de usar protocolos un paso más allá al proporcionar soluciones a algunas de las peculiaridades y limitaciones más comunes de las interfaces que afectan a muchos otros lenguajes de programación.

Introducción a la programación orientada a protocolos en Swift

Escriba código flexible y extensible en Swift con programación orientada a protocolos.
Pío

En versiones anteriores de Swift, solo era posible extender clases, estructuras y enumeraciones, como ocurre en muchos lenguajes de programación modernos. Sin embargo, desde la versión 2 de Swift, también fue posible ampliar los protocolos.

Este artículo examina cómo se pueden usar los protocolos en Swift para escribir código reutilizable y mantenible, y cómo los cambios en una gran base de código orientada a protocolos se pueden consolidar en un solo lugar mediante el uso de extensiones de protocolo.

protocolos

¿Qué es un protocolo?

En su forma más simple, un protocolo es una interfaz que describe algunas propiedades y métodos. Cualquier tipo que se ajuste a un protocolo debe completar las propiedades específicas definidas en el protocolo con los valores adecuados e implementar los métodos necesarios. Por ejemplo:

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

El protocolo Queue describe una cola que contiene elementos enteros. La sintaxis es bastante sencilla.

Dentro del bloque de protocolo, cuando describimos una propiedad, debemos especificar si la propiedad es solo gettable { get } o tanto gettable como settable { get set } . En nuestro caso, la variable Count (de tipo Int ) solo se puede obtener.

Si un protocolo requiere que una propiedad se pueda obtener y configurar, ese requisito no se puede cumplir con una propiedad almacenada constante o una propiedad calculada de solo lectura.

Si el protocolo solo requiere que una propiedad sea obtenible, el requisito puede ser satisfecho por cualquier tipo de propiedad, y es válido que la propiedad también sea configurable, si esto es útil para su propio código.

Para funciones definidas en un protocolo, es importante indicar si la función cambiará el contenido con la palabra clave mutating . Aparte de eso, la firma de una función es suficiente como definición.

Para cumplir con un protocolo, un tipo debe proporcionar todas las propiedades de instancia e implementar todos los métodos descritos en el protocolo. A continuación, por ejemplo, hay un Container de estructura que se ajusta a nuestro protocolo de Queue . La estructura esencialmente almacena Int s insertados en items de una matriz privada.

 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() } }

Sin embargo, nuestro protocolo de cola actual tiene una gran desventaja.

Solo los contenedores que tratan con Int s pueden cumplir con este protocolo.

Podemos eliminar esta limitación utilizando la función de "tipos asociados". Los tipos asociados funcionan como genéricos. Para demostrarlo, cambiemos el protocolo Queue para utilizar tipos asociados:

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

Ahora el protocolo Queue permite el almacenamiento de cualquier tipo de artículos.

En la implementación de la estructura Container , el compilador determina el tipo asociado a partir del contexto (es decir, el tipo de devolución del método y los tipos de parámetros). Este enfoque nos permite crear una estructura de Container con un tipo de elementos genéricos. Por ejemplo:

 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() } }

El uso de protocolos simplifica la escritura de código en muchos casos.

Por ejemplo, cualquier objeto que represente un error puede ajustarse al protocolo Error (o LocalizedError , en caso de que queramos proporcionar descripciones localizadas).

La misma lógica de manejo de errores se puede aplicar a cualquiera de estos objetos de error en todo el código. En consecuencia, no necesita usar ningún objeto específico (como NSError en Objective-C) para representar errores, puede usar cualquier tipo que se ajuste a los protocolos Error o LocalizedError .

Incluso puede extender el tipo String para que se ajuste al protocolo LocalizedError y arrojar cadenas como errores.

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

Extensiones de protocolo

Las extensiones de protocolo se basan en la genialidad de los protocolos. Nos permiten:

  1. Proporcione la implementación predeterminada de los métodos de protocolo y los valores predeterminados de las propiedades del protocolo, haciéndolos así "opcionales". Los tipos que se ajustan a un protocolo pueden proporcionar sus propias implementaciones o utilizar las predeterminadas.

  2. Agregue la implementación de métodos adicionales no descritos en el protocolo y "decore" cualquier tipo que se ajuste al protocolo con estos métodos adicionales. Esta característica nos permite agregar métodos específicos a múltiples tipos que ya se ajustan al protocolo sin tener que modificar cada tipo individualmente.

Implementación del método predeterminado

Vamos a crear un protocolo más:

 protocol ErrorHandler { func handle(error: Error) }

Este protocolo describe objetos que se encargan de manejar los errores que ocurren en una aplicación. Por ejemplo:

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

Aquí solo imprimimos la descripción localizada del error. Con la extensión del protocolo, podemos hacer que esta implementación sea la predeterminada.

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

Hacer esto hace que el método handle sea opcional al proporcionar una implementación predeterminada.

La capacidad de ampliar un protocolo existente con comportamientos predeterminados es bastante poderosa, lo que permite que los protocolos crezcan y se amplíen sin tener que preocuparse por romper la compatibilidad del código existente.

Extensiones Condicionales

Así que proporcionamos una implementación predeterminada del método handle , pero imprimir en la consola no es muy útil para el usuario final.

Probablemente preferiríamos mostrarles algún tipo de vista de alerta con una descripción localizada en los casos en que el controlador de errores sea un controlador de vista. Para hacer esto, podemos extender el protocolo ErrorHandler , pero podemos limitar la extensión para que solo se aplique en ciertos casos (es decir, cuando el tipo es un controlador de vista).

Swift nos permite agregar tales condiciones a las extensiones de protocolo usando la palabra clave 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 "S" mayúscula) en el fragmento de código anterior se refiere al tipo (estructura, clase o enumeración). Al especificar que solo extendemos el protocolo para los tipos que heredan de UIViewController , podemos usar métodos específicos de UIViewController (como present(viewControllerToPresnt: animated: completion) ).

Ahora, cualquier controlador de vista que se ajuste al protocolo ErrorHandler tiene su propia implementación predeterminada del método handle que muestra una vista de alerta con una descripción localizada.

Implementaciones de métodos ambiguos

Supongamos que hay dos protocolos, los cuales tienen un método con la misma firma.

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

Ambos protocolos tienen una extensión con una implementación por defecto de este método.

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

Ahora supongamos que hay un tipo que se ajusta a ambos protocolos.

 struct S: P1, P2 { }

En este caso, tenemos un problema con la implementación de métodos ambiguos. El tipo no indica claramente qué implementación del método debe usar. Como resultado, obtenemos un error de compilación. Para arreglar esto, tenemos que agregar la implementación del método al tipo.

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

Muchos lenguajes de programación orientados a objetos están plagados de limitaciones en torno a la resolución de definiciones de extensiones ambiguas. Swift maneja esto con bastante elegancia a través de extensiones de protocolo al permitir que el programador tome el control donde el compilador se queda corto.

Agregar nuevos métodos

Echemos un vistazo al protocolo Queue una vez más.

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

Cada tipo que se ajusta al protocolo Queue tiene una propiedad de instancia de count que define el número de elementos almacenados. Esto nos permite, entre otras cosas, comparar dichos tipos para decidir cuál es más grande. Podemos agregar este método a través de la extensión del 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 } }

Este método no se describe en el propio protocolo de Queue porque no está relacionado con la funcionalidad de la cola.

Por lo tanto, no es una implementación predeterminada del método de protocolo, sino una implementación de método nuevo que "decora" todos los tipos que se ajustan al protocolo Queue . Sin extensiones de protocolo, tendríamos que agregar este método a cada tipo por separado.

Extensiones de protocolo frente a clases base

Las extensiones de protocolo pueden parecer bastante similares al uso de una clase base, pero existen varios beneficios al usar extensiones de protocolo. Estos incluyen, pero no se limitan necesariamente a:

  1. Dado que las clases, las estructuras y las enumeraciones pueden ajustarse a más de un protocolo, pueden adoptar la implementación predeterminada de varios protocolos. Esto es conceptualmente similar a la herencia múltiple en otros lenguajes.

  2. Los protocolos pueden ser adoptados por clases, estructuras y enumeraciones, mientras que las clases base y la herencia están disponibles solo para clases.

Extensiones de la biblioteca estándar de Swift

Además de ampliar sus propios protocolos, puede ampliar los protocolos de la biblioteca estándar de Swift. Por ejemplo, si queremos encontrar el tamaño promedio de la colección de colas, podemos hacerlo extendiendo el protocolo de Collection estándar.

Las estructuras de datos de secuencia proporcionadas por la biblioteca estándar de Swift, cuyos elementos se pueden recorrer y acceder a través de subíndices indexados, generalmente se ajustan al protocolo de Collection . A través de la extensión de protocolo, es posible extender todas esas estructuras de datos de biblioteca estándar o extender algunas de ellas de forma selectiva.

Nota: El protocolo anteriormente conocido como CollectionType en Swift 2.x pasó a llamarse Collection en 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()))) } }

Ahora podemos calcular el tamaño promedio de cualquier colección de colas ( Array , Set , etc.). Sin extensiones de protocolo, hubiéramos tenido que agregar este método a cada tipo de colección por separado.

En la biblioteca estándar de Swift, las extensiones de protocolo se utilizan para implementar, por ejemplo, métodos como map , filter , reduce , etc.

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

Extensiones de protocolo y polimorfismo

Como dije anteriormente, las extensiones de protocolo nos permiten agregar implementaciones predeterminadas de algunos métodos y también agregar nuevas implementaciones de métodos. Pero, ¿cuál es la diferencia entre estas dos características? Volvamos al controlador de errores y averigüémoslo.

 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)

El resultado es un error fatal.

Ahora elimine la declaración del método handle(error: Error) del protocolo.

 protocol ErrorHandler { }

El resultado es el mismo: un error fatal.

¿Significa que no hay diferencia entre agregar una implementación predeterminada del método de protocolo y agregar una nueva implementación de método al protocolo?

¡No! Existe una diferencia, y puede verla cambiando el tipo de handler de variables de Handler a ErrorHandler .

 let handler: ErrorHandler = Handler()

Ahora la salida a la consola es: No se pudo completar la operación. (Error de aplicación 0.)

Pero si devolvemos la declaración del método handle(error: Error) al protocolo, el resultado volverá a ser el error fatal.

 protocol ErrorHandler { func handle(error: Error) }

Veamos el orden de lo que sucede en cada caso.

Cuando existe declaración de método en el protocolo:

El protocolo declara el método handle(error: Error) y proporciona una implementación predeterminada. El método se anula en la implementación del Handler . Por lo tanto, la implementación correcta del método se invoca en tiempo de ejecución, independientemente del tipo de variable.

Cuando la declaración del método no existe en el protocolo:

Debido a que el método no está declarado en el protocolo, el tipo no puede anularlo. Es por eso que la implementación de un método llamado depende del tipo de variable.

Si la variable es de tipo Handler , se invoca la implementación del método del tipo. En caso de que la variable sea de tipo ErrorHandler , se invoca la implementación del método desde la extensión del protocolo.

Código orientado a protocolos: seguro pero expresivo

En este artículo, demostramos parte del poder de las extensiones de protocolo en Swift.

A diferencia de otros lenguajes de programación con interfaces, Swift no restringe los protocolos con limitaciones innecesarias. Swift soluciona las peculiaridades comunes de esos lenguajes de programación al permitir que el desarrollador resuelva la ambigüedad según sea necesario.

Con los protocolos Swift y las extensiones de protocolo, el código que escribe puede ser tan expresivo como la mayoría de los lenguajes de programación dinámicos y aún así tener seguridad de tipos en el momento de la compilación. Esto le permite garantizar la reutilización y la capacidad de mantenimiento de su código y realizar cambios en la base de código de su aplicación Swift con más confianza.

Esperamos que este artículo le sea útil y agradecemos cualquier comentario o información adicional.