Introduction
This is the Flint Wiki. You will be able to find anything regarding Flint in this Wiki. It's primary focus is the syntax of Flint, how it works and how to use the language effectively. All features of Flint and how to use them are explained in this Wiki. All chapters are structured in a way where later chapters build on the lessions of earlier chapters, so you will naturally have the best experience with Flint if you follow the guide closely from front to end.
Flint is a high‐level, statically‐typed language designed to deliver maximum power in the most approachable way. Its core philosophy is the Data-Object Convergence Paradigm — an ECS-inspired model where data is separated from behavior and then composed deterministically into class-like entities. Every function in Flint is pure (no side effects), and functions themselves are data that can be passed around or even hot-reloaded — thanks to the Thread Stack, which eliminates the possibility of race conditions by design. Write fast, predictable code without hidden complexity.
Installation
Installing Flint is really easy. Just download the flintc
binary for your given platform from the Releases page of the flintc
repository.
Linux
To make the Flint compiler available from any path in your terminal, and to make it executable through just calling flintc
in your terminal, you need to copy the flintc
executable into the $HOME/.local/bin/
directory and you need to ensure it is marked as executable with this command:
chmod +x $HOME/.local/bin/flintc
You need base-devel
(Arch) or build-essential
(Ubuntu) in order for the Flint compiler to be able to compile any program. It needs the crt1.o
, crti.o
and crtn.o
files available to it.
Windows
It is actively worked on an installer (.msi
file) which will download the compiler for you and will set the PATH
variable accordingly. Until then, the path to the executable (flintc.exe
) needs to be specified manually when compiling a Flint program.
From Beginner To Intermediate
In this guide, you will learn all the basics of Flint. For easier chapter management, the whole journey of "From Beginner to Master" is split into three parts:
- "From Beginner to Intermediate" which handles all basics of Flint
- "From Intermediate to Expert" which goes more in depth into advanced Flint features
- "From Expert to Master" which tries to explain everything Flint has to offer
With each chapter, you will learn more and more about the Flint language and its inner workings. At the end of this guide, you will know absolutely everything there is to know about the Flint language. No hidden features, no "never ending learning" of the language itself. Flint is made to be finite. Thats also the reason to why Flint does not ship a built-in standard library (stl).
Flint is meant to be a language framework, which aims to be as easy as possible while being as powerful as possible. We aim to provide you with a box full of little LEGOs. At the end of this guide series, you will know every LEGO block that exists.
Flint is sometimes limited on purpose. It has no manual memory management capabilities, for example, but these limitations all serve a singular purpose: Making Flint easier and quicker to work with, but not letting it feel like magic. Every design choise of Flint has a purpose. At the end of this full guide, you will understand every single design decision we took and why it was made.
But enough talking, lets jump right into it, lets turn you into a Flint master, shall we?
Basics
In this chapter you will learn the absolute basics of Flint. These basics are very important and you need to keep then in mind from now on.
The basics of every simple program is printing
. Any program needs to display information to the user of the program in one form or another. It does not matter if its a GUI (Graphical User Interface) or a CLI (Command Line Interface), the user of a program will almost certainly always need information to reed when executing a program.
So, printing to the console is, in a sense, the most minimal thing a program should do. So lets start the basics with exactly that, and lets print to the console!
The Hello World Program
Lets create a simple Hello World!
program which just prints the text Hello, World!
to the CLI. But before we can do that we need to create an entry point for our Flint program.
The entry point of every Flint program is the main
function. This function is reserved for the programs entry point, so no other function is allowed to be called main
. The entry point of any program is the point where the program starts its execution. Any program works from top to bottom, and everything within the main
function is executed first. You dont need to worry about functions
yet, they are explained in a later chapter.
But, lets create the example first and then explain what actually happens afterwards:
use Core.print
def main():
print("Hello, World!\n");
There is a lot to unpack here. You can safely ignore the use Core.print
line for now. Just remember that this line needs to be written in order for the print
function to be accessible. But what that line actually does will be described in a later chapter.
The def main():
line is the definition of the main function, but functions are described in a later chapter too.
Now we come to the interesting line. print("Hello, World!\n");
. print
is a function call. A function call is like an instruction to execute. In this case we tell the Flint program to print the text to the console thats written in between the "
symbols. This text between the "
symbols is called a string. Here, we tell the print
function to print the string Hello, World!\n
. But what is this \n
character? Its an escaped character for a new line. The print
function does not print a new line after the string, so we need to provide a newline character manually.
The output of the above program would look like this:
Hello, World!
Try to remove the \n
character and see what it does for yourself!
Always remember: The best way to learn is to try new things. Your computer won't explode if you make mistakes in a program, so just try out a few things and see for yourself what works and what doesn't!
Compiling the Program
Save the code from the previous chapter into a file named hello.ft
. ft
is the file extension for Flint
source files. They only contain code in written form. Its then the resposibility of the Flint compiler to take a file containing its source code and creating an executable from it.
To compile the .ft
file to an executable file, we call
flintc --file hello.ft --out hello
This will output the executable in the current working directory. It can be exexuted with the command
./hello
And now you should see the output of the previous chapter in the console!
Comments in Flint
In Flint, comments are used to explain code or temporarily disable parts of it during debugging. Flint supports two types of comments:
- Single-line comments: Start with // and continue until the end of the line.
- Multi-line comments: Start with /* and end with */.
Here’s how you use comments:
use Core.print
def main():
// This is a single-line comment explaining the print statement below
print("Hello, Flint!"\n);
/*
This is a multi-line comment.
You can use it for detailed explanations
or temporarily disabling multiple lines of code.
*/
// print("This line is commented out and won't run.");
Comments are essential for writing clear, understandable, and maintainable code. Use them to explain your logic to others (or to yourself when revisiting code later).
Try to copy the code above into your hello.ft
file and try to compile and run it.
Indentation in Flint
Flint enforces strict indentation rules to ensure clean and readable code. Also, indentation is needed for scoping. Everything writen within the main
functions is "inside" that function. If you don't indent instructions that are meant to be placed within the main function, for example, Flint will try to see if the line you wrote is a function or something else, like the use Core.print
line.
Lets look at it in action:
use Core.print
def main():
print("This is not indented correctly.\n");
When you run the above code, you’ll see an error message:
Parse Error at hello.ft:4:1 -- Expected a body, but got: print("This is not indented correctly.");
This happens because Flint expects all code inside main to be indented. Here’s the correct way to write it:
def main():
print("This is correctly indented.\n"); // Properly indented
Proper indentation is not just a stylistic choice in Flint – it’s a fundamental part of the syntax. You do not need to indent using hard tabs (\t
), 4 spaces are interpreted as tabs by the Flint compiler.
Variables and Types
In the previous chapter, we learned the basics of printing to the console in Flint. Now that we have a way to display information, let's talk about how to store and manipulate that information. In this chapter, we will explore the fundamental concepts of variables and types in Flint.
What are Variables?
A variable is a named storage location that holds a value. Think of it as a labeled box where you can store a value. You can then use the variable name to refer to the value stored in the box.
What are Types?
A type is a classification of data that determines the type of value a variable can hold. In Flint, every variable has a specific type, which determines the kind of value it can store. For example, a variable of type i32
can only store integer values, while a variable of type str
can only store text.
Why are Variables and Types Important?
Variables and types are essential concepts in programming because they allow you to:
- Store and manipulate data in a program
- Write more efficient and readable code
- Avoid errors by ensuring that the correct type of data is used
In this chapter, we will delve deeper into the world of variables and types in Flint. We will learn how to declare variables, assign values to them, and use them in our programs. We will also explore the different types of data that Flint supports, including integers, strings, and more.
What to Expect
In this chapter, we will cover the following topics:
- Declaring variables and assigning values to them
- Understanding the different types of data in Flint
- Using variables and types in our programs
- Best practices for working with variables and types
By the end of this chapter, you will have a solid understanding of variables and types in Flint, and you will be able to use them to write more effective and efficient programs. Let's get started!
Primitive Types
Primitive types are Flint's fundamental building blocks for representing data. They are the simplest forms of values you can use in your programs. Flint has the following primitive types:
i32
Represents whole numbers, both positive and negative. Usei32
for counting, indexing, or whenever you need discrete values.
i32 x = 42; // A positive integer
i32 y = -15; // A negative integer
i32 z = 0; // Zero is also an integer
u32
Represents whole numbers, but only positive ones. Useu32
for IDs.
u32 id_1 = 1;
u32 id_0 = 0:
f32
Represents floating-point numbers (decimal numbers). Usef32
for measurements, precise calculations, or any value that requires a fractional component.
f32 pi = 3.14; // Approximation of π
f32 zero_kelvin = -273.15; // Negative floating point values are valid
f32 zero = 0.0; // Zero with a decimal
str
Represents a sequence of characters or text, as already described in the printing chapter. Usestr
for names, messages, or any textual data. Strings must be enclosed in double quotes ("
).
str name = "Flint"; // A simple string
str empty = ""; // An empty string
str greeting = "Hello, World!"; // A common example
These types are the foundation of Flint's data handling. As you write more complex programs, you'll combine them in creative ways to represent and manipulate information. But these are not the only primitive types there are available for Flint, they are just the most frequently used ones.
Bit-Width
Now lets talk a bit about bit-width. You surely have seen the number after i
, u
and f
. This is the bit-width. Computers work with bits, in this case it means that the integer and floating point types are 32 bits long. Here is a full list of all primitive types in Flint and their minimum and maximum values:
Type | Description | Min | Max | Precision |
---|---|---|---|---|
u8 | unsigned 8 bit integer | 0 | 255 | Whole numbers |
u32 | unsigned 32 bit integer | 0 | 4,294,967,295 | Whole numbers |
u64 | unsigned 64 bit integer | 0 | ≈ 1.844 × 10^19 | Whole numbers |
i32 | signed 32 bit integer | -2,147,486,648 | 2,147,486,647 | Whole numbers |
i64 | signed 64 bit integer | ≈ -1.844 × 10^19 | ≈ 1.844 × 10^19 | Whole numbers |
f32 | 32 bit floating point number | ≈ ±1.175 × 10^-38 | ≈ ±1.701 × 10^38 | ≈ 6 - 9 digits |
f64 | 64 bit floating point number | ≈ ±1 × 10^-383 | ≈ ±9.999 × 10^384 | ≈ 16 digits |
Variables
Let's talk about variables and what they really are. A variable is an element of data that can be modified during the execution of a program, this can be an integer value, for example, or a string value. Variables are one of, if not even the most important part of a program.
Every variable needs to have a fixed type assigned to it. There are much more types than only the primitive types of the last chapter, but everything in this chapter will apply to other types as well. First, we need to define a variable. This is done like this:
def main():
i32 x = 5;
You have already seen this in the last chapter, so lets unpack it now. i32
is the type of the variable. x
is the name (identifier) of the variable we just created, and we declare the variable x
to be the value of 5
. This whole line you see above is called a declaration
, because we declare a new variable.
Now lets look at how to assign new values to the variable:
def main():
i32 x = 5; // The declaration
x = 7; // The assignment
We now store the value of 7
in the previously declared variable x
. The variable x
still has the type of i32
. Types of variables cannot change after we declared a variable. This whole characteristic, that the type cannot change after the creation of a variable, and that the type of a variable is fixed, is called static typing.
Shadowing
But what if we write something like this?
def main():
i32 x = 7;
i32 x = 8;
You can try to compile this program yourself and see what you get. But to make things a bit easier i show you. You will get this error:
Parse Error at main.ft:3:9 -- Variable 'x' already exists
As you can see, Flint only allows one single variable with the identifier x
to exist within the main function, even if its type differs:
def main():
i32 x = 7;
f32 x = 3.3;
This fails with the same error. Some languages support that a variable is "redefined" with another type, and from this new declaration onwards the variable has a different type. This is called shadowing, and Flint does not support this intentionally. Every variable can only exist once within a scope (we talk about scopes later on).
So, remember: The same identifier can only used once for variables!
Inferred Typing
Flint allows for inferred typing, meaning you don't always need to explicitly state the type of a variable. The compiler can infer the type based on the assigned value.
How it works
x := 42; // The compiler infers that x is an i32
pi := 3.14; // The compiler infers that pi is a f32
greeting := "Hello, Flint!"; // The compiler infers that greeting is a str
While inferred typing is convenient and makes code concise, explicit typing can improve readability in more complex programs. You can always use explicit typing if you prefer its clarity:
i32 x = 42; // Explicitly declare x as an i32
f32 pi = 3.14; // Explicitly declare pi as a f32
str greeting = "Hello, Flint!"; // Explicitly declare greeting as a str
Use inferred typing for shorter, simpler programs and explicit typing when clarity is crucial.
Operators
Flint has quite a few operators in its disposal. The most common and most simple ones, however, are +
, -
, *
and /
. The +
and -
operators dont need much explaination, really.
Integer Arithmetic
Lets start with the most obvious example first: integer types and lets go through all operations one by one:
The +
Operator
The +
operator is very easy, it is used to add two numbers together. But there is a catch... do you remember the Bit-Width table from this chapter? This now becomes important, more specifically the Min and Max value columns. For example, if you add two u32
typed variables together, like so:
def main():
u32 n1 = 4_294_967_290;
u32 n2 = 10;
u32 n3 = n1 + n2;
what value should n3
then have? Oh, and dont get confused by the _
values in the number. Flint will ignore these and it will read the number as 4294967290
just fine, but they make the number much more readable!
If you execute the built binary you will see this message printed to your console:
u32 add overflow caught
This happens, because the maximum value of an u32
is 4.294.967.295
and 4.294.967.290 + 10 = 4.294.967.300
, which is greater than the maximum value of the u32
type. In any computers, the numbers are represented as binaries (0
and 1
) and any number uses the binary
number system. If you are interested in this topic you can search for Integer Overflow.
In Flint, however, the u32
value is just capped to the maximum value of u32
. So, n3
will have the value 4.294.967.295
saved in it after the addition.
The -
Operator
The same as above applies here. The minimum value for u32
values is 0
, so if we try to run this program:
def main():
u32 ten = 10;
u32 twenty = 20;
u32 result = ten - twenty;
we get a similar message as before:
u32 sub underflow caught
A underflow
is the same as an overflow
but for the minimum value instead of the maximum value. Just like with the overflows, Flint clamps the value at 0
here too.
The /
Operator
Because integer types (i32
, u32
, ...) don't have a fractional part like floating point types (f32
, ...) they cannot preserve their fractional information when dividing. This means that in this function here:
def main():
i32 n1 = 100;
i32 n2 = 30;
i32 n3 = n1 / n2;
The varaible n3
will have the value 3
saved in it, even though the value actually would be 3,333..
. This is a characteristic of integer division. The fractional part is always cut off. If you program a bit you will actually find out that this behaviour can work to your advantage. So, this also means that the result of 100 / 60
is 1,666..
with the fractional, but for integer divisions its just 1
.
The *
Operator is pretty easy to understand, actually. There aren't much things to consider when using it ecept for a possible integer overflow if the result becomes too large and order of operation.
Order of Operation (or better said the wrong order of operation) is a very common mistake regarding integer arithmetic. It is commonly overlooked that a division can lead to a 0
, for example with the calculation of 10 / 8 * 16
, we would expect the result to be 20
. But the calculation is evaluated as follows: (10 / 8) * 16
which expands to 10 / 8
which is 0
.
So, mathematically we would expect the result to be 20
but because of the order of operations it has become 0
instead. This can only be fixed by applying the correct oder, in our case this would be 10 * 16 / 8
which gets evaluated to (10 * 16) / 8
which will result in the expected result of 20
.
Note that you can also use parenthesis to explicitely state the order of operation you want to have: 10 / (8 * 16)
(this will still result in 0
but its evaluated differently).
The ==
and !=
Operators
One can also compare two integer values with one another. The comparison returns a boolean (bool
type) which is used for comparisons, but you will learn more about what a bool
is in the upcoming chapter here. For now, just note that the operator exists.
Floating Point Arithmetic
Floating Point Arithmetic can be pretty tricky at times. Flint's floating points use the IEEE-754 standard. If you don't know what this means, dont worry, we will talk about it here, as easy as possible. If you want to know more about the standard and how floating point numbers are actually implemented, it is recommended to look here.
But, in a nutshell, any floating point number comes with a specific level of inprecision. You dont need to worry about what this means for now, just remember that a number might not be exactly the number you specified, because some numbers (like 1 / 3
) cannot be stored in a number on the computer, because its result is 0.333...
and it cannot be stored and compared reliably. Also, some numbers just cannot be stored fully in floating point numbers. Here is a small example showing floating point arithmetic inprecision in action:
use Core.print
def main():
f64 val = 0.05;
val += 0.3;
print($"0.05 + 0.3 = {val}\n");
print($"{val} == 0.35 ? {val == 0.35}\n");
From this small program, you will see the output
0.05 + 0.3 = 0.350000012665987 0.350000012665987 == 0.35 ? false
Don't worry about that $"{..}"
thing, its called string interpolation and you will learn about it shortly.
But, as you can see the condition is false
, thats because val
is not exactly the same as 0.35
in this case, because their bits differ. So, use the ==
and !=
operators with caution when dealing with floating point variables.
Strings
Strings (str
type) do not support the -
, *
or /
operators. But you can use the +
operator to add (concatenate) two strings:
use Core.print
def main():
str hello = "Hello, ";
str world = "World!\n";
str hello_world = hello + world;
print(hello_world);
If you run this program, you will see this output in your console:
Hello, World!
Typecasting
First of all, what is typecasting? We know what a type
is, but what is casting
? Casting is the act of converting one type into another. Different types are incompatible with one another, even i32
and u32
are incompatible, even though both of them have the same bit width. For example, the i32
value of -233235653
would actually be 4061731643
when the bits would be interpreted as an u32
instead. And if the 32 bits of the i32
value would be interpreted as an f32
value, we would end up with the number -3.032584e30
. (The bit pattern of this example is 1111 0010 0001 1001 0001 1011 0011 1011
)
As you can see, just storing the bits of one number into the bits of another number is not enough. This is the reason to why we need to convert, or cast types. Try to compile this program and see what the compiler tells you:
def main():
i32 val = 3.3;
The compiler will throw an error like:
Parse Error at main.ft:2:15 -- Type mismatch of expression 3.3; -- Expected i32 but got f32
and it will tell you exactly what went wrong. The type of i32
was expected, beause we want to store something of type i32
in the variable val
, but we provided a floating point number, which defaults to f32
. So, we need to convert the type of f32
to i32
in this case.
Explicit Typecasting
Types can be explicitely cast like this:
def main():
i32 val = i32(3.3);
We just write the type we want to have like i32
in this case followed by an open paren (
and then the expression which has a different type and then the closing paren )
.
Also, every type can be cast to an str
type! Try to run this program and see what happens:
use Core.print
def main():
f32 fval = 3.3;
str message = "fval = ";
message += str(fval);
message += "\n";
print(message);
You will see the message
fval = 3.3
printed to the console.
Okay, and now a bit more complicated example:
use Core.print
def main():
f32 fval = 3.7;
i32 ival = i32(fval);
str message = "fval = ";
message += str(fval);
message += ", ival = ";
message += str(ival);
message += "\n";
print(message);
This small program prints this message to the console:
fval = 3.7, ival = 3
You maybe expected a result like
fval = 3.7, ival = 4
but here comes some very important information about type casting: When casting floating point types to integer types the fractional part is simply cut off. Its never rounded, only cut. But that definitely can be a good thing, if used correctly. Just remember, that when casting floating point values to integer values you will simply loose the fractional information.
Implicit Typecasting
Everything we have discussed up until now was regarding the act of explicitely casting values. But, some values can be cast implicitely by the compiler. There exists one rule of thumb in Flint: You can cast types implicitely if you won't loose any information through that cast. So, you can happily implicitely cast i32
to f32
but you cannot implicitely cast f32
to i32
. Also, pretty much any type can be implicitely cast to a str
type too! Here's how this looks in action:
use Core.print
def main():
f32 fval = 3;
str message = "fval = ";
message += fval;
message += "\n";
print(message);
Notice how we did not write any explicit casting whatsoever? This program prints this message to the console:
fval = 3.0
Note that the floating point to string conversion happened implicitely?
This behaves differntly in the current release of Flint
Currently, you won't see fval = 3.0
printed to the console, but rather fval = 3
. This is not a big deal, it only happens when the fractional part of the floating point value is zero, but this behaviour is a bit misleading, as you now could think that the value used to print 3
is an integer type, not a floating point type. Printing 3.0
makes this unambiguous and is considered the correct way to do it.
String Interpolation
Until now you have seen quite often that we do something like this:
use Core.print
def main():
f32 fval = 3;
str message = "fval = ";
message += fval;
message += "\n";
print(message);
We create the message
variable and then write +=
over and over again to fill the string with values. But, there is a much simpler way of integrating variables into a string, its called string interpolation. You have already seen it once, but lets explain it now.
The syntax of string interpolation is quite simple. You write a normal string, like "Hello Flint"
. Then you put a dollar sign in front of the string: $"Hello, Flint"
. And thats (almost) it. Now, everything thats written in between curly braces {}
is handled as an expression. Expressions are everything that you would write on the right side of the equals sign, for example. So, here an easy example:
use Core.print
def main():
str name = "Flint";
i32 age = 1;
print($"Hello, my name is {name} and I am {age} years old.\n");
Output:
Hello, my name is Flint and I am 1 years old.
And now you see why its important that almost any type can be implicitely cast to a str
type, because otherwise we would need to write str(age)
here, or need to write str(..)
any time we would want to use string interpolation.
You can actually interpolate any variable or expression into a string:
use Core.print
def main():
f32 pi = 3.14;
// Inserting floating point values
print($"The value of pi is approximately {pi}\n");
// Inserting an arithmetic expression
print($"2 + 2 equals {2 + 2}\n");
Which prints these lines to the console:
The value of pi is approximately 3.14 2 + 2 equals 4
Control Flow
In the previous chapters, we learned the basics of printing to the console and working with variables and types in Flint. Now that we have a way to display information and store data, let's talk about how to control the flow of our program's execution. In this chapter, we will explore the fundamental concepts of control flow in Flint.
What is Control Flow?
Control flow refers to the order in which a program's statements are executed. In other words, it's the way a program decides what to do next. Control flow is essential in programming because it allows you to write programs that can make decisions, repeat tasks, and handle different situations.
Why is Control Flow Important?
Control flow is crucial in programming because it enables you to:
- Write programs that can adapt to different inputs and situations
- Repeat tasks without having to write the same code multiple times
- Make decisions based on conditions and data
In this chapter, we will learn about the different control flow statements in Flint, including conditional statements, loops, and functions. We will see how to use these statements to write more efficient, readable, and effective programs.
What to Expect
In this chapter, we will cover the following topics:
- Conditional statements: if, else, and switch
- Loops: for, while, and repeat
- Best practices for working with control flow statements
By the end of this chapter, you will have a solid understanding of control flow in Flint, and you will be able to use it to write more sophisticated and efficient programs. Let's get started!
The bool
Type
In Flint, the bool
type represents truth values: true
or false
. Boolean values are fundamental in programming, as they enable decision-making and control flow. Boolean values are equal both in programming and in mathematics, they actually dont really differ. Here is a small example of how to declare a boolean variable and how to print its value to the console:
use Core.print
def main():
bool is_learning = true;
bool is_hungry = false;
print($"is_learing = {is_learning}, is_hungry = {is_hungry}\n");
This program prints this line to the console:
is_learing = true, is_hungry = false
Checking for equality and inequality
You can check if a boolean type is equal to another boolean type. The result of the ==
and !=
operators is always a bool
type, because either they are equal or not. So, if you look if two i32
values are equal, the result of this check will be of type bool
.
use Core.print
def main():
i32 val1 = 4;
i32 val2 = 6;
bool is_eq = val1 == val2;
bool is_neq = val1 != val2;
print($"val1 = {val1}, val2 = {val2}, is_eq = {is_eq}, is_neq = {is_neq}\n");
This program prints this line to the console:
val1 = 4, val2 = 6, is_eq = false, is_neq = true
But now lets compare two boolean values with one another:
use Core.print
def main():
bool t_eq_f = true == false;
print($"t_eq_f = {t_eq_f}\n");
bool t_neq_f = true != false;
print($"t_neq_f = {t_neq_f}\n");
bool t_eq_t = true == true;
print($"t_eq_t = {t_eq_t}\n");
bool t_neq_t = true != true;
print($"t_neq_t = {t_neq_t}\n");
bool f_eq_f = false == false;
print($"f_eq_f = {f_eq_f}\n");
bool f_neq_f = false != false;
print($"f_neq_f = {f_neq_f}\n");
This program prints these lines to the console:
t_eq_f = false t_neq_f = true t_eq_t = true t_neq_t = false f_eq_f = true f_neq_f = false
Boolean Operations
Boolean operators, such as and
, or
and not
combine or modify bool
values. They’re useful for creating more complex conditions. In this chapter, you will learn how each operator works.
and
Operator
The and
operator combines two conditions and evaluates to true
only if both conditions are true
.
use Core.print
def main():
bool is_adult = true;
bool has_id = false;
print($"Is adult and has an ID? {is_adult and has_id}\n");
This program will print this line to the console:
Is adult and has an ID? false
or
Operator
The or
operator combines two conditions and evaluates to true
if at least one condition is true
.
use Core.print
def main():
bool is_vip = true;
bool has_ticket = false;
print($"Is VIP or has a ticket? {is_vip or has_ticket}\n");
This program will print this line to the console:
Is VIP or has a ticket? true
not
Operator
The not
operator inverts the value of a boolean, so it makes false
to true
and vice versa.
use Core.print
def main():
bool is_raining = false;
print($"Is it not raining? {not is_raining}\n");
This program will print this line to the console:
Is it not raining? true
Operator Precedence
The precedence (default order of execution) of and
is higher than the one of or
, similar to how *
has a higher precedence than +
in arithmetics. This means that the and
operation will always be evaluated before the or
operation:
use Core.print
def main():
// Evaluates to 'true' ('and' happens first)
bool condition = true or false and false;
print($"condition = {condition}\n");
// Evaluates to 'false' ('and' happens second)
bool clarified = (true or false) and false;
print($"clarified = {clarified}\n");
This program prints these lines to the console:
condition = true clarified = false
Branching
Branching is the act of executing different "blocks" of code depending on conditions (booleans). But, before we can talk about branching we need to talk about what this block really is. Take the main function, for example:
use Core.print
def main():
f32 val = 3.3;
i32 val_i = i32(val);
print($"{val} = {val_i}\n");
In this function, everything thats indented once is considered to be "inside" the main function. But what does this "inside" really mean? There exists a formal description for this "inside", it's called a Scope
. The Scope of the main function is everything thats written down within the main functions body ("inside" the function).
Scopes are a really important concept in programming, because there exist several consistent and deterministic rules about scopes and subscopes. But thats enough theory for now, lets look at the easiest example for branching, the if
statement.
The if Statement
The if statement lets your program execute code only when a condition evaluates to true. Here's how it works:
use Core.print
def main():
i32 age = 18;
if age >= 18: // The condition evaluates to true
print($"You are {age} years old, so you can vote!\n");
This program will print this line to the console:
You are 18 years old, so you can vote!
Try changing the age
to 17
and watch what happens.
If the condition evaluates to false, the program skips the block of code inside the if
statement. We say that the print
call here is inside the if statement's scope. But what does scope really mean? Have a look at this example:
use Core.print
def main():
i32 age = 18;
if age >= 18:
i32 somevalue = 22;
print("Age is above or equal to 18!\n");
print($"somevalue = {somevalue}\n");
Here, the declaration of the age
variable is inside the main
function's scope, while the somevalue
declaration and the first print
call are inside the if
statement's scope. The second print
call, however, is inside the main
function's scope again. As you can see, the scope is direclty determined by the level of indentation.
If yout try to compile this program you will see a compilation error:
Parse Error at main.ft:10:26 -- Use of undeclared variable 'somevalue'
But why is that? We did define the variable somevalue
in the if statement's scope, right? Yes, we defined the variable in the if statement's scope, but here comes one of the mentioned rules of scopes into play: Visibility.
But what's visibility? Visibility describes the visibility of variables within scopes and their parent or child scopes. The if statement's scope, for example, is a child scope
of the main
function's scope. Because its a child scope it can "see" all variables of its parent scope. But it can only see variables that have been defined up until the child scope. To illustrate what this means, look at this example:
use Core.print
def main():
i32 val1 = 1;
if val1 < 10:
print("val1 < 10\n");
i32 val2 = 2;
what i described above means nothing else than the simple fact that val1
is visible inside the if statements scope, but val2
is not, because val2
is defined after the if statements scope. So, a child scope can only see variables of its parent scope that have been declared before it.
Parent scopes do not inherit any variable definitions of their child scopes. This program, for example:
use Core.print
def main():
i32 val1 = 2;
if val1 < 10:
i32 val2 = 44;
print($"val1 = {val1}, val2 = {val2}\n");
i32 val2 = 12;
print($"val1 = {val1}, val2 = {val2}\n");
will compile and run fine. But lets discuss why that is. The variable val2
is defined inside the if statements scope, this means that its visible for the rest of the if statements scope and all possible child scopes of it, but it is not visible for its parent scope. Because the variable val2
does not exist in the main function's scope yet, this works fine, as no variable definition is shadowed. Because the variable val2
never existed in the main function's scope, we can declare a new variable val2
inside the main function.
This behaviour of visibility given down to children but never up to parents is the very reason why the compile error from above happened, becaue the variable somevalue
was never defined in the main functions scope, so it simply does not exist at the position we wanted to use it.
The else Keyword
Now that you know scopes and visibility, the else statement should actually be pretty easy to understand. An else statement is used whenever you want to do something if another condition failed. It is explained best through an example:
use Core.print
def main():
i32 age = 16;
if age >= 18: // If this is false...
print($"You are {age} years old, so you can vote!\n");
else: // ...then this block executes
print($"You are {age} years old, so you cannot vote.\n");
This program will print this line to the console:
You are 16 years old, so you cannot vote.
Try changing the age
variable to, lets say 20, and see what happens.
The else if Keyword
Sometimes, you need multiple conditions. Instead of stacking multiple if statements, you can use else if to create a chain of conditions. The important thing to remember here is that only one of the branches will be executed, not multiple ones.
use Core.print
def main():
i32 age = 16;
if age >= 65:
print("You qualify for senior discounts.\n");
else if age >= 18:
print("You can vote but no senior discounts yet!\n");
else:
print("You are too young to vote.\n");
This program will print this line to the console:
You are too young to vote.
Play around a bit. Change the value of age
and see what happens. Try to write your own conditions and branches.
Loops
Programming often involves repeating tasks. For example, imagine printing every number from 1
to 10
. It’s tedious to write print 10 times! Loops automate such repetition. But what even is a loop? Lets start with the most simple form of a loop, the while
loop.
The while
Loop
The while loop is actually the more simple loop you can have. The body of the loop (its scope) is executed as long as the condition of the while loop evaluates to true
. But be cautious, you can create infinite loops relatively easily with a while
loop. Here is a simple example of a while loop in action:
use Core.print
def main():
i32 num = 0;
while num < 5:
print($"num = {num}\n");
num++;
But wait! Whats this num++
? The ++
is called an increment operator it literally just increments num
by 1. So, num++
is just a neat way to say num += 1
, which is a neat way to say num = num + 1
. The increment operator exists because its just so common to write incrementations in loops (and it looks nicer too).
The above program will print these lines to the console:
num = 0 num = 1 num = 2 num = 3 num = 4
As you can see, the body of the while loop got executed 5
times. If you, for example, would forget the num++
line, this while loop would turn into an infinite loop. Can you see why?
Its because the condition num < 5
will always stay true
because 0 < 5
. But the while loop is only one of the looping statements which exist in Flint, there are more.
The do while
Loop
The do while loop does not yet work in the compiler.
Do while loops do not work at the moment, as they just have not been implemented in the compiler yet. But they will work in future releases eventually.
The do while
loop is actually very similar to the while
loop. The expression of the while
loop gets evaluated before the body of the loop is executed. It executes the body if the condition is true, and then jumps back to the condition and checks again and so on. In do while
loops this is different. Here, the body is executed first and then the condition is checked. Similar to while
loops, do while
loops run as long as the condition evaluates to true. Here is a small example of a do while
loop:
use Core.print
def main():
i32 num = 0;
do:
print($"num = {num}\n");
num++;
while num < 5;
This loop will have the same output as the while
loop above. But try changing the initial value of num
to something bigger or equal to 5
, for example setting it to 10
. Do you recognize a difference between the two loop types?
The do while
loop actually always executes at least once while the while
loop can actually skip its body entirely. Ensuring that a loop runs at least once is not as common as the "normal" while loop, but when you need it you will be greatful that it is supported, as emulating the same behaviour with "normal" while loops is pretty hard.
The for
Loop
A for
loop is very interesting, and probably the loop type you will end up writing the most common in Flint. The important part of the for
loop is, that it is composed of three main parts:
- The variable declaration statement
- A condition (exactly how
while
's condition works this condition is evaluated before the body runs) - A statement that will be executed at the end of each iteration
But it is best shown how this will look:
use Core.print
def main():
for i32 i = 0; i < 5; i++:
print($"Iteration {i}\n");
This program will print these lines to the console:
Iteration 0 Iteration 1 Iteration 2 Iteration 3 Iteration 4
If you look closely, its actually pretty much the same as with our while loop. Both for
and while
loops are actually interchangable from one another, meaning that one loop type can easily be converted to the other type. In our case, the while
loop implementation of this very same loop would look like this:
use Core.print
def main():
i32 i = 0;
while i < 5:
print($"Iteration {i}\n");
i++;
But there exists one rather big difference between for
and while
loops. While in the while
loop, the variable i
is now part of the main function's scope, for the for
loop, this is not the case. The i
variable is only contained inside the for
loops scope. This is very important, because it is most common to use i
for the incrementing variable of a loop:
use Core.print
def main():
for i32 i = 0; i < 5; i++:
print($"Loop 1, iteration: {i}\n");
// 'i' cannot be used after the for loop
// 'i' can be re-declared here, because 'i' was
// never declared inside the main functions scope
for u32 i = 0; i < 4; i++:
print($"Loop 2, iteration: {i}\n");
This program will print these lines to the console:
Loop 1, iteration: 0 Loop 1, iteration: 1 Loop 1, iteration: 2 Loop 1, iteration: 3 Loop 1, iteration: 4 Loop 2, iteration: 0 Loop 2, iteration: 1 Loop 2, iteration: 2 Loop 2, iteration: 3
Remember
The general rule of thumb is to use for
loops when you know the bounds of your iteration (from number X
to number Y
, or run 10 times
) and use while
loops when the number of iterations of the loop is unknown to you. If you follow this rule of thumb you whould have very vew problems with loops.
Enums
What is an enum? An enum essentially is just a number under the hood, but one with very interesting properties. It can be thought of as a tag which can only have one of a selected number of tags. Here is an example:
use Core.print
enum MyEnum:
TAG1, TAG2, TAG3;
def main():
MyEnum my_enum = MyEnum.TAG1;
if my_enum == MyEnum.TAG1:
print("is TAG1\n");
else if my_enum == MyEnum.TAG2:
print("is TAG2\n");
else if my_enum == MyEnum.TAG3:
print("is TAG3\n");
else:
print("This code path is actually impossible to reach, no matter which value 'my_enum' has!\n");
This program will print this line to the console:
is TAG1
As you can see, defining our own enum
is very simple. We write the enum
keyword followed by the name of the enum, similar how we define the name of data. As you can see, the name MyEnum
is now a new user-defined type, which is the reason why a variable (my_enum
) can be declared to be of type MyEnum
. Then, we write a colon :
to signify the "body" of the enum, where we define all the values the enum could have. And then, we define the tag names the enum could have. Each tag name has to be unique within the same enum, so we would not be allowed to define TAG1
twice. Tags are comma-separated and Flint sees everything as a tag until it finds a semicolon ;
.
If you dont like the horizontal layout, you can also define an enum like so:
enum MyEnum:
TAG1, // Some description of TAG1
TAG2, // Some description of TAG2
TAG3; // Some description of TAG3
Comparing Enums
Enums are considered equal if their type and their tag match. Here is an example of what this means:
use Core.print
enum Enum1:
TAG1, TAG2, TAG3;
enum Enum2:
TAG1, TAG2, TAG3;
def main():
Enum1 e1 = Enum1.TAG1;
if e1 == Enum2.TAG1:
print("is Enum2.TAG1!\n");
This program will print this error to the console:
Parse Error at main.ft:12:8 -- Type mismatch of expression e1 == Enum2.TAG1 -- Expected Enum1 but got Enum2
The error message differs in the current version of the compiler.
The above error message is the message that should be displayed. But in its current form the compiler will produce this error message instead:
Parse Error at main.ft:1:1 -- Type mismatch of expression EOF -- Expected Enum1 but got Enum2
There is no information contained where the mismatch happened or what the expression the error happened in was. This will be fixed eventually, but requires some changes in unrelated areas of the compiler, so it might take a while for it to be fixed.
Enums with functions
Enums are considered to be non-complex data types in Flint, even though they are user-defined. So, we can easily pass in enums to a function and return them from it:
use Core.print
enum ComparisonResult:
BIGGER, SMALLER, EQUAL;
def compare(i32 x, i32 y) -> ComparisonResult:
if x > y:
return ComparisonResult.BIGGER;
else if y < x:
return ComparisonResult.SMALLER;
else:
return ComparisonResult.EQUAL;
def main():
ComparisonResult result = compare(10, 5);
if result == ComparisonResult.BIGGER:
print("is bigger\n");
else if result == ComparisonResult.SMALLER:
print("is smaller\n");
else if result == ComparisonResult.EQUAL:
print("is equal\n");
else:
print("Impossible to reach code block\n");
This program will print this line to the console:
is bigger
The above program is very useless, though, as it would be much more efficient to remove the enum entirely and just use the comparisons directly. But these examples are not always meant to be absolutely useful, they are there to get a point across. So, we can not only return enums from a function but also pass them to a function. Lets dive into a bit bigger example now.
use Core.print
enum Operation:
PLUS, MINUS, MULT, DIV;
data NumberContainer:
i32 a;
f32 b;
u64 c;
NumberContainer(a, b, c);
def apply_operation(mut NumberContainer container, Operation op, f32 value):
if op == Operation.PLUS:
container.(a, b, c) += (i32(value), value, u64(value));
else if op == Operation.MINUS:
container.(a, b, c) -= (i32(value), value, u64(value));
else if op == Operation.MULT:
container.(a, b, c) *= (i32(value), value, u64(value));
else if op == Operation.DIV:
container.(a, b, c) /= (i32(value), value, u64(value));
def main():
NumberContainer container = NumberContainer(-10, 22.5, u64(889));
print($"container.(a, b, c) = ({container.a}, {container.b}, {container.c})\n");
apply_operation(container, Operation.PLUS, 3.4);
print($"container.(a, b, c) = ({container.a}, {container.b}, {container.c})\n");
apply_operation(container, Operation.MULT, 7.2);
print($"container.(a, b, c) = ({container.a}, {container.b}, {container.c})\n");
apply_operation(container, Operation.MINUS, 22.1);
print($"container.(a, b, c) = ({container.a}, {container.b}, {container.c})\n");
apply_operation(container, Operation.DIV, 6.9);
print($"container.(a, b, c) = ({container.a}, {container.b}, {container.c})\n");
This program will print these lines to the console:
container.(a, b, c) = (-10, 22.5, 889) container.(a, b, c) = (-7, 25.9, 892) container.(a, b, c) = (-49, 186.479996, 6244) container.(a, b, c) = (-71, 164.37999, 6222) container.(a, b, c) = (-11, 23.823187, 1037)
Switch
Switch statements and expressions do not work yet.
Switch statements are not yet implemented in the compiler, at all. You will get compile errors front and center if you try to use them.
Switch Statements
Switch statements are actually pretty easy to understand once you grasped if chains. switch
statements are primarily used for pattern-matching purposes or for cases where you have a limited selection of possible values, like with enums, where you only have the possibilities of each tag.
Let's actually re-write the same example from the previous example, but without the if chain we now use a switch statement:
use Core.print
enum ComparisonResult:
BIGGER, SMALLER, EQUAL;
def compare(i32 x, i32 y) -> ComparisonResult:
if x > y:
return ComparisonResult.BIGGER;
else if y < x:
return ComparisonResult.SMALLER;
else:
return ComparisonResult.EQUAL;
def main():
ComparisonResult result = compare(10, 5);
switch result:
ComparisonResult.BIGGER:
print("is bigger\n");
ComparisonResult.SMALLER:
print("is smaller\n");
ComparisonResult.EQUAL:
print("is equal\n");
This program will print this line to the console:
is bigger
If you know switch statements from other languages you may wonder "where are the case
and the break
keywords?". Flint does not have such keywords, at least in this context (break
still exists for loops, just like continue
). In most languages like C, switch statements undergo a default fallthrough and you must manually opt out of the fallthrough behaviour, which is extremely error prone. But if you dont know what fallthrough is, lets discuss this first.
Fallthrough
Fallthrough is the act of executing multiple switch branches after another. We best look at this example from C:
#include <stdio.h>
typedef enum { VAL1, VAL2, VAL3 } MyEnum;
int main() {
MyEnum e = VAL1; // In C you dont need to write MyEnum.VAL1
switch (e) {
case VAL1:
printf("is val1\n");
case VAL2:
printf("is val2\n");
case VAL3:
printf("is val3\n");
}
}
This program will print these lines to the console:
is val1 is val2 is val3
And now you might think...what? Why does this happen? This is fallthrough in action. The first case that got matched is actually the case VAL1
line. Falltrough means that "the execution falls through (to the next branch)". So, after the case VAL1
branch, the case VAL2
branch got executed and the case VAL3
branch afterwards. If we want the intuitively expected behaviour, where each branch is executed with no fallthrough, we would need to add the break
keyword to every single branch:
#include <stdio.h>
typedef enum { VAL1, VAL2, VAL3 } MyEnum;
int main() {
MyEnum e = VAL1; // In C you dont need to write MyEnum.VAL1
switch (e) {
case VAL1:
printf("is val1\n");
break;
case VAL2:
printf("is val2\n");
break;
case VAL3:
printf("is val3\n");
break;
}
}
This program will print this line to the console:
is val1
And this is the behaviour that Flint has by default. In Flint, falltrough is not opt-out but rather opt-in. We don't actually have a keyword for this but rather an annotation. Here is an example of it:
use Core.print
enum MyEnum:
VAL1, VAL2, VAL3;
def main():
MyEnum e = MyEnum.VAL1;
switch e:
#fallthrough
MyEnum.VAL1:
print("is val1\n");
MyEnum.VAL2:
print("is val2\n");
MyEnum.VAL3:
print("is val3\n");
This program will print these lines to the console:
is val1 is val2
It actually is not clear yet if there will exist an explicit keyword for the fallthrough. We did not want to use the continue
keyword for this purpose, because what happens if you have a switch statement within a for loop, would the for loop continue or would the switch branch fall through? Flint tries to avoid ambiguity at all cost at all places, so we are pretty careful with its design.
Switch Expressions
A switch, however, can not only exist as a statement but as an expression too. Instead of executing an arbitrary block of code, each switch branch needs to be an explicit expression now, marked with the arrow ->
syntax. Have a look:
use Core.print
enum MyEnum:
VAL1, VAL2, VAL3;
def main():
MyEnum e = MyEnum.VAL1;
i32 result = switch e:
MyEnum.VAL1 -> 1;
MyEnum.VAL2 -> 2;
MyEnum.VAL3 -> 4;
print("result = {result}\n");
This program will print this line to the console:
result = 1
For switch expressions, there does not exist such thing as a fallthrough, because there are no code blocks executed but only single expressions. Yes, a literal like 1
is an expression too, but you could write function calls or even another nested switch expression on the place of that literal.
Functions
In the previous chapter, we learned the control flow to control the flow of our program's execution in Flint through conditions, if statements and loops. Now that we have a solid foundation in the basics, let's talk about how to organize and reuse code in our programs. In this chapter, we will explore the fundamental concepts of functions in Flint.
What are Functions?
A function is a reusable block of code that performs a specific task. Functions make programs easier to read, debug, and maintain by encapsulating logic into manageable pieces. Think of a function like a recipe. You can reuse it to "cook" something multiple times without rewriting the steps every single time. Similar to how loops have made our life simpler for repetition, functions make our life easier when we want to do a similar operation at multiple places in our code.
Why are Functions Important?
Functions are essential in programming because they allow you to:
- Break down complex programs into smaller, more manageable pieces
- Reuse code to avoid duplication and reduce errors
- Write more modular and maintainable code
In this chapter, we will learn about the basics of functions in Flint, including how to declare and call functions, how to pass arguments to functions, and how to return values from functions. We will also explore how to use functions to write more efficient and effective programs.
What to Expect
In this chapter, we will cover the following topics:
- Declaring and calling functions
- Passing arguments to functions
- Returning values from functions
- Function scope and lifetime
- Best practices for working with functions
By the end of this chapter, you will have a solid understanding of functions in Flint, and you will be able to use them to write more organized and reusable code. Let's get started!
What is a Function?
A function is a reusable block of code designed to perform a specific task. We have been working this entire time with a function, actually, the main
function. No, actually we have worked with multiple functions, because we also have called the print
function a lot throughout the last few chapters. Now is the time you are going to understand what a function really is and how to define your own ones!
Okay, lets start very simple first. We define a function with the def
keyword (define). Following by the def
keyword we put the name of the function and parenthesis ()
. Note that the names main
, _main
and all names starting with __flint_
are disallowed by the compiler. Without these few exceptions, you can name your functions how you like.
use Core.print
def say_hello():
print("Hello, World!\n");
def main():
say_hello();
This program will print this line to the console:
Hello, World!
There is a very important note to make here. The ordering of definition does not matter in Flint. So, you can define a function like say_hello
after the main
function and still be able to use it within the main function:
use Core.print
def main():
say_hello();
def say_hello():
print("Hello, World!\n");
This is an important part of how Flint works. The reasons to why this works like this are a bit more technical, but just note that ordering of definition does not matter in Flint, which will make your life a lot easier in the future, trust me.
While this function is cool, its not very useful yet because it will always only print the same message to the console. To make functions more useful we will need to add ways to pass data into and recieve data from functions. So, lets jump to the next chapter and discuss arguments.
Adding Parameters
Parameters are "variables" of functions which you can change when calling the function, enabling the function to operate on different data, making them far more versatile and useful. Lets start with an example where we add a single parameter to a function:
use Core.print
def greet(str name):
print($"Hello, {name}!\n");
def main():
greet("Alice");
greet("Bob");
Its a pretty simple example, but you can clearly see that we insert the paramter name
into the string interpolation when calling the print
function. So, the above example will print these lines to the console:
Hello, Alice! Hello, Bob!
Multiple Parameters
Functions can have multiple parameters. To declare multiple parameters, we separate them by commas, like this:
use Core.print
def add_two_numbers(i32 a, i32 b):
print($"The sum is {a + b}.\n");
def main():
add_two_numbers(5, 7);
This program will print this line to the console:
The sum is 12.
Important Notes:
- The type of each argument matters. For example, if
a
andb
are declared asi32
, you cannot pass values of any other type, likef32
oru32
. - The order of arguments also matters. Always pass values in the same order as declared in the function.
There is an important difference between parameters
and arguments
, alltough this difference is only conceptual. When we define a function the "variables" that are defined, like a
and b
in our add_two_numbers
function are called parameters of the function.
When we call a function and pass in values, like the values 5
and 7
for the call add_two_numbers(5, 7)
, they are called arguments of the function call. This difference is important in later chapters, as they are not interchangably used by this wiki. So, if we talk about arguments we talk about calls and if we talk about parameters we talk about function definitions.
Returning Values
Imagine you want a function to calculate the area of a rectangle. It’s not enough to just print the result – you may need to use the value elsewhere in your program. This is where returning values is essential to any program. Here is a small and easy example of a function which returns a value:
def get_greeting() -> str:
return "Hello, Flint!\n";
As you can see, you need to declare a return type after the ->
symbol in the function header. Also, if you want to return a value from within the function you need to use the return
keyword followed by the value you want to return.
So, here is the full example:
use Core.print
def get_greeting() -> str:
return "Hello, Flint!\n"
def main():
str greeting = get_geeting();
print(greeting);
This program will print this line to the console:
Hello, Flint!
Adding Parameters and Returning Values
Now let’s combine function parameters with a return value:
use Core.print
def add_two_numbers(i32 a, i32 b) -> i32:
return a + b;
def main():
i32 result = add_two_numbers(10, 20);
print($"The result is {result}\n");
This program will print this line to the console:
The result is 30
Okay, now that you know how to pass in arguments to a function and return values from the function lets move to the next chapter, recursion.
Recursion
First of all, what even is recursion? Recursion is the act of calling a function from within itself forming a "chain" of calls. Lets start with the most simple example of a recursive function, calculating a fibonacci number:
use Core.print
def fib(i32 n) -> i32:
if n <= 1:
return n;
else:
return fib(n - 1) + fib(n - 2);
def main():
for i := 0; i < 10; i++:
i32 fib_i = fib(i);
print($"fib({i}) = {fib_i}\n");
This program will print these lines to the console:
fib(0) = 0 fib(1) = 1 fib(2) = 1 fib(3) = 2 fib(4) = 3 fib(5) = 5 fib(6) = 8 fib(7) = 13 fib(8) = 21 fib(9) = 34
If you don't know what the fibonacci sequence is, you really should look into it, its beautiful. But, back to the recursive function, as there is a lot to unpack here. The loop itself is not part of the recursive function, only the content of the fib
function make it recursive, because the function calls itself through the calls fib(n - 1)
and fib(n - 2)
.
To understand the function you must first understand the fibonacci sequence itself. Basically, every number is the sum of the last two numbers that came before it with the exceptions of 1
and 0
as they are just the number itself. This exception is handled in the recursive function through the if
branch. So, fib(2)
is the sum of the last two numbers, 0
and 1
, so its result is 1
and so on and so forth.
Returning Multiple Values
Sometimes, a single return value isn’t enough. For instance, a function might need to calculate both the area and perimeter of a rectangle. Flint allows functions to return multiple values at once, without limiting the number of maximum returned values.
How to Return Multiple Values
To return multiple values we use parentheses ()
to group multiple values in the return statement and also use parentheses for declaring the return types:
def calculate_rectangle(i32 length, i32 width) -> (i32, i32):
i32 area = length * width;
i32 perimeter = 2 * (length + width);
return (area, perimeter);
As you can see, this function now returns a group
. Groups are a special concept of Flint, but you will learn about them more in the next chapter. Its important that you separate the values you want to return with commas inside the parenthesis like shown above.
Accessing Multiple Return Values
To recieve the values from a function which returns multiple values we also need to use a group to assign them.
use Core.print
def calculate_rectangle(i32 length, i32 width) -> (i32, i32):
i32 area = length * width;
i32 perimeter = 2 * (length + width);
return (area, perimeter);
def main():
(area, perimeter) := calculate_rectangle(5, 3);
print($"Area: {area}, Perimeter: {perimeter}\n");
This program will print this line to the console:
Area: 15, Perimeter: 16
As you can see, we can declare two variables at once using inferred typing (:=
) from the call calculate_rectangle
. Both area
and perimeter
are of type i32
now and then we can print their values.
Important Note
The types and order of the group must match the function’s return type.
Groups
What are groups? Groups are a new concept of Flint which allow us to do operations on multiple variables at the same time. You will see the potential and the integration of groups into other systems of Flint in later chapters, but even now groups are very powerful.
You have already seen groups in action when returning multiple values from a function, but that was just the beginning. Lets kick things off with a very simple example: variable swaps.
Lets define a simple small program which prints the values of two i32
variables:
use Core.print
def main():
i32 x = 1;
i32 y = 5;
print($"x = {x}, y = {y}\n");
This program will print this line to the console:
x = 1, y = 5
Okay, now lets say that we want to swap the values of x
and y
. With most languages, better said with almost every language, you would need to create a temporary value to swap values:
use Core.print
def main():
i32 x = 1;
i32 y = 5;
print($"x = {x}, y = {y}\n");
i32 temp = x;
x = y;
y = temp;
print($"x = {x}, y = {y}\n");
This program will print these lines to the console:
x = 1, y = 5 x = 5, y = 1
The temp variable exists because once we store the value of y
into x
, everything that was stored in x
before is lost, so we need a way to keep track of the old value of x
to be able to store it in y
.
Okay, but now do groups help us with that? Have a look at the same example, but this time utilizing groups:
use Core.print
def main():
i32 x = 1;
i32 y = 5;
print($"x = {x}, y = {y}\n");
(x, y) = (y, x);
print($"x = {x}, y = {y}\n");
This program will print these lines to the console:
x = 1, y = 5 x = 5, y = 1
Okay, lets unpack what this group even says. We know that the right hand side of any assignment is always executed before assigning the value. So, we create a group and load the values of y
and x
into it. Note that a group does not create any temporary values or "store" the values anywhere. When we load y
and x
in the group (y, x)
these values exist only in the cache of the CPU, they are not really stored annywhere else.
So then, when we assign (y, x)
which holds the values (5, 1)
to the group of (x, y)
this is called a grouped assignment as we assign multiple values of multiple variables at the same time. So, we store the values of (5, 1)
on the group (x, y)
which means that we store 5
in x
and 1
in y
, swapping the values of the variables.
It is very important to note that groups have no runtime footprint, they exist in order for us to be able to "tell" the compiler that we want stuff to happen at the same time. But groups are not limited to only 2 values, we can have as many values in a group as we would like. Here an example:
use Core.print
def main():
i32 a = 1;
i32 b = 2;
i32 c = 3;
i32 d = 4;
print($"a = {a}, b = {b}, c = {c}, d = {d}\n");
(a, b, c, d) = (c, d, a, b);
print($"a = {a}, b = {b}, c = {c}, d = {d}\n");
This program will print these lines to the console:
a = 1, b = 2, c = 3, d = 4 a = 3, b = 4, c = 1, d = 2
So, we assigned c
to a
, d
to b
, a
to c
and b
to d
. Try around a bit, you can also assign all values to a
in the same group:
use Core.print
def main():
i32 a = 1;
i32 b = 2;
i32 c = 3;
i32 d = 4;
print($"a = {a}, b = {b}, c = {c}, d = {d}\n");
(a, b, c, d) = (a, a, a, a);
print($"a = {a}, b = {b}, c = {c}, d = {d}\n");
This program will print these lines to the console:
a = 1, b = 2, c = 3, d = 4 a = 1, b = 1, c = 1, d = 1
Funny thing is, you can also assign to the same variable twice inside a single group, because the order of operation is strictly defined. So, you can write this without a problem:
use Core.print
def main():
i32 a = 3;
print($"a = {a}\n");
(a, a, a) = (a + 1, a + 2, a + 3);
print($"a = {a}\n");
This program will print these lines to the console:
a = 3 a = 6
As you can see, this does not yield to an error...why? Because there is no reason why it should. The compiler should, however, print a warning describing the behaviour in this case. The right hand side of the grouped assignment is executed before any values are stored on the left, so first the group is evalueated to be (4, 5, 6)
and then it is assigned to the group of (a, a, a)
. Grouped assignments work from left to right, so we first assign a
to be 4
, then to be 5
and finally to be 6
. The evaluation of a group is completely separate from the assignment of it, so this is a well-defined situation, and a
will have its rightmost value from the group assigned to it.
But groups will become very important later on, and they will become more and more powerful regarding data
and SIMD
. If you know what this means, great. If not, don't worry, as all of these concepts will be explained in later chapters and data
is actually the next chapter to follow!
Data
Flint brings along a completely new paradigm called Data-Object Convergence Paradigm (DOCP). What this paradigm means and what it brings to the table will be discussed thoroughly in later chapters, but data
is very important for it. Unlike other paradigms like OOP (Object-Oriented Programming) you cannot attach functions to data. In Flint, data is exactly just that: data. Nothing more, nothing less.
Data is used to "pack" values together and to make them reusable, just like functions made instructions and operations reusable, data
modules make working with similar data much simpler.
But lets not focus on the theory so much but dive into declaring and using data right away.
Declaring Data Modules
To define a new data
module in Flint, we use the data
keyword. A data
module consists of fields (the pieces of information it holds) and a constructor (in which order to initialize those fields).
Basic Syntax:
data Vector2:
i32 x;
i32 y;
Vector2(x, y);
As you can see, we start with the data
keyword, followed with the name of our data module, in this case Vector2
. Then, we start by defining the fields of the data one by one. At the end of the definition we write the Constructor of the data, which specifies in which order we need to pass in the field values when creating the data module. This might seem weird for now, but keep going, things will become more clear as we go.
The important thing to note is that we now have a new type at our disposal: Vector2
. Defining data modules creates new types, so you now can create variables of type Vector2
, just like we did before with i32
. Here is a small example:
data Vector2:
i32 x;
i32 y;
Vector2(x, y);
def main():
Vector2 v2 = Vector2(10, 20);
As you can see, the variable v2
now is of type Vector2
and we create it by calling the constructor of the data type with Vector2(10, 20)
. This constructor sets x
to 10
and y
to 20
. But when we try to run this program we cannot see anything in the console, we need a way to print the values the data fields have.
Field Access
When we want to access a field of our data variable, for example the x
field of our v2
varaible we need to do so through a field access. There exists a symbol for this very use case: The .
(dot). It's best if you just look at the example for yourself:
use Core.print
data Vector2:
i32 x;
i32 y;
Vector2(x, y);
def main():
Vector2 v2 = Vector2(10, 20);
print($"v2.x = {v2.x}, v2.y = {v2.y}\n");
This program will print this line to the console:
v2.x = 10, v2.y = 20
As you can see, the variable of type Vector2
now contains two fields of type i32
, x
and y
and we can access and modify themthrough the .
access.
Field Assignment
In the next example we will store a new value only on the x
field of data:
use Core.print
data Vector2:
i32 x;
i32 y;
Vector2(x, y);
def main():
Vector2 v2 = Vector2(10, 20);
print($"v2.x = {v2.x}, v2.y = {v2.y}\n");
v2.x = 15;
print($"v2.x = {v2.x}, v2.y = {v2.y}\n");
This program will print these lines to the console:
v2.x = 10, v2.y = 20 v2.x = 15, v2.y = 20
As you can see, we can only modify a single field of data without touching the other fields. But thats not all... now let's talk about how groups can make our life with data easier.
Grouped Field Access
You already know what a group is, but groups can also be extremely powerful for data manipulation. Grouped field accesses are a new concept of Flint, together with groups. The idea is simple: Access and modify multiple fields of data at the same time. Here is a small example showcasing it:
use Core.print
data Vector3:
f32 x;
f32 y;
f32 z;
Vector3(x, y, z);
def main():
Vector3 v3 = Vector3(1.0, 2.0, 3.0);
(x, y, z) := v3.(x, y, z);
print($"(x, y, z) = ({x}, {y}, {z})\n");
This program will print this line to the console:
(x, y, z) = (1, 2, 3)
The syntax is pretty easy, actually. First, we say the variable we want to access the fields in: v3.
and then we open a left paren (
and within the parenthesis we describe the names of the fields we want to access and we wrap it up with the closing paren )
. You could see that this line: v3.(x, y, z)
is actually the same as writing this: (v3.x, v3.y, v3.z)
but it's much neater to look at and to write. Why should we write v3.
three times when we only want to access multiple fields of it?
Grouped Field Assignment
Just like we can access mutliple fields of data at once, we can also assign multiple values of it at the same time. Here is an example of that:
use Core.print
data Vector3:
f32 x;
f32 y;
f32 z;
Vector3(x, y, z);
def main():
Vector3 v3 = Vector3(1.0, 2.0, 3.0);
print($"v3.(x, y, z) = ({v3.x}, {v3.y}, {v3.z})\n");
v3.(x, y, z) = v3.(z, x, y);
print($"v3.(x, y, z) = ({v3.x}, {v3.y}, {v3.z})\n");
This program will print these lines to the console:
v3.(x, y, z) = (1, 2, 3) v3.(x, y, z) = (3, 1, 2)
As you can see, we did the same thing as we did for variable swaps, but now on data fields. This is only possible through the concoept of groups. A very important thing is that groups themselves have a type. If you would write out the type of the access v3.(x, y, z)
it would look like this: (f32, f32, f32)
. As you can see, this looks exactly like the return type of a function when we would return multiple values, enforcing the connection that a function returning multiple values returns a group of values.
But swaps are not all we can do, we can for example calculate multiple values at once, for example incrementing all fields of the vector v3
by one:
use Core.print
data Vector3:
f32 x;
f32 y;
f32 z;
Vector3(x, y, z);
def main():
Vector3 v3 = Vector3(1.0, 2.0, 3.0);
print($"v3.(x, y, z) = ({v3.x}, {v3.y}, {v3.z})\n");
v3.(x, y, z) += (1.0, 1.0, 1.0);
print($"v3.(x, y, z) = ({v3.x}, {v3.y}, {v3.z})\n");
This program will print these lines to the console:
v3.(x, y, z) = (1, 2, 3) v3.(x, y, z) = (2, 3, 4)
As you can clearly see, all fields of the variable v3
have been incremented by one. By combining data with groups you can create very powerful and still compact code.
Default Values
Default values do not work yet.
While default values are able to be parsed in the data definition itself, the constructor of data cannot be called with the _
operator to create a default instance of the type.
Sometimes, you may want a field to have a default value. In Flint, this is done by assigning a value to the field directly in its declaration.
data MyData:
i32 x = 5;
i32 y;
MyData(x, y);
When instantiating this data module, you can use _
to signify using the default value for a field. The _
operator is only used in unused or default contexts, nowhere else. So, if you see a single _
in Flint you can always assume that either something is unused or set to a default.
use Core.print
data MyData:
i32 x = 5;
i32 y;
MyData(x, y);
def main():
MyData d = MyData(_, 20); // x uses the default value of 5
print($"d.x = {d.x}, d.y = {d.y}\n");
This program will print this line to the console:
d.(x, y) = (5, 20)
If all fields of a given data type have default values set the constructor of the data type can be called with a single _
operator to singify to set every field to its default value. But, note that this only works if every field in the given data type has a default value set. If one of them has no default value set this will fail.
use Core.print
data MyData:
i32 x = 5;
i32 y = 7;
MyData(x, y);
def main():
MyData d = MyData(_);
print($"d.(x, y) = ({d.x}, {d.y})\n");
This program will print this line to the console:
d.(x, y) = (5, 7)
- Default values simplify initialization but are optional.
- If a field doesn’t have a default value, using
_
will result in a compiler error
Nested Data
Data modules can include other data modules as fields. This allows you to create nested structures, which are common in real-world programming. Here is an example of this concept in action:
use Core.print
data Point:
i32 x;
i32 y;
Point(x, y);
data Rectangle:
Point top_left;
Point bottom_right;
Rectangle(top_left, bottom_right);
def main():
Point p1 = Point(0, 0);
Point p2 = Point(10, 10);
Rectangle rect = Rectangle(p1, p2);
print($"rect.top_left.(x, y) = ({rect.top_left.x}, {rect.top_left.y})\n");
print($"rect.bottom_right.(x, y) = ({rect.bottom_right.x}, {rect.bottom_right.y})\n");
This program will print these lines to the console:
rect.top_left.(x, y) = (0, 0) rect.bottom_right.(x, y) = (10, 10)
Note that storing the Point
variables in the rect
variable through its constructor creates copies of the points. In other languages this would need to be done manually, but in Flint its automatic. So, when changing p1
and p2
after the creation of rect
, the top_left
and bottom_right
fields will not be changed:
use Core.print
data Point:
i32 x;
i32 y;
Point(x, y);
data Rectangle:
Point top_left;
Point bottom_right;
Rectangle(top_left, bottom_right);
def main():
Point p1 = Point(0, 0);
Point p2 = Point(10, 10);
Rectangle rect = Rectangle(p1, p2);
print($"p1.(x, y) = ({p1.x}, {p1.y})\n");
print($"p2.(x, y) = ({p2.x}, {p2.y})\n");
print($"rect.top_left.(x, y) = ({rect.top_left.x}, {rect.top_left.y})\n");
print($"rect.bottom_right.(x, y) = ({rect.bottom_right.x}, {rect.bottom_right.y})\n");
print("\n");
p1.(x, y) = (4, 5);
p2.(x, y) = (22, 33);
print($"p1.(x, y) = ({p1.x}, {p1.y})\n");
print($"p2.(x, y) = ({p2.x}, {p2.y})\n");
print($"rect.top_left.(x, y) = ({rect.top_left.x}, {rect.top_left.y})\n");
print($"rect.bottom_right.(x, y) = ({rect.bottom_right.x}, {rect.bottom_right.y})\n");
This program will print these lines to the console:
p1.(x, y) = (0, 0) p2.(x, y) = (10, 10) rect.top_left.(x, y) = (0, 0) rect.bottom_right.(x, y) = (10, 10) p1.(x, y) = (4, 5) p2.(x, y) = (22, 33) rect.top_left.(x, y) = (0, 0) rect.bottom_right.(x, y) = (10, 10)
What about Circular References?
The below example actually compiles, but its impossible to run.
Data is actually allowed to contain itself, but its impossible to initialize, as Flint has noo concept of nullpointers or null like other languages have. Flint has its optionals Opt<T>
instead, but they are not implemented yet. Its amusing how the below example actually compiles fine.
Flint does not allow a data module to reference itself directly or indirectly like showcased below:
data Node:
i32 value;
Node next;
Node(value, next);
While this may seem restrictive, it is pretty easy explained why this does not work: If you try to initialize a new variable of type Node
you need to provide both its fields for the initializer. The value
is fine, you can just pass in a literal, but what about the second field, next
? To create a new variable of type Node
you need an already existent variable of the same type to pass into, and thats impossible.
Hint:
Flint can handle circular references with the help of the optional type (Opt
). These convert a reference to a wek reference in circular context's, thus enabling the use of data in of itself, for example for linked lists.
Using Data in Functions
Data modules can be passed to and returned from functions, enabling you to manipulate them easily.
use Core.print
data Point:
i32 x;
i32 y;
Point(x, y);
def print_point(Point p):
print($"Point(x: {p.x}, y: {p.y})\n");
def main():
Point p = Point(3, 4);
print_point(p);
This program will print this line to the console:
Point(x: 3, y: 4)
Mutability and Immutability
Okay, but what if we want to modify the point in a function? Lets look at an example:
use Core.print
data Point:
i32 x;
i32 y;
Point(x, y);
def increment_by(Point p, i32 value):
p.(x, y) += (value, value);
def main():
Point p = Point(3, 4);
increment_by(p, 3);
print($"Point(x: {p.x}, y: {p.y})\n");
If you try to compile this program you will actually get a compile error:
Parse Error at main.ft:9:5 -- Variable 'p' is marked as 'const' and cannot be modified!
But why is that? For this to explain we actually need to talk about mutability for a bit. Mutability is the ability to mutate (change) variables. Up until now this has not been a problem yet, because Flint actually has clear mutability rules:
- Local variables declared within a scope are always mutable except explicitely made immutable
- Function parameters are always immutable except explicitely made mutable
Flint has two keywords for this very reason: mut
and const
. You can use const
when declaring a variable in a scope to make the variable constant, thus not-changable after its declaration and you can use mut
to make parameters of functions explicitely mutable. Note that putting const
in front of a function parameter has no effect, as its const annyway, same as putting mut
in front of a variable declaration, as variables are mutable annyway.
So, to fix our little compile error we need to change the signature of our function a bit:
use Core.print
data Point:
i32 x;
i32 y;
Point(x, y);
def increment_by(mut Point p, i32 value):
p.(x, y) += (value, value);
def main():
Point p = Point(3, 4);
increment_by(p, 3);
print($"Point(x: {p.x}, y: {p.y})\n");
This program will print this line to the console:
Point(x: 6, y: 7)
As you can see, the functions signature of the increment_by
function now explicitely states that its parameter is a mutable one. This means that we can only pass in mutable Point
variables to it when calling it. So, this example:
use Core.print
data Point:
i32 x;
i32 y;
Point(x, y);
def increment_by(mut Point p, i32 value):
p.(x, y) += (value, value);
def main():
const Point p = Point(3, 4);
increment_by(p, 3);
print($"Point(x: {p.x}, y: {p.y})\n");
will not compile again. Because now we have declared p
to be immutable, but we try to pass it to to the call increment_by
which expects a mutable Point
argument, so we have a type mismatch here, because when we made p
immutable it would be wrong if it could be modified by a function. We get this compile error:
Parse Error at main.ft:13:18 -- Variable 'p' is marked as 'const' and cannot be modified!
Returning Data from Functions
Returning data variables from functions does not wory yet.
The error lies somewhere hidden in the codebase, but its a nontrivial fix so i just did not bother for now. So, you will need to pass mutable references to functions for now, but thats fine in my view.
You can also return data from functions, for example when creating them inside the function.
use Core.print
data Point:
i32 x;
i32 y;
Point(x, y);
def create_point(i32 x, i32 y) -> Point:
return Point(x, y);
def main():
Point p = create_point(5, 7);
print($"Point(x: {p.x}, y: {p.y})\n");
By using functions with data, you can create and manipulate complex structures easily.
Tuples
Tuples are a really nice concept in general and they exist in pretty much every language. But first, lets talk about what tuples even are and maybe take a closer look at data
in general.
In Flint, data
is essentially just a struct
from C, if you have seen that one before. So when we write
data Vector2:
i32 x;
i32 y;
Vector2(x, y);
we could do something similar in C which would look like this:
typedef struct {
int x;
int y;
} Vector2;
Under the hood, both Flint's data
module and C's struct
are exactly the same. They are just collections of data packed into a struct. So, what are tuples then? Well, tuples are collections of data, packed into a struct too. But with one big difference: They are anonymous, meaning that they dont get a type name, but when looking at the lowest level, data
and tuples are actually extremely similar.
Defining Tuples
Because tuples are anonymous they are not defined like data modules are. They are rather defined inline, like a variable, for example. So here is the basic syntax to define a tuple in Flint:
def main():
data<i32, f32, str> tuple = (3, 2.2, "hello!");
Do you recognize the data
keyword? This is the reason i told you earlier that tuples and data are actually pretty much the same thing, but one is named while the other one is not. This connection and the understanding of it is crucial to understand tuples, because otherwise you now would be really confused by the syntax: "Wait, what does the data
keyword have to do with tuples here?".
And here we have another very nice property of Flint's groups – they enable seemless interoperability between different types! As you can see, the "initializer" for a tuple is actually a group with the same types as the tuple itself. So, the "initializer" of a tuple could also be a grouped field access (d.(a, b, c)` or a group from multiple variables or anything else you can do with groups. As you can see, groups form a whole layer of making syntax easier for a lot of systems.
Tuple Access
But what about assigning and accessing the specific fields of a tuple? With data
modules, we can access the fields directly by the name of the field (v2.x
) but tuples are anonymous, meaning that neither the type itself has a name, nor do the fields.
In Flint, we access the fields of a tuple by its "index". The first field of the tuple above is of type i32
, the second of type f32
and the third of type str
, so we can use the fixed ordering as our accessing syntax right away. But we cannot do tuple.0
, tuple.1
etc directly because that would look pretty weird to have an integer literal directly. Here is an example of how to access the single values of a tuple in Flint:
use Core.print
def main():
data<i32, f32, str> tuple = (3, 2.2, "hello!");
i32 first = tuple.$0;
f32 second = tuple.$1;
str third = tuple.$2;
print($"first = {first}\n");
print($"second = {second}\n");
print($"third = \"{third}\"\n");
This program will print these lines to the console:
first = 3 second = 2.2 third = hello!
As you can clearly see, we access the elements of the tuple with the .$N
syntax, where N
is the index of the element we want to access. Like always for indices, we start at 0 as Flint has zero-based indexing. If we would try to access an element which is out of bounds, like .$3
in our case we would actually get a compile error:
def main():
data<i32, f32, str> tuple = (3, 2.2, "hello!");
i32 = tuple.$3;
The error message for this error has not been added yet.
Currently the error is Custom Error: 2
without giving any information what error it is.
Tuple Assignment
Just like we can access elements from a tuple, we can also assign new values to the elements of a tuple. Here is a simple example of this in action:
use Core.print
def main():
data<i32, f32, str> tuple = (3, 2.2, "hello!");
tuple.$0 = 7;
tuple.$1 = 4.7;
tuple.$2 = "yes";
print($"first = {tuple.$0}\n");
print($"second = {tuple.$1}\n");
print($"third = \"{tuple.$2}\"\n");
This program prints these lines to the console:
first = 7 second = 4.7 third = "yes"
Grouped accesses and assignments
Just like with "normal" data you can do grouped field accesses and assignments with tuples too. Instead of the field names you need to write the field ids again:
use Core.print
def main():
data<i32, f32, str> tuple = (3, 2.2, "hello!");
tuple.($0, $1, $2) = (7, 4.7, "yes");
print($"tuple.($0, $1, $2) = ({tuple.$0}, {tuple.$1}, \"{tuple.$2}\")\n");
This program prints this line to the console:
tuple.($0, $1, $2) = (7, 4.7, "yes")
Addition Information
Multi-Type overlap
Tuples are not allowed to be defined as a type that can be represented with a mutli-type instead. So, this example for example:
use Core.print
def main():
data<i32, i32, i32> tuple = (1, 1, 1);
tuple.($0, $1, $2) = (2, 3, 4);
print($"tuple.($0, $1, $2) = ({tuple.$0}, {tuple.$1}, {tuple.$2})\n");
will throw a compilation error and tell you to use a i32x3
type instead of the data<i32, i32, i32>
type.
The error message for this error has not been added yet.
Currently the error is Custom Error: 2
without giving any information what error it is.
Returning Tuples
It is not allowed to return a tuple from a function if its the only return type of said function. You need to return a group instead and this is compile-time enforced. The exact reason to why this is required will be clarified in the chapter aboout Flint's error handling. So, this code:
use Core.print
def get_tuple(i32 a, f32 b, str c) -> data<i32, f32, str>:
data<i32, f32, str> tuple = (a, b, c);
return tuple;
def main():
data<i32, f32, str> tuple = get_tuple(1, 4.7, "hello");
print($"tuple.($0, $1, $2) = ({tuple.$0}, {tuple.$1}, {tuple.$2})\n");
will produce a compile error telling you to use a group of type (i32, f32, str)
instead as the return type of the function. Because you can do this:
use Core.print
def get_tuple(i32 a, f32 b, str c) -> (i32, f32, str):
return (a, b, c);
def main():
data<i32, f32, str> tuple = get_tuple(1, 4.7, "hello");
print($"tuple.($0, $1, $2) = ({tuple.$0}, {tuple.$1}, {tuple.$2})\n");
annyway. This program will print this line to the console:
tuple.($0, $1, $2) = (1, 4.7, hello)
Passing Tuples to functions
Tuples can also be passed to functions as any value can:
use Core.print
def print_tuple(data<i32, f32, str> tuple):
print($"tuple.(i32, f32, str) = ({tuple.$0}, {tuple.$1}, \"{tuple.$2}\")\n");
def main():
data<i32, f32, str> tuple = (1, 2.2, "three");
print_tuple(tuple);
This program will print this message to the console:
tuple.(i32, f32, str) = (1, 2.2, "three")
Also, like "normal" data, tuples can be passed to functions as mutable references:
use Core.print
def change_tuple(mut data<i32, f32, str> tuple):
tuple.($0, $1, $2) = (2, 3.3, "four");
def main():
data<i32, f32, str> tuple = (1, 2.2, "three");
print($"tuple.(i32, f32, str) = ({tuple.$0}, {tuple.$1}, \"{tuple.$2}\")\n");
change_tuple(tuple);
print($"tuple.(i32, f32, str) = ({tuple.$0}, {tuple.$1}, \"{tuple.$2}\")\n");
This program will print these lines to the console:
tuple.(i32, f32, str) = (1, 2.2, "three") tuple.(i32, f32, str) = (2, 3.3, "four")
Multi-Types
Multi-Types are essentially vectorized variants of some primitive types to increase both readability, performance (SIMD) and ease of use for vectorized math operations and much more. Here is an example of how multi-types work:
use Core.print
def main():
i32x3 v3 = (1, 2, 3);
print($"v3.(x, y, z) = ({v3.x}, {v3.y}, {v3.z})\n");
v3.(x, y, z) = (4, 5, 6);
print($"v3.(x, y, z) = ({v3.x}, {v3.y}, {v3.z})\n");
This program will print these lines to the console:
v3.(x, y, z) = (1, 2, 3) v3.(x, y, z) = (4, 5, 6)
As you can see, the 3-width i32 multi-type has the "fields" of x, y and z, each being of type i32
. There exist several multi-types in Flint today:
Type | Element Type | Vector Size | "Field" Names |
---|---|---|---|
i32x2 | i32 | 2 | x , y |
i32x3 | i32 | 3 | x , y , z |
i32x4 | i32 | 4 | r , g , b , a |
i32x8 | i32 | 8 | $N |
i64x2 | i64 | 2 | x , y |
i64x3 | i64 | 3 | x , y , z |
i64x4 | i64 | 4 | r , g , b , a |
f32x2 | f32 | 2 | x , y |
f32x3 | f32 | 3 | x , y , z |
f32x4 | f32 | 4 | r , g , b , a |
f32x8 | f32 | 8 | $N |
f64x2 | f64 | 2 | x , y |
f64x3 | f64 | 3 | x , y , z |
f64x4 | f64 | 4 | r , g , b , a |
bool8 | bool | 8 | $N |
All multi-types with less than width 4 can be accessed via the field names directly, while all multi-types which are bigger, like i32x8
can only be accessed with the same index-based accesser like tuples through the .$N
syntax. This is also the reason why tuples needed to be explained before multi-types.
Multi-Types with Functions
But let's move on to functions, because multi-types can be returned from functions too, unlike tuples. So, we can very well define a function like this:
use Core.print
def get_vec_2(i32 x, i32 y) -> i32x2:
return (x, y);
def main():
(x, y) := get_vec_2(10, 20);
print($"(x, y) = ({x}, {y})\n");
This program will print this line to the console:
(x, y) = (10, 20)
As you can see, interoperability between mutli-types and groups just works. Groups are Flint's "type interoperability layer". You can pack multiple single values into a group, then store it in a tuple. Or access multiple fields of a tuple and store it in a multi-type etc. Groups are the real "middle-ground" of Flint's type system, because you can return a group of (i32, i32)
and still store it in a mutli-type or you can return a i32x2
and store it in a group. The group, however, could also be a grouped assignment of a tuple, so you could very well write tuple.($0, $2) = get_vec_2(10, 20);
and store the i32x2
return value on the $0
and $2
fields of the tuple, because its a grouped assignment and groups are natively meant to be interoperable with Flint's other types.
Multi-Type Arithmetic
Multi-types are primitive types in Flint, which means that they have first-class arithmetic support. The mutli-type variant of any type supports the same arithmetic operations as its underlying type. Here is one example of this:
use Core.print
def main():
i32x4 v4_1 = (1, 2, 3, 4);
i32x4 v4_2 = (5, 6, 7, 8);
i32x4 sum = v4_1 + v4_2;
print($"sum = ({sum.r}, {sum.g}, {sum.b}, {sum.a})\n");
This program will print this line to the console:
sum = (6, 8, 10, 12)
Important Note
When using Multi-Types you gain free access to SIMD instructions. SIMD means Single Instruction, Multiple Data and its a very optimized way of doing operations, such as additions. For example, adding two i32x4
variables is just as fast as adding a single i32
variable. This makes Flint's multi-types both extremely fast and extremely easy to use.
Arrays
In the previous chapter we learned the importance of data
, groups, mutli-types, tuples and how to use all of them in functions. With all these concepts we now developed a good understanding of Flint's type-system. So, its now time to move to more complex types than those of the last chapter – arrays.
What are Arrays?
An array is a collection of the same data type which can be resized and filled with values. While mutli-types and tuples are great for storing a small amount of values, what if we want to store a hundred of them? Using a tuple to store 100 elements would not only be extremely tedious but also extremely verbose. Just imagine writing i32,
a hundred times inside the data<..>
definition.
You actually already know an array type: Strings! The str
type is just an array of characters (i8
) but you will also learn how strings work under the hood in this chapter!
Why are Arrays Important?
Arrays are essential in programming because the allow you to:
- Store and manipulate large amounts of data
- Perform operations on multiple values at once
- Use indexing to access specific values in the array
What to Expect
In this chapter, we will conver the following topics:
- Declaring and using arrays
- Accessing and modifying array elements
- Using arrays in functions and data modules
- What strings and arrays have in common with one another
- What ranges are and how to use them
- The enhanced for loop, what it is and how it works
- Multi-dimensional arrays and access patterns
- Best practices for working with arrays
Introduction to Arrays
An array is a data structure that stores a collection of elements sequentially in memory. Arrays are useful for storing multiple values of the same type, such as numbers, strings, or even custom data modules. In Flint, arrays are immutable by default in terms of references — assigning one array to another always creates a copy, not a reference.
- Arrays are always stored sequentially in memory, making access to their elements efficient.
- Arrays are value types in Flint. This means copying an array creates a new, independent copy of its data.
- If you modify an array inside a data object, you should access it directly using data.array instead of copying it out, as changes made to the copy won’t automatically reflect back in the original array.
- They are considered complex data types, so passing them to functions passes them as a reference, not a copy.
Creating Arrays
To declare a one-dimensional array, we write brackets after the array type, for example i32[]
for an array of i32
values:
def main():
i32[] arr = i32[10](0);
This program does not print anything to the console, but we need to talk about it nonetheless and talk about whats happening here. So, we create an array of type i32[]
and store it on the variable arr
. We initialize the array quite similar to how we would initialize data
. For data
, we wrote the name of the data type, followed by parenthesis in which we wrote the initializer arguments. For arrays, this works a bit differently. First of all, we need to provide a size in between the squared brackets, in the above example the size will be set to 10
, which means that the array arr
will contain 10
values of type i32
. And lastly, the (0)
. For arrays, we need to define a "default-value" with which all array elements are filled. In our case, this is the i32
value of 0
, which means that every single one of the 10 elements in the array has the value 0
stored in it after the arrays creation.
Accessing Elements
To access an element of an array we need to use a new syntax, different from when we accessed elements of the tuple via .$N
. Now, for arrays, we need to access it using [N]
, where N
is the index we want to access. Here is a small example of that:
use Core.print
def main():
i32[] arr = i32[10](0);
i32 elem_3 = arr[3];
print($"elem_3 = {elem_3}\n");
This program will print this line to the console:
elem_3 = 0
Okay, the array is of size 10
and we start counting at 0
, so the last index we are allowed to access is 9
...what happens if we access index 10
? In most languages this would yield into a hard crash of the program, but in Flint we have extra safety-guards in place for out-of-bounds checks. But try it for yourself, try to compile and run this program:
use Core.print
def main():
i32[] arr = i32[10](0);
i32 elem_10 = arr[10];
print($"elem_10 = {elem_10}\n");
This program will print these lines to the console:
Out Of Bounds access occured: Arr Len: 10, Index: 10 elem_10 = 0
As you can see, Flint just continues with execution. When you try to access an value thats outside the bounds of the array, Flint will just clamp the index to the last element of the array instead, and printing a message that an Out Of Bounds access has occured. You can actually change Flint's behaviour for OOB-handling with the Array Options. Here is a small cutout of the help message of the compiler:
Array Options: --array-print [Default] Prints a small message to the console whenever accessing an array OOB --array-silent Disables the debug printing when OOB access happens --array-crash Hard crashes when an OOB access happens --array-unsafe Disables all bounds checks for array accesses
Try compiling the above code with the different array options set and see for yourself how the Flint program behaves. Flint aims to be as safe and as verbose (in its output) as possible and we try to make safety the default and let you opt-out of safety (for example through the --array-unsafe flag) if you are 100% sure that OOB accesses are impossible for your program.
Assigning Values
To assign new values to elements of the array we use the same accessing-syntax as before:
use Core.print
def main():
i32[] arr = i32[10](0);
arr[3] = 8;
arr[4] = 4;
print($"arr[3] + arr[4] = {arr[3] + arr[4]}\n");
This program will print this line to the console:
arr[3] + arr[4] = 12
Grouped Access and Assignment
This feature is not yet implemented in the compiler
Currently, this feature does not yet work in the current version of the compiler, but it is definitely planned to be implemented in a later version.
Just like with tuples, mutli-types, data or basically any type in Flint, arrays have some form of interoperability with groups too. The syntax looks a bit different, though. Here is the same example as above, but using a grouped assignment:
use Core.print
def main():
i32[] arr = i32[10](0);
arr.[3, 4] = (8, 4);
print($"arr[3] + arr[4] = {arr[3] + arr[4]}\n");
This program will print this line to the console:
arr[3] + arr[4] = 12
As you can see, instead of doing .(x, y)
(for example for i32x2
) we write .[idx1, idx2]
. We definitely need the .
in front of the [
symbol to differentiate a grouped array access from a multi-dimensional array access, but you will learn about multi-dimensional arrays soon.
Iterating Over Arrays
Often, you will want to process each element of an array inside of a loop. For this, you can easily use a for
loop, like so:
use Core.print
def main():
i32[] arr = i32[5](4);
// Set each element to the double of the index
for i := 0; i < 5; i++:
arr[i] = i * 2;
print($"Index: {i}, Value: {arr[i]}\n");
This program will print these lines to the console:
Index: 0, Value: 0 Index: 1, Value: 2 Index: 2, Value: 4 Index: 3, Value: 6 Index: 4, Value: 8
With loops, we can better demonstrate the OOB-behaviour mentioned in the last chapter. Here is an example to better demonstrate this behaviour:
use Core.print
def main():
i32[] arr = i32[5](4);
for i := 0; i < 10; i++:
arr[i] = i * 2;
print($"Index: {i}, Value: {arr[i]}\n");
print("\n");
for i := 0; i < 5; i++:
print($"Index: {i}, Value: {arr[i]}\n");
This program will print these lines to the console:
Index: 0, Value: 0 Index: 1, Value: 2 Index: 2, Value: 4 Index: 3, Value: 6 Index: 4, Value: 8 Out Of Bounds access occured: Arr Len: 5, Index: 5 Out Of Bounds access occured: Arr Len: 5, Index: 5 Index: 5, Value: 10 Out Of Bounds access occured: Arr Len: 5, Index: 6 Out Of Bounds access occured: Arr Len: 5, Index: 6 Index: 6, Value: 12 Out Of Bounds access occured: Arr Len: 5, Index: 7 Out Of Bounds access occured: Arr Len: 5, Index: 7 Index: 7, Value: 14 Out Of Bounds access occured: Arr Len: 5, Index: 8 Out Of Bounds access occured: Arr Len: 5, Index: 8 Index: 8, Value: 16 Out Of Bounds access occured: Arr Len: 5, Index: 9 Out Of Bounds access occured: Arr Len: 5, Index: 9 Index: 9, Value: 18 Index: 0, Value: 0 Index: 1, Value: 2 Index: 2, Value: 4 Index: 3, Value: 6 Index: 4, Value: 18
You can spot two out of bounds accesses here. The first one happens when we want to assign i * 2
to the array at i
and the second one is in the printing when trying to print arr[i]
in the string interpolation. And then, at the end we print the current values of the array and you can clearly see that the last element at index 4 holds the value 18, which is double the last index of the last loop. As you can see, OOB accesses are considered "safe" in Flint, because it is well-defined what will happen when an OOB access occurs.
Strings
As stated earlier, strings and arrays have very much in common, actually. If we would make a deep-dive into how Flint works internally we would see that arrays and strings actually are the same data structure, but thats too low level for now. But, the important thing to note here is the similarity of strings with arrays, as a string is essentially just an array of characters, but what is a character in string really?
In Flint, a character has the type u8
, a type we have not discussed until now. So, if you save the sting hello
on a variable its essentially the same as if you would store a u8[]
array of length 5
. There exists a separate str
type, however, to make our life with strings a lot easier than it would if we would need to think of strings as arrays of bytes.
But what does all of this mean? Well, first we can access a given character of a string exactly as we would access any element of an array, here is an example:
use Core.print
def main():
str name = "Marc";
u8 third = name[2];
print($"third = '{third}'\n");
This program will print this line to the console:
third = 'r'
Now you might say...wait a minute, why is the character printed as r
and not as a number, it's an u8
type nonetheless, right? Yes, it is. Flint currently only supports characters from the ASCII set. In computers, characters as text is represented as numbers in the ASCII set. You can look at the whole ASCII table here. According to the table, the character r
should be the ASCII value of 114
, lets check that:
use Core.print
def main():
str name = "Marc";
u8 third = name[2];
print($"third = '{third}' at idx {i32(third)}\n");
This program will print this line to the console:
third = 'r' at idx 114
As you can see, r
really is just the number 114
inside the computer. But what does this mean for arithmetic, comparisons etc? Because u8
is "just a normal integer type" we can add, multiply, substract, divide, compare etc, everything like with i32
values. But, we can not directly just store numbers on it without explicit typecasting. Here is an example of this:
use Core.print
def main():
u8 character = 'C';
print($"character '{character}' is {i32(character)}\n");
character++;
print($"character '{character}' is {i32(character)}\n");
This program will print these lines to the console:
character 'C' is 67 character 'D' is 68
Getting a strings length
It is not uncommon to have a string as a paramter of a function, for example, and then we often want to get the length of the string somehow, maybe we dont know the length of a string beforehand, for example when parsing user input (will be talked about in a later chapter). But, very often we don't know the size of a string when writing the program, so we need a way to get a strings length at runtime. Here is a small program demonstrating how to get and use the length of a string:
use Core.print
def main():
str some_string = "some neat string";
len := some_string.length;
print($"string '{some_string}' is {len} characters long\n");
This program will print this line to the console:
string 'some neat string' is 16 characters long
The variable len
is of type u64
here. The length
field of a string is always a u64
. One-dimensional arrays have also the result type of u64
for their .length
field. Here is a small example how you can print an unknown string line by line:
use Core.print
def print_str(str input):
for i := 0; i < input.length; i++:
print($"{i}: '{input[i]}'\n");
def main():
print_str("Hello");
print_str(", ");
print_str("World!\n");
This program will print these lines to the console:
0: 'H' 1: 'e' 2: 'l' 3: 'l' 4: 'o' 0: ',' 1: ' ' 0: 'W' 1: 'o' 2: 'r' 3: 'l' 4: 'd' 5: '!' 6: ' '
Multidimensional Arrays
Flint supports multidimensional arrays. In Flint, these arrays are always rectangular, meaning that the length of each dimension is locked, unlike jagged arrays, which behave more like "array of arrays". Multidimensional Arrays are particularly useful for storing grid-like data, such as images or matrices.
Declaring Multidimensional Arrays
The number of commas between the brackets of the array type directly indicates the dimensionality of an array. So, the array i32[]
is a one-dimensional array (we have one "line" of values), then i32[,]
is a two-dimensional array (we have a "plane" of values), i32[,,]
is a three-dimensional array (we have a "cube" of values) and so on. There is no limit to how high the dimensionality of an array can be, really. We start with dimensionality of 1
because a dimensionality of 0
is already defined: its a single value, so i32
is "an array of 0 dimensionality" if you want to see it like this.
When declaring a multi-dimensional array we use the same syntax as for "normal" arrays, with the same default-value that gets put into all elements of the array as usual:
def main():
// 2D array
i32[,] plane = i32[10, 10](0);
// 3D array
i32[,,] cube = i32[10, 10, 10](0);
Here, we defined the plane
array to be of size 10 × 10
, which means there can be stored 100
elements in this two-dimensional array. We also defined the cube
array to be of size 10 × 10 × 10
, which means there can be stored 1000
elements in this three-dimensional array.
Accessing Multidimensional Arrays
To access and assign elements at a given index we need to specify the index of each dimensionality explicitely. For our plane, this would mean that we need to specify the "row" and the "column" of the plane, or the "x" and the "y" coordinates (if the plane is seen as a coordinate plane).
use Core.print
def main():
i32[,] plane = i32[10, 10](0);
// Set the element at row 1, column 2
plane[1, 2] = 10;
print($"plane[1, 2] = {plane[1, 2]}\n");
This program will print this line to the console:
plane[1, 2] = 10
Getting the lengths of multi-dimensional arrays
In the last chapter we talked about how we can access the length of strings and arrays by the .length
field on them. This also is true for multi-dimensional arrays. But we have more than one dimension, so how is it possible to get the lengths in one u64
variable? Thats a good question, and the answer is very simple: we don't.
Instead, when accessing the .length
field of an array we actually get a group of size N
, where N
is the dimensionality of the array. So, if we access the .length
field of an one-dimensional array we get a group of size 1, so we get one u64
value. If we access the .length
field on the plane
array we will get a (u64, u64)
group as a return, and if we access the length of a 3D array like our cube
we will get a (u64, u64, u64)
group as the lengths value, one value for each dimension:
use Core.print
def main():
i32[,] plane = i32[10, 20](0);
(x, y) := plane.length;
print($"plane.length = ({x}, {y})\n");
This program will print this line to the console:
plane.length = (10, 20)
Iterating over Multidimensional Arrays
Now that we know how to access the lengths of a multi-dimensional array we also can iterate through the array. And for this very reason we need to discuss row-major vs column-major formats. In Flint, arrays are always stored in row-major format, but what does this mean and why is it important?
The array format describes how elements are layed out in memory. Multi-dimensional arrays are essentially "fake"... and in this small section you will also learn why the distinction between the str
type and the u8[]
type is important. Lets get started then...
Multi-dimensional arrays are "fake" because the values are still stored in one contiguous line in memory. Lets look at this easy example here to understand it better: a i32[,]
array where each dimensionality has the size of 3
. Below is a small table in which we give every element a unique ID, from top left to bottom right. We actually start counting at the top left at 0:
X0 | X1 | X2 | |
---|---|---|---|
Y0 | 0 | 1 | 2 |
Y1 | 3 | 4 | 5 |
Y2 | 6 | 7 | 8 |
What this table tells us is that arr[0, 2]
would be the value of 6
(x = 0, y = 2). Okay, so the difference between row-major and column-major is this one:
In Row-Major format, the array is stored in memory like this:
0 1 2 3 4 5 6 7 8
In Column-Major format, the array is stored in memory like this:
0 3 6 1 4 7 2 5 8
Note that the numbers that have been chosen do not matter at all, they are just to showcase how it works under the hood. For you, it actually doesn't really matter if it would be saved in row-major or column-major format, if you access arr[0, 2] you would get the same value (X0
, Y2
) for both formats, its just a matter of how it's saved to memory. But this very reason, how it is saved to memory, is really important for one and only one reason: performance.
You see, when we iterate over an array we can choose between those two methods:
use Core.print
def main():
i32[,] mat = i32[3, 3](0);
i32 n = 0;
// Row-major looping + fills the array
for i32 y = 0; y < 3; y++:
for i32 x = 0; x < 3; x++:
mat[x, y] = n;
print($"mat[{x}, {y}] = {mat[x, y]}\n");
n++;
// Print one empty line in between
print("\n");
// Column-major looping
for i32 x = 0; x < 3; x++:
for i32 y = 0; y < 3; y++:
print($"mat[{x}, {y}] = {mat[x, y]}\n");
This program will print these lines to the console:
mat[0, 0] = 0 mat[1, 0] = 1 mat[2, 0] = 2 mat[0, 1] = 3 mat[1, 1] = 4 mat[2, 1] = 5 mat[0, 2] = 6 mat[1, 2] = 7 mat[2, 2] = 8 mat[0, 0] = 0 mat[0, 1] = 3 mat[0, 2] = 6 mat[1, 0] = 1 mat[1, 1] = 4 mat[1, 2] = 7 mat[2, 0] = 2 mat[2, 1] = 5 mat[2, 2] = 8
As you can see, the two looping techniques directly correlate to the order the elements are stored in memory for the examples i have provided you with above. But what is more performant now? If you access the element at mat[1, 2]
you are actually accessing the 8th
element of the array (when starting to count at 1
here). Because in row-major format, when accessing the element at the third row (y is the row, x is the column) we first need to go through all elements of the two rows that came before it, which is 6
elements.
So, you may be able to see now that the index at which we would read memory from would constantly jump between positions when iterating through an array using column-major looping whereas when we loop through the array using row-major looping we go through all indices of the two-dimensional array one by one. This is called a sequentail operation and the other one is called a random operation in computer science. The CPU is much more performant with sequential operations than it is with random operations, as it is not as prone to cache-misses with sequential loads. If you want to read more about this topic, look here.
TLDR: The row-major loop properly utilizes the CPU cache and reduces cache-misses, making the operations much faster in return.
Before you wonder why i told you all of this, everything i talked about becomes important in the next chapter!
Enhanced for Loops
Enhanced for loops are for loops without explicitely declaring a range, but instead they directly operate on a so-called iterable. They are extremely useful for iterating through arrays, as enhanced for loops will always iterate through a multidimensional array sequentially.
Here is a small example:
use Core.print
def main():
i32[] arr = i32[5](0);
// First, fill the array with meaningful values
for i := 0; i < arr.length; i++:
arr[i] = i * 2;
// Iterate through the array element by element
for (idx, elem) in arr:
print($"Index {idx}, Value {elem}\n");
This program will print these lines to the console:
Index 0, Value 0 Index 1, Value 2 Index 2, Value 4 Index 3, Value 6 Index 4, Value 8
Okay, lets go through everything about the enhanced for loops one by one. You surely wonder what this (idx, elem)
is, and why it looks like a group, right? Well, because it is! When iterating through an iterable we always get the index as the first value of the iteration context (the group) and the element at that index as the second value of the iteration context.
Note that elem
is a mutable reference to the array element. So, writing elem = ...
is the same as if you would write arr[..] =
. Here is an example:
use Core.print
def main():
i32[] arr = i32[5](0);
// First, fill the array with meaningful values
for (idx, elem) in arr:
elem = idx * 2;
// Iterate through the array element by element
for (idx, elem) in arr:
print($"Index {idx}, Value {elem}\n");
This program will print these lines to the console:
Index 0, Value 0 Index 1, Value 2 Index 2, Value 4 Index 3, Value 6 Index 4, Value 8
As you can see, modifying elem
directly modifies the array at the current index inplace. This is extremely powerful for mutli-dimensional arrays, because yes, multidimensional arrays are considered iterables too!
Not using index or elem
We can opt out of using the index
or elem
variables for enhanced for loops entirely through the _
operator. Again, it is used in the context of unused
here. Here is a small example:
use Core.print
def main():
i32[] arr = i32[5](0);
// Just fill the array with stuff
for (idx, elem) in arr:
elem = idx * 2;
// Ignoring the index value in the enhanced for loop
for (_, elem) in arr:
print($"elem is {elem}\n");
// Ignoring the elem in the enhanced for loop
for (idx, _) in arr:
print($"Iteration {idx}\n");
This program will print these lines to the console:
elem is 0 elem is 2 elem is 4 elem is 6 elem is 8 Iteration 0 Iteration 1 Iteration 2 Iteration 3 Iteration 4
Iterating through Mutlidimensional Arrays
As multidimensional arrays are also considered iterables we can use the enhanced for loop on multi-dimensional arrays just like we did with the nested for loops:
use Core.print
def main():
i32[,] plane = i32[3, 3](0);
for (index, elem) in plane:
elem = i32(index);
for y := 0; y < 3; y++:
for x := 0; x < 3; x++:
print($"plane[{x}, {y}] = {plane[x, y]}\n");
This program will print these lines to the console:
plane[0, 0] = 0 plane[1, 0] = 1 plane[2, 0] = 2 plane[0, 1] = 3 plane[1, 1] = 4 plane[2, 1] = 5 plane[0, 2] = 6 plane[1, 2] = 7 plane[2, 2] = 8
As you can see, using the enhanced for loop for multi-dimensional arrays yields both the best performance, as we iterate sequentially through the whole array, we have an easy counter to see in which iteration we are and we can modify each element of the multi-dimensional array one by one. If you ever need to modify each element of the array without necessarily needing the positional information (x
and y
in our case) enhanced for loops are your friend!
Iteration Context
It was said earlier that the group with the index and the element is called the iteration context but what does this mean and why does it have a name? Its actually pretty simple: Because this context can be a tuple variable as well!
The index
of the iteration context is always a const
variable, while the elem
of it is always a mut
"variable" (its a reference). The iteration context as a tuple itself is const
, and the elem
field of it (the second field of the tuple) is not a mutable reference anymore, but its an immutable copy (for primitives) or an immutable reference (for complex data types) instead. This means that when using a tuple as the iteration context, we can no longer change the iterable directly through the elem
reference. Here is an example:
use Core.print
def main():
i32[] arr = i32[5](0);
// First, initialize the array, as always
for (idx, elem) in arr:
elem = idx * 2;
for ctx in arr:
print($"{ctx.$0}: {ctx.$1}\n");
This program will print these lines to the console:
0: 0 1: 2 2: 4 3: 6 4: 8
Enhanced for loop for strings
You can also iterate over a string using the enhanced for loop, just like you can for arrays. Here is an example of this in action:
use Core.print
def main():
str my_string = "something useful";
for (idx, elem) in my_string:
if idx == 2 or idx == 4:
// The difference between upcase and lowercase is 32
u8 tmp_elem = elem;
elem = tmp_elem - u8(32);
print($"my_string = '{my_string}'\n");
This program will print this line to the console:
my_string = 'soMeThing useful'
CLI Arguments
CLI Arguments stands for Command Line Interface Arguments. CLI arguments describe the act of passing in arguments to a program when executing said program, just like executing a function and passing in the arguments to the function. In fact, many programs can be thought of as "functions" in the Linux world, where you pass in data to a function and get something from it in return.
Here is an example of CLI arguments in action:
use Core.print
def main(str[] args):
for (idx, elem) in args:
print($"args[{idx}] = {elem}\n");
Note how the signature of the main function has changed. The main function is allowed to have a parameter of type str[]
or to have no parameter. All other cases will lead to an compile error.
This program will print different lines to the console, depending on how we execute it. If we execute the built program (for example the main
binary) with this command:
./main
we will see this line being printed to the console:
args[0] = ./main
If we execute the program like so:
./main someargument somethingelse third-thing
we will se theese lines being printed to the console instead:
args[0] = ./main args[1] = someargument args[2] = somethingelse args[3] = third-thing
As you can see, the first argument is always the command with which the program was executed with. If the built binary is in a subdirectory, for example, and you execute it with this command:
./somedirectory/main
you will see this line being printed to the console in this case:
args[0] = ./somedirectory/main
So, if you execute a program with the absolute path to said program, the first CLI argument will contain the command used to execute the program.
Imports
Up until now, we have always worked with one single file, and with a small file too. But a project that only contains a sinlge file is extremely limiting in what you can do with it and it quickly becomes very messy and confusing if you only have one very large file in which everything is defined in.
For this very reason, Flint supports multi-file projects. Unlike languages like C or C++, where you manually need to collect all your translation units when compiling, Flint provides a much clearer experience: You only specify the file which contains your main
function and flintc
will dynamically discover all included files, build its internal dependency tree and compile your project from this one and single entry point. So, you can compile your programs with the Flint compiler the same way you did until now, support for multi-file projects does not make compilation any harder for you.
The use
clausel
The use
clausel is a top-level definition, like a function definition for example, which provides a way to "import" code from other files (or modules). You have seen it quite a lot until now:
use Core.print
You have seen this line a lot of times and you have surely wondered what it means. But why do we call it a clausel and not a statement? In many other languages this inclusion of other code is known as the use-statement or include/import-statement, and they end with a semicolon. But, as you can see, the use
clausel does not end with a semicolon in Flint, but why is that?
A statement is a line of code thats written within a scope. Many languages see the empty space in which we define our functions as their top-level scope or file-level scope. This means that global variables, imports, function definitions etc are all defined at this global scope. But Flint is a bit different in this regard. We do not call it a use-statement because in Flint there is no global scope. You cannot define a variable outside a function and use it inside multiple different functions. There is no global state in Flint, and that's a deliberate design choice. This also means that the use-clausel is not a statement, so it cannot be written inside the body of a function itself (unlike C or C++, for example).
Example
But before moving on to any more complex topics, here is a small example of creating two files and compining them together:
This is the main.ft
file:
use "utils.ft"
use Core.print
def main():
i32 x = 5;
i32 y = 6;
i32 res = add(x, y);
print($"res = {res}\n");
This is the utils.ft
file:
def add(i32 x, i32 y) -> i32:
return x + y;
And you compile the program with the same command as usual:
flintc -f main.ft
When running the built program, this line will be printed to the console:
res = 11
As you can see, we have successfully called the function add
defined in the file utils.ft
from the main
function inside the main.ft
file, and the Flint compiler discovered the used file dynamically during compilation.
Circular Dependencies
You may have asked yourself already "What will happen if file A
imports file B
and file B
imports file A
again?". This is called a circular dependency. It's called circular, because the dependency graph forms a circle, where the "line of imports" ends up at its starting point. If you try to write import statements in C where every file imports another file you will get a compilation error, as circular dependencies are not allowed and cannot be resolved.
But Flint's use
clausels work quite different from the #import
from C-style languages. Whereas these literally just copy and paste the code from the other file, the use
clausel in Flint is a lot...smarter. The use
clausel only imports files at a depth of 1
, but what does this mean? Well, here is a small example to showcase what i mean with that:
The helper.ft
file:
def substract_and_mult(i32 x, i32 y) -> i32:
i32 diff = x - y;
return diff * 2;
The utils.ft
file:
use "helper.ft"
def some_operation(i32 x, i32 y) -> i32:
i32 res = substract_and_mult(x, y);
return res - (x + y);
The main.ft
file:
use "utils.ft"
use Core.print
def main():
i32 res = some_operation(44, 33);
print($"res = {res}\n");
When compiling this program, you will see this line printed to the console:
res = -55
In this example you can see how Flint has an importing depth of 1
, unlike many other languages. So, when you include utils.ft
in the main.ft
file you only gain access to the some_operation
function, but not to the substract_and_mult
function from the helper.ft
file. There is no recursive resolution of imports happening, meaning that every import in Flint is "shallow". If you would need the substract_and_mult
function within your main.ft
file you would need to write an explicit use "helper.ft"
clausel. This is absolutely intentional, because having only shallow inclusions we get something even better: circular inclusion support.
Circular dependencies are not considered a fault in Flint, at all. Often times you want to separate code on meaning, but the single files still need access to one another. In C-style languages you would solve this with forward-declarations, header files etc. But in Flint you just include any file you like, and it simply does not matter if a circle emerges or not, the Flint compiler will handle it all! Here is an example showcasing circular dependencies with a recursive function:
The utils.ft
file:
use "main.ft"
use Core.print
def recursive_count_utils(i32 x):
if x > 5:
print("utils end\n");
return;
print($"utils: {x}\n");
recursive_count_main(x + 1);
The main.ft
file:
use "utils.ft"
use Core.print
def recursive_count_main(i32 x):
if x > 5:
print("main end\n");
return;
print($"main: {x}\n");
recursive_count_utils(x + 1);
def main():
recursive_count_main(0);
print("\n");
recursive_count_utils(0);
This program will print these lines to the console:
main: 0 utils: 1 main: 2 utils: 3 main: 4 utils: 5 main end utils: 0 main: 1 utils: 2 main: 3 utils: 4 main: 5 utils end
As you can see, circular dependencies are absolutely no problem in Flint, and the only reason they are no problem is the dynamic exploratory nature of the compiler (you only specify one file and the compiler will find all included functions on its own) and the fact that the inclusion depth is only 1, so every use clausel is a shallow include.
Side note
Because the files main.ft
and utils.ft
form a circle in the last example, you actually also could compile the program with the command flintc -f utils.ft
and it would still work, as it would explore all files until it finds the main function. You can always think of file dependencies as a "tree". If, for example, file main.ft
includes file A
, which includes file B
and you specify file A
when compiling, you will get an error that no main function is defined, as the main.ft
function was no longer part of the tree. If, however, file A
or file B
include main.ft
, the compiler will be able to find the main file and main function again. Try it out and test a few file dependency trees and see for yourself how the compiler will react to it.
Import Aliasing
Import aliasing works, but is messed up.
Use Import aliasing with caution, its pretty messed up at the moment, so it would be best to avoid it for the current version of Flint.
Import aliasing is pretty useful if you have a lot of files in your project and if you have colliding definition names between your files, or imported libraries. For this very reason you can use import-aliasing, to make definitions from different files unambigue. Here is a small example of this:
The utils.ft
file:
use Core.print as p
def print(str msg):
p.print(msg + "\n");
The main.ft
file:
use "utils.ft"
def main():
print("Hello, World!");
This program will print this line to the console:
Hello, Word!
As you can see, any use
clausel can be aliased. The identifier after the as
keyword is the aliasing name, which you need to specify when you call the function. If you would remove the p.
in the utils.ft
file you would get a compile error.
Core Modules
Core Modules are essential to Flint, as Flint does not ship with a standard library. Core Modules provide core functionality which just cannot be implemented in pure Flint code, as Flint is a high level language. The general rule of thumb is that everything that can be implemented in pure Flint code will not be part of Core Modules. In Flint, we aim to provide libraries over on FlintHub and aim to make it as easy as possible to include FlintHub libraries. These libraries are the place where "standard" libraries can be found.
You have actually seen the core modules in action quite a lot until now: The use Core.print
line is a special use clausel which tells the compiler to include the print
Core module. There are several more core modules than just the print
module, though. In this chapter, you will learn which Core modules there exist, which functions they provide and how to use them.
use Core.print
The print
core module provides several print functions. Here are all the print functions this module provides. There exist a lot of builtin print overloads for the print function.
Parameter Types | Return Types | Can Throw? |
---|---|---|
str | void | No |
i32 | void | No |
i64 | void | No |
u32 | void | No |
u64 | void | No |
f32 | void | No |
f64 | void | No |
u8 | void | No |
bool | void | No |
Note that none of the print functions prints a new line after the print. This could be important when printing values in a loop, for example, because calling a "native" print function like print(i32)
is generally speaking faster than calling the print(str)
function with an interpolated string as argument, as string casting + concatenation takes more time than just calling the specialized print functions one after another. So, while string interpolation is much more ergonomic for the programmer, its is also a bit slower generally speaking.
The print(str)
function was used throughout this wiki until now. Every string interpolation evaluates to a string value, so this is the function we have called exclusively thus far, to make printing not as overwhelming.
read
use Core.read
The read
module provides several functions to read input from the command line and to read input from the user, like numbers or text edited by the user.
Function Name | Parameter Types | Return Types | Can Throw? |
---|---|---|---|
read_str | No | str | No |
read_i32 | No | i32 | Yes |
read_i64 | No | i64 | Yes |
read_u32 | No | u32 | Yes |
read_u64 | No | u64 | Yes |
read_f32 | No | f32 | Yes |
read_f64 | No | f64 | Yes |
read_str
The read_str
function has no parameters and returns a str
value. It is used to read a whole line from the console. Note that tis function cannot return an error, as there is no input parsing or input validation taking place.
use Core.print
use Core.read
def main():
str text = read_str();
print($"entered text: \"{text}\"\n");
read_i32
The read_i32
function has no parameters and returns a i32
value. It is used to read i32
values from the console. It can throw an error if the entered text is not parsable to an signed integer value.
use Core.print
use Core.read
def main():
i32 num = read_i32();
print($"entered i32: {num}\n");
read_i64
The read_i64
function has no parameters and returns a i64
value. It is used to read i64
values from the console. It can throw an error if the entered text is not parsable to an signed integer value.
use Core.print
use Core.read
def main():
i64 num = read_i64();
print($"entered i64: {num}\n");
read_u32
The read_u32
function has no parameters and returns a u32
value. It is used to read u32
values from the console. It can throw an error if the entered text is not parsable to an unsigned integer value.
use Core.print
use Core.read
def main():
u32 num = read_u32();
print($"entered u32: {num}\n");
read_u64
The read_u64
function has no parameters and returns a u64
value. It is used to read u64
values from the console. It can throw an error if the entered text is not parsable to an unsigned integer value.
use Core.print
use Core.read
def main():
u64 num = read_u64();
print($"entered u64: {num}\n");
read_f32
The read_f32
function has no parameters and returns a f32
value. It is used to read f32
values from the console. It can throw an error if the entered text is not parsable to an floating point value.
use Core.print
use Core.read
def main():
f32 num = read_f32();
print($"entered f32: {num}\n");
read_f64
The read_f64
function has no parameters and returns a f64
value. It is used to read f64
values from the console. It can throw an error if the entered text is not parsable to an floating point value.
use Core.print
use Core.read
def main():
f64 num = read_f64();
print($"entered f64: {num}\n");
assert
use Core.assert
The assert
module provides a single assert
function, which returns an error if the given condition evaluates to false. It is used for code-assertions and to fail loud and clear.
use Core.assert
def main():
i32 x = 5;
assert(x > 6);
When executing this program you will see this error message printed to the console:
ERROR: Program exited with exit code '10'
This error message is not final.
The whole error system of Flint is not yet finished, as the errors are all i32
values at the moment still, and error sets
are not supported yet. You will see an error message like
ERROR: Assertion 'x > 6' failed at main.ft:5:5
but this needs a lot more work still to be finished.
filesystem
use Core.filesystem
The filesystem
module provides several functions to read data from and write data to files.
Function Name | Parameter Types | Return Types | Can Throw? |
---|---|---|---|
read_file | str | str | Yes |
read_lines | str | str[] | Yes |
file_exists | str | bool | No |
write_file | str , str | No | Yes |
append_file | str , str | No | Yes |
is_file | str | bool | No |
read_file
The read_file
function takes a str
parameter, which is the path to the file that wants to be read and returns a str
value, containing the content of the given file. This function throws an error if the file does not exist or is not readable.
use Core.print
use Core.filesystem
def main(str[] args):
if args.length < 2:
print("No path provided as a cli argument! Exiting...\n");
return;
str file_content = read_file(args[1]);
print($"Read file '{args[1]}':\n");
print(file_content);
print("\n");
When executing this program with the command ./main main.ft
we get this output:
Read file 'main.ft': use Core.print use Core.filesystem def main(str[] args): if args.length < 2: print("No path provided as a cli argument! Exiting...\n"); return; str file_content = read_file(args[1]); print($"Read file '{args[1]}':\n"); print(file_content); print("\n");
read_lines
The read_lines
function reads a given file (the str
path to the file) and returns an array of all read lines (str[]
). This function is really useful for reading a file and iterating through each line after reading the file. This function throws an error if the file does not exist or is not readable.
use Core.print
use Core.filesystem
def main(str[] args):
if args.length < 2:
print("No path provided as a cli argument! Exiting...\n");
return;
str[] lines = read_lines(args[1]);
print($"Read file '{args[1]}':\n");
for (idx, line) in lines:
print($"{idx}:\t| {line}\n");
When executing this program with the command ./main main.ft
we get this output:
Read file 'main.ft': 0: | use Core.print 1: | use Core.filesystem 2: | 3: | def main(str[] args): 4: | if args.length < 2: 5: | print("No path provided as a cli argument! Exiting...\n"); 6: | return; 7: | 8: | str[] lines = read_lines(args[1]); 9: | print($"Read file '{args[1]}':\n"); 10: | for (idx, line) in lines: 11: | print($"{idx}:\t| {line}\n");
file_exists
The file_exists
function checks whether the given file (str
path to the file) exists. This function cannot crash, as it checks for a file's existence, so when it does not exist or is not readable, it just returns false
.
use Core.print
use Core.filesystem
def main(str[] args):
if args.length < 2:
print("No path provided as a cli argument! Exiting...\n");
return;
bool exists = file_exists(args[1]);
print($"Does file '{args[1]}' exist? {exists}\n");
When executing this program with the command ./main main.ft
we get this output:
Does file 'main.ft' exist? true
write_file
The write_file
function takes two arguments. The first argument is the path to the file to write to (or create) as a str
path. The second parameter is the content of the to-be-written file (str
). This function will create a file at the given path if the file does not exist yet. If the file exists, this function just overwrites it. This function will throw an error if the given file coould not be opened or could not be written to (for example a permission error).
use Core.print
use Core.filesystem
def main():
write_file("test_file", "Test File content\nThis is going to be great!");
str file = read_file("test_file");
print($"test_file content:\n{file}\n");
This program will print these lines to the console:
test_file content: Test File content This is going to be great!
append_file
The append_file
function will try to append text to an already existent file. The first parameter of the function is the path to the file the new content is appended (str
path). The second parameter is the content which will be appended to the file (str
). This function will throw an error if the given file does not exist or could not be opened with write access.
use Core.print
use Core.filesystem
def main():
write_file("test_file", "Test File content\nThis is going to be great!");
append_file("test_file", "\n\nThis is written with one space in between!");
str file = read_file("test_file");
print($"test_file content:\n{file}\n");
This program will print these lines to the console:
test_file content: Test File content This is going to be great! This is written with one space in between!
is_file
The is_file
function checks whether the file at the given path (str
) even is a file. It will return false
in the case that the file / directory does not exist. It will also return false if the given "file" is actually a directory. This function cannot throw any errors.
use Core.print
use Core.filesystem
def main():
print($"is 'test_file' a file? {is_file("test_file")}\n");
print($"is 'somegarbage' a file? {is_file("somegarbage")}\n");
This program will print these lines to the console:
is 'test_file' a file? true is 'somegarbage' a file? false
env
use Core.env
The env
module provides several functions to read from and write to environment variables.
Function Name | Parameter Types | Return Types | Can Throw? |
---|---|---|---|
get_env | str | str | Yes |
set_env | str , str , bool | bool | No |
get_env
The get_env
function recieves the currently stored value of a given environment variable (str
). The content of the environment variable is returned as a str
. This function will throw an error if the requested environment variable does not exist.
use Core.print
use Core.env
def main():
print($"HOME = {get_env("HOME")}\n");
This program will print this line to the console:
HOME = /home/zweiler1
set_env
The set_env
function sets a given environment variable (str
) to a newly specified value (str
). The third parameter (bool
) controls whether the given environment variable should be overwritten if it already exists. If the third parameter is false
an already existent environment variable wont be overwritten. This function cannot throw any errors.
use Core.print
use Core.env
def main():
bool overwrite_home = set_env("HOME", "something new", false);
print($"HOME overwritten? {overwrite_home}\n");
print($"HOME value: {get_env("HOME")}\n");
bool create_new = set_env("NEW_ENV_VARIABLE", "some nice value", false);
print($"NEW_ENV_VARIABLE craeted? {create_new}\n");
print($"NEW_ENV_VARIABLE content: {get_env("NEW_ENV_VARIABLE")}\n");
This program will print these lines to the console:
HOME overwritten? true HOME value: /home/zweiler1 NEW_ENV_VARIABLE craeted? true NEW_ENV_VARIABLE content: some nice value
system
use Core.system
The system
module provides functions to interact with the system, for example to execute system commands.
Function Name | Parameter Types | Return Types | Can Throw? |
---|---|---|---|
system_command | str | i32 , str | Yes |
system_command
The system_command
function executes a given command, for example ls -lah
and returns the exit code of the given command together with the output of the command, stored in a string. The function can throw an error if the process (the command) cannot be created.
use Core.print
use Core.system
def main():
(exit_code, output) = system_command("ls");
print($"exit_code = {exit_code}\n");
print($"output = '{output}'\n");
This program will print these lines to the console:
exit_code = 0 output = 'build build.zig build.zig.zon cmake CMakeLists.txt compile_flags.txt documents examples fetch_crt.sh include LICENSE logfile lsp main main.o main.obj output.ll README.md resources scripts src test test_files test.o tests vendor '
Error Sets
Everything regarding error handling will definitely change for later releases.
The current implementation of everything error-handling related is not final for Flint, and it will change quite a lot. This segment of the guide will be updated when the new system works and is in place. For the time being, the description of how it currently works in FLint is written here. So, expect substantial re-writes of this chapter for future releases.
Flint's error system is quite unique. Every function can fail. Absolutely every single function a user defines in his code can fail. Because every function can fail, Flint can deeply integrate error handling into the language. A function returning a str
value, for example, actually returns a (i32, str)
value. The first implicit return type of any Flint function is the error value of said function. This error value, however, is completely hidden from the user outside of Flint's error handling syntax.
Flint has two keywords for error handling: throw
and catch
. But, unlike Java or C++, where the error handling happens outside the normal execution path ("happy path" / "unhappy path") which completely breaks execution consistency and results in lots of context switching for the CPU, Flint has it's error handling system built directly into the calling / returning code of every function, which makes it much faster than traditional exception-based error handling.
Throwing an error
Throwing an error is actually really simple. Here is a small examlpe:
use Core.print
def throw_if_bigger_than(i32 x, i32 y):
if x > y:
// We can only throw i32 values at the moment
throw 69;
def main():
throw_if_bigger_than(10, 5);
This program will print this line to the console:
ERROR: Program exited with exit code '69'
You have already seen this ERROR:
message before, actually, at the assert
core module. Even the main
function returns an implicit i32
error value. And if the main function returns an error value, the error value gets printed to the console like seen above. Because every function can throw an error, every function automatically propagates its returned error up the stack, if we dont handle the error. This is the reason why the call throw_if_bigger_than
let the error bubble up to the main function.
Catching an error
Catching an error is a bit more complicated.
use Core.print
def err_div(i32 x, i32 y) -> i32:
if y == 0:
throw 69;
return x / y;
def main():
i32 result = err_div(10, 0) catch err:
print($"err = {err}\n");
print($"result = {result}\n");
This program will print these linese to the console:
err = 69 result = 0
As you can see, if we catch
an error explicitely, the first implicit return value of the function is stored inside the err
variable. It is now up to us to handle the error. Because the error is just an i32
value, the err
variable is also of type i32
here. We can just compare it to values, and for some values we can set the result
value, or for another value we could manually re-throw the error with a throw
statement.
Because there does not exist such thing as "uninitialized state" in Flint, the result
variable is set to its default value in the case of an error. For the type i32
this default value is 0
. For the str
type the default value would be an empty string, a string of size 0, ""
.
We can also change the result
variable within the catch block:
use Core.print
def err_div(i32 x, i32 y) -> i32:
if y == 0:
throw 69;
return x / y;
def main():
i32 result = err_div(10, 0) catch err:
print($"err = {err}\n");
result = 3;
print($"result = {result}\n");
This program will print these lines to the console:
err = 69 result = 3
But that's basically it. That's all you need to know about Flint's current error handling features!