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: allow to mark assigments of errors to return immediately when non-nil or to call a handler function #42318

Closed
be-impl opened this issue Nov 1, 2020 · 16 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

@be-impl
Copy link

be-impl commented Nov 1, 2020

In search for a solution to eliminate the infamous

	if err != nil {
		return err
	}

3-liner, I came up with an intriguing idea: What if Go could let you mark any identifier on the left-hand side of an assignment or variable declaration to immediately return the new value, when it is != nil? While investigating this idea further, I eventually decided to cut it down to error handling as of now. You can read about the much broader approach in the section Where I’m coming from with this proposal – and where this could lead to, in case you are interested.

But let’s dive right into how this proposal could improve error handling in Go.

Use ^ to mark error identifiers or error handling functions

The design proposes a change to the syntax of Go to allow the ^ character as a prefix to an identifier on the left-hand side of

  • variable declarations with initializers
  • short variable declarations
  • = assignments (no prefix operators allowed)

and only inside a function body.

Example of marking a returned value of type error in a short variable declaration:

^err := failableTask()

When used in this context, the ^ character may be called shortcut return mark for now (see section Considerations regarding the naming of the feature for alternative names).

^-marked identifiers return if != nil

To put it in a formalized way: When the newly assigned value is != nil, the identifier’s new value will be returned.
As an example, this code

f, ^err := os.Open(filename)

is a shorter version of

f, err := os.Open(filename)
if err != nil {
	return err
}

Using function identifiers to handle errors

When an error should not only be returned as it is, but needs to be wrapped or treated otherwise, the ^-mark should be allowed on a function value like in the following example:

func doWork() error {
	handleError := func(err error) SpecificError {
		return SpecificError{ ..., Err: err }
	}
	...
	f, ^handleError := os.Open(filename)
	...
}

The function behind handleError gets called, when os.Open returns an error (meaning it is != nil). The error is passed as a parameter to the handler function, which returns a SpecificError in the example and that type implements the error interface. It is thus compatible with the return value of function doWork and supplies the value to be returned, an instance of SpecificError.

More formally, a function value reference can be preceded by the ^-mark and turned into an error handler, if

  • The function has exactly one parameter
  • The sole parameter’s type implements the error interface
  • The function returns an error type that is compatible with the last return value of the function doing the actual assignment to the error handler function using the ^-mark

What is returned for other return values of a function (anything but the error)?

If a function is being shortcut-returned by a ^-marked error or handler function, any return value other than the error should return its type’s zero-value. This matches the behavior of other proposals.

I think the exception should be named return values that already had a (non-zero) value assigned, that is: They should keep and return their already assigned value. But I put it up for debate what would be more of a gotcha: the implicit zeroing of an explicitly set return value or that a return value of a "failable" function is != it’s zero value when an error occurred. A handler function could always explicitly zero any named return value, though.

Allow an error handler function to return more than just the error

Following up on then previous section, it should be permitted for an error handling function to return not only the error to the function that uses the error handler, but all of its return values. That would e.g. allow handlers to supply fallback values when a certain type of error occurs. Example:

func printCurrentDirectoryCmd() string, error {
	handlePwdErr := func(err error) string, error {
		handleCdErr := func(err error) error {
			return errors.New("Unknown OS environment")
		}

		cmd := exec.Command("cd")
		^handleCdErr := cmd.Run()
		return "cd", nil
	}
	
	cmd := exec.Command("pwd")
	^handlePwdErr := cmd.Run()
	return "pwd", nil
}

The function printCurrentDirectoryCmd should return the name of the command to print the current working directory: pwd in Unix-like environments, cd in Windows. First, pwd is tried. If the command is present, the function returns the name of the command and no error in the last line of the function body.

When pwd cannot be found and an error occurred, the error handling function handlePwdErr is called. Look at its return values: it returns the same type as printCurrentDirectoryCmd and in the same order. The statement ^handlePwdErr := cmd.Run() thus returns both values from handlePwdErr, which tries to do the same thing for the cd command. If cd isn’t found either, an error is returned by handleCdErr, along with an empty string (the zero-value of type string).

Let an error handler function return a type other than an error

Let’s look at the example from the previous section again. Function printCurrentDirectoryCmd could as well just omit the error and state in its documentation that the returned command is empty, if no matching command could be found for the current execution environment. An error handling function should be allowed to return a type other than error, so the example could be rewritten like this:

