inlinable y usableFromInline: exponer el cuerpo
Cómo cruza una optimización la frontera de un módulo. La resiliencia de ABI y el cuerpo invisible, qué hacen exactamente los atributos inlinable y usableFromInline, las reglas que imponen sobre el código que escribes, y el trade-off irreversible entre rendimiento y libertad para cambiar la implementación.
Dentro de un módulo, el compilador lo ve todo y optimiza sin pedir permiso. En cuanto una llamada cruza a otro módulo, la situación se invierte: el cliente conoce la firma de la función y nada más. Sabe cómo llamarla, no qué hace. Esa ignorancia es deliberada y tiene un nombre, resiliencia, y una virtud enorme: permite reemplazar la biblioteca por una versión nueva sin recompilar a nadie. También tiene un precio que se cobra en cada llamada que atraviesa la frontera, porque un cuerpo invisible no se inserta en línea, no se especializa y no permite propagar nada. Los dos atributos de esta lección son el mecanismo por el que renuncias a una porción de esa resiliencia a cambio de recuperar la optimización, y la decisión que tomas al escribirlos es, en el sentido más literal, irreversible.
- Explicar qué es la resiliencia de ABI y por qué convierte cada llamada entre módulos en una barrera.
- Describir con precisión qué hace
@inlinabley qué papel complementario cumple@usableFromInline. - Enumerar las restricciones que ambos atributos imponen sobre el cuerpo de la función.
- Decidir con criterio qué API marcar y aceptar el compromiso de compatibilidad que eso implica.
La frontera y el cuerpo invisible
Cuando compilas una biblioteca con estabilidad de ABI activada, el compilador emite una interfaz de módulo con las declaraciones públicas, sus firmas y sus tipos, pero sin los cuerpos. El cliente compila contra esa interfaz. La consecuencia es que cada llamada a la biblioteca es opaca: no se puede insertar en línea, no se pueden propagar constantes hacia dentro, y una función genérica no se puede especializar para el tipo del cliente porque no hay cuerpo que instanciar.
La resiliencia va más allá de las funciones. También los tipos son opacos: el cliente no conoce el tamaño ni la disposición de un struct público resiliente, así que lo maneja de forma indirecta a través de su tabla de testigos de valor. Añadir un campo a ese struct en una versión futura no rompe a nadie, precisamente porque nadie horneó su tamaño en su binario.
// En la biblioteca, sin atributos
public func maximo(_ a: Int, _ b: Int) -> Int { a > b ? a : b }
// En el cliente
let m = maximo(3, 4) // llamada real a la biblioteca, aunque el resultado sea trivial
Esa llamada cuesta más que la comparación que envuelve, y el compilador del cliente no puede hacer nada al respecto, porque para él la función podría hacer cualquier cosa. Multiplicado por un bucle, el sobrecoste deja de ser anecdótico.
Un paquete corriente compilado desde fuentes junto a tu aplicación no sufre esta barrera: el compilador tiene los cuerpos y optimiza a través de los módulos si usa optimización de módulo completo entre ellos. La resiliencia se activa al construir bibliotecas con estabilidad de ABI, que es el caso de las bibliotecas del sistema y de los binarios distribuidos precompilados. Marcar @inlinable en un paquete de fuentes no suele cambiar nada medible y sí te ata las manos para siempre.
Los dos atributos y sus reglas
@inlinable publica el cuerpo de una función en la interfaz del módulo. El cliente pasa a tener el código fuente disponible y puede insertarlo en línea, especializarlo si es genérico y optimizar a través de él como si fuera propio.
@usableFromInline resuelve el problema derivado. Un cuerpo publicado solo puede referirse a cosas que el cliente también ve; si toca un miembro internal, la interfaz sería incoherente. @usableFromInline marca ese miembro interno como visible para el enlazador y disponible desde cuerpos publicados, sin convertirlo en parte de la API pública: el cliente no puede escribirlo en su código, pero el cuerpo insertado sí puede llamarlo.
@usableFromInline
internal struct Estado {
@usableFromInline var contador: Int
@usableFromInline init(contador: Int) { self.contador = contador }
}
@inlinable
public func avanzar(_ n: Int) -> Int {
var e = Estado(contador: n) // legal: Estado es usableFromInline
e.contador += 1
return e.contador
}
Las reglas que impone el compilador se siguen de ahí. Un cuerpo @inlinable no puede referirse a nada private o fileprivate, ni a nada internal sin marcar. No puede declarar tipos locales. Y no puede acceder a los almacenamientos de un tipo resiliente si no son también accesibles, porque el cliente no conoce su disposición.
@inlinable
public func mal(_ xs: [Int]) -> Int {
struct Aux { var v: Int } // error: tipo local en cuerpo inlinable
return xs.reduce(0, +)
}
flowchart TB
L[Llamada del cliente a la biblioteca] --> R{Esta la biblioteca compilada con resiliencia}
R -->|No| D[El compilador ve el cuerpo y optimiza sin atributos]
R -->|Si| I{Esta la funcion marcada inlinable}
I -->|No| O[Llamada opaca: sin insercion ni especializacion]
I -->|Si| C[El cuerpo viaja en la interfaz del modulo]
C --> V[El cliente inserta especializa y propaga]
C --> F[El cuerpo antiguo queda horneado en binarios ya compilados]
F --> P[Dos versiones del cuerpo conviven para siempre]
style D fill:#a6e3a1,color:#11111b
style V fill:#a6e3a1,color:#11111b
style O fill:#f38ba8,color:#11111b
style P fill:#f9e2af,color:#11111bLos parientes: frozen, alwaysEmitIntoClient y el genérico que no se especializa
@inlinable no viaja solo. Forma familia con otros dos mecanismos que atacan la misma frontera desde ángulos distintos, y confundirlos lleva a marcar lo que no toca.
@frozen es el equivalente para los tipos. Un struct público resiliente es opaco en tamaño y disposición, así que el cliente lo maneja indirectamente; congelarlo publica su disposición y permite manipularlo en registros. Un enum congelado permite además al cliente compilar un switch exhaustivo sin caso por defecto, porque se le garantiza que no aparecerán casos nuevos. El precio es simétrico al de @inlinable y más severo: no podrás añadir un campo ni un caso jamás.
@frozen public struct Coordenada { // el cliente conoce el tamano: dos Double
public var x: Double
public var y: Double
}
@_alwaysEmitIntoClient va un paso más allá que @inlinable: el cuerpo no solo se publica sino que no existe como símbolo en la biblioteca; se emite entero dentro del cliente. Es el mecanismo con el que la biblioteca estándar añade funcionalidad nueva que puede ejecutarse en sistemas antiguos, porque el código viaja con la aplicación y no hace falta que el sistema lo tenga. Al empezar por guion bajo no es API estable del lenguaje.
El caso que más sorprende es el de los genéricos. Una función genérica pública en una biblioteca resiliente no puede especializarse en el cliente sin cuerpo visible, así que se ejecuta en su forma no especializada, recibiendo metadatos y tablas de testigos como argumentos implícitos y pagando indirección en cada operación sobre el tipo genérico. Marcarla @inlinable es lo que devuelve la especialización, y por eso las abstracciones genéricas de la biblioteca estándar están marcadas casi sin excepción: sin el atributo, map sobre un array pagaría una llamada opaca por elemento.
El precio: un cuerpo que ya no es tuyo
Aquí está el compromiso que hace de esta decisión algo distinto a una opción de compilación. Cuando marcas una función @inlinable, los clientes que ya compilaron llevan una copia de ese cuerpo horneada en su binario. Si publicas una versión nueva con una implementación distinta, esos clientes seguirán ejecutando la vieja hasta que recompilen, y los que recompilen ejecutarán la nueva. Durante un tiempo indeterminado conviven dos implementaciones de la misma función.
De ahí salen las obligaciones reales. La nueva implementación debe ser semánticamente equivalente a la antigua, porque no controlas cuál corre. No puedes arreglar un fallo de comportamiento cambiando solo el cuerpo. No puedes cambiar la representación interna de la que ese cuerpo dependía. Y cualquier detalle interno que el cuerpo revele pasa a ser, de facto, parte de tu contrato observable.
// Version 1.0 publicada como inlinable
@inlinable public func promedio(_ xs: [Double]) -> Double {
xs.reduce(0, +) / Double(xs.count) // divide por cero con array vacio
}
// Version 1.1 que corrige el caso vacio: los clientes viejos siguen fallando
@inlinable public func promedio(_ xs: [Double]) -> Double {
xs.isEmpty ? 0 : xs.reduce(0, +) / Double(xs.count)
}
El criterio que se deduce es restrictivo a propósito. Marca @inlinable en funciones pequeñas y estables: envoltorios triviales, accesores calculados, comparaciones, operadores aritméticos, adaptadores genéricos cuya única razón de ser es desaparecer al especializarse. No lo marques en algo que contenga lógica de negocio, política que pueda cambiar, o cualquier cosa que sospeches que vas a querer arreglar. Y no lo marques nunca por si acaso: el atributo no tiene marcha atrás, mientras que añadirlo más tarde sí es un cambio compatible.
Marca lo pequeño y estable
Accesores, operadores, envoltorios de una línea y adaptadores genéricos. Su valor está en desaparecer, y su implementación no va a cambiar.
No marques lógica que evolucione
Si la función contiene una decisión que podrías querer corregir, publicar su cuerpo te impide corregirla para quien ya compiló.
Quitar el atributo rompe la ABI
Añadirlo es compatible; retirarlo no. Ante la duda, no lo pongas todavía.
La manera habitual de presentar estos atributos como una opción de rendimiento oculta lo que de verdad son: un mecanismo de versionado. Toda biblioteca compilada por separado vive con una tensión que no tiene solución general, solo puntos de equilibrio. Si el cliente sabe poco, la biblioteca es libre de cambiar y el cliente es lento. Si el cliente sabe mucho, el cliente es rápido y la biblioteca queda congelada en aquello que reveló. Los lenguajes resuelven esa tensión de formas muy distintas y la elección los define. El modelo de cabeceras de C y C++ eligió el extremo de la exposición total y por eso arrastra el problema de la ABI frágil, donde añadir un campo privado a una clase obliga a recompilar el mundo. Los lenguajes con máquina virtual eligieron el otro extremo y compran la libertad con una capa de indirección permanente que solo un compilador en tiempo de ejecución puede deshacer. Swift hizo algo poco común: no eligió, sino que puso el dial en manos del autor de la biblioteca y lo dejó por declaración individual. Por defecto la biblioteca es libre y el cliente lento; cada @inlinable es un punto donde el autor decide, con nombre y apellidos, congelar una implementación concreta a cambio de velocidad. Y lo que hace la decisión tan seria es que su unidad de medida no es el rendimiento sino el tiempo: al publicar un cuerpo no estás prometiendo que la función sea rápida, estás prometiendo que su comportamiento observable no cambiará mientras existan binarios que la incrustaron. Es una promesa hacia el futuro, hecha en el presente, cobrada por terceros que no conoces. La biblioteca estándar de Swift está llena de estos atributos precisamente porque sus autores aceptaron esa carga a cambio de que un map sobre un array no pague una llamada opaca por elemento. Cuando escribes uno en tu código, estás firmando el mismo contrato con una fracción de su experiencia.
La resiliencia de ABI oculta los cuerpos al cliente y con ello impide inserción en línea y especialización a través de la frontera. @inlinable publica el cuerpo en la interfaz del módulo y @usableFromInline hace visibles al enlazador los miembros internos que ese cuerpo necesita, sin añadirlos a la API pública. Un cuerpo publicado no puede tocar miembros private ni declarar tipos locales. El precio es que las versiones antigua y nueva del cuerpo conviven en clientes distintos, así que la implementación debe permanecer semánticamente equivalente. Marca solo funciones pequeñas y estables, y recuerda que añadir el atributo es compatible pero retirarlo no.
- Compila una biblioteca pequeña con estabilidad de ABI y mide una llamada trivial desde un cliente, antes y después de marcarla
@inlinable. - Escribe un cuerpo
@inlinableque use unstructinterno y observa el error del compilador; corrígelo con@usableFromInlineen el tipo y en sus miembros. - Intenta declarar un tipo local dentro de un cuerpo
@inlinabley explica, a partir del mecanismo, por qué la restricción es necesaria. - Simula el escenario de dos versiones: compila un cliente contra una implementación, cámbiala en la biblioteca sin recompilar el cliente y comprueba qué comportamiento se ejecuta.
- Recorre la API pública de un módulo tuyo y clasifica cada función en apta o no apta para el atributo, justificando cada caso por su probabilidad de cambio y no por su tamaño.