Polymorphic Structs and Functions

Polymorphism is used to define functions and structs that require at compile-time known types T via $T.

The dollar-sign before the type-parameter $T indicates that this type has to be derived by the compiler and therefore all required type information has to be known at compile time.

Polymorphic type declarations

There are (at least) three major ways of defining polymorphic types. As we already saw, we can define the type of a variable via e.g. x : int;. Here, the compiler knows the type at compiletime, since it’s explicitely written out.

If we accept any (compile-time) type, we can use the dollar sign, e.g.

x : $T

In this case, any type is matched to T, that includes any struct, int, bool, Any, etc. There is no restriction on the possible types used. On the one hand, this enables us to write truly generic functions, on the other hand, we expect the programmers to know, what they’re using x for - and in case they don’t, the compiler will complain or the program will crash.

We can be more precise than that. Assuming we have polymorphic structs (see below), e.g. Foo(T: Type), we can restrict the type of x by only allowing Foos:

x : Foo($T)

In this case, the inner type T still could be anything, but at least we know that x is some kind of Foo. This declaration can be nested, e.g.

x : Foo(Bar($T, Sth($C, $D)), $U)

Sometimes, however, this way of restricting the type of x is too strict. It is possible to require members of x similar to traits or interfaces in other languages via the / notation:

x : $T/Foo

Here, we know that x has the fields of Foo and we can treat it as such. This does not mean x is a Foo! It can simply incorporate a Foo via e.g. using f: Foo;. This enables also component bases systems where each struct via using _c: SomeComponent;

In all of these cases, it is possible to re-use the polymorphic types, e.g. T, once the compiler could figure what they were. Examples of that are further below.

There are also other ways of defining the types, e.g. $T/interface Foo that will be explained further below.

Functions

Let’s take a look at a simple function:

foo :: (x: $T) {
    print("%\n", x);
}

At this point, we don’t know the type of x, but we name it T and it has to be known at compile time! We can use it like this:

x := 42; // T == int
y := "hello"; // T == string

foo(x); // knows it's an int
foo(y); // knows it's a string

Of course, functions can have multiple polymorphic variables

foo :: (a: $A, b: $B, c: $C) {...}

You can use the same type for multiple parameters and return values, just use the identifier for the type.

foo :: (a: $T, b: T, c: T) -> T {...}

Polymorphic return parameter:
We already know, that we can reuse the polymorphic type definition for the return type of a function, see the example above. However, in some cases, the return parameter depends on the argument parameter types

foo :: (a: $A, b: $B) -> [some function determining the type depending on A and B] {...}

One way of doing this is #modify which will be explained further down below. Unfortunately, this method is cumbersome since it does not work directly with the types A and B, but the AST representation of the parameters.

Another way of achieving this functionality, is with helper-structs: We can define

Helper :: struct(A: Type, B: Type) {
  T :: #run helper(A,B);
}
helper :: ($A: Type, $B: Type) -> Type {
  T : Type = A; // do your logic here
  return T;
}

The helper function actually does the logic and returns the wanted return type depending on the input types. It is run at compile-time via the #run instruction. To use the return type, we now define our original function foo as

foo :: (a: $A, b: $B) -> Helper(A,B).T {...}

The Helper struct can be used anywhere to define the type of a variable, e.g. in foo

x : Helper(A,B).T;

Structs

Similar to function, polymorphic structs are also possible! In this case, you need to introduce the polymorphic type $T after the struct keyword for compile-time constants:

Foo :: struct(x: $T) {...}

This way, the type of x has to be known at compile time:

a : Foo(42); // ok, 42 is a constant
x := 2;
// b : Foo(x); // not ok, x is not a constant value!
y :: 2;
b : Foo(y); // ok, y is a constant value

If you want to define a struct that has a polymorphic type for it’s members, you can use the type Type to define it during compile-time

Foo :: struct(T: Type) {
    some_data : T;
}

When using this struct, you have to declare the type

f : Foo(int);

Continuing the example above, you can access the type parameter of Foo through Foo.T.

f: Foo(int);
print("type = %\n", f.T); // prints out "type = int"

The polymorphic struct do not only restrict to data types, but they can also extend to functions:

Foo :: struct(
  // everything here has to be known at compile time
  // these entries will be baked out and do not remain part of the struct in memory during run-time!

  T: Type,
  fun: (T) -> T
  // ...
) {
  // everything here can be chaged 
  // (as long as it's not a constant via :: ) at run-time 
  // and stays in memory

  value: T;
  // ...
}

Further, it is possible to define recursive types, e.g.

Foo :: struct(
  T: Type,
  fun: (T) -> int
){}

Bar :: struct(_T: Type) {
  using f: Foo(Bar(_T), bar_fun)
}
bar_fun :: (b: Bar($T)) -> int {
  return 42;
}

Do not attempt to print the type: (beta 0.0.083)

T :: Bar(int);   // the compiler resolves the type just fine
print("%\n", T); // this will go into an infinite loop at run-time 

Arrays

It is possible to use polymorphic arrays int both size N and element-type T, e.g.

foo :: (x: [$N]$T) {
    print("%: %, %\n", type_of(N), N, T);
}

Here, both N and T have to be known at compile time. N refers to the number of elements and is itself of type int! Using the above definition of foo, we’d get in this example

x := int.[1,2,3,4];
foo(x); // prints "s64: 4, s64"

It is important to know, that arrays of different sizes are different types! So

[4]int != [5]int

Type Comparison

We can compare types with the equals operator ==:

x : float64 = 0.1;
assert(type_of(x) == float64);

n := 42;
assert(type_of(n) == int);

Foo :: struct {}
f : Foo;
assert(type_of(f) == Foo);

However, when using polymorphic structs, e.g.

Bar :: struct(T: Type) {
    value: T;
}

we can only compare specializations of said type, in this case

b : Bar(int);
assert(type_of(b) == Bar(int));
assert(b.T == int);

It is not possible to compare without specialization:

b : Bar(int);
assert(type_of(b) == Bar); // this does not work!

even though

print("%\n", type_of(b)); // prints "Bar".

$T/Object syntax

$T/Object indicates that the $T must be a parameterized struct of the type Object. This saves time so that one does not have to type out all the parameters of a parameterized struct. Consider the following example:

Hash_Table :: struct (K: Type, V: Type, N: int) {
  keys: [N] K;
  values: [N] V;
}

function1 :: (table: Hash_Table($K, $V, $N), key: K, value: V) {
  // do stuff
}

function2 :: (table: $T/Hash_Table, key: T.K, value: T.V) {
  // do stuff
}

function3 :: (table: $T, key: T.K, value: T.V) {
  // do stuff
}

All the following ways are correct ways to write functions with parameterized structs. function1 is slightly more verbose and utilizes pattern matching to specify the type, while function2 is less verbose but still specifies that the $T must be a Hash_Table. function3 is the most generic, least verbose, but loses a lot of useful type information. Use whatever way fits ones own programming style.

Implicit Polymorphism

This is another way of writing $T/Object, called implicit polymorphism. In this example, Table is being called directly.

function4 :: (table: Table, key: table.K, value: table.V) {
  // do stuff
}

$T/interface Object syntax

$T/interface Object indicates that the $T must have the fields that Object has. The changelog has this to say:

New experimental feature: keyword ‘interface’ for use with polymorphism. Accepts only types that
contain members declared in the target struct. Basically, this does duck typing in a straightforward
simple way. See uses of the keyword ‘interface’ in modules/Math/module.jai and modules/Math/matrix.jai.

#modify directive

The #modify compiler directive lets one put a block of code that is executed at compile-time each time a call to that procedure is resolved. The #modify directive allows one to inspect parameter types at compile-time.

Here is an example for how to use #modify.

function :: (x: $T)
#modify {
  using Type_Info_Tag;
  if T.type != INTEGER {
    print("$T must be an integer!\n");
    T = null;
  }
}
{  // rest of the function

}

In the example above, setting T = null tells the compiler that $T must be an integer type or it will be a compile-time error.