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

Interoperabilidad con C++: lo que llega y lo que se queda

Desde Swift 5.9 el compilador importa C++ directamente: espacios de nombres, plantillas instanciadas, contenedores estándar y tipos con constructor de copia y destructor. Cómo se decide entre semántica de valor y semántica de referencia, qué anotaciones gobiernan esa decisión, y qué fronteras siguen doliendo.

⏱ 21 min

Durante casi una década la respuesta oficial a “cómo llamo a C++ desde Swift” fue una capa de funciones extern "C" escritas a mano. Swift 5.9 cambió el planteamiento: el mismo importador que traduce C aprendió a traducir C++, y lo hizo apuntando a algo más ambicioso que llamar funciones sueltas. Hoy un tipo de C++ con constructor de copia y destructor llega a Swift como un struct con semántica de valor correcta, std::vector se recorre con un for, y una plantilla instanciada aparece como un tipo concreto. Nada de esto es magia ni conversión en ejecución: sigue siendo traducción en tiempo de compilación. Pero C++ codifica muchas más garantías que C —y muchas otras que decide no codificar—, así que la frontera es más rica y también más afilada.

🎯 Al terminar esta lección sabrás
  • Activar el modo de interoperabilidad y describir qué construcciones de C++ importa el compilador.
  • Distinguir cómo se importa un tipo con semántica de valor de uno con semántica de referencia.
  • Usar las anotaciones que corrigen una traducción que el compilador no puede deducir.
  • Reconocer las fronteras no resueltas y decidir cuándo escribir una capa intermedia.

Lo que el compilador sabe traducir

La interoperabilidad no está activa por defecto. En SwiftPM se enciende por objetivo, y hay que encenderla también en los objetivos que consumen el tuyo:

.target(
    name: "Motor",
    dependencies: ["MotorCxx"],
    swiftSettings: [.interoperabilityMode(.Cxx)]
)

Con el modo activo, la traducción cubre bastante más terreno del que se suele suponer. Los espacios de nombres llegan como enumeraciones sin casos que actúan de contenedor, de modo que geo::Punto se escribe en Swift como geo.Punto. Las funciones y métodos llegan con sus parámetros por defecto y sus sobrecargas. Los operadores que Swift también tiene se traducen a sus equivalentes, y operator() se convierte en la función callAsFunction. Los contenedores estándar se importan con conformidades reales: std::vector y std::map satisfacen los protocolos de la familia CxxSequence, y std::string conversa con String en ambos sentidos.

import CxxStdlib
import Motor

var v = std.vector<CInt>()
v.push_back(3)
v.push_back(5)

for x in v { print(x) }                 // se recorre como cualquier secuencia
let suma = v.reduce(0, +)               // y admite los algoritmos de Swift
let s = std.string("hola")
print(String(s).uppercased())

La traducción de miembros sigue las mismas convenciones de reescritura que ya conoces del importador de C: los métodos conservan su nombre, los parámetros por defecto se respetan y las funciones estáticas de la clase llegan como métodos de tipo. Lo que no llega automáticamente es la ergonomía: un par de métodos accesores sigue siendo un par de métodos hasta que la cabecera declara lo contrario, y una función que devuelve un código de error sigue devolviendo un entero. Esa capa de idiomatización es tuya, y el lugar natural para escribirla es una extensión Swift sobre el tipo importado.

extension MallaF {
    var vertices: Int { Int(self.contarVertices()) }     // propiedad calculada encima
    func validada() throws -> MallaF {
        guard self.esValida() else { throw ErrorMalla.degenerada }
        return self
    }
}

Las plantillas tienen una restricción importante que conviene interiorizar temprano: Swift no puede instanciarlas. Solo llegan las instanciaciones que ya existen en el lado de C++, así que si quieres exponer una plantilla debes fijar sus argumentos con un alias en la cabecera. Ese alias es lo que Swift verá como tipo concreto:

// motor.h
template <typename T> struct Malla { /* ... */ };
using MallaF = Malla<float>;            // sin esta linea, Swift no ve nada

Valor o referencia: la decisión central

Todo tipo de C++ que cruza la frontera debe caer en uno de dos regímenes, y esa clasificación gobierna todo lo demás.

