Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Single-line comments: Start with // and continue until the end of the line.
  2. 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:

  1. i32 Represents whole numbers, both positive and negative. Use i32 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
  1. u32 Represents whole numbers, but only positive ones. Use u32 for IDs.
u32 id_1 = 1;
u32 id_0 = 0:
  1. f32 Represents floating-point numbers (decimal numbers). Use f32 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
  1. str Represents a sequence of characters or text, as already described in the printing chapter. Use str 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:

TypeDescriptionMinMaxPrecision
u8unsigned 8 bit integer0255Whole numbers
u32unsigned 32 bit integer04,294,967,295Whole numbers
u64unsigned 64 bit integer01.844 × 10^19Whole numbers
i32signed 32 bit integer-2,147,486,6482,147,486,647Whole numbers
i64signed 64 bit integer-1.844 × 10^191.844 × 10^19Whole numbers
f3232 bit floating point number±1.175 × 10^-38±1.701 × 10^38≈ 6 - 9 digits
f6464 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:

  1. The type of each argument matters. For example, if a and b are declared as i32, you cannot pass values of any other type, like f32 or u32.
  2. 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)
  1. Default values simplify initialization but are optional.
  2. 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:

TypeElement TypeVector Size"Field" Names
i32x2i322x, y
i32x3i323x, y, z
i32x4i324r, g, b, a
i32x8i328$N
i64x2i642x, y
i64x3i643x, y, z
i64x4i644r, g, b, a
f32x2f322x, y
f32x3f323x, y, z
f32x4f324r, g, b, a
f32x8f328$N
f64x2f642x, y
f64x3f643x, y, z
f64x4f644r, g, b, a
bool8bool8$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:

X0X1X2
Y0012
Y1345
Y2678

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.

print

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 TypesReturn TypesCan Throw?
strvoidNo
i32voidNo
i64voidNo
u32voidNo
u64voidNo
f32voidNo
f64voidNo
u8voidNo
boolvoidNo

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 NameParameter TypesReturn TypesCan Throw?
read_strNostrNo
read_i32Noi32Yes
read_i64Noi64Yes
read_u32Nou32Yes
read_u64Nou64Yes
read_f32Nof32Yes
read_f64Nof64Yes

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 NameParameter TypesReturn TypesCan Throw?
read_filestrstrYes
read_linesstrstr[]Yes
file_existsstrboolNo
write_filestr, strNoYes
append_filestr, strNoYes
is_filestrboolNo

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 NameParameter TypesReturn TypesCan Throw?
get_envstrstrYes
set_envstr, str, boolboolNo

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 NameParameter TypesReturn TypesCan Throw?
system_commandstri32, strYes

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!