Description
Author background
- Would you consider yourself a novice, intermediate, or experienced Go programmer?
I'm a student, but i'm mainly using go from the first day I started programming and now I'm using
it for 3 years. I coded a bunch of libraries, some targeting specific needs for different
OSes, the largest one is a TCP/HTTPs server library (Server v2). - What other languages do you have experience with?
I use regularly Javascript, Java, SQL databases (HTML and CSS if you consider them programming languages).
Related proposals
- Has this idea, or one like it, been proposed before?
Yes, there have been a lot of proposals related to this topic.- If so, how does this proposal differ?
First of all, this proposal matches the official guidelines related to this topic.
Then it will be completely backwards compatible and will not introduce any weird
constructs that will preserve the readability and simplicity of the Go code.
- If so, how does this proposal differ?
- Does this affect error handling?
Yes, the topic is the error handling.-
If so, how does this differ from previous error handling proposals?
This does not to implement anything like a try-catch, considering that this is already
too similar to the panic-recover already present.This proposal has more of a macro-like behaviour, reducing the possibility of overworking
on a new mechanic, with the risk of diverging from the Go principle of "error checking".
-
Error Handling Proposal
This proposal addresses the problem that affects every project written in Go
that has to deal with a lot of error handling, resulting in a very verbose
code, without removing the principle that you should always handle every
error returned by a function in the best way.
This would benefit every go programmer, considering it's about error handling, and
is a widely discussed topic among every programmer, even non-go ones.
Changes
The proposal, by adding only a new keyword to the language, the catch
one, will add
to the language 2 new strictly related construct (more like 1 construct and 1 prefix):
- the definition of an error-handler function that is relevant only for
the function it's declared in; this construct will be presented below - the explicit statement that you want to trigger the previously-declared function above;
this prefix will be presented below
The backwards compatibility will be discussed below along with code snippets showing the changes.
We will discuss also interaction between other language features, like goroutine functions ran with
the go
keyword (this proposal has a similar concept, that is adding a prefix to a function call to
explicitly say that you want to tread that function differently from normal).
This proposal is not about performance improvements, but (with my knowledge) should not even affect it
in a bad way.
Introduction
Here is a good example of a code that would benefit from the introduction
of this concept:
package myPackage
import (
"encoding/json"
"fmt"
"io"
"os"
"time"
)
type MyStruct struct {
Message string `json:"message"`
Time time.Time `json:"time"`
}
func LoadJSONFile(filePath string) (*MyStruct, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("could not load json file <%s>: %w", filePath, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("could not load json file <%s>: %w", filePath, err)
}
var result *MyStruct
err = json.Unmarshal(data, result)
if err != nil {
return nil, fmt.Errorf("could not load json file <%s>: %w", filePath, err)
}
return result, nil
}
It's obvious that there is a lot of code that is redundant and verbose: this could be
fixed if we could tell the compiler what to do every time a function returns as the
last return-value an error and it is not nil, like a macro. Here is an example:
func LoadJSONFile2(filePath string) (*MyStruct, error) {
// declaration of the `catch function`
catch (err error) {
// the return statement (if present) must match the calling function signature
return nil, fmt.Errorf("could not load json file <%s>: %w", filePath, err)
}
f := catch os.Open(filePath)
defer f.Close()
data := catch io.ReadAll(f)
var result *MyStruct
catch json.Unmarshal(data, result)
return result, nil
}
Note: in this proposal there are two different usage of the
catch
keyword:
- one is the declaration of the macro function, I will refer to
it ascatch function
- the other one is the request to handle an error with the
catch function
,
I will refer to it ascatch prefix
in the context of a function call
First Usage
The first usage of the catch
keyword is to tell the compiler that from
this point until the end of the function, without inheritance
from other function calls inside, if you call other functions
with the catch prefix
, it should implicitly call the catch function
only if err != nil
.
To set the catch function
you must use this syntax only:
catch (<variable_name> error) { ... }
without using func() { ... }
or any pre-declared function (like with defer
or go
)
to underline that this catch function
should be explicitly made
for this function errors and should not be handled by a function
that is used for every error in the program/package.
However once inside the catch function body, it's always possible
to call other functions as a normal function.
The catch function
must not have a return signature (like a 'void'
function) because in reality it's implicitly matching the one
of the calling function (in this case (*MyStruct, error)
).
This is also why the catch function must be declared inline.
Second Usage
The second use case of the catch
keyword is by placing it
in front of a function call that has as the last return type an error.
By doing this you can avoid taking this last error and
tell the compiler to handle it automatically with the catch
function if the error is not nil (removing the need constantly use of the
construct if err != nil { ... }
).
Function with named parameters in the return statement
The catch argument can have whatever name you desire, but it
must be an error. In this case if you named it err
, the
one declared in the function signature would be shadowed.
func LoadJSONFile3(filePath string) (result *MyStruct, err error) {
catch (catchErr error) {
// This is what the catch function can be if the return
// signature has variable names
err = fmt.Errorf("could not load json file: %w", catchErr)
return
// Or directly
return nil, fmt.Errorf("could not load json file: %w", err)
// It has all the possible return statements that you
// would have in a normal function body
}
// Same stuff ...
return
}
Illegal Usage and Skip error check
func LoadJSONFile4(filePath string) (*MyStruct, error) {
// This is illegal, because the `catch function` is not
// declared at this point (like accessing a non-already
// declared variable)
fileStats := catch os.Stat() // ILLEGAL
catch (err error) {
return nil, fmt.Errorf("could not load json file: %w", err)
}
// this is obviously illegal because i have not used the catch
// prefix on the function call, preserving backwards compatibility
f := os.Open(filePath) // ILLEGAL
defer f.Close()
// This is how we already explicitly ignore returned errors and
// this will be the only way to really ignore them
data, _ := io.ReadAll(f)
// also this is how we already ignore returned errors if the
// function only returns one error. Not the absence of the
// `catch` keyword before the function
json.Unmarshal(data, result)
return
}
Manually trigger the catch function
From now we are going to change the context a little bit
for the next sections
We will be using the same MyStruct with a message and a timestamp
and two functions, one private and one public with different
arguments but the same return signature, both used to create a new
instance of the struct.
This is widely used in packages that has a private method that exposes
more options and a public one that has less arguments and maybe also
does some sanity check first
The private method just checks if the message is not empty and asks
for an explicit timestamp that will be directly injected in the returned
struct.
The public method instead only asks for a message, the time will be
time.Now(), and also checks that the message is not on multiple lines.
var (
MultilineError = errors.New("message is multiline")
)
func NewMyStruct(message string) (*MyStruct, error) {
catch (err error) {
return nil, fmt.Errorf("could not create MyStruct: %w", err)
}
if strings.Contains(message, "\n") {
catch errors.New("message is multiline")
// or
catch MultilineError
// or (similar)
catch fmt.Errorf("message of length %d is multiline", len(message))
}
//... will be continued in the next code snippet
}
The catch function
can be manually triggered by using the
catch
keyword followed by an error value, this being the only
returned value of a function or a pre-existing variable.
In reality this is no so much different from what we have already
discussed above, but has a slightly different purpose, so here it is.
In fact the only thing that is doing is calling a function that returns only
an error (like the json.Unmarshal(...) before), but we surely know that the
returned error will not be nil.
Discussion
Functions returning other functions
Now let's see when a function returns in the end another function
with the same return signature. Here is the original code snippet:
func newMyStruct(message string, t time.Time) (*MyStruct, error) {
if message == "" {
return nil, errors.New("message can't be empty")
}
return &MyStruct{ Message: message, Time: t }, nil
}
func NewMyStruct(message string) (*MyStruct, error) {
// previous stuff ...
result, err := newMyStruct(message, time.Now())
if err != nil {
return nil, fmt.Errorf("could not create MyStruct: %w", err)
}
return result, err
}
In this case this is what the last return statement
will be equal to with the catch
:
func newMyStruct(message string, t time.Time) (*MyStruct, error) {
// Same as above ...
}
func NewMyStruct(message string) (*MyStruct, error) {
catch (err error) {
return nil, fmt.Errorf("could not create MyStruct: %w", err)
}
if strings.Contains(message, "\n") {
catch errors.New("message is multiline")
}
return catch newMyStruct(message, time.Now())
}
And if you think that you should not modify the returned
values, you can always directly use
return newMyStruct(message, time.Now())
without the catch prefix
: the compiler will know that you do not want
to execute the catch function
on the error.
Inheritance
As previously mentioned, this is not like a defer-recover construct:
any error, even in the function called, is not handled by the most
recent version of the catch version, but only by the one inside the same
function body. In this way it be easier for an LSP (go vet, staticcheck
or others similar) to detect an improper use of the catch function
.
One thing to be pointed out is that the catch function
should not
be treated as a function variable, because there could be problems with
inline-declared function, especially used with goroutines:
A function like that does not have to match the "parent" function return
statement in which is declared, and normally doesn't (goroutines
function usually does not return anything): this breaks the principle behind the catch
macro style and also goes against the reason why the
catch function
must be declared for each distinct function,
that is to discourage the usage of a universal function handler for every
function in the program/package.
So:
func Foo() error {
catch (err error) {
return nil, fmt.Errorf("could not load json file: %w", err)
}
// Some stuff with error handling with the catch ...
wg := new(sync.WaitGroup)
wg.Add(2)
go func() {
defer wg.Done()
value1, value2 := catch SomeFunctionThatReturnsAnError() // This is invalid
// Use of the values ...
}()
f2 := func() {
defer wg.Done()
catch (err error) {
log.Printf("error occurred in the second goroutine: %v\n", err)
return
}
value1, value2 := catch SomeFunctionThatReturnsAnError() // This is completely valid
// Use of the values ...
}
go f2()
wg.Wait()
return nil
}
The return statement inside the catch function
It was not previously mentioned, but the return statement
inside the catch function
is not mandatory, in fact if you
would not use the return statement, the catch function
will
be executed normally, doing everything it contains, and then
continuing the function execution.
Again, one thing is what the language lets you do, one thing
is what is actually correct to do in a program.
An example that demonstrates that this kind of logic is already
possible is the following:
func LoadJSONFileWithLogging(filePath string) *MyStruct {
f, err := os.Open(filePath)
if err != nil {
log.Printf("could not load json file: %v", err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
log.Printf("could not load json file: %v", err)
}
var result *MyStruct
err = json.Unmarshal(data, result)
if err != nil {
log.Printf("could not load json file: %v", err)
}
return result
}
func LoadJSONFileWithCatchLogging(filePath string) *MyStruct {
catch (err error) {
log.Printf("could not load json file: %v", err)
}
f := catch os.Open(filePath)
defer f.Close()
data := catch io.ReadAll(f)
var result *MyStruct
catch json.Unmarshal(data, result)
return result
}
Note: both functions do not return any error
Change of the catch function
One last thing that can be discussed is the possibility to change the
catch function
behaviour inside the same function: honestly, I thing this
would be kind of overkill, if you have the need to constantly changing the
catch function
, you would probably be better controlling each error
like we are used to. But an example would be like this:
func LoadJSONFile5(filePath string) (*MyStruct, error) {
catch (err error) {
return nil, fmt.Errorf("could not load json file: %w", err)
}
f := catch os.Open(filePath)
defer f.Close()
// Other stuff with more error handling with the first catch function ...
catch (err error) {
return nil, fmt.Errorf("another error while loading json file: %w", err)
}
// From now, every catch prefix will use this second catch function
data := catch io.ReadAll(f)
var result *MyStruct
catch json.Unmarshal(data, result)
return result, nil
}
How the compiler should behave (conceptually)
At compile time, considering the catch function
is valid, the compiler should
literally insert that function's code in the right places as can be evinced from
the differences between the old and the new code.
Conclusions
I really think that this would make people save a lot of lines of code,
while not compromising readability of the code and backwards
compatibility. I think this could be even implemented, without considering
development time, in a go v1.21
or go v1.22
.
This change will not make Go easier to learn, but not even harder. This should
make writing Go code, both for production and for personal use, more pleasing
and less tedious.
As stated before, this should not have any performance impact on programs, thanks
to the nature of this feature, that is being a like a macro. This obviously will
put more strain on the compiler and any LSP, but I don't think this will be huge.
This is my first proposal I do in my life so sorry if I missed some
aspects and I remain available for discussions.
Hope you like this idea. Thanks for the time dedicated to this proposal