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 else err after function calls #65793

Closed
markusheukelom opened this issue Feb 19, 2024 · 20 comments
Closed

proposal: Go 2: allow else err after function calls #65793

markusheukelom opened this issue Feb 19, 2024 · 20 comments
Labels
error-handling Language & library change proposals that are about error handling. LanguageChange Proposal Proposal-FinalCommentPeriod v2 A language change or incompatible library change
Milestone

Comments

@markusheukelom
Copy link

markusheukelom commented Feb 19, 2024

Proposal Details

In Go, a map lookup, a type assertion and a channel receive operation will report success or failure in a second result value of type bool , if requested so for by assignment. This value is false if the operation failed and true if succeeded.
Functions and methods report failure in a in similar way by returning a last (and possibly only) value of type error. If this value is not nil, the function failed and the caller should handle the failure appropriately by logging, returning a decorated error, panicking, ignoring, etc. There are exceptions, such as fmt.Errorf, where returning an error is not a failure, but these are mostly obvious.

As such the following constructs are ubiquitous in Go code:

// Current syntax for detecting/handling failure

// map key lookup
val, ok := m[key]
if !ok {
	// key is not found in the map
}

// type assertions
rw, ok := r.(io.ReadWriter)
if !ok {  
	//  r does not satisfy io.ReadWriter
}

// channel receiving
val, ok := <-ch
if !ok {
	// channel closed
}

// file opening
f, err := os.Open("readme")
if err != nil {
 //	...
}

This proposal contains an idea to syntactically handle these kinds of failure reporting using the else keyword.

This is would works as follows.

Map lookup, type assertion and channel receive operations can optionally be followed by the else keyword and a code block. If so, the ensuing code block is executed if the operation failed (ie. ok in the examples above would be false). When used in an assignment, the assignment (or short variable declaration) takes place before executing the else block.

Example:

// Proposed new syntax

// map lookup failure handling
x := m["domain"] else {
	x = "localhost"  
}

// type assertion failure handling
rw := r.(io.ReadWriter) else {
	return fmt.Errorf(...)
}


// channel receive failure handling
val := <- ch else {
	panic("channel unexpectally closed")
}

For sake of clarity, the map lookup the example above is identical to following current syntax

var x string
{
	var ok bool
	x, ok = m["domain"]
	if !ok {
		x = "localhost" 
	}
}

and in a similar way for type assertions and channel receive operations.

For function and method calls, if the last return argument of the function is of type error, the call can also optionally be followed by the else keyword. In this case else must be followed by both an identifier and a code block. The identifier is used to create a variable in the code block capturing the error value which is removed from the call's returned argument list. The else-block is executed if the error value is not nil. The identifier cannot be omitted but is allowed to be _.

This would look as follows:

// Proposed new syntax

f := os.Open("readme.txt") else err {
	return fmt.Errorf("uhoh: %w", err)
}

f.Close() else err {
	log.Println("warning: error closing file: %s", err.Error())
	return 
}

// ignore error by assigning it to `_`
f.Close() else _ {
	panic("how checks closing errors anyway")
}


Note that the err variable is lifted to a separate scope (and ok variables are elided completely).

If the result of the call is used in an assignment, the assignment (or short variable declaration) of all other values take place before executing the ensuing else code block.

// set a default value on error
// (notice that val is visible in else-block because 
// assignment happense before the else-block is executed)
val := parse(input) else err {
		log.Printf("...")
		val = "Go"  // ok
}

// here val is not visible because the short variable 
// declaration is not part of the else construct.
val := toUpper(parse(input) else err {
		log.Printf("...")
		val = "Go"  // error: "val" is not defined
})

This is the full proposal. What follows is some motivation for this proposal and a discussion of some open ends.

Motivation

In current Go, variables that are needed to handle failures, often named err and ok , are almost always part of the same scope as the result variables. As a consequence, they are visible long after their (intended) usage even though they are no longer needed. A typical symptom of this situation is having to change err := into err = (or back again) when reorganising code, or having to use err = instead of err := because err was used earlier in the scope somewhere already, handling an error that is no longer relevant.

This is resolved using the proposed syntax (if the programmer chooses so), because ok variables are now elided completely and err variables are now scoped to the block that handles the error. As a result, the "happy path" of the code stays free from the variables that deal with the "therapy path" (failure / error path).

Some bugs in Go code appear to be a result of how Go currently deals with error handling syntactically:

str := "Go"
println(str)

if foo() {
    str, err := parse(...) 
    if err != nil {
	return fmt.Errorf("...", err)
    }
    log.Println("found str: ", str)
}

// BUG: str is here still "Go", while the result of 
// parse() is expected if foo() is true

