wandres.dev
UNSAFE Y PUNTEROS · bajar al metal

La familia de punteros: leer el nombre y saber qué promete

Los tipos de puntero de Swift no son un catálogo arbitrario sino una gramática de tres ejes. Qué significa exactamente el prefijo `Unsafe`, qué añade `Mutable`, qué quita `Raw`, por qué la nulidad vive en `Optional` y no en el tipo, y cómo se traduce cada firma de C al importarla.

⏱ 18 min

Si buscas la palabra Pointer en la biblioteca estándar aparecen ocho tipos que, vistos de golpe, parecen un exceso barroco. No son ocho decisiones independientes: son el producto cartesiano de tres preguntas binarias que el diseño del lenguaje decidió inscribir en el nombre en vez de dejarlas en la documentación. ¿Puedo escribir aquí? ¿Sé de qué tipo son estos bytes? ¿Sé cuántos hay? En C esas tres preguntas también existen, pero se responden en la cabeza de quien programa y, con suerte, en un comentario; en Swift se responden en el tipo, y el comprobador las recuerda por ti aunque haya dejado de protegerte. Aprender la familia de punteros no consiste, por tanto, en memorizar ocho nombres, sino en adquirir un hábito de lectura: ver un identificador y deducir de él el contrato entero, incluida la parte del contrato que te toca cumplir a ti.

🎯 Al terminar esta lección sabrás
  • Descomponer cualquier nombre de puntero en sus tres ejes y enunciar qué garantiza cada uno.
  • Distinguir memoria ligada a un tipo de memoria cruda, y qué operaciones exige cada una.
  • Explicar por qué la nulidad no vive en el tipo de puntero sino en Optional, y a qué coste.
  • Traducir las firmas de C importadas a sus equivalentes exactos en Swift.

Una gramática de tres ejes

El eje de la mutabilidad decide si el puntero concede permiso de escritura. El eje del tipado decide si la memoria apuntada está ligada a un tipo concreto o se trata como una secuencia de bytes sin interpretación. El eje de la extensión decide si el puntero conoce cuántos elementos hay a partir de esa dirección. Combínalos y obtienes exactamente la tabla que ofrece la biblioteca:

UnsafePointer<T>                  // lectura, tipado, sin cuenta
UnsafeMutablePointer<T>           // escritura, tipado, sin cuenta
UnsafeRawPointer                  // lectura, bytes, sin cuenta
UnsafeMutableRawPointer           // escritura, bytes, sin cuenta
UnsafeBufferPointer<T>            // lectura, tipado, con cuenta
UnsafeMutableBufferPointer<T>     // escritura, tipado, con cuenta
UnsafeRawBufferPointer            // lectura, bytes, con cuenta
UnsafeMutableRawBufferPointer     // escritura, bytes, con cuenta

El prefijo Unsafe no es uno de los ejes: es la condición común a los ocho. Significa algo muy preciso y muy poco tranquilizador. No significa “peligroso” ni “desaconsejado”; significa que el compilador deja de verificar las precondiciones y las da por ciertas. Cuando escribes p.pointee, el lenguaje asume sin comprobar que la dirección es válida, que está alineada para el tipo, que la memoria está inicializada y que nadie más la está mutando en ese instante. Si alguna de esas cuatro cosas es falsa, el programa no falla: entra en comportamiento indefinido, que es cosa muy distinta y mucho peor.

let p = UnsafeMutablePointer<Int>.allocate(capacity: 3)
p.initialize(repeating: 0, count: 3)

p.pointee = 10           // el primer elemento
p[1] = 20                // subindice, igual que advanced(by: 1).pointee
(p + 2).pointee = 30     // la aritmetica avanza en unidades de stride, no de bytes

print(p[0], p[1], p[2])  // 10 20 30
p.deinitialize(count: 3)
p.deallocate()

Fíjate en el detalle de la aritmética, porque es la primera divergencia real con C que casi nadie enuncia: p + 2 no suma dos bytes ni dos veces el tamaño del tipo, sino dos veces MemoryLayout<T>.stride. El stride incluye el relleno de alineación que separa dos elementos consecutivos en un array, mientras que size es el tamaño lógico del valor aislado. Para Int coinciden; para un struct con campos de tamaños dispares no tienen por qué, y confundirlos produce desplazamientos que funcionan en las pruebas y se rompen en producción.

