Swift – Tipos Genéricos

Hoy aprenderemos sobre los tipos genéricos en Swift, un tema bien importante dentro del lenguaje y fundamental en nuestro camino a la especialización.

Los tipos genérico constituyen una de las características más potentes del lenguaje Swift, de hecho, un alto porcentaje de las librerías estándar de Swift están escrita con código genérico. Incluso hasta nosotros hemos estado usando código genérico todo este tiempo, me refiero a los Array y Dictionary, ya que ambos son colecciones genéricas.

¿Por qué?

Si analizamos con detenimiento, no existe un tipo ArrayInt o ArrayString, solamente contamos con una sola versión de esta colección. Por esta razón es que decimos que ha sido implementado usando código genérico, ya que podemos crear un Array que contenga valores de tipo Int y otro con valores String, de manera similar podemos crear un Dictionary con valores de cualquier tipo.

Motivación

Veamos un ejemplo clásico que motiva a su vez el uso de código genérico. Consiste en dos funciones NO genéricas llamadas swapInts y swapStrings:

//: Playground - noun: a place where people can play

import Cocoa

func swapInts(firstValue: inout Int, secondValue: inout Int) {
    
    let temporalValue = firstValue
    
    firstValue = secondValue
    
    secondValue = temporalValue
    
} // intercambioDeEnteros

func swapStrings(firstValue: inout String, secondValue: inout String) {
    
    let temporalValue = firstValue
    
    firstValue = secondValue
    
    secondValue = temporalValue
    
} // intercambioDeEnteros

var firstIntValue = 10
var secondIntValue = 20

var firstStringValue = "The"
var secondStringValue = "WiseRatel"

print("El valor de firstIntValue es: \(firstIntValue) y el de secondIntValue es: \(secondIntValue)")
print("El valor de firstStringValue es: \(firstStringValue) y el de secondStringValue es: \(secondStringValue)")

swapInts(firstValue: &firstIntValue, secondValue: &secondIntValue)
swapStrings(firstValue: &firstStringValue, secondValue: &secondStringValue)

print()

print("El valor de firstIntValue es: \(firstIntValue) y el de secondIntValue es: \(secondIntValue)")
print("El valor de firstStringValue es: \(firstStringValue) y el de secondStringValue es: \(secondStringValue)")

la salida en pantalla sería:

El valor de firstIntValue es: 10 y el de secondIntValue es: 20
El valor de firstStringValue es: The y el de secondStringValue es: WiseRatel

El valor de firstIntValue es: 20 y el de secondIntValue es: 10
El valor de firstStringValue es: WiseRatel y el de secondStringValue es: The

En este ejemplo tenemos dos funciones que nos vienen a ayudar con el intercambio de valores entre dos variables, para esto hemos usado parámetros inout (en el artículo sobre las funciones en Swift explicamos el uso de parámetros inout) y como se habrán dado cuenta hemos creado dos versiones, una enfocada en el tipo Int y la otra en String.

Ahora, imaginémonos que luego de unos pocos meses nuestro jefe nos informa que necesitamos un intercambio de valores para un tipo Double o para uno custom (personalizado), bajo estos requerimientos tendríamos que crear otra función similar a estas dos ya creadas, re-compilar nuestro código y volver a distribuir la nueva versión de nuestra aplicación.

¿Podemos reducir todo este trabajo? ¿Habrá alguna solución eficiente?

La respuesta es sí, veamos a continuación algunas soluciones posibles:

Usando Any

El lenguaje Swift provee dos alias de tipo para cuando trabajamos con tipos no específicos, es decir cuando no sabes que tipo de valor contendrá una variable, ellos son:

  • AnyObject – Puede representar una instancia de cualquier tipo de clase.
  • Any – Puede representar una instancia de cualquier tipo, incluyendo funciones.

Dicho esto pues una posible solución sería hacer uso de Any para unificar así el intercambio de valores en una sola función:

