wandres.dev
SOME Y ANY · opacos y existenciales

Por dentro: tablas de testigos y contenedores existenciales

Cómo representa Swift una conformidad en memoria. La tabla de testigos de protocolo y la de testigos de valor, la disposición exacta del contenedor existencial en cinco palabras, la optimización de valores pequeños y la caja en el montón. Por qué any cuesta más de lo que parece y por qué some no siempre es literalmente gratis.

⏱ 20 min

Todo lo dicho hasta aquí sobre coste y flexibilidad se vuelve evidente en cuanto ves las estructuras reales. Swift representa una conformidad mediante dos tablas —una para el comportamiento del protocolo, otra para la mecánica del valor— y un existencial mediante un contenedor de tamaño fijo que carga con ambas. Mirar esa disposición palabra por palabra convierte reglas memorizadas en consecuencias obvias: por qué una caja mide cuarenta bytes, por qué un struct grande provoca una asignación en el montón, por qué copiar un array de cajas genera tráfico de conteo de referencias, y por qué el opaco puede evitar todo eso sin renunciar a la abstracción.

🎯 Al terminar esta lección sabrás
  • Distinguir la tabla de testigos de protocolo de la tabla de testigos de valor y saber qué guarda cada una.
  • Reconstruir la disposición del contenedor existencial y calcular su tamaño para una composición de protocolos.
  • Explicar la optimización de valores pequeños y cuándo el valor pasa a una caja del montón.
  • Enumerar las facturas invisibles del existencial y el matiz que impide llamar a some gratuito en términos absolutos.

Dos tablas, dos preguntas distintas

Cuando un tipo conforma a un protocolo, el compilador genera una tabla de testigos de protocolo: una estructura estática, en memoria de solo lectura, con un puntero por cada requisito del protocolo, apuntando a la implementación que ese tipo aporta. Junto a los métodos guarda también los metadatos de cada tipo asociado y las conformidades que esos tipos asociados necesiten.

// Disposicion conceptual de la tabla de testigos de Circulo respecto a Figura
// struct TablaTestigosFigura {
//     var area: (UnsafeRawPointer) -> Double   // implementacion de Circulo
//     // ...un puntero por requisito, en orden fijo
//     // ...metadatos de cada tipo asociado y sus conformidades
// }

Hay una sola tabla por par tipo y protocolo, compartida por todos los valores. Mil círculos dentro de mil cajas tienen mil punteros a datos distintos y el mismo puntero a tabla repetido mil veces.

La segunda estructura responde a otra pregunta. La tabla de testigos de protocolo dice cómo se comporta; hace falta además saber cómo se manipula: cuánto ocupa, cómo se copia, cómo se destruye, cómo se mueve. Eso vive en la tabla de testigos de valor, alcanzable desde los metadatos del tipo, y es lo que permite que código genérico compilado una sola vez maneje valores cuyo tamaño desconoce.

// Conceptualmente, la tabla de testigos de valor de cualquier tipo
// struct TablaTestigosValor {
//     var inicializarCopiando: ...   // copiar un valor
//     var destruir: ...              // liberar sus recursos
//     var asignarTomando: ...        // mover sin copiar
//     var tamano: Int, alineacion: Int, paso: Int
// }

Sin ella, un existencial no podría destruirse: no sabría cuánta memoria devolver ni qué referencias liberar.

El contenedor existencial, palabra a palabra

Un valor de tipo any P no es un puntero: es un registro de tamaño fijo. En una máquina de 64 bits ocupa cinco palabras, cuarenta bytes, repartidas así:

  • Tres palabras de buffer en línea para el valor, si cabe.
  • Una palabra con el puntero a los metadatos del tipo real, desde donde se alcanza la tabla de testigos de valor.
  • Una palabra con el puntero a la tabla de testigos del protocolo.

La última partida es la que escala: una composición de dos protocolos añade una palabra más, porque necesita una tabla por cada uno.

protocol A {}
protocol B {}

// any A       -> 3 + 1 + 1 = 5 palabras  = 40 bytes
// any A & B   -> 3 + 1 + 2 = 6 palabras  = 48 bytes
// any AnyObject & A -> disposicion de clase: 1 referencia + 1 tabla

Cuando el existencial está restringido a clases, la disposición cambia y se vuelve mucho más compacta: basta la referencia al objeto más las tablas, porque los metadatos ya viajan en la cabecera del objeto. Es la razón por la que un existencial sobre un protocolo de solo clases es notablemente más barato que uno sobre un protocolo de valores.

flowchart TB
C[Contenedor existencial de cinco palabras] --> B1[Palabras 1 a 3: buffer en linea]
C --> M[Palabra 4: metadatos del tipo real]
C --> W[Palabra 5: tabla de testigos del protocolo]
B1 --> D1{El valor cabe en tres palabras y es trivial de mover}
D1 -->|Si| IN[Se guarda dentro sin asignar memoria]
D1 -->|No| OUT[Caja en el monton con conteo de referencias]
M --> VW[Tabla de testigos de valor: copiar destruir medir]
W --> PW[Punteros a la implementacion de cada requisito]
style IN fill:#a6e3a1,color:#11111b
style OUT fill:#f38ba8,color:#11111b
style C fill:#89b4fa,color:#11111b

Dentro o fuera: la optimización de valores pequeños

El buffer de tres palabras solo sirve si el valor cabe y es trivialmente movible. Cuando no lo es, el valor se asigna en el montón dentro de una caja con conteo de referencias, y el contenedor guarda un puntero a esa caja.

struct Punto: Figura { var x, y: Double; var area: Double { 0 } }        // 2 palabras
struct Matriz: Figura { var a, b, c, d, e, f: Double; var area: Double { a } } // 6

let dentro: any Figura = Punto(x: 1, y: 2)      // sin asignacion de memoria
let fuera:  any Figura = Matriz(a: 1, b: 2, c: 3, d: 4, e: 5, f: 6)  // caja en el monton

Esa caja es compartida y se comporta con semántica de copia al escribir: copiar el existencial retiene la caja sin duplicar el contenido, pero mutar el valor a través de una de las copias obliga a asignar una caja nueva. El resultado práctico es que cruzar el umbral de tres palabras convierte una copia de registros en una asignación de memoria y en tráfico de conteo de referencias, y ese salto no aparece en ninguna parte del código fuente.

⚠️
El umbral no es solo el tamaño

Un valor puede no caber en el buffer aunque mida poco, si no es trivialmente movible: por ejemplo, si contiene referencias con semántica especial. Y al revés, tres palabras exactas caben. Añadir un campo a un struct que viaja dentro de existenciales puede empujarlo silenciosamente al montón y degradar una ruta caliente sin que cambie una sola línea de la lógica.

Las facturas invisibles y el matiz sobre some

El precio del existencial se cobra en cuatro conceptos, y solo el primero es el que suele mencionarse.

Asignación y liberación cuando el valor no cabe en el buffer, más el conteo de referencias asociado a la caja en cada copia y cada destrucción.

Llamadas indirectas a través de la tabla de testigos: un acceso a memoria extra por llamada y, sobre todo, la imposibilidad de insertar el cuerpo del método en línea.

Optimizaciones bloqueadas: sin conocer el tipo, el compilador no puede especializar el código genérico, no puede propagar constantes a través de la llamada, no puede vectorizar un bucle ni eliminar comprobaciones redundantes.

Copias del contenedor: cuarenta bytes se mueven en cada paso de argumento y en cada elemento de un array, frente a los ocho o dieciséis de un valor pequeño concreto.

// Un bucle sobre cajas paga por elemento: llamada indirecta y posible ARC
var total = 0.0
for f in escena { total += f.area }     // escena es [any Figura]

// El equivalente opaco o generico se especializa y puede insertarse en linea
func sumar<F: Figura>(_ xs: [F]) -> Double { xs.reduce(0) { $0 + $1.area } }

Hay además un coste que rara vez se contabiliza y que no es de ejecución sino de razonamiento: al borrar el tipo, el existencial traslada a la ejecución errores que el compilador habría detectado. Una conversión condicional que devuelve nulo, una rama que nunca se toma, un requisito que el conformante implementa de forma inesperada. Cada caja es un punto donde el sistema de tipos dejó de vigilar.

