Intro To Rust
Intro to Rust
Rust is a general purpose, multi-paradigm compiled language whose speed is comparable to C++, that is also memory safe (C++ memory management is often quite simple as well due to the useage of destructors that are often the default in most classes and any class that inherit them). Rust is used in browsers, games, embedded systems and even the linux kernel (recently)!
Prelims
First you must install rust
and cargo
. With brew it would be:
brew install rust
brew install cargo
Compiler
Without any package management, you can compile a rust program with rustc
as such:
rustc program.rs
To run the compiled program, by default the rust compiler makes the executable with the same name as the script file, thus we would simply run it as such:
./ program
Cargo
Cargo is similar to node.js or Bazel where in it manages the project. It can compile the project, manage the dependencies necessary and run the project. The project is managed with a cargo.toml
file (analagous to package.json
).
To create a new rust project with cargo, simply run:
cargo new project_name
To add a dependency
cargo add package_name
If there are features you want to add, simply add the --features feature_name
tag after the package_name
part of cargo add package_name
.
To compile and run
cargo run
Memory Management
Rust uses a unique memory management system called ownership. Ownership is a system where instead of pointers, each variable has a single owner. There can only be 1 owner at a time, and when the owner is out of scope, the memory is freed.
Similar to pointers in C++, data is borrowed by the other scopes instead of making copys. However, unlike C++, often times there are restrictions on passing copies of data, due to the fact that there can only be 1 owner in each scope. Thus, Rust generally forces the use of pointers in functions (borrowing) rather than copies (in C and C++ there are no restrictions). Essentially, Rust uses a more strict usage of pointers to ensure memory safety, as opposed to grabage collection.
Syntax
Variable Initialization
Semi-colons indicate the end of a line and are required. Loops and functions are enclosed with curly braces as in C++. All variables are initialized with let
. For example, if I wanted to set a variable num
to a value 3, it would look like:
let num = 3;
One can also specify type of variable as such:
let num: i32 = 3;
where i32
signifies a signed 32-bit integer. Rust has support (out of the box) for integers of length 8, 16, 32, 64, and 128. There is also a size arch
which depends on your CPU type.
By default, all variables are immutable. However, in cases where the sizes may change, like for a vector for example, one can use the keyword mut
before initializing a variable as such:
let mut num = vec![];
If we want to use a borrowed value, one uses the &
character before the variable as such:
let num = 32;
let borrowed_num = #
To access the data inside a borrowed variable, we need to dereference it. If we want to dereference a borrowed value, we use the *
character as such:
let borrowed_num = #
let dereferenced_num = *borrowed_num;
Standard I/O
Writing to standard out is done with println!
. Unlike python, the call is a bit weird:
println!("{}", var); // if var can be a string
println!("{:?}", var); // debug mode, if var is not string
println!("{:#?}", var); // debug, and prettify if var is not string
println!("I want var here: {:#?} and some more stuff after", var);
The different print statements above depends on the variable you want to print. In general, its better to use "{:#?}"
since it will prettify the variable you want to print, give you the data type, and print it regardless if its a stiring or not.
Conditionals
If statements are similar to python in that one does not need to bracket the condition. However, curly braces are still needed to wrap the remainder of an if statement:
if x < 10{
foo();
}else if y >= 100{
bar()
}else{
baz();
}
For multiple if statements, instead of switch, rust uses the match keyword as such:
match x {
1 => foo(),
2 | 3 | 5 | 7 => bar(), // multiple cases
13..19 => baz(), // in a range
_ => buzz() // rest of cases
}
Functions
functions are denoted with the fn
keyword as such:
fn add_num(num1: i32, num2: i32) -> i32{
return num1 + num2
}
functions in rust require a return type (denoted with the arrow), and the arguments also require a data type to be specified.
If one wants to use borrowed values instead of copies, similar to C++ and C, add a &
before the variable types as such:
fn add_num(num1: &i32, num2: &i32) -> i32{
return *num1 + *num2;
}
To make a function public (meaning it can be accessed by other files) you must add the pub
keyword before fn
as such:
pub fn add_num(...
Loops
for-loops have syntax similar to python where it follows for i in ...
as such:
for i in 0..10{
println!("{}", i);
}
In the above, 0..10
is equivalent to range(10)
in python (a sequence of integers starting from 0 and ending in 9).
While loops also follow python syntax where its while condition ...
:
let n: i32 = 0;
while n < 100{
n += 1;
}
Imports and Function Calls
Similar to C++, calling functions that belong to a library is done with the ::
token. Thus, suppose we wanted to call function bar
from module foo
, we have:
foo::bar(args);
Imports are done with the use
command at the top of the file as such:
use std;
fn main(){}
Importing files that you create are a bit more complicated. Consider the basic case of a file belonging in the same directory. Suppose in the same directory there is a main.rs
and a helper.rs
:
.
├── main.rs
└── helper.rs
There are functions in helper.rs
that we want to use in main.rs
. Since helper.rs
is in the same directory, its quite simple:
use std;
mod helper;
fn main(){}
Here mod helper
calls the file helper.rs
as a module, and we can call a function foo
in helper.rs
with:
helper::foo();
Now consider the harder case. Suppose we have a subdirectory called helpers/
and in helpers/
we have some files sort.rs
and search.rs
:
.
├── main.rs
└── helpers/
├── sort.rs
└── search.rs
We need both in main.rs
. First, we create a mod.rs
in helpers/
:
.
├── main.rs
└── helpers/
├── sort.rs
├── search.rs
└── mod.rs
the purpose of mod.rs
is that it publicizes the modules in the subdirectory. We do that with:
pub mod search; // recall the pub keyword from the functions section
pub mod sort;
Now, in main.rs
, we call helpers the same way we did before as such:
use std;
mod helpers;
fn main(){}
if we want to call a function foo
in search, and baz
in sort we have:
helpers::search::foo();
helpers::sort::baz();
Classes
Unlike python or C++, Rust does not use classes. It instead uses Structs. Thus, if we wanted to define a class with some variables, it would be done with:
struct foo{
bar: i32,
baz: bool,
name: String,
}
We can then define functions centered around structs, like in C.
Practice
-
Make a function
add_up
that borrows two argumentsx
andy
returnsz=x+∑i=1yiz = x + \sum\limits_{i=1}^{y} i
Run (compile and run) said function with values 10 and 11 and print the output.
-
Make a subdirectory called
helper/
. Add the function made in 1 into a file calledhelp.rs
. Now call the function in your original file (must be imported correctly). Compile and run with values 10 and 11.
Asynchronous functions
Asynchronous (async for short) are functions that allow you to run many concurrent tasks at the same time. They are important for multithreading, event-driven applications and more. In rust, asynchronous programming is handled with futures. A future is essentially a representation of the final result of the async computation. It will need to be polled in order to make sure we have the final result.
In applicaiton, asynchronous functions are called by adding an async
before fn
as such:
pub async fn foo(){}
async fn baz(){}
In most cases, async functions return a Result
object. A Result
object essentially has two things it can be, an Ok
object or an Err
object. An Ok
object means that the result of the async computation was successful, this Ok
result then needs to be unwrapped to get the actual result. You can also check if a result was Ok
or not by using an if statement or a match statement. Putting it together, an async function can be defined with:
struct FooError{
num: i32
};
pub async fn foo() -> Result<i32, FooError>{
let result: i32 = 0;
// do stuff to result
Ok(result);
}
In the above once the function is completed, we send an Ok response. Since the result is of type i32, we specify that in the angle brackets after Result
. FooError
is just a generic struct used to illustrate the example. In practice, the package you are using would have a more appropriate error. For example, when using the reqwest
library to make requests, there is a reqwest::Error
struct that handles issues that arise when making requests.
Now lets consider the other case, the case where we make a call to an asynchronous function. How do we handle the result that comes out of it? Suppose we have an async function foo
. We simply add a match case to check if its ok, and if it is, we take it.
match foo(){
Ok(sk) => {
println!("{:?}", sk);
bar(sk);
},
Err(e) => {
println!("Error: {:?}", e);
}
}
If you are confident there is indeed something there, you can skip the match case and do this instead:
let result = foo().await.unwrap();
Similar to Result
, there is an Option
class which is defined by a single type (no error type) and signifies either data existing or not. For example, if we wanted to pull a key from a json, we would get an Option
instead of the actual value. We then use the Some
class to check if there is data. If there is, we unwrap.
let data = serde_json::from_str(&raw_data).unwrap();
if data[key].is_some(){
foo();
}
Here, we are setting data
to a json object. Since the from_str
method is not async, the await is not necessary. Since we are also confident the file will always exist, we don't bother with checking if the function worked before calling unwrap
. However, we are not sure about key
being a key in our json file. This causes us to check if there is something there with is_some
.
Practice
- Create a new project called
get_ip
using cargo. Addrequest
with featurejson
by adding a command line argument--features json
. Also addserde_json
libraries, with feature derive. - Create a file
get_ip.rs
in the same directory and import intomain.rs
- Get requests can be made using
reqwest
. To do so, you first create a Client withreqwest::Client::new()
. Then useclient.get(url).send()
to make the request. Note that this is an async function call and make the correct adjustments. Make an async function that makes a get request tohttps://api.ipify.org?format=json
, and then returns the text response (result.text()
would give you the raw text result of the request, assuming the final result ofclient.get().send()
was stored in a variableresult
) - Take the result from 3, and put it into a json. Assuming you have a String response (and not a
Result
orOption
, achieve this by doing the correct checks and unwraps), useserde_json::from_str
to dump the text data into a json object.