Course 4: Rust Language

Struct, enums, Traits and Object definitions

Pierre Cochard, Tanguy Risset

Struct in Rust

Course: Structs (from https://doc.rust-lang.org/book/)

We have already seen the Struct concept: Structs are similar to tuples in that they both hold multiple related values. Unlike tuples, you can name each field member so it’s clear what the types mean. Adding these names means that structs are more flexible than tuples: you don’t have to rely on the order of the data to specify or access the values of an instance.

Here is an example of Struct definition:

#![allow(unused)]
fn main() {
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}
}

We create an instance by stating the name of the struct and then add curly brackets containing key: value pairs:

#![allow(unused)]
fn main() {
let user1 = User {
    active: true,
    username: String::from("someusername123"),
    email: String::from("someone@example.com"),
    sign_in_count: 1,
};
}

To get a specific value from a struct, we use dot notation. For example, to access this user’s email address, we use user1.email.

When creating instances from other instances The syntax .. specifies that the remaining fields not explicitly set should have the same value as the fields in the given instance:

#![allow(unused)]
fn main() {
let user2 = User {
    email: String::from("another@example.com"),
    ..user1
};
}

Unit struct (i.e. struct without fields) and Tuple struct (i.e. struct with no named fields) are special Rust features that might be useful in some cases (see https://practice.course.rs/compound-types/struct.html)

Consider the following struct definition (replacing string by string slice str):

#![allow(unused)]
fn main() {
struct User {
    active: bool,
    username: &str,
    email: &str,
    sign_in_count: u64,
}
}

Try to create an instance of this structure in a main program.

You will not be able because the lifetime of the str slice is not known (it depends on the lifetime of the string it points to). In a Struct all fields must have the same lifetime.

Try to solve the problem by adding lifetime in your definition, using the compiler error message.

Correction
$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:15
  |
3 |     username: &str,
  |               ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 ~     username: &'a str,
#![allow(unused)]
fn main() {
struct User<'a> {
    active: bool,
    username: &'a str,
    email: &'a str,
    sign_in_count: u64,
}
}

Methods in Rust

As in any object-oriented langage, methods can be defined within the context of a struct making a struct a regular object as defined in the object-oriented programming paradigm. (In Rust, methods can be also defined in the context of an enum or a trait object. The definition of methods use the keyword fn as function but the are preceded by the keyword impl (eg: impl MyStruct { fn [...] }) to specify that this function is only defined in the context of a particular type object.

For a method, the first parameter is always &self (or self if the method need to take ownership of self). &self is a short cut for &self: MyObjectType whatever this type is.

For example, if we want to defined a methode name() for the previous structure User (the first on, with String) in order to obtain the username of a User, we will use the following syntax:

impl User {
    fn name(&self)->&String{
        &self.username
    }
}

When calling this method, the self argument is never written, it is implicit (e.g. : user1.name()). The above method is ofter called a getter. Getters are not implemented by default for Rust Struct, it is a good habit to name the getter after the field name they are getting (hence we should have called this method username())

Define a struct Rectangle with two integer fields width and height. Then implement two methods for Rectangle: area(&self) (which computes the surface of the rectangle) and fits_in(&self, other_rect: &Rectangle) which indicate if self fits completely inside the other_rectrectangle.

Correction
#![allow(unused)]
fn main() {
struct Rectangle {
    height: i32,
    width: i32,
}

impl Rectangle {
    fn area(&self)->i32{
        self.height * self.width
    }
}

impl Rectangle {
    fn fits_in(&self,other_rect: &Rectangle)->bool{
        if other_rect.width>self.width && other_rect.height>self.height {
            return true;
        } else  {
            return false;
        }
    }
}
}

Enum and pattern matching

Course: Enum and option Enum (from https://doc.rust-lang.org/book/)

As in C, Enum gives you a way of saying a value is one of a possible set of values. For instance, An IP address can be either an IPv4 address or an IPv6 address, but not both at the same time:

#![allow(unused)]
fn main() {
enum IpAddrKind {
    V4,
    V6,
}
}

This allows to store the two possible kind in "the same" memory location given that they will never be active together in one instance.

A new feature comparing to C is that we can put data directly into each enum variant.

#![allow(unused)]
fn main() {
enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);

let loopback = IpAddr::V6(String::from("::1"));
}

Rust has an extremely powerful control flow construct called match that allows you to compare a value against a series of patterns and then execute code based on which pattern matches. Pattern matching in an important area of computer science and compilation, we just show a very simple example here:

#![allow(unused)]
fn main() {
enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}
}

The Option Enum

As explained in TD1, the Null value was invented to represent the "no value" value and it was a bad invention. With the Option Enum Rust proposes a mecanism that explicitely distinguish the cases where a variable has a value or no value. Rust defines (in the standard library, in the prelude) a particular Option<T> enum:

#![allow(unused)]
fn main() {
 enum Option<T> {
    None,
    Some(T),
}
}

The <T> syntax is a feature of Rust called a generic type parameter (similar to template in C++) that will be explained hereafter.

Write a function that computes the square root of a f32 number and return None if the number is negative.

Correction
fn square_root(a: f32) -> Option<f32>{
    match a  {
        n if n >= 0.0 => Some(a.sqrt()),
        _ => None,
    }
}

fn main() {
    println!("square root of N={:?}: {:?}",5,square_root(5.0));
    println!("square root of N={:?}: {:?}",-5,square_root(-5.0)); 
}

Of course, a Some(T) object cannot be "casted" or "simplified" in a T object. The only way to get the Tobject is to unwrap the option.

unwrap will be explained in next course (for Result type), here it will return the Tobject or panic in case of None. The Option<T> enum has a large number of methods that are useful in a variety of situations https://doc.rust-lang.org/std/option/enum.Option.html.

Try to unwrap (i.e. apply .unwrap()) your square root for positive and negative number.

Correction
fn square_root(a: f32) -> Option<f32>{
    match a  {
        n if n >= 0.0 => Some(a.sqrt()),
        _ => None,
    }
}

fn main() {
    println!("square root of N={:?}: {:?}",5,square_root(5.0).unwrap());
    println!("square root of N={:?}: {:?}",-5,square_root(-5.0).unwrap()); //fails
}

Generic Types

Every programming language has tools for effectively handling the duplication of concepts. In Rust, one such tool is generics. Generic Types, Traits and Lifetimes are three kind of generics.

Generic Data Types can be used in the definition of functions, struct or enum. Generic Data Type are very close to the template concept in C++.

Write a function that will be able to find the minimum of a Vector may this vector be a vector of i32, f32 or characters or on any type that has the possibility of comparing elements. Use the following function signature: smallest<T: PartialOrd> (v: &[T])-> &T

Correction
fn smallest<T: PartialOrd> (v: &[T])-> &T {
    let mut min=&v[0];
    for e in v{
        if e < min {
            min = e;
        }
    }
    min
}

fn main() {
    let v1 = vec![2,7,9,1];
    println!("min v1= {}",smallest(&v1));
    let v2 = vec![2.0,7.7,9.9,1.1];
    println!("min v2= {}",smallest(&v2));
    let v3 = vec!['a','t','4','j'];
    println!("min v3= {}",smallest(&v3));
}

Define a structure Point with two fields xand y that can be integer or floating point. Is Point{x:2,y:2.3} a valid instance of the structure Point?

Correction

No, because the types of the two fields are not some same: either Point{x:2, y:3} or Point{x:2.0, y:2.3}.

#![allow(unused)]
fn main() {
struct Point<T> {
    x: T,
    y: T,
}
}

Traits: Defining Shared Behavior

A trait defines the functionality or behaviour that a particular type has and can share with other types. We have already seen some very common traits: Clone or Debug. The trait Clone for instance represents the fact that a variable of a type can be duplicated (i.e. cloned) to a second instance of the type, identical to the variable but refering to a different memory location. Traits are similar to a feature often called interfaces in other languages, although with some differences.

Defining a trait consists in the following syntax: impl TheTrait for TheType {...}.

In general, trait names should be defined in UpperCamelCase (e.g. IsEven) and traits methods should be defined with snake_case name (e.g. is_even(&self)). Implementing a trait for a particular type is done using impl name_of_trait for name_of_type{[...]}.

Define a trait IsEven that is composed of the method is_even(&self). Implement the trait for the Rectangletype defined préviously (a Rectangle is even if both height and width are even)

Correction
trait IsEven {
    fn is_even(&self)->bool;
}

#[derive(Debug)]
struct Rectangle {
    height: i32,
    width: i32,
}

impl IsEven for Rectangle {
    fn is_even(&self)->bool {
        return (self.height%2 == 0) && (self.width%2 == 0)
    }
}

fn main() {
    println!("Hello, world!");
    let r1=Rectangle{height:20,width:30};
    println!("Rectangle {:?} is even: {}",r1,r1.is_even());
}

You can specify a default implementation of each method in the definition of the trait (then an explicit implementation will hide the default implementation)

Trait as parameter: the Trait Bound Syntax

Trait can be used as function parameter, the function will be valid for any type implementing the trait. There are two possible syntaxes, the impl Trait syntax for simple cases and the Trait Bound Syntax for the general case:

fn myFunction(a_variable: &impl TheTrait) { [..]}

fn myFunction<T: TheTrait>(a_variable: &T) { [..]}

Define a function notifyEven that takes as parameter a type that implements the trait IsEven and notify (i.e. print) the fact that the object is even. Note that you can implement IsEven and Debug traits together by specifying IsEven+Debug.

Correction
fn notify_even<T: IsEven+Debug> (o: &T){
    println!("This object: {:?} is even? {}",o,o.is_even());
}

Sometimes, this syntax can be heavy and you can use the where Clause: instead of writing this:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