It is likely that this type of bug occurs less with the new syntax, because := is no longer used with a mix of existing and new (error) variables:

str := "Go"
println(str)
if foo() {
	str = parse(...) else err {
		return fmt.Errorf("...", err)
	}
	log.Println("found str: ", str)
}

Notice that error handling syntax becomes smoother with the proposal. The overwhelmingly common error handling case of the form

// current Go
f, err := SomeFunc() 
if err != nil {
	// handle error (do nothing, return, decorate, panic, log, etc)
}

can now be written as

// proposed Go
f := SomeFunc() else err {
	// ... 
}

while still allowing handling the error exactly as before (i.e. by means of logging, returning, panicking, ignoring etc). This is a reduction 25% in terms of lines, but also reduces err != nil to err which saves some additional typing. Of course, the current way of handling errors is still perfectly valid.

Finally, using this construct it becomes possible to use functions that return both a value and error (T, error) in chain calls and as argument to other calls without using intermediate variables. Of course, readability should always prevail, but this allows "inline" error handling just like it is allowed to e.g. "inline" define a function:

func foo(r io.Reader) (string, error) {}

// "inline" error handling
s := foo(io.Open("readme") else err {
	panic(err)
})

type Bar struct{}

func (b Bar) DoA() (Bar, error)
func (b Bar) DoB() (Bar, error)
func (b Bar) DoS() (string, error)

var b Bar

// Chain calling functions that return (T, error)
s := b.DoA() else err {
	panic("a")
}.DoB() else err {
	panic("b")
} else err {
	panic("s")
}

Discussion

I do not expect that this proposal is water-tight on arrival. What follows are some open ends. Also, it might turn out that the proposal is completely unusable because I oversaw some syntax ambiguities that arises when using else this way. However, it seemed to me that using the else keyword to handle failure is worth investigating (maybe even in a different form if this proposal doesn't work) as it does not introduce new keywords and feels somewhat natural to use in this regard.

Custom error types

In this proposal, handling errors with else the function must return a last argument of type error and not a type that merely satisfies error. I chose this for simplicity but the idea could possibly be extended to allow for custom error types requiring only that the error interface is satisfied.

type MyError struct{}
func (m *MyError) Error {}

func foo() *MyError {
  // ...
}

foo() else err {  // allowed?

}

Custom map types

Likewise, it can be expected that container types will mimic map behaviour by exposing methods such as:

type CustomMap[Key comparable, Value Any] struct{}

func (c *CustomMap[Key, Value]) Lookup(k Key) (Value, bool) {

}

These methods cannot be used with the syntax proposed here, although it could possibly be extended to work with function having a last argument of bool instead of error as well. However, it is unclear if this does not interfere with if syntax too much so I left it out.

Interference with if

Finally, it is easy to come up with examples that look a little weird when combined with if. For example:

// possibly funny looking construct on first sight
if strconv.ParseBool(input) else err {
	return fmt.Errorf("expected 'true' or 'false'")
} {
	println("got true")
}

Of course, handling errors using else is complimentary to current failure handling so if confusion arises it might be better to use the current way of error handling.

@gopherbot gopherbot added this to the Proposal milestone Feb 19, 2024
@seankhliao
Copy link
Member

doesn't seem very different from #41908 #56895

@seankhliao
Copy link
Member

Please fill out https://github.com/golang/proposal/blob/master/go2-language-changes.md when proposing language changes

@seankhliao seankhliao added LanguageChange v2 A language change or incompatible library change error-handling Language & library change proposals that are about error handling. labels Feb 19, 2024
@seankhliao seankhliao changed the title proposal: syntax for failure handling use the "else" keyword proposal: Go 2: allow else err after function calls Feb 19, 2024
@seankhliao
Copy link
Member

also #32848 #37243

@adonovan
Copy link
Member

adonovan commented Feb 19, 2024

What happens if the else block completes normally?

x := m["domain"] else {
	x = "localhost"  
}

The scope of variable x starts after this complete statement, so you can't assign to it from the 'else' block. Are you proposing to change the scope rules too?

This doesn't seem to add anything not already rejected in prior proposals (thanks @seankhliao for the links).

@markusheukelom
Copy link
Author

What happens if the else block completes normally?

Nothing special.


x := m["domain"] else {

	x = "localhost"  

}

The scope of variable x starts after this complete statement, so you can't assign to it from the 'else' block. Are you proposing to change the scope rules too?

In my proposal any assignment happens just before the else scope is excuted (if so). So x can indeed be accessed in the else block. I've included an example of how it would translate to current syntax in the proposal.

This doesn't seem to add anything not already rejected in prior proposals (thanks @seankhliao for the links)

@earthboundkid
Copy link
Contributor

I would say see #31442, but I think you're familiar with it. 😄

@adonovan
Copy link
Member

What happens if the else block completes normally?

Nothing special.

Well, something must happen because the postfix else {...}operator changed the type of its operand (the call) so that it no longer has an error. So where did the error go? Was it silently discarded, causing the program to press on into uncharted states?

@nirui
Copy link

nirui commented Feb 20, 2024

I think this proposal, as well as many others were based on the assumption that the error returning format v, err := call() is something special to the language. But maybe it's not the case in reality.

Currently, returning the error by err, v := call() worked equally well. To the language, the returned err is just another return value, nothing special.

So I guess in order for proposal like this to be successful, the v, err := call() format must be "formalized" to the language spec. But then that may in turn introduce some inconvenient limit to the language.

@apparentlymart
Copy link

apparentlymart commented Feb 20, 2024

The original writeup compares an existing supported pattern of an declaration/assignment followed by an if statement to a new proposal that combines the two into a single statement.

I think it's also interesting to compare the new proposal to the other currently-supported form where the declaration/assignment is embedded in the if statement:

// map key lookup
if val, ok := m[key]; !ok {
	// key is not found in the map
}

// type assertions
if rw, ok := r.(io.ReadWriter); !ok {  
	//  r does not satisfy io.ReadWriter
}

// channel receiving
if val, ok := <-ch; !ok {
	// channel closed
}

// file opening
if f, err := os.Open("readme"); err != nil {
	// ...
}

This shorthand has a similar "weight on the page" as the proposed new grammar. In this case the if condition must still be written out explicitly -- there is no assumption about what the final return value might represent -- but the ability to bundle it into the if statement header makes it collapse visually into that header, where it's easier to ignore while scanning. (The fact that it's harder to scan is actually why I often don't use this style, but for the sake of this comparison I'm assuming that having fewer lines is more important, since that seems to be a goal of the proposal.)

Comparing these two, the most significant difference seems to be that of scoping: when embedding a declaration in an if statement header, all of the declarations attach to the nested block rather than to the containing block. That means that val, rw, val, and f in the above are dropped once the if statement is done, which tends to encourage a less idiomatic style where both cases are indented:

if f, err := os.Open("readme"); err == nil {
	// do something with f
} else {
	// do something with err
}

If the primary concerns of this proposal are conciseness and scoping, I wonder if there's a smaller change in finding a way to place f in the containing block while keeping err limited to the nested block, while otherwise keeping this still as a familiar if statement that doesn't make any assumptions about what the condition might be.


A strawman follows. I don't particularly love this specific syntax but I'm including it to illustrate what I mean by making only a small adjustment to the existing construct:

if ^f, err := os.Open("readme"); err != nil {
	// do something with err
}
// do something with f

Strawman specification: For declarations that appear in the header of an if, for, or switch statement (and no others), the symbol ^ before a symbol declares that it should be exported into the block that contains the statement.

I don't really like the non-orthogonality of it being limited only to those three contexts, but I might justify it by noting that these are three situations where the declarations are sitting on a boundary between two scopes: visually, these declarations are neither inside the nested block nor directly in the parent block. In all other contexts a declaration is clearly either inside or outside of each block.

I realize this is essentially a competing proposal, but for the moment I'm just mentioning it to see what @markusheukelom thinks about whether this still achieves similar goals despite being a smaller language change.

