Swift y Objective-C

[Swift intermedio #4] Dominar los tipos asociados de Swift (associatedtype)

Los tipos asociados (associatedtype) son espacios reservados para tipos dentro de los protocolos. Aquí se explica el famoso error de generic constraint, cómo SE-0309 y SE-0346 eliminaron sus restricciones y cómo usar primary associated types.

6 min de lectura
Imagen de portada de [Swift intermedio #4] Dominar los tipos asociados de Swift (associatedtype)

Al usar protocolos de Swift, tarde o temprano aparece un muro: los errores de tipos asociados al intentar usar Equatable como tipo de variable o guardar Collection en una propiedad. El célebre “Protocol can only be used as a generic constraint because it has Self or associated type requirements” es el ejemplo más conocido. La identidad de ese muro son los tipos asociados (associatedtype).

Este es el cuarto artículo de la serie intermedia. Repasamos qué son los tipos asociados, por qué aparecía aquel error y la evolución del lenguaje hasta primary associated types. Si has leído los artículos sobre genéricos y some·any, ya tienes todos los conceptos necesarios.

Qué son los tipos asociados — Espacios para tipos dentro de los protocolos

En el artículo sobre genéricos, <T> se describió como un “espacio para completar el tipo más adelante”. Un tipo asociado es ese espacio dentro de un protocolo.

Supongamos que creamos un protocolo de contenedor. Queremos abstraer la capacidad común de “insertar y extraer elementos” de pilas y colas, pero el problema es el tipo del elemento. Los elementos de IntStack son Int y los de StringQueue son String, así que el protocolo no puede fijar el tipo. Lo declaramos como un espacio.

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

El tipo que adopta el protocolo completa el espacio: IntStack usa Int y StringQueue usa String para Item. Normalmente ni siquiera hace falta escribir explícitamente typealias Item = Int, porque el compilador lo infiere a partir del tipo del parámetro de append.

En realidad, no es un concepto nuevo. La biblioteca estándar está construida por completo con tipos asociados: Element e Index de Collection, Element de IteratorProtocol y C.Element, visto en las cláusulas where del artículo sobre genéricos, apuntan a este espacio. Incluso Equatable tiene un requisito Self (static func == (lhs: Self, rhs: Self) -> Bool), primo de los tipos asociados. En resumen, son ciudadanos de primera clase del mundo de los protocolos.

La diferencia con <T> de los genéricos se resume así: el parámetro genérico lo completa quien usa el código, mientras que el tipo asociado lo completa quien adopta el protocolo. Stack<Int> permite que el usuario elija Int, pero Item de Container lo decide el propio tipo IntStack.

Por qué aparece el error — No se puede determinar el formato de la caja

Ahora podemos analizar aquel famoso error. ¿Por qué un protocolo con tipos asociados no podía usarse como tipo?

En el artículo sobre some·any dijimos que una variable de tipo protocolo es un tipo existencial, es decir, una caja. Escribir var c: Container significa crear una caja que contiene algo que conforma a Container. El problema surge al extraer un elemento: ¿cuál es el tipo de c[0]? Es Item, pero depende de lo que haya dentro de la caja. En IntStack es Int y en StringQueue es String. El compilador no puede responder mirando solo la caja.

Una expresión cuyo tipo no puede determinarse no se admite en un lenguaje de tipado estático, así que las versiones antiguas de Swift la bloqueaban en la entrada. El error significaba realmente: “Usa este protocolo solo como restricción”. Con el genérico <C: Container>, C queda determinado al llamar y C.Item también, por lo que no hay ningún problema. El mensaje era poco amable, pero la solución era exacta: usar un genérico en lugar de una caja.

Ilustración de un robot que no puede inspeccionar una caja de tipo desconocido y un sello de restricción genérica
La entrada estaba bloqueada porque no se podía conocer el tipo de Item extraído de la caja.

Evolución del lenguaje — La historia de cómo se abrieron los cerrojos

Este inconveniente tuvo mala fama durante años, y Swift fue abriendo el cerrojo mediante varias propuestas.

Swift 5.7, propuesta Swift Evolution SE-0309. Los protocolos con tipos asociados ya pueden usarse con any. var c: any Container compila. Sin embargo, el Item extraído se trata como “un tipo desconocido”, lo que limita sus usos. La puerta está abierta, pero todavía se puede hacer poco dentro.

Por la misma época, SE-0346 introdujo primary associated types. Este fue el verdadero cambio decisivo: los protocolos podían exponer sus tipos asociados principales entre corchetes angulares.

protocol Container<Item> {
    associatedtype Item
    // ...
}

var numbers: any Container<Int>   // Itemun contenedor Int
func process(_ c: some Container<Int>)  // También de forma concisa en genéricos

any Container<Int> es una “caja cuyo Item está fijado como Int”. El compilador sabe que el elemento extraído es Int, por lo que la utilidad de la caja aumenta enormemente. La biblioteca estándar también se reorganizó con esta sintaxis. Desde entonces pueden escribirse formas como any Collection<String> y some Sequence<Int>.

Conviene entender la dirección de esta evolución. La regla absoluta “un protocolo con tipos asociados no puede usarse como tipo” se convirtió en la regla precisa “puede usarse, pero sus capacidades disminuyen según los tipos asociados que no estén determinados”. Es la continuación de la filosofía vista en some·any: de la prohibición a explicitar el coste.

Patrones prácticos — Cómo trabajar con tipos asociados

Llevemos la teoría a situaciones prácticas.

Al diseñar: usa un tipo asociado cuando “cada tipo que adopta el protocolo determina un tipo diferente”. Un protocolo Repository es un buen ejemplo. Con associatedtype Entity, UserRepository se completa con User y OrderRepository con Order. Si el tipo debe elegirlo quien usa el código, independientemente del adoptante, corresponde una función o tipo genérico.

Al consumir: la opción predeterminada son las restricciones genéricas; si necesitas almacenar y mezclar, usa any con su primary associated type especificado. Usarlo como restricción, como en func sync<R: Repository>(_ repo: R) where R.Entity == User, es la primera opción; almacenarlo, como en var repos: [any Repository<User>], es la segunda.

Cuando te bloquees: usa type erasure como último recurso, el patrón AnyX. Si ni siquiera primary associated type resuelve restricciones complejas, queda la type erasure manual: envolver el tipo en uno concreto y ocultar el tipo asociado, como AnySequence de la biblioteca estándar o AnyPublisher de Combine. Desde SE-0346 se necesita mucho menos. Antes de crear un nuevo wrapper AnyX, comprueba primero si primary associated type resuelve el problema.

Un punto más: los requisitos Self. Operaciones como == solo tienen sentido “entre valores del mismo tipo”, por eso se declaran con Self. En consecuencia, dos cajas any Equatable no pueden compararse directamente, porque sus tipos internos pueden ser diferentes. En estos casos, lo habitual es abandonar la caja y resolverlo con genéricos.

Ilustración de una línea temporal de puertas en la que SE-0309 y SE-0346 abren los cerrojos sucesivamente
SE-0309 y SE-0346 abrieron los cerrojos uno tras otro

Resumen

  • Los tipos asociados son espacios para tipos dentro de los protocolos y los completan los tipos adoptantes, normalmente por inferencia. La biblioteca estándar se apoya en ellos, como ocurre con Element de Collection.
  • El origen del error antiguo: la entrada se bloqueaba porque no se podía determinar el tipo asociado del valor extraído de una caja existencial. Con restricciones genéricas, nunca hubo problema.
  • SE-0309 habilitó el uso de any y los primary associated types de SE-0346 (any Container<Int>) hicieron prácticas las cajas.
  • Orden práctico: primero las restricciones genéricas, después any con el primary associated type especificado y, por último, la type erasure manual.

El próximo artículo trata un tema más práctico: map, filter, reduce y las diferencias entre compactMap y flatMap, además del rendimiento con secuencias lazy.


Referencias

Seguir leyendo