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
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
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).
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.
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.
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()?
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).
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!
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.
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.
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.
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.
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.
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
}
}
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.
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.
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
.
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
Its possible to support placing the handler on the left-hand-side
v := handler ? f(...)
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)
}
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: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.