Mor Shonrai

A blog covering everything from programming, data science, astronomy and anything that pops into my head.

Rust Tutorial Part III

In this tutorial we’ll start dealing with more advanced topics in Rust, specifically moving towards an object orientated programming approach. By the end of this tutorial you’ll understand how to make “objects” in Rust using structs. You’ll learn how to make generic and flexible structs and functions using generic types. You’ll also learn how to use traits to build common functionality for multiple data types at a time.

Object Orientated Programming in Rust

In contrast to languages like Python and C++, Rust diverges from class-based inheritance. It emphasizes struct composition and trait-based polymorphism.

Rather than relying on class inheritance, Rust promotes struct composition, allowing structs to contain instances of other structs or types.

traits, serving as a form of polymorphism, define sets of methods that types can implement, offering shared behaviors across different types without a single inheritance hierarchy.

This trait-based approach fosters modularity and flexibility while ensuring safety and performance.

Structs in Rust

Structs in Rust form the foundation of object-oriented programming (OOP). They can be seen as collections of variables that serve a related purpose or represent a specific context.

Consider the example below:

struct Point3D {
    x: f32,
    y: f32,
    z: f32,
    coord_system: String,
}
Rust

We’ve created a struct named Point3D, representing a point in 3D space.

The struct is defined by encapsulating member data within curly brackets {} after naming it.

Within this struct, we’ve defined fields such as x, y, and z, each having the data type f32, representing the coordinates in the x, y, and z axes, respectively.

Additionally, there’s a field named coord_system of type String, serving to describe the coordinate system.

In Rust, it’s common practice to separate each field with a , and a new line for readability.

The presence of a trailing , after the last field doesn’t cause a compile-time error and is often used to facilitate future struct modifications.

Methods can be implemented for a struct in Rust, functioning as functions that the struct itself can utilize. These methods can modify the struct, perform actions based on the field data, and more. The impl keyword is used to define these methods:

impl Point3D {
    
    // Return a new Point3D
    fn new() -> Point3D{
        Point3D{
            x: 0.0_f32,
            y: 0.0_f32,
            z: 0.0_f32,
            coord_system: String::from("cartesian"),
        }
    }

    // Get the magnitude of the Point3D
    fn get_magnitude(self :&Self) -> f32{
        (self.x.powi(2) + self.y.powi(2) + self.z.powi(2))
        .sqrt()  
    }

    // Add a constant value to the Point3D
    fn add_constant(self: &mut Self, c : f32) -> (){
        self.x += c / 3.0_f32.sqrt();
        self.y += c / 3.0_f32.sqrt();
        self.z += c / 3.0_f32.sqrt();
    }
}
Rust

In the code example above, we’ve implemented three methods for the `Point3D` struct.

  • The new function on line 3 creates and returns a new Point3D with default values at the origin (0, 0, 0). To use it: let mut my_point = Point3D::new();
  • Line 14 contains the get_magnitude method, which accesses the struct’s data without modifying it. It takes a non-mutable reference to itself (&Self) and returns a f32. To call it: my_point.get_magnitude().
  • The add_constant method, defined on line 20, modifies the Point3D‘s data using a given value. It requires a mutable reference to itself (&mut Self) and takes a constant (f32). Usage example: my_point.add_constant(3.14);. Note since f32 implements the Copy trait, we don’t need to pass by reference since the f32 will be copied when passed to the function.

Combining the above code would give the following usable example:

struct Point3D {
    x: f32,
    y: f32,
    z: f32,
    coord_system: String,
}


impl Point3D {
    
    // Return a new Point3D
    fn new() -> Point3D{
        Point3D{
            x: 0.0_f32,
            y: 0.0_f32,
            z: 0.0_f32,
            coord_system: String::from("cartesian"),
        }
    }

    // Get the magnitude of the Point3D
    fn get_magnitude(self :&Self) -> f32{
        (self.x.powi(2) + self.y.powi(2) + self.z.powi(2))
        .sqrt()  
    }

    // Add a constant value to the Point3D
    fn add_constant(self: &mut Self, c : f32) -> (){
        self.x += c / 3.0_f32.sqrt();
        self.y += c / 3.0_f32.sqrt();
        self.z += c / 3.0_f32.sqrt();
    }
}

fn main(){
    
    let mut my_point = Point3D::new();
    let mut a = 25.0;
    my_point.add_constant(a);

    println!("Magnitude = {}", my_point.get_magnitude());
    a += 12.3;
    println!("Added = {}", a);

}
Rust

On line 32 we use the new method to get a new empty Point3D. On line 39 we call the add_constant method, passing the mutable f32 a. On line 41 we call get_magnitude and print the results. On line 42 we add a value to a, showing that a wasn’t consumed by add_constant.

Generic Types

Generic types in Rust bear resemblance to C++ templates. They enable us to write versatile code that isn’t tied to specific data types. This allows for more reusable and adaptable code. Consider the following illustration:

