F# is the .NET Rust

jkone27
16 min readMay 19, 2023

--

🦀 Rust is a very cool language! let’s compare it with F#!

Ferris the Crab — https://rustacean.net/, the unofficial Rust Mascotte

Intro

I am no Rust developer, I mostly develop web APIs in .NET and C#, but I do love F# and Rust got lots of my attention lately, as it seems very closely related in language syntax and type system to my very own favourite F#. If you encounter any mistake excuse me and feel free to correct me, please do comment and add your remarks I will try to amend the article asap.

My main claim here is that if you recently learned Rust or want to learn Rust, as it seems to be one of the most beloved languages, you might fancy F# as well, as they are very much close cousins with a common ancestor.

If you want to quickly give Rust and F# a try, you can test them from within your browser on their respective playgrounds:

1. What Are They?

(Rust is) A language empowering everyone
to build reliable and efficient software. — src

Rust is a language that emphasizes, amongst others, memory safety, while F# is a functional-first programming language that is known for its expressive type system and seamless integration with the .NET ecosystem, so it is a GC (garbage collected) language with regards to memory management.

F# also integrates with other languages thanks to the amazing Fable compiler, and recently also compiles to Rust!

F# is a universal programming language for writing succinct, robust and performant code. — src

I would say the Universality of the above statement stems not only from the fact that F# is an official .NET language, thus being available both as server-side and client-side language via Blazor/Wasm but also thanks to Fable, which allows F# to dip into the node.js ecosystem, the Python ecosystem and other languages as well, such as Rust, Dart and Php. The support for languages other than Javascript is still in the testing phase but quite promising and exciting!

Fable is a compiler that brings F# into the JavaScript ecosystem [and more] — src

2. Get Started

With .NET and F#, you need thedotnet-sdk, and then you are ready to go

brew install dotnet-sdk // mac
apt install dotnet-sdk // ubuntu
winget install Microsoft.DotNet.SDK.6 // win


dotnet --version // nuget package manager comes for free

dotnet fsi // F# has a REPL like python
> "hello";;
> #quit;;

For rust, you will need rustup. In the Rust development environment, all tools are installed to the ~/.cargo/bin directory and this is where you will find the Rust toolchain, including rustc, cargo, and rustup. Set it up in your path variable on Windows or source in Nix after the installation if you cannot access Rust commands.

brew install rustup // mac
apt install rustup // ubuntu (a bit more check google...)
winget install Rustlang.Rustup // win

rustup init // initializes rust and cargo package manager

rustc --version

2.1 — Create a new Project

dotnet new console -lang F# -o Test.FSharp.Project
tree output

In Rust

cargo new hello_cargo
tree output

2.2 — The Project File

F# and .net in general make use of the XML project file format: .csproj for C# and .fsproj for F#.

The project files among .NET languages are very similar with some minor differences. In our case the F# compiler requires file ordering and explicit file inclusion, to avoid circular dependencies.

This means that your last file is always gonna be the entry point of your program or the main module in the case of a library, with all dependencies on its top (a bit like C and C++).

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>

<!-- dependencies: more in the next section
<ItemGroup>
<PackageReference Include="..." Version="..." />
</ItemGroup>
-->

</Project>

Rust uses instead the TOML format for its project file, a bit like node.js rust has a cargo.toml and a cargo.lock file, one for requesting dependencies and the other for pinning down installed dependencies for production environments.

