Swift desde Objective-C: la cabecera generada y sus límites
El sentido inverso de la frontera. La cabecera `-Swift.h`, la regla de `NSObject`, qué expone `@objc` y qué añade `@objcMembers`, el papel de `dynamic` en el despacho por mensajes, y el inventario preciso de lo que el sistema de tipos de Swift no puede exportar.
Exportar hacia Objective-C es un problema de naturaleza distinta al de importar. Al importar, Swift recibe un contrato pobre y decide con prudencia cómo enriquecerlo; el peor resultado posible es una API incómoda. Al exportar ocurre lo contrario: Swift tiene tipos que el runtime de Objective-C simplemente no puede representar, y ninguna anotación los hará representables. La frontera deja de ser una traducción y se convierte en un filtro. Aprender a cruzarla consiste, en gran medida, en saber de antemano qué pasa el filtro, por qué el resto no lo pasa, y qué forma dar al diseño para que la parte expuesta sea deliberada en vez de accidental.
- Localizar y usar la cabecera generada, y respetar la regla de herencia de
NSObject. - Diferenciar qué exponen
@objc,@objcMembersy las inferencias automáticas del compilador. - Explicar qué añade
dynamicy por qué lo exigen la observación de claves y el intercambio de métodos. - Enumerar las construcciones de Swift que no son representables y diseñar la fachada que las sortea.
La cabecera generada
El compilador de Swift produce, sin que se lo pidas, una cabecera de Objective-C con todo lo que tu módulo exporta. En un objetivo de aplicación se incluye desde los archivos de implementación con el nombre del módulo seguido de -Swift.h; en un framework se incluye con la forma de módulo, y solo contiene declaraciones públicas.
// EnUnArchivoPuntoM.m
#import "MiApp-Swift.h"
Carrito *c = [[Carrito alloc] initConDivisa:@"EUR"];
[c anadirArticulo:@"cafe" cantidad:2];
Esa cabecera tiene dos reglas que causan la mayoría de los problemas de compilación cruzada. La primera: no puede incluirse desde una cabecera de Objective-C que a su vez sea visible para Swift, porque se formaría un ciclo. Cuando una cabecera necesita mencionar una clase Swift, la forma correcta es la declaración adelantada con @class y dejar la inclusión completa para el archivo de implementación. La segunda: solo aparece en ella lo que el compilador consideró exportable, y el silencio no genera aviso alguno. Si un método tuyo no está en la cabecera, no es que Objective-C no lo encuentre: es que no existe para él.
La condición de partida es la herencia. Solo las clases que descienden de NSObject cruzan. Una clase Swift que no herede de nada no tiene puntero isa, no está registrada en el runtime y no puede recibir mensajes; no hay atributo que lo remedie. Junto a las clases cruzan también los protocolos marcados con @objc y las enumeraciones marcadas con @objc cuyo valor bruto sea entero.
Qué expone cada atributo
Hasta Swift 3 casi todo lo que se declaraba en una subclase de NSObject se exportaba automáticamente, y el resultado era binarios inflados de metadatos que nadie usaba. Desde Swift 4 la inferencia se retiró y la exportación es explícita.
@objc final class Carrito: NSObject {
@objc private(set) var total: Double = 0
@objc(initConDivisa:)
init(divisa: String) { ... }
@objc func anadirArticulo(_ nombre: String, cantidad: Int) { ... }
func recalcular() { ... } // invisible desde Objective-C
}
@objc sobre un miembro genera el punto de entrada del runtime para ese miembro y nada más. Con argumento, como en el inicializador de arriba, fija el selector exacto: es el reflejo simétrico de NS_SWIFT_NAME en el otro sentido, y sirve para dar a la API un nombre idiomático en cada lenguaje.
@objcMembers sobre la clase aplica el atributo a todos sus miembros, a los de sus extensiones y a los de sus subclases. Es cómodo y casi siempre excesivo: exporta también lo que solo era detalle interno, y ese exceso se paga en tamaño del binario y en superficie de API que alguien acabará usando.
Los protocolos siguen la misma lógica con un matiz propio. Un protocolo marcado con @objc solo puede ser adoptado por clases, no puede tener tipos asociados ni implementaciones por defecto en extensiones, y a cambio gana algo que ningún protocolo Swift tiene: requisitos opcionales, que es como se han modelado siempre los delegados del sistema.
@objc protocol SesionDelegado: AnyObject {
func sesion(_ s: Sesion, recibio datos: Data)
@objc optional func sesionTermino(_ s: Sesion) // no obliga a implementarlo
}
Ese optional no existe fuera de esta frontera y conviene no importarlo al diseño Swift puro, donde el mismo problema se resuelve mucho mejor con una implementación por defecto en una extensión del protocolo.
Hay además inferencias que siguen activas y conviene reconocer: un método que sobrescribe uno de Objective-C ya es @objc, un miembro que satisface el requisito de un protocolo @objc también lo es, y los atributos de interfaz —@IBOutlet, @IBAction, @NSManaged— lo implican por su propia naturaleza.
@objc publica el símbolo, pero las llamadas desde Swift siguen resolviéndose por tabla de métodos. dynamic es lo que obliga a que toda llamada, venga de donde venga, pase por el envío de mensajes del runtime. Ese rodeo es imprescindible para tres cosas: la observación de valores por clave, el intercambio de implementaciones en ejecución y el mecanismo de propiedades gestionadas de Core Data. Fuera de esos casos es un impuesto sobre cada invocación que no compra nada.
Lo que no cruza
El inventario es corto de enunciar y decisivo de recordar. No son representables en Objective-C:
- Los structs y las enumeraciones que no estén marcadas con
@objccon valor bruto entero. NiPunto, niResult, ni unenumcon valores asociados. - Los genéricos. Una clase genérica no puede marcarse con
@objc, ni puede exponerse un método cuya firma mencione un parámetro de tipo. - Las tuplas, los tipos opacos con
some, los existenciales con tipos asociados y las funciones que reciben o devuelven cualquiera de ellos. - Los opcionales de tipos de valor: un entero opcional no tiene representación, porque en Objective-C la ausencia se codifica con un puntero nulo y un entero no es un puntero.
- Las funciones libres de nivel de módulo, los tipos anidados y los valores por defecto de los parámetros, que se pierden y obligan a declarar cada sobrecarga.
- Los protocolos con tipos asociados y, en general, todo lo que el sistema de tipos de Swift añade por encima del modelo de objetos de los años ochenta.
Dos casos merecen matiz porque suelen darse por perdidos sin serlo. Un método throws marcado con @objc sí cruza: el compilador le añade un parámetro final de tipo NSError ** y convierte el valor de retorno en algo que puede señalar el fallo, exactamente la operación inversa a la que vimos al importar. Y un método async marcado con @objc también cruza: aparece en la cabecera generada como un método con bloque de terminación.
flowchart TB S[Declaracion Swift] --> Q1[Es una clase que hereda de NSObject] Q1 -->|no| X[No cruza la frontera] Q1 -->|si| Q2[Su firma usa genericos structs o tuplas] Q2 -->|si| F[Envuelve en una fachada de NSObject] Q2 -->|no| Q3[Lleva el atributo objc] Q3 -->|no| X Q3 -->|si| Q4[Necesita KVO o intercambio de metodos] Q4 -->|si| D[Anade dynamic] Q4 -->|no| OK[Visible con despacho por tabla] style X fill:#f38ba8,color:#11111b style OK fill:#a6e3a1,color:#11111b style D fill:#f9e2af,color:#11111b
Cuando algo no cruza, la solución no es mutilar el diseño interno sino levantar una fachada: una clase que hereda de NSObject, expone tipos representables y traduce hacia el modelo rico que hay dentro. El coste de esa capa es pequeño y su beneficio es doble, porque además fija por escrito qué parte de tu módulo es de verdad pública para el otro lenguaje.
struct Precio { let importe: Decimal; let divisa: String } // no cruza
@objc final class PrecioObjC: NSObject {
@objc let importe: NSDecimalNumber
@objc let divisa: NSString
init(_ p: Precio) { ... }
}
Conviene además tener presente qué cuesta cada exportación, porque el precio no es solo de tamaño. Un miembro @objc obliga al compilador a emitir un punto de entrada adicional y los metadatos que lo describen, y ese símbolo no puede eliminarse en el enlazado aunque nadie lo llame, porque el runtime podría buscarlo por nombre. Un miembro dynamic renuncia además a la inserción en línea y a cualquier optimización que dependa de conocer la implementación llamada. Y una clase exportada renuncia a las optimizaciones que Swift aplica a las jerarquías cerradas, porque cualquiera puede subclasificarla desde el otro lado.
Solo cruzan clases que descienden de NSObject, protocolos y enumeraciones marcadas con @objc. Nada se exporta por ser público: hace falta el atributo. dynamic solo se añade cuando el runtime debe interceptar la llamada. Lo no representable —genéricos, structs, tuplas, opcionales de valor— se expone mediante una fachada explícita, nunca empobreciendo el modelo interno.
Puerta estrecha y explícita
Cada @objc es una promesa pública. Enumerarlas a mano vale más que exportarlas todas con un atributo de clase.
Simetría de nombres
@objc con argumento hace en este sentido lo que NS_SWIFT_NAME hace en el contrario: dos nombres idiomáticos para una misma implementación.
Fachada, no mutilación
Si algo no cruza, envuélvelo. Nunca rediseñes el modelo interno para caber en un sistema de tipos más pobre.
Aquí se aprecia con una claridad poco común una asimetría fundamental de toda interoperabilidad: importar y exportar no son la misma operación en direcciones opuestas. Importar es un problema de inferencia bajo incertidumbre —el otro lenguaje dijo menos de lo que Swift necesita saber, y hay que decidir prudentemente qué asumir—; el importador puede equivocarse, pero siempre tiene alguna salida, aunque sea el opcional implícito o el tipo inseguro. Exportar es un problema de proyección con pérdida, y ahí no hay salida posible: si el destino no puede representar un concepto, ninguna anotación lo hará aparecer. Un tipo genérico no es difícil de exportar, es inexpresable, porque el runtime de Objective-C no tiene la noción de tipo parametrizado que haría falta para hablar de él. Esa distinción explica el diseño de todo el mecanismo y también su historia. La retirada de la inferencia automática en Swift 4 no fue una optimización de tamaño de binario, aunque se justificara así: fue un cambio de filosofía. Mientras la exportación era implícita, la superficie visible desde Objective-C era un subproducto accidental de cómo estuviera escrito tu código, y cambiar un detalle interno podía romper a un consumidor que ni sabías que existía. Al volverla explícita, cada @objc pasó a ser una declaración de intención sobre el límite del sistema: esto y solo esto es lo que prometo a un mundo que no puede entender el resto de mi diseño. Y de ahí sale la disciplina que conviene llevarse: no escribas Swift empobrecido para que quepa por el filtro, porque estarías dejando que el lenguaje más pobre dicte la forma del más rico. Escribe el modelo con todos los tipos que Swift te ofrece y construye encima una fachada delgada, explícita y auditable. La fachada envejece bien porque es pequeña; el diseño mutilado envejece mal porque lo impregna todo.
- Localiza la cabecera generada de un objetivo tuyo y haz un inventario de qué miembros aparecen y cuáles no; explica cada ausencia.
- Marca una clase con
@objcMembersy después sustitúyelo por atributos miembro a miembro; compara ambas cabeceras generadas. - Expón un método
throwsy otroasync, y comprueba en la cabecera generada la firma exacta que produjo cada uno. - Intenta observar una propiedad con la observación por clave sin
dynamic, comprueba que no notifica, y añádelo después. - Toma un tipo con genéricos y valores asociados y diseña la fachada mínima que lo exponga sin perder invariantes; justifica cada decisión de la traducción.