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: abort? statement to bail early on non-nil errors #63575

Closed
sethbonnie opened this issue Oct 16, 2023 · 24 comments
Closed

proposal: Go 2: abort? statement to bail early on non-nil errors #63575

sethbonnie opened this issue Oct 16, 2023 · 24 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

@sethbonnie
Copy link

sethbonnie commented Oct 16, 2023

I’m aware of the long history of this particular topic. What’s different about this proposal is that it introduces a return construct conditional on errors. I’ve looked through previous proposals and none from what I saw build on the return statement. As I hope to show in the background section, keeping the current return semantics is the core constraint of any solution for this problem.

In a nutshell, I propose an abort? statement that transforms functions like this:

func (m Model) Delete(ctx context.Context, id int64) error {
	query := `DELETE FROM table WHERE id = $1`

	result, err := m.DB.ExecContext(ctx, query, id)
	if err != nil {
		return err
	}

	rowsAffected, err := result.RowsAffected()
	if err != nil {
		return err
	}

	if rowsAffected == 0 {
		return ErrRecordNotFound
	}
	return nil
}

into something like:

func (m Model) Delete(ctx context.Context, id int64) error {
	query := `DELETE FROM table WHERE id = $1`

	abort? result, err := m.DB.ExecContext(ctx, query, id); err
	abort? rowsAffected, err := result.RowsAffected(); err

	if rowsAffected == 0 {
		return ErrRecordNotFound
	}
	return nil
}

I think it’s helpful to systematically build up to a solution, so before diving in I want to build up an understanding of the problem. If my understanding of the problem is correct, then the solution is probably appropriate. However, if my understanding is flawed then the solution is likely also flawed, but we can at least catch that error and learn from it.

I tried my best to follow the language change template, but the questionnaire format was limiting as I prefer a systematic build up. However, I used the questions as a guide and all the relevant questions should be answered.

Background

Ironically, the “error-handling problem” is not about handling errors at all, but quite the opposite: not handling them and just bailing early.

It’s a problem that mostly affects what I'm calling mid-stack functions — functions that call other functions that can error but don’t have the context to decide how best to handle the error. These functions typically just return early and propagate the error to the caller. They have some variation of the familiar if err != nil { return ... } pattern.

Functions near the bottom of the stack are closer to the application’s entry point and thus have more context as to what to do on errors. Their code is likely to handle errors even if just to log or add a metric or exit altogether. The key characteristic for these is that after checking for an error, they don't immediately return.

Functions near the top of the stack typically create errors though they might also contain parts resembling mid-stack functions if they happen to make system calls. They check for logical errors or invalid states and then return error values, but they typically don't check for error values to begin with.

Why is this an issue just in Go?

This problem isn’t unique to Go, it’s something that happens in all languages. Whether or not you notice it is largely a matter of how the language treats errors.

Whenever an invalid state happens at the top of a call stack, you want to unwind quickly and get to functions inside the main application that have more context on what to do with that error.

Languages that use exceptions for errors throw an exception at the top of the stack and expect application functions to catch those exceptions. Mid-stack functions in these languages simply ignore the exception and let it bubble up. This transforms the problem into one of guessing which function might blow up. And even if it is clear which functions might throw, it means any function that throws forces the calling code to use a try/catch. It also means that if you want to transform or wrap an exception it means you have to catch one exception and throw another. But perhaps the biggest downside to this pattern is that it's not clear what invalid states are truly exceptions.

Languages that return result types also encounter this problem and sometimes use some special construct to quickly return in the failure case. This is the most they can do since each function in the chain still has to return a result. Go is most similar to these languages. Though it doesn’t strictly have result types, Go's return statement is powerful enough to mimic them. These languages have the downside of having to propagate errors up manually but they have an advantage in that they create a distinction between errors and truly exceptional states.

I think the most you can do in a language that treats errors as values is to create an abstraction that returns quickly when an error is encountered. Anything more powerful than that strays into exception territory.

Examples of Failing Early

