golang / go

The Go programming language
https://go.dev
BSD 3-Clause "New" or "Revised" License
124.32k stars 17.7k forks source link

Proposal: A built-in Go error check function, "try" #32437

Closed griesemer closed 5 years ago

griesemer commented 5 years ago

Proposal: A built-in Go error check function, try

This proposal has been closed. Thanks, everybody, for your input.

Before commenting, please read the detailed design doc and see the discussion summary as of June 6, the summary as of June 10, and most importantly the advice on staying focussed. Your question or suggestion may have already been answered or made. Thanks.

We propose a new built-in function called try, designed specifically to eliminate the boilerplate if statements typically associated with error handling in Go. No other language changes are suggested. We advocate using the existing defer statement and standard library functions to help with augmenting or wrapping of errors. This minimal approach addresses most common scenarios while adding very little complexity to the language. The try built-in is easy to explain, straightforward to implement, orthogonal to other language constructs, and fully backward-compatible. It also leaves open a path to extending the mechanism, should we wish to do so in the future.

[The text below has been edited to reflect the design doc more accurately.]

The try built-in function takes a single expression as argument. The expression must evaluate to n+1 values (where n may be zero) where the last value must be of type error. It returns the first n values (if any) if the (final) error argument is nil, otherwise it returns from the enclosing function with that error. For instance, code such as

f, err := os.Open(filename)
if err != nil {
    return …, err  // zero values for other results, if any
}

can be simplified to

f := try(os.Open(filename))

try can only be used in a function which itself returns an error result, and that result must be the last result parameter of the enclosing function.

This proposal reduces the original draft design presented at last year's GopherCon to its essence. If error augmentation or wrapping is desired there are two approaches: Stick with the tried-and-true if statement, or, alternatively, “declare” an error handler with a defer statement:

defer func() {
    if err != nil { // no error may have occurred - check for it
        err = … // wrap/augment error
    }
}()

Here, err is the name of the error result of the enclosing function. In practice, suitable helper functions will reduce the declaration of an error handler to a one-liner. For instance

defer fmt.HandleErrorf(&err, "copy %s %s", src, dst)

(where fmt.HandleErrorf decorates *err) reads well and can be implemented without the need for new language features.

The main drawback of this approach is that the error result parameter needs to be named, possibly leading to less pretty APIs. Ultimately this is a matter of style, and we believe we will adapt to expecting the new style, much as we adapted to not having semicolons.

In summary, try may seem unusual at first, but it is simply syntactic sugar tailor-made for one specific task, error handling with less boilerplate, and to handle that task well enough. As such it fits nicely into the philosophy of Go. try is not designed to address all error handling situations; it is designed to handle the most common case well, to keep the design simple and clear.

Credits

This proposal is strongly influenced by the feedback we have received so far. Specifically, it borrows ideas from:

Detailed design doc

https://github.com/golang/proposal/blob/master/design/32437-try-builtin.md

tryhard tool for exploring impact of try

https://github.com/griesemer/tryhard

unexge commented 5 years ago

maybe we can add a variant with optional augmenting function something like tryf with this semantics:

func tryf(t1 T1, t1 T2, … tn Tn, te error, fn func(error) error) (T1, T2, … Tn)

translates this

x1, x2, … xn = tryf(f(), func(err error) { return fmt.Errorf("foobar: %q", err) })

into this

t1, … tn, te := f()
if te != nil {
    if fn != nil {
        te = fn(te)
    }
    err = te
    return
}

since this is an explicit choice (instead of using try) we can find reasonable answers the questions in the earlier version of this design. for example if augmenting function is nil don't do anything and just return the original error.

dzrw commented 5 years ago

I'm concerned that try will supplant traditional error handling, and that that will make annotating error paths more difficult as a result.

Code that handles errors by logging messages and updating telemetry counters will be looked upon as defective or improper by both linters and developers expecting to try everything.

a, b, err := doWork()
if err != nil {
  updateCounters()
  writeLogs()
  return err
}

Go is an extremely social language with common idioms enforced by tooling (fmt, lint, etc). Please keep the social ramifications of this idea in mind - there will be a tendency to want to use it everywhere.

beoran commented 5 years ago

@politician, sorry, but the word you are looking for is not social but opinionated. Go is an opinionated programming language. For the rest I mostly agree with what you are getting at.

dzrw commented 5 years ago

@beoran Community tools like Godep and the various linters demonstrate that Go is both opinionated and social, and many of the dramas with the language stem from that combination. Hopefully, we can both agree that try shouldn't be the next drama.

beoran commented 5 years ago

@politician Thanks for clarifying, I hadn't understood it that way. I can certainly agree that we should try to avoid drama.

davexpro commented 5 years ago

I am confused about it.

From the blog: Errors are values, from my perspective, it's designed to be valued not to be ignored.

And I do believe what Rop Pike said, "Values can be programmed, and since errors are values, errors can be programmed.".

We should not consider error as exception, it's like importing complexity not only for thinking but also for coding if we do so.

"Use the language to simplify your error handling." -- Rob Pike

And more, we can review this slide

image

seehuhn commented 5 years ago

One situation where I find error checking via if particularly awkward is when closing files (e.g. on NFS). I guess, currently we are meant to write the following, if error returns from .Close() are possible?

r, err := os.Open(src)
if err != nil {
    return err
}
defer func() {
    // maybe check whether a previous error occured?
    return r.Close()
}()

Could defer try(r.Close()) be a good way to have a manageable syntax for some way of dealing with such errors? At least, it would make sense to adjust the CopyFile() example in the proposal in some way, to not ignore errors from r.Close() and w.Close().

dzrw commented 5 years ago

@seehuhn Your example won't compile because the deferred function does not have a return type.

func doWork() (err error) {
  r, err := os.Open(src)
  if err != nil {
    return err
  }
  defer func() {
    err = r.Close()  // overwrite the return value
  }()
}

Will work like you expect. The key is the named return value.

pierrec commented 5 years ago

I like the proposal but I think that the example of @seehuhn should be adressed as well :

defer try(w.Close())

would return the error from Close() only if the error was not already set. This pattern is used so often...

a8m commented 5 years ago

I agree with the concerns regarding adding context to errors. I see it as one of the best practices that keeps error messages much friendly (and clear) and makes debug process easier.

The first thing I thought about was to replace the fmt.HandleErrorf with a tryf function, that prefixs the error with additional context.

func tryf(t1 T1, t1 T2, … tn Tn, te error, ts string) (T1, T2, … Tn)

For example (from a real code I have):

func (c *Config) Build() error {
    pkgPath, err := c.load()
    if err != nil {
        return nil, errors.WithMessage(err, "load config dir")
    }
    b := bytes.NewBuffer(nil)
    if err = templates.ExecuteTemplate(b, "main", c); err != nil {
        return nil, errors.WithMessage(err, "execute main template")
    }
    buf, err := format.Source(b.Bytes())
    if err != nil {
        return nil, errors.WithMessage(err, "format main template")
    }
    target := fmt.Sprintf("%s.go", filename(pkgPath))
    if err := ioutil.WriteFile(target, buf, 0644); err != nil {
        return nil, errors.WithMessagef(err, "write file %s", target)
    }
    // ...
}

Can be changed to something like:

func (c *Config) Build() error {
    pkgPath := tryf(c.load(), "load config dir")
    b := bytes.NewBuffer(nil)
    tryf(emplates.ExecuteTemplate(b, "main", c), "execute main template")
    buf := tryf(format.Source(b.Bytes()), "format main template")
    target := fmt.Sprintf("%s.go", filename(pkgPath))
    tryf(ioutil.WriteFile(target, buf, 0644), fmt.Sprintf("write file %s", target))
    // ...
}

Or, if I take @agnivade's example:

func (p *pgStore) DoWork() (err error) {
    tx := tryf(p.handle.Begin(), "begin transaction")
        defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()
    var res int64
    tryf(tx.QueryRow(`INSERT INTO table (...) RETURNING c1`, ...).Scan(&res), "insert table")
    _, = tryf(tx.Exec(`INSERT INTO table2 (...) VALUES ($1)`, res), "insert table2")
    return tryf(tx.Commit(), "commit transaction")
}

However, @josharian raised a good point that makes me hesitate on this solution:

As written, though, it moves fmt-style formatting from a package into the language itself, which opens up a can of worms.

jacobcartledge commented 5 years ago

I'm totally on board with this proposal and can see its benefits across a number of examples.

My only concern with the proposal is the naming of try, I feel that its connotations with other languages, may skew deveopers perceptions of what its purpose is when coming from other languages. Java comes to find here.

For me, i would prefer the builtin to be called pass. I feel this gives a better representation of what is happening. After-all you are not handling the error - rather passing it back to be handled by the caller. try gives the impression that the error has been handled.

bitfield commented 5 years ago

It's a thumbs down from me, principally because the problem it's aiming to address ("the boilerplate if statements typically associated with error handling") simply isn't a problem for me. If all error checks were simply if err != nil { return err } then I could see some value in adding syntactic sugar for that (though Go is a relatively sugar-free language by inclination).

In fact, what I want to do in the event of a non-nil error varies quite considerably from one situation to the next. Maybe I want to t.Fatal(err). Maybe I want to add a decorating message return fmt.Sprintf("oh no: %v", err). Maybe I just log the error and continue. Maybe I set an error flag on my SafeWriter object and continue, checking the flag at the end of some sequence of operations. Maybe I need to take some other actions. None of these can be automated with try. So if the argument for try is that it will eliminate all if err != nil blocks, that argument doesn't stand.

Will it eliminate some of them? Sure. Is that an attractive proposition for me? Meh. I'm genuinely not concerned. To me, if err != nil is just part of Go, like the curly braces, or defer. I understand it looks verbose and repetitive to people who are new to Go, but people who are new to Go are not best placed to make dramatic changes to the language, for a whole bunch of reasons.

The bar for significant changes to Go has traditionally been that the proposed change must solve a problem that's (A) significant, (B) affects a lot of people, and (C) is well solved by the proposal. I'm not convinced on any of these three criteria. I'm quite happy with Go's error handling as it is.

Redundancy commented 5 years ago

To echo @peterbourgon and @deanveloper, one of my favourite things about Go is that code flow is clear and panic() is not treated like a standard flow control mechanism in the way it is in Python.

Regarding the debate on panic, panic() almost always appears by itself on a line because it has no value. You can't fmt.Println(panic("oops")). This increases its visibility tremendously and makes it far less comparable to try() than people are making out.

If there is to be another flow control construct for functions, I would far prefer that it be a statement guaranteed to be the leftmost item on a line.

hmage commented 5 years ago

One of the examples in the proposal nails the problem for me:

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

Control flow really becomes less obvious and very obscured.

This is also against the initial intention by Rob Pike that all errors need to be handled explicitly.

While a reaction to this can be "then don't use it", the problem is -- other libraries will use it, and debugging them, reading them, and using them, becomes more problematic. This will motivate my company to never adopt go 2, and start using only libraries that don't use try. If I'm not alone with this, it might lead to a division a-la python 2/3.

Also, the naming of try will automatically imply that eventually catch will show up in the syntax, and we'll be back to being Java.

So, because of all of this, I'm strongly against this proposal.

dolmen commented 5 years ago

I don't like the try name. It implies an attempt at doing something with a high risk of failure (I may have a cultural bias against try as I'm not a native english speaker), while instead try would be used in case we expect rare failures (motivation for wanting to reduce verbosity of error handling) and are optimistic. In addition try in this proposal does in fact catches an error to return it early. I like the pass suggestion of @HiImJC.

Besides the name, I find awkward to have return-like statement now hidden in the middle of expressions. This breaks Go flow style. It will make code reviews harder.

In general, I find that this proposal will only benefit to the lazy programmer who has now a weapon for shorter code and even less reason to make the effort of wrapping errors. As it will also make reviews harder (return in middle of expression), I think that this proposal goes against the "programming at scale" aim of Go.

gbbr commented 5 years ago

One of my favourite things about Go that I generally say when describing the language is that there is only one way to do things, for most things. This proposal goes against that principle a bit by offering multiple ways to do the same thing. I personally think this is not necessary and that it would take away, rather than add to the simplicity and readability of the language.

eandre commented 5 years ago

I like this proposal overall. The interaction with defer seems sufficient to provide an ergonomic way of returning an error while also adding additional context. Though it would be nice to address the snag @josharian pointed out around how to include the original error in the wrapped error message.

What's missing is an ergonomic way of this interacting with the error inspection proposal(s) on the table. I believe API's should be very deliberate in what types of errors they return, and the default should probably be "returned errors are not inspectable in any way". It should then be easy to go to a state where errors are inspectable in a precise way, as documented by the function signature ("It reports an error of kind X in circumstance A and an error of kind Y in circumstance B").

Unfortunately, as of now, this proposal makes the most ergonomic option the most undesirable (to me); blindly passing through arbitrary error kinds. I think this is undesirable because it encourages not thinking about the kinds of errors you return and how users of your API will consume them. The added convenience of this proposal is certainly nice, but I fear it will encourage bad behavior because the perceived convenience will outweigh the perceived value of thinking carefully about what error information you provide (or leak).

A bandaid would be if errors returned by try get converted into errors that are not "unwrappable". Unfortunately this has pretty severe downsides as well, since it makes it so that any defer could not inspect the errors itself. Additionally it prevents the usage where try actually will return an error of a desirable kind (that is, use cases where try is used carefully rather than carelessly).

Another solution would be to repurpose the (discarded) idea of having an optional second argument to try for defining/whitelisting the error kind(s) that may be returned from that site. This is a bit troublesome because we have two different ways of defining an "error kind", either by value (io.EOF etc) or by type (*os.PathError, *exec.ExitError). It's easy to specify error kinds that are values as arguments to a function, but harder to specify types. Not sure how to handle that, but throwing the idea out there.

boomlinde commented 5 years ago

The problem that @josharian pointed out can be avoided by delaying the evaluation of err:

defer func() { fmt.HandleErrorf(&err, "oops: %v", err) }()

Doesn't look great, but it should work. I'd prefer however if this can be addressed by adding a new formatting verb/flag for error pointers, or maybe for pointers in general, that prints the dereferenced value as with plain %v. For the purpose of the example, let's call it %*v:

defer fmt.HandleErrorf(&err, "oops: %*v", &err)

The snag aside, I think that this proposal looks promising, but it seems crucial to keep the ergonomics of adding context to errors in check.

Edit:

Another approach is to wrap the error pointer in a struct that implements Stringer:

type wraperr struct{ err *error }
func (w wraperr) String() string { return (*w.err).Error() }

...

defer handleErrorf(&err, "oops: %v", wraperr{&err})
prologic commented 5 years ago

Couple of things from my perspective. Why are we so concerned about saving a few lines of code? I consider this along the same lines as Small functions considered harmful.

Additionally I find that such a proposal would remove the responsibility of correctly handling the error to some "magic" that I worry will just be abused and encourage laziness resulting in poor quality code and bugs.

The proposal as stated also has a number of unclear behaviors so this is already problematic than an explicit extra ~3 lines that are more clear.

kungfusheep commented 5 years ago

We currently use the defer pattern sparingly in house. There's an article here which had similarly mixed reception when we wrote it - https://bet365techblog.com/better-error-handling-in-go

However, our usage of it was in anticipation of the check/handle proposal progressing.

Check/handle was a much more comprehensive approach to making error handling in go more concise. Its handle block retained the same function scope as the one it was defined in, whereas any defer statements are new contexts with an amount, however much, of overhead. This seemed to be more in keeping with go's idioms, in that if you wanted the behaviour of "just return the error when it happens" you could declare that explicitly as handle { return err }.

Defer obviously relies on the err reference being maintained also, but we've seen problems arise from shadowing the error reference with block scoped vars. So it isn't fool proof enough to be considered the standard way of handling errors in go.

try, in this instance, doesn't appear to solve too much and I share the same fear as others that it would simply lead to lazy implementations, or ones which over-use the defer pattern.

earthboundkid commented 5 years ago

If defer-based error handling is going to be A Thing, then something like this should probably be added to the errors package:

        f := try(os.Create(filename))
        defer errors.Deferred(&err, f.Close)

Ignoring the errors of deferred Close statements is a pretty common issue. There should be a standard tool to help with it.

komuw commented 5 years ago

A builtin function that returns is a harder sell than a keyword that does the same.
I would like it more if it were a keyword like it is in Zig[1].

  1. https://ziglang.org/documentation/master/#try
frou commented 5 years ago

Built-in functions, whose type signature cannot be expressed using the language's type system, and whose behavior confounds what a function normally is, just seems like an escape hatch that can be used repeatedly to avoid actual language evolution.

dominikh commented 5 years ago

We are used to immediately recognize return statements (and panic's) because that's how this kind of control flow is expressed in Go (and many other languages). It seems not far fetched that we will also recognize try as changing control flow after some getting used to it, just like we do for return. I have no doubt that good IDE support will help with this as well.

I think it is fairly far-fetched. In gofmt'ed code, a return always matches /^\t*return / – it's a very trivial pattern to spot by eye, without any assistance. try, on the other hand, can occur anywhere in the code, nested arbitrarily deep in function calls. No amount of training will make us be able to immediately spot all control flow in a function without tool assistance.

Furthermore, a feature that depends on "good IDE support" will be at a disadvantage in all the environments where there is no good IDE support. Code review tools come to mind immediately – will Gerrit highlight all the try's for me? What about people who choose not to use IDEs, or fancy code highlighting, for various reasons? Will acme start highlighting try?

A language feature should be easy to understand on its own, not depend on editor support.

qrpnxz commented 5 years ago

@kungfusheep I like that article. Taking care of wrapping in a defer alone already drives up readability quite a bit without try.

I'm in the camp that doesn't feel errors in Go are really a problem. Even so, if err != nil { return err } can be quite the stutter on some functions. I've written functions that needed an error check after almost every statement and none needed any special handling other than wrap and return. Sometimes there just isn't any clever Buffer struct that's gonna make things nicer. Sometimes it's just a different critical step after another and you need to simply short circuit if something went wrong.

Although try would certainly make that code a lot easier to nicer to read while being fully backwards compatible, I agree that try isn't a critical must have feature, so if people are too scared of it maybe it's best not to have it.

The semantics are quite clear cut though. Anytime you see try it's either following the happy path, or it returns. I really can't get simpler than that.

docmerlin commented 5 years ago

This looks like a special cased macro.

qrpnxz commented 5 years ago

@dominikh try always matches /try\(/so I don't know what your point is really. It's equally as searchable and every editor I've ever heard of has a search feature.

eandre commented 5 years ago

@qrpnxz I think the point he was trying to make is not that you cannot search for it programatically, but that it's harder to search for with your eyes. The regexp was just an analogy, with emphasis on the /^\t*, signifying that all returns clearly stand out by being at the beginning of a line (ignoring leading whitespace).

earthboundkid commented 5 years ago

Thinking about it more, there should be a couple of common helper functions. Perhaps they should be in a package called "deferred".

Addressing the proposal for a check with format to avoid naming the return, you can just do that with a function that checks for nil, like so

func Format(err error, message string, args ...interface{}) error {
    if err == nil {
        return nil
    }
    return fmt.Errorf(...)
}

This can be used without a named return like so:

func foo(s string) (int, error) {
    n, err := strconv.Atoi(s)
    try(deferred.Format(err, "bad string %q", s))
    return n, nil
}

The proposed fmt.HandleError could be put into the deferred package instead and my errors.Defer helper func could be called deferred.Exec and there could be a conditional exec for procedures to execute only if the error is non-nil.

Putting it together, you get something like

func CopyFile(src, dst string) (err error) {
    defer deferred.Annotate(&err, "copy %s %s", src, dst)

    r := try(os.Open(src))
    defer deferred.Exec(&err, r.Close)

    w := try(os.Create(dst))
    defer deferred.Exec(&err, r.Close)

    defer deferred.Cond(&err, func(){ os.Remove(dst) })
    try(io.Copy(w, r))

    return nil
}

Another example:

func (p *pgStore) DoWork() (err error) {
    tx := try(p.handle.Begin())

    defer deferred.Cond(&err, func(){ tx.Rollback() })

    var res int64 
    err = tx.QueryRow(`INSERT INTO table (...) RETURNING c1`, ...).Scan(&res)
    try(deferred.Format(err, "insert table")

    _, err = tx.Exec(`INSERT INTO table2 (...) VALUES ($1)`, res)
    try(deferred.Format(err, "insert table2"))

    return tx.Commit()
}
marwan-at-work commented 5 years ago

This proposal takes us from having if err != nil everywhere, to having try everywhere. It shifts the proposed problem and does not solve it.

Although, I'd argue that the current error handling mechanism is not a problem to begin with. We just need to improve tooling and vetting around it.

Furthermore, I would argue that if err != nil is actually more readable than try because it does not clutter the line of the business logic language, rather sits right below it:

file := try(os.OpenFile("thing")) // less readable than, 

file, err := os.OpenFile("thing")
if err != nil {

}

And if Go was to be more magical in its error handling, why not just totally own it. For example Go can implicitly call the builtin try if a user does not assign an error. For example:

func getString() (string, error) { ... }

func caller() {
  defer func() {
    if err != nil { ... } // whether `err` must be defined or not is not shown in this example. 
  }

  // would call try internally, because a user is not 
  // assigning an error value. Also, it can add a compile error
  // for "defined and not used err value" if the user does not 
  // handle the error. 
  str := getString()
}

To me, that would actually accomplish the redundancy problem at the cost of magic and potential readability.

Therefore, I propose that we either truly solve the 'problem' like in the above example or keep the current error handling but instead of changing the language to solve redundancy and wrapping, we don't change the language but we improve the tooling and vetting of code to make the experience better.

For example, in VSCode there's a snippet called iferr if you type it and hit enter, it expands to a full error handling statement...therefore, writing it never feels tiresome to me, and reading later on is better.

Merovius commented 5 years ago

@josharian

Though it wouldn’t be “a modest library change”, we could consider accepting func main() error as well.

The issue with that is that not all platforms have clear semantics on what that means. Your rewrite works well in "traditional" Go programs running on a full operating system - but as soon as you write microcontroller-firmware or even just WebAssembly, it's not super clear what os.Exit(1) would mean. Currently, os.Exit is a library-call, so Go implementations are free just not to provide it. The shape of main is a language concern though.


A question about the proposal that is probably best answered by "nope": How does try interact with variadic arguments? It's the first case of a variadic (ish) function that doesn't have its variadic-nes in the last argument. Is this allowed:

var e []error
try(e...)

Leaving aside why you'd ever do that. I suspect the answer is "no" (otherwise the follow-up is "what if the length of the expanded slice is 0). Just bringing that up so it can be kept in mind when phrasing the spec eventually.

fewebahr commented 5 years ago
natefinch commented 5 years ago

I see two problems with this:

  1. It puts a LOT of code nested inside functions. That adds a lot of extra cognitive load, trying to parse the code in your head.

  2. It gives us places where the code can exit from the middle of a statement.

Number 2 I think is far worse. All the examples here are simple calls that return an error, but what's a lot more insidious is this:

func doit(abc string) error {
    a := fmt.Sprintf("value of something: %s\n", try(getValue(abc)))
    log.Println(a)
    return nil
}

This code can exit in the middle of that sprintf, and it's going to be SUPER easy to miss that fact.

My vote is no. This will not make go code better. It won't make it easier to read. It won't make it more robust.

I've said it before, and this proposal exemplifies it - I feel like 90% of the complaints about Go are "I don't want to write an if statement or a loop" . This removes some very simple if statements, but adds cognitive load and makes it easy to miss exit points for a function.

predmond commented 5 years ago

I just want to point out that you could not use this in main and it might be confusing to new users or when teaching. Obviously this applies to any function that doesn't return an error but I think main is special since it appears in many examples..

func main() {
    f := try(os.Open("foo.txt"))
    defer f.Close()
}

I'm not sure making try panic in main would be acceptable either.

Additionally it would not be particularly useful in tests (func TestFoo(t* testing.T)) which is unfortunate :(

MrTravisB commented 5 years ago

The issue I have with this is it assumes you always want to just return the error when it happens. When maybe you want to add context to the error and the return it or maybe you just want to behave differently when an error happens. Maybe that is depending on the type of error returned.

I would prefer something akin to a try/catch which might look like

Assuming foo() defined as

func foo() (int, error) {}

You could then do

n := try(foo()) {
    case FirstError:
        // do something based on FirstError
    case OtherError:
        // do something based on OtherError
    default:
        // default behavior for any other error
}

Which translates to

n, err := foo()
if errors.Is(err, FirstError) {
    // do something based on FirstError
if errors.Is(err, OtherError) {
    // do something based on OtherError
} else {
    // default behavior for any other error
}
cpuguy83 commented 5 years ago

To me, error handling is one of the most important parts of a code base. Already too much go code is if err != nil { return err }, returning an error from deep in the stack without adding extra context, or even (possibly) worse adding context by masking the underlying error with fmt.Errorf wrapping.

Providing a new keyword that is kind of magic that does nothing but replace if err != nil { return err } seems like a dangerous road to go down. Now all code will just be wrapped in a call to try. This is somewhat fine (though readability sucks) for code that is dealing with only in-package errors such as:

func foo() error {
  /// stuff
  try(bar())
  // more stuff
}

But I'd argue that the given example is really kind of horrific and basically leaves the caller trying to understand an error that is really deep in the stack, much like exception handling. Of course, this is all up to the developer to do the right thing here, but it gives the developer a great way to not care about their errors with maybe a "we'll fix this later" (and we all know how that goes).

I wish we'd look at the issue from a different perspective than *"how can we reduce repetition" and more about "how can we make (proper) error handling simpler and developers more productive". We should be thinking about how this will affect running production code.

*Note: This doesn't actually reduce repetition, just changes what's being repeated, all the while making the code less readable because everything is encased in a try().

One last point: Reading the proposal at first it seems nice, then you start to get into all the gotchas (at least the ones listed) and it's just like "ok yeah this is too much".


I realize much of this is subjective, but it's something I care about. These semantics are incredibly important. What I want to see is a way to make writing and maintaining production level code simpler such that you might as well do errors "right" even for POC/demo level code.

gotwarlost commented 5 years ago

Since error context seems to be a recurring theme...

Hypothesis: most Go functions return (T, error) as opposed to (T1, T2, T3, error)

What if, instead of defining try as try(T1, T2, T3, error) (T1, T2, T3) we defined it as try(func (args) (T1, T2, T3, error))(T1, T2, T3)? (this is an approximation)

which is to say that the syntactic structure of a try call is always a first argument that is an expression returning multiple values, the last of which is an error.

Then, much like make, this opens the door to a 2-argument form of the call, where the second argument is the context of the try (e.g. a fixed string, a string with a %v, a function that takes an error argument and returns another error etc.)

This still allows chaining for the (T, error) case but you can no longer chain multiple returns which IMO is typically not required.

qrpnxz commented 5 years ago

@cpuguy83 If you read the proposal you would see there is nothing preventing you from wrapping the error. In fact there are multiple ways of doing it while still using try. Many people seem to assume that for some reason though.

if err != nil { return err } is equally as "we'll fix this later" as try except more annoying when prototyping.

I don't know how things being inside of a pair of parenthesis is less readable than function steps being every four lines of boilerplate either.

It'd be nice if you pointed out some of these particular "gotchas" that bothered you since that's the topic.

pierrec commented 5 years ago

Readability seems to be an issue but what about go fmt presenting try() so that it stands out, something like:

f := try(
    os.Open("file.txt")
)
boomlinde commented 5 years ago

@MrTravisB

The issue I have with this is it assumes you always want to just return the error when it happens.

I disagree. It assumes that you want to do so often enough to warrant a shorthand for just that. If you don't, it doesn't get in the way of handling errors plainly.

When maybe you want to add context to the error and the return it or maybe you just want to behave differently when an error happens.

The proposal describes a pattern for adding block-wide context to errors. @josharian pointed out that there is an error in the examples, though, and it's not clear what the best way is to avoid it. I have written a couple of examples of ways to handle it.

For more specific error context, again, try does a thing, and if you don't want that thing, don't use try.

MrTravisB commented 5 years ago

@boomlinde Exactly my point. This proposal is trying to solve a singular use case rather than providing a tool to solve the larger issue of error handling. I think the fundamental question if exactly what you pointed out.

It assumes that you want to do so often enough to warrant a shorthand for just that.

In my opinion and experience this use case is a small minority and doesn't warrant shorthand syntax.

Also, the approach of using defer to handle errors has issues in that it assumes you want to handle all possible errors the same. defer statements can't be canceled.

defer fmt.HandleErrorf(&err, “foobar”)

n := try(foo())

x : try(foo2())

What if I want different error handling for errors that might be returned from foo() vs foo2()?

qrpnxz commented 5 years ago

@MrTravisB

What if I want different error handling for errors that might be returned from foo() vs foo2()?

Then you use something else. That's the point @boomlinde was making.

Maybe you don't personally see this use case often, but many people do, and adding try doesn't really affect you. In fact, the rarer the use case is to you the less it affects you that try is added.

cpuguy83 commented 5 years ago

@qrpnxz

f := try(os.Open("/foo"))
data := try(ioutil.ReadAll(f))
try(send(data))

(yes I understand there is ReadFile and that this particular example is not the best way to copy data somewhere, not the point)

This takes more effort to read because you have to parse out the try's inline. The application logic is wrapped up in another call. I'd also argue that a defer error handler here would not be good except to just wrap the error with a new message... which is nice but there is more to dealing with errors than making it easy for the human to read what happened.

In rust at least the operator is a postfix (? added to the end of a call) which doesn't place extra burden to dig out the the actual logic.

zeebo commented 5 years ago

Expression based flow control

panic may be another flow controlling function, but it doesn't return a value, making it effectively a statement. Compare this to try, which is an expression and can occur anywhere.

recover does have a value and affects flow control, but must occur in a defer statement. These defers are typically function literals, recover is only ever called once, and so recover also effectively occurs as a statement. Again, compare this to try which can occur anywhere.

I think those points mean that try makes it significantly harder to follow control flow in a way that we haven't had before, as has been pointed out before, but I didn't see the distinction between statements and expressions pointed out.


Another proposal

Allow statements like

if err != nil {
    return nil, 0, err
}

to be formatted on one line by gofmt when the block only contains a return statement and that statement does not contain newlines. For example:

if err != nil { return nil, 0, err }

Rationale

f, err := os.Open(file)
try(maybeWrap(err))
value, err := something()
if err != nil { t.Fatal(err) }
n, err := src.Read(buf)
if err == io.EOF { return nil }
else if err != nil { return err }

In summary, this proposal has a small cost, can be designed to be opt-in, doesn't preclude any further changes since it's stylistic only, and reduces the pain of reading verbose error handling code while keeping everything explicit. I think it should at least be considered as a first step before going all in on try.

Some examples ported ## From https://github.com/golang/go/issues/32437#issuecomment-498941435 ### With try ```go func NewThing(thingy *foo.Thingy, db *sql.DB, client pb.Client) (*Thing, error) { try(dbfile.RunMigrations(db, dbMigrations)) t := &Thing{ thingy: thingy, scanner: try(newScanner(thingy, db, client)), } t.initOtherThing() return t, nil } ``` ### With this ```go func NewThing(thingy *foo.Thingy, db *sql.DB, client pb.Client) (*Thing, error) { err := dbfile.RunMigrations(db, dbMigrations)) if err != nil { return nil, fmt.Errorf("running migrations: %v", err) } t := &Thing{thingy: thingy} t.scanner, err = newScanner(thingy, db, client) if err != nil { return nil, fmt.Errorf("creating scanner: %v", err) } t.initOtherThing() return t, nil } ``` It's competitive in space usage while still allowing for adding context to errors. ## From https://github.com/golang/go/issues/32437#issuecomment-499007288 ### With try ```go func (c *Config) Build() error { pkgPath := try(c.load()) b := bytes.NewBuffer(nil) try(emplates.ExecuteTemplate(b, "main", c)) buf := try(format.Source(b.Bytes())) target := fmt.Sprintf("%s.go", filename(pkgPath)) try(ioutil.WriteFile(target, buf, 0644)) // ... } ``` ### With this ```go func (c *Config) Build() error { pkgPath, err := c.load() if err != nil { return nil, errors.WithMessage(err, "load config dir") } b := bytes.NewBuffer(nil) err = templates.ExecuteTemplate(b, "main", c) if err != nil { return nil, errors.WithMessage(err, "execute main template") } buf, err := format.Source(b.Bytes()) if err != nil { return nil, errors.WithMessage(err, "format main template") } target := fmt.Sprintf("%s.go", filename(pkgPath)) err = ioutil.WriteFile(target, buf, 0644) if err != nil { return nil, errors.WithMessagef(err, "write file %s", target) } // ... } ``` The original comment used a hypothetical `tryf` to attach the formatting, which has been removed. It's unclear the best way to add all the distinct contexts, and perhaps `try` wouldn't even be applicable.
qrpnxz commented 5 years ago

@cpuguy83 To me it is more readable with try. In this example I read "open a file, read all bytes, send data". With regular error handling I would read "open a file, check if there was an error, the error handling does this, then read all bytes, now check if somethings happened..." I know you can scan through the err != nils, but to me try is just easier because when I see it I know the behaviour right away: returns if err != nil. If you have a branch I have to see what it does. It could do anything.

I'd also argue that a defer error handler here would not be good except to just wrap the error with a new message

I'm sure there are other things you can do in the defer, but regardless, try is for the simple general case anyway. Anytime you want to do something more, there is always good ol' Go error handling. That's not going away.

qrpnxz commented 5 years ago

@zeebo Yep, I'm into that. @kungfusheep 's article used a one line err check like that and I got exited to try it out. Then as soon as I save, gofmt expanded it into three lines which was sad. Many functions in the stdlib are defined in one line like that so it surprised me that gofmt would expand that out.

cpuguy83 commented 5 years ago

@qrpnxz

I happen to read a lot of go code. One of the best things about the language is the ease that comes from most code following a particular style (thanks gofmt). I don't want to read a bunch of code wrapped in try(f()). This means there will either be a divergence in code style/practice, or linters like "oh you should have used try() here" (which again I don't even like, which is the point of me and others commenting on this proposal).

It is not objectively better than if err != nil { return err }, just less to type.


One last thing:

If you read the proposal you would see there is nothing preventing you from

Can we please refrain from such language? Of course I read the proposal. It just so happens that I read it last night and then commented this morning after thinking about it and didn't explain the minutia of what I intended. This is an incredibly adversarial tone.

qrpnxz commented 5 years ago

@cpuguy83 My bad cpu guy. I didn't mean it that way.

And I guess you gotta point that code that uses try will look pretty different from code that doesn't, so I can imagine that would affect the experience of parsing that code, but I can't totally agree that different means worse in this case, though I understand you personally don't like it just as I personally do like it. Many things in Go are that way. As to what linters tell you to do is another matter entirely, I think.

Sure it's not objectively better. I was expressing that it was more readable that way to me. I carefully worded that.

Again, sorry for sounding that way. Although this is an argument I didn't mean to antagonize you.

elagergren-spideroak commented 5 years ago

https://github.com/golang/go/issues/32437#issuecomment-498908380

No one is going to make you use try.

Ignoring the glibness, I think that's a pretty hand-wavy way to dismiss a design criticism.

Sure, I don't have to use it. But anybody I write code with could use it and force me to try to decipher try(try(try(to()).parse().this)).easily()). It's like saying

No one is going to make you use the empty interface{}.

Anyway, Go's pretty strict about simplicity: gofmt makes all the code look the same way. The happy path keeps left and anything that could be expensive or surprising is explicit. try as is proposed is a 180 degree turn from this. Simplicity != concise.

At the very least try should be a keyword with lvalues.

Merovius commented 5 years ago

It is not objectively better than if err != nil { return err }, just less to type.

There is one objective difference between the two: try(Foo()) is an expression. For some, that difference is a downside (the try(strconv.Atoi(x))+try(strconv.Atoi(y)) criticism). For others, that difference is an upside for much the same reason. Still not objectively better or worse - but I also don't think the difference should be swept under the rug and claiming that it's "just less to type" doesn't do the proposal justice.