Introduction
This is a (non-comprehensive) guide for C# and .NET developers that are completely new to the Rust programming language. Some concepts and constructs translate fairly well between C#/.NET and Rust, but which may be expressed differently, whereas others are a radical departure, like memory management. This guide provides a brief comparison and mapping of those constructs and concepts with concise examples.
The original authors1 of this guide were themselves C#/.NET developers who were completely new to Rust. This guide is the compilation of the knowledge acquired by the authors writing Rust code over the course of several months. It is the guide the authors wish they had when they started on their Rust journey. That said, the authors would encourage you to read books and other material available on the Web to embrace Rust and its idioms rather than attempting to learn it exclusively through the lens of C# and .NET. Meanwhile, this guide can help answers some question quickly, like: Does Rust support inheritance, threading, asynchronous programming, etc.?
Assumptions:
- Reader is a seasoned C#/.NET developer.
- Reader is completely new to Rust.
Goals:
- Provide a brief comparison and mapping of various C#/.NET topics to their counterparts in Rust.
- Provide links to Rust reference, book and articles for further reading on topics.
Non-goals:
- Discussion of design patterns and architectures.
- Tutorial on the Rust language.
- Reader is proficient in Rust after reading this guide.
- While there are short examples that contrast C# and Rust code for some topics, this guide is not meant to be a cookbook of coding recipes in the two languages.
The original authors of this guide were (in alphabetical order): Atif Aziz, Bastian Burger, Daniele Antonio Maggio, Dariusz Parys and Patrick Schuler.
License
Copyright © Microsoft Corporation.
Portions Copyright © 2010 The Rust Project Developers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE
Contributing
You are invited to contribute 💖 to this guide by opening issues and submitting pull requests!
Here are some ideas 💡 for how and where you can help most with contributions:
-
Fix any spelling or grammatical mistakes you see as you read.
-
Fix technical inaccuracies.
-
Fix logical or compilation errors in code examples.
-
Improve the English, especially if it's your native tongue or you have excellent proficiency in the language.
-
Expand an explanation to provide more context or improve the clarity of some topic or concept.
-
Keep it fresh with changes in C#, .NET and Rust. For example, if there is a change in C# or Rust that brings the two languages closer together then some parts, including sample code, may need revision.
If you're making a small to modest correction, such fixing a spelling error or a syntax error in a code example, then feel free to submit a pull request directly. For changes that may require a large effort on your part (and reviewers as a result), it is strongly recommended that you submit an issue and seek approval of the maintainers/editors before investing your time. It will avoid heartbreak 💔 if the pull request is rejected for various reasons.
Making quick contributions has been made super simple. If you see an error on a page and happen to be online, you can click edit icon 📝 in the corner of the page to edit the Markdown source of the content and submit a change.
Contribution Guidelines
-
Stick to the goals of this guide laid out in the introduction; put another way, avoid the non-goals!
-
Prefer to keep text short and use short, concise and realistic code examples to illustrate a point.
-
As much as it is possible, always provide and compare examples in Rust and C#.
-
Feel free to use latest C#/Rust language features if it makes an example simpler, concise and alike across the two languages.
-
Avoid using community packages in C# examples. Stick to the .NET Base Class Library as much as possible. Since the Rust Standard Library has a much smaller API surface, it is more acceptable to call out crates for some functionality, should it be necessary for illustration (like
rand
for random number generation), but make sure they are mature, popular and trusted. -
Make example code as self-contained as possible and runnable (unless the idea is to illustrate a compile-time or run-time error).
-
Maintain the general style of this guide, which is to avoid using you as if the reader is being told or instructed; use the third-person voice instead. For example, instead of saying, “You represent optional data in Rust with the
Option<T>
type”, write instead, “Rust has theOption<T>
type that is used to represent optional data”.
Getting Started
Rust Playground
The easiest way to get started with Rust without needing any local installation is to use the Rust Playground. It is a minimal development front-end that runs in the Web browser and allows writing and running Rust code.
Dev Container
The execution environment of the Rust Playground has some limitations, such as total compilation/execution time, memory and networking so another option that does not require installing Rust would be to use a dev container, such as the one provided in the repository https://github.com/microsoft/vscode-remote-try-rust. Like Rust Playground, the dev container can be run directly in a Web browser using GitHub Codespaces or locally using Visual Studio Code.
Local Install
For a complete local installation of Rust compiler and its development tools, see the Installation section of the Getting Started chapter in the The Rust Programming Language book, or the Install page at rust-lang.org.
Language
This sections compares C# and Rust language features.
Scalar Types
The following table lists the primitive types in Rust and their equivalent in C# and .NET:
Rust | C# | .NET | Note |
---|---|---|---|
bool | bool | Boolean | |
char | char | Char | See note 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 or ValueTuple | See notes 2 & 3. |
object | Object | See note 3. |
Notes:
-
char
in Rust andChar
in .NET have different definitions. In Rust, achar
is 4 bytes wide that is a Unicode scalar value, but in .NET, aChar
is 2 bytes wide and stores the character using the UTF-16 encoding. For more information, see the Rustchar
documentation. -
While a unit
()
(an empty tuple) in Rust is an expressible value, the closest cousin in C# would bevoid
to represent nothing. However,void
isn't an expressible value except when using pointers and unsafe code. .NET hasValueTuple
that is an empty tuple, but C# does not have a literal syntax like()
to represent it.ValueTuple
can be used in C#, but it's very uncommon. Unlike C#, F# does have a unit type like Rust. -
While
void
andobject
are not scalar types (even though scalars likeint
are sub-classes ofobject
in the .NET type hierarchy), they have been included in the above table for convenience.
See also:
Strings
There are two string types in Rust: String
and &str
. The former is
allocated on the heap and the latter is a slice of a String
or a &str
.
The mapping of those to .NET is shown in the following table:
Rust | .NET | Note |
---|---|---|
&mut str | Span<char> | |
&str | ReadOnlySpan<char> | |
Box<str> | String | see Note 1. |
String | String | |
String (mutable) | StringBuilder | see Note 1. |
There are differences in working with strings in Rust and .NET, but the
equivalents above should be a good starting point. One of the differences is
that Rust strings are UTF-8 encoded, but .NET strings are UTF-16 encoded.
Further .NET strings are immutable, but Rust strings can be mutable when declared
as such, for example let s = &mut String::from("hello");
.
There are also differences in using strings due to the concept of ownership. To read more about ownership with the String Type, see the Rust Book.
Notes:
- The
Box<str>
type in Rust is equivalent to theString
type in .NET. The difference between theBox<str>
andString
types in Rust is that the former stores pointer and size while the latter stores pointer, size, and capacity, allowingString
to grow in size. This is similar to theStringBuilder
type in .NET once the RustString
is declared 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 Literals
String literals in .NET are immutable String
types and allocated on the heap.
In Rust, they are &'static str
, which is immutable and has a global lifetime
and does not get allocated on the heap; they're embedded in the compiled binary.
C#
string str = "Hello, World!";
Rust
let str: &'static str = "Hello, World!";
C# verbatim string literals are equivalent to Rust raw string literals.
C#
string str = @"Hello, \World/!";
Rust
let str = r#"Hello, \World/!"#;
C# UTF-8 string literals are equivalent to Rust byte string literals.
C#
ReadOnlySpan<byte> str = "hello"u8;
Rust
let str = b"hello";
String Interpolation
C# has a built-in string interpolation feature that allows you to embed expressions inside a string literal. The following example shows how to use string interpolation in C#:
string name = "John";
int age = 42;
string str = $"Person {{ Name: {name}, Age: {age} }}";
Rust does not have a built-in string interpolation feature. Instead, the
format!
macro is used to format a string. The following example shows how to
use string interpolation in Rust:
let name = "John";
let age = 42;
let str = format!("Person {{ name: {name}, age: {age} }}");
Custom classes and structs can also be interpolated in C# due to the fact that
the ToString()
method is available for each type as it inherits from 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);
In Rust, there is no default formatting implemented/inherited for each type.
Instead, the std::fmt::Display
trait must be implemented for each type that
needs to be converted to a string.
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}");
Another option is to use the std::fmt::Debug
trait. The Debug
trait is
implemented for all standard types and can be used to print the internal
representation of a type. The following example shows how to use the derive
attribute to print the internal representation of a custom struct using the
Debug
macro. This declaration is used to automatically implement the Debug
trait for the Person
struct:
#[derive(Debug)]
struct Person {
name: String,
age: i32,
}
let person = Person {
name: "John".to_owned(),
age: 42,
};
println!("{person:?}");
Note: Using the :? format specifier will use the
Debug
trait to print the struct, where leaving it out will use theDisplay
trait.
See also:
Structured Types
Commonly used object and collection types in .NET and their mapping to Rust
C# | Rust |
---|---|
Array | Array |
List | Vec |
Tuple | Tuple |
Dictionary | HashMap |
Array
Fixed arrays are supported the same way in Rust as in .NET
C#:
int[] someArray = new int[2] { 1, 2 };
Rust:
let someArray: [i32; 2] = [1,2];
List
In Rust the equivalent of a List<T>
is a Vec<T>
. Arrays can be converted
to Vecs and vice versa.
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());
Tuples
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);
// deconstruction supported
let (a, b) = something;
println!("a = {} b = {}", a, b);
NOTE: Rust tuple elements cannot be named like in C#. The only way to access a tuple element is by using the index of the element or deconstructing the tuple.
Dictionary
In Rust the equivalent of a Dictionary<TKey, TValue>
is a 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());
See also:
Custom Types
The following sections discuss various topics and constructs related to developing custom types:
Classes
Rust doesn't have classes. It only has structures or struct
.
Records
Rust doesn't have any construct for authoring records, neither like record struct
nor record class
in C#.
Structures (struct
)
Structures in Rust and C# share a few similarities:
-
They are defined with the
struct
keyword, but in Rust,struct
simply defines the data/fields. The behavioural aspects in terms of functions and methods, are defined separately in an implementation block (impl
). -
They can implement multiple traits in Rust just as they can implement multiple interfaces in C#.
-
They cannot be sub-classed.
-
They are allocated on stack by default, unless:
- In .NET, boxed or cast to an interface.
- In Rust, wrapped in a smart pointer like
Box
,Rc
/Arc
.
In C#, a struct
is a way to model a value type in .NET, which is typically
some domain-specific primitive or compound with value equality semantics. In
Rust, a struct
is the primary construct for modeling any data structure (the
other being an enum
).
A struct
(or record struct
) in C# has copy-by-value and value equality
semantics by default, but in Rust, this requires just one more step using the
#derive
attribute and listing the traits to be implemented:
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct Point {
x: i32,
y: i32,
}
Value types in C#/.NET are usually designed by a developer to be immutable.
It's considered best practice speaking semantically, but the language does not
prevent designing a struct
that makes destructive or in-place modifications.
In Rust, it's the same. A type has to be consciously developed to be
immutable.
Since Rust doesn't have classes and consequently type hierarchies based on sub-classing, shared behaviour is achieved via traits and generics and polymorphism via virtual dispatch using trait objects.
Consider following struct
representing a rectangle in 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})";
}
The equivalent in Rust would be:
#![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)
}
}
Note that a struct
in C# inherits the ToString
method from object
and
therefore it overrides the base implementation to provide a custom string
representation. Since there is no inheritance in Rust, the way a type
advertises support for some formatted representation is by implementing the
Display
trait. This then enables for an instance of the structure to
participate in formatting, such as shown in the call to println!
below:
fn main() {
let rect = Rectangle::new(12, 34, 56, 78);
println!("Rectangle = {rect}");
}
Interfaces
Rust doesn't have interfaces like those found in C#/.NET. It has traits, instead. Similar to an interface, a trait represents an abstraction and its members form a contract that must be fulfilled when implemented on a type.
Just the way interfaces can have default methods in C#/.NET (where a default implementation body is provided as part of the interface definition), so can traits in Rust. The type implementing the interface/trait can subsequently provide a more suitable and/or optimized implementation.
C#/.NET interfaces can have all types of members, from properties, indexers, events to methods, both static- and instance-based. Likewise, traits in Rust can have (instance-based) method, associated functions (think static methods in C#/.NET) and constants.
Apart from class hierarchies, interfaces are a core means of achieving
polymorphism via dynamic dispatch for cross-cutting abstractions. They enable
general-purpose code to be written against the abstractions represented by the
interfaces without much regard to the concrete types implementing them. The
same can be achieved with Rust's trait objects in a limited fashion. A trait
object is essentially a v-table (virtual table) identified with the dyn
keyword followed by the trait name, as in dyn Shape
(where Shape
is the
trait name). Trait objects always live behind a pointer, either a reference
(e.g. &dyn Shape
) or the heap-allocated Box
(e.g. Box<dyn Shape>
). This
is somewhat like in .NET, where an interface is a reference type such that a
value type cast to an interface is automatically boxed onto the managed heap.
The passing limitation of trait objects mentioned earlier, is that the original
implementing type cannot be recovered. In other words, whereas it's quite
common to downcast or test an interface to be an instance of some other
interface or sub- or concrete type, the same is not possible in Rust (without
additional effort and support).
Enumeration types (enum
)
In C#, an enum
is a value type that maps symbolic names to integral values:
enum DayOfWeek
{
Sunday = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6,
}
Rust has practically identical syntax for doing the same:
enum DayOfWeek
{
Sunday = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6,
}
Unlike in .NET, an instance of an enum
type in Rust does not have any
pre-defined behaviour that's inherited. It cannot even participate in equality
checks as simple as dow == DayOfWeek::Friday
. To bring it somewhat on par in
function with an enum
in C#, use the #derive
attribute to
automatically have macros implement the commonly needed functionality:
#[derive(Debug, // enables formatting in "{:?}"
Clone, // required by Copy
Copy, // enables copy-by-value semantics
Hash, // enables hash-ability for use in map types
PartialEq // enables value equality (==)
)]
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:?}");
}
As the example above shows, an enum
can be coerced to its assigned integral
value, but the opposite is not possible as in C# (although that sometimes has
the downside in C#/.NET that an enum
instance can hold an unrepresented
value). Instead, it's up to the developer to provide such a helper function:
impl DayOfWeek {
fn try_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)
}
}
}
The try_from_i32
function returns a DayOfWeek
in a Result
indicating success
(Ok
) if n
is valid. Otherwise it returns n
as-is in a Result
indicating failure (Err
):
let dow = DayOfWeek::try_from_i32(5);
println!("{dow:?}"); // prints: Ok(Friday)
let dow = DayOfWeek::try_from_i32(50);
println!("{dow:?}"); // prints: Err(50)
There exist crates in Rust that can help with implementing such mapping from integral types instead of having to code them manually.
An enum
type in Rust can also serve as a way to design (discriminated) union
types, which allow different variants to hold data specific to each variant.
For example:
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
This form of enum
declaration does not exist in C#, but it can be emulated
with (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;
}
The difference between the two is that the Rust definition produces a closed
type over the variants. In other words, the compiler knows that there will be
no other variants of IpAddr
except IpAddr::V4
and IpAddr::V6
, and it can
use that knowledge to make stricter checks. For example, in a match
expression that's akin to C#'s switch
expression, the Rust compiler will
fail code unless all variants are covered. In contrast, the emulation with C#
actually creates a class hierarchy (albeit very succinctly expressed) and
since IpAddr
is an abstract base class, the set of all types it can
represent is unknown to the compiler.
Members
Constructors
Rust does not have any notion of constructors. Instead, you just write factory
functions that return an instance of the type. The factory functions can be
stand-alone or associated functions of the type. In C# terms, associated
functions are like having static methods on a type. Conventionally, if there
is just one factory function for a struct
, it's named 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 }
}
}
Since Rust functions (associated or otherwise) do not support overloading; the
factory functions have to be named uniquely. For example, below are some
examples of so-called constructors or factory functions available on String
:
String::new
: creates an empty string.String::with_capacity
: creates a string with an initial buffer capacity.String::from_utf8
: creates a string from bytes of UTF-8 encoded text.String::from_utf16
: creates a string from bytes of UTF-16 encoded text.
In the case of an enum
type in Rust, the variants act as the constructors.
See the section on enumeration types for more.
See also:
Methods (static & instance-based)
Like C#, Rust types (both enum
and struct
), can have static and
instance-based methods. In Rust-speak, a method is always instance-based and
is identified by the fact that its first parameter is named self
. The self
parameter has no type annotation since it's always the type to which the
method belongs. A static method is called an associated function. In the
example below, new
is an associated function and the rest (length
, width
and area
) are methods of the type:
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()
}
}
Constants
Like in C#, a type in Rust can have constants. However, the most interesting aspect to note is that Rust allows a type instance to be defined as a constant too:
struct Point {
x: i32,
y: i32,
}
impl Point {
const ZERO: Point = Point { x: 0, y: 0 };
}
In C#, the same would require a static read-only field:
readonly record struct Point(int X, int Y)
{
public static readonly Point Zero = new(0, 0);
}
Events
Rust has no built-in support for type members to adverstise and fire events,
like C# has with the event
keyword.
Properties
In C#, fields of a type are generally private. They are then
protected/encapsulated by property members with accessor methods (get
and
set
) to read or write to those field. The accessor methods can contain extra
logic, for example, to either validate the value when being set or compute a
value when being read. Rust only has methods where a getter is named after the
field (in Rust method names can share the same identifier as a field) and the
setter uses a set_
prefix.
Below is an example showing how property-like accessor methods typically look for a type in 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 }
}
// like property getters (each shares the same name as the field)
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 }
// like property setters
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 }
// like computed properties
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()
}
}
Extension Methods
Extension methods in C# enable the developer to attach new statically-bound
methods to existing types, without needing to modify the original definition
of the type. In the following C# example, a new Wrap
method is added to the
StringBuilder
class by extension:
using System;
using System.Text;
using Extensions; // (1)
var sb = new StringBuilder("Hello, World!");
sb.Wrap(">>> ", " <<<"); // (2)
Console.WriteLine(sb.ToString()); // Prints: >>> Hello, World! <<<
namespace Extensions
{
static class StringBuilderExtensions
{
public static void Wrap(this StringBuilder sb,
string left, string right) =>
sb.Insert(0, left).Append(right);
}
}
Note that for an extension method to become available (2), the namespace with
the type containing the extension method must be imported (1). Rust offers a
very similar facility via traits, called extension traits. The following
example in Rust is the equivalent of the C# example above; it extends String
with the method 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! <<<
}
Just like in C#, for the method in the extension trait to become available
(2), the extension trait must be imported (1). Also note, the extension trait
identifier StrWrapExt
can itself be discarded via _
at the time of import
without affecting the availability of wrap
for String
.
Visibility/Access modifiers
C# has a number of accessibility or visibility modifiers:
private
protected
internal
protected internal
(family)public
In Rust, a compilation is built-up of a tree of modules where modules contain
and define items like types, traits, enums, constants and
functions. Almost everything is private by default. One exception is, for
example, associated items in a public trait, which are public by default.
This is similar to how members of a C# interface declared without any public
modifiers in the source code are public by default. Rust only has the pub
modifier to change the visibility with respect to the module tree. There
are variations of pub
that change the scope of the public visibility:
pub(self)
pub(super)
pub(crate)
pub(in PATH)
For more details, see the Visibility and Privacy section of The Rust Reference.
The table below is an approximation of the mapping of C# and Rust modifiers:
C# | Rust | Note |
---|---|---|
private | (default) | See note 1. |
protected | N/A | See note 2. |
internal | pub(crate) | |
protected internal (family) | N/A | See note 2. |
public | pub |
-
There is no keyword to denote private visibility; it's the default in Rust.
-
Since there are no class-based type hierarchies in Rust, there is no equivalent of
protected
.
Mutability
When designing a type in C#, it is the responsiblity of the developer to
decide whether the a type is mutable or immutable; whether it supports
destructive or non-destructive mutations. C# does support an immutable design
for types with a positional record declaration (record class
or readonly record struct
). In Rust, mutability is expressed on methods through the type
of the self
parameter as shown in the example below:
struct Point { x: i32, y: i32 }
impl Point {
pub fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
// self is not mutable
pub fn x(&self) -> i32 { self.x }
pub fn y(&self) -> i32 { self.y }
// self is mutable
pub fn set_x(&mut self, val: i32) { self.x = val }
pub fn set_y(&mut self, val: i32) { self.y = val }
}
In C#, you can do non-destructive mutations using with
:
var pt = new Point(123, 456);
pt = pt with { X = 789 };
Console.WriteLine(pt.ToString()); // prints: Point { X = 789, Y = 456 }
readonly record struct Point(int X, int Y);
There is no with
in Rust, but to emulate something similar in Rust, it has
to be baked into the type's design:
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 }
// following methods consume self and return a new instance
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) }
}
In C#, with
can also be used with a regular (as opposed to record) struct
that publicly exposes its read-write fields:
struct Point
{
public int X;
public int Y;
public override string ToString() => $"({X}, {Y})";
}
var pt = new Point { X = 123, Y = 456 };
Console.WriteLine(pt.ToString()); // prints: (123, 456)
pt = pt with { X = 789 };
Console.WriteLine(pt.ToString()); // prints: (789, 456)
Rust has a struct update syntax that may seem similar:
mod points {
#[derive(Debug)]
pub struct Point { pub x: i32, pub y: i32 }
}
fn main() {
use points::Point;
let pt = Point { x: 123, y: 456 };
println!("{pt:?}"); // prints: Point { x: 123, y: 456 }
let pt = Point { x: 789, ..pt };
println!("{pt:?}"); // prints: Point { x: 789, y: 456 }
}
However, while with
in C# does a non-destructive mutation (copy then
update), the struct update syntax does (partial) moves and works with
fields only. Since the syntax requires access to the type's fields, it is
generally more common to use it within the Rust module that has access to
private details of its types.
Local Functions
C# and Rust offer local functions, but local functions in Rust are limited to the equivalent of static local functions in C#. In other words, local functions in Rust cannot use variables from their surrounding lexical scope; but closures can.
Lambda and Closures
C# and Rust allow functions to be used as first-class values that enable
writing higher-order functions. Higher-order functions are essentially
functions that accept other functions as arguments to allow for the caller to
participate in the code of the called function. In C#, type-safe function
pointers are represented by delegates with the most common ones being Func
and Action
. The C# language allows ad-hoc instances of these delegates to be
created through lambda expressions.
Rust has function pointers too with the fn
type being the simplest:
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
}
However, Rust makes a distinction between function pointers (where fn
defines a type) and closures: a closure can reference variables from its
surrounding lexical scope, but not a function pointer. While C# also has
function pointers (*delegate
), the managed and type-safe
equivalent would be a static lambda expression.
Functions and methods that accept closures are written with generic types that
are bound to one of the traits representing functions: Fn
, FnMut
and
FnOnce
. When it's time to provide a value for a function pointer or a
closure, a Rust developer uses a closure expression (like |x| x + 1
in the
example above), which translates to the same as a lambda expression in C#.
Whether the closure expression creates a function pointer or a closure depends
on whether the closure expression references its context or not.
When a closure captures variables from its environment then ownership rules come into play because the ownership ends up with the closure. For more information, see the “Moving Captured Values Out of Closures and the Fn Traits” section of The Rust Programming Language.
Variables
Consider the following example around variable assignment in C#:
int x = 5;
And the same in Rust:
let x: i32 = 5;
So far, the only visible difference between the two languages is that the position of the type declaration is different. Also, both C# and Rust are type-safe: the compiler guarantees that the value stored in a variable is always of the designated type. The example can be simplified by using the compiler's ability to automatically infer the types of the variable. In C#:
var x = 5;
In Rust:
let x = 5;
When expanding the first example to update the value of the variable (reassignment), the behavior of C# and Rust differ:
var x = 5;
x = 6;
Console.WriteLine(x); // 6
In Rust, the identical statement will not compile:
let x = 5;
x = 6; // Error: cannot assign twice to immutable variable 'x'.
println!("{}", x);
In Rust, variables are immutable by default. Once a value is bound to a name,
the variable's value cannot be changed. Variables can be made mutable by
adding mut
in front of the variable name:
let mut x = 5;
x = 6;
println!("{}", x); // 6
Rust offers an alternative to fix the example above that does not require mutability through variable shadowing:
let x = 5;
let x = 6;
println!("{}", x); // 6
C# also supports shadowing, e.g. locals can shadow fields and type members can shadow members from the base type. In Rust, the above example demonstrates that shadowing also allows to change the type of a variable without changing the name, which is useful if one wants to transform the data into different types and shapes without having to come up with a distinct name each time.
See also:
- Data races and race conditions for more information around the implications of mutability
- Scope and shadowing
- Memory management for explanations around moving and ownership
Namespaces
Namespaces are used in .NET to organize types, as well as for controlling the scope of types and methods in projects.
In Rust, namespace refers to a different concept. The equivalent of a namespace
in Rust is a module. For both C# and Rust, visibility of items
can be restricted using access modifiers, respectively visibility modifiers. In
Rust, the default visibility is private (with only few exceptions). The
equivalent of C#'s public
is pub
in Rust, and internal
corresponds to
pub(crate)
. For more fine-grained access control, refer to the visibility
modifiers reference.
Equality
When comparing for equality in C#, this refers to testing for equivalence in
some cases (also known as value equality), and in other cases it refers to
testing for reference equality, which tests whether two variables refer to the
same underlying object in memory. Every custom type can be compared for equality
because it inherits from System.Object
(or System.ValueType
for value types,
which inherits from System.Object
), using either one of the abovementioned
semantics.
For example, when comparing for equivalence and reference equality in 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) False
Console.WriteLine(ReferenceEquals(a, b)); // (2) False
Console.WriteLine(ReferenceEquals(a, c)); // (2) True
record Point(int X, int Y);
-
The equality operator
==
and theEquals
method on therecord Point
compare for value equality, since records support value-type equality by default. -
Comparing for reference equality tests whether the variables refer to the same underlying object in memory.
Equivalently in 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)));
}
The compiler error above illustrates that in Rust equality comparisons are
always related to a trait implementation. To support a comparison using ==
,
a type must implement PartialEq
.
Fixing the example above means deriving PartialEq
for Point
. Per default,
deriving PartialEq
will compare all fields for equality, which therefore have
to implement PartialEq
themselves. This is comparable to the equality for
records in 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
}
See also:
Eq
for a stricter version ofPartialEq
.
Generics
Generics in C# provide a way to create definitions for types and methods that can be parameterized over other types. This improves code reuse, type-safety and performance (e.g. avoid run-time casts). Consider the following example of a generic type that adds a timestamp to any value:
using System;
sealed record Timestamped<T>(DateTime Timestamp, T Value)
{
public Timestamped(T value) : this(DateTime.UtcNow, value) { }
}
Rust also has generics as shown by the equivalent of the above:
use std::time::*;
struct Timestamped<T> { value: T, timestamp: SystemTime }
impl<T> Timestamped<T> {
fn new(value: T) -> Self {
Self { value, timestamp: SystemTime::now() }
}
}
See also:
Generic type constraints
In C#, generic types can be constrained using the where
clause. The following example shows such constraints in C#:
using System;
// Note: records automatically implement `IEquatable`. The following
// implementation shows this explicitly for a comparison to 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);
}
The same can be achieved in 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
}
}
Generic type constraints are called bounds in Rust.
In C# version, Timestamped<T>
instances can only be created for T
which
implement IEquatable<T>
themselves, but note that the Rust version is more
flexible because it Timestamped<T>
conditionally implements PartialEq
.
This means that Timestamped<T>
instances can still be created for some
non-equatable T
, but then Timestamped<T>
will not implement equality via
PartialEq
for such a T
.
See also:
Polymorphism
Rust does not support classes and sub-classing therefore polymorphism can't be achieved in an identical manner to C#.
See also:
- Virtual dispatch using trait objects, as explained in the Structures section
- Generics
- Inheritance
- Operator overloading
Inheritance
As explained in structures section, Rust does not provide (class-based) inheritance as in C#. A way to provide shared behavior between structs is via making use of traits. However, similar to interface inheritance in C#, Rust allows to define relationships between traits by using supertraits.
Exception Handling
In .NET, an exception is a type that inherits from the
System.Exception
class. Exceptions are thrown if a
problem occurs in a code section. A thrown exception is passed up the stack
until the application handles it or the program terminates.
Rust does not have exceptions, but distinguishes between recoverable and
unrecoverable errors instead. A recoverable error represents a problem that
should be reported, but for which the program continues. Results of operations
that can fail with recoverable errors are of type Result<T, E>
,
where E
is the type of the error variant. The panic!
macro stops
execution when the program encounters an unrecoverable error. An unrecoverable
error is always a symptom of a bug.
Custom error types
In .NET, custom exceptions derive from the Exception
class. The documentation
on how to create user-defined exceptions mentions
the following example:
public class EmployeeListNotFoundException : Exception
{
public EmployeeListNotFoundException() { }
public EmployeeListNotFoundException(string message)
: base(message) { }
public EmployeeListNotFoundException(string message, Exception inner)
: base(message, inner) { }
}
In Rust, one can implement the basic expectations for error values by
implementing the Error
trait. The minimal user-defined error
implementation in Rust is:
#[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("Could not find employee list.")
}
}
impl std::error::Error for EmployeeListNotFound {}
The equivalent to the .NET Exception.InnerException
property is the
Error::source()
method in Rust. However, it is not required to provide an
implementation for Error::source()
, the blanket (default) implementation
returns a None
.
Raising exceptions
To raise an exception in C#, throw an instance of the exception:
void ThrowIfNegative(int value)
{
if (value < 0)
{
throw new ArgumentOutOfRangeException(nameof(value));
}
}
For recoverable errors in Rust, return an Ok
or Err
variant from a method:
fn error_if_negative(value: i32) -> Result<(), &'static str> {
if value < 0 {
Err("Specified argument was out of the range of valid values. (Parameter 'value')")
} else {
Ok(())
}
}
The panic!
macro creates unrecoverable errors:
fn panic_if_negative(value: i32) {
if value < 0 {
panic!("Specified argument was out of the range of valid values. (Parameter 'value')")
}
}
Error propagation
In .NET, exceptions are passed up the stack until they are handled or the program terminates. In Rust, unrecoverable errors behave similarly, but handling them is uncommon.
Recoverable errors, however, need to be propagated and handled explicitly. Their presence is always indicated by the Rust function or method signature. Catching an exception allows you to take action based on the presence or absence of an error in C#:
void Write()
{
try
{
File.WriteAllText("file.txt", "content");
}
catch (IOException)
{
Console.WriteLine("Writing to file failed.");
}
}
In Rust, this is roughly equivalent to:
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!("Writing to file failed."),
};
}
Frequently, recoverable errors need only be propagated instead of being handled.
For this, the method signature needs to be compatible with the types of the
propagated error. The ?
operator propagates errors
ergonomically:
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(())
}
Note: to propagate an error with the question mark operator the error
implementations need to be compatible, as described in a shortcut for
propagating errors. The most general
"compatible" error type is the error trait object Box<dyn Error>
.
Stack traces
Throwing an unhandled exception in .NET will cause the runtime to print a stack trace that allows debugging the problem with additional context.
For unrecoverable errors in Rust, panic!
Backtraces offer a
similar behavior.
Recoverable errors in stable Rust do not yet support Backtraces, but it is currently supported in experimental Rust when using the provide method.
Nullability and Optionality
In C#, null
is often used to represent a value that is missing, absent or
logically uninitialized. For example:
int? some = 1;
int? none = null;
Rust has no null
and consequently no nullable context to enable. Optional or
missing values are instead represented by Option<T>
. The
equivalent of the C# code above in Rust would be:
let some: Option<i32> = Some(1);
let none: Option<i32> = None;
Option<T>
in Rust is practically identical to 'T option
from F#.
Control flow with optionality
In C#, you may have been using if
/else
statements for controlling the flow
when using nullable values.
uint? max = 10;
if (max is { } someMax)
{
Console.WriteLine($"The maximum is {someMax}."); // The maximum is 10.
}
You can use pattern matching to achieve the same behavior in Rust:
let max = Some(10u32);
match max {
Some(max) => println!("The maximum is {}.", max), // The maximum is 10.
None => ()
}
It would even be more concise to use if let
:
let max = Some(10u32);
if let Some(max) = max {
println!("The maximum is {}.", max); // The maximum is 10.
}
Null-conditional operators
The null-conditional operators (?.
and ?[]
) make dealing with null
in C#
more ergonomic. In Rust, they are best replaced by using the map
method. The following snippets show the correspondence:
string? some = "Hello, World!";
string? none = null;
Console.WriteLine(some?.Length); // 13
Console.WriteLine(none?.Length); // (blank)
let some: Option<String> = Some(String::from("Hello, World!"));
let none: Option<String> = None;
println!("{:?}", some.map(|s| s.len())); // Some(13)
println!("{:?}", none.map(|s| s.len())); // None
Null-coalescing operator
The null-coalescing operator (??
) is typically used to default to another
value when a nullable is null
:
int? some = 1;
int? none = null;
Console.WriteLine(some ?? 0); // 1
Console.WriteLine(none ?? 0); // 0
In Rust, you can use unwrap_or
to get the same behavior:
let some: Option<i32> = Some(1);
let none: Option<i32> = None;
println!("{:?}", some.unwrap_or(0)); // 1
println!("{:?}", none.unwrap_or(0)); // 0
Note: If the default value is expensive to compute, you can use
unwrap_or_else
instead. It takes a closure as an argument, which allows you to
lazily initialize the default value.
Null-forgiving operator
The null-forgiving operator (!
) does not correspond to an equivalent construct
in Rust, as it only affects the compiler's static flow analysis in C#. In Rust,
there is no need to use a substitute for it.
Discards
In C#, discards express to the compiler and others to ignore the results (or parts) of an expression.
There are multiple contexts where to apply this, for example as a basic example, to ignore the result of an expression. In C# this looks like:
_ = city.GetCityInformation(cityName);
In Rust, ignoring the result of an expression looks identical:
_ = city.get_city_information(city_name);
Discards are also applied for deconstructing tuples in C#:
var (_, second) = ("first", "second");
and, identically, in Rust:
let (_, second) = ("first", "second");
In addition to destructuring tuples, Rust offers
destructuring of structs and enums using ..
, where ..
stands for the remaining part of a type:
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
}
When pattern matching, it is often useful to discard or ignore part of a matching expression, e.g. in C#:
_ = ("first", "second") switch
{
("first", _) => "first element matched",
(_, _) => "first element did not match"
};
and again, this looks almost identical in Rust:
_ = match ("first", "second")
{
("first", _) => "first element matched",
(_, _) => "first element did not match"
};
Conversion and Casting
Both C# and Rust are statically-typed at compile time. Hence, after a variable is declared, assigning a value of a value of a different type (unless it's implicitly convertible to the target type) to the variable is prohibited. There are several ways to convert types in C# that have an equivalent in Rust.
Implicit conversions
Implicit conversions exist in C# as well as in Rust (called type coercions). Consider the following example:
int intNumber = 1;
long longNumber = intNumber;
Rust is much more restrictive with respect to which type coercions are allowed:
let int_number: i32 = 1;
let long_number: i64 = int_number; // error: expected `i64`, found `i32`
An example for a valid implicit conversion using subtyping is:
fn bar<'a>() {
let s: &'static str = "hi";
let t: &'a str = s;
}
See also:
Explicit conversions
If converting could cause a loss of information, C# requires explicit conversions using a casting expression:
double a = 1.2;
int b = (int)a;
Explicit conversions can potentially fail at run-time with exceptions like
OverflowException
or InvalidCastException
when down-casting.
Rust does not provide coercion between primitive types, but instead uses
explicit conversion using the as
keyword (casting).
Casting in Rust will not cause a panic.
let int_number: i32 = 1;
let long_number: i64 = int_number as _;
Custom conversion
Commonly, .NET types provide user-defined conversion operators to convert one
type to another type. Also, System.IConvertible
serves the purpose of
converting one type into another.
In Rust, the standard library contains an abstraction for converting a value
into a different type, in form of the From
trait and its
reciprocal, Into
. When implementing From
for a type, a default
implementation for Into
is automatically provided (called blanket
implementation in Rust). The following example illustrates two of such type
conversions:
fn main() {
let my_id = MyId("id".into()); // `into()` is implemented automatically due to the `From<&str>` trait implementation for `String`.
println!("{}", String::from(my_id)); // This uses the `From<MyId>` implementation for `String`.
}
struct MyId(String);
impl From<MyId> for String {
fn from(MyId(value): MyId) -> Self {
value
}
}
See also:
Operator overloading
A custom type can overload an overloadable operator in C#. Consider the following example in 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}";
}
In Rust, many operators can be overloaded via traits. This is possible
because operators are syntactic sugar for method calls. For example, the +
operator in a + b
calls the add
method (see operator overloading):
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
}
Documentation Comments
C# provides a mechanism to document the API for types using a comment syntax that contains XML text. The C# compiler produces an XML file that contains structured data representing the comments and the API signatures. Other tools can process that output to provide human-readable documentation in a different form. A simple example in C#:
/// <summary>
/// This is a document comment for <c>MyClass</c>.
/// </summary>
public class MyClass {}
In Rust doc comments provide the equivalent to C# documentation comments.
Documentation comments in Rust use Markdown syntax. rustdoc
is the
documentation compiler for Rust code and is usually invoked through cargo doc
, which compiles the comments into documentation. For example:
/// This is a doc comment for `MyStruct`.
struct MyStruct;
In the .NET SDK there is no equivalent to cargo doc
, such as dotnet doc
.
See also:
Memory Management
Like C# and .NET, Rust has memory-safety to avoid a whole class of bugs
related to memory access, and which end up being the source of many security
vulnerabilities in software. However, Rust can guarantee memory-safety at
compile-time; there is no run-time (like the CLR) making checks. The one
exception here is array bound checks that are done by the compiled code at
run-time, be that the Rust compiler or the JIT compiler in .NET. Like C#, it
is also possible to write unsafe code in Rust, and in fact,
both languages even share the same keyword, literally unsafe
, to mark
functions and blocks of code where memory-safety is no longer guaranteed.
Rust has no garbage collector (GC). All memory management is entirely the responsibility of the developer. That said, safe Rust has rules around ownership that ensure memory is freed as soon as it's no longer in use (e.g. when leaving the scope of a block or a function). The compiler does a tremendous job, through (compile-time) static analysis, of helping manage that memory through ownership rules. If violated, the compiler rejects the code with a compilation error.
In .NET, there is no concept of ownership of memory beyond the GC roots (static fields, local variables on a thread's stack, CPU registers, handles, etc.). It is the GC that walks from the roots during a collection to detemine all memory in use by following references and purging the rest. When designing types and writing code, a .NET developer can remain oblivious to ownership, memory management and even how the garbage collector works for the most part, except when performance-sensitive code requires paying attention to the amount and rate at which objects are being allocated on the heap. In contrast, Rust's ownership rules require the developer to explicitly think and express ownership at all times and it impacts everything from the design of functions, types, data structures to how the code is written. On top of that, Rust has strict rules about how data is used such that it can identify at compile-time, data race conditions as well as corruption issues (requiring thread-safety) that could potentially occur at run-time. This section will only focus on memory management and ownership.
There can only be one owner of some memory, be that on the stack or heap, backing a structure at any given time in Rust. The compiler assigns lifetimes and tracks ownership. It is possible to pass or yield ownership, which is called moving in Rust. These ideas are briefly illustrated in the example Rust code below:
#![allow(dead_code, unused_variables)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let a = Point { x: 12, y: 34 }; // point owned by a
let b = a; // b owns the point now
println!("{}, {}", a.x, a.y); // compiler error!
}
The first statement in main
will allocate Point
and that memory will be
owned by a
. In the second statement, the ownership is moved from a
to b
and a
can no longer be used because it no longer owns anything or represents
valid memory. The last statement that tries to print the fields of the point
via a
will fail compilation. Suppose main
is fixed to read as follows:
fn main() {
let a = Point { x: 12, y: 34 }; // point owned by a
let b = a; // b owns the point now
println!("{}, {}", b.x, b.y); // ok, uses b
} // point behind b is dropped
Note that when main
exits, a
and b
will go out of scope. The memory
behind b
will be released by virtue of the stack returning to its state
prior to main
being called. In Rust, one says that the point behind b
was
dropped. However, note that since a
yielded its ownership of the point to
b
, there is nothing to drop when a
goes out of scope.
A struct
in Rust can define code to execute when an instance is dropped by
implementing the Drop
trait.
The rough equivalent of dropping in C# would be a class finalizer, but
while a finalizer is called automatically by the GC at some future point,
dropping in Rust is always instantaneous and deterministic; that is, it
happens at the point the compiler has determined that an instance has no owner
based on scopes and lifetimes. In .NET, the equivalent of Drop
would be
IDisposable
and is implemented by types to release any
unmanaged resources or memory they hold. Deterministic disposal is not
enforced or guaranteed, but the using
statement in C# is typically used to
scope an instance of a disposable type such that it gets disposed
determinstically, at the end of the using
statement's block.
Rust has the notion of a global lifetime denoted by 'static
, which is a
reserved lifetime specifier. A very rough approximation in C# would be static
read-only fields of types.
In C# and .NET, references are shared freely without much thought so the idea
of a single owner and yielding/moving ownership may seem very limiting in
Rust, but it is possible to have shared ownership in Rust using the smart
pointer type Rc
; it adds reference-counting. Each time the smart
pointer is cloned, the reference count is incremented. When the
clone drops, the reference count is decremented. The actual instance behind
the smart pointer is dropped when the reference count reaches zero. These
points are illustrated by the following examples that build on the previous:
#![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 dropped!");
}
}
fn main() {
let a = Rc::new(Point { x: 12, y: 34 });
let b = Rc::clone(&a); // share with b
println!("a = {}, {}", a.x, a.y); // okay to use a
println!("b = {}, {}", b.x, b.y);
}
// prints:
// a = 12, 34
// b = 12, 34
// Point dropped!
Note that:
-
Point
implements thedrop
method of theDrop
trait and prints a message when an instance of aPoint
is dropped. -
The point created in
main
is wrapped behind the smart pointerRc
and so the smart pointer owns the point and nota
. -
b
gets a clone of the smart pointer that effectively increments the reference count to 2. Unlike the earlier example, wherea
transferred its ownership of point tob
, botha
andb
own their own distinct clones of the smart pointer, so it is okay to continue to usea
andb
. -
The compiler will have determined that
a
andb
go out of scope at the end ofmain
and therefore injected calls to drop each. TheDrop
implementation ofRc
will decrement the reference count and also drop what it owns if the reference count has reached zero. When that happens, theDrop
implementation ofPoint
will print the message, “Point dropped!” The fact that the message is printed once demonstrates that only one point was created, shared and dropped.
Rc
is not thread-safe. For shared ownership in a multi-threaded program, the
Rust standard library offers Arc
instead. The Rust language will
prevent the use of Rc
across threads.
In .NET, value types (like enum
and struct
in C#) live on the stack and
reference types (interface
, record class
and class
in C#) are
heap-allocated. In Rust, the kind of type (basically enum
or struct
in
Rust), does not determine where the backing memory will eventually live. By
default, it is always on the stack, but just the way .NET and C# have a notion
of boxing value types, which copies them to the heap, the way to allocate a
type on the heap is to box it using Box
:
let stack_point = Point { x: 12, y: 34 };
let heap_point = Box::new(Point { x: 12, y: 34 });
Like Rc
and Arc
, Box
is a smart pointer, but unlike Rc
and Arc
, it
exclusively owns the instance behind it. All of these smart pointers allocate
an instance of their type argument T
on the heap.
The new
keyword in C# creates an instance of a type, and while members such
as Box::new
and Rc::new
that you see in the examples may seem to have a
similar purpose, new
has no special designation in Rust. It's merely a
coventional name that is meant to denote a factory. In fact they are called
associated functions of the type, which is Rust's way of saying static
methods.
Resource Management
Previous section on memory management explains the differences between .NET and Rust when it comes to GC, ownership and finalizers. It is highly recommended to read it.
This section is limited to providing an example of a fictional database connection involving a SQL connection to be properly closed/disposed/dropped
{
using var db1 = new DatabaseConnection("Server=A;Database=DB1");
using var db2 = new DatabaseConnection("Server=A;Database=DB2");
// ...code using "db1" and "db2"...
} // "Dispose" of "db1" and "db2" called here; when their scope ends
public class DatabaseConnection : IDisposable
{
readonly string connectionString;
SqlConnection connection; //this implements IDisposable
public DatabaseConnection(string connectionString) =>
this.connectionString = connectionString;
public void Dispose()
{
//Making sure to dispose the SqlConnection
this.connection.Dispose();
Console.WriteLine("Closing connection: {this.connectionString}");
}
}
struct DatabaseConnection(&'static str);
impl DatabaseConnection {
// ...functions for using the database connection...
}
impl Drop for DatabaseConnection {
fn drop(&mut self) {
// ...closing connection...
self.close_connection();
// ...printing a message...
println!("Closing connection: {}", 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...
} // "Dispose" of "db1" and "db2" called here; when their scope ends
In .NET, attempting to use an object after calling Dispose
on it will typically
cause ObjectDisposedException
to be thrown at runtime. In Rust, the compiler
ensures at compile-time that this cannot happen.
Threading
The Rust standard library supports threading, synchronisation and concurrency. Also the language itself and the standard library do have basic support for the concepts, a lot of additional functionality is provided by crates and will not be covered in this document.
The following lists approximate mapping of threading types and methods in .NET to 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 |
Launching a thread and waiting for it to finish works the same way in C#/.NET and Rust. Below is a simple C# program that creates a thread (where the thread prints some text to standard output) and then waits for it to end:
using System;
using System.Threading;
var thread = new Thread(() => Console.WriteLine("Hello from a thread!"));
thread.Start();
thread.Join(); // wait for thread to finish
The same code in Rust would be as follows:
use std::thread;
fn main() {
let thread = thread::spawn(|| println!("Hello from a thread!"));
thread.join().unwrap(); // wait for thread to finish
}
Creating and initializing a thread object and starting a thread are two
different actions in .NET whereas in Rust both happen at the same time with
thread::spawn
.
In .NET, it's possible to send data as an argument to a thread:
#nullable enable
using System;
using System.Text;
using System.Threading;
var t = new Thread(obj =>
{
var data = (StringBuilder)obj!;
data.Append(" World!");
});
var data = new StringBuilder("Hello");
t.Start(data);
t.Join();
Console.WriteLine($"Phrase: {data}");
However, a more modern or terser version would use closures:
using System;
using System.Text;
using System.Threading;
var data = new StringBuilder("Hello");
var t = new Thread(obj => data.Append(" World!"));
t.Start();
t.Join();
Console.WriteLine($"Phrase: {data}");
In Rust, there is no variation of thread::spawn
that does the same. Instead,
the data is passed to the thread via a closure:
use std::thread;
fn main() {
let data = String::from("Hello");
let handle = thread::spawn(move || {
let mut data = data;
data.push_str(" World!");
data
});
println!("Phrase: {}", handle.join().unwrap());
}
A few things to note:
-
The
move
keyword is required to move or pass the ownership ofdata
to the closure for the thread. Once this is done, it's no longer legal to continue to use thedata
variable ofmain
, inmain
. If that is needed,data
must be copied or cloned (depending on what the type of the value supports). -
Rust thread can return values, like tasks in C#, which becomes the return value of the
join
method. -
It is possible to also pass data to the C# thread via a closure, like the Rust example, but the C# version does not need to worry about ownership since the memory behind the data will be reclaimed by the GC once no one is referencing it anymore.
Synchronization
When data is shared between threads, one needs to synchronize read-write
access to the data in order to avoid corruption. The C# offers the lock
keyword as a synchronization primitive (which desugars to exception-safe use
of Monitor
from .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);
In Rust, one must make explicit use of concurrency structures like 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());
}
A few things to note:
-
Since the ownership of the
Mutex
instance and in turn the data it guards will be shared by multiple threads, it is wrapped in anArc
(1).Arc
provides atomic reference counting, which increments each time it is cloned (2) and decrements each time it is dropped. When the count reaches zero, the mutex and in turn the data it guards are dropped. This is discussed in more detail in Memory Management). -
The closure instance for each thread receives ownership (3) of the cloned reference (2).
-
The pointer-like code that is
*data += 1
(4), is not some unsafe pointer access even if it looks like it. It's updating the data wrapped in the mutex guard.
Unlike the C# version, where one can render it thread-unsafe by commenting out
the lock
statement, the Rust version will refuse to compile if it's changed
in any way (e.g. by commenting out parts) that renders it thread-unsafe. This
demonstrates that writing thread-safe code is the developer's responsibility
in C# and .NET by careful use of synchronized structures whereas in Rust, one
can rely on the compiler.
The compiler is able to help because data structures in Rust are marked by
special traits (see Interfaces): Sync
and Send
. Sync
indicates that references to a type's instances are safe to share between
threads. Send
indicates it's safe to instances of a type across
thread boundaries. For more information, see the “Fearless Concurrency”
chapter of the Rust book.
Producer-Consumer
The producer-consumer pattern is very common to distribute work between
threads where data is passed from producing threads to consuming threads
without the need for sharing or locking. .NET has very rich support for this,
but at the most basic level, System.Collections.Concurrent
provides the BlockingCollection
as shown in the next example in 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($"Message #{n}");
messages.CompleteAdding();
});
producer.Start();
// main thread is the consumer here
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:
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!("Message #{}", n)).unwrap();
}
});
// main thread is the consumer here
for received in rx {
println!("{}", received);
}
producer.join().unwrap();
}
Like channels in Rust, .NET also offers channels in the
System.Threading.Channels
namespace, but it is primarily designed to be used
with tasks and asynchronous programming using async
and await
. The
equivalent of the async-friendly channels in the Rust space is offered by the
Tokio runtime.
Testing
Test organization
.NET solutions use separate projects to host test code, irrespective of the
test framework being used (xUnit, NUnit, MSTest, etc.) and the type of tests
(unit or integration) being wirtten. The test code therefore lives in a
separate assembly than the application or library code being tested. In Rust,
it is a lot more conventional for unit tests to be found in a separate test
sub-module (conventionally) named tests
, but which is placed in same source
file as the application or library module code that is the subject of the
tests. This has two benefits:
-
The code/module and its unit tests live side-by-side.
-
There is no need for a workaround like
[InternalsVisibleTo]
that exists in .NET because the tests have access to internals by virtual of being a sub-module.
The test sub-module is annotated with the #[cfg(test)]
attribute, which has
the effect that the entire module is (conditionally) compiled and run only
when the cargo test
command is issued.
Within the test sub-modules, test functions are annotated with the #[test]
attribute.
Integration tests are usually in a directory called tests
that sits adjacent
to the src
directory with the unit tests and source. cargo test
compiles
each file in that directory as a separate crate and run all the methods
annotated with #[test]
attribute. Since it is understood that integration
tests in the tests
directory, there is no need to mark the modules in there
with the #[cfg(test)]
attribute.
See also:
Running tests
As simple as it can be, the equivalent of dotnet test
in Rust is cargo test
.
The default behavior of cargo test
is to run all the tests in parallel, but this can be configured to run consecutively using only a single thread:
cargo test -- --test-threads=1
For more information, see "Running Tests in Parallel or Consecutively".
Output in Tests
For very complex integration or end-to-end test, .NET developers sometimes log
what's happening during a test. The actual way they do this varies with each
test framework. For example, in NUnit, this is as simple as using
Console.WriteLine
, but in XUnit, one uses ITestOutputHelper
. In Rust, it's
similar to NUnit; that is, one simply writes to the standard output using
println!
. The output captured during the running of the tests is not shown
by default unless cargo test
is run the with --show-output
option:
cargo test --show-output
For more information, see "Showing Function Output".
Assertions
.NET users have multiple ways to assert, depending on the framework being used. For example, an assertion xUnit.net might look like:
[Fact]
public void Something_Is_The_Right_Length()
{
var value = "something";
Assert.Equal(9, value.Length);
}
Rust does not require a separate framework or crate. The standard library comes with built-in macros that are good enough for most assertions in tests:
Below is an example of assert_eq
in action:
#[test]
fn something_is_the_right_length() {
let value = "something";
assert_eq!(9, value.len());
}
The standard library does not offer anything in the direction of data-driven
tests, such as [Theory]
in xUnit.net.
Mocking
When writing tests for a .NET application or library, there exist several
frameworks, like Moq and NSubstitute, to mock out the dependencies of types.
There are similar crates for Rust too, like mockall
, that can
help with mocking. However, it is also possible to use conditional
compilation by making use of the cfg
attribute as a simple
means to mocking without needing to rely on external crates or frameworks. The
cfg
attribute conditionally includes the code it annotates based on a
configuration symbol, such as test
for testing. This is not very different
to using DEBUG
to conditionally compile code specifically for debug builds.
One downside of this approach is that you can only have one implementation for
all tests of the module.
When specified, the #[cfg(test)]
attribute tells Rust to compile and run the
code only when executing the cargo test
command, which behind-the-scenes
executes the compiler with rustc --test
. The opposite is true for the
#[cfg(not(test))]
attribute; it includes the annotated only when testing
with cargo test
.
The example below shows mocking of a stand-alone function var_os
from the
standard that reads and returns the value of an environment variable. It
conditionally imports a mocked version of the var_os
function used by
get_env
. When built with cargo build
or run with cargo run
, the compiled
binary will make use of std::env::var_os
, but cargo test
will instead
import tests::var_os_mock
as var_os
, thus causing get_env
to use the
mocked version during testing:
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.
/// Utility function to read an environmentvariable and return its value If
/// defined. It fails/panics if the valus is not valid Unicode.
pub fn get_env(key: &str) -> Option<String> {
#[cfg(not(test))] // for regular builds...
use std::env::var_os; // ...import from the standard library
#[cfg(test)] // for test builds...
use tests::var_os_mock as var_os; // ...import mock from test sub-module
let val = var_os(key);
val.map(|s| s.to_str() // get string slice
.unwrap() // panic if not valid Unicode
.to_owned()) // convert to "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"));
}
}
Code coverage
There is sophisticated tooling for .NET when it comes to analyzing test code coverage. In Visual Studio, the tooling is built-in and integrated. In Visual Studio Code, plug-ins exist. .NET developers might be familiar with coverlet as well.
Rust is providing built-in code coverage implementations for collecting test code coverage.
There are also plug-ins available for Rust to help with code coverage analysis. It's not seamlessly integrated, but with some manual steps, developers can analyze their code in a visual way.
The combination of Coverage Gutters plug-in for Visual Studio Code and Tarpaulin allows visual analysis of the code coverage in Visual Studio Code. Coverage Gutters requires an LCOV file. Other tools besides Tarpaulin can be used to generate that file.
Once setup, run the following command:
cargo tarpaulin --ignore-tests --out Lcov
This generates an LCOV Code Coverage file. Once Coverage Gutters: Watch
is
enabled, it will be picked up by the Coverage Gutters plug-in, which will show
in-line visual indicators about the line coverage in the source code editor.
Note: The location of the LCOV file is essential. If a workspace (see Project Structure) with multiple packages is present and a LCOV file is generated in the root using
--workspace
, that is the file that is being used - even if there is a file present directly in the root of the package. It is quicker to isolate to the particular package under test rather than generating the LCOV file in the root.
Benchmarking
Running benchmarks in Rust is done via cargo bench
, a specific
command for cargo
which is executing all the methods annotated with the
#[bench]
attribute. This attribute is currently unstable and
available only for the nightly channel.
.NET users can make use of BenchmarkDotNet
library to benchmark methods and
track their performance. The equivalent of BenchmarkDotNet
is a crate named
Criterion
.
As per its documentation, Criterion
collects and stores
statistical information from run to run and can automatically detect performance
regressions as well as measuring optimizations.
Using Criterion
is possible to use the #[bench]
attribute without moving to
the nightly channel.
As in BenchmarkDotNet
, it is also possible to integrate benchmark results with
the GitHub Action for Continuous Benchmarking. Criterion
,
in fact, supports multiple output formats, amongst which there is also the
bencher
format, mimicking the nightly libtest
benchmarks and compatible with
the above mentioned action.
Logging and Tracing
.NET supports a number of logging APIs. For most cases, ILogger
is a good
default choice, since it works with a variety of built-in and third-party
logging providers. In C#, a minimal example for structured logging could look
like:
using Microsoft.Extensions.Logging;
using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
var logger = loggerFactory.CreateLogger<Program>();
logger.LogInformation("Hello {Day}.", "Thursday"); // Hello Thursday.
In Rust, a lightweight logging facade is provided by log. It has less
features than ILogger
, e.g. as it does not yet offer (stable) structured
logging or logging scopes.
For something with more feature parity to .NET, Tokio offers
tracing
. tracing
is a framework for instrumenting Rust
applications to collect structured, event-based diagnostic information.
tracing_subscriber
can be used to implement and
compose tracing
subscribers. The same structured logging example from above
with tracing
and tracing_subscriber
looks like:
fn main() {
// install global default ("console") collector.
tracing_subscriber::fmt().init();
tracing::info!("Hello {Day}.", Day = "Thursday"); // Hello Thursday.
}
OpenTelemetry offers a collection of tools, APIs, and SDKs used to instrument, generate, collect, and export telemetry data based on the OpenTelemetry specification. At the time of writing, the OpenTelemetry Logging API is not yet stable and the Rust implementation does not yet support logging, but the tracing API is supported.
Conditional Compilation
Both .NET and Rust are providing the possibility for compiling specific code based on external conditions.
In .NET it is possible to use the some preprocessor directives in order to control conditional compilation
#if debug
Console.WriteLine("Debug");
#else
Console.WriteLine("Not debug");
#endif
In addition to predefined symbols, it is also possible to use the compiler
option DefineConstants to define symbols that can be used with #if
,
#else
, #elif
and #endif
to compile source files conditionally.
In Rust it is possible to use the cfg attribute
,
the cfg_attr attribute
or the
cfg macro
to control conditional compilation
As per .NET, in addition to predefined symbols, it is also possible to use the
compiler flag --cfg
to arbitrarily set configuration options
The cfg attribute
is requiring and evaluating a
ConfigurationPredicate
use std::fmt::{Display, Formatter};
struct MyStruct;
// This implementation of Display is only included when the OS is unix but foo is not equal to bar
// You can compile an executable for this version, on linux, with '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("Running without foo=bar configuration")
}
}
// This function is only included when both unix and foo=bar are defined
// You can compile an executable for this version, on linux, with '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("Running with foo=bar configuration")
}
}
// This function is panicking when not compiled for unix
// You can compile an executable for this version, on windows, with 'rustc main.rs'
#[cfg(not(unix))]
impl Display for MyStruct {
fn fmt(&self, _f: &mut Formatter<'_>) -> std::fmt::Result {
panic!()
}
}
fn main() {
println!("{}", MyStruct);
}
The cfg_attr attribute
conditionally includes attributes based on
a configuration predicate.
#[cfg_attr(feature = "serialization_support", derive(Serialize, Deserialize))]
pub struct MaybeSerializableStruct;
// When the `serialization_support` feature flag is enabled, the above will expand to:
// #[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.
if cfg!(unix) {
println!("I'm running on a unix machine!");
}
See also:
Features
Conditional compilation is also helpful when there is a need for providing
optional dependencies. With cargo "features", a package defines a set of named
features in the [features]
table of Cargo.toml, and each feature can either be
enabled or disabled. Features for the package being built can be enabled on the
command-line with flags such as --features
. Features for dependencies can be
enabled in the dependency declaration in Cargo.toml.
See also:
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 provides the same environment variable access functionality at
runtime via the var
and var_os
functions from the std::env
module.
The var
function will return a Result<String, VarError>
, and either returns the
variable if it is set or returns an error if the variable is either not set or 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 also provides environment variable access functionality 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 compile time access to environment variables can be achieved, albeit in a less straightforward way, via source generators.
Configuration
Configuration in .NET is possible with configuration providers. The framework
provides 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 provider 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 desugared 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 once
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;
}
}
}
Project Structure
While there are conventions around structuring a project in .NET, they are less strict compared to the Rust project structure conventions. When creating a two-project solution using Visual Studio 2022 (a class library and an xUnit test project), it will create the following structure:
.
| SampleClassLibrary.sln
+---SampleClassLibrary
| Class1.cs
| SampleClassLibrary.csproj
+---SampleTestProject
SampleTestProject.csproj
UnitTest1.cs
Usings.cs
- Each project resides in a separate directory, with its own
.csproj
file. - At the root of the repository is a
.sln
file.
Cargo uses the following conventions for the package layout to make it easy to dive into a new Cargo package:
.
+-- Cargo.lock
+-- Cargo.toml
+-- src/
| +-- lib.rs
| +-- main.rs
+-- benches/
| +-- some-bench.rs
+-- examples/
| +-- some-example.rs
+-- tests/
+-- some-integration-test.rs
Cargo.toml
andCargo.lock
are stored in the root of the package.src/lib.rs
is the default library file, andsrc/main.rs
is the default executable file (see target auto-discovery).- Benchmarks go in the
benches
directory, integration tests go in thetests
directory (see testing, benchmarking). - Examples go in the
examples
directory. - There is no separate crate for unit tests, unit tests live in the same file as the code (see testing).
Managing large projects
For very large projects in Rust, Cargo offers workspaces to organize the project. A workspace can help manage multiple related packages that are developed in tandem. Some projects use virtual manifests, especially when there is no primary package.
Managing dependency versions
When managing larger projects in .NET, it may be appropriate to manage the versions of dependencies centrally, using strategies such as Central Package Management. Cargo introduced workspace inheritance to manage dependencies centrally.
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.