Skip to content

reinhash/rust-dev-talk

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 

Repository files navigation

Presentation guide für Reinhard's Rust Rant

The borrow checker

  1. create train struct
struct Train {
    name: String,
    speed: f64,
}
  1. initialize train struct from main
let train = Train {
  name: String::from("Thomas"),
  speed: 99.0,
};
  1. print the train name
println!("{}", train.name);
  1. print it again
println!("{}", train.name);

Does it compile?

Observe that all is good!

  1. create a new printer function
fn printer(train: Train) {
    println!("{}", train.name);
}
  1. print with the new function
printer(train);

Does it compile?

  1. print again with the new function
printer(train);

Does it compile?

Observe the error message

Explanation: You just moved the value into the function on the first call. Therefore, it is not available anymore on the second call.

  1. Now lets borrow it to solve the problem
printer(&train);
printer(&train);

Observe that the function signature needs to change as well.

  1. Update the function signature
fn printer(train: &Train) {
    println!("{}", train.name);
}

You just learned that a function can borrow!

Enums

  1. Create an enum of train type
enum TrainType {
    ICE,
    IC,
    Regional,
    S,
}
  1. Add the train type to the Train struct
struct Train {
    name: String,
    speed: f64,
    type_: TrainType,
}
  1. Run cargo check and notice that we receive an error

  2. Add the train type to the instance

let train = Train {
    name: String::from("Thomas"),
    speed: 99.0,
    type_: TrainType::Regional,
};

Nested Enums

  1. Now lets add some magic to the enum and let the Regional Train Type wrap the String type
enum TrainType {
    ICE,
    IC,
    Regional(String),
    S,
}
  1. Let's also fix the initialization
let train = Train {
    name: String::from("Thomas"),
    speed: 99.0,
    type_: TrainType::Regional(String::from("S-7")),
};
  1. Let's see how we can print the train type in the printer function
fn printer(train: &Train) {
    let name = &train.name;
    let speed = &train.speed;
    let type_name = match &train.type_ {
        TrainType::ICE => "ICE".into(),
        TrainType::IC => "IC".into(),
        TrainType::Regional(name) => format!("Regional {}", name),
        TrainType::S => "S-Bahn".into(),
    };
    println!(
        "Es fährt ein Zug namens {} ein und er hat folgenden Typ {}. Er kommt mit {} km/h eingefahren",
        name, type_name, speed
    );
}

As you can see the power of pattern matching is quite amazing. But the locality of logic in this code is very bad. Let's refactor it!

Trait implementations

  1. Create an Display implementation for the enum TrainType
  • first, import the display trait use core::fmt::Display;
  • second, import the formatter needed for the implementation: use core::fmt;
  • third: implement it on the enum:
impl Display for TrainType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let type_name = match self {
            TrainType::ICE => "ICE".into(),
            TrainType::IC => "IC".into(),
            TrainType::Regional(name) => format!("Regional {}", name),
            TrainType::S => "S-Bahn".into(),
        };
        write!(f, "{}", type_name)
    }
}
  1. Use the Display implementation to print the type in the printer
fn printer(train: &Train) {
    let name = &train.name;
    let speed = &train.speed;
    let type_name = &train.type_;
    println!(
        "Es fährt ein Zug namens {} ein und er hat folgenden Typ {}. Er kommt mit {} km/h eingefahren",
        name, type_name, speed
    );
}

Great! The function is much slimmer and we have moved the logic to a more appropriate location. But can we do even better?

  1. Create an implementation of Display on the Train struct
impl Display for Train {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Es fährt ein Zug namens {} ein und er hat folgenden Typ {}. Er kommt mit {} km/h eingefahren",
        self.name, self.type_, self.speed)
    }
}
  1. Now we just need to use this method and get rid of the printer function
fn main() {
    let train = Train {
        name: String::from("Thomas"),
        speed: 99.0,
        type_: TrainType::Regional(String::from("S-7")),
    };
    print!("{}", train);
}

Awesome! We just learned how to implement Traits from the standard library to streamline our code and move logic into implementation blocks of our data structures.

Cargo