func swapValues(firstValue: inout Any, secondValue: inout Any) {
    
    let temporalValue = firstValue
    
    firstValue = secondValue
    
    secondValue = temporalValue
    
} // intercambioDeEnteros

var firstIntValue: Any = 10
var secondIntValue: Any = 20

var firstStringValue: Any = "The"
var secondStringValue: Any = "WiseRatel"

print("El valor de firstIntValue es: \(firstIntValue) y el de secondIntValue es: \(secondIntValue)")
print("El valor de firstStringValue es: \(firstStringValue) y el de secondStringValue es: \(secondStringValue)")

swapValues(firstValue: &firstIntValue, secondValue: &secondIntValue)
swapValues(firstValue: &firstStringValue, secondValue: &secondStringValue)

print()

print("El valor de firstIntValue es: \(firstIntValue) y el de secondIntValue es: \(secondIntValue)")
print("El valor de firstStringValue es: \(firstStringValue) y el de secondStringValue es: \(secondStringValue)")

la salida en pantalla es idéntica al ejemplo anterior.

Esta nueva versión del código comienza con la definición de una función llamada swapValues que es muy similar a las anteriores, se diferencia de estas solamente en el tipo de parámetros con los que trabaja, en este caso de tipo Any, permitiéndonos así pasar varios tipos de datos sin necesidad de duplicar o triplicar nuestro código.

¿Es esta una solución real?

No, solamente sería práctica en algunos casos, pero no en el que estamos desarrollando.

Swift no permite que pasemos una variable Int a una función que espera inout Any, por esto es que tuvimos que declarar nuestras variables como Any en lugar de Int y String. Es decir que no estamos interactuando con varios tipos de datos, hemos unificando nuestros valores numéricos y de cadena bajo el tipo Any para así poder pasarlo a la función swapValues.

El problema con este enfoque es que terminado el intercambio de valores si intentamos, como parte de nuestra lógica, pasar estos valores a otros métodos o funciones que quizás esperen un valor Int o String, pues no podremos, el compilador de Swift nos mostraría un error similar a este:

error: cannot convert value of type 'Any' (aka 'protocol<>') to expected argument type 'String'

el mensaje de error nos informa que no podemos convertir de un tipo Any al tipo del argumento esperado que en este caso es String. Es evidente que tener nuestros valores como de tipo Any nos limita un poco, lo ideal sería tener, de igual manera, una sola función pero que sea capaz de interactuar con cualquier tipo de datos sin necesidad de recurrir a Any.

Polimorfismo paramétrico

La solución real a este tipo de problemas es el uso de código genérico, lo que también se conoce como polimorfismo paramétrico y que no es más que la técnica donde una función es escrita de un modo en el que esta puede ejecutar sus operaciones sobre los valores de los parámetros sin importar de que tipo de dato sean estos.

Para lograr este enfoque tendríamos que modificar nuestra función de la siguiente manera:

func swapValues<T>( firstValue: inout T, secondValue: inout T) {
    
    let temporalValue = firstValue
    
    firstValue = secondValue
    
    secondValue = temporalValue
    
} // swapValues

en la primera línea se concentran todos los cambios. Podemos notar el segmento <T> a continuación del nombre de la función y esto significa que los parámetros que estaremos manejando serán de tipo T.

Al mismo tiempo todos sabemos que no existe el tipo T, y es que en este ámbito T viene a ser un comodín que enmascara cualquier tipo de dato. Así cuando trabajamos con enteros, T sería de tipo Int y cuando trabajamos con cadenas pues de tipo String, es decir T será del mismo tipo de dato que el de los parámetros que estemos pasando a la función.

Como bien se explica en los comentarios de este ejemplo:

var x: Int = 10
var y: Int = 20

// En esta llamada a swapValues() T es de tipo Int, ya que tanto x como y son ambos de tipo Int

swapValues(firstValue: &x, secondValue: &y)

var a = "Hola"
var b = "Mundo"

// En esta llamada a swapValues() T es de tipo String, ya que tanto a como b son ambos de tipo String

swapValues(firstValue: &a, secondValue: &b)

Es bueno aclarar que lo que le da esta denotación especial a la letra T es el hecho de haberla declarado dentro de <>, si hubiéramos escrito <A> por ejemplo, pues A sería el tipo genérico a manejar dentro de la función.

Lo que sucede es que la mayoría de los libros siempre escogen T (deduzco que viene de Type), en otro lenguajes de programación también se usa T entonces como ven ya esto se ha vuelto como una norma a seguir.

Tipos genéricos

Al igual que podemos declarar funciones que interactúan con tipos de datos de manera genérica, también podemos definir tipos genéricos.

Imaginemos la situación donde queremos definir nuestra propia colección de datos, una Pila. Recordemos que una Pila es una estructura de datos last-in first-out, es decir el último en entrar es el primero en salir, como en una pila de platos que ponemos sobre una mesa.

El último que ponemos es el primero que podemos tomar de esta, luego seguiríamos con el de más abajo y así hasta llegar al último que está haciendo contacto con la mesa y que fue el primero en ponerse.

Nuestra Pila tendrá, como todas, dos funciones básicas: la primera donde introduciremos (push) un elemento en ella y la segunda donde extraeremos (pop) él elemento que se encuentre en la cima de la misma, el último que se introdujo. Tal y como podemos ver en la siguiente imagen:

Genéricos - Comportamiento de una Pila

Si a la hora de implementar nuestra Pila usamos un enfoque tradicional en el cual no usamos código genérico, terminaríamos por tener una Pila que solamente tenga soporte para un solo tipo de dato, veamos:

struct Stack {
    
    var items = [Int]()
    
    mutating func push(newItem: Int) {
        
        items.append(newItem)
        
    } // push
    
    mutating func pop() ->Int? {
        
        guard !items.isEmpty else {
            
            return nil
            
        } // guard
        
        return items.removeLast()
        
    } // pop
    
} // Stack

var intStack = Stack()

intStack.push(newItem: 1)
intStack.push(newItem: 2)

print(intStack.pop() as Any)
print(intStack.pop() as Any)
print(intStack.pop() as Any)

la salida en pantalla sería:

Optional(2)
Optional(1)
nil

Luego de implementar nuestra pila creamos una instancia de la misma, luego hacemos uso del método push para introducir dos enteros y ya al final efectuamos tres llamadas al método pop el cual nos devuelve el último elemento en la pila.

La tercera llamada al método pop fue deliberada para probar el bloque guard, como podemos observar nos retorna nil debido a que no hay un tercer elemento en la Pila.

Aunque este ejemplo funciona, sucede lo mismo que en el primer ejemplo de este artículo, está limitado a trabajar solamente con un tipo de dato. Así que veamos como podemos modificar este código para lograr una colección genérica:

struct Stack<T> {
    
    var items = [T]()
    
    mutating func push(newItem: T) {
        
        items.append(newItem)
        
    } // push
    
    mutating func pop() ->T? {
        
        guard !items.isEmpty else {
            
            return nil
            
        } // guard
        
        return items.removeLast()
        
    } // pop
    
} // Stack

var intStack = Stack<Int>()
var doubleStack = Stack<Double>()
var stringStack = Stack<String>()

intStack.push(newItem: 1)
intStack.push(newItem: 2)

doubleStack.push(newItem: 2.5)
doubleStack.push(newItem: 3.14)

stringStack.push(newItem: "Hola")
stringStack.push(newItem: "Mundo")

print(intStack.pop() as Any)
print(intStack.pop() as Any)
print(intStack.pop() as Any)

print()

print(doubleStack.pop() as Any)
print(doubleStack.pop() as Any)
print(doubleStack.pop() as Any)

print()

print(stringStack.pop() as Any)
print(stringStack.pop() as Any)
print(stringStack.pop() as Any)

la salida en pantalla sería:

Optional(2)
Optional(1)
nil

Optional(3.1400000000000001)
Optional(2.5)
nil

Optional("Mundo")
Optional("Hola")
nil

De la línea 26 a las 28 podemos constatar los beneficios de esta última versión, creamos tres pilas distintas de tres tipos de datos distintos, con una sola implementación, con el mismo código.

Funciones genéricas

Lo que hemos visto hasta ahora lo podemos hacer también con las clases y las enumeraciones, haciendo uso de la misma sintaxis, incluso los métodos de las clases o las estructuras pueden ser genéricos. Por ejemplo: nuestra recién creada función map la podemos convertir en un método de nuestra Pila, veamos:

struct Stack<T> {
    
    var items = [T]()
    
    mutating func push(newItem: T) {
        
        items.append(newItem)
        
    } // push
    
    mutating func pop() ->T? {
        
        guard !items.isEmpty else {
            
            return nil
            
        } // guard
        
        return items.removeLast()
        
    } // pop
    
    func map<U>(f: (T) -> U) -> Stack<U> {
        
        var mappedItems = [U]()
        
        for item in items {
            
            mappedItems.append(f(item))
            
        } // for
        
        return Stack<U>(items: mappedItems)
        
    } // map
    
} // Stack

var intStack = Stack<Int>()

intStack.push(newItem: 1)
intStack.push(newItem: 2)

var resultStack = intStack.map(f: { 2 * $0})

print(intStack.pop() as Any)
print(intStack.pop() as Any)

print()

print(resultStack.pop() as Any)
print(resultStack.pop() as Any)

la salida en pantalla sería:

Optional(2)
Optional(1)

Optional(4)
Optional(2)

Como podemos constatar la función map ha sufrido algunos cambios para adaptarla a las necesidades de nuestra Pila. Como podemos ver en la línea 23 especificamos un solo comodín U cuando dentro del método trabajamos también con T.

Lo que sucede es que T está siendo manejado a nivel de la estructura y lógicamente puede ser usado por nuestro método, por esta razón no tiene sentido especificarlo.

En los parámetros ya no necesitamos el arreglo pues ya se ha especificado por la estructura así que solamente necesitamos especificar el closure que ejecutará sobre nuestro método el usuario de nuestra Pila, tal y como vemos en la línea 44, la cual también pudiéramos haber escrito de la siguiente forma:

var resultStack = intStack.map(f: { 2 * $0})

Una nueva Pila es retornada con los resultados de multiplicar cada elemento por 2, valores que luego imprimimos en las líneas 51 y 52.

¿Es realmente map un método genérico?

Si nos estemos preguntando si el método map es realmente genérico por qué no podemos multiplicar una cadena de texto por un número determinado como en el último ejemplo, y esto al final nos limita a usar valores numéricos.

Pues ciertamente es así, pero también tenemos que tener en cuenta de que hay una parte analítica que reside en nosotros, por esto jamás necesitaremos multiplicar una cadena de texto, pues no tiene sentido, por otra parte dado que el closure lo declaramos nosotros pues podríamos hacer lo siguiente:

var intStack = Stack<String>()

intStack.push(newItem: "Hola")
intStack.push(newItem: "Mundo")

var resultStack = intStack.map() { $0.uppercased() }

print(intStack.pop() as Any)
print(intStack.pop() as Any)

print()

print(resultStack.pop() as Any)
print(resultStack.pop() as Any)

la salida en pantalla sería:

Optional("Mundo")
Optional("Hola")

Optional("MUNDO")
Optional("HOLA")

Así es, convertimos el texto a mayúsculas, algo que a su vez sería absurdo sobre valores numéricos, y lo hemos logrado sin modificar el código de nuestra Pila. Por esta razón decía que una parte del código genérico recae en nosotros, en el análisis de nuestro lado en cuanto a lo que tiene sentido y lo que no.

En este ejemplo que hemos abordado es más que evidente que no pudiéramos haber logrado el comportamiento deseado sin el closure ya que aquí es donde realmente se marca la diferencia, aquí es donde permitimos que el código del ejemplo se comporte de manera realmente genérica.

Múltiples comodines

Asumamos la necesidad de una función map, la cual ejecutará un closure (¿Qué es un Closure?) que el usuario defina sobre cada elemento de un arreglo, terminando por retornar otro arreglo con los resultados.

La función en cuestión es la siguiente:

func map<T, U>(items: [T], f: (T) -> (U)) -> [U] {
        
   var result = [U]()
        
   for item in items {
            
      result.append(f(item))
            
   } // for
        
   return result

} // map

vemos en la firma de esta función una variación en la sintaxis que habíamos usado hasta ahora, y es que tenemos dos comodines en lugar de uno, tenemos a T y U.

Esto lo que viene a significar es lo mismo que hemos comentado hasta ahora con la diferencia de que en este método se manejarán dos tipos de datos distintos en lugar de uno, por ende T y U no pueden sustituir al mismo tipo de dato.

Por ejemplo T puede ser Double y U quizás Int y viceversa, pero ambos jamás podrán ser del mismo tipo al mismo tiempo, es decir no pueden ser ambos Int, Double, String o cualquier otro tipo ya que de ser así no necesitaríamos a U, con T ya sería suficiente.

Como acabamos de comentar en esta función manejamos dos tipos de datos y lo expresamos de esa manera, luego se definen los dos parámetros que recibirá este método.

El primero es un arreglo de tipo T y el segundo una función o closure que recibe un parámetro de tipo T y retorna un valor de tipo U, finalizamos con el valor de retorno de nuestra función que será un arreglo de tipo U.

Para darle un poco de contexto a estas variables diríamos que T es el tipo de dato de cada elemento en un arreglo. Luego U vendría a ser ese elemento T ya modificado por el closure, razón por la que necesitamos representarlo con otra letra cualquiera, para así, de manera explícita, informar al compilador que el tipo de dato no será el mismo.

En la primera línea creamos un arreglo vacío de tipo U que contendrá el arreglo final que retornaremos. Luego pasamos al bucle for que itera por cada elemento del arreglo de tipo T.

Dentro del for hacemos uso del método append de la colección Array y sobre este método hacemos una llamada al closure, quedando almacenado el valor que este devuelva, al final retornamos el arreglo de tipo U.

Veamos un ejemplo de esta función en uso:

func map<T, U>(items: [T], f: (T) -> (U)) -> [U] {
    
    var result = [U]()
    
    for item in items {
        
        result.append(f(item))
        
    } // for
    
    return result
    
} // map

let strings = ["one", "two", "three"]

let stringLengths = map(items: strings, f: { $0.characters.count })

print(stringLengths)

En la línea 15 creamos un arreglo de tipo String con tres cadenas, luego en la línea 17 creamos una constante llamada stringLengths que a su vez establecemos que sea igual al arreglo que retorne nuestra función map.

Como podemos observar a map le pasamos el arreglo en su primer parámetro y acto seguido establecemos un closure que hace referencia a item que sería lo mismo que decir que $0 representa cada elemento del arreglo en cada iteración del bucle for.

Sobre este elemento obtenemos la cantidad de caracteres en un valor de tipo Int y lo almacenamos en el arreglo result que al final es retornado y almacenado en stringLengths y que imprimimos mostrando la siguiente salida en pantalla:

[3, 3, 5]

donde tenemos la cantidad de caracteres de cada palabra almacenada en el arreglo.

Restricción por Tipo

Hasta ahora creo que se entiende el valor de tener una Pila que acepte cualquier tipo de dato en lugar de una que esté limitada a tipos Int o String.

Ahora, todo tiene un costo. El impacto práctico de no conocer el tipo de dato final es que podemos hacer bien poco con el comodín (<T>) que estemos usando.

Es algo lógico, ciertas operaciones son válidas para un tipo determinado de datos, la multiplicación en el caso de tipos numéricos, mientras que en el caso de un tipo String esta operación generaría un error.

Otro ejemplo sería el siguiente: no podemos comprobar que dos comodines del mismo tipo son iguales, el siguiente código generaría un error:

func checkIfEqual<T>(first: T, _ second: T) -> Bool {
    
    return first == second
    
} // checkIfEqual

el código de error sería:

Playground execution failed: TypeConstraints.playground:1:18: error: binary operator '==' cannot be applied to two 'T' operands

El problema reside en que si esta función compilase podría trabajar con cualquier tipo de dato incluyendo aquellos a los que no tendría sentido que comparásemos como closures o tipos definidos por nosotros que no adoptan el protocolo Equatable.

Dicho esto creo que se entiende perfectamente porque es un comportamiento peligroso y que el compilador prohibe. Swift está enfocado en la seguridad y es de esperar que algo así (con tan poco contexto) jamás compile.

¿Solución?

La solución es bien sencilla. Swift, que es un lenguaje muy bien pensado, nos permite restringir el tipo que podemos enmascarar a través del comodín. Es decir, nos permite especificar con cuales características debe cumplir el tipo de dato para que luego las operaciones sobre este sean válidas, y evitar así un error en tiempo ejecución al intentar dividir una cadena de texto. 🤷‍♂️

A esto se le llama restricción de tipo y lo explico para que se entienda mejor.

Hay dos  restricciones de tipo:

  • Se establece que el tipo de dato sea una subclase de una clase específica.
  • El tipo tiene que adoptar un protocolo en particular.

Un ejemplo de esto último sería el protocolo Equatable del que hablamos hace poco. Con este estaríamos restringiendo el uso de tipos de datos que puedan ser comparados y así evitar los errores antes comentados, veamos:

func checkIfEqual<T: Equatable>(first: T, _ second: T) -> Bool {
    
    return first == second
    
} // checkIfEqual

con esta pequeña modificación ya nuestro código compila.

Algo similar pudiéramos hacer para comparar la descripción de dos tipos de datos:

func checkIfDescriptionsMatch<T: CustomStringConvertible, U: CustomStringConvertible>(first: T, _ second: U) -> Bool {
    
    return first.description == second.description
    
} // checkIfEqual

aquí vemos una versión del ejemplo anterior, ahora trabaja con dos tipos de datos distintos y para ser aceptados por la función genérica cada uno de estos tiene que adoptar el protocolo CustomStringConvertible.

Veamos un ejemplo de uso:

func checkIfDescriptionsMatch<T: CustomStringConvertible, U: CustomStringConvertible>(first: T, _ second: U) -> Bool {
    
    return first.description == second.description
    
} // checkIfEqual

print(checkIfDescriptionsMatch(Int(1), UInt(1)))
print(checkIfDescriptionsMatch(1, 1.0))
print(checkIfDescriptionsMatch(Float(1.0), Double(1.0)))
print(checkIfDescriptionsMatch(5, "5"))

la salida en pantalla:

true
false
true
true

En caso de que estos datos no adoptasen el protocolo CustomStringConvertible no podríamos acceder a la propiedad description y por ende obtendríamos un error en tiempo de ejecución.

La restricción de tipo es esto, lo que hemos visto. Su función no es otra que brindar contexto a la función genérica sobre los datos que recibirá como parámetro. Todo a favor de la seguridad.

Protocolos con tipos asociados

Cuando definimos un protocolo y tenemos que declarar la firma de un método genérico nos encontramos ante un problema. Como ya sabemos en el caso de los métodos genéricos no sabemos con antelación que tipo de dato este manejará, entonces:

¿Cómo especificamos esto dentro de la declaración del protocolo teniendo en cuenta de que no podemos usar T?

AssociatedType

La solución al problema anterior reside en el uso de tipos asociados como parte de la propia definición del protocolo, siendo esta la vía de hacer referencia a este valor a priori del tipo de dato final.

Veamos un ejemplo de un tipo asociado:

protocol Container {
    
    associatedtype ItemType
    
    mutating func append(item: ItemType)
    
    var count: Int { get }
    
    subscript(i: Int) -> ItemType { get }

} // Container

Un tipo asociado se define mediante la palabra clave associatedtype, en el ejemplo lo hemos llamado: ItemType. Este tipo de dato asociado enmascara un tipo de dato que aún no se conoce y es lógico. Nosotros desconocemos quién implementará el protocolo y por ende tampoco podemos predecir que tipo de dato se utilizará.

El protocolo Container define tres requerimientos que todo aquel que lo adopte tiene que cumplir:

  • Tiene que permitir agregar un elemento nuevo al contenedor haciendo uso del método append(_:).
  • Tiene que permitir el acceso al número de elementos que forman parte del contenedor a través de la propiedad count.
  • Tiene que permitir obtener cada elemento del contenedor con un subscript que reciba como parámetro el índice del valor.

Como podemos constatar este protocolo no especifica como se almacenarán los elementos en el contenedor o de que tipo de dato estos debieran de ser, solamente se declaran las tres funcionalidades que tienen que ser implementadas para que pueda ser adoptado satisfactoriamente.

Veamos un ejemplo de este protocolo en uso:

protocol Container {
    
    associatedtype ItemType
    
    mutating func append(item: ItemType)
    
    var count: Int { get }
    
    subscript(i: Int) -> ItemType { get }

} // Container

struct IntStack: Container {

    var items = [Int]()

    typealias ItemType = Int
    
    mutating func push(item: Int) {
        
        items.append(item)
    
    } // push
    
    mutating func pop() -> Int {
        
        return items.removeLast()
    
    } // pop
    
    mutating func append(item: Int) {
        
        self.push(item)
    
    } // append
    
    var count: Int {
        
        return items.count
    
    } // count
    
    subscript(i: Int) -> Int {
        
        return items[i]
    
    } // subscript

} // IntStack

La pila IntStack, como su propio nombre lo indica, trabaja solamente con el tipo Int, por ende en esta línea estamos informando al compilador de manera explicita que el tipo ItemType ya no es desconocido.

Si te estás preguntando si esto que hemos hecho se pudiera aplicar a un tipo genérico, la respuesta es sí.

Veamos este ejemplo de la versión genérica de nuestra Pila:

struct Stack<T> {
    
    var items = [T]()
    
    mutating func push(newItem: T) {
        
        items.append(newItem)
        
    } // push
    
    mutating func pop() ->T? {
        
        guard !items.isEmpty else {
            
            return nil
            
        } // guard
        
        return items.removeLast()

    } // pop
    
    mutating func append(item: T) {
        
        self.push(item)
    
    } // append
    
    var count: Int {
        
        return items.count
    
    } // count
    
    subscript(i: Int) -> T {
        
        return items[i]
    
    } // subscript

} // Stack

var stringStack = Stack<String>()

stringStack.append("Hola")
stringStack.append("Mundo")

print()

print("Número de elementos en la Pila: \(stringStack.count)")

print()

print(stringStack.pop())
print(stringStack.pop())

print()

print("Número de elementos en la Pila: \(stringStack.count)")

print()

la salida en pantalla sería:

Número de elementos en la Pila: 2

Optional("Mundo")
Optional("Hola")

Número de elementos en la Pila: 0

Como era de esperar funciona perfectamente, y trabajamos con T como nuestro comodín y como ven no ha habido necesidad de hacer referencia a ItemType ya que el tipo de dato es inferido por el compilador de Swift.

Restringiendo la extensión de protocolos

Los protocolos también pueden ser extendidos y en estas ocasiones quizás nos resulte útil filtrar ciertos comportamientos. Es decir, que la extensión de código se encuentre disponible solamente bajo ciertas condiciones, ejemplo: que el tipo de dato comodín sea igual a String.

Veamos un ejemplo de esto:

protocol Animal {