we can use a where clause, like this:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{

The impl trait syntax can be used to specify that a result of a function must implement a trait.

Using Trait Bounds to Conditionally Implement Methods

The following example (from https://doc.rust-lang.org/book/ch10-02-traits.html) illustrates the fact the trait can be used to conditional implementation of methods

#![allow(unused)]
fn main() {
use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}
}

Programming paradigms in Rust

This section is largely inspired by https://corrode.dev/blog/paradigms/.

Rust is a multi-paradigm programming language, accommodating imperative, object-oriented, and functional programming styles. It is important to be aware that the programming paradigm is an important design choice when you start a new Rust program. The choice of style often depends on a developer’s background and the specific problem they’re addressing. but there are also many "known habits" of Rust developpers.

A specificity compared to other recent languages is the important and growing influence of functional programming in Rust.

A simple example: integer Vector sum.

Consider the problem of suming all the component of vector with i32 values. Write a program to do it in an iterative/imperative way (i.e. a loop accumulating in a temporary variable)

Correction
#![allow(unused)]
fn main() {
let vector = vec![9,6,-2,7];
    let mut sum = 0;
    for i in 0..vector.len()
     {
        sum += vector[i];
    }
    println!("Hello, world sum = {}!",sum);
}

Do the same thing in a more functionnal way: Use the iter() method of Vector type and sum() method of iterators

Correction
#![allow(unused)]
fn main() {
let vector = vec![9,6,-2,7]; 
let sum2 :i32 = vector.iter().sum();
println!("Hello, world sum2 = {}!",sum2);
}

The second formulation is, of course, much more concise, as is often the case in functional programming, but it is less suited to certain types of processing (matrix calculations, for example).

A More Complete Example

Consider the following Rust code, which defines a list of several languages along with the paradigms they are associated with. You will start from this code. The task will be to find the top five languages that support functional programming and have the most users.

#![allow(unused)]
fn main() {
#[derive(PartialEq,Clone,Debug)]
enum Paradigm {
    Functional,
    ObjectOriented,
}

#[derive(Clone,Debug)]
struct Language {
    name: &'static str,
    paradigms: Vec<Paradigm>,
    nb_users: i32,
}

impl Language {
    fn new(name: &'static str, paradigms: Vec<Paradigm>, nb_users: i32) -> Self {
        Language { name, paradigms, nb_users }
    }
}

let languages = vec![
    Language::new("Rust", vec![Paradigm::Functional,Paradigm::ObjectOriented], 100_000),
    Language::new("Go", vec![Paradigm::ObjectOriented], 200_000),
    Language::new("Haskell", vec![Paradigm::Functional], 5_000),
    Language::new("Java", vec![Paradigm::ObjectOriented], 1_000_000),
    Language::new("C++", vec![Paradigm::ObjectOriented], 1_000_000),
    Language::new("Python", vec![Paradigm::ObjectOriented, Paradigm::Functional], 1_000_000),
];
}

Give an imperative solution to the task using nested for loops

Correction
#![allow(unused)]
fn main() {
fn five_best_func_lang1(lang_list: &Vec<Language>)->Vec<Language>{
    let mut fun_lang: Vec<Language> =   vec![];
    for l in lang_list {
        if l.paradigms.contains(&Paradigm::Functional){
            fun_lang.push(l.clone());
        }
    }
    //buble sort to sort the vector
    let mut changed = true;
    while changed {
        changed = false;
        for i in 1..fun_lang.len(){
            if fun_lang[i].nb_users > fun_lang[i-1].nb_users {
                fun_lang.swap(i,i-1);
                changed = true;
            }
        }
    }
         //Select the first five
    while fun_lang.len()>5 {
        fun_lang.remove(fun_lang.len()-1);
        }
    fun_lang 
    }
}

Give a more functional implementation by transforming the vector in an iterator and using the following method (see https://docs.rs/itertools/latest/itertools/trait.Itertools.html):

  • use itertools::Itertools;

  • into_iter() method transforms a Vector into an iterator

  • filter() method can keep only element with a property (use a lambda as an argument to filter)

  • sorted_by_key() sorts all iterator elements into a new iterator in ascending order (use Reverse())

  • collect() transform an iterator into a collection.

Correction
#![allow(unused)]
fn main() {
fn five_best_func_lang2(lang_list: &Vec<Language>)->Vec<Language>{
      lang_list.into_iter()
            .sorted_by_key(|lang| lang.nb_users)
            .filter(|lang| lang.paradigms.contains(&Paradigm::Functional))
            .rev()
            .take(5)
            .cloned()
            .collect()
}
}

Which implementation is more efficient in terms of computation time?

-use std::time::Instant;

  • Or (more complex) use criterion crate:
#![allow(unused)]
fn main() {
[dev-dependencies]
criterion = "0.5"
}
Correction
   let start = Instant::now();
   let l2 = five_functionnal_lang2(&languages);
   let t2 = start.elapsed();
   println!("func2: {:?}", t2);