diff --git a/blog/maybedoer-2020-05-23.markdown b/blog/maybedoer-2020-05-23.markdown new file mode 100644 index 0000000..2881cd0 --- /dev/null +++ b/blog/maybedoer-2020-05-23.markdown @@ -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: + +
![the source code of package maybedoer](/static/blog/maybedoer.png)
+ +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. diff --git a/static/blog/maybedoer.go b/static/blog/maybedoer.go new file mode 100644 index 0000000..4a69a12 --- /dev/null +++ b/static/blog/maybedoer.go @@ -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 +} diff --git a/static/blog/maybedoer.png b/static/blog/maybedoer.png new file mode 100644 index 0000000..47234c6 Binary files /dev/null and b/static/blog/maybedoer.png differ