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
andimpl
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 hastrait
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 andtrait bounds
, to constraint the allowed types.
- Objects contain data and behaviour:
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.
Property | struct or enum | trait | Objects in other languages |
---|---|---|---|
Stores Data | Yes | No | Yes |
Stores Behaviour | No | Yes | Yes |
Data and behaviour | Seperated by impl blocks. | Combined | Combined |
Uses | Store same items together | Store common behaviour and allow abstraction | Store same items and their common behavior |
-
An example:
-
Problem: Let's say initially we have components such as
Button
andImage
that may use a common functionality to draw on the screen. It's possible that someday programmers want to introduce one more component namedSelectBox
. 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 astruct
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 theDraw
:#![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 aScreen
instance that has a list of components all of typeButton
or all of typeTextField
. 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 ofButton
,TextField
etc.
- Generics with trait bounds: If you’ll only ever have homogeneous collections. For Example, all elements of vector will be of type
-
-
Static Dispatch vs Dynamic Dispatch:
Static Dispatch | Dynamic 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 onPost
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(); }
-