Contents

Ch.18 — Object-Oriented Programming Features

Chapter 18

Object-Oriented Programming Features


Object-oriented programming (OOP) is a way of modeling programs. Objects as a programmatic concept were introduced in the language Simula in the 1960s. Those objects influenced Alan Kay’s programming architecture in which objects pass messages to each other; to describe it, he coined the term object-oriented programming in 1967. Many competing definitions describe what OOP is: by some of them Rust is object oriented, by others it is not.

This chapter explores characteristics commonly considered object oriented and how they translate to idiomatic Rust. It then implements an object-oriented design pattern in Rust and discusses the trade-offs versus a solution that uses some of Rust’s strengths instead.

  • 18.1 Characteristics of Object-Oriented Languages
  • 18.2 Using Trait Objects to Abstract over Shared Behavior
  • 18.3 Implementing an Object-Oriented Design Pattern

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

1

Chapter 18 · 18.1 Characteristics of Object-Oriented Languages

What makes a language object oriented?

There is no consensus in the programming community about what features a language must have to be considered object oriented. Rust is influenced by many paradigms, including OOP (Chapter 13 explored the features that came from functional programming). Arguably, OOP languages share certain common characteristics — namely, objects, encapsulation, and inheritance. Let’s see what each means and whether Rust supports it.

Objects contain data and behavior

Design Patterns: Elements of Reusable Object-Oriented Software by Gamma, Helm, Johnson, and Vlissides (Addison-Wesley, 1994), colloquially the Gang of Four book, is a catalog of object-oriented design patterns. It defines OOP this way:

Definition 18.1 (Object). “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.”

By this definition Rust is object oriented: structs and enums have data, and impl blocks provide methods on them. Even though structs and enums with methods aren’t called objects, they provide the same functionality.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

2

Chapter 18 · 18.1 Characteristics of Object-Oriented Languages

Encapsulation that hides implementation details

Definition 18.2 (Encapsulation). The implementation details of an object aren’t accessible to code using that object. The only way to interact with an object is through its public API; code using the object shouldn’t be able to reach into its internals and change data or behavior directly. This lets the programmer change and refactor an object’s internals without changing the code that uses the object.

Chapter 7 showed how to control encapsulation: the pub keyword decides which modules, types, functions, and methods are public; by default everything else is private. For example, a struct AveragedCollection can hold a vector of i32 values plus a field with their average, so the average needn’t be computed on demand — the struct caches the calculated average.

Listing 18.1: An AveragedCollection struct that maintains a list of integers and the average of the items in the collection — src/lib.rs

pub struct AveragedCollection {

    list: Vec<i32>,

    average: f64,

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

3

Chapter 18 · 18.1 Characteristics of Object-Oriented Languages

Public methods guard the private fields

The struct is marked pub so other code can use it, but its fields stay private. That matters here: whenever a value is added to or removed from the list, the average must also be updated. We do this by implementing add, remove, and average methods on the struct.

Listing 18.2: Implementations of the public methods add, remove, and average on AveragedCollection — src/lib.rs

pub struct AveragedCollection {

    list: Vec<i32>,

    average: f64,

}

 

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,

        }

    }

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

4

Chapter 18 · 18.1 Characteristics of Object-Oriented Languages

Reading the average, updating it privately

Listing 18.2 (continued) — src/lib.rs

 

    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 an instance of AveragedCollection. When an item is added to list with add or removed with remove, each implementation calls the private update_average method, which updates the average field as well.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

5

Chapter 18 · 18.1 Characteristics of Object-Oriented Languages

Why the fields stay private

We leave the list and average fields private so external code has no way to add or remove items in the list field directly; otherwise the average field might get out of sync when the list changes. The average method returns the value of the average field, so external code can read the average but not modify it.

Because the implementation details are encapsulated, we can easily change aspects such as the data structure later. For instance, the list field could use a HashSet<i32> instead of a Vec<i32>. As long as the signatures of the public methods add, remove, and average stay the same, code using AveragedCollection wouldn’t need to change.

If list were public instead, this wouldn’t necessarily hold: HashSet<i32> and Vec<i32> have different methods for adding and removing items, so external code that modified list directly would likely have to change.

Remark. If encapsulation is a required aspect for a language to be considered object oriented, Rust meets that requirement: the option to use pub or not for different parts of code enables encapsulation of implementation details.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

6

Chapter 18 · 18.1 Characteristics of Object-Oriented Languages

Inheritance as a type system and as code sharing

Definition 18.3 (Inheritance). A mechanism whereby an object can inherit elements from another object’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 object oriented, Rust is not such a language: there is no way to define a struct that inherits the parent struct’s fields and method implementations without using a macro. But if inheritance is in your toolbox, Rust has other solutions, depending on why you reached for it.

There are two main reasons. The first is reuse of code:implement behavior for one type, then reuse it for a different type. Rust allows this in a limited way with default trait method implementations — as in Listing 10.14, where the Summary trait got a default summarize method. Any type implementing Summary has summarize without further code, like a child class inheriting a parent’s method. Overriding the default when implementing Summary is like a child class overriding an inherited method.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

7

Chapter 18 · 18.1 Characteristics of Object-Oriented Languages

Inheritance for the type system: polymorphism

The other reason to use inheritance relates to the type system: to enable a child type to be used in the same places as the parent type. This is also called polymorphism.

Definition 18.4 (Polymorphism). You can substitute multiple objects for each other at runtime if they share certain characteristics. More generally, polymorphism refers to code that can work with data of multiple types.

Polymorphism

To many people, polymorphism is synonymous with inheritance. But it’s actually a more general concept that refers to code that can work with data of multiple types. For inheritance, those types are generally subclasses.

Definition 18.5 (Bounded parametric polymorphism). Rust’s approach instead: generics abstract over different possible types, and trait bounds impose constraints on what those types must provide.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

8

Chapter 18 · 18.1 Characteristics of Object-Oriented Languages

Why Rust does not offer inheritance

Rust has chosen a different set of trade-offs by not offering inheritance. Inheritance has these risks:

  • It is often at risk of sharing more code than necessary. Subclasses shouldn’t always share all characteristics of their parent class but will do so with inheritance, which can make a program’s design less flexible.
  • It 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.
  • Some languages only allow single inheritance (a subclass can inherit from only one class), further restricting the flexibility of a program’s design.

For these reasons, Rust takes the different approach of using trait objects instead of inheritance to achieve polymorphism at runtime. The next section looks at how trait objects work.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

9

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

When the set of types must stay open

Chapter 8 noted that vectors can store elements of only one type. Listing 8.9 worked around this with a SpreadsheetCell enum whose variants hold integers, floats, and text, so a vector could represent a row of cells. That is a perfectly good solution when the interchangeable items are a fixed set of types known when the code is compiled.

Sometimes, though, we want the library user to be able to extend the set of valid types. Example: a GUI tool that iterates through a list of items, calling a draw method on each to draw it to the screen — a common technique for GUI tools. We’ll create a library crate called gui with the structure of a GUI library. It might include types such as Button or TextField, and gui users will want to create their own drawable types: one programmer might add an Image, another a SelectBox.

When writing the library, we can’t know and define every type other programmers might create. But we do know that gui must keep track of many values of different types and call draw on each. It doesn’t need to know what exactly happens when draw is called — only that the value has that method available.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

10

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

From a Component base class to a Draw trait

In a language with inheritance, we might define a class Component with a method draw. Other classes such as Button, Image, and SelectBox would inherit from Component and thus inherit draw. Each could override draw with custom behavior, while the framework treats all of them as Component instances and calls draw on them. Rust has no inheritance, so gui needs another structure that lets users create new compatible types.

Defining a trait for common behavior

We’ll define a trait named Draw with one method named draw. Then we can define a vector that takes a trait object.

Definition 18.6 (Trait object). A value that points to both an instance of a type implementing a specified trait and a table used to look up trait methods on that type at runtime. It is written as some sort of pointer (a reference or a Box<T> smart pointer), then the dyn keyword, then the trait — e.g. Box<dyn Draw>.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

11

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

What trait objects guarantee — and what they are not

We can use trait objects in place of a generic or concrete type. Wherever a trait object is used, Rust’s type system ensures at compile time that any value used in that context implements the trait object’s trait. Consequently, we don’t need to know all the possible types at compile time.

Remark. Why trait objects must use a pointer is explained in “Dynamically Sized Types and the Sized Trait” in Chapter 20.

In Rust we refrain from calling structs and enums “objects” to distinguish them from other languages’ objects. In a struct or enum, the data in the fields and the behavior in impl blocks are separated, whereas other languages often label data and behavior combined into one concept an object.

Trait objects differ from objects in other languages in that we can’t add data to a trait object. They aren’t as generally useful as objects in other languages: their specific purpose is to allow abstraction across common behavior.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

12

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

The Draw trait and a Screen of trait objects

Listing 18.3 defines a trait named Draw with one method named draw; this syntax is familiar from Chapter 10.

Listing 18.3: Definition of the Draw trait — src/lib.rs

pub trait Draw {

    fn draw(&self);

}

Next comes new syntax: Listing 18.4 defines a struct Screen holding a vector named components of type Box<dyn Draw>, which is a trait object: a stand-in for any type inside a Box that implements the Draw trait.

Listing 18.4: Definition of the Screen struct with a components field holding a vector of trait objects that implement the Draw trait — src/lib.rs

pub struct Screen {

    pub components: Vec<Box<dyn Draw>>,

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

13

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

Screen::run calls draw on every component

On the Screen struct we define a method named run that calls the draw method on each of its components, as shown in Listing 18.5.

This works differently from defining a struct that uses a generic type parameter with trait bounds. A generic type parameter can be substituted with only one concrete type at a time, whereas trait objects allow multiple concrete types to fill in for the trait object at runtime.

Listing 18.5: A run method on Screen that calls the draw method on each component — src/lib.rs

pub struct Screen {

    pub components: Vec<Box<dyn Draw>>,

}

 

impl Screen {

    pub fn run(&self) {

        for component in self.components.iter() {

            component.draw();

        }

    }

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

14

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

The alternative: generics and trait bounds

We could instead have defined the Screen struct using a generic type and a trait bound, as in Listing 18.6.

This restricts us to a Screen instance whose list of components is all of type Button or all of type TextField. If you’ll only ever have homogeneous collections, generics and trait bounds are preferable, because the definitions will be monomorphized at compile time to use the concrete types.

Listing 18.6: An alternate implementation of the Screen struct and its run method using generics and trait bounds — src/lib.rs

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();

        }

    }

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

15

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

Homogeneous versus mixed collections

On the other hand, with the method using trait objects, one Screen instance can hold a Vec<T> that contains a Box<Button> as well as a Box<TextField>. Next we see how this works, and then the runtime performance implications.

Table 18.1: The two Screen designs compared

Generic + trait bound (Listing 18.6)Trait object (Listing 18.5)
Field typeVec<T> where T: DrawVec<Box<dyn Draw>>
Types per ScreenOne concrete type at a timeMany concrete types, filled in at runtime
Example contentsAll Button, or all TextFieldBox<Button> and Box<TextField> together
CompilationMonomorphized to the concrete typeMethod looked up at runtime (dynamic dispatch)
Prefer whenCollections are always homogeneousUsers must be able to add new types

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

16

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

Implementing the trait: a Button

Now we add some types that implement the Draw trait, starting with Button. Actually implementing a GUI library is beyond the scope of this book, so the draw method has no useful body. To imagine what the implementation might look like, a Button struct might have fields for width, height, and label, as in Listing 18.7.

Listing 18.7: A Button struct that implements the Draw trait — src/lib.rs

pub struct Button {

    pub width: u32,

    pub height: u32,

    pub label: String,

}

 

impl Draw for Button {

    fn draw(&self) {

        // code to actually draw a button

    }

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

17

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

A library user adds a SelectBox

Fields differ between components: a TextField might have the same fields plus a placeholder. Each drawable type implements Draw but uses different code in draw to define how to draw itself. Button might also have an additional impl block with methods for when a user clicks it — methods that don’t apply to types like TextField.

A library user who implements a SelectBox struct with width, height, and options fields implements Draw on it as well.

Listing 18.8: Another crate using gui and implementing the Draw trait on a SelectBox struct — src/main.rs

use gui::Draw;

 

struct SelectBox {

    width: u32,

    height: u32,

    options: Vec<String>,

}

 

impl Draw for SelectBox {

    fn draw(&self) {

        // code to actually draw a select box

    }

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

18

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

Mixing types in one Screen

The library’s user can now write their main function to create a Screen instance. To it they add a SelectBox and a Button by putting each in a Box<T> to become a trait object. They can then call run on the Screen instance, which calls draw on each of the components.

Remark. In the book’s file, the SelectBox definition from Listing 18.8 precedes this code.

Listing 18.9: Using trait objects to store values of different types that implement the same trait — src/main.rs

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"),

                ],

            }),

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

19

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

Screen draws a type it never knew about

Listing 18.9 (continued) — src/main.rs

            Box::new(Button {

                width: 50,

                height: 10,

                label: String::from("OK"),

            }),

        ],

    };

 

    screen.run();

}

When we wrote the library, we didn’t know that someone might add the SelectBox type. Yet our Screen implementation was able to operate on the new type and draw it, because SelectBox implements the Draw trait, which means it implements the draw method.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

20

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

Like duck typing, but checked at compile time

Definition 18.7 (Duck typing). In dynamically typed languages, being concerned only with the messages a value responds to rather than the value’s concrete type: if it walks like a duck and quacks like a duck, then it must be a duck!

The concept of trait objects is similar. In the implementation of run on Screen in Listing 18.5, run doesn’t need to know the concrete type of each component. It doesn’t check whether a component is a Button or a SelectBox; it just calls draw on it. By specifying Box<dyn Draw> as the type of the values in the components vector, we’ve defined Screen to need values that we can call the draw method on.

The advantage of using trait objects and Rust’s type system to write code similar to duck typing: we never have to check at runtime whether a value implements a particular method, or worry about errors if a value doesn’t implement a method but we call it anyway. Rust won’t compile our code if the values don’t implement the traits that the trait objects need.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

