5 minutes
Introducing Maybe package, bring functional to Go
One of the advantages of Go is that null (nil
) values are rarer than in some other languages. The following snippet will fail:
func getDay() string {
return nil
}
In order to make that snippet work, a pointer is needed as null values are the zero value for pointers (the same applies for interfaces, channels and function types).
func getDay() *string {
return nil
}
Being that pointers can introduce null and hence panics, and knowing that null is considered to be “The billion dollar mistake”, why is still so common in Go to use pointers for everything? Below are some arguments that I faced:
Optimization
The most common example here are slices. There is a general misconception on how much is a lot, in other words, how many items in a slice are too much to copy it by value. Of course, the answer is it depends. And this is true, a slice with 100 integers is probably less memory intensive that a slice with 50 complex structures with nested slices.
That being said, there is a good saying: avoid premature optimization. Favor copying slices by value instead of using pointers unless you have a super specific case that proves the contrary.
You can find here a nice article doing benchmark on slices using pointers and values.
Modifying the receiver
Imagine the following func:
func (cart *Cart) AddItem (item item) {
cart.Items = append(cart.Items, item)
}
Do you like what you see? Maybe you won’t after visiting these concepts:
Pure functions: A function whose return values are identical for identical arguments - also called deterministic. There is no usage of static variables, mutable references or streams. These functions have no side effects. These kind of functions are very easy to test.
Immutability: An immutable is one whose state cannot be changed after the creation. This means that the immutable is thread safe as you can pass it around contexts knowing they won’t be modified.
Knowing the benefits one can refactor this to eliminate the pointers in something like:
func (cart Cart) AddItem (item item) Cart {
items := append(cart.Items, item)
return newCart(items)
}
JSON property representing three different states
Let’s image that for some reason (which I cannot recommend) there is a Person
structure that contains a Salary
property. The system you are consuming will return the person with different salary depending the employment situation:
{ "name": "Pablo", "salary": 100 }
means paid job.{ "name": "Pablo", "salary": 0 }
means unpaid internship.{ "name": "Pablo", "salary": null }
means unemployment.
If this structure would be deserialized with the following struct, can you image what is going to happen?
type Person struct {
Name string `json:"name"`
Salary int `json:"salary"`
}
If you guessed that Salary
will never be nil
then you guessed correctly. As explained on the first snippet in this post, the zero value for int is 0
. Then as 0
was used for unpaid internship, one would never know the difference between this and being unemployed.
But if the Salary
is changed from int
to *int
, will this work?
type Person struct {
Name string `json:"name"`
Salary *int `json:"salary"`
}
And the answer is yes, but at what cost? Do we want to introduce deliberately pointers in our system to solve this?
Introducing Maybe library
Functional languages have a lot of interesting features that are having more traction as time passes and end up being ported to other languages. One of my favorites is the Option
data type. Think of it as Schrodinger’s cat variable. It may have a value or it may not have a value; and the only way to know it is checking if effectively has one.
UPDATE: Since Go 1.18 has been released, the library now supports generics so it is not limited to string, int, float, time, bool.
Let’s image the following struct:
package maybe
type Maybe[T any] struct {
value T
hasValue bool
}
func Set[T any](value T) Maybe[T] {
return Maybe[T]{
value: value,
hasValue: true,
}
}
func (m Maybe[T]) HasValue() bool {
return m.hasValue
}
func (m Maybe[T]) Value() T {
return m.value
}
This is how an option implementation can look in Go 1.18. By doing so, null
values are not possible. As stated before, while I cannot recommend designing a system where null
and 0
represent different things, we can take another look at the previous example:
- Salary 100 ->
Maybe.HasValue()
will betrue
andMaybe.Value()
will be100
. - Salary 0 ->
Maybe.HasValue()
will betrue
andMaybe.Value()
will be0
. - Salary null ->
Maybe.HasValue()
will befalse
andMaybe.Value()
will be0
.
This is how it looks like in our previous struct:
type Person struct {
Name string `json:"name"`
Salary maybe.Maybe[int] `json:"salary"`
}
But Pablo, this is serialized in the following way, right?
{
"name":"Pablo",
"salary":{
"hasValue":true,
"salary":100
}
}
Not really! Go allows customization of the MarshalJSON()
and UnmarshalJSON()
of a struct, so the serialization will look like:
package maybe
func (m Maybe[T]) MarshalJSON() ([]byte, error) {
var t *T
if m.hasValue {
t = &m.value
}
return json.Marshal(t)
}
If the maybe.Maybe[int]
was not initialized, then the serialization in our example will look like { "salary": null }
; however if maybe.Set(100)
was called, then the hasValue
will be true and the serialization will be { "salary": 100 }
.
Now for the unmarshal, the usage of pointers and null are being handled by the library:
func (m *Maybe[T]) UnmarshalJSON(data []byte) error {
var t *T
if err := json.Unmarshal(data, &t); err != nil {
return err
}
if t != nil {
*m = Set(*t)
}
return nil
}
If the json is { "salary": 100 }
then the pointer is not null and the maybe.Maybe[int]
will be set to HasValue() = true
and Value() = 100
. In the case of { "salary": null }
, the pointer will be nil
and therefore the maybe.Maybe[int]
will contain HasValue() = false
and Value() = 0
.
With this implementation we can code the following assumption:
func (p Person) IsEmployed() bool {
// If salary has value, but it is 0 we assume unpaid internship
return p.Salary.HasValue()
}
Summary
- Don’t use pointers unless you really know what you are doing.
- Try to eliminate the need of
nil
whenever is possible. - Option types are a really nice feature of functional languages.
- 👉 Link to the library. 👈