This project currently goes under the Working name eval.
The semantics and even syntax are by no means finalized yet.
The goals of eval are as follows:
- Allow fine-grained memory control.
- Allow anything that can happen at run-time to happen at compile-time.
- Make initialization easy and reliable.
- Provide genericity by making types and even definitions first-class citizens.
- Make side-effects visible and contained.
The syntax is inspired by a mixture of Rust, Haskell, Javascript, Odin, and a
hodgepodge of other stuff. The compile general compile-time support is a
logical next step from that already found in languages such as C++
(constexpr) or Zig (comptime).
A minimal program consists of a main procedure such as the following:
let main = proc(args: [][]u8) -> void => {
std.print("Hello World!\n")
}
We can see here the general structure of a definition in eval: The keyword
let, followed by a name, an optional type prefixed by : (absent in the
above case), and a value prefixed by = - which is again optional.
In the above case, the value of main is a proc - procedure - taking as its
single argument a slice of slices of u8s, which is accessible as args in the
procedure's body. Its return type is void. \
We can also already see that there are no semicolons or statement terminators of any kind. Instead the syntax is designed to make it unambiguous where expressions end, making explicit terminators redundant.
We have chosen to make the body of main a block, introduced by { and
terminated by }, as should be familiar from most C-family languages. Since
this block only contains a single "statement", we can omit it like so:
let main = proc(args: [][]u8) -> void => std.print("Hello World!\n")
Generally, most language constructs can be used in most places, since they are
generally expressions and not statements in the traditional sense.
if can thus also be used like the ternary ?: operator in many other
languages:
let x = if condition() then 1 else 2
Variables declared using let are by default immutable. This means that their
value cannot be altered after they are defined.
To create a mutable variable the keyword mut is used instead of let:
let i = 5
i = 6 // compiler error
mut j = 7
j = 8 // OK
Immutability is always "deep", meaning that it is transitively applied to members of a variable.
Along with this concept there is also that of the distinction between functions
(func) and procedures (proc). This is similar to that found in Nim, in that
funcs may not access mutable global data, while procs may.
Interestingly, a pure function is then a func which only takes
value-arguments or immutable reference-arguments (i.e., pointers, slices, or
types containing them as members).
There are only a few types of control flow constructs:
- Loops, introduced by the keyword
for(there is nowhile;fordoes double-duty) - Conditionals, introduced by the keyword
if - Return, which comes in two variants:
return value, as well asleave. This is sadly necessary to avoid ambiguity in case of void returns due to the lack of an expression terminator. - Break, which, again, comes in two variants: The familiar
break, simply exiting the surrounding loop, andyield value, which exits with the given value. The rationale for having both is the same as withreturnandleave.
Each of these constructs is examined in more detail in the following sections.
All types of loops are introduced by the keyword for. To write a traditional
while loop, the following syntax is used:
for condition do {
// repeated until condition is false
}
The do in this case is optional, and mainly intended to make loops that have
non-block bodies more legible.
Additionally a step can be specified (equivalent to to third element in C's
for loops):
for i < 10, i += 1 {
// Assuming i is initially 0, this will repeat 10 times
}
Variables can also be defined inside the loop header using the where keyword
inspired by Haskell:
for i < 10, i += 1 where mut i: u32 = 0 {
// This will repeat 10 times, with i ranging from 0 to 9
}
The same can also be used with if and even function definitions.
Loops may yield a value using the yield and finally keywords:
let five = for i < 10, i += 1 where mut i: u32 = 0 {
if i == 5 then
yield i
} finally undefined
As soon as a yield is reached, the loop terminates and evaluates to the
yielded value (in this contrived case always 5).
If no yield is reached before the loop's condition becomes false, the value
provided by the finally clause is used instead. In the above example, the
keyword undefined means that we assure the compiler that this statement may
never be reached.
Infinite loops can be expressed by omitting the loop condition like so:
for do {
// Round and round and round and ...
}
Lastly, ranged loops can be expressed as well, using the <- element-of
"operator":
let is: []u32 = some_slice()
for i <- is {
// Do something with each i
}
// Or more succinctly using where
for i in is where is: []u32 = some_slice() {
// Do something with each i
}
The iterated value must be of a type that implements the Container trait.
A slice of Ts for example has
impl Container([]T, T) = {
// Compiler magic I guess
}
Conditionals are introduced using the keyword if and subsume the role of
traditional if-statements as well as the ternary operator ?::
if condition then {
consequent
} else {
alternative
}
Just like the do for loops, the then is optional. else may also be
omitted, in which case the if's type must however be void:
if condition {
std.print("condition was true\n")
}
The final control-flow construct is the switch. It allows succinctly
executing code based on the value of a switched-over expression, working mostly
like that familiar from Java or C.
switch value
case a => got_a()
case b => got_b()
case c => got_c()
case _ => got_other()
Unlike C or C++, fallthrough between cases is not supported.
Instead of using default to indicate to code to execute in case no other
cases match, case _ is used.
Just like if and for, switch is also an expression, meaning that it can
have a result, and also supports a where clause:
let x = switch y where y = some_value()
case a => 1
case b => 2
case c => 3
case _ => undefined
Types are first-class citizens, meaning that - at least during compile-time - they can be stored in variables, inspected, and reassigned.
The basic built-in types are as follows:
- Unsinged integers -
u8,u16,u32andu64. It is planned to eventually extend this tou<any-bit-width>. - Signed integers -
s8,s16,s32ands64. Just like for unsigneds, these shall eventually be arbitrary-width. - IEEE 754 single- and double-precision floating point numbers -
f32andf64. - boolean truth values -
bool - The unit type -
void - slices, or counted pointers -
[]<element-type> - Pointers -
*<pointed-type>, or?<pointed-type>to allownull. These do not support pointer arithmetic. - Multi-pointers -
[*]<pointed-type>or[?]<pointed-type>. These are equivalent to normal pointers but support pointer arithmetic and indexing like slices. - Arrays -
[<count>]<element-type> - Tail-arrays -
[...]<element-type>to support arbitrary-length aligned access to elements after a header (like a zero-length array in common C/C++ extensions). - Varargs -
...<arg-type>to support variadic functions. - Compile-time integers -
CompInteger. This is the type assigned to integer literals. It is also a first-class citizen of the language, meaning e.g.1 + 2still results in a value of typeCompInteger.
Unlike other types, this one supports implicit conversion to integer types. - Compile-time floats -
CompFloat, following the same semantics asCompIntegerbut for floating-point literals. - Compile-time strings -
CompString, again being the type assigned to string literals. These can be implicitly converted to[<strlen>]u8. - The Type-Type -
Type- to store other types - The Definition-Type -
Definition, holding a name, type, default value, and other tidbits such as mutability information. - Composite and array initializer types -
CompCompositeandCompArrayrespectively. These act similar to the otherComp*types, but for composite and array initializers (discussed further under Initialization).
Note that there is no dedicated character type. This is meant to drive home the
point that there is no such thing as a simple "character", but rather only
concepts such as code-units and -points in modern encodings (i.e., Unicode).
And even their usefulness is debatable.
Instead, treating text as a slice of unsigned integers of an appropriate
bit-width is encouraged.
All types that have elements apart from arrays also support a mut between the
type introducer and the element type - as an example, *mut u32 in case of a
pointer.
This decouples the mutability of these reference types from that of their
referenced data:
let p0: *u32 = something() // Immutable pointer to immutable data
let p1: *mut u32 = something() // Immutable pointer to mutable data
mut p2: *u32 = something() // Mutable pointer to immutable data
mut p3: *mut u32 = something() // Mutable pointer to mutable data
Composite types - struct, union, and anything in between - are created
using type builders, which expose three functions:
let create_type_builder = func() -> TypeBuilder // Creates a fresh type builder
let add_type_member = func(mut tb: TypeBuilder, definition: Definition, offset: ?s64) -> void // Adds a member to the type being built
let complete_type = func(tb: TypeBuilder, size: u64, align: u64, stride: u64) -> Type // Complete the type being built.
These are intended to be abstracted behind functional interfaces. To implement
the C struct keyword, the following function would suffice (admittedly using
some not-yet-introduced builtins to introspect definitions):
let CStruct = func(members: ...Definition) -> Type => {
mut tb = create_type_builder()
mut offset: s64 = 0
mut max_align: u64 = 1
for member <- members
{
if is_global(member) then
{
add_type_member(tb, member)
}
else
{
let align = alignof(type(member))
if align > max_align then
max_align = align
offset = next_multiple(.of = offset, .factor = align)
add_type_member(tb, member, .offset = &offset)
offset += sizeof(type(member))
}
}
let size = next_multiple(.of = offset, .factor = max_align)
// The last expression in a block is also its value, meaning it is implicitly `return`ed
complete_type(tb, .size = size /* C-structs are padded to their alignment */, .align = max_align, .stride = size)
}
This can now be used to create e.g. the same layout as an arbitrary struct:
struct MyStruct
{
int8_t x = 4;
static uint32_t* const glob;
int32_t y = 2;
};
becomes
let MyStruct = CStruct
(
mut x: s8 = 4,
global glob: *u32,
mut y: s8 = 2, // Note that trailing commas are allowed. That is a good thing™
)
union can equally be derived, by adjusting member offsets and the resulting
type's total size.
Eval uses a system somewhere in-between
nominal and
structural type
equivalence that can be most easily understood by how it is implemented:
- Two references to the same primitive type are considered equivalent.
- Two types created by different calls to
complete_typeare considered distinct. - Calls to all other functions that return types are effectively cached,
meaning that the same type-valued function called twice with the same
arguments returns the same type, since the same underlying call to
complete_typegets reused. - The results of calls to type-valued functions whose return type is marked
with the
distinctoperator are considered distinct as when bound to an identifier.
However, they are still considered equivalent to the same call not bound to an identifier.
Some examples to illustrate this:
let T1 = func(def: Definition) -> Type => {
mut tb = create_type_builder()
add_type_member(tb, def, .offset = 0)
complete_type(tb, .size = sizeof(def), .align = alignof(def), .stride = strideof(def))
}
// Word-for-word copy of T1
let T2 = func(def: Definition) -> Type => {
mut tb = create_type_builder()
add_type_member(tb, def, .offset = 0)
complete_type(tb, .size = sizeof(def), .align = alignof(def), .stride = strideof(def))
}
let T1a = T1(x: u32)
let T1b = T1(x: u32)
let T1c = T1(y: u32)
let T2a = T2(x: u32)
assert(T1a == T1b) // Equivalent due to same arguments
assert(T1a != T1c) // Distict due to differing arguments
assert(T1a != T2a) // Distinct due to different underlying funcs
let D = func(def: Definition) -> Type = distinct T1(def)
assert(D(x: u32) == D(x: u32)) // Equivalent, since not bound to identifier
let Da = D(x: u32)
let Da2 = Da
let Db = D(x: u32)
assert(Da == D(x: u32)) // Equivalent, since one side is not yet bound to an identifier
assert(Da == Da2) // Equivalent, since both result from the same call site
assert(Da != Db) // Distinct, since both are bound to an identifier
This ensures that there are no accidental type equivalences due to equivalent structures; It also allows type functions to be used directly and still be considered equivalent, essentially allowing the same usage as C++ templates.
To elaborate a bit on the distinct operator (which is currently not yet
implemented or even syntactically supported): It cannot only be used on
function return values, but more generally on types, working similarly to
Haskell's newtype:
let MyU32 = distinct u32
let x: MyU32 = 1
let y: MyU32 = 2
let a: u32 = 3
let _ = x + y // OK
let _ = x + a // compile-time error due to incompatible types.
Initialization is supported by two constructs: Named arguments and compound literals.
Named arguments have already been used in the section on types. They
take the form of a . followed by an identifier, an = and then the desired
value:
let my_func = double(value: u32) -> u32 = 2 * value
let four_o_four = my_func(.value = 202)
Named arguments can occur in any order in the call and be mixed with non-named
arguments. The semantics of this are similar to those of C-style enums, with
non-named arguments following a named one being assigned to the following
parameters of the called function.
Additionally, there are compund literals, divided into two types, Array and composite.
Array literals are started by .[, and ended by a matching ]. The following
for example defined my_array as a four-element array holding the integer
values from 1 to 4.
let my_array: [_]u8 = .[1, 2, 3, 4]
Positional initialization is not yet supported, but definitely planned for the future.
Composite literals in the meantime are similar to C99's or Zig's designated
initializers and enclosed in .{ and a matching }. Each value can either be
positional or named, using the same syntax as named function arguments:
let Vec2 = Struct(x: f32, y: f32)
let my_vec: Vec2 = .{ .x = 0.5, .y = 0.8 }
let my_other_vec: Vec2 = .{ 1.0, 71.3 }
This is currently still up for consideration. I believe it should be based on
traits, similar to those found in Rust or Haskell's classes. Traits in this
concept express a relation over types.
A good example to consider seems to be that of iteration, noting that the
following is just a mess of ideas that are not even internally consistent at
this point:
// This would be a built-in trait supporting the `<-` element-of syntax in loops
let Container = trait(Cont: Type, Iter: Type, Elem: Type) =
{
let iterator = func(cont: *Cont) -> Iter
let has_next = func(iter: *Iter) -> bool
let next = func(iter: *mut Iter) requires has_next(iter) -> *Elem
}
impl Container(.Cont = []u8, .Iter = []u8 /* Use a slice as an iterator for itself by advancing its beginning */, .Elem = u8) =
{
let iterator = func(cont: *[]u8) -> []u8 = cont.*
let has_next = func(iter: *[]u8) -> bool = count(iter.*) != 0
let next = func(iter: *mut []u8) -> *u8 =
{
let head = &iter.*[0]
iter.* = iter.*[1..]
head
}
}
// We can now use the `<-` element-of syntax on []u8, as we have defined an impl for it.
let iterating
for i <- my_slice where my_slice: []u8 = some_slice()
{
// Do something with each i
}
Functional contracts, supported in the form of pre- and postconditions, are
supported by the keywords requires and ensures respectively (both initially
inspired by Midori).
These can follow a function type and define the permissible values of inputs
and global state, or the possible resulting return value and global state
repsectively. The current intention is to runtime-check them in debug mode,
while using them for optimizations in release mode. During compile-time
evaluation they should probably always be checked.
To build this project, you will need
- A
cmakeinstallation of at least version 3.22 - One of the supported C++ compilers
Once cmake is installed configure your build system as follows:
- For msvc:
cmake -S . -B build/msvc -DCMAKE_CXX_COMPILER=msvc -DCMAKE_C_COMPILER=msvc - For gcc:
cmake -S . -B build/gcc -DCMAKE_CXX_COMPILER=g++ -DCMAKE_C_COMPILER=gcc - For clang:
cmake -S . -B build/clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_COMPILER=clang
Note that the exact build directories can of course be set to anything of your
liking, but the given values correspond to those used by build-all.ps1,
making it possible to reuse the same build configuration when you decide to use
this wrapper.
Once the build system is configured run cmake again to actually perform your build:
cmake --build <build-directory-from-before>
This will create a binary compiled in debug mode somewhere inside your build
directory (the exact location depends on your compiler).
To run the resulting compiler on the source.evl file found in the sample
directory, use
<build-directory-from-before>/<path-to-exe> -config sample/config.toml
The project's test suite can be run as part of the cmake build by specifying
the run-tests target. By default this is not enabled to speed up quick
rebuilds, even though the test suite clocks in at under one second in total.
If you want to build the project on all available supported compilers and
platforms, you can use make-all.ps1. This is a wrapper around cmake which
invokes all the available supported compilers on your
system.
If you test a new build configuration, please add it to the script, as well as
the list of supported compilers.
The following is a list of the compilers and platforms under which builds are known to succeed.
| Platform | Compiler | Versions |
|---|---|---|
| Windows 10 | msvc | 19.33, 19.39, 19.44 |
| Ubuntu | clang | 18.1 |
| Ubuntu | gcc | 13.3 |