wandres.dev
INTEROPERABILIDAD · C, C++ y Objective-C

Objective-C desde Swift: el puente que ya estaba puesto

Cómo se declara la visibilidad con un `bridging header` o un módulo, qué reglas reescriben una API de Objective-C hasta volverla idiomática en Swift, qué tipos se convierten solos entre ambos mundos y a qué precio, y qué anotaciones te devuelven el control de la traducción.

⏱ 20 min

Objective-C es el caso más favorable de toda la interoperabilidad de Swift, y no por casualidad: Swift se diseñó para convivir con él. Comparten el mismo modelo de objetos, el mismo recuento automático de referencias, el mismo runtime y los mismos metadatos de clase, de modo que un objeto no se convierte al cruzar la frontera porque nunca deja de ser el mismo objeto. Lo que sí ocurre —y esto es lo interesante— es que el importador no se limita a traducir: reescribe. Toma una API pensada para un lenguaje de selectores largos y la reexpresa con etiquetas de argumento, opcionales, errores lanzados, funciones asíncronas y enumeraciones reales. Entender esas reglas de reescritura es entender por qué el mismo método se llama distinto en cada lenguaje sin que nadie haya escrito un envoltorio.

🎯 Al terminar esta lección sabrás
  • Configurar la visibilidad de código Objective-C mediante bridging header, cabecera paraguas o módulo de SwiftPM.
  • Anticipar cómo el importador reescribe nombres, errores, nulabilidad, genéricos y devoluciones de llamada.
  • Distinguir los tipos que se puentean automáticamente de los que exigen una conversión explícita, y su coste.
  • Usar las anotaciones de la cabecera para corregir una traducción insatisfactoria.

Declarar qué se ve

Hay tres configuraciones y conviene no confundirlas. En un objetivo de aplicación de Xcode se usa un bridging header: un archivo de cabecera cuyo único contenido son las inclusiones del código Objective-C que quieres ver desde Swift. Xcode lo crea al añadir el primer archivo mixto y registra su ruta en la opción de compilación correspondiente. Todo lo que ese archivo incluya queda visible en todos los archivos Swift del objetivo, sin import alguno.

En un objetivo de framework el bridging header no existe, y es una decisión deliberada: un framework tiene una interfaz pública y no puede exponer detalles privados por la puerta de atrás. Ahí la visibilidad la da la cabecera paraguas, y solo llega a Swift lo que esté declarado en cabeceras marcadas como públicas.

En SwiftPM el mecanismo es el más simple de los tres: un objetivo de Objective-C con sus cabeceras públicas en el directorio de inclusión se convierte en un módulo, y desde Swift se escribe import con el nombre del objetivo.

import Analitica          // objetivo Objective-C dentro del mismo paquete

let s = ANSesion(identificador: "abc")   // el init viene de initWithIdentificador:

Cómo se reescribe la API

Esta es la parte que más sorprende a quien llega de leer documentación de Objective-C, porque el nombre que se escribe en Swift casi nunca coincide con el selector.

Nombres. El importador elimina el prefijo de dos o tres letras de las clases cuando el módulo lo declara, parte el selector por sus dos puntos y convierte cada fragmento en una etiqueta de argumento, y suprime las palabras redundantes cuando el tipo del parámetro ya las dice. Así stringByAppendingPathComponent: acaba siendo appendingPathComponent(_:) y arrayByAddingObject: acaba siendo adding(_:).

Inicializadores. Todo método cuyo selector empiece por init se convierte en un inicializador de Swift, y los métodos de fábrica de clase que devuelven una instancia del propio tipo también: colorWithRed:green:blue:alpha: llega como init(red:green:blue:alpha:).

Errores. Un método cuyo último parámetro sea NSError ** y que señale el fallo devolviendo nil o NO se importa como una función que lanza. El parámetro desaparece de la firma y el error viaja por el canal de Swift.

- (nullable NSData *)leerDesde:(NSURL *)url error:(NSError **)error;
let datos = try lector.leer(desde: url)     // sin parametro de error

Concurrencia. Un método con un bloque final de terminación se importa además como función async. La versión con devolución de llamada sigue existiendo, pero la asíncrona es la idiomática y es la que el compilador ofrece primero.

Nulabilidad y genéricos. Un puntero sin anotar llega como opcional implícitamente desenvuelto, que es la forma que tiene el importador de decir “no lo sé”. Anotado con nullable llega como opcional honesto y con nonnull como no opcional. Los genéricos ligeros hacen su parte con las colecciones: NSArray<NSString *> * llega como un array de cadenas en lugar de un saco de valores cualesquiera.

