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:
- Preventing Unnecessary Operations: It stops unnecessary operations, particularly for time- or resource-intensive tasks, leading to improved performance.
- Enhanced Readability: Implementing explicit error checking, such as returning a Result struct in Rust, enhances code readability and robustness.
- Error Avoidance: Through explicit error checking, potential errors can be avoided.
The disadvantages are:
- Increased Complexity: The pattern may introduce extra complexity due to additional state-checking in objects.
- 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:
- There are only two operation we can perform on an open file:
close()
andread()
. Theclose()
method returns aMemoryFile
inClosedState
and theread()
method simply returns a string - 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:
- When we create a
MemoryFile
we create it in theClosedState
, that is why we put the constructor here. - The
open()
method simple returns aMemoryFile
in theOpenState
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:
- We create a file in the
ClosedState
. TheClosedState
implementation is also the only one with this constructor. - 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. - 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.