RustBrock/Characteristics of OO Languages.md
2025-04-02 15:37:00 -06:00

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