Object oriented Programming

In this section we'll use structure our code as objects. It may be a good idea to first review the introduction to struct.

An object (used interchangeably here with struct and class) contains two things:

  • Variables, also known as state.
  • Methods, which can act on this state.

These are defined in the struct block and the impl block respectively. These are defined for any instance of this class. Specific instances (variables) of the class, where you use let variable: Class = Class::new() have specific values for each of their variables.

Variables should typically not be accessed directly, and instead use methods to read or write their values.

It is common to define a public (pub) new() method to create an instance of the class. This may compute the state from some of the arguments. For example:

#[derive(Debug, Default)]
struct Cat {
    name: String,
    age: usize,
    tabby: bool,
}

impl Cat {
    pub fn new(name: &str, age: usize, pattern: &str) -> Self {
        let tabby = if pattern == "stripes" {true} else {false};
        Self {name: name.to_string(), age, tabby}
    }

    pub fn age(&self) -> usize {
        self.age
    }

    pub fn year_passed(&mut self) {
        self.age += 1;
    }

    pub fn is_tabby(&self) -> bool {
        self.tabby
    }
}

pub fn main() {
    let martha = Cat::new("Martha", 6, "stripes");
    let annie = Cat::new("Annie", 7, "tuxedo");
    println!("{:?} {:?}", martha, annie);
}

Methods usually take a reference to self to allow access to the state, with mut if any of the state is changed.

The return type of new() should the the type of the class itself, i.e. Cat. Using the keyword Self (note the capital) is the same in the above example of writing Cat, but with the advantage that if the class name is ever changed this doesn't have to be updated more than once.

Deriving Debug lets us print the contained values with the debug directive {:?}, deriving Default allows a default (empty) value of the class to be set.

You can also make a new class directly by defining all of its state:

#![allow(unused)]
fn main() {
struct Cat {
    name: String,
    age: usize,
    tabby: bool,
}

let martha = Cat {name: "Martha".to_string(), age: 6, tabby: true};
}

But it's usually better to define a new() method (sometimes known as the constructor) so you have more control.

More advanced uses

We will not use these here, but the following are common in object-oriented programming.

You may sometimes also use inheritance, where objects have relationships like parent and child sharing some of their variables and methods. An example would be a parent class Animal with variables legs and method walk(), and child classes Cat and Dog which inherit both of these and then add their own variables (e.g. tabby and gundog) and methods meow() and bark(). These may be overriden, for example the Cat class may provide its own definition of the walk() method.

Polymorphism is where a the actual function definition used is different depending on the type used. This is very common in R code, where print() and plot() and summary() all do different things depending on the type they are passed. Try making a linear regression with model <- lm(y ~ x) and running these functions on model. If you pass these functions a data.frame or a phylo object they will all still run, but be running different functions.

A practical example: Gaussian blur

As our exercise, we will use rust to apply a Gaussian blur on a grayscale image. You can find a good overview of how to do this here: https://stackoverflow.com/a/1696200

We'll do this in four steps:

  1. Calculate the Gaussian filter
  2. Create a struct for 2D arrays (matrices)
  3. Apply the filter across the image (convolution)
  4. Putting it all together on a real image

Step 1: Calculating the Gaussian filter

Let's first create a function in main() which makes a Gaussian filter of a given size radius:

use std::f64::consts::PI;

fn main() {
    let radius = 3;
    let sigma = max((radius as f64 / 2), 1.0);
    let kernel_width = (2 * radius) + 1;

    // We will add the code for this in step 2
    let kernel = PixelArray::zero_array(kernel_width, kernel_width);
    for x in -radius..radius {
        for y in -radius..radius {
            // Calculate the Gaussian density for given x, y
            let density = todo!()
            println!("{x} {y} {density}");
            // We will add the code for this in step 2
            kernel.set_pixel(x + radius, y + radius, density);
        }
    }
}

Add the code to calculate the Gaussian density at the given (x,y), using the following formula: \[ G(x, y) = \frac{1}{2\pi\sigma^2}\exp(-\frac{x^2 + y^2}{2\sigma^2}) \]

The value of sigma is set at the start, and the constant PI is already imported.

Step 2: Struct for 2D arrays

The pixels of our image and our filter are both going to be 2D arrays of numbers which we'll want to be able to access given a pair of (x,y) co-ordinates. We could use a vector of vectors Vec<Vec<i32>> which can be accessed by a[1][2], but instead we'll take a more efficient approach of using a single vector wrapped around rows (recall the row-major format in the first session):

#![allow(unused)]
fn main() {
struct PixelArray {
    pixels: Vec<f64>,
    height: usize,
    width: usize
}

impl PixelArray {
    fn get_pixel(&self, x: usize, y: usize) -> f64 {
        let index = x + y * self.width;
        self.pixels[index]
    }

    fn set_pixel() {
        todo!()
    }
}

}

Try and fill in the set_pixel() function definition to take an \(x\), \(y\) and value which updates the given pixel. Then create a simple PixelArray, and try getting and setting some pixel values. You can edit the above code directly, but it may be better to start your own rust project by running cargo new.

What happens if you pick an \(x\) or \(y\) which is invalid? Modify the functions to check for these cases and throw an error message using Option:

#![allow(unused)]
fn main() {
struct PixelArray {
    pixels: Vec<f64>,
    height: usize,
    width: usize
}

impl PixelArray {
    fn get_pixel(&self, x: usize, y: usize) -> Option<f64> {
        if x > self.width || y > self.height {
            None
        } else {
            let index = x + y * self.width;
            Some(self.pixels[index])
        }
    }
}
}

Do the same check for set_pixel(), but use panic!() to error in the function if an invalid x or y is given.

Then, add an empty_array() function to the impl, which should create a PixelArray full of zeros with the specified size:

#![allow(unused)]
fn main() {
fn zero_array(height: usize, width: usize) -> Self {
    let pixels = vec![0; height * width];
    Self { pixels, height, width }
}
}

Finally, add a function to the impl block which normalises the kernel, making all the values sum to 1.

Step 3: Apply the image across the filter (convolution)

We are now going to apply the blur, which we do using a convolution. For an image, this means running the kernel across the image, multiplying each overlapping pixel value by the corresponding entry in the kernel, and summing these to get the new value for that pixel. This averages across the kernel, with a density given by the Gaussian function.

Once done in the main block, after loading your image (in step 4) you'll sweep over all the pixels row-by-row as follows:

#![allow(unused)]
fn main() {
for y in 0..image.height {
    for x in 0..image.width {
        image.apply_kernel(x, y, &kernel);
    }
}
}

But first we need to define the convolution function apply_kernel(). Add this as a method to PixelArray in the impl block as follows:

#![allow(unused)]
fn main() {
impl PixelArray {
    // get_pixel(), set_pixel() etc functions here...
    //...

    fn apply_kernel(&mut self, x_centre: usize, y_centre: usize, kernel: &PixelArray) -> f64{
        // NB: this prevents overflow at the edges, but the kernel is not normalised correctly
        let kernel_radius = (kernel.height - 1) / 2;
        let x_min = x_centre.saturating_sub(kernel_radius);
        let x_max = (x_centre + kernel_radius).min(self.width - 1);
        let y_min = y_centre.saturating_sub(kernel_radius);
        let y_max = (y_centre + kernel_radius).min(self.height - 1);

        let mut pixel_value = 0.0;
        for x in x_min..=x_max {
            for y in y_min..=y_max {
                let kernel_val = kernel.get_pixel(x + kernel_radius - x_centre, y + kernel_radius - y_centre);
                // Add to the pixel value by multiplying the pixel value at (x,y) by kernel_val
                todo!();
            }
        }
        pixel_value // return the result of the convolution at that pixel
    }
}
}

When you are using a usize, saturating_sub() will mean any subtraction which would be \( < 0\) will be truncated at zero rather than causing an error. The first five lines therefore find the range of pixels surrounding the centre pixel which don't overflow outside the boundary. (We are ignoring that at the edges we are missing some of the kernel).

Complete this function in so that pixel_value is summed correct in the inner loop, and at the end so that pixel_value is assigned to the correct pixel in the image by calling .set_pixel().

Step 4: Putting it all together on a real image

Let's blur the following image, which for simplicity is in black and white:

A royalty-free tiger

This is a bitmap, which is just an array of pixels plus some metadata, almost identical to your PixelArray struct. The values are u8 rather than f64, but are also stored in row-major order with a width and height.

Right click the image and save this into your rust project directory.

Rather than working out how to load and save bitmaps, we'll use the bmp package. To use this add to your Cargo.toml:

[dependencies]
bmp = "*"

and add:

#![allow(unused)]
fn main() {
extern crate bmp;
use bmp::Pixel;
}

at the top of main.rs. Using packages is usually this easy, as cargo will deal with downloading and compiling them for you. We'll go into a bit more detail at the end.

Using the package is easily and maps very well onto your existing struct. You can read the package docs to work out how to load and save bitmaps, or just add the following functions I made earlier to your impl (click the arrow to expand)

Loading and saving bitmaps
#![allow(unused)]
fn main() {
impl PixelArray {
    fn from_bitmap(file: &str) -> Self {
        let img = bmp::open(file).unwrap_or_else(|e| {
            panic!("Failed to open: {}", e);
         });
        let mut image_array = Self::empty_array(img.get_height() as usize, img.get_width() as usize);
        for y in 0..image_array.height {
            for x in 0..image_array.width {
                let px = img.get_pixel(x as u32, y as u32);
                let px_avg = px.b as f64; // Just take a single pixel, as r,g,b are all the same in a B&W image
                image_array.set_pixel(x, y, px_avg);
            }
        }
        image_array
    }

    fn to_bitmap(&self, file: &str) {
        let mut img = bmp::Image::new(self.width as u32, self.height as u32);
        for y in 0..self.height {
            for x in 0..self.width {
                let px_val = (self.get_pixel(x, y)) as u8;
                let px = Pixel::new(px_val, px_val, px_val); // Set r,g,b all the same for B&W
                img.set_pixel(x as u32, y as u32, px);
            }
        }
        img.save(file).unwrap_or_else(|e| {
            panic!("Failed to save: {}", e)
        });
    }
}
}

To get this all to work, add to your main() function after the kernel is set up:

  1. Load the tiger image using PixelArray::from_bitmap("tiger.bmp")
  2. Add the convolution loop in from above which calls .apply_kernel() over every pixel.
  3. Save the blurred image using the .to_bitmap() function.

You can then run your program with cargo run. If it works, it should look like this:

A blurry royalty-free tiger

Try with different radiuses of blur and see what happens.

Is there any difference in runtime between the debug and release versions?