wandres.dev
ERRORES COMUNES · y cómo salir

Sobreingeniería con genéricos y protocolos: la abstracción que nadie necesitaba

El protocolo con una sola conformidad, el genérico que solo se instancia con un tipo y la jerarquía de `associatedtype` que existe para un test que ya no se escribe. Cómo medir el coste real de una abstracción prematura y cómo desmontarla sin perder ni una capacidad.

⏱ 19 min

La sobreingeniería no viene de la ignorancia sino de haber aprendido bien la lección anterior. Quien acaba de entender los tipos opacos, las restricciones y los tipos asociados quiere usarlos, y el sistema de tipos de Swift es lo bastante expresivo como para permitirle construir catedrales sobre problemas que cabían en una función. El resultado tiene una firma reconocible: protocolos con una única conformidad, parámetros genéricos que solo se instancian con un tipo, capas de indirección cuya justificación es «por si mañana». Esta lección trata la abstracción como lo que es, una inversión con coste y con retorno, y ofrece un método para saber cuándo el retorno nunca llegó y cómo revertir la inversión sin destruir nada de valor.

🎯 Al terminar esta lección sabrás
  • Medir el coste real de una abstracción en saltos de lectura, superficie pública y tiempo de compilación.
  • Reconocer los cuatro indicadores empíricos de abstracción muerta, empezando por la conformidad única.
  • Desmontar un protocolo innecesario y un genérico de una sola instanciación sin perder capacidad de prueba.
  • Distinguir la abstracción que paga —frontera con lo externo, punto de variación real— de la que solo posterga decisiones.

Las cuatro firmas de la abstracción muerta

El diagnóstico no requiere juicio estético; hay indicadores contables. El primero y más fiable es la conformidad única: un protocolo que un solo tipo implementa no abstrae nada, porque no hay dos cosas que unificar. Solo renombra el tipo y obliga a mantener dos declaraciones sincronizadas.

protocol RepositorioUsuarios {
    func obtener(id: UUID) async throws -> Usuario
    func guardar(_ u: Usuario) async throws
}

final class RepositorioUsuariosAPI: RepositorioUsuarios { /* unica conformidad */ }

El segundo es el parámetro genérico degenerado: un tipo o función con parámetro de tipo que en todo el proyecto se instancia con un único tipo concreto. El tercero es la restricción que reconstruye el tipo: una cláusula where tan específica que solo un tipo la satisface, en cuyo caso la generalidad es decorativa. El cuarto es la capa sin comportamiento: una capa que solo reenvía llamadas a la siguiente sin transformar nada.

// Generico degenerado: T siempre es Registro
struct Almacen<T: Codable & Identifiable> {
    func todos() -> [T] { /* ... */ }
}
let a = Almacen<Registro>()      // y no hay ninguna otra instanciacion
🚨

Conformidad única

Un protocolo, un conformante. Si el segundo lleva dos años sin llegar, el protocolo es un alias caro.

🧮

Genérico de una instancia

El parámetro de tipo se resuelve siempre igual. La generalidad no se usa y sí se paga.

📭

Capa sin comportamiento

Recibe, reenvía y devuelve. Cada llamada cruza un archivo más y no gana ninguna garantía.

Qué cuesta una abstracción

Conviene contabilizar el coste con precisión, porque la discusión suele quedarse en preferencias. Hay cuatro partidas y ninguna es hipotética.

La primera es navegación: cada indirección añade un salto a la lectura. Ir de una llamada a su implementación real deja de ser una pulsación y pasa a ser una búsqueda entre conformidades. En un módulo con cuatro capas de este tipo, entender una operación de principio a fin cuesta doce archivos.

La segunda es superficie pública: un protocolo publica un contrato que hay que mantener. Cambiar la firma de un método concreto es un refactor local; cambiarla en un protocolo obliga a tocar todas las conformidades y todos los dobles de prueba.

La tercera es rendimiento por despacho. Una llamada a través de any Protocolo no se puede especializar ni incorporar en línea: pasa por una tabla de testigos y, si el valor no cabe en el búfer interno del existencial, por una asignación en el montón. Con some o con genéricos el compilador puede especializar, pero entonces paga en tiempo de compilación.

La cuarta es compilación. Los genéricos con muchas restricciones y las conformidades condicionales anidadas son de lo más caro que puede procesar el verificador de tipos, y en proyectos grandes se convierten en el argumento que cierra la discusión.

flowchart TD
A[Necesidad de abstraer] --> B{Hay dos implementaciones reales hoy}
B -- Si --> C[Protocolo o generico justificado]
B -- No --> D{Es una frontera con algo externo}
D -- Si --> E[Abstrae solo la frontera, no el nucleo]
D -- No --> F[Usa el tipo concreto]
F --> G[Menos saltos, menos superficie, mas velocidad]
C --> H[El coste compra variacion que existe]

Desmontar sin perder nada

El argumento que siempre se esgrime a favor del protocolo con una conformidad es la testabilidad, y merece una respuesta seria porque es el único que apunta a algo real. La respuesta es que la testabilidad no exige un protocolo: exige un punto de sustitución, y hay formas más baratas de tenerlo.

La primera es la inyección de funciones. Si lo que se quiere sustituir son dos operaciones, dos propiedades de tipo función bastan, y el doble de prueba se escribe en tres líneas en vez de en un archivo.

struct RepositorioUsuarios: Sendable {
    var obtener: @Sendable (UUID) async throws -> Usuario
    var guardar: @Sendable (Usuario) async throws -> Void
}

extension RepositorioUsuarios {
    static let real = Self(obtener: { try await api.usuario($0) },
                           guardar: { try await api.subir($0) })

    static func falso(_ u: Usuario) -> Self {
        Self(obtener: { _ in u }, guardar: { _ in })
    }
}

Este diseño conserva íntegra la capacidad de prueba, elimina el protocolo, elimina la clase de doble y además permite variar una sola operación dejando las demás reales, algo que con conformidades completas obliga a escribir una clase entera. La segunda forma, cuando las operaciones son muchas, es abstraer únicamente la frontera de entrada y salida —el cliente HTTP, el acceso a disco— y dejar concreto todo lo que se construye encima. Se sustituye un solo tipo pequeño y se prueba de verdad la lógica en lugar de probar los dobles.

Para el genérico degenerado el desmontaje es aún más directo: se sustituye el parámetro por el tipo concreto y se comprueba qué se rompe. Si no se rompe nada, la generalidad no existía. Si aparece un segundo uso legítimo, el genérico estaba justificado y la duda queda resuelta con evidencia en lugar de con conjeturas.

El caso más caro de desmontar es el protocolo con associatedtype, porque contamina todo lo que lo toca: deja de poder guardarse en un array, obliga a propagar parámetros genéricos por cada tipo que lo use y suele acabar necesitando un borrador de tipos escrito a mano. Antes de introducir uno, conviene comprobar si el tipo asociado varía de verdad o si solo se declaró para no fijarlo.

// Contagioso: cada usuario del protocolo se vuelve generico
protocol Fuente {
    associatedtype Elemento
    func siguiente() -> Elemento?
}

// Suficiente en el noventa por ciento de los casos
protocol FuenteDeRegistros {
    func siguiente() -> Registro?
}

La segunda versión se puede guardar en [any FuenteDeRegistros], se puede pasar sin propagar parámetros y se lee sin reconstruir mentalmente ninguna instanciación. Si algún día hacen falta dos elementos distintos, convertirla en genérica es un refactor de una línea por declaración; haberlo hecho al revés habría costado un mes de indirecciones que nadie usó.

Abstraer es cambiar variación futura por opacidad presente

La razón de que este error sea tan universal es que la abstracción se enseña como una virtud sin coste, cuando en realidad es una operación con una estructura económica precisa: se paga opacidad ahora a cambio de flexibilidad después. La opacidad es inmediata, cuantificable y la sufre todo el que lee; la flexibilidad es diferida, probabilística y solo se cobra si la variación anticipada resulta ser la que ocurre. Ese último punto es el que casi nadie evalúa. Una abstracción prematura no solo apuesta a que habrá cambio, apuesta a que el cambio ocurrirá por el eje concreto que se eligió abstraer, y esa segunda apuesta es la que casi siempre se pierde. El protocolo de repositorio se escribió previendo que mañana habría una base de datos local; lo que llegó fue paginación, que atraviesa el protocolo de lado a lado y obliga a rehacerlo entero. La abstracción no solo no ayudó: encareció el cambio que sí ocurrió, porque hubo que modificar el contrato, las conformidades y los dobles en vez de una función. Frente a eso, el código concreto tiene una propiedad que se subestima: es maleable. No compromete ningún eje de variación, así que se deforma con igual facilidad en cualquier dirección, y cuando la variación real aparece, la abstracción que se extrae de dos casos existentes está informada por evidencia y acierta el eje por construcción. De ahí la asimetría que gobierna la decisión: convertir código concreto en abstracto cuando llega el segundo caso es un refactor mecánico y seguro, mientras que desmontar una abstracción equivocada exige reconstruir la lógica que quedó repartida entre capas. Abstraer tarde es barato y abstraer pronto es caro, así que la regla correcta no es «diseña bien desde el principio» sino «espera hasta tener el segundo caso delante».

Las abstracciones que sí pagan

No todo es prematuro. Hay tres situaciones en que abstraer con una sola conformidad es la decisión correcta, y todas comparten un rasgo: la segunda implementación existe, aunque no sea de producción.

La primera es la frontera con el mundo exterior —red, reloj, sistema de archivos, aleatoriedad—, donde la segunda implementación es la determinista de las pruebas y su existencia está garantizada. La segunda es la frontera de módulo publicada a otros equipos, donde el protocolo es el contrato que permite evolucionar el interior sin romper a nadie. La tercera es el punto de variación que el producto ya tiene aunque el código todavía no: dos proveedores de pago contratados, dos formatos de exportación en el plan del trimestre.

A esas tres se añade una cuarta que no es un punto de variación sino un requisito del propio lenguaje: la abstracción que existe para expresar una capacidad, no una alternativa. Equatable, Hashable, Codable, Sendable o Comparable no anticipan implementaciones futuras; describen lo que un tipo sabe hacer para que los algoritmos genéricos puedan apoyarse en ello. Conformar a un protocolo de la biblioteca estándar nunca es sobreingeniería, porque no añade indirección propia: añade participación en un vocabulario que ya existe.

La distinción se resume en una pregunta. Un protocolo que responde «quién hace esto» es una alternativa y necesita la prueba de la segunda implementación. Un protocolo que responde «qué sabe hacer esto» es una capacidad y se justifica solo. Casi toda la sobreingeniería de los proyectos de Swift consiste en escribir protocolos del primer tipo con la confianza que solo merecen los del segundo.

💡
La prueba de la segunda implementación

Antes de escribir un protocolo, nombra en voz alta la segunda conformidad y di cuándo existirá. Si no puedes nombrarla, o si la fecha es «algún día», el protocolo no es una abstracción: es una decisión aplazada con coste de mantenimiento diario.

⚔️ Auditoría de abstracciones
  1. Cuenta los protocolos de un módulo tuyo y, para cada uno, el número de conformidades excluyendo los dobles de prueba. Ordena la lista y quédate con los que tienen una sola.
  2. Elige el más usado y desmóntalo sustituyéndolo por el tipo concreto. Mide el número de archivos que desaparecen y los saltos de navegación que se ahorran en una operación completa.
  3. Reescribe sus pruebas con inyección de funciones. Compara las líneas del doble antes y después, y comprueba que puedes falsear una sola operación.
  4. Busca un tipo genérico y lista todas sus instanciaciones reales. Si solo hay una, sustituye el parámetro por el tipo concreto y anota qué se rompe.
  5. Registra el tiempo de compilación del módulo antes y después de los dos desmontajes. Con genéricos restringidos, la diferencia suele ser mayor de lo que nadie espera.