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

  1. Make a function add_up that borrows two arguments x and y returns

    z=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.

  2. Make a subdirectory called helper/. Add the function made in 1 into a file called help.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

  1. Create a new project called get_ip using cargo. Add request with feature json by adding a command line argument --features json. Also add serde_json libraries, with feature derive.
  2. Create a file get_ip.rs in the same directory and import into main.rs
  3. Get requests can be made using reqwest. To do so, you first create a Client with reqwest::Client::new(). Then use client.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 to https://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 of client.get().send() was stored in a variable result)
  4. Take the result from 3, and put it into a json. Assuming you have a String response (and not a Result or Option, achieve this by doing the correct checks and unwraps), use serde_json::from_str to dump the text data into a json object.