Closed lainio closed 2 months ago
Related Issues and Documentation
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
This appears to be a restatement of the check proposal, without being able to handle errors, and doesn't address any of the issues that led to check/handle being declined.
It seems to me it's not so much the check proposal, as the try proposal in #32437. This appears to be nearly identical to that proposal, but it doesn't address the reasons that that proposal was declined. In fact, this proposal even calls that out:
The most crucial difference is that even the
try
keyword can execute an implicit control flow branch
Yes: that is the one of the main reasons that #32437 was declined.
@seankhliao, thank you so much for your prompt reply. As @ianlancetaylor corrected, thank you so much; the proposal aligns more with #32437. I have read that check
wasn't the problem but the handle
. Of course, as so many others have mentioned, we community members must gather our information from scratch. It's hard to know what's official and what's not.
Unfortunately, your replies didn't address our proposal as a whole. The chapter that spokes about implicit control flows states, among other things:
The most crucial difference is that even the try keyword can execute an implicit control flow branch, i.e., error propagation, it’s no different than the language’s current implicit control-flows. We use a keyword with well-known and straightforward semantics, allowing us to offer clear (and orthogonal) error propagation control flow.
As we well know, the language already has many implicit control-flow switches. In this proposal, we have done everything possible to ensure minimal cognitive load (disturbance).
This proposal is unique because it helps only that part of the error handling that actually[1] needs help. The proposal says:
We leave the current error value-based handling as it is now.
[1] Actually, most Go repos use error propagation for most error handling cases: >60%. (I was hoping that this starts to raise eyebrows.)
There is a chapter in FAQ about differences to try()
macro proposal:
Q: Why not try() macro? A: Simple, try is not related to error values. It is related to functions. The try is an operator whose operand is a function.
If you have read the language spec part, you will notice that it makes a lot of sense. Of course, you cannot do these:
check err
try(err)
But the orthogonality in the Go language specs is undeniable with this proposal.
And this is from the try()
macro's proposal specification:
Yet, the context-sensitivity of try was considered fraught: For instance, the behavior of a function containing try calls could change silently (from possibly panicking to not panicking, and vice versa) if an error result was added or removed from the signature. This seemed too dangerous a property.
In our proposal, we reached the opposite conclusion. Context sensitivity has been proven to be very valuable in practice and the opposite of dangerous. That's also a meaningful difference between our proposal and #32437.
We are sorry that our proposal is so long. We still hope that someone from the Go team reads it all. Since 2018, we have tried to gather all the new knowledge.
Thank you for your valuable work.
Thanks for all the work you've put into this proposal. Still, this proposal is very similar to the rejected try proposal. We can't spend our time reconsidering rejected ideas.
Thanks.
I don't speak for our proposal anymore, but generally.
IMHO, we must reconsider rejected ideas at some point. We have made mistakes, received new information, made false presumptions, etc.
Yes: if there is relevant new information, then we will reconsider an earlier decision.
Go2 proposal:
try
keyword forTry Calls
Would you consider yourself a Go programmer?
Experienced.
What other languages do you have experience with?
Pro in C, C++, ASM (mainly x86, but others), Scala, C#, F#, Java, Object-C, Swift, Dart, etc.
Would this make Go easier to learn, and why?
Learning for Beginners
It depends on the individual, but it would make learning and adapting Go easier. Not only by offering a familiar mechanism but by bringing something that's missing—error propagation—would get you started faster.
Getting Proficient, Become a Real Expert
As highlighted by Daniel Kahneman in Thinking, Fast and Slow, expert programmers (like chess masters) often rely on System 1 thinking—intuitive and fast—when solving problems. Go's error-handling pattern, characterized by repetitive
if
statements, introduces unnecessary noise, disrupting this intuitive flow and hindering experienced programmers from leveraging their full capabilities. Furthermore, the Dreyfus model of skill acquisition shows that experts thrive on absorbed awareness—being deeply immersed in their work.Supertalented and visually competent programmers who excel at skimming and absorbing large codebases quickly, also suffer from the excessive cognitive burden imposed by
if err != nil
. The cluttered code obstructs their ability to utilize their visual strengths, ultimately limiting their efficiency and effectiveness.Neurodivergent Programmers
Neurodivergent individuals often face increased cognitive load with Go's current error-handling pattern. The repetitive and verbose error checking can be overwhelming, leading to slower onboarding and skill acquisition. That hampers their productivity and affects their engagement and satisfaction with the language.
Learning of Go will be more accessible with the proposal:
Has this idea, or one like it, been proposed before?
Several are somehow similar; these are the most important ones:
The proposal we present here needs features some might consider necessary for error handling. Those can be added later to the language, the standard library, or through 3rd party packages. We mention this one as an example:
How does this proposal differ?
The most crucial difference is that even the
try
keyword can execute an implicit control flow branch, i.e., error propagation, it's not different than the language's current implicit control-flows. We are using a keyword with well-known and straightforward semantics, which allows us to offer clear (and orthogonal) error propagation control flow.Who does this proposal help, and why?
Who it helps?
Before we answer the question, let's leave a couple of counter-questions lingering in our minds:
This proposal helps:
In summary, it helps us to make better Go programs faster, and it helps everyone to maintain those programs to their full potential.
Do you still need convincing?
Let's use a real-world example:
Test yourself how important small things can be for skimmability:
Everybody reacts differently.—these things matter.
Why does it help?
Go's CSP is the root cause of why it has error values. Values are data, and data is something we can quickly move through channels. How about if errors wouldn't be simple values to be processed and moved around but something you must first catch or something else? Please look at the code block below to see how easy it is to use
errCh
.Errors are values, and it's a good thing, but we have many function calls, some of which need help. We must offer a decent way to call functions that return errors—we need error propagation.
How much would it help? Do you have any figures?
We have measured a few well-known Go projects by running a script to get statistics. It's much easier to understand how important the issue is when we know the math behind it.
We have manually checked the figures of the following two repos are correct:
if (!=\|==) nil
variations:^\s*if.*err [!=]= nil
^\s*if.*err [!=]= nil {\n.*return.*err$
^\s*if.*err [!=]= nil {\n.*return.*\.(Fatal\|Errorf\|New).*err[\)]*$
^\s*if.*err != nil {\n.*panic\(.*err[\)]*$
^\s*(if\|case\|switch).*err == (?!nil).*{
^\s*(if\|case\|switch).*err == .*EOF.*{
^\s*(if\|case\|switch).*errors\.Is\(
^\s*(if\|case\|switch).*errors\.As\(
^\s*(if\|case\|switch).*errors\.Is\(.*EOF
^\s*panic\(
^.*recover\(\)
^\s*\btry\b
if (!=\|==) nil
variations:^\s*if.*err [!=]= nil
^\s*if.*err [!=]= nil {\n.*return.*err$
^\s*if.*err [!=]= nil {\n.*return.*\.(Fatal\|Errorf\|New).*err[\)]*$
^\s*if.*err != nil {\n.*panic\(.*err[\)]*$
^\s*(if\|case\|switch).*err == (?!nil).*{
^\s*(if\|case\|switch).*err == .*EOF.*{
^\s*(if\|case\|switch).*errors\.Is\(
^\s*(if\|case\|switch).*errors\.As\(
^\s*(if\|case\|switch).*errors\.Is\(.*EOF
^\s*panic\(
^.*recover\(\)
^\s*\btry\b
Clarifications:
Try Call
instead ofif err != nil { return ..., err }
; we could easily refactor (simplify) them with a script or a tool.if err != nil ...
withTry Call
if it clarifies error messages, or we could use deferred error annotation helpers. Also, this can be semi-automated.The results are positive for this proposal. Quite interestingly, K8s and Cockroach got almost the same percentage of the cases that would be easily (automatically) transformed to use
try
based error propagation. More than 60% of the error-handling cases would be able to be written withtry calls
in K8s. Note that we found a Go repo Dolt DB that's score is 80% for automatic error propagation!K8s and Cockroach ~58% interested us so much that we decided to check other famous Go repos like hugo, and it had the exact figure: 56.40% + 8.12% = 64.52%. According to Go's sources, 34% use error propagation, but 24% use
panic
to transport errors. Both figures are surprisingly high, but thepanic
score is explained by being a standard library. However, thepanic
usage is much higher than community police forces claim.Shocking fact is that how little all the repos do the actual error-handling, i.e., make programmatic decisions according to the error values.
If you compare the propagation figures to figures of
errors.Is
orerrors.As
, you should wonder what all the fuss is about error annotation.The Emperor's New Clothes
We ran statistics through our
mod
directory, and 56% of all (2003929) error-handling cases are currently propagations, i.e., plainif err != nil { return err }
. But the error-handling happens <1% of cases, and most areio.EOF
checks. Weird? No, not actually.It seems that the community has misunderstood the Go's error-handling proverb:
'... handle them gracefully'. If you listen to every word Rob Pike says in the error-handling part, he says 'Think about whether you should be doing something with that error'.
Go community has invented this 'you must add context to your errors or you aren't handling them'. There are several blog posts that have started to notice that something is wrong with this rule and its end results:
Does Golang have the best error handling in the world?
It would be fascinating to study what Go's error-handling policies and idioms have achieved. These figures make you wonder if Emperor Go is naked after all. Is the situation clearly that much better than in other languages?
What is the proposed change?
A new
try
keyword (operator) will be added to the language. It's similar a keyword like in Swift and in Zig.Example of the language spec
We propose a new expression,
Try Calls
to the language specification. TheTry Calls
will be an extension to Calls in the specification. We assume that thetry
keyword will be a new operator for functions, e.g., receive<-
operator is for channels.Try Call
Expressionf
to a new temporary (inline) functionf'
, and theninvokes a function call for the temporary function
f'
that will invoke the original functionf
and evaluates to the result of thef
call with the final error result removed.More formally:
turns into
The
f
must be a function or method call whose last result parameter is a value of typeerror
. Thetry
with a function whose last result parameter is not a value of typeerror
leads to a compile-time error.The
f
evaluates in a function or method call, producing n+1 result values of typesT1
,T2
, ...Tn
, anderror
for the last value. If the functionf
evaluates to a single value (n is 0), that value must be of typeerror
andtry f()
returns no result and can only appear as an expression statement.The
try call
can be used in two different types of code context depending on the result parameters of the enclosing function. If a compiler generates the enclosing function, it's treated like it has no result parameters.The usage categories are:
When the
try call
is used inside a function with at least one result parameter where the last result is of typeerror
the following happens:Invoking
try
with a function callf()
as in (pseudo-code)The line turns into the following (in-lined) code:
In other words, if the last value produced by the
f()
, of typeerror
, is nil, thetry f()
simply returns the first n values, with the final nil error stripped. If the last value produced by thef()
is not nil, the enclosing function's error result variable (called_err
in the pseudo-code above, but it may have any other name or be unnamed) is set to that non-nil error value and the enclosing function returns. If the enclosing function declares other named result parameters, those result parameters keep whatever value they have. If the function declares other unnamed result parameters, they assume their corresponding zero values (which is the same as keeping the the value they already have).When the
try call
is used inside a function whose last result parameter is not of typeerror
, the following happens:Invoking
try
with a function callf()
as in (pseudo-code)The line turns into the following (in-lined) code:
This version works similarly to the previous category. Only if the last value produced by the
f()
is not nil, the code panics with the currenterror
value. When panicking, enclosing function's result parameters are handled the same as in the previous category.The
try call
is an expression, and it can be used for all variable initializations, when previous category rules are fulfilled.The example:
Is this change backward compatible?
We did a few searches and found these, which confirms that
try
is used as a name, but also that it's easy to fix with tools:try
named variable in tests.try
named variables.When the Go version roll-out is done similarly as with the latest features: first, as an experimental feature and then official, it gives time and tools to prepare repos.
Show example code before and after the change.
Examples when enclosing function returns error
Example 1 - error values needed
Before:
After:
if err != nil { return err }
would wanted to replace it can be done. See the Orthogonality? chapter.Example 2 - mixed error propagation
Before
After:
Example 3 - Classic copy file
Before:
After:
cp
command in UnixExample 4 from the one possible future
Not part of the proposal
Optional - possibilities in the future. (Existing of
_err
would help implementation ofrecovererr()
)errdefer
is like the currentdefer
, but the deferred function is called only if the current's function's error return parameter is not nil and the deferred functions error return value replaces the current error return value of the function who deferred.recovererr()
builtin function like the currentrecover()
, but instead of returning possible panics,recovererr
returns possible error return value of the function wheredefer
orerrdefer
was used. The same rules apply asrecover
; it works only in deferred functions.recovererr
always returns an error if it's called inside an errdeferred function.defer
-recover
rules and are orthogonal in that way, too.Try Calls
offer a path that is easy to extend and still follow Go's principlesExamples when enclosing function does not return error
Example 5 usage in the
main
functionIn playground use, errors will trigger panics, and we will not stop them, which is commonly OK in playgrounds.
To stop panics and have proper error messages, add one line from some helper package:
Example 6 usage in tests
The panic functionality allows
Try Calls
to work as-is with the current test harness.When using our prototype, this has been a used feature, especially at each project's start.
What is the cost of this proposal?
How many tools would be affected?
All of them.
What is the compile time cost?
Marginally slower or the same. There are fewer code lines to process, but the compiler must build
Try Calls
. Less code but more to do; maybe it's ±0. It depends on the project. (Some information might be available from languages with similar keywords and functionality.)What is the run time cost?
No cost on error propagation
The
Try Calls
are built during the compile time, i.e., inline wrapped. If the functionf
intry f()
stays in-lined similarly as without thetry
then there won't be any performance penalty.In cases where
defer
is used for error handling, the error control flow is a little slower, but the happy path inside thef
is the same. During the prototype use, the main reason some functions were slower was the lack of inlining because of the use ofdefer
. That might be one reason why a newerrdefer
would be reasonable. It would bring a smaller optimization context. However, let's remember we are solving only error propagation with this proposal.All in all,
Try Calls
would open new opportunities to offer error and stack traces to programmers. We have tested these with the prototype with high success.Can you describe a possible implementation?
Please see the example of the language spec chapter.
Prototype
The OSS package err2 implements similar functionality through its
try.To()
functions. The package also shows how to add declarative error handlers withdefer
to your code.Learnings from the usage of the prototype in several Go projects which have >100 KLOC:
err2
package has taught us that nestedtry
is not desired or used. Searching the code base wheretry.ToX
has been used only two (2) times in the nested way wheretry.ToX
has been used 1144 times.try
with a just variable (try err
) is rarely used or needed. It was used 15 times, and the total was 1144.if err
statements were still used , especially with the channels. There were 80 error handling relatedif
statements.How would the language spec change?
Please see the example of the language spec chapter. In addition to that, we presume that
try
is a low precedence operator for error-returning function calls.Our previous definition holds when
try
is an operator andf()
is an operand. The operand thattry
operates must follow the conditions we defined inTry Call
chapter.Because the
try
'operator' has low precedence, we would need parenthesis to get the previous code line to work:In summary, the
try
operator must build a new expression from the given operand (a function call) before aTry Call
can be made, i.e., if you want to do nested calls, you have to use parenthesis. We have tested this in Swift and in Zig and they work the same.We think this is better:
Orthogonality?
As far as we know, this is orthogonal with the current language features.
try
.Try Call
.Try Call
and annotate errors in deferred helpers. There are OSS options, and the standard library will get its own soon after proposal ratification.For orthogonality reasons, we have decided that
try
is the operator, the operand is a functionf()
, and the result is a function call. You cannot writetry err
. It will only compile with help. However, help is easy to arrange:Nevertheless, we think that this clarity and simplicity (especially in the language spec) is a good thing.
try
is meant to be used with the function returning error value ◻︎Is the goal of this change a performance improvement?
No, not at the moment, but it might open new doors.
Does this affect error handling?
Very much.
How does this differ from previous error handling proposals?
We don't try to solve something that's not broken, i.e., error-value-based handling stays as it is now. According to our statistics, we bring minimalistic and orthogonal ways to propagate errors in more than half of the current error handling cases. .
This proposal is as simple and minimal as it can be. By using a keyword, we keep it semantically as near as possible with the current error value-based handling.
↓ Go-intuitive mapping ↑
Our experience with other languages with similar constructs confirms this. In our prototype, we offered a way to add a handler function to
try
, but no one used it.Is this about generics?
No.
FAQ
Q: Why is this proposal so long?
A: We think many things have drifted in the wrong direction in Go's error handling. But at the same time, so many new results and innovations have come to the surface that we thought it was time to
try
.Q: Why not
try()
macro?A: Simple,
try
is not related to error values. It is related to functions. Thetry
is an operator whose operand is a function.We also think that readability (skimmability) is better, and error handling is a serious matter, let's use the convention it deserves.
Vs.
The difference is slight but meaningful for some of us. For instance, the
try
as a separate keyword aligns well withdefer
andgo
even though these are statements. How convenient that they both start an implicit and separated control flow.Last but not least, we have learned that, e.g., dyslexic persons prefer wider stance of the text.
Q: Why not
guard
ormust
?A: We think both
guard
andmust
have different general semantics. For example, Swift'sguard
deals with boolean expressions and commonly needs anelse
-statement. Andmust
is more of a Go idiom for naming panic helpers. (These are just subjective opinions, not facts!)Q: Does this solve the shadowing problem?
A: Not necessarily, but it moves us towards the goal. We still need to assign the error value to a variable in cases where we really need to handle them, i.e., make an algorithmic decision according to the error value.
However, our experience in practice is that we'd not need to show anymore, because the required amount of the error variables per scope will go down drastically.
More information about the subject in handle/check spec and closing the issue 377.
Q: How about test coverage?
A: This can be handled similarly as other languages have done. Maybe some instrumentation has to be used.
Q: How about debugging?
A: Debugging will get new tools, e.g., you can ask the debugger to break if an error occurs in any
Try Call
automatically. When you set the breakpoint of the line, including thetry
operator, you can select which control flow branch it breaks. There are limitless options here, usually when a DRY violation is fixed. Debugger or Go runtime could offer a try-trap to where you could add, e.g., logging during a debugging session.Zig language has built error tracing (not entirely around
try
), but similarly in Go, we could start to usetry
as a source for automatic error traces, which would be a great help in debugging.