21

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

Trying a type that doesn’t implement Draw

Listing 18.10 shows what happens if we try to create a Screen with a String as a component.

Listing 18.10: Attempting to use a type that doesn’t implement the trait object’s trait — src/main.rs

use gui::Screen;

 

fn main() {

    let screen = Screen {

        components: vec![Box::new(String::from("Hi"))],

    };

 

    screen.run();

}

We get the error on the next slide because String doesn’t implement the Draw trait. It tells us that either we’re passing something to Screen we didn’t mean to pass and should pass a different type, or we should implement Draw on String so that Screen is able to call draw on it.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

22

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

Error: the trait bound String: Draw is not satisfied

$ cargo run

   Compiling gui v0.1.0 (file:///projects/gui)

error[E0277]: the trait bound `String: Draw` is not satisfied

  --> src/main.rs:5:26

   |

 5 |         components: vec![Box::new(String::from("Hi"))],

   |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`

   |

help: the trait `Draw` is implemented for `Button`

  --> src/lib.rs:23:1

   |

23 | impl Draw for Button {

   | ^^^^^^^^^^^^^^^^^^^^

   = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

 

For more information about this error, try `rustc --explain E0277`.

error: could not compile `gui` (bin "gui") due to 1 previous error

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

23

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

Performing dynamic dispatch

Recall from “Performance of Code Using Generics” in Chapter 10 the monomorphization process the compiler performs on generics: it generates nongeneric implementations of functions and methods for each concrete type used in place of a generic type parameter.

Definition 18.8 (Static dispatch). The compiler knows what method you’re calling at compile time. The code that results from monomorphization does static dispatch.

Definition 18.9 (Dynamic dispatch). The compiler can’t tell at compile time which method you’re calling. Instead, it emits code that at runtime will know which method to call.

When we use trait objects, Rust must use dynamic dispatch. The compiler doesn’t know all the types that might be used with the code that’s using trait objects, so it doesn’t know which method implemented on which type to call. Instead, at runtime, Rust uses the pointers inside the trait object to know which method to call.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

24

Chapter 18 · 18.2 Using Trait Objects to Abstract over Shared Behavior

The cost of dynamic dispatch

  • The runtime lookup incurs a cost that doesn’t occur with static dispatch.
  • Dynamic dispatch also prevents the compiler from choosing to inline a method’s code, which in turn prevents some optimizations.
  • Rust has rules about where you can and cannot use dynamic dispatch.

Definition 18.10 (Dyn compatibility). Rust’s rules about where you can and cannot use dynamic dispatch. They are beyond the scope of this chapter; the Rust Reference (doc.rust-lang.org/reference/items/traits.html#dyn-compatibility) describes them.

However, we did get extra flexibility in the code we wrote in Listing 18.5 and were able to support in Listing 18.9, so it’s a trade-off to consider.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

25

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

The state pattern

Definition 18.11 (State pattern). An object-oriented design pattern whose crux is that we define a set of states a value can have internally. The states are represented by a set of state objects, and the value’s behavior changes based on its state.

The example is a blog post struct with a field holding its state: a state object from the set “draft,” “review,” or “published.”

The state objects share functionality — in Rust, of course, we use structs and traits rather than objects and inheritance. Each state object is responsible for its own behavior and for governing when it should change into another state. The value that holds a state object knows nothing about the different behavior of the states or when to transition between them.

The advantage: when the program’s business requirements change, we won’t need to change the code of the value holding the state or the code that uses the value. We’ll only need to update the code inside one of the state objects to change its rules, or perhaps add more state objects.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

26

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

The blog post workflow we will build

First we implement the state pattern in a more traditional object-oriented way; then we use an approach that’s a bit more natural in Rust. We build the blog post workflow incrementally. The final functionality:

  1. A blog post starts as an empty draft.
  2. When the draft is done, a review of the post is requested.
  3. When the post is approved, it gets published.
  4. Only published blog posts return content to print, so unapproved posts can’t accidentally be published.

Any other changes attempted on a post should have no effect. For example, if we try to approve a draft blog post before we’ve requested a review, the post should remain an unpublished draft.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

27

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Attempting traditional object-oriented style

There are infinite ways to structure code to solve the same problem, each with different trade-offs. This section’s implementation is more of a traditional object-oriented style: possible to write in Rust, but not taking advantage of some of Rust’s strengths.

Later we demonstrate a different solution that still uses the object-oriented design pattern but is structured in a way that might look less familiar to programmers with object-oriented experience. Comparing the two solutions shows the trade-offs of designing Rust code differently than code in other languages.

Listing 18.11 (next slide) shows the workflow in code form: an example usage of the API we’ll implement in a library crate named blog. It won’t compile yet, because we haven’t implemented the blog crate.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

28

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

The desired behavior of the blog crate

Listing 18.11: Code that demonstrates the desired behavior we want our blog crate to have — src/main.rs

use blog::Post;

 

fn main() {

    let mut post = Post::new();

 

    post.add_text("I ate a salad for lunch today");

    assert_eq!("", post.content());

 

    post.request_review();

    assert_eq!("", post.content());

 

    post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());

}

Remark. The assert_eq! calls are for demonstration purposes. An excellent unit test for this would assert that a draft blog post returns an empty string from the content method, but the book doesn’t write tests for this example.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

29

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

What the API promises its users

We want to allow the user to create a new draft blog post with Post::new and to add text to it. If we try to get the post’s content immediately, before approval, we shouldn’t get any text, because the post is still a draft.

Next, we want to enable a request for a review of the post, and we want content to return an empty string while waiting for the review. When the post receives approval, it should get published, meaning the text of the post will be returned when content is called.

The only type we interact with from the crate is Post. It uses the state pattern and holds a value that is one of three state objects — draft, review, or published. Changing from one state to another is managed internally within Post: the states change in response to the methods users call on the Post instance, but users don’t manage the state changes directly. Users also can’t make a mistake with the states, such as publishing a post before it’s reviewed.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

30

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Defining Post and creating a new instance

We need a public Post struct that holds some content, plus an associated public new function to create an instance. We also make a private State trait defining the behavior all state objects for a Post must have.

Post holds a trait object of Box<dyn State> inside an Option<T>, in a private field named state. Why the Option<T> is necessary becomes clear shortly.

Listing 18.12: Definition of a Post struct and a new function that creates a new Post instance, a State trait, and a Draft struct — src/lib.rs

pub struct Post {

    state: Option<Box<dyn State>>,

    content: String,

}

 

impl Post {

    pub fn new() -> Post {

        Post {

            state: Some(Box::new(Draft {})),

            content: String::new(),

        }

    }

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

31

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Every new Post starts as a Draft

Listing 18.12 (continued) — src/lib.rs

trait State {}

 

struct Draft {}

 

impl State for Draft {}

The State trait defines the behavior shared by different post states. The state objects are Draft, PendingReview, and Published, and they will all implement State. For now the trait has no methods, and we define just the Draft state, because that is the state we want a post to start in.

When we create a new Post, we set its state field to a Some value holding a Box that points to a new instance of the Draft struct. So every new Post starts out as a draft — and because the state field is private, there is no way to create a Post in any other state! In Post::new we set the content field to a new, empty String.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

32

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Storing the text of the post content

Listing 18.11 calls a method named add_text and passes it a &str that is added as the text content of the post. We implement this as a method rather than exposing the content field as pub, so that later we can implement a method that controls how the content field’s data is read. add_text is straightforward; we add it to the impl Post block.

Listing 18.13: Implementing the add_text method to add text to a post’s content — src/lib.rs

impl Post {

    // --snip--

    pub fn add_text(&mut self, text: &str) {

        self.content.push_str(text);

    }

}

add_text takes &mut self because it changes the Post it is called on, and calls push_str on the String in content with the text argument. This behavior doesn’t depend on the post’s state, so it’s not part of the state pattern — add_text never touches the state field — but it is behavior we want to support.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

33

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Ensuring that a draft post’s content is empty

Even after we’ve called add_text, we want the content method to return an empty string slice, because the post is still in the draft state (the first assert_eq! in Listing 18.11). For now we implement content with the simplest thing that fulfills this requirement: always return an empty string slice. So far posts can only be drafts, so the content should always be empty; we change this once a post can be published.

Listing 18.14: Adding a placeholder implementation for the content method on Post that always returns an empty string slice — src/lib.rs

impl Post {

    // --snip--

    pub fn content(&self) -> &str {

        ""

    }

}

With this added content method, everything in Listing 18.11 through the first assert_eq! works as intended.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

34

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Requesting a review changes the post’s state

Next we add functionality to request a review of a post, which should change its state from Draft to PendingReview.

Listing 18.15: Implementing request_review methods on Post and the State trait — src/lib.rs

impl Post {

    // --snip--

    pub fn request_review(&mut self) {

        if let Some(s) = self.state.take() {

            self.state = Some(s.request_review())

        }

    }

}

 

trait State {

    fn request_review(self: Box<Self>) -> Box<dyn State>;

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

35

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Draft and PendingReview implement request_review

Listing 18.15 (continued) — src/lib.rs

struct Draft {}

 

impl State for Draft {

    fn request_review(self: Box<Self>) -> Box<dyn State> {

        Box::new(PendingReview {})

    }

}

 

struct PendingReview {}

 

impl State for PendingReview {

    fn request_review(self: Box<Self>) -> Box<dyn State> {

        self

    }

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

36

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Consuming the old state: self: Box<Self> and take

Post gets a public method request_review that takes a mutable reference to self. It calls an internal request_review method on the current state of Post, and this second request_review consumes the current state and returns a new state.

We add request_review to the State trait, so all implementing types must now implement it. Its first parameter is not self, &self, or &mut self but self: Box<Self>: the method is only valid when called on a Box holding the type. This syntax takes ownership of Box<Self>, invalidating the old state so that the Post’s state value can transform into a new state.

To consume the old state, request_review needs ownership of the state value. That is where the Option in Post’s state field comes in: we call take to take the Some value out of the state field and leave a None in its place, because Rust doesn’t let us have unpopulated fields in structs. This lets us move the state value out of Post rather than borrowing it. Then we set the post’s state to the result of this operation.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

37

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Each state is responsible for its own rules

We need to set state to None temporarily, rather than writing code like self.state = self.state.request_review();, to get ownership of the state value. This ensures Post can’t use the old state value after we’ve transformed it into a new state.

request_review on Draft returns a new, boxed instance of a new PendingReview struct, the state of a post waiting for a review. PendingReview also implements request_review but doesn’t transform anything: it returns itself, because requesting a review on a post already in PendingReview should keep it there.

Now the advantages of the state pattern start to show: request_review on Post is the same no matter its state value. Each state is responsible for its own rules.

We leave content on Post as is, returning an empty string slice: a Post can now be in PendingReview as well as Draft, and we want the same behavior in both. Listing 18.11 now works up to the second assert_eq! call!

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

38

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Adding approve to change content’s behavior

The approve method is similar to request_review: it sets state to the value that the current state says it should have when that state is approved.

Listing 18.16: Implementing the approve method on Post and the State trait — src/lib.rs

impl Post {

    // --snip--

    pub fn approve(&mut self) {

        if let Some(s) = self.state.take() {

            self.state = Some(s.approve())

        }

    }

}

 

trait State {

    fn request_review(self: Box<Self>) -> Box<dyn State>;

    fn approve(self: Box<Self>) -> Box<dyn State>;

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

39

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

approve on Draft and PendingReview

Listing 18.16 (continued) — src/lib.rs

struct Draft {}

 

impl State for Draft {

    // --snip--

    fn approve(self: Box<Self>) -> Box<dyn State> {

        self

    }

}

 

struct PendingReview {}

 

impl State for PendingReview {

    // --snip--

    fn approve(self: Box<Self>) -> Box<dyn State> {

        Box::new(Published {})

    }

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

40

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

The Published state

Listing 18.16 (continued) — src/lib.rs

struct Published {}

 

impl State for Published {

    fn request_review(self: Box<Self>) -> Box<dyn State> {

        self

    }

 

    fn approve(self: Box<Self>) -> Box<dyn State> {

        self

    }

}

We add the approve method to the State trait and add a new struct that implements State: the Published state.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

41

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

How each state answers request_review and approve

As with request_review on PendingReview, calling approve on a Draft has no effect, because approve returns self. Calling approve on PendingReview returns a new, boxed Published instance. Published returns itself from both methods, because the post should stay published in those cases.

Table 18.2: State transitions after Listing 18.16

Current staterequest_review returnsapprove returns
DraftBox::new(PendingReview {})self (stays Draft)
PendingReviewself (stays PendingReview)Box::new(Published {})
Publishedself (stays Published)self (stays Published)

Now we update the content method on Post. We want its return value to depend on the current state, so Post delegates to a content method defined on its state (Listing 18.17).

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

42

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Post::content delegates to its state

Listing 18.17: Updating the content method on Post to delegate to a content method on State — src/lib.rs

impl Post {

    // --snip--

    pub fn content(&self) -> &str {

        self.state.as_ref().unwrap().content(self)

    }

    // --snip--

}

The goal is to keep all of these rules inside the structs that implement State. So we call a content method on the value in state, pass the post instance (that is, self) as an argument, and return what that method returns.

We call as_ref on the Option because we want a reference to the value inside it rather than ownership. As state is an Option<Box<dyn State>>, as_ref returns an Option<&Box<dyn State>>. Without as_ref we’d get an error, because we can’t move state out of the borrowed &self of the function parameter.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

43

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Why unwrap is safe here, and where content goes

We then call unwrap, which we know will never panic: the methods on Post ensure that state always contains a Some value when they are done. This is one of the cases from “When You Have More Information Than the Compiler” in Chapter 9: we know a None value is never possible, even though the compiler isn’t able to understand that.

When we call content on the &Box<dyn State>, deref coercion takes effect on the & and the Box, so the content method is ultimately called on the type that implements the State trait.

That means we need to add content to the State trait definition, and that is where we put the logic for what content to return depending on which state we have (Listing 18.18).

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

44

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

A default content, overridden by Published

Listing 18.18: Adding the content method to the State trait — src/lib.rs

trait State {

    // --snip--

    fn content<'a>(&self, post: &'a Post) -> &'a str {

        ""

    }

}

 

// --snip--

 

struct Published {}

 

impl State for Published {

    // --snip--

    fn content<'a>(&self, post: &'a Post) -> &'a str {

        &post.content

    }

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

45

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

The workflow is complete

We add a default implementation of content that returns an empty string slice, so we don’t need to implement content on the Draft and PendingReview structs. The Published struct overrides content and returns the value in post.content. While convenient, having content on State determine the content of the Post blurs the lines between the responsibility of State and the responsibility of Post.

This method needs lifetime annotations, as discussed in Chapter 10: it takes a reference to a post as an argument and returns a reference to part of that post, so the lifetime of the returned reference is related to the lifetime of the post argument.

And we’re done — all of Listing 18.11 now works! We’ve implemented the state pattern with the rules of the blog post workflow. The logic related to the rules lives in the state objects rather than being scattered throughout Post.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

46

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Why not an enum? Evaluating the state pattern

Why not an enum?

An enum with the possible post states as variants is certainly a possible solution — try it and compare the end results to see which you prefer! One disadvantage: every place that checks the value of the enum needs a match expression or similar to handle every possible variant, which could get more repetitive than this trait object solution.

Evaluating the state pattern

Rust can implement the object-oriented state pattern to encapsulate the different kinds of behavior a post should have in each state. The methods on Post know nothing about the various behaviors. To know the different ways a published post can behave, we look in only one place: the implementation of State on Published.

Without the state pattern, we might use match expressions in the methods on Post, or even in main, that check the state and change behavior there. Then we would have to look in several places to understand all the implications of a post being published.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

47

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Easy to extend: exercises

With the state pattern, the Post methods and the places we use Post don’t need match expressions. To add a new state, we would only need to add a new struct and implement the trait methods on that one struct, in one location.

The implementation is easy to extend with more functionality. To see how simple it is to maintain code that uses the state pattern, try a few of these suggestions:

Example 18.12. Extensions to try on the blog crate:

  • Add a reject method that changes the post’s state from PendingReview back to Draft.
  • Require two calls to approve before the state can be changed to Published.
  • Allow users to add text content only when a post is in the Draft state. Hint: have the state object responsible for what might change about the content but not responsible for modifying the Post.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

48

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Downsides: coupled states and duplicated logic

Coupling. Because the states implement the transitions between states, some states are coupled to each other. Adding a state such as Scheduled between PendingReview and Published would mean changing the code in PendingReview to transition to Scheduled instead. It would be less work if PendingReview didn’t need to change, but that would mean switching to another design pattern.

Duplicated defaults. We might try default implementations of request_review and approve on the State trait that return self. This wouldn’t work: when State is used as a trait object, the trait doesn’t know what the concrete self will be, so the return type isn’t known at compile time. (This is one of the dyn compatibility rules.)

Duplicated delegation. request_review and approve on Post both use Option::take on state and, if it is Some, delegate to the wrapped value’s same-named method and store the result. With many such methods, we might define a macro to remove the repetition (see “Macros” in Chapter 20).

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

49

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Encoding states and behavior as types

By implementing the state pattern exactly as it’s defined for object-oriented languages, we’re not taking as full advantage of Rust’s strengths as we could. Some changes to the blog crate can make invalid states and transitions into compile-time errors.

We rethink the state pattern to get a different set of trade-offs. Rather than encapsulating the states and transitions completely so that outside code has no knowledge of them, we encode the states into different types. Consequently, Rust’s type-checking system prevents attempts to use draft posts where only published posts are allowed, by issuing a compiler error.

Consider again the first part of main from Listing 18.11 (next slide).

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

50

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

The starting point: main from Listing 18.11

src/main.rs

use blog::Post;

 

fn main() {

    let mut post = Post::new();

 

    post.add_text("I ate a salad for lunch today");

    assert_eq!("", post.content());

 

    post.request_review();

    assert_eq!("", post.content());

 

    post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

51

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Draft posts without a content method

We still enable the creation of new posts in the draft state using Post::new, and the ability to add text to the post’s content.

But instead of a content method on a draft post that returns an empty string, draft posts won’t have a content method at all. That way, trying to get a draft post’s content gives a compiler error telling us the method doesn’t exist. As a result, it is impossible to accidentally display draft post content in production, because that code won’t even compile.

Listing 18.19 (next slide) shows the definition of a Post struct and a DraftPost struct, as well as methods on each.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

52

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Post and DraftPost as separate types

Listing 18.19: A Post with a content method and a DraftPost without a content method — src/lib.rs

pub struct Post {

    content: String,

}

 

pub struct DraftPost {

    content: String,

}

 

impl Post {

    pub fn new() -> DraftPost {

        DraftPost {

            content: String::new(),

        }

    }

 

    pub fn content(&self) -> &str {

        &self.content

    }

}

 

impl DraftPost {

    pub fn add_text(&mut self, text: &str) {

        self.content.push_str(text);

    }

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

53

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

The state now lives in the type

Both Post and DraftPost have a private content field that stores the blog post text. The structs no longer have a state field, because the encoding of the state moves to the types of the structs. Post represents a published post, and its content method returns the content.

Post::new still exists, but it returns an instance of DraftPost instead of Post. Because content is private and no function returns Post, it’s not possible to create an instance of Post right now.

DraftPost has an add_text method, so we can add text to content as before, but it has no content method! So all posts start as draft posts, and draft posts don’t have their content available for display. Any attempt to get around these constraints results in a compiler error.

So how do we get a published post? A draft must be reviewed and approved before it can be published, and a post pending review should still not display any content. We add a struct PendingReviewPost, define request_review on DraftPost to return a PendingReviewPost, and define approve on PendingReviewPost to return a Post (Listing 18.20).

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

54

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Transitions as transformations into other types

Listing 18.20: A PendingReviewPost that gets created by calling request_review on DraftPost and an approve method that turns a PendingReviewPost into a published Post — src/lib.rs

impl DraftPost {

    // --snip--

    pub fn request_review(self) -> PendingReviewPost {

        PendingReviewPost {

            content: self.content,

        }

    }

}

pub struct PendingReviewPost {

    content: String,

}

 

impl PendingReviewPost {

    pub fn approve(self) -> Post {

        Post {

            content: self.content,

        }

    }

}

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

55

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

The workflow encoded into the type system

The request_review and approve methods take ownership of self, consuming the DraftPost and PendingReviewPost instances and transforming them into a PendingReviewPost and a published Post, respectively. So no DraftPost instances linger after we’ve called request_review on them, and so forth.

PendingReviewPost has no content method, so attempting to read its content is a compiler error, as with DraftPost. The only way to get a published Post — which does have content — is to call approve on a PendingReviewPost, and the only way to get a PendingReviewPost is to call request_review on a DraftPost. We’ve now encoded the blog post workflow into the type system.

main needs small changes too. request_review and approve return new instances rather than modifying the struct they’re called on, so we add more let post = shadowing assignments to save the returned instances. We also can’t — and needn’t — assert that draft and pending posts’ contents are empty strings: code that tries to use the content of posts in those states no longer compiles.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

56

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

The updated main

Listing 18.21: Modifications to main to use the new implementation of the blog post workflow — src/main.rs

use blog::Post;

 

fn main() {

    let mut post = Post::new();

 

    post.add_text("I ate a salad for lunch today");

 

    let post = post.request_review();

 

    let post = post.approve();

 

    assert_eq!("I ate a salad for lunch today", post.content());

}

Remark. Each let post = binds the value returned by the transition to a new post of a different type: DraftPost, then PendingReviewPost, then Post.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

57

Chapter 18 · 18.3 Implementing an Object-Oriented Design Pattern

Trading encapsulation for compile-time guarantees

Reassigning post in main means this implementation doesn’t quite follow the object-oriented state pattern anymore: the transformations between the states are no longer encapsulated entirely within the Post implementation. However, invalid states are now impossible because of the type system and the type checking that happens at compile time! Certain bugs, such as displaying the content of an unpublished post, are discovered before they make it to production.

Try the tasks suggested earlier (Example 18.12) on the blog crate as it is after Listing 18.21 to see what you think about this version’s design. Some of the tasks might be completed already in this design.

Even though Rust is capable of implementing object-oriented design patterns, other patterns, such as encoding state into the type system, are also available, with different trade-offs. You might be very familiar with object-oriented patterns, but rethinking the problem to take advantage of Rust’s features can provide benefits, such as preventing some bugs at compile time. Object-oriented patterns won’t always be the best solution in Rust, due to features like ownership that object-oriented languages don’t have.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

58

Chapter 18 · Summary

Chapter summary

Regardless of whether you think Rust is an object-oriented language after reading this chapter, you now know that:

  • you can use trait objects to get some object-oriented features in Rust;
  • dynamic dispatch can give your code some flexibility in exchange for a bit of runtime performance;
  • you can use this flexibility to implement object-oriented patterns that can help your code’s maintainability;
  • Rust also has other features, like ownership, that object-oriented languages don’t have.

An object-oriented pattern won’t always be the best way to take advantage of Rust’s strengths, but it is an available option.

Remark. Next, Chapter 19 looks at patterns, another of Rust’s features that enable lots of flexibility. We’ve seen them briefly throughout the book but haven’t yet seen their full capability.

The Rust Programming Language · Ch.18 Object-Oriented Programming Features

59