Unlocking Rust’s Efficiency: Mastering the Easy Art of the Balking Pattern for Simplicity

Photo by Harri Hofer: https://www.pexels.com/photo/barred-wooden-door-9554231/

Introduction

The Balking Pattern might not be widely known, but it plays a crucial role in preventing certain actions on objects based on their state. For instance, attempting to read the contents of an unopened file should not be allowed.

Advantages and disadvantages of the Balking Pattern

The Balking Pattern has several advantages:

  1. Preventing Unnecessary Operations: It stops unnecessary operations, particularly for time- or resource-intensive tasks, leading to improved performance.
  2. Enhanced Readability: Implementing explicit error checking, such as returning a Result struct in Rust, enhances code readability and robustness.
  3. Error Avoidance: Through explicit error checking, potential errors can be avoided.

The disadvantages are:

  1. Increased Complexity: The pattern may introduce extra complexity due to additional state-checking in objects.
  2. Developer Discipline: Proper state checking requires discipline on the part of developers.

In this article I will show two implementations of this pattern: one using enums, one using the Typestate Pattern.

Implementation in Rust

In this example we will implement a simple MemoryFile, which is basically a string in our implementation.

This type can have two states: Open and Closed:

enum FileState {
    Open,
    Closed,
}

Next we define the MemoryFile struct:

struct MemoryFile {
    name: String,
    state: FileState,

}

And we implement it:

impl MemoryFile {
    fn new(name: &str) -> MemoryFile {
        MemoryFile {
            name: String::from(name),
            state: FileState::Closed,
        }
    }
    fn open(&mut self) -> Result<(), Box<dyn Error>> {
        match self.state {
            FileState::Closed => {
                self.state = FileState::Open;
                Ok(())
            },
            FileState::Open => Err("File must be closed before it can be opened".into()),
        }
    }
    fn close(&mut self) -> Result<(), Box<dyn Error>> {
        match self.state {
            FileState::Open => {
                self.state = FileState::Closed;
                Ok(())
            },
            FileState::Closed => Err("File must be open before it can be closed".into()),
        }
    }
    fn read(&self) -> Result<String,Box<dyn Error>> {
        match self.state {
            FileState::Open => Ok(self.name.clone()),
            FileState::Closed => Err("File must be open before it can be read".into()),
        }
    }
}

Note that in each method we check the file state to make sure our operation is possible.

Test time

Because of the fact that each method in our new struct returns a Result we need to check the results of each operation:

fn main() {
    let mut f = MemoryFile::new("my_file.txt");
    match f.open() {
        Ok(_) => {
            println!("File opened successfully");
            match f.read() {
                Ok(contents) => println!("File contents: {}", contents),
                Err(e) => println!("Error reading file: {}", e),
            }
            match f.close() {
                Ok(_) => println!("File closed successfully"),
                Err(e) => println!("Error closing file: {}", e),
            }
        }

        Err(e) => println!("Error opening file: {}", e),
    }

}

You could perhaps avoid writing some of the match statements by using unwrap() but as you know: Don’t use unwrap() in production code. Always handle your errors gracefully.

Implementation using the Typestate Pattern

The Typestate pattern is another way of implementing this pattern in Rust. This pattern store information about the object’s state in its type. This is very useful in Rust since it can prevent certain errors at compile time.

Does this all sounds cryptic? Well, let’s have a look:

First we will define the two states our MemoryFile can be in:

struct OpenState;
struct ClosedState;

These are just empty structs in our example. In practice they can contain some extra data when needed.

Next we define the MemoryFile which is now a generic type:

struct MemoryFile<State> {
    name: String,
    state: std::marker::PhantomData<State>,
}

This is the same as the previous example, apart from the std::marker::PhantomData<State>, this is to mark the struct like it owns the State type.

Next we will implement the OpenState:

impl MemoryFile<OpenState> {

    fn close(&self) -> MemoryFile<ClosedState> {

        MemoryFile {
            name: self.name.clone(),
            state: std::marker::PhantomData,
        }

    }
    fn read(&self) -> String {
        self.name.clone()
    }
}

Some notes:

  1. There are only two operation we can perform on an open file: close() and read() . The close() method returns a MemoryFile in ClosedState and the read() method simply returns a string
  2. Also note that neither method change the inner state of the class, so we do not need to put the mut keyword there.

The implementation for the ClosedState is similar:

impl MemoryFile<ClosedState> {
    fn new(name: &str) -> MemoryFile<ClosedState> {
        MemoryFile {
            name: String::from(name),
            state: std::marker::PhantomData,
        }
    }
    fn open(&self) -> MemoryFile<OpenState> {
        MemoryFile {
            name: self.name.clone(),
            state: std::marker::PhantomData,
        }
    }
}

Some notes here:

  1. When we create a MemoryFile we create it in the ClosedState, that is why we put the constructor here.
  2. The open() method simple returns a MemoryFile in the OpenState

Testing time

Now we can test it, and the testing code looks a bit simpler than our previous implementation:

fn main() {
    let f = MemoryFile::<ClosedState>::new("my_file.txt");
    let f = f.open();
    let contents = f.read();
    println!("File contents: {}", contents);
    let _f = f.close();
}

Some notes:

  1. We create a file in the ClosedState. The ClosedState implementation is also the only one with this constructor.
  2. We open the file, and read it. Had we forgotten to open the file, we would have gotten a compiler error, since read() does not exist in the closed state.
  3. We read and print out the contents and close the file.

Conclusion

Both approaches offer solutions to prevent actions on objects in specific states. The enum approach is clear but demands developer discipline. The TypeState Pattern in Rust appears more powerful, leveraging the compiler for assistance, but it also requires precision from developers. Regardless of the chosen approach, understanding the allowed actions based on object states is essential, involving domain knowledge and adherence to application-specific business rules.

Leave a Reply

Your email address will not be published. Required fields are marked *