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.
Implementation in Go
In this example we will implement a simple MemoryFile, which is basically a string in our implementation.
This type can have three states: OpenState
, ClosedState
and UndefinedState
. The UndefinedState
is used to indicate that either the file has just been created or is in some form of error state . In our Go implementation they are implemented as empty structs:
package main
import "errors"
type OpenState struct{}
type ClosedState struct{}
type UndefinedState struct{}
Next we implement the MemoryFile
struct:
type MemoryFile struct {
name string
state interface{}
}
func NewMemoryFile(name string) *MemoryFile {
return &MemoryFile{name: name, state: UndefinedState{}}
}
func (f *MemoryFile) Open() (*MemoryFile, error) {
switch f.state.(type) {
case ClosedState:
return &MemoryFile{name: f.name, state: OpenState{}}, nil
case UndefinedState:
return &MemoryFile{name: f.name, state: OpenState{}}, nil
default:
return &MemoryFile{name: f.name, state: UndefinedState{}}, errors.New("file already open")
}
}
func (f *MemoryFile) Close() (*MemoryFile, error) {
switch f.state.(type) {
case OpenState:
return &MemoryFile{name: f.name, state: ClosedState{}}, nil
default:
return &MemoryFile{name: f.name, state: UndefinedState{}}, errors.New("file already closed")
}
}
func (f *MemoryFile) Read() (*string, error) {
switch f.state.(type) {
case OpenState:
return &f.name, nil
default:
return nil, errors.New("file not ready to read from since it's either closed or undefined")
}
}
Note that we check the current state of the file in both the Open()
and Close()
methods, to make sure the operation is possible. Also note that we return the original object in case of an error, but in an undefined state. Since we do not panic, the original object can not be dropped. It depends on the developer using the API to handle the errors.
Time to test
Now we can test our setup:
func main() {
file := NewMemoryFile("test.txt")
file, err := file.Open()
if err != nil {
fmt.Printf("Something went wrong opening the file: %v\n", err)
} else {
println("file opened")
}
content, err := file.Read()
if err != nil {
fmt.Printf("Something went wrong reading the file: %v\n", err)
} else {
println("file content: ", *content)
}
file, err = file.Close()
if err != nil {
fmt.Printf("Something went wrong closing the file: %v\n", err)
} else {
println("file closed")
}
content, err = file.Read()
if err != nil {
fmt.Printf("Something went wrong reading the file after closing: %v\n", err)
} else {
println("file content: ", *content)
}
file, err = file.Close()
if err != nil {
fmt.Printf("Something went wrong closing the file: %v\n", err)
} else {
println("file closed")
}
}
Note that we have to check the error return value after each method call to make sure that everything went well. Also note that if something went wrong, i.e. you try to open and already opened file you will get back the object in its UndefinedState
, that is why reading the second time goes wrong.
Conclusion
As you can see, preventing or allowing actions based on the state of an object is something that requires some planning, and some idea of state-transitions in our domain. In our example we’re only dealing with three states, but you can imagine more complex systems. However, the implementation of this pattern usually is done on small objects, embedded in larger systems, thereby hiding some complexity.
One problem is, is that errors need to be handled explicitly. That is why I introduced the UndefinedState
to prevent certain operations on the object when it has just been created, or when it is in an errorstate. One enhancement could be the addition of an ErrorState
struct, but that is the subject of another post.