Skip to content

Instantly share code, notes, and snippets.

@networkimprov
Last active September 5, 2018 22:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save networkimprov/c6cb3e2dff18d31840f2ef22e79d4a1e to your computer and use it in GitHub Desktop.
Save networkimprov/c6cb3e2dff18d31840f2ef22e79d4a1e to your computer and use it in GitHub Desktop.
Missing Multiple Error Handlers (a Revised Handler Concept), re Go2 handle/check proposal

Now posted on the golang issue tracker, #27519.

The #id/catch error model, a revision of check/handle

Appreciations to the Go team for presenting a thoughtful and detailed set of Go2 draft designs. The Error Handling Draft is great for logging errors via the handler chain, and returning them succinctly. However, Go programs commonly:

a) handle an error and continue the function that received it, and
b) have two or more kinds of recurring error handling in a single function, such as:

{ log.Println(err.Error()); return err }
{ log.Fatal(err) }
{ if err == io.EOF { break } }
{ conn.Write([]byte("oops: " + err.Error())) } // e.g. a network message processor

The check/handle scheme doesn't accommodate these patterns, necessitating an awkward mix of Go1 & Go2 idioms:

if err = f(); err != nil {
   if isSerious(err) { check err }
   // handle recoverable error
}

And there are other problems, outlined in Why Not check/handle below.

The #id/catch Error Model

func (db *Db) GetX(data []byte) (int, error) {
   n, #_   := db.name()                          // return our own errors via default handler
   
   f, #err := os.Open(n)                         // this file's presence is optional
   defer f.Close()
   _, #err  = f.Seek(42, io.SeekStart)
   l, #err := f.Read(data)
   
   #_ = db.process(data)
   
   catch err error {                             // handle OS errors here
      if !os.IsNotExist(err) { log.Fatal(err) }
      log.Println(n, "not found; proceding")
      #err = nil                                 // resume (should be the default?)
   }
   return l, nil
}

Here a catch identifier (catch-id) e.g. #err selects an error handler. A single catch-id may appear in any assignment. A handler is known by its parameter name; the parameter can be of any type. Handlers with the same parameter name in related scopes form a chain. A handler follows the catch-id(s) that trigger it and starts with keyword catch. Catch-ids are not variables and handler parameters are only visible within handlers, so there's no re-declaration of error variables.

Several points are unresolved, see Open Questions below. Catch-id syntax is among them; #id is reminiscent of the URL form for goto id, but ?id, @id, and others are viable.

Please help clarify (or fix) this proposal sketch, and describe your use cases for its features.

Feature Summary

We can select one of several distinct handlers:

func f() error {
   v1, #fat := fatalIfError()     // a non-zero value for #id triggers the corresponding catch
   v2, #wrt := writeIfError()

   v3, #_   := returnIfError()    // default handler, returns the value if triggered
   v4, #!   := panicIfError()     // default handler, panics if triggered; for callers that don't return
   
   catch fat error { log.Fatal(fat) }             // if type is error, type name could be optional
   catch wrt error { con.Write(...); return nil } // return/exit required in last handler on chain
}

We can chain handlers across scopes, as in the Error Handling Draft:

func f() {
   v, #fat := x()
   if v != nice {                                 // new scope
      #fat = y(&v)
      catch fat error { debug.PrintStack() }      // no return/exit; chained with next catch fat
   }
   catch fat error { log.Fatal(fat) }             // parameter types must be the same across the chain
}

We can invoke a handler defined at package level (thanks @8lall0):

func f() error {
   #pkg = x()
   catch pkg { ... }        // optional
}

catch pkg {                 // package-level handler
   log.Println(pkg.Error())
   return pkg               // return signature must match function invoking pkg handler
}

