LINQ
Esta sección discute LINQ en el contexto y con el propósito de consultar o
transformar secuencias (IEnumerable/IEnumerable<T>) y, típicamente,
colecciones como listas, conjuntos y diccionarios.
IEnumerable<T>
El equivalente de IEnumerable<T> en Rust es IntoIterator.
Así como una implementación de IEnumerable<T>.GetEnumerator() devuelve un
IEnumerator<T> en .NET, una implementación de IntoIterator::into_iter
devuelve un Iterator. Sin embargo, cuando es momento de iterar
sobre los elementos de un contenedor que anuncia soporte para la iteración a
través de estos tipos, ambos lenguajes ofrecen azúcar sintáctica en forma de
constructos de bucles para iterables. En C#, existe foreach:
using System;
using System.Text;
var values = new[] { 1, 2, 3, 4, 5 };
var output = new StringBuilder();
foreach (var value in values)
{
if (output.Length > 0)
output.Append(", ");
output.Append(value);
}
Console.Write(output); // Imprime: 1, 2, 3, 4, 5
En Rust, el equivalente es simplemente for:
use std::fmt::Write;
fn main() {
let values = [1, 2, 3, 4, 5];
let mut output = String::new();
for value in values {
if output.len() > 0 {
output.push_str(", ");
}
// ! descarta/ignora cualquier error de write
_ = write!(output, "{value}");
}
println!("{output}"); // Imprime: 1, 2, 3, 4, 5
}
El bucle for sobre un iterable esencialmente se descompone en lo siguiente:
use std::fmt::Write;
fn main() {
let values = [1, 2, 3, 4, 5];
let mut output = String::new();
let mut iter = values.into_iter(); // obtiene el iterador
while let Some(value) = iter.next() { // en bucle mientras haya más elementos
if output.len() > 0 {
output.push_str(", ");
}
_ = write!(output, "{value}");
}
println!("{output}");
}
Las reglas de ownership y data race conditions de Rust se aplican a todas las instancias y datos, y la iteración no es una excepción. Entonces, aunque iterar sobre un arreglo pueda parecer sencillo y muy similar a C#, hay que tener en cuenta la propiedad cuando se necesita iterar sobre la misma colección/iterable más de una vez. El siguiente ejemplo itera la lista de enteros dos veces: una vez para imprimir su suma y otra para determinar e imprimir el entero máximo:
fn main() {
let values = vec![1, 2, 3, 4, 5];
// suma todos los valores
let mut sum = 0;
for value in values {
sum += value;
}
println!("sum = {sum}");
// determina el valor maximo
let mut max = None;
for value in values {
if let Some(some_max) = max { // si el máximo está definido
if value > some_max { // y el valor es mayor
max = Some(value) // entonces tenemos un nuevo máximo
}
} else { // el máximo es indefinido cuando la interacción arranca
max = Some(value) // entonces establece el primer valor como máximo
}
}
println!("max = {max:?}");
}
Sin embargo, el código anterior es rechazado por el compilador debido a una
diferencia sutil: values ha sido cambiado de un arreglo a un
Vec<int>, un vector, que es el tipo de Rust para arreglos
dinámicos (similar a List<T> en .NET). La primera iteración de values
termina consumiendo cada valor a medida que se suman los enteros. En otras
palabras, la propiedad de cada elemento en el vector pasa a la variable de
iteración del bucle: value. Dado que value sale del alcance al final de cada
iteración del bucle, la instancia que posee se elimina. Si values hubiera sido
un vector de datos alojados en el heap, la memoria en el heap que respalda cada
elemento se liberaría a medida que el bucle avanzara al siguiente elemento. Para
solucionar el problema, uno debe solicitar la iteración sobre
referencias compartidas usando &values en el bucle for. Como resultado,
value será una referencia compartida a un elemento en lugar de tomar su
propiedad.
A continuación se muestra la versión actualizada del ejemplo anterior que
compila. La corrección consiste simplemente en reemplazar values por &values
en cada uno de los bucles for.
fn main() {
let values = vec![1, 2, 3, 4, 5];
// suma todos los valores
let mut sum = 0;
for value in &values {
sum += value;
}
println!("sum = {sum}");
// determina el valor máximo
let mut max = None;
for value in &values {
if let Some(some_max) = max { // si el máximo esta definido
if value > some_max { // y el valor es mayor
max = Some(value) // entonces tenemos un nuevo máximo
}
} else { // max no esta definido cuando empieza a iterar
max = Some(value) // entonces asigna el primer valor
}
}
println!("max = {max:?}");
}
El ownership y la liberación de recursos se pueden observar en acción incluso
cuando values es un array en lugar de un vector. Considera solo el bucle de
suma del ejemplo anterior sobre un array de una estructura que envuelve un
entero:
struct Int(i32);
impl Drop for Int {
fn drop(&mut self) {
println!("{} liberado", self.0)
}
}
fn main() {
let values = [Int(1), Int(2), Int(3), Int(4), Int(5)];
let mut sum = 0;
for value in values {
sum += value.0;
}
println!("sum = {sum}");
}
Int implementa Drop para que se imprima un mensaje cuando una instancia se
libera. Al ejecutar el código anterior, se imprimirá:
value = Int(1)
Int(1) liberado
value = Int(2)
Int(2) liberado
value = Int(3)
Int(3) liberado
value = Int(4)
Int(4) liberado
value = Int(5)
Int(5) liberado
sum = 15
Es evidente que cada valor se adquiere y se libera mientras el bucle está en
ejecución. Una vez que el bucle termina, se imprime la suma. Si values en el
bucle for se cambia a &values, de esta forma:
for value in &values {
// ...
}
entonces la salida del programa cambiará radicalmente:
value = Int(1)
value = Int(2)
value = Int(3)
value = Int(4)
value = Int(5)
sum = 15
Int(1) liberado
Int(2) liberado
Int(3) liberado
Int(4) liberado
Int(5) liberado
Esta vez, los valores se adquieren pero no se liberan durante el bucle porque
cada elemento no es poseído por la variable del bucle de iteración. La suma se
imprime una vez que el bucle termina. Finalmente, cuando el array values, que
aún posee todas las instancias de Int, sale de alcance al final de main, su
liberación, a su vez, libera todas las instancias de Int.
Estos ejemplos demuestran que, aunque iterar sobre tipos de colecciones puede parecer tener muchas similitudes entre Rust y C#, desde las construcciones de bucles hasta las abstracciones de iteración, aún existen diferencias sutiles con respecto a la propiedad que pueden llevar al compilador a rechazar el código en algunos casos.
Mira también:
Operadores
Los operadores en LINQ están implementados en forma de métodos de extensión en
C# que se pueden encadenar para formar un conjunto de operaciones, siendo lo más
común la creación de una consulta sobre algún tipo de data source. C# también
ofrece una sintaxis de consulta inspirada en SQL, con cláusulas como from,
where, select, join y otras, que pueden servir como una alternativa o
complemento al encadenamiento de métodos. Muchos bucles imperativos pueden
reescribirse como consultas en LINQ, mucho más expresivas y componibles.
Rust no ofrece nada similar a la sintaxis de consultas de C#. Tiene métodos, llamados [adaptadores] en términos de Rust, sobre tipos iterables y, por lo tanto, directamente comparables al encadenamiento de métodos en C#. Sin embargo, mientras que reescribir un bucle imperativo como código LINQ en C# a menudo es beneficioso en términos de expresividad, robustez y componibilidad, existe un compromiso con el rendimiento. Los bucles imperativos orientados a cálculos generalmente se ejecutan más rápido porque el compilador JIT los puede optimizar y se incurren en menos despachos virtuales o invocaciones indirectas de funciones. Lo sorprendente en Rust es que no existe tal compromiso de rendimiento al elegir usar cadenas de métodos en una abstracción como un iterador en lugar de escribir un bucle imperativo manualmente. Por lo tanto, es mucho más común ver lo primero en el código.
La siguiente tabla enumera los métodos más comunes de LINQ y sus contrapartes aproximadas en Rust:
| .NET | Rust | Note |
|---|---|---|
Aggregate | reduce | Mira nota 1. |
Aggregate | fold | Mira nota 1. |
All | all | |
Any | any | |
Concat | chain | |
Count | count | |
ElementAt | nth | |
GroupBy | - | |
Last | last | |
Max | max | |
Max | max_by | |
MaxBy | max_by_key | |
Min | min | |
Min | min_by | |
MinBy | min_by_key | |
Reverse | rev | |
Select | map | |
Select | enumerate | |
SelectMany | flat_map | |
SelectMany | flatten | |
SequenceEqual | eq | |
Single | find | |
SingleOrDefault | try_find | |
Skip | skip | |
SkipWhile | skip_while | |
Sum | sum | |
Take | take | |
TakeWhile | take_while | |
ToArray | collect | Mira nota 2. |
ToDictionary | collect | Mira nota 2. |
ToList | collect | Mira nota 2. |
Where | filter | |
Zip | zip |
-
La sobrecarga de
Aggregateque no acepta un valor inicial es equivalente areduce, mientras que la sobrecarga deAggregateque acepta un valor inicial corresponde afold. -
collecten Rust generalmente funciona para cualquier tipo coleccionable, que se define como un tipo que puede inicializarse a partir de un iterador (verFromIterator).collectnecesita un tipo de destino, que a veces el compilador tiene dificultades para inferir, por lo que el turbofish (::<>) se usa a menudo en combinación con él, como encollect::<Vec<_>>(). Por esta razón,collectaparece junto a varios métodos de extensión de LINQ que convierten una fuente enumerable/iterable en una instancia de algún tipo de colección.
El siguiente ejemplo muestra lo similar que es transformar secuencias en C# y hacer lo mismo en Rust. Primero en C#:
var result =
Enumerable.Range(0, 10)
.Where(x => x % 2 == 0)
.SelectMany(x => Enumerable.Range(0, x))
.Aggregate(0, (acc, x) => acc + x);
Console.WriteLine(result); // 50
Y en Rust:
let result =
(0..10)
.filter(|x| x % 2 == 0)
.flat_map(|x| (0..x))
.fold(0, |acc, x| acc + x);
println!("{result}"); // 50
Deferred execution (laziness)
Muchos operadores en LINQ están diseñados para ser lazy, de manera que solo
realizan trabajo cuando es absolutamente necesario. Esto permite la composición
o encadenamiento de varias operaciones/métodos sin causar efectos secundarios.
Por ejemplo, un operador LINQ puede devolver un IEnumerable<T> que está
inicializado, pero no produce, calcula ni materializa ningún ítem de T hasta
que se itera sobre él. Se dice que el operador tiene semántica de ejecución
diferida. Si cada T se calcula a medida que la iteración llega a él (en lugar
de cuando comienza la iteración), se dice que el operador transmite los
resultados.
Los iteradores en Rust tienen el mismo concepto de laziness y transmisión de resultados.
En ambos casos, esto permite representar secuencias infinitas, donde la secuencia subyacente es infinita, pero el desarrollador decide cómo debe terminarse la secuencia. El siguiente ejemplo muestra esto en C#:
foreach (var x in InfiniteRange().Take(5))
Console.Write($"{x} "); // Muestra "0 1 2 3 4"
IEnumerable<int> InfiniteRange()
{
for (var i = 0; ; ++i)
yield return i;
}
Rust admite el mismo concepto a través de rangos infinitos:
// Los generadores y yield en Rust son inestables en este momento, por lo que
// en su lugar, este ejemplo utiliza `Range`:
// https://doc.rust-lang.org/std/ops/struct.Range.html
for value in (0..).take(5) {
print!("{value} "); // Muestra "0 1 2 3 4"
}
Métodos de Iterador (yield)
C# tiene la palabra clave yield que permite al desarrollador escribir
rápidamente un método de iterador. El tipo de retorno de un método de iterador
puede ser un IEnumerable<T> o un IEnumerator<T>. El compilador convierte el
cuerpo del método en una implementación concreta del tipo de retorno, en lugar
de que el desarrollador tenga que escribir una clase completa cada vez.
Coroutines, como se les llama en Rust, todavía se consideran una característica inestable en el momento de escribir esto.