John Cinnamond (speaker) on November 6, 2017
This talk is about how Category Theory can help you write better code, but without using the words "Category" or "Theory" (or monad or functor or any of the scary terminology). We'll look at how the idea behind "Errors are Values" from the Go blog can be applied to different kinds of programming problems, and how we can make our code easier to compose by moving units of control flow into types. (That sounds a bit fancy, but it's much more straightforward than it sounds.)
Note: This post was live-blogged at dotGo 2017. Let us know on Twitter (@sourcegraph) if we missed anything. All content is from the talk; any mistakes or misrepresentations are our fault, not the speaker's.
conn := net.Dial("tcp", "server:9876")
What if this goes wrong?
conn, err := net.Dial("tcp", "server"9876")
... John goes through several more examples of function calls where you could easily forget to handle an error, and would have to interrupt your development to handle the error. Instead of understanding what the code does, he sees this instead:
if err != nil
conn.Write(command1)
Let's add to the behavior of net.Conn
to handle errors => create a new type
type SafeConn struct {
conn net.Conn
err error
}
f(c *SafeConn) write(b []byte) {
c.conn.Write(b)
}
Add error handling:
func (c* SafeConn) write(b []byte) {
if c.err != nil {
return
}
_, c.err := c.conn.Write(b)
}
c:= SafeConn{conn, nil}
c.write(command1) // if this has an error
c.write(command2) // then this does nothing
if c.err != nil {
panic("omg")
}
Repeat for other errors
conn, err := net.Dial("tcp", "server:9876")
if err != nil {
panic(err)
}
func safeDial(network, address string) SafeConn {
conn, err := net.Dial(network.address)
return SafeConn{conn ,err}
}
c:= safeDial("tcp", "server:9876") // if this fails
c.write(command1) // then this does nothing
c.write(command2) // same for this
if err != nil {
panic("gonna")
}
We still handle the error, but the error handling doesn't get in the way.
But, we introduced a new abstraction. Abstractions have costs. Some details are hidden, but this is nothing new as we use abstractions all the time. Is this abstraction appropriate?
Given 3 numbers (a,b,c) => a/b/c
func divide(a, b, c int) {
answer := a / b / c
fmt.Println(answer)
}
divide(100, 10, 2) // 5
divide(100, 10, 0) // panic: runtime error: integer divide by zero
Let's solve this badly.
func divide(a, b, c int) {
if b == 0 || c == 0 {
fmt.Printf("Can't divide by zero")
return
}
answer = a / b / c
fmt.Println(answer)
}
if b == 0
and if c == 0
resembles err != nil
.
To solve this in a better way:
Create a new type:
type Divideinator struct {
answer int
}
Wrap the initial value:
d := Divideinator{a}
Wrap the behaviour:
func (d *Divideinator) divide(X int) {
d.answer = d.answer/x
}
Wrap the conditional:
func (d *Divideinator) divide(x int) {
if x == 0 {
d.isZero = true
return
}
d.answer = d.answer / x
func (d Divideinator) String() string {
if d.isZero {
return fmt.Sprintf("Can't divide by zero")
}
return fmt.Sprintf("%d", d.answer)
Put it all together
func divide(a, b, c int) {
d := Divideinator{a}
d.divide(b)
d.divide(c)
fmt.Println(d)
}
divide(100, 10, 2)
// 5
divide(100, 0, 2)
// Can't divide by zero
divide(100, 10, 0)
// Can't divide by zero
This is the same approach as error handling:
To understand this let's look at the shape of the code.
Lift the initial value into a new type
Lift the behavior
Lift the conditionals.
The key insight is that we can write our code as a simple composition of steps.
John has been working on a new project http://doesgohavegenericsyet.com
func signupHandler(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("email")
if !validateEmail(email) {
logRequest("invalid email", r)
htpp.Error(w, ...)
return
}
if alreadyRegistered(email) {
sellEmailToRecruiters(email)
logRequest("already registered", r)
http.Error(w, ...)
return
}
if err := register(email); err != nil {
sellEmailToRecruiters(email)
logRequest("registration failed", r)
http.Error(w, ...)
return
}
}
We know how to do this
Create a new type
type SignupRequest struct {
}
Lift initial data
type SignupRequest struct {
w http.ResponseWriter
r *http.Request
}
Lift behaviour
func (s *SignupRequest) validate() {
if s.email == "" || ... {
s.err = "invalid email"
}
}
func (s *SignupRequest) checkNewRegistration() {
if existingEmails.Contain(s.email) {
s.err = "already registereD"
}
}
Lift control flow
func (s *SignupRequest) checkNewRegistration() {
if existingEmails.Contains(s.email) {
s.err = "already registered"
}
}
func (s *SignupRequest) checkNewRegistration() {
if s.err != nil {
return
}
if existingEmails.Contain(s.email) {
s.err = "already registered"
}
}
Compose the functions
func signupHandler(w http.ResponseWriter, r *http.Request) {
s := newSignupRequest(w, r)
s.validate()
s.checkNewRegistration()
s.register()
s.sellEmailToRecruiter()
s.log()
s.respond()
}
Maybe don't rewrite your request handlers like this. John is not trying to tell you how to write code. He is trying to give you something new to think about. Think about the shape of the code. Think about using types. John wants to give you new tools to cope with complex code. It's up to you to decide how to use them.
On further thought this talk isn't about introducing a new tool to use, it is more about having a deeper understanding of the theory behind the talked about tool.
We solve problems by breaking them into smaller pieces, but the we need to join the pieces back together again. We need to understand the forces at play. Mathematics gives us this understanding. Mathematics lets us achieve more. Mathematics is pretty useful.