Skip to content

Instantly share code, notes, and snippets.

@networkimprov
Last active September 5, 2018 22:19
Show Gist options
  • 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

@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