OOP (Object Oriented Programming)

  • Characterstics of OOP:
    • Objects contain data and behaviour:
      • Programs should make up of objects.
      • An object packages both data and it's methods.
      • Rust offers struct, enum and impl blocks to provide this characterstic.
    • Encapsulation:
      • When the implementation details are hidden from the code that is using the object.
      • The only way to use an object is through it's public API.
      • This enables the programmer to change and refactor an object’s internals without needing to change the code that uses the object.
      • Rust encapsulates everything by default and offers pub keyword to make things public.
    • Inheritance:
      • When an object can inherit from another object’s definition, so that it can use it's parent object's data and behavior without defining them again.
      • Rust doesn't offers inheritance between struct, but has trait where you can use default methods that is both reusable and can be overridden.
      • The reasons to use inheritance are: code reusability and polymorphism.
      • Polymorphism means that you don't need to explicitly define the type in code, but can be detected during runtime. It is useful when two types share same characterstics.
      • Rust offers polymorphism in a more general manner. It offers generics to generalize the accepted types and trait bounds, to constraint the allowed types.

Note: People think "polymorphism is synonymous with inheritance". But it is a more general concept which means that a certain code can be referred to multiple types. It is used when two types share some common characterstics. In inheritance, those types are only subclasses.

  • Problems with Inheritance:

    • It adds the risk of sharing more code than necessary.
    • Subclasses are forced to share all the characterstics of the parents, even though sometimes it's not necessary or even undesired.
    • Sometimes calling the functions on the subclass doen't makes sense and even cause errors.
    • Due to this, some programming languages will only allow a subclass to inherit from one class, further restricting the flexibility of a program’s design.
  • Rust is different, it takes a completely different approach by using trait objects instead of inheritance.

Defining a common behaviour using trait

  • A trait object points to both:

    • An instance of a type implementing our specified trait
    • A table used to look up trait methods on that type at runtime.
Propertystruct or enumtraitObjects in other languages
Stores DataYesNoYes
Stores BehaviourNoYesYes
Data and behaviourSeperated by impl blocks.CombinedCombined
UsesStore same items togetherStore common behaviour and allow abstractionStore same items and their common behavior
  • An example:

    • Problem: Let's say initially we have components such as Button and Image that may use a common functionality to draw on the screen. It's possible that someday programmers want to introduce one more component named SelectBox. So, what we'll end up with are different types of structures that wants to use a common functionality.

    • Solution: We can invent a common function named draw(), which will have different implementations for different types of components.

    • How to build: We'll initialise a trait that can be shared among various components and a struct that can hold these compoenents.

    • The trait will look like this:

      #![allow(unused)]
      fn main() {
      // A common functionality shared between multiple components
      pub trait Draw {
          fn draw(&self);
      }
      }
    • We can build a struct that holds the components that implements the Draw:

      #![allow(unused)]
      fn main() {
      pub struct Screen {
          // Box will allow to store the components on heap
          // dyn keyword will add the ability to detect a type that implements Draw on runtime
          pub components: Vec<Box<dyn Draw>>,
      }
      
      impl Screen {
          pub fn run(&self) {
              for component in self.components.iter() {
                  component.draw();
              }
          }
      }
      }
    • The difference with the alternative implementation using trait bounds in struct is that it restricts us to a Screen instance that has a list of components all of type Button or all of type TextField. At compile time, the definitions will be monomorphized.

      #![allow(unused)]
      fn main() {
      pub struct Screen<T: Draw> {
          pub components: Vec<T>,
      }
      
      impl<T> Screen<T>
      where
          T: Draw,
      {
          pub fn run(&self) {
              for component in self.components.iter() {
                  component.draw();
              }
          }
      }
      }
    • Programmers can now create new components like this:

      #![allow(unused)]
      fn main() {
      use gui::Draw;
      
      pub struct Button {
          // Some fields
      }
      
      impl Draw for Button {
          fn draw(&self) {
              // code to actually draw something
          }
      }
      }
    • Users of this library can now use it like this:

      use gui::{Button, Screen};
      
      fn main() {
          let screen = Screen {
              components: vec![
                  Box::new(SelectBox {
                      width: 75,
                      height: 10,
                      options: vec![
                          String::from("Yes"),
                          String::from("Maybe"),
                          String::from("No"),
                      ],
                  }),
                  Box::new(Button {
                      width: 50,
                      height: 10,
                      label: String::from("OK"),
                  }),
              ],
          };
      
          screen.run();
      }
    • This concept is similar to the concept like duck typing: if it walks like a duck and quacks like a duck, then it must be a duck!

    • Use Cases:

      • Generics with trait bounds: If you’ll only ever have homogeneous collections. For Example, all elements of vector will be of type Button.
      • Box<dyn T>: You can use heterogeneous collections. For Example, elements can be a mix of Button, TextField etc.
  • Static Dispatch vs Dynamic Dispatch:

Static DispatchDynamic Dispatch
Concrete types are decided at compile time.The compiler emits code that at runtime will figure out which method to call.
Compiler writes some new code for various concrete types.At runtime, it is decided whether a selected type can follow the requirements.
When we use trait bounds on generics, static dispatch happens.When we want to perform dynamic dispatch, we can use the dyn keyword.
No runtime cost is added.Some runtime cost is added.

The State Pattern

  • The state pattern is an object-oriented design pattern.

  • The current state is stored inside the struct, along with it's value(s).

    #![allow(unused)]
    fn main() {
    pub struct Post {
        // Box and dyn are used because the state variable
        // will have different states during the life of Post
        state: Option<Box<dyn State>>,
        content: String,
    }
    }
  • There are state objects, you can create a new object by implementing this trait.

    #![allow(unused)]
    fn main() {
    trait State {
        // The first two functions, results in transitions to different states.
        fn request_review(self: Box<Self>) -> Box<dyn State>;
        fn approve(self: Box<Self>) -> Box<dyn State>;
        // This function, can be called on any state object,
        // similar to the above two functions, except instead
        // of causing a state transition, it will return value
        // as if we conditionally returned output for each state
        fn content<'a>(&self, _post: &'a Post) -> &'a str {
            ""
        }
    }
    }
    • The state pattern is built such that, methods defined on state objects will cause changes in the Post, but the methods defined on Post will have no idea what these changes will look like. Hence, state objects will encapsulate behaviour changes from the main struct.

    • You can look at it's complete implementation over here.

    • Some downsides of State Pattern:

      • Extra Modifications: If we add a new state, we'll need to modify other states too. It's due to the reason that one state can only make transitions to another state.
      • Code Duplication: It leads us to write common code inside state objects, as we cannot write directly in trait's default implementation because traits don't know about the concrete type.
    • There's another implementation of state pattern in Rust. It doesn't follow the classic OOP pattern, as we'll require to store the object in new variable, whenever a state transition will happen. Here's the code.

      #![allow(unused)]
      fn main() {
      // This will cause a state transition from PendingReview to Published
      let post = post.approve();
      }