Skip to content

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`)