Por defecto, un tipo se importa como struct Swift con semántica de valor. El compilador usa el constructor de copia de C++ donde Swift copiaría y el destructor donde Swift destruiría, con lo que la regla de los cinco se respeta sin que escribas nada. Es la traducción correcta para tipos que se comportan como valores: un vector, una cadena, una matriz, un punto.

El problema aparece con los tipos que en C++ tienen identidad y se manejan siempre por puntero: un nodo de un grafo, un recurso compartido, un objeto con recuento de referencias propio. Copiarlos por valor es semánticamente falso y a menudo caro o imposible. Para esos existe la anotación que los convierte en clases Swift:

class Sesion {
public:
    void enviar(const char *mensaje);
} SWIFT_SHARED_REFERENCE(sesion_retener, sesion_liberar);

void sesion_retener(Sesion *s);
void sesion_liberar(Sesion *s);

Con esa anotación el tipo llega a Swift como una class inmortal frente al copiado: se pasa por referencia y el compilador inserta las llamadas a las dos funciones de recuento en los puntos exactos donde insertaría un retener y un liberar de ARC. Hay una variante, SWIFT_IMMORTAL_REFERENCE, para singletons y objetos que viven lo que dura el proceso, y una tercera, SWIFT_UNSAFE_REFERENCE, para cuando quieres semántica de referencia y aceptas gestionar la vida a mano.

💡
Anotaciones que corrigen la traducción

Además de las tres de referencia, hay un pequeño vocabulario que vale su peso en oro. SWIFT_NAME renombra un símbolo para que respete las convenciones de Swift. SWIFT_COMPUTED_PROPERTY convierte un par de métodos accesores en una propiedad. SWIFT_NONCOPYABLE marca un tipo cuyo constructor de copia está eliminado, y llega a Swift como un tipo que no conforma a Copyable. Todas viven en la cabecera de C++ y no requieren tocar la implementación.

flowchart TB
T[Tipo de C plus plus en la cabecera] --> Q1[Tiene constructor de copia utilizable]
Q1 -->|si| V[Struct Swift con semantica de valor]
Q1 -->|no| Q2[Tiene funciones de retener y liberar]
Q2 -->|si| C[Class Swift con SWIFT SHARED REFERENCE]
Q2 -->|no| Q3[Vive lo que dura el proceso]
Q3 -->|si| I[Referencia inmortal]
Q3 -->|no| U[Referencia insegura o capa intermedia]
style V fill:#a6e3a1,color:#11111b
style C fill:#89b4fa,color:#11111b
style U fill:#f38ba8,color:#11111b

Las fronteras que aún duelen

Conviene conocer los límites antes de apoyar una arquitectura sobre ellos.

Las excepciones no cruzan. Una excepción de C++ que escapa hasta un marco de pila de Swift termina el proceso. No hay traducción a throws porque el modelo de propagación es incompatible con el desenrollado que Swift genera. La consecuencia práctica es que toda API de C++ que lance debe envolverse en el lado de C++ en una función que capture y devuelva un código o un tipo de resultado.

La herencia no es bidireccional. Swift puede llamar a métodos virtuales de una clase de C++ y el despacho funciona, pero una clase Swift no puede heredar de una clase de C++ ni implementar sus métodos virtuales, y una clase de C++ no puede heredar de una clase Swift. Cualquier patrón que dependa de que el consumidor derive de una clase base debe reformularse como una interfaz de punteros a función o una devolución de llamada.

Las proyecciones son inseguras y el compilador lo sabe. Un método que devuelve una referencia o un puntero a almacenamiento interno —data, front, begin, un operator[] que devuelve referencia— produce en Swift un valor cuya vida no está atada a la del objeto que lo produjo. Swift importa esas APIs, pero bajo seguridad de memoria estricta quedan marcadas como inseguras y exigen unsafe explícito. Es la misma clase de error que el puntero colgante de la lección anterior, con la diferencia de que aquí el compilador al menos te lo señala.

var m = MallaF()
let p = m.data()          // puntero al almacenamiento interno de m
m.redimensionar(1024)     // posible realojo: p acaba de quedar invalido

Los iteradores y los tipos que solo se mueven son terreno parcial: la mayoría de plantillas con tipos dependientes complejos, los punteros inteligentes con propiedad exclusiva y las jerarquías con herencia virtual llegan mal o no llegan. La regla de oro es empírica: si el símbolo no aparece al autocompletar, no está importado, y ninguna cantidad de sintaxis lo va a invocar.

⚖️

La clasificación manda

Valor o referencia no es una preferencia de estilo: determina si el compilador inserta copias o recuentos. Clasifica cada tipo en la cabecera antes de escribir una línea de Swift.

🧨

Excepciones en el origen

Toda función de C++ que pueda lanzar necesita una envoltura que capture. Es la única capa manual que sigue siendo imprescindible.

🔬

El autocompletado es el oráculo

Si un símbolo no aparece al escribir el punto, no está importado. Comprobarlo cuesta un segundo y ahorra tardes enteras de sintaxis inútil.

Hay una consecuencia de diseño que conviene extraer de todo lo anterior. La interoperabilidad con C++ funciona espléndidamente cuando la API del otro lado está compuesta por tipos con semántica de valor y funciones que devuelven valores, y se degrada con rapidez en cuanto aparecen jerarquías con herencia, iteradores expuestos y APIs que devuelven vistas del almacenamiento interno. Como esa segunda familia describe bastante bien la biblioteca estándar de C++ y buena parte del código empresarial escrito antes de 2011, el criterio práctico es sencillo: si la librería es tuya, adáptala en su propio lenguaje hacia el primer estilo antes de exponerla; si no lo es, escribe una fachada en C++ moderno que la reexprese, y expón esa fachada.

⚠️
El sentido inverso también existe

Swift puede exponerse a C++ generando una cabecera con -emit-clang-header-path. Los structs y las clases Swift aparecen allí como tipos de C++ con la semántica correspondiente. La limitación es simétrica y previsible: lo que no tiene equivalente —genéricos sin especializar, protocolos con tipos asociados, funciones asíncronas— no aparece en la cabecera generada.

Dos lenguajes que resolvieron el mismo problema con contratos distintos

Lo fascinante de esta frontera es que Swift y C++ comparten muchísimo más de lo que sugiere su reputación: ambos tienen tipos de valor con copia profunda, ambos destruyen de forma determinista al salir del ámbito, ambos permiten abstracción sin coste. Un struct Swift y un tipo de C++ que respeta la regla de los cinco son, en el fondo, la misma idea. Por eso la traducción es posible sin adaptadores y por eso funciona tan bien cuando funciona. Lo que separa a los dos lenguajes no es el modelo de valores, sino quién es responsable de las invariantes que el modelo no expresa. C++ dice: el programador sabe cuánto vive esa referencia, sabe que no reasignará el vector mientras sostiene el puntero, sabe que la excepción se captura arriba. Swift dice: si el programador lo sabe pero el compilador no, entonces el programa no lo sabe. Cada una de las fronteras que duelen se deduce de esa única discrepancia. Las excepciones no cruzan porque su desenrollado exige una disciplina que Swift no puede verificar. Las proyecciones exigen unsafe porque devuelven un puntero cuya vida no está atada a nada que el compilador pueda seguir. La herencia no es bidireccional porque cada lenguaje tiene su propia noción de qué garantiza una clase base. Vista así, la interoperabilidad con C++ no es una lista de funciones pendientes de implementar: es un ejercicio de traducción entre dos sistemas de responsabilidad, y las anotaciones que escribes en la cabecera son literalmente el acta donde declaras quién responde de qué. Escribir SWIFT_SHARED_REFERENCE no le añade una capacidad al tipo; le añade una promesa verificable. Y ese es el criterio con el que conviene decidir cuánto C++ dejar entrar en un proyecto Swift: no cuánto compila, sino cuántas promesas estás dispuesto a escribir por extenso.

⚔️ Mide dónde está el filo
  1. Activa el modo de interoperabilidad en un paquete y expón una clase de C++ con constructor de copia y destructor; comprueba con trazas que Swift llama a ambos donde esperas.
  2. Anota esa misma clase con SWIFT_SHARED_REFERENCE y repite el experimento: observa cómo cambia el número de copias.
  3. Expón una plantilla sin alias y con alias, y documenta qué ve el autocompletado en cada caso.
  4. Escribe una función de C++ que lance una excepción, llámala desde Swift y observa el resultado; después envuélvela para que devuelva un código de error.
  5. Genera la cabecera de C++ a partir de un módulo Swift con -emit-clang-header-path y haz un inventario de qué declaraciones tuyas no aparecieron y por qué.