func printCurrentDirectoryCmd() string {
	handlePwdErr := func(err error) string {
		handleCdErr := func(err error) string {
			return ""
		}

		cmd := exec.Command("cd")
		^handleCdErr := cmd.Run()
		return "cd"
	}
	
	cmd := exec.Command("pwd")
	^handlePwdErr := cmd.Run()
	return "pwd"
}

Semantics of ^-mark syntax

A return statement passes control (along with the return values) up the call stack to its callee. The same is true for a shortcut return. The ^ character is occasionally used to point to something above in various contexts, because it looks like the head of an arrow pointing upwards.

func main() {
^   // ...
|   func doWork() error { // call via doWork(), function body for illustrating program flow
|       // ...
|__________
           |
        f, ^err := os.Open(filename) // pass err up the call stack to main

I could imagine that, in everyday talk, Gophers would say things like “just up-return the error” or “just up the err”, which would make perfect sense to me.

Considerations regarding the naming of the feature

I want to put the following four alternative names for this feature up for discussion:

  • shortcut return [mark] (my favorite)
  • conditional up-return [mark], could be abbreviated as coup [mark]
  • quick return [mark]
  • return on assignment [mark]

Where I’m coming from with this proposal – and where this could lead to

As I mentioned before, in search for a solution to get rid of error handling boilerplate code I imagined an approach that would go way beyond handling errors. The idea would be that you could mark any identifier on the left-hand side of a (short) variable declaration or assignment using the ^ character to immediately return its value when the new value is unequal to the zero value of the type. How could this be useful? Let’s say you have multiple functions that wrap certain command line program calls and pass back their return codes of type int. By convention, 0 means that the command line program executed successfully. Calling such a series of command line programs - and aborting when one of them fails - could be written very elegantly using the ^-mark just like this:

^rc := cmd1()
^rc := cmd2()
^rc := cmd3()

I eventually discarded this idea for now, because it raised more and more questions, the longer I thought about it. And because I wasn’t sure what the implications of this language feature being widely available would have on Go code. Most importantly, how this would blur the lines between assignment, function calls and conditional program flow.

The good thing is: Allowing the ^-mark on any type, not just errors, could be brought to Go at a later point in time without the need for a breaking change.

Restricting the identifier name when shortcut-returning plain errors

It should be considered to allow only err as a valid identifier name when marking plain errors with the ^ character. Otherwise, small typos could lead to possibly hard to debug and identify errors like in the following example:

func doWork() error {
	handleError := func(err error) SpecificError {
		return SpecificError{ ..., Err: err }
	}
	...
	f, ^handelError := os.Open(filename)
	...
}

Can you spot the typing error? In this case, handleError isn’t being called and the call to os.Open returns its error as-is, which is unintended behavior.

To prevent these kinds of programming mistakes, the first incarnation of the feature should allow the shortcut-retuning of plain-errors with ^err only. This rule could be relaxed later, either when extending shortcut returns like described previously or when a general feeling develops in the Go community that this restriction isn’t necessary or somehow prevents better code.

Resuming control

Other proposals like the one by Marcel van Lohuizen have no way of resuming control, once the error handling is underway. This proposal would have two ways of providing conditional shortcut-returning on errors which typically means inspecting them more deeply. The first approach is based on syntax introduced so far, and could be written in a 2-liner like this:

err := failableTask()
^err = handleErr(err)

So, the first statement would simply capture the error. In the second statement, a handler function would be called, that conditionally checks the supplied error and returns an error or not, based on what is appropriate. Because the err on the left-hand side of the assignment is ^-marked, the statement would shortcut-return that error in the surrounding function if handleErr returns a value != nil.

For the second idea, let’s look again at what the meaning of the ^-mark is: When used with a handler function, that function is called only when the error parameter is != nil. And errors are returned directly, when != nil. To combine and nest these two steps, a double ^-mark could be considered:

^^handleErr := failableTask()

How to read this? Again, this is a nested statement, so the first thing happening is ^handleErr, meaning that the handler function is only being called if the sole parameter is != nil. If the handler is called, it’s return value (an error) is evaluated a second time by the first of the two ^ characters in the above statement: It shortcut-returns the error returned by handleErr from the surrounding function.

To recap:

Benefits

  • no new reserved word(s) needed, unlike in most other solutions on error checking and handling
  • syntax change should be backwards compatible
  • ^-mark introduces minimal visual clutter, while still having enough eye-catching ability
  • understanding of code is always clear on-line: errors to return are marked or handler function being used must be mentioned by name => no subordinate "magic" happening

Downsides

  • blurs the lines between assignment, function calls and conditional program flow
  • editors/tools need to recognize and handle the syntax change accordingly
  • using regular functions for (error) handling instead of a special language construct leads to otherwise unnecessary copying of function parameters
@gopherbot gopherbot added this to the Proposal milestone Nov 1, 2020
@mvdan
Copy link
Member

mvdan commented Nov 1, 2020

Please note that you should fill https://github.com/golang/proposal/blob/master/go2-language-changes.md when proposing a language change.

@mvdan mvdan added the WaitingForInfo Issue is not actionable because of missing required information, which needs to be provided. label Nov 1, 2020
@martisch martisch added error-handling Language & library change proposals that are about error handling. v2 A language change or incompatible library change LanguageChange labels Nov 2, 2020
@martisch
Copy link
Contributor

martisch commented Nov 2, 2020

It seems as pointed out there have been similar proposals before to introduce handler functions and annotate variables to invoke those handlers and allow exiting based on conditions.

See #40432 for umbrella issue.

Not using a new builtin but a marker has been proposed before e.g.: #32884

One previous concern about single char markers has been:
"These are typically rejected because they are cryptic. Often a single ! or other character leads to a change in flow control."

As this proposal does not seem to address the problem of hiding control flow could you elaborate what additional utility over previous proposals this proposal adds that makes it better then the previous ones with single character markers to not need to address that issue?

@be-impl
Copy link
Author

be-impl commented Nov 2, 2020

@martisch Thanks for adding labels!

I read the linked issues mentioned in the meta issue, and some more (Sidenote: link to #33241 seems to point to an unrelated issue).

While my proposal certainly has overlapping aspects to various degrees with many other ones, especially from the marker char camp, I want to point out several observations.

  1. relatively simple change to the syntax parser (some proposals would introduce complex handling function after the actual error emmitting expression)
  2. no getting lost/oversight in longish expressions on the right-hand side
  • The presence of the ^-mark makes it clear that the surrounding function always returns on != nil, except when using ^^, so generally no handlers allowed that don't return anything
  • The ^^-mark put up for debate would allow to resume control from an error handler, but explicitly and on-line
  • Besides the ^-mark, this proposal doesn't introduce anything new to Go, error handling is done by regular function values

I get the general criticism about proposals using mark characters, but I think it makes quite a difference to require an identifier like in #33074 and this proposal. I don't think there is too much magic about it, a meaningful identifier gives you all the hints about WHAT exactly is specially handled by a mark character. And if the character itself makes sense, that helps too. Other languages have special syntax constructs as well. I think if their presence can be justified and you give it a catchy name that is easy to remember (think of the elvis operator), it makes sense to include such syntax changes instead of introducing new keywords.

So yeah, I think my proposal is both minimalist and powerful enough, somewhat like Go in general ;)

@ianlancetaylor
Copy link
Contributor

See #33150 for another proposal in this general space.

I want to stress what @martisch already mentioned: there have been strong objections to having a single character cause a change in control flow. And, to make it worse, the change in control flow is conditional. There just isn't any construct in Go today that works anything like that.

@ianlancetaylor
Copy link
Contributor

For language change proposals, please fill out the template at https://go.googlesource.com/proposal/+/refs/heads/master/go2-language-changes.md .

When you are done, please reply to the issue with @gopherbot please remove label WaitingForInfo.

Thanks!

@smasher164
Copy link
Member

smasher164 commented Nov 26, 2020

I wonder how these "assignment-marking" proposals would fare if instead of using a character like ^ to mark assignments, they used an in-built control-flow mechanism. Taking @be-impl's example but using return:

f, return err := os.Open(filename)

Granted, this overloads the meaning of return into something like return-if-not-nil, but if I saw this on the left-hand-side of an assignment, it would cause me to be more aware of the function's behavior. Also, an editor would color return as a keyword, attracting it further attention.

@be-impl
Copy link
Author

be-impl commented Dec 13, 2020

Would you consider yourself a novice, intermediate, or experienced Go programmer?

Consider me a benevolent observer.

What other languages do you have experience with?

Java, JavaScript, Pascal, TypeScript, P/L SQL (Oracle), SQL PL (IBM), Lisp, Objective-C.

Would this change make Go easier or harder to learn, and why?

There would be one more syntactical element to learn.

Has this idea, or one like it, been proposed before?
If so, how does this proposal differ?

The idea of using marker characters to reduce boilerplate in error handling has been proposed before several times.

Who does this proposal help, and why?

It helps to reduce boiler plate code in error handling significantly.

What is the proposed change?
Please describe as precisely as possible the change to the language.

The design proposes a change to the syntax of Go to allow the ^ character as a prefix to an identifier on the left-hand side of

  • variable declarations with initializers
  • short variable declarations
  • = assignments (no prefix operators allowed)

and only inside a function body.

   What would change in the language spec?

Syntax rules for variable declarations with identifiers, short variable declarations and assignments need to be changed to allow the ^-character as a prefix to identifiers.

Is this change backward compatible?
Breaking the Go 1 compatibility guarantee is a large cost and requires a large benefit.

Yes, the syntax change is fully backwards compatible.

Show example code before and after the change.

Before

f, err := os.Open(filename)
if err != nil {
	return err
}

After

f, ^err := os.Open(filename)

What is the cost of this proposal? (Every language change has a cost).
How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected?

Every tool parsing (and formatting) Go code needs to be adapted.

What is the compile time cost?

There is no compile time cost.

What is the run time cost?

If handler functions are used, there could be costs due to copying of error parameters.

Can you describe a possible implementation?
Do you have a prototype? (This is not required.)

Prototype not available.

How would the language spec change?

See above.

Orthogonality: how does this change interact or overlap with existing features?

The change is just syntactical sugar for reducing boilerplate code.

Is the goal of this change a performance improvement?

No.

How does this differ from previous error handling proposals?

Answered in detail in this comment.

@be-impl
Copy link
Author

be-impl commented Dec 13, 2020

@gopherbot please remove label WaitingForInfo

@ianlancetaylor ianlancetaylor removed the WaitingForInfo Issue is not actionable because of missing required information, which needs to be provided. label Dec 13, 2020
@deanveloper
Copy link

I wonder how these "assignment-marking" proposals would fare if instead of using a character like ^ to mark assignments, they used an in-built control-flow mechanism. Taking @be-impl's example but using return:

f, return err := os.Open(filename)

Granted, this overloads the meaning of return into something like return-if-not-nil, but if I saw this on the left-hand-side of an assignment, it would cause me to be more aware of the function's behavior. Also, an editor would color return as a keyword, attracting it further attention.

Overloading the meaning of return is extremely unattractive with functions that only return an error:

func someFunction() (int, error) {
    // ....
    return err := os.Chdir("dir")
    // looks like unreachable code, but isn't
}

@donutloop
Copy link
Contributor

Negative points of this approach (My own opinion)

  • Introduction of new language magic behavior (I’m not a fan of any magic, magic is always bad)
  • The proposed special character is kinda difficult to write compare to the infamous 3 liner
  • If you mix both versions of error handling inside of a function then it looks only wired

@sirkon
Copy link

sirkon commented May 1, 2021

In my code review this "infamous"

if err != nil {
    return err
}

will likely to be asked: why no decoration. In my code reviews this is only allowed if there's just one return with error point in a whole function. In other cases you must decorate error with a description of what was really happening when you got an error.

@vatine
Copy link

vatine commented Mar 2, 2022

As someone who writes at least some production Go code, I don't think any of my production code contains only

err := somefunc(...)
if err != nil {
        return err
}

In essentially all situations where my code returns an error, it will also emit logging information, at a suitable level (this could be informational, a warning, or an error), frequently also incrementing metrics counters and other things.

From where I am sitting, this is not syntactic sugar that I see the need for, and I'd argue that it is at best neutral for Go.

Now, I realise that this is possibly different from general-purpose libraries, where error-decorations are probably better.

@ianlancetaylor
Copy link
Contributor

As with the rejected try proposal, this proposal calls for hidden control flow, in this case hidden by ^.

Based on that and the discussion above, this is a likely decline. Leaving open for four weeks for final comments.

@be-impl
Copy link
Author

be-impl commented Mar 24, 2022

@ianlancetaylor I understand and would like to ask: From the Go team's perspective, could you please clarify what is considered 'hiding' the control flow and what isn't. I think that a clarification would be a valuable thing for any future proposals on the issue of error handling.

@ianlancetaylor
Copy link
Contributor

Ordinary flow of control goes through function calls, from one statement to the next, and falling off the end of a function to return. Any other kind of flow control is unusual. In Go unusual flow control is always determined by a keyword like return, goto, for, or by one special case: a function call to panic. Hidden control flow would be control flow that is triggered by anything else.

@ianlancetaylor
Copy link
Contributor

No change in consensus.

@golang golang locked and limited conversation to collaborators Apr 6, 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

10 participants