Now that we've gotten the fundamentals out of the way, we can finally get into the basics of Rust-code.
This series is written from the perspective of getting to know Rust from knowing C#. There are already many good getting-started-guides out there for Rust, but this series tries to leverage your existing .NET/C#-knowledge to fast-track you into understanding Rust, by relating the concepts to what you already know.
Disclaimer: This series is being written while I'm learning Rust, so I'm by no means an expert and maybe this might be reflected in some of the example code. If you find any blatant errors, feel free to add some feedback in the comments.
Setup
In the post about Rust fundamentals, we could read about how Rust provides a complete toolchain for development, which is made available by installing Rustup, the recommended way of installing Rust.
Create a new project in a command-line by running: cargo new example_project
The file src/main.rs
is your application's entry point, which will be run when you execute cargo run
. So this is a good file to start with, to follow along in your code-editor. This file will only contain a function called main
, with a simple Hello World, when generated by Cargo:
fn main() {
println!("Hello, world!");
}
Code Basics
Let's start looking at some basic example-code, to go through some basic concepts in Rust. Since Rust is a C-style language, you will probably recognize much of the syntax you see in this code-snippet, as a C#-developer.
You can always find the full code for this series in the from-csharp-to-rust
-project on GitHub.
Variables
Variables in Rust are created by using the let
-keyword, in a very similar way to the use of the var
-keyword in C#.
let title = "Ghost Buster";
let year: u16 = 1984;
Types are inferred in Rust, as you can see with the title
-variable. You can also specify the type, as we do with the year
-variable, where we want to use a smaller number-type than what is inferred by default.
One important difference between Rust and C# is that variables are immutable by default in Rust. If you want a variable to be mutable, you have to add mut
to the variable declaration:
let mut rating_avg = 7.8;
// Someone rated the movie
rating_avg = 7.9;
Variables can be created as constants in Rust, by using the const
-keyword, just like in C#. These variables have to have their types specified:
const GOOD_MOVIE_LIMIT: f32 = 7.5;
Naming Conventions
As you may have noticed, Rust naming conventions use Snake case (example_variable_name
) for its variables. Unlike C# which usually uses Camel case (exampleVariableName
) or Pascal case (ExampleVariableName
) for most of its variables, fields, and properties.
The Rust-compiler will even warn you about using the wrong conventions for different namings.
At the moment of writing, Rust-developers also seems to prefer shorter names for variables and functions, than we usually see in C# and .NET.
Strings
In C#, we only have one string-type for all types of strings. In Rust, there are two different string-types you learn about at first. The primitive str
-type, which is immutable and fixed length, as well as the String
-type, which can be modified and grow in size.
let mut value = String::default();
for x in 65..68 {
value.push(x as u8 as char);
}
// value: "ABC"
On Stack Overflow, a question called What are the differences between Rust's String
and str
? the most popular answer, by far, summarizes:
Use
String
if you need owned string data (like passing strings to other threads, or building them at runtime), and usestr
if you only need a view of a string.
Conversion between the two types is quite straight forward:
let title = "Ghostbusters";
// From str to String
let title_string = String::from(title);
// From String to str
let title_str = title_string.as_str();
In practical use, you can see Rust's String
-type as the equivalent of StringBuilder
in C#, while Rust's str
is the equivalent of ReadOnlySpan<char>
.
Collections
In Rust, the two most common types used to create collections are array
and Vec
(vector).
The array
-type is a fixed-size collection and is compared to an array of a type in C# and .NET, like string[]
, int[]
, char[]
, and so on. The Vec
-type can change in size and can be compared to List<T>
in C# and .NET.
Both types can be mutated if the variable has the mut
-keyword. They can also easily be converted between each other.
let mut arr = [1, 2, 3];
arr[0] = 0;
let mut vec = vec![1, 2];
vec.push(3);
vec[0] = 0;
let arr_from_vec = &vec[0.. 2];
let vec_from_arr = arr.to_vec();
Rust also has the HashMap
-type, which is the equivalent of Dictionary<TKey, TValue>
in C# and .NET.
let mut map = HashMap::new();
map.insert("key", 123);
Generics
Rust has support for generics, just like C# does, but with a little bit more smart functionality built into the compiler. In C#, you don't always have to specify the exact generics used in some contexts, since it can be inferred. Rust has some additional functionality for this.
For both Vec
and HashMap
, you don't have to specify the type explicitly when creating the object. It will be inferred by the first items added and enforced to the following added items.
Logic Flow
The if
-statements in Rust are very similar to the ones in C#, except you don't need parenthesis around the condition, but it's possible to use it if you want to.
let mut info = String::from(title);
if rating_avg >= GOOD_MOVIE_LIMIT {
info += " - You're in for a good movie";
} else if rating_avg < GOOD_MOVIE_LIMIT && year <= 1995 {
info += "- Could be a classic...";
} else {
info += "... Do you want to look for another movie?";
}
The if
-statement is in itself an expression, from which you can catch the result. This means that you can use an if
-statement to assign a variable. You can catch the value resulting from the if
, the if else
, or the else
.
This can be used to create a shorthand if. This is a good replacement for the ternary conditional operator (In C#: var a = true ? b : c
), since it's not supported in Rust.
let classic = year <= 1995 && !title.ends_with(" II");
let text = if classic { "Potential classic" } else { "We'll see..." };
Pattern Matching
Rust also has very appreciated pattern-matching-functionality. This is something that C# has implemented and seems to keep on improving it with every version.
Pattern-matching in Rust can do a lot more than just replace simple if
-statements. It can also, among other things, be used to assign the result to variables. More sophisticated usages will be shown as this series goes on.
let info = match rating_avg {
x if x > GOOD_MOVIE_LIMIT =>
format!("{} - You're in for a good movie", x),
x if x < GOOD_MOVIE_LIMIT && year <= 1995 =>
format!("{} - Could be a classic...", x),
_ => String::from("... Do you want to look for another movie?"),
};
Standard Output
To write to the standard output, in the equivalent way of Console.WriteLine
in C# and .NET, you use the function println!
in Rust.
let hello = "Hello";
let world = "World";
println!("{} {}!", hello, world);
In .NET, we specify the indexes inside the placeholder-curly brackes like this: Console.WriteLine("{1} {0}!", world, hello)
. These explicit indexes can also be used in Rust like this println!("{1} {0}!", world, hello)
.
If you don't specify the index In Rust and just use the empty placeholder {}
, as in the above example, the index will be inferred.
Just like in C# and .NET, you can add formatting to a parameter by adding a colon-character (:
) after it and a specific flag. For debugging, it's very useful to use the debug-formatting, which shows the whole content of the object in the console.
let obj = complex_obj();
println!("Debug: {:?}", obj);
println!("Debug pretty: {:#?}", obj);
Command-Line Arguments
Standard input is not as straight forward as Standard output and not a one-liner like in C# and .NET with Console.ReadLine
. So we will get more into that in future articles.
Getting access to the command-line arguments passed to the application is a lot easier. This can be used to quickly build a CLI-type application.
// Execute: cargo run "The Shawshank Redemption"
let args: Vec<String> = env::args().collect();
let first_arg =
if args.len() > 1 { args[1].clone() } else { String::default() };
// first_arg: "The Shawshank Redemption"
Functions
Functions in Rust work just like in C#, except you declare it using the fn
-keyword. You can specify a return-type, but if you don't the function will just act like a void
in C#.
fn is_sequel(title: &str) -> bool {
if title.is_empty() {
return false;
}
let sequel_title = title.ends_with("II");
sequel_title
}
Notice the lack of a trailing semicolon (;
) behind the sequel_title
-variable at the end of the method. This makes the statement into an "expression" and Rust will implicitly accept this as a return statement, as long as it's the final expression of the method. This is why a return
is needed in the body of the early returning if
-statement.
This is a feature that might look weird when you're coming from another C-style language, but that you will see all over the place in Rust-code.
Functions are then called with their names and parameters, just like in C#:
fn main() {
let sequel = is_sequel("Ghostbusters");
}
Macros
In this article, you've seen the use of the println!
-function, with the unusually looking exclamation-point (!
) at the end. This annotation makes this a macro. This is a too advanced of a topic to go into in this part about basic code, so I'll just point to official Rust Programming Language Book's section on macros, which summarizes:
Macros are a way of writing code that writes other code, which is known as metaprogramming.
Null-Values
The notorious Billion dollar mistake of allowing null
-values into programming languages seems to be a problem tackled only recently. C# got Nullable reference types with C# 8.0 in late 2019 and F# doesn't have nulls built-in at all (even if there are ways to get around it).
Rust does not support null values. Instead, it uses patterns to wrap around types, to help indicate if there was a value or not. The most commonly used such type is the Option
-type. It will return either a Some
, carrying the value, or a None
, indicating there was no object.
fn main() {
let quotient = divide(123, divisor);
match quotient {
Some(x) if x > 10 => println!("Big division result: {}", x),
Some(x) => println!("Division result: {}", x),
None => println!("Division failed")
}
}
fn divide(dividend: i32, divisor: i32) -> Option<i32> {
if divisor == 0 {
return None;
}
Some(dividend / divisor)
}
Object-Orientation
Object-orientation in Rust is a point in the code-basics where things get a bit strange for the regular C#-developer. In C#, we are used to having a class
that owns its fields, properties, and methods. The class is then a template for creating an instance of an object.
Rust uses what they call a struct
instead of classes, which is just a "dumb" data-structure, which contains fields, but they do not (directly) contain properties or methods. Instead, Rust provides the possibility to add functions to a struct
through the impl
-keyword.
pub struct Movie {
pub title: String,
release_year: u16,
}
impl Movie {
// Constructor - Associated method
pub fn new(title: &str, release_year: u16) -> Self {
Self { title: String::from(title), release_year }
}
// Method
pub fn display_title(&self) -> String {
format!("{} ({})", self.title, self.release_year)
}
// Method with mutability
pub fn update_release_year(&mut self, year: u16) {
self.release_year = year;
}
}
fn main() {
let movie = Movie::new("Ghostbusters", 1984);
println!("Movie: {}", movie.display_title());
}
First, we define the struct
and its fields, where we add the pub
-keyword to the struct
itself and its field title
. All public fields are mutable in Rust if used with the mut
-keyword by other code. If you want to protect a field, use methods, like the update_release_year
-function in this example.
In the impl
-block, we have the new
-function, which acts as a constructor. It's an associated method to the type, which is the equivalent of a static
method in C#. It returns a new instance of the implementing type, which in this case is a Movie
.
The function display_title
is a method on the instance of the object and through the self
-keyword in the first parameter, it can access fields and methods on the object, even non-public ones. It's the equivalent to the this
-keyword in C#.
Inheritance
Rust doesn't have inheritance. At least not in the way a C#-developer is used to it. This seems to be a very conscious decision by the Rust-team.
Instead of inheritance, Rust provides the very powerful concept of traits, which allows you to extend any type, no matter if you've created the type or not. As a C#-developer it acts like of mixture of interfaces and extension-methods. We will look closer at traits in upcoming articles in the series.
Ownership & Borrowing
You've seen the use of the ampersand (&
) character in some places of the example-code. These indicate that the use of an object is done by reference. This is something you normally very rarely need to deal with in C# and .NET.
This relates to the unique concept in Rust of References and Borrowing. This is the foundational concept which enables the language's memory-safety, performance, and "fearless concurrency".
This is also one of the hardest parts of Rust to master. It's usually the number one complaint about the language from newcomers and a very frequent topic on Stack Overflow and other Q&A-forums.
Primitives Comparison Table
Rust has a set of primitives that overlap quite well with the ones we're used to in C# and .NET. They are listed for reference:
Rust | C# | Comments |
---|---|---|
bool | bool | |
char | char | |
i8, i16, i32, i64, i128 | sbyte, short, int, long, (N/A) | Signed 128-bit integer not available in .NET |
u8, u16, u32, u64, u128 | byte, ushort, uint, ulong, (N/A) | Unsigned 128-bit integer not available in .NET |
isize, usize | (N/A) | |
f32, f64 | float, double | .NET has the 128-bit floating-point number decimal , which is not available in Rust out-of-the-box |
Array | Array | Not a primitive type in .NET. Fixed size. |
Tuple | System.ValueTuple / System.Tuple | Not a primitive type in .NET |
Slice | Span<T> | Not a primitive type in .NET |
str | string | str is immutable and fixed length in Rust, but not in .NET |
Function | Func<T> | Not a primitive type in .NET |
Learn More
If you want to learn more about Rust, unrelated to its equivalent functionality in C# and .NET, you can check out the Learn Rust-section of the official website, which points to multiple resources, including the Rust Programming Language-book and the Rust by Example-book.
I also found the guide Writing Easy Rust very helpful, because it helps to explain the more complicated Rust-concepts in easier terms.
Summary
This article tried to lead you into the Rust-language by relating concepts to the ones you already know in C# and .NET.
Rust is very similar syntactically to other C-style languages, including C#. There are some concepts in Rust that are relatively different from what you're used to, but this can be a great way to be inspired to solve things in new ways. I've actually heard multiple people say that they've rediscovered their love for programming by learning Rust.