Tipado frente a crudo

Un puntero tipado promete que la región apuntada está ligada al tipo T. Ligar memoria no es una anotación cosmética: es un estado real que el modelo de memoria de Swift mantiene sobre cada región y que el optimizador explota para reordenar cargas y almacenamientos. Un puntero crudo, en cambio, no promete nada sobre el contenido; solo direcciona bytes. Por eso UnsafeRawPointer carece de pointee y de subíndice de elemento, y en su lugar ofrece load y storeBytes, que reinterpretan explícitamente.

var valor: UInt32 = 0x01020304

withUnsafeBytes(of: &valor) { bytes in
    // bytes es UnsafeRawBufferPointer, una coleccion de UInt8
    print(bytes.count)                          // 4
    print(bytes.load(as: UInt32.self))          // 16909060, requiere alineacion
    print(bytes.loadUnaligned(fromByteOffset: 1, as: UInt16.self))
}

loadUnaligned llegó con SE-0349 en Swift 5.7 y resolvió un problema muy concreto: hasta entonces, leer un entero de dieciséis bits desde un desplazamiento impar de un búfer de red exigía copiar byte a byte, porque load exige que la dirección esté alineada para el tipo destino. Esa exigencia no es un capricho, y la trataremos en la última lección: en varias arquitecturas una carga desalineada es lenta, y en algunas es una excepción de hardware.

🔓

Unsafe suspende la verificación

No describe una intención sino un reparto de responsabilidad. Las precondiciones siguen existiendo; lo que desaparece es quien las comprueba por ti.

🏷️

Tipado significa memoria ligada

La ligadura a un tipo es un estado del modelo de memoria, no una etiqueta del puntero. El optimizador razona con ella.

📏

La aritmética va en stride

Sumar uno avanza MemoryLayout<T>.stride bytes, con el relleno de alineación incluido. size y stride no siempre coinciden.

Mutabilidad, nulidad y el puente con C

La mutabilidad se comporta como una relación de subtipo dirigida: donde se espera un UnsafePointer puedes pasar un UnsafeMutablePointer, y la conversión es implícita en el punto de llamada. Al revés no. Conviene entender qué expresa realmente esa inmutabilidad: no dice que la memoria sea de solo lectura, sino que este puntero no concede permiso de escritura. Otro puntero mutable a la misma región puede estar escribiendo mientras tú lees, y el lenguaje no lo impedirá.

La nulidad, en cambio, salió del tipo. Un UnsafePointer nunca es nulo; para expresar la posibilidad de nulidad se usa Optional como con cualquier otro tipo, y el compilador aplica una optimización de representación que hace que el envoltorio no cueste nada: el caso nil se codifica con la dirección cero, de modo que un puntero opcional ocupa exactamente lo mismo que uno normal.

var quiza: UnsafeMutablePointer<Int>? = nil
print(MemoryLayout.size(ofValue: quiza))   // 8 en 64 bits, sin bit extra de etiqueta

Esa decisión es la que permite que el importador de C traduzca las firmas sin perder información. Un puntero de C es potencialmente nulo salvo que esté anotado, así que se importa como opcional implícitamente desenvuelto:

// C                          Swift importado
// int *p                     UnsafeMutablePointer<Int32>!
// const int *p               UnsafePointer<Int32>!
// void *p                    UnsafeMutableRawPointer!
// const void *p              UnsafeRawPointer!
// struct Contexto *p         OpaquePointer!
// _Nonnull int *p            UnsafeMutablePointer<Int32>

Fuera de la gramática de los tres ejes viven tres primos lejanos que conviene reconocer para no confundirlos con la familia principal. OpaquePointer representa una dirección cuyo contenido no puede describirse en Swift —el manejador típico de una biblioteca de C, declarado pero nunca definido—, y por eso no ofrece ni pointee ni aritmética: solo se guarda y se devuelve. AutoreleasingUnsafeMutablePointer aparece al importar los parámetros de salida de Objective-C que devuelven un objeto, y arrastra la semántica de liberación diferida de aquel entorno. Y Unmanaged no es un puntero sino un envoltorio que suspende ARC sobre una referencia, para poder atravesar un contexto de C que no sabe retener ni liberar.

// El patron clasico: llevar un objeto Swift a traves de una API de C
let objeto = MiClase()
let contexto = Unmanaged.passRetained(objeto).toOpaque()   // retiene, da un puntero crudo

// ... mas tarde, dentro del callback ...
let recuperado = Unmanaged<MiClase>.fromOpaque(contexto).takeRetainedValue()   // libera

Ese par de llamadas es la traducción literal de un retain y un release escritos a mano, y le aplican todas las reglas del nivel: si passRetained no encuentra su takeRetainedValue, hay fuga; si lo encuentra dos veces, hay liberación doble.

flowchart TB
base[Un nombre de puntero] --> u[Prefijo Unsafe comun a los ocho]
u --> e1[Eje mutabilidad]
u --> e2[Eje tipado]
u --> e3[Eje extension]
e1 --> m1[Con Mutable concede escritura]
e1 --> m2[Sin Mutable solo lectura]
e2 --> t1[Tipado exige memoria ligada al tipo]
e2 --> t2[Raw solo direcciona bytes]
e3 --> c1[Buffer conoce la cuenta y es Collection]
e3 --> c2[Sin Buffer solo una direccion suelta]
style u fill:#f38ba8,color:#11111b
style t1 fill:#a6e3a1,color:#11111b
style c1 fill:#89b4fa,color:#11111b
⚠️
La conversión desde un array es un préstamo efímero

Swift permite pasar un array donde se espera un UnsafePointer, y también pasar una variable con el prefijo del operador de dirección. Es azúcar de llamada: el compilador materializa una dirección válida solo durante esa llamada. Guardar ese puntero en una variable para usarlo después compila sin una sola advertencia y apunta a memoria muerta. Es el error más frecuente de esta familia entera.

El nombre como cuarentena léxica

La verbosidad de estos identificadores suele leerse como un defecto ergonómico y es justo lo contrario: es el mecanismo de diseño central. Swift no puede impedir que escribas código con punteros, porque un lenguaje de sistemas sin punteros es un lenguaje de aplicación. Lo que sí puede hacer es garantizar que ese código sea imposible de escribir por accidente y trivial de encontrar por búsqueda. Ahí está la jugada: al meter la palabra Unsafe en el nombre de todos los tipos y de todas las funciones de acceso, la seguridad de memoria del programa deja de ser una propiedad difusa del conjunto y pasa a ser una propiedad localizable con una expresión regular. Puedes auditar un millón de líneas y saber en minutos dónde está el riesgo. Compáralo con C, donde el asterisco es la construcción más barata y frecuente del lenguaje y por tanto no señala nada. La consecuencia epistemológica es más profunda que la práctica: en Swift, la ausencia de la palabra Unsafe en un fichero es una afirmación verificable sobre ese fichero, no una esperanza. El lenguaje convirtió una propiedad global e incomprobable —este programa es seguro en memoria— en una conjunción de propiedades locales e inspeccionables. Swift 6.2 llevó esa intuición a su conclusión con el modo estricto de seguridad de memoria, que exige marcar cada expresión insegura y permite que un módulo declare por completo su ausencia. La lección de diseño es transferible: cuando no puedas eliminar un peligro, hazlo nominal.

⚔️ Lee el nombre, deduce el contrato
  1. Escribe un struct con un Bool y un Int64 y compara MemoryLayout.size, stride y alignment; explica con esos tres números por qué la aritmética de punteros usa stride.
  2. Reserva memoria para cinco Int, escríbelos con tres notaciones distintas —pointee, subíndice y aritmética— y comprueba que producen lo mismo.
  3. Convierte un puntero tipado a UnsafeRawPointer y lee el primer byte de un UInt32 conocido; deduce de ahí el orden de bytes de tu máquina.
  4. Declara una función que reciba UnsafePointer y llámala con un UnsafeMutablePointer; después intenta lo contrario y anota el error exacto.
  5. Guarda en una variable el puntero que recibe una función que toma un array y úsalo tras la llamada. Ejecuta con el sanitizador de direcciones activado y describe qué ocurre y por qué el compilador no dijo nada.