Migrating from F# to Gossamer¶
F# and Gossamer both favor immutable let bindings, pattern matching,
Option<T>, Result<T, E>, and left-to-right pipelines. The main
differences are syntax, pipe argument order, concurrency, and the
absence of F# metaprogramming features.
Quick Map¶
| F# | Gossamer | Notes |
|---|---|---|
let x = 5 |
let x = 5 |
Same. |
let mutable x = 5 |
let mut x = 5 |
Same meaning. |
let f x y = x + y |
fn f(x: i64, y: i64) -> i64 { x + y } |
Functions are not curried. |
fun x -> x + 1 |
|x: i64| x + 1 |
Closure syntax. |
if c then a else b |
if c { a } else { b } |
Braces required. |
match x with | A -> ... |
match x { A => ... } |
Exhaustive. |
| record | struct |
Construct with braces. |
| discriminated union | enum |
Tuple variants use parentheses. |
async { ... } |
spawn(|| { ... }) |
Goroutine, inside a cohort { } outside main. |
Task result |
spawn(..).join(), a channel, or a direct Result |
Blocking calls are acceptable. |
printfn "%d" n |
println(f"{n}") |
See Formatting below. |
$"{name} is {age}" |
f"{name} is {age}" |
Placeholders name values; they do not compute. |
Syntax At A Glance¶
F# uses indentation and separates list elements with semicolons. Gossamer uses
semicolons only between same-line statements. Inside delimiters, commas are
required on one line and
newlines are canonical across multiple lines. Multiline commas remain accepted
for migration, but gos fmt removes them.
struct User {
name: String
active: bool
}
fn rename(
user: User
name: String
) -> User {
User {
name: name
active: user.active
}
}
enum Lookup {
Found {
index: i64
user: User
}
Missing(String)
}
let user = User { name: "Ada", active: true } // one line needs commas
Gossamer uses indexing for sequences, named fields for structs, numeric fields
for tuples, and Option-returning lookup for maps:
let users = #[user, rename(user, "Grace")]
let first = users[0] // Vec/array index; traps if out of bounds
let initial = first.name[0] // String index is a char; byte_at(i) is the byte
let pair = (first.name, first.active)
let enabled = pair.1
let mut by_name: Map<String, User> = Map::new()
by_name.insert(first.name, first)
let cached = by_name.get("Ada") // Map lookup returns Option<V>
let found = Lookup::Found {
index: 0
user: cached.unwrap()
}
Gossamer collection literals cover the common F# collection shapes:
#[a, b] for Vec<T>, [a, b] for a fixed array, {key: value} for
Map<K, V>, and #{a, b} for Set<T> or an expected BTreeSet<T>.
Queue<i64>, Stack<i64>, Deque<i64>, MaxHeap<i64>, and MinHeap<i64>
are built through their type with new() or from([...]).
Formatting And Interpolated Strings¶
An F# interpolated string $"..." becomes an f"..." string, and sprintf
becomes format, whose {} placeholders fill in order. A placeholder in an
f"..." string holds any expression, with an optional spec after a :.
| F# | Gossamer |
|---|---|
$"{name} is {age}" |
f"{name} is {age}" |
$"{List.length xs} items" |
f"{xs.len()} items" |
sprintf "%s %d" s n |
format("{} {}", s, n) or f"{s} {n}" |
%A |
{:?} |
$"{price:F2}", %.2f, %5d, %x |
f"{price:.2}", {:.2}, {:5}, {:x} |
{} renders any value, records and unions included, as %A does.
Pipe Operator¶
F# pipes into the next function's first argument, which works because
currying makes List.filter f a genuine one-argument function.
Gossamer has no currying, so a step that writes arguments is a closure
whose parameter is the slot the piped value fills - anywhere in the
argument list, not a fixed end.
use std::iter
[1, 2, 3, 4]
|> |v| iter::filter(v, |n: i64| n % 2 == 0)
|> |v| iter::sum_by(v, |n: i64| n * n)
Naming the slot is what lets one operator serve any callee, whatever its parameters. Every stdlib free function takes its data first, mirroring the method receiver, and a step reads the same when it pipes into a function this program declares or a package's.
The other half of the translation is that Gossamer has methods and F#
does not. Where F# must pipe, Gossamer usually chains - s.trim(),
xs.iter().filter(f).sum() - and the pipe is for the free functions
that have no receiver to chain from. A method chain can feed a pipe.
Records To Structs¶
type Config = {
Host: string
Port: int
Verbose: bool
}
let cfg = { Host = "localhost"; Port = 8080; Verbose = false }
let updated = { cfg with Port = 9090 }
struct Config {
host: String
port: i64
verbose: bool
}
let cfg = Config { host: "localhost", port: 8080, verbose: false }
let updated = Config { port: 9090, ..cfg }
Named structs use braces with keyed fields:
Parentheses are reserved for tuple structs and enum tuple variants.
Discriminated Unions To Enums¶
enum Tree {
Leaf
Node(i64, Tree, Tree)
}
fn sum(t: Tree) -> i64 {
match t {
Tree::Leaf => 0
Tree::Node(v, l, r) => v + sum(l) + sum(r)
}
}
Recursive enum variants are runtime-managed. Add T only when it
makes a public type clearer.
Option And Result¶
Both languages share the same vocabulary:
use std::option
let parsed = input
|> |v| option::and_then(v, try_parse)
|> |v| option::unwrap_or(v, 0)
For fallible work, ? is usually clearer than a pipeline:
fn load(path: String) -> Result<Config, errors::Error> {
let text = fs::read_to_string(path)?
parse_config(text)
}
Concurrency¶
Gossamer has stackful goroutines and channels instead of computation
expressions. A cohort { } joins every goroutine spawned in it on every
exit and answers the first failure, which covers Async.Parallel; outside
main, a spawn sits inside one (GT0086). async is a declined keyword
(GP0061).
use std::{errors, http}
fn fetch_both(a: String, b: String) -> Result<(), errors::Error> {
cohort {
let first = spawn(|| http::get(a, #[]))
let second = spawn(|| http::get(b, #[]))
let _ = first.join()
let _ = second.join()
}
}
For fan-in over a channel, a sync::WaitGroup closes the channel once every
sender is done:
let wg = sync::WaitGroup::new()
let tx, rx = channel()
for url in urls {
wg.add(1)
let tx = tx.clone()
spawn(|| {
defer wg.done()
tx.send(http::get(url, #[]))
})
}
spawn(|| {
wg.wait()
tx.close()
})
while let Some(result) = rx.recv() {
process(result)
}
Traits¶
F# interfaces are object-oriented. Gossamer traits are nominal and implemented explicitly:
trait Area {
fn area(&self) -> f64
}
struct Circle { r: f64 }
impl Area for Circle {
fn area(&self) -> f64 { 3.14159 * self.r * self.r }
}
Generic bounds use T: Area. For a closed set of cases, prefer an
enum and exhaustive match. A type with impl Iterator for T works like a
seq: for walks it, and it gains map, filter, take, fold, and
collect.
Missing F# Features¶
Gossamer does not have computation expressions, active patterns, units of measure, type providers, higher-kinded types, or curried functions. Use closures for partial application:
Integer Overflow And Wrapping Arithmetic¶
F# arithmetic is unchecked by default and wraps on overflow, unless a scope
opens Checked. Gossamer's plain +, -, and * behave like Checked on
every tier and in every profile, release builds included: an overflow
panics. Code that relies on wrapping (hashes, checksums, pseudo-random
generators) ports to the wrapping operators, which wrap at the declared
width everywhere. There is no switch that restores F#'s unchecked default:
wrapping is written where it is intended.
| F# | Gossamer |
|---|---|
a + b, a - b, a * b (unchecked) |
a +% b, a -% b, a *% b |
Checked.(+) / open Checked |
a + b (panics on overflow) |
hash <- hash * 16777619u |
hash *%= 16777619 |
^^^, <<<, >>> |
^, <<, >> |
uint32 b, 2166136261u |
b as u32, a u32 binding |
let fnv1a (data: byte[]) =
let mutable hash = 2166136261u
for b in data do
hash <- (hash ^^^ uint32 b) * 16777619u
hash
let nextSeed (seed: int64) = seed * 6364136223846793005L + 1442695040888963407L
fn fnv1a(data: String) -> u32 {
let mut hash: u32 = 2166136261
for b in data.bytes() {
hash ^= b as u32
hash *%= 16777619
}
hash
}
fn next_seed(seed: i64) -> i64 {
seed *% 6364136223846793005 +% 1442695040888963407
}
Visibility¶
Gossamer has three visibilities, and they are declared per item, per method, and per struct field.
| Annotation | Reachable from |
|---|---|
| none | the declaring module and its descendants |
pub(package) |
every module of the declaring package |
pub |
anything that depends on the package |
A package is the unit of distribution: one project.toml, one project id.
A module is a directory under src/. A module nested inside another is a
module descendant, and visibility flows inward only: a descendant reaches
its ancestors' private items, never the reverse.
// src/money/mod.gos
pub struct Amount {
pub currency: String,
cents: i64, // private representation
}
impl Amount {
pub fn new(currency: String, cents: i64) -> Amount {
Amount { currency: currency, cents: cents }
}
pub fn cents(&self) -> i64 { self.cents }
fn normalize(&self) -> i64 { self.cents } // private helper
}
pub(package) fn round_trip(a: Amount) -> i64 { a.normalize() }
A pub type may keep private methods and private fields, so a struct with any
private field can only be built by the module that declares it. Importing does
not widen anything: a use is a spelling convenience, and visibility is
decided by where the name is used.
Coming from F#: pub is public, no annotation is closer to
private (module-scoped, and visible to nested modules), and pub(package)
is the internal equivalent. Unlike F#, declaration order does not affect
visibility - a module's items are visible throughout it regardless of where
they appear.
Standard Library Map¶
| F# / .NET | Gossamer |
|---|---|
System.IO.File.ReadAllText |
fs::read_to_string(path) |
System.IO.File.WriteAllText |
fs::write(path, data) |
Environment.GetEnvironmentVariable |
env::var(name) |
Environment.GetCommandLineArgs |
env::args() |
Console.WriteLine |
println(...) |
sprintf "%s %d" s n |
f"{s} {n}" |
List.map f xs |
xs.map(f), or xs |> |v| iter::map(v, f) |
List.filter f xs |
xs.filter(f), or xs |> |v| iter::filter(v, f) |
List.fold f init xs |
xs.iter().fold(init, f), or xs |> |v| iter::fold(v, init, f) |
Map.find k m |
m.get(k) |
Set.contains x s |
s.contains(x) |
String.trim s |
s.trim() |
Int32.TryParse s |
s.to_i64(), an Option<i64> |
Task.Run |
spawn(|| { ... }) |
Thread.Sleep(ms) |
time::sleep(ms) |
HttpClient.GetAsync(url) |
http::get(url, []) |
Regex(pattern) |
regex::compile("literal"), or regex::new(pattern)? for a pattern built at run time |
JsonSerializer.Serialize(v) / Deserialize |
json::to_json::<T>(v)? / json::from_json::<T>(text)? |
[<JsonPropertyName("key")>] / [<JsonIgnore>] |
#[rename("key")] / #[skip] |