for f in escena {
    guard let c = f as? Circulo else { continue }   // comprobacion en ejecucion
    print(c.radio)                                  // que el opaco nunca necesita
}

Y ahora el matiz honesto sobre some. El código genérico no especializado también recibe en ejecución los metadatos y las tablas de testigos, solo que como argumentos implícitos de la llamada en lugar de guardados dentro del valor. La diferencia estructural sigue siendo enorme —no hay contenedor, no hay caja, no hay borrado— pero la frase coste cero describe el resultado tras la especialización, que el optimizador realiza dentro de un módulo y a través de fronteras solo cuando el código es susceptible de serlo. Un genérico atravesando una biblioteca resiliente puede seguir pagando indirección.

La conformidad como valor de primera clase, y su precio en cuatro palabras

Lo verdaderamente elegante del diseño que acabas de destripar es que Swift tomó una decisión distinta a la de casi todos sus predecesores: en lugar de meter el puntero a la tabla dentro de cada objeto —el puntero virtual clásico de los lenguajes orientados a objetos—, lo dejó fuera del valor, en el contenedor existencial o en los argumentos implícitos de una llamada genérica. La consecuencia es un principio que atraviesa todo el lenguaje: un tipo no paga ni un byte por ser potencialmente polimórfico. Un struct de dos enteros que conforma a diez protocolos sigue midiendo dieciséis bytes; solo cuando formas un existencial aparecen las cinco palabras. Es el mismo principio de no pagar por lo que no usas, aplicado a la abstracción. Pero hay algo más hondo. Al separar la tabla de testigos de protocolo de la tabla de testigos de valor, Swift está separando dos preguntas que otros lenguajes fusionan: qué sabe hacer un tipo y cómo se maneja un tipo. La primera es semántica y la elige el protocolo; la segunda es mecánica y la impone la representación en memoria. Esa separación es exactamente lo que permite que el mismo código genérico, compilado una sola vez, opere sobre un entero de ocho bytes y sobre un struct de doscientos sin recompilarse ni saber nada de ellos: pregunta a la tabla de valor cuánto medir y cómo copiar, y a la tabla de protocolo qué ejecutar. Y ahí está la clave para entender por qué any cuesta más de lo que parece: no cuesta por la llamada indirecta, que en una CPU moderna con la tabla caliente en caché es casi ruido. Cuesta porque haber trasladado la identidad del tipo a un dato de ejecución desactiva, en cadena, todo el edificio de optimizaciones que dependía de conocerla: especialización, inserción en línea, propagación de constantes, eliminación de conteo de referencias, vectorización. La factura no es un salto extra; es un compilador al que le has retirado la premisa sobre la que razonaba.

📝
Lo esencial del interior

Cada conformidad genera una tabla de testigos de protocolo estática y única por par tipo y protocolo, con un puntero por requisito y los metadatos de los tipos asociados. Cada tipo tiene además una tabla de testigos de valor con cómo copiarse, destruirse y medirse. Un existencial es un contenedor de cinco palabras: tres de buffer, una de metadatos y una por tabla de protocolo; si el valor no cabe o no es trivialmente movible, se asigna en una caja del montón con conteo de referencias. El coste real del existencial no es la indirección sino las optimizaciones que desactiva, y el llamado coste cero del opaco describe el resultado tras la especialización, no una garantía absoluta en todo contexto.

⚔️ Mide la caja
  1. Calcula sobre el papel el tamaño de un existencial de un protocolo, de una composición de dos y de uno restringido a clases; comprueba después tus cifras midiendo el tamaño de cada tipo.
  2. Define un struct de dos palabras y otro de seis, guarda ambos en existenciales y razona cuál provoca una asignación en el montón.
  3. Explica qué campos de la tabla de testigos de valor son imprescindibles para destruir correctamente un existencial y qué ocurriría sin ellos.
  4. Escribe el mismo bucle sobre un array de cajas y sobre un array homogéneo y enumera, sin medir todavía, las optimizaciones disponibles solo en el segundo.
  5. Argumenta por qué mil valores del mismo tipo dentro de mil cajas comparten el puntero a la tabla de testigos pero no el puntero a datos.