Rust and Haskell don’t shy away from powerful features. As a result, both languages have steep learning curves compared with other languages. Trying to learn Rust or Haskell can be frustrating, especially in the first couple of months.
But if you already know Rust, you have a head start with Haskell; and vice versa.
In this article, we want to show how knowledge of one of these languages can help you get up to speed with another.
We won’t cover all the similarities or differences and won’t talk about language domains. Our main goal is to show the bridge between the languages; you can decide whether you want to walk it.
We won’t cover syntax as well, but get ready to switch between indentation and braces, as well as read code in opposite directions. 😉
Basic concepts
Haskell and Rust have both been influenced by the ML programming language. ML has strong static typing with type inference, and so do Haskell and Rust.
There are other similarities:
- algebraic data types;
- pattern matching;
- parametric polymorphism;
- ad-hoc polymorphism.
We’ll cover all of these later in the article, but first, let’s talk about compilers. Both languages focus on safety – they are extremely good at compile-time checks.
Type system
If you’re coming from one of these languages, we don’t have to convince you that types are our friends: they help us avoid silly mistakes and reduce the number of bugs.
Rust and Haskell have similar type systems. Both support conventional basic types, such as integers, floats, booleans, strings, etc. Both make it easy to create new types, use newtypes, and type aliases.
🙂 When using strings, Rust beginners puzzle over String
vs. &str
, and Haskell beginners puzzle over String
vs. Text
vs. ByteString
.
Rust can infer types when possible.
let bools = vec![true, false, true];
let not_head = bools[0].not(); // false, has type bool
But omitting function parameter types is not allowed.
fn get_double_head(ints) {
// ^ error: expected one of `:`, `@`, or `|`
// note: anonymous parameters are removed in the 2018 edition (see RFC 1685)
// help: if this is a parameter name, give it a type
// help: if this is a type, explicitly ignore the parameter name
ints[0] * 2
}
Omitting function return types is also not allowed.
fn get_double_head(ints: Vec<i32>) {
ints[0] * 2
// ^^^^^^^^^^^ expected `()`, found `i32`
// help: try adding a return type: `-> i32`
}
In Rust, we always have to specify both:
fn get_double_head(ints: Vec<i32>) -> i32 {
ints[0] * 2
}
Haskell can infer types when they’re not ambiguous.
let bools = [True, False, True]
let notHead = not (head bools) -- False, has type Bool
We don’t have to annotate function parameters and return types.
getDoubleHead ints =
head ints * 2
But it usually results in a warning, and adding a type signature is a good practice.
getDoubleHead :: [Integer] -> Integer
getDoubleHead ints =
head ints * 2
💡 Note: You can test the Haskell code snippets by pasting them in the REPL. Rust doesn’t have an interactive environment, so you have to reorganize the snippets and use the main
function if you want to give them a try.
Variables and mutability
Rust variables are, by default, immutable – when you want a mutable variable, you have to be explicit.
let immutable_x: i32 = 3;
immutable_x = 1;
// ^^^^^^^^^^^^^^^ error: cannot assign twice to immutable variable
let mut mutable_x = 3; // 3
mutable_x = 1; // 1
mutable_x += 4; // 5
There are no mutable variables in Haskell – the syntax has no such thing as a reassignment statement. However, the value to which the variable is bound may be a mutable cell, such as IORef
, STRef
, or MVar
. The type system tracks mutability, and Haskell does not require a separate mut
keyword.
💡 Note that Haskell allows name shadowing. But it’s discouraged and can be caught with a warning.
let immutable_x = 3
let immutable_x = 1
-- ^^^^^^^^^^^ warning
-- This binding for ‘immutable_x’ shadows the existing binding
In Rust, name shadowing is considered idiomatic. For example, the following code snippet reuses x
and y
:
// parse a string of the form "42,20"
let (x, y) = s.split_once(',').unwrap();
let x: i32 = x.parse().unwrap();
let y: i32 = y.parse().unwrap();
Haskell relies heavily on purely functional data structures, operations on which return new versions of data structures, while the original reference stays unmodified and valid.
For example, if we have a map and want to do some operation on a slightly modified map, we can have a value that keeps the old map but also works with the new map (without much performance cost).
import qualified Data.HashMap.Strict as HashMap
let old = HashMap.fromList [("Donut", 1.0), ("Cake", 1.2)]
let new = HashMap.insert "Cinnamon roll" 2.25 old
print old
-- Prints: fromList [("Cake", 1.2),("Donut", 1.0)]
print new
-- Prints: fromList [("Cake",1.2),("Cinnamon roll", 2.25),("Donut", 1.0)]
💡 Yes, that’s how Haskell prints a map. Yes, it’s weird.
The print
function converts values to strings by using show
and outputs it to the standard output. The result of show
is a syntactically correct Haskell expression, which can be pasted right into the code.
In Rust, standard operations mutate the collection, so there’s no way to access its state before the modification.
use std::collections::HashMap;
let mut prices = HashMap::from([("Cake", 1.2), ("Donut", 1.0)]);
prices.insert("Cinnamon roll", 2.25);
println!("{:?}", prices);
// Prints: {"Cake": 1.2, "Donut": 1.0, "Cinnamon roll": 2.25}
💡 What is {:?}
?
We can use it to debug-format any type. It relies on the fmt::Debug
trait, which should be implemented for all public types.
If we want to emulate Haskell when working with default collections, we have to clone
the old one.
use std::collections::HashMap;
let old = HashMap::from([("Cake", 1.2), ("Donut", 1.0)]);
let mut new = HashMap::new();
new.clone_from(&old);
new.insert("Cinnamon roll", 2.25);
println!("{:?}", old);
// Prints: {"Cake": 1.2, "Donut": 1.0}
println!("{:?}", new);
// Prints: {"Cake": 1.2, "Donut": 1.0, "Cinnamon roll": 2.25}
💡 Note that persistent data structures in Haskell are usually designed for cheap cloning, so most operations have O(log n)
complexity. In Rust, we don’t tend to clone that often, so access and mutation typically have O(1)
and cloning – O(n)
.
💡 Haskell has mutable constructs (such as STArray
and IORef
), which you can use when you need mutability.
Algebraic data types (ADTs)
In simple terms, ADTs are a way to construct types. Haskell uses the single keyword data
to declare product and sum types, while Rust uses struct
and enum
.
To learn more about algebraic data types, check out our article on ADTs in Haskell.
Product types (structs)
In Rust, product types are represented by structs, which come in two forms:
- tuple structs;
- structs with named fields.
// A tuple struct has no names:
struct SimpleItem(String, f64);
// A struct has named fields:
#[derive(Debug)]
struct Item {
name: String,
price: f64,
}
Which is quite similar to Haskell’s datatypes and records.
-- A simple datatype with no names:
data SimpleItem = SimpleItem String Double
-- A record has named fields:
data Item = Item
{ name :: String
, price :: Double
}
deriving (Show)
Note that #[derive(Debug)]
corresponds to deriving (Show)
.
In Haskell, we have to provide a type constructor (the name of our type) and a data constructor (used to construct new instances of the type), which are the same as in the previous snippet.
Creating instances of types
We can create instances of product types. In Rust:
// Creating a tuple struct:
let simple_donut = SimpleItem("Donut".to_string(), 1.0);
// Creating an ordinary struct:
let cake = Item {
name: "Cake".to_string(),
price: 1.2,
};
In Haskell:
-- Creating an ordinary datatype:
let simpleDonut = SimpleItem "Donut" 1.0
-- Creating a record:
let donut = Item "Donut" 1.0
let cake = Item{name = "Cake", price = 1.2}
We can use either the ordinary syntax or the record syntax to create records in Haskell.
Getting field values
In Rust, we can get the value of a field by using dot notation.
let cake_price = cake.price;
println!("It costs {}", cake_price);
// Prints: "It costs 1.2"
In Haskell, we have been using field accessors (basically, getters), such as price
:
let cakePrice = price cake
print $ "It costs " ++ show cakePrice
-- Prints: "It costs 1.2"
But since GHC 9.2, we can use dot notation as well.
💡GHC is the Glasgow Haskell Compiler, the most commonly used Haskell compiler.
let cakePrice = cake.price
print $ "It costs " ++ show cakePrice
-- Prints: "It costs 1.2"
🤑 What is $
?
We use the dollar sign ($
) to avoid parentheses.
Function application has higher precedence than most binary operators. The following usage results in a compilation error:
-- These are the same:
print "It costs " ++ show cakePrice
(print "It costs ") ++ (show cakePrice)
$
is also a function application operator (f $ x
is the same as f x
). But it has very low precedence. The following usage works:
-- These are the same:
print $ "It costs " ++ show cakePrice
print ("It costs " ++ show cakePrice)
Updating field values
In Rust, if the struct variable is mutable, we can change its values.
let mut cake = Item {
name: "Cake".to_string(),
price: 1.2,
};
cake.price = 1.4;
println!("{:?}", cake);
// Prints: Item { name: "Cake", price: 1.4 }
In Haskell, a record update returns another record, and the original one stays unchanged (as we’ve covered in the mutability section).
let cake = Item{name = "Cake", price = 1.2}
let updatedCake = cake{price = 1.4}
print cake
-- Prints: Item {name = "Cake", price = 1.2}
print updatedCake
-- Prints: Item {name = "Cake", price = 1.4}
If we want to do something similar in Rust, we can use struct update syntax to copy and modify a struct.
let pricy_cake = Item { price: 1.6, ..cake };
println!("{:?}", pricy_cake);
// Prints: Item { name: "Cake", price: 1.6 }
Both languages have a simplified field initialization syntax if there are matching names in scope:
let name = "Cinnamon roll".to_string();
let price = 2.25;
let cinnamon_roll = Item { name, price }; // instead of “name: name”
To use it in Haskell, you have to enable GHC2021
or the NamedFieldPuns
extension.
let name = "Cinnamon roll"
let price = 2.25
let cinnamonRoll = Item{name, price} -- instead of “name = name”
Sum types (enums)
Here is an example of a simple sum type:
#[derive(Debug)]
enum DonutType {
Regular,
Twist,
ButtermilkBar,
}
let twist = DonutType::Twist;
data DonutType = Regular | Twist | ButtermilkBar
deriving (Show)
let twist = Twist
The variants don’t have to be boring and can contain fields (which can be unnamed or named).
For example, we can have a Donut
with DonutType
:
#[derive(Debug)]
enum Pastry {
Donut(DonutType),
Croissant,
CinnamonRoll,
}
let twist_donut = Pastry::Donut(DonutType::Twist);
data Pastry
= Donut DonutType
| Croissant
| CinnamonRoll
deriving (Show)
let twistDonut = Donut Twist
Partial field accessors
Haskell allows partial field accessors, while Rust does not.
For example, let’s take Croissant
: the price
field is present in both constructors, while filling
is present only in WithFilling
. In Rust, we get a compilation error when we try to access the filling of a plain croissant. In Haskell, we get a runtime error.
enum Croissant {
Plain { price: f64 },
WithFilling { filling: String, price: f64 },
}
let plain = Croissant::Plain { price: 1.75 };
println!("{}", plain.price)
// ^^^^^
// error[E0609]: no field `price` on type `Croissant`
data Croissant
= Plain {price :: Double}
| WithFilling {filling :: String, price :: Double}
let plain = Plain 1.75
-- This is ok and works as expected:
print $ price plain
-- Prints: 1.75
-- This is not
print $ filling plain
-- runtime error: No match in record selector filling
Pattern matching
We could have used pattern matching to deconstruct values in the previous snippets. To illustrate this, let’s create a function that returns a receipt for a croissant.
fn to_receipt(croissant: Croissant) -> String {
match croissant {
Croissant::Plain { price } => format!("Plain croissant: ${price}"),
Croissant::WithFilling { filling: _, price } => {
format!("Croissant with filling: ${}", price)
}
}
}
let croissant = Croissant::WithFilling {
filling: "Ham & Cheese".to_string(),
price: 3.35,
};
println!("{:?}", to_receipt(croissant));
// Prints: "Croissant with filling: $3.35"
toReceipt :: Croissant -> String
toReceipt croissant = case croissant of
Plain price -> "Plain croissant: $" <> show price
WithFilling _ price -> "Croissant with filling: $" <> show price
print $ toReceipt $ WithFilling "Ham & Cheese" 3.35
-- Prints: "Croissant with filling: $3.35"
Haskell also allows an alternative syntax:
toReceipt :: Croissant -> String
toReceipt (Plain price) = "Plain croissant: $" <> show price
toReceipt (WithFilling _ price) = "Croissant with filling: $" <> show price
Partial patterns
Rust is strict about pattern matches being complete.
fn to_receipt(croissant: Croissant) -> String {
match croissant {
// ^^^^^^^^^
Croissant::Plain { price } => format!("Plain croissant: ${}", price),
}
}
// error[E0004]: non-exhaustive patterns:
// pattern `Croissant::WithFilling { .. }` not covered
While Haskell allows partial pattern matches:
toReceipt :: Croissant -> String
toReceipt croissant = case croissant of
(PlainCroissant price) -> "Plain croissant: $" <> show price
print $ toReceipt $ WithFilling "Ham & Cheese" 3.35
-- runtime error: Non-exhaustive patterns in case
But it’s highly discouraged, and a warning can catch this at compile time.
Pattern match(es) are non-exhaustive
In a case alternative:
Patterns of type ‘Croissant’ not matched: WithFilling _ _
Failure handling
Now, let’s look at two commonly used ADTs: Option
/ Maybe
and Result
/ Either
, as well as the standard ways of dealing with errors.
Option
/ Maybe
and Result
/ Either
Option
has two variants: None
or Some
; Maybe
: Nothing
or Just
.
// Defined in the standard library
enum Option<T> {
None,
Some(T),
}
let head = ["Donut", "Cake", "Cinnamon roll"].get(0);
println!("{:?}", head);
// Prints: Some("Donut")
let no_head: Option<&i32> = [].get(0);
println!("{:?}", no_head);
// Prints: None
-- Defined in the standard library
data Maybe a = Just a | Nothing
safeHead :: [a] -> Maybe a
safeHead (x : _) = Just x
safeHead [] = Nothing
print $ safeHead ["Donut", "Cake", "Cinnamon roll"]
-- Prints: Just "Donut"
print $ safeHead []
-- Prints: Nothing
Result
also has two variants: Ok
or Err
; Either
: Right
and Left
(by convention, Right
is success and Left
is failure).
// Defined in the standard library
enum Result<T, E> {
Ok(T),
Err(E),
}
#[derive(Debug)]
struct DivideByZero;
fn safe_division(x: i32, y: i32) -> Result<i32, DivideByZero> {
match y {
0 => Err(DivideByZero),
_ => Ok(x / y),
}
}
println!("{:?}", safe_division(4, 2));
// Prints: Ok(2)
println!("{:?}", safe_division(4, 0))
// Prints: Err(DivideByZero)
-- Defined in the standard library
data Either a b = Left a | Right b
data DivideByZero = DivideByZero
deriving (Show)
safeDivision :: Int -> Int -> Either DivideByZero Int
safeDivision x y = case y of
0 -> Left DivideByZero
_ -> Right $ x `div` y
print $ safeDivision 4 2
// Prints: Right 2
print $ safeDivision 4 0
// Prints: Left DivideByZero
When we work with either of the types, it’s common to pattern match to deal with different cases. Because it can be tiresome, both languages provide alternatives. Let’s check them out first.
In Rust, we can get a value from an Option
or a Result
by calling unwrap
.
println!("{:?}", safe_division(4, 2).unwrap());
// Prints: 2
println!("{:?}", safe_division(4, 0).unwrap());
// thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: DivideByZero'
Calling it on a None
or Error
will panic the program, defeating the purpose of error handling.
💡 What happens when a panic occurs?
By default, panics will print a failure message, unwind, clean up the stack, and abort the process. You can also configure Rust to display the call stack.
We can use unwrap_or()
to safely unwrap values.
let item = [].get(0).unwrap_or(&"Plain donut");
println!("I got: {}", item);
// Prints: I got: Plain donut
💡 There are a couple of ways to safely unwrap values:
-
unwrap_or()
, which eagerly evaluates the default value; -
unwrap_or_else()
, which lazily evaluates the default value; -
unwrap_or_default()
, which relies on the type’sDefault
trait implementation.
It’s not very idiomatic to get things out of things in Haskell, but the standard library provides a similar function called fromMaybe
.
import Data.Maybe (fromMaybe)
let item = fromMaybe "Plain donut" $ safeHead []
print $ "I got: " ++ item
-- Prints: I got: Plain donut
We prefer to chain things together in Haskell.
We can chain multiple enums and operations in Rust using the and_then()
method. For example, we can sequence a few safe divisions:
let eighth = safe_division(128, 2)
.and_then(|x| safe_division(x, 2))
.and_then(|x| safe_division(x, 2));
println!("{:?}", eighth);
// Prints: Ok(16)
let failure = safe_division(128, 0).and_then(|x| safe_division(x, 2));
println!("{:?}", failure);
// Prints: Err(DivideByZero)
💡 These are lambdas (we’ll cover them in detail later):
|x| x + 2
\x -> x + 2
In Haskell, we use the bind (>>=
) operator.
let eighth =
safeDivision 128 2 >>= \x ->
safeDivision x 2 >>= \x ->
safeDivision x 2
print eighth -- Prints: Right 16
let failure = safeDivision 128 0 >>= \x -> safeDivision x 2
print failure
-- Prints: Left DivideByZero
And last but not least, Rust provides the question mark operator (?
) to deal with Result
and Option
.
use std::collections::HashMap;
let prices = HashMap::from([("Cake", 1.2), ("Donut", 1.0), ("Cinnamon roll", 2.25)]);
fn order_sweets(prices: HashMap<&str, f64>) -> Option<f64> {
let donut_price = prices.get("Donut")?; // early return if None
let cake_price = prices.get("Cake")?; // early return if None
Some(donut_price + cake_price)
}
let total_price = order_sweets(prices);
println!("{:?}", total_price);
// Prints: Some(2.2)
The operation short-circuits in case of failure. For example, if we try to look up and use a non-existing item:
fn order_sweets(prices: HashMap<&str, f64>) -> Option<f64> {
let donut_price = prices.get("Donut")?;
let cake_price = prices.get("Cake")?;
let missing_price = prices.get("Something random")?; // Fails here
Some(donut_price + cake_price + missing_price)
}
let total_price = order_sweets(prices);
println!("{:?}", total_price);
// Prints: None
And in Haskell, we favor do-notation (syntactic sugar to chain special expressions; we are big fans of these).
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HashMap
let prices = HashMap.fromList [("Donut", 1.0), ("Cake", 1.2), ("Cinnamon roll", 2.25)]
orderSweets :: HashMap String Double -> Maybe Double
orderSweets prices = do
donutPrice <- HashMap.lookup "Donut" prices -- early return if Nothing
cakePrice <- HashMap.lookup "Cake" prices -- early return if Nothing
Just $ donutPrice + cakePrice
let totalPrice = orderSweets prices
print totalPrice
-- Prints: Just 2.2
And as expected, if one lookup fails, the whole function fails:
orderSweets :: HashMap String Double -> Maybe Double
orderSweets prices = do
donutPrice <- HashMap.lookup "Donut" prices
cakePrice <- HashMap.lookup "Cake" prices
missingPrice <- HashMap.lookup "Something random" prices -- Fails here
Just $ donutPrice + cakePrice + missingPrice
let totalPrice = orderSweets prices
print totalPrice
-- Prints: Nothing
General failure philosophy
We can split errors into two categories:
- recoverable errors or regular errors (such as a “file not found” error);
- unrecoverable errors or programmer mistakes (aka bugs, such as the “dividing by zero” error).
As we review in the previous section, the first category can be covered by enums. It’s the errors that we either want to handle, report to the user, or retry the operation that caused them.
Let’s look into unrecoverable errors. Rust has a panic!
macro that stops program execution in case of an unrecoverable error.
// Hey users, don't forget to check for 0 yourselves!
fn optimistic_division(x: i32, y: i32) -> i32 {
match y {
0 => panic!("This should never happen"),
_ => x / y,
}
}
optimistic_division(2, 0);
// thread 'main' panicked at 'This should never happen' ...
Even though it’s possible, code like this is generally discouraged in Rust.
Haskell developers painstakingly try to use types to avoid the need for errors in the first place. If you get approvals from at least two other developers and CTO, you can use error
(or impureThrow
):
-- Hey users, don't forget to check for 0 yourselves!
optimisticDivision :: Int -> Int -> Int
optimisticDivision _ 0 = error "This should never happen"
optimisticDivision x y = x `div` y
optimisticDivision 2 0
-- error: This should never happen
-- CallStack (from HasCallStack): error, called at ...
But let’s be honest: we’re all prone to think that we’re smarter than the compiler, which leads to an occasional “this should never happen” error message in the logs. Both standard libraries expose functions that can panic/error (for instance, accessing elements of the standard vector/list by index).
💡 Haskell adds another dimension to failure handling by distinguishing pure functions from potentially impure ones. We’ll cover this later in the article.
Polymorphism
Most polymorphism falls into one of two broad categories: parametric polymorphism (same behavior for different types) and ad-hoc polymorphism (different behavior for different types).
Parametric polymorphism
Rust supports two types of generic code:
- compile-time generics, similar to C++ templates;
- run-time generics, similar to virtual functions in C++ and generics in Java.
Using compile-time generics (which we just call generics) is similar to using types with type variables in Haskell. For example, we can write a generic function that reverses any type of vector.
// [1] [1]
fn reverse<T>(vector: &mut Vec<T>) {
let mut new_vector = Vec::new();
while let Some(last) = vector.pop() {
new_vector.push(last);
}
*vector = new_vector;
}
// [1]: `T` is a type parameter.
// The function is generic over the type `T`.
// That means that `T` can be of any type.
💡 We can use any identifier to signify a type parameter in Rust.
It’s common to name generic types starting from the T
and continuing alphabetically. Sometimes the names can be more meaningful, for example, the types of keys and values: HashMap<K, V>
.
The reverse
function iterates over the elements of a vector using the while
loop. We get the elements in the reverse order because pop
removes the last element from a vector and returns it. We can use this function with any vector:
let mut price_vector: Vec<f64> = vec![1.0, 1.2, 2.25];
reverse(&mut price_vector);
println!("{:?}", price_vector);
// Prints: [2.25, 1.2, 1.0]
let mut items_vector: Vec<&str> = vec!["Donut", "Cake", "Cinnamon roll"];
reverse(&mut items_vector);
println!("{:?}", items_vector);
// Prints: ["Cinnamon roll", "Cake", "Donut"]
We can write a similar function for Haskell lists. Note that the Rust function reverses the vector in place, while the Haskell function returns a new list.
-- [1]
reverseList :: [a] -> [a]
reverseList = go []
where
go accumulator [] = accumulator
go accumulator (current : rest) = go (current : accumulator) rest
-- [1]: `a` is a type variable.
-- That means that `a` can be of any type.
We use the intermediate go
function to recursively go over the original list and accumulate its elements in reverse order.
💡 Type variable names in Haskell start with a lowercase letter.
Conventions:
- Ordinary type variables with no particular meaning:
a
,b
,c
, etc. - Errors:
e
. - Famous typeclasses:
f
(functors, applicatives),m
(monads), etc.
We can use this function with any list:
let priceList = [1.0, 1.2, 2.25]
print $ reverseList priceList
-- Prints: [2.25, 1.2, 1.0]
let itemsList = ["Donut", "Cake", "Cinnamon roll"]
print $ reverseList itemsList
-- Prints: ["Cinnamon roll", "Cake", "Donut"]
Ad-hoc polymorphism
Rust traits and Haskell typeclasses are siblings – their methods can have different implementations for different types.
We’ve already seen (and even derived) one standard trait and typeclass: Debug
and Show
, which allow us to convert types to strings for debugging. Let’s derive and use another one for comparing values.
#[derive(Debug, PartialEq)]
struct Item {
name: String,
price: f64,
}
let donut = Item {
name: "Donut".to_string(),
price: 1.0,
};
let cake = Item {
name: "Cake".to_string(),
price: 1.2,
};
println!("{}", donut == cake);
// Prints: false
println!("{}", donut == donut);
// Prints: true
💡PartialEq
and Eq
.
We can’t derive Eq
for Item
in Rust because Eq
is not implemented for f64
(because NaN != NaN
).
data Item = Item
{ name :: String
, price :: Double
}
deriving (Show, Eq)
let donut = Item "Donut" 1.0
let cake = Item "Cake" 1.2
print $ donut == cake
-- Prints: False
print $ donut == donut
-- Prints: True
It shouldn’t be surprising that we can also define our own typeclasses and traits. For example, let’s create one to tax data.
trait Taxable {
fn tax(&self) -> f64;
}
// It's 10% of the price
impl Taxable for Item {
fn tax(&self) -> f64 {
self.price * 0.1
}
}
println!("{}", donut.tax());
// Prints: 0.1
In Rust, we implement the Taxable
trait for the Item
struct; in Haskell, we create an instance of the Taxable
typeclass for the Item
datatype.
💡 tax
is an instance method – a trait method that requires an instance of the implementing type via the &self
argument. The self
is an instance of the implementing type that gives us access to its internals.
class Taxable a where
tax :: a -> Double
-- It's 10% of the price
instance Taxable Item where
tax (Item _ price) = price * 0.1
print $ tax donut
-- Prints: 0.1
We can implement functions that rely on typeclasses and traits. Such as a function that returns a sum of taxes for a collection of taxable elements.
fn tax_all<T: Taxable>(items: Vec<T>) -> f64 {
items.iter().map(|x| x.tax()).sum()
}
println!("{}", tax_all(vec![donut, cake]));
// Prints: 0.22
In Rust, we use trait bounds to restrict generics: <T: Taxable>
declares a generic type parameter with a trait bound. In Haskell, we use (typeclass) constraints to restrict type variables; in the following snippet, it’s Taxable a
.
taxAll :: Taxable a => [a] -> Double
taxAll items = sum $ map tax items
print $ taxAll [donut, cake]
-- Prints: 0.22
💡 We can use multiple constraints in both languages:
fn tax_everything<T: Taxable, U: Taxable + Eq>(items: Vec<T>, special: U) -> f64
taxEverything :: (Taxable a, Taxable b, Eq b) => [a] -> b -> Double
On the surface, these mechanisms are quite similar, but there are some differences. Rust doesn’t allow orphan instances – a trait implementation must appear in the same crate (package) as either the type or trait definition. This prevents different crates from independently defining conflicting implementations (instances).
In Haskell, we should define instances in the same module where the type is declared or in the module where the typeclass is. Otherwise, Haskell emits a warning.
Also, Rust refuses to accept code with overlapping (ambiguous duplicate) instances. At the same time, Haskell allows several instances that apply to the same type – compilation fails only when we try to use an ambiguous instance.
💡 Fun fact: Haskell also has a mechanism called Generics.
The Generic
typeclass allows us to define polymorphic functions for a variety of data types based on their generic representations – we can ignore the actual datatypes and work with their “shapes” or “structure” (that consists, for example, of constructors and fields).
Advanced topics
We can’t go too deep on these topics because each deserves more attention, but we’ll provide basic comparisons and pointers for further learning.
Metaprogramming
The next abstraction level is compile-time metaprogramming, which helps generate boilerplate code and is represented by macros and Template Haskell.
Macros in Rust are built into the language. They come in two different flavors: declarative and procedural macros (which cover function-like macros, custom derives, and custom attributes). Declarative macros are the most widely used; they are referred to as “macros by example” or, more often, as plain “macros”.
Let’s use them to make pairs.
macro_rules! pair {
($x:expr) => {
($x, $x)
};
}
The syntax for calling macros looks almost the same as for calling functions.
💡 We’ve been using the println!
macro throughout the article.
let pair_ints: (i32, i32) = pair!(1);
println!("{:?}", pair_ints);
// Prints: (1, 1)
let pair_strs: (&str, &str) = pair!("two");
println!("{:?}", pair_strs);
// Prints: ("two", "two")
Template Haskell, also known as TH, is a language extension; the template-haskell
package exposes a set of functions and datatypes for it.
Let’s use it to make pairs.
import Language.Haskell.TH
pair :: ExpQ
pair = [e|\x -> (x, x)|]
We have to define it in another module because top-level splices, quasi-quotes, and annotations must be imported, not defined locally. To use Template Haskell, we have to enable the TemplateHaskell
extension.
{-# LANGUAGE TemplateHaskell #-}
let pairInts :: (Int, Int) = $pair 1
print pairInts
-- Prints: (1, 1)
let pairStrs :: (String, String) = $pair "two"
print pairStrs
-- Prints: ("two", "two")
Template Haskell doesn’t have a spotless reputation: it introduces extra syntax, increases complexity, and in the past, considerably affected compilation speed (it’s way better these days). Some people prefer to use alternative methods of reducing boilerplate, such as Generics (Generic
typeclass).
💡 Later versions of GHC support typed Template Haskell, which type-checks the expressions at their definition site rather than at their usage (like regular TH).
Runtime and concurrency
Rust has no built-in runtime (scheduler). The standard library allows using OS threads directly through std::thread
. For example, we can spawn a thread:
use std::thread;
use std::time::Duration;
thread::spawn(|| {
thread::sleep(Duration::from_secs(1));
println!("Hello from the spawned thread!");
});
println!(“Spawned a thread”);
thread::sleep(Duration::from_secs(2));
println!("Hello from the main thread!");
The Rust language defines async/await
but no concrete execution strategy.
#[derive(Debug)]
struct Donut;
async fn order_donut() -> Donut {
println!("Ordering a donut");
Donut
}
async fn eat(donut: Donut) {
println!("Eating a donut {:?}", donut)
}
async fn order_and_consume() {
// Wait for the order before consuming it.
// `await` doesn't block the thread
let donut = order_donut().await;
eat(donut).await;
}
// Blocks the current thread until the future is completed
use futures::executor::block_on;
block_on(order_and_consume());
We use the standard futures
, which has its own executor but is not a full runtime. To get an asynchronous runtime in Rust, we have to use external libraries, such as Tokio
and async-std
.
Here is a snippet of code that uses Tokio
to do some concurrent work using three different services and then either collect the successful result or return a timeout error (all the other errors are ignored for simplicity):
use tokio::time::timeout;
// Imagine `do_work` does some meaningful work
// async fn do_work(work: &Work, service: &Service) -> String
async fn concurrent_program(work: &Work) -> Result {
timeout(Duration::from_secs(2), async {
tokio::join!(
do_work(work, &Service::ServiceA),
do_work(work, &Service::ServiceB),
do_work(work, &Service::ServiceC)
)
})
.await
.map(|(a, b, c)| Result::Success(vec![a, b, c]))
.unwrap_or_else(|_| Result::Timeout)
}
// Still have to run this program
concurrent_program(&some_work).await;
Note that this will run all the work concurrently on the same task. If we want to do the job simultaneously or in parallel, we need to call tokio::spawn
for each of the tasks to spawn and run it.
💡 Rust doesn’t oblige you to stick to one type of concurrency model (such as threads, async, etc.). You can use any if you find a suitable crate (library).
Haskell has green threads – the runtime system manages threads (instead of directly using native OS threads). The essential operation is forking a thread with forkIO
:
import Control.Concurrent (threadDelay, forkIO)
forkIO $ do
threadDelay $ 1000 * 1000
print "Hello from the spawned thread!"
print "Spawned a thread"
threadDelay $ 2 * 1000 * 1000
print "Hello from the main thread!"
Various libraries in Haskell take advantage of green threads to provide powerful and composable APIs. For example, the async
package (library). The following snippet uses async
to do concurrent work with three services and then assembles the result.
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (mapConcurrently)
import System.Timeout (timeout)
-- Imagine `doWork` does some meaningful work
-- doWork :: Work -> Service -> IO PartialResult
concurrentProgram :: Work -> IO Result
concurrentProgram work = do
results <-
timeout twoSeconds $
mapConcurrently (doWork work) [ServiceA, ServiceB, ServiceC]
pure $ case results of
Just success -> Success success
Nothing -> Timeout
where
twoSeconds = 1000 * 1000
-- Run the program
concurrentProgram someWork
And we have to mention the Software Transactional Memory (STM) abstraction in Haskell, which allows us to group multiple state-changing operations and perform them as a single atomic operation.
Imagine the situation: I have $0 and want to buy a donut for $3; also, I earn $1 per second. I can keep trying to buy a donut until I have sufficient funds. We have a typical money transfer transaction, which needs to be retried and can be simulated using STM. We don’t need to worry about transaction rollbacks or retries – STM handles all of these for us.
runSimulation :: IO ()
runSimulation = do
-- Start with 0
wallet <- newTVarIO 0
cashRegister <- newTVarIO 100
-- Get paid at the same time
_ <- forkIO $ getPaid wallet
-- Try to make a donut-money transaction
atomically $ do
myCash <- readTVar wallet
-- Check if I have enough money
check $ myCash >= donutPrice
-- Subtract the amount from my wallet
writeTVar wallet (myCash - donutPrice)
storeCash <- readTVar cashRegister
-- Add the amount to the cash register
writeTVar cashRegister (storeCash + donutPrice)
myFinal <- readTVarIO wallet
print $ "I have: $" ++ show myFinal
storeFinal <- readTVarIO cashRegister
print $ "Cash register: $" ++ show storeFinal
where
donutPrice = 3
-- Put $1 in my wallet every second
getPaid :: TVar Int -> IO ()
getPaid wallet = forever $ do
threadDelay $ 1000 * 1000
atomically $ modifyTVar wallet (+ 1)
print "I earned a dollar"
💡 TVar
stands for transactional variable, which we can read or write to within STM, using operations such as readTVar
and writeTVar
. We can perform a computation in the STM using the atomically
function.
When we run the program, we see that I must be paid at least three times before the transaction succeeds:
runSimulation
-- Prints:
-- "I earned a dollar"
-- "I earned a dollar"
-- "I earned a dollar"
-- "I have: $0"
-- "Cash register: $103"
Functions and functional programming
Functions are widespread in both languages. Haskell and Rust support higher-order functions. Haskell is always functional, and functional Rust is frequently just proper, idiomatic Rust.
Lambdas and closures
A lambda is an anonymous function that can be treated like a value. We can use lambdas as arguments for higher-order functions.
In Rust:
let bump = |i: f64| i + 1.0;
println!("{}", bump(1.2));
// Prints: 2.2
let bump_inferred = |i| i + 1.0;
println!("{}", bump_inferred(1.2));
// Prints: 2.2
In Haskell:
let bump = \(i :: Double) -> i + 1.0
print $ bump 1.2
-- Prints: 2.2
let bumpInferred = \i -> i + 1.0
print $ bumpInferred 1.2
-- Prints: 2.2
💡 Note that annotating a type variable might require the ScopedTypeVariables
extension, depending on your usage. But also, writing lambdas like that can be redundant in Haskell. We could have written a normal function:
let bump :: Double -> Double
bump i = i + 1.0
We can use closures to capture values from the scope/environment in which they’re defined. For example, we can define a simple closure that captures the outside
variable:
|x| x + outside
\x -> x + outside
In Rust, each value has a lifetime. Because closures capture environment values, we have to deal with their ownership and lifetimes. For example, let’s write a function that returns a greeting function:
fn create_greeting() -> impl Fn(&str) -> String {
let greet = "Hello,";
move |name: &str| format!("{greet} {name}!")
}
let greeting_function = create_greeting();
println!("{}", greeting_function("Rust"));
// Prints: Hello, Rust!
We use move
to force the closure to take ownership of the greet
variable.
💡 If we forget to use move
, we get an error.
error[E0373]: closure may outlive the current function, but it borrows `greet`, which is owned by the current function
|
| |name: &str| format!("{greet} {name}!")
| ^^^^^^^^^^^^ ----- `greet` is borrowed here
| |
| may outlive borrowed value `greet`
|
note: closure is returned here
|
| |name: &str| format!("{greet} {name}!")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `greet` (and any other referenced variables), use the `move` keyword
|
| move |name: &str| format!("{greet} {name}!")
| ++++
💡 What is Fn
?
How a closure captures and handles values from the environment determines which traits (one, two, or all three) it implements and how the closure can be used. There are three traits:
-
FnOnce
(can be called once); -
FnMut
(can be called more than once, may mutate state); -
Fn
(can be called more than once, no state mutation).
In Haskell, we don’t worry much about closures.
Currying and partial application
Currying is converting a function that takes multiple arguments into a function that takes them one at a time. Each time we call a function, we pass it one argument, and it returns another function that also takes one argument until all arguments are passed.
In Haskell, all functions are considered curried, which might not be obvious because it’s hidden in the syntax: Double -> Double -> Double
is actually Double -> (Double -> Double)
and add x y
is actually (add x) y
.
add :: Double -> Double -> Double
add x y = x + y
bump :: Double -> Double
bump = add 1.0
full :: Double
full = add 1.0 1.2
When we pass 1.0
to add
, we get another function, bump
, which takes a double, adds 1 to it, and returns that sum as a result.
add x y = x + y
bump = add 1.0
print $ bump 1.2
-- Prints: 2.2
bump
is the result of partial application, which means we pass less than the total number of arguments to a function that takes multiple arguments.
We can do this in Rust, but it’s not idiomatic.
fn add(x: f64) -> impl Fn(f64) -> f64 {
move |y| x + y
}
let bump = add(1.0);
println!("{}", bump(1.2));
// Prints: 2.2
There are crates that make it easier like partial_application:
use partial_application::partial;
fn add(x: f64, y: f64) -> f64 {
x + y
}
let bump2 = partial!(add => 1.0, _);
println!("{}", bump(1.2));
// Prints: 2.2
Currying and partial application are convenient when we pass functions around as values and allows us to use neat features, such as composition.
Function composition
In Haskell, we can use function composition, which pipes the result of one function to the input of another, creating an entirely new function.
💡 We use the dot operator (.
) to implement function composition in Haskell.
For example, we can compose three functions to get a maximum price from the list and negate it.
import qualified Data.HashMap.Strict as HashMap
-- HashMap.elems :: HashMap String Double -> [Double]
-- maximum :: [Double] -> Double
-- negate :: Double -> Double
negativeMaxPrice :: HashMap String Double -> Double
negativeMaxPrice = negate . maximum . HashMap.elems
let prices = HashMap.fromList [("Donut", 1.0), ("Cake", 1.2)]
print $ negativeMaxPrice prices
-- Prints: -1.2
We can technically do it in Rust, but it’s not commonly used and not part of the standard library.
Iterators
In Rust, the iterator pattern allows us to traverse collections. Iterators are responsible for the logic of iterating over each item and determining when the sequence has finished.
For example, let’s use iterators to get the total price of all the products except for donuts:
use std::collections::HashMap;
let prices =
HashMap::from([("Donut", 1.0), ("Cake", 1.2), ("Cinnamon roll", 2.25)]);
let total: f64 = prices
.into_iter()
.filter(|&(name, _)| name != "Donut")
.map(|(_, price)| price)
.sum();
println!("{}", total);
// Prints: 3.45
Iterators are lazy — they don’t do anything unless they are consumed. We can also use collect
to transform an iterator into another collection. For example, we can return the prices instead:
use std::collections::HashMap;
let prices =
HashMap::from([("Donut", 1.0), ("Cake", 1.2), ("Cinnamon roll", 2.25)]);
let other_prices: Vec<f64> = prices
.into_iter()
.filter(|&(name, _)| name != "Donut")
.map(|(_, price)| price)
.collect();
println!("{:?}", other_prices);
// Prints: [1.2, 2.25]
In Haskell, we work with collections directly.
import qualified Data.HashMap.Strict as HashMap
let prices =
HashMap.fromList [("Donut", 1.0), ("Cake", 1.2), ("Cinnamon roll", 2.25)]
let total =
sum
$ HashMap.elems
$ HashMap.filterWithKey (\name _ -> name /= "Donut")
$ prices
print total
-- Prints: 3.45
import qualified Data.HashMap.Strict as HashMap
let prices =
HashMap.fromList [("Donut", 1.0), ("Cake", 1.2), ("Cinnamon roll", 2.25)]
let otherPrices =
HashMap.elems $
HashMap.filterWithKey (\name _ -> name /= "Donut") prices
print otherPrices
-- Prints: [1.2, 2.25]
Associated functions and methods
In Rust, we can connect functions to particular types — via associated functions or methods. Associated functions are called on the type, and methods are called on a particular instance of a type.
struct Item {
name: String,
price: f64,
}
impl Item {
fn free(name: String) -> Item {
Item { name, price: 0.0 }
}
fn print_receipt(&self) {
println!("{}: ${}", self.name, self.price);
}
}
// Calling an associated function:
let free_donut = Item::free("Regular donut".to_string());
// Calling a method:
free_donut.print_receipt();
// Prints: Regular donut: $0
In Haskell, we can’t and don’t.
Things we worry about in Rust
Speaking of the things we don’t do in Haskell. It shouldn’t be a surprise that the feature that sets Rust apart is its ownership model and the borrow checker.
Remember how we used move
to force the closure to take ownership of the variable?
fn create_greeting() -> impl Fn(&str) -> String {
let greet = "Hello,";
move |name: &str| format!("{greet} {name}!")
}
Rust is a statically memory-managed language, which requires us to think about the lifetimes of values.
💡 If you want to learn more about this topic, check out the Understanding Ownership chapter of the Rust book.
And in Haskell? In Haskell, we have space leaks (but we usually don’t worry about those). 😉
Things we worry about in Haskell
Laziness
Haskell programs are executed using lazy evaluation, which doesn’t perform a computation until its result is required and avoids unnecessary computation.
Laziness encourages programming in a modular style without worrying about intermediate data and allows working with infinite structures. Let’s illustrate this by writing a function that returns the first prime number larger than 10 000
. We can start with an infinite list of naturals, filter the prime numbers, and take the first prime after 10 000
.
naturals :: [Int]
naturals = [1 ..]
-- Check there is no divisors for `n`
isPrime :: Int -> Bool
isPrime n = null [number | number <- [2 .. n - 1], n `mod` number == 0]
print $ take 1 $ filter (> 10000) $ filter isPrime naturals
-- Prints: [1000003]
Because of laziness, the work stops right after it finds the correct prime number – no need to calculate the rest of the list or the rest of the primes.
This computation takes ~40MB
of memory (YMMV), primarily the runtime overhead. If we increase the number to 100 000
, the memory usage stays the same.
All good so far. What if we try something else? Let’s take 1 000 000
naturals and return their sum along with the length of the list.
let nums = take 10000000 naturals
print $ (sum nums, length nums)
-- (500000500000,1000000)
This computation takes ~129MB
of memory (YMMV). And if we take 10 000 000
– the memory usage goes up to ~1.376GB
. Oops.
💡 Why does it happen?
Because the nums
list is used for both sum
and length
computations, the compiler can’t discard list elements until it evaluates both.
Note that sum $ take 10000000 naturals
runs in constant memory.
💡 If you want to learn more, check out our videos on laziness.
So, it’s favorable to be mindful of how expressions are evaluated when working with data in Haskell to avoid space leaks.
Purity
Haskell is pure – invoking a function with the same arguments always returns the same result.
As a consequence, in Haskell, there is no distinction between a zero-argument function and a constant.
f(x, y) // functino with two arguments
f(x) // function with one argument
f() // function with zero arguments
C // constant
f x y -- function with two arguments
f x -- function with one argument
F -- zero argument / constant
c -– zero argument / constant
💡 Pure functions do not have side effects.
Purity, together with laziness, raises some challenges. Without digging too deep into the rabbit hole, let’s look at an example pseudo-Haskell code. The following impure example reads 2 integers from the standard input and returns them as a list (pay attention to the order):
-- `readNumber` is an impure function that reads a number from stdin
-- some impure computation
pseudoComputation =
let first = readNumber
let second = readNumber
print [second, first]
As we’ve learned, because of laziness, Haskell doesn’t evaluate an expression until it is needed: evaluation of first
and second
is postponed until they are used, and when it starts forcing the list, it starts with reading the second
number and then first
. Which is the opposite of what we want and expect.
To deal with this mess, Haskell has IO
– a special datatype that offers better control over its execution. IO
is a description of a computation. When executed, it can perform arbitrary effects before returning a value of type a
.
💡 Executing IO
is not the same as evaluating it. Evaluating an IO
expression is pure – it returns the same description. For example, io1
and io2
are guaranteed to be the same:
-- some IO function
-- doSomeAction :: String -> IO ()
let variable = doSomeAction "parameter"
let io1 = (var, var)
let io2 = (doSomeAction "parameter", doSomeAction "parameter")
We’ve been using the print
function to print things out; here is its type signature:
print :: Show a => a -> IO ()
We can’t print outside of IO
. We get a compilation error if we try otherwise:
noGreet :: String -> String
noGreet name = print $ "Hello, " <> name
-- ^^^^^^^^^^^^^^^^^^^^^^^^^
-- Couldn't match type: IO ()
-- with: String
Okay, we have an IO
function. How do we run it? We need to define the program’s main
IO
function – the program entry point – which the Haskell runtime will execute. Let’s look at the executable module example: it asks the user for the name, reads it, and prints the greeting.
module Main where
greet :: String -> IO ()
greet name = print $ "Hello, " <> name
main :: IO ()
main = do
print "What's your name?"
name <- getLine
greet name
💡 Remember the do-notation syntax that we used with Maybe
and Either
? We can use it with IO
as well! It’s convenient to put actions together.
This differs from all the languages in which we can perform side effects anywhere and anytime.
For completeness, this is how a main module looks like in Rust:
fn main() {
let two = one() + one();
println!("Hello from main and {}", two);
}
fn one() -> i32 {
println!("Hello from one");
1
}
Note that we can execute arbitrary side effects.
Higher-order programming
In Haskell, we prefer general solutions for common patterns. For example, do-notation is syntactic sugar for the bind operator (>>=
), which is overloaded for a bunch of types: Maybe
, Either e
, []
, State
, IO
, etc.
do
x <- action1
y <- action2
f x y
-- Desugared version:
action1 >>= \x ->
action2 >>= \y ->
f x y
As a result, we can use the notation to express many problems and deal with many use cases: optionality, non-determinism, error handling, state mutation, concurrency, etc.
-- Dealing with optionality:
maybeSweets :: HashMap String Double -> Maybe Double
maybeSweets prices = do
donutPrice <- HashMap.lookup "Donut" prices
cakePrice <- HashMap.lookup "Cake" prices
Just $ donutPrice + cakePrice
-- Dealing with IO:
ioSweets :: Statistics -> SweetsService -> IO Double
ioSweets statistics sweets = do
print "Fetching sweets from external service"
prices <- fetchPrices sweets
updateCounters statistics prices
pure prices
This is one of the design patterns for structuring code in Haskell. We implement them using typeclasses, and they are supported by laws (a whole different topic). When Haskell developers see that the library provides a datatype or an interface related to one of these typeclasses, they have some expectations and guarantees about its behavior.
💡 Top 7 useful typeclasses everyone should know about:
Semigroup
, Monoid
, Functor
, Applicative
, Monad
, Foldable
, and Traversable
.
Higher-kinded types are the basis for these typeclasses. A higher-kinded type is a type that abstracts over some polymorphic type. Take, for instance, f
in the following Functor
definition:
class Functor f where
fmap :: (a -> b) -> f a -> f b
f
can be Option
, []
, IO
, etc.; while f a
can be Option Int
, [String]
, IO Bool
, etc.
You can check out Kinds and Higher-Kinded Types for a more detailed explanation.
Rust doesn’t support higher-kinded types (yet), and it’s more complex to implement concepts such as monads and their friends. Instead, Rust provides alternative approaches to solve the problems that these typeclasses solve in Haskell:
- If you want to deal with optionality or error handling, you can use
Option
/Result
with the?
operator. - If you want to deal with async code –
async
/.await
. - etc.
Conclusion
Turns out Rust and Haskell have a lot in common: both languages have a lot of features and can be frustrating to learn. Luckily they share a lot of concepts, and knowledge of one language can be helpful while pursuing another.
And please remember that in both cases, the compiler has your back, so don’t forget that compilers are your friends. 😉
For more articles on Haskell and Rust, follow us on Twitter or subscribe to the newsletter via the form below.
Top comments (0)