Qué se pierde: el subconjunto y su lógica interna
El inventario exacto de lo que el modo embebido rechaza —metatipos, reflexión, existenciales arbitrarios, genéricos sin especializar— la excepción que reciben los protocolos ligados a clase, qué ocurre con los errores y las cadenas, y por qué lo que queda después del recorte sigue siendo reconociblemente Swift y no un lenguaje empobrecido.
La lista de restricciones de Embedded Swift asusta al leerla en frío y deja de asustar en cuanto se entiende que no es una lista: es un teorema con sus corolarios. De la premisa de mundo cerrado se deduce que no hacen falta metadatos, y de la ausencia de metadatos se deduce, una por una y sin excepciones caprichosas, cada cosa que el compilador rechaza. Ninguna restricción está ahí porque alguien decidiera que era mejor prescindir de ella; están porque preguntarle a un runtime que no existe es una operación sin significado. Entender esa cadena deductiva convierte los mensajes de error en algo previsible, y convierte el subconjunto en una herramienta que se puede razonar en lugar de una lista que hay que memorizar.
- Enumerar las construcciones que el modo embebido rechaza y deducir cada una de la premisa de mundo cerrado.
- Explicar por qué los protocolos ligados a clase conservan su existencial y los demás no.
- Reescribir un diseño basado en
any Protocolousando genéricos, enumerados o clases. - Justificar por qué el subconjunto resultante conserva la identidad del lenguaje y no lo degrada a un C con azúcar.
El inventario y su deducción
Todo lo que cae, cae por la misma razón: exige preguntarle en ejecución algo que solo el runtime sabría responder. El listado es corto y conviene tenerlo presente entero.
Los metatipos dinámicos desaparecen. Un tipo puede seguir usándose como restricción genérica y como anotación, pero no se puede guardar un tipo dentro de una variable, pasarlo como argumento ni compararlo en ejecución, porque el valor que representaría a ese tipo es precisamente el metadato que ya no se emite. La reflexión cae con ellos: Mirror no existe, no hay forma de recorrer las propiedades de un valor sin conocerlas estáticamente y todo lo que se construía sobre esa capacidad, desde la serialización automática hasta los volcados de depuración genéricos, hay que resolverlo en compilación o no resolverlo.
Las conversiones dinámicas arbitrarias se van por el mismo camino: preguntar si un valor cumple un protocolo cualquiera requiere consultar una tabla que ya no se emite. Sobrevive un caso, la conversión descendente entre clases, porque la jerarquía de clases del programa es enteramente visible bajo el mundo cerrado y esa comprobación se puede resolver con la información que la propia clase ya lleva.
Los genéricos sin especializar no es que estén prohibidos: es que no existen. Cada instanciación se resuelve en compilación, y si el compilador encuentra un uso que no puede resolver, la compilación falla en lugar de degradarse. Lo mismo ocurre con la interoperabilidad con Objective-C entera, que asume un runtime que aquí no está, y con la carga dinámica de cualquier especie.
Dos zonas del subconjunto se han movido entre versiones del compilador y no conviene fiarse de lo que leíste hace un año. Las referencias débiles y sin dueño tardaron en aparecer porque exigen una tabla lateral en el runtime, y el soporte de String es deliberadamente reducido: existe el tipo, pero la maquinaria completa de grafemas Unicode es cara en flash y muchas construcciones de interpolación piden conformidades que solo se resuelven estáticamente. Fija la versión del toolchain y comprueba contra ella, no contra la documentación general.
Existenciales: por qué la clase es la excepción
Aquí está la restricción que más duele a quien viene de programación orientada a protocolos, y también la que mejor ilustra la lógica del subconjunto. Un valor de tipo any Protocolo es una caja: guarda un valor de tamaño desconocido, más un puntero a la tabla de testigos que explica cómo cumple el protocolo, más la información de cómo copiarlo y destruirlo. Todo eso es metadato, y el metadato ya no está.
protocol Sensor {
func leer() -> Int32
}
let s: any Sensor = Termopar() // rechazado: existencial de tipo arbitrario
Ahora cambia una palabra y el mismo diseño pasa:
protocol Sensor: AnyObject {
func leer() -> Int32
}
final class Termopar: Sensor { func leer() -> Int32 { 0 } }
let s: any Sensor = Termopar() // aceptado: existencial ligado a clase
La diferencia no es arbitraria. Al ligar el protocolo a AnyObject, cualquier conformidad es una instancia de clase, y una instancia de clase es siempre un puntero de tamaño conocido con su propia información de despacho alcanzable desde el objeto. La caja deja de necesitar metadatos externos porque el objeto los lleva encima. El polimorfismo en tiempo de ejecución sigue disponible; lo que hay que aceptar es el precio que siempre tuvo y que en el escritorio no se notaba: una asignación en el montículo y un recuento de referencias por cada valor.
Genéricos
Polimorfismo en compilación. Coste cero en ejecución, coste en tamaño por cada instanciación.
Enumerados
Conjunto cerrado de alternativas con valores asociados. Sin montículo, sin recuento, exhaustividad verificada.
Clases
Polimorfismo en ejecución para conjuntos abiertos. Paga montículo y recuento, pero funciona.
Las tres tarjetas anteriores son, en la práctica, las tres salidas para todo diseño que antes usaba un existencial arbitrario. Si el conjunto de tipos es conocido y cerrado, un enum con valores asociados lo expresa mejor de lo que lo expresaba el existencial y encima obliga al compilador a verificar la exhaustividad. Si el conjunto es abierto pero se conoce en el punto de uso, un parámetro genérico o un some Sensor lo resuelve sin coste. Y si de verdad hace falta una colección heterogénea decidida en ejecución, ahí es donde la clase gana su sueldo.
// La reescritura habitual: de existencial arbitrario a enumerado cerrado
enum Lectura {
case temperatura(centesimas: Int32)
case presion(pascales: UInt32)
case fallo(codigo: UInt8)
}
func procesar(_ l: Lectura) -> UInt32 {
switch l {
case .temperatura(let c): return UInt32(bitPattern: c)
case .presion(let p): return p
case .fallo: return .max
}
}
Errores, sin caja y con tipo
El manejo de errores merece párrafo propio porque es donde más gente tropieza. Un throws sin tipar produce un valor de tipo any Error, que es exactamente el existencial arbitrario que el modo no puede representar. La solución no es renunciar a los errores sino escribirlos con el tipo puesto, y resulta que los typed throws de Swift 6 encajan aquí como si se hubieran diseñado para esto.
enum ErrorBus: Error {
case sinRespuesta
case checksum(esperado: UInt8, recibido: UInt8)
}
func leerRegistro(_ dir: UInt8) throws(ErrorBus) -> UInt8 {
guard esperarAck() else { throw ErrorBus.sinRespuesta }
return recibirByte()
}
Con el tipo declarado, el error viaja como un valor concreto de tamaño conocido, sin caja y sin asignación. El compilador conoce el conjunto exacto de fallos posibles en cada frontera, la propagación se verifica igual que siempre y el mecanismo cuesta lo mismo que devolver un enumerado a mano, que es lo que se hacía en C con códigos de error salvo que aquí nadie puede ignorar el resultado por descuido.
flowchart LR
a[Necesitas polimorfismo]
a --> b{Conoces el conjunto de tipos}
b -->|Si y es cerrado| c[Enum con valores asociados]
b -->|Si en el punto de uso| d[Generico o some Protocolo]
b -->|No, se decide en ejecucion| e[Protocolo ligado a AnyObject]
c --> f[Sin monticulo sin recuento]
d --> f
e --> g[Monticulo y recuento de referencias]Lo que queda sigue siendo Swift
Es tentador leer el inventario anterior y concluir que queda poco. Queda casi todo, y merece la pena decirlo con nombres propios. Siguen intactos los tipos por valor con su semántica de copia, los opcionales con su desenvolvimiento seguro, los genéricos con restricciones y cláusulas where, los protocolos con extensiones e implementaciones por defecto, los enumerados con valores asociados y el ajuste de patrones exhaustivo, los cierres con su lista de captura, defer, guard, el recuento automático de referencias, la ley de exclusividad, la comprobación de límites en las colecciones, la aritmética que atrapa el desbordamiento, los tipos no copiables con borrowing y consuming, y los operadores propios.
Un fragmento cualquiera de lógica de dominio lo demuestra mejor que la enumeración: lo que sigue compila en modo embebido sin una sola concesión y no se distingue del Swift que escribirías para una aplicación.
enum Estado: UInt8 { case reposo, midiendo, error }
struct Ventana<T: FixedWidthInteger>: ~Copyable {
private var datos: (T, T, T, T)
private var i = 0
mutating func añadir(_ v: T) { /* rotacion sobre la tupla */ }
var media: T { (datos.0 &+ datos.1 &+ datos.2 &+ datos.3) / 4 }
}
func siguiente(_ e: Estado, hayMuestra: Bool) -> Estado {
switch (e, hayMuestra) {
case (.reposo, true): return .midiendo
case (.midiendo, false): return .error
default: return e
}
}
Hay ahí un enumerado con valor bruto, un genérico con restricción, un tipo no copiable, una propiedad calculada, mutación controlada y ajuste de patrones sobre una tupla con exhaustividad verificada. Nada de eso paga montículo, nada de eso emite metadatos y todo eso sería, en C, una estructura con enteros desnudos y un switch cuya exhaustividad nadie comprueba.
Siguen intactas, además, dos capacidades que sorprende encontrar en un microcontrolador. Las macros funcionan enteras, y funcionan precisamente porque se ejecutan en la máquina que compila y no en la que ejecuta: el programa de la macro corre en tu portátil y lo único que llega al chip es el código generado. De ahí sale la mejor herramienta de modelado de hardware que tiene el ecosistema. Y los result builders también sobreviven, con lo que un lenguaje declarativo interno para describir una configuración de periféricos es perfectamente viable.
Quien llega del escritorio vigila las asignaciones y se olvida del código. En modo embebido la fuente principal de crecimiento inesperado es la especialización: un genérico útil instanciado con ocho tipos distintos emite ocho cuerpos, y un protocolo con extensiones por defecto usado desde muchos sitios los multiplica. Antes de culpar al recuento de referencias, mira el mapa del enlazador y ordena los símbolos por tamaño.
La lectura superficial del subconjunto dice que Embedded Swift renuncia a la abstracción para caber en un chip, y esa lectura es falsa de un modo interesante. Lo que se elimina no es la abstracción sino un mecanismo concreto de implementarla: el que consiste en diferir la respuesta hasta la ejecución y guardar en el binario la información necesaria para contestarla entonces. La abstracción por genéricos no se toca porque su respuesta se conoce en compilación; la abstracción por enumerado no se toca porque su respuesta está en el propio valor; la abstracción por clase no se toca porque su respuesta la lleva el objeto encima. Solo cae el existencial de tipo arbitrario, que es el único caso donde la respuesta vive en una tabla externa que alguien tendría que emitir por si acaso. Dicho de otro modo: el modo embebido no penaliza abstraer, penaliza ignorar en compilación lo que se podría haber sabido en compilación. Y ahí aparece el hallazgo que trasciende el firmware: la mayoría de los usos de any Protocolo en código Swift corriente no necesitaban dinamismo, lo usaban por comodidad de escritura, y al obligarte a reescribirlos descubres que el conjunto de tipos era cerrado, que el enumerado lo expresa mejor y que ahora el compilador verifica la exhaustividad que antes se comprobaba con una rama por defecto. Programar un microcontrolador acaba siendo, sin proponérselo, un ejercicio de higiene de diseño que mejora también el código que jamás verá un microcontrolador.
Caen los metatipos dinámicos, la reflexión, las conversiones dinámicas arbitrarias, los genéricos sin especializar, la interoperabilidad con Objective-C y los existenciales, salvo el caso de los protocolos ligados a AnyObject, que conservan su caja porque el objeto lleva su propia información. Los errores se escriben con typed throws para evitar la caja de any Error. Sobreviven los valores, los opcionales, los genéricos, los enumerados, el ajuste de patrones, ARC, la exclusividad, la propiedad, las macros y los result builders.
- Toma una jerarquía tuya basada en
any Protocoloy decide para cada uso si es un conjunto cerrado, un genérico o un caso real de despacho en ejecución. - Convierte la variante cerrada en un
enumcon valores asociados y comprueba qué ramas te obliga a cubrir el compilador que antes no cubrías. - Liga un protocolo a
AnyObjecty mide en el mapa del enlazador la diferencia de tamaño frente a la versión genérica. - Migra una función con
throwssin tipar a typed throws y razona dónde desaparece la asignación. - Busca en tu código un uso de
Mirroro detype(of:)y propón la forma estática de obtener la misma información.