Point2D<T>{
    x:T,
    y:T,
}
Rust

Here we have defined a generic Point2D struct that represents a 2D point in space. This struct is designed to work with any data type, as it utilizes the placeholder type T for both the x and y coordinates. Using this placeholder type allows the struct to remain agnostic to the specific data type used for its coordinates.

The versatility of this generic struct becomes apparent when implementing methods or functionalities that can work universally across various data types.

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

impl <T> Point2D<T>{
    fn get_x(self : &Self) -> &T{
        &self.x
    }

    fn get_y(self : &Self) -> &T{
        &self.y
    }
}
Rust

In the above we have implemented two functions assuming a type T. Each return references of type T. If we wanted to use these we could run:

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

impl <T> Point2D<T>{
    fn get_x(self : &Self) -> &T{
        &self.x
    }

    fn get_y(self : &Self) -> &T{
        &self.y
    }
}

fn main(){
    let my_point :Point2D<f32> = Point2D{x:0.1, y:4.3};
    println!("x = : {} ", my_point.get_x());
    println!("y = : {} ", my_point.get_y());
}
Rust

On line two, we are explicitly specifying the data type as f32.

We can combine traits and generics to enable custom data types that possess specific traits.

Traits in Rust

traits in Rust provide a means to define common interfaces that can be implemented by different structs. They enable struct types to share behavior or functionality through shared methods.

Lets consider the following. Humans can speak, we can produce noise in a coherent fashion that is understood by others. Let say that Humans have a function called speak. Behind the scenes, the human body will need to “do things” to speak, maybe Human::think() , Human::blow_air() and Human::open_mouth(). So when we call Human::speak(), we may call these three functions behind the scenes. We can define a trait called CanSpeak. In order to have this trait assignable, the type must be able to implement some function speak() that will return a noise. For humans, this is easy, we already have the Human::speak() functions.

Consider now we have dogs. While dogs can’t speak like humans can, they can be trained to produce a noise when told to “speak”. In this analogy this would be something like Dog::bark(). If we wanted to say that dogs have the CanSpeak trait, we would need to map the Dog::bark() method to a speak() function.

If both Humans and Dogs have the CanSpeak trait we can write functions that can take a generic data type (T) which implements the CanSpeak trait, knowing that T will always have some callable function T::speak() that we can utilize. Let’s see some examples of this.

It’s possible that the first time you’ll encounter traits is when try to print custom structs. Consider the following example:

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


fn main(){
    
    let mut my_point = Point2D::<f32>{x : 2.3, y: 5.7};

    println!("{}", my_point);
}
Rust

Here we define a Point2D struct which takes a generic type T. On line 9 we specify a f32 when creating a new type. On line 11 we try to print my_point. If we ran this we would see an error like:

error[E0277]: `Point2D<f32>` doesn't implement `std::fmt::Display`
  --> src/main.rs:11:20
   |
11 |     println!("{}", my_point);
   |                    ^^^^^^^^ `Point2D<f32>` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point2D<f32>`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `testbed` (bin "testbed") due to previous error
Rust

This is telling us that Point2D doesn’t implement the trait std::fmt::Display. This makes sense, if we want to print our custom struct we need to know what it is that we’re printing. We can implement this trait as follows:

use std::fmt;
struct Point2D <T> {
    x: T,
    y: T,
}

impl <T : fmt::Display> fmt::Display for Point2D<T>{
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {

        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main(){
    
    let mut my_point = Point2D::<f32>{x : 2.3, y: 5.7};

    println!("{}", my_point);
}
Rust

On line 1 we are using an external crate from the standard library called fmt. As the name might suggest fmt has lots of formatting functionality. It also has the fmt::Display trait that we want to implement for our struct. We implement this for Point2D on lines 7-12.

Notice the syntax on line 7. Since we’re using a generic type T, we want to make sure that this generic type can be displayed so we require that type T also implements the fmt::Display trait. We can add multiple requirements like <T: fmt::Display + fmt::Debug + CanSpeak>. This would require that type T implement the fmt::Display, fmt::Debug and the CanSpeak traits. The fmt::Display trait has a single function fmt which takes a immutable reference to itself and a mutable reference to a fmt::Formatter type (we don’t need to worry about this) and returns a fmt::Result type (again don’t worry). We use the write! macro to format the string to print “(x, y)”. When we print on line 18 we would see:

(2.3, 5.7)
Rust

So we can now pass Point2D to any function that takes any generic data type that implements the fmt::Display trait.

Let’s write our own trait:

trait PointLike{
    fn get_magnitude(self: &Self) -> f32;
    fn add(self: &mut Self, c : f32) -> ();    
}
Rust

Here we have a trait called PointLike. In order to be “PointLike” a type must be able to call get_magnitude and add. When defining the trait we sketch out the functions, adding what arguments to take and what is returned. get_magnitude takes an immutable reference to Self and returns a f32, while add takes a mutable reference to Self and a f32 and returns nothing (()). We don’t need to know what get_magnitude and add do under the hood, just the arguments they take and the return type.

We can implement the PointLike trait for the Point2D struct:

struct Point2D {
    x: f32,   
    y: f32,
}

impl PointLike for Point2D{
    fn get_magnitude(self: &Self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }

    fn add(self: &mut Self, c : f32) -> () {
        self.x += c / 2.0_f32.sqrt();
        self.y += c / 2.0_f32.sqrt();
    }
}
Rust

Here we haven’t implemented any other functions for Point2D we’re using the impl for PointLike to define the functions. Let’s also define a Point3D:

struct Point3D{
    x: f32,
    y: f32,
    z: f32,
}

impl Point3D{
    fn new() -> Point3D{
        Point3D{x:0., y:0., z:0.}
    }

    fn add_constant(self : &mut Self, c :f32 ) -> (){
        self.x += c / 3.0_f32.sqrt();
        self.y += c / 3.0_f32.sqrt();
        self.z += c / 3.0_f32.sqrt();
    }

    fn abs(self : &Self) -> f32{
        (self.x.powi(2) + self.y.powi(2) + self.z.powi(2)).sqrt()
    }
}
Rust

Here we’ve defined the Point3D and implemented some functionality. We’ll notice that the add_constant and abs functions are similar to the add and get_magnitude functions required by the PointLike trait. We can extended Point3D to be PointLike by implementing this trait and simply mapping the functions. Something like:

impl PointLike for Point3D{
    fn add(self: &mut Self, c : f32) -> () {
        self.add_constant(c);
    }

    fn get_magnitude(self: &Self) -> f32 {
        self.abs()
    }
}
Rust

Here we’re using existing functions and adding the trait by simply adding calls to the preexisting functions.


Let’s extend this a little further and write some generic functions that can work on anything that implements the PointLike trait.

fn print_point_details <T:PointLike> (point: &T) -> (){
    println!("The magnitude is: {}", point.get_magnitude());
}
Rust

Here we have a function that takes a reference to a generic type T. We have specified that the generic type T must implement the PointLike trait (<T:PointLike>). Note that we are passing the generic type as a reference as they types don’t necessarily implement the Copy trait.

Wrapping all this together into a full working example we have something like:

trait PointLike{
    fn get_magnitude(self: &Self) -> f32;
    fn add(self: &mut Self, c : f32) -> ();    
}


struct Point3D{
    x: f32,
    y: f32,
    z: f32,
}

impl Point3D{
    fn new() -> Point3D{
        Point3D{x:0., y:0., z:0.}
    }

    fn add_constant(self : &mut Self, c :f32 ) -> (){
        self.x += c / 3.0_f32.sqrt();
        self.y += c / 3.0_f32.sqrt();
        self.z += c / 3.0_f32.sqrt();
    }

    fn abs(self : &Self) -> f32{
        (self.x.powi(2) + self.y.powi(2) + self.z.powi(2)).sqrt()
    }
}

impl PointLike for Point3D{
    fn add(self: &mut Self, c : f32) -> () {
        self.add_constant(c);
    }

    fn get_magnitude(self: &Self) -> f32 {
        self.abs()
    }
}

struct Point2D {
    x: f32,   
    y: f32,
}

impl PointLike for Point2D{
    fn get_magnitude(self: &Self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }

    fn add(self: &mut Self, c : f32) -> () {
        self.x += c / 2.0_f32.sqrt();
        self.y += c / 2.0_f32.sqrt();
    }
}

fn print_point_details <T:PointLike> (point: &T) -> (){
    println!("The magnitude is: {}", point.get_magnitude());
}


fn main(){
    
    let mut my_point = Point2D{x : 2.3, y: 5.7};
    let a = 1.5;
    my_point.add(a);
    println!("2D magnitude: {}", my_point.get_magnitude());


    let mut my_point_3D = Point3D::new();
    my_point_3D.add(a);
    println!("3D magnitude: {}", my_point_3D.get_magnitude());
    
    print_point_details(&my_point);
    print_point_details(&my_point_3D);
}
Rust

Summary

In this tutorial we learnt how to write object orientated programs in Rust. We used structs to create custom data types. We then implemented (impl) functionality to these data types to create methods that can access data within the struct. We then expanded the structs by adding generic data types. We used traits to define a common set of functions that we can implement for other data types. Using the traits we were also able to write generic functions that will work for any data types which implements our custom traits.

I hope this tutorial series helps you begin your Rust journey. There are still lots to learn in Rust such as macros, lifetimes and creating your own crates or packages. I may touch on some of these topics in the future (see other Rust posts!). In the meantime let me know what you think, I’d love to hear your thoughts!