wandres.dev
GENÉRICOS AVANZADOS · restricciones y where

Especialización: cómo el compilador convierte un genérico en código concreto

Del genérico sin especializar con tablas de testigos a la copia monomórfica por tipo: cuándo ocurre la especialización, qué se gana en rendimiento, qué se paga en tamaño y cómo forzarla a través de módulos.

⏱ 17 min

Un genérico de Swift no es una plantilla que se expande siempre ni una función que siempre indirecciona: es las dos cosas según lo que el compilador pueda demostrar. Entender cuándo genera una copia concreta para cada tipo —y cuándo no puede— es la diferencia entre escribir abstracciones que cuestan cero y escribir abstracciones que cuestan una llamada indirecta por operación en el camino caliente.

🎯 Al terminar esta lección sabrás
  • Distinguir la representación genérica con tablas de testigos de la copia especializada.
  • Saber qué condiciones habilitan la especialización y cuáles la bloquean entre módulos.
  • Estimar qué se gana en rendimiento y verificarlo en lugar de suponerlo.
  • Evaluar el coste en tamaño de binario y tiempo de compilación antes de forzarla.

Dos maneras de ejecutar un genérico

Sin especializar, una función genérica se compila una sola vez y funciona con cualquier tipo mediante indirección. El valor se maneja a través de su dirección, con un tamaño y una alineación desconocidos en compilación, y cada operación pasa por dos estructuras de metadatos.

📇

Value witness table

Cómo copiar, mover y destruir un valor de ese tipo, y cuánto ocupa. Es lo que permite manipular algo cuyo tamaño no se conoce al compilar.

🔗

Protocol witness table

Qué implementación concreta corresponde a cada requisito del protocolo. Cada llamada a un método del contrato es un salto a través de esta tabla.

Copia especializada

Una versión del cuerpo reescrita para un tipo concreto: tamaños fijos, valores en registros, llamadas directas y candidatas a inlining.

La especialización elimina las dos tablas. El optimizador clona el cuerpo sustituyendo el parámetro por el tipo real, y a partir de ahí todo el arsenal habitual se vuelve aplicable: propagación de constantes, desenrollado, vectorización, eliminación de retención y liberación. El resultado suele ser idéntico al de haber escrito la función a mano para ese tipo.

func sumar<T: Numeric>(_ xs: [T]) -> T {
    var acc = T.zero
    for x in xs { acc += x }
    return acc
}

let total = sumar([1, 2, 3])   // el optimizador genera sumar especializada para Int
flowchart LR
A[Funcion generica en el fuente] --> B[El optimizador ve el cuerpo y el tipo concreto]
B -->|si| C[Clon especializado por tipo]
B -->|no| D[Version generica con tablas de testigos]
C --> E[Inlining, registros, sin indireccion]
D --> F[Indireccion por puntero y salto por tabla]
style C fill:#a6e3a1,color:#11111b
style D fill:#f9e2af,color:#11111b

Cuándo especializa y cuándo no puede

La regla operativa es simple: el optimizador necesita ver el cuerpo de la función genérica y conocer el tipo concreto en el punto de llamada, a la vez, dentro de la misma unidad de optimización.

  • Dentro del mismo módulo con optimización de módulo completo, es lo normal. El compilador ve todo y especializa con agresividad.
  • Con compilación incremental por archivo y sin optimización, no. En modo de depuración casi nada se especializa, y por eso las mediciones en debug no dicen nada útil.
  • Cruzando el límite de módulo, el cuerpo no viaja con la interfaz. La solución es marcar la función como inlinable, lo que serializa su cuerpo en el módulo y permite que el consumidor la especialice, a cambio de congelar la implementación como parte del contrato público.
@inlinable
public func sumar<T: Numeric>(_ xs: [T]) -> T {
    var acc = T.zero
    for x in xs { acc += x }
    return acc
}

Si el cuerpo llama a algo interno, ese algo debe exponerse con la anotación correspondiente para que siga siendo visible desde fuera. Existen además directivas para pedir versiones especializadas concretas por adelantado, útiles cuando conoces de antemano los tipos que van a dominar en tus consumidores.

Hay un tercer factor que suele pasarse por alto: la forma del genérico. El optimizador especializa con más facilidad cuerpos pequeños y sin recursión sobre el propio parámetro, y se rinde antes ante funciones enormes o cadenas de llamadas profundas cuyo coste de clonado no compensa su heurística. Partir un genérico gigante en varias funciones pequeñas suele habilitar la especialización que el monolito bloqueaba.

📝
Depuración y publicación miden cosas distintas

En modo de depuración el compilador conserva la versión genérica para que los símbolos y los puntos de interrupción tengan sentido, así que ahí verás siempre la indirección. Cualquier conclusión sobre el coste de los genéricos extraída de una compilación de depuración es, sencillamente, falsa.

⚠️
Existencial no es genérico

Un parámetro declarado como existencial siempre paga la indirección: el tipo concreto se decide en ejecución por definición y no hay nada que especializar. Un parámetro genérico con la misma restricción, en cambio, es candidato a especialización. La diferencia de coste entre ambas formas en un bucle caliente puede ser de un orden de magnitud.

Qué se gana y cómo medirlo

Los números dependen del cuerpo, del tipo y de la carga, así que la única respuesta honesta es medir. Tres herramientas bastan para tener certeza en lugar de fe.

  1. Inspeccionar el binario. Buscar en la tabla de símbolos los nombres decorados de las especializaciones te dice si el clon existe. Un nombre desdecodificado que menciona el tipo concreto es la prueba de que ocurrió.
  2. Leer la representación intermedia. Emitir el código intermedio optimizado muestra si la llamada quedó como salto directo o como acceso a tabla de testigos.
  3. Medir con un banco de pruebas honesto. Compilando en modo de publicación, con datos que no puedan constantizarse y con un consumo del resultado que impida que el optimizador borre el bucle entero.
# Compilar con optimizacion de modulo completo
swiftc -O -whole-module-optimization Fuente.swift -o binario

# Buscar la especializacion en la tabla de simbolos y desdecodificar el nombre
nm binario | swift demangle | grep -i "specialized"

# Ver la representacion intermedia optimizada
swiftc -O -emit-sil Fuente.swift | grep -A5 "sumar"

El patrón de ganancia habitual: en operaciones aritméticas o de acceso a memoria sobre tipos de valor pequeños, la versión especializada elimina retenciones, permite mantener el acumulador en un registro y abre la puerta a la vectorización. En operaciones que ya son caras por sí mismas —red, cadenas complejas, asignación de memoria— la indirección se diluye y la diferencia deja de ser medible.

📝
El banco de pruebas honesto

Tres condiciones para que una medición signifique algo: compilar en modo de publicación, impedir que la entrada se constantice —léela de un origen que el compilador no pueda predecir— y consumir el resultado de forma que el bucle no pueda eliminarse por código muerto. Sin las tres, estarás midiendo la habilidad del optimizador para borrar tu prueba, no la de tu código para ir rápido.

El precio de la copia

La especialización no es gratis y por eso el compilador aplica heurísticas en lugar de clonarlo todo.

  • Tamaño de binario. Cada instanciación es código nuevo. Un genérico usado con veinte tipos concretos puede multiplicar por veinte su contribución al ejecutable, con el efecto colateral de más presión sobre la caché de instrucciones.
  • Tiempo de compilación. Clonar y optimizar cada variante cuesta, y la optimización de módulo completo sacrifica la compilación incremental.
  • Estabilidad de la interfaz. Marcar una función como inlinable convierte su cuerpo en parte del contrato: cambiarlo afecta a binarios ya compilados contra la versión anterior.

La lectura de diseño es clara. Escribe genéricos con naturalidad y deja que el optimizador decida; interviene solo con datos delante, en las funciones que el perfilado señale, y prefiere reducir el trabajo algorítmico antes que forzar clones.

Hay además una jerarquía de decisiones que conviene tener ordenada antes de tocar una sola anotación:

  1. Confirma que el punto importa. Un perfilado real, no una corazonada. La mayoría de los genéricos de una app nunca aparecen en el perfil.
  2. Comprueba si ya está especializado. Muy a menudo el optimizador ya hizo el trabajo y la anotación no cambiaría nada salvo el tamaño del binario.
  3. Revisa la forma antes que la representación. Un existencial en un bucle caliente, una conversión repetida o una asignación por iteración cuestan más que la indirección genérica.
  4. Solo entonces expón el cuerpo. Y asume conscientemente el compromiso de estabilidad que implica hacerlo en una interfaz pública.
Abstracción sin coste no es un eslogan: es una propiedad demostrable

Aquí se cierra el círculo del nivel entero. Las restricciones te dieron un contrato verificable, las cláusulas where te dieron un álgebra para expresarlo y la conformidad condicional te dio composicionalidad; la especialización es lo que hace que todo eso no cueste nada en ejecución. Y conviene apreciar la elegancia del diseño: Swift no eligió entre monomorfización total, como C++ con sus plantillas y su explosión de código, ni indirección total, como el borrado de tipos de Java. Eligió que ambas representaciones sean semánticamente equivalentes y dejó que el optimizador escoja según lo que pueda demostrar. Esa decisión tiene una consecuencia enorme que casi nadie enuncia: permite tener estabilidad de ABI y bibliotecas que evolucionan sin recompilar a sus consumidores, algo que la monomorfización obligatoria hace imposible, sin renunciar al rendimiento cuando el compilador sí tiene la información. Por eso el sistema tiene anotaciones para exponer cuerpos: son la forma explícita de decir “aquí prefiero velocidad a flexibilidad de evolución”, y son una decisión de ingeniería, no un truco. Cuando entiendas ese intercambio, dejarás de preguntar si los genéricos son lentos y empezarás a preguntar lo correcto: si en este punto concreto el compilador tiene lo que necesita para demostrarlo.

⚔️ Verifica la especialización con datos
  1. Escribe la función sumar genérica, compílala en modo de publicación con optimización de módulo completo y busca en la tabla de símbolos del binario el nombre de la especialización para Int.
  2. Repite el experimento en modo de depuración y comprueba que la especialización desaparece.
  3. Mueve la función a un módulo aparte y mide la diferencia con y sin la anotación de inlinable.
  4. Compara en un bucle de un millón de iteraciones un parámetro genérico restringido frente al mismo parámetro declarado como existencial. Anota la diferencia y explícala con lo aprendido.