Course 1: 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 concepts: Structs are similar to tuples in that both hold multiple related values. Unlike with tuples, in a struct you’ll name each piece of data so it’s clear what the values 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:

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 as for example:

    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.:

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):

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 life time of the string it points to). In a Struct all fields must have the same lifetime.

Try to solve adding lifetime in your definition using the compiler message ?

Methods in Rust

As in any object oriented langage, methods are defined within the context of a struct making a struct a regular object as defined by 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 { le 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.

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:

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.

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:

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:

 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.

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.

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

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?

Traits: Defining Shared Behavior

A trait defines the functionality 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 dupplicated (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 defining the methods we can call on that type, using the keywork trait. 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)

You can specify a default implementation of each method in the definition of the trait (then an explicite 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. The usual syntax for that is called the Trait Bound Syntax:

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

Define a function notifyEven that takes as paremeter a type that implements the trait IsEven as parameter 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.

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

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 paradigm 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 programming. 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.

An originality compared to other recent languages is the important influence of functionnal 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)

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

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 that 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.

#[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 a imperative solution to the task using nested for loops

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

  • into_iter() method transforms a Vector in a 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.

Which implementation is more efficient in terms of computation time? TODO Correction