Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Go 2 error handling alternative: handlers as normal functions and an error return?

Its amazing to see the error handling problem being properly addressed. The existing proposal is good, but I think we can iterate to make it better. I think we can simplify it and address these issues:

  • Defining handlers as a stack is presumptious: it forces handlers to be side-effecting, and ruins interop with defer.
  • return statements are placed in handlers, ruining composition.
  • using check as a prefix function reduces code clarity.
  • a new anonymous function syntax is introduced into the proposal, but only available to handle.
  • the proposal introduces two keywords, but one may suffice

It's useful to take a step back and clearly state what we are trying to do:

  • provide an abstraction that allows for the insertion of a return statement for errors.
  • compose handler functions together before they are used with the error return

In the existing go language, I cannot write a handler function which will create an early return from the function. There are a few approaches that languages use for this control flow:

  • Macros (e.g. Rust originally used a try! macro).
  • Ruby supports anonymous functions that return from the enclosing function (method)
  • exceptions or gotos
  • A Haskell function can use a Monad that supports early return

Ruby's blocks with early returns

Lets look at Ruby's block syntax.

irb(main):001:0> def block(&block); block.call() ; end
=> :block
irb(main):008:0> def return1(); block { return 1 } ; return 9 ;  end
=> :return1
irb(main):009:0> return1()
=> 1

Its even possible to write the returning function ahead of time

irb(main):006:0> def returns2() ; b = Proc.new { return 2 } ; block(&b) ; 3 end
=> :returns2
irb(main):007:0> returns2()
=> 2

Could we introduce Ruby's block's to go to solve this problem? In the simple case yes, but in the general case there is a composability problem: you cannot really compose two handlers that both want to return early.

irb(main):012:0> def notcomposed()
irb(main):013:1>   c = Proc.new { return 10 } ; d = Proc.new { return 20 } ;
irb(main):014:1*   c.call() + d.call()
irb(main):015:1> end
=> :notcomposed
irb(main):016:0> notcomposed()
=> 10

Existing proposal's handlers with returns

The current proposal has a handle keyword with a new special anonymous function that behaves equivalent to Ruby's Proc.new. That is, a return returns from the outer function.

