Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal: Go 2: return if (with context) #53017

Closed
switchupcb opened this issue May 20, 2022 · 22 comments
Closed

proposal: Go 2: return if (with context) #53017

switchupcb opened this issue May 20, 2022 · 22 comments
Labels
error-handling Language & library change proposals that are about error handling. FrozenDueToAge LanguageChange Proposal Proposal-FinalCommentPeriod v2 A language change or incompatible library change
Milestone

Comments

@switchupcb
Copy link

switchupcb commented May 20, 2022

This proposal is not a duplicate, but similar to #33113, #27135, #27794, #32860, and #52977. For more information, read Justification.

Code

Iteration 3

Fixes iteration two and scopes the returned variables or functions.

Statements that drastically change the program's control flow (like return) should be prominent and easy to spot.
@ALTree

Single

func bezo(baller, dollers string) error {
    bucko, err := strconv.Atoi(baller)
    return if err != nil { err }
    bucks, _ := strconv.Atoi(dollers)
    ballin, err := isBaller(bucko + bucks, 2147483647)
    return if err != nil { fmt.Errorf("error: balled too hard\n%w", err) }

    fmt.Printf("ballin: %v", ballin)
    return nil
}

Multi

func page(money string) (bool, error) {
    if money == "long" {
        money = "longer"
    }

    grad, err := findHarvardGrad()
    return if err != nil { false, err }
    assignOrkutSupportTickets(grad)

    return true, nil
}

Iteration 2

Fixes iteration 1 by requiring the user to return a variable. However, this version offsets logic by adding a trailing if after the returned variables, in contrast to placing the returned variables AFTER the if statement (i.e IF err != nil { return ERR }).

return err if err != nil
return a if a != ""

Iteration 1

The issue with this version is that it implicitly allows 1-line if statements to be used on a variety of code. The reader will NOT be able to guarantee that the 1-line if statement is returning from the function.

if err != nil { return err }

// implies the following is valid.
if bucko + bucks == 0 { bankrupt() }
if a != "" { a = "AHHHH" }

This proposal only supports Iteration 3.

Justification

Is it cryptic? No information is lost while the return statement is made more explicit.
Is it complex? It's understandable by new people.
Is it worth? error handling + one-line returns codebase %.
Is it backwards compatible? Yes.
Does it allow wrapping? Yes.
Does it pass code coverage tools? if if does.

#33113

We decided long ago not to allow this kind of density. If the problem is specifically error handling, then we have other ideas for that (as noted). But the decision that there are no 1-line if statements is done.
@rsc

This statement is made in the context of a now-closed error proposal (catch) and implies that the 1-line if statements would apply to all Go code.

#27794 (comment)
This statement involves the syntax for the first iteration, which is not what this proposal sets forth. More important is the argument is that there is already one method to conditionally return (preceding if), such that a trailing if is unnecessary; if it only removes a few lines. As a result, this proposal would be further justified if #21498 passes.

Are there any special rules? No special rules are required for error values or named return values (#37141).

Are errors treated as special values? No, since return if can be used on any value (matching fundamentals).

Think of the vim users! return if requires 9 characters, while a statement such as err != nil requires 10, giving Vim users 59 (80 - (19 + 2)) characters of free-width. The cost is 1 vertical line as opposed to 3.

Readability

People reading code can distinguish that if statements involve further evaluation; as opposed to mixing up error-handle or variable-assignment or any other one-line if functionality that is prevalent in Go codebases.

func request() error {
    req, err := acquireRequestFromPool()
    
    // This statement requires extra evaluation, and can NOT be simplified.
    // This isn't a special rule, but a result of only allowing return variables in { ... }
    if errors.Is(err, ErrBadRequest) { 
        req.HandleBadRequest()
        return fmt.Errorf("bad request: %w", err) 
    }

    // The reader knows this line can ONLY return on the condition.
    return if err != nil { err }
    return nil
}

Here is an example of error code handling (provided by a comment below) in go 1.18:

var ErrBadInput = errors.New("bad input")

func Foo() error {
    err := CanReturnMultipleErrorTypes()
    if errors.Is(err, ErrBadInput) {
        return fmt.Errorf("invalid input: %w", err)
    } else if err != nil { 
        return err
    }

    return nil
}

Here is an example with return if:

func Foo() error {
    err := CanReturnMultipleErrorTypes()
    return if errors.Is(err, ErrBadInput) { fmt.Errorf("invalid input: %w", err) }
    return if err != nil { err }
    return nil
}

PLEASE READ THE FOLLOWING COMMENTS BEFORE COMMENTING
#53017 (comment)

As of 5/24, twenty-one people have downvoted without further explanation.
Please upvote the proposal by reacting with a thumbs up.
Downvote by stating your objections in a comment or upvoting a similar comment respectively.

@gopherbot gopherbot added this to the Proposal milestone May 20, 2022
@ianlancetaylor ianlancetaylor added LanguageChange v2 A language change or incompatible library change error-handling Language & library change proposals that are about error handling. labels May 20, 2022
@deltamualpha
Copy link

I'm almost afraid to ask, but I assume else blocks or chained else if, i.e. return if err != nil { err } else { nil }, are not allowed?

var ErrBadInput = errors.New("bad input")

func Foo() error {
    err := CanReturnMultipleErrorTypes()
    return if errors.Is(err, ErrBadInput) { fmt.Errorf("invalid input: %w", err) } else if err != nil { err } else { nil }
}

Pretty nasty looking.

@switchupcb
Copy link
Author

switchupcb commented May 20, 2022

@deltamualpha Correct, else if or else chains should NOT be allowed; justified by classifying return if as a keyword.

Edit: Note that this maintains explicitness.

func Foo() error {
    err := CanReturnMultipleErrorTypes()
    return if errors.Is(err, ErrBadInput) { fmt.Errorf("invalid input: %w", err) }
    return if err != nil { err }
    return nil
}

Originally

var ErrBadInput = errors.New("bad input")

func Foo() error {
    err := CanReturnMultipleErrorTypes()
    if errors.Is(err, ErrBadInput) {
        return fmt.Errorf("invalid input: %w", err)
    } else if err != nil { 
        return err
    }

    return nil
}

Originally Alternatively

if err := CanReturnMultipleErrorTypes(); errors.Is(err, ErrBadInput) {
    return fmt.Errorf("invalid input: %w", err)
} else if 
...

@switchupcb
Copy link
Author

switchupcb commented May 20, 2022

The following people have downvoted with no further explanation: carlmjohnson (from #21498 conversation), chrispickard, florianl, BroccoliHijinx, and gmeligio, MojixCoder, seankhliao, ericlagergren, krhubert, and jimen0.

Included to address "community voting".

@switchupcb
Copy link
Author

switchupcb commented May 20, 2022

(moved to proposal)

@switchupcb
Copy link
Author

Potential Supporters (from similar proposals): @schwrzstrbn @urban-wombat @eldandev @FlorianUekermann @l0k18 @perholmes @andreynering @alanfo @mccolljr

@beoran
Copy link

beoran commented May 21, 2022

I also like this idea. It is similar to Ruby's end of expression if but by placing return if at the beginning of the statement, it becomes much more readable.

@mccolljr
Copy link

mccolljr commented May 21, 2022

Here's my opinion:

Do I find the current state of error handling somewhat unwieldly? Yes.

Does this proposal improve upon the things I find unwieldly? No. I think that a proposal like this is too singularly focused. This simplifies single error returns, but I'm not sure it successfully addresses things like error aggregation or propagation.

I'm glad you're thinking about alternative solutions for error handling. Please continue to do so. I just don't personally feel like conditional return is the solution.

@switchupcb
Copy link
Author

switchupcb commented May 21, 2022

I think that a proposal like this is too singularly focused. This simplifies single error returns, but I'm not sure it successfully addresses things like error aggregation or propagation.

I ask that you correct me where I'm misinformed in the following statement.

I agree that this proposal doesn't consider error propagation or aggregation. However, it's impossible to solve every mechanic with one proposal, without compromising on fundamentals. I also don't believe it's necessary. In the original error-handling draft design, creators of the language state:

These constructs are in the spirit of “errors are values” but aim to reduce the verbosity of handling errors.
—(@mpvl @rsc)

Error propagation

Go treats errors as values. The program does not crash when you ignore a string, so you cannot crash when you ignore an error.

Go already has technical stacktraces upon panic, and business stack traces upon fmt.Errof("%w", err). If you want to return a static error, you can use errors.New("") but these all are ways to HANDLE errors (with respect to the business case).

If error propagation refers to the request to handle errors, that request is wrong. You are NOT required to handle ignored strings, so why would you be required to handle ignored errors? You are NOT. In comment #52416 (comment) of #52416, @sirkon uses a code statement similar to Russ Cox Error Handling Problem Overview to outline the "issue" with errors. What does Russ say about that code?

It is nice, clean, elegant code. It is also invisibly wrong.
@rsc

Go will never support mandatory error handling and thus never support error propagation. You can propagate error values with functions similar to passing a string around a program. You can't say 1 + 1 return 3 and expect Go to fix that for you. Ignoring an error that needs to be handled isn't an issue with Go, but the developer.

Error Aggregation

Handling Multiple

The Russ Cox Error Handling Problem Overview states no issue with aggregation.

For Go 2, we would like to make error checks more lightweight, reducing the amount of Go program text dedicated to error checking.

Keep in mind that @rsc starts this same overview with the following statement:

In general Go programs have too much code checking errors and not enough code handling them. 

Then proceeds to make a proposal for check and handle that reduces the amount of code handling them... To be specific, using a function to handle errors assumes that every error will use the same error message. So he is not handling any aggregation of errors, but rather handling every error with the same message using jumpy syntax. That was 4 years ago.

Comparison

If aggregation refers to the comparison of errors, we are usually referring to a problem creating by a developer. If I want you to compare 2 strings, I expect you to use the == statement. If I want you to compare a custom type (consisting of strings), I expect you to use a provided func or a pointer.

An error is an interface { Error() string }. When you create an error variable, you are instantiating a type interface { Error() string }. Can you compare interfaces using ==? Yes. Is there a way to compare interfaces? Yes. Is there a way to compare strings? Yes. Is there a way to compare errors? Yes. People are unable to compare errors correctly, when there are already tools in the language to do so.

Why do we need a language feature for this?

@beoran
Copy link

beoran commented May 21, 2022

I think this proposal is interesting because it would also be useful when returning various values from a function even when not used for error handling.

I used the similar conditional if expression a lot in Ruby before, it is very convenient. Do this is more than just a convenient shorthand for error handling.

@zhuah
Copy link

zhuah commented May 23, 2022

it's similar to if-expression in Rust:

if num > 3 {
    "Tom"
} else {
    "Jerry"
};

Personally, i like this proposal, it's more expressive, but it may also be againsted with reasons like "one way", "keep Go simple", etc..

@l0k18
Copy link

l0k18 commented May 23, 2022

I think if gofmt was changed to accept one liner if statements none of this would be interesting. I don't like the implicit return syntax of Rust, which I see many clearly referring to, because it's even worse than a naked return on a long function or unnamed multiple return values and 5 different variable declarations scattered over 60+ lines of code. The gofmt solution changes nothing in the compile it just makes a small readability improvement. We can make one liner function declarations, why not one liner for all parenthesised blocks?

@perholmes
Copy link

perholmes commented May 23, 2022

I think if gofmt was changed to accept one liner if statements none of this would be interesting. I don't like the implicit return syntax of Rust, which I see many clearly referring to, because it's even worse than a naked return on a long function or unnamed multiple return values and 5 different variable declarations scattered over 60+ lines of code. The gofmt solution changes nothing in the compile it just makes a small readability improvement. We can make one liner function declarations, why not one liner for all parenthesised blocks?

A thousand times THIS! This is purely a formatting problem. In other (more relaxed) languages, you just get all of this on one line, and the problem goes away:

result, err := getFromDatabase(); if (err) return err

Or whatever. It's a hack, but GoFmt could simply make it a rule that if a "return" statement is on a line, it isn't formatted. Instant 70% reduction in code spam caused by error handling.

@deltamualpha
Copy link

Yes, but that's what #33113 proposed. And this proposal boils down to adding a new keyword to avoid trying to have that discussion again.

@mccolljr
Copy link

mccolljr commented May 23, 2022

Error propagation

If error propagation refers to the request to handle errors, that request is wrong.
[...]
Go will never support mandatory error handling and thus never support error propagation.

This isn't really what I was referring to.

A lot of code can generate errors. Syscalls, parsing, validation, network requests, etc - most code we write can in some way produce an error. It is not always clear what that error should be or where our code should handle those errors, especially during initial prototyping.

To take a limited example, think about making an API request - some errors are immediately retryable, some are retryable after some action (i.e. we send a request to refresh an auth token), and some are unrecoverable. If you want to handle those 3 cases differently, you have to pipe around the raw errors and check them. This necessarily makes error returns a part of your API surface area. You have to add error returns to every step in the call chain, check those errors at every step in the call chain (if err != nil { return err }), and then handle them somewhere. Making the wrong choice early on can be an absolute pain to refactor later, as adding an error return to one function that used to enforce an invariant with a panic can be a cascading change that affects all code.

From that perspective, saving a few characters of typing for each intermediate error return doesn't really address my pain point, which is that I have to either: assume I will eventually want to handle an error and add error returns to my functions even when I'm not currently generating errors, or choose to omit error returns and potentially have a massive refactor to deal with later when I decide I do need to propagate an error upwards through a large call chain.

Another common thing that comes up is the need to do a series of N things, where each thing can generate a single value and a single error, and where each thing N depends on the value generated by thing N-1. In this case, I end up writing N lines of function calls and Nx3 lines of error handling. Even if that were simplified to N lines of function calls and N lines of error handling with simplified return statements, the error handling code takes up 50% of the space even though I'm not handling any of the errors locally to that code - I'm just sending them somewhere else to be handled, ignored, reported, or aggregated. If I want to use "default" values at each step, rather than just return the errors? It goes back up to Nx3 lines of code because I can't use the early return anymore. If I need to clean up after the previous steps? I either have to pull this N-step process out into a helper function and use defer, or I have to manually write that cleanup code at each step. If I have many return values? I have to specify a value for each one, each time I return an error.

I do not want try/catch semantics in Go. I'm not sure what form a solution that would address these pain points would take. In any case, these are the things I'm referring to when I say "error propagation".

Error Aggregation

If aggregation refers to the comparison of errors

It does not.

I was referring to these situations:

  1. The need to collect multiple non-nil errors,
  2. The need to flatten a collection of collections of errors,
  3. The need to take the first in a series of errors

And I'm not sure that a language feature is needed to address this. There are libraries that help, like multierror, but these libraries 3rd-party, so there is no standard and some library maintainers roll their own.

In general, I think errors are values is both a true and a false statement.

It is true, because errors are literally values, in that they can be ignored, collected, moved around, and in general treated as any other value. I think this is a good thing.

It is false, because they are also special in that they have a unique role in a program. They are values, but they are values that are treated specially by most code that uses them. Can an integer be an error? Absolutely. Can a string? Absolutely. Can the empty struct be an error? Again, absolutely. But when that object gets wrapped in error, it is no longer playing the role of the type it holds - it is playing the role of communicating to the some part of the program that an irregular state has been encountered and something needs to be done, reported, or ignored to continue making progress.

Perhaps a part of this pain point is how general the error interface is - it is basically the fmt.Stringer interface but with a fancy method name. 90% of the processing of that value beyond just printing it is left entirely as an exercise to the reader.

@mccolljr
Copy link

it's similar to if-expression in Rust:

if num > 3 {
    "Tom"
} else {
    "Jerry"
};

If expressions in rust can be used anywhere an expression is expected. This includes assignments, in macro and function calls, etc.

This proposal is specific to return statements.

If this were a proposal for some form of inline If expression I would be more supportive - that would be much more general-purpose.

I think such a proposal would be unlikely to pass debate though - the Go team seems like they've firmly made up their minds that such a thing will never exist in the language

@switchupcb
Copy link
Author

why not one liner for all parenthesised blocks?
@l0k18
@perholmes

See Iteration One. One-liners can decrease readability by combining extraneous statements with return statements. In a similar manner to what deltamualpha stated in #53017 (comment).

@switchupcb
Copy link
Author

In general, I think errors are values is both a true and a false statement.
@mccolljr

The main thing is that in Go, errors are values. It's a fundamental of the language.

A lot of code can generate errors. Syscalls, parsing, validation, network requests, etc - most code we write can in some way produce an error. It is not always clear what that error should be or where our code should handle those errors, especially during initial prototyping.

Again, this code needs to provide a way to compare errors if it is necessary. As an example, network requests usually involve status codes which provide context to an error.

You have to add error returns to every step in the call chain, check those errors at every step in the call chain (if err != nil { return err }), and then handle them somewhere. [...] From that perspective, saving a few characters of typing for each intermediate error return doesn't really address my pain point [...] I end up writing N lines of function calls and Nx3 lines of error handling. Even if that were simplified to N lines of function calls and N lines of error handling with simplified return statements, the error handling code takes up 50% of the space even though I'm not handling any of the errors locally to that code.

The pain point in the other proposals point at the verbosity of error handling being the issue. I understand what you're saying with regards to error handling, but you cannot avoid handling errors because errors are values. I am not sure what you mean by the following statement.

If I want to use "default" values at each step, rather than just return the errors?

You can return the zero value with an error.

The series of N issue — as you have explained it — can only be handled with exceptions (i.e try catch of a non-zero value). You would have to return the error or treat errors as a special value. In the exception handler, you end up comparing errors since the context is lost.

The following code is from the check and handle proposal:

func CopyFile(src, dst string) error {
	handle err {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	r := check os.Open(src)
	defer r.Close()

	w := check os.Create(dst)
	handle err {
		w.Close()
		os.Remove(dst) // (only if a check fails)
	}

	check io.Copy(w, r)
	check w.Close()
	return nil
}

In any case, handle ends up being a shorthand for if err != nil before the statement occurs. If it were implemented as exceptions:

func CopyFile(src, dst string) error {
	handle err {
		// requires error comparison
                if error.Is(ErrOsOpen) {
                  ...
                else if error.Is(ErrOsCreate) {
                    w.Close()
		    os.Remove(dst) // (only if a check fails) 
                } else if
               ...
	}

	r := check os.Open(src)
	defer r.Close()
	w := check os.Create(dst)
	io.Copy(w, r)
	w.Close()
	return nil
}

When errors are values, we are forced to handle (or ignore) the error at the source. As a result, the only way to improve error handling is by making it easier to write (and read).

@beoran
Copy link

beoran commented May 24, 2022

@switchupcb Indeed, and error handling at the source is good, because the context is kept. With return if this would become that much easier.

@switchupcb
Copy link
Author

@mccolljr There is also https://pkg.go.dev/errors which is a standard library approach to managing aggregated errors.

@mccolljr
Copy link

mccolljr commented Jun 1, 2022

@switchupcb sorry for delayed response here.

I'm aware of the errors package, of course, but it isn't actually standard library as far as I am aware, even if it is commonly used.

As for your other points regarding what is/isn't possible with errors in go due to the fact that errors are values:

I'd be likely to support a proposal for novel control flow that could benefit error handling as a side effect. As it stands right now, I see conditional return as a minor space-saving syntax optimization, which I can live without for the time being if it means a more aggressive control flow optimization may be considered in the future.

If this feature were added to the language I might make use of it, but I don't get the sense that it would dramatically improve my experience with error handling as it stands.

@ianlancetaylor
Copy link
Contributor

The suggestion here is that we replace

    if err != nil {
        return err
    }

with

    return if err != nil { err }

The new code has fewer lines but the same number of characters. The syntax, with a value or list of values in curly braces, is unlike any other current Go syntax.

The emoji voting is opposed to this change.

Therefore, this is a likely decline. Leaving open for four weeks for final comments.

@l0k18
Copy link

l0k18 commented Jun 21, 2022

why not one liner for all parenthesised blocks?
@l0k18
@perholmes

See Iteration One. One-liners can decrease readability by combining extraneous statements with return statements. In a similar manner to what deltamualpha stated in #53017 (comment).

I know this is over but after reading the replies I'd have revised that to "one liner if without else statements in gofmt" It's the smallest change.

@golang golang locked and limited conversation to collaborators Jun 21, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
error-handling Language & library change proposals that are about error handling. FrozenDueToAge LanguageChange Proposal Proposal-FinalCommentPeriod v2 A language change or incompatible library change
Projects
None yet
Development

No branches or pull requests

9 participants