Skip to content

zjom/ff

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

179 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ff

functional ff

a small, highly extensible, functional language with strong rust interoperability and native actor based concurrency.

book

at a glance

# arithmetic is exact-rational; `**` is power
1 + 2 * 3          # 7
2 ** 10            # 1024
1 / 3 + 1 / 3      # 2/3 — no floating-point drift

# bindings are immutable within a scope; rebinding shadows
greeting = "hello"
println(greeting :: ", world")

values

# numbers, strings, bools
42
"abc" :: "def"     # "abcdef"
true && !false     # true

# `()` is the unit value
()

# three collections — lists, objects, sets. Lists are heterogeneous.
[1, "two", true]
{"lang": "ff", "version": 0.1}
{1, 2, 2, 3}             # {1, 2, 3} — dedup

# `.` indexes lists by position and atom-keyed objects by name
[10, 20, 30].0           # 10
{:x: {:y: 7}}.x.y        # 7

# use the `Object` module to access object with arbitrary keys
# objects and sets are unordered
squares = {1: 1, 2: 4, 4: 16}
Object.get 2 squares          # 4
Object.put 5 25 squares       # {1: 1, 2: 4, 4: 16, 5: 25}
# you can also use cons to put to objects
[5, 25] :: squares           # {1: 1, 2: 4, 4: 16, 5: 25}

Layouts are forgiving: commas and newlines are interchangeable inside [], {}, and call args.

xs = [
  1
  2, 3
  4,
]

functions

functions are values. => builds a lambda; parameters can be comma- or space-separated, with or without parens.

inc   = x => x + 1
add   = (x, y) => x + y
add3  = a b c => a + b + c

inc(5)         # 6
inc 5          # 6  — juxtaposition is application
add(3)(4)      # 7  — everything is curried
add3 1 2 3     # 6

# partial application falls out of currying
add5 = add(5)
add5(10)       # 15
# as a convenience, we define pipe `(|>)`, left composition `(<<)` and right composition `(>>)` in the prelude
[1, 2, 3] |> map(x => x * x) |> reduce((a, b) => a + b, 0)   # 14

inc      = x => x + 1
double   = x => 2 * x

double_then_inc = inc << double
double_then_inc 3          # 7

inc_then_double = inc >> double
inc_then_double 3          # 8

pattern matching

match dispatches on shape. The scrutinee is optional — a bare match is a one-argument function, which is the idiomatic way to define case-analyzing functions.

describe = match
  []          -> "empty",
  [x]         -> "one",
  [x, y]      -> "two",
  _           -> "many"

describe([1, 2, 3])      # "many"

Cons-patterns peel one element off any sequence — list, string, or set:

sum = match
  h :: t -> h + sum(t),
  _      -> 0

sum([1, 2, 3, 4])        # 10

Destructuring also works in plain assignments, with ..rest for the middle or the tail:

[head, ..tail] = [1, 2, 3, 4]    # head = 1, tail = [2, 3, 4]
[x, y]         = [10, 20]
{"lang": lang}  = {"lang": "ff", "version": 0.1}

…and in function parameters, so a function can destructure its arguments directly. Any pattern that works on the LHS of = works as a parameter — including list, cons, and object patterns:

first = [a, ..] => a
key   = [k, _] :: _ => k                  # peel the first entry of a object
name  = {:name: n} => n
add   = ([a, b], c) => a + b + c          # multi-param mix

A parameter pattern that doesn't match raises a runtime error at the call site, so for partial patterns (e.g. cons on a possibly-empty list) prefer match with a fallback arm.

Guards refine an arm:

sign = match
  n if n < 0 -> "neg",
  0          -> "zero",
  _          -> "pos"

optional arguments and variadic functions

optional arguments and variadic functions are not supported at the language level. it is trivial to implement on your own via pattern matching on a list.

for example, the stdlib assert function takes an optional msg and is implemented as

assert = args => (
  [val, msg] = match args
    [val,msg] -> [val,msg],
    val -> [val, "assert failed"]

  if val != default(val) then val else panic msg
  )

blocks and control flow

parentheses with multiple statements form a block. the value of the last expression is the value of the block; inner bindings don't leak.

area = (
  w = 4
  h = 5
  w * h
)                        # area = 20

abs = n => if n < 0 then -n else n

laziness and ranges

[a..b], [a..=b], and [a..] are lazy ranges. Because :: doesn't force its tail, list combinators stream:

match map(x => x * 2, [0..])         # infinite range
  a :: b :: c :: _ -> [a, b, c]       # [0, 2, 4]

[0..] |> filter(x => x % 2 == 0) |> take 5    # [0, 2, 4, 6, 8]

atoms

:name is an atom — a self-evaluating constant that compares equal only to itself. Pairs well with lists and pattern matching for tagged-union style.

:ok                          # :ok
:ok == :ok                   # true
:ok == :error                # false