Let's see why cargo is awesome!

Adding packages

  1. First we inspect the Cargo.toml file
  2. Now let's add a package:
cargo add serde_json
  1. Let's use that package to transform some json. First we need to get some json data:
cp ../train.json .
  1. Now we can use the serde package to parse the json. We can now open the file in our main function:
let mut file = File::open("train.json").unwrap();
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap();

println!("{}", contents);

Let's check this code out for a minute. What are these .unwrap()'s for? Also let's run the code!

Does it compile?

Observe the error message. We did not import the necessary packages. But the compiler is very helpful and tells us what to do.

use std::fs::File;

Let's run the code again!

Does it compile?

Observe the error message. We did not import the necessary packages, again! But the compiler is very helpful and tells us what to do.

use std::io::Read;

Let's give this another try!

Does it compile?

  1. Since we can see that we have successfully read the file, let's try to parse the json with serde_json
use serde_json::Value;

let train: Value = serde_json::from_str(&contents).unwrap();

Do not forget to remove the following line or the borrow checker will complain:

println!("{}", contents);

Let's also uncomment the train initialization and printing it:

    // let train = Train {
    //     name: String::from("Thomas"),
    //     speed: 99.0,
    //     type_: TrainType::Regional(String::from("S-7")),
    // };

    // print!("{}", train);
  1. Now we can access the fields of the json object
let name = train["name"].as_str().unwrap();
let speed = train["speed"].as_f64().unwrap();
let type_ = train["type_"].as_str().unwrap();
println!("Name: {}, Speed: {}, Type: {}", name, speed, type_);

Nice! We can access the values like on a hashmap. But what if we want to access the values in a more type safe way?

  1. Let's create a struct that represents the json object. First we need to add the core serde package:
cargo add serde

To use the feature we need from serde, we need to add a feature flag to our Cargo.toml:

[dependencies]
serde = { version = "1.0.215", features = ["derive"] }

Now we can use its derive macro to decorate the Train struct:

use serde::Deserialize;

#[derive(Deserialize)]
struct Train {
    name: String,
    speed: f64,
    type_: TrainType,
}

As you can see we still cannot compile the code. Why? Because our TrainType enum is not deserializable. Let's fix that!

  1. Add the derive macro to the TrainType enum
#[derive(Deserialize)]
enum TrainType {
    ICE,
    IC,
    Regional(String),
    S,
}
  1. Now we can parse the json object into our Train struct
let train: Train = serde_json::from_str(&contents).unwrap();

println!("Train: {:?}", train);

Does it compile?

As you can see, this does not compile. Why?

We need to derive Debug to print the Train and TrainType struct. Let's do that!

#[derive(Deserialize, Debug)]
enum TrainType {
    ICE,
    IC,
    Regional(String),
    S,
}
#[derive(Deserialize, Debug)]
struct Train {
    name: String,
    speed: f64,
    type_: TrainType,
}

Great! Now check out the code we used to get there. As you can see, we explicitly told the compiler to use Train on the left side of the equals sign. This is needed to apply the correct transformation. This is a very powerful feature of Rust, as it allows us to be very explicit about what we want to do.

Option and Result

Two of the most powerful types in Rust are Option and Result. Let's see how they work!

  1. Let's create a function that returns an Option
fn get_train() -> Option<Train> {
    let train = Train {
        name: "Lego".into(),
        speed: 11.0,
        type_: TrainType::S,
    };
    Some(train)
}

Let's uncomment the json parsing for now:

    // let mut file = File::open("train.json").unwrap();
    // let mut contents = String::new();
    // file.read_to_string(&mut contents).unwrap();

    // let train: Train = serde_json::from_str(&contents).unwrap();

    // println!("Train: {:?}", train);

Now we can use the Option in the main function

let train = get_train().unwrap();

Does it compile?

As you can see, there is no problem with this code. But what happens if the Option is None? Let's change it!

fn get_train() -> Option<Train> {
    None
}

Does it compile?

It does! But why? Isnt Rust memory safe? So why does a runtime error occur? This is where .unwrap() comes into play. It is a method that is available on the Option type. It will panic if the Option is None, which we do not want.

  1. Let's change the code to handle the None case
match get_train() {
    Some(train) => println!("Train found: {:?}", train),
    None => println!("No train found"),
};

Does it compile?

Great! Now we have a way to handle the None case. But what if we want to return an error instead of None?

  1. Let's change the function so that it returns a Result
fn get_train() -> Result<Train, String> {
    Err("Strike! Today we announce Schienenersatzverkehr!".into())
}

Does it compile?

As you can see, the compiler is not happy with this code. Why? We did not adjust for the change from Option to Result. Let's do that!

match get_train() {
    Ok(train) => println!("Train found: {:?}", train),
    Err(message) => println!("Error: {}", message),
};

Since we are responsible developers, let's write some tests!

Running tests

  1. Let's create a test
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_get_train() {
        let train = get_train().unwrap();
        assert_eq!(train.name, "ICE 1 - Thomas die Lokomotive");
    }
}

Observe the structure. We have several elements:

  • the test macro
  • the module declaration with "mod tests"
  • the use statement to import the function we want to test
  • the actual test function and its test decorator (macro)
  1. Now we can run the tests
cargo test

Observe the output. The test fails. Let's adjust the function to make it pass. To do this, let's move the logic to parse the json to the get_train function.

fn get_train() -> Result<Train, String> {
    let mut file = File::open("train.json").unwrap();
    let mut contents = String::new();
    file.read_to_string(&mut contents).unwrap();

    let train: Train = serde_json::from_str(&contents).unwrap();
    Ok(train)
}

Run the test again, as you can see it passes now.

Creating the documentation

  1. In order to create the documentation, we need to add some comments to our code. Let's do that!
/// This is the main function
/// Here we get a train
/// # Returns
/// A Train
/// # Errors
/// If the train company is on strike
  1. Now we can create the documentation
cargo doc --open

As you can see Rust is awesome, we can create documentation for our whole codebase with a single command.

Bonus Section

Webserver

But wait, there is more. Let's create a webserver! We can use the famous axum crate for this.

  1. First we need to add the axum crate to our dependencies
cargo add axum
cargo add tokio
  1. Also, make sure you enable the feature flag for the tokio runtime in your Cargo.toml
tokio = { version = "1.43.0", features = ["rt-multi-thread", "macros"] }
  1. Now we can create a webserver

Import the necessary packages

use axum::{
    routing::get,
    Router,
    Json,
};

Decorate the main function with the tokio runtime

#[tokio::main]
async fn main() {}

Create a router and start a server

    // build our application with a single route
    let app = Router::new().route("/", get(|| async { "Hello, World!" }));

    // run our app with hyper, listening globally on port 3000
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();

Let's see if it works!

cargo run
curl http://localhost:3000

You should see the message "Hello, World!". Congratulations, you just created a webserver with Rust!

Let's now return a JSON of our Train instead. First we create a new handler for this purpose:

    async fn train_handler() -> Json<Train> {
        let train = get_train().unwrap();
        Json(train)
    }

Then we return it as json:

    let app = Router::new().route("/", get(train_handler));

** Does it compile? **

No it does not! And the message is quite cryptic. What is the problem? Our train_handler function returns a Json type, but to convert our Train and TrainType structs to JSON, we need to derive Serialize from the serde package. Let's do that!

use serde::Serialize;

#[derive(Deserialize, Serialize, Debug)]
enum TrainType {
    ICE,
    IC,
    Regional(String),
    S,
}
#[derive(Deserialize, Serialize, Debug)]
struct Train {
    name: String,
    speed: f64,
    type_: TrainType,
}

Throw it into a Docker container

  1. First we need to create a Dockerfile
FROM rust

WORKDIR /app

COPY . .

RUN cargo build --release

CMD ["/app/target/release/trains"]
  1. Now we can build the Docker image
docker build -t trains .
  1. And run the container
docker run -p 3000:3000 trains

Let's check if it works!

curl http://localhost:3000

Yay! You just created a Rust webserver and put it into a Docker container! That's all folks! I hope you enjoyed the presentation!

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors