Type-C has been announced to the public! And it rocks! In this post, we will dive into some of its features and how they work under the hood.
Type-C Release
I have posted about Type-C on Reddit and dev.to and now let's look at some of its features!
A Look at Type-C in Action
Type-C offers most features you would expect from a AAA programming language. It has a strong type system, generics, classes, closures, you know the drill.
In this post we will look at a specific example, std.array.Array class.
This class provides a basic Array class to push/pop elements. The class definition is rather large, so we will focus only on small parts of it.
In this post, I will go through two things, Generics and Coroutines&Closures.
Generics
Similar to TypeScript, Type-C's compiler is able to identify generics from usage. This applies to all class method except the constructor methods.
Let's look at map method:
It requires an argument f which is a function that takes a single argument of type T and returns a value of type Z.
Now let's see it in action:
If we compile this:
Ignore the table and the env-text which showcases the runtime info. #1 is our std output.
The compiler is able to recursively infer the concrete type of Z, by recursively looking at the parameter given.
How does this happen? Well the expression fn(x: u32) = "#"+x is inferred as (pseudocode):
Now since map method belongs to a generic class, once we create a concrete type of it, the method is cloned, where each generic type is replaced with the concrete type. Hence resulting in map which requires the following argument type:
Now if we compare them side by side:
There we have it! We clone the function once again, replacing the generic type Z with the concrete type String.
This process of recursively extracting the concrete type from the function is done through the method getGenericParametersRecursive implemented in every data type in the compiler, let's have a look at an example:
StructType
This method is called on a data type that might contain generic, so in our case, map.
First, notice the method preGenericExtractionRecursion and postGenericExtractionRecursion. These are used to prevent infinite recursion when a generic type is used in a recursive data structure. The first thing this function does (after avoiding our compiler to die), is to make sure we are comparing a StructType with another StructType. Then it iterates through the fields of the struct, making sure the field names match, and then recursively calls getGenericParametersRecursive on the field type.
Now let's look at its parameters:
- ctx: The context of the current compilation
- originalType: The original type of the struct (it is the concrete type, because this is the generic type)
- declaredGenerics: The generics we expect to find, these are extracted from the definition, for example map<Z>, Z is the declared generic.
- typeMap: A map of the generic types to the concrete types. At the end, every generic type in declaredGenerics needs to be replaced with the concrete type.
If we are, we call the function recursively on each field. This is the same behavior for every data type that has subfields.
This process ends when we enter GenericType.getGenericParametersRecursive which is the base case of the recursion. This function is responsible for replacing the generic type with the concrete type.
Since GenericType is our terminal, let's look at its implementation:
GenericType
Now the method has a different behavior that calling the same method on all its subfields! Since it is a terminal it needs to check a few conditions:
- Is the generic present in the declared generics? If not, it is an error.
- Is the generic already in the type map? If so, we need to make sure the type matches the constraint (if applicable)
- If the generic is not in the type map, we add it to the type map.
- If the generic is in the type map, we need to compare the existing type and the new one
- If the types match exactly, we are good
- If the types do not match, we find a common type and override the type map with the common type.
- If the common type does not match the constraint, it is an error.
- If the common type matches the constraint, we are good.
Now that's a lot of rules. But it is necessary to make sure the generic types are used correctly. This is the core of the type system in the compiler.
Coroutines and Closures
This function is our main focus! As it does two things in one line: return coroutine iterator first of all, just the mention of iterator as reference to a function creates a closure. Then based on this closure, it creates a coroutine.
You start to see a bit of how the VM functions here, coroutine expects a closure and they work hand-in-hand pretty well, because for a closure, the upvalues will need to be backedup after it returns, but coroutines already persist the registers.
Let's have a look at an example using this:
This code will print (with non-sense removed):
Let's explore the IR of this method:
Type-C IR is a bit different from regular IR, it assumes infinite disposable registers (with destroy_tmp), and assumes reference assignment for everything.
So for example:
It means tmp_1 references local argument x and then assigned tmp_2, essentially updating x since all values have to go through registers first.
Now let's get back on track.
In Type-V, closure env are passed as arguments, right after the regular function arguments, in our case, cfn takes 0 arguments. So the closure env is passed as the first argument.
Sadly the graph plot of main is not as nice as it has jumps and all.
If you want to look into it yourself, compile with --generate-ir flag and you will see dot files in the output directory you provided to the compiler.