safe_div = (a, b) =>
  if b == 0 then [:error, "div0"] else [:ok, a / b]

handle = match
  [:ok, v]      -> v,
  [:error, msg] -> -1

handle(safe_div(10, 2))      # 5
handle(safe_div(10, 0))      # -1

atoms work anywhere a value does — list/set elements, object keys, and patterns. objects keyed by atoms read back with .name:

m = {:lang: "ff", :fullname: "functionalff"}
m.lang                                     # "ff"

{:lang: lang} = m                           # lang = "ff"

error handling

functions that may fail should return length 2 lists of atom, value. e.g., [:ok, 1], [:error, "msg"]

this allows the caller to handle the cases as they wish via pattern matching.

safe_div = (a, b) =>
  if b == 0 then [:error, "div0"] else [:ok, a / b]

handle = match
  [:ok, v]      -> v,
  [:error, msg] -> -1

handle(safe_div(10, 2))      # 5
handle(safe_div(10, 0))      # -1

custom operators

any sequence of operator characters can be a user-defined infix; precedence is OCaml-style, picked from the first character (*///% bind tighter than +/-, which bind tighter than =/</>, etc.).

prefix ops start with ? or ~.

(<|>) = (x, y) => if x != default(x) then x else y
"" <|> "fallback"        # "fallback"

(~?) = x => default(x)
~?[1, 2, 3]              # []

Wrapping any operator in parens turns it into a normal value:

plus = (+)
[1, 2, 3] |> reduce(plus, 0)    # 6

modules

import "path.ff" returns an atom-keyed object containing whatever the file marked export — a module is just an object. As a bare statement (not the RHS of =), an import splats those names into the current scope.

# math.ff
square = x => x * x
cube   = x => x * x * x
export square, cube

# main.ff
import "math.ff"
square(7)                # 49

# or keep it namespaced
M = import "math.ff"
M.cube(3)                # 27

concurrency

ff has an actor based concurrency model inspired by erlang.

actors are sequential "processes" that communicate via message passing (see Communicating Sequential Processes).

an actor is just an object with atom-keyed callbacks: :init, :handle_request, :handle_notify. Actor.request and Actor.notify are equivalent to erlang's call and cast respectively.

Each actor processes its mailbox strictly in order; concurrency comes from interleaving many actors.

handle_request = (msg, n) => match msg
  :get -> [n, n]                       # [reply, new_state]
handle_notify = (msg, n) => match msg
  [:add, x] -> n + x,                  # new_state only
  :reset -> 0

counter = {
  :init: () => 0
  :handle_request: handle_request
  :handle_notify: handle_notify
}

[:ok, pid] = Actor.spawn(counter)
Actor.notify(pid, [:add, 5])             # fire-and-forget
Actor.notify(pid, [:add, 7])
Actor.request(pid, :get)                  # [:ok, 12]

Primitives: Actor.spawn, Actor.notify, Actor.request, Actor.self, Actor.alive, Actor.stop. notify returns () immediately; request blocks the calling actor (or top-level code) until the target replies. Actor.self(self_pid) from inside a handler is detected as :self_deadlock and surfaces as [:error, :self_deadlock] rather than blocking forever; calls to a dead or unknown pid return [:error, :no_proc]. A handler that raises kills its actor and surfaces [:error, "handler crashed: ..."] to the caller without aborting the script.

Actors run on a tokio multi-thread runtime: each spawn parks the actor on its own blocking task, so independent actors progress in parallel on different OS threads. Per-actor ordering is still strict (one handler at a time, mailbox is FIFO via tokio::sync::mpsc). Synchronous call is implemented via a tokio::sync::oneshot reply channel that the caller blocks on until the target's handler returns.

working with rust

use f2::interop::{FfResult, define_value, register};
use f2::interpreter::{Scope, eval_program};
use f2::parser::parse;
use f2::prelude;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct User { name: String, age: u32 }

let env = Scope::new();
prelude::install(&env);

// Push a Rust value into the script's env.
define_value(&env, "me", &User { name: "Ada".into(), age: 36 }).unwrap();

// Expose a Rust function — args and return value (de)serialize via serde.
register(&env, "greet", |u: User| format!("Hello, {}!", u.name));

// Fallible natives use FfResult so ff sees a tagged pair.
register(&env, "checked_div", |a: i64, b: i64| -> FfResult<i64> {
    if b == 0 { FfResult::Err("divide by zero".into()) } else { FfResult::Ok(a / b) }
});

let prog = parse("greet(me)").unwrap();
assert_eq!(eval_program(&prog, &env).unwrap().to_string(), "\"Hello, Ada!\"");

let prog = parse("checked_div(10, 0)").unwrap();
assert_eq!(
    eval_program(&prog, &env).unwrap().to_string(),
    "[:error, \"divide by zero\"]"
);

see the interop module docs for more information.

running

cargo install f2

f2                    # REPL
f2 path.ff            # run a file

About

ff lang

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages