Statements and Expressions¶
Variables¶
There are three keywords to define variables: var
, let
and const
.
var
and let
get assigned at runtime and const
gets evaluated at compile time.
let
may only be assigned during its declaration, otherwise the value is supposed to be
a constant.
Variables in global scope might additionally be specified with export
to make other
modules able to refer to them.
Additionally, the #extern
pragma may be applied. Normally variables are prefixed
by the module name in the form of module::name
in the resulting binary. If
extern is specified, only the name of the variable is used as the name inside the binary.
// Let must be assigned a value
let a = 20
// a = 30 // This is illegal because
// let creates a constant
// You may assign multiple values at once
var b, c = 10, 30
// You can define the type
// of a variable like this
var d: int64 = 4
// This variable is a compile time constant
const MY_CONST = 10 * 20
// ~~~~~~~
// This runs at compile time
// This variable is exported
export var special = 20
A variable that is called _
may be defined multiple times. Accessing it is not recommended
but it is going to return the last written value. This is useful for discarding certain arguments:
def a_lot_of_stuff -> int, int, int {
return 10, 20, 30
}
let a, _, _ = a_lot_of_stuff()
Cast expression¶
You can convert between types using the cast expression. It looks like this:
let a: double = 20.5
let b = a !int
Be careful, sometimes there might be data loss!
If you want to convert the bit pattern, then you have to use pointers. Unlike in C, an operation like this is not undefined behavior, so you are safe to do it that way.
If statements¶
The if statements in Princess work just like in any other programming language.
You do not need parenthesis around the condition. However,
every if statement needs to be followed by {}
.
var a = 20
if a > 15 {
print("A is bigger than 15\n")
} else if a > 10 {
print("A is bigger than 10\n")
} else {
print("A is something else!\n")
}
Additionally there is an if expression which works like this:
let a = 20 if 10 == 20 else 30
It works basically like the ternary operator in C just with a more sensible syntax.
Static if¶
Static ifs work just like normal if statements, except that the expression gets evaluated
at compile time. This means that the body of the if statement is getting substituted
in the constant evaluation stage. Only the parser needs to be happy about
the contents, and the branches that are cut out don’t get typechecked.
An #if
statement doesn’t create a new scope but instead everything inside
is of the parent scope. That means variables defined inside of it are visible from outside.
#if defined WIN32 {
def my_code() -> int { return 1 }
} else {
def my_code() -> int { return 2 }
}
Switch statement¶
The switch statement currently only works for integral types and enums. Unlike in C, there is no fallthrough. You can specify multiple values in a single case, or use ranges to match multiple values. Case labels need to be compile time constants at the moment. This will change as soon as pattern matching becomes a thing.
There might optionally be one case label without arguments, which is called when none of the other cases match.
var i = 20
switch i {
case 10, 20; print("Good number")
case 20..40; print("Bad number")
case; print("I don't know about this")
}
Loops¶
In Princess there are three kinds of loops. while
, loop
and for
.
Additionally you may exit a looop with break
or go to the next iteration with continue
.
var i = 0
loop {
if i == 2 { continue }
print(i, " ")
i += 1
if i == 10 { break }
}
print("\n")
A loop
is basically analog to while true
. It executes forever until a break
is
used to exit the loop.
A while loop executes while a condition holds true:
var i = 0
while i < 10 {
print(i, " ")
i += 1
}
print("\n")
A for loop might go over a range, or the values in a generator.
for var i in 0..10 {
print(i, " ")
}
print("\n")
Assert¶
Assert is basically a way to make sure that a condition is true, and abort in the case of failure. This is to make sure that the program is in a safe state, and not to return errors to the user of your program. When an assertion is failing, it is printing the message specified and a stack trace. Assertions behave a bit differently inside of tests, see the section on this for more information.
// This simply fails (unreachable code)
assert
// This fails if the condition is met
assert 1 + 5 == 6
// This also prints a message
assert 10 == 20, "This fails"
Functions¶
Functions may be defined at top level with the keyword def like this: The arguments are specified in parenthesis after the function name and are of the form “name”: “type”. The types need to be defined here and can’t be inferred like for variables.
The return type is optionally defined by using an arrow followed by one or more return types.
def add(a: int, b: int) -> int {
return a + b
}
def foo -> int {
return 10
}
Functions may be defined in any order, so this is perfectly valid:
hello
def hello {
print("Hello World!\n")
}
Note however that this does not apply to compile time code. Inside of a const or a function that is called at compile time, the function may only refer to code that has already been defined. This might not be a problem if you import a module as everything in there has been defined already, but watch out if using compile time code in the same module. This restriction is subject to change in the future.
You might mark a function with implicit
. When trying to convert from a type A into a type B
it is going to search for an implicit function that is imported into the current scope.
type A = struct { s: Str }
implicit def to_string(a: A) -> Str {
return a.s
}
let a = [ s = "Hello" ] !A
let b: Str = a // implicit call here
Just like variables, #extern
may be used on a function, this does exactly the same thing.
Functions may optionally be defined without a body, this might be useful if you are referring to a function that is provided by an external library.
A windows only functionality is the #dllimport
and #dllexport
flags which do import
a function from a DLL or export it when creating a DLL.
Additionally a function may have a variable amount of parameters. For this you either define the
last argument as ...
or together with a type like this: a: int...
. The first form
is only useful when calling to C as there is no way to read out the arguments which are supplied
in that way. The second form passes an array of type [int]
in this case. The function may
also be called with an array as the last argument, which does pass the array as varargs.
Warning
The arguments array gets cleaned up by the calling function after calling your function, so you need to copy it if you plan to store it in a global variable.
def some_function(...) {}
some_function(10, "string", -2.5)
def sum(args: int...) -> int {
var sum = 0
for var arg in args {
sum += arg
}
return sum
}
Functions may be overloaded, that is they might have the same name but accept different arguments:
def add(a: int, b: int) -> int {
return a + b
}
def add(a: double, b: double) -> double {
return a + b
}
add(10, 5) // This calls the first function
add(10.5, 5.2) // second function
An overloaded function may be accessed by using the parameters directly on the identifier:
let a = *add::(double, double)
let b = *add::(int, int)
Polymorphic Functions¶
A function may become polymorphic if it gets specialized with a specific type at compile time. Currently there are a few ways to create polymorphic functions. One is by accepting a specific type like so:
def my_function(type A) -> A {
return [] !A
}
This essentially means, that a type is provided to the function at compile time. So multiple
versions of this function get created if different types get specified here.
A
is only valid after its creation, so you can not refer to the type A
from an argument that is defined prior to the type argument.
There is also a way to accept arguments of a type directly like so:
def my_function(a: type T) -> T
This will take the type that is supplied as the argument itself. You can also provide types that are more complex:
def my_function(a: type [T]) -> T
This essentially means that the function only accepts arrays as a parameter.
The other way of creating polymorphic functions is by using interfaces. More on that see the section about Interfaces.
Operator Overloading¶
A function may refer to an overloaded operator if it is using that operator as a name. These functions get converted to have a different name in the final output and can be referenced as that.
Neither pointer arithmetic nor the and
, or
or the .
operator
can be overloaded. Additionally, the reference *
and dereference @
operators
may not be overloaded either. This might change in the future.
::
also looks like an operator but it is actually part of an
identifier.
Unary Additon |
+ |
|
Binary Additon |
+ |
|
Unary Subtraction |
- |
|
Binary Subtraction |
- |
|
Multiplication |
* |
|
Division |
/ |
|
Modulo |
% |
|
Right Shift |
>> |
|
Left Shift |
<< |
|
Bitwise and |
& |
|
Bitwise or |
| |
|
Bitwise xor |
^ |
|
Bitwise negation |
~ |
|
Less than |
< |
|
Greater than |
> |
|
Less or equal |
<= |
|
Bigger or equal |
>= |
|
Equal |
== |
|
Not equal |
!= |
|
Compound assignment |
-= |
|
Compound assignment |
+= |
|
Compound assignment |
*= |
|
Compound assignment |
/= |
|
Compound assignment |
%= |
|
Compound assignment |
>>= |
|
Compound assignment |
<<= |
|
Compound assignment |
&= |
|
Compound assignment |
|= |
|
Compound assignment |
^= |
|
Tests¶
A function may be marked with #test
, this renames the function by prepending __test::
to it and adds an extra context parameter called env. The built in testrunner can compile a file with
test functions in it and it is basically going to run a separate process to call these functions.
This means that a segmentation fault or similar is not going to bring down the entire test
runner.
The env parameter essentially looks like this and is defined in runtime.pr:
export type TestEnvironment = struct {
out: def [] -> &string
err: def [] -> &string
assertion_handler: def [bool, *char] -> []
}
Out and err return the captured standard output and standard error, so that the test can make assumptions based on these. Calling any of these functions will reset the buffer, which means that when calling it again it is not going to return text that has been printed prior to the first call.
def #test test_random_stuff {
assert 10 == 10
print("Hello World")
assert env.out() == "Hello World"
}
assert
statements will evaluate and call env.assertion_handler
instead of aborting
the program outright.
Generators¶
Generators are basically coroutines that may return multiple values. Any function can become
a generator by using a yield
statement in its body. The return type of the function is
going to be runtime::Generator(T)
with T being the specified return value of the function.
Generators can be used manually by calling generator.next()
which returns an Optional(T)
.
When this Optional
is empty, the Generator was done processing.
Alternatively you may use a for loop to iterate over a generator.
def generator -> int {
yield 1
yield 2
return 3 // A return stops the generator
// and returns a last value
}
for var i in generator {
print(i, " ")
}
print("\n")
You may also use yield from
inside a generator to chain generators together, this is basically
equivalent to using a for loop and yielding every value from the generator.
Closures¶
Functions defined inside of other functions are basically closures.
They have the type [A, B] -> [C, D]
.
A closure has access to the variables of the outer function but only as copies. It is possible however to refer to the addresses of variables outside of the closure. You can use this to modify variables from outside of the closure.
Warning
Do note however that the lifetime of these variables is not extended. When the calling functon returns, these variables are gone.
def main {
var a = 5
var b = 20
def closure {
assert b == 20
let pa = *a
assert @pa == 10
}
b = 30
a = 10
closure()
}
main
Function calls¶
Function calls in Princess are the function name followed by an open parenthesis and a
closing parenthesis. Functions with zero arguments may be called without
the parenthesis. This means that if you want to take a reference of a function, you
need to use the address of *
operator.
A function call might optionally use named arguments at the end of the argument list. These might be mixed with normal function calls as needed. The order of the named arguments is not fixed, you may call them in any order.
def my_function(a: int, b: double, option: bool) {}
my_function(10, 1.5, option = false)
Constructors and Destructors¶
There are two extra magic methods in Princess to
support RAII and valid copying. Both of these functions
must be marked export
The first is the copy constructor. This gets called whenver a value is copied. It receives a pointer to the new object as the first argument and a pointer to your object as the second argument. If a copy constructor is defined, the object’s data is not getting cloned, you have to do that yourself!
The second special function is the destructor. It gets called whenver your object gets out of scope. You can use it to clean up resources. It gets a pointer to your object as the only argument.
type MyStruct {
a: *int
}
def make_my_struct(a: int) {
let ptr = allocate(int)
@ptr = a
return [ ptr ] !MyStruct
} -> MyStruct
export def construct(
copy: *MyStruct, this: *MyStruct) {
copy.a = allocate(int)
@copy.a = @this.a
}
export def destruct(this: *MyStruct) {
free(this.ptr)
}
Defer¶
A defer statement is essentially run at the end of a function. It works similar to Go, where the defers are added to a list and worked through in reverse order in which they were encountered.
This can be useful to make sure that a resource is correctly freed, without having to do such at the end of the function. This way the initialization and destruction can happen in the exact same place.
def read_file {
let fp = open("My file.txt", "r")
defer close(fp)
print(read_all(fp))
// The defer happens here
}
read_file
Sizeof and Alignof¶
The two keywords size_of
and align_of
return the size of a type and the
alignment of a type respectively. These might be removed in the future as it
is possible to get both by using reflection in the form of type.size
and type.align
.
type S = struct {
a: int
b: int
}
assert (size_of S) == 8
assert S.size == 8
assert (align_of S) == 4
assert S.align == 4
Typeof¶
The type_of
keyword returns the type of an expression.
This can be useful for generic programming.
assert (type_of 10 + 20) == int
Defined¶
Use defined
to check if an identifier exists. You can use this
together with typed function identifiers to check if a function for
a specific type exists.
It’s also useful to check for windows or linux as the identifiers for each are only defined on the respective platforms.
#if defined WIN32 {
#if defined special_function::(int, int) {
special_function(10, 20)
}
}