Translate

viernes, 9 de mayo de 2025

Trabajando con partes de colecciones sin copiar: slices, spans y más


Cuando trabajamos con colecciones o buffers de datos, es muy común necesitar operar solo sobre una parte de ellos: una porción de un array, un segmento de texto, o un rango de bytes. La solución más directa suele ser copiar esa parte a una nueva estructura… pero eso introduce sobrecarga innecesaria de memoria y CPU. Si queremos trabajar con partes de una colección (por ejemplo, un array, un string o un buffer de bytes) sin crear copias, es decir, tener una vista o referencia a un fragmento de la colección original.  

Es decir, queremos qu esta solución sea:

  • Ligera en memoria (sin asignaciones adicionales)
  • Segura (sin acceder fuera de los límites)
  • Eficiente (idealmente sin costo en tiempo de ejecución)

Muchos lenguajes modernos han ido incorporando construcciones para resolver este problema. Veamos cómo lo hacen C#, Go y Rust.

A partir de C# 7.2, se introdujo Span<T>, una estructura de tipo ref struct que representa una ventana sobre memoria contigua. 


int[] datos = { 10, 20, 30, 40 };

Span<int> segmento = datos.AsSpan(1, 2); // contiene {20, 30}


Podés usar Span<T> para:

  • Evitar copiar arrays o strings.
  • Trabajar con memoria en el stack (stackalloc).
  • Reutilizar buffers en pipelines o parsers.
  • Procesar archivos grandes en trozos.


Span<byte> buffer = stackalloc byte[1024]; // sin heap


Limitaciones:

  • No puede usarse como campo de clases (sólo en structs).
  • No se puede usar con async/await ni capturar en lambdas.
  • Solo dentro del alcance del stack (por diseño).


Go tiene slices desde siempre: son una capa por encima de los arrays. Un slice guarda un puntero al array subyacente, longitud y capacidad.


arr := [5]int{1, 2, 3, 4, 5}

s := arr[1:4] // contiene {2, 3, 4}


Ventajas:

  • Livianos y eficientes.
  • Se puede modificar el contenido (afecta al array original).
  • Permiten crecer mediante append si hay capacidad.


s[0] = 99 // también cambia arr[1]


La semántica de slicing en Go es natural y permite componer operaciones sin asignar memoria.


Rust maneja este problema con referencias segmentadas: &[T] para vistas inmutables y &mut [T] para mutables.


let arr = [1, 2, 3, 4];

let slice = &arr[1..3]; // &[2, 3]


Características:

  • Completamente seguras en tiempo de compilación.
  • El borrow checker impide aliasing mutable.
  • Altamente eficientes, sin sobrecarga.
  • Son la base de muchas APIs estándar.


fn print_slice(s: &[i32]) {

    for val in s {

        println!("{}", val);

    }

}


¿Y cuál conviene?

  • Si estás en un lenguaje GC-friendly como C#, Span<T> te da poder sin pagar costo de GC.
  • Si buscás simplicidad y velocidad de desarrollo, Go es imbatible con su slicing natural.
  • Si necesitás seguridad al máximo y performance nativa, Rust con slices es lo más robusto.

Es decir, depende del lenguaje que estes usando ... 

Y Otros lenguajes tambien tenemos cosas parecidas: 

  • C++20: std::span<T> cumple un rol casi idéntico a Span<T> de C#, y también es zero-copy.
  • Python: memoryview permite trabajar con buffers sin copiar, aunque menos seguro.
  • Java: No tiene slices como tal, pero ByteBuffer puede simularlos.
  • Nim, Zig, D: Todos ofrecen slices como vistas eficientes sobre datos.


En la práctica, estas estructuras son fundamentales para escribir código eficiente, especialmente en procesamiento de datos, parsers, sistemas embebidos o cualquier aplicación donde el rendimiento importa.