We can cancel the chain and resume after the handler:

   #err = f()
   catch err { log.Println(...); #err = nil }  // exit handler; don't goto next handler in chain
                                               // can be the only/last handler in chain
   x()                                         // called after handler

We can change the input for the next chained handler:

   if t {
      #err = f()
      catch err {
         if ... {
            #err = MyError{...}  // exit handler; set input for next handler in chain
         } else {
            err = nil            // no effect on next handler
         }
      }
   }
   catch err { return err }      // if missing, compiler complains about self-invocation

We can forward to a different handler chain (can be applied to create an explicit chain in lieu of an implicit one):

   if ... {
      #err = f()
      catch err { if ... { #ret = err } }  // exit handler; pass err to handler in same or outer scope
                                           // err must be non-zero
   }
   catch ret { ... }
   catch err { ... }                       // may not be called

We can see everything from the scope where a handler is defined, like closure functions:

   v1 := 1
   if t {
      v2 := 2
      #err = f()
      catch err { x(v1, v2) }
   }

We can still use Go1 error handling:

   v1, err := x()  // OK
   v2, err := y()  // but re-declaration might be abolished!

Open Questions

  • What catch-id syntax? #id, ?id, @id, id!, $id, ...
  • What catch-id for default handlers? #_, #!, #default, #panic, ...
  • What handler definition syntax? catch id, catch id type, catch (id type), except id, ...
  • Require #id or _ for return values of type error?
  • Provide check functionality with f#id()? e.g. x(f1#_(), f2#err())
    If so, disallow nesting? x(f1#err(f2#err()))
  • Allow unused handler?
     func f() {
        catch ret { ... }   // unused
        #ret = x()
        catch ret { ... }   // OK; preceding catch-id selects this
        catch err { ... }   // unused
     }
    
  • Drop implicit handler chaining? (Handler default action would be resume.)
     if ... {
        #err = f1()
        catch err { log.Println(...) }
     }
     #err = f2()
     catch err { return nil } // sees errors from f1 & f2; was f2 the only intended source?
    
  • Provide more context to package-level handlers, e.g. caller name, arguments?
     catch pkg, caller {
        log.Println(caller, pkg.Error())
     }
    
  • Allow multiple handler arguments?
     #val, #err = f()               // return values must be assignable to catch parameter types
     catch val T, err error { ... } // either parameter could be non-zero
    

Disallowed Constructs

Reading a catch-id:

   #err = f()
   if #err != nil { ... }   // compiler complains
   catch err { ... }

Multiple catch-ids per statement:

   #val, #err = f()   // compiler complains
   catch val { ... }  // if f() returns two non-zero values, which handler is executed?
   catch err { ... }

Shadowing of local variables in handlers:

func f() {
   if t {
      err := 2
      #err = f()            // OK; #err handler can't see this scope
   }
   pkg := 1                 // OK; #pkg handler (see above) can't see local variables
   err := 1
   #err = f()
   catch err { return err } // compiler complains; err==1 is shadowed
}

Contiguous handlers with the same catch-id and scope:

   #err = f()
   catch err { ... }
   #ret = f()
   catch err { return err }  // compiler complains
   catch ret { ... }
   catch ret { return ret }  // compiler complains

Why Not check/handle?

  • check is specific to type error and the last return value.
  • check doesn't support multiple distinct handlers.
  • Function call nesting with a per-call unary operator can foster unreadable constructions:
    f1(v1, check f2(check f3(check f4(v4), v3), check f5(v5)))
    May I remind you, we are forbidden this: f(t ? a : b)
  • The outside-in handle chain can only bail out of a function.
  • Handlers that add context should appear after the relevant calls, in the order of operations:
     for ... {
        #err = f()
        catch err { #err = fmt.Errorf("loop: %s", err.Error()) }
     }
     catch err { return fmt.Errorf("context: %s", err.Error()) }
    
  • There is relatively little support for the draft design on the feedback wiki.

Named Handlers Are Popular!

At last count, more than 1/3rd of posts on the feedback wiki suggest ways to select one of several handlers:

  1. @didenko github
  2. @forstmeier gist
  3. @mcluseau gist
  4. @the-gigi gist
  5. @PeterRK gist
  6. @marlonche gist
  7. @alnkapa github
  8. @pdk medium
  9. @gregwebs gist
  10. @gooid github
  11. @networkimprov this page

/cc @rsc @mpvl @griesemer @ianlancetaylor @8lall0 @sdwarwick @kalexmills
@gopherbot add Go2 LanguageChange Proposal

Thanks for your consideration,
Liam Breck
Menlo Park, CA, USA

@peterbourgon
Copy link

I don't believe items (2) or (3) are common. log.Fatal should not be invoked outside of func main, and conn.Write(err) (or any other mechanism of handling an error that doesn't involve returning it) is outside the scope of "error handling".

@networkimprov
Copy link
Author

networkimprov commented Aug 29, 2018

log.Fatal should not be invoked outside of func main

Thanks for your input! A goroutine can encounter a fatal error, so your assertion implies an error channel which main() must wait or select on. And pushing an error to that channel is rather like writing it to a conn :-)

@pborman
Copy link

pborman commented Aug 29, 2018

There are lots of log.Fatal calls in packages outside of main (at Google), though these packages generally are specific to a single program or suite of programs. I do agree that packages that are expected to be used outside their own little world probably should not call log.Fatal, unless that is their documented behavior. They also should not call os.Exit or panic.

@8lall0
Copy link

8lall0 commented Aug 29, 2018

(i'm answering here after your comment @networkimprov :) )

I don't think that handlers should be declared wherever you want, beside normal function declaration.

I personally would like catch ret error { return ret } before v, #ret := returnIfError() because it doesn't appear like a flow. I know that it's possible, but i'd like it to be mandatory, otherwhise this isn't very different from exceptions that we all "love".

But i like the idea of "binding" the error variable to the function via catch, even if i don't like this syntax. I can see those even at a package level, binding those different error handling functions to error vars and then use the correct err variable inside your package functions.

@networkimprov
Copy link
Author

networkimprov commented Aug 29, 2018

@8lall0, thanks for your input! Great idea to allow package-level catch pkgVar error {...}! I've added that to my text.

I suggested catches at top or bottom of scope, but not anywhere. I want them at the bottom because a handler could be long, and I find it odd to read what to do last at the top of a scope. However that could be mitigated by allowing local-scope func f() {...} (i.e. not f := func(){...}) to appear anywhere in a function and be callable before it appears.

@sdwarwick
Copy link

very much agree that multiple handlers should be possible.
another aspect not being discussed is testing - what syntax would make a testing harness easiest?

@dchenk
Copy link

dchenk commented Aug 29, 2018

I think the new catch-handle feature already would make Go code slightly harder to grasp quickly in some places. This alternative concept would reduce readability even more.

@networkimprov
Copy link
Author

@dchenk, how so? My concept is simpler than the proposal. Most code would look like:

func x() error {
   #err = f1() // #flag is more noticeable than check
   #err = f2()
   catch err { return err }
}

@8lall0
Copy link

8lall0 commented Aug 31, 2018

The more i read the catch syntax, the less i like. I would prefer a func()-like syntax, to avoid unreadability, or maybe:

#err = func(var T, err error) {}

Also i don't like this:

 err := 1    // compiler complains here
 #err = f()  // or here

I see better:

 err := 1    // still legal, i want to be free (and "err" becomes type int,
             // so compiler complains if i want it afterwars as an Error type)
 #err = f()  // compiler complains if it isn't an error handling function
             // or if err is already declared with a different type, because it's a new assignment.

EDIT by owner to break comment lines
EDIT: some changes and something i read bad :)

@networkimprov
Copy link
Author

Can you give more context for this? I'm not sure what it replaces...

#err = func(var T, err error) {}

@8lall0
Copy link

8lall0 commented Aug 31, 2018

Instead of:

catch err { log.Fatal(err) }

this:

#err = func(err Error) { log.Fatal(err) }

When you do assignment with #, it allows only a function that contains at least an Error parameter on input.

Tl;dr: #err = func() is just a short way to write, replicate and reuse if (err != nil) { ... }, like Rust macros.

@networkimprov
Copy link
Author

networkimprov commented Aug 31, 2018

EDIT: fixed the shadowing restriction, thanks!

#err = f() in my text can be assignment of anything, not just type error from a function call. Any non-zero value triggers the handler. And it could be extended to look for a specific value per handler...

I agree with the orig proposal that we need a distinct keyword for the handlers. And this is an exception scheme, so catch is the natural choice. Also I want to change their proposal as little as possible, as I assume they put a lot of thought into it...

I'm gradually building up a counter-proposal here to be posted in the golang issue tracker. I'll be banging on this a lot over the weekend :-)

@kalexmills
Copy link

kalexmills commented Sep 2, 2018

The need for distinct variables names for different types of errors is well taken, but I don't know why we need any sort of # symbol or its variants, given that errors are already assigned to variables today. What is the difference between the first Features example and this rewrite?

func f() error {
   v1, fat := fatalIfError()   
   v2, wrt := writeIfError()
   v3, _   := returnIfError()    
   
   handle fat { log.Fatal(fat) }        
   handle wrt { con.Write(...); return nil }
}

I am wary of changing the input for the next handler in the chain. I shouldn't have to waste too many cycles thinking about where the error I got at the command-line came from. It seems like swapping inputs about makes that sort of tracing inevitable.

Finally, I think an argument could be made that using catch as reminiscent of C / Java could be more confusing for folks who might it to act like catch does in other languages. A handle keyword indicates the difference.

@networkimprov
Copy link
Author

@kalexmills, thanks for your input.

err := f() appears to declare an ordinary variable (usually checked with if err != nil). If (much) later handle err magically makes it a handler invocation, that's easily overlooked. I think we should flag the special case, so the reader knows to look for a handler.

Re changing input to next handler in chain, a use case:

#err = f1()
if state_x {
   #err = foreignPackage.F()                 // we have no control over what errors this returns
   catch err {
      if odd_case { #err = fmt.Errorf(...) } // fix an error value which foreignPackage messes up
      ...                                    // add context about 'state_x'
   }
}
catch err { ... }                            // we get consistent error values

What features of catch from C++/Java/JavaScript are you referring to?

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