The primary goal of FFL’s type system is to ensure that every FFL expression has a definite type, that is known when the formula is parsed. By knowing the type of an expression, we know if the way it is being used is legal, and can report appropriate error messages.
To allow this to occur, symbols in FFL must have types associated with them. For instance, if we have this expression:
a + b
What is its type? It depends on the types of ‘a’ and ‘b’. If a and b are strings, the expression will be a string, if a and b are ints, the expression will be an int.
This is why, in strict mode, you have to specify a type for properties. So when properties are used, the compiler will know what type they are.
Types are organized into an indefinite hierarchy, with the type any at the top of the hierarchy. If FFL can’t determine what type something is, it will be deemed to be type any. Primitive types – ints, strings, decimals, and so forth, are all distinct types.
But I said an indefinite hierarchy, didn’t I? What do I mean by that? We define a type, Numeric, to be all ints and all decimals. So really, int < Numeric < any
.
(I use ‘<’ in this post between types to mean ‘subset of’)
Numeric is not specially defined by the engine, it’s just a user-defined type. We define it in types.cfg like this:
Numeric: "int|decimal"
This is called a type union. A type that might be an int or a decimal. You don’t need to use types.cfg to use type unions, though, it’s just a convenience, you could write a function like this:
square: "def(decimal|int num) -> decimal|int num*num"
Note that FFL is smart enough to infer the result type of any expression. For instance, if you instead wrote,
square: "def(decimal|int num) -> decimal num*num"
You’d get a compile time error in strict mode telling you that you claimed your function evaluates to decimal, but after parsing it, FFL discovered it might actually be an int, so decimal|int is the correct result type.
An important thing to remember when dealing with types is the more specific a type is, the more useful it is. any can be used to describe any expression, but it’s not very useful. If you have a value of type any, you’ll be allowed to do very little with it, because there’s very little you can safely do with it.
More on types.cfg and types: {}
You can define types in two places. In a module’s data/types.cfg and a custom_object can define types to be used within its definition in a types: {} block. As an example (will make more sense as you read on in this…)
types: {
Numeric: "int|decimal",
Loc: "[int,int]",
DamageMessage: "{source: string, damage: int, target: string}"
}
Note that types defined like this must being with a capital letter to be recognized.
Custom objects
So what type is a custom object, such as Frogatto’s object? All “Objects”, that is, complex C++ objects with an FFL interface are considered to be of type “object”. However, custom objects are of type “custom_obj” which is a child of object. Then, Frogatto’s object is of type “obj frogatto_playable”. frogatto_playable is based on the playable prototype, though, so it’s also of type “obj playable”.
So there’s a big long hierarchy going on: obj frogatto_playable < obj playable < custom_obj < object < any
Now, remember, in strict mode you will only be allowed to do things that FFL knows will definitely be successful. Let’s say you have a reference to Frogatto’s object. What can you legally do with it based on the type of the reference?
- If you have an ‘any’ you can only pass it to functions that accept an any, or use type discoverability to try to work out a more specific type (see more on that later).
- If you have an ‘object’ you can look up symbols inside the object, e.g. frog.hitpoints, but the symbol will be looked up at runtime and the type of frog.hitpoints will be ‘any’. (Since the engine has no idea what type it actually is). Note that generic ‘object’ types are a little unsafe even in strict mode, in that you can look up arbitrary symbols and if you look something up that’s not present you’ll get a run time error. I’m considering forbidding symbol lookups on generics objects.
- If you have a ‘custom_obj’ you can look up symbols that are common to all custom objects, such as hitpoints or x and y and they will have full type information.
- If you have a obj playable you can look up symbols defined in the playable type.
- If you have a obj frogatto_playable you can look up symbols defined in frogatto_playable.
The null type
In most languages, references (or pointers) to objects can also have the value null. In FFL, we do things a little differently. null is a type of its own, and values of type null can have only one possible value – null. So ‘null’ is the name both of a value and the type that value is. Have you ever seen Frogatto spew the error “Expected type string but found null null”? Know why there is the redundancy of nulls there? Because the engine outputs the type and then the value. It might say “Expected type string but found int 5”. But when the value is null it’ll output the type – null – and the value – null.
This means that if you have a symbol of e.g. type ‘obj frogatto_playable’ you know that this will NOT be null. It will definitely have a frogatto_playable object. Thus a function such as def(obj frogatto_playable frog)
must be passed a frogatto_playable as an argument. null is not a valid argument.
If it’s possible for a symbol to be null, use a union with the null type. e.g. def(null|obj frogatto_playable frog)
allows ‘frog’ to be passed as null.
However, in strict mode, FFL will not allow you to lookup symbols in a reference that may be null. Using frog.hitpoints
inside this function will cause an error, since ‘frog’ might be null.
To lookup symbols in frog, you must convince FFL that frog is not null. You do this with type discoverability.
Type discoverability
Inside a formula, types of symbols are not necessarily static. FFL analyzes the structure of a formula and infers as much information as it can about the type of symbols within different branches of the formula. Let’s take a simple example of some bad code:
get_life: "def(null|obj frogatto_playable frog) -> int
frog.hitpoints"
Strict mode will give you an error here. “frog might be null”. It’ll refuse to go on. How do we placate it? Like this:
get_life: "def(null|obj frogatto_playable frog) -> int
if(frog != null, frog.hitpoints, 0)"
That’s sensible coding, even without the type system. If frog might be null, protect the symbol lookup with an if statement. What’s nice is that FFL sees what you did there and while it has frog’s type down as ‘null|obj frogatto_playable’ in the ‘then’ branch of the if statement it automatically modifies the type of ‘frog’, making it a ‘obj frogatto_playable’.
It’s smart enough to know about things like and and or and so will parse this legally:
get_life: "def(null|obj frogatto_playable frog) -> int
if(frog != null and frog.hitpoints > 0, frog.hitpoints, 0)"
Since the ‘frog.hitpoints > 0’ lookup came after and ‘and’ where we checked frog isn’t null, FFL will recognize that this is legal and frog.hitpoints is guaranteed to work.
As well as guarding things with if statements, it also recognizes asserts:
get_life: "def(null|obj frogatto_playable frog) -> int
frog.hitpoints asserting frog"
It recognizes that if frog is null, it’ll die in the assert, so frog must not be null and thus it infers frog’s type as “obj frogatto_playable”, without the null.
These mechanisms allow you to fairly easily prove to FFL that something isn’t null, and is generally good code that you should be writing anyhow.
Of course, sometimes you want to discover types in more sophisticated ways – not just null vs not null. So, FFL provides the is operator. The is operator tells you if an expression is of a given type:
if(message is string, set(dialog.text, message))
Again, FFL is smart enough to infer, within the ‘then’ branch of the if statement, that message must be of type string.
Use of the is operator is powerful, but it should be used judiciously, overuse can result in complex and unmaintainable code.
Here are some examples of common idioms which you’ll find useful in dealing with the type system:
map(filter(level.chars, value is obj mywidget), value.do_operation())
Note that the filter() is smart enough to realize that its result is a [obj mywidget]. Thus even if do_operation() is only available in 'mywidget’s definition, FFL will realize this is a legal expression.
Alternatively this can be written as a list comprehension:
[mywidget.do_operation() | mywidget <- level.chars, mywidget is obj mywidget]
Note that list comprehensions are particularly powerful at this, because of the way they infer types. Let’s suppose you wanted to do some operation on all ‘mywidgets’ that passed some certain criteria.
[mywidget.do_operation() | mywidget <- level.chars, mywidget is obj mywidget, mywidget.is_good()]
Note that in the mywidget.is_good()
expression, FFL has already worked out that mywidget is of type ‘obj mywidget’ and so can successfully resolve the is_good symbol lookup.
We also have a new find_or_die() function which is nice if you just know you should find something:
find_or_die(level.chars, value is obj frogatto_playable).spit()
find_or_die() will know, in this case, that it’s returning an obj frogatto_playable. Regular find() would return type null|obj frogatto_playable.
The :: and <- operators
FFL provides two operators for forcing conversions. These are the :: and <- operators. The :: operator is very useful for working out formula errors, for defining properties, and in general is always safe. It only affects the compile process and has no effect at run time. The <- operator is a run-time operation and can result in a run-time error. It should be used judiciously.
How do they work then? The :: operator is really simple. It just says “This expression is of type T” the FFL compiler will compile it and if it disagrees, will raise an error. Examples:
level_width: "int :: level.dimensions[2]" //define a level_width property of type int
decimal :: (5 + 4.0)/2 //I think this will result in a decimal -- right?
This is useful sometimes when you’re getting a compile error you don’t understand. Add it to expressions to ensure the compiler agrees they are the type you think they are. Also, this is the way to explicitly state the type of an object property. Often it’s required to do this for an object property since the compiler might not be able to infer the type when it’s first required.
The <- operator is used when you just know that an expression evaluates to a certain type, but FFL really can’t figure it out. For instance,
5 + (int <- a)
This basically says “look I know you have a down as type any but it’s actually going to be an int. For sure. I’m the coder. Trust me.”
FFL will take your word for it, compile the expression as an int. Then if it turns out to be something different at run time you will get a run time error saying so.
Sometimes using <- is necessary – and will be especially so while our support for types is still maturing – but should eventually become more and more obscure. If you’re using it too often there’s a sign something is wrong. Using :: is much nicer and safer!
An example of when <- is definitely necessary is if you receive a FSON tree from an unknown source. For instance, loading it from the user’s preferences directory with get_document(). You’d want to use <- to tell the code to load and validate it into some kind of type you provide. If it’s not in the correct format you’ll get a run time error – which is the exact reasonable behavior you’d expect when loading a file at runtime that is corrupt.
Lists
FFL’s type system supports two kinds of sequences: the more general lists, and the more specific tuples. A list is simply a sequence of a certain type and can be any size. Examples of lists:
list //a list of any type and size
[any] //same as above
[int] //a list of integers
[int|string] //a list of integers or strings
[custom_obj] //a list of custom objects
[obj ant_black] // a list of ant_black custom objects
[null|obj ant_black] //a list of values that can be ant_black custom objects or null
[[int]] //a list of list of ints
A tuple is a list of a certain size, with well-defined types for each element. For instance,
[int,int] //a tuple of 2 elements, both ints
[string,int,int] //a tuple of 3 elements -- a string, then int, then another int.
[int,] //a tuple of exactly one integer. Note the ',' disambiguates it from [int] which would be a list.
[int|string,int|string] //a tuple of two elements, each of which can be an int or a string.
The thing to understand about tuples is that they are considered a subset of lists. That is to say, if you have a [int,int]
it will be happily accepted anywhere an [int]
is required. If you have a [int,int,decimal]
it will be accepted anywhere an [int|decimal]
is required.
The big rationale for this is to allow you to continue to write list constants the way you always have. For instance, if you write,
What is that exactly? Maybe it’s just a list that could be any length, or maybe it’s specifically three items because it’s meant to be in 3D co-ordinate space and is going to be used as a tuple that must be three elements. We don’t really know. We could force writing of list literals differently, so that you have to identify more about them, but I think that would be more burden on the coder.
So instead, that expression is treated by FFL as a [int,int,int]
. But FFL will happily degrade it to a [int]
implicitly.
Tuples do allow things like this, which we use in Citadel:
//in types.cfg
Loc: "[int,int]"
And then we have functions which take these Loc types. They will be validated so they must be tuples exactly as given. Note that the conversion does not go the other way – a [int]
can only be converted to a [int,int]
with the <- operator.
Maps
We have something similar going on with maps. Except I don’t have a good name for the ‘tuple version’ of maps, so I call them ‘specific maps’. They could be called structs, I suppose.
//Examples of maps
{int -> string}
{string -> int|null}
{string -> {string -> [int]}}
Maps are designed for cases where you might want lots of different keys and you don’t know how many keys you will have or what they will be called when you’re writing the code.
However, there are many cases where you use maps differently. You want to write a map like { name: “Frogatto”, damage: 17, speed: 24 } – and you know that these keys, name, damage, and speed will always be present.
Such a map is a ‘specific map’. This is how we’d write the definition:
{name: string, damage: int, speed: int}
Note the use of ‘:’ instead of ‘->’. This is what differentiates a specific map from a map.
You can actually provide for some keys to be omitted, to do so make it so that the value can be null. For instance, if you want to make speed optional, do this,
{name: string, damage: int, speed: int|null}
As with tuples, map literals are inferred as specific maps, but will happily degrade into maps implicitly.
Note that get_document() will try to resolve at compile time if you give it a constant string filename that’s not in a user directory, giving you full access to the document structure and automatically reading the type as a specific map.
Specific maps allow you to very easily create your own little types that you can put in types.cfg or an object’s types section.