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: try keyword for Try Calls #68391

Closed
lainio opened this issue Jul 11, 2024 · 7 comments
Closed

proposal: Go 2: try keyword for Try Calls #68391

lainio opened this issue Jul 11, 2024 · 7 comments
Labels
error-handling Language & library change proposals that are about error handling. LanguageChange Suggested changes to the Go language Proposal v2 An incompatible library change
Milestone

Comments

@lainio
Copy link

lainio commented Jul 11, 2024

Go2 proposal: try keyword for Try 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:

  • Because it's easier to start hacking with the language: faster feedback loops.
  • More skimmable code helps you to understand and learn how to use the language
    faster.
  • Similarities with other languages are always good when learning a new one.
  • Easier to refactor and maintain is undeniably essential in any phase of
    learning a new programming language—how to do things better.

Has this idea, or one like it, been proposed before?

Several are somehow similar; these are the most important ones:

  1. handle/check spec
  2. Try-proposal spec
  3. guard & must -proposal

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:

  1. proposal: errors/errd: helper package for deferred error handlers
How does this proposal differ?
  1. We leave the current error value-based handling as it is now.
  2. We bring a new expression to help calls to error-returning functions and
    propagate errors if they happen.
  3. The new expression is just another version to make a function call. It works
    in all the same contexts where function calls have been working; only the
    call behavior changes accordingly.

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:

Is Go's error-handling strategy inadvertently discriminating against different
cognitive styles and preferences? Are we dismissing the needs of neurodivergent programmers and those who excel through visual and intuitive coding methods?

This proposal helps:

  1. Those who cannot now use their full potential to skim Go code.
  2. Those who want to be able to refactor Go code quickly. See these
    examples
    why the current
    error handling makes it difficult.
  3. Everyone who is using some Incremental Hacking
    Cycle
    ,
    TDD, etc.
  4. Polyglot developers.
  5. Everyone who has made a copy and past mistakes with their error annotations
    in Go.
  6. Everyone who has suffered the Go program's error message stuttering.

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:

"I find those if-blocks degrade readability significantly. When I read code I
haven't written, I almost always want to see what it does in the happy path. I
usually don't care how an error is handled. Having those blocks everywhere not
only makes things more difficult for me to read, but it might hide proper if
blocks
since they might be a single tree in that forest" — Anonymous,
Reddit

(bold text annotations made by proposal author)

  1. the happy path: That's the real problem. It's both subjective per person
    but it's a verified and generally recognized way of controlling flows that should be
    handled.
  2. don't care how an error is handled: This is a perfect example of how things
    could be twisted. Of course, it doesn't mean absolutely or always. It's
    when we are skimming the code, maybe the first time.
  3. more difficult for me to read: We picked this here that maybe finally
    some empathy and inclusivity would come around in the Go community. The
    person behind the comment says: 'for me.' The code full of repetitive DRY
    violations doesn't bother some, but others may suffer greatly.
  4. hide proper if blocks: We have suffered dramatically
    with legacy Go code. You cannot find those critical decision points from Go
    code as fast as you are used to.

Test yourself how important small things can be for skimmability:

  • set your tab size to 1
  • remove syntax highlighting from your tools

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.

errCh := make(chan error)
go func() {
     data, err := os.ReadFile(kubeconfigPath)
     t.Logf("read %d, err=%v\n", len(data), err)
     ctx := ktesting.Init(t)
     errCh <- run(ctx, s) // Look me mama now!
}()

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.

  • The executed script
    has been:
    ag --ignore '*_test.go' -c -G '\.go$' --no-filename --no-group '%s' | jq -s 'add'
    • We have skipped test files because we're more interested in how actual algorithms
      are structured in this study.
    • The script can have blind spots and corner cases. However, we have manually
      tested the figures from the repos presented here.
    • Our script is conservative. For example, if code has a comment line after the
      if-statement but it still just propagates the error, we don't count it.
    • If you are interested in these edge cases, use the script's
      debugging capabilities; the download is below.
    • Play with yourself: full bash-script as gist

We have manually checked the figures of the following two repos are correct:

kubernetes

Error Handling Subject RegEx Count % of 1st
All if (!=|==) nil variations: ^\s*if.*err [!=]= nil 46073 100.00%
Auto Propagation: ^\s*if.*err [!=]= nil {\n.*return.*err$ 26915 58.41%
Easy Propagation: ^\s*if.*err [!=]= nil {\n.*return.*\.(Fatal|Errorf|New).*err[\)]*$ 3369 7.31%
Panic Propagation: ^\s*if.*err != nil {\n.*panic\(.*err[\)]*$ 505 1.09%
Plain Sentinels: ^\s*(if|case|switch).*err == (?!nil).*{ 313 .67%
EOF Checks: ^\s*(if|case|switch).*err == .*EOF.*{ 124 .26%
If errors.Is: ^\s*(if|case|switch).*errors\.Is\( 261 .56%
If errors.As: ^\s*(if|case|switch).*errors\.As\( 65 .14%
If errors.Is io.EOF: ^\s*(if|case|switch).*errors\.Is\(.*EOF 27 .05%
panic calls: ^\s*panic\( 2604 5.65%
recover calls: ^.*recover\(\) 121 .26%
try keyword: ^\s*\btry\b 1 0%

cockroach

Error Handling Subject RegEx Count % of 1st
All if (!=|==) nil variations: ^\s*if.*err [!=]= nil 29746 100.00%
Auto Propagation: ^\s*if.*err [!=]= nil {\n.*return.*err$ 17389 58.45%
Easy Propagation: ^\s*if.*err [!=]= nil {\n.*return.*\.(Fatal|Errorf|New).*err[\)]*$ 292 .98%
Panic Propagation: ^\s*if.*err != nil {\n.*panic\(.*err[\)]*$ 643 2.16%
Plain Sentinels: ^\s*(if|case|switch).*err == (?!nil).*{ 79 .26%
EOF Checks: ^\s*(if|case|switch).*err == .*EOF.*{ 74 .24%
If errors.Is: ^\s*(if|case|switch).*errors\.Is\( 357 1.20%
If errors.As: ^\s*(if|case|switch).*errors\.As\( 138 .46%
If errors.Is io.EOF: ^\s*(if|case|switch).*errors\.Is\(.*EOF 15 .05%
panic calls: ^\s*panic\( 4202 14.12%
recover calls: ^.*recover\(\) 96 .32%
try keyword: ^\s*\btry\b 2 0%

Clarifications:

  • Auto Propagation means that in a repo, we could have used Try Call instead
    of if err != nil { return ..., err }; we could easily refactor (simplify) them
    with a script or a tool.
  • Easy Propagation means that in a repo, we could replace if err != nil ...
    with Try 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 with try 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 the panic score is explained by being a standard
library. However, the panic 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 or
errors.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., plain
if err != nil { return err }. But the error-handling happens <1% of
cases, and most are io.EOF checks. Weird? No, not actually.

It seems that the community has misunderstood the Go's error-handling
proverb
:

Don't just check errors, handle them gracefully.

'... 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:

failed to generate a random document ID: failed to generate random bytes: unexpected EOF`

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.

This section of the proposal has used the following specifications as a source
and copy&paste some parts of them directly:

  1. handle/check spec
  2. Try-proposal spec
Example of the language spec

We propose a new expression, Try Calls
to the language specification. The Try Calls will be an extension to
Calls in the specification. We assume that the
try keyword will be a new operator for functions, e.g., receive <-
operator is for channels.

Try Call Expression
  1. wraps a function f to a new temporary (inline) function f', and then

  2. invokes a function call for the temporary function f' that will invoke the
    original function f and evaluates to the result of the f call with the
    final error result removed.

    More formally:

    try f(a1, a2, … an) // where F(a1 T1, a2 T2, … an Tn) (RT1, RT2, …, RTn, error)
    

    turns into

    f'(f, a1, a2, … an) // where F'(f F, a1 T1, a2 T2, … an Tn) (RT1, RT2, …, RTn)
    

The f must be a function or method call whose last result parameter is a value
of type error. The try with a function whose last result parameter is not a
value of type error leads to a compile-time error.

The f evaluates in a function or method call, producing n+1 result values of
types T1, T2, ... Tn, and error for the last value. If the function f
evaluates to a single value (n is 0), that value must be of type error and
try 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:

  1. When the try call is used inside a function with at least one result
    parameter where the last result is of type error the following happens:

    Invoking try with a function call f() as in (pseudo-code)

    func g() (T1, T2, …, error) {
         x1, x2, … xn = try f() // the line
    }

    The line turns into the following (in-lined) code:

    t1, … tn, te := f() // t1, … tn, te are local (invisible) temporaries
    if te != nil {
         _err = te      // assign te to the error result parameter
         return         // return from enclosing function
    }
    x1, … xn = t1, … tn // assignment only if there was no error

    In other words, if the last value produced by the f(), of type error, is
    nil, the try f() simply returns the first n values, with the final nil
    error stripped. If the last value produced by the f() 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).

  2. When the try call is used inside a function whose last result parameter
    is not of type error, the following happens:

    Invoking try with a function call f() as in (pseudo-code)

    func g() {
         x1, x2, … xn = try f() // the line
    }

    The line turns into the following (in-lined) code:

    t1, … tn, te := f() // t1, … tn, te are local (invisible) temporaries
    if te != nil {
         panic(te)      // transport error 'te' with panic
    }
    x1, … xn = t1, … tn // assignment only if there was no error

    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 current
    error 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:

package sample
var (
defInstance = &Instance{
     Counter: 0,
     AAGUID:  try uuid.Parse("12c....8-..af-4..d-b..f-f.....1a...1"),
     Origin:  try url.Parse(Origin),
}
Origin = "http://localhost"
)

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:

  • K8s has a try named variable in tests.
  • Cockroach has similarly a couple of try named variables.
  • Also, Hugo has a couple of 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:

err = m.Up()
if errors.Is(err, migrate.ErrNoChange) {
     glog.Info("no new migrations, skipping db modifications")
} else {
     if err != nil {
     ...
}

After:

err = m.Up()
if errors.Is(err, migrate.ErrNoChange) {
     glog.Info("no new migrations, skipping db modifications")
} else {
     if err != nil {
     ...
}
  • The error value is needed to make decisions; no code changes.
  • If if err != nil { return err } would wanted to replace it can be done. See
    the Orthogonality? chapter.
Example 2 - mixed error propagation

Before

func tarAddFS(w *tar.Writer, fsys fs.FS) error {
	return fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		info, err := d.Info()
		if err != nil {
			return err
		}
		h, err := tar.FileInfoHeader(info, "")
		if err != nil {
			return err
		}
		h.Name = name
		if err := w.WriteHeader(h); err != nil {
			return err
		}
		f, err := fsys.Open(name)
		if err != nil {
			return err
		}
		defer f.Close()
		_, err = io.Copy(w, f)
		return err
	})
}

After:

func tarAddFS(w *tar.Writer, fsys fs.FS) error {
	return fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}

		info := try d.Info()
		h := try tar.FileInfoHeader(info, "")
		h.Name = name
		try w.WriteHeader(h)

		f := try fsys.Open(name)
		defer f.Close()
		try io.Copy(w, f)
		return nil
	})
}
  • Did you notice how easy it was to skim important decision points?
  • Did you get the idea what was going on faster?
  • Did you, too, get the idea of a Go-style assertion package?
    try assert.NoError(err)
    try assert.ThatNot(d.IsDir(), nil)
    ...
Example 3 - Classic copy file

Before:

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)
	}
	return nil
}
  • In practice, the above version stutters.
  • It's full of DRY violations.

After:

func CopyFile(src, dst string) (err error) {
     defer Annotate(&err, "copy")

     r := try os.Open(src)
     defer r.Close()

     w := try os.Create(dst)
     defer func() {
          w.Close()
          if err != nil {
               os.Remove(dst) // only if a "try" fails below
          }
     }()

     try io.Copy(w, r)
     return nil
}
--- generic helper:
func Annotate(err *error, s string) {
     if *err != nil {
          *err = fmt.Errorf("%s: %w", s, *err)
     }
  • No stuttering
  • Easier to skim
  • FYI, it would work well without deferred helpers, e.g., part of the cp command in
    Unix
Example 4 from the one possible future

Not part of the proposal
Optional - possibilities in the future.
(Existing of _err would help implementation of recovererr())

//                             ↓   ↓  no named return parameters needed
func CopyFile(src, dst string) error {
     errdefer Annotate("copy")           // called only on error

     r := try os.Open(src)
     defer r.Close()

     w := try os.Create(dst)
     errdefer os.Remove(dst)             // only on error
     defer w.Close()

     try io.Copy(w, r)
     return nil
}

--- more possibilities to build helpers:
func Annotate(s string) error {
     // this is called only if error happens => recovererr() returns error
     return fmt.Errorf("%s: %w", s, recovererr()) // ←new builtin
}

func Handle(f func(err error) error ) { // could be used wrap os.Remove
     err := f(recovererr())
     if err != nil {
          return fmt.Errorf("%w: %w", recovererr(), err) // new error
     }
     return recovererr() // error stays the same
}
  • Glimpse of the potential future, which is only shown to bring more design
    ideas to the table
    , but which aren't directly related or anyhow mandatory
    for the functionality of this proposal. (We suggest leaving comments on
    the actual proposals.)
  • These only show us that if we want to continue language modifications, the most
    prominent ones would be these:
    1. errdefer is like the current defer, 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.
    2. recovererr() builtin function like the current recover(), but instead
      of returning possible panics, recovererr returns possible error return
      value of the function where defer or errdefer was used. The same rules
      apply as recover; it works only in deferred functions. recovererr
      always returns an error if it's called inside an errdeferred function.
  • as can be seen, they are aligned with the current defer - recover rules and
    are orthogonal in that way, too.
  • as also can be seen, Try Calls offer a path that is easy to extend and still
    follow Go's principles
Examples when enclosing function does not return error
Example 5 usage in the main function

In playground use, errors will trigger panics, and we will not stop them, which
is commonly OK in playgrounds.

func main() {
     hex := try io.ReadAll(os.Stdin)
     data := try parseHexdump(string(hex))
     try os.Stdout.Write(data)
}

To stop panics and have proper error messages, add one line from some
helper package:

func main() {
     defer err2.Catch()

     hex := try io.ReadAll(os.Stdin)
     data := try parseHexdump(string(hex))
     try os.Stdout.Write(data)
}
Example 6 usage in tests

The panic functionality allows Try Calls to work as-is with the current test
harness.

func TestCheckIntegrity(t *testing.T) {
     // - OK versions
     try alice.CheckIntegrity()
     try bob.CheckIntegrity()
     try carol.CheckIntegrity()
     ...

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
function f in try f() stays in-lined similarly as without the try then
there won't be any performance penalty.

Pure hypothetically, this compile-time build Try Call might open some new
optimization opportunities.

In cases where defer is used for error handling, the error control flow
is a little slower, but the happy path inside the f is the same. During the
prototype use, the main reason some functions were slower was the lack of
inlining because of the use of
defer
. That
might be one reason why a new errdefer 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 with defer to your code.

Learnings from the usage of the prototype in several Go projects which have >100
KLOC:

  1. over four years of practice with the err2 package has taught us that
    nested try is not desired or used. Searching the
    code base where try.ToX has been used only two (2) times in the nested way
    where try.ToX has been used 1144 times.
  2. The prototype has taught us that try with a just variable (try err) is rarely used or needed. It was used 15 times, and the
    total was 1144.
  3. The same package has taught that if err statements were still used
    , especially with the channels. There were 80 error handling related if
    statements.
  4. Developers in projects have been pleased with the prototype.
  5. End-users have praised the error messages.
  6. Devops have thanked the runtime flags for error and stack traces.

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.

count := try try os.Open("data.txt").Read(data)   // WRONG, compiler error

Our previous definition holds when try is an operator and f() is an operand.
The operand that try operates must follow the conditions we defined in Try Call chapter.

Because the try 'operator' has low precedence, we would need parenthesis to get
the previous code line to work:

count := try (try os.Open("data.txt")).Read(data) // Works (but is it ugly?)

In summary, the try operator must build a new expression from the given operand (a
function call) before a Try 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:

file := try os.Open("data.txt")
count := try file.Read(data)

Orthogonality?

As far as we know, this is orthogonal with the current language features.

  1. If you need to check the error value and transport it to the channel,
    continue precisely as you would now without the try.
  2. If your function usage is good to auto propagate, you perform a Try Call.
  3. If your function usage happens inside a function that needs to annotate the
    errors, you would still perform a 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 function f(), and the result is a function call. You cannot write
try err. It will only compile with help. However, help is easy to arrange:

func noop(err error) error { return err } // OSS pkgs have this too.
func check(err error) error { return err } // ... Or readability?

...
try noop(err)
try check(err)

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.

file := try os.Open("data.txt")

↓ Go-intuitive mapping ↑

file, err := os.Open("data.txt")
if err != nil {
    return err
}

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. The
try 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.

file := try os.Open("data.txt")
count := try file.Read(data)

Vs.

file := try(os.Open("data.txt"))
count := try(file.Read(data))

The difference is slight but meaningful for some of us. For instance, the try as a separate keyword aligns well with defer and go even
though these are statements. How convenient that they both start an implicit and
separated control flow.

defer f.Close()
try f.Close()
go f.Close()

Last but not least, we have learned that, e.g., dyslexic persons prefer wider
stance of the text.

Q: Why not guard or must?

A: We think both guard and must have different general semantics. For
example, Swift's guard deals with boolean expressions and commonly needs
an else -statement. And must 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.

_, err := os.Stdin.Read(data)
if errors.Is(err, io.EOF) {
    ...

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 the try 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 use try as a source for automatic error traces, which
would be a great help in debugging.

@gopherbot gopherbot added this to the Proposal milestone Jul 11, 2024
@seankhliao seankhliao added v2 An incompatible library change error-handling Language & library change proposals that are about error handling. LanguageChange Suggested changes to the Go language labels Jul 11, 2024
@seankhliao
Copy link
Member

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.

@seankhliao seankhliao closed this as not planned Won't fix, can't repro, duplicate, stale Jul 11, 2024
@ianlancetaylor
Copy link
Contributor

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.

@lainio
Copy link
Author

lainio commented Jul 12, 2024

@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.

@ianlancetaylor
Copy link
Contributor

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.

@lainio
Copy link
Author

lainio commented Jul 12, 2024

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.

@ianlancetaylor
Copy link
Contributor

Yes: if there is relevant new information, then we will reconsider an earlier decision.

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 Suggested changes to the Go language Proposal v2 An incompatible library change
Projects
None yet
Development

No branches or pull requests

5 participants