Skip to content

proposal: spec: the #id/catch error model, a rethink of check/handle #27519

Closed
@networkimprov

Description

@networkimprov

Please do not down-vote this post if you are against any new syntax for error handling.
Instead, vote in the first comment below. Thanks!

Having heard users' frustrations with Go1 error handling, the Go team has committed to delivering a new method. Ideally, a solution would stem from a familiar language. The Go2 Draft Design is fine for wrapping errors with context, and returning them succinctly. But its feel is novel, and it has significant drawbacks, discussed in Golang, how dare you handle my checks!

Besides returning errors, 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) }
{ debug.PrintStack(); log.Fatal(err) }
{ if err == io.EOF { break } }
{ conn.Write([]byte("oops: " + err.Error())) } // e.g. a network message processor

There is indeed a long list of Requirements to Consider for Go2 Error Handling. The check/handle scheme accommodates a tiny subset of these, necessitating an awkward mix of Go1 & Go2 idioms, e.g.

handle err { return fmt.Errorf(..., err) }
v, err := f()
if err != nil {
   if isBad(err) {
      check err     // means 'throw'
   }
   // recover
}

Herein is a widely applicable approach to error handling, leveraging the C-family catch block. For the record, the author is grateful that Go does not provide C++ style exceptions, and this is not a plot to sneak them into the language through a side door :-)

The #id/catch Error Model

Let a catch identifier (catch-id) e.g. #err select a named 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. A handler follows the catch-id(s) that trigger it and starts with catch <parameter>. Catch-ids are not variables and handler parameters are only visible within handlers, so there's no re-declaration of error variables.

These are not unique ideas. At last count, 17 posts on the feedback wiki suggest various ways to define and invoke named handlers, and 13 posts suggest invocation of handlers using assignment syntax.

func (db *Db) GetX(data []byte) (int, error) {
   n, #return := db.name()                       // return our own errors
   
   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)
   
   #return = db.process(data)
   
   catch err error {                             // handle OS errors here
      if !os.IsNotExist(err) { log.Fatal(err) }
      log.Println(n, "not found; proceding")
   }

   #return = db.index(data)                      // executes unless catch exits
   return l, nil
}

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.

Advantages: similarity to established try/catch method (but without a try-block); clarity as to which handler is invoked for a given statement; certain statements may be skipped after an error occurs; handlers can return or continue the function.

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

Feature Summary

Draft-2, 2018-09-19. Discussion following this comment below pertains to this draft.

These features meet a large subset of the Requirements to Consider for Go 2 Error Handling.

We can select one of several distinct handlers:

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

                                  // predefined handlers
   v3, #_   := ignoreIfError()    // or log the error in debug mode
   v4, #r   := returnIfError()    // aka #return
   v5, #p   := panicIfError()     // aka #panic
   
   catch fat       { log.Fatal(fat) }           // inferred parameter type
   catch wrt error { conn.Write(wrt.Error()) }
}

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

func f() error {
   #pkg = x()
}

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

We can specify a type for implicit type assertion:

   f := func() error { return MyError{} }
   #err = f()
   catch err MyError { ... }

We can skip statements on error and continue after the handler:

   #err = f()
   x(1)                              // not called on error
   catch err { log.Println(err) }
   x(2)                              // always called

