wandres.dev
PROTOCOLOS A FONDO · POP y sus límites

Los límites de POP: cuándo un enum o una clase son mejores

La programación orientada a protocolos tiene un punto de rendimiento decreciente. Señales de contorsión, el conjunto cerrado que pide un enum, la identidad que pide una clase y un criterio de decisión basado en el problema de la expresión.

⏱ 20 min

Cuatro lecciones defendiendo los protocolos piden una quinta que marque el límite. La programación orientada a protocolos resuelve un problema real —compartir comportamiento sin acoplar jerarquías— y como toda buena idea se aplica en exceso: protocolos con un solo conformante, envoltorios de borrado de tipos escritos a mano, restricciones genéricas que se propagan por media base de código. Hay dos herramientas que Swift ofrece con el mismo rango y que a menudo son la respuesta correcta: el enum, cuando el conjunto de casos es cerrado, y la class, cuando lo que modelas tiene identidad. Saber cuándo abandonar el protocolo es tan técnico como saber escribirlo.

🎯 Al terminar esta lección sabrás
  • Reconocer las señales de que un protocolo está siendo una contorsión.
  • Elegir un enum cuando el conjunto de variantes es cerrado y conocido.
  • Elegir una class o un actor cuando importa la identidad y el estado compartido.
  • Aplicar el problema de la expresión como criterio de decisión.

Señales de contorsión

Ninguna de estas señales condena un diseño por sí sola; tres juntas casi siempre sí.

👤

Un solo conformante

Un protocolo con una única implementación real no abstrae nada: duplica la superficie de la API y obliga a mantener dos declaraciones sincronizadas.

🧵

Genéricos que se propagan

Un associatedtype obliga a que cada función que toque el tipo sea genérica, y esa restricción sube por la pila hasta contaminar tipos que no deberían saber nada.

🪆

Borrado de tipos a mano

Si escribes un envoltorio que guarda closures solo para poder meter valores en un array, el sistema de tipos te está diciendo que el hueco sobraba.

🍊

Requisitos que casi nadie usa

Un contrato donde la mitad de los conformantes implementa métodos vacíos ha dejado de describir una capacidad y ha pasado a describir una categoría.

Hay un caso especialmente frecuente: el protocolo creado solo para poder inyectar un doble en los tests. A veces está justificado; a menudo la misma independencia se consigue inyectando una función, una struct con closures o un valor de configuración, sin añadir un tipo abstracto permanente al dominio.

// Version 1. Contorsion: protocolo de un solo conformante mas su doble de test.
protocol ServicioUsuario {
    func cargar(id: String) async throws -> Usuario
}
// Version 2. La dependencia es una funcion, y el doble es otra funcion.
struct ServicioUsuario {
    var cargar: (String) async throws -> Usuario
}

extension ServicioUsuario {
    static let real = ServicioUsuario { id in try await red.get(id) }
    static let prueba = ServicioUsuario { _ in Usuario.ejemplo }
}

La segunda versión no declara ningún tipo abstracto, no obliga a mantener dos firmas sincronizadas y permite construir un doble parcial en una línea dentro del propio test. La abstracción sigue existiendo; simplemente ha dejado de necesitar un protocol para expresarse.

Cuando gana el enum: el conjunto cerrado

Si las variantes son conocidas y finitas, el enum domina al protocolo en todos los ejes que importan.

enum Pago {
    case efectivo(Decimal)
    case tarjeta(numero: String, cuota: Int)
    case transferencia(iban: String)
}

func comision(de pago: Pago) -> Decimal {
    switch pago {
    case .efectivo: return 0
    case .tarjeta(_, let cuota): return cuota > 1 ? 3 : 1
    case .transferencia: return Decimal(0.5)
    }
}

Las ventajas son concretas, no estéticas. El switch es exhaustivo: añadir un caso rompe la compilación en cada punto que deba actualizarse, lo cual es exactamente lo que quieres. No hay caja ni indirección: el valor cabe en la pila. Los datos asociados difieren por caso sin necesidad de propiedades opcionales que solo tienen sentido a veces. Y añadir una operación nueva es escribir una función nueva, sin tocar ninguna declaración existente.

Con protocolos, ese mismo modelo pierde la exhaustividad —nada avisa de que falta manejar un conformante— y gana una libertad, la de que terceros añadan tipos, que en un dominio cerrado como los medios de pago no sirve para nada.

💡
No son excluyentes

El enum cerrado y el protocolo se combinan bien: modela las variantes con un enum y hazlo conformar a los protocolos que describan capacidades transversales —Codable, Hashable, CustomStringConvertible—. Lo que no funciona es usar un protocolo en lugar del enum para representar las variantes: ahí pierdes la exhaustividad sin ganar nada a cambio.

Cuando gana la clase: identidad y estado compartido

Los tipos por valor se copian, y hay cosas que no deben copiarse. Una conexión abierta, un caché compartido, un coordinador de navegación, un objeto que otros observan: todos ellos tienen identidad, y esa identidad es el modelo, no un detalle de implementación.

final class Sesion {
    private(set) var token: String?
    private var observadores: [() -> Void] = []

    func renovar(_ nuevo: String) {
        token = nuevo
        observadores.forEach { $0() }
    }

    deinit { /* cerrar recursos */ }
}

La clase aporta cuatro cosas que ningún protocolo sobre un tipo por valor da: una referencia compartida donde todos ven la misma mutación, deinit para liberar recursos de forma determinista, referencias weak para romper ciclos, y comparación por identidad. Si tu diseño acaba pasando el mismo struct con inout por veinte funciones para que todas vean el cambio, estás reimplementando una clase peor.

Cuando además hay concurrencia, la respuesta suele ser un actor: identidad más aislamiento de estado, sin candados manuales.

⚠️
Marca final por defecto

Elegir una clase no obliga a aceptar la herencia. Marcar final conserva la semántica de referencia y elimina de golpe el despacho dinámico innecesario, el riesgo de que una subclase rompa un invariante y la clase base frágil. La herencia abierta debería ser una decisión explícita, no el estado por defecto.

flowchart TB
q1{el conjunto de variantes es cerrado} -->|si| en[enum con switch exhaustivo]
q1 -->|no| q2{importa la identidad o el estado compartido}
q2 -->|si y hay concurrencia| ac[actor]
q2 -->|si| cl[class final]
q2 -->|no| q3{habra varios conformantes reales}
q3 -->|no| st[una struct concreta basta]
q3 -->|si| pr[protocolo con extensiones]

El criterio de fondo

El problema de la expresión: por qué ninguna de las dos gana siempre

Detrás de la elección entre protocolo y enum hay un resultado clásico de la teoría de lenguajes, formulado por Wadler en 1998 con el nombre de problema de la expresión. Todo modelo de datos vive en una matriz de dos dimensiones: las variantes —cuántas formas distintas puede tomar el dato— y las operaciones —cuántas cosas distintas se hacen con él—. La pregunta es cuál de las dos dimensiones puedes extender sin modificar código existente, y el resultado incómodo es que los dos estilos clásicos resuelven ejes opuestos. El polimorfismo por protocolos hace trivial añadir variantes: un tipo nuevo conforma y todo el código genérico lo acepta sin recompilar una línea de lógica ajena; pero hace caro añadir operaciones, porque cada método nuevo hay que declararlo en el contrato y volver a visitar a todos los conformantes. El enum con switch hace lo simétrico: añadir una operación es escribir una función nueva en un archivo nuevo sin tocar nada, mientras que añadir una variante rompe todos los switch del programa. Ninguna de las dos es superior; cada una elige qué eje quiere que sea barato. De ahí sale el criterio de ingeniería, que es sorprendentemente operativo: pregúntate qué eje crecerá en tu dominio. Los medios de pago, los estados de una máquina, los códigos de error de un protocolo de red y las unidades de un sistema de medida son conjuntos cerrados donde las operaciones se multiplican con el tiempo —usa enum, y agradece que el compilador te obligue a revisar cada switch el día que el conjunto sí cambie—. Los adaptadores de almacenamiento, los renderizadores, los transportes de red y los formatos de exportación son conjuntos abiertos donde las operaciones están estabilizadas y las implementaciones se multiplican —usa protocolos, y agradece que un tipo nuevo entre sin tocar nada—. El error caro no es elegir la herramienta menos idiomática: es elegir la que abarata el eje que no crece y descubrirlo tres años después, cuando cada operación nueva exige tocar cuarenta conformantes o cada variante nueva rompe cuatrocientos switch. La marca de madurez de un diseño en Swift no es cuántos protocolos tiene, sino cuántos de ellos siguen justificándose cuando se les hace esta pregunta.

📝
Lo esencial

Un protocolo con un solo conformante, con requisitos que casi nadie usa o que obliga a escribir borrado de tipos a mano suele ser una contorsión. Si el conjunto de variantes es cerrado, el enum da exhaustividad y coste cero. Si hay identidad, estado compartido o recursos que liberar, la class marcada final —o un actor bajo concurrencia— es el modelo correcto. El criterio de fondo es el problema de la expresión: elige la herramienta que abarate el eje que vaya a crecer.

⚔️ Audita y reescribe
  1. Busca en un proyecto tuyo un protocolo con un solo conformante y evalúa si aporta algo que no aporte el tipo concreto.
  2. Modela un conjunto cerrado —estados de una descarga, por ejemplo— primero con protocolos y luego con un enum; compara qué pasa al añadir un caso.
  3. Sobre el mismo modelo, compara qué pasa al añadir una operación nueva en cada versión.
  4. Toma un struct que pases con inout por varias funciones y reescríbelo como final class; observa qué desaparece del código.
  5. Escribe en una frase, para cada abstracción de tu proyecto, qué eje esperas que crezca. Reconsidera las que no coincidan con su herramienta.