blog: maybedoer (#153)

This commit is contained in:
Cadey Ratio 2020-05-23 12:04:53 -04:00 committed by GitHub
parent 21dee82c96
commit dccacdd6ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 220 additions and 0 deletions

View File

@ -0,0 +1,167 @@
---
title: "maybedoer: the Maybe Monoid for Go"
date: 2020-05-23
tags:
- go
- golang
- monoid
---
# maybedoer: the Maybe Monoid for Go
I recently posted (a variant of) this image of some Go source code to Twitter
and it spawned some interesting conversations about what it does, how it works
and why it needs to exist in the first place:
<center>![the source code of package maybedoer](/static/blog/maybedoer.png)</center>
This file is used to sequence functions that could fail together, allowing you
to avoid doing an `if err != nil` check on every single fallible function call.
There are two major usage patterns for it.
The first one is the imperative pattern, where you call it like this:
```go
md := new(maybedoer.Impl)
var data []byte
md.Maybe(func(context.Context) error {
var err error
data, err = ioutil.ReadFile("/proc/cpuinfo")
return err
})
// add a few more maybe calls?
if err := md.Error(); err != nil {
ln.Error(ctx, err, ln.Fmt("cannot munge data in /proc/cpuinfo"))
}
```
The second one is the iterative pattern, where you call it like this:
```go
func gitPush(repoPath, branch, to string) maybedoer.Doer {
return func(ctx context.Context) error {
// the repoPath, branch and to variables are available here
return nil
}
}
func repush(ctx context.Context) error {
repoPath, err := ioutil.TempDir("", "")
if err != nil {
return fmt.Errorf("error making checkout: %v", err)
}
md := maybedoer.Impl{
Doers: []maybedoer.Doer{
gitConfig, // assume this is implemented
gitClone(repoPath, os.Getenv("HEROKU_APP_GIT_REPO")), // and this too
gitPush(repoPath, "master", os.Getenv("HEROKU_GIT_REMOTE")),
},
}
err = md.Do(ctx)
if err != nil {
return fmt.Errorf("error repushing Heroku app: %v", err)
}
return nil
}
```
Both of these ways allow you to sequence fallible actions without having to
write `if err != nil` after each of them, making this easily scale out to
arbitrary numbers of steps. The design of this is inspired by a package used at
a previous job where we used it to handle a lot of fiddly fallible actions that
need to happen one after the other.
However, this version differs because of the `Doers` element of
`maybedoer.Impl`. This allows you to specify an entire process of steps as long
as those steps don't return any values. This is very similar to how Haskell's
[`Data.Monoid.First`](http://hackage.haskell.org/package/base-4.14.0.0/docs/Data-Monoid.html#t:First)
type works, except in Go this is locked to the `error` type (due to the language
not letting you describe things as precisely as you would need to get an analog
to `Data.Monoid.First`). This is also similar to Rust's `and_then` combinator.
If we could return values from these functions, this would make `maybedoer`
closer to being a monad in the Haskell sense. However we can't so we are locked
to one specific instance of a monoid. I would love to use this for a pointer (or
pointer-like) reference to any particular bit of data, but `interface{}` doesn't
allow this because `interface{}` matches _literally everything_:
```go
var foo = []interface{
1,
3.4,
"hi there",
context.Background(),
errors.New("this works too!"),
}
```
This could mean that if we changed the type of a Doer to be:
```go
type Doer func(context.Context) interface{}
```
Then it would be difficult to know how to handle returns from the function.
Arguably we could write some mechanism to check if it is an error:
```go
result := do(ctx)
if result != nil {
switch result.(type) {
case error:
return result // result is of type error magically
default:
md.return = result
}
}
```
But then it would be difficult to know how to pipe the result into the next
function, unless we change Doer's type to be:
```go
type Doer func(context.Context, interface{}) interface{}
```
Which would require code that looks like this:
```go
func getNumber(ctx context.Context, _ interface{}) interface{} {
return 2
}
func double(ctx context.Context, num interface{}) interface{} {
switch num.(type) {
case int:
return 2+2
default:
return fmt.Errorf("wanted num to be an int, got: %T", num)
}
return nil
}
```
But this kind of repetition would be required for _every function_. I don't
really know what the best way to solve this in a generic way would be, but I'm
fairly sure that these fundamental limitations in Go prevent this package from
being genericized to handle function outputs and inputs beyond what you can do
with currying (and maybe clever pointer usage).
I would love to be proven wrong though. If anyone can take this [source code
under the MIT license](/static/blog/maybedoer.go) and prove me wrong, I will
stand corrected and update this blogpost with the solution.
This kind of thing is more easy to solve in Rust with its
[Result](https://doc.rust-lang.org/std/result/) type; and arguably this entire
problem solved in the Go package is irrelevant in Rust because this solution is
in the standard library of Rust.

53
static/blog/maybedoer.go Normal file
View File

@ -0,0 +1,53 @@
// Package maybedoer contains a pipeline of actions that might fail. If any action
// in the chain fails, no further actions take place and the error becomes the pipeline
// error.
//
// MIT License
package maybedoer
import "context"
// Doer is a function that implements a fallible action that can be done.
type Doer func(context.Context) error
// Impl sequences a set of actions to be performed via calls to
// `Maybe` such that any previous error prevents new actions from being
// performed.
//
// This is, conceptually, just a go-ification of the Maybe monoid, but
// defined to the error type in Go.
type Impl struct {
Doers []Doer
err error
}
// Do executes the list of doers, right-folding the functions and seeing if one
// returns an error. This is semantically identical to Data.Monoid.First in
// Haskell, but specific to the error type in Go. Ideally this could be generalized
// to any pointer-like datatype in Go, but Rob Pike says we can't have nice things.
//
// See the Haskell documentation for Data.Monad.First for more information:
// http://hackage.haskell.org/package/base-4.14.0.0/docs/Data-Monoid.html#t:First
func (c *Impl) Do(ctx context.Context) error {
for _, doer := range c.Doers {
c.Maybe(ctx, doer)
if c.err != nil {
return c.err
}
}
return nil
}
// Maybe performs `f` if no previous call to a Maybe'd action resulted
// in an error
func (c *Impl) Maybe(ctx context.Context, f func(ctx context.Context) error) {
if c.err == nil {
c.err = f(ctx)
}
}
// Error returns the first error encountered in the Error chain.
func (c *Impl) Error() error {
return c.err
}

BIN
static/blog/maybedoer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB