This user guide provides a comprehensive overview of IvoryScript, a functional programming language designed for dynamic data types, uniform data persistence and explicit control over evaluation.
It explains key concepts and practical usage, highlighting distinctive features compared to more conventional languages. Whilst this guide focuses on practical aspects of the language, a formal specification of the syntax and semantics is provided by the reference manual.
Many of the syntactic constructs ultimately transform into a much simpler core language. One particular feature of which is that all evaluation is explicit, so the meaning of reduction and evaluation operators and coercion for different syntactic terms is explained fully to enable a comprehensive understanding of the meaning of expression values.
Although the language is generally purely functional, mutable heap variables are supported for changes of application state.
Programming in the language involves scripts written in its grammar, which a compiler translates into either bytecode for an interpreter or C++ source code for further compilation by external tools.
IvoryScript has a deliberately minimal core language. The core provides the fundamental building blocks: types, expressions, function application, lazy and strict evaluation, name literals, and the class and instance mechanism. It does not include concrete sequence operations, arithmetic operators, selection, or any built-in collection type. These are all supplied by modules.
The most important module is the Prelude, which is loaded automatically for every script. The Prelude defines the foundational classes that give IvoryScript its characteristic expressive power:
Num, Eq, Ord — arithmetic
and comparison;
Select — dot-notation property selection;
Seq, IndexableSeq,
ReverseableSeq — sequence operations;
Textual, Show — text formatting and
output.
Because selection and arithmetic are class-based rather than
built-in, a dot expression such as store.value and an
addition such as x + y are both ordinary function
applications resolved through the class system. This uniformity means
the same operators work seamlessly for any type that provides the
appropriate instance — user-defined types included.
Beyond the Prelude, a family of modules covers the requirements of typical application development. These fall into several natural groups:
Sequences. List and
ListFunction provide the lazy list type [a]
and its processing functions; StringList adds
string-as-character-sequence operations. Vector and
Array cover mutable and fixed-size indexed collections;
Tree supports hierarchical data. All share the
Seq and IndexableSeq interfaces defined in the
Prelude, so the same operations apply across collection types. The
bracket notation [e1, e2, ...] used in list literals also
appears in collection constructors — for example
PropertySet [alice: 42, bob: "Hello, world"] first builds a
[Property] list before the constructor processes
it.
Name/property collections. Property,
PropertySet, Binding, NameAnyMap,
and their companion function modules form the core of IvoryScript's
application modelling approach: arbitrary sets of named values that can
be assembled, inspected, and modified at runtime without a fixed schema.
HashTable and HashTableFunction provide
efficient key-indexed storage when the key set is not known in
advance.
I/O and streams. IO covers basic
input/output; Streams provides typed byte-stream reading
and writing with StreamExtract and
StreamInsert instances across all transferable types;
Serialisable adds higher-level object
serialisation.
Persistence. TransientStore and
TransientDataStore give scripts named, persistent key-value
roots backed by the environment model; SimpleRoot provides
in-process global state without serialisation overhead.
Numeric and mathematical. Arith extends the
Prelude's Num class with rounding, absolute value, and
conversion functions; MathConst supplies standard
constants; NumAggregate covers sum, product and statistical
aggregates over sequences; IntSeq provides number-theoretic
sequences such as primes.
Dynamic and polymorphic operations. The Any
module defines the Any type used in property collections;
AnyNum, AnySeq and AnyBindingSet
extend numeric, sequence and binding-set operations to dynamically-typed
values, enabling generic code that works across heterogeneous
collections.
The reference manual covers the core language exclusively. This guide is structured in two parts. Part 1 describes core language features: types, expressions, names, evaluation, and the language-level optimisations that matter for practical use. Part 2 introduces the Prelude and the essential modules required for most practical scripts, including list processing, persistence, and name/property collections.
Readers new to IvoryScript are recommended to begin with Chapters 1–7 (lexical structure, script forms, namespaces, types, evaluation, and expression forms), then move directly to Chapter 14 (the Prelude) and Chapter 15 (Sequences). Those seven chapters provide sufficient grounding to write useful, idiomatic scripts. The remaining Part 1 chapters — name optimisation, coercion, tail recursion, garbage collection, and embedded deployment — and the later Part 2 modules can be consulted as specific needs arise.
As IvoryScript continues to evolve, this guide will be updated to reflect new features - such as derived classes etc.
Scripts consist of ASCII characters that are are subdivided into tokens based on the following rules.
Two types of comments are supported:
Single-line comments start with -- and
continue to the end of the line.
Nested comments start with {- and end with
-}. Comment symbols within string codes do not introduce
comments.
Whitespace characters (spaces, tabs, newlines) serve as separators between tokens like identifiers, keywords, literals and operators. Consecutive whitespace characters are treated as a single separator.
A name consists of letters, digits and underscores. It must start
with a letter, and names are used in different contexts dependent on the
case of the first letter. e.g. x, Ptr and
temp_123 etc.
Lowercase words like
class, inline, let,
module and type etc.
are reserved keywords with special meaning and so unavailable for use as general identifiers. For a full list see.
Special symbols, such as ;, (),
[], :=, ->, ::,
!, ^ and #, have specific meaning
as described in the language syntax. For a full list see.
Numeric literals can be either integer or floating-point:
Integer literals consist of digits (e.g.
6144). Hexadecimal form if there is a 0x or
0X prefix (e.g. 0x23FF).
Floating-point literals consist of digits, a decimal
point and may include an exponent (e.g. 3.14e2).
Character literals are single characters enclosed in single
quotes, and they support 'C' style escape sequences (e.g.
'\n' for newline). For a full list see.
String literals are sequences of characters enclosed in
double quotes and also support the same escape sequences (e.g.
'Hello, world\n').
IvoryScript supports two primary script forms: Order and Module.
An Order script is typically associated with an application, such as a session in the console. It comprises a sequence of expressions that are executed in order. Each step in a sequence is typically either an action or a value to be shown.
An example of an Order script:
tan (radians 45);
sqrt 2;
let {
x :: Int = 10;
y :: Int = x * 2;
z :: Int = y + 5
} in
z
For the last, the variables x, y and
z are assigned sequentially, and then the value of
z is shown.
Module scripts are designed to define reusable names associated with types, classes, functions etc. Modules serve as libraries or packages that can be imported and used elsewhere in an application. Although the primary use is for declarations and definitions that are initialised in definition order, initialisation can cause side effects - although this is not something to be generally recommended.
An example of a Module script:
module MathOperations where {
square :: Int -> Int;
square x = x * x;
cube :: Int -> Int;
cube x = x * x * x;
sideEffect :: Int = {show "Module initialised"; 42}
}
which defines reusable functions, but also includes a side effect that is executed when the module is initialised.
Names may occur in four distinct namespaces.
Type namespace
Class namespace
Module namespace
Value namespace
In IvoryScript, Type, Class, and Module names must begin with a capital letter. In contrast, names in the Value namespace can begin with either an uppercase or lowercase letter.
A name can appear in multiple namespaces. For example,
Point can be a type constructor in the Type
namespace and a data constructor in the Value namespace:
type Point a = Point a a -- 'Point' as both type constructor and data constructor
let p :: Point Double = Point 1.0 1.0 -- 'Point' constructs a value of type Point
In this case, Point is a type constructor when
defining the type and a data constructor when creating a
value.
The Type namespace holds type constructor names, including
built-in types (Int, Double) and custom
types.
type Point a = ...
Here, Point is introduced as a type constructor in the
Type namespace.
The Class namespace contains type classes, which define sets of functions or operations that can be implemented for various types.
class Point a where
move :: a -> a
In this case, Point is a type class in the
Class namespace that defines a move function.
The Module namespace holds module names, which group related functions, types, and values. Modules allow for code organization and reusability.
module Point where
origin = Point 0 0
Here, Point is the name of a module in the
Module namespace.
The Value namespace holds variables, functions, constants, and data constructors—names representing values or operations on them.
let p = Point 1.0 1.0
In this example, Point is a data constructor in the
Value namespace, used to create a value of type
Point.
| Namespace | Contains | Example |
|---|---|---|
| Type | Types, type constructors | Int, Point |
| Class | Type classes | Eq, Point |
| Module | Module names | MathOps, Point |
| Value | Variables, functions, constants, data constructors | x, Point |
IvoryScript’s type system is designed to maintain clarity and safety,
ensuring that values are used consistently with their declared types.
This chapter introduces the core types in IvoryScript, including
primitive types, type signatures, function types, and the essential
Ptr a. Types such as List and
Bool are not part of the core language but are expected to
be present in the prelude or list modules.
IvoryScript supports several primitive types that serve as the foundation for more complex structures. These include:
Int: Represents integer numbers.
let x = 42 -- x is inferred as Exp Int
let y = !x -- y is of type Int
Double: Represents floating-point numbers.
let z = 3.14 -- z is inferred as Exp Double
let w = !z -- w is of type Double
Char: Represents a single character.
let letter = 'a' -- letter is inferred as Exp Char
let strictLetter = !letter -- strictLetter is of type Char
String: Represents a sequence of characters (a list
of Char).
let greeting = "Hello, World!" -- greeting is inferred as Exp String
let strictGreeting = !greeting -- strictGreeting is of type String
Primitive types are inferred as lazy expressions (Exp a)
by default. When a value is forced with ! or
#, it changes to its strict type a.
IvoryScript introduces two critical built-in types:
Name: Typically, name values result from a literal,
such as #Mary, or from the special binding syntax, e.g.,
Mary:"Had a little lamb". For the latter, a pattern
Bind name _ matches the name.
Type: Type values can be created from a literal type
signature, such as let typeOfVar = #::Int, and are also the
return value of the typeOf function, which provides
reflection capabilities.
These types are essential for working with more dynamic aspects of IvoryScript, allowing you to reference and manipulate names and types directly.
Type signatures in IvoryScript explicitly declare the types of values and functions. These signatures ensure correctness and make the function's input and output types clear.
The general form of a type signature is:
name :: Type
For example, a function that adds two integers might have the following type signature:
add :: Int -> Int -> Int
This indicates that add takes two arguments of type
Int and returns an Int. For lazy values, the
signature might be:
add :: Exp Int -> Exp Int -> Exp Int
Function types are a core part of IvoryScript’s type system. They define how functions take arguments and return results.
The arrow (->) separates argument types from the
return type:
multiply :: Int -> Int -> Int
This shows that multiply takes two Int
values and returns an Int. For functions that operate on
lazy values:
multiply :: Exp Int -> Exp Int -> Exp Int
Function types can also be higher-order, where a function returns another function:
compose :: (b -> c) -> (a -> b) -> (a -> c)
Here, compose is a function that takes two functions as
arguments and returns a new function.
IvoryScript distinguishes between lazy and strict types:
Exp a: Represents a lazy expression of type
a. Lazy expressions are not immediately evaluated and only
reduced when explicitly forced.
let x = 42 -- x is of type Exp Int
let y = #42 -- y is of type Int
a: Represents a strict, fully evaluated
type.
Values are typically inferred as Exp a unless explicitly
forced to be strict using the ! or #
operators. This allows IvoryScript to manage evaluation efficiently.
Tuples group multiple values of potentially different types into a single structure. They are enclosed in parentheses, with values separated by commas.
let point = (3, 4) -- point is of type Exp (Int, Int)
Strict tuples can be defined as well:
let strictPoint = !(point) -- strictPoint is of type (Int, Int)
Type signatures for functions that use tuples follow this pattern:
first :: Exp (a, b) -> Exp a
This signature shows that the first function takes a
lazy tuple and returns the first element lazily.
The Ptr a type is an essential polymorphic primitive
type in IvoryScript. It represents a pointer to a value of type
a and is primarily used for referencing memory or
interacting with lower-level aspects of the runtime system. The use of
Ptr a is primarily for operations that involve state
changes or for efficient memory access in embedded or
performance-critical applications.
Ptr a: Represents a pointer to a value of type
a.
let ptr = Ptr someValue -- ptr is of type Exp (Ptr a)
The primary intention of Ptr a is for managing state
changes or mutable references within an application. Although
Ptr a can be used imperatively, doing so is generally less
efficient than a pure, tail-recursive functional approach.
Pointers allow interaction with application state in a controlled manner:
let p = Ptr 10 -- p is of type Ptr Int
While Ptr a provides a way to handle mutable state, its
use should be limited to genuine scenarios requiring changes to
application state. Pure functional recursion often achieves similar
results in a more efficient and declarative manner.
IvoryScript’s type system introduces essential types such as
Name, Type, and Ptr a, alongside
primitive types like Int and Char. The
distinction between lazy (Exp a) and strict
(a) types ensures that evaluations are performed only when
necessary, allowing for efficient management of resources. Function
types further provide a foundation for defining behavior, whether
working with lazy or strict values.
Everything in IvoryScript is treated as a value, including expressions that require further reduction or evaluation. Values can be strict or lazy, depending on their declaration.
A strict value is fully evaluated when used, while a lazy value represents an expression that can undergo further reduction when needed.
There is nothing special about functions returning lazy values. Coercion often transforms lazy values into their appropriate forms without requiring explicit reduction or evaluation.
The coercion mechanism automatically adjusts values to fit the
context in which they are used, reducing the need for explicit use of
(!) or !. However, because of this
flexibility, it is essential to declare a function’s type, particularly
for recursive functions.
The example below demonstrates how IvoryScript represents lazy and nested expressions:
typeOf (take 100 (!primes))
-- Output: Exp (Exp [Int])
In this case, Exp (Exp [Int]) indicates that the result
of take 100 operates on a value that itself still requires
further evaluation, even after a single reduction step. The outer
Exp refers to the need for additional reductions to
retrieve the result, while the inner Exp [Int] signifies
that the list of integers has yet to be fully evaluated. Thus,
Exp represents the delayed or pending computation that will
eventually yield the final value.
This is quite different from many other languages in common use,
where lazy values or deferred evaluations are less explicit or hidden
from the developer. In IvoryScript, this flexibility requires managing
types carefully, especially with recursive functions or lazy expressions
that may need multiple steps of reduction before becoming usable values.
Declaring types such as Exp explicitly allows control over
how and when evaluation occurs, providing the benefit of lazy
computation while maintaining clarity about the process.
This chapter provides an overview of the primary expression forms in IvoryScript. The goal is to offer a practical understanding of how different expressions are used, balancing between detailed reference and introductory examples.
Literal expressions represent direct values in IvoryScript. They include numbers, strings, booleans, and more.
Example:
42 -- Integer literal
"hello" -- String literal
True -- Boolean literal
Literal expressions are straightforward and are used to represent fixed values that do not require further reduction.
Variable expressions refer to names that have been previously defined in a scope. They are used to access values stored in variables.
Example:
let {
x = 10;
y = x * 2
} in
y -- Variable 'y' refers to the result of 'x * 2'
Variables in IvoryScript are immutable, meaning once a value is assigned, it cannot be changed. Variable expressions rely on earlier bindings within their scope.
Function application is the most common way of invoking functions in IvoryScript. It consists of calling a function with one or more arguments.
Example:
let {
square x = x * x;
result = square 5
} in
result -- Applies the function 'square' to the argument 5
Function application is reduced by passing the argument(s) to the function and reducing the function body with these arguments.
Lambda expressions represent anonymous functions in IvoryScript. They
are defined using the syntax \x -> expression where
x is the argument.
Example:
let {
add = \x y -> x + y;
result = add 3 7
} in
result -- Applies the lambda function to arguments 3 and 7
Lambda expressions are useful for defining functions inline, especially when the function is only needed in a limited scope.
The let expression is used to introduce local bindings.
Variables defined in a let block are only visible within
that block.
Example:
let {
x = 5;
y = x + 3
} in
y -- Returns 8, as 'x' and 'y' are local to the let block
The let construct in IvoryScript is a fundamental
building block for defining local variables and functions. It provides a
way to bind expressions to names within a particular scope, allowing for
modularity and reusability in code. The let construct is
versatile, supporting both sequential and mutually recursive
definitions.
The most common usage of let involves binding
expressions to names. These bindings are local to the block in which
they are defined, and they are evaluated in sequence. Here is a basic
example:
let {
x = 10;
y = x * 2;
z = y + 5
} in
z
In this example, the variables x, y, and
z are defined locally within the let block,
and the result of the expression is the final value of z.
The variables are evaluated in the order they are written, making
let a simple and powerful tool for local definitions.
The let construct also supports function definitions
within its scope. This allows for defining local functions that can
operate on the values bound in the same let block. Here's
an example of a let block defining a function:
let {
add x y = x + y;
result = add 5 10
} in
result
In this case, the function add is defined locally within
the let block, and is then used to calculate the value of
result. This allows for encapsulating functionality within
the scope of the let expression.
The let construct in IvoryScript also supports recursive
and mutually recursive function definitions. This enables defining
functions that refer to themselves or to each other within the same
let block. Here's an example of a mutually recursive
function pair:
let {
isEven n = if n == 0 then True else isOdd (n - 1);
isOdd n = if n == 0 then False else isEven (n - 1)
} in
isEven 4
Here, the functions isEven and isOdd call
each other, making them mutually recursive. Both are defined within the
same let block, allowing for their mutual recursion to be
expressed cleanly.
In IvoryScript, the evaluation order within a let block
is strictly sequential. This means that each expression is evaluated in
the order it is written, and the result is bound to the corresponding
name. If a variable depends on a previous definition, that previous
expression must have been fully evaluated before the dependent variable
can be used. This ensures a clear and predictable evaluation flow,
avoiding potential ambiguities in execution.
However, if a function is defined within the let block
and it is not immediately invoked, the function itself is treated as a
value that can be evaluated later. This introduces the concept of
delayed evaluation, where the function's body is not executed until the
function is called. This behavior is particularly important when dealing
with recursive or mutually recursive functions, as discussed in the next
section.
A significant challenge with the let construct arises
when dealing with mutually recursive functions, especially in terms of
the availability and initialisation of closures. When two or more
functions are mutually recursive, the closure (the environment capturing
the function's variables and references) for each function may not be
fully initialised at the time the other functions try to reference
it.
In practical terms, this means that while the names of mutually
recursive functions are available within the same let
block, their closures may be incomplete when one function tries to call
the other. This delayed initialisation can result in runtime errors or
undefined behavior if a function is invoked before its closure has been
fully initialised.
For example, consider this mutually recursive pair of functions:
let {
f x = if x == 0 then 1 else g (x - 1);
g y = if y == 0 then 0 else f (y - 1)
} in
f 5
In this example, f and g are mutually
recursive, meaning they call each other. However, if the function
g is invoked before its closure has been fully initialised
(due to f calling it), it could result in incorrect
behavior. To handle this, IvoryScript currently ensures that the names
of these mutually recursive functions are available, but the full
initialisation of their closures may be delayed until the functions are
first invoked.
This is a key consideration when working with mutually recursive
functions in IvoryScript. A common workaround is to structure recursive
functions carefully, ensuring that any function that relies on a
mutually recursive closure is invoked only after it has been fully
initialised. Another approach is to split the mutually recursive
functions across separate let blocks or modules, ensuring
that each function's closure is fully constructed before mutual
recursion occurs.
Conditional expressions are written using the
if/then/else structure. IvoryScript evaluates the condition
and returns the corresponding branch.
Example:
let {
result = if x > 0 then "positive" else "non-positive"
} in
result
The condition must reduce to a boolean, and the result depends on whether the condition holds true.
Case expressions allow for pattern matching against
different values. They are useful for deconstructing data types and
handling multiple cases explicitly.
Example:
let {
describeList lst = case lst of {
[] -> "Empty list";
_ :+ _ -> "Non-empty list"
}
} in
describeList [1, 2, 3] -- Matches the non-empty list case
Pattern matching with case expressions is flexible,
allowing behavior to be defined for various structures of data.
Operators like +, -, *, and
== are used to perform arithmetic or logical
operations.
Example:
let {
sum = 1 + 2;
isEqual = 5 == 5
} in
(sum, isEqual) -- Returns (3, True)
Operator expressions are syntactic sugar for function applications.
For example, x + y is equivalent to add x y
where add is the operator function.
The expression forms in IvoryScript provide the building blocks for defining logic, performing calculations, and manipulating data. From simple literals and variable bindings to more advanced constructs like pattern matching and user-defined types, expressions form the core of writing functional programs in IvoryScript.
IvoryScript provides a first-class Name type
representing interned identifiers. Names are a core-language concept:
the # literal syntax and the Eq Name instance
are defined in the core. The Property type — a name paired
with a dynamically-typed value — and the collection types built on it
(PropertySet, NameAnyMap,
TransientStore) are all module-provided and are covered
fully in the Name/property collections chapter in Part 2. This chapter
introduces the core concepts that those modules depend on.
A Name value is an interned identifier. Two names are
equal if and only if they represent the same identifier string. The
# prefix introduces a name literal:
#alice
#totalAmount
#x
Name literals may appear wherever any other value appears. They can
be stored in collections, passed as function arguments, returned as
results, and compared using the Eq Name instance:
#alice = #alice -- True
#alice = #bob -- False
The underlying representation is a compact integer index rather than a character string, so equality comparison is O(1) in the common case. The performance characteristics of name comparison across different environments — and how to guarantee O(1) comparison in all cases using built-in name registration — are discussed in the following chapter.
A Property, defined in the Property module,
is a name/value pair where the value is of type Any.
Any is a uniform wrapper that preserves the runtime type of
any IvoryScript value; it is defined in the Any module and
described fully in Part 2. The #-syntax name is the key;
any expression may appear as the value.
The colon shorthand constructs a Property with the
identifier on the left coerced to a Name literal. These two
forms are equivalent:
-- Shorthand (colon notation)
alice: 42
bob: \x :: Int -> x * x
-- Explicit equivalent
Property #alice 42
Property #bob (\x :: Int -> x * x)
The colon notation is only meaningful as a Property
constructor — it is not a general key/value separator in other contexts.
The full collection types built on Property are introduced
in Part 2.
Dot selection is not a built-in syntactic form in IvoryScript. It is
defined entirely through the Select class, which is part of
the Prelude:
class Select a, b where {
(.) :: a -> Name -> b
};
An expression such as store.result desugars to an
application of (.) with the right-hand identifier coerced
to a Name value. Any type can participate in dot selection
by providing a Select instance; there is no privileged
treatment of any particular collection type.
Selection chains associate left: contact.address.city is
parsed as (contact.address).city, first selecting
address from contact, then selecting
city from the result. Each step is a separate application
of (.), potentially dispatching to a different
Select instance.
The Select class is defined in the Prelude; instances
for PropertySet, NameAnyMap, and
TransientStore are provided by their respective modules and
introduced in Part 2.
All identifiers are represented internally as compact integer indices, so equality is resolved by comparing those indices rather than by examining identifier text. Built-in names form an initial collection whose indices are fixed and shared across every environment. Names introduced within a script receive indices allocated in that script's environment but participate in comparison in the same way.
When a built-in identifier appears within a script, a corresponding local entry is created so that persistent data remains independent of any particular built-in set. The association with the built-in index is retained, ensuring that constant-time comparison continues to apply.
Name/value collections such as PropertySet and
NameAnyMap are intended to be used throughout IvoryScript
in place of the static record structures commonly found in other
languages. Dynamic collections can be created, inspected, and combined
without loss of efficiency, since all identifier comparisons are
index-based. An example is shown below:
PropertySet [
alice: 42,
mary: "Hello, world",
bob: \x :: Int -> x * x
]
Such structures can contain identifiers of any length, yet operations on them rely on index comparison rather than string matching.
A name/value access such as .store.p may involve values
drawn from distinct environments: the environment holding the store
variable, and the environment associated with each retrieved value. When
two names originate from different environments their integer indices
are only guaranteed to be equal if both refer to the same built-in name.
Otherwise a string comparison is required to establish equality.
For a commonly used identifier this cross-environment string comparison can be avoided entirely by registering the identifier as a built-in name. In a C++ application file this takes the form:
static Name p_name = builtInName(p);
After registration, any occurrence of the corresponding name literal
#p in a script, and any stored name that resolves to the
same identifier, will carry the fixed built-in index. Equality
comparisons involving #p then reduce to a single integer
comparison regardless of how many environment boundaries are
crossed.
Registration is most beneficial for identifiers that form a stable, application-wide vocabulary — property names accessed repeatedly across multiple stores or passed through many function calls. Identifiers that are purely local to a single script or that appear only rarely do not warrant registration.
Additional built-in identifiers may be defined by an application when predictable performance characteristics are required across all environments that the application creates.
In IvoryScript, coercion and type casting are essential mechanisms that ensure expressions conform to expected types, with a focus on small steps of graph reduction rather than full evaluation. Coercion is applied automatically when values are transferred between contexts, such as when passing function arguments or defining names.
When an expression requires type adaptation, IvoryScript represents it as:
(COERCE, expr)
During type checking, IvoryScript attempts to match the type of
expr with the expected type. If the types align, the
coercion is no longer needed, and the expression is used directly.
Otherwise, the expression is transformed using a cast:
(!)(castexpr)
This transformation introduces the necessary graph reduction steps for cases where type adaptation is required, ensuring the expression can be used in the given context.
Cast
ClassIvoryScript uses the Cast class as the general mechanism
for handling all type conversions. Instances of the Cast
class define how an expression of one type can be cast to another. While
there are many types of casts, the most common involves graph
reduction.
The instance that deals with reductions is defined as follows:
instance Cast Exp b, b where {
inline cast x = (!)x
};
This instance specifically handles the conversion from an expression
of type Exp b to type b; a single step of
graph reduction to the denoted value of x, ensuring that
the expression is reduced to a value before it is used. Given that
function application and constants are lazy, this cast is frequently
encountered in practice.
Casting between values of different strict types (where appropriate),
such as from Int to Double, is also handled by
the general Cast class. However, for strict types, the
transformation is direct and does not involve reduction. For example,
casting from Int to Double is achieved using
the following instance of StrictCast:
primitive fromIntDouble :: Int -> Double;
instance StrictCast Int, Double where {
inline strictCast = fromIntDouble;
inline fromInt = fromIntDouble
};
In this case, the StrictCast instance uses a primitive
function, fromIntDouble, to cast the value directly. This
transformation occurs without coercion or reduction, as the cast
involves only a straightforward type conversion between two strict
types.
IvoryScript also supports casting from strict values to lazy
expressions, such as from a strict type a to
Exp a. This conversion is handled using the general
Cast class and is performed via instances of
StrictCast that convert strict values into lazy
expressions.
-- mkExpr :: a -> Expr a;
instance StrictCast a, Exp a | a ¬= Void where {
inline strictCast x = mkExpr x
}
In this instance, the strict value is wrapped in a lazy expression of
type Exp a using the mkExpr function. This
cast is essential for supporting deferred computation and graph
reduction in IvoryScript. The qualifier a¬= Void ensures
that this transformation is valid for types that have representable
values, excluding Void.
Coercion and type casting in IvoryScript are critical for managing
type transformations while ensuring that expressions conform to expected
types. The Cast class serves as the general mechanism for
all conversions, and reduction is the most common type of cast due to
the laziness of function application and constants:
The Cast class handles all type conversions,
including strict type casts and transformations between expressions and
values.
Instances of Cast (Exp a), a involve reduction using
the reduceCast function, which reduces the expression step
by step as required. This is the most frequently encountered
cast.
Casting between strict types, such as from Int to
Double, involves direct conversion without
reduction.
Strict values can be transformed into lazy expressions, enabling
deferred computation and graph reduction, through instances of
StrictCast.
If the result of a function is a direct function application, then the function call can be made simply by adjusting the stack appropiately and transferring control. This is referred to as a tail call and, if the call is recursive, then such a function is termed tail recursive.
Thus tail recursion allows the compiler to simply discard the current function’s stack frame, which results in efficient recursion with constant memory usage, similar to iterative loops.
Consider:
let sum n =
if n = 0 then 0 else n + sum (n - 1)
This function is not tail recursive because the recursive call
sum (n - 1) is combined with the addition of
n. The function cannot return directly from the recursive
call; the addition must first be completed.
To make the function tail recursive, the operation must be included in the recursive call. This is often done by introducing an accumulator:
let sum_tail n acc =
if n = 0 then acc else sum_tail (n - 1) (acc + n)
In this version, thesum_tail, making it tail recursive.
The accumulator acc carries the result forward, allowing
the function to be optimised and avoid the need for new stack
frames.
To achieve tail recursion, a function may need to be restructured. The goal is to ensure that the recursive call is the final action in the function. This often involves introducing an additional parameter, such as an accumulator, to carry intermediate results. Here’s how a typical non-tail-recursive function might be restructured:
let factorial n =
if n = 0 then 1 else n * factorial (n - 1)
This function can be restructured into a tail-recursive version by using an accumulator:
let factorial_tail n acc =
if n = 0 then acc else factorial_tail (n - 1) (acc * n)
In this tail-recursive version, the recursive call is the final operation, and the intermediate result is carried through the accumulator.
Inline polymorphic functions require special handling to achieve tail recursion. Because they are inlined directly, a tail-recursive function may need to delegate to a non-inline helper function which performs the actual recursion.
An example of this can be seen in the lengthList
function, which computes the length of a list:
lengthList :: [a] -> Int;
inline lengthList l =
let { lengthList_ :: [a] -> Int -> Int;
lengthList_ l acc =
case l of {
[] -> acc;
_ :+ xs -> lengthList_ ((!)xs) (acc + 1)
}
} in
lengthList_ l 0;
The function lengthList is an inline polymorphic
function, but the actual recursion occurs in the helper function
lengthList_, which uses an accumulator to make the
recursion tail recursive. The recursive call
lengthList_ ((!)xs) (acc + 1) is the final operation,
ensuring that the function remains efficient and tail-recursive while
allowing the polymorphic behavior to be maintained.
Tail recursion offers several key advantages:
Improved performance: Reusing the stack frame for each recursive call reduces the overhead of recursion and leads to faster execution.
Avoidance of stack overflow: Tail-recursive functions can handle deep recursion without risking stack overflow, making them more reliable for large data sets.
Memory efficiency: Since tail-recursive functions do not accumulate stack frames, they use constant memory, even for complex or deeply recursive operations.
While tail recursion must currently be implemented manually in IvoryScript, future enhancements are planned to introduce program analysis that can automatically detect and transform functions into their tail-recursive equivalents. This would allow the compiler to optimise recursion without requiring explicit restructuring by the developer.
For now, restructuring functions for tail recursion requires manual refactoring to ensure the recursive call is the final action, using accumulators or helper functions as necessary.
The Mark_GC class is provided to support garbage
collection of expression, function and Ptr values. There
must be an instance of Mark_GC for every type, although the
essential method mark_GC need only be defined if the
representation of any constructor includes one or more primitive
expression, function or Ptr values, or a value of nested
type similarly.
The mark_GC method ensures that all reachable objects of
the specified type are correctly processed by the garbage collector.
This method traverses the structure of the object, marking any heap
allocated values, which is essential for preventing the reclamation of
live objects during memory management.
class Mark_GC a where
mark_GC :: a -> ()
instance Mark_GC MyType where
mark_GC v = case v of
MyType x y -> {
mark_GC x;
mark_GC y;
}
In this example, the mark_GC function uses a
case expression to deconstruct MyType, marking
each of its fields to ensure all components are handled during garbage
collection.
Although every custom type currently requires an explicit
Mark_GC instance with an appropriate definition of
mark_GC, future developments are expected to allow
automatic derivation of mark_GC based on the data
constructor(s) representation function(s). This simplification would
enable the compiler automatically to generate the necessary marking
logic for most types without manual intervention and reduce the need for
repetitive code in custom type definitions.
IvoryScript is designed to underpin the Ivory System declarative event-driven model, which can be specifically tailored for resource-constrained applications. By natively supporting multiple environments and persistence, it is particularly suited for embedded systems operating under constraints on memory and communication bandwidth. This chapter explains how resource usage can be minimised by separating the compilation process, using remote bytecode interpretation and dynamic linking to the runtime system. It also explores how diverse applications can evolve dynamically and run non-stop with remote management for inspection and modification, enabling flexible deployment and control of a wide range of mobile units including vehicles, drones and spacecraft etc.
In embedded systems where resources are constrained, the compiler can be omitted entirely. This reduces the memory footprint and computational overhead, as the only necessary components are the bytecode interpreter and runtime system.
Local compilation and bytecode preparation: Order scripts are prepared on a local host with full compiler support. The resulting bytecode, which is compact and efficient, is then ready for transmission to the embedded device.
Remote bytecode transmission and interpretation: The compiled bytecode is transmitted to the remote platform and dynamically linked to the runtime system. The lightweight interpreter on the device then executes the bytecode with no need to reboot or restart processes to execute new Order scripts - allowing updates and inspection to happen seamlessly in real-time.
Applications can be updated dynamically as new operational requirements arise, all while minimising communication bandwidth and avoiding re-deployment. This flexibility ensures that systems can evolve over time and remain in continuous operation.
Part 2 of this guide covers the modules that form the practical foundation of IvoryScript programming. The first and most important of these is the Prelude.
The Prelude is loaded automatically for every IvoryScript script. It extends the core language with the classes and types that give the language its day-to-day expressive power, without which even basic operations such as addition or list traversal would be unavailable. Understanding the Prelude is therefore a prerequisite for reading the subsequent module chapters.
The Prelude defines several types that are used pervasively across all other modules:
| Type | Description |
|---|---|
Bool |
False | True — the boolean type used by all conditional
expressions. |
Ordering |
LT | EQ | GT — result type of the compare
method. |
Maybe a |
Nothing | Just a — optional value; the standard way to
represent a result that may be absent. |
Ptr a |
Null | Ptr a — a nullable heap pointer; the basis for
mutable state and recursive data structures. |
Plain a |
A wrapper marking a value as environment-independent, enabling certain code-generation optimisations. |
Uneval a |
Wraps an Exp a to mark it explicitly as unevaluated;
used where automatic reduction must be suppressed. |
The Prelude defines all fundamental classes. Each is summarised below; instances for the built-in and module-defined types are described in the relevant module chapters.
Every heap-allocated value in IvoryScript carries a reference to the
environment (managed memory space) that owns it. The Env
class abstracts over this relationship:
class Env a where {
needsEnv :: a -> Bool;
mapToEnv :: a -> Env -> a;
copyToPtr :: a -> Ptr a -> Env -> Void
};
Scalar types (Int, Float,
Double, Char) receive the default
implementations — needsEnv returns False and
mapToEnv is the identity. Heap-allocated types override
needsEnv to return True and provide a
mapToEnv that remaps all internal pointers to the
destination environment. This mechanism underpins both garbage
collection and persistence.
class Eq a where {
(=) :: a -> a -> Bool;
(/=) :: a -> a -> Bool
};
class Eq a => Ord a where {
(<) :: a -> a -> Bool;
(<=) :: a -> a -> Bool;
(>) :: a -> a -> Bool;
(>=) :: a -> a -> Bool;
compare :: a -> a -> Ordering;
max :: a -> a -> a
};
Instances are provided for all primitive types (Int,
Float, Double, Char,
Bool, Name) and for most module-defined
types.
class Num a where {
zero :: a;
succ :: a -> a;
pred :: a -> a;
(+) :: a -> a -> a;
(-) :: a -> a -> a;
(*) :: a -> a -> a;
(mod) :: a -> a -> a;
negate :: a -> a
};
class FractionalNum a where {
(/) :: a -> a -> a
};
Because arithmetic is class-based, the operators +,
-, * and mod work uniformly for
any type that provides a Num instance. The primitive types
Int, Float and Double all provide
instances; Float and Double additionally
provide FractionalNum.
class Select a, b where {
(.) :: a -> Name -> b
};
Dot-notation selection is an ordinary class method, not a built-in
syntactic form. Any type providing a Select instance
supports the dot operator. The right-hand identifier is coerced to a
Name at the call site. Instances for
PropertySet, NameAnyMap and
TransientStore are provided by their respective
modules.
class Seq a where {
length :: a -> Int;
tail :: a -> a;
take :: Int -> a -> a;
drop :: Int -> a -> a;
(++) :: a -> a -> a
};
class Seq a => ReverseableSeq a where {
reverse :: a -> a
};
class IndexableSeq a, b where {
getAt :: a -> Int -> b;
putAt :: a -> Int -> b -> Void;
head :: a -> b
};
These three classes define the standard sequence interface.
Seq covers length, slicing, and concatenation.
IndexableSeq adds random access and head extraction.
ReverseableSeq adds reversal. Instances are provided by the
List, Vector, and String
modules.
class Textual a where {
format :: a -> String -> String;
stringify :: a -> String
};
class Show a where {
show :: a -> Void;
showWithNewline :: a -> Void
};
Textual converts a value to a String,
optionally via a format string. Show writes a value to the
standard output stream; the default implementation calls
stringify and then the Show String instance,
so providing a Textual instance is usually sufficient.
class Cast a, b where {
cast :: a -> b
};
class StrictCast a, b where {
strictCast :: a -> b
};
class Eval a, b where {
eval :: a -> b
};
These classes provide the type-directed coercion and evaluation
mechanisms described in the Coercion chapter. Cast performs
a lazy coercion; StrictCast forces evaluation before
coercing; Eval reduces an expression to its value. The
default Eval Exp b, b instance applies
reduce.
In addition to class definitions, the Prelude provides a set of general utility functions available in every script:
| Name | Type | Description |
|---|---|---|
id |
a -> a |
Identity function. |
compose |
(b->c) -> (a->b) -> a -> c |
Function composition. |
reduce |
Exp a -> a |
Forces evaluation of a lazy expression. |
ignore |
a -> Void |
Discards a value; suppresses unused-value warnings. |
fix |
((a->a)->a->a) -> a->a |
Fixed-point combinator for unary recursive functions. |
fix2 |
((a->a->a)->a->a->a) -> a->a->a |
Fixed-point combinator for binary recursive functions. |
fst |
(a,b) -> a |
First component of a pair. |
snd |
(a,b) -> b |
Second component of a pair. |
fst3, snd3, thd3 |
(a,b,c) -> a/b/c |
Components of a triple. |
isNull |
Ptr a -> Bool |
Tests whether a pointer is Null. |
dePtr |
Ptr a -> a |
Extracts the value from a non-null pointer. |
isNothing |
Maybe a -> Bool |
Tests whether a Maybe value is
Nothing. |
error |
String -> a |
Raises a runtime error with the given message. |
The Seq, IndexableSeq, and
ReverseableSeq classes from the Prelude define common
methods for working with ordered, traversable collections —
length, head, take,
drop, ++, and more. Any type that implements
these classes can be used wherever a sequence is expected, regardless of
its underlying structure. IvoryScript provides four such types in the
standard library: lazy lists, suited to recursive functional processing,
vectors for efficient mutable random-access storage, character lists and
strings for text and binary trees for hierarchical data.
The three sequence classes from the Prelude are declared as follows:
class Seq a where {
length :: a -> Int;
tail :: a -> a;
take :: Int -> a -> a;
drop :: Int -> a -> a;
(++) :: a -> a -> a
};
class Seq a => ReverseableSeq a where {
reverse :: a -> a
};
class IndexableSeq a, b where {
head :: a -> b;
getAt :: a -> Int -> b;
putAt :: a -> Int -> b -> Void
};
Any instance defintion for a data type (which may be polymorphic)
implements one or more of the type class methods; examples are the
List, Vector, and StringList
modules.
The list type [a], defined in the List
module, is an important sequence type. A list is either empty or a
pairing of a head value (which may be lazy) with a lazy list tail;
making it natural for recursive processing and well-suited to sequences
whose length is not known in advance. List literals use bracket
syntax:
[1, 2, 3, 4, 5] -- [Int]
['a', 'b', 'c'] -- [Char]
[[1, 2], [3, 4], [5, 6]] -- [[Int]]
[] -- empty list (any element type)
The types shown above are illustrative. In the absence of
constraining context, type inference produces more general forms —
[[1, 2], [3, 4], [5, 6]] is inferred as
Exp [Exp [Exp Int]] rather than [[Int]]. In
practice, sequences appear in contexts that supply the necessary
constraints so the simpler types reflect typical usage.
let xs = [10, 20, 30, 40, 50]
head xs -- 10
tail xs -- [20, 30, 40, 50]
length xs -- 5
take 3 xs -- [10, 20, 30]
drop 3 xs -- [40, 50]
xs ++ [60, 70] -- [10, 20, 30, 40, 50, 60, 70]
reverse xs -- [50, 40, 30, 20, 10]
getAt xs 3 -- 40
Sorting and merging ordered lists are provided by the
List module directly:
mergeSort [5, 2, 8, 1, 9, 3] -- [1, 2, 3, 5, 8, 9]
merge [1, 3, 5, 7] [2, 4, 6, 8] -- [1, 2, 3, 4, 5, 6, 7, 8]
The List module provides several constructors for
arithmetic sequences and infinite lists. Because list tails are lazy,
infinite lists are perfectly valid — only the portion actually consumed
is ever evaluated.
-- Bounded arithmetic sequence: listFromTo start step end
listFromTo 1 1 5 -- [1, 2, 3, 4, 5]
listFromTo 0 2 10 -- [0, 2, 4, 6, 8, 10]
listFromTo 10 (-1) 1 -- [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
-- Infinite arithmetic sequence: listFrom start step
take 6 (listFrom 1 1) -- [1, 2, 3, 4, 5, 6]
take 5 (listFrom 0 3) -- [0, 3, 6, 9, 12]
-- Constant repetition
take 4 (repeat 42) -- [42, 42, 42, 42]
replicate 0 5 -- [0, 0, 0, 0, 0]
-- Cyclic repetition
take 8 (cycle [1, 2, 3]) -- [1, 2, 3, 1, 2, 3, 1, 2]
The most powerful range constructor is iterate, from the
ListFunction module. It generates an infinite list by
repeatedly applying a function to a seed value. Powers of two:
take 8 (iterate ((*) 2) 1) -- [1, 2, 4, 8, 16, 32, 64, 128]
A more expressive use of iterate carries a pair of state
values through each step. The Fibonacci sequence, for example, advances
a pair (a, b) to (b, a+b) at each step, with
mapf fst extracting the leading value:
mapf fst (take 10 (iterate (\p -> (snd p, fst p + snd p)) (0, 1)))
-- [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
The ListFunction module provides the higher-order
functions that form the backbone of functional list processing.
mapf applies a function to every element of a list,
producing a new list of results:
mapf ((*) 3) [1, 2, 3, 4, 5] -- [3, 6, 9, 12, 15]
mapf length ["one", "two", "three"] -- [3, 3, 5]
filter retains only those elements satisfying a
predicate:
filter (\n -> n mod 2 = 0) [1, 2, 3, 4, 5, 6] -- [2, 4, 6]
filter (\n -> n * n < 20) [1, 2, 3, 4, 5] -- [1, 2, 3, 4]
flatMap maps a function returning a list over every
element, then flattens the results into a single list. It is equivalent
to applying flatten after mapf:
flatMap (\x -> [x, x * x]) [1, 2, 3, 4]
-- [1, 1, 2, 4, 3, 9, 4, 16]
-- Expand each word into its characters, inserting a separator
quotedChars(flatten (mapf (\w -> #!(chars w) ++ [' ']) ["red", "green", "blue"]))
-- ['r','e','d',' ','g','r','e','e','n',' ','b','l','u','e',' ']
foldl reduces a list to a single value by applying a
combining function left-to-right, carrying an accumulator:
foldl (+) 0 [1, 2, 3, 4, 5] -- 15 (sum)
foldl (*) 1 [1, 2, 3, 4, 5] -- 120 (product; 5!)
foldl max 0 [3, 1, 9, 1, 5, 4] -- 9 (running maximum)
foldr applies the combining function right-to-left. It
is the natural choice when the result shares the list's structure, since
it can operate lazily on infinite inputs where foldl
cannot:
-- Count elements satisfying a predicate using foldr
let inline count p xs = foldr (\x acc -> if p x then acc + 1 else acc) 0 xs
in count (\n -> n mod 2 = 0) [1, 2, 3, 4, 5, 6] -- 3
span splits a list at the first element that fails a
predicate, returning the prefix and the remainder as a pair:
span ((>) 5) [1, 3, 5, 7, 2] -- ([1, 3], [5, 7, 2])
span (\c -> not (c = ':')) (chars "host:8080")
-- (['h','o','s','t'], [':','8','0','8','0'])
The second example shows a common pattern: splitting a string at a
delimiter character. Combined with string, this gives a
simple field extractor:
let {
splitAt :: Char -> String -> (String, String);
splitAt delim s =
case span (\c -> c ¬= delim) (chars s) of {
(before, rem) -> (string before, string (tail rem))
}
} in splitAt ':' "Content-Type: text/plain"
groupBy partitions a list into runs of
consecutive elements satisfying a binary predicate. Two
adjacent elements are placed in the same group when the predicate
returns True:
groupBy (=) [1, 1, 2, 3, 3, 3, 1]
-- [[1,1],[2],[3,3,3],[1]]
-- Group by equal parity
groupBy (\x y -> x mod 2 = y mod 2) [2, 4, 1, 3, 6, 5]
-- [[2,4],[1,3],[6],[5]]
Note that groupBy only compares adjacent elements; it
does not sort. To collect all globally equal elements together, sort the
list first:
-- Frequency table: sort then group, count each run
let {
frequencies :: [Int] -> [(Int, Int)];
frequencies xs =
mapf (\grp -> (head grp, length grp))
(groupBy (=) (mergeSort xs))
} in
frequencies [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5] -- [(1,2),(2,1),(3,2),(4,1),(5,3),(6,1),(9,1)]
IvoryScript distinguishes between the primitive String
type — an efficient non-decomposable value — and the (lazy) character
list [Char] which supports all sequence methods. The
StringList module bridges the two.
chars converts a String to a
[Char], making the full list API available:
chars "Hello" -- [H,e,l,l,o]
length (chars "Hello") -- 5
reverse (chars "Hello") -- [o,l,l,e,H]
filter (\c -> c ¬= 'l') (chars "Hello") -- [H,e,o]
string converts back from [Char] (or
Exp [Char]) to String:
string (reverse (chars "stressed")) -- "desserts"
string (filter (\c -> c = 'a' | c = 'e' | c = 'i' | c = 'o' | c = 'y')
(chars "IvoryScript")) -- "oyi"
A String can also be coerced directly to
Exp [Char] through the StrictCast instance,
and a strict [Char] coerces back to String in
the same way.
quotedChars converts a character list to a list of
strings, each string containing the character in single-quote notation.
This is primarily useful for displaying character groups to match
IvoryScript syntax:
quotedChars ['H', 'i'] -- ['H','i']
Vector a, defined in the Vector module, is
a dynamic-length mutable array implemented as a segmented slice
structure. Unlike a list, a vector supports O(1) random access and
in-place update via getAtVector and
putAtVector. The constructor takes an initial element list
and a slice size:
let scores = Vector [85, 92, 78, 95, 60] 8
length scores -- 5
getAt scores 2 -- 78
Updates are performed in place. The vector itself is a mutable heap object, so changes are immediately visible through the same binding:
putAtVector scores 2 100
getAtVector scores 2 -- 100
findVectorIndex scores 95 -- 3 (returns -1 if not found)
The Seq instance provides length; the
IndexableSeq instance provides head,
getAt, and putAt. There is no
ReverseableSeq instance — reversal of a mutable array is
destructive and is left to application code.
Vectors are the appropriate choice when elements need to be looked up or updated by index, when the collection is constructed once and then read many times, or when interoperating with C++ code via pointer arrays.
The Tree module provides a binary tree type suited to
hierarchical data such as expression trees, decision trees, and priority
structures. A tree is one of three cases: an empty node, a leaf holding
a single value, or a branch with two lazy subtrees:
type Tree a = Empty | Leaf a | Branch (Exp (Tree a)) (Exp (Tree a))
Because subtrees are lazy, arbitrarily large trees can be defined without evaluating every node:
let t :: Exp (Tree Int)= Branch (Branch (Leaf 4) (Leaf 7))
(Branch (Leaf 2) (Branch (Leaf 9) (Leaf 1)))
in
...
depthTree t -- 4
maxTree t -- 9
sumTree t -- 23
Trees are naturally described by recursive functions. A depth-first in-order traversal collecting all leaf values into a list:
let inorder :: Exp (Tree a) -> [a];
inorder tr =
case #!tr of {
Empty -> [];
Leaf x -> [x];
Branch lt rt -> inorder lt ++ inorder rt
}
inorder t -- [4, 7, 2, 9, 1]
Combining a tree traversal with list processing shows how the two modules complement each other. Collecting and sorting the leaf values:
mergeSort (inorder t) -- [1, 2, 4, 7, 9]
The following example draws together lists, character lists,
higher-order functions, and the quotedChars display utility
to perform a run-length analysis of a string — identifying runs of
consecutive identical characters.
Step 1. Decompose the string into a character list:
chars "Mississippi"
-- [M,i,s,s,i,s,s,i,p,p,i]
Step 2. Apply groupBy (=) to partition the list
into runs of consecutive equal characters. Because groupBy
only compares adjacent elements, the original order of the string is
preserved:
groupBy (=) (chars "Mississippi")
-- [[M],[i],[s,s],[i],[s,s],[i],[p,p],[i]]
Step 3. The run-length encoding follows directly. Each group
is a list of identical characters; head extracts the
representative character and length counts the
repetitions:
let rle s = mapf (\grp -> (head grp, length grp))
(groupBy (=) (chars s))
in
rle "Mississippi" -- [(M,1),(i,1),(s,2),(i,1),(s,2),(i,1),(p,2),(i,1)]
Step 4. quotedChars converts each character
group to a list of single-quoted strings. Applying it via
mapf across the outer list:
mapf quotedChars (groupBy (=) (chars "Mississippi"))
-- [['M'],['i'],['s','s'],['i'],['s','s'],
-- ['i'],['p','p'],['i']]
The complete expression is a single pipeline: each stage is a pure function applied to the output of the previous one. No intermediate bindings are required unless readability demands them, and no mutation occurs at any point. This compositional style — building complex transformations from small, independently testable pieces — is characteristic of idiomatic IvoryScript.
Extending rle to reconstruct the original string from
its encoding confirms the round-trip:
let expand pairs =
string (flatten (mapf (\p -> replicate (fst p) (snd p)) pairs))
expand (rle "Mississippi") -- "Mississippi"
Here replicate produces a list of n copies
of a character, mapf applies this to every run-length pair,
flatten concatenates the resulting list of lists into a
single character list, and string converts it back to a
String.
Name/Property collections differ from the fixed-schema records of
statically-typed languages. Rather than declaring a named type with a
fixed set of typed fields, a script assembles collections of named
values at runtime, with each value carrying its type at the
Any level. The collection can be extended, queried by name,
and passed through generic code without any schema declaration.
This chapter covers the module stack that enables this:
Any as the uniform dynamic-type wrapper;
Binding as the name/value pair; Property and
its colon-notation shorthand; PropertySet for fixed,
vector-backed collections; and NameAnyMap for mutable,
hash-backed maps. The lower-level HashTable type is also
described for situations where a custom key type is required.
Any, defined in the Any module, is a
uniform wrapper that holds a value of any IvoryScript type together with
its runtime type descriptor. Internally it is the pair
(Type, Ptr a): the first component identifies the concrete
type; the second points to the stored value.
Any concrete value is wrapped as Any via the subordinate
StrictCast instance, which forces evaluation and records
the type. A type annotation on the binding site unwraps the value
again:
let a1 = Any #42 in ... -- Any holding an Int
let a2 = Any #"Hello" in ... -- Any holding a String
let a3 = Any #!True in ... -- Any holding a Bool
...
typeOfAny a1 -- the runtime Type descriptor for Int
let n :: Int = ::a1 in n -- 42
let s :: String = ::a2 in s -- Hello
let b :: Bool = ::a3 in b -- True
Any supports environment remapping, garbage-collection
marking, and byte-stream serialisation — all dispatched through the type
descriptor for the type. This means Any values appear
freely in persistent stores and environments without special
handling.
A Binding a b, defined in the Binding
module, is a pair of typed values: a key of type a and a
value of type b. The single data constructor is
Bind:
Bind #host "localhost" -- Binding Name String
Bind 42 "the answer" -- Binding Int String
The BindingSet class abstracts over any mutable
collection that supports keyed insertion, lookup, and removal:
class BindingSet a, b where {
addBinding :: a -> b -> c -> Void;
hasBinding :: a -> b -> Bool;
removeBinding :: a -> b -> Void
};
Both NameAnyMap and HashTable provide
BindingSet instances, so a function written against the
class works with either collection type.
A Property, defined in the Property module,
is an association between a key of type Any and a value of
type Any. It is the element type of
PropertySet.
Properties are most commonly written using the colon shorthand: an
identifier followed by : and a fully evaluated value
expression.
-- Shorthand notation
name: "Alice"
age: 30
active: True
-- Equivalent explicit form
Property #name !"Alice"
Property #age !30
Property #active !True
The runtime type of the value is preserved inside the
Any wrapper and can be recovered at any point:
let p :: Property = age: 30
in
typeOfProperty p -- Int
The colon notation is valid wherever a Property value is
expected, most commonly in the list literal passed to the
PropertySet constructor.
PropertySet, defined in the PropertySet
module, is an ordered, fixed collection of Property values
backed by a contiguous BasicVector. The constructor accepts
a list of properties, typically written with the colon shorthand:
let person = PropertySet [
name: "Alice",
age: 30,
active: True
] in
person
Values are retrieved by name through the dot operator, which
dispatches via the Select PropertySet, Any instance. The
result type is always Any; a type annotation on the binding
site or an explicit cast extracts the concrete value:
let name :: String = person.name in name -- "Alice"
let age :: Int = person.age in age -- 30
Dot expressions associate left and chain naturally through nested sets:
let contact = PropertySet [name: "Alice", address: PropertySet [city: "London", postCode: "EC1A"]]
in
contact.address.city -- London
Because the reprentatious is contiguous, access is a linear scan over
the property vector. The built-in name
optisation (chapter 9). reduces each comparison to a single integer
test for registered names, making PropertySet very
efficient for most typical objects.
A common use of PropertySet is to model a structured
event or message where the fields are known in advance. A UI click event
carrying positional and user information:
let {
mkClickEvent :: String -> Int -> Int -> PropertySet;
mkClickEvent user x y = PropertySet [
type_: "click",
user: "foo",
position: PropertySet [x: x, y: y]
]
} in
...
let ev = mkClickEvent "alice" 320 240
in
...
let evType :: String = ev.type_ -- click
let evUser :: String = ev.user -- alice
let evX :: Int = ev.position.x -- 320
A handler function can pattern-match on the event type and then read further properties as needed, without requiring a pre-declared record type.
NameAnyMap, defined in the NameAnyMap
module, is a mutable hash-table-backed map from Name keys
to Any values. The constructor takes the number of hash
table slots; typically a prime number:
let config :: NameAnyMap = NameAnyMap 47
Entries are managed through the BindingSet interface.
Values are generally implicitly coerced to Any.
addBinding config #host "localhost"
addBinding config #port 8080
addBinding config #timeout 30
addBinding config #debug False
Retrieval uses the same dot syntax as PropertySet:
let h :: String = config.host -- "localhost"
let p :: Int = config.port -- 8080
Entries may be removed and replaced across the map lifetime.
removeBinding config #debug
addBinding config #debug True -- overwrite with updated value
The NameAnyMapFunction module provides higher-order
operations. foldNameAnyMap visits every entry and
accumulates a result; entry order follows the internal hash slot
ordering, not insertion order:
-- Count the number of entries
let mapSize m = foldNameAnyMap (\k v acc -> acc + 1) 0 m
mapSize config -- 4
-- Collect all keys
let mapKeys m =
mergeSort (foldNameAnyMap (\k v acc -> (stringify k) :+ (::acc)) [] m)
mapKeys config -- e.g. [#debug, #timeout, #port, #host]
nameAnyMapNames returns a formatted diagnostic string
listing all names whose value has a given runtime type:
nameAnyMapNames config #::String -- ["host"]
nameAnyMapNames config #::Int -- ["port", "timeout"]
HashTable keyType valType, defined in the
HashTable module, is the underlying implementation used by
NameAnyMap. It is available directly for situations where a
key type other than Name is needed, or where explicit
control over slot allocation is required.
Unlike NameAnyMap, the HashTable API
requires the caller to supply the slot index (the hash of the key modulo
the table size) explicitly for every operation. The table size is set at
construction time:
let ht = HashTable 32 -- 32 slots, key: Int, value: String
let slot k = k mod (nHashTableSlots ht)
hashTableAdd ht (slot 42) 42 "forty-two"
hashTableAdd ht (slot 99) 99 "ninety-nine"
hashTableGet ht (slot 42) 42 -- Just "forty-two"
hashTableGet ht (slot 99) 99 -- Just "ninety-nine"
hashTableGet ht (slot 7) 7 -- Nothing
hashTableRemove ht (slot 42) 42
hashTableGet ht (slot 42) 42 -- Nothing
Each slot holds a linked chain of entries
(HashTableEntry nodes), allowing multiple keys to share a
slot when collisions occur. findInHashTableSlot searches a
single chain; the higher-level hashTableGet combines
hashing and chain traversal in one call.
The HashTableFunction module provides
foldHashTable, which visits every key/value entry across
all slots and is the basis for foldNameAnyMap:
-- Sum all integer values in a HashTable Name Int
let total ht = foldHashTable (\k v acc -> acc + v) 0 ht
The following example illustrates the complementary use of
PropertySet and NameAnyMap in a
request-handling scenario. The static server configuration is expressed
as a PropertySet — its schema is fixed and known at
startup. The per-request context is a NameAnyMap — it is
assembled incrementally as middleware layers process the request.
-- Static configuration: fixed schema, constructed once
let serverConfig = PropertySet [
host: "0.0.0.0",
port: 8443,
maxClients: 100,
debug: False
]
let cfgPort :: Int = serverConfig.port -- 8443
let cfgDebug :: Bool = serverConfig.debug -- False
The per-request context starts empty and accumulates entries as routing and authentication middleware add information:
let handleRequest rawPath userId =
let ctx = NameAnyMap 16 in {
-- Routing layer
addBinding ctx #method "GET";
addBinding ctx #path rawPath;
-- Authentication layer
addBinding ctx #userId userId;
addBinding ctx #authed True;
-- Handler reads from context
let method :: String = ctx.method;
let path :: String = ctx.path;
let uid :: Int = ctx.userId;
-- Log if debug mode is on
if cfgDebug then {
showWithNewline method;
showWithNewline path
};
processRequest ctx
}
Neither layer needs to know about the fields added by the other.
foldNameAnyMap can enumerate the full context for
diagnostics without any advance knowledge of which keys are present:
let logContext ctx =
foldNameAnyMap
(\k v acc -> { showWithNewline k; acc })
Void
ctx
This pattern — a fixed configuration object plus a dynamic per-invocation context — recurs throughout the Ivory System and is the natural way to model application objects in IvoryScript.
IvoryScript provides I/O at two levels. At the lower level, the
built-in FileHandle type gives direct byte-by-byte access
to files. At the higher level, the Streams module wraps
file handles in typed InputStream and
OutputStream values and provides uniform serialisation and
deserialisation for all standard types through the
StreamInsert and StreamExtract classes. The
Dir module adds directory listing as a lazy list.
Files are opened with the built-in openFile, which takes
a path and an IO_Mode and returns a
FileHandle:
type IO_Mode = IO_Read | IO_Write | IO_Append | IO_ReadWrite
let fh = openFile "data.bin" IO_Write
writeFileByte fh 72 -- write byte 0x48
writeFileByte fh 101 -- write byte 0x65
closeFile fh
Reading is symmetric:
let fh = openFile "data.bin" IO_Read
let b1 :: Byte = readFileByte fh
let b2 :: Byte = readFileByte fh
closeFile fh
Direct byte access is the appropriate level for custom binary protocols or when interoperating with external C++ code that manages file handles directly. For structured IvoryScript values, the typed stream interface described in the next section is more convenient and handles all framing automatically.
The Streams module wraps FileHandle in two
typed stream types:
type InputStream a = InputStream (Exp a) (Exp Void)
type OutputStream a = OutputStream (a -> Void) (Exp Void)
Each stream packages its I/O action (or function) alongside a close action. File-backed byte streams are created with:
fileInputByteStream :: String -> InputStream Byte
fileOutputByteStream :: String -> OutputStream Byte
Values are written and read through the StreamInsert and
StreamExtract class methods:
insert :: OutputStream Byte -> b -> Void -- serialise b to stream
extract :: InputStream Byte -> b -- deserialise b from stream
The concrete type of b is resolved from the context at
the call site. Writing a structured record to a file:
let os = fileOutputByteStream "record.bin"
insert os 42
insert os "Alice"
insert os True
#!(streamCloseOutputAction os)
Reading it back requires type annotations to tell the compiler which
instance of extract to use at each step:
let is = fileInputByteStream "record.bin"
let n :: Int = extract is
let s :: String = extract is
let b :: Bool = extract is
#!(streamCloseInputAction is)
The close actions are lazy expressions; they must be forced with
#! to actually close the underlying file. Forgetting to
close a stream is a resource leak.
Stream accessor functions are available when the stream object needs to be passed to lower-level code:
streamInputAction :: InputStream a -> Exp a
streamOutputFn :: OutputStream a -> (a -> Void)
streamCloseInputAction :: InputStream a -> Exp Void
streamCloseOutputAction:: OutputStream a -> Exp Void
The Streams module provides StreamInsert
and StreamExtract instances for all primitive and
structural types:
| Type | Notes |
|---|---|
Int, Float, Double |
Fixed-width binary encoding. |
Char |
Single-byte character encoding. |
String |
Length-prefixed byte sequence. |
Bool |
Single byte: 0 for False, 1 for True. |
Name |
Serialised as its identifier string; restored via interning on read. |
Type |
Runtime type descriptor. |
(a, b), (a, b, c) |
Components written in left-to-right order. |
(a, b, ..., n) — arities 4–10 |
Provided by the Streams_Tuple_4_10 module. |
Ptr a |
Null serialised as 0; Ptr x as 1 followed
by the serialised value. |
Exp a |
The expression is forced before serialisation. |
a -> b |
Function closures, including their captured environments. |
Env |
The full managed memory environment. |
All module-defined collection types — [a],
Vector a, Tree a, HashTable k v,
NameAnyMap, PropertySet, and others — provide
their own instances in their respective modules, so insert
and extract work uniformly across the full type system.
The Serialisable module defines a format-neutral
abstraction over round-trip conversion:
class Serialisable a, b where {
serialise :: a -> b;
deserialise :: b -> a
};
Where StreamInsert and StreamExtract
prescribe a specific binary wire format, Serialisable a b
expresses only the round-trip contract — that values of type
a can be converted to and from type b
losslessly. The serialised form b might be a
String (for a text representation), a
PropertySet (for a structured object representation), or
any other intermediate form.
User-defined types that need format flexibility — for example an API
type that must be representable as both binary and JSON — can provide
separate Serialisable instances for each target, keeping
the representation concern separate from the data model.
The Dir module provides a single high-level
function:
dir :: String -> Exp [String]
dir opens the directory at the given path, reads all
entries lazily using the underlying openDir,
nextDirEntry, and dirEntryName primitives, and
returns a lazy list of filename strings. An empty list is returned if
the path does not exist or is not a directory.
Because the result is an Exp [String], the full suite of
list operations from the List and ListFunction
modules applies directly:
-- List all entries in a directory
dir "/home/alice/scripts"
-- Count entries
length (dir "/tmp")
-- Filter to IvoryScript source files only
filter (\name -> length name > 3 &&
drop (length name - 3) name = ".is")
(dir "/home/alice/scripts")
-- Sorted listing
mergeSort (dir "/home/alice/data")
The underlying primitives are available for lower-level directory traversal where explicit control over the stream lifecycle is needed:
openDir :: String -> Maybe Dir
nextDirEntry :: Dir -> Maybe DirEntry
dirEntryName :: DirEntry -> String
closeDir :: Dir -> Void
The Dir module implementation uses these directly and
shows the idiomatic pattern: open the directory, recurse with
nextDirEntry until it returns Nothing, then
rely on garbage collection to release the handle. Application code
should prefer dir and reserve the primitives for cases
where filtering or early exit needs to happen inside the traversal
loop.
The following example writes a list of name/score pairs to a binary
file and reads them back. Because the length of the list is not encoded
by the stream format itself, it is written as an Int prefix
so that the reader knows how many records to extract.
-- Write a list of (String, Int) records to a binary file
let writeScores filename scores =
let os = fileOutputByteStream filename in {
insert os (length scores);
mapf (\p -> { insert os (fst p); insert os (snd p) }) scores;
#!(streamCloseOutputAction os)
}
-- Read them back
let readScores filename =
let is = fileInputByteStream filename;
n :: Int = extract is;
readOne = \_ -> let name :: String = extract is;
score :: Int = extract is
in (name, score)
in {
let scores = mapf readOne (listFromTo 1 1 n);
#!(streamCloseInputAction is);
scores
}
Usage:
let scores = [("Alice", 95), ("Bob", 82), ("Carol", 91)]
writeScores "scores.bin" scores
readScores "scores.bin"
-- [("Alice",95),("Bob",82),("Carol",91)]
Combining I/O with the Dir module, all score files in a
directory can be aggregated into a single sorted leaderboard:
let loadAll dir_ =
flatten (mapf readScores
(filter (\n -> drop (length n - 4) n = ".bin")
(dir dir_)))
mergeSort (mapf snd (loadAll "/data/scores"))
-- all scores from all .bin files, sorted ascending
Four modules cover the numeric and mathematical areas of the standard
library. MathConst provides a set of high-precision
floating-point constants. Arith supplies general-purpose
arithmetic functions — factorial, GCD, LCM, and the Fibonacci
recurrence. NumAggregate defines aggregate classes
(SumAggregate, MeanAggregate) with instances
for Int and Double lists, and a polymorphic
median function. IntSeq rounds out the group
with a collection of infinite lazy integer sequences — factorials,
Fibonacci numbers, primes, and prime gaps — that integrate naturally
with the sequence combinators covered in Chapter 15.
The MathConst module exports six named
Double constants at full IEEE 754 double precision:
| Name | Value | Meaning |
|---|---|---|
e |
2.71828182845904523536 | Euler's number — base of the natural logarithm |
pi |
3.14159265358979323846 | Ratio of a circle's circumference to its diameter |
phi |
1.61803398874989484820 | Golden ratio — (1 + √5) / 2 |
sqrt2 |
1.41421356237309504880 | Square root of 2 |
ln2 |
0.69314718055994530942 | Natural logarithm of 2 |
ln10 |
2.30258509299404568402 | Natural logarithm of 10 |
A circle's area and circumference follow directly:
circleArea :: Double -> Double
circleArea r = pi * r * r
circumference :: Double -> Double
circumference r = 2.0 * pi * r
Continuous compound interest uses e:
-- compoundValue principal rate years
compoundValue :: Double -> Double -> Double -> Double
compoundValue p r t = p * (exp (r * t))
The golden ratio satisfies phi² = phi + 1; a quick verification illustrates direct use of the constant:
let diff :: Double = phi * phi - phi - 1.0
-- diff is approximately 0.0 (within floating-point rounding)
The Arith module provides four standard functions:
| Signature | Description |
|---|---|
fact :: Int -> Int |
Factorial via tail recursion |
gcd :: Int -> Int -> Int |
Greatest common divisor (Euclidean algorithm) |
lcm :: Int -> Int -> Int |
Least common multiple, defined as
x * y div (gcd x y) |
fibonacci :: Int -> Int |
nth Fibonacci number via tail-recursive accumulator |
fact is implemented with a tail-recursive helper
accumulating the product:
fact 0 -- 1
fact 5 -- 120
fact 10 -- 3628800
Because the loop is tail-recursive, arbitrarily large factorials do not grow the stack.
gcd uses the Euclidean algorithm and handles the case
where either argument is zero:
gcd 54 24 -- 6
gcd 0 17 -- 17
lcm 4 6 -- 12
lcm 54 24 -- 216
A common use is simplifying a fraction before display:
simplify :: (Int, Int) -> (Int, Int)
simplify (n, d) =
let g :: Int = gcd n d
in (n div g, d div g)
simplify (36, 48) -- (3, 4)
fibonacci n returns the nth Fibonacci number (0-indexed:
fibonacci 0 = 0, fibonacci 1 = 1):
fibonacci 10 -- 55
fibonacci 20 -- 6765
For a list of successive Fibonacci values, fibonaccis
from IntSeq (§18.4) is more efficient as it shares
intermediate results. fibonacci is the appropriate choice
when a single value at a given index is required.
NumAggregate defines two multi-parameter classes and a
standalone median function.
The class declarations follow the two-parameter style used throughout the library:
class SumAggregate a, b where {
sum :: a -> b
}
class MeanAggregate a, b where {
mean :: a -> b
}
The provided instances cover integer and floating-point lists:
| Instance | Result type | Notes |
|---|---|---|
SumAggregate [Int], Int |
Int |
Integer sum via foldl |
MeanAggregate [Int], Double |
Double |
Promotes integer sum to Double before dividing |
SumAggregate [Double], Double |
Double |
Floating-point sum; starts accumulator at zero |
MeanAggregate [Double], Double |
Double |
Divides floating-point sum by list length |
Basic descriptive statistics over a sample:
let scores :: [Int] = [72, 85, 91, 68, 78, 95, 60, 83]
let total :: Int = sum scores -- 632
let avg :: Double = mean scores -- 79.0
median is a polymorphic function, not a class method. It
sorts its argument with mergeSort and returns the middle
element for odd-length lists, or the arithmetic mean of the two middle
elements for even-length lists:
median :: [a] -> a
median [3, 1, 4, 1, 5, 9, 2, 6] -- 3 (integer truncation for even-length Int list)
median [3.0, 1.0, 4.0, 1.0, 5.0] -- 3.0
For an [Int] list of even length, the two central
elements are summed and divided with integer division, so the result is
truncated. When exact midpoints are required for integer data,
converting to [Double] first is advisable.
median calls error on an empty list.
IntSeq provides a collection of named infinite lazy
sequences, each stored as a shared Exp [Int] (or
Exp [(Int,Int)]) value created with variable.
All sequences are forced with the #! operator before use,
and standard list combinators such as take,
filter, and mapf apply directly.
Three foundational sequences are pre-built:
positiveInts :: Exp [Int] -- 1, 2, 3, 4, ...
factorials :: Exp [Int] -- 1, 1, 2, 6, 24, 120, ...
fibonaccis :: Exp [Int] -- 0, 1, 1, 2, 3, 5, 8, 13, ...
Taking a prefix from each:
take 5 (#!positiveInts) -- [1, 2, 3, 4, 5]
take 7 (#!factorials) -- [1, 1, 2, 6, 24, 120, 720]
take 8 (#!fibonaccis) -- [0, 1, 1, 2, 3, 5, 8, 13]
Because each sequence is a shared variable, the
underlying list is computed at most once regardless of how many times it
is accessed from different call sites.
The primes sequence is constructed with a lazy Sieve of
Eratosthenes. A sieve helper strips multiples of the
current head from its tail, and iterate sieve applies the
sieve at each step. Taking the head of each successive filtered list —
with mapf hd — yields the prime sequence:
primes :: Exp [Int] -- 2, 3, 5, 7, 11, 13, ...
take 10 (#!primes) -- [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
Two convenience functions query the sequence by value or position:
nthPrime :: Int -> Int
-- Returns the nth prime (1-indexed).
nthPrime 1 -- 2
nthPrime 5 -- 11
nthPrime 10 -- 29
maxPrime :: Int -> Int
-- Returns the largest prime strictly less than n.
maxPrime 20 -- 19
maxPrime 100 -- 97
primeGaps pairs each prime with the gap to the next
prime. maximalPrimeGaps filters this to the subsequence of
pairs where each gap exceeds all previous gaps — the record-setting
gaps:
primeGaps :: Exp [(Int, Int)] -- (prime, gap to next prime)
maximalPrimeGaps :: Exp [(Int, Int)] -- record-setting (prime, gap) pairs
take 6 (#!primeGaps)
-- [(2,1),(3,2),(5,2),(7,4),(11,2),(13,4)]
take 6 (#!maximalPrimeGaps)
-- [(2,1),(3,2),(7,4),(23,6),(89,8),(113,14)]
The first maximal gap is 1 (between 2 and 3). The first gap exceeding that is 2 (between 3 and 5). The first gap exceeding 2 is 4 (between 7 and 11), and so on.
This example combines all four modules. Given a range of integers up to some limit, the code reports the number of primes in that range, their sum and mean, and the largest prime gap encountered.
-- Collect all primes up to (but not including) n.
primesUpTo :: Int -> [Int]
primesUpTo n = takeWhile (\p -> p < n) (#!primes)
-- Summary statistics for primes up to n.
primeStats :: Int -> Void
primeStats n =
let {
ps :: [Int] = primesUpTo n;
count :: Int = length ps;
total :: Int = sum ps;
avg :: Double = mean ps;
med :: Double = median (mapf (:: :: Int -> Double) ps)
} in {
show "Primes below "; show n; show ": "; show count; show "\n";
show "Sum: "; show total; show "\n";
show "Mean: "; show avg; show "\n";
show "Median: "; show med; show "\n"
}
Running primeStats 100 produces:
Primes below 100: 25
Sum: 1060
Mean: 42.4
Median: 41.0
The largest prime gap up to the same limit can be extracted from
maximalPrimeGaps:
largestGapBelow :: Int -> (Int, Int)
largestGapBelow n =
let gaps :: [(Int, Int)] = takeWhile (\g -> fst g < n) (#!maximalPrimeGaps)
in last gaps
largestGapBelow 100 -- (89, 8)
The largest maximal prime gap with first prime below 100 starts at
89, with the next prime (97) being 8 away. A circle whose radius is the
mean prime (42.4) has an area of approximately
pi * 42.4 * 42.4, which can be woven in directly using
MathConst.pi:
let r :: Double = mean (primesUpTo 100) -- 42.4
let area = pi * r * r -- ~5647.0 (using MathConst.pi)
IvoryScript provides native persistence: any IvoryScript value can be written to a binary stream and later restored with full type fidelity, without any external schema, serialisation format, or configuration file. The mechanism is a natural consequence of the language's typed environment model, and the same compiler infrastructure that supports persistence also underpins garbage collection and environment migration. Where other languages require a separate serialisation layer (JSON, XML, Protocol Buffers), IvoryScript values carry all the structural information needed to reconstruct them, and the runtime handles the rest transparently.
The primary user-facing types are TransientStore (a
named key/value store backed by a NameAnyMap) and the
lower-level TransientDataStore a (a generic store whose
root may be any IvoryScript type). For in-process global state that does
not require serialisation, SimpleRoot provides a
pre-allocated NameAnyMap accessible as the global binding
root.
The foundation of persistence is the Env: an isolated
managed memory space that owns the heap storage for all values allocated
within it. Every heap-allocated value — a Name, a
String, a function closure, or any algebraic data type that
contains them — carries a reference to its owning environment alongside
the value itself. Scalar types (Int, Float,
Double, Char) are represented directly by
value and require no environment reference.
This classification is enforced at compile time. During the location
generation pass the compiler tracks heap-allocated values as (value,
environment) pairs: each such value occupies two stack slots, one for
the value and one for its environment pointer. Operands that require an
environment pointer are resolved with representation tag
REPR_ENV_PTR. At each heap allocation site a garbage
collection stub records the stack positions of all live (value, env)
pairs in a frame descriptor, giving the collector precise information to
traverse the live set without a conservative scan.
When a persistent store is serialised, the runtime uses these same per-type methods to walk the environment graph, writing the type tag, the heap contents, and the root value to the output stream. On deserialisation, a fresh environment is allocated and the root value is remapped into the new address space. Because the type structure is embedded in the binary, no external description is required: the stream is self-describing.
Every transferable cell in IvoryScript carries a
CellInfo descriptor that stores four method labels
generated by the compiler. The same four methods are generated for two
distinct categories of cell:
Closure cells, generated within
Lambda::gen. For each lambda that captures free variables a
closure cell is created and the following methods are generated and
stored in the cell's CellInfo:
copyClosure (_copyFnLabel) — maps the
closure's free variables to a target environment via
mapFreeVarToEnv.
extractFreeVars (_extractFnLabel) —
deserialises the closure's free variables from an
InputStream Byte.
insertFreeVars (_insertFnLabel) —
serialises the closure's free variables to an
OutputStream Byte.
markFreeVars_GC (_gcFnLabel) — marks
the closure's free variables for garbage collection.
Because lambdas may be nested, Lambda::gen is called
recursively, and each nested closure receives its own independent set of
methods.
Algebraic data type cells, generated by
Code::genTypeMethods. For each exposed closed type a
TypeDescrInstruction is emitted carrying the entry-point
labels for the corresponding methods:
mapGenPtr :: Ptr a -> Env -> Ptr a — remaps
all heap values within the type to a target environment.
assignByPtr :: Ptr a -> Ptr a -> Void — copies
a value in place from one pointer to another.
showGenPtr :: Ptr a -> Void — displays the
value.
extractGenPtr :: InputStream Byte -> Ptr a —
deserialises a heap value from a byte stream.
insertGenPtr :: OutputStream Byte -> Ptr a -> Void
— serialises a heap value to a byte stream.
markGenPtr :: Ptr a -> Void — marks the heap
value for garbage collection.
These are generated by resolving constrained snippets (for example,
mapGenPtr constrained to type
Ptr a -> Env -> Ptr a) through the standard type
class machinery, then calling Lambda::gen on the resulting
lambda. The serialisation and GC methods are conditionally compiled
under the SERIALISATION and GARBAGE_COLLECTION
build flags respectively.
The result is that every value that can cross an environment boundary
— whether a closure or an algebraic data value — carries with it all the
code needed to copy, serialise, deserialise, and collect it. The
persistence types TransientDataStore and
TransientStore simply invoke these methods via the
StreamInsert and StreamExtract type class
instances.
TransientDataStore a is the primitive persistent
container. It allocates an isolated environment and holds a single root
value of type a. Any IvoryScript type that has
StreamInsert and StreamExtract instances may
serve as the root. The binary format begins with the magic header
I_SDS_v_0001 followed by the serialised environment and
root value.
TransientStore is defined as
TransientDataStore NameAnyMap and is the standard
user-facing persistence type. A NameAnyMap root provides a
named dictionary of heterogeneous Any values, making
TransientStore directly equivalent to the configuration
files common in other languages, but with typed values and no parser.
The key operations are:
TransientStore — constructs a new store with an
isolated environment and a 47-slot hash map.
addBinding — associates a Name key with
an Any value in the store.
removeBinding — removes a binding by name.
(.) — retrieves a value by name (the
Select instance).
insert — serialises the store to a file
path.
extract — deserialises a store from a file
path.
destroy — releases the store's environment and all
heap storage it owns.
The following example demonstrates the full persistence cycle. A lazy
list of prime numbers is computed using the Sieve of Eratosthenes, the
first 100 values are stored under the name 'primes, and the
store is written to a file. On the next run the store is reloaded and
the saved sequence is retrieved without recomputation.
-- Compute and persist the first 100 prime numbers
let {
sieve :: [Int] -> [Int];
sieve l =
case l of {
[] -> [];
p :+ xs -> filter (\x -> x mod p /= 0) (::xs)
};
primes :: [Int];
primes = mapf hd (iterate sieve (listFrom 2 1))
} in
let store = TransientStore() in {
addBinding 'primes (toAny (take 100 primes)) store;
insert "primes.is" store;
destroy store
}
To reload the persisted data in a subsequent session:
let store = extract "primes.is" :: TransientStore in
let savedPrimes = fromAny (store . 'primes) :: [Int] in
show savedPrimes
The type annotation on extract is required because the
return type is polymorphic; it tells the runtime which
StreamExtract instance to use when reading the file. Once
loaded, the value is retrieved by name and cast from Any
back to its original type. No schema file, no parser, and no format
conversion is involved at any stage. The compiler-generated
extractGenPtr and insertGenPtr methods handle
the binary encoding of each constituent type automatically.
For state that must persist across calls within a single process but
does not need to be serialised to disk, SimpleRoot provides
a pre-allocated NameAnyMap in an isolated environment,
accessible via the global binding root.
-- Store an application counter in the global root
addBinding 'counter (toAny (0 :: Int)) root;
-- Retrieve it elsewhere in the same process
let n = fromAny (root . 'counter) :: Int in
show n
Unlike TransientStore, root is a single
shared instance; there is no constructor call and no
destroy. It is well suited to lightweight global
application state — configuration values, caches, or counters — where
the overhead of a separate environment and serialisation round-trip is
unnecessary.
The chapters so far have operated in IvoryScript's statically typed
world, where the type of every value is known at compile time. The
Any type (Chapter 16) provides an escape hatch: any value
of any type can be wrapped in Any and carried through code
that does not need to inspect it. The three modules covered in this
chapter extend that capability by providing class instances for
Any itself, enabling standard operators and methods —
arithmetic, sequence concatenation, dot notation, and binding mutation —
to be applied to Any values with full runtime dispatch to
the correct concrete implementation.
AnyNum provides a Num instance for
Any, dispatching arithmetic to Int or
Double based on the runtime type of the operand.
AnySeq provides a Seq instance, dispatching
sequence concatenation. AnyBindingSet provides both a
Select and a BindingSet instance, so that dot
notation and binding operations work uniformly over an Any
that wraps a PropertySet, a NameAnyMap, or a
TransientStore.
IvoryScript resolves class instances at compile time using type
inference. When the type is Any — a value whose concrete
type is known only at runtime — a second mechanism is needed. The three
modules in this chapter use two language features to provide it.
Instance for *. In IvoryScript an
instance declaration whose type parameter is * defines
behaviour for the Any type. The arithmetic instance in
AnyNum is declared as:
instance Num * where { ... }
Within the implementation, typeOfAny returns the runtime
type tag embedded in the Any wrapper, and a
case expression on that tag selects the concrete operation.
The type names appear as name literals — #::Int,
#::Double, #::String — against which the tag
is matched.
Subordinate instances. The subordinate
qualifier on an instance declaration lowers its priority in instance
selection. A subordinate instance is chosen only when no non-subordinate
instance matches. This is the mechanism by which
AnyBindingSet provides dot notation for Any:
code that holds a PropertySet directly still resolves to
the dedicated Select PropertySet, Any instance; code that
holds the same value through an Any wrapper resolves to the
subordinate Select Any, Any instance, which then unwraps
and redispatches.
AnyNum supplies a Num instance for
*. The addition operator inspects the runtime type of its
left operand and promotes both operands to that type before performing
the operation:
instance Num * where {
(+) anyX anyY =
case typeOfAny anyX of {
#::Int -> Any #!(((::anyX)::Int) + ((::anyY)::Int));
#::Double -> Any #!(((::anyX)::Double) + ((::anyY)::Double))
}
}
The result is always re-wrapped in Any. A heterogeneous
list of numeric values can therefore be reduced with a single
accumulator, provided all elements share a consistent concrete type at
runtime:
let ints :: [Any] = [Any 1, Any 2, Any 3, Any 4, Any 5]
let total :: Any = foldl (+) (Any (0::Int)) ints
-- total wraps Int 15
let doubles :: [Any] = [Any 1.5, Any 2.5, Any 3.0]
let dtotal :: Any = foldl (+) (Any (0.0::Double)) doubles
-- dtotal wraps Double 7.0
The dispatch is on the type of the left operand. Mixing
Int and Double Any values in a
single fold will fail at runtime when the coercion is attempted. For
genuinely mixed numeric data, an explicit conversion pass before folding
is the recommended approach.
AnySeq provides a Seq instance for
*, currently implementing the concatenation operator
(++) for String:
instance Seq * where {
(++) anyX anyY =
case typeOfAny anyX of {
#::String -> Any #!(((::anyX)::String) ++ ((::anyY)::String))
}
}
This allows string fragments carried as Any to be
assembled with the familiar (++) operator, without
unwrapping and re-wrapping at each step:
let parts :: [Any] = [Any "Hello", Any ", ", Any "world", Any "!"]
let msg :: Any = foldl (++) (Any ("" :: String)) parts
-- (::msg)::String = "Hello, world!"
As with AnyNum, the dispatch is on the left operand.
Additional sequence types — lists and vectors — may be added as further
typeOfAny cases in future releases.
AnyBindingSet is the most consequential of the three
modules. It provides two instances for Any that together
allow container-agnostic code to be written against any of the
name/property collection types.
The subordinate Select Any, Any instance unpacks the
concrete container type from the Any wrapper and
redispatches the dot operation to the appropriate typed instance:
subordinate instance Select Any, Any where {
(.) any name = case any of {
#Any (#::PropertySet, Ptr (ps::PropertySet)) -> (.) ps name;
#Any (#::NameAnyMap, Ptr (m::NameAnyMap)) -> (.) m name;
#Any (#::TransientStore, Ptr (ts::TransientStore)) -> (.) ts name
}
}
The pattern #Any (#::PropertySet, Ptr (ps::PropertySet))
matches the constructor of the Any type, extracting the
runtime type tag and dereferencing the pointer to recover the typed
value. The subordinate qualifier ensures that code holding
a PropertySet directly is unaffected — the subordinate
instance is only selected when the compiler sees Any as the
container type.
From the caller's perspective, dot notation simply works:
-- A function that reads a named value from any supported container.
getValue :: Any -> Name -> Any
getValue container name = container.name
let ps :: PropertySet = [x: 10, y: 20]
let any :: Any = Any ps
-- Direct access (Select PropertySet, Any instance):
let v1 :: Any = ps.#x -- Any 10
-- Access through Any (subordinate Select Any, Any instance):
let v2 :: Any = any.#x -- Any 10
Both expressions return the same result; the instance selected differs, but the calling code is identical.
The BindingSet Any, Name, Any instance dispatches
addBinding and removeBinding to
NameAnyMap or TransientStore (mutable
containers; read-only PropertySet is excluded):
instance BindingSet Any, Name, Any where {
addBinding any n v =
case any of {
#Any (#::NameAnyMap, Ptr (m::NameAnyMap)) -> addBinding m n v;
#Any (#::TransientStore, Ptr (ts::TransientStore)) -> addBinding ts n v
};
removeBinding any n =
case any of {
#Any (#::NameAnyMap, Ptr (m::NameAnyMap)) -> removeBinding m n;
#Any (#::TransientStore, Ptr (ts::TransientStore)) -> removeBinding ts n
}
}
This enables middleware or plugin code to mutate a context object
without committing to whether the backing store is a
NameAnyMap or a persistent TransientStore.
A request-handling pipeline often passes a context object through a
chain of handlers, each of which reads and writes named values. The
backing store for that context might be a lightweight
NameAnyMap during unit tests but a
TransientStore in production, where values must survive
garbage collection. With AnyBindingSet, the handler code
need not care which it is.
First, a utility that reads an optional integer from an
Any container, returning a default if the name is
absent:
getInt :: Any -> Name -> Int -> Int
getInt ctx name dflt =
let v :: Any = ctx.name
in if isNothing (v :: Maybe Int)
then dflt
else (::v)::Int
A handler that records an attempt counter and timestamps its last activation, regardless of container type:
recordAttempt :: Any -> Int -> Void
recordAttempt ctx timestamp =
let count :: Int = getInt ctx #attempts 0
in {
addBinding ctx #attempts (Any (count + 1));
addBinding ctx #lastSeen (Any timestamp)
}
The same handler works with either backing store:
-- In-process test context backed by NameAnyMap:
let testCtx :: Any = Any (NameAnyMap [])
recordAttempt testCtx 1000
recordAttempt testCtx 1001
-- testCtx.#attempts evaluates to Any 2
-- Production context backed by TransientStore:
let prodCtx :: Any = Any (TransientStore someEnv)
recordAttempt prodCtx 2000
-- prodCtx.#attempts evaluates to Any 1
A supervisor that processes a list of contexts and reports those that
have exceeded a threshold uses only Any-typed operations
throughout:
flagOverLimit :: [Any] -> Int -> [Any]
flagOverLimit contexts limit =
filter (\ctx -> getInt ctx #attempts 0 > limit) contexts
The combination of AnyNum and AnyBindingSet
makes it straightforward to write aggregate reporting across a
heterogeneous list of contexts. A total attempt count across all
containers:
totalAttempts :: [Any] -> Int
totalAttempts contexts =
foldl (\acc ctx -> acc + getInt ctx #attempts 0) 0 contexts
In this last example the (+) call operates on
Int values directly — getInt already unwraps.
The same accumulator could be written over Any values using
AnyNum if the container mix includes unknown numeric types
that should be summed without unwrapping at each step.