(I also have a feeling that something like this was already proposed before, but I wasn't able to guess what to search for to find it, if so.)

@earthboundkid
Copy link
Contributor

@apparentlymart Your strawman seems similar to the guard statement in Swift. See my comment on the old issue: #31442 (comment)

@apparentlymart
Copy link

That does seem similar indeed! I think the notable difference is that I intended that only f would be declared into the parent block, while err would be limited only to the nested block.

It seems that guard makes it an all-or-nothing decision: all of the symbols go either in the parent block or the nested block. The err symbol being available for later use in the function (when there's no error) seemed to be one of the points of contention that this proposal was aiming to address.

@markusheukelom
Copy link
Author

if ^f, err := os.Open("readme"); err != nil {
	// do something with err
}
// do something with f

Strawman specification: For declarations that appear in the header of an if, for, or switch statement (and no others), the symbol ^ before a symbol declares that it should be exported into the block that contains the statement.

@apparentlymart Yes, this is also an idea to keep the main scope "clean" from err and ok variables. I wrote a proposal for this here. The drawback is that a new operator must be created, or an existing one overloaded. I still like the idea, maybe .. would possibly a better choice as it resembles a similar operation in filesystem access.

if ..f, err := os.Open("readme"); err != nil {
 	// do something with err
}

But it seemed that people just didn't like the idea (judging on the downvotes), maybe the syntax is too ugly or maybe there's other problems with it that I don't realise. I do think that the if-statement does become a bit noisy.

@apparentlymart
Copy link

Hah... I guess it was your earlier proposal that I was remembering. 🤦‍♂️ Sorry for proposing your own idea back to you.

FWIW, I prefer the smaller change of just allowing some more flexibility in how these symbols are scoped when using the existing constructs over adding an entirely new construct, especially because the smaller change is also more general and doesn't make any assumptions about error handling in particular. But perhaps I'm in the minority on that viewpoint. 🤷🏻‍♂️

@markusheukelom
Copy link
Author

I think this proposal, as well as many others were based on the assumption that the error returning format v, err := call() is something special to the language. But maybe it's not the case in reality.

(Just for completeness, that was not my assumption; I did realise that it is mere a convention and not a language feature. Of course, error itself is special to the language.)

Currently, returning the error by err, v := call() worked equally well. To the language, the returned err is just another return value, nothing special.

So I guess in order for proposal like this to be successful, the v, err := call() format must be "formalized" to the language spec. But then that may in turn introduce some inconvenient limit to the language.

@nirui

Yes, this is a drawback of this proposal, but note that in e.g. the range-iterator proposal, functions with special signature are also becoming part of the language specification. Is that significantly different?

Note that the syntax could be made non-error aware by requiring a condition:

f := os.Open("readme") else err; err != nil {

}

Would that make such a proposal more likely to be of interest? I decided to keep the proposal as simple as possible, but I do understand your point.

@nirui
Copy link

nirui commented Feb 23, 2024

Hi @markusheukelom, what if a function returned multiple errors?

In my program, I have a function which does v, err1, err2 := call(). err1 and err2 leads to different handling pathways, similar to:

v, err1, err2 := function(...)
if err1 != nil {
    // reset the reader and continue
}
if err2 != nil {
    return err2
}
// make use of `v`
....

Note: the pathway re-joins after the handling of err1, and if it's not the case, the code then can simply be written as:

if v, err1, err2 := function(); err1 != nil {
    // handle err1
} else if err2 != nil {
    // handle err2
} else {
    // make use of `v`
}

I can't write it in v, err := function(...) because err1 and err2 might return the same error value (io.EOF etc), and creating a type-identifier-wrapper error is too expensive for the use case.

It could be helpful if your proposal can cover this use case too.

@markusheukelom
Copy link
Author

markusheukelom commented Feb 26, 2024

@nirui

I am afraid my proposal cannot (or even should) handle your case elegantly, as the proposal is meant for situations that have a single error variable. I am certainly not saying that your case isn't valid of course, it's just a use case that is a bit rare and for which the current way of returning multiple values can be used. I am not sure it's worthwhile to try an support these cases as for example Go also does not have a do {} while construct even though its easy to think of use-cases for do-while.

There are other error-reporting constructs that cannot be handled by my proposal, for example see my note on custom error types (although an extension can be imagined). If custom error types are to be supported and you are able to change the function signature then of course you could do something like

type ErrPair struct {
	Err1, Err2 error
}

func (e ErrPair) Error() string {}

func function() (Value, ErrPair) {...}

v := function() else err {
	if err.Err1 != nil {
		// reset the reader and continue
	}
	if err.Err2 != nil {
    	    return err2
	}
}

// make use of `v`
....

This would work in the current proposal as well if the function returns function() (Value, error) but then you need a type assertion in your error handling block (which I assumed is too expensive as you mentioned).

@ianlancetaylor
Copy link
Contributor

This is a lot like the recent #65579, with different syntax.

@ianlancetaylor
Copy link
Contributor

Based on the discussion, this is a likely decline. Leaving open for four weeks for final commens.

@markusheukelom
Copy link
Author

@ianlancetaylor

This is a lot like the recent #65579, with different syntax.

Yes there is certainly some resemblance. However, this proposal does not introduce a new operator or keyword and is more general in that is also handles map lookup, type assertion, channel receive failure. (There are other proposals using the else keyword, btw).

Understand/agree it is a likely decline.

I hope that you've read my "Motivation" section. It outlines an issue with current error handling besides verbosity of syntax which I think is not so explicitly expressed elsewhere (i.e. having err, ok ok in scope long after they are needed), at least to my knowledge. Maybe this can add to other ideas/proposals wrt error handling.

@ianlancetaylor
Copy link
Contributor

No change in consensus.

@ianlancetaylor ianlancetaylor closed this as not planned Won't fix, can't repro, duplicate, stale Apr 3, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
error-handling Language & library change proposals that are about error handling. LanguageChange Proposal Proposal-FinalCommentPeriod v2 A language change or incompatible library change
Projects
None yet
Development

No branches or pull requests

8 participants