Declarations - IS4Code/Sona GitHub Wiki
Declarations are statements that introduce new code elements to be reused in other parts of the code, such as variables, functions, or members of types.
Variables
Variable declarations define named slots to store values into, to be identified later by their name. They are introduced by a keyword such as let
or var
that specifies their semantics.
Syntax
variable_declaration:
('let' | 'var' | 'use' 'var'? | 'const' | 'lazy')
pattern '=' expression
(',' pattern '=' expression)*;
Each variable declaration consists of at least one binding that matches the result of the evaluated expression to the pattern (must be total). The most common kind of pattern used for variables is the variable pattern, which consists of a single name with a lower-case first character, optionally followed by a type assertion via as
:
// `let` declaration binding the value 1 to the variable `a`:
let a = 1
// Same but with an explicit type assertion:
let a as int = 1
It is also possible to bind multiple variables from a single binding by using a pattern with multiple variables, or other more complex patterns:
// Deconstructing a tuple:
let (x, y, z) = (1, 2, 3)
// Deconstructing color components via a custom pattern:
case function Color(c as Color)
return (c.R, c.G, c.B, c.A)
end
let Color(r, g, b, a) = Color.Red
Depending on the keyword used to declare the variables, their semantics differ:
let
defines variables that are immutable, i.e. their values cannot be changed by reassignment.var
defines mutable variables, whose value can be changed later via=
.use
defines variables of types that implementSystem.IDisposable
, supporting deterministic cleanup. When such variables go out of scope,Dispose
is automatically called on their values. Whenuse var
is used, the variables are mutable.const
defines compile-time constants, which may be used in places where compile-time constant expressions are required (such as attributes).lazy
defines variables whose values are computed only at the point of usage. If multiple variables are defined in a single declaration, they are initialized at the same time, in the same order as defined.
The same variable may be re-defined multiple times, unless the definition would result in a package or type member with the same name. Normally, the names of the newly bound variables are not visible in the expressions that initialize them, however this can be changed using #pragma recursive
:
// Error because `r2` is used before its definition:
let
r1 = { Other = r2 },
r2 = { Other = r1 }
// Fine now, creates two records pointing to each other:
#pragma push recursive on
let
r1 = { Other = r2 },
r2 = { Other = r1 }
#pragma pop recursive
Functions
Functions are named blocks of code that may be executed multiple times when the function is called, optionally with variable parameters initialized by the values of the arguments passed alongside the call.
Syntax
function_declaration:
'inline'? 'function' identifier '?' ('(' (pattern ((',' | ';') pattern)*)? ')')+
('as' type ';'?)?
statement*
'end';
The function declaration is indicated by the function
keyword, preceded by optional modifiers, and followed by the name of the function and its parameters, each expressed by a total pattern.
When the name of the function is followed by ?
, it indicates return
in the function is optional.
Parameters
Due to the way the underlying F# runtime exposes functions, there are two styles of function parameters/arguments:
- Tupled parameters are separated by
,
and generally indicate tightly aggregated values, components of a whole, or values with positional meaning. They are often materialized as class tuples, meaning they can be passed as a single value, or constructed using the spread operator..
. The individual parameters separated by,
may never be empty. - Curried parameters are separated by
;
or enclosed in parentheses, and generally express different roles or entities the function puts into relation. As their name suggests, they perform automatic currying when called ‒ only the first few arguments may be provided, resulting in a new function with the rest of the parameters. The individual parameters separated by;
may be empty, indicating an unusedunit
parameter.
Both styles of parameters may be employed for a single function, useful when there are multiple parts of the parameters that benefit from both of them. In either case, the style of arguments when the function is called must match its declaration's style of parameters.
// Coordinates are sensible to be expressed as a tuple:
function createPoint(x, y, z)
...
end
// Needs to be called the same way:
createPoint(1, 2, 3)
// But also works when a class tuple is passed:
let coords = (as class; 1, 2, 3)
createPoint(coords)
// In the case of any tuple, `..` can be used:
let coords = (1, 2, 3)
createPoint(..coords)
// Two endpoints of a relation are sensible to be expressed as curried:
function drawLine(point1; point2)
...
end
// Also called the same way:
drawLine(p1; p2)
// Can specify the first argument and leave the rest for later:
let drawTo = drawLine(p1)
drawTo(p2)
drawTo(p3)
// Both forms may be combined:
function drawLine(x1, y1, z1; x2, y2, z2)
...
end
When to use tupled and curried parameters
When unsure about which style to use for new functions, it is reasonable to default to the tupled style, because it matches the form in which native .NET methods are always exposed. When writing imperative or mostly imperative code, there are not that many situations that would benefit from automatic currying, since that operation can also be expressed manually: let createPoint2D = createPoint & 0
fills the first parameter and produces a new function taking two (tupled) parameters.
There are no performance differences between the two styles of parameters when the function is called normally. However, there are other caveats associated with both styles:
- Since the curried form is satisfied with fewer values that required, forgetting an argument at the end results in a valid expression that evaluates to a function. This kind of mistake is prevented when the function is used directly in a statement, but if its result is passed to a generic function, boxed, or ignored, the function will likely never run.
- As mentioned, the tupled form may also be satisfied with a single value that is deduced to be a tuple. This may be unintended and when interacting with generic code, such a mistake could go unnoticed, however, it is safer than the curried form since triggering this requires forgetting all but one arguments.
When using the curried form, it is recommended to use ;
when the parameters are meant to be used together, and individual (
…)
packs when separately:
// Expected to be called as drawLine(a; b):
function drawLine(point1; point2)
...
end
// Exactly the same but suggests using `drawLine(a)`:
function drawLine(point1)(point2)
...
end
inline
functions and recursion
The inline
modifier may be used in a function declaration to indicate that the function should be inlined at the call site. This has significant effects on the nature of the function:
- The function's body is inserted into every location where it is called, which gives the compiler more opportunities at optimization. As a result, it may be able to reason better about the flow of code and elide certain constructions.
- As a result, all code elements referenced from within the function must also be visible to any potential caller.
- Certain additional operations become available in the function's body, such as using operators on generic types, or invoking constrained members.
- Depending on the number of utilized operations of this kind, the function may not be usable from other .NET languages, or may resort to reflection to work there or when called dynamically (offering worse performance as a result).
The inline
modifier also affects how a function is able to reference itself and other functions. By default, a function may reference itself and even any of the following functions, as long as they are not separated by other statements. This behaviour is broken when any of those functions is declared inline
:
// This works:
function f1()
f2()
end
function f2()
f3()
end
function f3()
...
end
function f()
f()
end
// This does not:
inline function f1()
f2()
end
inline function f2()
...
end
inline function f()
f()
end
return
Optional Function returns are statically typed, which means that the type must be known to the compiler, which enforces it in all locations where a value is returned from the function. This includes the implicit return at the end (equivalent to return unit
), meaning a normal function is not allowed to combine both return
and return val
for non-unit
values.
This can be changed by marking the function as optional, via ?
after its name. This has the following effects:
- The function's return type is enforced to be
?
, which is also automatically appended after the return type when stated explicitly (i.e.function f?() as int
returnsint?
). - When
return
is used to return from the function with no value, it is treated asreturn none
. - When
return
is used to return a value from the function, it is treated asreturn some
, wrapping the value in an option. - The
?
pseudo-operator may be used in combination withreturn
to mark a pre-existing option that should be returned directly, without implicitsome
.
Optional functions are effectively syntactic sugar to aid when writing functions that may or may not return a useful value:
function f1?() as int
...
end
function f2?()
if x then
return 1 // returns `some 1`
end
if y then
return // returns `none`
end
if z then
return f1()? // returns whatever f1 returns
end
// Also returns `none` here
end
It should be noted that the implementation of ?
, some
, and none
, as used to explain the behaviour of return
here, is kept unchanged throughout the function, matching the return type, even when #pragma option
is used to change the implementation.
Case functions
Case functions, also known as active patterns, discriminators, or recognizers, are a special category of functions that can be used in patterns, to either test a value for a particular custom condition, extract its components or other relevant values, or both. They are especially useful to treat native .NET types with the same convenience as records or unions.
Syntax
case_function_declaration:
'inline'? 'case' 'function' (identifier | '(' identifier ('or' identifier)* ')') '?' ('(' (pattern ((',' | ';') pattern)*)? ')')+
('as' type ';'?)?
statement*
'end';
The syntax and interpretation of case functions is very similar to that of regular functions, with these differences:
- The
case
keyword is used immediately beforefunction
. - The last curried parameter of the function receives the matched value. Any previous parameters receive normal arguments when the function is used in a pattern.
- When used in a pattern, the last curried argument of the function contains the pattern the result of the function must match.
- A set of identifiers may be used instead of a name, wrapped in parentheses, indicating multiple alternative labels, similarly to union cases.
- Any of the identifiers forming the name of the pattern may be used inside of the function to construct a value indicating that case.
Deconstructing patterns
The simplest category of case functions are those used for deconstructing, i.e. to extract individual components of a value. Such a case function may return a single value, or multiple wrapped in a tuple, allowing to process the result in another pattern:
// `c` receives the matched value.
case function Color(c as Color)
return (c.R, c.G, c.B, c.A)
// Equivalently:
return Color(c.R, c.G, c.B, c.A)
end
switch Color.Red
case Color(r, g, b, a) do
echo $"rgba({r}, {g}, {b}, {a})"
// No else needed.
end
// Simple deconstruction without switch:
let Color(r, g, b, a) = Color.Red
Multi-case patterns
When multiple identifiers are used, the case function is treated as returning a union of multiple cases, each corresponding to one of the identifiers. A value of that case is formed by using its identifier, optionally followed by parentheses to indicate values wrapped in that case:
case function(Empty or One or Many)(array as [])
switch array.Length
case 0 do
return Empty
case 1 do
return One(array[0])
else
return Many(array[0], array[1])
end
end
switch [1, 2, 3]
case One(a) do
...
case Many(a, b) do
...
case Empty do
...
end
Like with other total patterns, the compiler understands that the switch
cases are exhaustive, and does not require else
.
Partial patterns
A partial pattern is indicated by ?
following the case function name. This has similar semantics related to return
as normal partial functions, but also means that the case function may not match certain inputs for which it returns none
.
case function Int?(str as string)
let result = Int32.TryParse(str)
if result.Item1 then
return result.Item2
end
end
switch "42"
case Int(i) do
echo $"{i:d}"
else
echo "not an integer"
end
Partial multi-case patterns are not supported. If multiple cases are necessary, it is always possible to add another one that conveys the meaning of none
.
Like in normal functions, using the ?
pseudo-operator after a value in return
indicates that the value should be used directly, without wrapping in an option type. This works even on bool
values, useful in situations when the pattern is not supposed to have any results:
inline case function NonZero?(num)
return (num != LanguagePrimitives.GenericZero)?
end
Referencing case functions by name
A pre-existing case function may be referenced in expressions as an actual function by using the case
keyword, followed by the name formed the same way the case function was declared, i.e. using parentheses, or
, and ?
to indicate the type and labels of the function:
let arrayTest = case (Empty or One or Many)
let nonZeroTest = case NonZero?
If the case function is a member of a package, its name is put after the case
keyword, such as case Patterns.NonZero?
.
Packages
Packages are isolated groupings of code elements (types, functions, and variables) with deterministic initialization.
Syntax
package_declaration:
'package' identifier
statement*
'end';
The package
keyword is followed by the name of the new package and its contents, including arbitrary statements or declarations.