Whenever designing an abstraction, it’s helpful to see the different variations in order to figure out what’s constant and what varies. We should be able to abstract away the constant elements while providing a way to express what varies. Let’s look at a few examples of functions that immediately bail once they encounter an error.

First, we need to account for the error being returned in any position in the result list of an inner function call:

func F1() error {
	err := G1()
	if err != nil {
		return err
	}
	// ...
	return nil
}

func F1() error {
	x, err := G2()
	if err != nil {
		return err
	}
	// ...
	return nil
}

func F1() error {
	x, y, err := G3()
	if err != nil {
		return err
	}
	// ...
	return nil
}

func F1() error {
	x, err, y := G3()
	if err != nil {
		return err
	}
	// ...
	return nil
}

We also need to account for functions that bail-early while returning multiple values:

func F2() (*A, error) {
	if err := G1(); err != nil {
		return nil, err
	}
	// ...
	return a, nil
}

func F3() (*A, B, error) {
	if err := G1(); err != nil {
		return nil, B{}, err
	}
	// ...
	return a, b, nil
}

We also might want to replace or wrap the error in some fashion before returning:

func F3() (*A, B, error) {
	if err := G1(); err != nil {
		return nil, B{}, fmt.Errorf("annotation: %w", err)
	}
	// ...
	return a, b, nil
}

var ErrNotFound = errors.New(“not found”)
func F2(id int64) (*A, error) {
	a, err := db.find(id)
	if err != nil {
		return nil, ErrNotFound
	}
	return a, nil
}

From the cases above, we can see that the constant in all these variations is the extraction and testing of err. The return statement can vary and so it can't be abstracted away.

The core problem seems to be reducing the ceremony around extracting and testing for an error, while keeping the semantics of the return statement.

Proposed Solution

My proposal is an abort? statement that immediately bails from the function whenever it encounters a non-nil error, returning a specified list of values.

Code before abort?:

// from https://pkg.go.dev/github.com/dsnet/try#section-readme
func (a *MixedArray) UnmarshalNext(uo json.UnmarshalOptions, d *json.Decoder) error {
	switch t, err := d.ReadToken(); {
	case err != nil:
		return err
	case t.Kind() != '[':
		return fmt.Errorf("got %v, expecting array start", t.Kind())
	}

	if err := uo.UnmarshalNext(d, &a.Scalar); err != nil {
		return err
	}
	if err := uo.UnmarshalNext(d, &a.Slice); err != nil {
		return err
	}
	if err := uo.UnmarshalNext(d, &a.Map); err != nil {
		return err
	}

	switch t, err := d.ReadToken(); {
	case err != nil:
		return err
	case t.Kind() != ']':
		return fmt.Errorf("got %v, expecting array end", t.Kind())
	}
	return nil
}

Code with abort?:

func (a *MixedArray) UnmarshalNext(uo json.UnmarshalOptions, d *json.Decoder) error {
	abort? t, err := d.ReadToken(); err 

	if t.Kind() != '[' {
		return fmt.Errorf("got %v, expecting array start", t.Kind())
	}

	abort? uo.UnmarshalNext(d, &a.Scalar)
	abort? uo.UnmarshalNext(d, &a.Slice)
	abort? uo.UnmarshalNext(d, &a.Map)

	abort? t, err := d.ReadToken(); err

	if t.Kind() != ']' {
		return fmt.Errorf("got %v, expecting array end", t.Kind())
	}

	return nil
}

From the above code, we can see that there are 2 variants of abort?. A two clause version and a single-clause version.

Two clause version

abort? t, err := d.ReadToken(); err 

The first clause of abort expects an expression list that it uses to test for non-nil errors. In the above we're using the left-hand side of the assignment (t, err) as the expression list. If any of these is a non-nil error, then the last clause is returned. As a result, the last clause is an expression list that MUST match the return signature of the function.

The assigned values are available to the surrounding scope. This strays slightly from how other statements with an assignment preamble work (e.g., if and switch) since they constrain assigned values to the following block. Since abort? doesn't have a block, I think this behavior remains intuitive.

One clause version

The single-clause version is used when the first expression list used to test also matches the return signature of the function. We can separate the assignment to its own line and write the 2 clauses in 2 steps:

// with 2 clauses
abort? t, err := d.ReadToken(); err 

// in 2 steps
t, err := d.ReadToken()
abort? err; err 

But since the first list matches the second list we can just merge them into one:

t, err := d.ReadToken()
abort? err

That's what's happening for the abort? uo.UnmarshalNext(...) lines:

// expanded to 2 lines and 2 clauses
err := uo.UnmarshalNext(d, &a.Map)
abort? err; err

// single clause with err assignment
err := uo.UnmarshalNext(d, &a.Map)
abort? err

// using the return value of the function for both clauses
abort? uo.UnmarshalNext(d, &a.Map)

Definition

Formally, it can be defined as:

AbortStmt = "abort?" [ ExpressionList | Assignment ";" ] ExpressionList

The abort? statement tests its first ExpressionList for the presence of an error and checks if the error is not nil. If it is nil then execution of the current function continues. If the error is not nil, it returns the last ExpressionList.

In the case of an Assignment, the left-hand ExpressionList of the Assignment is used for testing for a non-nil error.

The last ExpressionList MUST match the return signature of the function. In the case of the single-clause version, the first and last ExpressionList are the same thing.

It's illegal for the first ExpressionList to not contain an error type. We could check for this at compile time. This limits the blast radius of this change and makes it strictly for unwinding the stack without too much ceremony.

Examples

I think it's worth showing a couple more examples to get a feel for how code might look with abort?. These are random functions from the standard library and kubernetes that contained instances of if err != nil { return ... }:

// Before
func (fsys dotFileHidingFileSystem) Open(name string) (http.File, error) {
	if containsDotFile(name) { // If dot file, return 403 response
		return nil, fs.ErrPermission
	}

	file, err := fsys.FileSystem.Open(name)
	if err != nil {
		return nil, err
	}
	return dotFileHidingFile{file}, nil
}

// After
func (fsys dotFileHidingFileSystem) Open(name string) (http.File, error) {
	if containsDotFile(name) { // If dot file, return 403 response
		return nil, fs.ErrPermission
	}
	abort? file, err := fsys.FileSystem.Open(name); nil, err
	
	return dotFileHidingFile{file}, nil
}
// Before
func (t *multiWriter) Write(p []byte) (n int, err error) {
	for _, w := range t.writers {
		n, err = w.Write(p)
		if err != nil {
			return
		}
		if n != len(p) {
			err = ErrShortWrite
			return
		}
	}
	return len(p), nil
}

// After
func (t *multiWriter) Write(p []byte) (int, error) {
	for _, w := range t.writers {
		abort? n, err := w.Write(p)
		if n != len(p) {
			return n, ErrShortWrite
		}
	}
	return len(p), nil
}
// Before
func (dc *DeploymentController) rolloutRolling(ctx context.Context, d *apps.Deployment, rsList []*apps.ReplicaSet) error {
	newRS, oldRSs, err := dc.getAllReplicaSetsAndSyncRevision(ctx, d, rsList, true)
	if err != nil {
		return err
	}
	allRSs := append(oldRSs, newRS)

	// Scale up, if we can.
	scaledUp, err := dc.reconcileNewReplicaSet(ctx, allRSs, newRS, d)
	if err != nil {
		return err
	}
	if scaledUp {
		// Update DeploymentStatus
		return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
	}

	// Scale down, if we can.
	scaledDown, err := dc.reconcileOldReplicaSets(ctx, allRSs, controller.FilterActiveReplicaSets(oldRSs), newRS, d)
	if err != nil {
		return err
	}
	if scaledDown {
		// Update DeploymentStatus
		return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
	}

	if deploymentutil.DeploymentComplete(d, &d.Status) {
		if err := dc.cleanupDeployment(ctx, oldRSs, d); err != nil {
			return err
		}
	}

	// Sync deployment status
	return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
}

// After
func (dc *DeploymentController) rolloutRolling(ctx context.Context, d *apps.Deployment, rsList []*apps.ReplicaSet) error {
	newRS, oldRSs, err := dc.getAllReplicaSetsAndSyncRevision(ctx, d, rsList, true)
	abort? err
	allRSs := append(oldRSs, newRS)

	// Scale up, if we can.
	scaledUp, err := dc.reconcileNewReplicaSet(ctx, allRSs, newRS, d)
	abort? err
	if scaledUp {
		// Update DeploymentStatus
		return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
	}

	// Scale down, if we can.
	scaledDown, err := dc.reconcileOldReplicaSets(ctx, allRSs, controller.FilterActiveReplicaSets(oldRSs), newRS, d)
	abort? err
	if scaledDown {
		// Update DeploymentStatus
		return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
	}

	if deploymentutil.DeploymentComplete(d, &d.Status) {
		abort? dc.cleanupDeployment(ctx, oldRSs, d)
	}

	// Sync deployment status
	return dc.syncRolloutStatus(ctx, allRSs, newRS, d)
}

The last example is particularly interesting in that it highlights when you might want to skip putting things on a single line and instead use a separate assignment statement. abort? follows the usage of the if statement in that regard.

Closing Thoughts

This idea came to me randomly and fully-formed for the most part so I hope I've done it justice. Please let me know if there's anything unclear or any edge-cases I didn't cover.

Costs

There's certainly a cost for adding a new keyword in both language and tooling. The compile-time and runtime costs should be roughly similar to the if err != nil { return ... } pattern since this is mostly an abstraction of that pattern rather than a new construct. I'm not familiar with the tooling implementation so I can't speak to the cost of that, but hopefully I've provided enough information for those that are to deem whether or not this is worth the cost.

There's also the cost of learning a new statement for both experienced Go programmers and those new to the language. I've been careful not to introduce any constructs that don't already exist in the language. I think there is a cost here to be sure, but it's a minimal one since this is mostly giving a name to a frequently used pattern.

Benefits

The core benefit of this change would be to reduce boilerplate and increase information density. It saves 2-3 lines of code each time it’s used. That's not a lot in any singular case, but it compounds over multiple cases. As long as we're not losing information when condensing the code then the abstraction is worth.

It’s important not just to reduce boilerplate, but to communicate the intention behind that boilerplate. I think abort? does a good job of being lossless in that regard.

Alternative Names Considered

My first version was abort to match the look of the other keywords in Go. That name was likely to cause issues because

  1. It would conflict with any existing code using abort as a package or variable name.
  2. It sounds like a command and not conditional at all.
    Adding the ? suffix solved both problems. It's not a breaking change since ? is illegal in package/variable names. It also captures the optional nature of bailing early only when an error is exists.

return? was considered, but while it captures the same sentiment as abort?, it hurts the ability to quickly scan code because it’s only a single character away from return and it does nearly the same thing which would probably cause confusion and mistakes.

error? and try were also considered but they signal checking for errors rather than returning when one is found. try also has the added baggage of try/catch in other languages and teaching a new meaning on top of that would be fighting an unnecessary battle.

Comparison with Other Languages

I intentionally stayed away from discussing other languages since the design of abort? is based mostly on the return statement and I wanted to keep this focused on that. I mentioned earlier that Go is most similar to languages that have result types. If you’re curious how abort? compares with other "fail early" constructs, I think it’s an interesting exercise to rewrite code with the Result type in Go using abort?.

@sethbonnie sethbonnie added LanguageChange Proposal v2 A language change or incompatible library change labels Oct 16, 2023
@gopherbot gopherbot added this to the Proposal milestone Oct 16, 2023
@seankhliao seankhliao added the error-handling Language & library change proposals that are about error handling. label Oct 16, 2023
@seankhliao
Copy link
Member

This looks like #46655, with check -> abort? and moving its position

@ianlancetaylor
Copy link
Contributor

  1. Similar ideas have been proposed several times before.
  2. This kind of approach means that reading the code focuses on error handling, because every line starts with abort?. That makes the code somewhat harder to read.
  3. Early on you mention code that returns an annotated error, but I don't see any examples of such code using abort?.

