Los tres despachos: estático, por tabla y dinámico
Cómo decide Swift a qué código saltar en cada llamada. El despacho estático y la inserción en línea, las tablas virtuales de clase y las tablas de testigos de protocolo, y el envío de mensajes de Objective-C. Su coste relativo real, por qué el salto extra casi nunca es lo caro, y la trampa clásica de los métodos de extensión.
Toda llamada a función termina siendo un salto a una dirección de memoria, y la única pregunta que importa para el rendimiento es cuándo se conoce esa dirección. Si el compilador la conoce mientras compila, puede hacer algo mucho más ambicioso que emitir un salto: puede no emitirlo, copiar el cuerpo en el punto de llamada y seguir optimizando a través de la frontera como si nunca hubiera existido una función. Si la dirección solo se conoce en ejecución, el salto se emite y, con él, se levanta un muro para el optimizador. Swift ofrece tres mecanismos de resolución con propiedades muy distintas, y elegir entre ellos no es una micro-optimización marginal: es la decisión que determina cuánto del resto del edificio de optimizaciones queda en pie.
- Distinguir despacho estático, por tabla y dinámico, y saber qué construcción del lenguaje activa cada uno.
- Reconstruir la mecánica de una tabla virtual de clase y de una tabla de testigos de protocolo.
- Explicar por qué el coste dominante no es el salto indirecto sino las optimizaciones que bloquea.
- Diagnosticar la trampa de los métodos declarados solo en una extensión de protocolo.
Quién resuelve la dirección, y cuándo
El compilador clasifica cada llamada en una de tres categorías según cuánta información tiene sobre el receptor.
En el despacho estático, la dirección del cuerpo se conoce en tiempo de compilación. Ocurre en funciones globales, en métodos de struct y enum que no son requisitos de protocolo alcanzados por un existencial, en métodos marcados final, en miembros private o fileprivate sin sobrescrituras visibles, y en los miembros declarados únicamente en una extensión de protocolo. Es la única categoría en la que el compilador puede insertar el cuerpo en línea, propagar constantes a través de la llamada, vectorizar el bucle que la contiene y eliminar retenciones redundantes.
En el despacho por tabla la dirección se lee de una estructura de datos en ejecución. Hay dos variantes. Las clases no final usan una tabla virtual: cada clase tiene un array de punteros a función y cada método sobrescribible ocupa un índice fijo, heredado por las subclases, que solo reemplazan la entrada correspondiente. Las conformidades de protocolo usan una tabla de testigos: una tabla por par tipo y protocolo, con un puntero por requisito. En ambos casos la llamada cuesta cargar el puntero a la tabla, cargar la entrada y saltar a ella.
En el despacho dinámico de Objective-C no hay índice sino un nombre. La llamada se traduce a objc_msgSend, que recibe el receptor y un selector, consulta una caché de métodos por clase y, si falla, recorre la jerarquía. Se activa con dynamic, con @objc dynamic, y en cualquier miembro alcanzado desde código de Objective-C.
struct Punto { func norma() -> Double { 0 } } // estatico
class Base { func f() {} } // tabla virtual
final class Hoja: Base { override func f() {} } // f sigue en la tabla de Base
protocol P { func g() }
func usar(_ p: any P) { p.g() } // tabla de testigos
class Modelo: NSObject {
@objc dynamic var titulo: String = "" // objc_msgSend, permite KVO
}
flowchart TB
L[Llamada en el codigo fuente] --> Q1{Conoce el compilador el tipo exacto del receptor}
Q1 -->|Si y no es sobrescribible| E[Despacho estatico y candidato a insercion en linea]
Q1 -->|No| Q2{Que abstraccion introduce la indireccion}
Q2 -->|Herencia de clase| V[Tabla virtual: indice fijo por metodo]
Q2 -->|Conformidad de protocolo| W[Tabla de testigos: un puntero por requisito]
Q2 -->|Marcado dynamic o expuesto a ObjC| D[objc_msgSend con selector y cache]
E --> O[El optimizador sigue razonando a traves de la llamada]
V --> B[Barrera para el optimizador]
W --> B
D --> B
style E fill:#a6e3a1,color:#11111b
style D fill:#f38ba8,color:#11111b
style B fill:#f9e2af,color:#11111bEl coste que se mide y el coste que se paga
Las cifras directas de cada mecanismo son modestas y conviene retenerlas con orden de magnitud, no con precisión falsa. Una llamada estática ya insertada en línea cuesta cero: no existe. Una llamada estática no insertada cuesta el protocolo de llamada, del orden de uno o dos nanosegundos. Una llamada por tabla añade uno o dos accesos a memoria que, con la tabla caliente en caché, apenas suman fracciones de nanosegundo, más el salto indirecto, que puede fallar en el predictor si el sitio de llamada es polimórfico. Una llamada por objc_msgSend con caché caliente ronda los pocos nanosegundos y es varias veces más cara que las anteriores; con caché fría, mucho peor.
Si el análisis terminara ahí, la conclusión sería que el despacho casi nunca importa. La conclusión correcta es la contraria, y la razón es indirecta.
// Estatico: el compilador ve el cuerpo, lo inserta, y el bucle se vuelve aritmetica pura
func areaTotal(_ xs: [Circulo]) -> Double {
xs.reduce(0) { $0 + $1.area }
}
// Por tabla: el cuerpo es opaco en cada iteracion
func areaTotal(_ xs: [any Figura]) -> Double {
xs.reduce(0) { $0 + $1.area }
}
En la primera versión el optimizador inserta area, descubre que es una multiplicación, saca la constante del bucle, y puede desenrollarlo o vectorizarlo. En la segunda no puede insertar nada, y como no sabe qué hace el cuerpo, tampoco puede asumir que no muta memoria global, que no lanza, ni que no retiene objetos. Cada llamada indirecta obliga a mantener vivos valores que habría podido descartar y a reemitir cargas que habría podido reutilizar. El salto cuesta un nanosegundo; la barrera que levanta puede costar un orden de magnitud en el bucle entero.
Hay además un efecto de segundo orden que solo aparece con volumen. Un sitio de llamada indirecta que siempre salta al mismo destino es barato porque el predictor de saltos del procesador acierta; un sitio polimórfico, que alterna entre varios destinos según el elemento, falla la predicción con frecuencia y cada fallo cuesta el vaciado del cauce de instrucciones, del orden de una decena de ciclos. Un array de existenciales que mezcla tipos de verdad no solo paga la tabla: paga la impredecibilidad.
// Monomorfico en la practica: el predictor acierta casi siempre
for f in soloCirculos { total += f.area } // [any Figura] con un solo tipo
// Polimorfico: el destino cambia y el predictor falla
for f in mezcla { total += f.area } // circulos, rectangulos, poligonos
Un miembro declarado en el cuerpo del protocolo y también en la extensión se despacha por tabla de testigos: gana la implementación del tipo conformante. Un miembro declarado solo en la extensión no tiene entrada en la tabla, así que se resuelve estáticamente sobre el tipo visible en el punto de llamada. La consecuencia es que un tipo puede definir un método con el mismo nombre y no ser llamado nunca a través del protocolo. No es un problema de rendimiento sino de corrección, y su origen es exactamente el mecanismo de despacho.
Lo que solo el despacho dinámico permite
Sería un error leer la escala de costes como una escala de calidad. objc_msgSend es el mecanismo más caro porque es el único que mantiene abierto el conjunto de respuestas posibles, y esa apertura compra capacidades que ninguna de las otras dos ofrece.
La observación de valores necesita interceptar el acceso a una propiedad sin que el código que la escribe sepa nada. La implementación clásica sustituye la clase del objeto por una subclase generada al vuelo cuyos accesores notifican antes y después. Eso solo funciona si la resolución es por nombre y en ejecución.
final class Reproductor: NSObject {
@objc dynamic var segundo: Double = 0 // observable, y por eso dinamico
var tasa: Double = 1.0 // rapido, y por eso no observable
}
El intercambio de métodos y los proxies dependen de la misma propiedad: poder cambiar a qué apunta un nombre después de compilar. Y toda la interoperabilidad con marcos de Objective-C, incluidos los patrones de delegado y de objetivo y acción, viaja por selectores.
La conclusión práctica es que dynamic no es un atributo que se quite para ganar rendimiento sin más: se quita cuando compruebas que nadie depende de esas capacidades. Retirar @objc dynamic de una propiedad observada rompe silenciosamente a quien la observaba, y el fallo se manifiesta como una vista que deja de actualizarse, no como un error de compilación.
Devirtualización: cuando la tabla desaparece
El compilador no acepta la indirección como definitiva. Si puede demostrar el tipo exacto en un punto de llamada, sustituye la lectura de tabla por una llamada directa y recupera todas las optimizaciones. Esa transformación es la devirtualización, y prospera cuando la información de tipos no ha escapado.
let h = Hoja()
h.f() // el tipo es exacto y Hoja es final: llamada directa
var b: Base = Hoja()
b.f() // devirtualizable si el analisis de flujo no pierde la pista
Con la optimización de módulo completo activada, el compilador ve todas las subclases del módulo y puede marcar como efectivamente final una clase que nadie hereda, o insertar una comprobación de tipo especulativa que salta a la versión directa en el caso frecuente y a la tabla en el resto. Nada de esto funciona a través de una frontera de biblioteca con estabilidad de ABI, donde el compilador debe asumir que cualquier cosa puede haber sido sobrescrita.
Estático
Funciones globales, métodos de struct y enum, miembros final y private, y lo declarado solo en extensiones de protocolo. Es el único que permite inserción en línea.
Por tabla
Métodos de clase sobrescribibles y requisitos de protocolo alcanzados desde un existencial o un genérico no especializado. Uno o dos accesos a memoria y una barrera para el optimizador.
Dinámico
dynamic y @objc dynamic. Búsqueda por selector con caché. Es el más caro y el único que habilita intercambio de métodos, observación de valores y proxies.
Hay una manera de leer estos tres mecanismos que ordena todo el nivel: cada uno corresponde a un momento distinto en el que se toma una decisión, y el coste es exactamente el precio de haberla aplazado. En el despacho estático la decisión se tomó al escribir el código y se cerró al compilar; el programa que se ejecuta ya no contiene la pregunta, solo la respuesta. En el despacho por tabla la decisión se aplazó hasta el instante en que existe un valor concreto, y el precio de ese aplazamiento es una estructura de datos que hay que consultar. En objc_msgSend la decisión se aplaza tanto que ni siquiera el conjunto de respuestas posibles está cerrado: se puede añadir un método a una clase con el programa en marcha, y por eso la búsqueda es por nombre y no por índice. Lo interesante es que la escala de coste es exactamente la escala de apertura del sistema, y esa correspondencia no es casual sino necesaria: mantener abierta una pregunta cuesta guardar en algún sitio la maquinaria capaz de responderla más tarde. Pero el corolario práctico es el que suele malentenderse. Casi todo el mundo asume que optimizar el despacho consiste en ahorrar el salto indirecto, y por eso concluye, con razón y equivocándose, que el despacho no importa: un salto son nanosegundos. Lo que importa es que la dirección desconocida es también un cuerpo desconocido, y un cuerpo desconocido obliga al compilador a suponer lo peor sobre memoria, sobre excepciones y sobre conteo de referencias. La llamada indirecta no es cara por lo que hace, sino por lo que impide suponer. Optimizar el despacho es, en el fondo, un ejercicio de devolverle premisas al optimizador, y las cinco lecciones de este nivel no son otra cosa que un catálogo de maneras de hacerlo.
El despacho estático resuelve la dirección al compilar y es el único que habilita inserción en línea y todo lo que viene detrás. El despacho por tabla usa una tabla virtual indexada para clases o una tabla de testigos para conformidades, y cuesta uno o dos accesos a memoria más un salto indirecto. El despacho dinámico usa objc_msgSend con selector y caché, es el más caro y el único que permite intercambio de métodos y observación. El coste dominante en los tres casos no es el salto sino la barrera que levanta para el optimizador, y la devirtualización es el mecanismo por el que el compilador intenta derribarla cuando puede probar el tipo exacto.
- Toma un archivo real de tu proyecto y clasifica cada llamada en estático, por tabla o dinámico, justificando la categoría con la construcción del lenguaje que la provoca.
- Escribe un método declarado solo en una extensión de protocolo y otro declarado también en el cuerpo del protocolo; comprueba con un tipo conformante cuál de los dos se llama a través del existencial.
- Emite el SIL optimizado de un bucle sobre un array homogéneo y de otro sobre un array de existenciales, y localiza en el primero la ausencia de la instrucción de llamada indirecta.
- Marca
finaluna clase de tu código y compara el SIL antes y después para observar la devirtualización. - Argumenta con un ejemplo por qué la diferencia de rendimiento entre las dos versiones del bucle es mucho mayor que la suma de los saltos indirectos ahorrados.