In this article, we’ll compare Rust and Go’s error handling mechanisms by taking a hands-on approach and implementing a problem in both languages. Our task is to read information from an unreliable file and show default information in case of an error. We’ll assume that our read_file function will always give an error, forcing us to handle it and return the default result from another function. Through this exercise, we'll explore the differences and similarities between the two languages and discuss the pros and cons of each approach. Let's dive in!
The first step in implementing error handling in Rust is to define an error type which can include any necessary data fields. In the example, the CustomError
structure has one field called Io which can contain data of type String
enum CustomError {
Io(String),
}
Next, you would typically implement the Display
trait for the error type, which allows the error to be printed in a user-friendly way. This can be useful for debugging and user feedback.
impl fmt::Display for CustomError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CustomError::Io(msg) => write!(f, "I/O Error: {}", msg),
}
}
}
In Rust, the language does not know how to automatically display your custom data structures for printing, and it is up to the programmer to provide an implementation of the std::fmt::Display
trait to indicate how the data should be displayed. The Display trait provides a fmt
method that takes a formatter object and outputs the data in a desired format.
CustomError::Io(msg) => write!(f, "I/O Error: {}", msg),
In the specific case of CustomError::Io(msg)
, it means that when a CustomError
instance has the Io variant, it should be formatted as an I/O error with the message msg, using the write!()
macro from Rust's standard library. The {} placeholder inside the format string is a placeholder that will be replaced by the actual message msg when the string is printed.
The next step is to define the read_file
function with error handling.
fn read_file() -> Result<Bytes> {
Err(CustomError::Io("File Not Found".to_string()))
}
In the example, the read_file()
function returns a Result<Bytes, CustomError>
type, indicating that it can return either a byte array Bytes or an instance of CustomError, where Result<Bytes, CustomError>
is an alias of the Result<T, E = CustomError> = std::result::Result<T, E>.
It can be written in full notation as follows:
fn read_file() -> std::result::Result<Bytes, CustomError> {
Err(CustomError::Io("File Not Found".to_string()))
}
The Result type is commonly used for error handling. This type has two type parameters, Ok
and Err
, and can be used to return a value or an error from a function. The error variant of Result typically includes an instance of the error type, such as CustomError
.
Let’s see how these pieces come together in a full implementation. First, we call the read_file()
function and handle any errors that may occur. If an error is returned, we then call the default_content()
function, which returns an array of bytes. Finally, we print the resulting content after all of these steps.
use std::fmt;
enum CustomError {
Io(String),
}
impl fmt::Display for CustomError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CustomError::Io(msg) => write!(f, "I/O Error: {}", msg),
}
}
}
type Bytes = Vec<u8>;
type Result<T, E = CustomError> = std::result::Result<T, E>;
fn default_content() -> Bytes {
b"Hello World".to_vec()
}
fn read_file() -> Result<Bytes> {
Err(CustomError::Io("File Not Found".to_string()))
}
fn main() {
let content = match read_file() {
Err(err) => {
println!("{}", err);
default_content()
}
Ok(file) => file,
};
let content = String::from_utf8_lossy(&content);
println!("Content: {}", content);
}
The output of the code is
I/O Error: File Not Found
Content: Hello World
Next, I will attempt to implement the same approach using Go.
package main
import (
"fmt"
)
type CustomError struct {
msg string
}
func (e CustomError) Error() string {
return fmt.Sprintf("I/O Error: %s", e.msg)
}
type Bytes []byte
func default_content() Bytes {
return []byte("Hello World")
}
func read_file() (Bytes, error) {
return Bytes{}, CustomError{"File Not Found"}
}
func main() {
tryFileContent, err := read_file()
var content Bytes
if err != nil {
fmt.Println(err)
content = default_content()
} else {
content = tryFileContent
}
fmt.Printf("Content: %s", string(content))
}
The output of the code is
I/O Error: File Not Found
Content: Hello World
In the Go code, everything should be pretty understandable. Instead of the Result type, Go uses the functionality of returning multiple values.
return Bytes{}, CustomError{"File Not Found"}
Let’s move on to the next step and implement the parse_line method which will validate the structure of the content based on specific rules.
KEY0 => VALUE0
KEY1 => VALUE1
fn parse_line(input: &str) -> Result<Vec<(&str, &str)>> {
let mut pairs = Vec::new();
for (i, line) in input.lines().enumerate() {
let mut parts = line.split("=>").map(str::trim).filter(|t| !t.is_empty());
match (parts.next(), parts.next()) {
(Some(key), Some(value)) => pairs.push((key, value)),
_ => {
return Err(CustomError::FormatError {
line: i + 1,
msg: "Invalid format".to_string(),
});
}
}
}
Ok(pairs)
}
The crucial aspect of this function is the Err return when a value of any element is empty. In such a scenario, parse_line will generate a new error of type CustomError::FormatError
. This error will include an error message and the line number where the error occurred.
Now let’s take a look at the full implementation of the code, which includes two error handling mechanisms
use std::fmt;
enum CustomError {
Io(String),
FormatError { line: usize, msg: String },
}
impl fmt::Display for CustomError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CustomError::Io(msg) => write!(f, "I/O Error: {}", msg),
CustomError::FormatError { line, msg } => {
write!(f, "Parsing Error: {} on the line {}", msg, line)
}
}
}
}
type Bytes = Vec<u8>;
type Result<T, E = CustomError> = std::result::Result<T, E>;
fn default_content() -> Bytes {
b"KEY0 => VALUE0 \n KEY1 => VALUE1".to_vec()
}
fn read_file() -> Result<Bytes> {
Err(CustomError::Io("File Not Found".to_string()))
}
fn parse_line(input: &str) -> Result<Vec<(&str, &str)>> {
let mut pairs = Vec::new();
for (i, line) in input.lines().enumerate() {
let mut parts = line.split("=>").map(str::trim).filter(|t| !t.is_empty());
match (parts.next(), parts.next()) {
(Some(key), Some(value)) => pairs.push((key, value)),
_ => {
return Err(CustomError::FormatError {
line: i + 1,
msg: "Invalid format".to_string(),
});
}
}
}
Ok(pairs)
}
fn main() {
let content = match read_file() {
Err(err) => {
println!("{}", err);
default_content()
}
Ok(file) => file,
};
let content_str = String::from_utf8_lossy(&content);
match parse_line(&content_str) {
Ok(pairs) => {
for (key, value) in pairs {
println!("Content: {} = {}", key, value);
}
}
Err(err) => println!("{}", err),
}
}
To add new error handling, we simply needed to extend the functionality of the CustomError
enum and the implementation of the Display
trait for each error case. This illustrates how Rust’s error handling mechanism provides a concise and consistent way of dealing with errors compared to other programming languages.
In case
fn default_content() -> Bytes {
b"KEY0 => VALUE0 \n KEY1 => ".to_vec()
}
The output of the code is
Parsing Error: Invalid format on line 2
In case
fn default_content() -> Bytes {
b"KEY0 => VALUE0 \n KEY1 => VALUE1".to_vec()
}
The output of the code is
Content: KEY0 = VALUE0
Content: KEY1 = VALUE1
Moving on, let’s explore how this approach can be implemented in Go
package main
import (
"fmt"
"strings"
)
type FormatError struct {
Line uint32
Msg string
}
type CustomError struct {
Io string
}
func (e FormatError) Error() string {
return fmt.Sprintf("Parsing Error: %v on the line %v", e.Msg, e.Line)
}
func (e CustomError) Error() string {
return fmt.Sprintf("I/O Error: %v", e.Io)
}
type Pair struct {
Key string
Value string
}
func defaultContent() []byte {
return []byte("KEY0 => VALUE0 \n KEY1 => VALUE1")
}
func readFile() ([]byte, error) {
return nil, CustomError{Io: "File Not Found"}
}
func parseLine(input string) ([]Pair, error) {
lines := strings.Split(input, "\n")
var result []Pair
for i, line := range lines {
line = strings.TrimSpace(line)
if len(line) == 0 {
continue
}
parts := strings.Split(line, "=>")
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if key == "" {
return nil, FormatError{Line: uint32(i + 1), Msg: "Invalid format"}
}
if value == "" {
return nil, FormatError{Line: uint32(i + 1), Msg: "Invalid format"}
}
result = append(result, Pair{key, value})
}
return result, nil
}
func main() {
content, err := readFile()
if err != nil {
fmt.Println(err)
content = defaultContent()
}
strContent := string(content)
pairs, err := parseLine(strContent)
if err != nil {
fmt.Println(err)
return
}
for _, pair := range pairs {
fmt.Printf("Content: %v = %v\n", pair.Key, pair.Value)
}
}
When implementing the same approach in Go, we will need new data structures and functions to handle new error types.
Let’s see a few examples of how it works in this code.
In case
func defaultContent() []byte {
return []byte("KEY0 => VALUE0 \n KEY1 => ")
}
The output of the code is
Parsing error: invalid format on line 2
In case
func defaultContent() []byte {
return []byte("KEY0 => VALUE0 \n KEY1 => VALUE1")
}
The output of the code is
Content: KEY0 = VALUE0
Content: KEY1 = VALUE1
Looking at all the examples, Rust's error handling can be more verbose, but it also allows for more expressive and informative error messages, as well as more powerful error handling mechanisms. In real-world software development, there may be errors that lead to a high level of complexity in function calls, but Rust’s easy mechanism for handling errors makes our lives as software developers happier.
Top comments (0)