Golang : a valid competitor for the crown of error handling
let's venture into the world of Golang and the intricacies of Error handling in it
Table of contents
- This means that errors in Go are plain values that can be assigned to variables, returned from functions, and passed as arguments just like any other value.
- A little Deeper:
- Sentinel Errors:
- Handling Errors with if Statements
- How is extra information added to errors in Golang?
- Answer: Wrapping Errors
- The shorthand solution
- So what can be done?
- errors.Is() && errors.As()
- Is the Error handling in Go Limited only for returning errors explicitly?
- NO
- 1. defer
- The defer statement has a multitude of Use cases
- This means that if multiple defer statements are used within a function, the one defined last will be executed first.
- 2. panic
- panic is a built-in function in Go that is used to deliberately cause a runtime error.
- 3. recover
- It's important to note that recover is not meant for regular error handling. It is meant for exceptional situations where you need to regain control over the program and potentially perform cleanup or logging
- Conclusion
Error handling is a crucial aspect of any programming language, and Golang is no exception. In fact, Go's approach to error handling is quite distinctive and designed to be explicit and straightforward. In this blog, we'll delve into the intricacies of error handling in Go, exploring how it differs from other languages and highlighting best practices along the way.
Go's approach to error handling is focused on simplicity and clarity. It follows the philosophy that errors should be treated as values, not exceptions. This approach is very different from Python and Javascript which rely on Exceptions.
In Go, an error is represented as an instance of an interface Error, with the following inner definition.
type error interface {
Error() string
}
which encompasses an Error()
method. So Any type that implements this method satisfies the error
interface and can be treated as an error.
This means that errors in Go are plain values that can be assigned to variables, returned from functions, and passed as arguments just like any other value.
In Go, it's common for functions to return both a result and an error. This double return pattern is often used to indicate success or failure. Such as
func funcName(var1, var2 dataType) (returnType1, error) {
if /*Condition for failure*/ {
return 0, errors.New("Error Statement")
}
return value, nil
}
/* note : *datatype* represents the primitive datatypes
as well as structs and interface */
A little Deeper:
Go's Standard library provides Errors
package for giving high-level control over the error implementation and customization. The errors.new()
is a commonly utilized function, defined as
func New(text string) error {
return &errorString{text}
}
type errorString struct {
s string
}
New
returns an error that formats as the given text by instantiating an object of type errorString
with given text value
Sentinel Errors:
Now that we've discussed errors.New()
let's talk about sentinel errors
Generally, sentinel errors refer to specific predefined error values that are used to represent particular error conditions. These error values act as "sentinels"(Call them markers if you want to) to indicate certain exceptional situations in the code. Sentinel errors are typically constants or global variables.
import (
"io"
"os"
)
func readFile() error {
file, err := os.Open("example.txt")
if err != nil {
if err == io.EOF {
// Handle end of file
} else {
// Handle other errors
}
return err
}
defer file.Close() //closing the file is the last thing that happens
// space for processing "file" if everything goes to plan
return nil
}
Here io.EOF
is a sentinel Error defined inside the io
package as follows
var EOF = errors.New("EOF")
Different packages in a programming language may define their own sentinel errors to represent specific error conditions that are relevant to the functionality of those packages. These sentinel errors are typically constants or variables defined within the package and are used to indicate specific error situations.
For reference "database/sql"
pkg
provides sql.ErrNoRows
sentinel with definition
var ErrNoRows = errors.New("sql: no rows in result set")
Handling Errors with if
Statements
In Go, error handling is explicit and typically involves using if
statements to check for errors.
result, err := funcName(arg_1,arg_2)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
How is extra information added to errors in Golang?
Answer: Wrapping Errors
As errors propagate through different layers of a Go application, it's often useful to add context or wrap errors to provide more information about what went wrong. The fmt.Errorf
function is commonly used for this purpose:
func HighOrderFunction() error {
data, err := someFunction()
if err != nil {
return fmt.Errorf("Extra Context for previously
acquired error: %w", err)
}
// ... process data
return nil
}
The %w
verb in the fmt.Errorf
format string allows you to wrap the original error, preserving its type and message while adding context.
But As errors are wrapped with additional context, the error chain can become more complex. This complexity can make it challenging to understand the exact source and nature of an error and excessive wrapping can lead to the loss of original error details. When multiple layers of wrapping are involved, it becomes difficult to access the original error message or other relevant information.
The shorthand solution
to this is to use errors.Unwrap()
for unwrapping the errors, but Manual Unwrapping for a complex Error chain is Avoided for 2 main reasons
Unwrapping errors manually often involves repetitive code patterns. You need to check each error in the chain, potentially with nested if statements, which can make your error-handling code verbose and less readable.
Manually unwrapping errors requires careful attention to detail. Mistakes in the unwrapping process can lead to incorrect error handling or even unexpected program behavior.
So what can be done?
Note: In Go, it is generally considered good practice to keep error wrapping to a minimum.
However, the Errors
package does provide 2 very useful functions
errors.Is()
&& errors.As()
Let us understand their purpose, usage and implementation.
errors.Is()
Purpose: It is used for checking if an error (or any of its wrapped errors) matches a specific target error value.
Usage: You pass two errors to. It returns
true
if it finds a match, indicating that the error (or a wrapped error) is of the same type as the target error. It's primarily used for error comparison.Example:
if errors.Is(err, io.EOF) { // here io.EOF is target error // code which executes if errors match.... }
errors.As()
:Purpose:
errors.As()
is used for type assertion(.As()
was named after Assertion), specifically to attempt to extract an error of a particular type from an error chain.Usage: You pass an error and a pointer to a target error type to
errors.As()
. It returnstrue
if it finds an error of the target type in the chain, and assigns that error to the provided pointer.Example:
var customErr *CustomError // pointer to custom error type if errors.As(err, &customErr) { // rest of the code handles the customErr }
To Avoid Confusion between the two, errors.Is()
is for checking if an error matches a specific target error, while errors.As()
is for attempting to extract an error of a particular type from an error chain.
Is the Error handling in Go Limited only for returning errors explicitly?
NO
In addition to returning errors explicitly, Go provides 3 special control flow mechanisms for handling errors: defer
, panic
and recover
1. defer
The defer
statement has a multitude of Use cases
schedule a function call to be executed just before the surrounding function returns.
It's often used for cleanup tasks or to ensure that certain actions happen, no matter how the function exits (like through a return or panic).
We also use
defer
to delay the execution of functions that might cause an error.
Fun fact : defer
statements are executed in a last-in, first-out (LIFO) order.
This means that if multiple defer
statements are used within a function, the one defined last will be executed first.
package main
import "fmt"
func main() {
//simulating a file being opened
file := "example.txt"
defer closeFile(file) // This will be executed last
performOperations()// Simulate some operations that might cause an error
defer logCompletion() // This will be executed second-to-last
// More operations
fmt.Println("More program execution...")
}
func performOperations() {
//Imagine an error prone funnction
fmt.Println("Performing operations...")
panic("An error occurred")//Simulation of an error
}
func closeFile(file string) {
// Simulate closing a file or releasing of resource
fmt.Printf("Closing file: %s\n", file)
}
func logCompletion() {
// Simulate logging the completion of a task
fmt.Println("Task completed.")
}
2. panic
panic
is a built-in function in Go that is used to deliberately cause a runtime error.
When a function encounters a panic
, it immediately stops executing, and the control flow is moved to deferred recover
functions (if any). If no deferred recover
function is found, the program will terminate.
func main() {
//Simple demonstration of panic()
fmt.Println("Help! Something bad is happening.")
panic ("Ending the program") //Terminates the program
fmt.Println("Waiting to execute")//doesn't execute
}
standalone panic
is typically used for unrecoverable errors or situations where it's unsafe to continue executing the program.
3. recover
recover
is also a built-in function in Go. main use is to regain control over a panicking goroutine and resume normal execution. recover
is only useful when called from a deferred function during a panic.
func main() {
defer func() {
if r := recover(); r != nil {
/*deferred recover func*/
fmt.Println("Recovered from panic:", r)
}
}()
if somethingBadHappened() { /*somethingBadHappend()
is a placeholder for situation considered to be error inducing*/
panic("Oh no, something bad happened!")
}
// Normal execution continues
}
It's important to note that recover
is not meant for regular error handling. It is meant for exceptional situations where you need to regain control over the program and potentially perform cleanup or logging
Conclusion
In this exploration of Go's error-handling capabilities, we've explored a language feature that truly sets Go apart. Go's approach to error handling, characterized by its simplicity, clarity, and adherence to strong conventions, might be second only to RUST.
So whether you're building a small utility or a large-scale application, Go's error handling shines as a robust mechanism that'll help you write code that is not only efficient but also maintainable in the long run.
So, if you value code that is clean, clear, and reliable, Go's error handling is yet another compelling reason to embrace this language for your software development projects.