    associatedtype Food

    func eat(food: Food)

} // Animal

struct Cow: Animal {

    typealias Food = String

    func eat(food: Food) {

       print("Soy una vaca y estoy comiendo: \(food)")

    } // eat
 
} // Cow
 
let cow = Cow()
 
cow.eat(food: "Hierba")

print()

struct Dog: Animal {
    
    typealias Food = [String]

    func eat(food: Food) {

       food.forEach {

          print("Soy un perro y estoy comiendo: \($0)")

       } // Array.forEach

    } // eat

} // Dog

let dog = Dog()
 
dog.eat(food: ["Pastel", "Batata", "Hueso"])

la salida en pantalla:

Soy una vaca y estoy comiendo: Hierba

Soy un perro y estoy comiendo: Pastel
Soy un perro y estoy comiendo: Batata
Soy un perro y estoy comiendo: Hueso

Hasta aquí todo bien, nada nuevo. Imaginemos ahora que necesita una implementación por defecto del método eat para todos aquellos que lo adopten.

Para lograr esto hacemos uso de las extensiones, de la siguiente forma:

protocol Animal {

    associatedtype Food

    func eat(food: Food)
    
} // Animal

extension Animal where Food: ExpressibleByStringLiteral {

    func eat(food: Food) {
        
        print("Soy una vaca y estoy comiendo: \(food)")
    
    } // eat
    
} // Animal extension

struct Cow: Animal {

    typealias Food = String

} // Cow

let cow = Cow()

cow.eat(food: "Hierba")

print()

struct Cat: Animal {
    
    typealias Food = String

} // Cow

let cat = Cat()

cat.eat(food: "Pescado")

la salida en pantalla sería la siguiente:

Soy una vaca y estoy comiendo: Hierba

Soy una vaca y estoy comiendo: Pescado

De la línea 9 a la 17 extendemos el protocolo Animal con una implementación por defecto del método eat y donde especificamos (haciendo uso de la sentencia where), que esta extensión solamente estará disponible para aquellos usuarios donde el comodín Food sea igual al tipo de dato String.

Si seguimos analizando el código vemos que hemos omitido el método eat en la estructura Cow dejando solamente la declaración explicita de que Food es de tipo String. Proseguimos creando una instancia de Cow y hacemos uso del método eat sin problemas.

Lo siguiente es otra declaración, en este caso de una estructura de nombre Cat pero a la que no le definimos el método eat y que al igual que la anterior, Food también es de tipo String.

Los tipos asociados son de gran utilidad cuando trabajamos con protocolos y queremos definir funciones o método genéricos. Pero también tenemos que ser cuidadosos, como podemos ver en la salida en pantalla del último código, el descuido que hemos cometido nos han creado un gato con problemas de identidad, el gato se cree una vaca y aun así come pescado :-).

Falta aún mucho por aprender en nuestro camino a convertirnos en iOS Developer. Suscríbete a nuestra lista de correo y síguenos en nuestras redes sociales. Mantente al tanto de todas nuestras publicaciones.

Espero que todo cuanto se ha dicho aquí, de una forma u otra le haya servido de aprendizaje, de referencia, que haya valido su preciado tiempo.

Este artículo, al igual que el resto, será revisado con cierta frecuencia en pos de mantener un contenido de calidad y actualizado.

¡Cualquier duda o sugerencia, ya sea errores a corregir o ejemplos a añadir, será más que bienvenida, necesaria!

Deja una respuesta

Su dirección de correo electrónico no será publicada.

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.

Este sitio web utiliza cookies para mejorar su experiencia. Asumiremos que está de acuerdo con esto, pero puede optar por no participar si lo desea. Aceptar Leer Más

RECIBE CONTENIDO SIMILAR EN TU CORREO

RECIBE CONTENIDO SIMILAR EN TU CORREO

Suscríbete a nuestra lista de correo y mantente actualizado con las nuevas publicaciones.

Se ha suscrito correctamente!