We can forward to a different handler (creates an explicit handler chain):

   #ret = x()
   if ... {
      #err = f()
      catch err {
         if ... { #ret = err }         // invoke alternate handler
         #ret = fmt.Errorf(..., err)   // invoke handler with alternate input
      }
   }
   catch ret { ... }

We can reuse catch-ids:

   #err = f(1)
   catch err { ... }
   #err = f(2)
   catch err { ... }

We can nest catch blocks:

   #era = f(1)
   catch era {
      #erb = f(2)
      catch erb { ... }      // cannot use 'era'; shadowing in catch disallowed
   }

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 style for predefined handlers? #r, #p, #_, #return, #panic, #nil, ...

  • What handler definition syntax? catch id [type], catch id(v type), id: catch v [type], ...
    Infer parameter from previous stmt? #err = f(); catch { log.Println(err) }

  • Invoke handler when ok=false for v, #ok := m[k]|x.(T)|<-c, etc?
    Pass a type error with context? v, #err := m[k]; catch { log.Println(err) }

  • Treat parameter as const? catch err { err = nil } // compiler complains
    Lets forwarding skip test for nil: catch err { #ret = err }

  • Require #id for return values of type error? proposal: spec: require return values to be explicitly used or ignored (Go 2) #20803

  • Provide check functionality with f#id()? e.g. x(f1#_(), f2#err())
    If so, disallow nesting? x(f1#err(f2#err()))
    Allow position selector? f#id.0() tests first return value

  • Provide more context to package-level handlers, e.g. caller name, arguments?
    catch (pkg error, caller string) { ... }

  • Allow handlers in defer stack?

     defer last()              // skip if handler returns
     defer catch errd { ... }
     defer next#errd()         // skip if first() invokes handler
     defer first#errd()
    
  • Allow multiple handler arguments?

     #val, #err = f()                 // return values assignable to catch parameter types
     catch (val T, err error) { ... } // either parameter could be non-zero
    

Disallowed Constructs

Declaring or reading a catch-id:

   var #err error           // compiler complains
   #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
}

Self-invocation:

   #err = f(1)
   catch err {
      #err = f(2)        // compiler complains
   }
   #err = f(3)
   catch err { ... }

Unused handlers:

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

Discarded Ideas

Chain handlers with same catch-id in related scopes implicitly, as in the Draft Design:

func f() {
   v, #fat := x()
   if v != nice {                      // new scope
      #fat = y(&v)
      catch fat {                      // invoked 1st
         if ... { #fat = nil }         // can skip other handlers in chain
      }                                // no return/exit, continue along chain
   }
   catch fat { log.Fatal(fat) }        // invoked 2nd
}

Changelog

2018-09-19 draft-2 (discussion below)
a) Move implicit handler chain to new section "Discarded Ideas".
b) Make continuing after catch the default behavior.
c) Document catch-id reuse and nested catch block.
d) Disallow unused handlers (was "contiguous handlers with same catch-id") and self-invocation.
e) Add #_ predefined handler to ignore or log input.
f) Add implicit type assertion.

Why Not check/handle?

Please read Golang, how dare you handle my checks! for a discussion of each of the following points.

  • No support for multiple distinct handlers.
  • The last-in-first-out handle chain cannot continue a function.
  • check is specific to type error and the last return value.
  • The per-call unary check operator can foster unreadable constructions.
  • The default handler makes it trivial to return errors without context.
  • Handlers appear before the calls that trigger them, not in the order of operations.
  • The handle chain is inapparent; one must parse a function by eye to discover it.

Also, there is relatively little support for the draft design on the feedback wiki.

At last count, roughly 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. @spakin gist
  12. @morikuni gist
  13. @AndrewWPhillips blogspot
  14. @bserdar gist
  15. @martinrode medium
  16. @dpremus gist
  17. @networkimprov this page

And the following posts suggest ways to invoke a handler with assignment syntax:

  1. @oktalz gist
  2. @pborman gist
  3. @kd6ify blog
  4. @rockmenjack github
  5. @the-gigi gist
  6. @8lall0 gist
  7. @dpremus gist
  8. @bserdar gist
  9. @mcluseau gist
  10. @didenko github
  11. @gooid github
  12. @Kiura gist
  13. @networkimprov this page

/cc @rsc @mpvl @griesemer @ianlancetaylor @8lall0 @sdwarwick @kalexmills

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    LanguageChangeSuggested changes to the Go languageLanguageChangeReviewDiscussed by language change review committeeProposalerror-handlingLanguage & library change proposals that are about error handling.

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions