Island (originally standing for Immutable, sequential and non-destructive) is a multiparadigm general-purpose programming language fusing aspects of functional, imperative and object-oriented programming, as well as incorporating various forms of declarative programming (logic, pattern-driven and knowledge-driven).
It aspires to eventually serve as a practical programming tool for real-world applications, and designed with a strong emphasis on simplicity, clarity and aesthetics.
The core of the language is characterized by a sequential, statement-oriented style. However, the language cannot be formally classified as imperative (it has no mutable state), nor as truly functional (it does not promote an idiomatically functional style), or traditionally object-oriented. Instead, it represents a conceptually independent programming approach named stateless-sequential programming.
The language also embeds a statically-typed logic programming subsystem, that significantly deviates from the Prolog tradition - which mostly concentrates on the centrality of relations - and instead encourages tight interconnections between relations, functions and objects as complementary entities.
A new form of declarative programming, called knowledge-driven programming, is introduced. It is currently a part of an ongoing experimental design process and may eventually become fully assimilated into the core of the language, or branch out to a separate, future one, once it becomes sufficiently mature.
[TOC]
- Programming should be made accessible to every person who wishes to learn it. A language designer's role is to try to make the language as friendly and approachable as possible. This doesn't mean that powerful abstractions like generics, higher order methods, or other advanced features should be avoided. Instead, try to make the core of the language beginner-friendly, and more advanced features as transparent and unobtrusive as possible, such that users could gradually become familiar with more and more of them as they develop their skills.
- A programming language doesn't have to look like math or logic formulas. The vast majority of real-world programming tasks have weak, if any, resemblance to abstract mathematics. Most programmers would benefit more from comprehensive, domain-specific language features that simplify common tasks, than minimalistic, math-like syntax that is possibly "mathematically beautiful" but either very difficult to understand or becomes unusably complicated even when confronted with routine real-world problems.
- Many common programming traps can be prevented right at the design stage, either by stricter syntax and semantics, or better tooling and documentation. It is a part of the designer's responsibility to ensure that their language doesn't invite trivial mistakes that frustrate programmers and waste their time.
- Many mundane programming tasks can already be made partially, or fully automated, or for the very least, drastically simplified. Machine-learning based tools like GitHub Copilot are extremely powerful. However, a significant portion of their contribution is to introduce boilerplate or cut-and-paste code that might be better avoided or replaced with unambiguous, universal semantic references that would enable safe and convenient code reuse, of the kind proposed on the chapter discussing knowledge-driven programming.
- For the most part, a programming language should be fully designed before it reaches a full implementation stage. Spend as much time as needed at the design stage (even years, if that's what it takes). Try to cover all possible aspects, including advanced features, until the design matures into a coherent whole.
- A programming language is a work of art! It can be made aesthetically pleasing and enjoyable to use. That doesn't mean this objective is going to be easy to achieve. Beauty requires effort!
- Stateless loops (or alternatively structured loops) are an approach to iterative control flow that attempts to unify the best of both the imperative and functional idioms. Stateless loops are written in a sequential style but are bound by a strict structure that ensures they can be trivially reduced to tail-recursive functional iteration.
- Accumulative generators, as well as accumulative generator comprehensions enhance the declarative expressiveness of the language by abstracting over the notion of the "prior" output of a generator. Named return variables provide a safe and restricted form of mutability by enabling the return value to be "accumulated" in a write-only fashion, analogous to an accumulative generator.
- Partial and gradually constructed objects enable the instantiation of classes with one or more missing fields, such that some of the object's functionality becomes inaccessible. The language models this "partial" instantiation through special types that explicitly specify which of its fields are known and which are not.
- Abstract pattern recognizers are special methods that provide means to specify the recognition and capture of patterns that go well beyond the capabilities of the built-in expression syntax, as well as traditional regular expressions. They can capture arbitrary patterns within any type of input, and be written as polymorphic or higher-order abstractions.
- Class-embedded relations enable logic-programming style relations to be encapsulated within immutable container objects. Relations can be defined using a diverse mixture of programming approaches: rules, predicates, functions and generators.
- Knowledge-driven programming is a form of declarative programming where information entities are given precise semantics via universally referenceable schemas, and computations are synthesized at compile-time, by composing chains of inference rules that derive unknown information entities from known ones.
- All variables and values should be strictly immutable. I.e. both variables (locally and globally scoped) and values (primitive and compound objects, including any of their fields) must maintain their initially assigned value, forever.
- Adapt common imperative constructs like loops, objects and generators, while maintaining strict adherence to full immutability.
- Maintain a strict separation between pure and effect scopes (
function
vsaction
). - Types should be inferred whenever possible. Most programs should include only a few explicit type annotations.
- Allow for strong static analysis (static and strong typing, advanced type inference, flow analysis, generics and type classes, non-nullable, algebraic, refinement and assertion types, compile-time contracts).
- Allow for easy and effective concurrency (lightweight threads, asynchronous generators, automatic parallelization, structured concurrency).
- Maintain a look-and-feel that's not completely "alien"-looking for the average programmer. It should loosely resemble popular imperative languages like Python, TypeScript and C#.
- Aim for maximum simplicity and readability (great syntax does make a difference!). Aim for low-ambiguity, consistent syntax that reads somewhat like plain English (but don't overdo it for its own sake).
- Clean syntax: avoid employing unnecessary punctuation like
;
,:
,{
,}
,(
,)
and cryptic-looking symbols like$
,*
,&
,#
,^
etc. Instead, prefer short words likeand
,or
,it
,here
whenever appropriate. - No noisy double character operators like
++
,&&
,$$
,!!
, they make the code look too cryptic. - No abbreviated keywords like
fun
instead offunction
,int
instead ofinteger
etc. These abbreviations don't save that much on typing and many users find them confusing or alienating. - Keywords should be carefully named: what does
def
mean in Python?cons
in Haskell? Even worse:car
andcdl
in LISP? Put extra effort to find the best possible term that most closely matches the semantics of what it is intended to represent. - Always provide a way to name things: parameters, methods, types, return values, etc. A scalable language aimed towards professional programming tasks must provide naming facilities. It is not an option. Without naming it'd be mostly a "toy" language suitable only for small-to-medium projects.
- No unnecessary extra keywords or characters for the programmer to write, like
then
afterif
in Pascal,where
afterclass
in Haskell, colons (:
) to signal a block start in Python,end
to end blocks in many languages. - Try to keep consistent locality relationships between clauses and symbols. Orientational metaphors like "data flows from the left to the right" should be respected as much as possible.
- Expressive, rather than minimalist, syntax. Don't be afraid to introduce new special keywords if deemed necessary (use context-sensitive awareness to allow identifier names to be used even if they conflict with a keyword that's reserved elsewhere).
Variables are defined using the let
keyword:
let x = 24
let greeting = "Hello"
Types will be inferred using the values provided, but can also be annotated explicitly:
let x: integer = 24
let greeting: string = "Hello"
Variables can only be assigned once:
let x = 24
x = 25 // Error: invalid reassignment of 'x'
Variables are accessible from within both their declared scope and any of its nested scopes:
let x = 1
let greeting = "Hello"
if x > 0
print(greeting)
end
Variables redeclared using an existing name in an inner scope will shadow the ones in the outer scope:
let x = 1
let greeting = "Hello"
if x > 0
let greeting = "Hi"
print(greeting) // Prints "Hi"
end
print(greeting) // Prints "Hello"
Newly declared variable reusing an existing name will replace the previous one if redeclared in the same scope:
let greeting = "Hello"
// This is permitted since the previous binding of 'greeting' is not reachable anymore:
let greeting = "Hi"
print(greeting) // Prints "Hi"
Shadowing and redeclared variables must receive a type consistent with the previous one:
let greeting = "Hello"
if x > 0
let greeting = 25 // Error: shadowing inner variable must be of same type as outer variable
end
let greeting = "Hello"
let greeting = 25 // Error: redeclared variable must be of same type as its previous binding
Initialization can be deferred to a later code position, as long as all branches assign a value:
let greeting: string
if x > 0
greeting = "Hello"
else
greeting = "Hi"
end
If a type is not specified, it would be automatically inferred as long as the assigned values share the same underlying type:
let greeting
if x > 0
greeting = "Hello"
else
greeting = "Hi"
end
// Type inferred as string
This would not work:
let greeting
if x > 0
greeting = "Hello"
else
greeting = 24 // Error: incompatible types
end
Island is a statically typed language, meaning that every one of its values must be associated with a type that can be determined at compile-time.
Island has a few primitive data types:
integer
: arbitrary precision integer number.decimal
: real number (64-bit floating-point).boolean
: Boolean value (true
orfalse
).string
: Unicode character sequence.
Lists are ordered collections of values, in which every element has the same type. They are defined within square brackets and have 1-based indexes:
let numbers: List<integer> = [1, 2, 3, 4]
let listOfLists: List<List<string>> = [["a", "b"], ["c", "d", "e"], ["f"]]
let m1 = numbers[2] // m1 is 2
let m2 = listOfLists[1][2] // m2 is "b"
// Two ways of defining empty lists:
let emptyList: List<Integer> = []
let emptyList = List<Integer> []
Lists can be extended and concatenated using the concatenation operator |
, or a spread expression ([...someList, valueToAppend]
, [...someList, ...SomeOtherList]
):
let l1 = [1, 2]
let l2 = [3, 4, 5]
let l3 = l2 | 6 // l3 is [3, 4, 5, 6]
let l4 = l1 | l2 // l4 is [1, 2, 3, 4, 5, 6]
let l5 = [10, 11, ...l1] // l5 is [10, 11, 1, 2]
let l6 = [...l5, 100] // l6 is [10, 11, 1, 2, 100]
let l7 = [...l1, ...l2] // l7 is [1, 2, 3, 4, 5, 6]
List members can be non-destructively altered using the with
operator:
let l1 = [100, 200, 300, 400]
let l2 = l1 with [1] = -1 // l2 is [99, 200, 300, 400]
let l3 = l2 with [2] =+ 1, [3] -= 1, no [4] // l3 is [99, 201, 299]
with
expressions can be nested in concatenations or spread expressions:
let l1 = [100, 200]
let l2 = [300, 400, 500]
let l3 = (l1 with [1] -= 10) | (l2 with [1] *= 3) | 600 // l3 is [90, 200, 900, 400, 500, 600]
let l4 = [...(l3 with no [1], [2] *= 4), -200, 300] // l4 is [800, 900, 400, 500, 600, -200, 300]
Lists can be sliced:
let l1 = [100, 200, 300, 400]
let l2 = l1[2..4] // l2 is [200, 300, 400]
let l3 = l1[3..] // l3 is [300, 400]
let l3 = l1[..2] // l3 is [100, 200]
Tuples are ordered collections of fixed length in which each member may have a different type:
let myTuple: (string, integer) = ("Hi", 24)
let alteredTuple = myTuple with [2] = 42 // myTuple is ("Hi", 42)
Tuple members can be named:
let myTuple: (greeting: string, someNumber: integer) = ("Hi", 24)
let alteredTuple = myTuple with someNumber = 42
Island doesn't support 1 or 0 arity tuples:
let x = (5) // 'x' gets the plain type 'integer'`, there's no single member tuple in Island
let x = () // syntax error, '()' doesn't mean anything in Island
Dictionaries are unordered collections where keys and values can be of any type:
let fruits: Dictionary<string, integer> = { "apple": 55, "lemon": 95, "orange" : 31, "banana": 4 }
let fruitValue = fruits["orange"] // fruitValue = 31
let isFound1 = "orange" in fruits // isFound1 = true
let isFound2 = "avocado" in fruits // isFound2 = false
let alteredFruits = fruits with ["apple"] = 12, no ["orange"]
let alteredFruits2 = fruits with { "apple": 12, no "orange" } // Equivalent to previous
let extendedFruits1 = alteredFruits | { "mango": 76 }
let extendedFruits2 = { ...alteredFruits, "mango": 76 } // Same but with spread syntax
// Two ways of defining empty dictionaries:
let emptyDictionary: Dictionary<string, integer> = {}
let emptyDictionary = Dictionary<string, integer> {}
Sets are unordered collections containing only unique elements:
let fruits: Set<string> = { "apple", "lemon", "orange", "banana" }
let fruitValue = fruits["orange"] // fruitValue = true
let isFound1 = "orange" in fruits // isFound1 = true
let isFound2 = "avocado" in fruits // isFound2 = false
let alteredFruits = fruits with "berries", no "orange" // no need for { } here
let alteredFruits2 = fruits with { "berries", no "orange" } // Equivalent to previous
let extendedFruits1 = alteredFruits | "mango"
let extendedFruits2 = { ...alteredFruits, "mango" } // Same but with spread syntax
// Two ways of defining empty sets:
let emptySet: Set<string> = {}
let emptySet = Set<string> {}
Unpacking (also called destructuring in other languages) allows extracting elements from data structures into individual variables:
Unpack a list:
function getList() => [1, 2, 3, 4]
let [n1, n2, n3, n4] = getList() // n1 = 1, n2 = 2, n3 = 3, n4 = 4
let [x1, x2, ...] = getList() // n1 = 1, x2 = 2
Unpack a tuple:
function getTuple() => (1, 2, 3)
let (a, b, c) = getTuple() // a = 1, b = 2, c = 3
let (x, _, z) = getTuple() // x = 1, z = 3
let (n, ...) = getTuple() // n = 1
Objects (introduced in a future chapter) are unpacked based on the order of declared members:
class Person
firstName: string
lastName: string
age: integer
end
let person = Person("John", "Doe", 25)
let (f, _, a) = person // f = "John", a = 25
Lists allow for some additional unpacking patterns:
let values = [1, 2, 3, 4, 5]
// Capture head and tail of a list:
let [head, ...tail] = values // head = 1, tail = [2, 3, 4, 5]
// Capture first, second, and all the rest elements of the list:
let [first, second, ...rest] = values // first = 1, second = 2, rest = [3, 4, 5]
// Capture first and last elements:
let [firstElement, ..., lastElement] = values // firstElement = 1, lastElement = 5
The Island language has two main subroutine types: functions and actions.
Functions are "pure", in the sense they do not have side effects (no hidden change of state) and maintain referential transparency (given the same set of arguments, they would always return the same value).
function sum2(x: integer, y: integer) => x + y // Short syntax
function sum3(x: integer, y: integer, z: integer) // Long syntax
return x + y + z
end
let result = sum3(2, 3, 4)
print("Result: {result}")
A predicate is an alternate syntax for a function that returns either true
of false
. Using predicate
instead of function
simply ensures the return type will always be boolean
.
predicate areEqual(x: integer, y: integer) => x == y // Short syntax
predicate areEqual(x: integer, y: integer) // Long syntax
return x == y
end
Actions extend functions and allow for external effects. Actions can return values but can only be called from other actions (or the topmost scope):
action printNameAndAge(name: string, age: integer)
print("Name: {name}, Age: {age}")
return "OK"
end
let status = printNameAndAge("John Smith", 35)
// Prints "Name: John Smith, Age: 35" and returns "OK"
Despite allowing for "impure" operations like writing or reading from a file, actions do not allow for side-effects internal to the program itself, since all variables and values are always guaranteed to be immutable. This doesn't prevent, however, mutable state to be weakly "emulated" through, say, reading and writing to external memory:
action readMutableState() => readFile("myFile.state")
action writeMutableState(data: string)
writeFile("myFile.state", data)
end
let initialData = readMutableState()
writeMutableState(initialData | " changed!")
let modifiedData = readMutableState()
The program can read and write to external mutable state. However, the modified data must be read into a new variable (here modifiedData
) so the internal state of the program (its variables and values) is never altered.
Computed variables are functions that are referenced as if they were plain variables. They are only evaluated when first used:
Short form:
let a = 5
let b = 3
let c => a * b // computed variable 'c' is not evaluated at this point
let x = c + 1 // 'c' is now evaluated to 15 and 'x' gets the value 16
Long, function like, form:
let a = 5
let b = 3
computed c()
let a1 = a + 1
let a2 = b + 1
return a1 * a2
end
We will often collectively refer to functions, actions, and computed variables (and later include class computed fields and indexers) as methods, which is just another name for subroutines.
action printNameAndAge(name = "Anonymous", age = 0)
print("Name: {name}, Age: {age}")
end
printNameAndAge(age = 12) // prints Name: Anonymous, Age: 12
printNameAndAge(_, 12) // prints Name: Anonymous, Age: 12
First-class methods is a language feature allowing functions (and actions) to be used similarly to values. They can be assigned to variables, returned from a secondary method, or passed as an argument:
// This function accepts an argument of type 'function'
function giveMeFunction(f: (integer) => integer)
return f(10) + 1
end
A lexical closure allows a method to capture data from its environment:
// This function returns a value of type 'function'
function outerFunction(x: integer): (integer) => integer
function innerFunction(y: integer)
return x + y // x is captured from the outer scope
end
return innerFunction
end
In general, a function accepting another function as argument is called a higher-order function.
Anonymous methods, also known as lambda expressions are functions or actions that are defined as expressions and are not bound to any identifier.
let sum2 = (n1: integer, n2: integer) => n1 + n2 // Explicit parameter types
let sum2 = (n1, n2) => n1 + n2 // Implicit parameter types
let negative = n: integer => -n // Single explicitly typed parameter
let negative = n => -n // Single implicitly typed parameter
// Since 'print' is an action 'printInQuotes' implicitly becomes a action as well
let printInQuotes = s => print("'{s}'")
Consider this higher-order function that accepts a list of integers and a single-parameter predicate:
function findFirst(items: List<integer>, selectionPredicate: (integer) => boolean)
....
end
let numbers = [1, 2, 3, 4, 5, 6]
Instead of passing a full anonymous predicate as an argument, e.g.:
let evenNumbers = findFirst(numbers, number => number > 3)
The predicate can be shortened to a simpler expression where the it
keyword represents its parameter value:
let evenNumbers = findFirst(numbers, it > 3)
The same can be done for a function expecting a function accepting any type parameter, as long as there is only one:
function transform(items: List<integer>, transformer: (integer) => integer)
....
end
let doubledNumbers = transform(numbers, x => x * 2)
Function call can be simplified to:
let doubledNumbers = transform(numbers, it * 2)
In general, any expression involving the it
keyword, that's assigned to a placeholder where expected type is a single-parameter function, would be interpreted as an anonymous function (the it
keyword is also employed for pattern matching, as we'll see in a future chapter, but the two applications are distinct).
Overloading allows defining multiple methods sharing the same name, but with different parameter types.
Methods can be overloaded:
function f(a: integer, b: integer) => a * 2
function f(a: integer, b: string) => "{a}: {b}"
function f(a: boolean) => not a
Overloads can only differ by parameter types, not return types:
function f(a: integer): integer => a * 2
function f(a: integer): decimal => a * 2.0 // Error
Overloaded methods can be compacted to a shorter form:
function f
(a: integer, b: integer) => a * 2
(a: integer, b: string) => "{a}: {b}"
(a: boolean) => not a
end
(The usefulness of this syntax will become more apparent when refinement and assertion types are introduced in a later chapter, stay tuned!)
Function and action types can be written in several ways:
// Short form (unnamed parameters):
let f: (integer) => string
let f: (integer, boolean) => string
let a: action (string) => (integer, integer)
// Long form (named parameters):
let f: (index: integer) => string
let f: (index: integer, isUnique: boolean) => string
let a: action (message: string) => (integer, integer)
A method's last parameter, typed as a list, can be prefixed with ...
to accept all remaining arguments as its members:
function sum(...numbers: List<integer>) =>
numbers.reduce((sum, number) => sum + number)
let r1 = sum(1, 6, 3, 7) // r1 = 17
function multiplyAllBy(multiplier: integer, ...numbers: List<integer>) =>
numbers.map(number => number * multiplier)
let r2 = multiplyAllBy(10, 1, 2, 3, 4, 5) // r2 = [10, 20, 30, 40, 50]
It is also possible to explicitly pass a list to a rest parameter, using ...
as a suffix instead of a prefix:
function multiplyAllBy(multiplier, ...numbers: List<integer>) =>
numbers.map(number => number * multiplier)
let nums = [1, 2, 3, 4, 5]
let r2 = multiplyAllBy(10, nums...) // r2 = [10, 20, 30, 40, 50]
Sometimes it is more convenient to pass arguments bundled together as a tuple. This is possible by applying the ...
prefix on the tuple passed, and would work even for methods that don't include a rest parameter:
function averageOf3(a: decimal, b: decimal, c: decimal) =>
(a, b, c) / 3.0
let nums = (4.0, 5.0, 7.0)
let average1 = averageOf3(nums...) // pass nums tuple elements to parameters a, b, c
let average2 = averageOf3(2.0, nums[2..3]...) // pass nums tuple elements 2..3 to parameters b, c
The arguments
keywords allows getting a tuple bundling all the arguments passed to the current method:
action printArguments(a: integer, b: integer, c: integer)
print(arguments)
end
printArguments(1, 4, 5) // Prints "(1, 4, 5)"
Partial application allows to transform a given method to a new method with one or more of its arguments bound to fixed values:
action printThreeNumbers(a: integer, b: integer, c: integer)
print(a)
print(b)
print(c)
end
let print5AndTwoNumbers = printTwoNumbers(5, ...)
// print5AndTwoNumbers has the signature print5AndTwoNumbers(b: integer, c: integer)
print5AndTwoNumbers(9, -3) // prints "5 9 -3"
let print5And3AndNumber = printTwoNumbers(5, 3, ...)
// print5And3AndNumber has the signature print5And3AndNumber(c: integer)
print5And3AndNumber(1) // prints "5 3 1"
The with
operator can be used to partially apply any subset of parameters, regardless of their declared order:
let partiallyAppliedAction = printThreeNumbers with b = 11
// partiallyAppliedAction has the signature partiallyAppliedAction(a: integer, c: integer)
partiallyAppliedAction(a = 4, c = 8) // Prints 4, 11, 8
partiallyAppliedAction(c = 6) // Error: an argument for 'a' must be specified
Methods may be partially applied any number of times:
let partiallyAppliedAction2 = partiallyAppliedAction with a = 94
// partiallyAppliedAction2 has the signature partiallyAppliedAction2(c: integer)
partiallyAppliedAction2(c = -4) // Prints 94, 11, -4
If the target method has multiple overloads, the overload can be disambiguated by including a tuple containing the selected types for the unspecified members:
action printThreeNumbers(a: integer, b: integer, c: integer)
print(a)
print(b)
print(c)
end
action printThreeNumbers(a: integer, b: decimal, c: decimal)
print(a)
print("{b.roundToDecimals(3)}")
print("{c.roundToDecimals(3)}")
end
let print5AndTwoNumbers = printThreeNumbers(5, ...) // Error! Ambiguous call. There are two matching overloads!
let print5AndTwoNumbers = printThreeNumbers(5, ...(decimal, decimal)) // OK
The (param1: ParamType, param2: ParamType, ...) => ReturnType
syntax defines abstract method types. An identifier holding a method of this type cannot be directly invoked, only passed around or partially applied:
type PartialIntFunc = (integer, ...) => integer
function applyFirstArgument(f: PartialIntFunc, value: integer): PartialIntFunc
return f(value, ...)
end
function sum(a: integer, b: integer) => a + b
let partiallyAppliedSum = applyFirstArgument(sum, 11)
// Type of partiallyAppliedSum is (b: integer) => integer
partiallyAppliedSum(2) // returns 13
In the above example (integer, ...) => integer
represents a function having any number of parameters where the first parameter must be compatible with integer
and the return type must be compatible with integer
.
Other abstract function type examples:
(...) => any
represents a function of any number of parameters returning a value of any type other thannothing
(theany
type is described in detail in a following chapter).(...) => any?
represents a function of any number of parameters returning a value of any type, includingnothing
.(string, _, integer, _) => any?
represents a function of four parameters where the first parameter must be of typestring
and third of type integer, and any return type (includingnothing
).
Note that due to contravariance of function parameters (described in a future chapter), a function including a parameter of type any?
, e.g (any?) => integer
is not assignable from a function of type (T) => integer
where T != any?
. Unlike any?
, the _
type represents a type that is both a super-type of all types and a subtype of all types, so it can substitute for every possible parameter type).
Up until now, we've made a clear distinction between "pure" and "side-effect" scopes. Functions encompass purely deterministic computations. Actions, on the other hand, allow for external interaction (I/O, file reads and writes) as well as unpredictable operations (e.g. random number generators, timers etc.).
We can refine the distinction further to distinguish between two main classes of side-effects:
- Operations inducing clear impact on the external environment. e.g. writing to a file, moving a robot arm, displaying information on the monitor etc.
- Operations that only observe or query information from the larger computing environment (e.g. reading an open file or screen pixel data) as well as operations that are unpredictable by nature (measuring time, reading data from a hardware random number generator etc).
We can roughly classify the first class as "strong", since they clearly impact the system and the user at large. The second class could be described as "weak" in the sense that it doesn't actually "do" anything - they pose no "risk" aside from requiring a minor amount of computing resources.
The Island language provides an optional way to explicitly mark those weaker scopes, albeit these are open to the developers' own judgment to be marked as such:
view action getCurrentTime()
....
end
view
actions can only call other view actions, as well as functions. They cannot call regular (strong) actions.
The traditional if
- else if
- else
:
function abs(num: integer)
if num == 0
return 0
else if num < 0
return -num
else
return num
end
end
The similar, but more declarative when
- otherwise
syntax, uses =>
to directly return a value from an enclosing method:
function abs(num: integer)
when num == 0 => 0 // Equivalent to 'if num == 0 ..'
when num < 0 => -num // Equivalent to 'else if num < 0 ..'
otherwise => num // Equivalent to 'else ..'
end
The most important differences between when
-otherwise
and if
-else if
-else
are:
- It can only be used in
function
(pure computation) contexts. - It must be the only conditional in its enclosing function (surrounding
let
statements, nested functions and type alias declarations are permitted). - It must include an
otherwise
clause, or provably exhaust all cases (for more, see a following chapter about exhaustiveness checking).
when
-otherwise
clauses can be nested any number of times:
function nestedWhenExample(num: integer)
when num > 0
when num < 5 => "hey"
otherwise => "yo"
otherwise
when num == 0 => "no"
otherwise => "bye"
end
end
when
-otherwise
can also be written as an expression:
let abs = (num: integer) =>
when num > 0: num, when num < 0: -num, otherwise: 0
function gcd(a: integer, b: integer) =>
when b == 0: abs(a), otherwise: gcd(b, a mod b)
This function uses a structure and a when
statement block to convert an integer number on the range 1..999
to words:
function numToWords(num: 1..999): string
let numberNames = { 0: "", 1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six", 7: "seven", 8: "eight", 9: "nine", 10: "ten", 11: "eleven", 12: "thirteen", 13: "thirteen", 14: "fourteen", 15: "fifteen", 16: "sixteen", 17: "seventeen", 18: "eighteen", 19: "nineteen", 20: "twenty", 30: "thirty", 40: "fourty", 50: "fifty", 60: "sixty", 70: "seventy", 80: "eighty", 90: "ninety" }
when num <= 20 or (num < 100 and num mod 10 == 0) =>
numberNames[num]
when num < 100 =>
"{numToWords(num div 10)} {numToWords(num mod 10)} ".trimWhitespace()
otherwise =>
"{numToWords(num div 100)} hundered {numToWords(num mod 100)}".trimWhitespace()
end
Pattern matching is a form of a conditional which inspects one or more target values and their internal component parts. The match
-case
syntax expands over the traditional switch
-case
with more expressive control:
(it
represents the target value, which is num
in this example)
// (long statement form)
function abs1(num: integer)
match num
case 0
return 0
case it < 0
return -num
otherwise
return num
end
end
// (short statement form, '=>' returns a value from the enclosing method)
function abs2(num: integer)
match num
case 0 => 0
case it < 0 => -num
otherwise => num
end
end
// (expression form)
let absOfVal = match num:
case 0 => 0,
case it < 0 => -num,
otherwise => num
Match a tuple:
(any
matches any value, here
contextually matches the corresponding element of the target tuple someTuple
)
function tupleMatch(someTuple: (integer, string, boolean))
match someTuple
case (any, "Hi", any) => "Case 1"
case (here > 1 and here < 5, here.length > 2, any) => "Case 2"
case (here > 1, here[1] == "O", false) => "Case 3"
otherwise => "No match"
end
end
let r1 = matchTuple((1, "Hi", true)) // returns "Case 1"
let r2 = matchTuple((4, "Hello", true)) // returns "Case 2"
let r3 = matchTuple((100, "OK", false)) // returns "Case 3"
let r3 = matchTuple((100, "OK", true)) // returns "No match"
Matched elements can be nested, and can be captured using the let
keyword:
function nestedTupleMatch(someNestedTuple: (integer, string, (boolean, string)))
match someNestedTuple
case (any, "Hi", (true, let name)) => "Case 1, {name}"
case (let num, "Hi", (true, any)) => "Case 2, {num}"
otherwise => "No match"
end
end
Matching over an object (as well as a tuple with named members) introduces its members into the case
scope:
class Person
firstName: string
lastName: string
age: integer
end
function matchObject(person: Person)
match person
case firstName == "James" and lastName.length > 2 => "Case 1"
case firstName[-1] == "e" and age >= 30 and age <= 35 => "Case 2"
otherwise => "No match"
end
end
let r1 = matchObject(new Person("James", "Redd", 21)) // returns "Case 1"
let r2 = matchObject(new Person("Jane", "Doe", 33)) // returns "Case 2"
let r2 = matchObject(new Person("Jane", "Doe", 37)) // returns "No match"
In case the concrete type of the target object may be one of several derived types, the type can be matched as well:
function matchAnimal(animal: Animal)
match animal
case Dog where name == "Lucky" and owner.firstName == "Andy" => "Good dog, Andy!"
case Cat where age > 10 => "Old cat!"
case Horse where height > 180 => "Tall horse!"
otherwise => "Nothing interesting here"
end
end
A secondary syntax uses curly brackets to define a matching structure, which can be nested any amount of times:
function matchAnimal(animal: Animal)
match animal
case Dog { name == "Lucky", owner: { firstName == "Andy" } } => "Good dog, Andy!"
case Cat { age > 10 } => "Old cat!"
case Horse { height > 180 } => "Tall horse!"
otherwise => "Nothing interesting here"
end
end
A third, terser matching syntax uses constructor-like notation, based on the order of declared members (note the varying count of elements in parentheses and the ...
element signifying the rest of the elements are ignored):
function matchAnimal(animal: Animal)
match animal
case Dog("Lucky", Person("Andy", ...), ...) => "Good dog, Andy!"
case Cat(any, here > 10, ...) => "Old cat!"
case Horse(any, any, here > 180) => "Tall horse!"
otherwise => "Nothing interesting here"
end
end
match
can be applied to multiple variables, separated by commas:
function matchAnimalAndPerson(animal: Animal, person: Person)
match animal, person
case Dog where name == "Lucky", Man where age < 18 => "Good dog and young man!"
case Cat where age > 10, Woman where happinessLevel > 0.8 => "Old cat and happy woman!"
case Horse where height > 180, Person where hobby == "Horseriding" =>
"Tall horse and a true horseriding lover!"
otherwise => "Nothing interesting here"
end
end
Matching on multiple Boolean expressions allows to concisely specify a decision table:
function hasPromotions(repeatCustomer: boolean, hasMemberCard: boolean, orderAmount: decimal):
(freeShipping: boolean, discountPercent: decimal)
match repeatCustomer, hasMemberCard, orderAmount >= 100, orderAmount >= 1000
case true, any , false, false => (freeShipping: true, discountPercent: 0)
case any , true, false, false => (freeShipping: true, discountPercent: 0)
case true, true, true , false => (freeShipping: true, discountPercent: 0.05)
case true, true, true , true => (freeShipping: true, discountPercent: 0.10)
otherwise => (freeShipping: false, discountPercent: 0)
end
end
Matching over a list allows for the unpacking syntax to be used as a matching pattern:
function matchList(myList: List<integer>)
match myList
// Match if 'myList' is empty
case []
// Match first element only if it is smaller than 10 and capture it
// with the identifier 'head':
case [let head < 10, ...]
// Match if first element equals 25 and capture the last element as 'last':
case [25, ..., let last]
// Match if first element is greater or equal to 10. Capture the tail
// of the list with the identifier 'tail':
case [here >= 10, let ...tail]
// Match if first element smaller than 0, second not equals to first,
// capture them and the rest with the identifers 'first', 'second', 'rest':
case [let first < 0, let second != first, let ...rest]
// Find the first member of the list that's greater than 5:
case [..., let v > 5, ...]
end
end
Using similar syntax, list-typed method parameters can be pattern-matched as well:
function minimumValue
([]: List<integer>, currentMinimum: integer) => currentMinimum
([head, ...tail]: List<integer>, currentMinimum = infinity) =>
minimumValue(tail, minimum(head, currentMinimum))
end
Many examples in the previous section had the form:
function funcName(param1: ...., param2: ....)
match paramX, ....
case ....
case ....
otherwise ....
end
end
If the outermost scope of a method consists only of a match
statement (excluding any let
s, nested method or type declarations), the match paramX, ....
statement can be omitted and instead integrate directly into the function declaration, by modifying the matched parameters with the match
keyword:
function matchAnimalAndPerson(match animal: Animal, match person: Person)
case Dog where name == "Lucky", Man where age < 18 => "Good dog and young man!"
case Cat where age > 10, Woman where happinessLevel > 0.8 => "Old cat and happy woman!"
case Horse where height > 180, Person where hobby == "Horseriding" =>
"Tall horse and a true horseriding lover!"
otherwise => "Nothing interesting here"
end
Observing the above carefully, it may be noticed the : Animal
and : Person
annotations are not strictly necessary, since the parameter types are being explicitly asserted at every case clause. When this is the case, the type annotations can be omitted and would be inferred by the compiler:
function matchAnimalAndPerson(match animal, match person)
case Dog where name == "Lucky", Man where age < 18 => "Good dog and young man!"
case Cat where age > 10, Woman where happinessLevel > 0.8 => "Old cat and happy woman!"
case Horse where height > 180, Person where hobby == "Horseriding" =>
"Tall horse and a true horseriding lover!"
otherwise => Failure("Invalid match argument types!")
// Note the 'otherwise' clause must fail for the parameter types to be properly inferred!
//
// Having a non-failing 'otherwise' clause would mean that the function
// could possibly accept any type for 'animal' and 'person' and still succeed!
end
The matchAnimalAndPerson
function type is inferred to include several overloads, corresponding to each valid type combination case. Variants with and without assertion types (introduced in a later chapter) are shown:
// Without assertion types, the compiler infers:
function matchAnimalAndPerson(animal: Dog, person: Man)
function matchAnimalAndPerson(animal: Cat, person: Woman)
function matchAnimalAndPerson(animal: Horse, person: Person)
// With assertion types, the compiler infers:
function matchAnimalAndPerson(animal: Dog where name == "Lucky",
person: Man where age < 18)
function matchAnimalAndPerson(animal: Cat where age > 10,
person: Woman where happinessLevel > 0.8)
function matchAnimalAndPerson(animal: Horse where height > 180,
person: Person where hobby == "Horseriding")
Even without the match
modifier, parameter types can still be omitted and asserted in the method body using the is
operator:
function testXY(x, y)
when x is integer
when y is integer
when x > y and x > 0 => 1
when x < y or y == 0 => -1
otherwise => 0
end
when y is decimal
when x > y and x > 0 => 1.0
when x < y or y == 0.0 => -1.0
otherwise => 0.0
end
end
// (The omitted 'otherwise' clauses are interpreted as fail cases)
// Inferred signature:
// function testXY(x: integer, y: integer)
// function testXY(x: integer, y: decimal)
end
The compiler will try to ensure that matched cases include all possible values for a type:
function notExhaustive(someBoolean: boolean)
match someBoolean
case true => "OK!"
// Error! No handling of the case when someBoolean == false
end
end
Sometimes it may be useful to match a value against a single pattern. The matches
operator allows that:
function firstTwoElementsAreConsecutive(values: List<integer>): boolean =>
values matches [let first, here == first + 1, ...]
Loops are control flow mechanisms for specifying code to be executed repeatedly.
In Island, loops are rooted in functional iteration patterns and describe iterative progression in a more declarative manner than in traditional sequential languages.
Island's for
loops maintain immutability for all variables within the scope of each individual iteration of the loop.
This is achieved by:
- Requiring all alterable variables to be declared at the head of the loop.
- Requiring all variable alterations to be conducted within a
continue
orbreak
statement, or within the loop'sadvance
clause. - Requiring variables exposed to the outer scope (after the loop has finished) to be declared with the
out
modifier.
Here's the factorial operation, implemented using a for
loop:
function factorial(num: integer)
for i = 1, out result = 1
if i <= num
// The continue statement defines the next values of 'result' and 'i':
continue result = result * i, i = i + 1
end
end
return result
end
It may seem, at first, like i
and result
are no different than mutable variables, since they are repeatedly modified at every iteration, however, they are not actually mutable because they are not real variables!
Since Island loops act a lot like functions, i
and result
could be thought as being analogous to a function parameter and a return variable. Since the continue
statement is only executed at the moment the loop is ready to proceed to its next iteration, it can be seen as if the loop is "restarting" with a different initial state. This is analogous to a function being repeatedly tail-recursively invoked with a different set of arguments.
For comparison, here is equivalent code, translated to a tail-recursive function (reference code lines are in the comments):
function factorial(num: integer)
// for i = 1, out result = 1
function iterate(i = 1, result = 1)
// if i <= num
if i <= num
// continue result = result * i, i = i + 1
return iterate(result = result * i, i = i + 1)
else
// (implicitly returns)
return result
end
end
// return result
let iterationResult = iterate()
return iterationResult
end
The for
loop syntax also allows to define a continuation condition using a while
clause and a set of predefined alterations using an advance
clause, which are applied after any alterations in a continue
statement within the loop body. The factorial
function can be rewritten to:
function factorial(num: integer)
// In C-style languages this would be written as:
// int result;
// for (int i = 1, result = 1; i <= num; i += 1)
for i = 1, out result = 1 while i <= num advance i += 1
continue result *= i
end
return result
end
Loops can be aborted with the break
keyword, which also allows for alterations of out
variables:
function boundedFactorial(num: integer)
for i = 1, out result = 1 while i <= num advance i += 1
if i < 100
continue result *= i
else
break result = nothing
end
end
return result
end
Here's a binary search implemented using a for
loop and pattern matching, formatted to allow for better readability:
function binarySearch(values: List<integer>, target: integer)
for low = 1, high = values.length, mid = (low + high) div 2
while low <= high
advance mid = (low + high) div 2
match values[mid]
case target
return mid
case it < target
continue low = mid + 1
otherwise
continue high = mid - 1
end
end
return nothing
end
Here's equivalent code translated to a tail-recursive function (original code in comments):
function binarySearch(values: List<integer>, target: integer)
// for low = 1, high = values.length, mid = (low + high) div 2
// ..
// advance mid = (low + high) div 2
function iterate(low = 1, high = values.length, mid = (low + high) div 2)
// while low <= high
if low <= high
match values[mid]
case target
// return mid
return mid
case it < target
// continue low = mid + 1
return iterate(low = mid + 1, high = high)
otherwise
// continue high = mid - 1
return iterate(low = low, high = mid - 1)
end
else
return nothing
end
end
return iterate()
end
Loops can be nested:
function combinationsOf2(min: integer, max: integer)
for x = min, out result: List<(integer, integer)> = []
while x <= max
advance x += 1
for y = min, out row: List<(integer, integer)> = []
while y <= max
advance y += 1
continue row |= [(x, y)]
end
continue result |= row
end
return result
end
print(combinationsOf2(0, 1)) // prints [(0, 0), (0, 1), (1, 0), (1, 1)]
Nested loops are also reasonably straightforward to translate to recursive form:
function combinationsOf2(min: integer, max: integer): List<(integer, integer)>
function iterateX(x = min, result: List<(integer, integer)> = [])
function iterateY(y = min, row: List<(integer, integer)> = [])
when y <= max => iterateY(y = y + 1, row = row | [(x, y)])
otherwise => row
end
when x <= max => iterateX(x = x + 1, result = result | iterateY())
otherwise => result
end
return iterate()
end
However, directly returning from an inner loop would be more tricky to express in recursive form. For example, the following function, which iterates to find a value in a square matrix:
function findFirstInSquareMatrix(matrix: List<List<integer>>, value: integer): (integer, integer)?
for x = 1
while x <= matrix.length
advance x += 1
for y = 1
while y <= matrix.length
advance y += 1
if matrix[x][y] == value
return (x, y)
end
end
end
return nothing
end
Would be roughly translated to:
function findFirstInSquareMatrix(matrix: List<List<integer>>, value: integer): (integer, integer)?
function iterateX(x = 1): (integer, integer)?
function iterateY(y = 1): (integer, integer)?
when y <= matrix.length
when matrix[x][y] == value => (x, y)
otherwise => iterateY(y = y + 1)
otherwise => nothing
end
let result = iterateY()
when result is not nothing => result
when x < matrix.length => iterateX(x = x + 1)
otherwise => nothing
end
return iterateX()
end
A local method may capture a variable declared within a loop body, however, such a function cannot be passed outside of the loop scope:
function invalidReturnedFunction(): (integer) => integer
for i = 1 advance i += 1
let multiplyBy = (m: integer) => i * m // This is okay
let x = multiplyBy(3) // This is also okay
return localFunction // But this is invalid
end
end
(multiplyBy
is considered referentially transparent, if seen from within the scope of each loop iteration)
Similarly for deferred initialization:
let x
for i = 1 advance i += 1
x = i * 2 // This is invalid
end
It is common in continue
and break
statements to use with
to alter one or more of the iteration variables, for example:
for someTuple = (a: 1, b: 2)
continue someTuple = someTuple with (a += 2, b -= 5)
end
Instead of writing someTuple = someTuple with ...
, the with
operator can be shortened to:
for someTuple = (a: 1, b: 2)
continue someTuple with (a += 2, b -= 5)
end
A stream method (also called a generator) is a form of a subroutine enabling the incremental production of a sequence of values. Calling a stream method returns a stream object (also called an iterator), which is an object allowing step-wise consumption of the values produced by the stream method.
Stream methods produce values using the yield
statement. Streams can be consumed within for
loops using the x in stream
clause:
stream naturalNumbers()
for i = 1 advance i += 1
yield i
end
end
for i in naturalNumbers() // Loops forever
print(i)
end
// Prints 1, 2, 3, 4, 5, ....
Multiple streams may be consumed within a single for
loop. At every iteration, each stream is evaluated once by its order of declaration. The loop will terminate whenever any one of the streams end:
stream multiplesOfTwo()
for i in 1..infinity
if i mod 2 == 0
yield i
end
end
end
for i in 1..100, m in multiplesOfTwo() // This will repeat 100 times
print("Num: {i}, Multiple: {m}")
end
// Prints:
// "Num: 1, Multiple: 2"
// "Num: 2, Multiple: 4"
// "Num: 3, Multiple: 6"
// ....
// "Num: 100, Multiple: 200"
A stream object is a stateless object, of the form:
class Stream<T>
value: T? // The '?' means 'value' may be of type 'nothing'
ended: boolean
function next(): Stream<T>
end
Calling next()
returns a new stream object, and would cause the previous one to be disposed (any attempt to access it would cause a runtime error).
stream numsInRange(min: integer, max: integer)
for i in min..max
yield i
end
end
let step0 = numsInRange(1, 3)
let step1 = step0.next() // The stream requires an initial next() call to produce its first value.
print(step1.value) // Prints 1
let step2 = step1.next()
print(step2.value) // Prints 2
print(step1.value) // Error! step1 object has been disposed when step1.next() was called.
The Stream<T>.toList()
expansion method accumulates all successive elements of the stream into a list.
Using streams and toList()
, a previous example - which enumerates all combinations of two integers in a given range - can now be simplified further:
function combinationsOf2(min: integer, max: integer)
stream enumerateCombinations()
for x in min..max
for y in min..max
yield (x, y)
end
end
end
return enumerateCombinations().toList()
end
(Note calling toList()
on an infinite stream would never terminate and may rapidly consume all machine memory!).
A stream can yield the content of another stream using yield stream
:
stream a()
for x in 1..5
yield x
end
end
stream b()
for x in 9..12
yield x
end
end
stream c()
yield stream a // no need for parentheses if stream method has no parameters
yield stream b
end
for value in c
print(value)
end
// prints 1, 2, 3, 4, 5, 9, 10, 11, 12
Oftentimes it is useful to be able to make small, incremental alterations to a value, such as when piecing out a string, building an object, sorting a list, or calculating a complex math formula.
Imagine you wanted to create a function that builds a URL string from an object specifying its parts. In an imperative language, that would be easy. You could write something like:
function urlTostring(url: Url): string
var urlstring = "" // There's no 'var' in Island - this is only meant for illustration
if url.isSecure
urlstring |= "https://"
else
urlstring |= "http://"
end
urlstring |= url.hostname
if url.port is not nothing
urlstring |= ":{url.port}"
end
// ....
return urlstring
end
Unfortunately Island doesn't support the var
keyword, so how could we approach this?
One option would be to give a different name to urlstring
every time we want to change it:
function urlTostring(url: Url): string
let urlstring1
if url.isSecure
urlstring1 = "https://"
else
urlstring1 = "http://"
end
let urlstring2 = urlstring1 | url.hostname
let urlstring3
if url.port is not nothing
urlstring3 = urlstring2 | ":{url.port}"
end
// ....
return urlstringX
end
That looks, well, pretty bad. Couldn't we do better than that?
OK, so how about using an inner stream method?
function urlTostring(url: Url): string
stream buildstring()
if url.isSecure
yield "https://"
else
yield "http://"
end
yield url.hostname
if url.port is not nothing
yield ":{url.port}"
end
// ....
end
return buildstring().JoinStrings("")
end
Looks a bit better! but a solution like this will only work for simple list concatenations, we want something more flexible that would generalize over to arbitrary computations of a similar nature.
The general pattern seems to be that every yielded value "builds" over the previous one. So maybe the yield
statement could provide a "placeholder" variable that would represent the previous value? something like:
function urlTostring(url: Url): string
stream buildstring()
yield initial ""
if url.isSecure
yield prior | "https://"
else
yield prior | "http://"
end
yield prior | url.hostname
if url.port is not nothing
yield prior | ":{url.port}"
end
// ....
end
return buildstring().last()
end
We'll call this an accumulative stream.
However, this still doesn't look pretty (frankly, even more verbose than the previous solution), we've got that nested function, and all those repeated yield prior ..
s. Also, do we really need to yield all those intermediate results? There must be a simpler way.
We can take this pattern and make it more implicit by introducing the notion of a named return variable:
function urlTostring(url: Url): (urlstring: string = "")
if url.isSecure
urlstring = urlstring | "https://"
else
urlstring = urlstring | "http://"
end
urlstring = urlstring | url.hostname
if url.port is not nothing
urlstring = urlstring | ":{url.port}"
end
// ....
// No need to return anything, since the return variable(s) have been explicitly declared
end
But wait a minute! you said Island didn't have var
, and here you're using urlstring
like it was mutable? what's going on?
The answer is that urlstring
is not really mutable because it is not a real variable! It is a placeholder for a yield
pattern representing incremental computations. The pattern:
returnVariable = <expression possibly including returnVariable>
Is equivalent to:
yield <expression possibly including prior>
And:
: (returnValue: T = initialValue)
Is equivalent to:
yield initial initialValue
And finally, returning from the function implicitly returns the last value yielded.
Now we can now allow the resultVariable = resultVariable | something
pattern to be shortened to resultVariable |= something
:
function urlTostring(url: Url): (urlstring: string = "")
if url.isSecure
urlstring |= "https://"
else
urlstring |= "http://"
end
urlstring |= url.hostname
if url.port is not nothing
urlstring |= ":{url.port}"
end
// ....
// No need to return anything, urlstring is returned by default
end
And here you go! looks like reasonable code.
Now let's try to define more precisely what exactly are the constraints on a named return variable:
A named return variable is a special variable that:
- Can be reassigned multiple times, including from within conditionals and loop bodies.
- Can be passed to any method (including to an action or an object initializer).
- Can only be read from within an expression that is assigned back to itself.
- Cannot be accessed from within a nested function or action.
Accumulative patterns also allow for several optimizations to take place, for example when shuffling a list:
function shuffle(values: List<integer>, seed: integer): (suffledValues = values)
for i in 1..values.length, rand in Random(seed)
let randIndex = rand.range(i + 1, values.length)
suffledValues = suffledValues with
[i] = suffledValues[randIndex]
[randIndex] = suffledValues[i]
end
end
end
Since suffledValues
can only be read for the purpose of modifying itself, there is no need for the with
operation to create a new copy of the list every time it is performed, and the intermediate results can be written in-place.
With a named return variable, a previous example can be made simpler:
function combinationsOf2(min: integer, max: integer): (result: List<(integer, integer)> = [])
for x in min..max
for y in min..max
result |= (x, y)
end
end
end
List comprehensions allow building a list from for
-like expressions.
let l = [(for i in 1..5) => i ** 2]
// l = [1, 4, 9, 16, 25]
A filtering predicate can also be added, using the where
clause:
let l = [(for i in 1..5 where i mod 2 == 0) => i ** 2]
// l = [4, 16]
Multiple streams may be consumed concurrently, and each may have a different where
clause:
let l = [(for i in 1..6 where i mod 2 == 0, j in 1..9 where j mod 3 == 0) => i + j]
// l = [5, 10, 15]
Like in for
loops, consuming streams of different lengths would end whenever the shorter of them ends:
let l = [(for i in "a".."c", j in 1..6) => (i, j)]
// l = [("a", 1), ("b", 2), ("c", 3)]
Stream expressions can be nested, by successively passing the =>
symbol through multiple streams. This allows to further simplify the implementation for the combination enumeration example mentioned earlier:
function combinationsOf2(min: integer, max: integer) =>
[(for x in min..max) => (for y in min..max) => (x, y)]
print(combinationsOf2(0, 1)) // prints [(0, 0), (0, 1), (1, 0), (1, 1)]
Similarly to for
loops, comprehensions may introduce variables and include while
and advance
clauses:
let sumsOfNaturalsUpTo5 = [(for i in 1..5, sum = 1 advance sum += i) => sum] // [1, 3, 6, 10, 15]
Comprehensions may be accumulative, and make use of the initial
and prior
keywords:
let sumsOfNaturalsUpTo5 = [(for initial = 1, i in 2..5) => prior + i] // [1, 3, 6, 10, 15]
Stream comprehensions use syntax identical to stream comprehensions, excluding the brackets, and create a stream method instead:
let squaresOfEvenNumbers = (for i in 1..infinity where i mod 2 == 0) => i ** 2
// The type of squaresOfEvenNumbers is 'stream () => integer'
// (Note: since squaresOfEvenNumbers is a stream method with no parameters,
// the for..in syntax allows it to be optionally invoked without the parentheses '()')
for n in squaresOfEvenNumbers
print(n) // prints 4, 16, 36, 64, ....
end
Now the factorial example can be simplified to a simple two line function:
function factorial(num: 0..infinity)
let facSequence = (for initial = 1, i in 1..num) => prior * i
return facSequence().last
end
Here's the infamous "Fizz-Buzz" problem implemented using a stream comprehension and a when
expression:
predicate divides(x, y) => x mod y == 0
stream fizzBuzz() =
(for n in 1..infinity) =>
when divides(n, 15): "FizzBuzz",
when divides(n, 3): "Fizz",
when divides(n, 5): "Buzz",
otherwise: "{i}"
Here's a slightly more more efficient version, this time using a match expression instead:
stream fizzBuzz() =
(for n in 1..infinity) =>
match divides(n, 3), divides(n, 5):
case true, true: "FizzBuzz",
case true, false: "Fizz",
case false, true: "Buzz",
otherwise: "{i}"
Here's a simple bounded sieve of Eratosthenes using a for
loop and a list comprehension:
stream primesTo(max: integer)
for n in 2..max, nonprimes: Set<integer> = {}
if not n in nonprimes
yield n
let multiplesOfN = [(for initial = n ** 2 while prior < max) => prior + n]
continue nonprimes |= multiplesOfN
end
end
end
Wouldn't it be nice to make an infinite-length (unbounded) stream which enumerates all prime numbers? This can be achieved by, for each prime encountered, storing a stream enumerating its multiples, and at each step incrementally advancing the collected streams as needed:
stream primes()
// Generates the integer sequence n^2, n^2 + n, n^2 + n + n, n^2 + n + n + n, ...
stream multiplesOf(n: integer) =
(for initial = n ** 2) => prior + n
// For n in 2..infinity
// At each step, advance each stream until a value greater than or equal to n is reached
for n in 2..infinity, nonprimeStreams: List<Stream<integer>> = []
advance nonprimeStreams = [(for s in nonprimeStreams) => s.skipUntil(it >= n)]
// Search the stream object collection for a stream that reached exactly n
if not nonprimeStreams.includes(it.value == n)
// If none found then n is a prime - yield it
yield n
// Create a new (infinite-length) stream for the multiples of the prime just found
// and append it to the collection
continue nonprimeStreams |= multiplesOf(n)
end
end
end
List and stream comprehensions allow to easily implement common higher-order sequence processing functions like, map
, flatMap
, filter
, reduce
and zip
, evaluated either eagerly (through list comprehensions) or lazily (through stream comprehensions). The generality of these operations requires type parameters, which would be introduced in a future chapter:
stream map<E, R>(valueStream: Stream<E>, transform: E => R) =
(for e in valueStream) => transform(e)
stream flatMap<E, R>(valueStreams: Stream<Stream<E>>, transform: E => R) =
(for e in ((for i in valueStreams) => i)) => transform(e)
stream filter<E>(valueStream: Stream<E>, filteringPredicate: E => boolean) =
(for e in valueStream where filteringPredicate(e)) => e
stream zip<E1, E2>(stream1: Stream<E1>, stream2: Stream<E2>) =
(for e1 in stream1, e2 in stream2) => (e1, e2)
stream accumulate<E, R>(valueStream: Stream<E>, accumulator: (R, E) => R, initialResult: R) =
(for initial = initialResult, e in valueStream) => accumulator(e, prior)
stream reduce<E, R>(valueStream: Stream<E>, accumulator: (R, E) => R, initialResult: R) =>
accumulate(valueStream, accumulator, initialResult).last
Note the use of =
and not =>
when defining a method using a stream comprehension. Stream comprehensions are expressions that evaluate to stream methods, not values. We don't want a call to map()
to return a method, but a stream object. Using the equals operation binds the parameters of the declared function into the comprehension, composing a new function, which returns a stream object when called.
Here's a simple recursive quicksort implementation using pattern matching and list comprehensions:
function quicksort(match items: List<integer>)
case [] => []
otherwise
let pivot = items[items.length div 2]
let leftItems = quicksort([(for x in items where x < pivot) => x])
let rightItems = quicksort([(for x in items where x >= pivot) => x])
return leftItems | rightItems
end
Notice the two list comprehensions of the pattern [(for x in items where <some condition>) => x]
employ a single auxiliary variable x
only to represent the value selected by the loop. Using an abbreviated syntax, involving the it
keyword, quicksort
can be simplified to:
function quicksort(match items: List<integer>)
case [] => []
otherwise
let pivot = items[items.length div 2]
let leftItems = quicksort([items where it < pivot])
let rightItems = quicksort([items where it >= pivot])
return leftItems | rightItems
end
The expression:
[items where it < pivot]
is an example of a query comprehension, which is a simpler, but more limited form of a list or stream comprehension.
Let's see how it works:
items
is a list of integers, which can also be interpreted as a stream. The stream is then filtered by a predicate specified by a where
clause. The predicate is written in an abbreviated form using the it
keyword. Finally, like in stream comprehensions, the surrounding brackets indicate the resulting stream should be converted to a list.
It is equivalent to:
items.filter(item => item < pivot).toList()
Or using the abbreviated it
syntax:
items.filter(it < pivot).toList()
Since we've now got a short and easy-to-read syntax for filter
. Wouldn't it be nice to have one for map
as well?
Say I wanted to map a list of integers to their doubled values. With a list comprehension I can write:
let numbers = [1, 2, 3, 4, 5]
let numbersDoubled = [(for n in numbers) => n * 2]
but with a query comprehension I'll write:
let numbersDoubled = [numbers select it * 2]
where
and a select
can be combined. This will filter for the even numbers, and then double the result:
let evenNumbersDoubled = [numbers where it mod 2 == 0 select it * 2]
Which is equivalent to:
items.filter(n => n mod 2 == 0).map(n => n * 2).toList()
or
items.filter(it mod 2 == 0).map(it * 2).toList()
Like comprehensions, for
-loops can also be converted to stand-alone methods:
So instead of:
function factorial(num: integer)
for i in 1..num out result = 1
continue result *= i
end
return result
end
We can write:
function factorial(num: integer) = // Note the equals operator
for i in 1..num out result = 1
continue result *= i
end
Loop variables marked with out
would be treated as return values. If there are more than one, they would be returned as a tuple, arranged by their order of declaration.
A class is a template allowing the creation of object structures, which are also simply called objects:
class Person
firstName: string
lastName: string
age: integer
end
// Shorter syntax:
class Person with firstName: string, lastName: string, age: integer
The with
operator can be used to create a new object using the Person
class as its template:
let andy = Person with firstName = "Andy", lastName = "Williams", Age = 19
A class may also be invoked like a constructor method, and would return a new object, whose fields receive the arguments passed to it, by their order of declaration:
let andy = Person("Andy", "Williams", 19)
One or more class fields may have default values:
class Person
firstName = "Anonymous"
lastName = ""
age: integer
end
In addition to fields, classes may include functions, actions, computed fields, indexers and default streams:
class Person
// Fields
firstName: string
lastName: string
age: integer
// Function (long syntax)
function agePlusSomething(something: integer)
return age + something
end
// Function (short syntax)
agePlusSomething(something: integer) => age + something
// Action (long syntax)
action printDescription()
print(description)
end
// Action (short syntax)
action printDescription() => print(description)
// Computed field (long syntax)
computed description()
return "{firstName} {lastName}, of {age} years of age"
end
// Computed field (short syntax)
description => "{firstName} {lastName}, of {age} years of age"
// Indexer
this[match index]
case 0 => firstName
case 1 => lastName
end
// Default stream
stream this()
yield firstName
yield lastName
end
end
Member usage examples:
let p = Person("Catherine", "Jones", 41)
p.agePlusSomething(5) // returns 46
p.printDescription() // prints "Catherine Jones, of 41 years of age"
p.description // returns "Catherine Jones, of 41 years of age"
p[1] // returns "Jones"
for m in p
print(m) // prints "Catherine" "Jones"
end
A class method can use the this
object and the with
operator to create a altered copy of its containing object:
class Person
firstName: string
lastName: string
age: integer
getOlderPerson(yearsToAdd: integer) =>
this with age += yearsToAdd
end
Alterations can be applied deeply into the object hierarchy:
class Group
members: List<Person>
sharedInterest: string
end
let golfers = Group with
members = [Person("John", "Smith", 24), Person("Jane", "Doe", 42)],
sharedInterest = "Golf"
let deeplyAlteredGroup = golfers with
members[1].firstName = "Michael",
members[2].age = 45
The with
operator also allows for merging syntax on objects, the following is equivalent:
let deeplyAlteredGroup = golfers with { members: [{ firstName: "Michael" }, { age: 45 }] }
Extension allows a new type to be built on the basis of an existing type.
Classes can only extend a single base class:
class Person
firstName: string
lastName: string
age: integer
end
class PersonWithHeight extends Person
height: decimal
end
// Shorter syntax:
class PersonWithHeight extends Person with height: decimal
Extending class can override one or more members of its base class:
class Person
firstName: string
lastName: string
age: integer
description => "{firstName} {lastName}, of {age} years of age"
action printDescription() => print(description)
end
class PersonWithHeight extends Person
height: decimal
description => "{base.description} and {height} meters tall"
end
let james = PersonWithHeight("James", "Taylor", 19, 1.8)
james.printDescription() // Prints "James Taylor, of 19 years of age and 1.8 meters tall"
(james as Person).printDescription() // Prints "James Taylor, of 19 years of age"
If a class does not provide a body to one or more of its methods, it cannot be instantiated, however it can be used as a base class to a secondary class. This kind of class is called an abstract class:
abstract class AbstractPerson
firstName: string
lastName: string
age: integer
computed description()
action printDescription()
end
class ConcretePerson extends AbstractPerson
description => "{firstName} {lastName}, of {age} years of age"
action printDescription() => print(description)
end
A feature (roughly resembling a mixin in other languages) is an abstract class-like type specifying a set of required members. Classes may extend any number of features. A feature may optionally provide default implementations or values for its members:
feature Labeled
label: string
action printLabel() => print(label)
end
class Employee extends Labeled
fullName: string
end
action processLabeledObject(obj: Labeled)
obj.printLabel()
end
let someEmployee = Employee with fullName = "John Doe", label = "abc123"
processLabeledObject() // prints "abc123"
Note that since the label
field doesn't have a default value it must be explicitly assigned when the object is created. However, in order to initialize a new instance of Employee
using the constructor syntax (Employee(....)
) the value for the label
field must be passed using a named argument, since it doesn't have an inherent order in relation to Employee
's own fields:
let someEmployee = Employee("John Doe", label = "abc123")
Default implementations will be overridden if they are implemented by the extending type:
class Employee extends Labeled
fullName: string
action printLabel() => print("Employee: {label}")
end
processLabeledObject(Employee("John Doe", label = "abc123")) // prints "Employee: abc123"
If several extended features have methods with conflicting names or signatures, the overriding method declaration may specify to which feature it relates to:
feature Runner
action start(speed: decimal)
end
feature Processor
action start(speed: decimal)
end
class Example extends Runner, Processor
name: string
action Runner.start(speed: decimal)
....
end
action Processor.start(speed: decimal)
....
end
end
When a an method is overridden in this way, it is not possible to directly call it through the extending class:
let test = Example("New Example")
test.start(15) // Which of the two actions should be invoked?
Instead, a specific implementation can be invoked by casting the object to one of the feature types:
(test as Processor).start(15)
When a similar conflict occurs between two or more fields, it can be resolved in an analogous way:
feature FeatureA
index: integer
end
feature FeatureB
index: string
end
class ExampleClass extends FeatureA, FeatureB
name: string
// 'Example' has no field named 'index', instead it has:
FeatureA.index = 10 // default value for FeatureA field
FeatureB.index = "10" // default value for FeatureB field
end
Initialization must prefix the field name with the feature it relates to:
let test = ExampleClass("Something", FeatureA.index = 20, FeatureB.index = "20")
A secondary approach is to introduce an additional field and define the two feature's fields as computed fields that "route" back to it:
class ExampleClass extends FeatureA, FeatureB
name: string
index: integer = 10
FeatureA.index => index
FeatureB.index => index.toString()
end
Now the object can be instantiated normally using the constructor syntax:
let test = ExampleClass("Something", 20)
Features may extend any number of other features. A feature may override one or more of its base feature's members. When a feature extends two or more features with conflicting member names, the resolution can be done with a similar approach to the one described above:
feature Runner
action start(speed: decimal)
....
end
end
feature Processor
action start(speed: decimal)
....
end
end
feature ExampleFeature extends Runner, Processor
name: string
action Runner.start(speed: decimal)
....
end
action Processor.start(speed: decimal)
....
end
end
In this case, this means that ExampleFeature
doesn't have its own start
method. Instead it modified the default implementations for members of the features it inherited from such that a class which extends ExampleFeature
will have different default behaviors when it is cast to Runner
or Processor
:
class ExampleClass extends ExampleFeature
let x = ExampleClass("Test")
(x as Runner).start(13)
// The invoked implementation of 'start' is the one overriden by 'ExampleFeature',
// not the original one specified in 'Runner'.
A structure is a simple object-like container, analogous to a dictionary, but with a fixed set of predefined fields and member types:
let myStructure = { url: "https://example.com", speed: 9000 }
Anonymous structure types are types that describe a set of required object fields. For example, here's a function that would accept any object-like entity with the fields url: string
and speed: integer
function giveMeSomeStructure(s: { url: string, speed: integer })
....
end
giveMeSomeStructure(myStructure)
An anonymous structure type is different from dictionary or a tuple with named members by the fact that it can structurally match any class or feature with compatible member names and types. This kind of subtyping may be described as a weak form of structural typing:
class SomeClass
name: string
url: string
speed: integer
weight: decimal
....
end
let instanceOfSomeClass = SomeClass("SomeName", "https://example.com", 10000, 125.5)
giveMeSomeStructure(instanceOfSomeClass)
// This call compiles since SomeClass is assignable to the anonymous structure type
// { url: string, speed: integer }
Structure fields can be added and removed in an ad-hoc fashion, such that its type signature changes accordingly:
let s1 = { a: 1, b: false } // type of s1 is { a: integer, b: boolean }
let s2 = s1 with new c = "Hi" // type of s2 is { a: integer, b: boolean, c: string }
let s3 = s2 with no b // type of s3 is { a: integer, c: string }
// Note that if the assigned values are constants, like in the above example,
// the inferred types will be narrowed further via refinement typing:
// For example, the type of s1 will actually be narrowed to { a: 1, b: false }
// where '1' and 'false' are literal types.
This behavior doesn't imply dynamic typing. Whenever a value is altered in this way, its new type is statically inferred during compile-time. There is no runtime type management involved.
Structures may nest other structures. The nested structures may be similarly modified:
let s1 = { a: 1, b: { c: "Hello", d: false } }
// Type of s1 is { a: integer, b: { c: string, d: boolean } }
let s2 = s1 with new b.e = 3.14
// Type of s2 is { a: integer, b: { c: string, d: boolean, e: decimal } }
let s3 = s2 with no b.c
// Type of s3 is { a: integer, b: { d: boolean, e: decimal } }
Structure fields may be set to computed values:
let s1 = { a: 1, b: false, c => when b is true: a + 1, otherwise: a - 1 }
The computed (or "lazy") nature of c
is not reflected in the type - the type of s1
is nonetheless inferred as { a: integer, b: boolean, c: integer }
. Since structures are always read-only, it doesn't really matter whether a value is memorized or computed via a function.
Type companion objects (which may also be described as dedicated static member structures) are special objects that may share the same name as a class (or act as singleton objects). They are used to provide data and functionality that are not associated with a particular instance of a class:
class Vector2
x: decimal
y: decimal
end
object Vector2
zero = Vector2(0, 0)
distance(a: Vector2, b: Vector2) =>
sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
end
let z = Vector2.zero // z = (0, 0)
let d = Vector2.distance(Vector2(1, 3), Vector2(-5, 9)) // d = 8.485
Operators are functions using symbols as a calling mechanism. Operators can only be defined on type companions:
class Point
x: decimal
y: decimal
object Point
operator +(a: Point, b: Point) => Point(a.x + b.x, a.y + b.y)
operator -(a: Point, b: Point) => Point(a.x - b.x, a.y - b.y)
operator -(a: Point) => Point(-a.x, -b.y)
end
let sumOfPoints = Point(1, 4) + Point(-2, 5)
// sumOfPoints equals Point(-1, 9)
let differenceOfPoints = Point(7, 4) - Point(2, 3)
// differenceOfPoints equals Point(5, 1)
A type feature (also known as a type class in other languages) is a feature consisting only of type object members and operators and which consequently abstracts over different types (rather than instances of types). For example, a feature abstracting over all types supporting the ==
operator would be defined as:
type feature Equatable<T>
operator ==(x: T, y: T): boolean
end
In the following example, both Point
and Person
implement the Equatable
feature:
class Point
x: decimal
y: decimal
end
object Point extends Equatable<Point>
operator ==(a: Point, b: Point) =>
(a.x, a.y) == (b.x, b.y)
end
class Person
fullName: string
age: integer
end
object Person extends Equatable<Person>
operator ==(a: Person, b: Person) =>
(a.fullName, a.age) == (b.fullName, b.age)
end
function areEqual<T extends Equatable<T>>(a: T, b: T) => a == b
print(areEqual(Point(1, 2), Point(1, 2))) // prints "true"
print(areEqual(Person("John Doe", 24), Person("John Doe", 24))) // prints "true"
print(areEqual(Point(1, 2), Person("John Doe", 24))) // Error: couldn't find a type for T
Here's a monoid type feature (representing an associative binary operation with identity element):
type feature Monoid
operator +(x: this, y: this): this
identity: this
end
(Note the this
type would contextually refer to the concrete type of the object implementing the feature, not to the Monoid
abstraction)
Multiple type features used as constraints:
function propertyOf3Sums<T extends Monoid and Equatable>(a: T, b: T, c: T): boolean =>
((a + b) == T.identity) and ((b + c) != T.identity)
Using a type alias and a join type we can define a type that combines both instance and type members:
feature Person
firstName: string
lastName: string
end
type feature Equatable
operator ==(x: this, y: this): boolean
end
type EquatablePerson = Equatable and Person
Expansion introduces new members to an existing class, feature or type object, and can be performed any number of times:
class Person
firstName: string
lastName: string
age: integer
end
class expansion Person
fullname => "{firstName} {lastName}"
end
object Person
anonymous = Person("Anonymous", "", 0)
haveSameFirstName(p1: Person, p2: Person) => p1.firstName == p2.firstName
end
object expansion Person
operator ==(a: Person, b: Person) =>
(a.firstName, a.lastName, a.age) == (b.firstName, b.lastName, b.age)
end
Expansion can add any member kind apart from instance fields (though it can add type object fields):
class Person
firstName: string
lastName: string
age: integer
end
class expansion Person
favoriteNumber: integer // ERROR: This will not work
end
object Person
andy = Person("Andy", "Jones", 22)
end
object expansion Person
angela = Person("Anegla", "Jones", 25) // But this will work
end
object expansion Person
ben = Person("Ben", "Smith", 23) // And so will this
end
Class expansions can extend features, as well as override their default implementations:
class Employee
fullName: string
end
feature Labeled
label: string
action printLabel() => print(label)
end
class expansion Employee extends Labeled
label => fullName
action printLabel() => print("Great Employee: {label}")
end
Expansions can add members to features, as long as they provide default values or implementations:
feature Labeled
label: string
action printLabel() => print(label)
end
feature expansion Labeled
reversedLabel => label.reversed
end
Expansions are designed such that they never change the behavior of code outside of their own scope. This is ensured by several precedence rules.
For class or type object members added through an expansion:
overriden members > feature default implementations > expansion members
For feature members added through an expansion:
any feature members with same name and signature (including in features other than the expanded one) > feature expansion members
This means that if X
and Y
are features, and Z
extends both X
and Y
, and X
is expanded with a method that also exists in Y
, X
's expansion will be shadowed by Y
's implementation:
feature X
someField: string
end
feature expansion X
someFunction() => "X!"
end
feature Y
someFunction() => "Y!"
end
feature Z extends X, Y
end
function test(z: Z)
return z.someFunction() // There is no conflict here, this is will always return "Y!"
end
Generic typing (also known as parametric polymorphism) allows types and methods to refer to unknown, or partially known, types, which can vary and are determined individually at each class instantiation or method call.
A type parameter is introduced using the <T>
notation:
class Pair<T>
a: T
b: T
end
The unknown type parameter (named T
in this example) can accept any type:
let pairOfIntegers = Pair<integer>(1, 2)
let pairOfstrings = Pair<string>("a", "b")
let pairOfBooleans = Pair<boolean>(true, false)
Including the Pair
type itself:
let pairOfPairs = Pair<Pair<string>>(Pair<string>("a", "b"), Pair<string>("c","d"))
The type argument e.g. <integer>
is not required if it can be inferred from the constructor arguments, this simplifies the above to:
let pairOfIntegers = Pair(1, 2)
let pairOfstrings = Pair("a", "b")
let pairOfBooleans = Pair(true, false)
let pairOfPairs = Pair(Pair("a", "b"), Pair("c","d"))
(Note: Unlike C#, Java, TypeScript, and several other languages, the Island language does not allow both generic and non-generic types sharing a common identifier. This enables to safely omit the type argument with no chance of ambiguity)
Methods can accept and infer type arguments as well:
function firstOfPair<A>(p: Pair<A>) => p.a
let firstInteger = firstOfPair(Pair(1, 2)) // returns 1
let firststring = firstOfPair(Pair("a", "b")) // returns a
Type parameters can have constraints, which enforce a minimum assignability requirement:
feature Named
name: string
end
class Person extends Named
age: integer
end
function loveAffair<T extends Named>(a: T, b: T) =>
"{a.name} loves {b.name}"
end
let l = loveAffair(Person("Angela", 21), Person("Mike", 20))
// l = "Angela loves Mike"
Note that applying a type as a constraint is subtly different than specifying the type directly, especially in the case where the type parameter is used in multiple places:
class Fruit extends Named
name: string
weight: decimal
end
let l = loveAffair(Person("Angela", 21), Fruit("Apple", 1.5))
// Error! could not find a binding for type parameter T.
Even though both the types Person
and Fruit
satisfy the Named
feature, the T
type parameter can only be instantiated by a single type, therefore a compilation error is emitted.
Alternatively, if the Named
feature was used directly as a
and b
's parameter types, the code would compile successfully:
function loveAffair2(a: Named, b: Named) =>
"{a.name} loves {b.name}"
end
let l = loveAffair2(Person("Angela", 21), Fruit("Apple", 1.5)) // Works!
// l = "Angela loves Apple"
Multiple constraints may be applied to a type parameter:
feature Printable
action printMe()
end
class Fruit extends Named, Printable
name: string
weight: decimal
action printMe() => print("A {a.name} weighing {weight}kg")
end
action printNamedThing<T extends Named and Printable>(a: T) => a.printMe()
printNamedThing(Fruit("Banana", 0.5)) // prints "A Banana weighing 0.5kg"
Type parameters can have default values:
function transformToPair<T, R = integer>(v1: T, v2: T): (R, R)
....
end
let result = transformToPair(42.53, -14.7) // 'T' inferred as decimal, 'R' defaults to integer
// 'result' has the type '(integer, integer)'
Island supports implicit type parameters, meaning that generic types referenced without the introduction of type parameters will accept any type argument, given it satisfies their constraint set:
function firstsOfPairs(p1: Pair, p2: Pair) =>
(p1.a, p2.a)
which would be roughly equivalent to:
function firstsOfPairs<A, B>(p1: Pair<A>, p2: Pair<B>): (A, B) =>
(p1.a, p2.a)
p1
and p2
can accept any instances of Pair
, including ones with incompatible types:
let r = firstsOfPairs(Pair(1, 2), Pair("a", "b")) // r = (1, "a")
This is not always desirable. In case p1
and p2
are expected to have compatible instantiations of Pair
, a type parameter must be introduced:
function firstsOfPairs<T>(p1: Pair<T>, p2: Pair<T>) => (p1.a, p2.a)
let r = firstsOfPairs(Pair(1, 2), Pair("a", "b")) // Error: p1 and p2 must have compatible types!
Type associations may be defined ad-hoc, such that they only describe relationships between different polymorphic entities, but are not actually exposed as parameters. This kind of typing is called existential typing.
By introducing an existential type U
, it is possible to simplify the previous example to:
function firstsOfPairs(p1: Pair<any U>, p2: Pair<any U>) => (p1.a, p2.a)
This means that p1
and p2
must have a compatible type instantiation (which is "code-named" U
). However, an assignment for U
cannot be explicitly specified when firstOfPairs
is invoked.
When assigning a value to a plain variable of a given type, the value would be assignable to it only if the target type is identical to, or more general than the type of the value:
let a1: Animal = Animal() // Works: identical types
let a2: Animal = 34 // Doesn't work: unrelated types
let a3: Animal = Cat() // Works: target type is more general than assigned value type
let a4: Cat = Animal() // Doesn't work: target type is more specific than assigned value type
It may seem, at first, like this is the only manner in which types can relate to each other: after all, it doesn't make sense that an any Animal
type would substitute a Cat
type. However, there are cases where this is exactly what happens!
Here are two function types, one accepting an Animal
parameter type, the other a Cat
parameter type:
type GiveMeAnimal = (Animal) => string
type GiveMeCat = (Cat) => string
Now ponder this carefully: do you think that GiveMeCat
should assign to GiveMeAnimal
? GiveMeAnimal
should assign to GiveMeCat
? or maybe both should assign to each other? or neither one to any?
Let's go through it again:
- The type
GiveMeCat
describes a function that accepts aCat
object, and returns a string. - The type
GiveMeAnimal
describes a function that accepts anAnimal
object, and returns a string.
GiveMeAnimal
is more permissive, it will accept any animal, however GiveMeCat
is more strict, and will only accept a cat.
If you attempted to assign a function of type GiveMeCat
to a variable of type GiveMeAnimal
you'd take a strict function and put in a placeholder designated for a more permissive function:
let giveMeAnimal: (Animal) => string
giveMeAnimal = (Cat) => "Hello cat!"
// Because 'giveMeAnimal' accepts any animal, passing a dog as argument should work,
// but would it make any sense?
let str = giveMeAnimal(Dog()) // Would return "Hello cat!" ??!!
However, if you assigned GiveMeAnimal
to GiveMeCat
, it would, surprisingly, make more sense:
let giveMeCat: (Cat) => string
giveMeCat = (Animal) => "Hello animal!" // Seems to work, but why?
This phenomenon is called contravariance (substitution of general with specific) and the more "intuitive" substitution rule, mentioned in the context of plain variables, is called covariance (substitution of specific with general).
It turns out that when thinking about functions: return types ("out" positions) are covariant, however, parameter types ("in" positions) are contravariant.
Sometimes we may wish to constrain type parameters so that they can only be used in one of these positions. This is possible with the in
and out
modifiers:
class LookupTable<in K, out V>
function getValue(key: K): V
end
Consider this definition of the Person
class :
class Person
enum Gender with Male, Female
firstName: string
lastName: string
gender: Gender
age: integer
titleAndLastName => "{when gender == Gender.Male: "Mr.", otherwise: "Ms."} {lastName}"
fullName => "{firstName} {lastName}"
fullNameAndAge => "{fullName}, of {age} years of age"
end
Say we wanted to derive a class for a person who must be male and whose last name must be "Smith". In the traditional object-oriented style this can be done by extending Person
and fixing the lastName
and gender
fields to the constant values "Smith"
and Male
:
class MrSmith extends Person
final lastName = "Smith"
final gender = Gender.Male
end
A major limitation of this approach is that it can only work with values that are known at compile-time. What if we wanted to "partially apply" the Person
class with some arbitrary values for lastName
and gender
that are only known at run-time?
This can be done using the with
operator:
let mrSmith = Person with lastName = "Smith", gender = Gender.Male
Because some of mrSmith
's fields (namely firstName
and age
) are missing (and don't have default values), a full instance of Person
could not be constructed. Instead, the resulting value - mrSmith
is not an object of type Person
, but of the type partial Person with lastName, gender
.
Wouldn't it be nice if we could call some of the partially constructed object's methods? Unfortunately since methods may access the this
object (either implicitly or explicitly), there's no general, formal guarantee they wouldn't attempt to access uninitialized fields. However, in the common case, where the methods never pass the this
object to an external method, the requirements of each method can be determined automatically:
class Person
....
titleAndLastName => "{when gender == Gender.Male: "Mr.", otherwise: "Ms."} {lastName}"
fullName => "{firstName} {lastName}"
fullNameAndAge => "{fullName}, of {age} years of age"
end
The computed field titleAndLastName
can be called for mrSmith
:
print(mrSmith.titleAndLastName) // prints "Mr. Smith"
However trying to reference fullName
would result in a compilation error, since it requires firstName
to be initialized:
print(mrSmith.fullName) // Error: 'fullName' uses member 'firstName', which is not defined for type 'partial Person with lastName, gender'
In case a member passes the this
object explicitly, the receiving function must annotate its parameter with a compatible partial
type:
function giveMePartialPerson(p: partial Person with gender, lastName)
....
end
class Person
....
somethingElse => giveMePartialPerson(this)
end
We could continue adding more information to the object:
mrSmith.firstName = "John"
// The type of 'mrSmith' has now changed to 'partial Person with firstName, lastName, gender'
print(mrSmith.fullName) // prints "John Smith"
print(mrSmith.fullNameAndAge) // Error! 'fullNameAndAge' uses member 'age', which is not defined for type 'partial Person with firstName, lastName, gender'
Finally, when we add a value for age
, the object becomes fully constructed:
mrSmith.age = 28
// mrSmith finally receives the type 'Person'
print(mrSmith.fullNameAndAge) // prints "John Smith, of 28 years of age"
An alternative, but more limited, way to achieve a similar effect is to partially apply the constructor call:
class Point
x: decimal
y: decimal
end
let pointWhereXEquals1 = Point(1, ...)
// The type of 'pointWhereXEquals1' is 'partial Point with x'
Here's a different use case.
Say we had an object representing a database, and that has the fields connection
and name
:
class Database
connection: ServerConnection
name: string
action query(this, sql: string)
....
end
action verifyConnection({ connection }: this)
connection.verify(....)
end
end
For every new database object we wanted to create, we'd have to re-specify which server connection it uses:
let myConnection = connectServer("localhost:5555", "admin", "1234")
let db1 = Database with connection = myConnection, name = "DB1"
let db2 = Database with connection = myConnection, name = "DB2"
let db3 = Database with connection = myConnection, name = "DB3"
With a partially constructed object, the connection
field can be fixed once and then the resulting object reused:
let myConnection = connectServer("localhost:5555", "admin", "1234")
let databaseWithMyConnection = Database with connection = myConnection
// The type of 'databaseWithMyConnection' is 'partial Database with connection'
databaseWithMyConnection.verifyConnection() // Works!
let db1 = databaseWithMyConnection with name = "DB1"
let db2 = databaseWithMyConnection with name = "DB2"
let db3 = databaseWithMyConnection with name = "DB3"
In case some fields have default values but the desired behavior is to have those fields undefined on the partial object, their default values can be "erased" by explicitly applying the no
modifier within the with
expression:
class Person
firstName: string
lastName: string
planetResidence = "Earth"
end
let angelaFromUnknownPlanet = Person with firstName = "Angela", no planetResidence
Features can be partial as well, however since features cannot be instantiated directly the partial
type modifier is only effectively usable for specifying a subset of a feature's fields that are expected to be known. For instance:
feature Named
name: string
alias: string
id: string
end
action printThingName(thing: partial Named with name, id)
print("Name: {thing.name}, Id: {thing.id}")
end
Remember computed fields in a class?
class Person
firstName: string
lastName: string
fullName => "{firstName} {lastName}"
end
fullName
is only evaluated when it is called (and possibly the result is then stored for subsequent calls).
Using a computed variable, we could make a local variable that behaves in a similar way:
let x = 1
let y = 2
let z => x + y // The type of z is a plain integer
print(z) // prints 3
z
is a variable bound to a value of type integer
, but its value would only be calculated when it is first used.
With this approach, however, z
will lose its computed
characteristic when it is passed to a method:
function makePair(value1, value2) => (value1, value2)
let x = 1
let y = 2
let z => x + y
let w = makePair(z, 5) // z will be evaluated before makePair is called
// w is equal to (3, 5)
A second approach would be define the value itself (not the variable) as computed:
function makePair(value1: integer, value2: integer) => (value1, value2)
let x = 1
let y = 2
let z = compute x + y
// 'z' still has the plain type 'integer'
// The 'computed' characteristic is only tracked internally, in the runtime
let w = makePair(z, 5) // z will not be evaluated here
// w has the value (compute 1 + 2, 5)
let v = w[1] + 1 // 'compute 1 + 2' is finally evaluated to 3 and v gets the value 4
This behavior is called lazy evaluation. We can postpone the evaluation of compute 1 + 2
only to the point where it is actually needed. It can be passed to methods or stored in variables and objects, but will only be evaluated when it is a part of a complex expression that is immediately (eagerly) evaluated.
Computed values can be composed together:
let x = 1
let y = 2
let z = compute x + y
let s = compute sqrt(z) // 'z' is not evaluated but composed with the computation 'sqrt(....)'
// s now equals 'compute sqrt(1 + 2)'
When then spawn
keyword is added to a function call, the function is immediately executed in a separate thread. When the returned value of the spawned method is first read, execution may block if the method had not yet completed:
function heavyCalculation()
// ....
return result
end
let x = spawn heavyCalculation() // The function heavyCalculations() starts on a seperate thread
let y = somethingUnrelated(....) // This will execute even if heavyCalculations() has not completed
let z = x + y // This may block until heavyCalculations() returns and x receives a value
Multiple functions may be spawned at the same time:
let (r1, r2) = spawn (heavyCalculation1(), heavyCalculation2())
let a = r1 + 1 // Will block until r1 received a value and exeute even if r2 hasn't yet
let b = r2 + 1 // Blocks until r2 receives a value
The wait
statement can be used to explicitly wait for a particular value to become available:
let (r1, r2) = spawn (heavyCalculation1(), heavyCalculation2())
wait r2
For convenience, the wait
keyword can also be used as a modifier. The following is equivalent:
let (r1, wait r2) = spawn (heavyCalculation1(), heavyCalculation2())
As demonstrated, applying spawn
to a simple function call, it is possible to compute an individual value in the background. Using streams, it is also possible to instead compute a sequence of values in background.
This will evaluate a stream method in a background thread and save the yielded values to disk as soon as they arrive:
stream heavyCalculations()
for ....
....
yield result
end
end
for result in spawn heavyCalculations()
somethingUnrelated(....) // This will execute even if 'result' has not yet received a value
writeToDisk(result) // This will block until 'result' receives a value
end
Analogously to the single-value approach, we can spawn multiple streams and iterate both of them at the same time:
for (result1, result2) in spawn (heavyCalculations1(), heavyCalculations2())
writeToDisk(result1) // This will block until result1 receives a value, and will execute even if result2 didn't
writeToDisk(result2) // This will block until result2 receives a value
end
Sometimes the lazy behavior isn't desirable, and it is preferred to wait until one or all of the streams produce a value before the body of the loop is entered. The wait
keyword will cause execution to block until the reference variable(s) receive a value.
for (result1, result2) in spawn (heavyCalculations1(), heavyCalculations2())
wait result1, result2
// This will only execute once both result1 and result receive a value
print("Congratulations, we have new results!")
....
end
For convenience, the wait
keyword can also be integrated as a modifier to the loop variable:
for (wait result1, wait result2) in spawn (heavyCalculations1(), heavyCalculations2())
// This will only execute when both result1 and result receive a value
print("Congratulations, we have new results!")
....
end
Sometimes we want to allow for results to evaluate as soon as any one of several computations yields a value. By adding the any
modifier to the variable, a single result is received whenever any one of the streams yields a value.
// Note both streams should have compatible return types,
// otherwise 'result' will receive a choice type (introduced at later chapter)
for wait any result in spawn (heavyCalculations1(), heavyCalculations2())
writeToDisk(result)
end
The spawn
keyword can also be used in stream and list comprehensions.
This will define a stream method that computes an unbounded series of primes in the background:
let backgroundPrimes = (for p in spawn calculatePrimes()) => p
Since function
s and stream
s are pure computations, spawning may be performed automatically by the compiler, without any need for explicit annotation in the code, since execution of these methods does not carry any impact beyond the scope of their internal running context.
This means that normal code may be internally transformed during compilation to include spawn
modifiers based on the compiler's own judgement. For example:
let x = someFunction(....)
let y = anotherFunction(....)
for a in someStream()
....
end
May be transformed to:
let x = spawn someFunction(....)
let y = spawn anotherFunction(....)
for a in spawn someStream()
....
end
In the case of a stream, the compiler may also choose to precompute one or more future elements ahead of time (that is, in parallel to the execution of the loop body), since doing so would have no impact on the program's behavior (aside from possible slight increase in memory use).
spawn
allows pure functions and streams to be easily parallelized. Since pure computations have no side-effects, there's no need for much careful considerations when applying it. The worst that can happen is that performance may be degraded due to excessive overhead, when managed inappropriately.
When dealing with concurrency and parallelism involving effects, however, the situation becomes more subtle. If it was possible to freely "spawn" actions, that would open up several potential issues:
- If an action could be freely spawned to execute in a separate thread, would its execution be let to "invisibly" continue forever? even long after execution has left the original caller's scope?
- How would it be possible to conduct two-way communication with the spawned action? Perhaps by passing it a channel? but are free-form channels, which can be duplicated and moved everywhere, really a good fit for a safe and strict language like Island?
For these reasons, instead of free-form threading and channeling, Island provides delegates (no relation to C# or Kotlin delegates), which are worker-like action subroutines that are designed to follow a strict pattern of structured concurrency and messaging.
A delegate method (analogous to a stream method) is an action which once called, returns a delegate object (analogous to a stream object) that can be used for two-way communication with it via an embedded channel.
Here's a simple example:
action MyDelegate(): (in: string, out: string)
repeat
let name <- in
out <- "Hello {name}!"
end
end
The above delegate has both incoming and outgoing channels. An incoming one of type string
, and an outgoing one, also of type string
.
.... <- in
reads a message from the incoming channel.
out <- ....
writes a message to the outgoing channel.
Calling MyDelegate
returns an object of type Delegate<string, string>
. This object allows its caller scope to interactively communicate with it:
let myDelegate = MyDelegate()
myDelegate <- "Adam" // Sends "Adam" to the incoming channel
let result1 <- myDelegate // Receives the result "Hello Adam!" from the outgoing channel
myDelegate <- "Tom" // Sends "Tom" to the incoming channel
let result2 <- myDelegate // Receives the result "Hello Tom!" from the outgoing channel
A delegate's outgoing channel may be consumed by a for
loop, in a manner similar to a stream:
delegate MyDelegate(): (out: string)
// This delegate only has an outgoing channel.
// It is, in a sense, very similar to a stream, only that it may also
// produce side-effects, by invoking actions or spawning further delegates.
for i in 1..infinity
greetingChan <- "Hello {i}"
end
end
for greeting in myDelegate()
print(greeting)
end
// Prints "Hello 1", "Hello 2", "Hello 3", ....
Similarly to how multiple parallel streams can be consumed using the any
modifier, an incoming message can be consumed as soon as it's received from any of two or more delegates:
delegate KeyEvents(): (out: KeyEvent)
....
end
delegate MouseEvents(): (out: MouseEvent)
....
end
for match any inputEvent in (KeyEvents(), MouseEvents())
// 'inputEvent' has type 'KeyEvent or MouseEvent'
case KeyEvent
....
case MouseEvent
....
end
In real-world applications, however, it is sometimes the case that event sources are required to be dynamically subscribed and unsubscribed from throughout the program's runtime. This can be achieved by iterating over a dynamic collection of delegates (in this example a dictionary) and altering the collection in progressive iterations of the loop:
for eventSources = { "kEvents": KeyEvents() }, match any event in eventSources
case KeyEvent
print("Keyboard event!")
print("Now listening to mouse events instead!")
continue eventSources with
no ["kEvents"]
["mEvents"] = MouseEvents()
case MouseEvent
print("Mouse event!")
end
Channel type references can be used to disambiguate messages from different channels sharing the same base type:
delegate ProducesStrings(): (out: string)
....
end
delegate AlsoProducesStrings(): (out: string)
....
end
for match any stringMessage in (ProducesStrings(), AlsoProducesStrings())
// Superficially, 'stringMessage' has type 'string'. However, by matching over references
// to the delegate channels' identifiers, the correct case can be selected:
case ProducesStrings.out
....
case AlsoProducesStrings.out
....
end
A delegate executes in parallel to the calling thread, but is bound to the lifetime of its object. Once its object goes out of scope, its execution immediately terminates. It cannot be spawned and then "forgotten":
if someConditionIsTrue
let longRunningDelegate = LongRunningDelegate() // Delegate starts on a parallel thread
....
// 'longRunningDelegate' scope ends here
end
// The delegate execution's may have been abruptly terminated since its
// object is no longer in scope!
Messages received from its outgoing channel are evaluated asynchronously, similarly to how individual values yielded from a spawned stream only become potentially "blocking" towards the current thread once they are first read.
By using the wait
modifier, it is possible to convert a delegate call to a synchronous-like method invocation, in an await
-like fashion:
delegate readTextFileInBackground(fileName: string): (out: string)
....
out <- fileContent
// 'return fileContent' can also be interchangably used here
end
// Execution waits until a message is received
let text <- wait readTextFileInBackground("data.txt")
// Note that at this point, the delegate is terminated immediately after it sends its
// first message. If the delegate body included additional code after 'out <- fileContent',
// this code would not be executed, or alternatively a compile/run-time error may be issued
// (depending on settings)
By default, messages are delivered asynchronously:
myDelegate <- "Some data" // message will be buffered if receiver is busy
.... // any subsequent code is immediately executed
To ensure the message has been successfully delivered before execution proceeds, the wait
modifier can be similarly used:
wait myDelegate <- "Some data" // execution will wait until message is delivered
.... // any subsequent code will only be run only after the message is accepted by the delegate
A delegate object cannot be copied, only moved. Move semantics ensure that its channels can communicate with only one single endpoint at a time:
let myDelegate = MyDelegate()
let myDelegate2 = myDelegate // Delegate object now only reachable via myDelegate2
myDelegate2 <- "Nancy" // Works
myDelegate <- "Nancy" // Fails at compile-time
Whenever a delegate, or any of the secondary delegates it spawns, encounters an unhandled failure, the resulting failure object will be immediately captured and propagated to its outgoing channel. A convenient way to handle these failures is to add a Failure
match case handler when messages are read via a match
statement.
In this example, the Failure
type becomes a hidden component of the choice type inferred for anyEvent
. Failures will be caught from either KeyEvents
, MouseEvents
or any one of the delegates they encapsulate:
for match any inputEvent in (KeyEvents(), MouseEvents())
// 'inputEvent' has type 'KeyEvent or MouseEvent (or Failure)'
case KeyEvent
....
case MouseEvent
....
case Failure // Failure type can be further specialized, like to 'Failure<IO>'
....
end
The assert
statement can evaluate arbitrary assertions. It is evaluated both during compile-time and run-time, as needed.
function divide(a: decimal, b: decimal)
assert b != 0
return a / b
end
let r1 = divide(10, 0) // compiler error
let x = 0
let r2 = divide(10, x) // compiler error
let y = 2
let r2 = divide(10, y - 2) // compiler error
It can be positioned anywhere, including in the global scope:
let z = x + y
assert z > 5
It may include function and predicate calls (but not action calls):
predicate nonzero(x: decimal) => x != 0
function divide(a: decimal, b: decimal)
assert nonzero(b)
return a / b
end
It can reference the returned value as well, if declared as a named return variable (these types of assertions would always be evaluated after the function has returned):
function doSomeMath(x: decimal): (result: decimal)
assert result > x
....
end
The immutability property could potentially ease the analysis of more complex scenarios at compile-time, using a more advanced theorem prover:
function someMath1(x: decimal): (result: decimal)
assert result > 5
....
end
function someMath2(a: decimal, b: decimal)
assert b >= 0
assert a + b < 5
....
end
// 'r1' is always greater than 5, regardless of argument passed to 'someMath1':
let r1 = someMath1(???)
// Compiler error regardless of the value of 'r1' and the second argument:
let r2 = someMath2(r1, ???)
Refinement types can be seen as simple contracts annotated into the type itself:
The following two function declarations are practically equivalent:
function someMath1(x: decimal): (result: decimal)
assert x >= -4.0 and x <= 4.0
assert result >= 16.0
....
end
function someMath1(x: -4.0..4.0): 16.0..infinity
....
end
Since Island values and variables are strictly read-only after initialization, only one assertion check is needed to ensure the validity of a contract throughout the lifetime of the object and its binding variable. Types of operations between refined types can be inferred during compile-time (if feasible):
let x: 0..10
let y: -5..0
let z = x * y // z's type is -50..0
Refinement types can include more complex Boolean expressions:
function someMath2(x: -4..4 or 10..20): -16..16 and not 0
....
end
Refinement types can apply to strings, using regular expressions:
function properIdentifiersOnly(id: /[a..zA..Z]+/)
....
end
More complex assertions can be included in a type by referencing a predicate function, which must accept a single argument and a return type of boolean
. These kinds of types are called assertion types.
Assertion types, for the most part, cannot be checked during compile-time and would require a run-time call each time a variable of the type is initialized. The predicate's parameter type (which can be any type, including a refinement type) would be used to determine the base underlying type used at compile-time:
predicate MultipleOf10(num: integer) => num mod 10 == 0
function something(x: MultipleOf10)
....
end
Since MultipleOf10
represents both a method identifier and a type, it naturally lends for the is
operator to be used as an alternative syntax to assert over its truth-value:
let val: integer = ....
// Note that 'val' has been declared with the type 'integer', not 'MultipleOf10'
// The following type assertion depends on the runtime value of 'val':
if val is MultipleOf10 // practical alternative to writing MultipleOf10(val)
....
else
....
end
Like any other method, assertion types can accept type arguments:
predicate ShortList<T>(list: List<T>) => list.Length < 100
function something(x: ShortList<string>)
....
end
A less powerful, but more concise way to define assertion types employs the where
clause, used similarly to the match
predicate syntax:
function something(x: integer where x mod 10 == 0): (result: integer where result mod 2 == 0)
....
end
These capabilities enable overload resolution to include rudimentary match
-like predicates:
action doSomething(category: "Animal", isMammal: true, owner: Person where age >= 18)
print("Hello animal lover!")
end
action doSomething(category: "Animal", isMammal: false, owner: Person where age < 18)
print("Hello young animal lover!")
end
action doSomething(category: "Person", id: /[a..zA..Z]+/)
print("Hello random person!")
end
Using the compact overloading syntax would resemble more of the match
/case
structure. The following is semantically equivalent:
action doSomething
(category: "animal", isMammal: true, owner: Person where age >= 18)
print("Hello animal lover!")
end
(category: "animal", isMammal: false, owner: Person where age < 18)
print("Hello young animal lover!")
end
(category: "person", id: /[a..zA..Z]+/)
print("Hello random person!")
end
end
However, note that unlike match
statements, overloading assumes the given argument set must satisfy one of the overloads, thus an analogous otherwise
fallback is not needed. In case of a matching failure not caught during compile-time, a run-time error would be thrown.
Here's recursive Fibonacci implemented using overloading and refinement types:
function fibonacci
(num: 1) => 0
(num: 2) => 1
(num: 3..infinity) => fibonacci(num - 1) + fibonacci(num - 2)
end
Passing a number smaller than 1, e.g. fibonacci(0)
would cause a runtime error (alternatively an overload like (num: integer where num < 1) => throw ....
could be added to provide more specialized error handling).
Compare with a single function matching over num
as a parameter:
function fibonacci(match num)
case 1 => 0
case 2 => 1
case it > 3 => fibonacci(num - 1) + fibonacci(num - 2)
end
Passing fibonacci(0)
, would cause a compile-time error.
Also note that in both approaches, calling:
let result = fibonacci(2)
Would cause result
to have the literal type 1
as the return value could be inferred at compile-time.
Simple literal types like "Animal", '5' or 'true' can alternatively be stated without an identifier:
This would further simplify a previous example to:
action doSomething
("animal", true, owner: Person where age >= 18)
print("Hello animal lover!")
end
("animal", false, owner: Person where age < 18)
print("Hello young animal lover!")
end
("person", id: /[a..zA..Z]+/)
print("Hello random person!")
end
end
Having no identifiers, the first two parameters can still accept named arguments by being referenced by their index:
doSomething([2] = false, owner = Person("Lea","Johnson", 16), [1] = "Animal")
// prints "Hello young animal lover!"
A type alias defines a new name for a type expression.
type MyDictionary = Dictionary<string, (string, integer)>
It may include type parameters:
type MyDictionary<T, U> = Dictionary<T, (T, U)>
By default, type aliases are structural, meaning differently named type aliases representing equivalent types are interchangeable with each other.
This is not always desirable, say, when aliasing the decimal
type to represent different currencies:
type Dollars = decimal
type Euros = decimal
function iWantDollars(money: Dollars)
....
end
let euros: Euros = 45.0
iWantDollars(euros) // No error!
To ensure Dollars
and Euros
would not be interchangeable with each other one can add the unique
modifier. This would define the aliases as nominal (unique) types:
unique type Dollars = decimal
unique type Euros = decimal
function iWantDollars(money: Dollars)
....
end
let euros: Euros = 45.0 as Euros // Explicit cast needed here
iWantDollars(euros) // Error! incompatible types
A choice type (also called a union or a sum type) defines a type that may hold one of several different types:
type IntegerOrString = integer or string
Choice types can disambiguated at runtime, using a type assertion:
let x: IntegerOrString = 3
if x is integer
print("integer!")
else if x is string
print("string!")
end
Or using pattern matching:
let x: IntegerOrString = 3
match x
case integer
print("integer!")
case string
print("string!")
end
Choice types may include any type, including primitives, templates, features, other choice types or even refinement types:
type CanBeManyThings = integer or decimal or List<integer> or Cat or (string or boolean) or 0..10
Like any type alias, choice types may include type parameters:
type AnyCollection<T> = List<T> or Dictionary<string, T> or Set<T>
Choice types may be self-referencing, which allows modeling complex recursive structures like a binary tree:
unique type BinaryTree<V> = V or (leftNode: BinaryTree<V>?, rightNode: BinaryTree<V>?)
In the final example of the previous section we've used a choice type to define a binary tree type:
unique type BinaryTree<V> = V or (leftNode: BinaryTree<V>?, rightNode: BinaryTree<V>?)
One issue with this approach is that pattern matching to discriminate between a leaf and internal node is rather involved and error prone:
function leafOrInternal<T>(match tree: BinaryTree<T>)
case T
return "leaf!"
case (BinaryTree<T>?, BinaryTree<T>?)
return "internal!"
end
Wouldn't it be nicer if we could give those two possibilities names, to ease on pattern matching? It would be also nice to declare the type in a more organized way.
This is possible with variant types. A variant type (also called a tagged union) is a unique (nominal) choice type where each member has its own name.
With a variant type, pattern matching over a binary tree becomes much easier:
variant BinaryTree<V>
Leaf: V
Internal: (leftNode: BinaryTree<V>?, rightNode: BinaryTree<V>?)
end
function leafOrInternal<T>(match tree: BinaryTree<T>)
case Leaf
return "leaf!"
case Internal
return "internal!"
end
Values of variant members can be assigned, matched and extracted using the VariantMemberName(value)
syntax:
stream traverseBinaryTree<T>(match tree: BinaryTree<T>)
case Leaf(let value)
yield value
// Tuple typed variant members don't require the extra parentheses
// e.g. instead of Internal((let left, let right))
// we can write Internal(let left, let right)
case Internal(let left, let right)
if left is not nothing
yield stream traverseBinaryTree(left)
end
if right is not nothing
yield stream traverseBinaryTree(right)
end
end
Variant types allow for including members with duplicate types:
variant Currency
USDollar: decimal
Euro: decimal
Yen: decimal
end
let money: Currency = Euro(45.0)
Variant types with members of tuple types allow including a where
clause, in which the tuple's member names are introduced. In practice this appears similarly to how objects are matched:
variant PersonOrCar
Person: (name: string, height: decimal)
Car: (brand: string, maxSpeed: decimal)
end
function getResponseString(match personOrCar: PersonOrCar)
case Person where name == "James" => "Hi James"
case Person where height >= 2.0 => "Tall person"
case Car where maxSpeed >= 200.0 => "Fast car"
otherwise => "Not interesting"
end
Same as above using the constructor-style syntax:
function getResponseString(match personOrCar: PersonOrCar)
case Person("James", ...) => "Hi James"
case Person(any, here >= 2.0, ...) => "Tall person"
case Car(any, here >= 200, ...) => "Fast car"
otherwise => "Not interesting"
end
Members may individually include their own set of type parameters (this is related to the notion of a generalized algebraic data type):
variant PairOrTriple
Pair<T>: (x: T, y: T)
Triple<V>: (x: V, y: V, z: V)
end
function matchPairOrTriple(match pairOrTriple: PairOrTriple)
case Pair<string>("James", any) => "Hi James"
case Pair<string>("XYZ", "123") => "123"
case Pair<integer>(1, here > 100) => "Good"
case Triple<integer>(any, any, 55) => "55"
case Triple<boolean>(any, false, true) => "OK!"
otherwise => "Not interesting"
end
Variant types can be extended:
variant Currency
USDollar: decimal
Euro: decimal
Yen: decimal
end
variant ExtendedCurrency extends Currency
CanadianDollar: decimal
PoundSterling: decimal
end
variant ExtendedMoreCurrency extends ExtendedCurrency
SwedishKrona: decimal
SwissFranc: decimal
end
An extended variant type is a super-type of the variant it inherits from. This is the opposite relationship when compared to class inheritance, which creates a subtype.
For example:
function giveMeMoney(money: Currency)
....
end
giveMeMoney(Currency.Euro(10.0)) // works
giveMeMoney(ExtendedCurrency.CanadianDollar(10.0)) // doesn't work
However:
function giveMeMoney(money: ExtendedCurrency)
....
end
giveMeMoney(Currency.Euro(10.0)) // works
giveMeMoney(ExtendedCurrency.CanadianDollar(10.0)) // works
giveMeMoney(ExtendedMoreCurrency.SwissFranc(10.0)) // doesn't work
An extending variant type may override one or more members, as long as the overriding type is a super-type of the original:
variant WordKind
ActionWord: TransitiveVerb
ObjectWord: Noun
end
variant ExtendedWordKind extends WordKind
ActionWord: Verb // Verb is a super-type of TransitiveVerb
end
Variants may contain untyped members, which can be useful for representing possibilities or states that don't carry any data with them:
variant Currency
USDollar: decimal
Euro: decimal
Unknown
end
Variant types may embed class declarations for one or more of their members. Declaring Internal
as an embedded class now allows to implement a specialized traverse
method for the Internal
member:
variant BinaryTree<V>
Leaf: V
Internal: class
leftNode: BinaryTree<V>?
rightNode: BinaryTree<V>?
hasChildren => (leftNode, rightNode) is not (nothing, nothing)
stream traverse()
yield stream leftNode?.traverse()
yield stream rightNode?.traverse()
end
end
end
Like classes, variants may have companion type objects, which also enable them to support type features:
variant BinaryTree<V>
....
end
// Here, 'this' type substitutes for 'BinaryTree<V>':
object BinaryTree<V> extends Comparable<this>, Equatable<this>
stream traverse(match tree: this)
case Leaf(let value)
yield value
case Internal
yield stream tree.iterate()
end
function compare(t1: this, t2: this): integer
....
end
operator ==(t1: this, t2: this): boolean
....
end
end
Individual members of the variant may also receive their own dedicated type objects:
// Here, 'this' type substitutes for 'BinaryTree<V>':
object BinaryTree<V> extends Comparable<this>, Equatable<this>
....
// Here, 'this' type substitutes for 'BinaryTree<V>.Leaf'
object Leaf extends Comparable<this>, Equatable<this>
....
end
// And here, 'this' type substitutes for 'BinaryTree<V>.Internal'
object Internal extends Comparable<this>, Equatable<this>
....
end
end
An enumeration is a type expressing a choice between a set of identifiers associated with constant values. By default, enumeration members receive integer values following the sequence 1, 2, 3, ...
enum StatusCode with Waiting, OK, Failed
action alertStatus (match status: StatusCode)
case Waiting => print("Still waiting..")
case OK => print("Everything is OK!")
case Failed => print("Damn, failed :(")
end
Enumerations are special forms of variant types where each member must receive a unique type (which can also be a literal type like 4
, "hello"
or true
).
Here is StatusCode
equivalently expressed as its underlying variant type:
variant StatusCode // 1, 2, 3 are literal *types*
Waiting: 1
OK: 2
Failed: 3
end
let status: StatusCode = StatusCode.OK
Enumeration members can have integer values other than the 1, 2, 3, ...
sequence. If a member value is explicitly specified, all following members without explicit values are automatically incremented relative to it:
enum HttpStatusCode
OK = 200
Created // = 201
Accepted // = 202
MultipleChices = 300
MovedPermanently // = 301
Found // = 302
BadRequest = 400
Unauthorized // = 401
PaymentRequired // = 402
end
Enumerations can have members of types other than integer
, however, in this case all member values have to be explicitly specified:
enum Direction
Up = "UP"
Down = "DOWN"
Left = "LEFT"
Right = "RIGHT"
end
So far we've occasionally used the nothing
keyword, but hadn't really got into the details of what it really is.
nothing
may look superficially similar to null
in other languages. However, in Island nothing
is not primarily a value, but a type.
Take for example:
function computeSomething(integer num): integer or nothing // can also be written as 'integer?'
if num >= 0
return num * 2
else
return nothing // 'nothing' here acts somewhat like 'null'
end
end
let x = computeSomething(-1) // what type and value does 'x' receive?
return nothing
might seem like a value named nothing
is being returned from the function (similarly to, say return null
would in other languages). However, in practice, what is really happening is that x
receives the type nothing
which by default has a single empty value which is also called nothing
(this behavior is similar to a concept known as the unit type).
Actions that don't return any value can be optionally annotated as returning nothing
:
action printHelloWorld(): nothing // 'nothing' here acts like 'void' in the C family of languages
print("Hello World!")
end
Trying the read the result of a method returning only nothing
(either annotated as such or not) will fail, since the nothing
type doesn't contain any useful information by itself:
let x = printHelloWorld() // Error: 'printHelloWorld()' returns only 'nothing'
The nothing
type is designed such that there is very little you can do with it. However, it is at times a very useful tool when defining optional parameters or capturing "soft" failures when returning from methods.
The question mark symbol (?
) would modify a type to become a choice type including nothing
as one of its options. It would work on any type, including a type that already is a choice type, for example:
type Number = integer or decimal
type PossiblyNumber = Number? // PossiblyNumber = integer or decimal or nothing
The ?
symbol can also be used in several other ways:
The null-conditional operator applies member access ?.
, index access ?[]
or method call ?()
only if the preceding expression fragment evaluates to a value that is not of type nothing
, otherwise, the entire enclosing expression evaluates to nothing
:
class Address
city: string
street: string
houseNumber: integer
end
class Person
name: string
address: Address or nothing
petNames: List<string> or nothing
end
let person = Person with name = "Jimmy Jones", address = nothing, petNames = nothing
let houseNumber = person.address?.houseNumber // houseNumber = nothing
let firstPetName = person.petNames?[1] // firstPetName = nothing
Some notes on possible confusion with terminology used by other languages: In Island the unit type is called nothing
and the bottom type is called never
(introduced in a following chapter). In Scala the unit type is called Unit
and the bottom type Nothing
. However Haskell uses the term Nothing
in its option type to represent the option of having "No value", which is closer to the semantics intended here.
The any
type (roughly representing the top type). It is equivalent to a choice type including of all the types in the language, except nothing
and never
(any?
would include nothing
as well) .
Note this is not the same as a dynamic type, because it requires an explicit type assertion or a cast in order to be used for any meaningful purpose:
function whatIsThis(match x: any)
case integer => "integer!"
case decimal => "decimal!"
case string => "string!"
case boolean => "boolean!"
case List<boolean> => "list of booleans!"
otherwise => "I don't know?"
end
Assigning from a value having the any
type could also be done using an explicit cast:
let x: any = 5
let y: integer = x as integer
However assigning a value of the wrong type would produce a compile or runtime error:
let x: any = "hello"
let y: integer = x as integer // Error
The never
type represents the result of a computation that either never terminates, or always fails:
For example, if a for
loop, running inside a function
context, does not alter any of its variables and does not have any stopping condition, it is guaranteed to never terminate, and consequently the function would never return!
function neverEndingFunction(num: integer)
for i = 1, out result = 1
continue // never terminates!
end
return result
end
let x = neverEndingFunction(0) // what type is x?
In this case you may think the compiler should just raise an error (it will). However, x
will also get the never
type, which would be useful to allow more errors to be reported down the road.
Sometimes there are only particular cases where the function never returns:
function maybeNeverEndingFunction(num: integer)
for i = 1, out result = 1 while num > 0
continue
end
return result
end
let mysteryValue: integer = mysteryFunction(....)
let x = maybeNeverEndingFunction(mysteryValue) // what type is x?
If num <= 0
the function would return immediately with the value 1
. However, when num > 0
it will never terminate.
So x
receives the type integer or never
, which roughly means "if x has a value, it is of type integer
, but it may also never get to the point where receives any value".
The never
type is also known as the bottom type, meaning it is a type that has no values, and behaves somewhat like the empty set Ø.
A join type (also called an intersection type) allows combining multiple features in a convenient form:
If we wanted to write a function that accepts a parameter of a type implementing the two features Named
and Numbered
we could do something like:
feature Named
name: string
end
feature Numbered
id: integer
end
feature NamedAndNumbered extends Named, Numbered
function giveMeNamedAndNumbered(value: NamedAndNumbered)
....
end
Using a join type, this can be shortened to:
feature Named
name: string
end
feature Numbered
id: integer
end
function giveMeNamedAndNumbered(value: Named and Numbered)
....
end
Object or tuple type references may include references to members, method parameter or return types:
class Person
name: string
data: (integer, boolean, id: string)
action processMe(someData: integer, moreData: string): Set<string>
....
end
end
let n: Person.name // n receives the type string
let d: Person.data[2] // d receives the type boolean
let id: Person.data.id // id receives the type string
let p: Person.processMe.params // p receives the tuple type (someData: integer, moreData: string)
let md: Person.processMe.moreData // md receieves the type string (might be a choice type if overloaded)
let r: Person.processMe.return // r receives the type Set<string>
Same for methods outside of a class:
function myFunc(p1: (integer, boolean)): List<string>
....
end
let t: myFunc.p1 // t gets the type (integer, boolean)
let r: myFunc.p1.return // r gets the type List<string>
Referring to the types of companion object members is possible through the (object Type)
syntax:
object Person
bestPerson: string = "Cleopatra"
end
let best: (object Person).bestPerson // best receives the type string
So far we've used nothing
to represent failure cases, i.e. cases where the function doesn't succeed and instead returns an empty return value.
Sometimes we wish to be more specific and provide a more detailed report on what exactly went wrong. This is made possible by the failure type, which is a type representing a failed computation, and may also include further information.
Consider this case:
function divide(x: integer, y: integer)
return x div y
end
let r = divide(10, 0) // What should be the type of 'r'?
One approach would be to return nothing
when y
is 0:
function divide(x: integer, y: integer): integer?
when y == 0 => nothing
otherwise => x div y
end
let r1 = divide(10, someInt) // 'r1' gets type integer or 'nothing'
let r2 = divide(10, 0) // 'r2' gets type 'nothing'
let r3 = r1 + 10 // Error: 'r1' may be of type 'nothing'!
let r4 = r2 + 10 // Error: 'r2' is of type 'nothing'!
However, that would mean that in every computation done with divide
we would have to use a type assertion to check if the result type is not nothing
and then proceed:
let r3
if r1 is not nothing
r3 = r1 + 10
end
Alternatively, the Failure type is a special type designated to represent failures.
Island has two approaches to using the failure type:
- Returned directly from a method, as a part of a choice type, and then optionally assert on through the returned value. This is the only approach permittable for a function.
- Use the
fail
statement to raise an exception, together with atry
..detect
block to capture the error in a caller scope. This is only possible in action scopes.
The failure type possesses a special "vanishing" quality when included inside of a choice type. If the choice type contains only a single type that is not of type Failure
then no assertion is needed for the variable to be used as if it could only have that type.
function divide(x: integer, y: integer)
when y == 0 => Failure("Divide by zero!")
otherwise => x div y
end
let r1 = divide(10, someInt) // 'r1' gets type 'integer (or Failure<string>)'
let r2 = divide(10, 0) // 'r2' gets type 'Failure<string>'
let r3 = r1 + 10 // Works! no type assertion needed!
let r4 = r2 + 10 // Error: 'r2' is of type 'Failure<string>'
Note that if the result of the operation is immediately unpacked, the failure can still be asserted for any one of the unpacked variables:
function getKeyOrFail(key: integer, dict: { string: (age: integer, bestFriend: string) })
when key in dict => dict[key]
otherwise => Failure("Key '{key}' not found!")
end
let someDictionary = { "Linda": (25, "Mary"), "Alan": (34, "Anton") }
let (age, bestFriend) = getKeyOrFail("James", someDictionary)
if age is Failure
print("Couldn't find "James" in the dictionary!: {age as Failure<string>}")
end
The second approach, available only in action scopes (due to its reliance on side-effects), uses a try
..detect
block and behaves very similarly to try
..catch
in mainstream imperative languages:
action readLineFromFile(f: File)
if not f.exists
fail IOFailure("File not found")
end
return f.ReadLine()
end
action example(f: File)
try
let line = readLineFromFile(f)
print(line)
detect failure: IOFailure
print(failure.message)
end
We've previously introduced a syntax for list, tuple and object patterns, which can be used for pattern matching in conditional statements and expressions, such as:
match myList
case []
....
case [let head < 10, ...]
....
case [25, ..., let last]
....
case [here >= 10, let ...tail]
....
case [let first < 0, let second != first, let ...rest]
....
case [..., let v > 5, ...]
....
end
Pattern methods generalize over this feature, and allow to define arbitrary pattern recognizers via special-purpose subroutine-like helpers.
Pattern methods can also be used as a full replacement for string regular expressions.
For instance, we'll look at a regular expression that captures a phone number pattern. Conventionally we'll define something like:
let PhoneNumberRegExp = /^[\+]?[ ]?([0-9][0-9]?[0-9]?)[ ]?\(([0-9][0-9][0-9])\)[ ]?([0-9][0-9][0-9])\-([0-9][0-9][0-9][0-9])$/
// Example matching string: "+1 (534) 953-6345"
match str
case PhoneNumberRegExp of ("1", "800", any, let lineNumber)
....
end
With a pattern method, we could instead write:
match str
case PhoneNumberPattern of ("1", "800", any, let lineNumber)
....
end
Where PhoneNumberPattern
would be a pattern method defined as:
pattern PhoneNumberPattern() of (countryCode, areaCode, prefix, lineNumber) in string
accept optional "+"
countryCode = accept Repeated(Digit, 1, 3)
accept optional " "
accept "("
areaCode = accept Repeated(Digit, 3)
accept ")"
accept optional " "
prefix = accept Repeated(Digit, 3)
accept "-"
lineNumber = accept Repeated(Digit, 4)
accept end
end
A pattern method is written similarly to a standard method only it employs the accept
keyword which implicitly "advances" the recognizer whenever a pattern is matched:
.... = accept .... // Require the next member of the stream to match the given pattern and return it
.... = accept optional .... // Try to match the given pattern and return it, or skip if failed
accept end // Accept only if the stream ended
In the above example, Repeated
and Digit
are references to secondary pattern methods.
Digit
can be defined as:
pattern Digit() of (value) in string
value = accept if it in { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" }
end
accept if ....
will accept only if the given condition is satisfied. The it
keyword represents the target captured value (in string
it defaults to a single character). In general in accept <pattern> if it ....
it
would represent the subsequence captured by the pattern.
Repeated
is a more complex, higher-order pattern method parameterized over any underlying pattern, as well as for any stream type (which includes strings). Its implementation is included in a future section about abstract patterns.
The try
... else try
...else
block enables a limited form of transactional execution where multiple branches are attempted in turn until one of them succeeds (or otherwise the input is rejected). Whenever a rejection occurs within a branch, its assignments are rolled back.
Here's an illustrative example which will recognize and parse a date with any one of "/"
, "-"
or "."
as separator characters:
pattern Date() of (day, month, year) in string
// Will recognize a date like "21/5/1999" or "13-7-2020"
day = accept IntegerNumber(1, 31)
try
accept "/"
month = accept IntegerNumber(1, 12)
accept "/"
else try
accept "-"
month = accept IntegerNumber(1, 12)
accept "-"
else try
accept "."
month = accept IntegerNumber(1, 12)
accept "."
end
year = accept IntegerNumber
accept end
end
match str
case Date of (1, 12, let year >= 2005)
....
end
end
Pattern methods can recognize and parse patterns that go well beyond the constraints of regular languages.
We could rewrite the previous example such that the pattern method would be parameterized by an arbitrary set of separator characters:
pattern Date(seperatorCharacterSet: Set<string>) of (day, month, year) in string
day = accept IntegerNumber(1, 31)
let seperator = accept if it in seperatorCharacterSet
month = accept IntegerNumber(1, 12)
accept separator // The accepted character must be the same as the one previously captured
year = accept IntegerNumber
accept end
end
match str
case Date({"/", "-", "."}) of (1, 12, let year >= 2005)
....
end
end
Pattern methods can be used for arbitrary streams. Here it is used to recognize patterns in sequences of various types:
// Recognizes a sequence of exactly three primes p1, p2, p3
pattern ThreePrimes() in Stream<integer>
predicate isPrime(num) => ....
for _ in 1..3
accept if isPrime(it)
end
accept end
end
// Recognizes 2, 4, 6, 8, 10, ....
pattern EvenNaturalNumberSeries() in Stream<integer>
let evenNumbers = (n in 1.. where n mod 2 == 0) => n
for i in evenNumbers
try
accept if it == i
else try
accept end
end
end
end
// Recognizes a stream of ascending twin prime tuples like:
// (3, 5), (5, 7), (11, 13), (29, 31), ....
type IntegerPair = (first: integer, second: integer)
pattern TwinPrimesSequence() in Stream<IntegerPair>
predicate isPrime(num) => ....
pattern TwinPrimes in Stream<IntegerPair>
accept if it.second == it.first + 2 and isPrime(it.first) and isPrime(it.second)
end
for previousLowPrime = -1
try
(p1, _) = accept TwinPrimes if it.first > previousLowPrime
continue previousLowPrime = p1
else try
accept end
break
end
end
end
Sometimes it is useful to "peek" on one or more upcoming elements of the stream without advancing its position.
The expect
keyword acts similarly to accept
, only without advancing the current position in the stream.
For example in order to define a pattern that parses the content of a simplified HTML <title>
element:
pattern TitleXMLElement() in string
accept "<title>"
repeat
try
expect "</title>"
break // break out of the loop without advancing the read position
else try
accept Letter
end
end
accept "</title>" // since the stream position has not advanced, this should always succeed
end
More generally, this approach can be used to define a pattern which would accept anything until a stop pattern is encountered. This example relies on a higher-order pattern, which is introduced in the next section:
pattern AnythingUntil<T>(StopPattern: pattern in Stream<T>) of (results: List<T>) in Stream<T>
repeat
try
expect StopPattern
break // break out of the loop without advancing the read position
else try
results |= accept any
// 'results' acts similarly to a named return variable
// It can be incrementally updated,
// However, it can only be read when assigned back to itself
end
end
end
The match
syntax can also apply to abstract pattern types.
An abstract pattern may be expressed using a polymorphic type signature like:
type MyAbstractPattern = pattern() of (integer, boolean) in string
And then can be used to parameterize a function over any pattern that matches a given type signature. For example:
function recognizeThis(str: string, p: MyAbstractPattern, expectedValues: (integer, boolean))
match str
case p of expectedValues:
return true
otherwise
return false
end
end
end
pattern MyPattern() of (value, ok) in string
value = accept IntegerNumber
accept " "
try
accept "Yes"
ok = true
else try
accept "No"
ok = false
end
accept end
end
recognizeThis("42 Yes", MyPattern, (42, true)) // returns true
recognizeThis("10 No", MyPattern, (20, false)) // returns false
Here's an implementation of the Repeated
pattern mentioned in a previous section. It defines a higher-order pattern accepting an abstract pattern of polymorphic type.
type AnyPattern<T> = pattern() in Stream<T>
pattern Repeated<T>(p: AnyPattern<T>, minTimes: integer, maxTimes: integer)
of (results: List<T> = [])
in Stream<T>
if minTimes >= 1
for _ in 1..minTimes
results |= accept p
end
end
for _ in minTimes..maxTimes
try
results |= accept p
else
break
end
end
end
pattern Repeated<T>(p: AnyPattern<T>, times: integer) of (results: List<T> = []) in Stream<T>
results = accept Repeated(p, times, times)
end
Since pattern methods may reject some inputs, it is not possible to directly unpack via a pattern, say, with this kind of hypothetical syntax:
let str = "5/11/1972"
Date of let (day, month, year) = str // What would be assigned if the string is rejected?
Instead, the matches
operator, which was mentioned in a previous chapter, allows to conditionally "unpack" through the pattern, as well as safely handle the case where the input is rejected:
let str = "5/11/1972"
if str matches Date of let (day, month, year)
....
else
....
end
A simple pattern expression, like one that's used in match
statements and expressions:
case [let first < 0, let second != first, let ...rest]
....
end
can be made reusable by wrapping it in a pattern method:
pattern SomeListPattern() of (first, second, rest) in List<integer> = // note the '=' operator
[first < 0, second != first, ...rest]
end
and then applied via its method name and signature:
case SomeListPattern of let (first, second, rest)
....
end
The previous section suggests pattern methods may also describe simpler patterns, which could accept non-stream inputs like tuples, objects, or even unary values like integers or decimals, as well, for example:
pattern SuccessiveNumbers() of (first, second) in (integer, integer) =
[first, second == first + 1]
Or even:
pattern EvenNumber() in integer =
it mod 2 == 0
We can extend conventional pattern methods to support this as well, but that would mean there would only one accept
or reject
statement allowed (since there is only one input value):
pattern SuccessiveNumbers() of (first, second) in (integer, integer)
(first, second) = accept if it[2] == it[1] + 1
end
Here's a pattern method that tests if a number is a composite (non-prime) and captures its prime factors:
pattern CompositeNumber(primeFactors, isHighlyComposite) in integer
let number = accept if not isPrime(it)
primeFactors = getPrimeFactors(number)
isHighlyComposite = isHighlyComposite(number)
end
action printPrimalityInfo(match someNumber: integer)
case PrimeNumber
print("Prime!")
case CompositeNumber of (let factors, false)
print("Composite! with prime factors {factors}")
// (CompositeNumber pattern doesn't need to be recomputed since previous result is cached)
case CompositeNumber of (let factors, true)
print("Highly composite! with prime factors {factors}")
end
printPrimalityInfo(97) // prints "Prime!"
printPrimalityInfo(100) // prints "Composite! with prime factors 2, 5"
printPrimalityInfo(60) // prints "Highly composite! with prime factors 2, 3, 5"
Now there may be times where we wish to apply this kind of simple unary pattern matching to input types that are conventionally interpreted as streams, like string
s, List
s or even abstract Stream< >
objects. For these cases, accept all
enables the entire (or remaining) input to be captured all at once:
pattern FirstCharacterSameAsLast() of (first, last) in string
try
// Accept when string is empty
accept end
first = ""
last = ""
else try
[first, ..., last] = accept all // The entire string is consumed here
// Reject if first and last characters don't match
if first != last
reject
end
end
end
Island provides a statically typed logic programming system integrating closely with the rest of the language.
The relation
declaration defines a relation, which, in a sense, expands over the concept of a function and provides a way to express and query multi-directional dependencies between its parameters (also referred to as terms).
With respect to its output, a relation is, in practice, a lot like a stream method yielding a sequence of tuples where each tuple in the sequence represent a satisfying assignment for its terms.
A relation fact
statement defines a fact, which is like an axiom containing a combination of term values that is always true.
A relation rule
defines a conjunction of one or more clauses (also called goals) where a set of input arguments (which may be either assigned or unassigned) are flowed through them. If a goal cannot be satisfied with the given arguments the inference engine backtracks to the previous goal and proceeds with its next satisfying assignment. This continues either until the final goal is satisfied or all the possible alternatives are exhausted.
Here's the classic parent-siblings example expressed in Island's logic programming syntax:
class Family
relation Parent
fact ("Alice", "James")
fact ("Alice", "Angela")
fact ("Tom", "John")
end
relation Siblings
rule (sibiling1: string, sibling2: string)
Parent(let someParent, sibling1)
Parent(someParent, sibling2)
NotEqual(sibling1, sibling2)
end
end
end
let family = Family()
// Remember that since Parent returns a stream
// Getting its first result would require stepping once through the stream
// The 'exists' expansion property returns true if a stream yields at least one value
// The 'first' expansion property returns the first value yielded
family.Parent("Alice", ?).exists // returns true
family.Parent("Alice", "Angela").exists // returns true
family.Parent("Alice", "Angela").first // returns ("Alice", "Angela")
family.Parent("Alice", "John").exists // returns false
family.Parent(?, "John").exists // returns true
family.Parent(?, "John").first?.parent // returns "Tom"
for (sibling1, sibling2) in family.Siblings(?, ?)
print("({sibling1}, {sibling2})")
// prints "(James, Angela)", "(Angela, James)"
(Note that the order of sibling1
and sibling2
is significant for the inference engine - since it has no way to know the Sibling
relation is symmetric - the results included what appears like duplicates, in the next example we'll apply .distinctUnorderedPairs()
on the result sequence to filter out the duplicates)
A relation's fact database may be non-destructively altered using the with
operator, applied over the containing object:
let alteredFamlily = family with
Parent("Alice", "Lea")
Parent("Alice", "Chris")
no Parent("Alice", "James")
for (sibling1, sibling2) in alteredFamlily.Siblings(?, ?).distinctUnorderedPairs()
print("({sibling1}, {sibling2})")
end
// prints "(Angela, Lea)", "(Angela, Chris)", "(Lea, Chris)"
Here is factorial defined as a relation:
relation Factorial
fact (0, 1)
rule (number: integer, result: integer)
GreaterThan(number, 0)
Subtraction(number, 1, let previousNumber)
Factorial(previousNumber, let previousFactorial)
Product(number, previousFactorial, result)
end
end
print(Factorial(5, ?).first) // Prints "(5, 120)"
print(Factorial(5, ?).first.result) // Prints "120"
// Since Factorial is a relation we could potentially query for any one of its parameters
// Here we'll query which number has the factorial of 5040
print(Factorial(?, 5040).first) // Prints "(7, 5040)"
print(Factorial(?, 5040).first?.number) // Prints "7"
In order to express numeric relations like GreaterThan
, Subtraction
and Product
we will need some means to link relations with plain functions:
- The
predicate
declaration defines a simple predicate function returningtrue
orfalse
. - The
function
declaration defines a simple function returning a tuple representing a single term assignment - The
stream
declaration defines a stream method yielding a sequence of term assignments. - Every relation method parameter must be annotated as either
in
orout
, wherein
parameter must be returned unmodified in the resulting tuple andout
parameters must receive a value.
For example, here's how the GreaterThan
relation is defined. There are several overloads defined for different combinations of input and output terms (modified by in
and out
respectively):
The (in, in)
overload, in which both terms are known values is implemented using a predicate function, simply tests whether the first term is greater than the second one:
relation GreaterThan
predicate (in num1: integer, in num2: integer) => num1 > num2
end
The (in, out)
overload, in which the first term is a value and the second unknown, defines a stream method yielding a sequence of tuples where the first element is always (and must always be!) num1
and the second enumerates all the values smaller than num1
.
relation GreaterThan
stream (in num1: integer, out num2: integer)
for i = num1 - 1 advance i -= 1
yield (num1, i)
end
// yields (num1, num1 - 1), (num1, num1 - 2), (num1, num1 - 3), ...
end
end
The (out, in)
overload defines a stream method yielding a sequence of tuples where the first element enumerates all the values greater than num2
and the second is always num2
.
relation GreaterThan
stream (out num1: integer, in num2: integer)
for i = num2 + 1 advance i += 1
yield (i, num2)
end
// yields (num2 + 1, num2), (num2 + 2, num2), (num2 + 3, num2), ...
end
end
Since in
parameters are always returned as-is in the resulting tuple, we can avoid stating them in the yield
statement by using continue
- like syntax, which allows omitting arguments that were not assigned by the function. The following is equivalent:
relation GreaterThan
stream (in num1: integer, out num2: integer)
for i = num2 + 1 advance i += 1
yield num2 = i // implicitly equivalent to yielding the tuple (num1, i)
end
end
end
(An (out, out)
overload might also be defined, in theory, though it would be significantly more challenging to implement as it would need to somehow "fairly" cover all possible combinations of two integers where the first one is greater than the second)
Here's how the Subtraction
relation is defined:
relation Subtraction
predicate (in num1: integer, in num2: integer, in difference: integer) =>
num1 - num2 == difference
function (in num1: integer, in num2: integer, out difference: integer) =>
(num1, num2, num1 - num2)
function (in num1: integer, out num2: integer, in difference: integer) =>
(num1, num1 - difference, difference)
function (out num1: integer, in num2: integer, in difference: integer) =>
(num1 + num2, num2, difference)
// For illustration, here's a stream method defined for the more complex
// case where both num1 and num2 are unknowns:
stream (out num1: integer, out num2: integer, in difference: integer)
yield (0, 0 - difference, difference) // yield the case for num1 == 0
// Alternate between positive and negative values for num1
for i = 1 advance i += 1
yield (i, i - difference, difference)
yield (-i, -i - difference, difference)
end
// For the case where difference = 2 this would yield:
// (0, -2, 2), (1, -1, 2), (-1, -3, 2), (2, 0, 2), (-2, -4, 2), ....
end
The if
keyword allows branch-like functionality for relation blocks:
Here's the infamous "Fizz-Buzz" problem, this time implemented using a relation:
relation Divides
predicate (in x, in y) => x mod y == 0
end
relation FizzBuzz
rule (index: integer, output: string)
InRange(index, 1, infinity)
if Divides(index, 15)
Equals(output, "FizzBuzz")
else if Divides(index, 3)
Equals(output, "Fizz")
else if Divides(index, 5)
Equals(output, "Buzz")
else
Equals(output, "{index}")
end
end
end
FizzBuzz(30, "Buzz").exists // returns false
FizzBuzz(30, ?).exists // returns true
FizzBuzz(30, ?).first?.output // returns "FizzBuzz"
FizzBuzz(30, "FizzBuzz").exists // returns true
for (_, str) in FizzBuzz(?, ?)
print(str)
end
// prints "1", "2", "Fizz", "4", "Buzz", "Fizz" ....
if
blocks also handle cases where the conditional cannot be resolved:
For example, in the case where FizzBuzz(?, "Fizz")
is queried, since index
isn't bound to anything on the if
conditional, the inference engine unconditionally evaluates the branch, as well as any other unresolvable conditional branches, which in this example includes all of them (the otherwise
branch is considered unresolvable as well):
for (index, _) in FizzBuzzer.FizzBuzz(?, "Fizz")
print(index)
// prints 3, 6, 9, 12, 18, 21, ....
end
Here's the absolute value implemented as a relation using an if
conditional:
relation Abs
rule (number: integer, abs: integer)
if GreaterThanOrEquals(number, 0)
Equals(number, abs)
else
Negation(number, let negation)
Equals(abs, negation)
end
end
end
Abs(-65, 100).exists // returns false
Abs(-65, 65).exists // returns true
Abs(-65, ?).first?.abs // returns 65
Abs(?, 65).first?.number // returns -65
// This will query for any two numbers where the second is the absolute value of the first:
for (number, abs) in Abs(?, ?)
print("({number}, {abs})")
// prints "(0, 0)", "(-1, 1)", "(1, -1)", "(-2, 2)", ....
// (As a heuristic, the inference engine alternates between the unresolvable conditional
// branches to avoid getting "trapped" in case one of them produces an infinite
// amount of results)
end
Sometimes we would like to iterate over all the possible results of a relation. A common case would be when asserting over a property of a list:
relation MemberOf
predicate (in member, in list: List<integer>) => list.includes(member)
function (out member, in list: List<integer>) = for x in list => (x, list)
end
relation AllMembersGreaterThan
rule (in list: List<integer>, smallerValue: integer)
foreach MemberOf(let member, list)
GreaterThan(member, smallerValue)
end
end
end
AllMembersGreaterThan([3, 2, 4, 5], 3).exists // returns false
AllMembersGreaterThan([3, 2, 4, 5], -5).exists // returns true
It would be interesting to consider the query AllMembersGreaterThan([3, 2, 4, 5], ?)
, which asks for one or more values that are smaller than all the elements of the list.
Let's consider the execution of:
AllMembersGreaterThan([3, 2, 4, 5], ?)
If we were to unroll the foreach
loop to multiple steps it would look somewhat like:
GreaterThan(3, smallerValue) // Produces 2, 1, 0, -1, -2, -3, .... for smallerValue
GreaterThan(2, smallerValue)
GreaterThan(4, smallerValue)
GreaterThan(5, smallerValue)
Once smallerValue
receives a value in GreaterThan(3, ?)
the next evaluations of GreaterThan
test for that value. If it doesn't satisfy them, the inference engine backtracks until a value for smallerValue
is found that satisfies all the members (i.e. in this case 1
, which is smaller than all the members).
As you may notice this is a highly inefficient way to calculate this! Consider the case where list = [3, 2, 4, -1000000]
. It would require more than one million backtracking attempts to find the first satisfying result -1000001
!
A more efficient way would be to to define an overload based on a relation function
that quickly finds the minimum of the list and yields all the values smaller than it:
relation AllMembersGreaterThan
function (in list: List<integer>, out smallerValue: integer) =
for i in (list.minimum() - 1)..(-infinity) => (list, i)
end
Note that to ensure that the (in, out)
case is never processed by the slower, backtracking-based overload, its smallerValue
parameter can be marked as in
:
relation AllMembersGreaterThan
rule (in list: List<integer>, in smallerValue: integer)
....
end
In the next section we'll show a purely rule-based way to efficiently implement the AllMembersGreaterThan
relation, which makes use of the reduce
higher-order relation.
Just like functions can accept other functions as arguments, we can define relations that accept other relations as arguments:
Here's an implementation of the map
function, generalized to a relation:
relation Mapped<E, R>
type MappingRelation = relation(value: E, resultValue: R)
fact ([], [], ?)
rule ([head, ...tail]: List<E>, [resultHead,...resultTail]: List<R>, in mappingRelation: MappingRelation)
mappingRelation(head, resultHead)
Mapped(tail, resultTail)
end
end
Here's an example that represents a mapping between one list to a second list containing its members' preceding values:
relation Successor
predicate (in x: integer, in y: integer) => x == y + 1
function (in x: integer, out y: integer) => (x, y - 1)
function (out x: integer, in y: integer) => (y + 1, y)
end
let l1 = [2, 3, 4, 5]
let l2 = [1, 2, 3, 4]
Mapped(l1, l2, Successor).exists // returns true
Mapped(l1, ?, Successor).first // returns ([2, 3, 4, 5], [1, 2, 3, 4], Successor)
Mapped(l1, ?, Successor).first?[2] // returns [1, 2, 3, 4]
Mapped(?, l2, Successor).first?[1] // returns [2, 3, 4, 5]
Mapped(?, l1, Successor).first?[1] // returns [3, 4, 5, 6]
Here's reduce
expressed as a higher order relation:
relation Reduced<E, R>
type ReducingRelation = relation(in element: E, in currentResult: R, newResult: R)
fact ([], any, any, nothing)
rule (in [head, ...tail]: List<E>,
in reducer: ReducingRelation,
in initialResult: R,
result: R)
reducer(head, initialResult, let newResult)
if Equals(tail, [])
Equals(result, newResult)
else
Reduced(tail, reducer, newResult, result)
end
end
end
Using the reduced
relation we now can provide a more efficient rule-based implementation for AllMembersGreaterThan
:
relation MinimumOf2
rule (val1: integer, val2: integer, minimum: integer)
if SmallerOrEqual(val1, val2)
Equals(minimum, val1)
else
Equals(minimum, val2)
end
end
end
relation SmallestValue
fact ([], nothing)
rule (in values: List<integer>, smallestValue: integer)
Reduced(values, MinimumOf2, infinity, smallestValue)
end
end
relation AllMembersGreaterThan
rule (in values: List<integer>, smallerValue: integer)
SmallestValue(list, let smallestValue)
SmallerThan(smallestValue, smallerValue)
end
end
Relations are fully immutable. A rule's arguments can be either assigned (meaning they are bound to a concrete value) or unassigned. If they are assigned they cannot accept a new value (there's simply no mechanism to do that) and if they are unassigned they can only be assigned once by a subgoal.
Determinism (which is related to the property of referential transparency), means that an invocation of a relation with the same arguments would always yield the same results, and in the same order. Determinism is preserved since:
- There is no
action relation
. Relations do not have any side-effects. - Objects containing relations cannot be modified in-place. Facts cannot be added or removed from a relation unless the object is copied first (as is done with the
with
operator). - The inference engine is designed to always perform the search in the same order (even if the search is parallelized), so given the same clauses and fact database, it would always produce identical results (more specifically, it is meant that it would produce the same results for a given runtime session, changes in the ordering of declarations or files, or different versions of the compiler, might cause variations in the ordering).
Knowledge-driven programming is a form of declarative programming where programs are structured around semantically encoded information entities, rather than computations. It does not involve conventional subroutines (i.e. named functions or relations). Instead, knowledge-driven programs specify general inference rules describing methods for generating new knowledge from existing knowledge.
Knowledge-driven programs (or program subsets, if within a hybrid language) are synthesized by composing a computational graph mapping an initial set of known information entities to a target set of unknown information entities.
Unlike logic programs, knowledge-driven programs don't involve any runtime search. All planning and synthesis is done at compile-time, such that the resulting runtime code can be optimized to run at a performance closer to the machine's native capabilities.
A knowledge-driven program consists of contexts, properties and mapping rules.
A context is a knowledge schema in which information entities (properties) and inference rules (mappings) can be defined.
A property is an information entity representing a unique semantic identity.
A mapping rule is an unnamed inference rule specifying a method for deriving one or more unknown properties from one or more known properties, within a given context.
A context instance (also called a knowledge scope) is a materialized form of a context, analogous to how an object is a materialized form of a class. A context instance can be viewed as a simple immutable knowledge base. It can be initialized with a set of known property values, and then queried for unknown ones.
For example, this context describes the basic kinematic relations between distance, time and speed.
context BasicKinematics
distance: decimal
time: decimal
speed: decimal
distance given time, speed => time * speed
time given distance, speed => distance / speed
speed given distance, time => distance / time
end
.... given ....
defines a mapping rule that specifies a method for a property to be computed given the knowledge of the values of other properties.
If the set of required properties can be automatically inferred from the body of the rule, the given
clause may be omitted:
context BasicKinematics
distance: decimal
time: decimal
speed: decimal
distance => time * speed
time => distance / speed
speed => distance / time
end
A context may be instantiated similarly to a class, though unlike a class, it has no minimal set of required members. All of its properties are effectively "optional", in a sense, as they may be either provided or inferred using one or more of its mapping rules (or alternatively, they may not be knowable at all - yet the instantiation would still be perfectly valid).
We'll instantiate the BasicKinematics
context with values for distance
and time
:
let kinematics = BasicKinematics with distance = 10.0, time = 5.0
Once instantiated, its properties may be referenced directly, as if they were values. We'll query for the speed
property:
let speed = kinematics.speed // 'speed' gets the value 2.0
Despite the fact that no value was provided for speed
, the compiler was able to locate a set of mapping rules that enabled it to be computed, given the information we provided (here distance
and time
). The details of the particular rules the compiler selects are not a part of the program itself. The compiler may choose any rules it decides on, including rules the programmer is not aware of.
In this case, only one simple rule was needed:
speed => distance / time
However, consider a slightly more complex case, where an additional property is introduced:
context BasicKinematics
distance: decimal
time: decimal
speed: decimal // Assume this property is measured in meters per seconds
speedInMph: decimal
distance => time * speed
time => distance / speed
speed => distance / time
speedInMph => speed * 2.23694
speed => speedInMph / 2.23694
end
Now querying for speedInMph
let kinematics = BasicKinematics with distance = 10.0, time = 5.0,
let speed = kinematics.speedInMph // 'speed' gets the value 4.47388
requires two rules:
speed => distance / time // speed = 2.0
speedInMph => speed * 2.23694 // speedInMph = 4.47388
So far, this may not look much different than computed fields, albeit with the ability to define distinct computations for different combinations of known and unknown properties. In the next sections we'll introduce the concepts of embeddings, preconditions and semantic associations, which should demonstrate how its capabilities go well beyond being just a form of "computed fields on steroids".
At the end of the previous section we've mentioned the notion of describing speed in a unit other than the default (say, in miles per hour instead of meters per second).
If we wanted to include additional measurement units, we could add more properties and mapping rules to BasicKinematics
, but that wouldn't be a good style. Instead, it would be better to define a new context dedicated only for speed units, for example:
context Speed
metersPerSecond: decimal
milesPerHour: decimal
kilometersPerHour: decimal
metersPerSecond => milesPerHour / 2.23694
milesPerHour => metersPerSecond * 2.23694
metersPerSecond => kilometersPerHour / 3.6
kilometersPerHour => metersPerSecond * 3.6
end
But now we need some way to "combine" the knowledge we've expressed in this secondary context with the one in BasicKinematics
.
We can do that by embedding Speed
inside of BasicKinematics
:
context BasicKinematics
distance: decimal
time: decimal
speed: Speed
distance => time * speed.metersPerSecond
time => distance / speed.metersPerSecond
speed.metersPerSecond => distance / time
end
When a context is embedded in this way, its properties effectively become "namespaced" in the parent context, so they can be accessed from within mapping rules, or become their target - such as the rule that computes speed.metersPerSecond
above, as if metersPerSecond
was a part of the parent context itself.
Now speed can be read in multiple units of measurement:
let kinematics = BasicKinematics with time = 5.0, distance = 10.0
let speedInMph = kinematics.speed.milesPerHour
let speedInKph = kinematics.speed.kilometersPerHour
As well as be provided in units other than metersPerSecond
:
let kinematics = BasicKinematics with time = 5.0, speed.milesPerHour = 15.0
let distance = kinematics.distance
The notion of providing a value to a nested property like speed.milesPerHour
may seem a bit strange at first since it isn't something we're used to do with classes and objects, but remember that the embedded context really does become an integral part of the parent context, and that contexts, unlike classes, don't have a predefined set of required members, so a notation like speed.milesPerHour = 15.0
may make more sense, as there's no need to think of Speed
as needing to be "constructed" as an independent entity.
Properties may be set with default values. Default values must be knowable at compile-time.
context Person
name = "Anonymous" // type of 'name' is inferred as 'string'
age: integer
end
let person1 = Person with age = 20 // 'person1.name' gets te default value 'Anonymous'
let person2 = Person with name = "Ines", age = 20 // 'person2.name' gets the value 'Ines'
A property specifying a default value cannot be the target of a mapping rule:
context Person
name = "Anonymous"
age: integer
nickname: string
name => nickname // This is invalid, since 'name' is assured to always have a value
end
A mapping rule precondition is a predicate that must be satisfied in order for a mapping rule to be available for use.
The simplest form of a precondition is a predicate dependent on the truth-value of a Boolean property, like in this example:
context AbsoluteValue
input: decimal
result: decimal
inputIsNegative: boolean
inputIsNegative => input < 0
result given inputIsNegative == true => input * -1.0
result given inputIsNegative == false => input
end
inputIsNegative
will receive true
if input
is greater or equal to 0 and false
otherwise. Consequently, result
will receive input * -1
if inputIsNegative
is true, and input
otherwise.
An alternate rule for result
, for the case where inputIsNegative == false
, ensures the compiler can unconditionally determine that result
is always knowable when input
is known. When such a complementary rule is not provided, the property may become conditionally knowable and only be used within constrained circumstances (this variation is covered at a later section about conditionally knowable properties).
You may now realize that the ability to define simple preconditions on the truth-value of Boolean properties opens up the possibility for arbitrarily complex preconditions, since the Boolean property's mapping rules may potentially involve highly sophisticated computations.
However, introducing a new Boolean property for every precondition is not very convenient or elegant. It would be nicer to be able to use a more compact syntax. This is made possible by ad-hoc preconditions:
context AbsoluteValue
input: decimal
result: decimal
result given input < 0 => input * -1.0
result given input => input // Having no precondition is interpreted as a fallback case
end
An ad-hoc precondition like given input < 0
implicitly introduces a Boolean property and an associated mapping rule that computes its truth-value. The second rule (given input
) does not include a predicate, and acts as a fallback to "absorb" the case when input
is known but no other rule has been successfully matched to it.
Using an alternative syntax, the precondition can be refactored out to resemble the appearance of a conditional (though in fact it is not really a "true" conditional, since it doesn't introduce its own scope). This may be chosen for stylistic reasons, but will also be useful in the case where there are multiple rules sharing one or more identical preconditions:
context AbsoluteValue
input: decimal
result: decimal
given input < 0
result => input * -1.0
given input
result => input
end
Preconditions may match patterns as well as capture their component parts.
This example defines a context which recognizes and parses a phone number pattern, specified by a regular expression, where the parsed area
and number
components are introduced as variables into the body of the rule:
let PhoneNumberRegExp = /^{[0-9][0-9][0-9]}\-{[0-9]+}$/
context PhoneNumber
str: string
given str matches PhoneNumberRegExp of let (area, num)
isValid, areaCode, number => true, area, num
given str
isValid, areaCode, number => false, "", ""
end
A second example defines a context that extracts the first and last elements of a list using a pattern expression:
context MyList
items: List<integer>
given items matches [let f, …, let l]
first, last => f, l
given items
first, last => nothing, nothing
end
Notice how mapping rules can be shared by multiple properties simultaneously. This also implies that in order to compute any single property that’s included in the rule, all remaining properties have to be computed as well.
Up until now the only way to make use of contexts has been via explicit instantiation like:
let absoluteValue = AbsoluteValue with input = -11
let result = absoluteValue.result // result gets the value 11
This syntax may become too cumbersome in many cases. An alternative would be using a mapper to define a simple function-like method which accepts a set of known properties as parameters, and returns one or more unknown ones as return values:
mapper abs(AbsoluteValue.input) => AbsoluteValue.result
let x = abs(-11) // x gets the value 11
Like conventional functions, mapper signatures can be overloaded:
mapper getDistance(SimpleKinematics.Time,
SimpleKinematics.speed.metersPerSecond) => SimpleKinematics.distance
mapper getDistance(SimpleKinematics.Time,
SimpleKinematics.speed.kilometersPerHour) => SimpleKinematics.distance
Mapper parameters may receive aliases (though they are not generally necessary since semantic identities are always unique) and be invoked with named arguments:
mapper getDistance(time: SimpleKinematics.Time,
speed: SimpleKinematics.speed.kilometersPerHour) => SimpleKinematics.distance
let distance = getDistance(time = 54, speed = 75)
// Or alternatively, using full semantic identity references:
let distance = getDistance(SimpleKinematics.Time = 54,
SimpleKinematics.speed.kilometersPerHour = 75)
Mappers help make contexts more usable by enabling them to be applied via function-like method calls. However, in many cases, the opposite may also be useful. We may want to describe more trivial computations by a simpler, function-like syntax.
Pseudo-functions provide syntactic sugar to enable contexts to be declared via compact function-like declarations:
For example:
function context AddNumbers(num1: integer, num2: integer): (sum: integer)
sum = num1 + num2
end
Would be desugared to:
context AddNumbers
num1: integer
num2: integer
sum: integer => num1 + num2
mapper this(num1, num2) => sum
end
If the return variable name is not given, it can still be referenced via the default out
property. For example:
function context MultiplyByTwo(num: integer) => num * 2
Would be desugared to:
context MultiplyByTwo
num: integer
out: integer => num * 2
mapper this(num) => out
end
So far, we've only dealt with very simple problems that did not require much algorithmic "depth". Say now we want to approach a slightly more complex computations, like the factorial.
Based on the syntax we've introduced so far. We could write something like:
context Factorial
input: integer
result: integer
given input == 0 or input == 1
result => 1
end
given input > 1
result
for i = 1, out output = 1 while i <= input advance i += 1
continue output *= i
return output
end
end
given input
result => Failure("Input must be nonnegative")
end
end
Well, that might work, but wouldn't it be nicer if we could write it in a manner that is more idiomatic of the knowledge-driven style? One approach would be to recursively create an instance of Factorial
within the body of the mapping rule itself:
context Factorial
input: integer
result: integer
given input == 0 or input == 1
result => 1
end
given input > 1
result
let previousFactorial = Factorial with input = this.input - 1
return input * previousFactorial.result
end
end
given input
result => Failure("Input must be nonnegative")
end
end
This approach is called recursive instantiation, and works quite similarly to how functions may invoke themselves, or class members create an instance of their own class.
However, there's another, possibly more thought-provoking alternative. In a previous section we've embedded one context (Speed
) inside another (BasicKinematics
). What if we could embed Factorial
inside of Factorial
itself?
Long story short, it turns out there's no reason why that shouldn't be possible! There you go:
context Factorial
input: integer
result: integer
// Notice how the type of 'previousFactorial' is Factorial itself!
previousFactorial: Factorial
given input == 0 or input == 1
result => 1
given input > 1
previousFactorial.input => input - 1
result => input * previousFactorial.result
given input
result => Failure("Input must be nonnegative")
end
But how? why? Well that's because contexts are not the same as classes. They don't require a minimal amount of information to become materialized. A context instance represents a knowledge scope possibly accommodating information artifacts of various semantic identities (some of which may actually lie outside the realm of the context's own schema, as you'll see on future sections). It is not primarily intended as a data structure or as an assortment of value-bound methods.
If Factorial
is embedded inside of Factorial
itself, all that means is that an instance of Factorial
would also incorporate a secondary inner scope that happens to share its own knowledge schema, and which can be initialized with a different set of known and unknown properties than itself.
This kind of "self nesting" is called recursive embedding.
The way it's utilized in Factorial
is that there's one mapping rule that infers into the recursively embedded context:
given input > 1
previousFactorial.input => input - 1
Informally, what this mapping rule says is that 'when input is greater than one, the input of the previous factorial is same as this one, minus one'.
There's a second reference to previousFactorial
in the subsequent mapping rule:
given input > 1
....
result => input * previousFactorial.result
This one says that 'when input is greater than one, the result of this factorial is the input multiplied by the result of the previous factorial'.
Together these rules help form a declarative description of how the factorial can be computed, without the need to define explicit control flow or even ordering of operations.
This same approach can be used to describe more complex computations. For example, here is a purely knowledge-driven implementation of the quicksort algorithm:
context Quicksort
items: List<integer>
sortedItems: List<integer>
smallerThanPivot: this // 'this' type is synonymous with 'Quicksort'
greaterOrEqualToPivot: this
given items == []
sortedItems => []
given items
pivot => items[items.length div 2] // 'pivot' declaration is combined with a mapping rule
smallerThanPivot.items => [items where it < pivot]
greaterOrEqualToPivot.items => [items where it >= pivot]
sortedItems => smallerThanPivot.sortedItems | greaterOrEqualToPivot.sortedItems
end
Here are natural language translations of the mapping rules included in Quicksort
, described in an altered order:
given items == []
sortedItems => []
end
means: 'When the input is an empty list, the sorted items list is empty as well'.
and
given items
....
sortedItems => smallerThanPivot.sortedItems | greaterOrEqualToPivot.sortedItems
end
means: 'When the input item list is nonempty, the sorted items list is a concatenation of the sorted versions of the items that are smaller than the pivot and greater or equal to the pivot'.
and
given items
....
smallerThanPivot.items => [items where it < pivot]
greaterOrEqualToPivot.items => [items where it >= pivot]
end
means: 'The items fed to the "smaller than pivot" context are the input items, filtered to the ones that are smaller than the pivot. Similarly, the "greater or equal to the pivot" context is fed the items that are greater or equal to the pivot'.
and finally:
given items
pivot => items[items.length div 2]
end
means: 'The pivot is the value in the middle of the item list'.
Each context and property is associated with a unique semantic identity, which may be referenced via a local identifier (e.g. Quicksort.items
) or a global URI. This is similar to how the semantic web enables various pieces of information to be uniquely identified and their meaning precisely disambiguated.
For example, the Quicksort
context may be referenced by a URI such as:
<publisher.com/lib/Quicksort.isl#Quicksort>
and its sortedItems
property as:
<publisher.com/lib/Quicksort.isl#Quicksort.sortedItems>
Unlike the semantic web, however, the URI is also expected to be a true, functioning URL, pointing to the correct source file where the identity is defined. In this way, there is no need for libraries or modules. References to individual contexts and properties can be made via the exact location of the source file.
In terms of versioning, ideally, there shouldn't be a real need for version numbers, since semantic identities are expected to have precise and unchanging meanings.
Nonetheless, It is technically possible to publish two or more distinct semantic identities sharing the same name by including a version number in the URI path.
<publisher.com/lib/2.0.0/Quicksort.isl#Quicksort.sortedItems>
With an approach analogous to the semantic web, we could also define identities for more "abstract" concepts. For example we could define an identity for the abstract idea of a "sort":
context Sort
items: List<integer>
sortedItems: List<integer>
end
And it will similarly receive URIs like:
<publisher.com/lib/Sort.isl#Sort>
<publisher.com/lib/Sort.isl#Sort.items>
<publisher.com/lib/Sort.isl#Sort.sortedItems>
Now, what if using this more abstract context, we could somehow annotate Quicksort
as being a form of Sort
, such that by only referencing properties of Sort
the compiler could transparently make use of the mapping rules and auxiliary properties given in Quicksort
?
In object-oriented programming, what is usually done is setting Quicksort
as a "subclass" of Sort
. However, that's not really what we want to achieve. What we really want is for Quicksort.items
and Quicksort.sortedItems
to represent the exact same semantics as Sort.items
and Sort.sortedItems
, respectively. We don't want the properties of Sort
to represent something more "vague" than the properties of Quicksort
.
This subtle change in mindset opens up some very interesting possibilities. So instead of going in the traditional line of thinking of Quicksort extends Sort
. We'll do something else. We'll annotate individual properties of Quicksort
to be semantically equivalent to the corresponding properties of Sort
:
context Quicksort
items <=> <publisher.com/lib/Sort.isl#Sort.items>
sortedItems <=> <publisher.com/lib/Sort.isl#Sort.sortedItems>
....
end
These connections are called semantic links. What they mean is that every mapping rule that applies to Quicksort.items
, would also apply to Sort.items
, and vice-versa: every mapping rule that applies to Sort.items
would apply back to Quicksort.items
. Same between Sort.sortedItems
and Quicksort.sortedItems
.
This means we can now write something like:
let sortContext = <publisher.com/lib/sort.isl#Sort> with items = [5, 2, 3, 4, 1]
let result = sortContext.sortedItems // 'result' gets the value '[1, 2, 3, 4, 5]'
Notice what happened here: we've created an instance of a supposedly "abstract" context, which only defined two properties: items
and sortedItems
and no mapping rules of its own, and yet the compiler was able to find a way to transform between these properties, without the code mentioning any reference to a concrete implementation.
It is as if, in an object-oriented language, you'd create an instance of an abstract class and then "magically" expect its virtual methods to work when you call them directly. It might sound strange at first, but that's not a far-fetched analogy.
At this point you may start to realize just how powerful this idea is, and how much such a subtle alteration made it diverge from traditional object-oriented thinking.
This type of association may also be characterized as a form of knowledge augmentation as it "augments" the breadth of knowledge associated with a semantic identity. Here we've augmented the compiler's knowledge about the items
and sortedItems
properties of both Sort
and Quicksort
.
Let's try to take it even a step further. How about going back to our initial BasicKinematics
example and generalizing it such that it could work for any unit of measurement for distance, speed and time? And this time we'll use semantic links instead of embeddings, to emulate a system of "commonsense knowledge":
context CommonsenseKinematics
distance <=> <publisher.com/lib/Units.isl#Distance.meters>
speed <=> <publisher.com/lib/Units.isl#Speed.metersPerSecond>
time <=> <publisher.com/lib/Units.isl#Time.seconds>
distance <=> speed * time
end
distance <=> speed * time
is an example of a bidirectional mapping rule. It shares the same notation as a semantic link, but it isn't really the same thing. It is an abbreviated way to define multiple complementary mapping rules that are composed of simple, invertible, algebraic operations like addition, multiplication and division.
The Distance
, Speed
and Time
contexts are defined as:
(for brevity some property declarations have been combined with bidirectional mapping rules)
context Distance
meters: decimal
kilometers <=> meters * 1000
feet <=> meters * 0.3048
yards <=> feet * 3
miles <=> yards * 1760
end
context Speed
metersPerSecond: decimal
kilometersPerHour <=> metersPerSecond * 3.6
milesPerHour <=> metersPerSecond * 2.23694
end
context Time
seconds: decimal
minutes <=> seconds * 60
hours <=> minutes * 60
end
Now we can write something like:
let speedMph = Speed.milesPerHour given // speedMph gets the value 17.04545
Distance.yards = 1500,
Time.minutes = 3
let distanceMiles = Distance.miles given // distanceMiles gets the value 156.11951
Speed.kilometersPerHour = 33.5,
Time.hours = 7.5
// Same computations, but generalized to reusable mappers:
mapper computeSpeedMph(Distance.yards, Time.minutes) => Speed.milesPerHour
mapper computeDistanceMiles(Speed.kilometersPerHour, Time.hours) => Distance.miles
We've used a form of syntax we haven't seen before: let x = .... given ....
.
This form of expression poses an "abstract" semantic query that may mix semantic identities from various different contexts. Notice the code never mentioned any reference to CommonsenseKinematics
. Instead, the rules CommonsenseKinematics
exported, via semantic linking, became attached to Speed
, Distance
and Time
and the compiler was able to figure out how to compose a series of computations, which included numerous unit conversions, to successfully derive the desired information.
In fact, what this "query" notation actually does, behind the scenes, is to define an anonymous ad-hoc context where each property is associated with a particular semantic identity in a one-way fashion. The first query, when de-sugared, would look roughly like:
context AdHocContext
distanceYards: Distance.yards
speedMph: Speed.milesPerHour
timeMinutes: Time.minutes
end
let speedMph = (AdHocContext with distanceYards = 1500, timeMinutes = 3).speedMph
This "one-way" kind of association is called a semantic role. It will be covered in the next section.
A semantic role provides a way to link a local property to a foreign property without fully embodying its semantics.
A property may take up any number of distinct roles. Each role can only be taken once. A role cannot be shared between two or more properties within the same context.
A role, unlike a semantic link, does not cause mapping rules involving the representing (i.e. local) property to apply back to the represented (i.e. foreign) property.
Therefore it cannot really be said to be a form of knowledge augmentation, but rather of knowledge specialization.
Consider a very simple example. Say we wanted to define a context that would contain a name and also include a property containing its all-uppercase version, as well as an all-lowercase one.
First we'll define two contexts that describe the uppercase and lowercase transforms:
context Uppercase
plain: string
uppercase: string
....
end
context Lowercase
plain: string
lowercase: string
....
end
Next define the main context. We'll use some helper mappers to simplify the code:
mapper uppercase(Uppercase.plain) => Uppercase.uppercase
mapper lowercase(Lowercase.plain) => Lowercase.lowercase
context Name
name: string
nameUppercase: string => uppercase(name)
nameLowercase: string => lowercase(name)
end
That is okay, but there's a simpler way. We can use semantic roles to say that the name
property "pretends" to be a plain (unprocessed) string, with respect to the semantics of Uppercase
and Lowercase
, and that nameUppercase
and nameLowercase
"pretend" to act like their respective processed properties (Uppercase.uppercase
and Lowercase.lowercase
):
context Name
name: Uppercase.plain, Lowercase.plain
nameUppercase: Uppercase.uppercase
nameLowercase: Lowercase.lowercase
end
This looks much simpler.
The neat thing about it is that there's not even a need to introduce any mapping rules. The behaviors we wanted emerged naturally just by annotating a few "tags" in strategic positions. There wasn't even a need to say that name
, or any of the other properties, have the type string
, since it also followed from the annotations.
On a more technical note, what is actually happening here is that the Uppercase
and Lowercase
contexts are effectively being "superimposed" over the Name
context. This also means that any other properties they may have had could have been inferred with values, but would be totally "invisible" unless they were exposed in the form of roles within Name
. This kind of "layering" could be described as a weak form of information hiding that's quite different than how it's expressed in traditional object-oriented programming.
Now, it also turns out that roles can emulate some of the hierarchical relationships that we are used to in object-oriented programming, albeit in a more granular fashion.
Consider this classic example used to demonstrate object-oriented hierarchical relationships:
context Shape
area: decimal
end
context Circle
area: decimal => Pi * (radius ** 2)
radius: decimal
end
context Square
area: decimal => side ** 2
side: decimal
end
We want to describe something called Shape
that has an area
property. And two other things called Circle
and Square
that also have an area
property, as well as other properties we don't necessarily care about here (radius
and side
).
The traditional way would be to say that Circle extends Shape
and Square extends Shape
but that's not what we're going for (in fact, contexts don't actually support the extends
keyword at all).
What we'll do instead is say that the area
property of Circle
and Square
"represents" the area property of the Shape
context:
context Shape
area: decimal
end
context Circle
area: Shape.area => Pi * (radius ** 2)
radius: decimal
end
context Square
area: Shape.area => side ** 2
side: decimal
end
Now let's pose a scenario that will help us understand the meaning of this relationship. Say we define another context that holds two Shapes, and contains a property, together with a mapping rule that computes their total area:
context TwoShapes
shape1: Shape
shape2: Shape
totalArea: decimal => shape1.area + shape2.area
end
Now I write something like this:
let twoShapes = TwoShapes with
shape1 = Circle with radius = 5.0
shape2 = Square with side = 7.5
let totalArea = twoShapes.totalArea // Is this always computable?
I assigned a placeholder for a Shape
with instances of Circle
and Square
, despite the fact there are no formal relationships between these types!
Both Circle
and Square
have area
properties that embrace the semantics of the area
property of Shape
, so it makes sense that they can substitute for it. However, it might sound surprising but this relationship wasn't strictly necessary to enable the substitution. Fundamentally, there are no fixed hierarchies, any context type can be assigned to any other context type.
More formally, this kind of type relationship may be described as a form of ad-hoc behavioral subtyping.
But how does the compiler determine these assignments are safe? and how does it know if totalArea
is computable at all? We never initialized an explicit value to the area
properties of Circle
or Square
. How can it be confident that shape1.area + shape2.area
even means anything?
The answer is that there is no general way for the compiler to immediately determine that a given property is knowable. Instead, the compiler performs a localized analysis of each property reference and tries to sort out, on a case by case basis, which referenced properties are knowable, and which aren't. This form of static analysis is achieved by employing instance types, which are the subject of the next section.
An instance type is a form of refinement type used by the compiler to contextually model the knowability of different properties, given the particular circumstances of the surrounding code.
Let's look at the last code example from the previous section:
let twoShapes = TwoShapes with
shape1 = Circle with radius = 5.0
shape2 = Square with side = 7.5
let totalArea = twoShapes.totalArea
I'll try to demonstrate how the compiler ensures the reference to twoShapes.totalArea
is safe.
First, say we wrote something simpler like:
let circle = Circle with radius = 5.0
The compiler would infer the type of circle
as:
Circle with radius, area
It is clear why radius
is included, since it was assigned an explicit value, but why is area
there?
The answer is that from a knowledge-driven perspective, there's no substantial distinction between an information entity that is provided as "fact" and one that's computed. They are both considered knowable.
Now by using this method, the compiler can prove that twoShapes.totalArea
is knowable:
The expression Circle with radius = 5.0
gets the type Circle with radius, area
The expression Square with side = 7.5
gets the type Square with side, area
Now once assigned into the shape1
and shape2
properties of the TwoShapes
context, these types are both cast to the type Shape with area
.
Now twoShapes
consequently receives the type TwoShapes with shape1.area, shape2.area, totalArea
and thus the twoShapes.totalArea
property has been statically proven to be knowable:
let twoShapes = TwoShapes with
shape1 = Circle with radius = 5.0 // 'shape1' gets the type 'Shape with area'
shape2 = Square with side = 7.5 // 'shape2' gets the type 'Shape with area'
// 'twoShapes' gets the type 'TwoShapes with shape1.area, shape2.area, totalArea'
let totalArea = twoShapes.totalArea // totalArea has been proven to be computable
Instance types can be stated explicitly as an ad-hoc way to specify a set of required members for a context instance. This enables the context instance to emulate some of the characteristics of a traditional object structure:
context Person
firstName: string
lastName: string
age: integer
end
context Example
personInfo: Person with firstName, age
end
let validInstance = Example with personInfo = (Person with firstName = "Miguel", age = 57) // Okay
let invalidInstance = Example with personInfo = (Person with age = 34) // Fails to compile
Instance types may also explicitly state members that must not be provided during initialization, e.g.
context Example
personInfo: Person with firstName, lastName, no age
end
Context instances are fully immutable object-like entities. However, similarly to conventional Island objects, instances may be initialized in an incremental fashion, where each added property causes its instance type to be altered in a stepwise manner (these type changes are analyzed at compile-time thus provide full soundness guarantees).
For example, instead of setting all properties on the initial instantiation of the Kinematics
context, they can be gradually added later, even from within conditionals:
let kinematics: BasicKinematics
// At this point, 'kinematics' is effectively "empty". Its type is 'BasicKinematics'.
// It has no known properties and cannot be used for anything.
kinematics.distance = 10.0
// Now it has one known property, and its type has changed to 'BasicKinematics with distance',
// though it is still not very useful since not much new knowledge can be inferred from it.
if ....someCondition....
kinematics.time = 5.0
else
kinematics.time = 7.0 // This branch must also initialize a value for 'time'
end
// Its type has changed again, now to 'BasicKinematics with distance, time'.
// The value for the 'time' property was set within a conditional, but that's
// okay, since the compiler ensured that all branches assigned a value
// (otherwise, a compilation error would have occured).
// Now the 'speed' value can be computed, since sufficient information is available for it:
let speed = kinematics.speed
A property that has a default value cannot be late-initialized. To remedy this, the default value must be explicitly removed using the no
operator. For example:
context Person
name = "Anonymous"
age: integer
end
let person = Person with age = 20 // 'person.name' gets the default value "Anonymous"
// This is invalid! 'name' property has already been set to the default value:
person.name = "Anja"
However:
let person = Person with age = 20, no name // 'person.name' gets no value
// Now this works:
person.name = "Anja"
Anonymous contexts are context declarations included directly within a type annotation.
They can provide a convenient way to assign similar roles for two or more distinct properties:
context Name
firstName: context
plain: Uppercase.plain, Lowercase.plain
uppercase: Uppercase.uppercase
lowercase: Lowercase.lowercase
end
lastName: context
plain: Uppercase.plain, Lowercase.plain
uppercase: Uppercase.uppercase
lowercase: Lowercase.lowercase
end
fullNameUpperCase => firstName.uppercase | " " | lastName.upperCase
end
A variant of this syntax can also be used to integrate an ad-hoc context instance directly into the local procedural scope, such that via incremental initialization, its properties can be used as if they were plain local variables:
// Declaring 'context' with no identifier imports its properties into the local scope.
// Thus, in addition to defining the anonymous context type, it also introduces
// a nameless, "ghost" instance for it.
//
// Alternatively, 'let someName: context' would have namespaced the properties under
// the 'someName.' prefix, but otherwise behave identically.
context
name: string
age: integer
given age >= 18
greeting => "Hello {name} of {age} years of age!"
given age
greeting => "Hello young {name} of {age} years of age!"
end
name = "Luna" // This assigns directly into the anonymous instance's 'name' property.
if someCondition
age = 46
print(greeting) // prints "Hello Luna of 46 years of age!"
else
age = 15 // This branch must also assign a value for the 'age' property
print(greeting) // prints "Hello young Luna of 15 years of age!"
end
Let's look more closely at the previous section's example. Notice the two mapping rules that define the value of greeting
:
given age >= 18
greeting => "Hello {name} of {age} years of age!"
given age
greeting => "Hello young {name} of {age} years of age!"
Together these rules ensure that greeting
is knowable whenever age
is knowable. Now what if we take away the second rule, meaning that greeting
would only be knowable in the case where age >= 18
?:
given age >= 18
greeting => "Hello {name} of {age} years of age!"
// No other rules exist :(
Now we have to somehow ensure that age >= 18
before greeting
can be safely accessed. But how is it possible to achieve that? requiring a "guard"-like if
statement? declaring an assertion type?
age = getAgeFromSomewhere()
if age == 18 or age - 9 > age / 2
print(greeting) // is 'greeting' always knowable?
end
The thing is, with the exception of "toy" examples like the above, which may be solved using an "off-the-shelf" theorem prover (though with some amount of extra computational effort), it is not very easy for the compiler to statically ensure that some arbitrary user-provided formula logically entails the expected one (here age >= 18
).. So, sadly, that's not really a viable option, at least not in general.. :(
But wait a minute! maybe that's not really needed! The compiler already knows what the target precondition is, right? so why not let it test for it by itself?
age = getAgeFromSomewhere()
case
print(greeting)
otherwise
print("I don't know what to do?!")
end
What is going on here?? Looks like a skeletal match
body with a case
clause having no conditions attached? Is this some sort of an April fools' joke?
Well, actually no, it's not a joke!
The compiler already knows what is the set of conditions required for greeting
to be knowable, so it simply fills them automatically. You can think of case
as meaning case ???
where ???
represents a "hole".
age = getAge()
case ??? // the compiler automatically fills in 'age >= 18' in place of ???
print(greeting)
otherwise
print("I don't know what to do?!")
end
This example wasn't very illustrative since it only had one case. Here's a different one:
Say the user inputs a string. The string may either be:
- A phone number.
- A license plate number.
- A credit card number.
In each case I want to extract a different piece of information:
- Extract the country code.
- Extract the plate's prefix characters.
- Extract the card's four last digits.
I'll define an anonymous context containing the required properties and mapping rules, but add no fallback for the case where the user's input is invalid:
context
userInput: string
phoneCountryCode: string
licensePlatePrefixChars: string
creditCardLastDigits: string
given userInput matches PhoneNumberPattern of (let countryCode, ...)
phoneCountryCode = countryCode
given userInput matches LicensePlatePattern of (let prefixChars, ...)
licensePlatePrefixChars = prefixChars
given userInput matches CreditCardPattern of (_, _, _, let fourLastDigits)
creditCardLastDigits = fourLastDigits
end
// No fallback is given for the case where 'userInput' doesn't match
// any of the other rules.
Now I want the program to print a different prompt for the different cases where each piece of information is known, so I'll write:
userInput = readUserInput()
case
print("Your phone number's country code is {phoneCountryCode}")
case
print("Your license plate prefix characters are {licensePlatePrefixChars}")
case
print("Your credit card's last digits are {creditCardLastDigits}")
otherwise
print("Your input was invalid!")
For each case
block, the compiler synthesized a condition that represents the weakest possible assertion required to ensure that all the properties referenced within it are knowable.
It is possible, however, that two or more cases may both be satisfied at the same time. In this scenario, the first one listed will be selected (or compiler error emitted, if detection was trivial enough). It's also possible that due to lack of care or awareness, the conditions inferred wouldn't correctly represent the intention the programmer had in mind.
To help guard against issues of this kind, the editor experience will automatically visualize the inferred conditions for each case, so that the programmer can get immediate feedback and adjust accordingly.
If the programmer wishes to append custom preconditions of their own to a particular case, they can use the expect
statement:
userInput = readUserInput()
case
expect userInput.length >= 8 // This would be included in the inferred condition
print("Your phone number's country code is {phoneCountryCode}")
case
....
Case statement blocks can be nested. Nested cases may be required if a new value is received via an effect, such as reading a value from user input:
context
firstUserInput: string
secondUserInput: string
given firstUserInput matches PhoneNumberPattern of (any, let areaCode, ...)
phoneAreaCode => areaCode
given secondUserInput matches ContinentPattern of (let continent)
continentCategory => continent
end
firstUserInput = readUserInput()
case
print("Your phone area code is {phoneAreaCode}")
print("Which continent are you from?")
secondUserInput = readUserInput()
// This nested case block is required since the outer one can't assert on the
// value of 'secondUserInput' as it is acquired via an effect within the body
// of the case itself:
case
expect continentCategory == Continent.Eurasia
print("Hello Eurasian!")
case
expect continentCategory == Continent.Africa
print("Hello African!")
otherwise
print("I could not understand your input :(")
end
case
....
case
....
otherwise
....
By now, it may start to become evident that pattern recognizers could be highly effective tools for knowledge-driven programming. In the previous section, we've seen how matching can be partially "automated" when patterns are given as preconditions. We can also take it a step further, and reference patterns directly via their own dedicated contexts.
A pattern context, analogous to a function context
, is a pattern recognizer where its parameters and return values are interpreted as part of a context. For example:
pattern context PhoneNumberPattern()
of (countryCode: string, areaCode: string, prefix: string, lineNumber: string)
in string
<.... recognizer body ....>
would be translated to something like:
context PhoneNumberPattern
in: string // This property represents for the recognizer's input stream
// Each of the recognizer output values gets its own property:
countryCode: string
areaCode: string
prefix: string
lineNumber: string
// This (illustrative) rule implements the recognizer body:
given in
countryCode, areaCode, prefix, lineNumber =>
<.... recognizer body ....>
end
(note this is only an illustrative translation, since mapping rule bodies can't directly contain recognizer code)
So now we can greatly simplify the previous section's context definition by treating its patterns as contexts and binding to their input and output properties via roles:
context
userInput: PhoneNumberPattern.in, LicensePlatePattern.in, CreditCardPattern.in
phoneCountryCode: PhoneNumberPattern.countryCode
licensePlatePrefixChars: LicensePlatePattern.prefixChars
creditCardLastChars: CreditCardPattern.lastFourChars
end
(Sketch - Work in progress)
repeat
context
i = 0
next i => i + 1
print("Hello World! {i}")
end
repeat with i = 0, next i => i + 1
print("Hello World! {i}")
end
print("Let's play a game. Think of a number between 1 and 100.")
print("I'll ask you a series of simple questions until I'm able to guess what it is!")
context
result: integer
end
repeat
context
min = 1
max = 100
mid => (min + max) div 2
userResponse: boolean
given userResponse == true
next max => mid
given userResponse == false
next min => mid
given min == max
result => mid
end => true
end
print("Is the number smaller than {mid}?")
userResponse = receiveUserInput() // User input is received as a Boolean value
end
print("The number you thought of was {result}!")
Say we wanted to use roles to describe a simple computation that accepts a string-encoded decimal number and outputs the square of that number.
First we'll define contexts for the auxiliary computations:
context StringifiedNumber
source: decimal
stringified: string
....
end
context NumberSquare
number: decimal
squared: decimal
....
end
For the context that describes the main computation we'll define three properties, taking up four different roles:
- Represents the stringified number.
- Represents both the un-stringified number and the input to the square computation.
- Represents the the squared number.
For example:
context StringifiedNumberSquared
stringified: StringifiedNumber.stringified
unstringified: StringifiedNumber.number, NumberSquare.number
squared: NumberSquare.squared
end
We had to explicitly introduce the intermediate property unstringified
in order to "pipe" the two computations together. Hadn't we done that, there was no way for the compiler to know for certain if that was what we wanted to do. It is true that both StringifiedNumber.number
and NumberSquare.number
share the same type - decimal
, but that fact is not sufficient to deductively infer that these two must be bound together.
So, in a sense, if we didn't care about exposing unstringified
as a property, all we really needed to state is that StringifiedNumber.number
and NumberSquare.number
are somehow "glued" together. This can be expressed by the special role coupling operator =:=
:
context StringifiedNumberSquared
stringified: StringifiedNumber.stringified
StringifiedNumber.number =:= NumberSquare.number // This is called a "coupling rule"
squared: NumberSquare.squared
end
So how and why this works?
Remember that for every context instance, each semantic identity may only receive a single, unique value. So in effect, stating that the two semantic identities always get the same value is equivalent to introducing an intermediate property that takes both roles. The only difference is that here, this intermediate property becomes "anonymous", so it isn't possible to access its value via the conventional .
notation.
However, it is still possible to query its value via any of the roles it represents. For example:
let valueOfAnonymousProperty = StringifiedNumberSquared[StringifiedNumber.number] given
StringifiedNumberSquared.stringified = "5.32"
// valueOfAnonymousProperty gets the value 5.32 of type 'double'
StringifiedNumberSquared[StringifiedNumber.number]
references a property value via its semantic identity, instead of an explicit identifier. This syntax is called semantic indexing. Any semantic identity can be used as an index, including ones that are not explicitly mentioned within the schema itself, such as intermediate properties indirectly employed by mapping rules imported via semantic roles or links.
For comparison, if we had instead queried for StringifiedNumber.number
directly, e.g.:
let valueOfAnonymousProperty = StringifiedNumber.number given
StringifiedNumberSquared.stringified = "5.32"
// Error: no mapping rules were found to compute the desired information
We'll get a compilation failure since the reference to StringifiedNumber.number
signifies the general value of the identity, not the one that's specialized to the StringifiedNumberSquared
context. A non-specialized value could only have been derived if we explicitly supplied values for members of StringifiedNumber
itself, or indirectly via any of their semantic links (refer back to CommonsenseKinematics
for an illustration of that kind of scenario).
Alternatively, semantic indexing can also be applied on the instance directly:
let instance = StringifiedNumberSquared with stringified = "5.32"
let valueOfAnonymousProperty = instance[StringifiedNumber.number]
// valueOfAnonymousProperty gets the value 5.32
Let's go back to our initial BasicKinematics
example:
context BasicKinematics
distance: decimal
time: decimal
speed: decimal
distance <=> time * speed
end
We would like to add a second speed property that measures in miles per hour. However, this time, let's say we can't directly edit the context declaration, as it was provided by an external source.
There's no trivial way to "extend" BasicKinematics
via OO-like "subtyping", since it is not a class.
However, contexts do support expansion, so we can add the property we want by using an expansion declaration that will only be visible from within our own code. Here are a number of solutions based on different approaches:
A property paired with a bidirectional mapping rule containing a semantic query:
context expansion BasicKinematics
speedInMilesPerHour <=> Speed.milesPerHour given Speed.metersPerSecond = speed
end
Roles:
context expansion BasicKinematics
speedInMetersPerSecond: Speed.metersPerSecond => speed
speedInMilesPerHour: Speed.milesPerHour
speedInKilometersPerHour: Speed.kilometersPerHour
end
Embed Speed
context directly and map its metersPerSecond
property to the value of speed
:
context expansion BasicKinematics
speedInOtherUnits: Speed
speedInOtherUnits.metersPerSecond => speed
end
A property may also generate a stream of values.
...TODO...
One potential issue has to do with the way mapping rules allow multiple, possibly contradictory, computations to be defined for the same set of properties.
Trivially contradictory:
context TriviallyContradictory
num: integer
num => 0
num => 1 // This is technically legal code, but is obviously bogus.
end
Contradictions may also happen between mapping rules going in different directions:
context BidirectionallyContradictory
propertyA: decimal
propertyB: decimal
propertyA given propertyB => propertyB / 2
propertyB given propertyA => propertyA * 3 // Should this be allowed?
end
In the case that both mapping rules only contain trivial algebraic operations, contradictions can be detected at compile time.
For more complex cases, one approach to automate the detection of these types of inconsistencies is by having a special debug mode where the compiler would automatically inject consistency tests whenever a mapping rule is being used.
For example, if propertyA
is given a value x
, and consequently propertyB
is inferred with the value f(x)
, the program would also include an assertion for the complementary rule where propertyB
is given the resulting value (f(x)
) and propertyA
is the one that's inferred (its expected value would be x
). A similar approach can also be used to ensure consistency in scenarios where there are multiple rules to infer the value of a particular property.
Knowledge-driven programming enable unit tests to be described via an extremely simple and generic template based on a single semantic query:
expect .... == .... given ....
expect .... > .... given ....
expect isEmpty(....) given ....
In general:
expect <boolean experssion involving a semantic identifier> given <semantic identifer assignments>
For example:
expect Speed.milesPerHour ~= 4.47388 given Speed.metersPerSecond = 2.0
expect Distance.miles ~= 156.11951 given Speed.kilometersPerHour = 33.5, Time.hours = 7.5
The way that programs are broken down to assortments of independently addressable values enables a higher level of granularity for white-box testing. It is possible to "peek" deeper into the inner workings of the algorithm by querying for its intermediate values. If a context involves recursive embedding, it is also possible to assert over the values of properties "nested" within one or more levels of recursion. For example:
expect Factorial.previousFactorial.input == 5 given Factorial.input == 6
expect Factorial.previousFactorial.previousFactorial.input == 4 given Factorial.input == 6
...TODO...
Think of a context as if it was a blueprint for an imaginary "magic" room.
The room may contain one or more boxes, which act as an analogy for its properties.
Each box may contain an item of a particular type, e.g. a ball, a pen, a doll etc. The kind of thing the box may contain is analogous to the type of a property (string
, integer
etc.).
The box is also characterized by a secondary quality, which is completely unique to it. This quality describes what purpose the box represents in relation to other boxes in the room, as well as to the room as a whole. This quality is analogous to its semantic identity.
The room can be cast with a one or more magic spells that cause items to appear inside of empty boxes. These spells may depend on the content of nonempty boxes, including boxes that received their content due to magic. These spells are analogous to mapping rules.
I create a blueprint for a room. I add all sorts of boxes to it.
I create a secondary room blueprint and add some other boxes to it.
Now I also add another, very special kind of box to the second room. This special box is actually a container for an entire room! I set the blueprint for the room in the box to be the first room's blueprint.
Now I can freely cast spells that involve the boxes that reside inside of the room that's inside the special box, as if these boxes were a part of the outer room.
Same as previous, only the blueprint I use for the room inside the special box, is the blueprint of the outer room itself!
This means that there is an "infinite" nesting of rooms and special boxes: If I look inside the special box I find a room, and inside that room a special box, containing another room, containing a special box, containing a room, repeating endlessly..
I notice there's a risk that I might get caught in an infinite loop of looking deeper and deeper into the contents of these nested rooms, so I design the spells such that they never look into these inner rooms to more than a finite depth.
I create a blueprint for a room. It starts out completely empty.
I put two boxes in the room. I set the first box so it can only contain a doll, and the second to only contain a picture.
Now I create a secondary room. The secondary room starts out empty as well.
I put two boxes in the second room.
I declare that the first box is a "magic twin" of the first box in the first room. Same for the second box and the second box in the first room. The twin relationship between the boxes means that any spell I cast that involves one, becomes effective over its twin as well (it doesn't mean both must contain the same exact item, though. The pairing is only made over the spells, not the materialized box contents).
In the second room, I cast a spell that says that if box 1 gets a doll, box 2 would receive a picture of that doll. I don't cast any further spells (that is, if box 2 receives a picture, nothing special necessarily happens to box 1).
I use the second room blueprint to generate a new virtual room. I put a doll in the first box. A picture of that doll appears in the second box.
I use the first room blueprint and generate a new virtual room. I put a doll in the first box. A picture of that doll appears in the second box as well!
I create a new blueprint for a room. The room starts out completely empty.
I put two boxes in the room. I decide that both may only contain balls (i.e. I give both of them the same type, Ball
).
Now, I cast a spell that says that whenever there's a ball in box 1, box 2 gets a ball as well, but with a complementary color. For example, if I put a blue ball in box 1, an orange ball magically appears in box 2. I cast a second spell so that the reverse would happen as well, i.e., if I put an orange ball in box 2, a blue ball will appear in box 1.
I create a secondary room blueprint. The second room also starts out completely empty.
I put two boxes in the second room.
I would like these boxes to imitate how the boxes in the first room relate to one another, so I set box 1 in the second room to take the "role" of box 1 in the first room, and box 2 in the second room to take the role of box 2 in the second room.
I generate a room from the first blueprint I made (the first room).
I put a red ball in the first box, and I magically get a green ball in the second box.
Now I generate a room from the second blueprint I've made (the second room).
I test to see if the same thing happens. I put a red ball in the first box, and I verify that I get a green ball in the second box.
Now I can add more boxes to the second room's blueprint, and cast more spells, which may involve the two initial boxes, but these spells will have no impact on the behavior of the boxes in the first room.
I set up a new room blueprint with a number of boxes.
I set the boxes to take the roles of many other boxes, from many different rooms.
For example, I set box 1 to pretend like it's box 5 from room 11, box 2 to pretend like it's box 4 from room 3, and box 3 to pretend like it's box 15 from room 5.
Now I add a forth box, and I set it up to pretend like it's box 6 from room 9.
I generate a new room from the blueprint I've made.
I put items in boxes 1, 2, 3, but leave 4 empty.
I wait and see what item appears in box 4.
Room blueprint 1: I have two boxes, both accept only hats. I cast a spell such that the second box receives a hat that's twice larger than the one placed in the first.
Room blueprint 2: I have two boxes, both accept only hats. I cast a spell such that the second box receives a hat that's the complementary color relative to the one given on the first.
Room blueprint 3: I have two boxes, both accept only hats. I assign the first box the role of the first box in room 1, and the second of the second box in room 2.
I generate a room from the third blueprint.
I put a blue hat in the first box. Nothing happens!
I go back to the drawing board and realize that I forgot to connect the outcome of the spell from room 1 to the source to the spell from room 2.
I consider adding a third, intermediate box, that will contain the enlarged hat. However, I decide to instead cast a special spell that "binds" the roles of box 2 in room 1 and box 1 in room 2. This means that the room will also include a third, invisible box that would contain the enlarged hat, but I wouldn't be able to see it.
I try again. I generate a room from the third blueprint.
I put a blue hat in the first box. A twice-larger, red version of that hat appears in the second box.
I ask the room: "Can you please show me the content of the invisible box containing the enlarged hat"?
An enlarged, blue hat appears right in front of me, floating in the air.
The room contains a special box, containing a room. The inner room is not based on a secondary blueprint, but is an integral component of the blueprint of the outer room itself.
I'm relying on a room blueprint that was provided to me by an external source.
Instead of directly modifying the original blueprint (which I can't), I can add a bunch of additional boxes and spells that would only effect my own experience of it.
I have two boxes in the room blueprint, which are set to only contain pens.
I cast a spell such that if the first has a pen of some color, the other will receive a pen of the same color.
I create a new room from the blueprint and put pens in both boxes. One blue, but the other red.
The room explodes.
I have two boxes in the room blueprint, which are set to only contain pens.
I cast a spell such that if the first box has a red pen, the second will receive a blue pen.
Now I cast second spell that if the second box has a blue pen, the first will receive a green pen.
I can't use this blueprint, since it is invalid.
A list can be sliced using secondary list to specify an ordered subset of indices to be read from a list:
let nums = [10, 20, 30, 40, 50, 60, 70, 80, 90]
let indexes = [5, 3, 7]
let result = nums[indexes] // result = [50, 30, 70]
More generally, the same effect can be achieved using any stream of integers:
let nums = [10, 20, 30, 40, 50, 60, 70, 80, 90]
let indexStream = (for i in 9..1 where i mod 3 == 0) => i
let result = nums[indexStream] // result = [90, 60, 30]
And for modifying an existing list:
let nums = [10, 20, 30, 40, 50, 60, 70, 80, 90]
let indexStream = (for i in 9..1 where i mod 3 == 0) => i
let modifiedNums = nums with [indexStream] = [900, 600, 300]
// modifiedNums = [10, 20, 300, 40, 50, 600, 70, 80, 900]
let indicesToRemove = [4, 5, 7]
let modifiedNums2 = modifiedNums with no [indicesToRemove]
// modifiedNums2 = [10, 20, 300, 600, 80, 900]
(This is a sketch)
Numeric lists of the same length can be used like vectors. They can be added, subtracted and multiplied with each other:
let list1 = [1, 2, 3]
let list2 = [2, 3, 4]
let list3 = list1 * list2 // list3 = [2, 6, 12]
Numbers (scalars) can be operated with lists:
let list1 = [1, 2, 3] + 5 // list1 = [6, 7, 8]
let list1 = [1, 2, 3] * 0.5 // list1 = [0.5, 1, 1.5]
Operations on multidimensional lists follow matrix multiplication conventions:
Similarly length Boolean typed lists can be used with and
, or
, not
operation:
let b1 = [true, false, true] and [false, true, true] // b1 = [false, false, true]
let b2 = not [false, false, true] // b2 = [true, true, false]
So far, we've tried to design convenient loops and comprehensions that abstract over recursive iteration patterns. We can also take some of those ideas back on the other direction - to make plain recursions simpler and more convenient to use.
The recurse
keyword acts a lot like continue
by allowing to only state the alterations needed for a recursive call, relative to the method's received argument set. In this example the return value is a named tuple, who's members double as parameters by being declared with the param
keyword:
function repeatAandB(match count: integer): (param r1 = "", param r2 = "")
case 0 => return
otherwise => recurse count -= 1, r1 |= "a", r2 |= "b"
end
let (r1, r2) = repeatAandB(4) // r1 = "aaaa", r2 = "bbbb"
(Technical note: for convenience return
can be used both as a statement and expression in a case
and when
clause body)
Note that by modifying the returned tuple members with the param
keyword, they act like any other parameter and can be passed arguments when the method is called:
let (r1, r2) = repeatAandB(4, r1 = "Hello ", r2 = "World ")
// r1 = "Hello aaaa", r2 = "World bbbb"
With these features, combined with named return variables, we can further simplify the recursive binary search code from a previous chapter:
function binarySearch(values: List<integer>, target: integer)
function iterate(low = 1, high = values.length): (mid = low + high div 2)?
if low > high
return nothing
else
match values[mid]
case target => return
case it < target => recurse low = mid + 1
otherwise => recurse high = mid - 1
end
end
end
return iterate()
end
Comparisons are always done by value and traverse deep structural hierarchies:
let t1 = (1, "Hi", true, [5, 4, 3, 2])
let t2 = (1, "Hi", true, [5, 4, 3, 2])
let t3 = (1, "Hi", true, [5, 4, 3, 1])
print(t1 == t2) // prints true
print(t2 == t3) // prints false
Some syntax I find potentially confusing but haven't, to date, found better alternatives for:
Using the stream
keyword only for pure generators. Based on this logic, a "stream" of data read from a file isn't eligible to be called a "stream"?
Using the delegate
keyword for worker methods. Is this the best option? Maybe every action
could be modified to become a delegate
instead? (or maybe action delegate
??)
delegate
out
and in
channels currently use the <-
operator. Is it really necessary? Not having it would probably look cleaner.
Should enumeration
be shortened to enum
?
In Python, a statement block head usually ends with a :
to signify the upcoming content block. I've decided I'm not doing that in Island (same for not having then
after if
). This brings some situations that are not trivially intuitive to read.
On issue is with multi-line block opening statements (e.g. for ....
, if ....
) may become difficult to visually differentiate from block content:
if something1 and something2 and something3 and something4 and
something5 and something6 and something7 and something8
doSomething() // It's not visually clear this statement is within the body of the conditional.
In knowledge-driven programming context syntax, when mapping rules are written as blocks, the syntax becomes too sparse:
context Example
val: integer
val
for i in 1..10
....
end
return something
end
end
Overload block syntax also feels a bit too sparse to me, especially when the overload bodies are written as statement blocks:
action doSomething
(category: "animal", isMammal: true, owner: Person where age >= 18)
print("Hello animal lover!")
end
(category: "animal", isMammal: false, owner: Person where age < 18)
print("Hello young animal lover!")
end
(category: "person", id: /[a..zA..Z]+/)
print("Hello random person!")
end
The it
keyword doesn't feel like the best possible fit for the query comprehension syntax:
let evenNumbersDoubled = [numbers where it mod 2 == 0 select it * 2]
Seems like it
should represent numbers
itself not an element of it.
Using item
looks nicer:
let evenNumbersDoubled = [numbers where item mod 2 == 0 select item * 2]
but I'm not sure if I want to make it a reserved keyword.
- Should
integer
be infinite or finite precision? How about havinginteger<64>
,integer<32>
etc.? - Should
decimal
be infinite or finite precision? - Should
string
be renamed totext
? (sincestring
is not a completely descriptive name). - Should constant-length arrays be natively supported?
This work would not have been possible without ideas inspired by or built-upon the collective design effort invested towards many contemporary and past languages. In particular:
- TypeScript: class member syntax, anonymous method syntax, type annotation syntax, generics syntax, type alias syntax, method type syntax, optional type syntax,
this
type semantics, join (intersection) types,any
type,never
type, type cast and assertion syntax. - Lua block syntax
- C#: expansions (called "extension everything" by the C# designers), some query syntax (LINQ), computed fields (properties),
in
andout
type modifiers, disambiguation of conflicting inherited members by type prefixing. - JavaScript: array syntax, destructuring and rest parameter syntax,
arguments
keyword. - Python: indent-based blocks, generators and comprehensions.
- Haskell: type features (type classes), variant types (tagged unions).
- Scala: companion objects.
- Kotlin:
it
keyword. - Oz, Go: concurrency, messaging.
- Pascal:
div
andmod
keywords. - Prolog, Datalog, Oz: (functional-) logic programming.
The repository is located at github.com/island-lang/island-lang.github.io
Feel free to ask questions, report errors or make constructive suggestions.
Copyright © Rotem Dan
2017 - 2025