Type System

Plinth has a very rich type system, that can express anything from primitive integers to arrays of string conversion functions. The full list of sorts of type is:

  • Primitives
  • Arrays
  • Tuples
  • Functions
  • Classes
  • Interfaces
  • Enums
  • Compound values
  • Generic type parameters (with type bounds)
  • Wildcard type parameters (with type bounds)
  • Objects (the universal super-type)

All types in Plinth are non-nullable by default, but all types (including primitives) can be made nullable by prefixing them with a ?.

Similarly, all reference types can be made immutable, which makes it impossible to modify any of their internal state (including deep state stored in objects referenced by them). A reference type can be made immutable by prefixing it with a #. If a nullable and immutable type is required, it can be declared using ?#.

Primitive Types

Plinth has a wide range of primitive types, which account for signed and unsigned integers, floating point numbers, and booleans:

Name Size (bits) Signed? Floating point? Default value Internal Representation
boolean 1 no no false Single bit
ubyte 8 no no 0 8 bit number
ushort 16 no no 0 16 bit number
uint 32 no no 0 32 bit number
ulong 64 no no 0 64 bit number
byte 8 yes no 0 Two’s complement signed 8 bit number
short 16 yes no 0 Two’s complement signed 16 bit number
int 32 yes no 0 Two’s complement signed 32 bit number
long 64 yes no 0 Two’s complement signed 64 bit number
float 32 yes yes 0.0 IEEE 754 single precision floating point number
double 64 yes yes 0.0 IEEE 754 double precision floating point number

Array Types

Arrays are reference types that point to a list of values of an arbitrary fixed size. The size is stored inside the array, as a length field. The values inside the array are all of a specified base type, and are all stored contiguously in memory.

To use an array type, any type can be prefixed with a pair of square brackets. For example:

[]int ints;
[]?string strings;
[][]double matrix;

Multidimensional arrays are really just arrays of arrays, and have no guarantee of being square. The base type of an array can be any other type, and if required may be nullable and/or immutable. Nullability and immutability can be chained together arbitrarily, for example:

[]?[]?#[]?string array;

This refers to a non-nullable array of nullable arrays of nullable immutable arrays of nullable strings.

Tuple Types

Tuples are value types that contain some specific list of other types of value. They can contain one or more other types, and are represented by putting parentheses around a comma separated list of types.

A tuple can be made nullable by putting a ? in front of the bracketed list of types. This allows the whole value to be set to null, but makes it impossible to read individual sub-values without first performing a null check.

Here are some ways you can use tuples:

(string, float) useTuples((int, string) input) {
  (int, string) copy = input;
  (int, string) a, b = copy;
  (string, int) swapped = b, a;

  // underscore means "ignore this assignee"
  (string, int) str, _ = swapped;

  // retrieve the first element (1-based index)
  int x = input ! 1;
  int y = 4;
  y, x = x, y;

  ?(boolean, float) foo = null;
  foo = true, y;

  // To use an element of a nullable tuple, you must remove the nullability first.
  // Here, we use the ?: operator to provide an alternative.
  return str, ((foo ?: (false, 0.0)) ! 2);
}

Class, Interface, and Enum Types

These are custom types that can have arbitrary fields, properties, and methods. Each variable of one of these custom types is a pointer to an object on the heap. They are defined as discussed in the Type Definitions section. By default, these types are neither nullable nor immutable.

Because they are not nullable by default, fields and properties can be accessed and methods can be called on them without the possibility of errors occurring due to the pointer being null. To call a method or access a field on a nullable type, they can either be converted to a not-nullable type first, or the ?. operator can be used. This operator checks whether the object is null before accessing a field or calling a method, and if it is then the call/access is skipped and null is returned instead of whatever the return type was. For example:

Foo foo = new Foo(123);
?Foo fooOrNull = foo;
foo = cast<Foo> fooOrNull;
int x = foo.value;
?int y = fooOrNull?.value;

If a variable holds an immutable class, interface, or enum, it means that the value being pointed to by that variable cannot have any of its fields modified through that variable. For example, trying to alter the value field of a #Foo would be illegal. This also applies to methods: if a method is not marked as immutable, it cannot be called on an immutable object. Since immutable methods cannot modify any of their object’s state, making a variable immutable guarantees that the state of the object it points to cannot be modified through that variable. (This also applies to properties, the same way it does to fields).

The only exception to this rule about immutability is when a field (or sometimes a property) is marked as mutable. This modifier means that the field/property can be modified even when the object is immutable, which is useful for caching. There is no such exception for methods, as the only way to guarantee that they do not (transitively) alter global state is to mark them as immutable.

An important consideration when using immutable types is that they do not always guarantee that the object will not change, only that it cannot be changed through this reference (and even then, the guarantee does not apply to mutable fields). For example, a set could have an immutable lookup method, which could be called on one object’s #Set field, but another object could have a non-immutable reference to the same Set, and modify it while the first object was using it. Therefore, synchronisation must always be considered, even with immutable types.

Value Types

Value (or compound) types are very similar in terms of syntax to class, interface, and enum types, although conventionally their names begin with lower case letters. The only conceptual differences are that they are stored as values instead of on the heap, and that they cannot inherit from other types.

While being stored as a value usually means being stored on the stack, there are exceptions to this depending on where the it is being used: if it is a variable inside an object on the heap, then the value will be stored directly inside that object. The main slightly-counterintuitive exception is when a value-type is cast to an object or used as a generic type parameter (which internally are treated as object): since values of type object are always stored on the heap, converting a value type to one will result in a heap allocation. More notably, converting back from an object to the original value type will result in a reference to the value on the heap, until it is assigned to a variable. For example:

foo x = create foo(123);
object o = x;            // copy to the heap
foo y = cast<foo> o;     // copy back to y on the stack
y.value = 456;
stdout::println(x.value + ", " + (cast<foo> o).value + ", " + y.value); // 123, 123, 456
object o2 = o;           // make another reference to the heap value
(cast<foo> o).value = 789;
stdout::println(x.value + ", " + y.value); // 123, 456
stdout::println((cast<foo> o).value + ", " + (cast<foo> o2).value); // 789, 789

Function Types

Functions are values which can be invoked like a method. The syntax for a function type is:

{int, string -> string} function;

This declares a function which takes an int and a string as arguments, and returns another string. The function can be invoked as if it was a method:

function(1, "hello");

Some more examples of function types are:

{uint -> void} print;
{ -> string} getUUID;
{string -> void throws Exception} send;
?{ -> void} maybeDoSomething;

// this uses default parameters (the names of the default parameters are part of the type)
{string, boolean flush=..., boolean debug=... -> void} write;

// this function is immutable, just as an immutable method can be:
{uint #-> ulong} convert;

As shown, functions can have default parameters, and can be immutable, just like normal methods. They can also be nullable, which prevents them from being called without first being converted to a non-nullable type.

An immutable function can be a tricky thing to understand, as it doesn’t follow the usual concept of immutability that class types adhere to. With a class type you can convert from a normal object to an immutable one, which works because it’s just putting more restrictions on what you can do with it (i.e. you can’t alter its state), but you can’t go back from immutable to normal. However, you /can/ convert an immutable function type to a non-immutable function type, because the immutable function type can be called anywhere, but the normal function type cannot be called from inside an immutable method. Since the normal function type has more restrictions on it than the immutable function type, it is possible to convert from an immutable function to a normal function, but not the other way around.

The object Type

The object type is the super-type of all other types. It can hold any value, and has only the methods that all other types have, such as boolean equals(?#object).

object can be made nullable or immutable, as with class types. This makes ?#object the universal super-type, as all other types can be implicitly converted to it.

You can declare a variable of type object as follows:

object foo;
?#object bar = 123;

Converting something to an object works differently depending on the type being converted. For example, converting a class to an object is a no-op, as classes are already allocated on the heap. However, converting a primitive, compound type, tuple, or function to an object involves an implicit heap allocation, since those types are allocated on the stack by default.

Generic Type Parameters

Wildcard Type Parameters

Table Of Contents

Previous topic

Members

Next topic

Initialisation

This Page