Kernelf
Kernelf
Markus Voelter
independent/itemis AG
[email protected]
Permission to make digital or hard copies of part or all of this work for personal
or classroom use is granted without fee provided that copies are not made or dis-
tributed for profit or commercial advantage and that copies bear this notice and
the full citation on the first page. Copyrights for components of this work owned
by others than ACM must be honored. Abstracting with credit is permitted. To
copy otherwise, to republish, to post on servers, or to redistribute to lists, contact
the Owner/Author. Request permissions from [email protected] or Publica-
tions Dept., ACM, Inc., fax +1 (212) 869-0481. Copyright held by Owner/Author.
Publication Rights Licensed to ACM.
Figure 1. The three typical layers of a DSL: domain-
specific data structures, behavior based on an existing
Copyright
c ACM [to be supplied]. . . $15.00
paradigm, and at the core, functional expressions.
2 module: sandbox.core.expr.os includes code completion, type checking, refactoring and
3 model: sandbox.core.expr.os.expressions
4 node: Paper [TestSuite] (root)
debugging. In addition, programs should be executable
5 url: http://localhost:8080/select/org.iets3.core/ (by an interpreter) directly in the IDE to support quick
6 r:3dff0a9d-8b1d-4556-8482-b8653b921cfb/
7 7740953487934666415/
turnaround and the ability of end users to “play” with
the programs.
Portability The various languages into which KernelF
1.1 Design Goals will be embedded will probably use different ways of
Simplicity KernelF should be used as the kernel of execution. Likely examples include code generation to
DSLs. The users of these DSLs may or may not be Java and C, direct execution by interpreting the AST and
programmers – the overwhelming majority will not be as well as transformation into intermediate languages
experts in functional programming. These users should for execution in cloud or mobile applications. KernelF
not be “surprised” or “overwhelmed”. Thus, the language should not contain features that prevent execution on
should use familiar or easy to learn abstractions and any of these platforms. Also, while not a core feature of
notations wherever possible. the language, a sufficient set of language tests should be
provided to align the semantics of the various execution
Extensibility Extensibility refers to the ability to add platforms.
new language constructs to the language to customize it
for their domain-specific purpose. Specifically, it must be
2. KernelF Described
possible to add new types, new operators or completely
new expressions, such as decision tables. These must be In this section we describe the KernelF language. The
added to the language without invasively changing the description is complete in the sense that it describes
implementation of KernelF itself. every important feature. However, it is incomplete in
that it does not mention every detail; for example, several
Embeddability Embedding refers to the ability to use
of the obvious binary operators or collection functions
the language as the core of arbitrary other languages. To
are not mentioned. They can be found out easily through
enable this, several ingredients are needed: the existing
code completion in the editor.
set of primitive types must be replaceable, because
alternative types may be provided by the host language. 2.1 Types and Literals
More generally, the parts of the language that may not
be needed must be removable. And finally, extension also Three basic types are part of KernelF: boolean, number,
plays into embedding, because embedding into a new and string. This is a very limited set, but it can be
context always requires extension of the language with extended through language engineering. They can also
expressions that connect to (i.e., reference) elements from be restricted or entirely replaced if a particular host
this context (e.g., expressions that refer event arguments language wants to use other types.
in a state machine language). val aBool: boolean = true
val anInt: number = 42
Robustness The users of the DSLs that embeds val aReal: number{2} = 33.33
KernelF may not be experienced programmers – in val aString: string = "Hello"
val fourtyTwo = 42
fourtyTwo.oneOf[33, 42, 666] ==> true <boolean>
fourtyTwo.inRange[0..42] ==> true <boolean>
2.4 Error Handling using Attempt Types
// notice open upper bracket: excluded upper limit
fourtyTwo.inRange[0..42[ ==> false >boolean>
In the same way that KernelF encodes null checks into
the type system using option types, KernelF also pro-
vides type system support for handling errors using
2.3 Null Values and Option Types attempt types. An attempt type has a base type that
Option types are used to handle null values in a typesafe represent the payload (e.g., return value in a function)
way. The constant maybe in the code below can either if the attempt succeeds. It also has a number of error
literals that have to be handled by the client code. An at- reports an error on the try expression directly if not all
tempt type is written down as attempt<baseType|err1, errors are handled:
err2,..,errN>. As a consequence of type inference,
val toDisplay =
such a type is hardly ever written down in a program. // try will have error b/c error404 is not handled
Error handling has two ingredients. The first step try complete getHTML("http://mbeddr.com") => val
error<timeout> => "Timeout"
is reporting the error. In the example below, this is
performed in the getHTML function. Depending on what Similar to option types, the attempt types are also
happens when it attempts to retrieve the HTML, it overridden wrt. to their success type for the same
either returns the payload or reports an error using operators and dot expressions. The error literals are
error(<error>). The type inference mechanism inferes propagated accordingly.
the type attempt<string|timeout, err404> for the
getHTML("http://mbeddr.com").length ==> 4
alt expression and, transitively, the function getHTML. <attempt[number[0|inf]{0}|[error404, timeout]]>
getHTML("http://doesntExist.com").length ==> error(error404)
fun getHTML(url: string) : attempt<string|timeout, error404>
<attempt[number[0|inf]{0}|[error404, timeout]]>
= alt |..successful.. => theHTML |
|..timeout.. => error(timeout) |
|..unreachable.. => error(error404) |
2.5 Functions and Extension Functions
The client has to “unpack” the payload from the attempt
Even though function syntax may be domain-specific,
type using the try expression. In the successful case,
KernelF includes a default abstraction for functions.
the val expression provides access to the payload of the
Functions have a name, a list of arguments, an optional
attempt type. Errors can either be handled one by one
return type and an expression as the body; the code
(as shown in Figure ??), or with a generic error clause.
below shows a few examples. The body can use the block
val toDisplay : string = expression, which supports values as temporary variables
try getHTML("http://mbeddr.com") => val
error<timeout> => "Timeout" (similar to a let expression, but with a more friendly
error<error404> => "Not Found" syntax). As with variables, the return type is optional.
As with the unpacking of options using isSome, it is fun add(a: number, b: number) = a + b
fun addWithType(a: number, b: number) : number = a + b
possible to assign a name to the result of the called fun biggerFun(a: number) = {
function, so that name can be used instead of val in the val t1 = 2 * a
val t2 = t1 + a
success case: t2
}
try getHTML("http://mbeddr.com") as data => data
...
KernelF also supports extension functions. They must
If not all errors are handled, the type of the try expres- have at least one argument, the one that acts as the this
sion remains an attempt type. In the above example, we variable. They can then be called using dot notation
may not handle the error404 case: on an expression of the type of the first argument. In
contrast to regular functions, the advantage is in IDE
val toDisplay =
try getHTML("http://mbeddr.com") => val support: code completion will only show those functions
error<timeout> => "Timeout" that are valid for the first argument. Note that, at least
for now, no polymorphism is supported.
In this case, the type of try, and hence of toDisplay,
would be attempt<string|error404>. This way, error ext fun isSomethingInIt(this: list<number>) = this.size != 0
list(1, 2, 3).isSomethingInIt() ==> true <boolean>
handling can be delegated to an upstream caller. To
force complete handling of all errors, two strategies can
be applied. The first one involves a type constraint to 2.6 Function Types, Closures, Function
express that the success type is expected: References and Higher-Order Functions
val toDisplay: string = KernelF has full support for function types, closures and
try getHTML("http://mbeddr.com") => val
error<timeout> => "Timeout"
function references as well as higher-order functions.
We start by using a typedef to define abbreviations
In an incomplete case, where not all errors are handled for two function types. The first one, INT_BINOP is the
(either individually or with a generic error clause), the type of functions that take two numbers and return a
type of try will remain an attempt type with the non- number. The second one represents functions that map
handled errors. If an explicit return type expects a non- one number to another. Using typedefs is not necessary
attempt type, this type incompatibility will return in an for function types, they can also be used directly. But
error. A way of forcing the try expression to handle all since these types become long’ish, using a typedef
errors is to use the complete flag, as shown below. It makes sense.
type INT_BINOP : (number, number => number) reals ==> list(1.41, 2.71, 3.14)
type INT_UNOP : (number => number) <list<number[0.00|100.00]{2}>>
reals.add(1.00) ==> list(1.41, 2.71, 3.14, 1.00)
<list<number[0.00|100.00]{2}>>
Next, we define a function mul that is of type INT_BINOP. reals.at(1) ==> 2.71 <number[0.00|100.00]{2}>
We can verify this by assigning a reference to that reals[2] ==> 3.14 <number[0.00|100.00]{2}>
names.isEmpty ==> false <boolean>
function (using the colon operator) to a variable mulFun names.size ==> 2 <number>
: INT_BINOP. Alternatively we can define a closure, i.e., hometowns["Tamas"] ==> "Budapest" <string>
ing of some of a function’s arguments, returning new More examples are shown below; the list of operations
functions with correspondingly fewer arguments. The is expected to grow over time.
value multiplyWithTwo in the example below is a func-
ints.map(|it + 1|) ==> list(2, 3, 4, 5) <list<number>>
tion that takes one argument, because the other one ints.any(|it < 0|) ==> false <boolean>
has already been bound to the value 2 using bind. ints.all(|it > 3|) ==> false <boolean>
We could add an optional type to the constant (val
There is also a foreach which requires the lambda
multiplyWithTwo: INT_UNOP = ...) to verify that the
expression inside to have a sideeffect; it ”performs” the
type is indeed INT_UNOP. For demonstration purposes
sideeffect and then returns the original list.
we define another higher-order function and call it.
Inside where, foreach and map, the variable counter
val multiplyWithTwo = mulCls.bind(2) is available; it has a zero-based index value of the current
fun doWithOneInt(x: int, op: INT_UNOP) = op.exec(x)
doWithOneInt(5, multiplyWithTwo) ==> 10 <number> iteration (i.e., 0 in the first iteration, 1 in the second,
etc.).
2.7 Collections 2.8 Tuples
KernelF has lists, sets and maps. All are subtypes Tuples are non-declared multi-element values. The type
of collections. While KernelF does not have generics is written as [T1, T2, .., Tn], and the literals look
in general, the collections are parametrized with their essentially the same way: [expr1, expr2, .., exprN].
element types. They are also covariant. Tuple elements be accessed using an array-access-like
bracket notation.
val reals = list(1.41, 2.71, 3.14)
val names = set("Markus", "Markus", "Tamas") ext fun minMax(this: list<number>) = [this.min, this.max]
val hometowns = map("Markus"->"Heidenheim", ints.minMax() ==> [1, 4] <[number, number]>
"Tamas" ->"Puspokladany") ints.minMax()[0] ==> 1 <number>
val col : collection<real> = reals ints.minMax()[1] ==> 4 <number>
brother ==> #Person{"Mathias", none, "Voelter"} <Person> Valued enums associate an arbitrary value with each
meWithX ==> #Person{"MarkusX", none, "VoelterX"} <Person> literal; all values of a particular enum must be of the
meSwitched ==> #Person{"Voelter", none, "Markus"} <Person>
same type. That type is declared after the name of
the enum, adding that type makes an enum a valued
Grouping The groupBy operation supports grouping
the entries in an existing collection by a key. The result 2 This is short for categories and does not related to the animal
is a new collection of type group<KT, MT> where KT is :-).
enum. From an enum literal reference, you can get the
associated value using the value operation.
enum StarbuckSizes<number> {
big -> 100
venti -> 200
mega -> 300
}
enum Family<Person> {
me -> #Person{"Markus", none, "Voelter"}
myBrother -> #Person{"Mathias", none, "Voelter"}
}
returned by the cast expression (range between 10 and fun publish(data: string) = ...
20 in this case). Note that, because of type inference, val p1 = publish(somethingUnclassified)
val p2 = publish(somethingConfidential) // ERROR
the type of the val can be omitted, resulting in the val p3 = publish(somethingSecret) // ERROR
following code: val p4 = publish(somethingTopSecret) // ERROR
language concept and returning an EffectDescriptor For this to work, you will have to mark the add operation
from its effectDescriptor method; the descriptor has to have an effect, which will, transitively, also give store
Boolean flags for the various supported kinds of effects. an effect. However, add does not exist on immutable
Because it is called inside the standardize function, lists, so you need a whole second set of APIs for mutable
that function must also be marked to have an effect. collections. The list in this example cannot be the same
This is done by entering /R (reads), /M (modifies) or list as the one used earlier; it’s a mutable list, maybe
/RM (reads + modifies) behind the function name; an called mlist. In clonclusion, you need mutable versions
error will be reported otherwise. The mechanism also of all collections. This approach is a valid solution, and
works for function types: you can mark a function type some languages, for example, Scala, use it. However, it
as allowing effects, by entering the flag after the arrow is a lot of work and should be avoided.
in the function type; this is shown in the argument of
the function below. If declared this way, it is legal to Boxes Boxes are an alternative approach that do not
pass in functions that has an effect (or not). require mutable version of all immutable data structures.
Boxes explicitly values inside. The box itself is immutable
fun doSomethingWithAnEffect/RM(f: ( =>/RM string)) =
f.exec/RM()
(i.e., its own reference stays stable), but its contents can
change:
Note that the function call (to exec in this case) is val globalcounter: box<int> = box(0)
automatically marked to have an effect if the called fun incrCounter() {
globalcounter.update(globalcounter.val + 1)
function has an effect. }
Take a look at the following code: of state machines and other boxes, as shown in the ex-
ample below where the state machine modifies other
type intLE5: int where it <= 5
val c1: box<intLE5> = box(0) global data.
val c2: box<intLE5> = box(0) The language also supports nested transactions
fun incrementCounters(x1: int, x2: int) {
c1.update(it + x1) (which can be rolled back individually) as well as the
c2.update(it + x2) distinction between starting a new transaction (with
}
fun main() { newtx) and a block requiring to be executed in an
incrementCounters(1, 1) existing transaction (using intx).
incrementCounters(3, 5)
} Interpreter The reason why transctions work also
with state machines is that the current total state of
Boxes respect the constraints on their content type: if a state machine is also an immutable object; in other
you set a value that violates a constraint, than the update words, it also implements ITransactionalValue. The
fails. What actually happens then is configurable, at least implementation of the transaction in the interpreter
in KernelF’s default interpreter: output a log message looks like this:
and continue, or throw an exception that terminates the
1 Transaction tx = new Transaction(node);
interpreter. While, in the second case, the program stops 2 env[Transaction.KEY] = tx; // store in env for nested calls
anyway, and so it does not matter which value is set, 3 try {
4 Object res = #body;
in the first case we run into the problem that, for the 5 tx.commit();
second invocation of incrementCounters, c1 is updated 6 return res;
7 } catch (SomethingWentWrong ex) {
correctly, but the update of c2 is faulty. Transactions 8 tx.rollback();
can help with this: 9 } finally {
10 env[Transaction.KEY] = null; // no tx active anymore
fun incrementCounters(x1: int, x2: int) newtx{ 11 }
c1.update(it + x1)
c2.update(it + x2) This form of transactional memory is also used in Clojure,
}
as far as I understand.
A transaction block is like a regular block, but if some- 3.5 State Machines
thing fails inside it (interpreter: an exception is thrown),
it rolls back all the changes to mutable data inside that We have introduced basic state machines above. In this
transaction. Because the box contents themselvers are section we’ll introduce the remaining features of state
immutable, the interpreter simply stores the value of machines.
each box (or more generally, ITransactionalValue) be- Nested States States can be nested. A state S that
fore it performs the update and remembers them in the itself contains states considers the first F one as the
transaction. On rollback, it just re-sets the value. This initial state. Any entry into S automatically enters F,
also works with state machines, and with combinations recursively.
Actions State machines support entry and exit actions MPS provides syntax highlighting, code completion,
on states as well as transition actions. Ordering of their goto definition, find usages, and type checking. Because
execution is always exit-transition-entry. For nested MPS is a projectional editor, it also implicitly provides
states, the exit actions are executed inside-out, the entry formatting. Since all of this is pretty standard, we will
actions are executed outside in. not discuss this further.
Automatic Transitions In addition to transitions What is worth mentioning is that this IDE support
that are triggered by events (expressed using the on also automatically works for all extensions of KernelF,
keyword), automatic transitions are also supported. and it keeps working if KernelF is embedded into an-
They are introduced by the keyword if and do not other language. No ambiguities arise from combining
include a triggering event, only a guard condition. They grammars, and no disambiguation code has to be writ-
are executed upon state entry (after the entry actions) ten.
or if no triggered transition fires. 4.2 Interpreter
Timeouts A particular use case for automatic tran- KernelF comes with an in-IDE interpreter that directly
sitions is to use the timeInState variable in the guard interprets MPS’ AST. The semantic implementation
condition to implement time-dependent behaviours. It of the language concepts is implemented in Java. Note
contains the time since the last (re-)entry of the state. that it is not optimized for performance (in which case
Notice that if a transition on E -> S fires, this counts a completely different architecture would be required),
as a reentry. If you want to “stay” in the state, then avoid but for quick feedback for DSL code, in particular for
the -> S. Note that if you do not specify a target state, test cases. The interpreter can be executed on assert
then the transition must have an action. A transition entries in test cases; it can be started either from the
with no action and no target state is illegal (because it context menu or with Ctrl/Cmd-Alt-Enter. Complete
does not do anything). test cases and test suites can also be executed using the
same menu/keys.
3.6 Clocks
Notice that the interpreter performs extensive caching
KernelF supports clocks. There is a built-in type for expressions that have no effects. In particular, func-
clock whose values have a time operation that re- tion calls with the same arguments are executed only
turns the current time millis of the underlying clock. once (per interpreter session) if the function has no effect.
New values of type clock can be created by us- It is thus important that effect tracking is implemented
ing two expressions: systemclock returns a clock correctly in language concepts.
that represents the clock of the underlying system.
artificialclock(init) returns a clock initialized to 4.3 Read-Eval-Print-Loop
the init value. Note that artificialclock is also KernelF ships with a read-eval-print-loop (REPL; Figure
of type artificialclock, which, in addition to time, 7 shows an example). It is represented as its own root
also has an advanceBy(delta) operation that moves and is persisted; but its interaction is more like a
the clock forward by delta units. The tick operation console in the sense that whenever you evaluate an entry
corresponds to advanceBy(1). (using Ctrl/Cmd-Alt-Enter) the next one is created
Artificial clocks are useful for testing. However, built- and focused. Each entry is numbered, and you can refer
in expressions such as the timeInState mentioned above to each one using the $N expression.
default to the global clock. By default, the global clock is By default, each entry in a REPL is evaluated once,
the systemclock. If you want to use an artificial clock and you “grow” the REPL by adding new expressions.
for testing. you must register it as the global clock using However, by checking the downstream updates option,
the §global-clock pragma. you can change any REPL expression, and all the
transitively dependent ones are then reevaluated as well.
4. Tooling The easiest way to start a REPL is to select any
expression in a KernelF program and use the Open
4.1 MPS-based IDE REPL intention. It then creates a new REPL, adds the
The KernelF language is of course not dependent on expression in the first entry and evaluates it. By using
any particular IDE. However, what makes KernelF the Close and Return button in the REPL, the REPL
relevant (and not just another functional language) is is deleted and the node from which it was opened is put
its extensibility and embeddability. For this, it relies back in focus.
on MPS’ meta programming facilities. In other words,
KernelF can only be sensibly used within MPS. This 4.4 Debugger
also means thart the IDE support MPS provides is the One of the benefits of a functional language is that there
IDE support for KernelF. Like for any other langauge, is no program state to evolve; all computations can be
(consider val f = x() + x(), recursion, or the lamb-
das in higher-order functions). The frame tree shows the
hierarchical nesting of those computation steps. Each
node in the tree has an optional label (for example, cond
or then), the (abbreviated) syntax, the (abbreviated)
value and the time it took to compute it5 . The tree node
shows a yellow [E] if that node has (had) an effect. If
the node throws a constraint failure, this is highlighted
in red, in place of the blue value.
Next to the frame tree we see the value inspector.
When clicking on a node in the tree, the inspector shows
Figure 7. An example of a REPL session on a clock the structure (if any) of the value of the tree node. For
expression. example, an instance of a record as a tree, and if an
expression returns an MPS node, that node is clickable,
selecting that node in the MPS editor.
When double-clicking a node in the frame tree, the
respective node is decorated in the source. As shown
in Figure 9, it associates a value with each AST node.
Depending on the node’s complexity, it shows no value
at all (for literals, because the value would be the same
as the node syntax), or shows it next to or below the
Figure 8. The frame tree as shown in the debugger. node. The color is goverened by the nesting depth. The
decorated code always represents one particular value
seen as a tree of computed values. This means that assignment. Thus, to debug the values for lambda in
debugging does not require the step-and-inspect style the iterations of a coll.where(lambda) higher order
we know from imperative languages. Instead, debugging functions, you would click on the respective nodes in the
can just illustrate the computation tree in a convenient frame tree, highlighting each instance in the code.
way. Debugger UI The debugger opens a new frame tree
KernelF ships with a debugger that is based on this for each root for which the user opens the debugger. The
approach. Fundamentally, a computation in the KernelF red X closes the current tab. The green arrow reexecutes
interpreter collects a trace, and this trace can be in- the same root, if it is reexecutable (as determines by the
spected.4 The debugger, also known as the tracer, can be debugged program node). This is useful after updating
invoked for anything that has no upstream dependencies, the code. Node that the expansion state of the tree
i.e., test case assertions, gloval values and functions is retained across reexecutions. The little grey round
that have no arguments. Other domain-specific “main X removes all code decorations created by the current
program like”-constructs may be available in a DSL. tab. The blue filter icon toggles between the regulae
Whereas the interpreter is invoked via Ctrl-Alt-Enter, tree where only coarse-grained frames are shown and
the debugger is invoked with Ctrl-Alt-Shift-Enter a view where all interpreter steps are included. While
(or the Show Trace menu item in the context menu of this is usually overwhelming, it can sometimes be useful.
the respective program node). The reset arrow reverts the tree to its original expansion
Debugger Components The debugger comes with state (see below). The collapse all and expand all buttons
three components: the frame tree, the value inspector should be obvious.
and the code decorator; we will discuss each in turn. Breakpoints and Run To Breakpoints and Run
The frame tree shows a hierarchy of frames. Frames are To are two features known from classical debuggers. A
“coarse-grained” entities in the computation tree such as breakpoint stops execution on a specific statement, and
functions and function calls, local values or if expres- Run To runs the program until it reaches a particular
sions. Importantly, the tree does not show the program statement. We have adapted these ideas in the tracer to
nodes, it shows the computation steps involving these the world of debugging functional code. A program node
program nodes. This is important, because any node can be marked as REVEAL using an intention. Marked this
may be executed several times during as comoputation, way, when the debugger is invoked, the tree is expanded
but with differnet values, producing a differnet result to show all instances of that node, marked with a red
4 The trace can also be collected from other sources, for example,
a KernelF program that has been generated to Java code, as long 5 We might evolve the tracer to also support a simple form of
as the runtime also collects trace data. profiling in the future.
Figure 9. Decorated code that associates values with syntax nodes.
Using If Expressions The first-class concept with For multiple tested values we now use && instead of the
some turned out to be ugly, and also introduced new comma, because the && is used in logical expressions al-
keywords for something where users intuitively wanted ready as a conjunction; note that other logical operators
to use an if; so we allowed the if statement to be used, are not supported on isSme tests.
again with the same options: fun f(x: number, y: option<number>) =
if isSome(maybe(x)) as xval && isSome(y)
fun f(x: option<number>) = if isSome(x) then val else
then xval + y else 0
10
fun f(x: option<number>) = if isSome(x) then x.val else
10
fun f(x: number) = if isSome(maybe(x)) then val else 6. Evolution over Time
10
fun f(x: number) = if isSome(maybe(x) as v) then v else 6.1 Number Types
10
Initially, KernelF had been designed with the usual
A problem with using the existing if expression is that types for numbers: int and float. However, even in
users can construct arbitrarily complex expressions, such our very first customer projects it turned out that those
as the following: numberic types are really too much focussed on the need
fun f(x: option<number>) = of programmers (or even processors), and that almost
if isSome(x) || g(x) then val else 10 no business domain finds those types useful. Thus we
quickly implemented the number types as described
In this case it cannot (easily) be statically checked that
earlier. Since this happened during the first real-world
inside the then branch, x always has a value. To enforce
use, so this evolution did not involve any migration of
this, we ensure that the isSome expression is the topmost
existing, real-world models of customers, making the
expression in the if; it cannot be combined with others.
evolution process very simple.
This is trivial to check structurally and avoids the need
for advanced semantic analysis of complex expressions. 6.2 Transparent Options and Attempts
We had the idea of interpreting an option type as
Initially, option types and attempt types were more
Boolean to allow this syntax:
restricted than what has been described in this paper.
fun f(x: option<number>) = if x then val else 10 For example, if a value of option<T> is expected, users
had to explicitly construct a some(t) instead of just
However, we discarded this option because, for our target
returning t. Similarly for attempt types: users had to
audience, we think that too much type magic is too
return a success(t). Options and attempts also were
complicated. Another idea was to use the name of the
not transparent for operators. For example, the following
tested variable (if it is a simple expression) in the then
code was illegal, users first had to unpack the options
part, and type it to the content of the option. This would
to get at the actual values, which lead to hard to read
allow the following syntax:
nested if expressions.
fun f(x: option<number>) = if isSome(x) then x else 10
val something : opt<number> = 10
val noText : opt<string> = none
This is harder to implement because the type of x is now something + 10 ==> 20 <option[number[-inf|inf]{0}]>
different depending on the location in the source. This is noText.length ==> none <option[number[0|inf]{0}]>
The reasons for the initial decision to do it in the more that, for example, use custom syntax or support things
strict way were twofold. One, we thought that the more like inheritance. This extension hook has been used in
explicit syntax would make it clearer for users what several KernelF-based DSLs by now.
was going on (less magic). Instead it turned out it
was perceived as unintuitive and annoying. The second 6.5 Range Qualifiers
reason was that the original explicit version was easier A very common situation is to work with ranges of num-
to implement in terms of the type system and the bers. With the original scope of KernelF, for example,
interpreter, so we decided to go with the simpler option. one could use an alt expression to compute a value r
The migration to the current version happened after based on slices of another value t:
significant end-user code had been written, and so we val r = alt | t < 10 => A |
implemented an automatic migration where possible: | t < 10 && t < 20 => B | // or t.range[10..20]
| t > 20 => C |
all some(t) and success(t)were replaced by just t
by migration script that was automatically executed However, as our users told us, this is perceived as
once users opened the an existing model once the unintuitive. The situation gets worse once uses range
new language version was installed. The unnecessary checks as part of decision tables, where many more such
unpackings were flagged with a warning that explained conditions have to be used. Our solution to this approach
the now possible simpler version. We expected users to was to create explicit range qualifiers, so one could write
make the change manually because we were not able the following code:
to reliably detect and transform all cases, and because val r = split t | < 10 => A |
automated non-trivial changes to users’ code is often | 10..20 => B |
| > 20 => C |
not desired by users.
6.3 Enums with data Note that these are not really expressions, because, for
example in < 10, there is no argument given on which
Originally, enums, as described in Section 2.10, were the check has to be performed. That argument is implicit
available only in the traditional form, i.e., without from the context. This is why these range qualifiers can
associated values. However, it turned out that one major only be used in surrounding expressions that have built
use case for enums was to use them almost like a database specifically for use with range qualifiers. The split
table, where the structured value of one enum literal expression is an example. We decided to make this part
would refer to another enum literal (through using tuples of the core KernelF language instead of an extension
or records in as their value type): because these constructs are used regularly.
enum T<TData> {
t1 -> #TData(100, true, u1) 6.6 Enhanced Effects Tracking
t2 -> #TData(200, false, u2)
t3 -> #TData(300, true, u2) Originally, there was only one effect flag: an expression
}
either had an effect or it did not. However, when
enum U<number> { extending KernelF with mutable data, it quickly became
u1 -> 42
u2 -> 33
clear that we have to distinguish between read and
} mofify effects because, for example, a precondition or a
condition in an if is allowed to contain expression that
6.4 Records have read effects, but it is an error for them to have
write effects. Interpreting “has effect” as “has modify
According to our own design goal to keep KernelF small effect” also does not work, because, even for expressions
and simple, and in particular, the assumption that the with read effects, caching is invalid.
host language would supply all (non-primitive) data So far we have decided not to distinguish further
structures, we originally did not have records. However, between different kinds of effects (IO, for example),
it turned out that this was a bidge too far: records are because this distinction is irrelevant for our main use of
useful as temporary data structures, even if the hosting effect tracking, namely caching in the interpreter.
DSL defines the notion of a component, class or insurance
contract. Records are also useful for testing many other 7. Case Studies
language constructs.
However we did not add advanced features to records, 7.1 The Utilities Extension
such as inheritance; we reserve such features for host Context Our first case study is an extension of the
language domain-specific data types. However, the inter- core KernelF languages with more end-user friendly ways
nal implementation infrastructure for records is based of writing complex expressions: decision tables, decision
on interfaces. This way, it is very easy for extension trees and mathematical notations. Figures 16, 17 and
developers to create their own, record-like structures 18 show examples.
Figure 16. A decision tables makes a decision over two
dimensions, plus an optional default value.
P
Figure 19. The definition of the editor for the Ex-
pression essentially maps the structural members (body,
lower, upper) to predefined slots in the notational prim-
itive for math loops.
References
[1] J. S. Foster, M. Fähndrich, and A. Aiken. A theory of
type qualifiers. ACM SIGPLAN Notices, 34(5):192–203,
1999.
[2] U. Shankar, K. Talwar, J. S. Foster, and D. Wagner.
Detecting format string vulnerabilities with type qualifiers.
In USENIX Security Symposium, pages 201–220, 2001.