Metaprogramming
While gambols novelty lies in its runtime code modification and inspection capabilities, there are a limited number of possibilities for manipulating code at compile time that usually have applications in writing libraries and high performance code.
CFG
The compiler is capable of accepting configuration parameters through the
--cfg
command line option. You can then retrieve these flags and parameters at
compile time using cfg!(<param_name>)
expressions. For example if you
pass --cfg 'debug'
to the compiler, then the following cfg expression will
evaluate to true at compile time (and runtime of course).
print(cfg!(`debug`)) #! prints true
cfg expressions can also evaluate to integers and strings at compile time. For
example passing --cfg 'cfg_test:12'
to the compiler will result in the
following:
print(cfg!(`cfg_test`) * 10) #! prints 120
Note
120 above is evaluated at compile time. Read more about this below.
Compiler CFG List
There are a number of cfg parameters that are built into the compiler to provide valuable information about the target system:
print(cfg!(`target_arch`))
print(cfg!(`target_vendor`))
print(cfg!(`target_os`))
print(cfg!(`target_env`))
print(cfg!(`target_triple`))
print(cfg!(`target_os_version`))
print(cfg!(`target_object_format`))
print(cfg!(`target_cpu`))
print(cfg!(`target_features`))
print(cfg!(`nint_size`))
print(cfg!(`nfloat_size`))
print(cfg!(`nsimd_size`))
One possible output could be
x86_64
redhat
linux
gnu
x86_64-redhat-linux-gnu
0
ELF
haswell
+avx,+aes,+sahf,+pclmul,+crc32,+sse4.1,+xsave,+sse4.2,+invpcid,+64bit,+cmov,+movbe,+avx2,+bmi,+sse,+xsaveopt,+rdrnd,+cx8,+sse3,+fsgsbase,+lzcnt,+ssse3,+cx16,+bmi2,+fma,+popcnt,+f16c,+mmx,+sse2,+fxsr
64
64
256
Const!
You can define constant expressions using const!
. Constant expressions do not
necessarily have an evaluation at compile time as the word constant
might
suggest. They are instead more like aliases for expressions that re-evaluate
wherever and everytime they are used. For example the code below defines a
constant expression that subscripts into a list:
my_list = [1,2,3,4,5]
const! third = my_list[2]
print(third) #! prints 3
third = 3000
print(my_list) #! prints [1, 2, 3000, 4, 5]
Note
Constant expressions are evaluated everytime they are used (as opposed to defined) but that evaluation happens within the namespace they are defined in.
Blocks
Blocks consist of a wrapped series of instructions. The keyword block!
is
used to define blocks. There are 3 different types of blocks in Gambol.
Regular Blocks
You can wrap code in a block in order to assign attributes to that chunk of code. These blocks do not introduce any scope but you could add your own scope if you wanted to.
#print_section
block!
print(`hello`)
end
They attributes could control visibility or could serve other purposes such as finding the block using reflection.
Namespace blocks
These blocks are used to define a new namespace or lexical scope. The scope created however is not going to be a RAII scope. Objects are not going to be destroyed at the end of the scope. If two scopes are defined with the same fully qualified name (have the same name and are defined within the same scope themselves) then the second scope will be an extension of the first. In other words you can define the a scope in separate parts.
block! example
type MyType {
val Int64 = 12
}
end
block! example
fun get_str(t MyType) -> String { t.val.str() }
end
print(example.get_str(example.MyType() { val: 100 })) #! prints 100
Parametric Blocks
Parametric blocks provide a way to write code snippets once for reuse wherever
they reappear throughout the implementation. Functions also serve the same
purpose with the difference being that parametric blocks are not actually
called. They are rather replaced inline wherever they are used. A return
statement in a parametric block for example does not return from that block but
rather returns from the function that block is called in! Unlike constant
expressions parameteric blocks are evaluated in the namespace they are called
in not in the scope they are defined in. Also unlike the two block types above,
parametric blocks introduce a lexical and a RAII scope.
block! longer(a, b) {
a if a.len() >= b.len() else b
}
l = [`hi`, `hello`, `mahalo`, `aloha`]
for i in ..(l.len()-1) {
print(longer(l[i], l[i+1]))
}
output
hello
mahalo
mahalo
In the above code the two parameters a
and b
are actually considered to be
constant expressions from within the block. Parametric blocks can also receive
type parameters before constant expression parameters:
block! assign(T; a) { _ T = a }
print(assign(UInt8; 257)) #! prints 1
print(assign(UInt16; 257)) #! prints 257
Unlike functions, there can be multiple overloads of parametric blocks with differing numbers of parameters. There can also be parametric blocks taking a variadic arg as the last parameter.
block! count_args(a) { 1 }
block! count_args(a, b) { 2 }
block! count_args(a, b, *c) { 2 + len!(c) }
print(count_args(100)) #! prints 1
print(count_args(100, 101)) #! prints 2
print(count_args(100, 101, 102, 103, 104, 105)) #! prints 6
Visibility
Gambols compiler analyzes the code in multiple stages. The first stage of that
process after evaluating possible compile-time expressions is to determine
visibility. Through the attribute #enable
you can dictate which parts of the
code are going to be visible to the compiler and which parts aren't. I other
words you can turn on and turn off parts of the code before the compilation
process begins. As an example of this consider the code below:
#enable(cfg!(`debug`))
block! debug(message) { print(message) }
#enable(not cfg!(`debug`))
block! debug(message) {}
The above is an example of conditional compilation. Ordinarily you could not
define a block with exactly the same signature twice. However only one of
these can be visible to the compiler depending on whether the debug
flag is
used in the command line options or not. This makes it convenient to add debug
messages throughout your application during development and then remove them
with a flag for production builds. Notice that debug
here is defined as a
block instead of a function and therefore the expression message
is not going
to be evaluated until it is actually used in print
. This means adding debug
messages this way for development builds will have no impact whatsoever in the
performance of production builds.
Compile-Time Evaluations
Certain expressions have compile time evaluations. It means they could be used in places where the compiler expects a value to be provided. Here is the list of these expressions along with when and where they can be used in the compiler.
Integers
Binary and unary operations on integer literals have compile time evaluations. They can be used in both the first stage of compilation for visibility checking and in later stages.
print(99999999999999999999999999999999999999999*2222222222222222222222222222)
#! prints 222222222222222222222222222199999999999997777777777777777777777777778
The above binary expression *
is not actually implemented in the final
executable but rather the result is stored by the compiler as the argument to
print
. This is as a result of evaluating the above at compile time. Had one
of those operands been a variable this evaluation would no longer be possible.
Bool
Binary and unary operations on booleans are evaluated at compile time when the operands have compile time evaluations.
Strings
Binary operators ==
, <
, <=
. >
, >=
, is in
and the method len
can
be evaluated at compile time if the string itself and all operands have compile
time evaluations.
#enable(`popcount` is in cfg!(`target_features`))
block!
print(`Your cpu is awesome!`)
end
Is operations
There are two operators is
and is!
. The second one will return the result
as evaluated at compile time and as such will always have a value at compile
time. It is sometimes possible for the compiler to infer the result of the
first operator. In those cases the first one will also have a value at compile
time.
Const!
All constant expressions with right hand sides that have compile time evaluations will also evaluate to a value at compile time wherever they are used.
Note
For the purpose of visibility checking, it must be possible to evaluate
the argument to enable
before the compilation process even begins. As a
result constant expressions and is operations (not to be confused with
the is in
binary operator) cannot be used for that purpose.
If Else
If-else expressions themselves do not have compile time evaluation. However if the branch conditions can be evaluated at compile time then individual branches can be turned on or off at compile time.
fun first_length[T](element T) {
if T is! DynamicList then
return element[0].len()
else if T is! NInt then
return `NInt size is ` T.size
else
return element.len()
end
}
print(first_length([`hello`, `hi`, 12])) #! prints 5
print(first_length(512)) #! prints NInt size is 64
print(first_length(`Gambol`)) #! prints 6
Normally the above code would not compile. Consider the first case for example
where T
is a DynamicList
. The expression return T.size
would be a problem
since nothing named size
is defined in the type DynamicList
. If T
is an
NInt
then the expression element[0].len()
should not compile since integers
cannot be subscripted into. The reason the above code works is because individual
branch conditions of if-else can be evaluated at compile time and turned off
where applicable. For example when T
is DynamicList
the above function
becomes equivalent to:
fun first_length(element DynamicList) -> Len { return element[0].len() }
Info
The return type of the above function first_length[T]
is set to auto
which could mean either Len
or String
depending on which branch of the
if-else expression is enabled.
Pre-disabling all branches
There is one issue with the above branch evaluation though. Consider the code below:
fun first_length[T](element T) {
#disable
if T is! DynamicList then
fun output(element T) -> Len { element[0].len() }
else if T is! NInt then
fun output(element T) -> String { `NInt size is ` T.size }
else
fun output(element T) -> Len { element.len() }
end
output(element)
}
print(first_length([`hello`, `hi`, 12]))
print(first_length(512))
print(first_length(`Gambol`))
Without the #disable
attribute this code would produce a compile error:
test/a.gambol @ line: 137 column: 4 [ERROR] function name already exists in the current scope: output
fun output(element T) -> String { `NInt size is ` T.size; };
with the attribute the output is:
5
NInt size is 64
6
This is because as mentioned earlier, the compiler processes the code in
multiple stages. The if-else branches are disable at the very last stages of
analysis whereas for that analysis to occur in the first place symbols such as
output
are added to the symbol table in earlier stages. Adding the #disable
attribute on if-else would completely disable the if-else expression and all
subsequent instructions in that block until the compiler is ready to evaluate
the branch conditions.
Info
This would make a disabled if-else equivalent to the #enable
attribute
with the advantage that the if-else can work with a wider variety of
expressions not available at the beginning of the compilation process.
Warning
The disadvantage of a disabled if-else is that all subsequent instructions in the block are also disabled. Therefore if you have function definitions for example that come after the if-else you won't be able to use them before they are defined like you normally would.
Compile-Time Raise
You could use raise!
to raise an error at compile time if the raise!
statement gets statically analyzed by the compiler (e.g. it is not in a
disabled branch). This could be useful for example in defining parametric data
structures that have restrictions on the parameters etc.
fun add[T](a T, b T) -> T {
if T is! not nom IntegralNumber then raise! `T MUSTTT be an integer` end
a + b
}
add([1,2], [3,4])
example compile error
/path_to/test/a.gambol @ line: 4 column: 40 [ERROR] T MUSTTT be an integer
raise! `T MUSTTT be an integer`
/path_to/test/a.gambol @ line: 2 column: 1
...
Note
The above if-expression does not have any impact on performance as it is entirely evaluated at compile time not runtime.
Meta Expressions
These are callables that transform the arguments provided into something else.
One example of these is the cfg!
which was elucidated on earlier. cfg!
transforms its string literal argument into either a boolean, integer or
another string literal representing the flags passed to the compiler
executable.
pointer!
Will retrieve a pointer to the variable passed to it:
x Int64
p = pointer!(x)
p as Int64 = 12
print(x) #! prints 12
If using this in a function then you can also grab a pointer to the functions return space:
fun my_fun() -> Int64, String {
pointer!(return, 0) as Int64 = 110
pointer!(return, 1) as String = `hi`
#unsafe
return
}
print(my_fun()[0]) #! prints 110
print(my_fun()[1]) #! prints hi
In the above code my_fun
has to return a value at the end of its body but
we can bypass this requirement by writing an unsafe return statement.
ast!
Will retrieve a pointer to the AST node that represents the variable passed to
it. Gambol's assert
for example uses a block that retrieves the AST node of
the target proposition to generate assert error information:
#enable(cfg!(`debug`) or cfg!(`assert`))
block! assert(proposition, msg) {
if not proposition then global.__assert_error(ast!(proposition), msg) end
}
The global.__assert_error
function will then use the AST pointer to get
information about the expression such as its location and print it:
assert(100 == 200)
output
/path_to/test/a.gambol @ line: 2, col: 7 [ASSERT] 100 == 200
get!
You can access the value in nillable variables without doing any runtime checks
with get!
. By default every time you access a nillable variable there is a
runtime check to see if the variable is nil and raise an exception. You can
bypass that check with get!
which is an unsafe operation and also with the
unsafe member access operator .>
.
a Int64? = 12
print(get!(a)) #! prints 12
len!
Will return the compile time length of tuples and type specifier lists such as variadic type parameters and constant expressions.
fun show_length[*T; *ce]() -> Len, Len {
len!(T), len!(ce)
}
t, ce = show_length[Int64, String, Float; 13, `hello`]()
print(t) #! prints 3
print(ce) #! prints 2
You can also use len!
to get the number of items returned from a functions:
block! print_num_returns() { print(len!(return)) }
fun single_return() -> Int64 {
print_num_returns()
100
}
fun double_return() -> Int64, String {
print_num_returns()
100, `hello`
}
single_return() #! prints 1
double_return() #! prints 2
backend!
This is a low level feature that could be used to insert instructions in the
language used by the compiler backend to the extend that backend supports this
feature. Must provide 3 or more arguments with the first being a boolean
indicating whether or not the instruction is a terminator, the second is the
identifier string for the backend e.g. llvm
and the third the instruction and
any additional arguments and return values necessary as determined by the
backend.
For example the below would insert an unreachable
llvm IR instruction if the
backend is llvm
.
raise InvalidAtomicOrderException()
backend!(true, `llvm`, `unreachable`)