[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

F# has also a native package manager called paket, which behaves very similarly in regards to the lock file, but nowadays it is much easier to get started with the default commands of dotnet, and there is no need to add or install paket, especially if you are getting started.

If you wish to do so you can install it as a dotnet tool, and explore it for yourself as I will not dive into it now, but is truly a very cool dotnet package manager.

2.3 — Add a Package

dotnet add <project> package Package.Name // in another project folder
dotnet add package Package.Name // in the same folder

In Rust

cargo add crate_name

2.4 — Add a Project Reference

dotnet add reference <path_to_referenced_project.csproj>

In Rust from my understanding, there is no cli commands to do so, you need to manually edit the workspaces in the .toml file.

2.5 — Test, Build, Watch and Run

dotnet build
dotnet test
dotnet run
dotnet watch run

almost the same

cargo build
cargo test
cargo run
cargo watch -x 'run'

2.6 — Package and Publish your Library (To Nuget or Crates.io)

dotnet pack -c Release
dotnet nuget push <path_to_nupkg> --source <nuget_source> --api-key <api_key>

In Rust is all much easier here, looks easy and makes sense! love it.

cargo login // to crates.io
cargo publish

3. Similarities

type system diagram of some commonly known langs

Both F# and Rust are strongly typed languages, they allow developers to write uncluttered, self-documenting code, where your focus remains on your problem domain, rather than the lower details of programming. How? One of the key areas of similarities between Rust and F# is their type systems.

3.1 — Variable

In F#

let x = 5

let mutable x = 5

In Rust

let x = 5;

let mut x = 5;

3.2 — Function

in F#

let add x y = 
x + y

In Rust (also doesn’t need return), we see F# type inference algorithm works slightly better than Rust here, more on this in the next chapter.

fn add_fn(a: i32, b: i32) -> i32 {
a + b
}

3.3 — Collections

F# here shines a bit with the beautiful pipe forward operator |>

let numbers = [1; 2; 3; 4; 5]

let filteredNumbers =
numbers
|> List.filter (fun x -> x % 2 = 0)
|> List.map (fun x -> x * 2)

In Rust, we use dot or fluent notation, more object-oriented. Note that F# also still has the same via Linq or fluent extensions, but the pipe operator is usually more convenient and expressive

let numbers = vec![1, 2, 3, 4, 5];

let filtered_numbers: Vec<i32> =
numbers.into_iter()
.filter(|&x| x % 2 == 0)
.map(|x| x * 2)
.collect();

3.4 — Type Inference Algorithm

F# evolutionary language tree — comparing F# and Go

Both languages make use of variations on the ML type inference algorithm, which makes the best attempts to always infer the correct type signatures for you, or else it will fail compilation (mostly in some corner cases) and require explicit type annotation like other languages before moving forward.

They both inherited this and many other characteristics from one of their amazing mommy OCaml.

from IBM article

As a result, Rust and F# code will look much slimmer, almost like dynamic languages e.g. python or javascript or ruby. The main plot twist to consider here is that an actual strong type checking is happening in the background at compile phase. This is desired, avoiding unpredictable errors and unclear error messages at runtime, dynamic languages struggle a bit in this area.

A good editor or IDE combined with some minor tooling will still give you all the typing information you need as editor hints and at compile time, without cluttering the source code with type annotations when is not necessary.

3.4a — Refactoring like a GURU!

praying raccoons

As a plus, this will result in easing the refactoring process. As refactoring is mainly changing things without breaking them, automatic generalized type inference works well here. The compiler contains all you need and eventual refactoring aids tool (like ReSharper) or external static type checker will not be required here any longer. It all comes with refactoring batteries included in the language and compiler!

In contrast, dynamic languages are a bit weaker in servicing the user with clear error messages, as at runtime the information and the context are always a bit weaker than at compile time in most cases.

3.5 — Abstract Data Types

Category theory is a branch of mathematics that examines how things can be essentially the same without being exactly equal. — E.Cheng

Another area of comparison between Rust and F# is their support for Algebraic Data Types (ADTs), in specific the “basic” concepts of Sum(+) type and Product(x) Type. Here is a longer definition, if you crave to know more or the concept doesn’t ring a bell to you.

3.5a — Product (x)

let myTuple = (42, "hello", true)

type Person = {
name: string
age: int
}

let person = { name = "John"; age = 30 }

In Rust

let my_tuple = (42, "hello", true);

struct Person {
name: String,
age: i32,
}

let person = Person { name: String::from("John"), age: 30 };

3.5b — Sum (+)

A sum type is defined by an is relationship, a bit like inheritance in OOP-based languages like Java or C#.

A Cat is a Pet, but a Dog is also a Pet. So we can define Pet as a union of Cats and Dogs (and others pets if we want to).

Both languages offer powerful ADT features that allow developers to define complex data structures concisely and expressively.

In F#

type Pet =
| Cat of name: string * meow: string
| Dog of name: string * bark: string

let main =
let cat = Cat("Whiskers", "Meow!")

match cat with
| Cat(name, meow) ->
printfn "Cat Name: %s" name
printfn "Cat Meow: %s" meow
| Dog(name, _) ->
printfn "This is not a cat, it's a dog named %s" name

In Rust

enum Pet {
Cat { name: String, meow: String },
Dog { name: String, bark: String },
}

fn main() {
let cat = Pet::Cat {
name: String::from("Whiskers"),
meow: String::from("Meow!"),
};

match cat {
Pet::Cat { name, meow } => {
println!("Cat Name: {}", name);
println!("Cat Meow: {}", meow);
}
Pet::Dog { name, bark: _ } => {
println!("This is not a cat, it's a dog named {}", name);
}
}
}

3.6— Powerful Pattern Matching

Pattern matching can be applied to almost all ADTs and complex types, not only Union/Enums both in F# and Rust, here is an example in F# for lists.

let rec matchList lst =
match lst with
| [] -> printfn "Empty list"
| [first] -> printfn "Only one element: %d" first
| head :: tail ->
printfn "Head: %d" head
printfn "Tail: %A" tail

[1; 2; 3; 4; 5]
|> matchList

Here is an example in Rust

fn main() {
let numbers = vec![1, 2, 3, 4, 5];

match numbers.as_slice() {
[] => println!("Empty list"),
[first] => println!("Only one element: {}", first),
[head, tail @ ..] => {
println!("Head: {}", head);
println!("Tail: {:?}", tail);
}
}
}

3.7— Code Generation

Both Rust’s macros and F#’s type providers involve code generation and integration with external data sources.

An F# type provider is a component that provides types, properties, and methods for use in your program. Type Providers generate what are known as Provided Types, which are generated by the F# compiler and are based on an external data source.

For example, an F# Type Provider for SQL can generate types representing tables and columns in a relational database. In fact, this is what the SQLProvider Type Provider does. — src

Rust macros do not have a unique definition, but here is an attempt:

Rust macros are code transformation tools that allow you to generate and manipulate code at compile-time. They come in two main types: declarative macros, which use pattern matching to transform code patterns, and procedural macros, which provide advanced metaprogramming capabilities by executing Rust code at compile-time. Macros in Rust enable code generation, abstraction, and customization, enhancing the language’s expressiveness and flexibility. — chat gpt

As usual, it’s simpler to see it in action. Here is an example of a rust SQL macro

use sqlx::{PgPool, postgres::PgRow};
use sqlx::query_as;

#[derive(Debug)]
struct Band {
name: String,
year: i32,
}

async fn get_top_bands(pool: &PgPool) -> Result<Vec<Band>, sqlx::Error> {
let bands: Vec<Band> = query_as!(
Band,
"SELECT name, year FROM bands ORDER BY year DESC LIMIT 10"
)
.fetch_all(pool)
.await?;

Ok(bands)
}

And here is an example in F# using SQLProvider and the query computation expression that is loosely equivalent to the IQueryable interface in .NET.

We can see in F# the provided types are fully generated by the compiler in memory, but this is maybe just an implementation difference between the 2 systems.

open FSharp.Data.Sql
type db = SqlDataProvider<Common.DatabaseProviderTypes.POSTGRESQL, connectionString, ResolutionPath="./packages">
let ctx = db.GetDataContext()

let getTopBands () =
query {
for band in ctx.Bands do
select (band.Name, band.Year)
orderByDesc band.Year
take 10
}

Another approach in F# would be to use FSharp.Data.SQLClient library, a validation-aware type provider that works similarly as Dapper or other micro-orms, but with query validation against a development database at compile time. This is also what the Rust SQ macro does.

open FSharp.Data.SqlProvider

[<Literal>]
let devConnectionString = "Data Source=(local);Initial Catalog=YourDatabase;Integrated Security=True"

let getTopBands prodConnectionString =
use cmd = SqlCommandProvider<"
SELECT TOP TOP(@topN) Name, Year
FROM Bands
ORDER BY Year DESC
", devConnectionString>(prodConnectionString)

cmd.Execute(topN = 10L) |> printfn "%A"

4. Differences

4.1 — Memory Management

Memory management is a relevant aspect of programming languages. F# and Rust use different approaches to manage memory.

F# is a garbage-collected (GC) language running on the .NET runtime. This means that F# automatically manages the allocation and deallocation of memory for objects. The GC periodically frees memory that is no longer in use by the program. While this approach is convenient for the programmer, it can result in performance penalties due to the overhead of garbage collection and the possibility of memory leaks.

Rust, on the other hand, uses a concept called borrowing to manage memory. Borrowing is a way to ensure that memory is only accessed when it is valid and that it is not accessed after it has been freed. Rust enforces a set of rules around borrowing at compile time, which ensures that these memory safety guarantees are enforced at runtime. The benefit of this approach is that it can result in faster performance and fewer memory leaks.

4.1a — Borrowing 🔑

Rust takes a different approach to memory management. Instead of relying on a garbage collector, Rust uses a concept called borrowing to keep track of used memory. In Rust, memory is managed through a system of ownership and borrowing, which allows the compiler to ensure that there are no memory leaks or data races at compile time. Ownership in Rust is defined by the lifetime of a variable, which is the scope of the variable in which it is valid.

Borrowing has no counterpart in F# but is defined by the use of the & prepend operator in Rust.

// This function takes ownership of a box and destroys it
fn eat_box_i32(boxed_i32: Box<i32>) {
println!("Destroying box that contains {}", boxed_i32);
}

// This function borrows an i32
fn borrow_i32(borrowed_i32: &i32) {
println!("This int is: {}", borrowed_i32);
}

fn main() {
// Create a boxed i32, and a stacked i32
let boxed_i32 = Box::new(5_i32);
let stacked_i32 = 6_i32;

// Borrow the contents of the box. Ownership is not taken,
// so the contents can be borrowed again.
borrow_i32(&boxed_i32);
borrow_i32(&stacked_i32);

{
// Take a reference to the data contained inside the box
let _ref_to_i32: &i32 = &boxed_i32;

// Error!
// Can't destroy `boxed_i32` while the inner value is borrowed later in scope.
eat_box_i32(boxed_i32);
// FIXME ^ Comment out this line

// Attempt to borrow `_ref_to_i32` after inner value is destroyed
borrow_i32(_ref_to_i32);
// `_ref_to_i32` goes out of scope and is no longer borrowed.
}

// `boxed_i32` can now give up ownership to `eat_box` and be destroyed
eat_box_i32(boxed_i32);
}

4.1b — Heap and Smart Pointers 🧠

smart pointers cheat sheet — from this Reddit thread

Even though memory management “should” 🍭 be awesome in Rust, I think some arcane concepts on how to treat the Heap make you think of whether the GC approach does make your life easier sometimes.

I think Smart Pointer can be quite a sarcastic term, if you want to. Who likes pointers? I don’t.

In a scenario with no huge performance bottlenecks, and that can be quite the dominant one in many enterprise business web apps for example, GC memory management is much easier to use because it’s transparent for the developer.

You don’t have to think about whether to use Box<T> or Rc<T> or Arc<T> and why in dotnet or in Java or even in Go, and that makes them a bit easier to reason about in my opinion.

In general, I prefer the idea that memory management should be completely transparent for the developer, so I am a bit more fan of GC here. Personal preference

Here is some more info on the topic of smart pointers, and here is a nice vid.

In .NET there are ways to optimize for performance using span<T> and other techniques, but I like that this is not the default for any developer.

4.2— Who needs a Runtime?

.NET runs as interpreted language from an intermediate readable language, instead of bytecode like Java, but the concept is similar.
F# runs on .NET so it needs the .NET language runtime (CLR) to run unless optimized in specific ways or with AOT starting from .NET8.

For example, Javascript needs node.js as a runtime to run outside of a browser, in which case it would be called a web engine instead.

Java has the (in)famous JVM, java virtual machine, which provides a runtime for the Java bytecode, Java is historically the oldest language with a virtual machine (or engine/runtime) and a garbage collection, at least in most well-known languages, C# was originally a Microsoft Java implementation that eventually evolved in a very different language, running on its own with others (like F#, Powershell, IronPython and vb.net).

Python and Ruby likewise also need a runtime (environment) to be interpreted but are usually compiled at runtime (JIT).

4.2a — Rust runs From Scratch

Rust compiles to native code or binary, in a similar way as C or C++ or Go does (note on Go “runtime” is not an actual runtime). This means that Rust can be compiled to run on different kinds of devices and operating systems, making it efficient in optimization and performance, and possibly also interop with native libraries.

No runtime means that if you are running an application in a K8s cluster, and you are running it from a docker image, then after compilation and optimization you can potentially run the image as FROM scratch or FROM busybox (a mini small unix image with only bare essential tooling).

+-------------------+---------+
| docker img | Size |
+-------------------+---------+
| Spring Boot | 322 MB |
| ASP.NET Core | 318 MB |
| .NET Core | 235 MB |
| JRE on Alpine | 103 MB |
| Alpine Ruby on | 44 MB |
| Rails | |
| Alpine Python | 31 MB |
| Flask | |
| FROM busybox | <3 MB |
| FROM scratch | ~0 MB |
+-------------------+---------+

.NET runs (almost) everywhere, but not quite everywhere. Nevertheless, recent AOT (ahead-of-time compilation) developments are shifting the edges of .NET towards almost “native” compilation scenarios. Not that Java also has similar functionalities in the latest versions of some JVMs for example GraalVM.

These achievements and the development of wasi-sdk for dotnet (and GraalVM for java) to compile to wasm, might reduce or minimize this difference in the near future.

5. Final Remarks

I left out still many things, like compiler error output to user ⭐️ where I think Rust has no competitors, traits vs interfaces, and other features like computation expressions, list comprehensions, options, results, and much more. I tried to squeeze as much as possible in this article but then it couldn’t fit all of it anyways, but I trust you can deep dive more on this topic yourself, or feel free to add comments here to add the missing comparisons!

To outline one of the most important differences, I would say F# and Rust take very different approaches to memory management. F# relies on a garbage collector to manage memory, which makes memory management easy for developers but can result in performance penalties and memory leaks. Rust uses a system of ownership and borrowing to keep track of used memory, which provides compile-time guarantees of memory safety and eliminates the need for a garbage collector.

While Rust’s borrowing system may take some time to get used to, it provides an efficient and reliable way to manage memory in programs.

On the other hand, a GC language sets an easy road for a lazy developer, and in most scenarios that’s a safe and simpler choice, so I give a point to .NET here. Obviously, it depends on your requirements.

For performance improvements, also Span<T> and Memory<T> can be used in dotnet to achieve high-performance optimizations in regard to memory. So I better like the Opt-in to optimization of .NET, instead of the default of Rust here.

On many other aspects, F# and Rust are very similar, especially in their type system and inference algorithm where to be fair, F# shines a bit brighter ⭐ ️being able to infer types even better than Rust, and they both seem very productive and beautiful languages to use. I’d say if you liked Rust, give F# a try and vice-versa. F# also has a REPL making it quite nice and tidy for data exploration and scripting, a bit like Python.

Also here is a very interesting comparison on GitHub for extra and more detailed information. Have a nice day!

References

--

--