This stuff is pretty much all subject to change. Consider everything experimental R&D for the foreseeable future.
If you're looking for a formal grammar specification for Foi, it's in progress.
- Primitive Values
- Identifiers
- Code Comments
- Imports And Exports
- Function Calls
- Defining Variables
- Boolean Logic
- Equality and Comparison
- Pattern Matching
- Records and Tuples
- Defining Functions
- Base Unit Functions
- Loops
- List Comprehensions
- Monads (And Friends)
- Broader Category Theory Capabilities
- Concurrency / Asynchrony
- IO monad
- Generator Monad
- Type Annotations
Foi comes with the following built-in primitive value types:
- Empty:
empty
- Numbers:
42
,-3.14
- Booleans:
true
,false
- Strings:
"Hello, world!"
- Records, Tuples:
< id: 123 >
,< 1, 2, 3 >
- Monadic instances:
Id
,Left
The empty
value signifies the absence of any other affirmative value.
Numbers in Foi are by default specified in base-10 literals (digits 0-9
), including negative values (with -
) and decimals (with .
).
4.0
is a floating point value, whereas 4
is an integer value. These are sometimes interchangeable, and sometimes not.
There are no special numbers (e.g. NaN
, Infinity
) in Foi. If a value literal, or operation, produces a number value that does not fit validly into one of the number sub-types (floats, integers, etc), the result is a Left
(monad instance) holding a message indicating the nature of the failure.
6 / 0; // Left{"Divide by zero is infinite"}
6 / "a"; // Left{"Non-numeric operand"}
0.0000000000000001; // Left{"Number precision underflow"}
9999999999999999.9; // Left{"Number precision overflow"}
Foi does however have a "negative zero" value, consistent with IEEE-754, which is distinct from regular zero:
0 / 3; // 0
0 / -3; // -0
0 ?= -0; // false
To specify a more readable numeric literal, using _
as an arbitrary separator (in any position), prefix the number literal with a \
:
\100_000_003.25; // 100000003.25
Hexadecimal, Octal, and Binary integers can be specified with respective prefixes (lowercase):
\h603A; // 24634
\o60072; // 24634
\b110000000111010; // 24634
You can also specify an arbitrarily large (or small) precision value by prefixing with \@
:
\@9999999999999999.9; // Number{9999999999999999.9}
Such values are held in a Number
monadic instance. To perform mathematical operations with such values, an arbitrary-precision library must be used.
Note: One special numeric prefix is \u
, which actually produces a character/string from the numeric hexadecimal representation of its code-point. So the numeric literal \u263A
actually produces the single-character string "☺"
.
There are two boolean values: true
and false
; there are no implicitly "truthy" or "falsy" values in Foi -- coercion to boolean is never done implicitly.
However, most non-boolean values can be explicitly converted to a true
or false
equivalent, via the unary ?
operator. And the !
unary operator does the same as ?
, but also negates, from true
to a false
or vice versa:
?0; // false
!0; // true
? 1; // true
! 1; // false
?(empty); // false
!(empty); // true
? "hello"; // true
! "hello"; // false
It's much more common/advisable to use ?
/ !
operators with a variable (e.g., ?customerOrder
, !qty
, etc) than with a literal value.
Note: Whitespace is optional between ?
and the value/variable, as shown above. However, since ?empty
/ !empty
(with no whitespace) are actual operators, either whitespace (! empty
) or parentheses (?(empty)
, as above) are necessary to distinguish the intended expression from the operators. That said, ?(empty)
/ !(empty)
are pretty uncommon/unnecessary expressions in programs; true
and false
are shorter, respectively, and more semantic.
Strings are always delimited with " "
(double-quotes).
To include a "
character inside a string literal, it must be escaped with a second "
, like this: "Hello, ""friend""!"
; when printed, that value would look like Hello, "friend"!
.
Other than the ""
escaping, the contents of strings are not, by default, parsed/processed in any way.
That is, if you spread a string across multiple lines, those new lines (and any leading whitespace) are included in the string's contents:
"This is line one,
and this is line two.";
// This is line one,
// and this is line two.
If you want to collapse new lines (and line trailing/leading whitespace) into a single whitespace character, prefix the string literal with a \
, like this:
\"This is all on
the same
line.";
// This is all on the same line.
To interpolate expressions inside of a string, immediately prefix the string literal with a `
(back-tick), and then also delimit each expression inside the string with `
back-ticks:
`"My name is `name`.";
// My name is Kyle.
The interpolated expression (inside the ` .. `
) can be any valid Foi expression.
Warning: There's one minor caveat to the above statement. An interpolated expression cannot itself contain a bare (`".."
) interpolated string, because the `"
sequence would be a grammar ambiguity (opening another interpolated string, or closing the current interpolated expression and outer string). To nest an interpolated string inside an interpolated expression, you must use a slightly unfortunate work-around. For the inner/nested interpolated string literal, escape it with combined whitespace-collapsing as well (via \`
), as illustrated shortly; fortunately, the \`"
sequence is not grammatically ambiguous.
Interpolation is also the best way to include a unicode character in a string literal, via its hexadecimal code, using the \u
numeric prefix:
`"I was happy `\u263A` to see you!";
// I was happy ☺ to see you!
To include a literal `
back-tick by itself (not as an interpolation delimiter) in an interpolation-parsed string, escape it with a second `
, like this:
`"Here is a single `` back-tick, `name`.";
// Here is a single ` back-tick, Kyle.
To both collapse whitespace, as well as allow interpolation, combine the \
and `
, in order, like this:
\`"This is
one line, written
by `uppercase(name)`.";
// This is one line, written by KYLE.
In Foi, identifiers are case-sensitive, and can be comprised of any of these characters (with no whitespace):
A
-Z
,a
-Z
0
-9
_
~
Note: There is no restriction on the first character of identifiers, as in some languages; so 3stars
is a valid identifier name. However, an identifier must have at least one non-digit (0
- 9
) character somewhere in it. Otherwise, it's just a number.
Identifiers can be any length.
Identifiers cannot conflict with keywords: def
, defn
, deft
, import
, export
, etc. They also cannot conflict with named operators: ~each
, ~chain
, ~fold
, etc.
Adding comments to Foi code takes two forms, single-line and multi-line:
// this is a single line comment
whatever; // so is this one
another; /// But...
this is a block comment, and
can span as many lines
as needed.
///
The triple-slash ///
comment block can start anywhere on a line, span as many lines as needed (including just a single line), and must end with another triple-slash ///
.
To import named dependencies (including "globals" from the built-in #Std
), use the import
keyword:
def Std: import "#Std";
Std.log("Hello"); // "Hello"
Std.log(6 + 12); // 18
Or import specific members from a dependency:
def < :log >: import "#Std";
log("Hello"); // "Hello"
The project manifest maps dependency files to identifiers, which can then be imported by name:
def < :myHelper >: import "Utils";
To export lexical members from a module (no renaming, source and target names match):
export { :login, :logout };
To rename lexical exports (e.g., from lexical login
/ logout
to exported names doLogin
/ doLogout
):
export { doLogin: login, doLogout: logout };
When exporting, keep in mind that defn
function definitions do hoist -- so they can appear anywhere in the outmost module scope, even below an export
statement. This is the most common type of value to export from a module.
However, def
variable definitions (even if they hold functions) do not hoist. Those must always appear at the top of a scope, thus before any export
statements. Those variables must be assigned an intended value to export before an export
statement; subsequent variable assignments are not retroactively exported.
The traditional function call-form (e.g., log("Hello")
) always requires ( )
around the argument list, and must immediately follow the function name (no whitespace). If there are no arguments to pass, the call looks like someFn()
.
Inside the argument list, assigning arguments to the function's parameters is done positionally, from left-to-right. To skip an argument/parameter position, simply omit anything (exception optional whitespace) between two successive ,
commas:
myFn(1,,3,,,6);
Omitting an argument is the same as specifying empty
for that argument:
myFn(1,empty,3,empty,empty,6);
Note: Trailing comma(s) in the argument list are allowed (and ignored).
When you need to specify three or more operands (aka, "n-ary") to an operator, you need to invoke the operator as a function (herein referred to as operator-as-function form). To do so, wrap the operator in ( )
parentheses (no whitespace within), then invoke with arguments like a normal function:
(+)(1,2,3,4,5); // 15
It's nice to only need to list the operator once instead of 4 times!
Many operators in Foi are n-ary, such as the +
operator, +>
flow (composition) operator, and ?<=>
range-check operator.
The +
operator is a single symbol, so the preceding example yields a longer expression than just repeating the +
operator between each value, which may seem disfavorable.
However, other operators are comprised of two or more symbols, so the length of the operator-as-function form will likely end up shorter depending on how many arguments are provided.
Also, some operators may result in a change of value-type from the operand(s) to the result. In those cases, you cannot simply combine multiple infix operator usages like we did with +
.
For example, say you wanted to test 3 variables as all being equal to each other. The ?=
operator used with two operands (left and right), requires multiple expressions, and combining their results with the logical-AND ?and
operator:
(x ?= y) ?and (y ?= z) ?and (x ?= z);
Note: The ?=
equality comparison may not be transitive, depending on the types being compared, hence why we included the x ?= z
check for good measure.
But since the ?=
operator is also n-ary, we can invoke it as a function and provide it 3 or more arguments, resulting in a much shorter/nicer-to-read expression:
(?=)(x, y, z);
It should be clear how much more preferable the n-ary operator-as-function form can be!
If you only want to capture (or pass) a function reference for an operator, without invoking it:
def add: (+);
def subtract: (-);
add(2,5); // 7
subtract(8,5); // 3
Say we have a list of values (a Tuple, as we'll see later) called numbers
, and we want to "spread them out" as arguments to an operator/function. We can use the ...
operator:
add(...numbers);
add(0, ...numbers, 1000);
(+)(...numbers);
(+)(0, ...numbers, 1000);
OK, that's useful. But what about modifying an operator/function to automatically accept its inputs as a list?
def addNumsList: (...)(+);
addNumsList(numbers);
Since ...
is an operator, when passed an operator/function like +
, instead of a Tuple, it produces a new function (above, addNumsList()
) that will expect a single (Tuple) argument that's then spread out to the underlying operator/function when invoked.
Some operators like +
are commutative, so the operand/argument order doesn't matter (e.g., 3 + 4
is the same as 4 + 3
). But for most functions and operators, like -
, arguments are not commutative, so the order matters.
It happens often, especially when spreading a list of arguments, that the expected order is the opposite of what we want. Instead of reversing the order of the Tuple list of arguments, you can reverse the order that a function will apply its supplied arguments.
To do so, use the postfix (immediately after!) '
prime operator on the function/operator reference:
myFunc'(...nums); // aka, myFunc(...numsReversed)
(-)'(1,6); // 5 :: 6 - 1
Since '
is an operator, you can also use invoke it as a function, passing to it another function or operator:
def subtrRev: (')(-);
subtrRev(1,6); // 5
Yes, with (')(-)
, we just applied one operator against another operator, producing a new function.
Since this operation will be extremely common, a special sugar short-hand is available. Inside a ( )
operator-as-function reference, the '
prime operator may appear immediately after (no whitespace) the operator it's modifying:
(-')(1,6); // 5
This short-hand form (-')
should be preferred over the more verbose (')(-)
form, for readability sake, wherever practical. Thus these are all equivalent, but the final one is generally preferable:
(')(-)(1,6); // 5
(-)'(1,6); // 5
(-')(1,6); // 5
It's common in functional programming to produce more specialized functions by applying only some inputs to a more generalized (higher-arity) function; the result is a another function that expects the subsequent arguments.
This is referred to as partial application.
To signify a partial application, use | |
(pipes) to delimit the arguments list, instead of the traditional ( )
parentheses:
def add6: (+)|6|;
add6(12); // 18
Regardless of the arity (expected number of arguments) of the function or operator, and no matter how many arguments you pass, the | |
will always partially apply, meaning the arguments are remembered for later, but the function is not yet invoked:
def add37: (+)|6,12,19|;
add37(5); // 42
Partial application is also useful when you need to skip specific positional arguments (and thus provide them on the final invocation):
defn xyz(x,y,z)^log(`"x: `x`, y: `y`, z: `z`");
xyz(2,4,6); // x: 2, y: 4, z: 6
def fn: xyz|3,,7|;
fn(5); // x: 3, y: 5, z: 7
Reversing argument order with the '
prime operator combines with the partial application form, as expected:
def sub1: (-')|1|;
sub1(6); // 5 :: 6 - 1
The ...
spread operator is allowed inside the partial application arguments list:
defn xyz(x,y,z)^log(`"x: `x`, y: `y`, z: `z`");
def nums: < 9, 8, 7 >;
def fn: xyz'|...nums|;
fn(); // x: 7, y: 8, z: 9
To override positional argument->parameter binding at a function call-site, an argument can specify which parameter name it corresponds to (in any order):
defn add(x: 0, y) ^x + y;
add(x:3, y:4); // 7
add(y:5); // 5
add|x:4|(y:5); // 9
Warning: Be careful in relying on this capability, as it creates an additional refactoring dependency burden between the function definition and its call-site(s). If you change the name of a parameter in a function definition, any named-argument references at all call-sites must be updated.
This code style means that function parameter naming has readability implications at call-sites. If you are using this code style at call-sites, consider "standardizing" certain generic variable naming conventions in your function definitions, to make using such functions predictable.
For example:
- a general value parameter might always be named
v
orval
- a function-value parameter might always be named
cb
orfn
- a list/array parameter might always be named
list
,arr
, or evenvs
(i.e., the plural ofv
)
To define variables, use the def
keyword (not an operator/function):
def age: 42;
All definitions need a value initialization, but you can use the empty
value if there's no other value to specify.
def
definitions do not hoist, so to avoid confusion, they must not be preceded in any scope (module, function, or block) by any other non-definition (besides def
, deft
, defn
, and import
) statements.
To reassign a variable:
def age: 41;
// later
age := 42;
Unlike def
definitions, :=
re-assignments are allowed anywhere in the scope after the associated def
definition.
Multiple re-assignments (of the same value) can also be chained:
x := y := z := 0;
def
definitions attach to the nearest enclosing scope, whether that be module, function, or block. A block-scoped variable definition is thus:
{
def tmp: 42;
tmp := 43;
}
However, since def
definitions must appear at the top of their respective scopes, and there may be multiple such definitions in a block, the definitions-block form should be preferred for readability sake:
def (tmp: 42) {
tmp := 43;
};
Moreover, this definitions-block form is allowed anywhere in its enclosing scope, so it's more flexible than a non-block def
declaration.
In addition to the definitions-block form just shown, several other expressions in Foi allow a { }
block to be declared as part of the larger expression. For syntactic convenience, many of these expressions' blocks can be prefaced by the optional ( )
block-definitions clause:
-
A guard block with block-definitions clause:
// if x > y, swap them // (tmp is block-scoped) ?[x ?> y]: (tmp: x) { x := y; y := tmp; };
-
A pattern matching clause block with block-definitions clause:
?{ // x is odd? // (tmp is block-scoped to the clause) ?[mod(x,2) ?= 1]: (tmp) { tmp := (x * 3) + 1; ?[tmp ?> 100]: tmp := 100 myFn(tmp); } // x is non-zero (and even)? ?[x != 0]: myFn(x) // otherwise, x must be zero, // so skip calling function and // default to fixed value 1 ?: 1 };
-
A loop iteration block with block-definitions clause:
0..3 ~each (v,idx) { log(`"`v`: `idx`"); };
Note: While function body definitions, and the Record/Tuple def-block, both have { }
blocks, these cannot be prefaced by a block definitions clause.
When defining a variable where the assignment is intended to step into (aka, "destructure") a Record/Tuple value's contents, we can use a dedicated destructuring form:
def <
:items.0.price,
firstItem: items.0,
#order
>: getOrder(123);
price; // 29.97
firstItem; // < price: 29.97, label: ... >
order; // < id: 123, items: < < price: 29.97, ... >
The :items.0.price
syntax form implicitly assumes a target variable name from the final source property name (price
) above; for this syntax to be valid, the source property name must be fixed (cannot be a dynamic expression) and a valid identifier (cannot be a number like 0
).
If you need to "rename" the target variable -- for example, if the source property name isn't fixed or a suitable identifier -- the target name (firstItem
above) may be specified before the :
.
To capture the entire value being destructured, use #
prefixing a target variable name (as in #order
above).
To compute the top-level source property name with a dynamic expression:
def < lastItem: [size(items)-1] >: items;
These various destructuring forms are also allowed in a block-definitions clause:
def (< :items.0.price >: getOrder(123)) {
price; // 29.97
};
order.items ~each (< itemPrice: price >) {
itemPrice; // 29.97
};
The true
and false
boolean values are used primarily for decision making. Accordingly, non-negated, boolean-returning operators, aka logical operators, begin with the ?
character (to signal asking a question to make a decision).
To combine two or more boolean values with logical-AND (?and
):
def isValid: true;
def isComplete: true;
def isSuccess: false;
isValid ?and isComplete; // true
isValid ?and isComplete ?and isSuccess; // false
(?and)(isValid, isComplete, isSuccess); // false
And for logical-OR (?or
):
def isValid: true;
def isComplete: true;
def isSuccess: false;
isValid ?or isComplete ?or isSuccess; // true
(?or)(isValid, isComplete, isSuccess); // true
Note: As you can see, the ?and
and ?or
operators are n-ary, meaning they can take 2 or more arguments.
Any ?
-prefixed logical boolean operator can be flipped/negated by swapping the ?
character with !
in the operator. For example, !and
is NAND (not-and) and !or
is NOR (not-or):
// instead of these:
!(true ?and false); // true
!true ?or !false; // true
!(true ?and true); // false
!true ?or !true; // false
// or these:
!(false ?or false); // true
!false ?and !false; // true
!(true ?or false); // false
!true ?and !false; // false
// use negated operators:
true !and false; // true
true !and true; // false
false !or false; // true
true !or false; // false
We'll see more ?
-prefixed, boolean-returning operators in the next section, all of which can also be negated by swapping ?
for !
.
The ?=
operator checks for equality:
def x: 42;
def y: 42;
def z: 100;
x ?= 42; // true
(?=)(x, y, z); // false
Note: ?=
is another n-ary operator in the operator-as-function form. Keep in mind, equality comparison in Foi is not necessarily transitive.
The empty
value semantically means absence of a value. As such, checking for equality with empty
is somewhat of a confusing construct. It's valid to do so (x ?= empty
), but a dedicated ?empty
prefix boolean operator is provided to reduce confusion:
def x: 42;
def y: empty;
def z: empty;
x ?= empty; // false
y ?= empty; // true -- but a bit confusing!
?empty x; // false
?empty y; // true -- clearer!
(?empty)(x, y, z); // false
(?empty)(y, z); // true
To relationally compare (?<
less-than, ?>
greater-than):
def x: 100;
def y: 200;
x ?< y; // true
x ?> y; // false
And for the inclusive comparisons (?<=
less-than-or-equal, ?>=
greater-than-or-equal):
def x: 100;
def y: 200;
x ?<= x; // true
y ?>= y; // true
Note: These four operators are also n-ary operators. They compare the first operand against all other operands/inputs. For example, (?<)(x, y, z)
is the equivalent of (x ?< y) ?and (x ?< z)
, but does not compare y ?< z
.
A very common task is to check if a value is in a range between two other values:
def x: 100;
(x ?> 0) ?and (x ?< 500); // true
However, this can be done more idiomatically with the n-ary range-check operators, ?<>
(non-inclusive) and ?<=>
(inclusive):
def x: 100;
(?<>)( 0, x, 500); // true
(?<=>)(100, x, 100); // true
Note: Because these two operators have an arity of (exactly) 3, they cannot be used in the typical infix expression form, which would only allow two operands (left and right).
As mentioned in the previous section, all these ?
-prefixed comparison operators can also be flipped/negated by swapping the ?
with !
:
def x: 42;
def y: 100;
def z: empty;
x ?= 42; // true
x != 42; // false
?empty z; // true
!empty z; // false
x ?> y; // false
x !> y; // true
x ?>= y; // false
x !>= y; // true
x ?< y; // true
x !< y; // false
x ?<= y; // true
x !<= y; // false
(?<>)( 40, x, 50 ); // true
(!<>)( 40, x, 50 ); // false
(?<=>)(100, y, 100 ); // true
(!<=>)(100, y, 100 ); // false
To make decisions (with booleans!), use pattern matching. There are two forms:
-
Dependent: each pattern is matched against (dependent on) a single topic; delimited with an opening of
?( ){
, and closed with}
. -
Independent: each pattern has its own independent topic; delimited with an opening of
?{
, and closed with}
.
Each pattern clause is defined by ?[ ]: consq
, where the pattern is defined inside the [ ]
. A pattern can be negated as ![ ]
. The pattern match clause's consequent (consq
) can either be a single expression, or a { }
block; either way, it's only evaluated if the pattern is matched via the conditional.
Let's examine each pattern matching form separately, starting with dependent pattern matching. The topic of the match is any arbitrary expression, defined in the ?( ){
clause.
Consider:
def myName: "Kyle";
?(myName){
?["Kyle"]: log("Hello!");
!["Kyle"]: log("Goodbye!")
}
// Hello!
In this example, the topic is the myName
variable, which is evaluated once. Each pattern clause is evaluated, in order, and compared for equality with the topic. For the first clause whose pattern is matched, its consequent is evaluated and the result returned for the overall pattern match expression.
Dependent pattern matching expressions should be determinate, in that all possible conditional branches are defined. The result of a pattern matching expression is thus the consequent expression of whichever conditional clause was matched:
def myName: "Kyle";
def greeting: ?(myName){
?["Kyle"]: "Hello!";
!["Kyle"]: "Goodbye!"
};
greeting; // "Hello!"
However, if no pattern matches, the default result of the expression is an empty
.
Note: You can configure Foi to issue a warning notice in such a case.
To explicitly define a default pattern -- like an "else" clause, that matches if nothing else previously matched -- use ?:
as the last clause in the pattern matching expression:
def myName: "Kyle";
def greeting: ?(myName){
?["Kyle"]: "Hello!";
?: "Goodbye!"
};
greeting; // "Hello!"
The ?:
else-clause can also be abbreviated as :
if preferred. More on that in a bit.
Note: Comparing this example to the previous one, ?:
is equivalent to the !["Kyle"]
pattern. Readability preferences may dictate either style, depending on the circumstances.
A dependent style pattern can include a ,
comma separated list of multiple values, any of which may match the topic:
def myName: "Kyle";
def greeting: ?(myName){
?["Kyle","Fred"]: "Hello!";
?: "Goodbye!"
};
greeting; // "Hello!"
It may also be useful to access the topic of a pattern matching expression inside its clause(s); the topic is bound to the #
symbol:
def myName: "Kyle";
def greeting: ?(myName){
?["Kyle"]: `"Hello `#`!";
?: "Goodbye!"
};
greeting; // "Hello Kyle!"
Dependent pattern matching should only be used if the patterns only need equality-comparison of one or more discrete value(s) against the topic.
For more complex boolean-logic matching patterns, the independent pattern matching form is appropriate. Independent pattern matching has no topic, and thus begins with a ?{
instead of a ?( ){
.
In this form, each clause matches only if the pattern is a conditional that evaluates to true
. You could thus mentally model ?{
as if it was shorthand for ?(true){
:
def myName: "Kyle";
def greeting: ?{
?[myName ?= "Kyle"]: "Hello!";
![myName ?= "Kyle"]: "Goodbye!"
};
greeting; // "Hello!"
Note: The pattern-match conditional ![myName ?= "Kyle"]
is equivalent to ?[myName != "Kyle"]
. Readability preferences may dictate either style, depending on the circumstances.
Just as with dependent pattern matching, it's preferable for the overall independent pattern matching expression to be determinate, in that all conditional branches are covered. Again, to define a default (final) clause, ?:
(or abbreviated :
) may be used:
def myName: "Kyle";
def greeting: ?{
?[myName ?= "Kyle"]: "Hello!";
?: "Goodbye!"
};
greeting; // "Hello!"
Note: Again comparing this example to the previous one, ?:
is equivalent to the previous snippet's ![myName ?= "Kyle"]
conditional, or even ?[myName != "Kyle"]
. Moreover, ?:
is also equivalent to ?[true]
, which clearly would always match. Readability preferences may dictate any of those style options, depending on the circumstances, but generally the shorter ?:
form is most idiomatic.
Pattern-matching conditional clauses may optionally skip the leading ?
type-specifier, for visual brevity, if you so choose:
?{
[isLoggedIn()]: showDashboard();
[isRegistered()]: showLogin();
: showRegistration()
};
Here, the two [ .. ]: ..
clauses skip the leading type-signifier (a ?
is assumed). However, the !
type signifier is never assumed, and therefore must be explicitly stated for clauses. As mentioned earlier, the :
by itself on the third line illustrates an abbreviated ?:
else-clause.
In cases where both affirmative and negative clauses are present, it may be desirable (for visual consistency) to specify both the ?
and !
signifiers on the respective clauses, rather than only the !
being present and the ?
being assumed. For example:
// style 1
?{
[isLoggedIn()]: showDashboard();
![isRegistered()]: showRegistration()
};
// style 2
?{
?[isLoggedIn()]: showDashboard();
![isRegistered()]: showRegistration();
}
While style 1 above may be preferable (for brevity) to some, style 2 may be seen as more consistent for readability sake. Use your best judgement.
When an independent pattern matching expression would only have one clause, the clause can be specified standalone, as a guard expression.
For example:
def myName: "Kyle";
// full pattern matching expression:
?{
[!empty myName]: printGreeting(myName)
}
// standalone guard expression:
?[!empty myName]: printGreeting(myName);
// equivalent, if you prefer:
![?empty myName]: printGreeting(myName);
The leading ?
is required on standalone guard expressions, unlike the optional abbreviated form in pattern matching.
Records are immutable collections of values, delimited by < >
.
You can name each field of a record, but if you omit a name, numeric indexing is automatically applied. Any record with all numerically indexed fields (implicitly or explicitly defined) is a special case called a Tuple.
def idx: 2;
def prop: "last";
def numbers: < 4, 5, 6 >;
numbers.1; // 5
numbers[idx]; // 6
def person: < first: "Kyle", last: "Simpson" >;
person.first; // "Kyle"
person[prop]; // "Simpson"
Above, Record/Tuple fields are accessed with .
operator, whether numeric or lexical-identifier. [ ]
field access syntax evaluates field-name expressions (including strings that may include non-identifier characters).
Since .
is an operator, Record/Tuple field access can also be performed in the operator-as-function form, in which case it evaluates the second argument as an expression (like the [ ]
form does):
def idx: 2;
def prop: "last";
def numbers: < 4, 5, 6 >;
(.)(numbers, 1); // 5
(.)(numbers, idx); // 6
def person: < first: "Kyle", last: "Simpson" >;
(.)(person, "first"); // "Kyle"
(.)(person, prop); // "Simpson"
Strings are just syntax sugar for Tuples of characters. Once defined, a string and a Tuple of characters will behave the same.
def chars: < "H", "e", "l", "l", "o" >;
def str: "Hello";
chars.1; // "e"
str.1; // "e"
To determine the length of a string (or a Tuple), or the count of fields in a Record, use the size()
function:
def < :size >: import "#Std";
size("Hello"); // 5
size(< "O", "K" >); // 2
size(< a: 1 >); // 1
If the desired index or field name is held in a variable, you can use it in the Record/Tuple literal definition by prefixing the variable name with the %
sigil:
def idx: 3;
def nums: < 5, 10, 15, %idx: 20, 25 >;
// < 5, 10, 15, 20, 25 >
def field: "first";
def person: < %field: "Kyle", last: "Simpson" >;
// < first: "Kyle", last: "Simpson" >
This also works with a %
-prefixed string literal as the field name -- field names can thus contain arbitrary characters (like whitespace!):
def person: < name: "Kyle Simpson", %"favorite number": 42 >;
person["favorite number"]; // 42
When defining a Record and the field name matches the variable holding the value, this concise syntax is allowed:
def first: "Kyle";
def last: "Simpson";
def person: < :first, :last >;
// instead of:
def person: < first: first, last: last >;
Since Records/Tuples are primitive (and immutable) value types in Foi, equality comparison is structural (meaning deep contents comparison rather than reference identity).
def a: < 4, 5, 6 >;
def b: < 4, 5, 6 >;
def c: < 5, 4, 6 >;
a ?= b; // true
b ?= c; // false
c ?= a; // false
def a: < one: "hello", two: "world" >;
def b: < one: "hello", two: "world" >;
def c: < two: "world", one: "hello" >;
a ?= b; // true
b ?= c; // true
c ?= a; // true
You can determine if a value is in a Tuple with the ?in
/ !in
operator:
def numbers: < 4, 5, 6 >;
7 ?in numbers; // false
(?in)(4,numbers); // true
7 !in numbers; // true
(!in)(4,numbers); // false
Note: The in
operator only inspects numerically indexed fields.
You can determine if a field is defined in a Record with the ?has
/ !has
operator:
def person: < first: "Kyle", last: "Simpson" >;
person ?has "first"; // true
person ?has "middle"; // false
person !has "nickname"; // true
If you want to generate a list (Tuple) of sequential (aka "interval") data, you can use the binary ..
range operator (either infix or operator-as-function form).
This usage of the ..
range operator is valid with naturally sequential (ordered, fixed interval) values, such as integers and characters:
def someInts: 2..13;
someInts.5; // 7
def alphabet: (..)("a", "z");
alphabet.5; // "e"
The bounds (start and end) of a sequence/range can be held in variables:
def two: 2;
def thirteen: 13;
def someInts: two..thirteen;
def a: "a";
def z: "z";
def alphabet: a..z;
The start/end values must be of the same data type; 3.."g"
will not work.
Since Records/Tuples are immutable, to "change" their contents requires you to derive a new Record/Tuple.
The most basic way to derive a new Tuple is to concatenate two (or more) Tuples with +
:
def odds: < 1, 3, 5, 7, 9 >;
def evens: < 0, 2, 4, 6, 8 >;
odds + evens;
// < 1, 3, 5, 7, 9, 0, 2, 4, 6, 8 >
Another way to derive a new Record/Tuple is to select multiple elements using the .< >
syntax -- should be treated as a singular compound operator -- with one or more source indices/keys separated by commas:
def numbers: < 3, 4, 5, 6, 7 >;
def evenDigits: numbers.<1,3>;
// < 4, 6 >
(.<1,3>)(numbers);
// < 4, 6 >
def person: < first: "Kyle", last: "Simpson", nickname: "getify" >;
def profile: person.<first,nickname>;
// < first: "Kyle", nickname: "getify" >
(.<first,nickname>)(person);
// < first: "Kyle", nickname: "getify" >
Note: The .<
of this operator cannot have any whitespace between the two symbols, but whitespace is allowed inside the .< >
.
You can also select a ranged Tuple subset, with the .[ .. ]
syntax -- should be treated as a singular compound operator -- as a shorthand for the .< >
form:
def numbers: < 3, 4, 5, 6, 7 >;
def head: numbers.0; // 3
def first: numbers.[..0]; // < 3 >
def leading: numbers.[..-2]; // < 3, 4, 5, 6 >
def last: numbers.-1; // 7
def trailing: numbers.[-1..]; // < 7 >
def tail: numbers.[1..]; // < 4, 5, 6, 7 >
def middle: numbers.[1..3]; // < 4, 5, 6 >
(.[1..3])(numbers); // < 4, 5, 6 >
Note: The .[
of this pick syntax form cannot have any whitespace between the two symbols, nor can the 1..3
expression have whitespace; however, whitespace is allowed around the range (.[ 1..3 ]
).
A ranged Tuple subset such as .[2..5]
is a shorthand equivalent for .<2,3,4,5>
-- selecting out specific indices 2
, 3
, 4
, and 5
.
Certain ranges (e.g., .[0..]
, .[..-1]
, and .[0..-1]
) are no-op expressions, since they result in the same Tuple; as immutable values, there's no reason for Foi to actually copy the Tuple in these cases.
Additionally, inside the < >
syntactic definition of a Record/Tuple, a special &
pick sigil prefixed on a variable name (not an arbitrary expression) picks and includes some or all of the contents of that other Record/Tuple:
def numbers: < 4, 5, 6 >;
def allDigits: < 0, 1, 2, 3, &numbers, 7, 8, 9 >;
// < 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 >
def person: < first: "Kyle", last: "Simpson" >;
def friend: < &person, first: "Jenny" >;
// < first: "Jenny", last: "Simpson" >
Note: &
(pick) is a sigil, not an operator, and only has meaning inside a static < >
Record/Tuple definition.
The annotations &numbers
and &person
pick the entire contents of numbers
and person
, respectively, to be included in the new Tuple and Record values. The order of field definitions is always left-to-right, so subsequent field definitions override previous ones; thus, first: "Kyle"
is reassigned to first: "Jenny"
above.
Picking is useful for merging multiple sequences. For example, to define a Tuple holding all the base-64 characters:
def upper: "A".."Z";
def lower: "a".."z";
def digits: "0".."9";
def base64: < &upper, &lower, &digits, "+", "/" >;
// < "A", "B", "C", "D", ... "8", "9", "+", "/" >
Note: For type consistency, we intentionally defined digits
as the character sequence "0".."9"
instead of the integer sequence 0..9
.
To pick only a specific element:
def numbers: < 4, 5, 6 >;
def oddDigits: < 1, 3, &numbers.1, 7, 9 >;
// < 1, 3, 5, 7, 9 >
def person: < first: "Kyle", last: "Simpson" >;
def friend: < first: "Jenny", &person.last >;
// < first: "Jenny", last: "Simpson" >
Moreover, the picks &numbers.1
and &person.last
are shorthand equivalents for:
def numbers: < 4, 5, 6 >;
def oddDigits: < 1, 3, 2: numbers.1, 7, 9 >;
// < 1, 3, 5, 7, 9 >
def person: < first: "Kyle", last: "Simpson" >;
def friend: < first: "Jenny", last: person.last >;
// < first: "Jenny", last: "Simpson" >
One advantage of this more verbose form is, you can re-index/rename the field (something other than 2
or last
, respectively) in the target Record/Tuple.
The .< >
and .[ .. ]
syntaxes also work with the &
pick sigil inside a < >
Record/Tuple literal:
def numbers: < 3, 4, 5, 6, 7 >;
def evenDigits: < 2, &numbers.<1,3>, 8 >;
// < 2, 4, 6, 8 >
def fiveBelow: < 0, 1, 2, &numbers.[..2] >;
// < 0, 1, 2, 3, 4, 5 >
def person: < first: "Kyle", last: "Simpson", nickname: "getify" >;
def profile: < &person.<first,nickname> >;
// < first: "Kyle", nickname: "getify">
There is no dedicated exclude syntactic form for "skipping" an index/field while deriving a new Record/Tuple. However, subsequently assigning an empty
value to an index or field has the same effect:
def numbers: < 3, 4, 5, 6, 7 >;
def fewer: < 0, &numbers, 2: empty, 4: empty >;
// < 0, 3, 5, 6 >
def person: < first: "Kyle", last: "Simpson", nickname: "getify" >;
def entry: < &person, nickname: empty >;
// < first: "Kyle", last: "Simpson" >
As shown, empty
means the absence of a value, and thus cannot actually be held in a Record/Tuple.
Since Records and Tuples are immutable, if you need to define them bit by bit -- via conditionals, loops, etc -- lack of mutability can make things inconvenient. These are probably the two most obvious approaches:
-
Define all the contents ahead of time (in separate variables), and then assemble the Record/Tuple at the end:
def name: ?{ ?[customer.type ?= "business"]: customer.businessName; ?: `"`customer.last`, `customer.first`"; }; def orderTotal: orders ~fold (total,order) { total + order.total }; def record: < :name, :orderTotal >;
-
Redefine the Record/Tuple step-by-step, through derivations and variable re-assignment:
def record: empty; ?{ ?[customer.type ?= "business"]: record := < name: customer.businessName >; ?: record := < name: `"`customer.last`, `customer.first`" >; }; record := < &record, orderTotal: (~fold)(orders, (total,order) { total + order.total }) >;
These accomplish the task, but they're a bit imperative.
There's another strategy which is more FP idiomatic, using the Id
monad:
def record:
Id@ (?{
?[customer.type ?= "business"]: < name: customer.businessName >;
?: < name: `"`customer.last`, `customer.first`" >;
})
~map (record) {
def orderTotal: orders ~fold (total,order) { total + order.total };
< &record, :orderTotal >;
};
The benefit of this idiom is that we don't have to track reassignment of a record
variable as each part of the Record is computed and added. Each step (typically, a ~map
comprehension as shown) just returns the new (derived) Record value.
Note: In this snippet, record
is a monadic value (an instance of the Id
identity monad); the Record itself is held inside record
. To access/work with this underlying Record value, you can use monadic techniques and other capabilities.
A Record can also act as a map, in that you can use another Record/Tuple as a field (not just as a value), using the %
sigil to start the field name:
def numbers: < 4, 5, 6 >;
def dataMap: < %numbers: "my favorites" >;
dataMap[numbers]; // "my favorites"
Note: Like &
, the %
(map-field) sigil is not an operator, and can only be used inside a < >
Record definition.
A Set is a Tuple that only has unique values. An alternate Tuple definition form, delimited with <[ ]>
instead, is provided for convenience, to ensure each unique value is only stored once:
def something: < 4, 5, 6 >;
def another: < 6, 7 >;
def uniques: <[ &something, &another ]>;
// < 4, 5, 6, 7 >
All syntax rules/variation of Tuple definition < >
still apply inside the <[ ]>
, including use of the &
pick sigil (but not Record %
sigil); as Sets are Tuples, not Records, field names are not allowed.
Unlike +
which merely concatenates, the $+
operator acts as a unique-only Set-append operation:
def numbers: <[ 4, 5, 5, 6 ]>;
def moreNumbers: numbers $+ < 6, 7 >;
moreNumbers; // < 4, 5, 6, 7 >
Warning The $+
operator only works on Tuples (Sets), not Records.
Set equality comparison deserves special attention. Since Sets are merely a construction form for Tuples, the ?=
will perform Tuple equality comparison, where order matters. This may produce undesired results (false negatives).
As such, the ?$=
(set equality) operator, and the corresponding !$=
(set non-equality), perform unordered comparison of Sets (Tuples):
def set1: <[ 4, 5, 5, 6 ]>; // < 4, 5, 6 >
def set2: <[ 5, 5, 6, 4 ]>; // < 5, 6, 4 >
def set3: <[ 6, 4, 5, 0 ]>; // < 6, 4, 5, 0 >
set1 ?= set2; // false
set1 ?$= set2; // true
set1 !$= set3; // true
Note: Unordered (Set equality) comparison is slower than ordered comparison (Tuple equality). This cost is worth paying if you really need to compare two Sets, but it may be worth examining if a different approach is feasible.
To define a function, use the defn
keyword. To return a value from anywhere inside the function body, use the ^
sigil:
defn add(x,y) { ^x + y; }
Function definitions are always hoisted to their enclosing scope:
add(6,12); // 18
defn add(x,y) { ^x + y; }
Function definitions are also expressions (first-class values), so they can be assigned and passed around:
def myFn: defn add(x,y) { ^x + y; };
myFn(6,12); // 18
add(6,12); // 18
somethingElse(myFn);
Function definition expressions can also be immediately invoked, a so-called (IIFE: Immediately Invoked Function Expression):
(defn add(x,y){ ^x + y; })(6, 12); // 18
Concise function definitions may omit the name and/or the { }
around the body, but the concise body must be an expression marked by the initial ^
return sigil:
def myFn: defn(x,y)^x + y;
(defn add(x,y)^x + y)(6, 12); // 18
To default a function parameter value:
defn add(x: 0, y: 0) ^x + y;
The default is applied if the corresponding argument supplied has the empty
value, or if omitted.
To specify a function that collects all individual/positional arguments into a single parameter (as a list/Tuple):
defn add(*nums) ^(+)(...nums);
The *
sigil (not an operator) must immediately precede a single identifier (with no default value), and must be the only listed parameter in the function definition. So in the above snippet, all arguments passed will be gathered in a single value assigned to the nums
parameter.
A predicate is a boolean-returning function. For example:
defn isOdd(v) ^mod(v,2) ?= 0;
It can be quite useful to negate a predicate; this can be expressed with the unary !
prefixing a function value:
def isEven: !isOdd;
// or:
def isEven: (!)(isOdd);
Note: As shown here, !
is overloaded to produce a negated (aka, complement) function if used against a function (or operator) value. Otherwise, it acts to flip/negate a boolean value.
It's common that we write function logic while making certain assumptions (aka: expectations, requirements, pre-requisites) for the parameter inputs.
Functions should be as obvious as possible in surfacing such assumptions, rather than merely embedding this logic into the function body's runtime. Ideally, these pre-conditions are part of the explicit function signature, so a reader doesn't need to inspect and mentally execute the function's implementation.
Additionally, some pre-conditions may be verifiable at compile time. And even more importantly, pre-conditions can be evaluated before the function has been invoked, where a function might not even need to be invoked!
In most other programming languages, a pre-condition means: "if this condition is not met, the function cannot run". We might even call this an "assertion". And in some languages, exceptions might be thrown to indicate this failure.
In Foi, it's the opposite (indeed, Foi doesn't have exceptions).
We're intentionally flipping the mental model from "the function runs only if it can" to "the function runs only if it needs to". If a function's pre-condition is met, the function doesn't need to run; its result value is already explicitly known.
These aspects of the function's signature go beyond parameter type annotations. It's more than, "is this parameter always an int
?"; pre-conditions are lifted to the call-site, applied against the function's argument input value(s), and indeed the relationship(s) between such argument values.
Consider a function that returns 1
if its argument is less than or equal to 1
. We might call this a "base condition" or an "early return" in certain styles of programming.
You could write it this way:
defn myFn(x) {
?{
?[x ?<= 1]: ^1
?: empty
}
// ..
}
The problem is, this ^1
"early return" isn't particularly obvious, and requires reading into the body to determine.
Foi functions can and should do better.
Pre-conditions are guard expressions, of the form ?[ ]: expr
or ![ ]: expr
, which are applied to guard against the need to run the function. One or more of these pre-conditions may appear in the function definition, between the ( )
parameter list and the body of the function -- either the { }
full body, or the the ^
-denoted concise expression-body.
Thus, the above myFn()
function could be more appropriately defined as:
defn myFn(x) ?[x ?<= 1]: 1 {
// ..
}
Pre-conditions are evaluated -- hoisted to the call-site -- before actual function invocation. If a pre-condition matches, the consequent expr
is evaluated and returned (no ^
return sigil) and thus the function invocation is skipped.
Just like with pattern matching expressions, a preceding !
(in place of the ?
) negates the pre-condition. By using this form of a pre-condition, you somewhat conform it to the typical mental model of pre-conditions (as discussed earlier).
For example, if you want to define a function that only computes its result when the input is greater than 10
:
defn myFn(x) ![x ?> 10]: empty {
// ..
}
You can read/interpret the ![x ?> 10]: empty
pre-condition as: "x must be greater than 10; if it's not, return empty
instead". That's basically the way we interpret pre-conditions in any programming language.
Note: In this usage, empty
indicates to the calling code that the function had no valid computation to perform. However, there are other types of values that could (should!?) be returned here, such as a None
or Left
monads (more later).
If a function has multiple parameters, a pre-condition may imply a relationship between them. For example, to define a function where the first parameter must be larger than the second:
defn myFn(x,y) ![x ?> y]: empty {
^(x - y);
}
Here, if myFn(5,2)
is called, the result will be 3
. But if myFn(2,5)
is called, the function won't be invoked at all, and the result (from the pre-condition) will be empty
:
defn myFn(x,y) ![x ?> y]: empty {
^(x - y);
}
def result1: ?(myFn(5,2)){
![empty]: #
?: 0
};
def result2: ?(myFn(2,5)){
![empty]: #
?: 0
/?;
result1; // 3
result2; // 0
Function recursion is supported:
defn factorial(v) ![v ?> 1]: 1 {
^v * factorial(v - 1);
}
factorial(5); // 120
Tail-calls (recursive or not) are automatically optimized by the Foi compiler to save call-stack resources:
defn factorial(v,tot: 1) ![v ?> 1]: tot {
^factorial(v - 1,tot * v);
}
factorial(5); // 120
Function definitions can optionally be curried:
defn add(x)(y) ^x + y;
def add6: add(6);
add6(12); // 18
add(6)(12); // 18
// loose currying:
add(6,12); // 18
Function definitions must declare side-effects (reassignment of free/outer variables) using the :over
keyword:
def customerCache: empty;
def count: 0;
defn lookupCustomer(id) :over (customerCache) {
// ..
// this reassignment side-effect allowed:
customerCache := cacheAppend(customerCache,customer);
// but this is disallowed because `count`
// isn't listed in the `:over` clause:
count := count + 1;
}
Note: Closure over free/outer variables -- specifically, (r-value) read-only access -- is allowed without being listed in the :over
clause. The :over
clause must only list free/outer variables that will appear in an (l-value) assignment-target position.
Function composition can be defined in left-to-right style, with the +>
flow operator:
defn inc(v) ^v + 1;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
def compute1: inc +> triple +> half;
def compute2: (+>)(inc, triple, half);
compute1(11); // 18
compute2(11); // 18
Note: The +>
flow operator produces a unary function, meaning it will only accept and pass-along a single argument; any additional passed arguments are ignored.
It's also very common in FP to prefer right-to-left style composition. Probably the most obvious reason is the visual-ordering coherency between half(triple(inc(v)))
and a composition argument list like half, triple, inc
.
The <+
compose-right operator is equivalent to using the '
prime operator to reverse the order of the +>
operator's arguments, as +>'
:
defn inc(v) ^v + 1;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
def compute1: (<+)(half, triple, inc);
def compute2: (+>')(half, triple, inc);
compute1(11); // 18
compute2(11); // 18
By contrast, the #>
pipeline operator (F#-style) operates left-to-right like this:
defn inc(v) ^v + 1;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
11 #> inc #> triple #> half; // 18
11 #> (+>)(inc, triple, half); // 18
The first expression in a pipeline must be a value (or an expression that produces a value). Each subsequent step must resolve to a function, which when invoked produces a value to pass on to the next step.
Since the #>
operator is n-ary, multiple steps can also be used in the operator-as-function form:
defn inc(v) ^v + 1;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
(#>)(11, inc, triple, half); // 18
Recall that we can reverse the order of arguments with the '
prime operator, allowing us to do right-to-left pipelining (if we wanted to for some reason):
defn inc(v) ^v + 1;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
(#>')(half, triple, inc, 11); // 18
The topic of a pipeline step is the result of the previous step, and is implicitly passed as the single argument to the step's function. But the topic (i.e., the previous step's result value) can be explicitly referred to with the #
sigil:
defn add(x,y) ^x + y;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
11 #> add(1,#) #> triple #> half; // 18
Of course, if the add()
function is curried, we can get back to point-free style (no need for the explicit #
topic):
defn add(x)(y) ^x + y;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
11 #> add(1) #> triple #> half; // 18
A pipeline function is a specialized function definition form that replaces the ^
return sigil with a #>
pipeline as its concise body. The topic of the first step is automatically bound to the first parameter of the function:
defn add(x,y) ^x + y;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
defn compute(x) #> add(1,#) #> triple #> half;
compute(11); // 18
And again, if we define add()
as curried function, we can avoid the #
topic reference:
defn add(x)(y) ^x + y;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
defn compute(x) #> add(1) #> triple #> half;
compute(11); // 18
Compare this #>
pipeline function form to the previously-discussed +>
flow operator:
defn add(x)(y) ^x + y;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
def compute: (+>)(add(1), triple, half);
compute(11); // 18
The previous #>
pipeline function form is more powerful/flexible than the +>
approach, in that a pipeline function can declare multiple parameters, and access any of them throughout the pipeline via #
.
A unit function, also known as a unit constructor, is a utility for "constructing" a value. There are two helpful utility unit functions that are nearly ubiquitous in FP programs.
Foi provides both of these unit function utilities built-in.
The first is often referred to as "identity". It's a function that takes a single input and simply returns it untouched. You could define your own like this:
defn identity(v) ^v;
identity(42); // 42
However, this utility is so commonly needed that Foi provides it built-in, as well as its @
shorthand:
Value@42; // 42
Value@ 42; // 42
Value@(42); // 42
@42; // 42
@ 42; // 42
@(42); // 42
Both Value@
and the abbreviated @
are defined as the identity function. They are interchangeable, so the choice between them is primarily a readability/style preference.
Additionally, as shown, Foi provides a shorthand when a single-argument function name ends in @
; its argument can be applied without surrounding ( )
, with or without whitespace between @
and the argument, saving 1-2 characters.
Note: For readability/operator-precedence sake, you may still optionally use the ( )
around the argument; it's technically treated as an expression grouping rather than part of the required prefix function invocation syntax.
Here's an example of how you might use the identity unit function:
defn formatRecord(record,formatFn: @) {
// ..
^formatFn(record);
};
The @
serves as a default pass-through function here.
It's also very common to need a function which takes no argument, but returns some fixed value. There are places where a function is expected -- for example, operators like ~cata
or ~map
-- and yet we want to provide a placeholder function that returns a fixed value.
In Foi this is called "null application".
We can define either dedicated functions or inline functions for this purpose. For example:
defn fortyTwo() ^42;
fortyTwo(); // 42
But since that may happen a lot, we might want a utility for constructing such functions:
defn constant(v)() ^v;
def fortyTwo: constant(42);
fortyTwo(); // 42
Note: Notice the ()
in the constant()
function definition, which matches with the ()
at the fortyTwo()
call-site. That's where the label "null application" comes from.
Since this is so common in FP programs, Foi provides a null-application unit function:
def fortyTwo: Function@42;
def hello: Function@ "Hello!";
def yes: Function@(true);
fortyTwo(); // 42
hello(); // "Hello!"
yes(); // true
The name of this utility (Function@
) is a nod to the fact that it's a unit constructor for a function, specifically a null-application function.
But Function@
is a little verbose, so the same thing can be accomplished by partially-applying the @
value identity function:
def fortyTwo: @|42|;
def hello: @|"Hello!"|;
def yes: @|true|;
fortyTwo(); // 42
hello(); // "Hello!"
yes(); // true
Here's an example of how you might use this utility:
defn getName(record,getLabel: @|"Default"|) {
^(`"`getLabel(record)`: `record.name`");
};
Note: The @|"Default"|
form here constructs a default getLabel
function that just returns the value "Default"
when invoked.
Perhaps some of the most distinctive features in various programming languages (FP-oriented versus more general) is the mechanics of looping/iteration. Imperative languages tend to have a variety of loop types (for
, while
, do..while
, etc), whereas FP languages favor iterations/comprehensions (map
, filter
, reduce
/ fold
, etc).
Foi is unquestionably an FP-oriented language, but tries (to an extent!) to cast a wider, more pragmatic net, in hopes of being inclusive of broader programming styles. As such, there's a unified syntax which can be used for both imperative looping and declarative iteration/comprehension.
Let's start with the typical imperative loop approach. Here's a loop that prints "Hello!"
four times, using the ~each
loop operator:
0..3 ~each {
log("Hello!");
};
// Hello!
// Hello!
// Hello!
// Hello!
~each
is a operator/function that can be used either in the infix form (shown above) or the operator-as-function form. The first operand to ~each
defines the range, and the second operand defines the iteration operation(s).
-
The range is an expression that determines the bounds of the loop processing; this expression can take two forms:
-
If the range expression resolves to a Record/Tuple, the contents of the value are set as fixed bounds for loop processing. Examples of such an expression: an identifier, a function call, generated (
0..3
, as above), or explicit inline (such as< 0, 1, 2, 3 >
). -
If the range expression is a conditional of the form
?[ ]
or![ ]
-- same as the conditional of an independent pattern matching clause -- the expression will be evaluated before each iteration, and will only proceed with the iteration iftrue
;false
signals the end of the range and terminates the loop.For example:
def done: false; ![done] ~each { // .. };
This loop will keep running as long as
done
is false. The range could also have been written as?[!done]
, but the former should generally be preferred as easier to read. -
If the
range
expression is omitted,~each
returns another function that expects a single argument defining the range. For example:def printAll: ~each log; printAll(< 1, 3, 5, 7, 9 >); // 1 // 3 // 5 // 7 // 9
-
-
The iteration is an expression that defines what operation(s) to perform for each iteration. This expression can take several forms:
-
an expression that evaluates to a function to invoke for each iteration. For example:
0..3 ~each log; // 0 // 1 // 2 // 3
-
an inline block with a
( )
block-definitions clause (list of comma-separated definitions). For example:2..5 ~each (v, idx) { log(`"`idx`: `v`"); }; // 0: 2 // 1: 3 // 2: 4 // 3: 5
Warning: Beware that any initializations of these definitions (e.g.,
(v: 3, idx: 7)
) may very well be overwritten immediately, as they are assigned per-iteration according to the looprange
and the iteration-type.If the loop iteration doesn't need any block-scoped definitions, omit the
( )
block-definitions clause:0..3 ~each { log("Hello!"); }; // Hello! // Hello! // Hello! // Hello!
-
In general, the result of the ~each
operation is another range (e.g., Record/Tuple), such that multiple ~each
expressions can be chained together. For example, a ~each b ~each c
, which would loop performing b
over the a
range, then loop performing c
over the resultant range from the first ~each
operation. The same would be true of (~each)(a, b, c)
.
Note: For ~each
looping over a Record/Tuple range, ~each
will result in the same Record/Tuple. But in the case where the range was a conditional, the result of ~each
will be the final boolean false
that terminated the range.
However, moving beyond imperative ~each
looping of Records/Tuples, Foi provides a variety of list comprehensions, including: ~map
, ~flatMap
, ~filter
, ~fold
, and ~foldR
.
These must all have a non-conditional range operand; when the range is a list (Tuple), they act as list comprehensions.
The iteration operand for the ~filter
comprehension is a predicate, meaning for each value in the list (Tuple), it must compute a true
to keep (aka, "filter in") the value, or false
to discard (aka, "filter out") the value:
defn isEven(v) ^mod(v,2) ?= 0;
def evens: 0..9 ~filter isEven;
// < 0, 2, 4, 6, 8 >
def odds: 0..9 ~filter (v) {
!isEven(v);
};
// < 1, 3, 5, 7, 9 >
The ~filter
comprehension requires a list (Tuple) range, and its final result is always another list (Tuple).
Note: Just like with loops, all these comprehensions support the iteration operand being a function, an inline function definition, or an inline-block.
Perhaps one of the most common/recognizable list comprehensions is map:
defn double(v) ^v * 2;
def evens: 0..5 ~map double;
// < 0, 2, 4, 6, 8, 10 >
def evens: 0..5 ~map (v) { v * 2; };
// < 0, 2, 4, 6, 8, 10 >
Note: The ~map
comprehension expresses Functor/Mappable behavior from broader Category Theory (more later).
To compose multiple (~map
) comprehensions:
defn inc(v) ^v + 1;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
def odds: < 1, 3, 5, 7, 9 >;
odds ~map inc ~map triple ~map half;
// < 3, 6, 9, 12, 15 >
(~map)(odds, inc, triple, half);
// < 3, 6, 9, 12, 15 >
odds ~map (+>)(inc, triple, half);
// < 3, 6, 9, 12, 15 >
Further, we can take advantage of omitting the range (via | |
partial application invocation) to create a function out of the comprehension composition:
defn inc(v) ^v + 1;
defn triple(v) ^v * 3;
defn half(v) ^v / 2;
def compute: (~map)|, inc, triple, half|;
compute(< 1, 3, 5, 7, 9 >);
// < 3, 6, 9, 12, 15 >
Note: In compute
definition, the leading ,
in the arguments list indicates explicitly omitting the range, thereby producing a partially-applied function that expects the range on its invocation.
A map iteration doesn't always have to produce a single discrete value.
Let's consider the case where a map operation itself returns a list (Tuple) instead of a single discrete value; the result is a list of sub-lists. This operation is typically called a zip:
defn zip(xs,ys) ^xs ~map (x,i) { < x, ys[i] >; };
zip(< 1, 2, 3 >,< 4, 5, 6 >);
// < <1,4>, <2,5>, <3,6> >
As shown, returning a list (Tuple) from the map iteration ends up with a list of sub-lists. When a zip is called for, this is the approach.
When mapping two or more lists (Tuples) together, sometimes what we want is a single-level list, with all the sub-lists flattened out. This operation can be referred to as a merge.
To perform merge (aka, flattening while mapping), we can use the ~flatMap
comprehension:
defn merge(xs,ys) ^xs ~flatMap (x,i) { < x, ys[i] >; };
merge(< 1, 2, 3 >,< 4, 5, 6 >);
// < 1, 4, 2, 5, 3, 6 >
Note: You may recognize that "flatMap" often goes by alternate names in other contexts/languages: "bind", "chain", etc. As we'll see later with Monadic Bind, ~flatMap
has various aliases: ~bind
, ~chain
, and the shorter/generally more preferred ~<
. They all work identically, so readability preferences dictate which to use.
It may not seem obvious yet, but ~<
(aka, ~flatMap
, ~bind
, or ~chain
) is likely to be a fairly common list (Tuple) comprehension operation in your programs.
For example, it's common to flat-map with multiple lists, and need access to a value from each list to perform the mapping:
def xs: < 2, 4 >;
def ys: < 7, 8 >;
xs ~< (x) {
ys ~< (y) {
< x + y >;
};
};
// < 9, 10, 11, 12 >
Notice that to pull this off -- accessing both x
and y
in the same scope! -- we had to nest the second ~<
operation inside the iteration scope of the first ~<
operation. That works, but it should smell a little bit.
By the way, we illustrated the above with two ~<
flat-maps, but there's an alternate way to think about it:
def xs: < 2, 4 >;
def ys: < 7, 8 >;
xs ~< (x) {
ys ~map (y) {
x + y;
};
};
// < 9, 10, 11, 12 >
Notice that here, I replaced the innermost ~<
with a ~map
, and then omitted the < >
Tuple wrapped around the x + y
computation.
The end result is equivalent. Generally, it doesn't really matter which way makes most sense to you; pick one and go with it. But for this guide, I'll stick with the former double ~<
processing model.
Whichever way we mentally model this task, we'll likely encounter it rather often. And as ugly as multiple nested scopes can get (especially if there's 3 or more of these steps/nestings involved), Foi provides a special comprehension: ~<<
, called the do comprehension.
Note: In other languages (e.g., Haskell, Scala, etc), you may see something similar referred to as the "do syntax", most commonly in a monadic context. However, here we're broadening/generalizing the concept to include lists (Tuples).
Consider:
def xs: < 2, 4 >;
def ys: < 7, 8 >;
List ~<< {
def x:: xs;
def y:: ys;
x + y;
};
// < 9, 10, 11, 12 >
There are several special things going on here. But hopefully once I describe the processing steps, you'll recognize it as the same as the first snippet (double ~<
version) at the top of this section.
First off, the ~<<
operator's "range" operand is List
, the formal type for a Tuple. That should seem a bit strange, since nothing should really happen if you perform a comprehension/iteration across an empty list... right? But it's even more unusual, in that this is a type for the range rather than a concrete value. Just hang with me.
Notice the ::
instead of the typical :
in the def
statement. What def x:: xs
is saying is, pull out each value from xs
, one at a time, and assign each value to x
, on successive iterations of the block.
THAT you should recognize as a comprehension. Moreover, the second def y:: ys
is pulling out each value from y
one at a time, and assigning it to y
. Again: comprehension.
Here's the tricky part: the second comprehension is happening for each iteration of the first comprehension. In other words, the second is nested in the first. Just like the earlier double ~<
snippet, right?
When each iteration's x + y
operation is performed, the result is automatically wrapped in the same type as the range context -- remember: if omitted, an empty <>
tuple is assumed for the range. Finally, that result is flat-mapped into the overall result.
We can also just include the special iterative assignments in a block-definitions clause:
List ~<< (x:: xs, y:: ys) {
x + y;
};
// < 9, 10, 11, 12 >
As both forms are equivalent, readability and other code maintenance concerns dictate the appropriate style to choose.
Remember, the final value produced in the iteration block is automatically wrapped in the comprehension range type (list/Tuple here). That means a double-wrapping effect would occur if the value produced is already a list (Tuple); this might be desired but often is a mistake.
Consider the non-do comprehension form:
def xs: < 2, 4 >;
def ys: < 7, 8 >;
xs ~< (x) {
ys ~map (y) {
< x + y >; // <-- notice the tuple and ~map
};
};
// < < 9 >, < 10 >, < 11 >, < 12 > > <-- oops!
When it's a literal value like this, you can omit the < >
.
But when that "final" value comes from an outside computation, we can't just omit the < >
. So we're forced to use ~<
instead of ~map
:
defn tup(x,y) ^(< x + y >);
def xs: < 2, 4 >;
def ys: < 7, 8 >;
xs ~< (x) {
ys ~< (y) { // <-- notice ~< instead of ~map
tup(x,y);
};
};
// < 9, 10, 11, 12 >
But for the do comprehension form, we ostensibly cannot control the final step's behavior:
List ~<< (x:: xs, y:: ys) {
tup(x,y);
};
// < < 9 >, < 10 >, < 11 >, < 12 > > <-- oops!
So here's some workarounds:
List ~<< (x:: xs, y:: ys) {
tup(x,y).0; // ugh!
};
// < 9, 10, 11, 12 >
List ~<< (x:: xs, y:: ys) {
def v:: tup(x,y); // meh
v;
};
// < 9, 10, 11, 12 >
But these are a bit annoying, right?!
When you need to unwrap the final value, there's a special syntactic shortcut, a prefix of ::
on the final expression:
List ~<< (x:: xs, y:: ys) {
::tup(x,y); // <-- notice the ::
};
// < 9, 10, 11, 12 >
This special ::
usage (without a def
) may only prefix the final expression in the iteration block of the do comprehension.
You can think about this ::
as instructing the do comprehension to perform a final ~<
(instead of ~map
). Or you can think about it as skipping the automatic value wrapping that would otherwise occur.
You may have noticed that a single list (Tuple) in this ~<<
do comprehension form is equivalent to the ~map
comprehension on a single list:
def xs: < 2, 4 >;
List ~<< (x:: xs) {
x * 10;
}
// < 20, 40 >
xs ~map (x) {
x * 10;
}
// < 20, 40 >
The ~map
form is clearer here -- seems a bit more obvious and less magical -- and should probably be preferred in the specific case of having a single comprehension.
By contrast, the do comprehension form really shines when there are multiple comprehensions composed together, especially if computations need to access values from each of them, in a single scope. That's precisely what the magical do comprehension is all about.
The ~fold
comprehension (left-to-right), often referred to as reduce, works like this with lists (Tuples):
defn add(x,y) ^x + y;
0..9 ~fold add;
// 45 (0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9)
0..9 ~fold (acc,v) {
acc + v;
};
// 45 (0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9)
Note: The final result (and type) of the ~fold
comprehension is determined by the return value of the final iteration evaluation.
In this form of the ~fold
comprehension, the first iteration evaluation will receive the first value from the list (Tuple) as the accumulator (above: x
, acc
) argument, and the second value from the list as the iteration-value (above: y
, v
) argument.
So in the above snippet, the first invocation of add()
in each comprehension will have x
set as 0
and y
set as 1
. That return result (1
) comes in as x
for the second invocation of add()
, with y
being 2
. And so on.
But, folds/reductions sometimes need to start with a specific initial value rather than with just the first value in the list (Tuple). In this usage, the ~fold
comprehension is a representation of generalized Foldable behavior (covered later).
If the ~fold
comprehension's range argument is a list (Tuple), and there are at least two other operands specified (i.e., 3 or more operands total), then the second operand will be interpreted as the initial-value (or default-value), and the third operand is an iteration expression.
For example:
defn sub(x,y) ^x - y;
(~fold)(1..5, 100, sub);
// 85 (100 - 1 - 2 - 3 - 4 - 5)
When an initial value is provided to a fold/reduce, it is set as the accumulator argument to the first iteration, and the first value in the list (Tuple) is the iteration-value argument.
So in the snippet above, 100
is the initial value, and comes in as x
in the first invocation of sub()
, with y
being 1
. That return result (99
) comes in as x
for the second invocation of sub()
, with y
being 2
. And so on.
For a ~fold
comprehension to execute, there should be at least two values to operate upon. If the list (Tuple) only has one value in it, then specifying the initial-value provides this second value.
If there's only one value (either in the list or as the specified initial-value), the ~fold
comprehension short-circuits (i.e., skips evaluating the iteration operand), returning that single value.
But if there isn't at least one value provided, the ~fold
operation is invalid, and empty
is the result.
Note: You can configure Foi to issue a warning notice in such a case.
Folds can even produce another Record/Tuple result. One typical way to accomplish this is for the initial-value operand to be an empty Record/Tuple (<>
):
defn onlyOdds(list,v)
![mod(v,2) ?= 1]: list
^list + < v >
(~fold)(0..9, <>, onlyOdds);
// < 1, 3, 5, 7, 9 >
The ~foldR
comprehension works identically to the ~fold
comprehension, but in right-to-left order. Compare the two comprehensions here:
defn sub(x,y) ^x - y;
1..5 ~foldR sub;
// -5 (5 - 4 - 3 - 2 - 1)
1..5 ~fold sub;
// -13 (1 - 2 - 3 - 4 - 5)
The identity monad in Foi is called Id
, and the empty monad is called None
. These two are actually paired in the Maybe
monad type, as discussed later.
The @
sigil applies the "unit constructor" for any monad type, thus a monadic value can be constructed like this:
def m: Id@ 42; // Id{42}
def nil: None@; // None
A monadic value is a primitive, immutable value type in Foi, meaning equality comparison is structural (just like Records/Tuples). As such:
def m: Id@ 42;
def g: Id@ 42;
m ?= g; // true
For any monadic value besides instances of None
, if @
is partially applied with a monad type, you get a regular factory function for constructing that specific monad instance:
def ofId: Id@;
ofId(42); // Id{42}
A monadic value is a valid range for certain comprehensions, as we'll now explore.
Since broader Category Theory shows that Monads are Functors/Mappables, monadic values can also be used as the range for a ~map
comprehension:
defn double(v) ^v * 2;
def m: Id@ 21;
m ~map double; // Id{42}
m ~map (v) { v * 2; }; // Id{42}
Note: Unless the range is a List
monad, the iteration for monadic ~map
will always be provided the instance's single held value as argument; for list (Tuple) and the List
monad, the iteration is provided both value and index arguments.
Comprehension operations for None
are all no-ops, meaning they do nothing but simply return the same None
value again:
defn double(v) ^v * 2;
Id@ 21 ~map double; // 42
None@ ~map double; // None
Note: The double()
invocation won't happen for the None@
-constructed monadic value.
Monadic values also (obviously!) support the ~bind
comprehension:
defn double(v) ^v * 2;
def m: Id@ 21;
m ~bind (double +> Id@); // Id{42}
m ~flatMap (double +> Id@); // Id{42}
m ~chain (double +> Id@); // Id{42}
m ~< (double +> Id@); // Id{42}
Note: As shown, for convenience/familiarity sake, ~flatMap
, ~chain
and ~<
are all aliases for the ~bind
comprehension. All 4 are interchangeable, but for brevity sake, ~<
is generally most preferable.
For formality sake, here are the 3 monad laws demonstrated, using the Id
monad (via its @
unit-constructor) and the ~<
bind operator:
-
Left Identity:
defn incM(v) ^(Id@ v + 1); Id@ 41 ~< incM; // Id{42}
-
Right Identity:
Id@ 42 ~< Id@; // Id{42}
-
Associativity:
defn incM(v) ^(Id@ v + 1); defn doubleM(v) ^(Id@ v * 2); Id@ 20 ~< incM ~< doubleM; // Id{42} Id@ 20 ~< (v) { incM(v) ~< doubleM; }; // Id{42}
Recall the do comprehension for lists with the ~<<
operator.
In the same way that ~<<
makes it convenient to compose multiple list comprehensions (via ~<
chaining) while accessing values from each within a single scope, the same is possible and useful for monadic comprehensions.
Here's the nested ~<
form:
defn inc(v) ^v + 1;
defn double(v) ^v * 2;
def incM: inc +> Id@;
def doubleM: double +> Id@;
incM(1) ~< (x) {
doubleM(x) ~< (y) {
Id@ (3 * x) + y;
}
};
// Id{10}
To have access to both x
and y
in the final chain step, we used a nested scope. Alternatively, you could pack both values into a Record/Tuple to pass into the final step, but that's even uglier.
Here's the more ergonomic ~<<
do comprehension form:
Id ~<< (x:: incM(1), y:: doubleM(x)) {
(3 * x) + y;
};
// Id{10}
Id ~<< {
def x:: incM(1);
def y:: doubleM(x);
(3 * x) + y;
};
// Id{10}
Note: These should look familiar to the various styles presented in the Do Comprehension (List) section earlier.
In these snippets, the only substantive difference from the list comprehension form is the Id
(monad type) being passed as the first (range) operand to ~<<
. This provides the type of monad to wrap (via its @
unit constructor) around the final computed value. Remember: if omitted, a general list (Tuple) type is assumed, which is not desired here.
Don't forget the special ::
prefix on the final expression, in case you need to omit the automatic monadic type wrapping:
defn compute(x,y) ^Id@ ((3 * x) + y);
Id ~<< {
def x:: incM(1);
def y:: doubleM(x);
compute(x,y);
};
// Id{Id{10}} <-- oops!
Id ~<< {
def x:: incM(1);
def y:: doubleM(x);
::compute(x,y); // <-- notice ::
};
// Id{10}
A function's return value can be explicitly expressed monadically:
defn incM(v) ^(Id@ v + 1);
incM(3); // Id{4}
Non-monadic-returning functions can also be composed with the intended unit constructor:
defn inc(v) ^v + 1;
def incM: inc +> Id@;
incM(3); // Id{4}
That approach is often useful if the non-monadic-returning function is already independently defined, so effectively a monad is just being wrapped around the function's return value.
Monadic function returns can also be integrated directly into a function's logic:
defn factorialM(v,tot: Id@ 1) ![v ?> 1]: tot {
tot := tot ~map (t) { t * v; };
^factorialM(v - 1,tot);
}
factorialM(5); // Id{120}
You can use pattern matching to determine which type of monad instance is being dealt with:
def m: getSomeMonad(42);
?(m){
?[?as Id, ?as Right]: something(m)
?[?as None]: something(m,"default!")
?[?as Left]: something(m,"oops")
?: something(Left@ "Unknown!","oops")
};
Note: More on types and the ?as
operator later.
For something to be a monad, we need it to be able to satisfy the 3 monadic laws. In particular, it needs a bind operation and it needs a unit constructor.
Well... we've already seen that lists (Tuples) have the ~<
(aka ~flatMap
, ~bind
, or ~chain
) operator defined. So, all we're missing is a unit constructor for a list (Tuple).
Thankfully, Foi provides List
, such that List@
produces <>
and List @ 42
produces < 42 >
.
We can thus prove List
(any Tuple) is a monad:
defn incM(v) ^(List@ v + 1);
defn doubleM(v) ^(List@ v * 2);
// (1) Left Identity:
List@ 41 ~< incM;
// < 42 >
// (2) Right Identity:
List@ 42 ~< List@;
// < 42 >
// (3) Associativity:
List@ 20 ~< incM ~< doubleM;
// < 42 >
List@ 20 ~< (v) {
incM(v) ~< doubleM;
};
// < 42 >
So, there you go: the List
monad.
The Id
and None
monads are paired in the Maybe
Sum type monad. For readability preferences, Maybe.None
is an alias for None
, and Maybe.Id
is an alias for Id
. Moreover, to adhere to the monadic laws, Maybe@
is the same as Id@
:
Maybe@ 42; // Id{42}
Maybe@ empty; // Id{empty}
Id@ empty; // Id{empty}
None@; // None
But that's not particularly useful or interesting: we could already select Id
or None
explicitly.
A special Maybe._
constructor (not the main unit constructor) inspects the provided value and selects None
when it encounters the empty
value, and Id
for any other value:
Maybe._(42); // Id{42}
Maybe._(empty); // None
For syntactic consistency, you can still use the @
form:
Maybe._ @ 42; // Id{42}
Maybe._ @ empty; // None
For further convenience, you may choose to do this:
def May: Maybe._@;
May(42); // Id{42}
May(empty); // None
May@ 42; // Id{42}
May@ empty; // None
Instead of using Maybe._
, you can define custom constructor functions that select None
or Id
for various values:
defn nonZero(v)
?[v ?= 0]: None@
^Id@ v;
def qty: nonZero(0); // None
def cost: nonZero@ 1.99; // Id{1.99}
One common idiom where Maybe
is used is conditional property access:
defn prop(name)(obj) ^Maybe._ @ obj[name];
def order: Id@ <
shippingAddress: <
street: "123 Easy St",
city: "TX",
zip: "78889"
>
>;
order
~< prop("shippingAddress")
~< prop("street");
// Id{"123 Easy St"}
order
~< prop("billingAddress")
~< prop("street");
// None
As with other monad kinds, Maybe
has a ~<<
do comprehension form:
Maybe ~<< {
def shipAddr:: prop("shippingAddress",order);
def street:: prop("street",shipAddr);
Id ~<< (streetV:: street) {
log(`"Street: `streetV`");
};
};
// Street: 123 Easy St
Maybe ~<< {
def billAddr:: prop("billingAddress",order);
def street:: prop("street",billAddr);
Id ~<< (streetV:: street) {
log(`"Street: `streetV`");
};
};
// None
Since the prop("billingAddress",order)
returns a None
, the rest of that second Maybe ~<< { }
do comprehension will short-circuit exit.
We briefly mentioned Foldable earlier, with the ~fold
comprehension on lists (Tuples). We'll revisit the generalized Foldable (more broadly, Catamorphism) behavior now, in the context of monads.
Sum type monads in Foi have Foldable behavior built-in, expressed with the ~fold
/ ~cata
(catamorphism) comprehensions.
For a monadic value range, the ~fold
/ ~cata
comprehensions require multiple iteration expressions, one for each component of the Sum type; again, because this means 3 or more operands, the operator must be invoked as a function.
The difference between monadic ~fold
and ~cata
is with the default iteration operand. For ~fold
, this operand is an already computed default-value, whereas for ~cata
it's a function that computes a value. In either case, this operand is evaluated/used when the first/left-most component of the Sum type is encountered.
Let's consider a binary Sum type like Maybe
(comprised of None
and Id
), which thus requires a range expression and two iteration expressions. For None
, the default iteration expression (second operand, "defaultMsg
s below) is evaluated; for Id
, the alternate iteration expression (third operand, @
s below) is invoked.
For example:
def defaultMsg: @|"Default!"|;
def m: Maybe._@ 42; // Id{42}
def g: Maybe._@ empty; // None
(~fold)(m, defaultMsg(), @); // 42
(~cata)(g, defaultMsg, @); // Default!
As shown, for the ~fold
comprehension, we provide the already-computed result of the defaultMsg()
function for the default iteration operand. With ~cata
, we provide that defaultMsg
function reference, for it to be invoked only if needed.
Note: Folding (Catamorphism) of a monadic value often performs a natural transformation to another monadic type (via its unit constructor). Extracting a value (via identity @
as shown) is a much less typical usage; that's merely convenient for illustration purposes here.
There's some useful conceptual symmetry to recognize, specifically between ~fold
ing a list (Tuple) with an initial-value, versus ~fold
ing a binary Sum type:
defn two() ^2;
defn mult(x,y) ^x * y;
def list: < 1, 3, 7 >;
def m: Maybe._ @ 42;
(~fold)(list, two(), mult); // 42
(~fold)(m, two(), @ ); // 42
(~fold)(None@, two(), @ ); // 2
For the list (Tuple) ~fold
, the two()
function is eagerly invoked to provide the initial-value operand for the folding.
For the monadic ~fold
, the two()
instead provides the default-value operand (used in the case of a None
instance).
The Either
monad is another binary Sum type, pairing Left
and Right
. For readability preferences, Either.Left
is an alias for Left
, and Either.Right
is an alias for Right
. Also, the Either@
unit constructor is an alias for Right@
.
Either
is typically used for error flow-control in FP programs (as opposed to exception handling).
By holding an error message in a Left
-- similar to None
, except it can represent an affirmative value -- this error message can propagate through monadic operations (which are skipped), until the program handles the message. Right
is like Id
, in that it only holds some success value, and all its comprehensions are valid.
In this example, the error message specified for halve(0)
sets e1
as a Left
, and thus its associated ~map
comprehension does nothing:
defn print(v) ^log(`"Value: `v`");
defn halve(v)
![v ?> 1]: Left@ "Value must be greater than 1"
![mod(v,2) ?= 0]: Right@ (v + 1) / 2
^Right@ v / 2;
def e1: halve(0); // Left{"Value must be greater than 1"}
def e2: halve(4); // Right{2}
e1 ~map print; // Left{"Value must be greater than 1"}
e2 ~map print; // Value: 2
Both Maybe
and Either
are foldable, so we can define natural transformations between them:
defn halve(v)
![v ?> 1]: Left@ "Value must be greater than 1"
![mod(v,2) ?= 0]: Right@ (v + 1) / 2
^Right@ v / 2;
// natural transformation utilities
defn maybeFromEither(e)
^(~fold)(e, None@, Id@);
defn eitherFromMaybe(m)
^(~fold)(m, Left@ "Missing!", Right@);
def m1: halve(0) #> maybeFromEither; // None
def m2: halve(4) #> maybeFromEither; // Id{2}
(~cata)(eitherFromMaybe(m1), @, @); // "Missing!"
(~cata)(eitherFromMaybe(m2), @, @); // 2
Above, halve(0)
returns a Left
holding the error message, which we then transform to a None
with the maybeFromEither()
utility. halve(4)
produces a Right{2}
, which is transformed to Id{2}
.
Then, for m1
and m2
instances, we perform a natural transformation back to Either
, with the eitherFromMaybe()
utility. We fold the resulting Either
instances, extracting the values "Missing!"
and 2
, respectively.
So far, we've seen several behaviors/capabilities that are organized within broader Category Theory, such as Functor/Mappable (with ~map
comprehension), Monad (with ~<
bind/chain comprehension), and Foldable (with ~fold
/ ~cata
comprehension).
But there are certainly other capabilities/behaviors to consider. While they may often show up adjacent to monads, these are separate topics.
Applicative is a pattern for holding a function in a monadic instance, then "applying" the underlying value from another monadic instance as an input to this function, returning the result back as another monadic value.
If the function requires multiple inputs, this "application" can be performed multiple times, providing one input at a time.
Here's how we can perform Applicative with ~<
and ~map
:
defn add(x)(y) ^x + y;
def three: Id@ 3;
def four: Id@ 4;
(Id@ add)
~< (fn) {
three ~map fn
}
~< (fn) {
four ~map fn
};
// Id{7}
In this snippet, the add()
function is wrapped in an Id
, and then ~<
chained to access the fn
it holds. That function is used to map the three
monad, which calls add(3)
and wraps the curried function back in another Id
.
That Id
is then ~<
chained again to access the curried function fn
, and that function is used to map the four
monad. This invokes add(3)(4)
producing 7
, and then wraps that in yet another Id
.
Restating: Applicative is pattern to apply the value(s) held in one or more monads, one at a time, as the inputs to a curried function (also held in a monad).
Of course, we could define a function helper to make this process a little cleaner:
defn add(x)(y) ^x + y;
defn ap(m2)(m1) ^m1 ~< (fn) { m2 ~map fn; };
def three: Id@ 3;
def four: Id@ 4;
(Id@ add)
#> ap(three)
#> ap(four);
// Id{7}
However, for further built-in convenience and expressiveness, Foi provides the ~ap
operator:
defn add(x)(y) ^x + y;
def three: Id@ 3;
def four: Id@ 4;
(Id@ add)
~ap three
~ap four;
// Id{7}
Concatable (formally, Semigroup) defines the ability for values to be "concatenated" (combined with each other).
We've already seen a number of value types in Foi that are concatable. For example: strings and numbers. In fact, the +
operator invokes the underlying concatable behavior for all such value types:
1 + 2 + 3 + 4 + 5; // 15
(+)(1, 2, 3, 4, 5); // 15
"Hello" + " " + "world"; // "Hello world"
(+)("Hello", " ", "world"); // "Hello world"
Most monadic values in Foi also implement Concatable, meaning that if used with +
, they will delegate concatenation to their underlying value (if it is also Concatable):
(Id@ 30) + (Id@ 12);
// Id{42}
(+)(Id@"Hello", Id@" ", Id@"world");
// Id{"Hello world"}
Monoid is a Semigroup plus an "empty value" -- such that when concatenated with, the original value is unchanged. For example, these are all monoids:
- string concatenation with the
""
(empty string) - numeric addition with the
0
(empty number) - list (Tuple) concatenation with the
<>
(empty list/Tuple)
Revisiting concatenation from the previous section, a concatenation is a specialized type of fold, so we can use the list (Tuple) ~fold
comprehension together with the +
operator:
(~fold)(< "Hello", " ", "world" >, (+));
// "Hello world"
We can do the same with a list (Tuple) of monadic (monoidal) values:
(~fold)(< Id@ "Hello", Id@ " ", Id@ "world" >, (+));
// Id{"Hello world"}
It can be useful to perform a mapping on each of a list's values as they're being folded/concatenated, especially when that mapping is to lift non-monadic-but-monoidal values into monads.
The ~foldMap
comprehension does so, while applying the +
concatenation as its fold:
< "Hello", " ", "world" > ~foldMap Id@;
// Id{"Hello world"}
As shown, the Id@
unit constructor maps each string to a monad, and then since the Id
monad has a concatenation monoid (+
) defined -- and the underlying string values are monoidal with a +
string concatenation -- ~foldMap
then folds the monads (monoids) together, producing the single Id{"Hello world"}
value.
For the list (Tuple) ~fold
comprehension, recall that:
-
To perform a fold, at least two values are necessary, either both in the list (Tuple) to fold, or one value in the list and the initial-value provided in the expanded
~fold
form. -
If there's only one value provided, the iteration is skipped and that value is simply returned.
-
If there's no value available, the
~fold
operation is invalid and returnsempty
; configure Foi to report a warning in such a case.
Thankfully, this familiar behavior is defined the same for ~foldMap
. Thus, if the list (Tuple) only has one element, the "empty value" of a monoid (""
below) is a useful candidate to provide as the default-value, to ensure a fold actually occurs:
(~foldMap)(< "Hello" >, "", Id@);
// Id{"Hello"}
To define any custom monoid, provide a suitable "empty value", as an initial-value to the ~fold
/ ~foldMap
operations.
For example, we could define a monoid as the boolean-AND (?and
) concatenation of two boolean values, with true
as the "empty value". And we can do similarly for the boolean-OR (?or
) operation, with false
.
Consider:
defn all(bools) ^(~fold)(bools, true, (?and));
defn any(bools) ^(~fold)(bools, false, (?or));
def a: < true, true, true, true >;
def b: < true, false, true, true >;
all(a); // true
all(b); // false
any(b); // true
The above is a nice application of a monoid, but the ?and
and ?or
operators are already n-ary, thus we could have done:
defn all(bools) ^(~fold)(bools, true, (?and));
defn any(bools) ^(~fold)(bools, false, (?or));
def a: < true, true, true, true >;
def b: < true, false, true, true >;
all(a); // true
all(b); // false
any(b); // true
So how do we apply this approach for monadic values such as Id{true}
?
We have already identified the necessary monoidal empty value (true
or false
), which can be wrapped in Id
(i.e., Id{true}
and Id{false}
). However, there's no default concatenation operation (+
) defined for booleans in Foi; how could it know automatically whether the concatenation of booleans should be computed with logical-AND or logical-OR!?
Moreover, even if there were such a default +
concatenation for booleans, the ?and
and ?or
operators are not monad-aware.
So, let's instead define custom, monad-aware concatenation logic (andM()
and orM()
below):
defn andM(x,y) ^x ~map (xv) { xv ?and y; };
defn orM(x,y) ^x ~map (xv) { xv ?or y; };
defn all(bools) ^(~fold)(bools, Id@ true, andM);
defn any(bools) ^(~fold)(bools, Id@ false, orM);
def a: < true, true, true, true >;
def b: < true, false, true, true >;
all(a); // Id{true}
all(b); // Id{false}
any(b); // Id{true}
~fold
is more flexible in letting you specify custom folding (concatenation) logic for values. By contrast, ~foldMap
assumes/relies on the built-in +
operation for values being folded (and recursively, concatenating underlying values).
Foi does not have any concurrency or asynchrony built natively into it. However, programs absolutely perform and respond to external operations that are inherently concurrent/asynchronous -- network calls, file system, timers, etc.
As such, there are a variety of language features for managing concurrency. These features are oriented around data transmission, but they can also be used more generally for asynchronous flow control.
Note: all of these features inherently operate synchronously in the language. Only external mechanisms outside the program can influence a non-synchronous delay into the program.
The most basic component of Foi concurrency/asynchrony is Promise
. It resembles promises in other languages (JS, etc), but has some important differences.
Most importantly, Promise
is a monad. It's kind of like the Id
monad (it just holds a single value), except it starts out in a pending state where it doesn't yet hold any value. Any operations (~map
, ~<
) are deferred while a promise is pending.
Once the promise is resolved, any deferred operations are immediately (synchronously) performed. From then onward, the promise remains permanently in the resolved state, and any operations against it are evaluated synchronously.
You can construct a Promise
instance that's already resolved with the unit constructor:
def pr: Promise@ 21;
// Promise{21}
pr.resolved();
// true
pr ~map (v) {
v * 2;
};
// Promise{42}
If you're constructing a promise that will be resolved later, you actually need to construct a subject.
A subject is a record that contains a pr
holding the promise, and a resolve()
function for resolving the associated promise:
def subj: PromiseSubject@;
subj.pr.resolved();
// false
def pr2: subj.pr ~map (v) {
v * 2;
};
// Promise{..pending..}
pr2 ~map (v) {
log(`"v: `v`");
};
// Promise{..pending..}
subj.resolve(21);
// Right{21}
// v: 42
Promises in Foi only have a single resolved state, unlike in JS where they can be fulfilled or rejected. To communicate something like "success" or "failure", the most appropriate (FP) way is to resolve the promise with an Either
. This can then be folded into one promise or another, to fork different code paths.
Consider:
defn fetchCustomers() { ..returns promise.. };
defn getCacheData(key)
![cache ?has key]: Promise@ (Left@ "Not in cache")
^Promise@ (Right@ cache[key]);
getCacheData("customers")
~< (resE) {
(~cata)(resE, fetchCustomers, Promise@);
}
~map printRecords;
// Promise{..pending..}
The getCacheData()
function produces a Promise{Either}
value, which resolves to a Left
on failure or Right
on success.
We use the Left
to fetch the customers remotely, or the Right
to simply pass-through to the next step, which invokes printRecords()
.
Instead of constructing multi-step ~<
/ ~map
chains, Promise
also supports the helpful ~<<
do comprehension. The promise chain from the above snippet could instead be expressed as:
Promise ~<< {
def resE:: getCacheData("customers");
def customers:: (~cata)(
resE, fetchCustomers, Promise@
);
printRecords(customers);
};
// Promise{..pending..}
If the promises returned from getCacheData()
or the ~cata
operation are pending when encountered, the rest of the do comprehension block is suspended until the promise resolves.
Note: This kind of code may be familiar/recognizable as "async..await" style in JS.
One question that may now come to mind: how can you perform asynchronous comprehensions (looping), where a pending promise suspends the iteration/looping until it's resolved?
Here's one way:
defn fetch(url) { ..returns promise.. };
defn printResponses(prs)
![size(prs) ?> 0]: Promise@ "Complete."
{
^prs.0 ~< (resp) {
log(`"resp: `resp`");
printResponses(prs.[1..]);
};
};
def urls: <
"https://some.url/1",
"https://some.url/2",
"https://some.url/3",
>;
printResponses(urls ~map fetch)
~map log;
// Promise{..pending..}
//
// ... eventually ...
//
// resp: response 1
// resp: response 2
// resp: response 3
// Complete.
The printResponses()
function "asynchronously loops" through the prs
list of promises, with the recursive call to printResponses()
chained off each promise.
That approach works OK, but it's unfortunately a bit convoluted.
The ~<*
operator extends Promise
do comprehension, to loop asynchronously over (a list of) Promise
instances. The above printResponses()
function can thus be expressed as:
defn printResponses(prs) {
^prs ~<* (resp) {
log(`"resp: `resp`");
}
~map { "Complete."; };
};
If (as above) the provided range operand is a list of promises, iterating will proceed (synchronously or asynchronously) as each next promise resolves. The result of the comprehension is a pending promise that resolves once the iterating has completed.
This form can be thought of as like an asynchronous ~each
comprehension. That's a fair bit cleaner than the async-recursion approach, right!?
Note: Any non-promise values in the range list will be "lifted" to a resolved promise for the purposes of the iteration handling.
Moreover, as the ~<
part of the ~<*
operator suggests, the iteration clause is also a do comprehension block over promises (i.e., Promise ~<< { }
):
urls ~map fetch
~<* (resp) {
def v:: processResp(resp);
def success:: storeVal(v);
?[success]: log(`"Stored: `v`");
};
// Promise{..pending..}
Note: Any value unwrapped via def ::
in a ~<*
comprehension must be a Promise.
If the range operand provided to ~<*
is not a concrete value but instead a type (like Promise
) -- as range always is with regular ~<<
do comprehensions -- the iteration looping will continue until a Left
is produced as the final result of an iteration:
defn printResp(v) { log(`"Resp: `v`"); };
defn fetchMoreData() { ... Promise<Either> ... };
Promise ~<* {
def respE:: fetchMoreData();
Either ~<< (resp:: respE) {
printResp(resp);
};
};
// Promise{..pending..}
The outer ~<*
looping do comprehension above pauses each iteration while the promise returned from fetchMoreData()
is waiting to resolve.
Once it does, the inner Either ~<< { }
do comprehension attempts to unwrap the respE
Either value. If a Left
is encountered, this inner do comprehension short-circuits out, and its resultant Left
value terminates the loop. Otherwise, printResp(resp)
is called, and the outer looping is allowed to keep going.
It's common to want to perform operations across multiple promises. Two such promise combinators come built into Foi.
race()
creates a promise that will resolve as soon as the first promise in the provided list resolves (left-to-right ordering):
def subj1: PromiseSubject@;
def subj2: PromiseSubject@;
race(< subj1.pr, subj2.pr >)
~map (v) {
log(`"Value: `v`");
};
// Promise{..pending..}
subj2.resolve(42);
// Right{true}
// Value: 42
subj1.resolve(10);
// Right{true}
all()
creates a promise that will resolve once all promises in the provided list have resolved; the resolved value will be a list of those source promise resolutions in the same order as the list provided to all()
, regardless of the order of resolution operations:
def subj1: PromiseSubject@;
def subj2: PromiseSubject@;
all(< subj1.pr, subj2.pr >)
~map (vals) {
log(`"Values: `vals`");
};
// Promise{..pending..}
subj2.resolve(42);
// Right{true}
subj1.resolve(10);
// Right{true}
// Values: < 10, 42 >
CSP (Communicating Sequential Processes) is a classic pattern for coordinating data transmission. It's primarily used in languages like Go and Clojure, though it's been implemented in many others (including JS).
CSP Channels are a mechanism primarily useful for coordinating communication of values between separate (independent) aspects of a program, without these aspects needing to know about each other explicitly; the only thing they share is the channel communication conduit.
One such common coordination pattern is referred to as "back pressure", in that the producer(s) of a value into a channel is throttled to produce no faster than the consumer(s) of that channel are willing to take these values out.
Foi defines CSP as a first class feature, via the Channel
type.
It's important to distinguish: a Channel
instance is not itself monadic -- it has no ~<
/ ~map
defined. However, all the operations on Channel
instances produce Promise
monad instances, so you still interact with Channel
in a monadic-oriented way.
A channel is a container (by default, with no internal buffer) where both the putting-in of a value (put()
) and reading-out of a value (take()
) are coordinated. Both operations produce a Promise
instance, and these promises won't resolve until both operations have occurred (regardless of ordering).
Both operations' promises resolve with Either
values, indicating (eventual) success/failure of the operation.
Consider these illustrative helpers, which we'll use throughout the rest of our CSP examples:
defn putVal(ch,v) ^(
ch.push(v)
~map (ev) {
Either ~<< (v:: ev) {
log(`"Value put: `v`");
};
}
);
defn takeVal(ch) ^(
ch.take()
~map (ev) {
Either ~<< (v:: ev) {
log(`"Value taken: `v`");
};
}
);
Note: Notice the Either ~<< { }
do comprehensions again neatly and conditionally handle the Either
instance, either invoking log()
or short-circuiting out of the block with a Left
.
Now, let's create a channel and use those helpers:
def ch: Channel@;
putVal(ch,42);
// Promise{..pending..}
takeVal(ch);
// Promise{}
// Value put: 42
// Value taken: 42
As you can see, the initial put()
is pending until the corresponding take()
occurs.
Multiple put()
operations can be queued up, and each subsequent take()
will pull the next value in order off the "queue".
def ch: Channel@;
putVal(ch,42);
// Promise{..pending..}
putVal(ch,100);
// Promise{..pending..}
takeVal(ch);
// Promise{}
// Value put: 42
// Value taken: 42
takeVal(ch);
// Promise{}
// Value put: 100
// Value taken: 100
Note: Don't think of queued put()
operations as a buffer. Regardless of implementation details, the best way to think of these queued put()
s, are as values that are waiting to go into the channel, rather than values that are already in the channel. That's why we don't see the "Value put: 100"
confirmation above until the 100
is being taken from the channel. In other words, channel is creating an implicit dependent relationship between each attempted put()
and its corresponding take()
. Channel buffering is a separate concept we'll cover in a bit.
We can also attempt a take()
before a corresponding put()
:
def ch: Channel@;
takeVal(ch);
// Promise{..pending..}
putVal(ch,42);
// Promise{}
// Value taken: 42
// Value put: 42
And of course, multiple take()
operations can be queued, satisfied sequentially by subsequent put()
operations:
def ch: Channel@;
takeVal(ch);
// Promise{..pending..}
takeVal(ch);
// Promise{..pending..}
putVal(ch,42);
// Promise{}
// Value taken: 42
// Value put: 42
putVal(ch,100);
// Promise{}
// Value taken: 100
// Value put: 100
Note: Again, don't think of multiple pending take()
s as buffered, but rather as externally pending operations, until satisfied by corresponding put()
s.
Recall the ~<*
async-comprehension discussed in the Promise
section. It's quite useful for explicitly coordinating a queue of put()
s and/or take()
s:
def ch: Channel@;
1..3 ~<* (v) {
::putVal(ch,v);
};
// Promise{..pending..}
// elsewhere:
Promise ~<* {
::takeVal(ch);
};
// Value put: 1
// Value taken: 1
// Value put: 2
// Value taken: 2
// Value put: 3
// Value taken: 3
The putVal()
loop above terminates once the 1..3
range is eventually completed. However, the Promise ~<* { }
loop will remain in a paused state, waiting for the next value to come into the channel.
You may recall that ~<*
terminates only when the final expression resolves to a Left
. The only way a Channel
instance's take()
can resolve to a Left
is when the channel itself is closed.
So, if we issued ch.close()
in the above program:
ch.close();
// Right{true}
// Error: Channel already closed
The close()
above returns Right{true}
indicating it was successful in closing the channel.
Note: Subsequent close()
calls on an already closed channel will return Left{"Channel already closed"}
.
After closing, the pending take()
(from inside takeVal()
) is resolved with a Left{"Channel already closed"}
; that short-circuits out of the Either
do comprehension..
Finally, the paused ~<*
loop will terminate, when encountering that Left{"Channel already closed"}
value.
As illustrated so far, channels by default have no internal buffering.
But you can override this when you construct a channel; if you set a positive integer for a buffer size, that many values may be accepted into the channel (immediately resolving the put()
s) even before the corresponding take()
s are received.
Buffering a channel is useful for allowing the producer side of a channel to be less back-pressure bounded to the consumer.
Warning: A very high value of buffer size effectively eliminates back pressure from the channel. However, you should reconsider doing so; this coordination between producer and consumer is the main point and spirit of CSP. If you really need unbounded producer-side messaging without such coordination, a PushStream
(covered later) is a better fit.
Consider:
def ch: Channel@ 3;
1..3 ~each (v) { putVal(ch,v); };
// Value put: 1
// Value put: 2
// Value put: 3
putVal(ch,4);
// Promise{..pending..}
Promise ~<* {
::takeVal(ch);
};
// Value put: 4
// Value taken: 1
// Value taken: 2
// Value taken: 3
// Value taken: 4
ch.close();
// Right{true}
// Error: Channel already closed
Any put()
that occurs while a buffer has capacity produces an immediately resolved promise, as shown by the messages printed during the ~each
loop.
But as shown by putVal(ch,4)
above, any put()
that occurs while a buffer is at capacity (and no take()
s are pending) produces a pending promise. In other words, it behaves exactly the same as on a channel with buffer size of 0
(as default).
If a put()
is pending when a take()
frees up capacity in the buffer, that pending put()
is immediately resolved and its value is treated as being placed into the internal buffer. That's illustrated by the timing of the "Value put: 4"
message above.
Aside from take()
, it can also be useful to peek()
at a channel, and see a value that's put()
into it, even if there's no take()
yet to actually retrieve it.
A peek()
operation also produces a pending promise (until any attempted put()
), but all peeks will see the same value once the next put()
is queued and until that value is take()
n out of the channel. In other words, peek()
operations don't queue (stack on each other sequentially) the way puts()
and takes()
do.
Consider:
defn peekAt(ch,idx) ^(
ch.peek()
~map (v) {
log(`"(`idx`) Peeking at value: `v`");
v;
}
);
def ch: Channel@;
1..3 ~each (idx) {
peekAt(idx,ch);
};
putVal(ch,42);
// Promise{..pending..}
// (1) Peeking at value: 42
// (2) Peeking at value: 42
// (3) Peeking at value: 42
peekAt(ch,4);
// Promise{42}
// (4) Peeking at value: 42
takeVal(ch);
// Promise{}
// Value put: 42
// Value taken: 42
peekAt(ch,5);
// Promise{..pending..}
The spirit of CSP channels is that the primary read operation should be take()
, not peek()
.
But peeking is especially useful when coordinating (without side effects) take()
s from multiple channels. For example: a merge operation (i.e., first-come-first-served race), or a zip operation (i.e., wait for all channels to have a value before taking them).
The merge/race operation can be performed with the alts()
utility:
def nextVal: alts(< ch1, ch2, ch3, ch4 >);
// Promise{..pending..}
The first channel (left to right) that has a value available (via a put()
) will resolve the pending promise, with a record that has a value
property with the value, and a channel
property holding the channel the value came from.
Races like this are often used for "timeouts", where you wait for a value from a channel for only a certain amount of time, and if the timeout expires, a value comes in on a second channel, thereby short-circuiting the waiting for a value from the first channel.
Warning: This ordered behavior means that if ch1
always has values available, it can starve out any attempt for ch2
, ch3
, and ch4
channels to transmit their values. To guard against such starvation, programs should be careful to perform some shuffling of the list order provided to alts()
, perhaps just a simple round-robin strategy.
The zip/all operation can be performed with every()
:
def nextVal: every(< ch1, ch2, ch3, ch4 >);
// Promise{..pending..}
The promise from every()
will resolve once a value is available from all channels, and will contain a list of these values in the same order as provided to every()
, regardless of what order the arrive in.
If you need a producer side to be unrestrained from the consumer side, the data transmission mechanism best suited is the PushStream
monad.
The most trivial example is a single-value stream, via the unit constructor:
def s: PushStream@ 21;
s ~< (v) {
PushStream@ (v * 2);
}
~map (v) {
log(`"Value: `v`");
};
// Value: 42
Note: A single-value stream (as above) seems similar conceptually to an already resolved Promise. One specific difference is that streams don't hold onto their values like promises do. So once a value is observed (another stream "subscribing" to it via ~<
or ~map
), that value is no longer in the original stream. Moreover, streams are either open or closed (close()
and closed()
), whereas promises are either pending or resolved (resolve()
and resolved()
).
Like promises, you typically construct a PushSubject
to control the stream:
def subj: PushSubject@;
def s2:
subj.stream ~< (v) {
PushStream@ (v * 2);
}
~map (v) {
log(`"(1) Value: `v`");
v + 1;
};
// PushStream{}
1..3 ~each subj.push;
// (1) Value: 1
// (1) Value: 2
// (1) Value: 3
s2 ~map (v) {
log(`"(2) Value: `v`");
};
// PushStream{}
// (2) Value: 2
// (2) Value: 3
// (2) Value: 4
As shown, the ~<
/ ~map
comprehensions remain active on an open stream, and are repeated for each new value pushed through the stream.
push()
returns a Right
with the pushed value, if successful; Left
is returned if pushing failed (i.e., the stream has been closed).
A stream remains open and active until close()
is called on it, or is called upstream; close signals actively propagate downstream, since no more values ever propagate through closed streams.
Consider:
def subj: PushStream@;
1..3 ~each subj.push;
def another: subj.stream ~map (v) {
log(`"Value: `v`");
};
// Value: 1
// Value: 2
// Value: 3
subj.push(4);
// Right{true}
// Value: 4
subj.stream.closed();
// true
another.closed();
// true
// ------------
subj.stream.close();
// Right{true}
subj.stream.closed();
// true
another.closed();
// true
subj.push(10);
// Left{"Stream already closed"}
Note: Closing a stream not only shuts down any further value propagations, but also cleans up memory used by the stream (and any downstream instances). As such, streams should always be closed when no longer needed.
PushStream
supports the ~<<
do comprehension form:
def subj: PushStream@;
1..3 ~each subj.push;
def s: PushStream ~<< (v:: subj.stream) {
log(`"(1) Value: `v`");
v * 2;
};
// PushStream{}
// (1) Value: 1
// (1) Value: 2
// (1) Value: 3
subj.push(4);
// Right{true}
// (1) Value: 4
s ~map (v) {
log(`"(2) Value: `v`");
};
// PushStream{}
// (2) Value: 2
// (2) Value: 4
// (2) Value: 6
// (2) Value: 8
5..6 ~each subj.push;
// (1) Value: 5
// (2) Value: 10
// (1) Value: 6
// (2) Value: 12
As you can see, just like ~<
/ ~map
, this form automatically repeats -- not really looping! -- each time a new value is pushed into the stream.
Note: As stream inherently repeats operations for each value, there is no corresponding ~<*
looping do comprehension form for streams; that's only for Promise
.
Where a PushStream
is controlled by the producer, a PullStream
is more like a CSP Channel
where control is shared by producer and consumer.
However, unlike Channel
, a PullStream
offers no back pressure, in that it does not block the producer pushing on the consumer pulling the value. That is, a push()
does not wait for a corresponding pullInto()
.
We can again create a single-value stream instance, via the unit constructor:
def s: PullStream@ 21;
s ~< (v) {
PullStream@ (v * 2);
}
~map (v) {
log(`"Value: `v`");
};
// PullStream{}
As shown, the 21
(and subsequent 42
) values don't actively propagate through the PullStream
instances, which is why nothing is logged.
To trigger the propagation, use pullInto()
:
def s: PullStream@ 21;
def t:
s ~< (v) {
PullStream@ (v * 2);
}
~map (v) {
log(`"Value: `v`");
};
// PullStream{}
t.pullInto(1);
// Right{1}
// Value: 42
The pullInto()
function takes an optional integer argument for the count of how many values to attempt to pull down the stream. If the attempt is successful (as above), the result will be a Right
holding the same numeric value.
Otherwise, if insufficient pending values are found anywhere upstream, the return will be a Left
holding the count of values not able to be pulled. Those pending requests remain active upstream, and will be fulfilled as any new values are pushed in.
You will typically construct a PullSubject
to control the stream:
def subj: PullSubject@;
def s2: subj.stream
~map (v) {
log(`"(1) Value: `v`");
v * 2;
};
// PullStream{}
1..3 ~each subj.push;
// (nothing)
s2 ~map (v) {
log(`"(2) Value: `v`");
};
// PullStream{}
s2.pullInto(2);
// Right{2}
// (1) Value: 1
// (2) Value: 2
// (1) Value: 2
// (2) Value: 4
s2.pullInto(3);
// Left{1}
// (1) Value: 3
// (2) Value: 6
// (1) Value: 4
// (2) Value: 8
5..6 ~each subj.push;
// (1) Value: 5
// (2) Value: 10
s2.pullInto(1);
// Right{1}
// (1) Value: 6
// (2) Value: 12
PullStream
also supports the ~<<
do comprehension form:
def subj: PushStream@;
1..3 ~each subj.push;
PullStream ~<< (v:: subj.stream) {
log(`"Value: `v`");
};
// PullStream{}
s.pullInto(3);
// Right{3}
// Value: 1
// Value: 2
// Value: 3
Note: Since the ~<<
block automatically repeats as each value is pulled through, there's no need for a separate ~<*
looping do comprehension form for PullStream
.
The first and most important rule of FP is that you have to be very careful to minimize and control side-effects whenever and wherever possible. Mismanaged side effects are the single greatest source of bug infection in our code.
One powerful tool for managing side effects in a mathematical, predictable way is the IO
monad.
In Foi, the IO
monad is special, in that it composes behaviors of multiple monad types together: IO, Task, and Reader.
Let's explore each of these capabilities separately.
Task is a pattern for lazily defining a set of actions, usually performing side-effects outside the program -- printing to the console, performing network requests, reading or writing to a file system, waiting on external async operations, generating random numbers, etc.
The key is, IO
instances are lazy; these actions do not run automatically. Moreover, multiple IO
monads are chained together to compose separate units of action into a single lazy action.
To construct an IO
instance, we give it an executor function that will perform the action(s) (side effects):
def task = IO@ (defn someTask(){
log("Log messages are a side effect!");
});
// (nothing)
Notice that the log message didn't actually happen. IO
instances are lazy. An IO
instance (which may be a composed chain of many IO
s) is run on-demand (one or more times), using the run()
method on the instance:
def task = IO@ (defn someTask(){
log("Log messages are a side effect!");
});
task.run();
// Log messages are a side effect!
When you simply want to hold a value in an IO
instance, instead of providing a function that only returns the value, we can use a special unit constructor as a shortcut:
def specialNumber = IOof@ 42;
specialNumber.run(); // 42
As with all monads, we can compose instances together via comprehensions like ~map
and ~<
(chain):
defn doubleIO(v) ^IO@ (v * 2);
defn incIO(v) ^ IO@ (v + 1);
defn finish(v) {
log(`"v: `v`");
^incIO(v);
};
def num = IOof@ 21;
def task = num
~map doubleIO
~< finish;
task.run(); // 43
// v: 42
Recall the ~<<
do-comprehension (for monads), which gives a special syntax for chaining monadic values together a more familiar imperative-style. It's especially convenient when you might otherwise need to nest ~<
chain steps to create a shared scope for accessing values from each step together.
Because this is so common with IO
, the do comprehension form is most common. The previous snippet could be done like this:
(IO ~<< (v:: num) {
def x:: doubleIO(v);
::finish(x);
}).run(); // 43
// v: 42
As you can see, the v:: num
statement unwraps the IO
instance num
, and assigns its value to v
. Likewise, the def x:: doubleIO(v)
unwraps the IO
instance that comes back from the function call, and assigns the result to x
.
Finally, the IO
instance from finish(x)
is returned (without wrapping, due to the ::
prefix).
You can interleave def ::
and def :
style definitions:
defn readFile(filename) {
// ..
^IO@ fileContents;
};
defn writeFile(filename,contents) {
// ..
^IO@ res;
};
defn processFile(filename) ^IO ~<< {
def text:: readFile(filename);
def uptext: uppercase(text); // <-- : instead of ::
def res:: writeFile("upper.txt",uptext);
< :res, :text >;
};
processFile("my-file.txt")
.run();
// < res: .., text: ... >
Reader is a pattern for carrying a value across monadic operations, without needing a shared outer scope to access it. We typically treat the Reader value as an environment context that is parameterized for the IO to run against. This enables a pure IO that doesn't need to access anything from its outer context.
IO
implements Reader by allowing a single argument (optional) to the run()
function. If a Reader value is provided, it's automatically passed as the first argument to the executor function:
def task: IO@ (defn(readerEnv){
log(`"X: `readerEnv.x`");
});
task.run(< x: 42 >);
// X: 42
Note: The Reader value can be anything, but it's most commonly a Record (or Tuple).
Inside a ~<
chain step, the carried Reader value can be accessed as so:
def task:
IOof@ 42
~< (v) {
IO@ (defn(env){
log(`"Value: `v`, Env.x: `env.x`");
});
};
task.run(< x: 3 >);
// Value: 42, Env.x: 3
This is a bit ugly/awkward, but is cleaner in do comprehension form to extract the Reader value:
// Recall: Value@ and @ are the identity function
defn getEnv() ^IO @(Value@);
def fortyTwo: IOof@ 42;
def task: IO ~<< {
def v:: fortyTwo;
def env:: getEnv();
log(`"Value: `v`, Env.x: `env.x`");
};
task.run(< x: 3 >);
// Value: 42, Env.x: 3
In fact, this is even cleaner:
def fortyTwo: IOof@ 42;
def task: IO ~<< (env, v:: fortyTwo) {
log(`"Value: `v`, Env.x: `env.x`");
};
task.run(< x: 3 >);
// Value: 42, Env.x: 3
As shown, the Reader value is automatically provided to the do comprehension block, as exposed by a block definitions clause.
The final super power of IO
is that it automatically acts as a transformer over each of the previous concurrency/asynchrony mechanisms: Promise
, Channel
, PushStream
, and PullStream
. That means that if an IO
operation encounters an instance of any of these, it will automatically lift to that space, thereby acting to unwrap such values.
Consider:
defn getValue() ^Promise@ 42;
defn printValue(v) ^IO@ (defn(){
log(`"Value: `v`");
});
def task: IO ~<< {
def v:: getValue();
::printValue(v);
};
task.run();
// Promise{}
// Value: 42
As illustrated, when the promise instance from getValue()
was encountered, the rest of the IO evaluation -- in other words, what's returned from the run()
call -- was lifted to a promise. Subsequent steps in the IO chain wait for a previous promise to resolve before proceeding.
That's basically the Promise ~<< { }
behavior combined automatically into IO
's ~<<
do comprehension.
If an IO
instance holds a Promise
instance, that's unwrapped automatically:
defn readValue() ^IOof@ (Promise@ 42);
defn printValue(v) ^IO@ (defn(){
log(`"Value: `v`");
});
def task: IO ~<< {
def v:: readValue();
::printValue(v);
};
task.run();
// Promise{}
// Value: 42
Even in the inverse scenario -- where a Promise
instance holds an IO
instance -- the transformation still occurs:
defn readValue() ^Promise@ (IOof@ 42);
defn printValue(v) ^IO@ (defn(){
log(`"Value: `v`");
});
def task: IO ~<< {
def v:: readValue();
::printValue(v);
};
task.run();
// Promise{}
// Value: 42
Here's IO
transforming over a Channel
:
defn getValue() ^Channel@ 42;
defn printValue(v) ^IO@ (defn(){
log(`"Value: `v`");
});
def task: IO ~<< {
def ev:: getValue();
::(~cata)(ev, IOof@, printValue);
};
task.run();
// Promise{}
// Value: 42
The def ev:: getValue()
unwrapping automatically calls take()
on the channel, which produces a Promise
instance; that lifts the IO
evaluation into the promise space.
Transformation is not limited to promises. It also applies to streams. For example, here's an IO
do comprehension transforming over a PushStream
:
defn getValues() {
def subj: PushSubject@;
1..3 ~each subj.push;
^subj.stream;
};
defn printValue(v) ^IO@ (defn(){
log(`"Value: `v`");
});
def task: IO ~<< {
def v:: getValues();
::printValue(v);
};
task.run();
// PushStream{}
// Value: 1
// Value: 2
// Value: 3
Notice how run()
this time returned a PushStream
instance instead of a Promise
, since the IO
was lifted into that space for its evaluation.
Here's the same, but with a PullStream
:
defn getValues() {
def subj: PullSubject@;
1..3 ~each subj.push;
^subj.stream;
};
defn printValue(v) ^IO@ (defn(){
log(`"Value: `v`");
});
def task: IO ~<< {
def v:: getValues();
::printValue(v);
};
def s: task.run();
// PullStream{}
s.pullInto(3);
// Right{3}
// Value: 1
// Value: 2
// Value: 3
Since we're using a PullStream
, we have to call pullInto(3)
to actually pull those values into the IO
evaluation.
Note: With IO
, typically PushStream
will be preferred over PullStream
, given this extra step of needing to call the pullInto()
on the returned stream.
Be aware that the order of transformation in an IO ~<<
do comprehension matters, with respect to what kinds of lifting is supported:
-
A concrete/synchronous IO can be lifted to a promise IO evaluation (as shown earlier). This includes a channel transformation, since the
take()
produces a promise. -
A stream IO evaluation can lift concrete/synchronous values, or promises.
-
A
PullStream
can be lifted to aPushStream
, or vice versa.
But importantly, a stream cannot be lifted to a promise evaluation (only vice versa). Perhaps counterintuitively, that means the following is invalid:
def getValue() ^Promise@ 42;
def getValues() ^PushStream@ 10;
IO ~<< {
def v:: getValue();
def x:: getValues();
log(`"v: `v`, x: `x`");
}.run();
// (error)
The getValue()
lifts the IO
evaluation into a promise, but then a subsequent PushStream
instance from getValues()
cannot be lifted into this promise evaluation.
But the reverse order is supported, so this is valid:
def getValue() ^Promise@ 42;
def getValues() ^PushStream@ 10;
IO ~<< {
def x:: getValues();
def v:: getValue();
log(`"v: `v`, x: `x`");
}.run();
// v: 42, x: 10
In other words, we can resolve a singular-value promise from getValue()
for each time we get a value from the stream returned by getValues()
. But we cannot do the reverse: pushing each value from the getValues()
stream into a single promise from getValue()
.
It's not common that you'll want to mix/compose promises and streams in the same IO
do comprehension -- usually just one or the other -- so this kind of confusing nuance won't be encountered very often.
Generators are a specialization of IO
, which behaves somewhat like a CSP Channel
.
Generators are useful for expressing "lazy" computations, meaning that they step through producing a single value at a time, and only compute the next value after the previous value has been requested.
The Gen@
unit constructor expects a single argument: a function that will receive the Reader value (env
below), and a function (yield
below) to produce values from the generator. Calling yield()
queues up a value to send through the iterator, and produces a Promise
instance that will resolve once the value has been taken from the iterator. If successful, the resolved value with be a Right
, and Left
otherwise.
def genIO: Gen@ (defn(env,yield){
def pr: yield(42);
// ..
});
Calling the unit constructor produces an IO
instance (genIO
above); when run()
is invoked on the IO
, the generator function starts running, and an iterator is returned to retrieve the generator instance's value(s).
The iterator holds two functions: next()
for retrieving values, and close()
for closing the iterator (and thus stopping the IO
instance of the generator).
The most classic example of a generator is computing the Fibonacci sequence:
def fib: Gen@ (defn(env,yield){
def a: 0;
def b: 1;
^Promise ~<* {
def cur: a;
def res:: yield(cur);
a := b;
b := cur + a;
};
});
def it: fib.run();
// print the first 10 Fibonacci numbers
0..9 ~<* {
def ev:: it.next();
(~cata)(ev, Left@, log);
};
// 0
// 1
// 1
// 2
// 3
// 5
// 8
// 13
// 21
// 34
Warning: The above generator is designed to run perpetually (doesn't stop itself), and without any delay, so be careful about using an unbounded looping to consume values from it; such a loop will also run synchronously, forever. The 0..9 ~<* { }
approach above limits how many values to take from the iterator.
Even though the iterator interface (yield()
and next()
, above) responds with Promises, this Fibonacci generator is fully synchronous; remember that Foi Promise
instances are not inherently asynchronous (as in some other languages).
Here's another example, of a generator that only produces a fixed number of values through its iterator (and then closes it):
def someNums: Gen@ (defn(env,yield){
^((env.start..env.end) ~<* yield)
~map { "Complete." };
});
def it: someNums.run(< start: 4, end: 7 >);
// consume all the values from this
// iterator
def pr: it ~<* (ev) {
(~cata)(ev, Left@, log);
};
// Promise{..pending..}
// 4
// 5
// 6
// 7
pr ~map log;
// Left{"Complete."}
Note: The ~<*
knows how to consume an iterator. It calls next()
under the covers, which produces a promise. When unwrapped, this value is a Right
or Left
; if it's a Left
, the looping will terminate. Above, we unwrap the Right
with ~cata
, but in the next snippet, we'll use an Either
do comprehension to process ev
.
You can also manually terminate an iteration early by closing the iterator:
def someNums: Gen@ (defn(env,yield){
^((env.start..env.end) ~<* yield)
~map { "Complete." };
});
def it: someNums.run(< start: 1, end: 10 >);
// consume all the values from this
// iterator
def pr: it ~<* (ev) {
Either ~<< (v:: ev) {
log(v);
// shall we terminate early?
::?[v ?= 3]: {
it.close();
Left@ "Terminated.";
};
};
};
// Promise{..pending..}
// 1
// 2
// 3
pr ~map log;
// Left{"Terminated."}
Note: The Either ~<< { }
do comprehension block is nested inside the outer Promise ~<* { }
looping do comprehension block. This allows us to ergonomically unwrap the Either value (ev
) that came back from next()
. If the v:: ev
unwrapping encounters a Left
, it short-circuits to skip the Either
comprehension block.
The manually produced Left@ "Terminated."
value forcibly terminates first the inner Either ~<< { }
block, and then the outer Promise ~<* { }
loop.
However, if that value were omitted (but the iterator was still closed), the loop would normally start a next (final) iteration, yet the next()
call would immediately fail with a Left@ "Complete."
-- from the final "Complete."
return value of the generator -- and that would terminate the loop.
Type annotations in Foi are applied to values/expressions (not to variables, etc). These are optional, as Foi uses type inference wherever possible. But applying them can often improve the performance optimizations the Foi compiler can produce.
A type annotation always begins with the :as
keyword:
def age: 42 :as int;
def cost: (*)(getQty(order,item), getPrice(item)) :as float;
Custom types can be defined, for use in subsequent annotations, with the deft
keyword:
deft OrderStatus { empty | "pending" | "shipped" };
def myStatus: getOrderStatus(order) :as OrderStatus;
Function signatures may optionally be typed via custom types:
deft InterestingFunc (int,string) ^empty;
defn whatever(id,name) :as InterestingFunc {
// ..
};
The ?as
operator corresponds to the :as
type annotation keyword; it's a boolean operator that returns true
if a value/expression matches the indicated type, false
otherwise:
def age: 42;
age ?as int; // true
(age :as bool) ?as int; // false
This operator is useful in pattern matching:
deft SimpleFunc (int) ^empty;
deft InterestingFunc (int,string) ^empty;
def result1: ?(myFn){
?[?as SimpleFunc]: myFn(42)
?[?as InterestingFunction]: myFn(42,"Kyle")
?: myFn()
};
// or:
def result2: ?{
?[myFn ?as SimpleFunc]: myFn(42)
?[myFn ?as InterestingFunction]: myFn(42,"Kyle")
?: myFn()
};
All code and documentation are (c) 2022-2023 Kyle Simpson and released under the MIT License. A copy of the MIT License is also included.