Strongly static typed
(similar to Haskell)
Hindley-Milner type system (this means type inference)
(Compare to Haskell, it does not have Higher-Kinded Types)
Linear / Affine types via ownership system that eliminates GC
(Unique feature of Rust)
RAII for resource management
(Similar to bracket pattern / with pattern in Haskell)
Immutable by default
(Similar to Haskell)
Rust’s strength: system programming, low-level performance with safety and good algebraic abstractions provided by functional style programming.
If
You love FP style and concepts
You want low-level control and ultimate performance, want to avoid GC if possible
You want to write safe code without worrying about memory bugs in C/C++
Then, you will definitely love Rust!
Every value has a unique owner.
tracks resource and eliminates GC. when a value does not have an owner it is dropped automatically.
At any time, there can be <= 1 mutable reference or any number of immutable references to any value.
This is the most FP-spirit axiom in Rust brings referential transparency and eliminates a lot of bugs related to misuse / overuse of state, mutation, and shared mutable state.
References cannot outlive its owner.
prevents dangling pointers and use-after-free bugs.
The following examples contains all core concepts of Rust’s ownership system and functional programming features. If you understand these examples, you will have a good mental model of Rust!
fn main() {
let x = String::from("hello");
let y = x; // x is moved to y
println!("x = {}, y = {}", x, y); // Error: x is no longer valid
}You don’t need to worry about move if the type is cheap and implements the Copy trait:
fn main() {
let x = 5;
let y = x; // x is copied to y
println!("x = {}, y = {}", x, y); // OK: x is still valid
}For the string, you will have to clone it explicitly:
fn main() {
let x = String::from("hello");
let y = x.clone(); // x is cloned to y
println!("x = {}, y = {}", x, y); // OK: x is still valid
}Rust’s three axiom requires that there can be either one mutable reference or any number of immutable references to a value at a time.
This axiom, together with immutability by default, gives referential transparency (which is the most important principle in FP) and mimics functional programming style very well.
fn main() {
let mut x = 5;
let y = &x; // immutable reference
let z = &mut x; // Error: cannot borrow `x` as mutable because it is also borrowed as immutable
println!("y = {}, z = {}", y, z);
}Here is an example of function taking ownership and mutates the value, but still a pure function because of the linearity:
fn append_exclamation(mut s: String) -> String {
s.push('!');
s
}This function does not break referential transparency (in FP viewpoint) because no other references to s can exist after the call to append_exclamation. It consumes an input and produces an output, from the caller’s perspective, the function exhibits no observable side effects.
Let’s do a side by side comparison
Rust
enum Void {} // A type with no inhabitants
enum Unit { // A type with a single inhabitant (no information)
Unit,
}
enum Option<T> { // A type representing an optional value
None,
Some(T),
}
enum Result<T, E> { // A type representing either success or failure
Ok(T),
Err(E),
}Haskell
data Void -- A type with no inhabitants
data Unit = Unit -- A type with a single inhabitant (no information)
-- Note: Unit is also canonically represented as ()
data Maybe a = Nothing | Just a -- A type representing an optional value
data Either a b = Left a | Right b
-- A type representing either success or failure
-- or an ad-hoc sum type with two type parametersCanonical example: linked list
Rust
enum List<T> {
Nil,
Cons(T, Box<List<T>>), // Box is a smart pointer for heap allocation
}(These two work exactly the same, the Haskell code contains a hidden pointer under the hood.)
Haskell
data List a = Nil | Cons a (List a)Note: Writing Cons(T, List<T>) won’t work here because in Rust, things are low-level and every constructor needs to be ‘unpacked’, rust needs to know the size of each variant at compile time to determine how much stack space to allocate. We have to use a Box (which is a smart pointer, of known size) to wrap and create an indirection to the recursive type reference.
Since Rust has excellent ADT support, and uses the same underlying type theory with Haskell, you would expect Pattern match to be very well supported: you can easily nest patterns.
Rust
enum PubUser {
PubUser { name: String, email: Option<String> },
AdminUser { name: String, level: u8 },
}
match user {
PubUser { name, email : Some(e) }
=> println!("{}'s email is {}", name, s),
PubUser { name, email : None }
=> println!("{}'s does not have an email.", name),
_ => println!("Not a public user."),
}Haskell
data PubUser = PubUser { name :: String, email :: Maybe String }
| AdminUser { name :: String, level :: Int }
case user of
PubUser { name = n, email = Just e } ->
putStrLn (n ++ "'s email is " ++ e)
PubUser { name = n, email = Nothing } ->
putStrLn (n ++ "'s does not have an email.")
_ -> putStrLn "Not a public user."Traits in Rust are almost identical to type classes in Haskell. Their mental models are completely the same, only syntax and implementation details differ.
Rust
pub trait Clone {
fn clone(&self) -> Self;
}
impl Clone for PubUser {
fn clone(&self) -> Self {
match self {
PubUser::PubUser { name, email } => PubUser::PubUser {
name: name.clone(),
email: email.clone(),
},
PubUser::AdminUser { name, level } => PubUser::AdminUser {
name: name.clone(),
level: *level,
},
}
}
}You don’t really need to implement Clone in Haskell because everything is immutable and cloned upon modification.
Haskell
class Clone a where
clone :: a -> a
instance Clone PubUser where
clone (PubUser name email) = PubUser name (clone email)
clone (AdminUser name level) = AdminUser name levelIn FP we care about declarative style and avoids imperative loops as much as possible.
Rust has a powerful iterator system that allows for declarative data manipulation similar to Haskell’s lazy lists and combinators.
Rust
let numbers = vec![1, 2, 3, 4, 5];
let doubled_even: Vec<i32>
= numbers.iter()
. filter(|&x| x % 2 == 0)
. map(|x| x * 2)
. collect();Haskell
let numbers = [1, 2, 3, 4, 5]
let doubled = map (*2) (filter even numbers)Less known is that Rust also has fold, just like FP languages:
Rust
let sum: i32 = numbers.iter().fold(0, |acc, &x| acc + x);Haskell
let sum = foldl' (+) 0 numbersWhen you write a function that takes another function as an argument in Rust, you must make a choice and use one of the three function traits: FnOnce, FnMut, and Fn.
Functions specified with FnOnce will only be allowed to be called once, they are the closures that take ownership of captured variables.
(This is similar to linear types in Haskell (a -> b) %1 -> c)
Functions specified with FnMut can be called multiple times, they are closures that mutably borrow captured variables.
Functions specified with Fn can be called multiple times, they are closures that immutably borrow captured variables.
(‘pure’ functions automatically fall under this category)
Here is a classical higher order function, that tries to apply a function to an argument:
Rust
fn apply<F, A, B>(f: F, x: A) -> B
where
F: Fn(A) -> B,
{
f(x)
}
fn main() {
let result = apply(|x| x + 1, 5);
println!("{}", result); // Output: 6
}Haskell
apply :: (a -> b) -> a -> b
apply f x = f x
main :: IO ()
main = do
let result = apply (\x -> x + 1) 5
print result -- Output: 6Unfortunately writing higher order functions are not so ergonomic in Rust due to the complexity of function traits and lifetimes, but it is still very much possible. Here is an example of function composition:
Rust
fn compose<F, G, A, B, C>(f: F, g: G) -> impl Fn(A) -> C
where
F: Fn(B) -> C,
G: Fn(A) -> B,
{
move |x| f(g(x))
}Move is necessary here because the returned closure may outlive the parameters f and g, you have to move their ownership into the returned closure.
Haskell
compose :: (b -> c) -> (a -> b) -> (a -> c)
compose f g x = f (g x)Rust is a great language for functional programming enthusiasts who want low-level control and performance without sacrificing safety and ergonomic type systems.
Its ownership system, algebraic data types, pattern matching, and trait system make it a powerful tool for writing clean and efficient code.
Higher-Kinded Types: Haskell has Higher-Kinded Types (HKT) which Rust does not support yet. This means you can’t say things like Monad, Functor, Applicative in Rust.
Purity: Haskell is the only pure functional programming language which means complete separation of pure functions and side-effects through its type system.
Rust is not a pure functional programming language as any function allows side effects, but this is not a big problem, its still functional enough (most functional languages are NOT pure!).
Its ownership system and borrowing rules help to mimic referential transparency and eliminate shared mutable state effectively.
Haskell has lazy evaluation which allows for more modular code and infinite data structures. But laziness is confusing on its own.
The purity of Haskell opens the door for STM superpower (Software Transactional Memory) which allows for composable memory transactions in concurrent programming.
Together with purity, green threads, concurrent and parallel programming in Haskell very easy and elegant. (But this requires a runtime!). It is well received that Haskell has the best single-machine concurrency experience. In comparison, async Rust is notoriously difficult to learn and use.
Ease of use of higher-order functions: Haskell’s syntax and type inference make it very easy to write and use higher-order functions.
Monads and do-notation: Haskell has great support for monads (Option and Result are monads!) and do-notation which makes it easy to work with side effects, error handling in clean, systematic, and composable way.
Advanced ‘enhanced HM’ type system features: Haskell has many more advanced type system features like multi-parameter typeclasses, GADTs, Type Families, Data Kinds, etc.