Error Handling in Go - Rob Pike Reinvented Monads
source link: https://www.tuicool.com/articles/hit/VbIVvyU
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Errors are values
In his post “Errors are values” , Rob Pike, one of the original authors of Go, attends the common perception that one must repetitively type
if err != nil
in order to handle errors.
He recounts an encounter of his with another Go programmer who had some code that looked like this:
_, err = fd.Write(p0) if err != nil { return err } _, err = fd.Write(p1) if err != nil { return err } _, err = fd.Write(p2) if err != nil { return err } // and so on
To help solve the repetition,
Rob defined a type called errWriter
type errWriter struct { w io.Writer err error }
with a write
method
that stops writing to w
as soon as it hits the first error:
func (ew *errWriter) write(buf []byte) { if ew.err != nil { return } _, ew.err = ew.w.Write(buf) }
This encapsulates the repetitive error handling and lets them simplify the above code to something like this:
ew := &errWriter{w: fd} ew.write(p0) ew.write(p1) ew.write(p2) // and so on if ew.err != nil { return ew.err }
He then notes that this pattern appears often
in the Go standard library,
including in the
bufio.Writer
class
which provides the same error handling as errWriter
above
while satisfying the
io.Writer
interface.
Using it changes the example into this:
b := bufio.NewWriter(fd) b.Write(p0) b.Write(p1) b.Write(p2) // and so on if b.Flush() != nil { return b.Flush() }
Rob closes by stating:
Use the language to simplify your error handling.
Using the language
The above solution is specific to
io.Writer
,
even though the same error handling strategy
makes sense for the
io.Reader
type,
and from the blog post we know that it is in fact repeated
in
bufio.Scanner
and the
archive/zip
and
net/http
packages.
Go does not support parametric polymorphism (or “Generics”), but if it did we could use it to write a single implementation of this error handling pattern and reuse it for different types.
Let’s check out what that might look like.
Result
Let’s start by considering
io.Writer
.
It is an interface with exactly one method:
type Writer interface { Write(p []byte) (n int, err error) }
That method’s return type is a pair consisting of a value and an error,
where in the common case that error is nil
,
indicating that the operation finished successfully.
If an error did occur, the value may still be present (and non-zero),
but we’ll ignore that case for this blog post.
With a sufficiently expressive type system
(and using completely
made up syntax)
we could express this as a type Result<A>
, say,
which represents the result of a computation
and covers two cases:
A error
An implementation could look similar to this:
type Result<A> struct { // fields } func (r Result<A>) Value() A {…} func (r Result<A>) Error() error {…}
This type provides a place to put the error handling strategy we’re after:
From a successful Result
we want to run the next “step” of our program,
which may itself return a Result
,
but as soon as one step fails we want to stop.
Let’s define a method Then
for this task:
func (r Result<A>) Then(f func() Result<A>) Result<A> {…}
If r
contains an error, Then
just returns r
,
otherwise it calls f
and returns the result of that call,
which is exactly what we wanted.
So far so good.
Polishing
You may have noticed that calling Then
simply discards the value of r
(if it’s a successful
Result
).
We also don’t need Then
to always return
a Result
containing the same
type of value.
Let’s lift those restrictions and generalize the method:
func (r Result<A>) Then(f func(A) Result<B>) Result<B> {…}
Note that f
is still free to ignore its argument
or to return a Result
containing a value of type A
,
and that a Result
containing an error
satisfies Result<A>
for any
type A
.
Using this type (and a Write
method that returns it),
the example code from above could look something like this:
r := fd.Write(p0).Then(func(_) { return fd.Write(p1) }).Then(func(_) { return fd.Write(p2) }) // and so on if r.Error() != nil { return r.Error() }
I’ll be the first to admit that this piece of code is not elegant, but I believe that that’s due to lack of support by the language, so let’s consider where improving that could get us.
The M-Word
The method Then
we defined above is well known
in certain circles that practice functional programming,
only those folks usually call it flatMap
(or bind
).
That’s because the Result
type is a monad
.
(Some other names for similar types are
Result
,
Either
,
Or
,
Xor
, or
\/
.)
And in languages with better support for monads we can more easily express computations using them. This is what the same piece of code looks like in Haskell:
do write fd p0 write fd p1 write fd p2
Yes, this code performs the exact same error handling as our examples above. Other code that handles errors the same way will also look the same, reusing the error handling strategy defined in the result type, removing the need to wrap facades around interfaces every time we want to handle the errors they generate.
In the end we accomplished exactly what is being preached for Go: We treat errors as values, and we are using our language to simplify our error handling. The difference is that we only have to do that once .
Conclusion
Two commonly perceived problems of Go are that handling errors is verbose and repetitive and that parametric polymorphism is unavailable.
One of the authors of Go offers a solution to one of those problems, but his advice boils down to “use monads,” and because of the other problem you cannot express this concept in Go.
This leaves us having to implement artisanal one-off monads for every interface we want to handle errors for, which I think is still as verbose and repetitive.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK