7.9 KiB
Characteristics of Object-Oriented Languages
There is no consensus about what features a language must have to be considered object-oriented.
Rust is influenced by many programming paradigms, including OOP.
Arguably, OOP languages share certain common characteristics, namely objects, encapsulations and ingeritance.
Lets see what each of those means and whether Rust supports it.
Object Contain Data and Behavior
The book Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides (Addison-Wesley Professional, 1994).
This is colloquially referred to as The Gang of Four book, is a catalog of object-oriented design patters.
It defines OOP as this:
Object-oriented programs are made up of objects.
An object packages both data and the procedures that operate on that data.
The procedures are typically called methods or operations.
Using this definition, Rust is object-oriented: structs and enumbs have data, and impl
blocks provide methods on structs and enums.
Even though structs and enums with methods aren't called obejcts, they provide the same functionaliy accound to the Gang of Four's definition of objects.
Encapsulation that Hides Implementation Details
An aspect commonly associated with OOP is the idea of encapsulation.
This means that the implementation details of an object aren't accessible to code using that object.
Therefore, the only way to interact with an object is through its public API.
Code using the object shouldn't be able to reach into the object's internals and change data or behaviro directly.
This enables the programmer to change and refactor an object's internals without needing to change the code that uses the object.
We discussed previously how to control encapsulation in Ch7.
We can use the pub
keyword to decide which modules, types, function and methods in our code should be public .
By default everything else is private.
For example, if we defined a struct AveragedCollection
that has a field containing a vector of i32
values.
The struct can also have a field that contains the average of te vlaues in the vector, meaning the average doesn't have to be computed on demand whenever anyone needs it.
In other words, AveragedCollection
will cache the calculated average of the values in the vector.
Meaning the average doesn't have to be computed on demand whenever it is needed.
AveragedCollection
will cache the calculated average for us.
Here is the definition of the AveragedCollection
struct
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
This struct is marked pub
os that other code can use it.
The fields within the struct remain private.
This is improtant becuase we want to ensure that whenever a value is added or removed from the lust, the average is also updated.
We accomplish this by implementing add
, remove
and average
methods on the struct.
This is shown here
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
The public methods, add
, remove
and average
are the only ways to access or modify data in any instance of AverageCollection
.
When an item is added or removed to list
, using the associated method, the implementations of each call the private update_average
method that handles updating the average
field as well.
We leave the list
and average
fields private, so that there is no way for external code to add or remove items to or form the list
field directly.
The average
method returns the value in the average
field, this allows for external code to read the average
but not modify it.
Because we have encapsulated the impleentation details of AveragedCollection
.
We can easily change aspects, such as the data structure, in the future.
For example, we could use a HashSet<i32>
instead of a Vec<i32>
for the list
field.
As long as the signatures of the add
, remove
, and average
public methods stay the same.
Code using AveragedCollection
wouldn't need to change to order to compile.
If we made list
public instead, this wouldn't necessarily be the case
HashSet<i32>
and Vec<i32>
have different methods for adding and removing items, so the external code would likely have to change if it were modifying list
directly.
If encapsulation is a required aspect for a language to be considered object-oriented, then Rust meets that requirement.
The option to use pub
or not for different parts of code enbalbes encapsulation of implementation details
Inheritance as a type System and as Code Sharing
Inheritance is a mechanism whereby an object can inherit elements from another obejct's definition, thus gaining the parent object's data and behavior without you having to define them again.
If a language must have inheritance to be an obejct-oriented, then Rust is not one.
There is no way to define a struct that inherits the parent struct fields and method implementations without using a macro.
However, if you are use to having this tool available, you cna use other solutions in Rust, depending on your reason for reaching for inheritance.
Yuo choose inheritance for main reasons.
Resue of code: you can implement particular behavior for one type, and ingeritance enables ou to reuse that implementation for a different type.
You can do this in a limited way in Rust ocde using default trait method implementations.
We saw this before with the default implemetnation of the summarize
method on the Summary
trait.
Any type implementing the Summary
trait would have the summarize
method available on it without any further code.
This is similar to a parent class having an implementation of the summarize
method when we implement
the Summary
trait, which is similar to a child class overriding the implementation of a method inherited form a parent class.
The other resaon to use ingeritance relates to the type system: to enable a child to be used in the same place as the parent type.
This has another name polymorphism, which means that you can substitute multiple objects for each other at runtime if they share certain characteristics.
Polymorphism
To many this is synonymous with inheritance.
But it is actually a more general concept that refers to code that can work with data of multiple tpyes.
For inheritance, those types are generally subclasses.
Rust instead use generics to abstract over different possible types and trait bounds to impose constraints on what those tpyes must provide.
This is sometimes called *bounded parametric polymorphism*.
Inheritance has recently fallen out of favor as a programming deisgn solution, becuase it is often at risk of sharing more code than necessary.
Subclasses shouldn't always share all characteristics of thier parent class but will do so with inheritance.
This can make a program's design less flexible.
This also introduces the possibility of calling methods on subclasses that don't make sense or that cause errors because the methods don't apply to the subclass.
Additionally some languages will only allow single inheritance, meaning a subclass can only inherit from one class, thus further restricting the flexibility of a program's design.
These reasons are why Rust takes the different approach of using trait objects instead of inheritance.
Next lets see how trait obejcts enable polymorphism in Rust. Here