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: go2 - Expand check functionality with scoped handle blocks #33002

Open
mathieudevos opened this issue Jul 9, 2019 · 8 comments

Comments

@mathieudevos
Copy link

commented Jul 9, 2019

Based on the current proposals and counter proposals I would propose a revision of the check/handle proposal. Proposals can be found here: Go2 Error Handling Feedback

My proposal is to add the option to have fully scoped blocks as well as requiring the handle to scoped, and enforce the variable assignment already used in golang. Finally the handle would move to the bottom, to maintain the logic of top to bottom programming logic (which the original check/handle breaks).

A simple example could be the following:

check {
	foo := FunctionThatReturnsErrorAsLast() // returns var, err
	bar := NoErrorFunction() // returns only value, no error
	AnotherErrorFunction() // returns err
} handle err {
	// you have the error, so you MUST consume it
	return err
}

Alternatively you can also write the following:

foo := check FunctionThatReturnsErrorAsLast() // returns var, err
bar := NoErrorFunction() // returns only value, no error
check AnotherErrorFunction() // returns err
handle _ {
	// err != nil, but no err assignment, as such no consumption
	fmt.Println("Saying hi from the handle")
}

There can be a discussion about omitting the handle part which would automatically translate to the following: handle err { return err; } - however, I prefer to be a bit more explicit and demand that every check has at least one handle assigned to it.

If we take a look at a common used example below:

func CopyFile(src, dst string) error {
	r, err := os.Open(src)
	if err != nil {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}
	defer r.Close()

	w, err := os.Create(dst)
	if err != nil {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	if _, err := io.Copy(w, r); err != nil {
		w.Close()
		os.Remove(dst)
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	if err := w.Close(); err != nil {
		os.Remove(dst)
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}
}

This can now be rewritten as such:

func CopyFile(src, dst string) error {
	r := check os.Open(src)
	defer r.Close()

	w := check os.Create(dst)

	check {
		io.Copy(w, r)
		w.Close()
	} handle _ {
		w.Close()
		os.Remove(dst)
	}
	handle err {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}
	return nil
}

or alternatively, depending on your preference:

func CopyFile(src, dst string) error {
	check {
		r := os.Open(src)
		defer r.Close()

		w := os.Create(dst)

		check {
			io.Copy(w, r)
		} handle _ {
			w.Close()
			os.Remove(dst) // (only if a check fails)
		}
		check {
			w.Close()
		} handle _ {
			os.Remove(dst) // (only if a check fails)
		}

		return nil
	} handle err {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}
}

or a mix of oldskool + proposal:

func CopyFile(src, dst string) error {
	r := check os.Open(src)
	defer r.Close()

	w := check os.Create(dst)

	err := io.Copy(w, r)
	if err != nil {
		w.Close()
		os.Remove(dst) // (only if a check fails)
	}
	if err := w.Close(); err != nil {
		os.Remove(dst)
	}
	handle err {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}
	return nil
}

This makes generic error handling (non-lazy as opposed to: if err != nil { return err; }) a lot simpler. Furthermore it's clean for the people reading the code since this is perfectly scoped.

As such the requirements are the following:

  • Every check must have at least 1 handle below it
  • check keyword is followed by either {, a function that returns an error as last parameter, or a variable of type error
  • handle keyword is followed by a variable name, or _ for unused variable name
  • Checked errors (through the use of check) go STRAIGHT to the next handle block, in order of top to bottom, or "bubble up" in the scope
  • handle always picks up an error type, it is either straight jumped to by the check as long as it is within its scope or an error can just continue down there.

The goal is to make the codebase repeat itself less while maintaining a clear flow of function handling, while giving the developers the possibility to handle the errors in whichever way they want.

The current try proposal just enables lazy error handling and without other additions to the language it provides a single way to attempt to fix a complex issue, while taking away the developers' choice on how to handle the error(s).


Discuss away, if you vote, please leave a reasoning as to why.

@gopherbot gopherbot added this to the Proposal milestone Jul 9, 2019

@gopherbot gopherbot added the Proposal label Jul 9, 2019

@percybolmer

This comment has been minimized.

Copy link

commented Jul 9, 2019

This just seems like a mimic of java error handeling with try catch, but just renaming it. I think this has been covered before, not this exact proposal, but try catch.

@mathieudevos

This comment has been minimized.

Copy link
Author

commented Jul 9, 2019

This just seems like a mimic of java error handeling with try catch, but just renaming it. I think this has been covered before, not this exact proposal, but try catch.

It can be seen as such, however it also allows for just adding the check keyword to multiple functions and handling them in a similar way.

Finally, I do prefer the scoping of a try / catch in other languages, as it is clear what is happening.

  • try proposal only enables lazy error handling, nothing proper to be done (just look at the example of copy)
  • check is a mess to follow the logic in, going from bottom to top, without the ability to scope it
@ianlancetaylor

This comment has been minimized.

Copy link
Contributor

commented Jul 9, 2019

When using a check block, what kind of statements are permitted? I didn't see a clear statement of that, though maybe I missed it. In particular it would be unfortunate if adding or removing an error result causes a silent change in behavior at the call site inside a check block, which may be far away.

@mathieudevos

This comment has been minimized.

Copy link
Author

commented Jul 9, 2019

When using a check block, what kind of statements are permitted? I didn't see a clear statement of that, though maybe I missed it. In particular it would be unfortunate if adding or removing an error result causes a silent change in behavior at the call site inside a check block, which may be far away.

The idea would be that a check block would be a scope where all the functions are checked.
If they return as last parameter an error which is not handled (assigned) it would do the check -> handle functionality.

Otherwise all normal operations are allowed, however, to stay consistent with the golang spec, everything declared within the check { ... } block should be scope specific. Newly-declared variables cannot be used outside.


I added more functions in the initial examples (first 2) to showcase how functions would be handled.

@ianlancetaylor

This comment has been minimized.

Copy link
Contributor

commented Jul 9, 2019

That does suggest that adding an error result to an existing function will cause a silent behavior change. If the check block calls F, and F does not return an error, then the call will occur and nothing else will happen. If we change F to add an error result, then the call will silently change to jump to the handler. While in many cases that will be correct, having a change in one part of a large program cause a silent change in a far away part of the program is problematic.

@mathieudevos

This comment has been minimized.

Copy link
Author

commented Jul 10, 2019

That does suggest that adding an error result to an existing function will cause a silent behavior change. If the check block calls F, and F does not return an error, then the call will occur and nothing else will happen. If we change F to add an error result, then the call will silently change to jump to the handler. While in many cases that will be correct, having a change in one part of a large program cause a silent change in a far away part of the program is problematic.

Would you suggest to require that all functions in a check block send an error at the end?

Alternatively we can suggest that every function inside a check block still requires the check in front of it.
If a person then changes the signature of NoErrorFunction() it will fail to compile as it now returns an error as well.

check {
	foo := check FunctionThatReturnsErrorAsLast() // returns var, err
	bar := NoErrorFunction() // returns only value, no error
	check AnotherErrorFunction() // returns err
} handle err {
	// you have the error, so you MUST consume it
	return err
}

Good catch on the silent behavior.

@mvndaai

This comment has been minimized.

Copy link

commented Jul 18, 2019

My main concern with this is that it disrupts the ability of happy path not being indented. Currently, if you don't use else understanding a function's happy path just means ignoring all indented code.

My other concern is having multiple statements in one check block. Some developers would probably put the entirety of a function within a check block. That would make the code look simpler but changes the philosophy of dealing with errors when they happen, which is my favorite part of go. It makes my code feel safe.

@DongchengWang

This comment has been minimized.

Copy link

commented Jul 20, 2019

I don't like adding more than one keyword.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
6 participants
You can’t perform that action at this time.