@sethbonnie
Copy link
Author

sethbonnie commented Oct 17, 2023

  1. Similar ideas have been proposed several times before.

Not sure what you mean by this. I'm aware of the history, but perhaps you mean it's not a technical issue?

From a technical perspective, I've seen the idea of returning from a function when an error is present, but the ones I've seen don't give the programmer much control of the values returned.

  1. This kind of approach means that reading the code focuses on error handling, because every line starts with abort?. That makes the code somewhat harder to read.

Only lines that need to bail early would start with abort? so it wouldn't be every line. Every line that starts with abort? also signals intent so it's information dense.

  1. Early on you mention code that returns an annotated error, but I don't see any examples of such code using abort?.
func (d *decodeState) unmarshal(v any) error {
	rv := reflect.ValueOf(v)
	if rv.Kind() != reflect.Pointer || rv.IsNil() {
		return &InvalidUnmarshalError{reflect.TypeOf(v)}
	}

	d.scan.reset()
	d.scanWhile(scanSkipSpace)
	// We decode rv not rv.Elem because the Unmarshaler interface
	// test must be applied at the top level of the value.
	err := d.value(rv)
	if err != nil {
		return d.addErrorContext(err)
	}
	return d.savedError
}

// With abort?
func (d *decodeState) unmarshal(v any) error {
	rv := reflect.ValueOf(v)
	if rv.Kind() != reflect.Pointer || rv.IsNil() {
		return &InvalidUnmarshalError{reflect.TypeOf(v)}
	}

	d.scan.reset()
	d.scanWhile(scanSkipSpace)
	// We decode rv not rv.Elem because the Unmarshaler interface
	// test must be applied at the top level of the value.
	abort? d.value(rv); d.addErrorContext(err)
	return d.savedError
}

First clause is checked for errors, last clause is what's returned.

@sethbonnie
Copy link
Author

@seankhliao I don't see the similarity with #46655

check works on singular values and hooks into named return values, meaning the return is implicit.

abort? works on lists of values and has an explicit list of return values. One of the examples above also shows how abort? helps get rid of named return values (shown here for reference):

// Before
func (t *multiWriter) Write(p []byte) (n int, err error) {
	for _, w := range t.writers {
		n, err = w.Write(p)
		if err != nil {
			return
		}
		if n != len(p) {
			err = ErrShortWrite
			return
		}
	}
	return len(p), nil
}

// After
func (t *multiWriter) Write(p []byte) (int, error) {
	for _, w := range t.writers {
		abort? n, err := w.Write(p)
		if n != len(p) {
			return n, ErrShortWrite
		}
	}
	return len(p), nil
}

@yurivish
Copy link

yurivish commented Oct 17, 2023

I'm sympathetic to Ian's second point above:

This kind of approach means that reading the code focuses on error handling, because every line starts with abort?

I wonder whether this concern would be alleviated by using a postfix expression instead of the abort? prefix, so that the left-hand side of the code would not be cluttered with error-handling.

Rust underwent a similar transformation in its error handling functionality when it introduced a postfix question mark operator. Instead of saying try!(foo()) one could write foo()? to early-return with an error result.

This discussion also reminds me a bit of Jonathan Amsterdam's 2017 talk on Errors as Side Notes about formatting code to place all error-handling code to one side, so that the main-line control flow can be more easily understood:

image

@ianlancetaylor
Copy link
Contributor

Similar ideas have been proposed several times before.

Not sure what you mean by this. I'm aware of the history, but perhaps you mean it's not a technical issue?

What I mean is: we've rejected similar ideas in the past. I agree that as far as I know this exact idea has not been proposed before.

abort? d.value(rv); d.addErrorContext(err)

Thanks for the example. This is quite different from other Go language constructs.

@sethbonnie
Copy link
Author

Rust underwent a similar transformation in its error handling functionality when it introduced a postfix question mark operator. Instead of saying try!(foo()) one could write foo()? to early-return with an error result.

Rust's ? operating is interesting, but I think it only works because the Result type is a tagged union. The ? operator doesn't have to bother with figuring out what to return for the Ok value when you exit. Any time you return from a Go function there could be multiple return values and you need a way (and room) to specify the other values. You could always assume zero values, but that might be too limiting in some scenarios where partial results apply.

I think it almost has to be on the left-hand side if you are going to return multiple values.

@yurivish
Copy link

yurivish commented Oct 18, 2023

I was imagining that code currently written like this:

err := d.value(rv)
if err != nil {
	return d.addErrorContext(err)
}

could be written as something like this:

err := d.value(rv) returniferror d.addErrorContext(err)

where the semantics of returniferror are similar to those of abort? but with the keyword placed syntactically after the expression.

I am not actually proposing this since I don't think I have internalized the Go design sensibility quite yet, but I found it interesting to think about.

@apparentlymart
Copy link

I think a specific challenge with this particular genre of proposal is that there have been two different levels of disagreement interleaved in all of the previous discussions I've seen:

  1. Those who disagree with specific details of the proposal, such as exactly which new keywords/symbols to use, and what order they would go in.
  2. Those who disagree that eliminating the if statement and its indented block is even a desirable goal at all, regardless of the fine details of exactly how the proposed "shorthand" is shaped.

(Note: I am not arguing either of those points here. For the sake of this comment, I'm neutral on the specific proposal in this issue.)

I think some of the "this is similar to previous proposals" reaction might be thinking about it from the perspective of the second kind of objection: those who expressed that they don't think this sort of goal is even desirable see this proposal as equivalent to all of the others because all it's changed is some irrelevant (to them) details about where on the line each clause goes and what delimits them.

I think all proposals for shorthands to avoid writing a normal if statement seem doomed to this outcome: it's unclear exactly what the rejection of the previous proposals represents -- whether it's a rejection of form 1 or form 2 above, or something else entirely -- and so therefore it's unclear whether a new proposal like this one constitutes "new information" to justify revisiting an earlier decision.

Perhaps it would be helpful to have a broader discussion (in a different place than this one issue) about what parts of "the error handling problem" seem to have sufficient consensus for it to be worth proposing solutions, what parts the proposals group feels have been retreaded enough to rule as "decided", and what parts of the problem space remain murky and perhaps in need of further problem-space research.

@xiaokentrl
Copy link

go错误处理

@xiaokentrl
Copy link

  1. 类似的想法以前已经多次提出过。
  2. 这种方法意味着阅读代码侧重于错误处理,因为每一行都以 开头。这使得代码更难阅读。abort?
  3. 早些时候,您提到了返回带注释错误的代码,但我没有看到任何使用 .abort?

go错误处理

@ianlancetaylor
Copy link
Contributor

@apparentlymart Thanks for the comment but I don't entirely agree with you. I think there are objections to this specific proposal that go beyond "we don't need any syntax change."

  1. Every error checking line starts with abort?, which buries the lede.
  2. The proposed syntax for abort? returning an annotated error is visibly different from all other Go syntax.

I agree that it's very hard to satisfy everyone. But any syntactic change here would be the biggest change to the language other than generics. It would affect some 1% of all Go lines in existence. It really has to be an excellent change, not just adequate. And if we can't make it excellent, we shouldn't do it.

@apparentlymart
Copy link

I'm sorry that I was unclear. I didn't mean to say that there are no legitimate criticisms of this proposal in particular, and for what it's worth I agree with the criticisms you shared and didn't mention them in my comment only because I was trying to make a broader point and wanted to separate it from my feelings about this proposal.

My comment was in response to the debate above about whether this is or is not a duplicate of a previously-rejected proposal. And in particular, that it's hard to say whether this is "different enough" from the previous proposals to constitute new information. It certainly isn't identical to any previous proposal, but it shares many characteristics with previous rejected proposals; it's unclear if what it shares are reasons for the previous proposals being rejected or not.

With that said, I can see that my comment could be read as dismissing all of the above dissent as being in my category 2. That was not my intention, and I apologize.

@ianlancetaylor
Copy link
Contributor

Sorry for misunderstanding.

@leisure520
Copy link

i agree with you,it's good idea!!!

@psnilesh
Copy link

Every error checking line starts with abort?, which buries the lede.

I felt the same. Was abort? made a prefix to the statement to avoid grammar ambiguities ? Would it be better if we separated function invocation from error handling ?

E.g,

func getPlatformName() (string, error) {
   os, err := getOSName(); err?;
   arch, err := getCpuArch(); err?;
   return fmt.Sprintf("%s/%s", os, arch), nil;
}

where err? (which is just a regular statement) expands to

if err != nil {
   // Return zero values for all non-error arguments
   return "", err;
}

This may help remove clutter when simply bubbling up the error. If we want to add more context or wrap the error instead, use if err != nil like before. However, I'm not sure if the grammar can support syntax like above unambiguously.

P.S Please ignore it if this has been proposed & rejected before.

@doggedOwl
Copy link

doggedOwl commented Oct 20, 2023

This kind of approach means that reading the code focuses on error handling, because every line starts with abort?. That makes the code somewhat harder to read.

TBH we are already in that boat. In most functions that do something interesting in go ~60-70% of lines concern error handling. Why is one keyword at the start more distracting than having to mentally skip the majority of the code lines when you are trying to read the flow of the happy path?

Having said that, a postfix notation would certainly be more helpful.

@ianlancetaylor
Copy link
Contributor

@doggedOwl

Why is one keyword at the start more distracting than having to mentally skip the majority of the code lines when you are trying to read the flow of the happy path?

I don't that's quite the right question. Exactly because so many lines of Go handle errors (though in my measurements it's not quite as high as you suggest), we have to be very careful about changing it. It will affect all existing Go code, which is a lot of code. And we really only get one chance at making a change: we can't keep changing so much code. So the question is not "is this proposal a bit better?" It's "is this the best possible proposal? Are there any possible problems with it that we may regret later?"

@apparentlymart
Copy link

apparentlymart commented Oct 20, 2023

I do agree about it perhaps not being the most important question, but to try to answer it anyway:

In today's Go, the error handling and the "happy path" are typically separated onto separate lines, so that when scanning along the left edge of the code a sighted reader can (with some practice) mentally ignore the error handling boilerplate when interested in the happy path, or can pay attention to the one-level-indented blocks when interested in the error handling.

This possibility is also what motivates some parts of idiomatic Go style, such as preferring to put the error paths inside nested blocks and "happy path" code at the top-level, and using the if err := whatever; err != nil { form sparingly to avoid "hiding" the whatever behind the if keyword.

Putting the action being taken and the error handling on the same line effectively forces prioritizing one over the other, because only one thing can be first on a line. I think it's valid to debate whether the happy path and error handling should be combined onto a single line at all for this reason, but if you accept that as okay then it's also valid to debate whether the error handling or the happy path should appear first, since one will necessarily be harder to quickly scan than the other.

In previous proposals of this genre I think we've seen three broad variations:

  1. Keep the happy path and the error path separated on separate lines, but reduce the number of lines used by the error path, typically to only one additional line.

    For example:

    err := DoSomething()
    return fmt.Errorf("doing something: %w") if err != nil

    Other variations in this category have made that second error-handling line begin with something that's unique only to error handling -- rather than a normal statement like return here -- to preserve (and possibly enhance) the ability to visually scan the left edge to quickly filter in or out the error-handling lines depending on one's goal when reading.

  2. Combine both onto the same line and prioritize the happy path first.

    For example:

    err := DoSomething(); if err != nil return fmt.Errorf("doing something: %w", err)

    (Typically proposals of this form have also tried to simplify further, such as making the err symbol implicit rather than explicit, but I'm only talking about the relative positioning of the different parts of the code so I've intentionally left everything explicit here.)

  3. Combine both onto the same line and prioritize the error-handling aspect, as is the case for this proposal.

Given that each of these options makes a different compromise, it seems like these are material differences that contribute to making a particular proposal unique from others, rather than just details that could be figured out later after accepting the proposal.

Assuming that reducing the amount of code spent on error handling is a goal (which, as I was noting earlier, also seems to be a broader point of disagreement), it does still seem important to consider the fine details of how this changes the "texture" of the code.

I subconciously wrote the three categories above in my personal preference order; error-handling first seems to me like the worst option, because it would make almost all lines of code be dominated by their error handling. I most prefer the first category, although I must admit I've also become accustomed to how Go code is currently written and so I don't feel any particular urgency to change it. (I don't object to changing it on principle, but agree with Ian that if it's going to change at all then it should be a change that is very clearly better, due to the cost of making a large amount of code retroactively non-idiomatic.)

@sethbonnie
Copy link
Author

I think that's a helpful summarization @apparentlymart.

As pertains to having the an error handling keyword like abort? on the left-hand side, the reason for it is precisely that it occupies "prime real-estate" so to speak. It's a trade-off -- you are reducing 3-4 lines of code into a single line of code so you are effectively trading 2-3 extra lines for 6 characters at the front of the line. I don't think it would be fair to readers to both compress the code and make it look like regular "happy-path" code.

I think it's fair to assume that most programmers scan code for patterns rather than read code carefully line for line, at least initially.

I'd also like to push back a bit against the notion that errors don't belong on the left-hand side. I think errors that force a function to immediately return are very different from errors that can be handled in any manner.

if err != nil { return ... } is effectively another way of indicating that "anything below these lines of code is invalid if err is not nil." I don't think it's accurate to say you are burying the lead if the error is important enough to invalidate the rest of the code in the function. At that point, it is more important than the happy path which is why we currently spend 3 lines to make sure the error is not nil.

I obviously can't speak to whether or not this constitutes an excellent change, but I will say if there is a construct powerful enough to both check for errors and return from the function, it should not be hidden somewhere programmers aren't accustomed to scanning. It should be obvious that a function could potentially return at that point.

@doggedOwl
Copy link

doggedOwl commented Oct 20, 2023

@apparentlymart
there is a 4th option or abandoning the locality of error handling (in terms of line vicinity but still within the bounds of the function). (according to the lines of #32437) which would be my favorite
but in the context of preserving locality your example nr. 2 could be simplified with appropriate support into something like

DoSomething() or return fmt.Errorf("doing something: %w", err)
or
res := DoSomething() or return err

it still gives priority to the happy path and reduces the information overload on error path. I'm not sure if it something like this can be implemented purely as sugar and the compiler then adds the usual if err != nil that at this point is just noise. On those cases that need a different condition (different than is not nill) the current pattern can be used.

Anyway I think that bridging the divide between those who think the happy path should be more readable and those who think that happy path is/should be no different than the error path is almost impossible as the history of countless proposals shows.

In that light I don't agree that every proposal should be seen under the "is this the best possible proposal?" question.
Maybe I'm wrong but I don't see a technical way to solve what is a point of view difference.
So maybe the question is, should go insist on "one true way" of doing error handling or supporting both views by chosing a second augmenting way in addition the current one.

@findleyr
Copy link
Contributor

findleyr commented Nov 1, 2023

Per #63575 (comment), this is a rather large change in syntax that doesn't quite fit with the rest of the language. Additionally, the emoji voting is not in favor. Marking as likely decline and leaving open for four weeks for additional comments.

@PungYoung
Copy link

I think annotations are very convenient,

func do() error {
  var err error
  @error("some error")
  res , err := foo()

  @error("some error")
  res , err2 := foo2()
}

or unified processing

func do() error {
  @error("some error")
  var err error
  
  res , err = foo() // if err != nil,directly return without executing subsequent code
 
  res , err = foo2()
}

@adonovan
Copy link
Member

adonovan commented Dec 6, 2023

No change in consensus.

@adonovan adonovan closed this as completed Dec 6, 2023
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