In this post, I want to go from step by step what it would look like building a taskr binary in both Go and Rust side by side so you can see conversion conventions and why Rust wins in the battle between the gopher and the crab.
Starting Point
package main
import "fmt"
func main() {
fmt.Println("Hello World")
}
fn main() {
println!("Hello World")
}
From both of these applications, you should be working in a directory structure that looks like:
~/work/personal/learning-go/taskr/main.go
~/work/personal/learning-rust/taskr/main.rs
To get the ~/work/personal/learning-go directory, create it and then create a new Go file using:
mkdir -p ~/work/personal/learning-go/taskr
cd ~/work/personal/learning-go/taskr
go mod init taskr
To get the ~/work/personal/learning-rust/taskr directory, run:
mkdir -p ~/work/personal/learning-rust
cargo new taskr
From that command you’ll get a Cargo.toml file which will be versioned to 0.1.0.
The program’s data structure
We’re building out a task, so lets define what that looks like in Go and in Rust.
type Status string
const (
StatusTodo Status = "Todo"
StatusDone Status = "Done"
)
type Task struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Status Status `json:"status"`
}
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
enum Status {
Todo,
Done,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Task {
id: u32,
title: String,
status: Status,
}
| Decorator | Usage |
|---|---|
Debug |
lets me print the value using {:?} format |
Serialize |
generate JSON code at compile time |
Deserialize |
read JSON code at compile time |
Clone |
allows you to duplicate a value |
PartialEq |
allows equality comparisons between Status values |
These decorators will be used throughout the program later. Specifically, the Debug will be used in println!() statements. serde import provides Serialize and Deserialize in order to engage with JSON files at compile time. Without PartialEq, the status would not be comparable. It’s required.
Let’s define what the print_usage() function is going to look like in Go and in Rust.
func printUsage() {
fmt.Println("taskr - A simple task manager")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" taskr add Add a new task" )
fmt.Println(" taskr list List all tasks")
fmt.Println(" taskr done Mark a task as done" )
fmt.Println(" taskr delete Delete a task" )
fmt.Println(" taskr search Search for a task" )
}
fn print_usage() {
println!("taskr - A simple task manager");
println!();
println!("Usage:");
println!(" taskr add Add a new task" );
println!(" taskr list List all tasks");
println!(" taskr done Mark a task as done" );
println!(" taskr delete Delete a task" );
println!(" taskr search Search for a task" );
}
This should go above the main() func in both main.go and main.rs respectively.
| Command | About |
|---|---|
taskr add |
Add a new task |
taskr list |
List all tasks |
taskr done |
Mark a task as done |
taskr delete |
Delete a task |
taskr search |
Search for a task |
Implementing the print_usage() func in both Go and Rust is very simple and is done in the main() func respectively:
func main() {
if len(os.Args) < 2 {
printUsage()
return
}
}
fn main() {
// .collect() returns the .args() into a Vec format
let args: Vec<String> = std::env::args().collect();
// ensure that there are enough arguments to run the program
if args.len() < 2 {
print_usage();
}
}
The big difference here is the Go os.Args and the Rust std::env::args().collect(). Clearly, Rust is more verbose, and without .collect() the value of the .args() could be a tuple of , but .collect() returns a Vec if there are no Err thrown.
With this basic program functionality, we can implement in the main() func in both languages what that switching is going to look like parsing the 2nd and 3rd arguments respectively.
func main(){
// ... existing
switch args[1] {
// 1. taskr search
case "search":
// 2. taskr add
case "add":
// 3. taskr list
case "list":
// 4. taskr done
case "done":
// 5. taskr delete
case "delete":
// 6. taskr catch-all
default:
}
//- end of main
}
fn main() {
// ... existing
match args[1].as_str() {
// 1. taskr search
"search" => {}
// 2. taskr add
"add" => {}
// 3. taskr list
"list" => {}
// 4. taskr done
"done" => {}
// 5. taskr delete
"delete" => {}
// 6. taskr catch-all
_ => {
eprintln!("Invalid command");
print_usage();
std::process::exit(1); // SIGTERM 1
}
} //- match arg[1]
//- end of main
}
The match (rust) and switch (go) statements are both reading from args[1]. The 0 in args[0] would be the filename of the binary running. In the example of taskr list, the args[0] is taskr and args[1] is list. In rust, the .as_str() is used to ensure that the underlying type is a string, since the statement is a String in "statement" => { } (rust) and case "statement": (go).
Next, we are going to be implementing the TaskStore, but before we do that, we need to ensure that our programs respectively have the correct imports.
import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
)
// serde is used for JSON structure support at compile time
use serde::{Deserialize, Serialize};
// read and write on file functions
use std::fs;
// access the io::Result type for propagating I/O errors
use std::io;
// owned mutable file path
use std::path::PathBuf;
These imports allow each of the programs to respectively handle the functionality needed. Within Go, you’ll interact with the os package for I/O related work and in Rust, you interact with std::io instead. Files are handled in Go using the os package, as well as the fs and ioutil packages. Rust consolidates it into a two packages called std::io and std::fs. Both languages are designed to be cross platform natively. One last thing to point out about this is the std::path::PathBuf and how its very similar to Go’s filepath package offering. In Rust, the PathBuf type is specifically for filesystem paths. On Windows, you split your directory tree using and on Linux, you split your directory tree using /. Those differences are handled under the hood by the filepath (Go) and std::path::PathBuf (Rust) automatically.
The Task Store
The bulk of the logic of the application is going to expose TaskStore in both Go and Rust. How these are implemented matter and you’ll see why that is.
type TaskStore struct {
tasks []Task
filePath string
}
struct TaskStore{
tasks: Vec<Task>,
file_path: PathBuf,
}
Both of these snippets show you how similar Go and Rust can appear. The []Task in Go becomes a Vec in Rust and the filePath uses Rust‘s PathBuf. In Go, the struct can use and store the filePath as a string since filepath package returns only String types back – Rust gives you an entirely new type called PathBuf whereas Go gives you a wrapper for it.
When implementing the methods on the TaskStore, this is where Go and Rust diverge quite radically.
func NewTaskStore(filePath string) (*TaskStore, error) {}
func (s *TaskStore) Save() error {}
func (s *TaskStore) Add(title string) {}
func (s *TaskStore) Complete(id uint32) error {}
func (s *TaskStore) Delete(id uint32) error {}
func (s *TaskStore) List() {}
func (s *TaskStore) Search(query string) []*Task {}
impl TaskStore {
fn new(file_path: PathBuf) -> io::Result<Self> {}
fn save(&self) -> io::Result<()> {}
fn add(&mut self, title: String) {}
fn complete(&mut self, id: u32) -> Result<(), String> {}
fn delete(&mut self, id: u32) -> Result<(), String> {}
fn list(&self) {}
fn search(&self, query: &str) -> Vec<&Task> {}
}
There is a pretty clean 1:1 relationship between the implemented methods of the TaskStore between Go and Rust and specifically what I want to note here for you is how in Rust everything is wrapped inside of the impl TaskStore {} block itself, whereas in Go, this can be literally anywhere at the root level of the file inside the package main project. The order of the routes does not matter in either language.
Rust has a convention called io::Result which in Go simply looks like (. The NewTaskStore method in Go is unattached per-se to the TaskStore struct itself, but it does return the Go equivalent of (*TaskStore, error) as io::Result. When io::Result is used, the method must also include Ok(()) in order to satisfy the contract.
At this point, let’s ensure that our Go program has two little helper functions in it in order to improve runtime readability – this is common in Go.
func parseID(arg string) uint32 {
id, err := strconv.ParseUint(arg, 10, 32)
if err != nil {
fmt.Fprintf(os.Stderr, "Invalid task ID: %sn", arg)
os.Exit(1)
}
return uint32(id)
}
func mustSave(s *TaskStore) {
if err := s.Save(); err != nil {
fmt.Fprintf(os.Stderr, "Error saving tasks: %vn", err)
os.Exit(1)
}
}
This application does not need or use helper functions in the Rust implementation. In Rust we’re using u32 for the id and in Go that is a uint32 which imports the strconv package. Rust does not need to import any packages.
Before implementing the specifics in the TaskStore, lets consume it in the main() func.
func main() {
// ... after checking the length of args
store, err := NewTaskStore("tasks.json")
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading tasks: %vn", err)
os.Exit(1)
}
// ... before switching args[1]
}
fn main() -> io::Result<()> {
// ... after checking the length of args
let database = PathBuf::from("tasks.json");
let mut store = match TaskStore::new(database){
Ok(store) => store,
Err(e) => {
eprintln!("Error loading tasks: {}", e);
std::process::exit(1); // SIGTERM 1
}
};
// ... before matching args[1]
}
Both of these implementations will leverage the current working directory and consume the resource tasks.json if it exists. If it does not exist, the program will create it, if it exists, the program will overwrite it.
In Rust, the TaskStore is consumed slightly differently than in Go because of the impl TaskStore {} differences. In Rust the TaskStore must have mut after let and before the name of the variable in order to indicate to the compiler that the value of store is eligible for changing in the future. Since the TaskStore::new(database) returns with an io::Result, we can match against it when assigning it to the store variable.
The Go tuple that io::Result is satisfying is (*TaskStore, error) and in Rust, the first component *TaskStore is represented in by the Ok() invocation. At the end of the various impl TaskStore {} methods, you’ll see an Ok(()) at the end of each method ; this invokes the contract and exits the method since we’re good. Otherwise, your program will not compile. When assigning a value to store and executing TaskStore::new(database), each component of the output gets matched to the Ok(store) => or Err(e) => {} blocks respectively. When we receive an Ok(()) then that returned store from Ok() is then assigned to the let mut store variable.
Otherwise, if Err(e) satisfies, it means that something went wrong and we’ll print the error and exit with status code 1. In Go, we use os.Exit(1) and in Rust we use std::process::exit(1);. In Go, we can use the fmt or slog or verbose packages to write to STDOUT and STDERR, and in Rust we use eprintln!() for STDERR and println!() for STDOUT respectively.
Implementing new TaskStore
We have stubbed out each of our methods, and at this point we need to implement the body of these methods in two locations. We’re going to implement the TaskStore now, and that means populating each of the methods in TWO locations. First in the impl TaskStore {} block for Rust and inside of the func (s *TaskStore) ... methods for Go.
| Method | Go Signature | Rust Signature |
|---|---|---|
new |
func NewTaskStore(filePath string) (*TaskStore, error) |
fn new(file_path: PathBuf) -> io::Result |
save |
func (s *TaskStore) Save() error |
fn save(&self) -> io::Result<()> |
list |
func (s *TaskStore) List() |
fn list(&self) |
complete |
func (s *TaskStore) Complete(id uint32) error |
fn complete(&mut self, id: u32) -> Result<(), String> |
delete |
func (s *TaskStore) Delete(id uint32) error |
fn delete(&mut self, id: u32) -> Result<(), String> |
add |
func (s *TaskStore) Add(title string) |
fn add(&mut self, title: String) |
search |
func (s *TaskStore) Search(query string) []*Task |
fn search(&self, query: &str) -> Vec<&Task> |
TaskStore’s new Implemented
func NewTaskStore(filePath string) (*TaskStore, error) {
tasks := []Task{}
data, err := os.ReadFile(filePath)
if err == nil {
if jsonErr := json.Unmarshal(data, &tasks); jsonErr != nil {
tasks = []Task{}
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
return &TaskStore{tasks: tasks, filePath: filePath}, nil
}
fn new(file_path: PathBuf) -> io::Result<Self> {
let tasks = if file_path.exists() {
let data = fs::read_to_string(&file_path)?;
serde_json::from_str(&data).unwrap_or_else(|_| Vec::new() )
} else {
Vec::new()
};
Ok(Self { tasks, file_path })
}
The most notable thing to understand in this code block is the difference between how Go and Rust respectively handle JSON. In Go, you can use the json.Unmarshal method to parse the string of data and map it into a struct – the compiler will let you break everything – by design. Rust on the other hand, blocks it entirely before you can even compile. What does this mean? Instead of handing off the task to the go runtime in order to determine the types of the fields and how they are to map from string form into structured data form – Rust does it before compilation time using .unwrap_or_else whereas Go relies on jsonErr != nil to understand if a problem occurred. Rust enforces the types of the struct that you’re mapping data into at compilation time, whereas Go attempts to do this at runtime. This difference could be spending 5x the amount of time you’d normally spend in Go running Unmarshal on the data getting the type explicit nature of Rust compiling correctly. Ultimately, Rust forces correctness before compilation time – preventing the most painful part of Go development – interacting with irregular JSON files.
Literally, an irregular JSON file being implemented in Rust could legitimately take you 5x the amount of time to get right. The investment of time in this, on the surface, feels like its a waste and unnecessary – but years down the road, when you re-compile your program and Rust will always have correctness built in, whereas at any point that Go changes the json package, the way the runtime determines type could change. Doesn’t mean that it will or is wants to – but it could. And that potential, matters when you’re choosing a language that you can write once, and compile 25 years later with zero effort. C gives that to you. C++ gives that to you. Rust gives that to you. Go tries to. Higher level languages like Python, PHP and Ruby – can’t by definition and design of their languages.
We consumed the new method in the main() func when we established our let mut store (rust) and store, err := NewTaskStore("tasks.json") (go).
TaskStore’s list Implemented
func (s *TaskStore) List() {
if len(s.tasks) == 0 {
fmt.Println("No tasks yet. Add one with: taskr add "your task"")
return
}
for _, task := range s.tasks {
marker := "[ ]"
if task.Status == StatusDone {
marker = "[X]"
}
fmt.Printf("%s %d - %sn", marker, task.ID, task.Title)
}
}
fn list(&self) {
if self.tasks.is_empty() {
println!("No tasks yet. Add one with: taskr add "your task"");
return;
}
for task in &self.tasks {
let market = match task.status {
Status::Todo => "[ ]",
Status::Done => "[X]",
};
println!("{} {} - {}", market, task.id, task.title);
}
}
Respectively, these two methods look pretty much the same. Specifically in Rust we’re leveraging the borrower for &self.tasks and in Go we just range over them without needing to indicate memory usage. Without the Debug derived decorator attacked to the Task struct, the println!() methods that use {} wouldn’t compile.
TaskStore’s add Implemented
func (s *TaskStore) Add(title string) {
var maxID uint32
for _, t := range s.tasks {
if t.ID > maxID {
maxID = t.ID
}
}
task := Task{
ID: maxID + 1,
Title: title,
Status: StatusTodo,
}
fmt.Printf("Added task %d: %sn", task.ID, task.Title)
s.tasks = append(s.tasks, task)
}
fn add(&mut self, title: String) {
let id = self.tasks.iter() // use the iterator
.map(|t| t.id) // use only the id field
.max() // select the maximum value in the set
.unwrap_or(0) + 1; // if no err, use # else use 0 + 1
let task = Task {
id, // short for `id: id,`
title, // short for `title: title,`
status: Status::Todo,
};
println!("Added task {}: {}", task.id, task.title);
self.tasks.push(task);
}
Most notably between this Go and Rust code you can see the manner for acquiring the next ID in the set is radically different. In Rust we are using the .iter() in order to .map() then .max() with a final check of .unwrap_or(0) in order to get an iterable list of IDs via the .map() extraction of just the id field, followed by the .max() choice. This quickly returns the largest integer of the result and then attempts to + 1 at the end of it. In Rust this expression is a simple statement and in Go its multiple lines using maxID inside the method.
In Rust without the &mut on the self argument of the method, the self.tasks.push(task); would fail because the compiler is missing the &mut on self in order to actually write via .push(). In Go, we simply use append(...).
Both languages now need to take this impl TaskStore { "add" => {} } and func (s *TaskStore) Add(title string) and consume them in the main() func.
func main() {
// ... existing after `switch args[1]`
case "add":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: taskr add " )
os.Exit(1)
}
store.Add(strings.Join(args[2:], " "))
mustSave(store)
}
// ... existing before `default:` case
}
fn main() {
// ... existing after `match args[1].to_str() {`
// 2. taskr add
"add" => {
if args.len() < 3 {
eprintln!("Usage: taskr add " );
std::process::exit(1); // SIGTERM 1
}
let title = args[2..].join(" ");
store.add(title);
store.save()?;
Ok(())
}
// ... existing before `_ => {}` match
}
Our Go code is consuming the mustSave helper method and our Rust code is calling .add() and .save() endpoints on the TaskStore impl.
TaskStore’s delete Implemented
func (s *TaskStore) Delete(id uint32) error {
for i, t := range s.tasks {
if t.ID == id {
fmt.Printf("Removed task %d: %sn", t.ID, t.Title)
s.tasks = append(s.tasks[:i], s.tasks[i+1:]...)
return nil
}
}
return fmt.Errorf("task %d not found", id)
}
fn delete(&mut self, id: u32) -> Result<(), String> {
let pos = self.tasks.iter() // get an iterator
.position(|t| t.id == id) // find the index of the id
.ok_or_else(||
format!("Task {} not found", id)
)?;
let removed = self.tasks.remove(pos);
println!("Removed task {}: {}", removed.id, removed.title);
Ok(())
}
As you can see – these methods look so similar and that’s because Go and Rust compliment each other, like C and C++ complimented each other. They are designed to be similar, but one for more lower level work and the other for more higher level data driven work. Either way, when you implement these methods, you see their implementations even look similar.
case "delete":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: taskr delete " )
os.Exit(1)
}
if err := store.Delete(parseID(args[2])); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
mustSave(store)
"delete" => {
if args.len() < 3 {
eprintln!("Usage: taskr delete");
std::process::exit(1);
}
let id: u32 = args[2].parse().unwrap_or_else(|_| {
eprintln!("Invalid task ID: {}", args[2]);
std::process::exit(1);
});
if let Err(e) = store.delete(id) {
eprintln!("{}", e);
std::process::exit(1); // SIGTERM 1
}
store.save()?;
Ok(())
}
Most notably in the differences between these two implementations, you can see the Go helper method parseID being used here where Rust just leverages .parse().unwrap_or_else(|_| {} ) instead.
TaskStore’s complete | done Implemented
func (s *TaskStore) Complete(id uint32) error {
for i := range s.tasks {
if s.tasks[i].ID == id {
s.tasks[i].Status = StatusDone
fmt.Printf("Completed task %d: %sn", s.tasks[i].ID, s.tasks[i].Title)
return nil
}
}
return fmt.Errorf("task %d not found", id)
}
fn delete(&mut self, id: u32) -> Result<(), String> {
let pos = self.tasks.iter() // get an iterator
.position(|t| t.id == id) // find the index of the id
.ok_or_else(||
format!("Task {} not found", id)
)?;
let removed = self.tasks.remove(pos);
println!("Removed task {}: {}", removed.id, removed.title);
Ok(())
}
The conventions between Rust and Go become very visibly noticeable in this manner of presentation.
case "done":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: taskr done " )
os.Exit(1)
}
if err := store.Complete(parseID(args[2])); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
mustSave(store)
"done" => {
if args.len() < 3 {
eprintln!("Usage: taskr done");
std::process::exit(1); // SIGTERM 1
}
let id: u32 = args[2].parse().unwrap_or_else(|_| {
eprintln!("Invalid task ID: {}", args[2]);
std::process::exit(1); // SIGTERM 1
});
if let Err(e) = store.complete(id) {
eprintln!("{}", e);
std::process::exit(1); // SIGTERM 1
}
store.save()?;
Ok(())
}
TaskStore’s search Implemented
func (s *TaskStore) Search(query string) []*Task {
queryLower := strings.ToLower(query)
var results []*Task
for i := range s.tasks {
if strings.Contains(strings.ToLower(s.tasks[i].Title), queryLower) {
results = append(results, &s.tasks[i])
}
}
return results
}
fn search(&self, query: &str) -> Vec<&Task> {
let query_lower = query.to_lowercase();
self.tasks.iter() // get an iterator
.filter(|task| // each task loop
task.title.to_lowercase() // get lowercase title
.contains(&query_lower) // match with query_lower
)
.collect() // gather into vector
}
Specifically, the Rust is returning Vec<&Task> which is a referenced object explicitly from the .collect() output.
case "search":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: taskr search " )
os.Exit(1)
}
query := strings.Join(args[2:], " ")
results := store.Search(query)
if len(results) == 0 {
fmt.Printf("No tasks found matching %qn", query)
} else {
fmt.Printf("Found %d tasks matching: %sn", len(results), query)
for _, task := range results {
marker := "[ ]"
if task.Status == StatusDone {
marker = "[X]"
}
fmt.Printf("%s %d: %sn", marker, task.ID, task.Title)
}
}
"search" => {
if args.len() < 3 {
eprintln!("Usage: taskr search " );
std::process::exit(1); // SIGTERM 1
}
let query = args[2..].join(" ");
let results = store.search(&query);
if results.is_empty() {
println!("No tasks found matching "{}"", query);
} else {
println!("Found {} tasks matching: {}", results.len(), query);
for task in &results {
let marker = match task.status {
Status::Todo => "[ ]", // incomplete items
Status::Done => "[X]", // completed items
};
println!("{} {}: {}", marker, task.id, task.title);
}
}
Ok(())
}
Conclusion
You can view the Go and Rust full source code respectively at either link provided.
This tutorial was designed to help bring Go engineers over to Rust by completing a command line task manager application in both languages to see how they are both the similar and different.
Are you coming into Rust from Go like I am? If so, what are some of your favorite resources to use and learn from?