func process(user string, files chan string) (n int, err error) {
    handle err { return 0, fmt.Errorf("process: %v", err)  }      // handler A

Just as in Ruby, the early return means composition does not work. In go, it should be easy easy to automatically handle the return: return the error given at the check statement, and any other return values should be set to the zero value. In fact, the proposal provides a special "Default Handler" which does just that.

The reason why the existing proposal has non-local returns is because in the proposal we are forced to construct handlers as a stack (see next section).

Stack scoping

The proposal forces handlers to compose as a stack scope. This is convenient in many cases. However, it forces a side-effecting style to handlers. Additionally, it limits the programmer and adds mental overhead to track the implicit stack. The stack-style itself is not always desired. That is, once a handler is added, it can only be deleted by adding a return to a new handler.

Lets look at the example from the proposal:

func SortContents(w io.Writer, files []string) error {
    handle err {
	return fmt.Errorf("process: %v", err)             // handler A
    }

    lines := []strings{}
    for _, file := range files {
	handle err {
	    return fmt.Errorf("read %s: %v ", file, err)  // handler B
	}
	scan := bufio.NewScanner(check os.Open(file))     // check runs B on error
	for scan.Scan() {
	    lines = append(lines, scan.Text())
	}
	check scan.Err()                                  // check runs B on error
    }
    sort.Strings(lines)
    for _, line := range lines {
	check io.WriteString(w, line)                     // check runs A on error
    }
}

Here handler B is able to disregard handler A by using a return. However, I think this demonstrates that although stacks may often be convenient, they are not quite what we want.

Alternative Proposal: handlers as functions, just a special check

Lets look at our goals again:

  • provide an abstraction that allows for the insertion of a return statement for errors.
  • compose handler functions together before they are used with the error return

Composition can be handled with normal functions. That means we just need a mechanism to insert a return. For my proposal, I will use a question mark operator ? rather than a check keyword. This is because the operator can be used postfix and there are advantages in readability to a postfix positioning (see Appendix: Operator versus check function). return is handled solely by the ? operator. Any return in a handler function is just the normal function return.

Putting this together, lets re-write the above SortContents

func SortContents(w io.Writer, files []string) error {
    handlerAny := func(err error) error {
	return fmt.Errorf("process: %v", err)
    }

    lines := []strings{}
    for _, file := range files {
	handlerFiles := func(err error) error {
	    return fmt.Errorf("read %s: %v ", file, err)
	}
	scan := bufio.NewScanner(os.Open(file) ? handlerFiles)
	for scan.Scan() {
	    lines = append(lines, scan.Text())
	}
	scan.Err() ? handlerFiles
    }
    sort.Strings(lines)
    for _, line := range lines {
	io.WriteString(w, line) ? handlerAny
    }
}

In this new form we no longer need to fight the stack with a non-local return in our handler.

Let's re-write this example from the proposal (slightly simplified):

func process(user string, files chan string) (n int, err error) {
    handle err { fmt.Errorf("process: %v", err)  }      // handler A
    for i := 0; i < 3; i++ {
	handle err { err = fmt.Errorf("attempt %d: %v", i, err) } // handler B
	check do(something())  // check 1: handler chain B, A
    }
    check do(somethingElse())  // check 2: handler chain A
}

Alternative:

func process(user string, files chan string) (n int, err error) {
    ahandler := func(err error) error { return fmt.Errorf("process: %v", err) }
    for i := 0; i < 3; i++ {
	bhandler := func(err error) error { return fmt.Errorf("attempt %d: %v", i, err) }
	do(something()) ? ahandler.ThenErr(bhandler)
    }
    do(somethingElse()) ? ahandler
}

Note that there is no special handle keyword, just a special check done by the ? operator. It is possible to combine handlers in the same way one would combine functions:

do(something()) ? ahandler.ThenErr(func(err error) error {
	return fmt.Errorf("attempt %d: %v", i, err) }
)

Or

do(something()) ? func(err error) { return ahandler(bhandler(err)) }

The example uses a hypothentical .ThenErr method (see appendix) as a way to compose error functions.

Checking error returns from deferred calls

The original proposal states that check is incompatible with defer. This is due to its use of a handler stack. So this alternative proposal has no such problem. It is quite possible now to support returning errors from defer:

defer w.Close()?

Results

This alternative proposal avoids introducing a special handle construct, and gives much greater flexibility to the programmer. Handlers can be naturally composed as functions, rather than fighting each-other with side-effects. By avoiding implicit stacking, it could return errors from defer. The code is still much more succinct and organized than current go error handling code.

This alternative proposal results in code that is slightly more verbose than the original proposal (see next section).

Handle and anonymous function syntax

The main reason this alternative proposal is more verbose than the original is that the handle construct is not just a keyword. It also introduces a special anonymous function syntax that is lighter-weight and infers types.

handle err { return fmt.Errorf("process: %v", err) }

Without this syntax, the proposal would read:

handle func(err error) error { return fmt.Errorf("process: %v", err) }

I think it is worthwhile to explore having anonymous functions that are lighter-weight. However, I think this should be useable anywhere rather than just with a single keyword. For example, the Rust syntax version:

handler := |err| fmt.Errorf("process: %v", err)

But its also possible to try to extend the existing go syntax:

handler := func err fmt.Errorf("process: %v", err)

But please leave this for another proposal rather than throw it in the mix with error handlers!

The question mark operator: unary or binary.

Note that the question mark operator can be used as a unary to just return the exception without any handlers running.

something()?

This is equivalent to

something() ? func(err error) error { return err }

I am favoring writing the unary form without any spaces in this case (more similar to Rust), but we should use whatever syntax the community finds best.

Notes on error handler function types

To respond to errors we want to do one of two things:

  • cleanup (side-effecting): (error) -> nil or () -> nil
  • modify the error: (error) -> error

It is possible to do both in one function, but then your function signature is the same as modifying the error. The question mark operator should accept all forms. A cleanup function will automatically be converted to return the original error that would have been passed to it.

Note that helpers which compose error functions such as ThenErr also need to compose the cleanup function such that it returns the error given to the cleanup function.

Appendix: Handling errors within the handler itself

A cleanup handler may generate a new error that should be propagated in addition to the current error. I believe this should just be handled by a multi-error technique, e.g. multierr.

Appendix: custom error types

The existing proposal seems like it would cast a concrete error type to the error interface when it is passed to a handler. I don't think this proposal is fundamentally different. I think this issue should be solved by the generics proposal.

Appendix: defer-like statements and break/continue

It is possible to support right-hand-side statements

f() ? panic("oh no")

However, I don't thing the complexity is justfied. It may be useful, however, to think about supporting break/continue.

f() ? break

I think though, that in many of those cases one would want to set an error value before breaking, so I am not sure how useful this is in practice.

Appendix: ThenErr

An implementation of ThenErr (some more combinations are needed, this should show the concept)

type Cleanup func(error)
type CleanupNoThanks func()
type ModifyError func(error) error


func (fn1 CleanupNoThanks) ThenErr(fn2 ModifyError) {
	return func(err error) error {
		fn1()
		fn2(error)
	}
}

func (fn1 ModifyError) ThenErr(fn2 Cleanup) {
	return func(err error) error {
		newErr := fn1(error)
		fn2(newErr)
		return newErr
	}
}

Appendix: Operator versus check function

The original proposal had just one argument given to check. This alternative favors the question mark because there are now 2 arguments. The original proposal states that there is a large readability difference in these two variants:

check io.Copy(w, check newReader(foo))
io.Copy(w, newReader(foo)?)?

However, I think this is a matter of personal preference. Once there is a left-hand-side assignment, the readability opinion may also change.

copied := check io.Copy(w, check newReader(foo))
copied := io.Copy(w, newReader(foo)?)?

Now lets add in a handlers and check our preference again.

copied := check(io.Copy(w, check(newReader(foo), ahandler), bhandler)
copied := io.Copy(w, newReader(foo) ? ahandler) ? bhandler

I believe ? will be slightly nicer to use due to

  • fewer parantheses
  • putting error handling solely on the right-hand-side rather than both the left and right.

Note that it is also possible to put all the error handling on the left-hand-side of the error source.

copied := check(bhandler, io.Copy(w, check(ahandler, newReader(foo)))

But because a success result is still transferred to the left, I prefer keeping error handling on the right-hand-side.

Here is another proposal that I believe advocates the same solution proposed in this alternative, but with a check function.

Appendix: invoke the handler from the assignment position

Other proposals are similar to this, but suggest invoking the handler on the left-hand-side in the assignment position.

v, #handler := f()

Where # is much like ?: it invokes a handler with an error. There has also been a suggestion to extend this to a more general pattern matching.

v, nil ? handler := f()

I would like to see a separate pattern matching proposal for go. In the mean-time, I still prefer error handling on the right-hand-side because it allows for inline handlers.

Appendix: built-in result type

A go programmer that has used Rust, Swift, Haskell, etc will be missing a real result type. I would like to see a go 2 proposal for discriminated unions which includes a result type. However, I think both the original proposal and this alternative proposal would work fine with the addition of a result type. This is because go effectively already has a result type. It is a tuple where the last member is of type error.

Appendix: intermediate bindings for readability

Error handling on the right-hand-side may increase line length undesirably or seem to be easy to miss. Its always possible to use an intermediate binding.

v, err := f(...) // could be a million lines long
err ? handler

Appendix: left-hand-side

Its possible to support placing the handler on the left-hand-side

v := handler ? f(...)

Appendix: all proposal examples re-written

Below are the rest of the code snippets shown in the original proposal, transformed to this alternative proposal.

func TestFoo(t *testing.T) {
	handlerFatal := func(err error) { t.Fatal(err) }
	for _, tc := range testCases {
		x := Foo(tc.a) ? handlerFatal
		y := Foo(tc.b) ? handlerFatal
		if x != y {
			t.Errorf("Foo(%v) != Foo(%v)", tc.a, tc.b)
		}
	}
}

func printSum(a, b string) error {
	handler := func(err error) error { fmt.Errorf("printSum(%q + %q): %v", a, b, err) }
	x := strconv.Atoi(a) ? handler
	y := strconv.Atoi(b) ? handler
	fmt.Println("result:", x + y)
	return nil
}

func printSum(a, b string) error {
	fmt.Println("result:", strconv.Atoi(x)? + strconv.Atoi(y)?)
	return nil
}

func CopyFile(src, dst string) error {
	handlerAll := func(err error) error {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	r := os.Open(src) ? handler
	defer r.Close()

	w := os.Create(dst) ? handlerAll
	handleCleanup := handlerAll.ThenErr(func(err error) {
		w.Close()
		os.Remove(dst) // (only if a check fails)
	})

	check io.Copy(w, r) ? handlerCleanup
	check w.Close() ? handlerCleanup
	return nil
}


func main() {
	handlerAll := func(err error) error {
		log.Fatal(err)
	}

	hex := check ioutil.ReadAll(os.Stdin) ? handlerAll
	data := check parseHexdump(string(hex)) ? handlerAll
	os.Stdout.Write(data)
}
@networkimprov

This comment has been minimized.

Copy link

@networkimprov networkimprov commented Aug 31, 2018

Wow that was thorough :-)

This scheme has the crucial advantage of being able to select one of several handlers, as with this alternate handler concept.

Placing the check (now ? functionName) after the function call might be a problem for multi-line calls:

v := f(argOne, argTwo,
       argThree{
          s: "somestring",
          n: 123.45e22,
       }) ? handler // easily overlooked

This may remind folks of unreadable nested ternaries: io.Copy(w, newReader(foo) ? ahandler) ? bhandler

Also I'm pretty sure they want to enable a chain of handlers, so inner ones can add context.

@gregwebs

This comment has been minimized.

Copy link
Owner Author

@gregwebs gregwebs commented Aug 31, 2018

@networkimprov Thanks for the feedback about multi-line readability. Since I just used examples from the proposal, I didn't go over that. I think that to some extent this is a problem with golang not having actual tuples (or a result discriminated union type). With tuples or a result discriminated union type I could do this:

verr := f(...) // could be a million lines long
v := verr ? handler

Perhaps that indicates a simple solution would look like this:

v, err := f(...) // could be a million lines long
err ? handler

However, it is still possible to put the example you give into one line.

three :=  argThree{
          s: "somestring",
          n: 123.45e22,
       }
v := f(argOne, argTwo, argThree) ? handler

A similar concern is in having one very long line.

v := f(argOne, argTwo, argThree{s: "somestring", n: 123.45e22, more: "more stuff", long: "this is getting long"} ) ? handler

However, in most cases a long line still means some arguments could be placed into variables before the call.
Another similar concern is just being far in indentation and now adding the handler makes you go over your line length limit. But it seems that one should be able to extend on to the next line.

Perhaps though you are making a strong argument for using a check function as proposed here. I would be happy with either approach.

I will throw out a few more alternatives. In all, the question mark could be placed before the function. So the first option would be saying that the binary operator accepts a handler on either side.

v := handler ? f(...)

The rest of the options are Haskell inspired: I have a feeling gophers won't like them.
In Haskell a binary operator can be curried:

v := (? handler)(f(...))

Similar syntax, but a different concept for this case:

v, (? handler) := f(...)

The operator could also be altered to indicate location.

v := handler ?= f(...)
v := f(...) =? handler
@networkimprov

This comment has been minimized.

Copy link

@networkimprov networkimprov commented Aug 31, 2018

I think all the same-line handler invocations, including ? handler and check lead to unreadable call nesting. Go forbids even this
f(t ? a : b)
but might now enable this?
v := check f1(check f2(), check f3(check f4()))

I think they'll realize that check is not Goey :-)

I'd be interested to hear any comments on the gist I linked above...

@gregwebs

This comment has been minimized.

Copy link
Owner Author

@gregwebs gregwebs commented Aug 31, 2018

@networkimprov I would be fine with ? handler or check in the given example. But still, one can always create more intermediates to improve readability:

v4 := f4() ? handler
v1 := f2() ? handler
v3 := f3(v4) ? handler
v := f1(v2, v3) ? handler

I don't think proposals need to optimize for this use case (they just need to be acceptable) if it is infrequent in production code (in my experience it is, but maybe yours is different?).

@ef6

This comment has been minimized.

Copy link

@ef6 ef6 commented Sep 1, 2018

Why don't you think about this, they are equally embarrassed

v, ok = m[key]             // map lookup
if !ok {
   ...
}

v, ok = x.(T)              // type assertion
if !ok {
    ...
}

v, ok = <-ch               // channel receive
if !ok {
    ...
}

Here is a good idea about that

v, (!nil  ? handler) := f(...)             //err handler
v, (!nil  ? return)  := f(...)             //err return
v, (false ? break)   := m[key]             // map lookup
v, (false ? panic)   := x.(T)              // type assertion
v, (false ? break)   := <-ch               // channel receive

They are equivalent to the present

v, err := f(...)           //err handler
if err != nil{
    handler(err)
}

v, err := f(...)           //err return
if err != nil{
    return ...,err         //According to the definition of the current function
}

v, ok = m[key]             // map lookup
if ok==false {
   break
}

v, ok = x.(T)              // type assertion
if ok==false {
    panic(...)             //The compiler prints the current information
}

v, ok = <-ch               // channel receive
if !ok {
    break
}

He also works well when returning multiple error values.

err1,err2,err3 := f(...)
if err1 != nil{
    ...
}
if err2 != nil{
    ...
}
if err3 != nil{
    ...
}

Can express like this

(!nil ? handler1),(!nil ? handler2),(!nil ? handler3) := f(...)

It advantage is that the err value is just a value. they are equality.

I don't like cheak and handle. They actually make the err value as an exception like try catch.

@gregwebs

This comment has been minimized.

Copy link
Owner Author

@gregwebs gregwebs commented Sep 1, 2018

@ef6 Interesting, that is similar to one of the alternatives I mentioned, but you have extended it to include a value test and break statements.

The similar version I mentioned was:

v, (? handler) := f(...)

Probably it makes more sense to remove the parens.

v, ?handler := f(...)

It makes sense to me to generally support break/continue in addition to a handler function in any of the proposals.
However, I think there are some problems with the form that allows different values:

  • !nil is a special syntax, it should probably be !=nil
  • it is less type-safe now that you are testing nil: it could more easily accidentally be used on a non-error interface.
  • there is some increased verbosity

Still, I see the value in trying to support checking false. Perhaps checking against a nil error could be implied when nothing is given?

v, ? handler := f(...)                // run handler if err is nil. Only compiles for the error interface
v, ? := f(...)                       // no handler, just return the err
v, (!= nil) ?  := f(...)                // return the value if the value is not nil
v, (!= nil) ? return := f(...)                // return nothing if the value is not nil
v, false ? break   := m[key]          // map lookup
v, false ? panic("oh no")   := x.(T)  // type assertion
v, false ? break   := <-ch            // channel receive

The panic form would be an additional ability to write a statement, much like how defer is used now. This would also be useful in my actual proposal with error handling on the right-hand-side.

In the end we may be re-inventing pattern matching though, so its probably better to think of how to separate out error handling from pattern matching.

@gregwebs

This comment has been minimized.

Copy link
Owner Author

@gregwebs gregwebs commented Sep 1, 2018

Also, that's an interesting note about returning multiple errors. However, I think that is a rare occurrence (I have never seen it myself) that can be handled the old way. Additionally, it is a questionable API design: I think in most cases it would be better is probably to return a combined error that can be interrogated (the other go proposal would help with the interrogation part).

@ef6

This comment has been minimized.

Copy link

@ef6 ef6 commented Sep 1, 2018

Oh, I realize I made a mistake.

value,(<cmp> ? <to handle function>) := f(...)

The corresponding return value of the function is compared with

If the return value equal Then to handle this value.

Actually that is just a syntactic sugar. it can translate

verr :=  f(...)
if err == <cmp> {
     <to handle function>
}

But

v, (!nil  ? handler) := f(...) 

verr :=  f(...)
if err == !nil {                        //Obviously he is wrong
     <to handle function>
}

I'll tell you what I think.

In the Golang err is a value,not is an exception.So I don't want talk about try/catch or check/handler.

In the first,We have to know An error return from this function.

So How?

In present golang is

<Variable>,<Variable>... = value/function()

We want to Assert what we expect return from function.

Now I have a new idea.It is easier to understand.

<Variable>,(<Value>) = function() ? handler

I use () to express it is Expected value, not is Variable.it assert the return value.

? express that assert fault,then handler this value.

It like this

v, (nil)  := f(...) ? handler         //err handler
v, (nil)  := f(...) ? return          //err return
v, (true) := m[key] ? break           // map lookup
v, (true) := <-ch   ? break           // channel receive
v, (true) := x.(T)  ? panic           // type assertion

And you can expect multiple values,like this

v,(nil || io.EOF) := io.Read() ? return //This is just an example

By the way, I think it not clarity and unity after ? .

like function handler/panic and control return/break/continue

Maybe we should think about that.

@gregwebs

This comment has been minimized.

Copy link
Owner Author

@gregwebs gregwebs commented Sep 4, 2018

@ef6. This makes a lot of sense: it is pattern matching (with early returns). However, I think we should clearly define pattern matching separate of early returns as a separate proposal and note how it could combine with early returns.

@billyh

This comment has been minimized.

Copy link

@billyh billyh commented Sep 27, 2018

I like a lot about this proposal, especially:

  1. Handlers as functions seems both more general and more like idiomatic Go, and also eliminates the need for one new keyword.
  2. Explicit chaining of handlers with something like ThenErr is both more general and clearer.
  3. Using ? on the RHS keeps the primary logic on the left making it easier to scan code for its intent. It's also succinct.

Quick question/suggestion: maybe

    something()?

should follow the behavior described by @the-gigi: return the zero value of unnamed return values and the current value of named return values?

I like the idea of including support for ? break and ? continue, too.

I'm not a fan of putting the handler invocation on the LHS of an assignment as some other proposals and some of the discussion above suggests, primarily because it's simply not an assignment. It feels unfamiliar and misleading to me.

I also like your proposal for streamlining the syntax for anonymous functions, which complements this proposal, but - as you say - should be considered separately.

PS: Your last two rewritten examples used the check keyword. Was that a mistake, or did I misunderstand something?

@billyh

This comment has been minimized.

Copy link

@billyh billyh commented Oct 3, 2018

Instead of

do(something()) ? ahandler.ThenErr(bhandler)

why not

do(something()) ? ahandler ? bhandler

The latter syntax, in addition to being more concise and consistent, implies that bhandler only gets called if ahandler returns a non-nil error value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment