Extensiones con implementación por defecto y el peligro del despacho estático
Una extensión de protocolo regala comportamiento a todo conformante, pero solo los requisitos declarados en el protocolo se despachan dinámicamente. Tabla de testigos, despacho estático y las reglas para que la abstracción no mienta.
Una extension sobre un protocolo es el mecanismo más productivo de Swift y también su trampa más silenciosa. Productivo, porque permite escribir una vez un algoritmo que todos los conformantes reciben gratis. Trampa, porque el método que escribes en la extensión puede comportarse de dos maneras radicalmente distintas según un detalle fácil de pasar por alto: si su firma aparece o no en la declaración del protocolo. En un caso se despacha dinámicamente y respeta la implementación del tipo; en el otro se resuelve en compilación y el tipo pierde la voz. El resultado son bugs que no lanzan errores: simplemente ejecutan el código equivocado.
- Escribir implementaciones por defecto en una
extensionde protocolo. - Explicar qué es la tabla de testigos y cuándo interviene.
- Reproducir el fallo del despacho estático y diagnosticarlo.
- Aplicar la regla de diseño que evita que la abstracción mienta.
Comportamiento regalado desde fuera
El protocolo declara el contrato; la extensión lo rellena. Todo tipo que conforme obtiene el cuerpo sin escribir nada.
protocol Reproductor {
var duracion: Double { get }
func reproducir()
}
extension Reproductor {
func reproducir() {
print("reproduciendo \(duracion) segundos")
}
var esLargo: Bool { duracion > 600 }
}
struct Podcast: Reproductor {
let duracion: Double
// no implementa reproducir: hereda el cuerpo de la extension
}
Podcast cumple el contrato aportando una sola propiedad. La extensión aporta el resto. Y como puede apoyarse en los requisitos —esLargo consulta duracion—, la capa por defecto es un algoritmo escrito en términos abstractos que se especializa para cada conformante.
Hay una asimetría deliberada en el ejemplo: reproducir está declarado en el protocolo y además tiene un cuerpo por defecto; esLargo solo existe en la extensión. Esa diferencia, que a la vista es de dos líneas, decide cómo se resolverá cada llamada.
La tabla de testigos y el despacho estático
Cuando un tipo conforma a un protocolo, el compilador construye una tabla de testigos (protocol witness table): una lista que asocia cada requisito declarado con la implementación concreta que ese tipo aporta. Al llamar a un requisito a través del protocolo, la llamada consulta la tabla en ejecución y encuentra la implementación real del tipo.
Un miembro que solo vive en la extensión no entra en esa tabla. No es un requisito, así que no hay nada que registrar. Su llamada se resuelve en compilación, según el tipo estático de la expresión.
protocol Formato {
func cabecera() -> String // requisito: entra en la tabla
}
extension Formato {
func cabecera() -> String { "por defecto" }
func pie() -> String { "pie por defecto" } // solo extension
}
struct Informe: Formato {
func cabecera() -> String { "cabecera de Informe" }
func pie() -> String { "pie de Informe" }
}
let concreto = Informe()
let abstracto: Formato = Informe()
concreto.cabecera() // cabecera de Informe
concreto.pie() // pie de Informe
abstracto.cabecera() // cabecera de Informe <- tabla de testigos
abstracto.pie() // pie por defecto <- despacho estatico
Las dos últimas líneas son el corazón de la lección. El mismo objeto, la misma llamada, dos resultados. abstracto.pie() ejecuta el cuerpo de la extensión porque el compilador solo sabe que la variable es un Formato, y Formato no promete ningún pie.
El compilador no advierte de esta divergencia: Informe no está sobreescribiendo nada, está definiendo un método propio que resulta llamarse igual. Sintácticamente es legítimo. El fallo aparece solo cuando el valor circula tipado como protocolo, cosa que ocurre en cuanto lo guardas en un array heterogéneo, lo pasas a una función que recibe el protocolo o lo devuelves desde una fábrica.
flowchart TB
call[llamada a un miembro sobre un valor] --> q{esta la firma declarada en el protocolo}
q -->|si| wit[tabla de testigos]
q -->|no| est[despacho estatico por tipo estatico]
wit --> real[se ejecuta la implementacion del tipo concreto]
est --> ext[se ejecuta siempre el cuerpo de la extension]Por qué existe esta regla
No es un descuido del diseño del lenguaje, sino una consecuencia del coste. La tabla de testigos añade una indirección por llamada; los miembros de extensión que nadie prometió pueden inlinearse y desaparecer. Swift optimiza por defecto lo que no forma parte del contrato, y reserva el despacho dinámico para lo que sí se prometió.
La lectura constructiva de esa regla es una guía de diseño precisa:
Declara lo personalizable
Si un conformante puede querer cambiar el comportamiento, la firma debe estar en el protocolo. La extensión aporta el cuerpo por defecto.
Deja fuera lo derivado
Lo que se calcula a partir de los requisitos y nunca debería variar, mantenlo solo en la extensión: ganas rendimiento y coherencia.
Sospecha del nombre repetido
Si un tipo define un miembro que existe en la extensión pero no en el protocolo, o falta una declaración o sobra un método.
Prueba a través del protocolo
Escribe tests que usen el valor tipado como protocolo, no como tipo concreto. Es donde el despacho estático se delata.
El bug tal como aparece en producción
El fallo casi nunca se presenta en un ejemplo de tres líneas. Se presenta así:
protocol Celda {
var altura: Double { get }
}
extension Celda {
var altura: Double { 44 }
var accesible: Bool { false } // solo extension: trampa
}
struct CeldaTitulo: Celda {
var altura: Double { 88 }
var accesible: Bool { true }
}
func configurar(_ celdas: [any Celda]) {
for c in celdas {
print(c.altura, c.accesible) // 88.0 false
}
}
altura responde correctamente porque es un requisito; accesible devuelve false para una celda que declaró true. La función que rompe el programa no contiene ningún error: el error está en la declaración del protocolo, a cien líneas y dos archivos de distancia, y consiste en algo que no se escribió. Mover la firma de accesible al protocol arregla el comportamiento sin tocar ni el conformante ni el bucle.
Aquí se toca un nervio de la teoría de tipos: el momento en que una abstracción deja de ser fiel. El principio de sustitución de Liskov dice que un valor de un subtipo debe poder usarse donde se espera el supertipo sin alterar la corrección del programa. El despacho estático sobre miembros de extensión rompe esa sustitución de forma silenciosa: el mismo Informe, visto como Informe, hace una cosa, y visto como Formato, hace otra. La corrección del programa pasa a depender no de qué es el valor, sino de por qué anotación de tipo ha viajado, que es precisamente lo que una abstracción debía ocultar. Y sin embargo la decisión de Swift no es un error, sino una elección consciente en un compromiso que ningún lenguaje esquiva. El despacho dinámico universal —el de Smalltalk, el de Objective-C, el de Python— es semánticamente uniforme pero impide inlinear, especializar y eliminar código muerto, y en un lenguaje que aspira a la programación de sistemas ese peaje se paga en cada llamada. El despacho estático universal —el de las plantillas de C++ sin funciones virtuales— es veloz pero incapaz de expresar polimorfismo en ejecución. Swift traza la frontera en el sitio más defendible que existe: lo que prometiste públicamente es dinámico, lo que solo añadiste por comodidad es estático. El contrato es literalmente la lista de puntos de extensión que pagas. La lección de diseño, entonces, no es “evita las extensiones”, sino asumir que cada firma que escribes en un protocol es una decisión de arquitectura con coste en ejecución y libertad para el conformante, y cada una que dejas fuera es una optimización que retira esa libertad. Elegir mal no produce un error de compilación; produce un programa que hace lo correcto en los tests unitarios y lo incorrecto en producción.
Una extensión de protocolo da cuerpo por defecto a todos los conformantes. Solo los miembros declarados en el protocolo entran en la tabla de testigos y se despachan dinámicamente; los que viven únicamente en la extensión se resuelven en compilación según el tipo estático. Declara en el protocolo todo lo que un tipo pueda querer personalizar y deja en la extensión solo lo derivado e invariable.
- Define un protocolo con un solo requisito y una extensión con dos métodos: uno declarado en el protocolo y otro no.
- Conforma un
structque redefina ambos. - Llama a los dos métodos sobre el valor concreto y sobre el mismo valor anotado como el protocolo; anota las cuatro salidas.
- Mueve la firma del segundo método al protocolo y repite. Explica qué cambió y por qué.
- Guarda tres conformantes distintos en un array del tipo del protocolo y recorre llamando a ambos métodos.