Buffers: la dirección y la cuenta convertidas en un tipo
`UnsafeBufferPointer` reifica la convención de C de pasar puntero más longitud, y al hacerlo gana la conformidad a `Collection` completa. Qué cuesta esa conformidad, por qué `baseAddress` es opcional, dónde desaparecen las comprobaciones de rango y cómo se cruza el puente con `Array` sin copiar dos veces.
La convención más repetida de la historia de la programación de sistemas es también la más frágil: pasar una dirección y, a su lado, un entero que dice cuántos elementos hay. Millones de funciones de C tienen esa forma, y prácticamente todos los desbordamientos de búfer de las últimas cuatro décadas nacen de que esos dos argumentos son independientes y nada obliga a que digan la verdad el uno del otro. UnsafeBufferPointer toma esa pareja y la convierte en un único valor. El cambio parece administrativo y no lo es: al unirlos en un tipo, la extensión deja de ser un dato que viaja aparte y pasa a ser una propiedad del objeto, y en cuanto existe una propiedad llamada count junto a una dirección, el tipo puede conformar a Collection y heredar de golpe la biblioteca de algoritmos entera. Sigue siendo inseguro; pero ahora es inseguro y expresivo.
- Describir la representación real de un buffer y por qué
baseAddresses opcional. - Enumerar qué gana un buffer al conformar a
RandomAccessCollectiony qué comprobaciones pierde al compilar con optimización. - Manejar rebanadas de buffer sin caer en la trampa de los índices heredados.
- Cruzar el puente con
Arrayen las dos direcciones, incluida la construcción sin doble inicialización.
Dos campos y una conformidad
Un UnsafeBufferPointer es, literalmente, un struct de dos campos: una dirección y una cuenta. No posee la memoria, no la libera, no la retiene; es una vista, y su tamaño en memoria es el de dos palabras.
let almacen = UnsafeMutablePointer<Int>.allocate(capacity: 4)
almacen.initialize(repeating: 7, count: 4)
let vista = UnsafeBufferPointer(start: almacen, count: 4)
print(vista.count, vista.startIndex, vista.endIndex) // 4 0 4
print(vista.reduce(0, +)) // 28
print(vista.map { $0 * 2 }) // [14, 14, 14, 14]
almacen.deinitialize(count: 4)
almacen.deallocate()
Esa última línea del ejemplo es la que cuenta la historia completa: reduce, map, filter, firstIndex, sorted y el resto del catálogo funcionan sin que nadie los haya escrito para punteros. Vienen de la conformidad a RandomAccessCollection, con Int como índice, startIndex en cero y endIndex en count. Es la misma jugada que hace Array, y por eso el código que recorre un buffer se lee igual que el que recorre un array.
Merece la pena subrayar lo que el buffer no es: no es dueño de nada. No retiene el almacenamiento, no lo libera, no participa en el conteo de referencias y no impide que quien lo posee lo reubique o lo destruya. Es una lente prestada sobre memoria ajena, y su corrección depende por completo de que alguien mantenga viva esa memoria mientras la lente exista.
La propiedad baseAddress es opcional, y la razón es sutil pero perfectamente consistente. Un buffer vacío es legítimo, y un buffer vacío no necesita apuntar a ninguna parte; la biblioteca no quiso inventar una dirección falsa ni exigir una reserva para representar la nada. Cuando count vale cero, baseAddress puede ser nil, así que el desenvolvimiento no es burocracia: es el caso vacío pidiéndote que lo trates.
Lo que el buffer no comprueba
Aquí llega la asimetría que decide cuándo usar un buffer y cuándo no. El subíndice de Array valida el rango siempre, también con optimización activada, y detiene el programa con un mensaje claro si te sales. El subíndice de UnsafeBufferPointer valida el rango solo en compilaciones de depuración: la comprobación está escrita con una precondición de depuración, de modo que al compilar optimizado desaparece por completo y un índice fuera de rango deja de ser un fallo controlado para convertirse en comportamiento indefinido.
let xs = [1, 2, 3]
xs.withUnsafeBufferPointer { b in
print(b[5]) // en depuracion: aborta. Con optimizacion: lee basura o algo peor
}
Ese es exactamente el intercambio que se compra: se paga con la garantía de detención a cambio de eliminar del bucle interno la comprobación de rango y, sobre todo, el tráfico de retención y liberación que el compilador debe conservar cuando no puede demostrar que el array no cambia bajo sus pies. Antes de pagarlo conviene medir, porque el optimizador moderno elimina muchas de esas comprobaciones por sí solo cuando el rango es demostrable; la regla honesta es no bajar a buffers sin un perfil que lo justifique.
La segunda trampa vive en las rebanadas. Rebanar un buffer produce un Slice que conserva los índices del padre, igual que ocurre con ArraySlice. Una rebanada que empieza en la posición tres tiene startIndex igual a tres, no cero, y el código que asume lo contrario funciona con la primera rebanada y falla con todas las demás.
xs.withUnsafeBufferPointer { b in
let cola = b[1...]
print(cola.startIndex) // 1, no 0
print(cola[1]) // correcto
// print(cola[0]) // fuera de rango: el indice cero pertenece al padre
}
Es una vista, no un dueño
Dos palabras en memoria, sin propiedad ni ciclo de vida propio. Si el almacenamiento subyacente muere, la vista queda colgando y nada lo señala.
El rango solo se comprueba en depuración
La precondición del subíndice es de depuración. Con optimización no queda ni rastro, y salirse deja de ser un aborto para ser indefinido.
Las rebanadas heredan índices
Slice mantiene la numeración del padre. Recorre siempre con indices o con la propia rebanada, nunca con un rango construido a mano desde cero.
El puente con Array
En la dirección de salida, el acceso canónico es withUnsafeBufferPointer y su gemela mutable, que entregan una vista sobre el almacenamiento contiguo real del array durante la ejecución del bloque. Para cualquier Sequence, existe además withContiguousStorageIfAvailable, que devuelve nil cuando la secuencia no tiene memoria contigua; es el mecanismo por el que los algoritmos de la biblioteca eligen una ruta rápida sin renunciar a la generalidad.
En la dirección de entrada, lo interesante no es construir un array desde un buffer con el inicializador de copia, sino evitar la doble escritura. Array(unsafeUninitializedCapacity:initializingWith:), incorporado con SE-0245, reserva el almacenamiento, te presta un buffer mutable sin inicializar y te pide dos cosas: que lo inicialices y que declares cuántos elementos dejaste válidos.
let cuadrados = Array<Int>(unsafeUninitializedCapacity: 100) { buffer, cuenta in
for i in 0..<100 {
buffer.initializeElement(at: i, to: i * i)
}
cuenta = 100 // si mientes aqui, el array queda con basura o con fugas
}
El bloque recibe dos parámetros y ambos son de salida: el búfer que debes rellenar y una cuenta que debes fijar. Si declaras una cuenta mayor que la cantidad de posiciones que inicializaste, el array contendrá basura que se destruirá al morir; si declaras una menor, los elementos sobrantes que sí inicializaste nunca se destruirán y fugarán sus recursos. Es la misma máquina de estados de la memoria manual, asomando por un hueco de una API de alto nivel.
Sin ese inicializador, el patrón habitual —crear un array de ceros y sobrescribirlo— escribe cada posición dos veces y, para tipos no triviales, ejecuta destrucciones inútiles. En un bucle caliente esa diferencia es medible; en un decodificador que construye millones de elementos, es decisiva.
Existe además el camino inverso y perezoso: UnsafeMutableBufferPointer ofrece sus propias operaciones de inicialización por rangos —incorporadas por SE-0370— que permiten poblar un búfer sin descender al puntero elemento a elemento, y que devuelven un índice o un par de iteradores indicando hasta dónde llegaron. Son la forma idiomática de escribir el cuerpo del bloque anterior cuando la fuente es otra secuencia.
Los buffers crudos completan el cuadro. UnsafeRawBufferPointer es una colección de UInt8 que permite mirar cualquier valor como bytes, y bindMemory recupera una vista tipada cuando conoces el formato.
var cabecera: UInt32 = 0xDEADBEEF
withUnsafeBytes(of: &cabecera) { bytes in
print(Array(bytes)) // los cuatro bytes en el orden de la maquina
let comoEnteros = bytes.bindMemory(to: UInt16.self)
print(comoEnteros.count) // 2
}
Y en la frontera con C, el buffer se deshace en el par del que venía: baseAddress y count viajan como dos argumentos, porque el prototipo de C no sabe de tipos compuestos. Es la prueba de que la unión de ambos campos es una disciplina de Swift y no una propiedad del formato de llamada.
datos.withUnsafeBufferPointer { b in
procesar(b.baseAddress, Int32(b.count)) // dos argumentos otra vez, pero coherentes
}
Conviene añadir un aviso sobre la contigüidad, porque Array no la garantiza siempre. Un array que provenga de Objective-C puede estar respaldado por un almacenamiento no contiguo, y ahí withUnsafeBufferPointer copia a un búfer temporal en vez de prestar el original: correcto, pero con un coste oculto. ContiguousArray existe precisamente para excluir esa posibilidad por construcción cuando el rendimiento del bucle interno importa.
flowchart LR arr[Array de Int] --> alm[Almacenamiento contiguo en el monticulo] alm --> vista[UnsafeBufferPointer con base y cuenta] vista --> col[Conformidad a RandomAccessCollection] col --> alg[Toda la biblioteca de algoritmos disponible] vista --> crudo[UnsafeRawBufferPointer como bytes] crudo --> reb[bindMemory devuelve otra vista tipada] style vista fill:#a6e3a1,color:#11111b style col fill:#89b4fa,color:#11111b style crudo fill:#fab387,color:#11111b
Un buffer con count cero y baseAddress nulo es válido y perfectamente utilizable: iterarlo no produce nada y reduce devuelve el valor inicial. El error clásico es desenvolver baseAddress con el operador de fuerza para pasárselo a una función de C que acepta el caso vacío; usa el desenvolvimiento opcional y pasa la dirección nula tal cual.
Detrás de este tipo diminuto hay una de las convergencias más limpias del diseño de lenguajes de las últimas dos décadas. La rebanada de Rust, el span de C++, el Memory de C# y el buffer de Swift son el mismo descubrimiento hecho cuatro veces por equipos distintos: que el defecto estructural de la interfaz de C no era el puntero, sino la separación entre el puntero y la longitud. Dos argumentos independientes definen un espacio de estados en el que la mayoría de las combinaciones son mentiras, y ninguna herramienta puede detectarlas porque nada relaciona formalmente un valor con el otro. Al unirlos, el espacio de estados inválidos no se reduce: cambia de naturaleza. Ya no puede aparecer por descuido en el punto de llamada, solo por una construcción explícita del par, que es un acto localizado, revisable y raro. Ahora bien, conviene ver también lo que este tipo aún no resuelve, porque ahí está el filo del arte. Un buffer conoce su extensión pero no su tiempo de vida: nada en el tipo dice hasta cuándo esa dirección es válida, y por eso guardar un buffer más allá del bloque que lo prestó sigue compilando. Rust cerró esa segunda mitad con los tiempos de vida en el sistema de tipos; Swift la ha ido cerrando con Span y sus parientes, que son exactamente un buffer al que se le ha añadido la dependencia de vida como información verificable. Leído así, UnsafeBufferPointer es un peldaño intermedio de una escalera larga: primero se reificó la longitud, después la duración. Cada peldaño convierte una obligación recordada en una obligación comprobada.
- Suma un millón de enteros de tres formas —bucle sobre el array,
reducey bucle sobrewithUnsafeBufferPointer— y compara los tiempos con optimización activada. - Accede fuera de rango en un buffer compilando primero sin optimizar y después optimizado; describe la diferencia de comportamiento y explica de dónde viene.
- Rebana un buffer desde la posición dos y recorre la rebanada con un bucle de índices construido desde cero; observa el fallo y arréglalo con
indices. - Construye un array de mil elementos con
Array(unsafeUninitializedCapacity:initializingWith:)y otro rellenando desde ceros; compara los tiempos y razona el porqué. - Toma un
structde tres campos, mira sus bytes conwithUnsafeBytesy localiza el relleno de alineación comparandosizeconstride.