-
Notifications
You must be signed in to change notification settings - Fork 0
Syntax
- General file requirements
- Basic language constructs
- Advanced Language Constructs
- Libraries
// Declare which namespace this file belongs to
namespace org.flint;
// Simple import
use org.flint.util.ArrayList;
// Import everything from a namespace
use org.flint.util.*;
// Import specific functions
use save from de.leghast.io.SaveManager;
// Import from local paths
use "./DirectoryManager.flint";
// This is a single line comment
/*
This is a mutli line comment
*/
To document you code, e.g. what a method is doing, you can use a YAML inspired structure to do so.
/**
Adds two numbers together
Params:
- x: The first number to add
- y: The second number to add
Returns:
- int: The sum of `a` and `b`
Author:
- Julius Grünberg
*/
def add(int x, y) -> int:
return x + y;
Declaring a variable is pretty straight forward: You can either explicitly declare it with a type or use type inferring. You also don't have to initialise the variable straight away.
It is also possible to declare several variables in one row, explicit and implicit. When using this feature explicitly you can only declare variables of the same type. This does not apply to the implicit version.
// Type explicit declaration
int i = 4;
// Type implicit declaration (type inferral)
i := 4;
// Multiple explicit declarations in one line. This is only possible whilst using one single type
int i, j, k = 1, 2, 3;
// Multiple implicit declarations in one line.
i, j, k := 1, "Test", 3.14;
When assigning a value to a variable you make the variable the owner of the value. If you assign the variable to another variable you change the owner of the variable. The first variable will now no longer have a value. You can borrow references to this value to methods.
int i = 4; // i is set to 4
int j = i; // j is set to the value of i; i is now null and j is 4
It is possible to swap two variables with just a single operation. Note that the values of two variables can only be swapped when the variables have the same type.
int i = 4;
int j = 7;
i <-> j;
Flint supports relational operators, like every modern programing language. Unlike most programing laguages, the flint
data type, which is more or less a float
data type, supports the ==
and !=
operators, and they work as expected.
print(1 == 0); // prints 'false'
print(1 != 0); // prints 'true'
print(1 > 0); // prints 'true'
print(1 >= 0); // prints 'true;
print(1 < 0); // prints 'false'
print(1 <= 0); // prints 'false'
print(0.1 == 0.2); // prints 'false'
print(0.1 != 0.2); // prints 'true'
print(0.1 > 0.2); // prints 'false'
print(0.1 > 0.1); // prints 'false'
print(0.1 >= 0.2); // prints 'false'
print(0.1 >= 0.1); // prints 'false'
print(0.1 < 0.2); // prints 'true'
print(0.1 <= 0.1); // prints 'false'
print(0.1 <= 0.2); // prints 'true'
print(0.1 <= 0.1); // prints 'false'
print(0.1 + 0.2 == 0.3); // prints 'true'
print("ABC" == "BCD"); // prints 'false'
print("ABC" == "ABC"); // prints 'true'
print("ABC" != "BCD"); // prints 'true'
print("ABC" != "ABC"); // prints 'false'
print(true == 1); // prints 'idk man'
print(true != 1); // prints 'idk man'
print(true < 1); // prints 'idk man'
print(true > 1); // prints 'idk man'
Flint supports the following boolean operators:
print(true and false); // prints 'false'
print(true or false); // prints 'true'
print(true and not false); // prints 'true'
print(true xor false); // prints 'true'
print(true nand false); // prints 'true'
print(true nor false); // prints 'false'
print(true xnor false); // prints 'false'
You can use if-statements to control the flow of you program. Each keyword should be followed by a code block, that is executed, when the condition is met.
int x = 4;
bool isFour = false;
// If-statement using code blocks
if x == 4:
print("x is 4");
isFour = true;
else:
print("x is not 4");
isFour = false;
// If-statement using one line of code
if x == 4: print("x is 4");
else if x == 5: print("x is 5");
else: print("x is not 4");
isFour = x == 4;
You can use the if statement to check, if a variable is null.
if val:
print("Values is not null");
else:
print("Value is null");
The variable var can be of any type. If val == null
, the else part will be printed, if
val != null
the if part will be printed. This comes in very handy for evaluating errors.
Note that null
is not part of the boolean but rather the if syntax itself.
Loops are a great way to iterate over items and execute code with them. In a while loop the condition is checked before executing the code and executes the block while the condition is true. A do-until loop checks the condition after executing the code and executes the block until the condition is met(true).
x := 8;
while x == 8:
// some code
do:
// some code
until x == 8;
for i := 0; i < 10; i++: ...
for ([INITIALISATION], [CONDITION], [ACTION]): [BODY]
for index, element in iterable: ...
for _, element in iterable: ...
for [<VAR_NAME_IDX> | '_'], [<VAR_NAME_ELEM> | '_'] in [ITERABLE]: [BODY]
The _
declares an unused value. Normally, with the enhanced for loop, bot the index and the iterated element are accessible within the for loops body. When writing a underscore one or both elements can be declared as unused.
def [VISIBILITY] <NAME>([INPUT VARIABLES]*) -> [(]?[OUTPUT VARIABLES]*[)]? : [BODY]
Every function takes 0-n input variables and returns 0-m output variables. There can be
thrown errors like one would expect but when the error gets catched with the catch
keyword the error gets saved into a variable.
The input parameters are set immutable by default, so you can't override, change or reassign these variables.
def add(int x, y) -> int:
return x + y;
result := add(1, 2);
print(result) // This will print '3'
def printAll(str[] inputs):
for _, input in inputs:
print(input);
str[] inputs = { "Hello, ", "World", "!" };
printAll(inputs);
// Outputs 'Hello, World!'
def getEverything() -> (int, str, flint):
return 3, "super", 4.12;
(x, y, z) := getEverything();
printAll(x, y, z);
// Outputs '3super4.12'
def extractVectorData(Vector3 vector) -> (flint, flint, flint, flint):
eulerX := sin(vector.z / sqrt(pow(vector.y, 2) + pow(vector.z, 2)));
eulerY := sin(vector.z / sqrt(pow(vector.x, 2) + pow(vector.z, 2)));
eulerZ := sin(vector.x / sqrt(pow(vector.y, 2) + pow(vector.z, 2)));
magnitude := sqrt(pow(vector.x, 2) + pow(vector.y, 2) + pow(vector.z, 2));
return (eulerX, eulerY, eulerZ, magnitude);
Vector3 vector = Vector3(1, 2, 3);
(eX, eY, eZ, magnitude) := extractVectorData(vector);
print(vector);
print(eX);
print(eY);
print(eZ);
print(magnitude);
// The result of a function can be ignored, too
(_, _, _, mag) := extractedVectorData(vector);
print(mag);
In this case this ignoring of return types is not very useful, but when using
a for loop, the iterator returns the element
as well as the index
, which
are the both return types of the "iterator-function", and they can be ignored
too.
def divide(flint f1, f2) -> flint:
return f1 / f2;
flint result = divide(2, 0);
print(result);
// will throw an error 'Division through 0 not allowed'
def divide(flint f1, f2) -> flint:
return f1 / f2;
flint result = divide(2, 0) catch err;
if err:
printWarning("Tried to divide through 0, this is generally a bad idea...");
print(result);
// This will print 'Tried to divide through 0, this is generally a bad idea...' and 'null' without an error being thrown
def divide(flint f1, f2) -> flint:
return f1 / f2;
flint result = divide(2, 0) catch err:
printWarning("Tried to divide by 0, this is not allowed!");
print(result);
// This will print the error message and 'null' without an error being thrown
def divide(flint f1, f2) -> flint:
if f2 == 0:
throw(ZERO_DIVISION, "You shall not divide by zero");
return f1 / f2;
flint result = divide(2, 0) catch err;
if err:
print(err.message);
print(result);
// This will print 'You shall not divide by zero' and 'null' directly after it
def divide(flint f1, f2) -> flint:
if f2 == 0:
print("Dividing by 0 is generally a bad idea. Please don't.");
return 0; //This will abort the function, but not return any error
return f1 / f2;
flint result = divide(2, 0);
print(result);
// This will print 'Dividing by 0 is generally a bad idea. Please don't.' and 'null' without an error being thrown
To make use of the ability of errors to be variables, which do not break the flow of operation, i had an idea for exceptions.
What if a function can throw errors by the programer and every possible thrown error has to be handled in order to be able to compile the program.
This would look like the following:
#throws NULL_DIVISION, NaN
def divide(flint f1, f2) -> flint:
if f2 == 0:
throw(NULL_DIVISION, "text");
if f1 == NaN or f2 == NaN:
throw(NaN, " text");
return f1 / f2;
flint result = divide(1, 0) catch err:
NULL_DIVISION:
printerr(err.message);
NaN:
result = 0;
The occured error will be something like an enum, where the enum, lets call it ErrEnum
will have the values NULL_DIVISION
and NaN
and every error which could occur has to ve dealt with. When the error is not defined in the functions signature and/or not explicitly thrown inside the method, and the error is not part of the ErrEnum
, it will just be re-thrown creating a stack trace.
Below is a second, a bit different approach to error handling, which does rel, on the builtin throwing abilities of the language.
#throws NULL_DIVISION
def divide(flint f1, f2) -> flint:
return f1 / f2;
result := divide(1, 0) catch err:
NULL_DIVISION:
printWarn("Tried to divide by zero. 'result' will now be infinite.");
result = err.lhs > 0 ? INF : -INF;
In this case, the error of type NULL_DIVISION
holds the data of the left hand side of the division. The right side is obviously zero. This data can be accessed via the error varible.
Another way of habdling errors is to just...don't. It may seem silly at first glance, but it just is another way of thinking about errors. When, for example, not an error gets thrown when we divide by zero, but instead assert that the function divide will never recieve a zero value divisor the program structure alters.
def divide(flint f1, f2) -> flint:
assertArg(f2, f2 != 0, "Called 'divide' with a divisor of zero!");
return f1 / f2;
result := divide(1, 0);
This will not throw an NULL_DIVISION error because it never gets to the part where the line executes. Instead it throws an assertion error, which does not mean "something out of your control went wrong" but means "you fucked up because this function should not have been called with these parameter values in the first place!", which is very different.
In the end, i think we will have to implement both ways of doing it. But often times, like in this example, assertions are more beneficial than throwing errors.
With assertions, a syntax error can be shown whenever the programmer tries to call a method with values which violate the assertions, making it "impossible" to call the function with wrong values. Of course, calling the function with variables as arguments could still violate the assertions.
The possibilities and ideas expored so far can be condensed into a unified system. First of all, a way has to be found where custom errors
can be defined which can be used as possible return types of a function. It has been said, that a error is saved in an variable, thus an error is a type of return value (data) on itself.
The best approach to errors is to see them as enumerators, or unique identifiers. I do, however, like the approach that Zig is taking to errors. An error of its own is just a "primitive" data type, and custom errors can be a set of given errors.
It is also important to think about how error types fit in the whole DCP side of things and how they could benefit from or be a benefit to it. Similar to the keywords data
, func
and entity
, a new keyword to declare an error
could be made.
error BaseError:
OutOfMemory, PermissionDenied, InvalidArgument;
error NetworkError(BaseError):
ConnectionTimeout, HostUnreachable, BadResponse;
error HttpError(NetworkError):
NotFound, Unauthorized, BadRequest;
As seen, a error consists of a given set of unique identifiers. Each error can "inherit" (= include) other errors. This means, that HttpError
is an extension of the NetworkError
set. The deeper the nesting of errors go, the "more" errors are available. A error can, however, include more than one error set. Because every new created error is a superset of the included errors, as many errors as wanted can be included.
error AlgebraicError:
NullDivision, NaN, UndefinedReference;
#throws AlgebraicError // Can throw all possible Errors from Above
def divide(flint f1, f2) -> flint:
if f2 == 0:
throw(AlgebraicError::NullDivision, "text");
if f1 == NaN or f2 == NaN:
throw(NaN, " text");
return f1 / f2;
However, the syntax for actually "catching" the errors will have to be altered a bit. Instead of direclty enforcing a switch statement after the catch statement like shown in the chapter zig-like exceptions, some changes have to be made.
First of all, it is now possible to check if a given error is part of a given subset of errors with the is
keyword. Unlike with inheritance, which is not built into Flint, the is
keyword is strictly used for errors and enums to check if a given error is inside the subset in the right hand side:
( [ specific error | enum ] is [ error_set | enum_set ] ) -> bool
Because even a single error is a set, just only containing a single element, but still a set nonetherless, the is
keyword can be used to check if a error is inside a given set of errors, or even if an error is a specific error. A statement like NullException is NaN
is syntactically correct, alltough always returning false
. It is intended to be used with variables, for example
if err is NullException:
print("is NullException");
switch(err):
NullException:
print("Is NullException");
NaN:
print("Is NaN");
_:
print("Is something Else");
With this new tool and knowledge solidified, the function divide
defined above, can now be called in multiple ways:
/* Saves the result in the error, which is of type 'AlgebraicError' but does
not do anything with the error. If nothing is done with it, the lsp will
recognize it as an unhandled error. A error variable has to be dealt with at some point in the code. */
flint result = divide(1, 0) catch err;
if err:
// Do something with the error
/* The err variable is only visible in the block after it, it is it's scope.
A switch statement could be placed here, for example, but this would
result in an additional indentation step, which is bad. This block is
meant for handling the error the same way regardless of which error it
is. */
flint result = divide(1, 0) catch err:
// _always_ true because the function only returns an 'AlgebraicError'
if err is AlgebraicError:
// Not always true
if err is AlgebraicError::NullException:
result = Flint.INF;
else:
result = 0;
/* The last possibility to handle an error, is a switch statement. Because
the syntax 'catch switch(err):' is very unpleasent, a new keyword,
catch_all, can be implemented. This keyword expands the code effectively
from:
catch_all err:
branches...
to:
catch err:
switch(err):
branches...
and with the 'all' indicates, that every possible error type has to be
handled. The '_' operator for the default branch can be used, of course.*/
flint result = divide(1, 0) catch_all err:
NullDivision:
print("A null division occured. Result is INF");
result = Flint.INF;
NaN:
print("A NaN error occured");
UndefinedReference:
print("Some undefined Reference occured");
// or
flint result = divide(1, 0) catch_all err:
NullDivision:
print("A null division occured. Result is INF");
result = Flint.INF;
_:
print("Some error occured");
When multiple errors can be returned by the function not every possible error inside the union set of all possible errors has to be handled seperately.
error ErrorSet1:
HardCrash, SoftCrash, DamnItCrashed;
error ErrorSet2:
FatalCrash, BrickedThePcLol, Bsod;
#throws ErrorSet1::DamnItCrashed, ErrorSet2::Bsod
def throwAnErrorFfs(bool set1or2):
if set1or2:
throw(ErrorSet1::DamnItCrashed, "Crash 1");
else:
throw(ErrorSet2::Bsod, "Crash 2");
throwAnErrorFfs(false) catch_all err:
ErrorSet1:
print("Error from set 1 occured");
ErrorSet2:
print("Error from set 2 occured");
This system allows errors to be bundled together, extended and used much like enums.
Enums work more or less exactly like errors, syntactically, but a variable can be of this enums type. Also, enums can be subsets of other enums.
enum MyGreatEnum:
VAL1, VAL2, VAL3;
enum MyEnumSuperset(MyGreatEnum):
VAL4, VAL5;
def isEnumOk(MyGreatEnum my_enum) -> bool:
return switch(my_enum):
VAL1 -> true;
VAL2 -> false;
VAL3 -> true;
/* DISCUSSION:
Should there be all the enum values from the first enum be inside the set,
so MyEnumSuperset = { VAL1, VAL2, VAL3, VAL4, VAL5 } or should there be a
"subset" of MyGreatEnum inside MyEnumSuperset, so MES = { MGE, VAL4, VAL5 }?
I honestly do _not_ know which of both is better, but my first guess would
be that the first one seems more natural to me. With errors, the second one
makes more sense, but with enums i would say that the first one should be
considered ok. I have both implementations below*/
def isSuperEnumOk(MyEnumSuperset my_enum) -> bool:
return switch(my_enum):
MyGreatEnum -> false;
VAL4 -> true;
VAL5 -> false;
def isSuperEnumOk(MyEnumSuperset my_enum) -> bool:
return switch(my_enum):
MyGreatEnum -> switch(my_enum as MyGreatEnum):
VAL1 -> false;
VAL2 -> true;
VAL3 -> false;
VAL4 -> false;
VAL5 -> true;
def isSuperEnumOk(MyEnumSuperset my_enum) -> bool:
return switch(my_enum):
VAL1 -> false;
VAL2 -> true;
VAL3 -> false;
VAL4 -> false;
VAL5 -> true;
An EnumMap
is just that: A map where all keys have to be of a given enum and the values type has to be a primitive type. By constraining the possibilities for the key, the map has a fixed size, thus a fixed size in memory.
enum ExampleEnum:
VAL1, VAL2, VAL3;
def main():
val := ExampleEnum.VAL2;
EnumMap<ExampleEnum, flint> enumMap;
enumMap[val] = 10.0;
enumMap[ExampleEnum.VAL3] = 2.0;
print(enumMap); // Prints '[ VAL1: 10.0, VAL2: 0.0, VAL3: 2.0 ]'
It is unsure if the viable data types for the values of the map should be extendd to also support data or even entity types.
Type casting is easily handled via the builtin cast-functions.
flint number = flint("1.9");
int intNumber = int(number);
str string = str(intNumber);
bool boolean = bool(str);
print(number); // Prints '1.9'
print(intNumber); // Prints '1'
print(string); // Prints '1'
print(boolean); // Prints 'true'
We want to create a Object-oriented programming language, which is especially easy to use. We have to think clearly about what will set us apart from the rest. Because we want to make a "true" OOP Language, classes will most proably be needed. But, first try to reach a complete OOP Style only with the most easy constructs, like structs. And i have another idea which i want to explore here...
First things first. OOP is Object Oriented Programming, where the main focus lies on often big classes which work together as "real" world objects do. DOP is "Data Oriented Programming" and relies on small data components and small functional components acting like modules, where your typical "class" is nothing more than a collection of modules. DOP makes the use of multithreading much easier and is way more performant than OOP in data-heavy tasks because the data is not spread around in memory but rather in "data clusters" inside the memory.
Because DOP in general is a quite hard topic to grasp and OOP is rather simple to understand, it is tried to find a middle ground solution which is both easy enough for newcomers to use like OOP but still teaches the basics of DOP with its benefits. It does not need to be a "complete" DOP slution, but something in the same direction.
If you combine Object Oriented Programming and Data Oriented Programming and form a hybrid,
you get DCP
, Data Centric Programming
.
/**
Data constructs. Data constructs have to be instantiated and they have to be
filled with data when doing so. The Constructor has no body, but the name of
the variables is given to define the argument sequence. The given argument
is saved to the field with the same name.
*/
data Vocal:
str sound;
Vocal(sound);
data Fur:
flint fluffyness;
bool waterRepellent;
Fur(fluffyness, waterRepellent);
data Claw:
int clawCount;
int damage;
int speed;
Claw(clawCount, daamage, speed);
data Wing:
bool hasFeathers;
Wing(hasFeathers);
/**
Functional constructs which work with and only with the declared data class.
These cconstructs "do" something with the data.
QUESTION: Can / Should there be func constructs which doo not rely on data? I believe so, this
could be math constructs for exaple. But when they do not rely on data they are independant
from anything except their arguments... does this mean these func constructs automatically
become utility constructs (e.g. final constructs)? Huh, this can be nice...
*/
func Grate requires(Claw claw):
def grate():
print($"Makes {claw.damage} damage");
func Roar requires(Vocal vocal):
def quietRoar():
print(vocal.sound);
func Fly requires(Wing wing):
def makeABarrelRoll():
print("Barrel roll whee");
def hasFeathers() -> bool:
return wing.hasFeathers;
func Stink requires(Fur fur):
def setFluffyness(flint flufflyness):
fur.fluffyness = fluffyness;
/**
The entity construct declares which components it consists of, it is a collection of functionality.
With the functionality comes the data. If you want to be able to Grate,
for example you have to include Claw as a data object too.
*/
entity Cat:
data:
/* You can use data without using the associated functionality
but why would you do that...is there a way this can be made useful? TODO*/
Claw, Fur, Vocal;
func:
Grate, Roar;
Cat(Claw, Fur, Vocal);
entity Dog:
data:
Fur, Vocal;
func:
Stink, Roar;
Dog(Fur, Vocal);
entity Eagle:
data
Wing, Claw, Vocal;
func:
Fly, Grate, Roar;
Eagle(Wing, Claw, Vocal);
/**
MAIN METHOD
*/
def main():
// To create an entity construct, its data has to be created beforehand before passing it in.
cat := Cat(Claw(10, 4, 8), Fur(6.9, false), Vocal("meow"));
dog := Dog(Fur(0.3, true), Vocal("wuff"));
eagle := Eagle(Wing(true), Claw(8, 16, 10), Vocal("rääääh"));
// The idea from point 6. A func could be used as an Interface, enabling many items to
// be saved at a single variable type
Roar roar = cat;
roar.quietRoar(); // prints "meow"
roar = dog;
roar.quietRoar(); // prints "wuff"
roar = eagle;
roar.quietRoar(); // prints "rääääh"
// Both Cats and Eagles have Claws, which means they can be stored in the same variable too
Grate grate = cat;
grate.grate(); // prints "Makes 4 damage"
grate = eagle;
grate.grate(); // prints "Makes 16 damage"
// The "normal" function calls work too, as expected
cat.grate();
eagle.makeABarrelRoll();
print(eagle.hasFeathers()); // prints 'true'
dog.setFluffylness(9000);
/**
data can be used without an entity. This removes every possible functionality from the
data, making it effectively to something like a struct. When using it this way all
fields should be visible from its 'data' construct should be easily distiguishable because
you simply cannot access the data of an entity directly. The data is only stored in the
data constructs of the entity, making it only accessible by its functions. When using the
data directly there is no functionality so you can use it directly. This is a gread
differential factor...
*/
Fur fur = Fur(0.5, false);
fur.fluffyness = 3;
fur.waterRepellent = true;
print(fur.fluffyness); // prints '3'
Alltough this approach seems very messy at first glance it could provide many of the DOP benefits while still working a lot like OOP. For example, the "Cat" entity is what in OOP would be a "class". The data constructs are the "class members". If you use data Wing, Claw and Vocal it could be seen as if you would use an Object of that type, being Wing, Claw or Vocal. The functionalty declared with the func constructs would be part of the spreaded objects, fly would be part of the Wing class, Grate part of the Claw class and so on.
BUT. What this approach brings to the table is very interesting.
- There is no data saved inside an entity. It is only a collection of pointers, pointing to the data and func constructs
- Because the func constructs are shared, they do not have to be part of every entity. A func "class" can be stored at one fixed spot in the Memory, with no need to save it in any instantiated object. Also, because it stays in the same place, this could be beneficial for CPU caches, because the same adresses would be called frequently.
- The func acts on a given data (Should only one func be able to change a data? This would need to be discussed further). This means that a given "type" of data, lets say every Claw Data, could be saved in memory in batches, where one "object" is saved after another. The idea: The "begin" of the Data batch could be the spot where the func is saved. Then every operation from the fixed spot from func could be operated with only a address offset. This can be really beneficial for optimization purposes
- Flexibility. You do not have to make big monolithic constructs any more, you build entities up from data and function modules.
- I could imagine a func class could be used as some kind of interface, where every entity which has this func could be saved on an "object" of that type. Because when the entity uses a given func it is guaranteed that it has the given set of functions, like with an interface.
- This means no inheritance is needed. In OOP, when one object inherits from another object they are very similar to one another. In our approach, this would simply mean they have more funcs in common.
- Question: Is it useful to have an entity as a field inside the data or would that be kinda against the mentality of the DOP / OOP Hybrid?
- Problem: How do we make the whole @Override stuff work from OOP inheritance? Can it even work and should we even implement it or stick with the module-approach? (If it is choosen).
A detailed comparison of the DCP approach of flint and the OOP approaches in Java can be found in OOP vs DCP.
All constructs from the example above are small enough and easy enough to use that they
could all be located in a single file, lets call it Animals.ft
. Inside this file the
namespace should be set accordingly:
namespace Animals;
// The data constructs
// The func constructs
// The entity constructs
And in the main file, lets call it Main.ft
, we could write:
use Animals;
def main():
// THE METHOD BODY
This way multiple constructs and functionalities can easily be "packaged" into one file, forming a complete package.
In the main function (for me) it looks a lot like "normal" OOP, but in the background a much much nicer separation of data and functionality takes place.
In the data construct, the arguments for the constructor can be asserted with the assertArg
method and to check if a variable is valid whenver it is written to that variable the assert
method can be used. It is very easy to add to an existing data constructor.
// The default constructor without asserts
data Distance:
int length;
flint increment;
Distance(length, increment);
// Using assertions to guarantee valid data states
data Distance:
int length:
assert(length > 0, "The length must be greater than zero!");
flint increment:
assert(increment < length, "The increment must be smaller than the length!");
assert(increment > 0, "The increment must be greater than zero!");
Distance(length, increment):
/**
In general, asserts get checked from left to right. In this case, all asserts
for _length_ get checked before the asserts of _increment_. Because the
increment depends on the length it is useful to put the increment to the
right of the length inside the constructor.
If an assertArgument exists, it will be checked before the "normal" variable
asserts (declared above). In this case, the assertArgument is redundand and
this assertion _should_ lead to a warning from either the compiler or the
lsp, because the condition is the same as above.
There _should_ ultimately exist a way to override variable asserts with
argument asserts, so that variable asserts are for "runtime changes" and
argument asserts are for construction. Maybe a value is not allowed to be
inserted as 'null' but it is allowed to be initialized as such, for example.
*/
assertArg(increment, increment < length, "The increment must be smaller than the length!");
In general, the syntax for assertions on variables would be
- assert( COND , MSG_IF_COND_FAILS )
or
- assert!( COND , MSG_IF_COND_HOLDS )
and on arguments would be
- assertArg( ARGUMENT , COND , MSG_IF_COND_FAILS )
or
- assertArg!( ARGUMENT , COND , MSG_IF_COND_HOLDS )
.
but i think instead of making a separate function for negating the condition one can simply write it for himself by writing assert( !COND , MSG_IF_COND_HOLDS )
instead of the assert!(..)
function. I think this would be more beneficial and (for now) way easier.
Assertions in general enable a lot of development-time hints through the lsp/compiler. For example, whenever they are used the lsp/compiler can check the conditions for the assertions. The conditions are linked directly to a given variable, thus setting this variable will always result in the same conditions being checked. This means, with assertions a programmer can assure, that he will never call the constructor, or set variables with values which would violate the assertions. A simple Hint or even syntax error could be shown.
The keyword shared
is used to singify that data is shared between entities. This means that this data
- Can not be initialized
- No constructor is allowed, because it cannot be initialized
- Every value needs to have a default value
- Is no longer local to a single entity, but shared, as the name suggests
- Is thread-save and equipped with mutex-locks
shared data SomeData:
int value = 0;
flint value2 = 0.0;
The keyword immutable
is used to signify that data is immutable. It still is local to entities, but it cannot be changed when it has been initialized.
immutable data SomeData:
int value = 0;
flint value2 = 0.0;
SomeData(value, value2);
def main():
data := SomeData(3, 4.2);
data.value = 7; // Will throw an error because immutable data cannot be changed
print(data.value2); // Will print '4.2' because the data can be acessed, but not changed
By default, every method declared in a func is declared as public
, so it is visible from everywhere outside that func construct. Because a func construct acts upon data, these methods are only accessible from inside an entity utilizing the func construct.
data SomeData:
int someInt;
flint someFlint;
SomeData(someInt, someFlint);
func SomeFunc requires(SomeData sd):
def increaseSomeInt(int amount):
sd.someInt += amount;
def getInt() -> int:
return sd.someInt;
entity SomeEntity:
data: SomeData;
func: SomeFunc;
SomeEntity(SomeData);
If a method has to be visible everywhere, it has to be declared as const
, making it a true black-box. If a func construct does not act upon any data, it can be declared as const
directly.
func SomeFunc requires(SomeData sd):
// Will yield a syntax error, because the data is not allowed to be accessed inside a const method
def const useSomeFlint() -> flint:
return sd.someFlint;
// Will work just fine, because the data is never accessed inside the const method
def const calcSomething() -> flint:
return 3 / 2;
// Will yield a syntax error, because a strictly const func is not allowed to act upon any data
const func SomeFunc requires(SomeData sd):
...
/**
Because the func construct is declared const, no data is allowed to be referenced by it and
the _const_ keyword for the methods does not need to be written, because every method isnide
this construct is const by default, even if they are private.
*/
const func SomeFunc:
def calcMatrixSum(flint[,] values) -> flint:
flint sum = 0;
for i := 0; i < values.length; i++:
// Extracts the values row by row. Because matrices will be saved in row-major format.
// This will be as memory efficient as possible.
sum += calcVecSum(values[*, i]);
return sum;
def calcVecSum(flint[] vec) -> flint:
flint sum = 0;
for _, value in vec:
sum += value;
return sum;
If any method inside a func construct is wanted to be declared private, the method name has to start with an underscore (_
).
func SomeFunc requires(SomeData sd):
def getMean() -> flint:
return _calcMean();
def _calcMean() -> flint:
return _calcSum() / 2;
def _calcSum() -> flint:
return sd.someFlint + sd.someInt;
When initializing an entity in, for example, the main method, the initialization is very verbose. For the data
, func
and entity
constructs:
data Position:
flint x;
flint y;
flint z;
Position(x, y, z);
data Translation:
flint speed;
flint acc;
Translation(speed, acc);
func FVector requires(Position pos):
def movePosition(flint x, y, z):
pos.x += x;
pos.y += y;
pos.z += z;
func FTranslation requires(Translation trans);
def setSpeed(flint speed):
trans.speed = speed;
entity Player:
data:
Position, Translation;
func:
FVector, FTranslation;
Player(Position, Translation);
With these easy enough data classes, the constructors look like this:
def main():
Position pos = Position(0.0, 1.0, 2.0);
Translation trans = Translation(10.0, -9.81);
Player player = Player(Position(2.0, 3.0, 4.0), Translation(-5.0, 12.1));
The verbosity of these constructors can be reduced by using type inference
def main():
pos := Position(0.0, 1.0, 2.0);
trans := Translation(10.0, -9.81);
player := Player(Position(2.0, 3.0, 4.0), Translation(-5.0, 12.1));
The order in which the data has to be written into the entity is fixed, it is declared inside the constructor for the entity itself. If the order is fixed and the argument count for each data construct is fixed, then the data names can be left away, right? This would result in:
def main():
pos := Position(0.0, 1.0, 2.0);
trans := Translation(10.0, -9.81);
player := Player(2.0, 3.0, 4.0, -5.0, 12.1);
It is much more compact than the previous approach, but is it better?
DISCUSSION
A feature which can be implemented are default values for data, which in itself would make the constructors less verbose.
data Position:
flint x = 0.0;
flint y = 0.0;
flint z = 0.0;
Position(x, y, z);
data Translation:
flint speed = 0.0;
flint acc;
Translation(speed, acc);
def main():
pos := Position(_, 2.0, _);
trans := Translation(_, 10.0);
Because the data links the first argument tight to its variable x
, it can be checked if it has an default value set or not. If it does, the _
is allowed to be used to clarify that x
should be initilized with it's default value.
If all variables of a given data construct have default values assigned to them, the constructor can be simplified even more:
def main():
pos := Position(_);
// Leads to an lsp / compile error because 'acc' has no default value assigned to it
trans := Translation(_);
With the underscore to signify the use of the default values, the construction of entities can be simplified too:
def main():
// This only works when on _all_ data constructs used for the player, default
// values are set. We will pretend here, that the acc has an default value of 0.0
player := Player(_);
// The constructor before the "verbosity optimization":
Player player = Player(Position(2.0, 3.0, 4.0), Translation(-5.0, 12.1));
Alternatively, instead of using an underscore when all variables have to use their default values, the braces could be left empty too (Player()
instead of Player(_)
) like so:
def main():
player := Player();
I think this would be even nicer.
DISCUSSION: What if one data has to be completely default and the other only a few varibles have to be?
An Entity is just a collection of data and functionality. The Addition of links
does not fundamentally change that, because links only redirect function calls.
So...what if any entity can be "extended" from any amount of other entities? This would just mean that all the entities data and func modules also have to be present on the extended entity.
To clarify the point, here is an Example (The "implementation" of the data
and func
modules is ignored here because the emphasis lies on entities themselves):
data HealthData;
data TransformData;
data InventoryData;
data InputData;
func HealthFunc requires(HealthData hd);
func TransformFunc requires(TransformData td);
func InventoryFunc requires(InventoryData id);
func InputFunc requires(InputData id);
entity Character:
data:
HealthData, TransformData, InventoryData;
func:
HealthFunc, TransformFunc, InventoryFunc;
Character(HealthData, TransformData, InventoryData);
entity PlayableCharacter:
data:
HealthData, TransformData, InventoryData, InputData;
func:
HealthFunc, TransformFunc, InventoryFunc, InputFunc;
PlayableCharacter(HealthData, TransformData, InventoryData, InputData);
In the above example it is clearly seen that a Character
could be something like a NPC while the PlayableCharacter
is like a Character, but more. And in this "more" lies the "problem".
One could imagine that any entity modelled this way would grow exponentially. So, what if something similar to inheritance, would be added? For example, there could be implemented a way of declaring that a PlayableCharacter
has to use all the data and func modules declared inside the Character
, and only the additional modules would have to be declared explicitly inside the PlayableCharacter
:
// Same Data and Func modules as above
// Same 'Character' entity as above
entity PlayableCharacter extends(Character character):
data:
InputData;
func:
InputFunc;
PlayableCharacter(character, InputData);
// The compiler will take all 'data' and 'func' modules from
// the extend entity and put them inside, which results in:
entity PlayableCharacter:
data:
HealthData, TransformData, InventoryData, InputData;
func:
HealthFunc, TransformFunc, InventoryFunc, InputFunc;
PlayableCharacter(HealthData, TransformData, InventoryData, InputData);
The above mentioned example results in multiple take-aways:
-
- The syntax is similar to the
requires
keyword forfunc
modules
- The syntax is similar to the
-
- The "referenced" Character
character
is used in the constructor of thePlayableCharacter
- The "referenced" Character
-
- The
data
andfunc
modules from theCharacter
are not declared explicitly
- The
-
- It looks like OOP despite not being OOP
With the Example above, the Character is being added to the Constructor of the PlayableCharacter. The Construction verbosity of such an entiry can be reduced with the methodics explained in the last chapter.
But what this "inheritance" also means is, that in order to create a PlayableCharacter
entity, a normal Character
has to be created first. This means any given Character can become a PlayableCharacter with ease.
character := Character(...);
// The character becomes a playable character
playableCharacter := PlayableCharacter(character, ...);
In the view of the compiler, these two entities do not stand in any relationship to each other, they just both use a lot of the same data and func modules. It should also be mentioned, that all links from the inherited entity stay intact, except foe when they are "overwritten" in the inheritor entity (in this case PlayerCharacter).
An entity is just a collection of data
and func
modules. If any given entity contains the same data and func modules as an other entity, they are considered equal.
entity E1:
data:
D1, D2;
func:
F1, F2;
E1(D1, D2);
entity E2:
data:
D1, D2;
func:
F1, F2;
E2(D1, D2);
At this point, it is unclear if the compiler and / or the LSP should shout an error or just a warning. I think a warning is more fitting in this case. The entity E1
contains the same data and functionality as the entity E2
. There is no valid reason i could think of, why E2
should even exist, considering its basically just E1
with a different name.
Checking for "inheritance" is done with the has
keyword, similar to the is
keyword and its usage with errors.
In the chapter Flint Errors it is described how the is
keyword works: ERR_SET_1 is ERR_SET_2
evaluates to true, if the ERR_SET_1
is
a superset of the ERR_SET_2
.
For "inheritance" purposes, has
works in a similar way. ENTITY_1 has ENTITY_2
evaluates to true, when ENTITY_1
is a superset of ENTITY_2
, or other said ENTITY_1
has
all the data and func constructs of ENTITY_2
, possible more. This means that all data
and func
constructs from ENTITY_2
must be contained inside ENTITY_1
.
// The Position modules and entity
data PositionData:
flint x = 0.0;
flint y = 0.0;
flint z = 0.0;
PositionData(x, y, z);
func PositionFunc requires(PositionData position):
def setPosition(flint x, y, z):
// This is a "grouping operator". It uses Flints ability to have multiple
// variables returned from a function. In this case it is used instead of
// position.x = x; position.y = y; position.z = z;
position.(x, y, z) = (x, y, z);
entity Position:
data: PositionData;
func: PositionFunc;
Position(PositionData);
// The Player modules and entity
data PlayerData:
int health = 0;
PlayerData(health);
func PlayerFunc requires(PlayerData player):
def incHealth(int amount):
player.health += amount;
entity Character extends(Position position):
data:
PlayerData;
func:
PlayerFunc;
Character(position, PlayerData);
def main():
position := Position(1.0, 2.0, 3.0);
character := Character(position, 100);
print(Character has Position); // prints 'true'
print(character has Position); // prints 'true'
print(character has position); // prints 'true'
print(Position has Character); // prints 'false'
print(position has Character); // prints 'false'
print(position has character); // prints 'false'
The keyword has
just inspects which data
and func
modules are present on the LHS and RHS of the operator. It evaluates to true whenever has
keyword, in general, could be written as LHS
RHS
. This has to be true for both the data and func modules to evaluate to true.
It is not possible to get direct inheritance of objects in flint. However, it is possible to link
methods to one another. In the following it is explained how it works and when and how it can and should be used. In its base form, a link
is nothing more than rerouting method calls. It has a few trick up its sleeve, which can be very powerful, if used correctly.
In Java, for example, inheritance is made directly, like in most OOP languages. Here is an example with two classes.
class Machine {
String sound = "Machine go brr";
void makeSound() {
System.out.println(sound);
}
}
class CNC extends Machine {
String chadSound = "CNC for the win boys!";
@Override
void makeSound() {
super.makeSound();
System.out.println(sound);
System.out.println(chadSound);
}
}
This is a very easy and not very useful example. But it gets the point across. A method can be overwritten in child classes extending from this class. All the variables of the parent class (if not declared private
) can be accessed in the child class and the overwritten method can also be accessed. The class CNC
is tied closely to its parent Machine
. If, for example, the parent of the CNC
Machine wants to be changed, a big rewrite will be needed when the machine grows.
Now lets look at how "Inheritance" is handled in flint:
// The Data constructs
data MachineData:
str sound;
MachineData(sound);
data CNCData:
str chadSound;
CNCData(chadSound);
// The func constructs
func MachineUtils requires(MachineData machineData):
def makeSound():
print(machineData.sound);
func CNCUtils requires(CNCData cncData):
def makeSound():
print(cncData.chadSound);
// The entity constructs
entity Machine:
data:
MachineData;
func:
MachineUtils;
Machine(MachineData);
entity CNC:
data:
CNCData;
func:
CNCUtils;
CNC(CNCData);
As seen, the classes Machine and CNC are completely decoupled from one another (for now). There are several approaches how the two classes can be interweaved to another.
One example of how the data from the other class could be accessed is with adding the data description to the entity and the func constructs (Every construct that has not changed is not noted here, but above):
func CNCUtils requires(CNCData cncData, MachineData machineData):
def makeSound():
print(machineData.sound);
print(cncData.chadSound);
entity CNC:
data:
MachineData, CNCData;
func:
CNCUtils;
CNC(MachineData, CNCData);
The problems seemed solved, yay, but there is a big problem. There is no way to access the MachineUtils
methods from within the CNC class because in the CNC entity the MachineUtils
are not added as a func dependency yet.
Lets try adding the MachineUtils func to the CNC entity next:
entity CNC:
data:
MachineData, CNCData;
func:
MachineUtils, CNCUtils;
CNC(MachineData, CNCData);
Ok, now for a CNC
entity both the CNCUtils
and the MachineUtils
methods can be accessed from outside. But the MachineUtils
methods cannot be accessed from within the CNCUtils
class and they also cannot be overwritten. This means, that a method with the same signature can not coexist within the MachineUtils and CNCUtils classes, because there would be a conflict when trying to call one of the methods: which method should be called?
In flint, there is no definite order of inheritance, thus a different solution has to be found.
The solution to the problem of indefintite inheritance order
can be solved with the use of links
. A Link
is a descriptive way of describing a call-connection between methods, similar to the concept of dynamic binding
in java.
Links
can be used to specify a order of operation. Here is an example of using link
:
func MachineUtils requires(MachineData machineData):
#linked
def makeSound():
print(machineData.sound);
func CNCUtils requires(CNCData cncData):
#linked
def makeSound():
print(cncData.chadSound);
entity Machine:
data:
MachineData;
func:
MachineUtils;
Machine(MachineData);
entity CNC:
data:
MachineData, CNCData;
func:
MachineUtils, CNCUtils;
link:
MachineUtils::makeSound to CNCUtils::makeSound;
CNC(MachineData, CNCData);
Through this added line, the compiler now is informed that, whenever the method makeSound
is called on an CNC
entity instance, not the method from the func MachineUtils
but the method from CNCUtils
must be called. Here is the example of the object used in a main method:
def main():
// Create the CNC entity
CNC cnc = CNC(MachineData("machine sound"), CNCData("cnc sound"));
// now, when calling cnc.makeSound(), the method from CNCUtils will be executed
cnc.makeSound(); // prints 'cnc sound'
// The object cnc can be saved to functional references of both its func constructs
CNCUtils cncUtils = cnc;
MachineUtils machineUtils = cnc;
// Now, when calling the .makeSound method on both of this instances, they will both print the same
cncUtils.makeSound(); // prints 'cnc sound'
machineUtils.makeSound(); // prints 'cnc sound'
Machine machine = Machine(MachineData("machine sound"));
machine.makeSound(); // prints 'machine sound'
machineUtils = machine;
machineUtils.makeSound(); // prints 'machine sound'
cncUtils = machine; // will get an syntax error because no func of type CNCUtils was declared in the Machine entity
As seen clearly, flint does not support inheritance like most other OOP programming languages. The only way of connecting different objects with a common denominator is through the use of func constructs. They can be seen as Interfaces. They act upon data but can not be initialized. They can, however, be used to make objects quasi-compatible.
The single thing that can not be achieved by now is the ability to call super.doStuff
inside an "overwritten" method. We will not implement this functionality directly,
because this functionality arises directly from the very nature of OOP. This functionality
can be achieved indirectly by creating a pub
method which does not rely on any outside
data but only on its given arguments for computation. The example below shows this in action:
func MachineUtils requires(MachineData machineData):
#linked
def makeSound():
makeSound(machineData.sound);
def const makeSound(str sound):
print(sound);
func CNCUtils requires(MachineData machineData, CNCData cncData):
#linked
def makeSound():
MachineUtils::makeSound(machineData.sound); // This is the same as calling super.makeSound()
print(cncData.chadSound);
The entity remains the same, because nothing inside it has changed from before. And just like that, OOP conventions work without actually using OOP conventions. Through separating functionality from data and creating many smaller "modules" instead of few monolithic classes has many advantages.
When a method has a return value which is not needed, for example for method stacking:
data ElementD:
int value;
ElementD(value);
func ElementF requires(ElementD e):
def incrementValue(int value) -> int:
e.value += value;
e.value;
entity Element:
data: ElementD;
func: ElementF;
Element(ElementD);
Element elementa = Element(10);
// Leads to an compile error because the return value is not used
elementa.incrementValue(5);
// To specify unused return values, use the underscore
// This singifys unused return values, thus no return values can be "forgotten"
// (This is most useful when returning multiple variables from a function)
_ = elementa.incrementValue(5);
The separation of data
, func
and entities
may be confusing or hard to wrap your head around for newcomers or unexperienced programmers. Also, when only a single data and func construct are needed, the added boiler plate is tremendous. With eliminating the data and func constructs and letting them generate from the monolithic entity the code becomes much simpler to understand for newcomers and much less verbose.
This also means, that when an entity is declared as monolithic, some aspects of flints DCP should be disabled:
- There should be no way to include
data
orfunc
constructs inside a monolithicentity
- Links could be invalid and not appliccable to monolithic entities, because a links purpose is to link functions of different func constructs to one another. But, a monolithic entity could still extend from another entity, or multiples, and whenever extending from other entities, the functions with the same signature have to be declared through links. Ultimately, links should be marked as a warning whenever no extension takes place, but are perfectly allowed when ther is some kind of extension taking place.
With these adjustments, monolithic entities can easily be implemented and the yield the same amount of performance and separation as the "normal" approach. It becomes a problem when the monolithic entity becomes too large, in that case the DCP separation is preferable. But for learning purposes, and for very small entities, the monolithic approach is very attractive.
// INSTEAD OF THIS
data SomeData:
int value1;
flint value2;
SomeData(value1, value2);
func SomeFunc requires(SomeData d):
def increase_value(int increment):
d.value1 += increment;
def increase_value(flint increment):
d.value2 += increment;
entity SomeEntity:
data:
SomeData;
func:
SomeFunc;
SomeEntity(SomeData);
// YOU CAN DO THIS
entity SomeEntity:
data:
int value1;
flint value2;
func:
def increase_value(int increment):
value1 += increment;
def increase_value(flint increment):
value2 += increment;
SomeEntity(value1, value2);
// WHICH THROUGH THE COMPILER WILL EXPAND TO THIS
data SomeEntity_D:
int value1;
flint value2;
SomeEndity_D(value1, value2);
func SomeEntity_D requires(SomeEntity_D d):
def increase_value(int increment):
d.value1 += increment;
def increase_value(flint increment):
d.value2 += increment;
entity SomeEntity:
data:
SomeEntity_D;
func:
SomeEntity_F;
SomeEntity(SomeEntity_D);
// WHICH IS THE _SAME_ AS THE FIRST ONE
Flint does not have something like a null
object or nullptr
pointer. Flint relies on the fact that either there is something or nothing. This is realized through the Opt
type. It works exactly like the Option
type in Rust. It is used whenever it is expected that a field can be "empty" or, more specific, contains "nothing".
data SomeData:
Opt<int> value;
Opt<str> desc;
SomeData(value, desc);
def main():
someData := SomeData(None, None);
someData.value = Some(10);
someData.desc = Some("description");
// Accessing is done by unwrapping the opt type through a switch
switch(someData.value):
None:
print("no value present in the 'value' field of the data");
Some(value):
print($"The 'value' is: {value}");
str description = switch(someData.desc):
None -> "None";
Some(desc) -> desc;
print($"The 'desc' is: {description}");
Often, it is not cared about the Opt
being None
. Often it is required that the value stored in Some
has to be accessed by unwrapping the Opt
in a more line-efficient way. This is done through the introduction of the ?
operator. It can be used to access the value of the Opt
directly, if there is any value saved. Here is an example of the ?
operator being used:
entity SomeEntity:
data:
SomeData value;
func:
def getValue() -> SomeData:
return value;
def setValue(SomeData sd):
value = sd;
SomeEntity(value);
def main():
Opt<SomeEntity> entityMaybe = None;
// Checks if entityMaybe is 'None' or 'Some'
// If it is 'None', this whole expression returns 'None'
// If it is 'Some', this whole expression returns 'Some(SomeData)'
entityMaybe?.getValue();
// This means that unwrapping an Opt type and aggregating a function call after
// it results in an 'Opt<ResultType>' being returned, not the 'ResultType' itself
// This results in a syntax error:
// "Type 'SomeData' does not match return type 'Opt<SomeData>'"
SomeData result = entityMaybe?.getValue();
// There remains the possibility that the result can be "None".
// When not using the return value of functions, or when the function
// does not have any return values, unwrapping can be used esasily
entityMaybe?.setValue(SomeData(10, "brr"));
// BUT there is a risk with unwrapping. It is possible that 'entityMaybe' was 'None',
// this means that the value of 'entityMaybe' was not set at all. It is better practice
// to explicitely declare what should happen when 'entityMaybe' is 'None', for example
// a console print informing that setting the variable failed.
Overall, the Opt
type, similar to Rusts Option
enables null-free operation and a more memory safe programming approach, making Flint less pround for runtime failures such as the famous NullPointerException
or NullReferenceException
when trying to acces fields or functions on an object which is null
.
In Flint, there are no null
s, and thats a good thing!
The clear separation of data and functionality makes Flint inheritely concurrent. Most operations on a larger quantity of entities can be parallelized. Alltough this makes concurrency very easy, a single problem, how to handle shared data, emerges. To tackle this problem, a new keyword has to be added: shared
.
shared
can only be applied on whole data constructs, making it unique in memory. With this keyword emerge multiple rules for how and when to use it:
- Whenever data is
shared
it can not be initialized - Each field in a
shared data
construct must have a default value - A
shared data
construct is neither allowed to have a constructor, nor is it allowed to be used inside the constructor of an entity using theshared data
construct
data SomeNormalData:
int count;
flint distance;
SomeNormalData(count, distance);
shared data SomeSharedData:
int count = 0;
flint distance = 0.0;
func SomeNormalFunc requires(SomeNormalData d):
def incrementCountBy(int inc):
d.count += inc;
def getCount() -> int:
return d.count;
func SomeSharedFunc requires(SomeSharedData d):
def incrementCountBy(int inc):
d.count += inc;
def getCount() -> int:
return d.count;
entity SomeEntity:
data:
SomeNormalData, SomeSharedData;
func:
SomeNommalFunc, SomeSharedFunc;
SomeEntity(SomeNormalData);
As seen, the func
construct does not change whether it acts upon shared
or "normal" data. At this point it is unclear if there even has to be made any differentiation at all. I do not see any reason on why it cannot be the same. If a function is called in parallel, it is not called inside the entity (or better said the func) but outside of it over a range of entities, thus removing the need to change the func
construct at all.
shared data
is considered thread-save, because it automatically implements access mutexes. Also, it can be used outside of threads to share data between entities, even when they are not called concurrently.
Flint comes with a few builtin function utilizing its concurrency abilities. In every function there is an argument called FUNC_OR_ENTITY_TYPE
which is used to declare on which entities the operations are made. Because no func
can exist on its own, but only exist mounted onto an entity
, the operations can only run on entities. If a func
type is given, the operation will be executed on every entity which has the given func construct.
The argument FUNCTION_REFERENCE
expects a reference to the function which will be called concurrently. This is done via TYPE::FUNCTION_NAME
without the parenthesis or any arguments. How the passing of arguments will be handled will be seen. Maybe using TYPE::FUNCTION_NAME(ARGS)
can still be seen as a function reference rather than a function call, which would look like TYPE.FUNCTION_NAME(ARGS)
.
The use of ::
for references, no matter if arguments are passed or not, makes it easier to differentiate function references from function calls.
run_on_all(FUNC_OR_ENTITY_TYPE, FUNCTION_REFERENCE)
run_on_all
simply executes the given function on all entities which have this function available to them, or on a given type of entities. It does not store nor return any results.
This means this function can only be used on functions not returning a value. Or, this has to be thought for, the result can simply be ignored. I think the second approach is better and easier to use.
map_on_all(FUNC_OR_ENTITY_TYPE, FUNCTION_REFERENCE) -> List<ResultType>
Similar to map
in functional programming, map_on_all
would apply a function across all instances of a specified type, collecting results in a new list.
Each entity would process the function independently, making it well-suited for embarrassingly parallel tasks like transforming data or applying calculations across elements in a collection.
filter_on_all(FUNC_OR_ENTITY_TYPE, PREDICATE) -> List<EntityType>
Executes a predicate function on all entities and returns a list of entities that satisfy the condition.
Each entity evaluates the predicate independently, allowing the runtime to distribute these checks across multiple threads.
In a game, filter_on_all
could identify all active NPCs within a certain distance, parallelizing the filter for large maps with hundreds of NPCs. In simulations, it could filter particles within specific energy levels, helping optimize physics calculations.
reduce_on_all(FUNC_OR_ENTITY_TYPE, FUNCTION_REFERENCE, INITIAL_VALUE) -> ResultType
Implements a reduction operation, combining results from all instances into a single final value.
Each entity performs its part of the computation independently, with partial results aggregated. This operation could use divide-and-conquer to split tasks across threads.
This could be used for summing values across entities, such as calculating the total kinetic energy in a particle system. This approach is also applicable in financial modeling, for cumpeting aggregate metrics across entities.
parallel_for(range, FUNCTION_REFERENCE)
Executes a function across a range of values in parallel. This is similar to parallel_for
in languages like C++
but tailored to Flint's data-centric model, potentially applying a function across any range or collection of entities.
The function divides the range among multiple threads, with each thread processing a subset of values or entities.
In machine learning pre-processing, parallel_for
could iterate over a range of data samples, applying transformations independently across threads.
reduce_on_pairs(FUNC_OR_ENTITY_TYPE, FUNCTION_REFERENCE, PAIRWISE_FUNC) -> ResultType
Applies a function to pairs of entities, combining results in a single final value.
Entities are grouped in pairs, and the operation is parallelized for each pair, combining their states as specified.
Useful for clustering or finding relationships in data, like comparing distances between particles or finding closest pairs of points in a large dataset.
partition_on_all(FUNC_OR_ENTITY_TYPE, PARTITION_KEY_FUNCTION) -> List<List<EntityType>>
Partitions entities into groups based on a key function, with each group running in parallel.
Flint's runtime could split and run each partition in parallel, allowing focused operations on each subset.
In network simulations, partition_on_all
could categorize nodes by type, parallelizing actions per node group.
Generics are handled more or less exactly like they are handled in Java syntax wise. The geberic types have to be wrapped in <>
. Any number of generic types are possible, but each Tyoe has to be used at least once, otherwise a syntax warning / or error will be displayed.
It remains unclear how "inheritance" of a generic type should be handled because it should (maybe) be differentiated between data
and entity
types. For now, lets assume a generic type can be any possible construct.
// Generic Data
data GenericData<T>:
T value;
GebericData(value);
data GenericDataM<T, U>:
T value1;
U value2;
GenericDataM(value1, value2);
// Generic Func
func GenericFunc<T> requires(GenericData<T> gd):
def setValue(T value):
gd.value = value;
def getValue() -> T:
return gd.value;
func GenericFuncM<T, U> requires(GenericDataM<T, U> gdm):
def setValues(T v1, U v2):
gdm.(value1, value2) = (v1, v2);
def getValues() -> (T, U):
return gdm.(value1, value2);
// Generic Entity
entity Generic<T>:
data:
GenericData<T>;
func:
GenericFunc<T>;
Generic(GenericData);
entity GenericM<T, U>:
data:
GenericDataM<T, U>;
func:
GenericFuncM<T, U>;
GenericM(GenericData);
As seen, the creation of generic data, functionality or even entities is pretry straight forward. Inside a func entity, the geberic data type declared in the head of its declaration can be used throughout the funcs functions. It does not have to be declared in every function definition. Only when a different generic type than the one declared in the head has to be used in a function it has to be declared explicitely. Below is an example of a possible main function using the above declared constructs.
def main():
g1 := Generic<int>(17);
g1.setValue(30);
print(g1.getValue); // prints '30'
g2 := GenericM<flint, str>(3.2, "ok");
g2.setValues(4.5, "not ok");
g2.setValues(_, "ok");
(v1, v2) := g2.getValues();
print(v1); // prints '4.5'
print(v2); // prints 'ok'
The amount of valid types in generic contexts can be limited by the use of the where
keyword. The syntax to use this keyword is as follows:
data <dataName> '<' <TypeName> '>' where( [conditions] ): <body>
This makes the syntax more generalizable. Theoretically, there could be passed other conditions into the where
statement, for example. The most easy and flexible in this case would be the has
keyword defined here. It is used to define whether an entity "inherits" from another entity, e.g. it forms a superset of the other entity. It feels natural in restricting generic types.
data SomeData:
int value;
flint something;
SomeData(value, something);
data SomeOtherData<T> where(T has SomeData):
// The Body
A variant
is a data type in Flint which is more or less like an Opt
, except that it can not be either None
or Some
, but can be None
or any other declared type from a type list. But it is best shown:
data SomeData:
...
func SomeFunc:
...
entity SomeEntity:
...
entity SomeEntity2:
func: SomeFunc;
...
entity SomeEntity3:
...
variant MyVariant:
SomeData, SomeFunc, SomeEntity;
Because a variant is like a type definition, i was thinking to giving it the keyword type
but that would be way less descriptive about what it actually is.
A variant can be unwrapped just like any Opt
:
def main():
MyVariant variant =
variant = SomeData(); // valid
variant = SomeEntity(); // valid
variant = SomeEntity2(); // valid because of SomeFunc
variant = SomeEntity3(); // invalid
str res = switch(variant):
None -> "nothing";
SomeData(sd) -> $"SomeData: '{sd}'";
SomeFunc(sf) -> $"SomeFunc: '{sf}'";
SomeEntity(se) -> $"SomeEntity: '{se}";
print(res);
Variants offer type variability while not allowing any type, keeping the types explicit like in the rest of the language.
Flint has support for lambdas, both short and long versions. Lambdas can be used everywhere, where a function reference is expected, for example in the co concurrent function run_on_all
which expects a function reference as its second argument. A short lambda is pretty straight forward:
run_on_all(Entity, (Entity e) -> e.doStuff());
As seen, the argument(s) of the lambda have to be strongly typed, they cannot emerge inherently by the signature of the function they are used in. This both enhances readability of the lambdas and increases the type checking abilities of flint. Short lambdas are used when only one action has to be made.
Long lambdas do have a body and can return values. They look very similar to a regular function definition (def
).
List<Entity> = filter_on_all(Entity, (Entity e) -> bool:
int value = e.doStuff() * 2;
return e.isSomething(value);
);
This may look confusing at first, but there is a way to make the code clearer.
Both the short and long lambdas are of type fn
. fn
is a shorthand for function. A function variable can exactly be called as a function itself.
fn short = (Entity e) -> e.doStuff();
run_on_all(Entity, short);
fn predicate = (Entity e) -> bool:
int value = e.doStuff() * 2;
return e.isSomething(value);
List<Entity> = filter_on_all(Entity, predicate);
Because a fn
is a variable and can be saved to memory, a fn
variable can even be saved to a data
construct, for example as a const:
data Cell:
flint capacity;
const fn degrade = (Cell cell) -> void:
cell.capacity *= 0.95;
Cell(capacity);
def main():
Cell[] cells = Cell[1000](Cell(1.0));
for _, cell in cells:
Cell::degrade(cell);
This is only a small example, but implementing a way to save functions as values, and maybe pass them to other functions, as long as the sigmature of the function is correct, is a great addition.
Now the only remaining open part is to strongly type a fn
signature. I have thought about this part for quite a bit before setteling on the following syntax to strongly type a fn
variable:
fn<[argument-types] -> [return-types]>
This syntax wraps the argument and return types in <>
, groupung them together to clarify it as a single type. Because these symbols (<>
) are only used as wrappers in the context of generic typing, this is a perfect fit here, as the fn signature is being typed. Here is an example of that signature:
def applyOperation(fn<int, int -> int> operation, int x, int y) -> int:
return operation(x, y);
def main():
fn<int, int -> int> add = (int x, y) -> x + y;
fn<int, int -> int> multiply = (int x, y) -> x * y;
int addition = applyOperation(add, 5, 3);
print(addition); // prints '8'
int multiplication = applyOperation(multiply, 5, 3);
print(multiplication); // prints '15'
As seen, this system of typing signatures can become very powerful very quick, especially in higher order functions:
// A function which takes a function in as its first argument
fn<fn<int, int -> bool> -> int> higherOrderFunction;
There exist two special edge cases, howewer, which both have a bit of altered syntax.
First, the possibility of no return type. When a fn should not have a return type, the return type in the signature must be defined as void:
fn<int -> void> printNumber = (int x) -> print(x);
Second, a function with no arguments but a return value. In this case, the empty arguments are represented by ()
to signify an empty arguments list. This may look like this:
fn<() -> int> meaningOfLife = () -> 42;
This visually signifys the absence of arguments. The special case of fn<() -> void>
is kinda rediculous in my mind. A function that just... does something...but it theoretically can be declared, yay.
Flint does support Rectangle Arrays for all data types. A rectangle array does have a fixed size along all array dimensions, where the dimension size can defer between dimensions but not within one dimension.
A 2D-Array, for example, has a predefined with and height, both of which are not changable until a new array is created.
// The creation of a 1D int array of size 10
array1D := int[10];
// The creation of a 2D int array with width 10 and height 15
array2D := int[10, 15];
// The creation of a 3D int array with width 10, height 15 and depth 5
array3D := int[10, 15, 5];
After creating the arrays, they are filled with empty primitives but they are not initialized with any specific value. This is considered unsafe and they should be initialized with a specified value. To do so, the special constructor for arrays can be used:
array1D := int[10](0);
array2D := int[10, 15](0);
array3D := int[10, 15, 5](0);
The round brackets declare that this value is used to initialize every element in the array with this value. When curly brackets are used instead, only the specified elements get filled:
array1D := int[10]{0, 0};
array2D := int[10, 15]{0, 0};
array3D := int[10, 15, 5]{0, 0};
Here, not the whole array gets filled with zeroes but only the first two elements. As long as there are less values inside the curly brackets than the specific dimension is long, everything is fine. When there are more values than the dimensions size, a syntax error or compile error will be thrown.
But the array can be initialized with an undefined size too. This works like the following:
array1D := int[]{2, 3, 5, 7, 11, 13};
array2D := int[,]{{1, 2, 3}, {0, 4}, {1}};
array3D := int[,,]{{{1, 2}, {3, 4}}, {{5. 6}, {7, 8}}};
Here, the length of the arrays dimensions is set at each dimension through the amount of values inside the array constructor. Through the
There is no support for jagged arrays like they can be found in java, for example.
Instead, a list-like construct has to be implemented. For this, the type vec
can be
used. To declare a vec, just type:
// Initializes an empty 1D vec array list
arrayList1D := vec<int>();
// Initializes an 1D vec array list with a given 1D array
arrayList1D = vec<int>(int[]{2, 3, 5, 7});
// Initializes an empty 2D vec array list
arrayList2D := vec<vec<int>>();
/**
This does not work, because the outer vec expects either a vec<int>[] for its constructor.
Only arrays of the data type within the vec are valid data types to create a vec array
*/
arrayList2D = vec<vec<int>>(int[,]{{1, 2}, {3, 4}});
// Alternatively, this can be done to achieve exactly that
arrayList2D = vec<vec<int>>(vec<int>[]{vec<int>(int[]{1, 2}), vec<int>(int[]{3, 4})});
/**
This is _very_ complex, just imagine the constructor for a 3D array!
Alternatively, it is recommended to either create the sub-arrays of a higher-dimensional
array beforehand, or to just add the items with the given methods (the first approach is quicker).
*/
subArray1D1 := vec<int>(int[]{1, 2});
subArray1D2 := vec<int>(int[]{3, 4});
arrayOf1DLists := vec<int>[]{subArray1D1, subArray1D2};
arrayList2D = vec<vec<int>>(arrayOf1DLists);
/**
This seems more complex at first glance, because there are more lines of code, but it
is much simpler than the above method.
*/
Flint focuses primarily on rectangle arrays. To make life easier with rectangle arrays,
the for all
operator, *
is introduced. This operator can be used when accessing
dimensions of arrays. Instead of the index, this operator will be used and instead of a
single cell, "all" cells of that dimension are accessed.
Below is an example with a 1D array, this example does not make much sense on the first glance, but then you may realize that it is a deep copy which gets created here:
// Create a 1D array with the values from 1 to 5
array1D := int[5]{1, 2, 3, 4, 5};
// Extracts all values of the array, essentially making a deep copy
arrayCopy := array1D[*];
// prints '{1, 2, 3, 4, 5}'
print(arrayCopy);
Jumping up the dimension to a 2D array, now a single row or column can be extracted from the 2D array with ease:
// Creates a new 2D array with width 3 and height 2
array2D := int[3, 2]{{1, 2, 3}, {4, 5, 6}};
// Extracts the first row of the array
rowCopy := array2D[*, 0];
// Extracts the first column of the array
columnCopy := array2D[0, *];
// Extracts the complete 2D array, making a deep copy of it
deepCopy := array2D[*, *];
// prints '{1, 2, 3}'
print(rowCopy);
// prints '{1, 4}'
print(columnCopy);
// prints '{{1, 2, 3}, {4, 5, 6}}'
print(deepCopy);
The for all
operator can, however, not only be used to extract data from an array, but
also to write data into an array. In the following example the first row and first
column of a 2D array will be set to 0 using an 0-filled array while leaving the rest of
the values as is:
// Create an empty array of sze 3×3
zero2Darray := int[3, 3](0);
// Create an array of size 3×3 with the values from 1 to 9
array2Dvalues := int[3, 3]{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
// Overwrite the first row of the values array with the values of the zero array
array2Dvalues[*, 0] = zero2Darray[*, 0];
// prints '{{0, 0, 0}, {4, 5, 6}, {7, 8, 9}}'
print(array2Dvalues);
// Overwrites the first column of the values array with the values of the zero array
array2Dvalues[0, *] = zero2Darray[0, *];
// prints '{{0, 0, 0}, {0, 5, 6}, {0, 8, 9}}'
print(array2Dvalues);
As can be seen easily, with this operator, many otherwise compley operations for
rectangle arrays become very easy. Because the size of the array is fixed too, the read
and write operations are very fast on memory, becase the pointer always jumps for a
fixed amount of addresses while reading / writing.
An example for now very easy operations is the transposing of matrices. Below are two
approaches, one uses the for all
operator, the other one copies the values manually.
// Transposing using the for all operator
def transpose(int[,] matrix) -> int[,]:
newMatrix := int[matrix.length, matrix[0, *].length];
for y := 0; y < matrix[0, *].length; y++:
newMatrix[*, y] = matrix[y, *];
return newMatrix;
// Transposing manually
def transpose(int[,] matrix) -> int[,]:
/**
The for all operator has to be used here because there actually is no other way to
find the length of the second dimension
*/
newMatrix := int[matrix.length, matrix[0, *].length];
for x := 0; x < matrix.length; x++:
for y := 0; y < matrix[0, *].length; y++:
newMatrix[y, x] = matrix[x, y];
return newMatrix;
In this example, the difference is not as drastic as it could be. But, for example, imagine the case with matrix multiplication or any case where one row or column of the matrix can be extracted and used in an method to calculate the new row / column, and the result can be set directly. This can be a very powerful operator for array operations.
The swap operator <->
can be used very efficiently and effectively in combination with arrays. Lets say, the content of an array wants to be 1. flipped in order or 2. sorted. Most of the times, either a secondary array has to be created where the values get copied into, or a temporal cache variable has to be initialized with a copy of the data to move. With the swap-operator both these cases can be simplified and no additional variable has to be declared.
/**
* Makes a whole new array instance and manually copies the values one by one into it
* This is very slow. One unnecessary memory allocation is made, and the copies are
* slow because the arrays are not localized (source and target can be very far apart
* in memory).
*/
def swapArrOrderCopy(int[] arr) -> int[]:
int biggestIdx = arr.size() - 1;
int[] newArr = int[](arr.size());
for int i = 0; i < arr.size() / 2; i++:
newArr[i] = arr[biggestIdx - i];
return newArr;
/**
* Does not make a complete new unnecessary memory allocation, but it doubles the
* amout of copies in comparison with the first solution. The value inside the array
* is copied from the array in memory to some other place in memory. Because double
* the total copies have to be made, this solution is still very slow compared to the
* swap operator.
*/
def swarpArrOrderCache(int[] arr) -> int[]:
int biggestIdx = arr.size() - 1;
int copy;
for int i = 0; i < arr.size() / 2; i++:
copy = arr[i];
arr[i] = arr[biggestIdx - i];
arr[biggestIdx - 1] = copy;
return arr;
/**
* The fastest and easiest solution of them all. It has the best performance and the
* least amount of written code. Swaps are pretty common. Instead of copying the value
* of the array to a variable somewhere in memory, it can simply be stored in the cpu's
* cache, then the value from the other index is copied and finally the value from the
* cache is written. Because there is only a single copy, which is a very localized one
* and the other copy is only to the cpu's cache, this is the fastest solution.
*/
def swapArrayOrderSwap(int[] arr) -> int[]:
int biggestIdx = arr.size() - 1;
for int i = 0; i < arr.size() / 2; i++:
arr[i] <-> arr[biggestIdx - i]
return arr;
A library is sinply a collection of files with a main enty point. This entry point, in many cases, is a single namespace, most of the times the name of the library.
The goal for all repositories is to make the collaboration-barrier as low as possible. This works by separaring flint into three main repositorys:
- Flint Repo: Here, only approved and stable libraries which are highly useful, find their place
- Flint Unstable Repo: Here, the newest versions of approved libraries from the Flint Repo can be found.
- Flint User Repo: Here, everyone can upload their own libraries. No approval or regulations will take place here. If projects become goof / popular / big enough they will eventually get into the other repos.
I have a system similar to nix
in mind for these repositories. The repositories and all libraries in them are required to be open source. The repository will be the bridge between developers. The community will gros fast through the repositories.
I wonder why this repository system worked so well for linux for so many years now, yet it has not been standardized for programming languages as well.
No duplicate library names are allowed in any repo.
A repository browser similar to nixpkgs will be available. But, every library needs to have a small description paragraph describing what the library is and what it is used for. In the browser, libraries should be searchable by name
and by keyword
(a word in the description text).
To centralize libraries and making them easily accessible and contributable by the community, it will make great libraries by themselves. This reduces the load on the Flint developers to create every library themselves.
To import a library from the flint repository, simply type
import flint.<library-name>;
The flint repo houses the most important repositorys of flint. This repository is regulated and libraries inside it are approved. This keeps the library clean.
To import a library from zhe flint unstable repository, simply type
import flint.unstable.<library-name>;
The unstable library will contain the newest version of every library from the stable repository. This means that here, the newest features of libraries will be found, but they are potentially unfinished, buggy or, as the name suggests, unstable
. In general, it should be avoided to use the unstable branch for production or serious projects. But thid repository is very useful, especially in collaborations, where libraries depend on each other and they work together on new features.
To import a library from the flint user repository, simply type
import flint.user.<library-name>;
The Flint User Repository houses all the libraries from all the users, potentially filling nieches or edge cases. Also, users often create very good libraries.
In bigger projects it might be beneficial to host a library repository on your own. Therr are tools available to do so. In the settings, the link to the repository must be declared. After that initial setup, the local repository can simply be acessed with
import local.<library-name>;
Building a good and great local repository over time is great for companies, which can reuse already built libraries between projects and slowly build up a repository that becomes bigger and bigger over time. The more libraries have been built and added to that repository, the more it works like playing LEGO: The building blocks have been created, and the fun part is bringing the parts together.
This is not only great for companies, but for individuals as well. Self-written libraries can be used throughout many projects, instead of rewriting everything from scratch for every new project (which happens a lot).