Hello world
fn main {
std.print("hello world\n")
}
Already you can see a couple of interesting things. First of all, a function
starts with fn keyword. If a function does not accept parameters, you don’t
have to type parenthesis. Same with the return type. This function returns
nothing.
The string “hello world\n”, is printed using std.print. std is the name of
the standard namespace. print is the name of the function. The standard library
has internal hierarchy, but some of the most common functions available right
there at the top of the library.
No semicolons.
Variables
There are a couple of ways to declare a variable:
- We can declare an uninitialized variable by specifying its name and its type.
- We declare a variable of arbitrary type by assigning it a value of that type.
Here are few examples:
fn main {
uninitialized int
initialized := true // initialized of type bool
}
Note that variable declaration starts with the name followed by the type. This
is different than C, where the type comes first. The := operator creates a
local variable and initializes it with a value. The type will be that of the
value.
Redeclaring variables
What happens if we already have a variable named a and we redeclare another
variable with the same name? Alchemy allows doing that and it is in fact a
convenient construct to use:
fn main {
a := read_something()
a := process_previous(a)
a := process_further(a)
produce_result(a)
}
We allow this in the language to allow initializing multiple variables with “:=” operator without worrying about mixing existing and new variables.
Functions
We’ve seen a couple of functions. Here’s one that accepts arguments:
fn func(arg1 int, arg2 int) {
std.print("{arg1} @ {arg2}\n")
}
Alchemy supports overloading functions, so we can have multiple functions named
func accepting different set of arguments:
fn func(arg1 int) {
...
}
fn func(arg1 int, arg2 int) {
...
}
The two variants are distinguished by the type and the number of the arguments. Still no return value though. Let’s add it:
fn div(divident int, divisor int) {
return divident / divisor, divident % divisor
}
Note a couple of things:
- The return type of the function is still not declared. In Alchemy, declaring the return type of the function is optional. If not specified, the return type is implied from the return statements. If a function has multiple return statements and they contradict, compiler will return an error.
- A function can return multiple results.
Error handling in functions
Here’s even more interesting example:
fn div(divident int, divisor int) {
if divisor == 0 {
return error("Division by zero")
}
return divident / divisor, divident % divisor
}
This gives us an introduction into error handling in Alchemy. Note that in this
case, the function returns either an error, or two integers. A function in
Alchemy can return either a single error or an arbitrary number of results. You
can think of the error keyword as a type casting that turns any variable or
object into an error variable or object. In this case, the function returns
an error with a string value “Division by zero”.
Handling errors works as follows:
fn arithmetic(expr str) {
arg1, arg2, op or err := parse_expression(expr)
if err != nil {
std.print("Error {err} parsing the expression")
return err
}
if op == "/" {
res := try div(arg1, arg2)
} else {
return error("Unsupported operation {op}")
}
}
There are two kinds of error handling here. The first one using or operator,
creates a new variable of type pointer to an error. We then check if the error
is nil and if it’s not, then it will point at the actual error value.
The second kind of error handling is using the try operator. This operator
will simply check if the function returned an error, and if it did, it will
propagate that error higher up the stack. If div did not return an error,
res will be initialized as expected.
Note that div and arithmetic both return an error. In both cases, the error
is a string. If we, for example, change div to return an integer error, then
this code will not compile because in one code path we will be returning a
string error and in another code path we will be returning an integer error. If
a function has multiple code paths that return an error, the type of the error
in all cases must match. This applies to both explicit return statements as
well as try statements.
What if we try to look at the arg1, arg2, or op, before we check for an
error? The values of these variables is conditioned on error being nil. If we
did not check if err is nil, then the values of these variables can be
potentially undefined and this will lead to a compilation error. In other words,
this example will not compile:
fn arithmetic(expr str) {
arg1, arg2, op or err := parse_expression(expr)
std.print("arg1 is {arg1}\n")
}
You can still ignore the error by using _ as the name of the error variable.
This is a special syntax telling Alchemy’s compiler to ignore the error. In this
case, variables arg1, arg2 and op will be initialized with the default
initializer for their type.
fn arithmetic(expr str) {
arg1, arg2, op or _ := parse_expression(expr)
std.print("arg1 is {arg1}\n") // will print 0 if there was an error
}
This example, is the first time we saw an if statement. The syntax is rather
straightforward. if followed by a boolean expression.
A glimpse into data structures
Let’s say we want to create a vector of integers:
v := std.adt.vector(int)
v.push(10)
v.push(20)
std.adt namespace contains different data structures. In this case we need
vector. This is the first time we see a macro. std.adt.vector is a macro
function. It generates the code for a vector of integers, creates an object of
that type and returns it.
Vector can also accept some options and we can initialize its value:
v := std.adt.vector(int, {capacity=16}, {10, 20, 30})
Here we initialized it with options. In this case, there is only one option - capacity. And we initialized it with initial values. We can omit initialization values or the options structure (it is a structure).
Aside from vector, there are other data structures. For instance
std.adt.linked_list is a doubly linked circular list. std.adt.bst is a
binary search tree. std.adt.hashmap is a hash table.
Data types, arrays and structures
Arrays are defined as
a := int[10]
You can initialize the array with
a := int[]{1, 2, 3, 4}
Note that you don’t have to specify the size if there is an initializer. You can mix size and initializer as well as tell Alchemy to initialize all members to a certain type.
a := int[10]{1, }
This syntax will create an array of 10 elements and will initialize them all with 1.
Built-in types
We saw int, which is a platform specific integer type. It is usually either 32
or 64 bits. uint is the unsigned version and float is the platform dependent
floating point type.
Here’s a list of other built-in types and their sizes:
- Types named
iNwhereNis one of 8, 16, 32, 64, 128, represent signed integer of different sizes. For instancei64is a 64-bit signed integer. - Types named
uNwhereNis one of the 8, 16, 32, 64, 128, represent unsigned integers of different sizes. - Types named
fNare the floating point types. stris the “fat” string type. It is usually anintrepresenting the length of the string, followed by the string’s characters.charrepresents a single character. It is usually 4 bytes to accomodate a single unicode character.byteis a single byte type. It is the equivalent ofu8.boolis the boolean type. Variables of this type can be eithertrueorfalse.
Structure types
We can create structures, as follows:
new_type := struct {
field1 int
field2 str
field3 f64
}
Note that this will create a new type. Yes, it uses :=, same operator we used
to create a variable. As you might have guessed, this implies that you can use
this operator to create aliases to types.
alias := new_type
array := int[20]
When using structure type, no need to specify the struct keyword (like you
would in C). You can use the type name directly.
Pointers
Alchemy supports pointers. Pointers to various types are declared as the name of
the type, followed by the * character. For example:
n := 10
pn := &n
pend := int* // uninitialized pointer
pstruct := new_type* // This is our structure type...
Macro
Macros are one of the most exciting features in Alchemy. Macros in Alchemy are
functions. Instead of declaring the function with fn we will use macro.
macro fibonacci(n int) {
if n <= 3 {
return 1
} else {
return fibonacci(n-1) + fibonacci(n-2)
}
}
Unlike the typical function, macros are executed in the compiler during
compilation. The consequence of that is that the arguments passed to macros must
be known at compile time. The following use of the fibonacci function
fn main { std.print("{fibonacci(10)}\n") }
will work, and the following:
fn main {
n := 10
std.print("{fibonacci(n)}\n")
}
will not work. That is because the value of the variable n is not known at
compile time.
The true power of macros in Alchemy comes from the ability to manipulate the AST nodes of the program. ADT, or Abstract Data Types in Alchemy are implemented using macros. Alchemy does not support templates or generics. Instead, macros fulfil this role.
Macros returns an array of AST nodes. These nodes are then stored in the variables that receive the return value of the macro.
list := std.adt.linked_list(int)
Here, linked_list is a macro inside of package std.adt. It receives a type
as an argument and returns the AST node representing an empty linked list of
that type. In the example above the type is an integer.
To implement something like linked_list we need two things. First we have to
introduce the linked_list structure into some scope. Since the implementation
depends on the type, the new type we introduce must be named after the type it
contains. For integers, linked_list_of_int seems like a good type name. The
scope we will introduce it to is the scope of the std.ast package. It is a
scope used by different packages and multiple users of linked lists in the
program will be able to find the linked_list_of_int there.
We start by checking if linked_list_of_int is already in the scope. One
helpful package that will help us with these and other AST manipulations is
std.ct. ct stands for Compile Time.
macro linked_list(n std.ct.type) {
if !std.ct.find_in_scope(std.ct.package, "linked_list_of_##n.name") {
std.ct.inject(std.ct.package, code {
linked_list_node_of_##n.name := struct {
next linked_list_node_of_##n.name*
prev linked_list_node_of_##n.name*
data n
}
linked_list_of_##n.name := struct {
head linked_list_node_of_##n.name
}
})
}
return code init linked_list_of_##n.name {
self.head.next = &self.head
self.head.prev = &self.head
}
}
Let’s break down what’s happening here:
The macro keyword declares a compile-time function. The argument n is of
type std.ct.type, meaning it receives a type (like int) at compile time.
We check if linked_list_of_##n.name already exists in the package scope. The
## operator concatenates identifiers, so for n = int, this becomes
linked_list_of_int. If the type doesn’t exist, we inject it.
The code { } block creates AST nodes from the code inside. This is how we
generate new code at compile time. We inject two struct definitions:
linked_list_node_of_##n.name: A node with next/prev pointers and datalinked_list_of_##n.name: The list itself, containing a sentinel head node
Finally, we return an initialization block. Initialization blocks are special
constructs for in-place initialization. Such blocks are allowed to use self
keyword to refer to the variable being initialized. When someone writes:
list := std.adt.linked_list(int)
The macro expands to:
list := linked_list_of_int {
self.head.next = &self.head
self.head.prev = &self.head
}
The init block guarantees that self IS list—same memory location, no copy.
This is critical for self-referential structures like our circular linked list,
where the sentinel node’s pointers must reference itself. Without this
guarantee, we would create a temporary, set its pointers, then copy it to
list, leaving the pointers dangling at the old location.
Inititialization blocks are like inline constructors that can see variables from their surrounding scope, making them more flexible than traditional class constructors.