The Three Pillars

Dear Computer

Chapter 12: Polymorphism Revisited

The Three Pillars

Object-oriented programming ruled the software development scene in the 1990s and 2000s. It was bolstered by considerable hype from developers who saw objects as the intuitive approach to organizing large software projects. As marketing teams touted the object-orientation of their products, the influencers of the time decreed that a language was only truly object-oriented if it supported these three “pillars” of object-oriented programming:

The languages C++, Java, and C# appeared in these decades and support all three pillars. Rust's first stable release didn't appear until 2015 and interestingly does not support all three pillars. Rust doesn't have classes in the traditional sense. While it supports subtype polymorphism, the community tends to favor parametric polymorphism as the vehicle for writing code to serve many types. It doesn't support inheritance. One struct or enum cannot inherit from another.

Had it been a contemporary of C++, Java, and C# at the height of the object-oriented boom, Rust would have been dismissed by many for not being object-oriented. By the time it did appear, software developers had sobered their judgements about object-orientation after encountering these issues with the paradigm:

Rust does not meet the popular definition of an object-oriented language, but perhaps the gatekeepers overstepped when defining the pillars. They appear to have looked at the languages of the day and extrapolated from them a set of prescriptions that didn't capture the deeper intent behind object-orientation. Similar under-informed laws have been advanced in the past. Aristotle looked around and saw moving objects slowing down, so he said that every object had a natural place it wanted to be. Galileo later came along and showed that a moving object maintained its motion unless an outside force like friction slowed it down. Maybe Rust does satisfy the deeper laws of object-orientation. Let's examine each in turn.

Encapsulation

The deeper intent behind the pillar of encapsulation is that types don't leave the responsibility of managing their values up to outside entities like a C struct does. This pillar is often implemented via classes. Earlier we defined a class as a bundle of state and behaviors. Rust doesn't have classes, but it does have enums and structs that encapsulate state, as demonstrated by these two types:

Rust
pub enum Duration {
  Days u32, 
  Hours u32, 
  Minutes u32, 
  Seconds u32, 
}

pub struct Ratings {
  upvotes: u32,
  downvotes: u32, 
}

Behaviors may be added to enums and structs with impl blocks. These blocks give the Duration type a behavior for converting to seconds and the Ratings type behaviors for upvoting and downvoting:

Rust
impl Duration {
  pub fn to_seconds(&self) -> u32 {
    match self {
      Duration::Seconds s => s,
      Duration::Minutes m => m * 60,
      Duration::Hours h => h * 60 * 60,
      Duration::Days d => d * 60 * 60 * 24,
    }
  }
}

impl Ratings {
  pub fn up(&mut self) {
    self.upvotes += 1;
  } 

  pub fn down(&mut self) {
    self.downvotes += 1;
  } 
}

Custom types in Rust aren't a single syntactic unit like they are in Java and C++, but they do encapsulate state and behaviors.

Inheritance

The deeper intent behind the pillar of inheritance is that not every new type has to start from scratch. It can borrow code from other sources. Rust doesn't allow enums or structs to inherit state, but it does allow them to plug in behaviors from traits. For example, we can add a default definition of the is_empty method to the Set interface:

Rust
trait Set {
  fn size(&self) -> usize;
  fn contains(&self, value: u32) -> bool; 
  fn add(&mut self, value: u32); 
  fn remove(&mut self, value: u32); 

  fn is_empty(&self) -> bool {
    self.size() == 0
  }
}

All implementors of Set will automatically have this method.

Traits cannot have fields, so they cannot be used to inherit state. Given the ambiguities that accompany state inherited from multiple sources, Rust's weaker form of inheritance may lead to safer software designs.

Subtype Polymorphism

The deeper intent behind subtype polymorphism is that high-level code can be written to serve many types at once. Parametric polymorphism is one way to achieve this, but it only works when the compiler knows the types. Subtype polymorphism is specifically needed when the types aren't known, perhaps because they range across a hierarchy as in a heterogeneous collection.

Rust's only hierarchies come through traits. If Rust were Java, we'd write code that serves a whole hierarchy by targeting a trait. This populate function attempts to populate a value of any type that implements Set with members 0 through 9:

Rust
fn populate(set: &mut Set) {
  for i in 0..10 {
    set.add(i);
  }
}

This is mostly correct, but direct targeting of traits was deprecated in 2015 and is an error in the latest Rust compilers. These days we must explicitly qualify any use of a trait where a type is expected with the dyn keyword:

Rust
fn populate(set: &mut dyn Set) {
  for i in 0..10 {
    set.add(i);
  }
}

The dyn keyword was added to make it clear that dynamic dispatch is going to be performed. The actual shape of a passed parameter is a value of the implementing type plus a pointer to the type's vtable. You can think of dyn as a wrapper type that adds a virtual pointer to an object's state.

Rust is not C++, Java, or C#, but it supports an object-oriented style of programming. Even better, it supports other styles of programming too, like procedural and functional.

← Subtype PolymorphismSupertypes and Subtypes →