Propiedades y bloques. Un par de accesores declarado como propiedad en Objective-C llega como propiedad Swift, con readonly traducido a una propiedad de solo lectura. Los bloques llegan como clausuras, y los que la cabecera marca con NS_NOESCAPE llegan como clausuras que no escapan, lo que permite usarlos sin capturar con fuerza.

@property (nonatomic, readonly) NSUInteger cuenta;
- (void)cadaElemento:(NS_NOESCAPE void (^)(id elemento))bloque;
for _ in 0..<sesion.cuenta { }               // propiedad, no metodo
sesion.cadaElemento { elemento in ... }      // clausura que no escapa

Enumeraciones. NS_ENUM produce un enum Swift con casos sin prefijo, NS_OPTIONS produce un tipo que conforma a OptionSet, y NS_TYPED_ENUM sobre un alias de cadena produce un struct con constantes estáticas, que es como llegan tantas claves de diccionario del sistema.

💡
Anotaciones para corregir la traducción

Cuando la reescritura automática no acierta, la cabecera manda. NS_SWIFT_NAME fija el nombre exacto que verá Swift. NS_REFINED_FOR_SWIFT esconde el símbolo tras un prefijo de guiones bajos para que puedas envolverlo en una extensión Swift más idiomática. NS_SWIFT_UNAVAILABLE lo retira del todo. NS_SWIFT_SENDABLE y NS_SWIFT_UI_ACTOR aportan la información de concurrencia que Objective-C no sabe expresar.

Los tipos que se puentean solos

Un grupo reducido de tipos tiene doble ciudadanía. String y NSString, Array y NSArray, Dictionary, Set, Data, Date, URL, IndexSet y unos cuantos más se convierten automáticamente al cruzar la frontera, y esa conversión es la que permite que una API de Foundation se sienta nativa en ambos lenguajes. El mecanismo tiene nombre: el tipo Swift conforma a un protocolo interno de puenteo que define cómo se produce el objeto equivalente y cómo se recupera el valor.

let s: String = "hola"
let ns = s as NSString              // hacia arriba: siempre funciona
let vuelta = ns as String           // hacia abajo: aqui tambien, es un puente
let cualquiera: Any = 42
let n = cualquiera as? NSNumber     // condicional: puede fallar

Hay tres cosas que conviene saber sobre el coste, porque no es uniforme. Primera: el puenteo de una colección de objetos es esencialmente gratuito, porque el array Swift puede envolverse en un NSArray que comparte el mismo almacenamiento. Segunda: el puenteo de una colección de valores —enteros, structs, enumeraciones— obliga a encajar cada elemento en un objeto, y eso es lineal en el número de elementos y genera asignaciones. Tercera: en el sentido inverso, un NSString que llega a Swift puede quedarse referenciado en lugar de copiarse, con lo que operaciones que en un String nativo son directas pasan a atravesar el runtime de Objective-C.

⚠️
El puente es un tipo de coste, no un tipo de gratuidad

En un bucle que cruza la frontera miles de veces, las conversiones dominan el perfil con facilidad. El patrón correcto es convertir una vez en la frontera y trabajar dentro con tipos nativos, nunca convertir dentro del bucle. Y si el perfil señala a bridgeToObjectiveC o a _conditionallyBridgeFromObjectiveC, ya sabes exactamente qué estás pagando.

Fuera de ese grupo, la traducción del tipo dinámico de Objective-C es directa: id llega como Any, id restringido a un protocolo llega como el existencial correspondiente, y una referencia a objeto sin más llega como AnyObject. Ese AnyObject es especial: permite invocar cualquier método declarado en cualquier clase Objective-C visible, con el resultado opcional, porque la resolución ocurre en ejecución contra el runtime.

flowchart TB
H[Cabeceras de Objective C] --> M[Modulo Clang o bridging header]
M --> I[Importador de Swift]
A[Anotaciones de nulabilidad y NS SWIFT NAME] --> I
I --> R[Reescritura de nombres y errores]
R --> API[API idiomatica en Swift]
API --> P[Tipos puenteados de Foundation]
P --> COSTE[Coste lineal si el elemento es un valor]
style I fill:#cba6f7,color:#11111b
style API fill:#a6e3a1,color:#11111b
style COSTE fill:#f38ba8,color:#11111b

Convivir en un objetivo mixto

En un objetivo con ambos lenguajes hay dos flujos de visibilidad que van en sentidos opuestos y nunca deben cruzarse: Objective-C se ve desde Swift a través del bridging header, y Swift se ve desde Objective-C a través de la cabecera generada que estudiaremos en la siguiente lección. Incluir la segunda dentro del primero forma un ciclo y el compilador se detiene; la salida es declarar hacia adelante con @class en la cabecera y dejar la inclusión completa para el archivo de implementación.

Del lado dinámico, Swift ofrece dos expresiones que convierten en comprobables cosas que en Objective-C eran cadenas de texto. #selector construye un selector verificando que el método existe y está expuesto, y #keyPath hace lo mismo con la ruta de una propiedad. Ambas convierten un fallo silencioso en ejecución en un error de compilación, que es exactamente el tipo de intercambio que justifica toda esta maquinaria.

boton.addTarget(self, action: #selector(pulsar), for: .touchUpInside)
observacion = objeto.observe(\.estado) { obj, _ in print(obj.estado) }

Queda un asunto que en Swift 6 aparece antes de lo que uno espera: los módulos de Objective-C antiguos no dicen nada sobre concurrencia, así que ningún tipo suyo es Sendable y ninguna devolución de llamada declara su aislamiento. Importarlos con @preconcurrency import rebaja esos diagnósticos a avisos mientras traduces, y las anotaciones de la cabecera —NS_SWIFT_SENDABLE y el marcado de actor principal— son la solución definitiva cuando la librería es tuya.

🧩

El nombre no es el selector

Ni siquiera se le parece. Consulta siempre la firma importada antes de suponer cómo se llama un método en Swift.

El opcional implícito es una confesión

Significa que la cabecera no estaba anotada. Trátalo como deuda, no como comodidad.

📊

Puentear se mide

Las conversiones de colecciones de valores son lineales. Convierte en la frontera, nunca dentro del bucle.

La única frontera de Swift donde no hay frontera

Vale la pena apreciar lo excepcional de esta relación, porque no volverá a repetirse con ningún otro lenguaje. Con C y con C++ la interoperabilidad consiste en traducir representaciones: dos modelos de memoria distintos que el compilador pone de acuerdo en el punto de la llamada. Con Objective-C no hay nada que poner de acuerdo. Una clase Swift que hereda de NSObject es una clase del runtime de Objective-C, con su puntero isa, su tabla de métodos y su recuento de referencias gestionado por el mismo ARC. El objeto no se convierte al cruzar porque no hay dos representaciones entre las que elegir. Lo que cruza la frontera es otra cosa: el significado. Objective-C describe su API con selectores largos que llevan la información en el nombre, sin distinguir opcionalidad, sin distinguir error de ausencia, sin decir si una colección es homogénea, sin decir si un objeto puede cruzar hilos. Swift exige todas esas distinciones porque su sistema de tipos las usa para demostrar propiedades del programa. De modo que el importador no es un traductor de datos sino un traductor de intenciones, y como toda traducción de intenciones, adivina cuando el original calla. Ahí es donde nació el opcional implícitamente desenvuelto, esa figura extraña que muchos toman por una comodidad sintáctica y que en realidad es una confesión: significa que el compilador no encontró en la cabecera la información necesaria para decidir, y decidió confiar en ti. Cada anotación que añades a una cabecera de Objective-C —nullable, nonnull, un genérico ligero, NS_SWIFT_SENDABLE— retira una de esas confianzas y la sustituye por una comprobación. Ese es el arco entero de la interoperabilidad bien hecha: no consiste en lograr que el código compile desde el otro lado, sino en ir convirtiendo, anotación a anotación, lo que el programador sabía en lo que el programa sabe.

⚔️ Reconstruye la traducción
  1. Toma cinco selectores largos de Foundation y predice su nombre en Swift antes de comprobarlo; analiza cada acierto y cada fallo.
  2. Escribe una clase Objective-C sin anotaciones de nulabilidad, úsala desde Swift y después anótala entera: cuenta cuántos opcionales implícitos desaparecen.
  3. Declara un método con NSError ** y compruébalo desde Swift con try; después elimina la anotación de retorno nulable y observa qué cambia.
  4. Mide el tiempo de puentear un array de un millón de enteros y otro de un millón de objetos, y explica la diferencia con lo visto en la lección.
  5. Usa NS_REFINED_FOR_SWIFT sobre un método incómodo y publica encima una extensión Swift con la firma que tú considerarías correcta.