blog: maybedoer (#153)
This commit is contained in:
parent
21dee82c96
commit
dccacdd6ca
|
@ -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.
|
|
@ -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
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 1.0 MiB |
Loading…
Reference in New Issue