Introducción
Esta es una guía (no exhaustiva) para desarrolladores de C# y .NET que son completamente nuevos en el lenguaje de programación Rust. Algunos conceptos y construcciones se traducen bastante bien entre C#/.NET y Rust, pero pueden expresarse de manera diferente, mientras que otros son un cambio radical, como la gestión de la memoria. Esta guía ofrece una breve comparación y mapeo de esas construcciones y conceptos con ejemplos concisos.
Los autores1 originales de esta guía eran desarrolladores C#/.Net que eran completamente nuevos en Rust. Esta guía es la compilación de conocimiento adquirido por los autores escribiendo código Rust durante varios meses. Es la guía que los autores desearían haber tenido cuando comenzaron su viaje en Rust. Dicho esto, los autores te animarían a leer libros y otro material disponible en la web para abrazar Rust y sus convenciones en lugar de intentar aprenderlo exclusivamente a través del prisma de C# y .NET. Mientras tanto, esta guía puede ayudar a responder algunas preguntas rápidamente, como: ¿Rust soporta herencia, concurrencia, programación asíncrona, etc.?
Suposiciones:
- El lector es un experimentado desarrollador de C#/.NET.
- El lector es completamente nuevo en Rust.
Objetivos:
- Proporcionar una breve comparación y mapeo de varios temas de C#/.NET con sus contrapartes en Rust.
- Proporcionar enlaces a referencias de Rust, libros y artículos para una lectura adicional sobre los temas.
No objetivos:
- Discusión de patrones de diseño y arquitecturas.
- Tutorial sobre el lenguaje Rust.
- Que el lector sea competente en Rust después de leer esta guía.
- Aunque hay ejemplos cortos que contrastan el código de C# y Rust para algunos temas, esta guía no pretende ser un recetario de recetas de codificación en los dos lenguajes.
Los autores originales de esta guía fueron (en orden alfabético): Atif Aziz, Bastian Burger, Daniele Antonio Maggio, Dariusz Parys and Patrick Schuler.
License
Contribución
Estas invitado a contribuir 💖 a esta guía abriendo issues y enviando pull requests.
Aquí algunas ideas 💡para como y donde tu puedes ayudar más con contribuciones.
-
Corrige cualquier error ortográfico o gramatical que encuentres mientras lees.
-
Corrige inexactitudes técnicas.
-
Soluciona errores lógicos o de compilación en ejemplos de código.
-
Mejora el inglés o el español, especialmente si es tu lengua materna o tienes un dominio excelente del idioma.
-
Amplía una explicación para proporcionar más contexto o mejorar la claridad de algún tema o concepto.
-
Mantén la información actualizada con cambios en C#, .NET y Rust. Por ejemplo, si hay un cambio en C# o Rust que acerca más a los dos lenguajes, algunas partes, incluido el código de muestra, pueden necesitar revisión.
Si estás realizando una corrección pequeña o modesta, como corregir un error ortográfico o un error de sintaxis en un ejemplo de código, siéntete libre de enviar una solicitud de extracción directamente. Para cambios que puedan requerir un esfuerzo considerable de tu parte (y de los revisores como resultado), se recomienda encarecidamente que presentes un issue y busques la aprobación de los mantenedores/editores antes de invertir tu tiempo. Esto evitará desilusiones 💔 en caso de que la solicitud de extracción sea rechazada por diversas razones.
Hacer contribuciones rápidas se ha vuelto muy sencillo. Si ves un error en una página y estás en línea, puedes hacer clic en el ícono de edición 📝 en la esquina de la página para modificar el origen en formato Markdown del contenido y enviar un cambio.
Directrices de Contribución
-
Apegarse a los objetivos de esta guía establecidos en la introducción; en otras palabras, ¡evitar los no objetivos!
-
Preferir mantener el texto breve y utilizar ejemplos de código cortos, concisos y realistas para ilustrar un punto.
-
Siempre que sea posible, proporcionar y comparar ejemplos en Rust y C#.
-
Siéntete libre de utilizar las últimas características del lenguaje C#/Rust si hace que un ejemplo sea más simple, conciso y similar en ambos idiomas.
-
Evita el uso de paquetes de la comunidad en ejemplos de C#. Apegarse a la Biblioteca Estándar de .NET tanto como sea posible. Dado que la Biblioteca Estándar de Rust tiene una API mucho más pequeña, es más aceptable mencionar crates para alguna funcionalidad, en caso de ser necesario para mostrar un ejemplo (como
rand
para generación de números aleatorios), pero asegúrate de que los crates sean maduros, populares y de confianza. -
Haz que el código de ejemplo sea lo más independiente y ejecutable posible (a menos que la idea sea explicar un error en tiempo de compilación o de ejecución).
-
Mantén el estilo general de esta guía, que es evitar usar la palabra tu como si se estuviera indicando o instruyendo al lector; en su lugar, utiliza la voz en tercera persona. Por ejemplo, en lugar de decir, “Tu representas datos opcionales en Rust con el tipo
Option<T>
”, escribe en su lugar, “Rust tiene el tipoOption<T>
que se utiliza para representar datos opcionales”.
Empezando
Rust Playground
La forma más sencilla de comenzar con Rust sin necesidad de ninguna instalación local es utilizar el Playground de Rust. Es una interfaz de desarrollo mínima que se ejecuta en el navegador web y permite escribir y ejecutar código Rust.
Dev Container
El entorno de ejecución de el Playground de Rust tiene algunas limitaciones, como el tiempo de compilación/ejecución, la memoria y la red. Otra opción que no requiere instalar Rust sería utilizar un dev container, como el proporcionado en el repositorio https://github.com/microsoft/vscode-remote-try-rust. Al igual que el Playground de Rust, el contenedor de desarrollo se puede ejecutar directamente en un navegador web utilizando GitHub Codespaces o localmente con Visual Studio Code.
Instalación Local
Para realizar una instalación local completa del compilador Rust y sus herramientas de desarrollo, consulta la sección Instalación del capítulo Empezando en el libro El Lenguaje de Programación Rust, o visita la página de instalación en rust-lang.org.
Lenguaje
Esta sección compara las características de los lenguajes C# y Rust.
Tipos Escalares
La siguiente tabla enumera los tipos primitivos en Rust y su equivalente en C# y .NET:
Rust | C# | .NET | Notas |
---|---|---|---|
bool | bool | Boolean | |
char | char | Char | Mirar la nota 1. |
i8 | sbyte | SByte | |
i16 | short | Int16 | |
i32 | int | Int32 | |
i64 | long | Int64 | |
i128 | Int128 | ||
isize | nint | IntPtr | |
u8 | byte | Byte | |
u16 | ushort | UInt16 | |
u32 | uint | UInt32 | |
u64 | ulong | UInt64 | |
u128 | UInt128 | ||
usize | nuint | UIntPtr | |
f32 | float | Single | |
f64 | double | Double | |
decimal | Decimal | ||
() | void | Void o ValueTuple | Mirar las notas 2 y 3. |
object | Object | Mirar la nota 3. |
Notas:
-
char
en Rust yChar
en .NET tienen diferentes definiciones. En Rust, unchar
tiene 4 bytes de ancho y es un Unicode scalar value, pero en .NET, aChar
tiene 2 bytes de ancho y almacena el carácter usando la codificación UTF-16. Para más información, mirar la documentación dechar
en Rust. -
Mientras que en Rust, unit
()
(una tupla vacía) es un valor expresable, el equivalente más cercano en C# seríavoid
para representar la nada. Sin embargo,void
no es un valor expresable, excepto cuando se usan punteros y código no seguro. .NET tieneValueTuple
, que es una tupla vacía, pero C# no tiene una sintaxis literal como()
para representarlo.ValueTuple
se puede usar en C#, pero es muy poco común. A diferencia de C#, F# sí tiene un tipo unit similar a Rust. -
Mientras
void
yobject
no son tipos escalares (aunque tipos escalares comoint
son subclases deobject
en la jerarquía de tipos de .NET), se han incluido en la tabla anterior por conveniencia.
Mira también:
Strings
Existen dos tipos de strings en Rust: String
and &str
. El primero es
alocado en el monticulo (heap) y el ultimo es un slice de String
o un &str
.
Nota: Slice significa rebana, parte, etc. quiere decir que es una porción de un texto.
La comparación de estos a .NET es mostrada en la siguiente tabla:
Rust | .NET | Nota |
---|---|---|
&mut str | Span<char> | |
&str | ReadOnlySpan<char> | |
Box<str> | String | mirar Nota 1. |
String | String | |
String (mutable) | StringBuilder | mirar Nota 1. |
Hay diferencias en trabajar con strings en Rust y .Net, pero los equivalentes de
arriba deberian de ser un buen punto de inicio. Una de las diferencias es que
los strings de Rust son codificados en UTF-8, pero los strings de .NET son
codificados en UTF-16.
Además los strings de .Net son inmutables, pero los strings en Rust pueden ser
mutables cuando se los declara como tal. por ejemplo
let s = &mut String::from("hello");
Hay también diferencias en usar strings debido al concepto del ownership. Para leer más acerca del ownership con el tipo String, mira el libro de Rust.
Notas
- El tipo
Box<str>
en Rust es equivalente a el tipoString
en .NET. La diferencia entre los tiposBox<str>
yString
en Rust es que el primero almacena el puntero y el tamaño mientras que el segundo almacena puntero, tamaño y capacidad, permitiendo alString
crecer en tamaño. Este es similar al el tipoStringBuilder
de .NET cuando el String de Rust es declarado como mutable.
C#:
ReadOnlySpan<char> span = "Hello, World!";
string str = "Hello, World!";
StringBuilder sb = new StringBuilder("Hello, World!");
Rust:
let span: &str = "Hello, World!";
let str = Box::new("Hello World!");
let mut sb = String::from("Hello World!");
String Literales
Las literales de cadena en .NET son tipos String
inmutables y alocados en el
heap (montículo). En Rust, son &'static str
, que es inmutable, tiene un
tiempo de vida global y no se asigna en el montículo; están integradas en el
binario compilado.
C#
string str = "Hello, World!";
Rust
let str: &'static str = "Hello, World!";
En C# los strings literales de verbatim son equivalentes a los string literales sin procesar en Rust.
C#
string str = @"Hello, \World/!";
Rust
let str = r#"Hello, \World/!"#;
En C# los string literales UTF-8 en C# son equivalentes a las string literales de bytes en Rust.
C#
ReadOnlySpan<byte> str = "hello"u8;
Rust
let str = b"hello";
Interpolación de Strings
C# tiene una característica incorporada de interpolación de cadenas que te permite incrustar expresiones dentro de una cadena literal. El siguiente ejemplo muestra cómo usar la interpolación de cadenas en C#:
string name = "John";
int age = 42;
string str = $"Person {{ Name: {name}, Age: {age} }}";
Rust no tiene una característica incorporada de interpolación de cadenas. En su
lugar, se utiliza la macro format!
para formatear una cadena. El siguiente
ejemplo muestra cómo usar la interpolación de cadenas en Rust:
let name = "John";
let age = 42;
let str = format!("Person {{ name: {name}, age: {age} }}");
Las clases y structs personalizados también se pueden interpolar en C# debido a
que el método ToString()
está disponible para cada tipo al heredar de
object
.
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public override string ToString() =>
$"Person {{ Name: {Name}, Age: {Age} }}";
}
var person = new Person { Name = "John", Age = 42 };
Console.Writeline(person);
En Rust, no hay un formato predeterminado implementado o heredado para cada
tipo. En su lugar, se debe implementar el trait std::fmt::Display
para cada
tipo que necesite ser convertido a una cadena.
use std::fmt::*;
struct Person {
name: String,
age: i32,
}
impl Display for Person {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(f, "Person {{ name: {}, age: {} }}", self.name, self.age)
}
}
let person = Person {
name: "John".to_owned(),
age: 42,
};
println!("{person}");
Otra opción es utilizar el trait std::fmt::Debug
. El trait Debug
está
implementado para todos los tipos estándar y se puede usar para imprimir la
representación interna de un tipo. El siguiente ejemplo muestra cómo utilizar
el atributo derive
para imprimir la representación interna de una estructura
personalizada utilizando la macro Debug
. Esta declaración se utiliza para
implementar automáticamente el trait Debug
para la estructura Person
:
#[derive(Debug)]
struct Person {
name: String,
age: i32,
}
let person = Person {
name: "John".to_owned(),
age: 42,
};
println!("{person:?}");
Nota: El uso del especificador de formato
:?
utilizará el traitDebug
para imprimir la estructura, mientras que omitirlo utilizará el traitDisplay
.
Mira también:
Tipos Estructurados
Tipos de objetos y colecciones comúnmente utilizados en .NET y su mapeo a Rust
C# | Rust |
---|---|
Array | Array |
List | Vec |
Tuple | Tuple |
Dictionary | HashMap |
Array
Los arrays fijos son compatibles de la misma manera en Rust que en .NET.
C#:
int[] someArray = new int[2] { 1, 2 };
Rust:
let someArray: [i32; 2] = [1,2];
Listas
En Rust, el equivalente de un List<T>
es un Vec<T>
. Los arrays pueden
convertirse a Vecs y viceversa.
C#:
var something = new List<string>
{
"a",
"b"
};
something.Add("c");
Rust:
let mut something = vec![
"a".to_owned(),
"b".to_owned()
];
something.push("c".to_owned());
Tuplas
C#:
var something = (1, 2)
Console.WriteLine($"a = {something.Item1} b = {something.Item2}");
Rust:
let something = (1, 2);
println!("a = {} b = {}", something.0, something.1);
// soporta deconstrucción
let (a, b) = something;
println!("a = {} b = {}", a, b);
NOTA: En Rust, los elementos de las tuplas no pueden tener nombres como en C#. La única forma de acceder a un elemento de la tupla es utilizando el índice del elemento o desestructurando la tupla.
Diccionarios
En Rust el equivalente de un Dictionary<TKey, TValue>
es un Hashmap<K, V>
.
C#:
var something = new Dictionary<string, string>
{
{ "Foo", "Bar" },
{ "Baz", "Qux" }
};
something.Add("hi", "there");
Rust:
let mut something = HashMap::from([
("Foo".to_owned(), "Bar".to_owned()),
("Baz".to_owned(), "Qux".to_owned())
]);
something.insert("hi".to_owned(), "there".to_owned());
Mirar también:
Tipos Personalizados
Las siguientes secciones discuten varios temas y constructos relacionados con el desarrollo de tipos personalizados:
Classes
Rust no tiene clases. Solo tiene estructuras o struct
.
Records
Rust no tiene ningún estructura para crear records, ya sea como record struct
o record class
en C#.
Estructuras (struct
)
Las estructuras en Rust y C# comparten algunas similitudes:
-
Se definen con la palabra clave
struct
, pero en Rust,struct
simplemente define los datos/campos. Los aspectos de comportamiento en términos de funciones y métodos se definen por separado en un bloque de implementación (impl
). -
Pueden implementar múltiples traits en Rust de la misma manera que pueden implementar múltiples interfaces en C#.
-
No pueden ser subclasificadas.
-
Se asignan en la pila (stack) por defecto, a menos que:
- En .NET, se haga boxing o se castee a una interfaz.
- En Rust, se envuelvan en un puntero inteligente como
Box
,Rc
/Arc
.
En C#, un struct
es una forma de modelar un value type (tipos de valor)
en .NET, que suele ser algún primitivo específico del dominio o compuesto con
semántica de igualdad de valores. En Rust, un struct
es la construcción
principal para modelar cualquier estructura de datos (la otra siendo un enum
).
Un struct
(o record struct
) en C# tiene copia por valor y semántica de
igualdad de valores por defecto, pero en Rust, esto requiere simplemente un paso
más utilizando el atributo #derive
y enumerando los traits que se
deben implementar:
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct Point {
x: i32,
y: i32,
}
En C#/.NET, los Value Types suelen ser diseñados por un desarrollador para ser
inmutables. Se considera una práctica recomendada desde el punto de vista
semántico, pero el lenguaje no impide diseñar un struct
que realice
modificaciones destructivas o en el lugar. En Rust, es lo mismo. Un tipo debe
ser conscientemente desarrollado para ser inmutable.
Dado que Rust no tiene clases y, en consecuencia, jerarquías de tipos basadas en la subclase, el comportamiento compartido se logra mediante traits y genéricos, y el polimorfismo a través de la despacho virtual utilizando trait objects.
Considera la siguiente struct
que representa un rectángulo en C#:
struct Rectangle
{
public Rectangle(int x1, int y1, int x2, int y2) =>
(X1, Y1, X2, Y2) = (x1, y1, x2, y2);
public int X1 { get; }
public int Y1 { get; }
public int X2 { get; }
public int Y2 { get; }
public int Length => Y2 - Y1;
public int Width => X2 - X1;
public (int, int) TopLeft => (X1, Y1);
public (int, int) BottomRight => (X2, Y2);
public int Area => Length * Width;
public bool IsSquare => Width == Length;
public override string ToString() => $"({X1}, {Y1}), ({X2}, {Y2})";
}
El equivalente en Rust sería:
#![allow(dead_code)]
struct Rectangle {
x1: i32, y1: i32,
x2: i32, y2: i32,
}
impl Rectangle {
pub fn new(x1: i32, y1: i32, x2: i32, y2: i32) -> Self {
Self { x1, y1, x2, y2 }
}
pub fn x1(&self) -> i32 { self.x1 }
pub fn y1(&self) -> i32 { self.y1 }
pub fn x2(&self) -> i32 { self.x2 }
pub fn y2(&self) -> i32 { self.y2 }
pub fn length(&self) -> i32 {
self.y2 - self.y1
}
pub fn width(&self) -> i32 {
self.x2 - self.x1
}
pub fn top_left(&self) -> (i32, i32) {
(self.x1, self.y1)
}
pub fn bottom_right(&self) -> (i32, i32) {
(self.x2, self.y2)
}
pub fn area(&self) -> i32 {
self.length() * self.width()
}
pub fn is_square(&self) -> bool {
self.width() == self.length()
}
}
use std::fmt::*;
impl Display for Rectangle {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(f, "({}, {}), ({}, {})", self.x1, self.y2, self.x2, self.y2)
}
}
Ten en cuenta que un struct
en C# hereda el método ToString
de object
y,
por lo tanto, anula la implementación base para proporcionar una
representación de cadena personalizada. Dado que no hay herencia en Rust, la
forma en que un tipo indica el soporte para alguna representación formateada
es mediante la implementación del trait Display
. Esto permite que una
instancia de la estructura participe en el formateo, como se muestra en la
llamada a println!
a continuación:
fn main() {
let rect = Rectangle::new(12, 34, 56, 78);
println!("Rectangle = {rect}");
}
Interfaces
Rust no tiene interfaces como las que se encuentran en C#/.NET. En su lugar, tiene traits. Similar a una interfaz, un trait representa una abstracción y sus miembros forman un contrato que debe cumplirse cuando se implementa en un tipo.
Al igual que las interfaces pueden tener métodos predeterminados en C#/.NET (donde se proporciona un cuerpo de implementación predeterminado como parte de la definición de la interfaz), los traits en Rust también pueden tenerlos. El tipo que implementa la interfaz/trait puede proporcionar posteriormente una implementación más adecuada y/o optimizada.
Las interfaces en C#/.NET pueden tener todo tipo de miembros, desde propiedades, indexadores y eventos hasta métodos, tanto estáticos como de instancia. De manera similar, los traits en Rust pueden tener métodos (de instancia), funciones asociadas (piensa en métodos estáticos en C#/.NET) y constantes.
Además de las jerarquías de clases, las interfaces son un medio fundamental para
lograr el polimorfismo mediante la despacho dinámico para abstracciones
transversales. Permiten escribir código de propósito general contra las
abstracciones representadas por las interfaces sin tener en cuenta mucho los
tipos concretos que las implementan. Lo mismo se puede lograr con los
Trait Objects en Rust de manera limitada. Un trait object es esencialmente una
v-table identificada con la palabra clave dyn
seguida del nombre del trait,
como en dyn Shape
(donde Shape
es el nombre del trait). Los trait objects
siempre viven detrás de un puntero, ya sea una referencia (por ejemplo,
&dyn Shape
) o el Box
asignado en el montón (por ejemplo, Box<dyn Shape>
).
Esto es algo similar a en .NET, donde una interfaz es un tipo de referencia, de
modo que un tipo de valor convertido a una interfaz se coloca automáticamente en
la montón gestionado. La limitación de paso de los trait objects mencionada
anteriormente es que el tipo de implementación original no se puede recuperar.
En otras palabras, mientras que es bastante común hacer un
downcast o probar si una interfaz es una instancia de alguna otra interfaz o
tipo subyacente o concreto, lo mismo no es posible en Rust (sin esfuerzo y
soporte adicionales).
Cuando hablamos de downcasting nos referimos al poder obtener a base de una abstracción un tipo concreto.
Puedes mirar también:
Tipos Enumeración (enum
)
En C#, un enum
es un tipo de valor que asigna nombres simbólicos a valores
enteros:
enum DayOfWeek
{
Sunday = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6,
}
Rust tiene una sintaxis prácticamente idéntica para hacer lo mismo:
enum DayOfWeek
{
Sunday = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6,
}
A diferencia de en .NET, una instancia de un tipo enum
en Rust no tiene ningún
comportamiento predefinido que se herede. Ni siquiera puede participar en
comprobaciones de igualdad tan simples como dow == DayOfWeek::Friday
. Para
hacerlo en cierta medida comparable en función con un enum
en C#, utiliza
el atributo #derive
para que los macros implementen automáticamente
la funcionalidad comúnmente necesaria:
#[derive(Debug, // habilita el formateo en "{:?}"
Clone, // requerido por Copy
Copy, // habilita la semántica de copia por valor
Hash, // habilita la posibilidad de usar en tipos de mapa
PartialEq // habilita la igualdad de valores (==)
)]
enum DayOfWeek
{
Sunday = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6,
}
fn main() {
let dow = DayOfWeek::Wednesday;
println!("Day of week = {dow:?}");
if dow == DayOfWeek::Friday {
println!("Yay! It's the weekend!");
}
// coerce to integer
let dow = dow as i32;
println!("Day of week = {dow:?}");
let dow = dow as DayOfWeek;
println!("Day of week = {dow:?}");
}
Como muestra el ejemplo anterior, un enum
puede ser convertido a su valor
integral asignado, pero lo contrario no es posible como en C# (aunque esto a
veces tiene la desventaja en C#/.NET de que una instancia de enum
puede
contener un valor no representado). En su lugar, depende del desarrollador
proporcionar una función auxiliar de este tipo:
impl DayOfWeek {
fn from_i32(n: i32) -> Result<DayOfWeek, i32> {
use DayOfWeek::*;
match n {
0 => Ok(Sunday),
1 => Ok(Monday),
2 => Ok(Tuesday),
3 => Ok(Wednesday),
4 => Ok(Thursday),
5 => Ok(Friday),
6 => Ok(Saturday),
_ => Err(n)
}
}
}
La función from_i32
devuelve un DayOfWeek
en un Result
indicando éxito
(Ok
) si n
es válido. De lo contrario, devuelve n
tal cual en un Result
que indica fallo (Err
):
let dow = DayOfWeek::from_i32(5);
println!("{dow:?}"); // prints: Ok(Friday)
let dow = DayOfWeek::from_i32(50);
println!("{dow:?}"); // prints: Err(50)
Existen crates en Rust que pueden ayudar a implementar este mapeo a partir de tipos integrales en lugar de tener que codificarlos manualmente.
Un tipo enum
en Rust también puede servir como una forma de diseñar tipos de
unión (discriminados), que permiten que diferentes variantes contengan datos
específicos para cada variante.
Por ejemplo:
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
Esta forma de declaración de enum
no existe en C#, pero se puede emular con
registros (class records):
var home = new IpAddr.V4(127, 0, 0, 1);
var loopback = new IpAddr.V6("::1");
abstract record IpAddr
{
public sealed record V4(byte A, byte B, byte C, byte D): IpAddr;
public sealed record V6(string Address): IpAddr;
}
La diferencia entre ambas es que la definición en Rust produce un
tipo cerrado sobre las variantes. En otras palabras, el compilador sabe que
no habrá otras variantes de IpAddr
excepto IpAddr::V4
y IpAddr::V6
, y
puede utilizar ese conocimiento para realizar verificaciones más estrictas.
Por ejemplo, en una expresión match
que es similar a la expresión switch
en
C#, el compilador de Rust generará un error a menos que se cubran todas las
variantes. En cambio, la emulación con C# crea realmente una jerarquía de
clases (aunque expresada de manera muy concisa) y, dado que IpAddr
es una
clase base abstracta, el conjunto de todos los tipos que puede representar es
desconocido para el compilador.
Miembros
Constructores
Rust no tiene ninguna noción de constructores. En su lugar, simplemente escribes
funciones factory que retornan una instancia del tipo. Las funciones Factory
pueden ser independientes o funciones asociadas al tipo. En términos de C# las
funciones asociadas son como tener metodos estaticos en un tipo.
Por convención, si hay solo una función factory para una estructura, se le
llama new
:
struct Rectangle {
x1: i32, y1: i32,
x2: i32, y2: i32,
}
impl Rectangle {
pub fn new(x1: i32, y1: i32, x2: i32, y2: i32) -> Self {
Self { x1, y1, x2, y2 }
}
}
Dado que las funciones en Rust (ya sean asociadas u otras) no admiten sobrecarga;
las funciones factory tienen que tener nombres únicos. Por ejemplo, a
continuación se presentan algunos ejemplos de las funciones constructores o
factory disponibles en String
.
String::new
: crea un string vació.String::with_capacity
: crea un string con una capacidad de buffer inicial.String::from_utf8
: crea un string desde bytes de texto codificado en UTF-8.String::from_utf16
: crea un string desde bytes de texto codificado en UTF-16.
En el caso de un tipo enum
en Rust, las variantes actúan como constructores.
Mira [la sección de tipos Enumerados][ennums] para ver más.
Mira también:
Métodos (estáticos y basados en instancias)
Al igual que en C#, los tipos de Rust (tanto enum
como struct
) pueden tener
métodos estáticos y basados en instancias. En la terminología de Rust, un método
siempre es basado en instancia y se identifica por el hecho de que su primer
parametro se llama self
. El parametro self
no tiene una anotación de tipo,
ya que siempre es el tipo al que pertenece el método. Un método estático se
llama función asociada. En el ejemplo de a continuación, new
es una función
asociada y el resto (length
, width
, y area
) son métodos de el tipo.
struct Rectangle {
x1: i32, y1: i32,
x2: i32, y2: i32,
}
impl Rectangle {
pub fn new(x1: i32, y1: i32, x2: i32, y2: i32) -> Self {
Self { x1, y1, x2, y2 }
}
pub fn length(&self) -> i32 {
self.y2 - self.y1
}
pub fn width(&self) -> i32 {
self.x2 - self.x1
}
pub fn area(&self) -> i32 {
self.length() * self.width()
}
}
Constantes
Al igual que en C#, un tipo en Rust puede tener constantes. Sin embargo, el aspecto más interesante de notar es que Rust permite que una instancia de tipo se defina como una constante.
struct Point {
x: i32,
y: i32,
}
impl Point {
const ZERO: Point = Point { x: 0, y: 0 };
}
En C#, lo mismo requeriría un campo de solo lectura estático.
readonly record struct Point(int X, int Y)
{
public static readonly Point Zero = new(0, 0);
}
Eventos
Rust no tiene soporte incorporado para que los miembros de tipo anuncien y
disparen eventos, como lo tiene C# con la palabra clave event
.
Propiedades
En C#, los campos de un tipo suelen ser privados. Luego, se protegen/encapsulan
mediante miembros de propiedades miembro como métodos de acceso (get y set) para
leer o escribir, para validar el valor al establecerlo o calcular un valor al
leerlo. Rust solo tiene métodos donde un getter tiene el mismo nombre que el
campo (En Rust, los nombres de los métodos pueden compartir el mismo
identificador que un campo) y el setter utiliza un prefijo set_
A continuación, se muestra un ejemplo que muestra cómo suelen lucir los métodos de acceso similares a propiedades para un tipo en Rust:
struct Rectangle {
x1: i32, y1: i32,
x2: i32, y2: i32,
}
impl Rectangle {
pub fn new(x1: i32, y1: i32, x2: i32, y2: i32) -> Self {
Self { x1, y1, x2, y2 }
}
// como getters de propiedades (cada uno comparte el mismo nombre que el campo)
pub fn x1(&self) -> i32 { self.x1 }
pub fn y1(&self) -> i32 { self.y1 }
pub fn x2(&self) -> i32 { self.x2 }
pub fn y2(&self) -> i32 { self.y2 }
// como setters de propiedades
pub fn set_x1(&mut self, val: i32) { self.x1 = val }
pub fn set_y1(&mut self, val: i32) { self.y1 = val }
pub fn set_x2(&mut self, val: i32) { self.x2 = val }
pub fn set_y2(&mut self, val: i32) { self.y2 = val }
// como propiedades calculadas
pub fn length(&self) -> i32 {
self.y2 - self.y1
}
pub fn width(&self) -> i32 {
self.x2 - self.x1
}
pub fn area(&self) -> i32 {
self.length() * self.width()
}
}
Métodos de Extensión
Los métodos de extensión en C# permiten al desarrollador adjuntar nuevos métodos
vinculados estáticamente a tipos existentes, sin necesidad de modificar la
definición original del tipo. En el siguiente ejemplo de C#, se añade un nuevo
método Wrap
a la clase StringBuilder
mediante una extensión:
using System;
using System.Text;
using Extensions; // (1)
var sb = new StringBuilder("Hello, World!");
sb.Wrap(">>> ", " <<<"); // (2)
Console.WriteLine(sb.ToString()); // Muestra: >>> Hello, World! <<<
namespace Extensions
{
static class StringBuilderExtensions
{
public static void Wrap(this StringBuilder sb,
string left, string right) =>
sb.Insert(0, left).Append(right);
}
}
Ten en cuenta que para que un método de extensión esté disponible (2), se debe
importar el namespace con el tipo que contiene el método de
extensión (1). Rust ofrece una facilidad muy similar a través de traits,
llamada extension traits. El siguiente ejemplo en Rust es equivalente al
ejemplo de C# anterior; extiende String
con el método wrap
:
#![allow(dead_code)]
mod exts {
pub trait StrWrapExt {
fn wrap(&mut self, left: &str, right: &str);
}
impl StrWrapExt for String {
fn wrap(&mut self, left: &str, right: &str) {
self.insert_str(0, left);
self.push_str(right);
}
}
}
fn main() {
use exts::StrWrapExt as _; // (1)
let mut s = String::from("Hello, World!");
s.wrap(">>> ", " <<<"); // (2)
println!("{s}"); // Prints: >>> Hello, World! <<<
}
Al igual que en C#, para que el método en el trait de extensión esté
disponible (2), el trait de extensión debe importarse (1). También ten en cuenta
que el identificador del trait de extensión StrWrapExt
puede descartarse
mediante _
en el momento de la importación sin afectar la disponibilidad de
wrap
para String
.
Modificadores de Visibilidad/Acceso
C# tiene varios modificadores de accesibilidad o visibilidad:
private
protected
internal
protected internal
(familia)public
En Rust, una compilación se compone de un árbol de módulos en el que los módulos
contienen y definen elementos como tipos, traits, enums, constantes y
funciones. Casi todo es privado por defecto. Una excepción es, por ejemplo,
elementos asociados en un trait público, que son públicos por defecto. Esto es
similar a cómo los miembros de una interfaz de C# declarados sin ningún
modificador público en el código fuente son públicos por defecto. Rust solo
tiene el modificador pub
para cambiar la visibilidad con respecto al árbol de
módulos. Hay variaciones de pub
que cambian el alcance de la visibilidad
pública:
pub(self)
pub(super)
pub(crate)
pub(in PATH)
Para obtener más detalles, consulta la sección Visibility and Privacy en la referencia de Rust.
La tabla a continuación es una aproximación de la correspondencia entre los modificadores de C# y Rust:
C# | Rust | Note |
---|---|---|
private | (default) | Mirar nota 1. |
protected | N/A | Mirar nota 2. |
internal | pub(crate) | |
protected internal (familia) | N/A | Mirar nota 2. |
public | pub |
-
No existe una palabra clave para denotar visibilidad privada; es la configuración predeterminada en Rust.
-
Dado que no hay jerarquías de tipos basadas en clases en Rust, no hay un equivalente de
protected
.
Mutabilidad
Al diseñar un tipo en C#, es responsabilidad del desarrollador decidir si un
tipo es mutable o inmutable; si admite mutaciones destructivas o no destructivas.
C# admite un diseño inmutable para tipos con una positional record declaration
(record class
o readonly record struct
).
En Rust, la mutabilidad se expresa en los métodos a través del tipo del
parámetro self
, como se muestra en el siguiente ejemplo:
struct Point { x: i32, y: i32 }
impl Point {
pub fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
// self no es mutable
pub fn x(&self) -> i32 { self.x }
pub fn y(&self) -> i32 { self.y }
// self es mutable
pub fn set_x(&mut self, val: i32) { self.x = val }
pub fn set_y(&mut self, val: i32) { self.y = val }
}
En C#, puedes realizar mutaciones no destructivas usando with
:
var pt = new Point(123, 456);
pt = pt with { X = 789 };
Console.WriteLine(pt.ToString()); // Muestra: Point { X = 789, Y = 456 }
readonly record struct Point(int X, int Y);
No hay with
en Rust, pero para emular algo similar en Rust, debe estar
integrado en el diseño del tipo:
struct Point { x: i32, y: i32 }
impl Point {
pub fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
pub fn x(&self) -> i32 { self.x }
pub fn y(&self) -> i32 { self.y }
// los siguientes métodos consumen self y devuelven una nueva instancia:
pub fn set_x(self, val: i32) -> Self { Self::new(val, self.y) }
pub fn set_y(self, val: i32) -> Self { Self::new(self.x, val) }
}
Funciones Locales
C# y Rust ofrecen funciones locales, pero las funciones locales en Rust están limitadas al equivalente de funciones locales estáticas en C#. En otras palabras, las funciones locales en Rust no pueden usar variables de su ámbito léxico circundante; pero las closures pueden.
También puedes ver:
Lambda y Closures
C# y Rust permiten que las funciones sean utilizadas como valores de primera
clase que posibilitan la escritura de funciones de orden superior. Las
funciones de orden superior son esencialmente funciones que aceptan otras
funciones como argumentos para permitir que el llamador participe en el código
de la función llamada. En C#, los function pointers seguros se representan
mediante delegados, siendo los más comunes Func
y Action
. El lenguaje C#
permite la creación de instancias ad hoc de estos delegados a través de
expresiones lambda.
Rust también tiene function pointers, siendo el tipo más simple fn
:
fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
f(arg) + f(arg)
}
fn main() {
let answer = do_twice(|x| x + 1, 5);
println!("The answer is: {}", answer); // Prints: The answer is: 12
}
Sin embargo, Rust hace una distinción entre funciones punteros (donde fn
define un tipo) y closures: una closure puede hacer referencia a variables
desde su ámbito léxico circundante, pero no una función puntero. Aunque C#
también tiene function pointers (*delegate
), el equivalente
gestionado y seguro para el tipo sería una expresión lambda estática.
Las funciones y métodos que aceptan closures se escriben con tipos genéricos que
está vinculado a uno de los traits que representa funciones: Fn
, FnMut
y
FnOnce
. Cuando es el momento de proporcionar un valor para un puntero a
función o un cierre, un desarrollador de Rust utiliza una closure expression
(como |x| x + 1
en el ejemplo anterior), que se traduce de la misma manera que
una expresión lambda en C#. Si la closure expression crea una pointer function
o una closure depende de si la closure expression hace referencia a su contexto
o no.
Cuando una closure captura variables de su entorno, entran en juego las reglas de ownership porque el ownership termina en la closure. Para obtener más información, consulta la sección "Moviendo valores capturados fuera de los closures y los traits Fn" de "El Lenguaje de Programación Rust".
Variables
Considera el siguiente ejemplo acerca de asignación de variables en C#:
int x = 5;
Y el mismo en Rust:
let x: i32 = 5;
Hasta este momento, la única diferencia visible entre los dos lenguajes es que la posición de la declaración del tipo es diferente. También, ambos, C# y Rust son type-safe: el compilador garantiza que los valores almacenados en una variable tiene siempre el mismo tipo designado. El ejemplo puede ser simplificado por usar la habilidad del compilador para automáticamente inferir el tipo de la variable. En C#:
var x = 5;
En Rust:
let x = 5;
Cuando ampliamos el primer ejemplo para cambiar el valor de la variable (reasignamiento), el comportamiento de C# y Rust difieren:
var x = 5;
x = 6;
Console.WriteLine(x); // 6
En Rust, la misma sentencia no compila:
let x = 5;
x = 6; // Error: cannot assign twice to immutable variable 'x'.
println!("{}", x);
En Rust, las variables son inmutables por defecto. Una vez que un valor es
vinculado a un nombre, la variable no puede ser cambiada. Las variables pueden
ser mutables por adición de mut
al comienzo de la declaración.
let mut x = 5;
x = 6;
println!("{}", x); // 6
Rust ofrece una alternativa para arreglar este ejemplo de encima que no requiere mutabilidad mediante shadowing:
let x = 5;
let x = 6;
println!("{}", x); // 6
C# también soporta shadowing, por ejemplo variables locales pueden ocultar campos y variables miembros del tipo base. En Rust, el ejemplo de arriba demuestra que el shadowing también permite cambiar el tipo sin cambiar el nombre, esto puede ser util si solo queremos transformar el dato en uno con diferente tipo y forma sin tener que tener una variable con distinto nombre en cada ocasión.
Puedes ver también:
- Data races y race conditions para más información acerca de las implicaciones de la mutabilidad
- Scope y shadowing
- Memory management para explicaciones acerca de moving y ownership
Namespaces
En .NET, se utilizan namespaces para organizar tipos, así como para controlar el ámbito de los tipos y métodos en proyectos.
En Rust, el término "namespace" se refiere a un concepto diferente. El
equivalente de un namespace en Rust es un módulo. Tanto en C#
como en Rust, la visibilidad de los elementos se puede restringir mediante
modificadores de acceso o modificadores de visibilidad, respectivamente.
En Rust, la visibilidad predeterminada es privada (con solo algunas
excepciones). El equivalente al public
de C# es pub
en Rust, y internal
se corresponde con pub(crate)
. Para un control de acceso más detallado,
consulta la referencia de modificadores de visibilidad.
Equivalencia
Cuando se compara por igualdad en C#, esto se refiere a probar la equivalencia en algunos casos (también conocida como igualdad de valor), y en otros casos se refiere a probar la igualdad de referencia, que verifica si dos variables se refieren al mismo objeto subyacente en memoria. Cada tipo personalizado puede ser comparado por igualdad porque hereda de System.Object
(o System.ValueType
para tipos de valor, que a su vez hereda de System.Object
), utilizando cualquiera de las semánticas mencionadas anteriormente.
Por ejemplo, al comparar por equivalencia e igualdad de referencia en C#:
var a = new Point(1, 2);
var b = new Point(1, 2);
var c = a;
Console.WriteLine(a == b); // (1) True
Console.WriteLine(a.Equals(b)); // (1) True
Console.WriteLine(a.Equals(new Point(2, 2))); // (1) True
Console.WriteLine(ReferenceEquals(a, b)); // (2) False
Console.WriteLine(ReferenceEquals(a, c)); // (2) True
record Point(int X, int Y);
-
El operador de igualdad
==
y el métodoEquals
en elrecord Point
comparan por igualdad de valor, ya que los registros admiten la igualdad de tipo valor de forma predeterminada. -
Comparar por igualdad de referencia verifica si las variables se refieren al mismo objeto subyacente en memoria.
Equivalente en Rust:
#[derive(Copy, Clone)]
struct Point(i32, i32);
fn main() {
let a = Point(1, 2);
let b = Point(1, 2);
let c = a;
println!("{}", a == b); // Error: "an implementation of `PartialEq<_>` might be missing for `Point`"
println!("{}", a.eq(&b));
println!("{}", a.eq(&Point(2, 2)));
}
El error del compilador anterior ilustra que en Rust las comparaciones de igualdad siempre están relacionadas con una implementación de trait. Para admitir una comparación usando ==
, un tipo debe implementar PartialEq
.
Corregir el ejemplo anterior significa derivar PartialEq
para Point
. Por defecto, al derivar PartialEq
se compararán todos los campos para la igualdad, por lo que ellos mismos deben implementar PartialEq
. Esto es comparable a la igualdad de registros en C#.
#[derive(Copy, Clone, PartialEq)]
struct Point(i32, i32);
fn main() {
let a = Point(1, 2);
let b = Point(1, 2);
let c = a;
println!("{}", a == b); // true
println!("{}", a.eq(&b)); // true
println!("{}", a.eq(&Point(2, 2))); // false
println!("{}", a.eq(&c)); // true
}
Véase también:
Eq
para una versión más estricta dePartialEq
Genéricos
Los genéricos en C# proporcionan una forma de crear definiciones para tipos y métodos que pueden ser parametrizados sobre otros tipos. Esto mejora la reutilización de código, la seguridad de tipos y el rendimiento (por ejemplo, evita conversiones en tiempo de ejecución). Considera el siguiente ejemplo de un tipo genérico que agrega una marca de tiempo a cualquier valor:
using System;
sealed record Timestamped<T>(DateTime Timestamp, T Value)
{
public Timestamped(T value) : this(DateTime.UtcNow, value) { }
}
Rust también tiene genéricos, como se muestra en el equivalente del ejemplo anterior:
use std::time::*;
struct Timestamped<T> { value: T, timestamp: SystemTime }
impl<T> Timestamped<T> {
fn new(value: T) -> Self {
Self { value, timestamp: SystemTime::now() }
}
}
Mira también:
Restricciones de tipo genérico
En C#, los tipos genéricos pueden ser restringidos usando la palabra clave where. El siguiente ejemplo muestra tales restricciones en C#:
using System;
// Nota: los registros implementan automáticamente `IEquatable`. La siguiente
// implementación muestra esto explícitamente para una comparación con Rust.
sealed record Timestamped<T>(DateTime Timestamp, T Value) :
IEquatable<Timestamped<T>>
where T : IEquatable<T>
{
public Timestamped(T value) : this(DateTime.UtcNow, value) { }
public bool Equals(Timestamped<T>? other) =>
other is { } someOther
&& Timestamp == someOther.Timestamp
&& Value.Equals(someOther.Value);
public override int GetHashCode() => HashCode.Combine(Timestamp, Value);
}
Lo mismo se puede lograr en Rust:
use std::time::*;
struct Timestamped<T> { value: T, timestamp: SystemTime }
impl<T> Timestamped<T> {
fn new(value: T) -> Self {
Self { value, timestamp: SystemTime::now() }
}
}
impl<T> PartialEq for Timestamped<T>
where T: PartialEq {
fn eq(&self, other: &Self) -> bool {
self.value == other.value && self.timestamp == other.timestamp
}
}
En Rust, las restricciones de tipo genérico se llaman bounds.
En la versión de C#, las instancias de Timestamped<T>
solo pueden crearse para
T
que implementen IEquatable<T>
ellos mismos, pero ten en cuenta que la
versión de Rust es más flexible porque Timestamped<T>
implementa condicionalmente PartialEq
. Esto significa que las instancias de
Timestamped<T>
aún pueden crearse para algunos T
que no son equiparables,
pero entonces Timestamped<T>
no implementará la igualdad a través de
PartialEq
para dicho T
.
Mira también:
Polimorfismo
Rust no admite clases y herencia, por lo tanto, el polimorfismo no se puede lograr de manera idéntica a C#.
Consulta también:
- Despacho virtual utilizando objetos de trait, como se explica en la sección Estructuras
- Genéricos
- Herencia
- Sobrecarga de operadores
Herencia
Como se explica en la sección Estructuras, Rust no proporciona herencia (basada en clases) como en C#. Una forma de proporcionar un comportamiento compartido entre structs es mediante el uso de traits. Sin embargo, similar a la herencia de interfaces en C#, Rust permite definir relaciones entre traits mediante el uso de supertraits.
Manejo de Excepciones
En .Net, una excepción es un tipo que hereda de la clase
System.Exception
. Excepción es lanzada si un problema
ocurre en una sección de código. Un lanzamiento de excepción es pasado hacia arriba
al stack hasta que la aplicación la maneje o el programa termine.
Rust no tiene excepciones, pero distingue entre errores recuperables y
no recuperables en su lugar. Un error recuperable representa un problema que
debe ser reportado, pero sin embargo el programa continua. Resultado de
operaciones que pueden fallar con errores recuperables son de tipo Result<T, E>
,
en donde E
es del tipo de variante de error. La macro panic!
detiene
la ejecución cuando el programa encuentra un error irrecuperable. Un error
irrecuperable es siempre un síntoma de un bug.
Tipos de errores personalizados
En .Net, una excepción personalizada deriva de la clase Exception
. La
documentación en Cómo crear excepciones definidas por el usuario
menciona el siguiente ejemplo:
public class EmployeeListNotFoundException : Exception
{
public EmployeeListNotFoundException() { }
public EmployeeListNotFoundException(string message)
: base(message) { }
public EmployeeListNotFoundException(string message, Exception inner)
: base(message, inner) { }
}
En Rust, uno puede implementar el comportamiento básico para los valores erróneos
via implementación de el trait Error
. La implementación minima
definida por el usuario en Rust:
#[derive(Debug)]
pub struct EmployeeListNotFound;
impl std::fmt::Display for EmployeeListNotFound {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("No se pudo encontrar empleado en la lista.")
}
}
impl std::error::Error for EmployeeListNotFound {}
El equivalente para la Exception.InnerException
de .Net es el método
Error::source()
en Rust. Sin embargo, este no requiere proveer una implementación
para Error::source()
, la implementación por defecto (blanket implementation)
retorna un None
.
Elevando excepciones
Para elevar una excepción en C#, lanza una instancia de la excepción:
void ThrowIfNegative(int value)
{
if (value < 0)
{
throw new ArgumentOutOfRangeException(nameof(value));
}
}
Para recuperar errores en Rust, retorna una variante de Ok
o de Err
desde
un método:
fn error_if_negative(value: i32) -> Result<(), &'static str> {
if value < 0 {
Err("El argumento especificado esta fuera del rango de valores validos. (Parámetro 'value')")
} else {
Ok(())
}
}
La macro panic!
crea errores irrecuperables:
fn panic_if_negative(value: i32) {
if value < 0 {
panic!("El argumento especificado esta fuera del rango de valores validos. (Parámetro 'value')")
}
}
Propagación de error
En .Net, excepciones son pasadas hacia arriba hasta que son tratadas o el programa termina. En Rust, los errores irrecuperables son similares, pero tratarlos es poco común.
Los errores recuperables, sin embargo necesitan ser propagados y tratarlos explícitamente. Están presentes siempre representados en la firma de funciones o métodos en Rust. Capturar las excepciones te permiten tomar acciones basadas en la presencia o ausencia de errores en C#.
void Write()
{
try
{
File.WriteAllText("file.txt", "content");
}
catch (IOException)
{
Console.WriteLine("Escribiendo el archivo fallo.");
}
}
En Rust, esto es un equivalente aproximado:
fn write() {
match std::fs::File::create("temp.txt")
.and_then(|mut file| std::io::Write::write_all(&mut file, b"content"))
{
Ok(_) => {}
Err(_) => println!("Escribiendo el archivo fallo."),
};
}
Frecuentemente, los errores recuperables necesitan ser propagados en lugar de ser
tratados. Para esto, la firma del metodo necesita ser compatible con el tipo de
error propagado. El operador ?
propaga errores
ergonómicamente:
fn write() -> Result<(), std::io::Error> {
let mut file = std::fs::File::create("file.txt")?;
std::io::Write::write_all(&mut file, b"content")?;
Ok(())
}
Nota: Para propagar un error con el question mark operator la implementación
del error necesita ser compatible, como describimos en un atajo para la propagación de errores. El tipo más "compatible" es el trait object error
Box<dyn Error>
.
Stack traces
Lanzar una excepción no capturada en .Net causara que el runtime imprima un stack trace que permitirá depurar el problema con contexto adicional.
Para errores irrecuperables en Rust, los backtraces de panic!
ofrecen un comportamiento similar.
Los errores recuperables en Rust estable no soportan aún los backtraces, pero es soportado en Rust experimental cuando usamos el método provide.
Nulabilidad y Opcionalidad
En C#, null
es a veces usado para representar un valor que es faltante, ausente
o lógicamente no inicializado. Por ejemplo:
int? some = 1;
int? none = null;
Rust no tiene null
y consecuencialmente contexto no nulleable para habilitar.
Los valores opcionales o faltantes son representados por Option<T>
en su lugar. El equivalente de el código C# de arriba en Rust debería ser:
let some: Option<i32> = Some(1);
let none: Option<i32> = None;
Option<T>
en Rust es prácticamente idéntico a 'T option
de F#.
Flujo de Control con Opcionabilidad
En C#, tal vez estes usando sentencias if
/else
para controlar el flujo
cuando uses valores nulleables.
uint? max = 10;
if (max is { } someMax)
{
Console.WriteLine($"El máximo es {someMax}."); // El máximo es 10.
}
Tu puedes usar pattern matching para lograr el mismo comportamiento en Rust:
Sería más conciso usar if let
:
let max = Some(10u32);
if let Some(max) = max {
println!("El máximo es {}.", max); // El máximo es 10.
}
Operadores de Condición Nula
Los operadores null-condicionales (?.
y ?[]
) facilitan el manejo de null en
C#. En Rust, es mejor reemplazarlos usando el método map
. El siguiente
fragmento muestra la comparación:
string? some = "Hola, Mundo!";
string? none = null;
Console.WriteLine(some?.Length); // 12
Console.WriteLine(none?.Length); // (blank)
let some: Option<String> = Some(String::from("Hola, Mundo!"));
let none: Option<String> = None;
println!("{:?}", some.map(|s| s.len())); // Some(12)
println!("{:?}", none.map(|s| s.len())); // None
Null-coalescing operator
El null-coalescing operator (??
) es típicamente usado para por defecto usar
otro valor cuando un nulleable es null
:
int? some = 1;
int? none = null;
Console.WriteLine(some ?? 0); // 1
Console.WriteLine(none ?? 0); // 0
En Rust, tu puedes usar unwrap_or
para obtener el mismo
comportamiento:
let some: Option<i32> = Some(1);
let none: Option<i32> = None;
println!("{:?}", some.unwrap_or(0)); // 1
println!("{:?}", none.unwrap_or(0)); // 0
Nota: Si el valor por defecto es costoso para computar, tu puedes usar
unwrap_or_else
en su lugar. Este tomara una closure como argumento, la cual
permitirá inicializar un valor por defecto de forma perezosa.
Null-forgiving operator
El operador null-forgiving (!
) no corresponde a un equivalente construido en
Rust, como este solo afecta al flujo de análisis estático en el compilador de C#.
En Rust, esto no es necesario de usar para un sustituto de este.
Descartes
En C#, los descartes expresan al compilador y a otros que ignoren los resultados (o partes) de una expresión.
Hay múltiples contextos donde se pueden aplicar, por ejemplo, como un ejemplo básico, para ignorar el resultado de una expresión. En C#, se vería así:
_ = city.GetCityInformation(cityName);
En Rust, ignorar el resultado de una expresión se ve de manera idéntica:
_ = city.get_city_information(city_name);
Los descartes también se aplican para la destrucción de tuplas en C#:
var (_, second) = ("first", "second");
y, de manera idéntica, en Rust:
let (_, second) = ("first", "second");
Además de la destrucción de tuplas, Rust ofrece desestructuración
de estructuras y enumeraciones usando ..
, donde ..
representa la parte
restante de un tipo:
struct Point {
x: i32,
y: i32,
z: i32,
}
let origin = Point { x: 0, y: 0, z: 0 };
match origin {
Point { x, .. } => println!("x is {}", x), // x is 0
}
Cuando se realiza pattern matching, a menudo es útil descartar o ignorar parte de una expresión coincidente, por ejemplo, en C#:
_ = ("first", "second") switch
{
("first", _) => "first element matched",
(_, _) => "first element did not match"
};
Y nuevamente, esto se ve casi idéntico en Rust:
_ = match ("first", "second")
{
("first", _) => "first element matched",
(_, _) => "first element did not match"
};
Conversión y Casting
Ambos en C# y Rust son estéticamente tipados en tiempo de compilación. Por eso, luego que una variable es declarada, asignar un valor de un tipo diferente (A menos que este sea implícitamente convertible a el tipo esperado) de la variable esta prohibido. Hay multiples formas para convertir tipos en C# que equivalen a Rust.
Conversiones implícitas
Las conversiones implícitas existen en C# como en Rust (llamadas cohesiones de tipos). Considera el siguiente ejemplo:
int intNumber = 1;
long longNumber = intNumber;
Rust es mucho más restrictivo al respecto de cual cohesion de tipo permitir:
let int_number: i32 = 1;
let long_number: i64 = int_number; // error: expected `i64`, found `i32`
Un ejemplo para una conversión implícita valida usando subtipificación es:
fn bar<'a>() {
let s: &'static str = "hi";
let t: &'a str = s;
}
Mirar también:
Conversión Explicita
Si convertir puede causar perdida de información, C# require conversión explicita usando una expresión de casting:
double a = 1.2;
int b = (int)a;
Conversiones explicitas pueden potencialmente fallar durante tiempo de ejecución
con excepciones como OverflowException
o InvalidCastException
cuando
se hace down-casting.
Rust no provee cohesión entre tipos primitivos, pero en su lugar usa
conversion explicita usando la palabra clave as
(casting).
Usar Casting en Rust no causara pánico.
let int_number: i32 = 1;
let long_number: i64 = int_number as _;
Conversión Personalizada
Comúnmente, los tipos de .Net proveen operadores de conversión definidos por el
usuario para convertir un tipo a otro tipo. También, System.IConvertible
tiene
el propósito de convertir un tipo en otro.
En Rust, la librería estándar contiene una abstracción para convertir un valor en
un tipo diferente, con el trait From
y recíprocamente Into
.
Cuando implementas From
para un tipo, una implementación por default de Into
es automáticamente provista (A esto se le llama blanket implementation en Rust).
El siguiente ejemplo ilustra dos conversiones de tipos.
fn main() {
let my_id = MyId("id".into()); // `into()` es implementado automáticamente debido a la implementación del `From<&str>` trait para `String`.
println!("{}", String::from(my_id)); // Esto usa la implementación `From<MyId>` de `String`.
}
struct MyId(String);
impl From<MyId> for String {
fn from(MyId(value): MyId) -> Self {
value
}
}
Mirar también:
Sobrecarga de operadores
Un tipo personalizado puede sobrecargar un operador sobrecargable en C#. Considera el siguiente ejemplo en C#:
Console.WriteLine(new Fraction(5, 4) + new Fraction(1, 2)); // 14/8
public readonly record struct Fraction(int Numerator, int Denominator)
{
public static Fraction operator +(Fraction a, Fraction b) =>
new(a.Numerator * b.Denominator + b.Numerator * a.Denominator, a.Denominator * b.Denominator);
public override string ToString() => $"{Numerator}/{Denominator}";
}
En Rust, muchos operadores pueden sobrecargarse mediante traits. Esto
es posible porque los operadores son azúcar sintáctica para llamadas a métodos.
Por ejemplo, el operador +
en a + b
llama al método add
(ver
sobrecarga de operadores):
use std::{fmt::{Display, Formatter, Result}, ops::Add};
struct Fraction {
numerator: i32,
denominator: i32,
}
impl Display for Fraction {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
f.write_fmt(format_args!("{}/{}", self.numerator, self.denominator))
}
}
impl Add<Fraction> for Fraction {
type Output = Fraction;
fn add(self, rhs: Fraction) -> Fraction {
Fraction {
numerator: self.numerator * rhs.denominator + rhs.numerator * self.denominator,
denominator: self.denominator * rhs.denominator,
}
}
}
fn main() {
println!(
"{}",
Fraction { numerator: 5, denominator: 4 } + Fraction { numerator: 1, denominator: 2 }
); // 14/8
}
Comentarios de Documentación
C# provee un mecanismo para documentar las APIs para tipos usando la sintaxis de un comentario que contiene texto XML. El compilador de C# produce un archivo XML que contiene estructuras de datos representando el comentario y la firma de la API. Otras herramientas pueden procesar la salida para proveer documentación legible para humanos en una forma diferente. Un ejemplo simple en C#:
/// <summary>
/// Esto es un comentario para documentar <c>MyClass</c>.
/// </summary>
public class MyClass {}
En Rust, los comentarios de documentación proporcionan el equivalente a los comentarios de documentación de C#. Los comentarios de documentación en Rust utilizan la sintaxis de Markdown. rustdoc es el compilador de documentación para el código Rust y generalmente se invoca a través de cargo doc, que compila los comentarios en documentación. Por ejemplo:
/// Este es un comentario de documentación para `MyStruct`.
struct MyStruct;
En el .Net SDK hay equivalente a cargo doc
, como dotnet doc
.
Mira también:
Gestión de Memoria
Al igual que C# y .NET, Rust tiene memoria segura para evitar toda clase de
errores relacionados con el acceso a la memoria, que terminan siendo la fuente
de muchas vulnerabilidades de seguridad en el software. Sin embargo, Rust puede
garantizar la seguridad de memoria en tiempo de compilación; no hay una
verificación en tiempo de ejecución (como el CLR). La única excepción aquí son
las verificaciones de límites de arreglos que realiza el código compilado en
tiempo de ejecución, ya sea el compilador de Rust o el compilador JIT en .NET.
Al igual que en C#, también es
posible escribir código inseguro en Rust, y de hecho, ambos
lenguajes incluso comparten la misma palabra clave, literalmente unsafe
,
para marcar funciones y bloques de código donde ya no se garantiza la seguridad
de memoria.
Rust no tiene un garbage collector (GC). Toda la gestión de memoria es completamente responsabilidad del desarrollador. Dicho esto, Rust seguro tiene reglas rodeando el concepto de ownership que aseguran que la memoria se libere tan pronto como ya no esté en uso (por ejemplo, al salir del ámbito de un bloque o de una función). El compilador hace un trabajo tremendo, a través del análisis estático en tiempo de compilación, para ayudar a gestionar esa memoria mediante las reglas de ownership. Si se violan, el compilador rechaza el código con un error de compilación.
En .NET, no existe el concepto de ownership de la memoria más allá de las raíces del Gargabe Collector (campos estáticos, variables locales en la pila de un hilo, registros de la CPU, manejadores, etc.). Es el GC quien recorre desde las raíces durante una recolección para determinar toda la memoria en uso siguiendo las referencias y purgando el resto. Al diseñar tipos y escribir código, un desarrollador de .NET puede permanecer ajeno al ownership, la gestión de memoria e incluso al funcionamiento del recolector de basura en su mayor parte, excepto cuando el código sensible al rendimiento requiere prestar atención a la cantidad y la velocidad a la que se asignan objetos en el montón. En contraste, las reglas del ownership de Rust requieren que el desarrollador piense y exprese explícitamente la propiedad en todo momento y esto impacta todo, desde el diseño de funciones, tipos, estructuras de datos hasta la forma en que se escribe el código. Además de eso, Rust tiene reglas estrictas sobre cómo se utiliza la información, de tal manera que puede identificar en tiempo de compilación data race conditions, así como problemas de corrupción (requiriendo seguridad en hilos) que podrían ocurrir potencialmente en tiempo de ejecución. Esta sección solo se enfocará en la gestión de memoria y la propiedad.
En Rust, solo puede haber un propietario de una porción de memoria, ya sea en el stack o en el heap, respaldando una estructura en un momento dado. El compilador asigna lifetimes y rastrea el ownership. Es posible pasar o ceder el ownership, lo cual se denomina mover en Rust. Estas ideas se ilustran brevemente en el siguiente código de ejemplo de Rust:
#![allow(dead_code, unused_variables)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let a = Point { x: 12, y: 34 }; // La instancia de Point es propiedad de a
let b = a; // ahora b es propietario de la instancia de Point
println!("{}, {}", a.x, a.y); // ¡error de compilación!
}
La primera instrucción en main
asignará un Point
y esa memoria será
propiedad de a
. En la segunda instrucción, la propiedad se mueve de a
a b
y a
ya no puede ser utilizado porque ya no posee nada ni representa una
memoria válida. La última instrucción que intenta imprimir los campos del punto
a través de a
fallará en la compilación. Supongamos que main
se corrige para
leerse de la siguiente manera:
fn main() {
let a = Point { x: 12, y: 34 }; // La instancia de Point es propiedad de a
let b = a; // ahora b es propietario de la instancia de Point
println!("{}, {}", b.x, b.y); // ok, usamos b
} // Point de b es liberado
Nota que cuando main
termina, a
y b
saldrán de su ámbito. La memoria
detrás de b
será liberada en virtud de que la pila regresará a su estado
previo a la llamada de main
. En Rust, se dice que el punto detrás de b
fue
descartado. Sin embargo, dado que a
cedió su propiedad del punto a b
, no
hay nada que descartar cuando a
sale de su ámbito.
Una struct
en Rust puede definir el código a ejecutar cuando se descarta una
instancia implementando el trait Drop
.
El equivalente aproximado de dropping en C# sería un
finalizador de clase, pero mientras que un finalizador es llamado
automáticamente por el GC en algún momento futuro, el dropping en Rust
siempre es instantáneo y determinista; es decir, ocurre en el punto en que el
compilador ha determinado que una instancia no tiene propietario basándose en
los ámbitos y los lifetimes. En .NET, el equivalente de Drop
sería
IDisposable
y se implementa en tipos para liberar cualquier
recurso no administrado o memoria que posean. La disposición determinística no
está impuesta ni garantizada, pero la declaración using
en C# se utiliza
típicamente para delimitar el ámbito de una instancia de un tipo desechable de
manera que se disponga de manera determinista, al final del bloque de la
declaración using
.
Rust tiene la noción de un lifetime global denotada por 'static
, que es un
especificador de lifetime reservado. Una aproximación muy general en C# serían
los campos estáticos de solo lectura de los tipos.
En C# y .NET, las referencias se comparten libremente sin mucha consideración,
por lo que la idea de un único propietario y ceder/mover la propiedad puede
parecer muy limitante en Rust, pero es posible tener propiedad compartida en
Rust utilizando el tipo de puntero inteligente Rc
; añade un conteo de
referencias. Cada vez que el puntero inteligente es clonado, se
incrementa el conteo de referencias. Cuando el clon se descarta, el conteo de
referencias se decrementa. La instancia real detrás del puntero inteligente se
descarta cuando el conteo de referencias alcanza cero. Estos puntos se ilustran
mediante los siguientes ejemplos que se basan en los anteriores:
#![allow(dead_code, unused_variables)]
use std::rc::Rc;
struct Point {
x: i32,
y: i32,
}
impl Drop for Point {
fn drop(&mut self) {
println!("¡Point descartado!");
}
}
fn main() {
let a = Rc::new(Point { x: 12, y: 34 });
let b = Rc::clone(&a); // compartido con b
println!("a = {}, {}", a.x, a.y); // esta bien usar a
println!("b = {}, {}", b.x, b.y); // y b
}
// imprime:
// a = 12, 34
// b = 12, 34
// ¡Point descartado!
Ten en cuenta que:
-
Point
implementa el métododrop
del traitDrop
e imprime un mensaje cuando se descarta una instancia dePoint
. -
El punto creado en
main
está envuelto detrás del puntero inteligenteRc
, por lo que el puntero inteligente posee el punto y noa
. -
b
obtiene un clon del puntero inteligente que efectivamente incrementa el conteo de referencias a 2. A diferencia del ejemplo anterior, dondea
transfirió la propiedad del punto ab
, tantoa
comob
poseen sus propios clones distintos del puntero inteligente, por lo que está bien seguir usandoa
yb
. -
El compilador habrá determinado que
a
yb
salen de su ámbito al final demain
y por lo tanto inyectará llamadas para descartar cada uno. La implementación deDrop
deRc
decrementará el conteo de referencias y también descartará lo que posee si el conteo de referencias ha alcanzado cero. Cuando eso sucede, la implementación deDrop
dePoint
imprimirá el mensaje, “¡Point descartado!”. El hecho de que el mensaje se imprima una vez demuestra que solo se creó, compartió y descartó un punto.
Rc
no es seguro para hilos. Para la propiedad compartida en un programa
multiproceso, la biblioteca estándar de Rust ofrece Arc
en su lugar.
El lenguaje Rust evitará el uso de Rc
entre hilos.
En .NET, los tipos de valor (como enum
y struct
en C#) residen en el stack y
los tipos de referencia (interface
, record class
y class
en C#) se asignan
en el heap. En Rust, el tipo de tipo (básicamente enum
o struct
en Rust)
no determina dónde residirá finalmente la memoria de respaldo. Por defecto,
siempre está en el stack, pero al igual que .NET y C# tienen la noción de hacer
boxing de los tipos de valor, lo que los copia al heap, la forma de asignar un
tipo en el heap es hacer boxing usando Box
:
let stack_point = Point { x: 12, y: 34 };
let heap_point = Box::new(Point { x: 12, y: 34 });
Al igual que Rc
y Arc
, Box
es un puntero inteligente, pero a diferencia de
Rc
y Arc
, posee exclusivamente la instancia detrás de él. Todos estos
punteros inteligentes asignan una instancia de su argumento de tipo T
en
el heap.
La palabra clave new
en C# crea una instancia de un tipo, y aunque miembros
como Box::new
y Rc::new
que ves en los ejemplos pueden parecer tener un
propósito similar, new
no tiene una designación especial en Rust. Es
simplemente un nombre convencional que se utiliza para denotar un factory.
De hecho, se les llama funciones asociadas del tipo, que es la manera de Rust
de decir métodos estáticos.
Administración de Recursos
En la sección anterior, Administración de Memoria explicamos la diferencia entre .NET y Rust cuando se trata del Garbage Collector, Ownership y finalizadores. Esto is altamente recomendado para leer.
Esta sección es limitada a proporcionar un ejemplo de una conexión de base de datos ficticia que involucra una conexión SQL que debe cerrarse/disposed/destruirse adecuadamente.
{
using var db1 = new DatabaseConnection("Server=A;Database=DB1");
using var db2 = new DatabaseConnection("Server=A;Database=DB2");
// ...código usando "db1" y "db2"...
} // "Dispose" de "db1" y "db2" se llamabara aquí; cuando su scope termine
public class DatabaseConnection : IDisposable
{
readonly string connectionString;
SqlConnection connection; //Esto implementa IDisposable
public DatabaseConnection(string connectionString) =>
this.connectionString = connectionString;
public void Dispose()
{
//Asegurando que se desecha la SqlConnection
this.connection.Dispose();
Console.WriteLine("Closing connection: {this.connectionString}");
}
}
struct DatabaseConnection(&'static str);
impl DatabaseConnection {
// ...funciones para usar la conexión de base de datos...
}
impl Drop for DatabaseConnection {
fn drop(&mut self) {
// ...cerrando la conexión...
self.close_connection();
// ...imprimiendo un mensaje...
println!("Cerrando conexión: {}", self.0)
}
}
fn main() {
let _db1 = DatabaseConnection("Server=A;Database=DB1");
let _db2 = DatabaseConnection("Server=A;Database=DB2");
// ...code for making use of the database connection...
// ...codigo para utilizar la conexión a la base de datos...
} // "Dispose" de "db1" y "db2" se llamabara aquí; cuando su scope termine
En .NET, intentar usar un objeto después de llamar a Dispose
en él típicamente
causará que se lance una ObjectDisposedException
en tiempo de ejecución. En
Rust, el compilador garantiza en tiempo de compilación que esto no puede suceder.
Threading
La biblioteca estándar de Rust admite hilos, sincronización y concurrencia. Aunque el lenguaje en sí y la biblioteca estándar tienen soporte básico para estos conceptos, gran parte de la funcionalidad adicional es proporcionada por crates y no se cubrirá en este documento.
A continuación se presenta una lista aproximada de la correspondencia entre los tipos y métodos de hilos en .NET y Rust:
.NET | Rust |
---|---|
Thread | std::thread::thread |
Thread.Start | std::thread::spawn |
Thread.Join | std::thread::JoinHandle |
Thread.Sleep | std::thread::sleep |
ThreadPool | - |
Mutex | std::sync::Mutex |
Semaphore | - |
Monitor | std::sync::Mutex |
ReaderWriterLock | std::sync::RwLock |
AutoResetEvent | std::sync::Condvar |
ManualResetEvent | std::sync::Condvar |
Barrier | std::sync::Barrier |
CountdownEvent | std::sync::Barrier |
Interlocked | std::sync::atomic |
Volatile | std::sync::atomic |
ThreadLocal | std::thread_local |
Lanzar un hilo y esperar a que termine funciona de la misma manera en C#/.NET y Rust. A continuación, se muestra un programa simple en C# que crea un hilo (donde el hilo imprime algún texto en la salida estándar) y luego espera a que termine:
using System;
using System.Threading;
var thread = new Thread(() => Console.WriteLine("¡Hola, desde un hilo!"));
thread.Start();
thread.Join(); // espera a que el hilo termine
El mismo código en Rust sería el siguiente:
use std::thread;
fn main() {
let thread = thread::spawn(|| println!("¡Hola, desde un hilo!"));
thread.join().unwrap(); // espera a que el hilo termine
}
Crear e inicializar un objeto hilo y comenzar un hilo son dos acciones
diferentes en .NET, mientras que en Rust ambas ocurren al mismo tiempo
con thread::spawn
.
En .NET, es posible enviar datos como un argumento a un hilo:
#nullable enable
using System;
using System.Text;
using System.Threading;
var t = new Thread(obj =>
{
var data = (StringBuilder)obj!;
data.Append(" Mundo!");
});
var data = new StringBuilder("¡Hola");
t.Start(data);
t.Join();
Console.WriteLine($"Frase: {data}");
Sin embargo, una versión más moderna o concisa usaría closures:
using System;
using System.Text;
using System.Threading;
var data = new StringBuilder("¡Hola");
var t = new Thread(obj => data.Append(" Mundo!"));
t.Start();
t.Join();
Console.WriteLine($"Frase: {data}");
En Rust, no hay ninguna variante de thread::spawn
que haga lo mismo. En su
lugar, los datos se pasan al hilo mediante un cierre closure:
use std::thread;
fn main() {
let data = String::from("¡Hola");
let handle = thread::spawn(move || {
let mut data = data;
data.push_str(" Mundo!");
data
});
println!("Frase: {}", handle.join().unwrap());
}
Algunas cosas a tener en cuenta:
-
La palabra clave
move
es necesaria para mover o pasar la propiedad dedata
al cierre para el hilo. Una vez hecho esto, ya no es legal seguir utilizando la variabledata
enmain
. Si es necesario,data
debe ser copiada o clonada (dependiendo de lo que admita el tipo de valor). -
Los hilos de Rust pueden devolver valores, como las tareas en C#, lo que se convierte en el valor de retorno del método
join
. -
Es posible también pasar datos al hilo de C# mediante una closure, como en el ejemplo de Rust, pero la versión de C# no necesita preocuparse por el ownership ya que la memoria detrás de los datos será reclamada por el GC una vez que nadie la esté referenciando.
Sincronización
Cuando los datos son compartidos entre hilos, es necesario sincronizar el acceso
de lectura y escritura a los datos para evitar la corrupción. En C#, se ofrece
la palabra clave lock
como un primitivo de sincronización (que se desenrolla
en el uso seguro de excepciones de Monitor
de .NET):
using System;
using System.Threading;
var dataLock = new object();
var data = 0;
var threads = new List<Thread>();
for (var i = 0; i < 10; i++)
{
var thread = new Thread(() =>
{
for (var j = 0; j < 1000; j++)
{
lock (dataLock)
data++;
}
});
threads.Add(thread);
thread.Start();
}
foreach (var thread in threads)
thread.Join();
Console.WriteLine(data);
En Rust, uno debe hacer uso explícito de estructuras de concurrencia como
Mutex
:
use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
let data = Arc::new(Mutex::new(0)); // (1)
let mut threads = vec![];
for _ in 0..10 {
let data = Arc::clone(&data); // (2)
let thread = thread::spawn(move || { // (3)
for _ in 0..1000 {
let mut data = data.lock().unwrap();
*data += 1; // (4)
}
});
threads.push(thread);
}
for thread in threads {
thread.join().unwrap();
}
println!("{}", data.lock().unwrap());
}
Algunas cosas a tener en cuenta:
-
Dado que la propiedad de la instancia de
Mutex
y, a su vez, los datos que protege serán compartidos por múltiples hilos, se envuelve en unArc
(1).Arc
proporciona recuento de referencias atómico, que se incrementa cada vez que se clona (2) y se decrementa cada vez que se elimina. Cuando el recuento alcanza cero, se elimina el mutex y, por lo tanto, los datos que protege. Esto se discute con más detalle en Gestión de Memoria. -
La closure para cada hilo recibe la propiedad (3) de la referencia clonada (2).
-
El código similar a un puntero, que es
*data += 1
(4), no es un acceso a puntero inseguro incluso si parece serlo. Está actualizando los datos envueltos en el mutex guard.
A diferencia de la versión de C#, donde se puede volver inseguro para hilos al
comentar la declaración lock
, la versión de Rust se negará a compilar si se
cambia de alguna manera (por ejemplo, al comentar partes) que la vuelva insegura
para hilos. Esto demuestra que escribir código seguro para hilos es
responsabilidad del desarrollador en C# y .NET mediante el uso cuidadoso de
estructuras sincronizadas, mientras que en Rust, uno puede confiar en el
compilador.
El compilador puede ayudar porque las estructuras de datos en Rust están
marcadas por traits especiales (ver Interfaces): Sync
y Send
.
Sync
indica que las referencias a las instancias de un tipo son
seguras para compartir entre hilos. Send
indica que es seguro
enviar instancias de un tipo a través de los límites de los hilos. Para obtener
más información, consulta el capítulo
"Concurrencia sin miedo" del libro de Rust.
Productor-Consumidor
El patrón productor-consumidor es muy común para distribuir trabajo entre hilos
donde los datos son pasados desde hilos productores a hilos consumidores sin
necesidad de compartir o bloquear. .NET tiene un soporte muy amplio para esto,
pero en el nivel más básico, System.Collections.Concurrent
proporciona
BlockingCollection
como se muestra en el siguiente ejemplo en C#:
using System;
using System.Threading;
using System.Collections.Concurrent;
var messages = new BlockingCollection<string>();
var producer = new Thread(() =>
{
for (var n = 1; i < 10; i++)
messages.Add($"Mensaje #{n}");
messages.CompleteAdding();
});
producer.Start();
// el hilo principal es el consumidor aquí
foreach (var message in messages.GetConsumingEnumerable())
Console.WriteLine(message);
producer.Join();
The same can be done in Rust using channels. The standard library primarily
provides mpsc::channel
, which is a channel that supports multiple producers
and a single consumer. A rough translation of the above C# example in Rust
would look as follows:
Lo mismo se puede hacer en Rust utilizando canales. La biblioteca estándar
principalmente proporciona mpsc::channel
, que es un canal que admite múltiples
productores y un único consumidor. Una traducción aproximada del ejemplo
anterior en C# a Rust se vería así:
use std::thread;
use std::sync::mpsc;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
let producer = thread::spawn(move || {
for n in 1..10 {
tx.send(format!("Mensaje #{}", n)).unwrap();
}
});
// el hilo principal es el consumidor aquí
for received in rx {
println!("{}", received);
}
producer.join().unwrap();
}
Al igual que los canales en Rust, .NET también ofrece canales en el espacio de
nombres System.Threading.Channels
, pero está diseñado principalmente para ser
utilizado con tareas y programación asincrónica mediante el uso de async
y
await
. El equivalente de los canales amigables para asincronía en el espacio de
Rust es ofrecido por el runtime de Tokio.
Testing
Organización de pruebas
Las soluciones .NET utilizan proyectos separados para alojar el código de
prueba, independientemente del framework de pruebas utilizado (xUnit, NUnit,
MSTest, etc.) y el tipo de pruebas (unitarias o de integración) que se estén
escribiendo. Por lo tanto, el código de los test vive en un espacio separado
al código de la aplicación o biblioteca que se está probando. En Rust, es mucho
más convencional que las pruebas unitarias se encuentren en un submódulo de
prueba separado (convencionalmente) llamado tests
, pero que se coloca en el
mismo archivo fuente que el código del módulo de aplicación o biblioteca que
es objeto de las pruebas. Esto tiene dos beneficios:
-
El código/módulo y sus pruebas unitarias viven juntos.
-
No hay necesidad de un truco como
[InternalsVisibleTo]
que existe en .NET porque las pruebas tienen acceso a los elementos internos al ser un submódulo.
El submódulo de prueba está anotado con el atributo #[cfg(test)]
, lo que tiene
el efecto de que todo el módulo se compila (condicionalmente) y se ejecuta solo
cuando se emite el comando cargo test
.
Dentro de los submódulos de prueba, las funciones de prueba están anotadas con
el atributo #[test]
.
Las pruebas de integración suelen estar en un directorio llamado tests
que se
encuentra adyacente al directorio src
con las pruebas unitarias y el código
fuente. cargo test
compila cada archivo en ese directorio como un crate
separado y ejecuta todos los métodos anotados con el atributo #[test]
. Dado
que se entiende que las pruebas de integración están en el directorio tests
,
no es necesario marcar los módulos allí con el atributo #[cfg(test)]
.
Mirar también:
Ejecución de pruebas
Tan simple como puede ser, el equivalente de dotnet test
en Rust
es cargo test
.
El comportamiento predeterminado de cargo test
es ejecutar todas las pruebas
en paralelo, pero esto se puede configurar para que se ejecuten de manera
consecutiva utilizando solo un hilo:
cargo test -- --test-threads=1
Para obtener más información, consulta "Ejecutando tests en paralelo o consecutivamente".
ejecutando-tests-en-paralelo-o-consecutivamente
Output en las Pruebas
Para pruebas de integración o de extremo a extremo muy complejas, a veces los
desarrolladores de .NET registran lo que está sucediendo durante una prueba. La
forma en que hacen esto varía con cada framework de pruebas. Por ejemplo, en
NUnit, esto es tan simple como usar Console.WriteLine
, pero en XUnit, se
utiliza ITestOutputHelper
. En Rust, es similar a NUnit; es decir, simplemente
se escribe en la salida estándar usando println!
. La salida capturada durante
la ejecución de las pruebas no se muestra por defecto a menos que cargo test
se ejecute con la opción --show-output
:
cargo test --show-output
Para obtener más información, consulta "Mostrando el Output de las funciones".
mostrando-el-output-de-las-funciones
Aserciones
Los usuarios de .NET tienen múltiples formas de hacer aserciones, dependiendo del framework de trabajo que estén utilizando. Por ejemplo, una aserción en xUnit.net podría lucir así:
[Fact]
public void Something_Is_The_Right_Length()
{
var value = "something";
Assert.Equal(9, value.Length);
}
Rust no requiere un framework o crate separado. La biblioteca estándar viene con macros integradas que son lo suficientemente buenas para la mayoría de las afirmaciones en las pruebas:
A continuación se muestra un ejemplo de assert_eq
en acción:
#[test]
fn something_is_the_right_length() {
let value = "something";
assert_eq!(9, value.len());
}
La biblioteca estándar no ofrece nada en la dirección de pruebas basadas en
datos, como [Theory]
en xUnit.net.
Mocking
Cuando se escriben pruebas para una aplicación o biblioteca .NET, existen varios
framework, como Moq y NSubstitute, para simular las dependencias de los tipos.
También hay crates similares para Rust, como mockall
, que pueden
ayudar con la simulación. Sin embargo, también es posible usar
compilación condicional haciendo uso del
atributo cfg
como un medio simple para la simulación sin
necesidad de depender de crates o frameworks externos. El atributo cfg
incluye
condicionalmente el código que anota en función de un símbolo de configuración,
como test
para pruebas. Esto no es muy diferente de usar DEBUG
para compilar
condicionalmente código específicamente para compilaciones de depuración. Una
desventaja de este enfoque es que solo se puede tener una implementación para
todas las pruebas del módulo.
Cuando se especifica, el atributo #[cfg(test)]
le indica a Rust que compile y
ejecute el código solo cuando se ejecute el comando cargo test
, que ejecuta el
compilador con rustc --test
. Lo contrario es cierto para el atributo
#[cfg(not(test))]
; incluye el código anotado solo cuando se realiza la prueba
con cargo test
.
El ejemplo a continuación muestra la simulación de una función independiente
var_os
de la biblioteca estándar que lee y devuelve el valor de una variable
de entorno. Importa condicionalmente una versión simulada de la función var_os
utilizada por get_env
. Cuando se compila con cargo build
o se ejecuta con
cargo run
, el binario compilado hará uso de std::env::var_os
, pero
cargo test
en su lugar importará tests::var_os_mock
como var_os
, lo que
hará que get_env
utilice la versión simulada durante las pruebas:
// Derechos de autor (c) Microsoft Corporation. Todos los derechos reservados.
// Licenciado bajo la licencia MIT.
/// Función de utilidad para leer una variable de entorno y devolver su valor
/// si está definida. Falla/pániquea si el valor no es Unicode válido.
pub fn get_env(key: &str) -> Option<String> {
#[cfg(not(test))] // para compilaciones regulares...
use std::env::var_os; // ...importar desde la biblioteca estándar
#[cfg(test)] // para compilaciones de prueba...
use tests::var_os_mock as var_os; // ...importar la simulación desde el submódulo de prueba
let val = var_os(key);
val.map(|s| s.to_str() // obtiene slice de string
.unwrap() // lanza un pánico si no es Unicode válido
.to_owned()) // convierte a "String"
}
#[cfg(test)]
mod tests {
use std::ffi::*;
use super::*;
pub(crate) fn var_os_mock(key: &str) -> Option<OsString> {
match key {
"FOO" => Some("BAR".into()),
_ => None
}
}
#[test]
fn get_env_when_var_undefined_returns_none() {
assert_eq!(None, get_env("???"));
}
#[test]
fn get_env_when_var_defined_returns_some_value() {
assert_eq!(Some("BAR".to_owned()), get_env("FOO"));
}
}
Cobertura de código
Existe herramientas sofisticadas para .NET en cuanto a análisis de cobertura de código de pruebas. En Visual Studio, las herramientas están integradas de forma nativa. En Visual Studio Code, existen complementos. Los desarrolladores de .NET podrían estar familiarizados con coverlet también.
Rust proporciona implementaciones integradas de cobertura de código para recopilar la cobertura de código de las pruebas.
También hay complementos disponibles para Rust que ayudan con el análisis de cobertura de código. No está integrado de manera perfecta, pero con algunos pasos manuales, los desarrolladores pueden analizar su código de manera visual.
La combinación del complemento Coverage Gutters para Visual Studio Code y Tarpaulin permite el análisis visual de la cobertura de código en Visual Studio Code. Coverage Gutters requiere un archivo LCOV. Se pueden usar otras herramientas además de Tarpaulin para generar ese archivo.
Una vez configurado, ejecuta el siguiente comando:
cargo tarpaulin --ignore-tests --out Lcov
Esto genera un archivo de cobertura de código LCOV. Una vez habilitado
Coverage Gutters: Watch
, será recogido por el complemento Coverage Gutters,
que mostrará indicadores visuales en línea sobre la cobertura de líneas en el
editor de código fuente.
Nota: La ubicación del archivo LCOV es esencial. Si hay un espacio de trabajo (ver Estructura del Proyecto) con múltiples paquetes y se genera un archivo LCOV en la raíz usando
--workspace
, ese es el archivo que se está utilizando, incluso si hay un archivo presente directamente en la raíz del paquete. Es más rápido aislar el paquete específico bajo prueba en lugar de generar el archivo LCOV en la raíz.
Benchmarking
La ejecución de benchmarks en Rust se realiza a través de
cargo bench
, un comando específico para cargo
que ejecuta
todos los métodos anotados con el atributo #[bench]
. Este atributo está
actualmente inestable y disponible solo para el canal nightly.
Los usuarios de .NET pueden utilizar la biblioteca BenchmarkDotNet
para
realizar benchmarks de métodos y realizar un seguimiento de su rendimiento. El
equivalente de BenchmarkDotNet
es una crate llamada Criterion
.
Según su documentación, Criterion
recopila y almacena
información estadística de ejecución en ejecución y puede detectar
automáticamente regresiones de rendimiento, así como medir optimizaciones.
Usando Criterion
, es posible utilizar el atributo #[bench]
sin necesidad de
cambiar al canal nightly.
Al igual que en BenchmarkDotNet
, también es posible integrar los resultados de
los benchmarks con la
GitHub Action para Benchmarking Continuo. De hecho,
Criterion
admite múltiples formatos de salida, entre los que también se
encuentra el formato bencher
, que imita los benchmarks nightly de libtest
y
es compatible con la acción mencionada anteriormente.
Logging y Tracing
.NET admite varias API de logging. Para la mayoría de los casos, ILogger
es
una buena opción predeterminada, ya que funciona con una variedad de proveedores
de registro integrados y de terceros. En C#, un ejemplo mínimo para registro
estructurado podría lucir así:
using Microsoft.Extensions.Logging;
using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
var logger = loggerFactory.CreateLogger<Program>();
logger.LogInformation("Hola {Day}.", "Jueves"); // Hola Jueves.
En Rust, se proporciona una fachada de logging ligera a través de log.
Tiene menos características que ILogger
, por ejemplo, aún no ofrece (de manera
estable) registro estructurado o ámbitos de registro.
Para algo con una paridad de características más cercana a .NET, Tokio ofrece
tracing
. tracing
es un framework para instrumentar
aplicaciones Rust para recopilar información de diagnóstico estructurada y
basada en eventos. tracing_subscriber
se puede
utilizar para implementar y componer suscriptores de tracing
. El mismo ejemplo
de registro estructurado anterior con tracing
y tracing_subscriber
se vería
así:
fn main() {
// Instalar el recolector global de mensajes de ("consola").
tracing_subscriber::fmt().init();
tracing::info!("Hola {Day}.", Day = "Jueves"); // Hola Jueves.
}
OpenTelemetry ofrece una colección de herramientas, APIs y SDKs utilizados para instrumentar, generar, recopilar y exportar datos de telemetría basados en la especificación de OpenTelemetry. En el momento de escribir esto, la API de registro de OpenTelemetry aún no es estable y la implementación de Rust todavía no soporta el registro, pero sí soporta la API de rastreo.
Compilación Condicional
Tanto .NET como Rust proporcionan la posibilidad de compilar código específico basado en condiciones externas.
En .NET es posible utilizar algunas directivas del preprocesador para controlar la compilación condicional.
#if debug
Console.WriteLine("Debug");
#else
Console.WriteLine("No debug");
#endif
Además de los símbolos predefinidos, también es posible utilizar la opción del
compilador DefineConstants para definir símbolos que se pueden utilizar con
#if
, #else
, #elif
y #endif
para compilar archivos fuente de forma
condicional en .NET.
En Rust, es posible utilizar el atributo cfg
, el
atributo cfg_attr
o el macro cfg
para controlar la
compilación condicional.
Al igual que en .NET, además de los símbolos predefinidos, también es posible
utilizar la bandera del compilador --cfg
para establecer
arbitrariamente opciones de configuración.
El atributo cfg
requiere y evalúa un ConfigurationPredicate
.
use std::fmt::{Display, Formatter};
struct MyStruct;
// Esta implementación de Display solo se incluye cuando el SO es Unix pero foo no es igual a bar
// Puedes compilar un ejecutable para esta versión, en Linux, con 'rustc main.rs --cfg foo=\"baz\"'
#[cfg(all(unix, not(foo = "bar")))]
impl Display for MyStruct {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str("Ejecutando sin la configuración foo=bar")
}
}
// Esta función solo se incluye cuando tanto unix como foo=bar están definidos
// Puedes compilar un ejecutable para esta versión, en Linux, con 'rustc main.rs --cfg foo=\"bar\"'
#[cfg(all(unix, foo = "bar"))]
impl Display for MyStruct {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str("Ejecutando con la configuración foo=bar")
}
}
// Esta función provoca un pánico cuando no se compila para Unix
// Puedes compilar un ejecutable para esta versión, en Windows, con 'rustc main.rs'
#[cfg(not(unix))]
impl Display for MyStruct {
fn fmt(&self, _f: &mut Formatter<'_>) -> std::fmt::Result {
panic!()
}
}
fn main() {
println!("{}", MyStruct);
}
El atributo cfg_attr
incluye condicionalmente atributos basados en
un predicado de configuración.
#[cfg_attr(feature = "serialization_support", derive(Serialize, Deserialize))]
pub struct MaybeSerializableStruct;
// Cuando la feature flag `serialization_support` está habilitada, lo anterior se expandirá a:
// #[derive(Serialize, Deserialize)]
// pub struct MaybeSerializableStruct;
The built-in cfg macro
takes in a single configuration predicate
and evaluates to the true literal when the predicate is true and the false
literal when it is false.
El macro cfg
incorporado toma un solo predicado de configuración
y evalúa al literal verdadero cuando el predicado es verdadero y al literal
falso cuando es falso.
if cfg!(unix) {
println!("¡Estoy ejecutándome en una máquina Unix!");
}
Mira también:
Features
La compilación condicional también es útil cuando es necesario proporcionar
dependencias opcionales. Con las "features" de Cargo, un paquete define
un conjunto de funcionalidades nombradas en la tabla [features]
de Cargo.toml,
y cada funcionalidad puede estar habilitada o deshabilitada. Las funcionalidades
del paquete que se está construyendo pueden habilitarse en la línea de comandos
con banderas como --features
. Las funcionalidades para las dependencias pueden
habilitarse en la declaración de dependencia en Cargo.toml.
Mira también:
Environment and Configuration
Accessing environment variables
.NET provides access to environment variables via the
System.Environment.GetEnvironmentVariable
method. This method retrieves the
value of an environment variable at runtime.
using System;
const string name = "EXAMPLE_VARIABLE";
var value = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrEmpty(value))
Console.WriteLine($"Variable '{name}' not set.");
else
Console.WriteLine($"Variable '{name}' set to '{value}'.");
Rust is providing the same functionality of accessing an environment variable at
runtime via the var
and var_os
functions from the std::env
module.
var
function is returning a Result<String, VarError>
, either returning the
variable if set or returning an error if the variable is not set or it is not
valid Unicode.
var_os
has a different signature giving back an Option<OsString>
, either
returning some value if the variable is set, or returning None if the variable
is not set. An OsString
is not required to be valid Unicode.
use std::env;
fn main() {
let key = "ExampleVariable";
match env::var(key) {
Ok(val) => println!("{key}: {val:?}"),
Err(e) => println!("couldn't interpret {key}: {e}"),
}
}
use std::env;
fn main() {
let key = "ExampleVariable";
match env::var_os(key) {
Some(val) => println!("{key}: {val:?}"),
None => println!("{key} not defined in the enviroment"),
}
}
Rust is also providing the functionality of accessing an environment variable at
compile time. The env!
macro from std::env
expands the value of the variable
at compile time, returning a &'static str
. If the variable is not set, an
error is emitted.
use std::env;
fn main() {
let example = env!("ExampleVariable");
println!("{example}");
}
In .NET a compile time access to environment variables can be achieved, in a less straightforward way, via source generators.
Configuration
Configuration in .NET is possible with configuration providers. The framework is
providing several provider implementations via
Microsoft.Extensions.Configuration
namespace and NuGet packages.
Configuration providers read configuration data from key-value pairs using
different sources and provide a unified view of the configuration via the
IConfiguration
type.
using Microsoft.Extensions.Configuration;
class Example {
static void Main()
{
IConfiguration configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
var example = configuration.GetValue<string>("ExampleVar");
Console.WriteLine(example);
}
}
Other providers examples can be found in the official documentation Configurations provider in .NET.
A similar configuration experience in Rust is available via use of third-party crates such as figment or config.
See the following example making use of config crate:
use config::{Config, Environment};
fn main() {
let builder = Config::builder().add_source(Environment::default());
match builder.build() {
Ok(config) => {
match config.get_string("examplevar") {
Ok(v) => println!("{v}"),
Err(e) => println!("{e}")
}
},
Err(_) => {
// something went wrong
}
}
}
LINQ
This section discusses LINQ within the context and for the purpose of querying
or transforming sequences (IEnumerable
/IEnumerable<T>
) and typically
collections like lists, sets and dictionaries.
IEnumerable<T>
The equivalent of IEnumerable<T>
in Rust is IntoIterator
.
Just as an implementation of IEnumerable<T>.GetEnumerator()
returns a
IEnumerator<T>
in .NET, an implementation of IntoIterator::into_iter
returns an Iterator
. However, when it's time to iterate over the
items of a container advertising iteration support through the said types,
both languages offer syntactic sugar in the form of looping constructs for
iteratables. In C#, there is 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); // Prints: 1, 2, 3, 4, 5
In Rust, the equivalent is simply 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(", ");
}
// ! discard/ignore any write error
_ = write!(output, "{value}");
}
println!("{output}"); // Prints: 1, 2, 3, 4, 5
}
The for
loop over an iterable essentially gets desuraged to the following:
use std::fmt::Write;
fn main() {
let values = [1, 2, 3, 4, 5];
let mut output = String::new();
let mut iter = values.into_iter(); // get iterator
while let Some(value) = iter.next() { // loop as long as there are more items
if output.len() > 0 {
output.push_str(", ");
}
_ = write!(output, "{value}");
}
println!("{output}");
}
Rust's ownership and data race condition rules apply to all instances and data, and iteration is no exception. So while looping over an array might look straightforward and very similar to C#, one has to be mindful about ownership when needing to iterate the same collection/iterable more than once. The following example iteraters the list of integers twice, once to print their sum and another time to determine and print the maximum integer:
fn main() {
let values = vec![1, 2, 3, 4, 5];
// sum all values
let mut sum = 0;
for value in values {
sum += value;
}
println!("sum = {sum}");
// determine maximum value
let mut max = None;
for value in values {
if let Some(some_max) = max { // if max is defined
if value > some_max { // and value is greater
max = Some(value) // then note that new max
}
} else { // max is undefined when iteration starts
max = Some(value) // so set it to the first value
}
}
println!("max = {max:?}");
}
However, the code above is rejected by the compiler due to a subtle
difference: values
has been changed from an array to a Vec<int>
,
a vector, which is Rust's type for growable arrays (like List<T>
in .NET).
The first iteration of values
ends up consuming each value as the integers
are summed up. In other words, the ownership of each item in the vector
passes to the iteration variable of the loop: value
. Since value
goes out
of scope at the end of each iteration of the loop, the instance it owns is
dropped. Had values
been a vector of heap-allocated data, the heap memory
backing each item would get freed as the loop moved to the next item. To fix
the problem, one has to request iteration over shared references via
&values
in the for
loop. As a result, value
ends up being a shared
reference to an item as opposed to taking its ownership.
Below is the updated version of the previous example that compiles. The fix is
to simply replace values
with &values
in each of the for
loops.
fn main() {
let values = vec![1, 2, 3, 4, 5];
// sum all values
let mut sum = 0;
for value in &values {
sum += value;
}
println!("sum = {sum}");
// determine maximum value
let mut max = None;
for value in &values {
if let Some(some_max) = max { // if max is defined
if value > some_max { // and value is greater
max = Some(value) // then note that new max
}
} else { // max is undefined when iteration starts
max = Some(value) // so set it to the first value
}
}
println!("max = {max:?}");
}
The ownership and dropping can be seen in action even with values
being an
array instead of a vector. Consider just the summing loop from the above
example over an array of a structure that wraps an integer:
struct Int(i32);
impl Drop for Int {
fn drop(&mut self) {
println!("{} dropped", 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
implements Drop
so that a message is printed when an instance get
dropped. Running the above code will print:
value = Int(1)
Int(1) dropped
value = Int(2)
Int(2) dropped
value = Int(3)
Int(3) dropped
value = Int(4)
Int(4) dropped
value = Int(5)
Int(5) dropped
sum = 15
It's clear that each value is acquired and dropped while the loop is running.
Once the loop is complete, the sum is printed. If values
in the for
loop
is changed to &values
instead, like this:
for value in &values {
// ...
}
then the output of the program will change radically:
value = Int(1)
value = Int(2)
value = Int(3)
value = Int(4)
value = Int(5)
sum = 15
Int(1) dropped
Int(2) dropped
Int(3) dropped
Int(4) dropped
Int(5) dropped
This time, values are acquired but not dropped while looping because each item
doesn't get owned by the interation loop's variable. The sum is printed ocne
the loop is done. Finally, when the values
array that still owns all the the
Int
instances goes out of scope at the end of main
, its dropping in turn
drops all the Int
instances.
These examples demonstrate that while iterating collection types may seem to have a lot of parallels between Rust and C#, from the looping constructs to the iteration abstractions, there are still subtle differences with respect to ownership that can lead to the compiler rejecting the code in some instances.
See also:
Operators
Operators in LINQ are implemented in the form of C# extension methods that
can be chained together to form a set of operations, with the most common
forming a query over some sort of data source. C# also offers a SQL-inspired
query syntax with clauses like from
, where
, select
, join
and others
that can serve as an alternative or a companion to method chaining. Many
imperative loops can be re-written as much more expressive and composable
queries in LINQ.
Rust does not offer anything like C#'s query syntax. It has methods, called adapters in Rust terms, over iterable types and therefore directly comparable to chaining of methods in C#. However, whlie rewriting an imperative loop as LINQ code in C# is often beneficial in expressivity, robustness and composability, there is a trade-off with performance. Compute-bound imperative loops usually run faster because they can be optimised by the JIT compiler and there are fewer virtual dispatches or indirect function invocations incurred. The surprising part in Rust is that there is no performance trade-off between choosing to use method chains on an abstraction like an iterator over writing an imperative loop by hand. It's therefore far more common to see the former in code.
The following table lists the most common LINQ methods and their approximate counterparts in Rust.
.NET | Rust | Note |
---|---|---|
Aggregate | reduce | See note 1. |
Aggregate | fold | See note 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 | See note 2. |
ToDictionary | collect | See note 2. |
ToList | collect | See note 2. |
Where | filter | |
Zip | zip |
-
The
Aggregate
overload not accepting a seed value is equivalent toreduce
, while theAggregate
overload accepting a seed value corresponds tofold
. -
collect
in Rust generally works for any collectible type, which is defined as a type that can initialize itself from an iterator (seeFromIterator
).collect
needs a target type, which the compiler sometimes has trouble inferring so the turbofish (::<>
) is often used in conjunction with it, as incollect::<Vec<_>>()
. This is whycollect
appears next to a number of LINQ extension methods that convert an enumerable/iterable source to some collection type instance.
The following example shows how similar transforming sequences in C# is to doing the same in Rust. First in 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
And in 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)
Many operators in LINQ are designed to be lazy such that they only do work
when absolutely required. This enables composition or chaining of several
operations/methods without causing any side-effects. For example, a LINQ
operator can return an IEnumerable<T>
that is initialized, but does not
produce, compute or materialize any items of T
until iterated. The operator
is said to have deferred execution semantics. If each T
is computed as
iteration reaches it (as opposed to when iteration begins) then the operator
is said to stream the results.
Rust iterators have the same concept of laziness and streaming.
In both cases, this allows infinite sequences to be represented, where the underlying sequence is infinite, but the developer decides how the sequence should be terminated . The following example shows this in C#:
foreach (var x in InfiniteRange().Take(5))
Console.Write($"{x} "); // Prints "0 1 2 3 4"
IEnumerable<int> InfiniteRange()
{
for (var i = 0; ; ++i)
yield return i;
}
Rust supports the same concept through infinite ranges:
// Generators and yield in Rust are unstable at the moment, so
// instead, this sample uses Range:
// https://doc.rust-lang.org/std/ops/struct.Range.html
for value in (0..).take(5) {
print!("{value} "); // Prints "0 1 2 3 4"
}
Iterator Methods (yield
)
C# has the yield
keword that enables the developer to quickly write an
iterator method. The return type of an iterator method can be an
IEnumerable<T>
or an IEnumerator<T>
. The compiler then converts the body
of the method into a concrete implementation of the return type, instead of
the developer having to write a full-blown class each time.
Coroutines, as they're called in Rust, are still considered
an unstable feature at the time of this writing.
Meta Programming
Metaprogramming can be seen as a way of writing code that writes/generates other code.
Roslyn is providing a feature for metaprogramming in C#, available since .NET 5,
and called Source Generators
. Source generators can create new
C# source files at build-time that are added to the user's compilation. Before
Source Generators
were introduced, Visual Studio has been providing a code
generation tool via T4 Text Templates
. An example on how T4 works is the
following template or its concretization.
Rust is also providing a feature for metaprogramming: macros. There are
declarative macros
and procedural macros
.
Declarative macros allow you to write control structures that take an expression, compare the resulting value of the expression to patterns, and then run the code associated with the matching pattern.
The following example is the definition of the println!
macro that it is
possible to call for printing some text println!("Some text")
macro_rules! println {
() => {
$crate::print!("\n")
};
($($arg:tt)*) => {{
$crate::io::_print($crate::format_args_nl!($($arg)*));
}};
}
To learn more about writing declarative macros, refer to the Rust reference chapter macros by example or The Little Book of Rust Macros.
Procedural macros are different than declarative macros. Those accept some code as an input, operate on that code, and produce some code as an output.
Another technique used in C# for metaprogramming is reflection. Rust does not support reflection.
Function-like macros
Function-like macros are in the following form: function!(...)
The following code snippet defines a function-like macro named
print_something
, which is generating a print_it
method for printing the
"Something" string.
In the lib.rs:
extern crate proc_macro;
use proc_macro::TokenStream;
#[proc_macro]
pub fn print_something(_item: TokenStream) -> TokenStream {
"fn print_it() { println!(\"Something\") }".parse().unwrap()
}
In the main.rs:
use replace_crate_name_here::print_something;
print_something!();
fn main() {
print_it();
}
Derive macros
Derive macros can create new items given the token stream of a struct, enum, or
union. An example of a derive macro is the #[derive(Clone)]
one, which is
generating the needed code for making the input struct/enum/union implement the
Clone
trait.
In order to understand how to define a custom derive macro, it is possible to read the rust reference for derive macros
Attribute macros
Attribute macros define new attributes which can be attached to rust items. While working with asynchronous code, if making use of Tokio, the first step will be to decorate the new asynchronous main with an attribute macro like the following example:
#[tokio::main]
async fn main() {
println!("Hello world");
}
In order to understand how to define a custom derive macro, it is possible to read the rust reference for attribute macros
Asynchronous Programming
Both .NET and Rust support asynchronous programming models, which look similar to each other with respect to their usage. The following example shows, on a very high level, how async code looks like in C#:
async Task<string> PrintDelayed(string message, CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
return $"Message: {message}";
}
Rust code is structured similarly. The following sample relies on async-std
for the implementation of sleep
:
use std::time::Duration;
use async_std::task::sleep;
async fn format_delayed(message: &str) -> String {
sleep(Duration::from_secs(1)).await;
format!("Message: {}", message)
}
-
The Rust
async
keyword transforms a block of code into a state machine that implements a trait calledFuture
, similarly to how the C# compiler transformsasync
code into a state machine. In both languages, this allows for writing asynchronous code sequentially. -
Note that for both Rust and C#, asynchronous methods/functions are prefixed with the async keyword, but the return types are different. Asynchronous methods in C# indicate the full and actual return type because it can vary. For example, it is common to see some methods return a
Task<T>
while others return aValueTask<T>
. In Rust, it is enough to specify the inner typeString
because it's always some future; that is, a type that implements theFuture
trait. -
The
await
keywords are in different positions in C# and Rust. In C#, aTask
is awaited by prefixing the expression withawait
. In Rust, suffixing the expression with the.await
keyword allows for method chaining, even thoughawait
is not a method.
See also:
Executing tasks
From the following example the PrintDelayed
method executes, even though it is
not awaited:
var cancellationToken = CancellationToken.None;
PrintDelayed("message", cancellationToken); // Prints "message" after a second.
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
async Task PrintDelayed(string message, CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
Console.WriteLine(message);
}
In Rust, the same function invocation does not print anything.
use async_std::task::sleep;
use std::time::Duration;
#[tokio::main] // used to support an asynchronous main method
async fn main() {
print_delayed("message"); // Prints nothing.
sleep(Duration::from_secs(2)).await;
}
async fn print_delayed(message: &str) {
sleep(Duration::from_secs(1)).await;
println!("{}", message);
}
This is because futures are lazy: they do nothing until they are run. The most
common way to run a Future
is to .await
it. When .await
is called on a
Future
, it will attempt to run it to completion. If the Future
is blocked,
it will yield control of the current thread. When more progress can be made, the
Future
will be picked up by the executor and will resume running, allowing the
.await
to resolve (see async/.await
).
While awaiting a function works from within other async
functions, main
is
not allowed to be async
. This is a consequence of the fact that
Rust itself does not provide a runtime for executing asynchronous code. Hence,
there are libraries for executing asynchronous code, called async runtimes.
Tokio is such an async runtime, and it is frequently used.
tokio::main
from the above example marks the async main
function as entry point to be executed by a runtime, which is set up
automatically when using the macro.
Task cancellation
The previous C# examples included passing a CancellationToken
to asynchronous
methods, as is considered best practice in .NET. CancellationToken
s can be
used to abort an asynchronous operation.
Because futures are inert in Rust (they make progress only when polled),
cancellation works differently in Rust. When dropping a Future
, the Future
will make no further progress. It will also drop all instantiated values up to
the point where the future is suspended due to some outstanding asynchronous
operation. This is why most asynchronous functions in Rust don't take an
argument to signal cancellation, and is why dropping a future is sometimes being
referred to as cancellation.
tokio_util::sync::CancellationToken
offers an
equivalent to the .NET CancellationToken
to signal and react to cancellation,
for cases where implementing the Drop
trait on a Future
is unfeasible.
Executing multiple Tasks
In .NET, Task.WhenAny
and Task.WhenAll
are frequently used to handle the
execution of multiple tasks.
Task.WhenAny
completes as soon as any task completes. Tokio, for example,
provides the tokio::select!
macro as an alternative for
Task.WhenAny
, which means to wait on multiple concurrent branches.
var cancellationToken = CancellationToken.None;
var result =
await Task.WhenAny(Delay(TimeSpan.FromSeconds(2), cancellationToken),
Delay(TimeSpan.FromSeconds(1), cancellationToken));
Console.WriteLine(result.Result); // Waited 1 second(s).
async Task<string> Delay(TimeSpan delay, CancellationToken cancellationToken)
{
await Task.Delay(delay, cancellationToken);
return $"Waited {delay.TotalSeconds} second(s).";
}
The same example for Rust:
use std::time::Duration;
use tokio::{select, time::sleep};
#[tokio::main]
async fn main() {
let result = select! {
result = delay(Duration::from_secs(2)) => result,
result = delay(Duration::from_secs(1)) => result,
};
println!("{}", result); // Waited 1 second(s).
}
async fn delay(delay: Duration) -> String {
sleep(delay).await;
format!("Waited {} second(s).", delay.as_secs())
}
Again, there are crucial differences in semantics between the two examples. Most
importantly, tokio::select!
will cancel all remaining branches, while
Task.WhenAny
leaves it up to the user to cancel any in-flight tasks.
Similarly, Task.WhenAll
can be replaced with tokio::join!
.
Multiple consumers
In .NET a Task
can be used across multiple consumers. All of them can await
the task and get notified when the task is completed or failed. In Rust, the
Future
can not be cloned or copied, and await
ing will move the ownership.
The futures::FutureExt::shared
extension creates a cloneable handle to a
Future
, which then can be distributed across multiple consumers.
use futures::FutureExt;
use std::time::Duration;
use tokio::{select, time::sleep, signal};
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() {
let token = CancellationToken::new();
let child_token = token.child_token();
let bg_operation = background_operation(child_token);
let bg_operation_done = bg_operation.shared();
let bg_operation_final = bg_operation_done.clone();
select! {
_ = bg_operation_done => {},
_ = signal::ctrl_c() => {
token.cancel();
},
}
bg_operation_final.await;
}
async fn background_operation(cancellation_token: CancellationToken) {
select! {
_ = sleep(Duration::from_secs(2)) => println!("Background operation completed."),
_ = cancellation_token.cancelled() => println!("Background operation cancelled."),
}
}
Asynchronous iteration
While in .NET there are IAsyncEnumerable<T>
and
IAsyncEnumerator<T>
, Rust does not yet have an API for
asynchronous iteration in the standard library. To support asynchronous
iteration, the Stream
trait from futures
offers a comparable set of functionality.
In C#, writing async iterators has comparable syntax to when writing synchronous iterators:
await foreach (int item in RangeAsync(10, 3).WithCancellation(CancellationToken.None))
Console.Write(item + " "); // Prints "10 11 12".
async IAsyncEnumerable<int> RangeAsync(int start, int count)
{
for (int i = 0; i < count; i++)
{
await Task.Delay(TimeSpan.FromSeconds(i));
yield return start + i;
}
}
In Rust, there are several types that implement the Stream
trait, and hence
can be used for creating streams, e.g. futures::channel::mpsc
. For a syntax
closer to C#, async-stream
offers a set of macros that
can be used to generate streams succinctly.
use async_stream::stream;
use futures_core::stream::Stream;
use futures_util::{pin_mut, stream::StreamExt};
use std::{
io::{stdout, Write},
time::Duration,
};
use tokio::time::sleep;
#[tokio::main]
async fn main() {
let stream = range(10, 3);
pin_mut!(stream); // needed for iteration
while let Some(result) = stream.next().await {
print!("{} ", result); // Prints "10 11 12".
stdout().flush().unwrap();
}
}
fn range(start: i32, count: i32) -> impl Stream<Item = i32> {
stream! {
for i in 0..count {
sleep(Duration::from_secs(i as _)).await;
yield start + i;
}
}
}
Estructura del Proyecto
Aunque existen convenciones sobre la estructuración de un proyecto en .NET, son menos estrictas en comparación con las convenciones de estructura de proyectos en Rust. Al crear una solución de dos proyectos usando Visual Studio 2022 (una biblioteca de clases y un proyecto de prueba xUnit), se creará la siguiente estructura:
.
| BibliotecaDeClasesDeEjemplo.sln
+---BibliotecaDeClasesDeEjemplo
| Class1.cs
| BibliotecaDeClasesDeEjemplo.csproj
+---TestDeEjemploDelProjecto
TestDeEjemploDelProjecto.csproj
UnitTest1.cs
Usings.cs
- Cada proyecto reside en un directorio separado, con su propio archivo
.csproj
. - En la raíz del repositorio hay un archivo
.sln
.
Cargo utiliza las siguientes convenciones para la estructura del paquete para facilitar la inmersión en un nuevo paquete de Cargo:
.
+-- Cargo.lock
+-- Cargo.toml
+-- src/
| +-- lib.rs
| +-- main.rs
+-- benches/
| +-- algun-bench.rs
+-- ejemplos/
| +-- algun-ejemplo.rs
+-- tests/
+-- algun-test-de-integracion.rs
Cargo.toml
yCargo.lock
se almacenan en la raíz del paquete.src/lib.rs
es el archivo de biblioteca predeterminado, ysrc/main.rs
es el archivo ejecutable predeterminado (ver descubrimiento automático de objetivos).- Los benchmarks se colocan en el directorio
benches
, las pruebas de integración se colocan en el directoriotests
(ver testing, benchmarking). - Los ejemplos se colocan en el directorio
examples
. - No hay un crate separado para las pruebas unitarias, las pruebas unitarias viven en el mismo archivo que el código (ver pruebas).
Gestión de proyectos grandes
Para proyectos muy grandes en Rust, Cargo ofrece workspace para organizar el proyecto. Un espacio de trabajo puede ayudar a gestionar múltiples paquetes relacionados que se desarrollan en conjunto. Algunos proyectos utilizan manifiestos virtuales, especialmente cuando no hay un paquete principal.
Gestión de versiones de dependencias
Al gestionar proyectos más grandes en .NET, puede ser apropiado gestionar las versiones de las dependencias de forma centralizada, utilizando estrategias como la Gestión Central de Paquetes. Cargo introdujo la herencia de workspace para gestionar las dependencias de forma centralizada.
Compilation and Building
.NET CLI
The equivalent of the .NET CLI (dotnet
) in Rust is Cargo (cargo
). Both
tools are entry-point wrappers that simplify use of other low-level tools. For
example, although you could invoke the C# compiler directly (csc
) or MSBuild
via dotnet msbuild
, developers tend to use dotnet build
to build their
solution. Similarly in Rust, while you could use the Rust compiler (rustc
)
directly, using cargo build
is generally far simpler.
Building
Building an executable in .NET using dotnet build
restores pacakges, compiles the project sources into an assembly. The
assembly contain the code in Intermediate Language (IL) and can typically be
run on any platform supported by .NET and provided the .NET runtime is
installed on the host. The assemblies coming from dependent packages are
generally co-located with the project's output assembly. cargo build
in Rust does the same, except the Rust compiler
statically links (although there exist other linking options) all
code into a single, platform-dependent, binary.
Developers use dotnet publish
to prepare a .NET executable for distribution,
either as a framework-dependent deployment (FDD) or self-contained
deployment (SCD). In Rust, there is no equivalent to dotnet publish
as the
build output already contains a single, platform-dependent binary for each
target.
When building a library in .NET using dotnet build
, it
will still generate an assembly containing the IL. In Rust, the build output
is, again, a platform-dependent, compiled library for each library target.
See also:
Dependencies
In .NET, the contents of a project file define the build options and
dependencies. In Rust, when using Cargo, a Cargo.toml
declares the
dependencies for a package. A typical project file will look like:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="morelinq" Version="3.3.2" />
</ItemGroup>
</Project>
The equivalent Cargo.toml
in Rust is defined as:
[package]
name = "hello_world"
version = "0.1.0"
[dependencies]
tokio = "1.0.0"
Cargo follows a convention that src/main.rs
is the crate root of a binary
crate with the same name as the package. Likewise, Cargo knows that if the
package directory contains src/lib.rs
, the package contains a library crate
with the same name as the package.
Packages
NuGet is most commonly used to install packages, and various tools supported it. For example, adding a NuGet package reference with the .NET CLI will add the dependency to the project file:
dotnet add package morelinq
In Rust this works almost the same if using Cargo to add packages.
cargo add tokio
The most common package registry for .NET is nuget.org whereas Rust packages are usually shared via crates.io.
Static code analysis
Since .NET 5, the Roslyn analyzers come bundled with the .NET SDK and provide code quality as well as code-style analysis. The equivalent linting tool in Rust is Clippy.
Similarly to .NET, where the build fails if warnings are present by setting
TreatWarningsAsErrors
to true
, Clippy can fail
if the compiler or Clippy emits warnings (cargo clippy -- -D warnings
).
There are further static checks to consider adding to a Rust CI pipeline:
- Run
cargo doc
to ensure that documentation is correct. - Run
cargo check --locked
to enforce that theCargo.lock
file is up-to-date.