Go: Making state explicit using the type system
Mohamed Edrah
Posted on May 15, 2022
In the last post we learned how to create self documenting data types, we used the type system to create data types that would be in a constant valid state (because of validation on construction and when updating the value), now we're going types to more easily express states that our data could be in.
Consider the following types:
type chatroom struct {
id uuidStr
name nameStr
members membersList
}
type membersList []member
type member struct {
id uuidStr
role membership
}
type membership int
const (
owner membership = iota
removed
banned
regular
)
Most of them are self explanatory the one we're interested in is member
because depending on the value of role
field the other data inside a member
might be interpreted differently and require us to preserve different invariants, this is a pretty common situation we say that member
has state because how we process member
differs due to the value of one or more of the fields.
This simple example doesn't do this problem justice, imagine a much bigger data structure like a shipping order for example you'd probably have a lot of fields with different data filled at different times depending on the state of the order, at best you'd probably have an enum to tell what state the order is in or at worst you'd have a few boolean flags for each state.
Generally speaking there are two ways to fix this problem:
- The functional way
- The object oriented way
The functional way
In functional programming data is just data, nothing more nothing less.
All objects in our programs have a type, and a type is a name for a set of values, a type can be either a product type (sometimes known as a composite, or record type) or it can be a sum type (sometimes known discriminated union, OR type, choice type)
product and sum types (RUST)
// Sum type
enum AwesomeFruit {
Apple,
Banana,
Orange,
}
// Product type
struct Fruit {
name: String,
calories: i32,
}
In a functional language you'd declare a choice type, create a few objects with it and then when it's time to preform some data processing, we use pattern matching to match the the objects against type patterns like this:
matching an object of choice type against patterns (RUST)
match apple {
AwesomeFruit::Apple => println!("You created an apple"),
AwesomeFruit::Banana => println!("You created a banana"),
AwesomeFruit::Orange => println!("You created an Orange"),
};
The compiler will refuse to compile code if we don't handle all the possible permutations (or at least provide a default catch all handler, you can do that in rust with _ => println!("")
)
Choice types are pretty powerful, sadly go doesn't support sum types (although there's current debate over adding some form of support for them), most go code out there is written in a OOP/imperative style, but don't let that discourage you there's a lot of people that write functional programs in go, functional go code is idiomatic.
Even though go doesn't have choice types, or pattern matching, there are multiple ways of faking them and we're going to look at the most common way which involves modeling the choice type as an interface type and have the variants/permutations of the type be objects that implement that interface:
type member interface{ member() }
type owner struct{ id uuidStr }
type banned struct{ id uuidStr }
type removed struct{ id uuidStr }
type regular struct{ id uuidStr }
func (*owner) member() {}
func (*banned) member() {}
func (*removed) member() {}
func (*regular) member() {}
We can then make constructors for each of the variants
func makeOwner(id uuidStr) member {
return &owner{id}
}
func makeRegular(id uuidStr) member {
return ®ular{id}
}
func makeBanned(id uuidStr) member {
return &banned{id}
}
func makeRemoved(id uuidStr) member {
return &removed{id}
}
And whenever we have a member object we can "match" against it using a type switch:
switch v := m.(type) {
case owner:
// Do stuff for the owner
case regular:
// Do stuff for the regular
case banned:
// Do stuff for the banned
case removed:
// Do stuff for the removed
default:
panic("missing handling for " + fmt.Sprintf("%T"))
}
This mimics pattern matching, but it has some important downsides to consider:
- This isn't pattern matching and isn't as powerful as pattern matching, also the compiler won't check that you've handled all the variants in the type switch.
- The default guard above will only be triggered in runtime causing a panic, panicking is an anti-pattern in go and even in functional programming.
We can fix these two problems by relying on static analyzers such as go-sumtypes
The object oriented way
In object oriented programming (unlike functional programming) data is protected & encapsulated and is grouped with public behavior that mutates the data.
To deal with an object that has many possible states, we use the strategy pattern, this pattern allows an object to easily have different behaviors by extracting the API of the required behaviors into a class or interface and then have implementations inherit that class or interface and implement the methods, we can then dynamically load instances of the subclass at runtime and use them.
You probably already used the strategy pattern, it's pretty common in go!
f, err := os.Open("foo")
if err != nil {
return err
}
defer f.Close()
sink := make([]byte, 250)
defer.Read(sink)
*os.File
implements the io.Reader
interface, which only has one method called Read
any object that implements Read
can be used as an io.Reader
.
in a similar fashion we can create objects that implement a member
interface and then define whatever services we want from a member on it
type interface member {
canSendMsg() bool
}
type banned struct {}
func (*banned) canSendMsg() bool { return false }
type removed struct{}
func (*removed) canSendMsg() bool { return false }
type owner struct {}
func (*owner) canSendMsg() bool { return true }
type regular struct{}
func (*regular) canSendMsg() bool { return true }
You'll see people use the OOP solution more often, and it's a lot more common in the standard library too, so learning how to use polymorphism and other OOP stuff is a must for any golang dev even you want to code in a functional style.
Posted on May 15, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.