Generics

Overview

CSL’s type system does not have a notion of generic types like that of C++ or Java. Instead, generic programming is achieved through CSL’s comptime features. The basic idea is that type is a type, with values such as i16, i32, bool, and so on, and one can perform computation using types at comptime.

A very simple generic function looks something like:

fn identity(comptime T: type, x: T) T {
  return x;
}

task t() void {
  var arg: i16 = 1;
  var one = identity(i16, arg);
}

Since type is a comptime-only type just like comptime_int, comptime_float, or comptime_string, T must be marked comptime in order to appear as the type for x and as the function’s return type.

CSL’s generics resemble C++ templates in some ways. Generic functions are monomorphized by the compiler. This means a generic function does not exist at runtime; instead, a copy of the function is compiled using each set of type arguments it is called with. The program will compile as long as each such copy is well-formed. An implication is that, for instance, a generic function that uses the unary - operator will not compile if it is called with a type like comptime_string that cannot be used with unary -:

fn negate(comptime T: type, x: T) T {
  return -x;
}

task t() void {
  negate(f16, -3.14); // OK
  negate(i16, 42); // OK
  negate(comptime_string, "hello"); // error
}

anytype

While explicitly passing a parameter of type type to a generic function is a straightforward mechanism, it can be verbose. For the user of a library that provides an abs function, there is not much benefit to writing abs(f32, 1.0 - x) versus a non-generic equivalent of abs_f32(1.0 - x). The anytype keyword provides a solution.

anytype can only appear as the type of function parameters. It is another way to write a generic function and it has the same effect of creating a version of the function for each type that it is called with. When declaring parameters with anytype, the @type_of builtin is useful to relate the types of parameters and the return value to each other:

fn ignore_x(x: anytype, y: @type_of(x)) @type_of(x) {
  return y;
}

task t() void {
  var arg: i16 = 1;
  var two = ignore_x(arg, arg + 1);
}

Generic structs are also supported using the same notion of comptime computation with types as generic functions:

fn Point(comptime T: type) type {
  return struct {
    x: T,
    y: T,
  };
}

const origin = Point(u16) { .x = 0, .y = 0 };
comptime {
  @comptime_print(origin); // {x = 0, y = 0}
}

The generic Point above is a function that takes a type parameter and returns a struct parameterized by that type.

Constraining Type Parameters

If a generic function is called with an invalid type, an error occurs when the compiler discovers that the generic function’s body is trying to do something invalid with its argument. This is typically a lower-level error than the actual mistake of calling the function with an incorrect argument type:

fn abs(x: anytype) @type_of(x) {
  if (x < 0.0) {
    return -x;
  }
  return x;
}

task t() void {
  abs("hello");
  // error: invalid comparison operation for type: 'comptime_string'
}

Here it is not so hard to piece together what went wrong, but if abs were a more complicated function, the mistake will be less obvious. Programmers who have used C++ templates may find this situation familar.

Instead, the @is_same_type builtin can test the provided type and fail a comptime assertion if it is invalid:

fn abs(x: anytype) @type_of(x) {
  const T = @type_of(x);
  @comptime_assert(
    @is_same_type(T, f16) or @is_same_type(T, f32),
    "x is not a float");
  if (x < 0.0) {
    return -x;
  }
  return x;
}

task t() void {
  abs("hello"); // error: comptime_assert failed: x is not a float
}

Specializing Logic

A related scenario is writing a generic function where a portion of the logic is only valid for some of the types over which one wants to define the function. Consider the example of sign from the <math> library. This function returns -1 if its argument is negative, 1 if it is positive, and 0 if it is zero. sign could naïvely be written like:

fn sign(x : anytype) @type_of(x) {
  if (x < 0) {
    return -1;
  } else if (x > 0) {
    return 1;
  }
  return x;
}

comptime {
  var x: i16 = 12;
  @comptime_print(sign(x)); // 1
}

math.sign allows x to be an unsigned integer. While sign(<some u16>) is not quite as interesting as sign of a float or signed integer, it is perfectly valid to allow. However, the above code would not compile if passed a u16 because -1 is not a valid u16.

To solve this problem, guard the if (x < 0) case with a check for the argument type:

fn is_signed(comptime T: type) bool {
  return @is_same_type(T, f16) or @is_same_type(T, f32)
    or @is_same_type(T, i8) or @is_same_type(T, i16)
    or @is_same_type(T, i32);
}

fn sign(x : anytype) @type_of(x) {
  if (comptime is_signed(@type_of(x))) {
    if (x < 0) {
      return -1;
    }
  }
  if (x > 0) {
    return 1;
  }
  return x;
}

comptime {
  var x: i16 = -12;
  var y: u16 = 25;
  @comptime_print(sign(x), sign(y)); // -1, 1
}

Evaluating the if condition at comptime ensures that the if (x < 0) case is only compiled at all if the type is correct.

There is one final change that needs to be added to properly support floats:

// using same is_signed() as above

fn sign(x : anytype) @type_of(x) {
  const T = @type_of(x);
  if (comptime is_signed(T)) {
    if (x < @as(T, 0)) {
      return @as(T, -1);
    }
  }
  if (x > @as(T, 0)) {
    return @as(T, 1);
  }
  return x;
}

comptime {
  var x: i16 = -12;
  var y: u16 = 25;
  var z: f16 = 0.0;
  @comptime_print(sign(x), sign(y), sign(z)); // -1, 1, 0
}

Since 1 and 0 are comptime_ints, they do not automatically convert to floats.

Computing With Types

As the previous use of @type_of alludes to, type specifiers can be any expression that has type type:

fn Point(comptime T: type) type {
  return struct {
    x: T,
    y: T,
  };
}

fn make_point(n: anytype) Point(@type_of(n)) {
  return Point(@type_of(n)) {
    .x = n,
    .y = n + 1,
  };
}

comptime {
  @comptime_print(make_point(3)); // {x = 3, y = 4}
}

A generic function can also abstract over properties of a type:

fn size_of_int(comptime T: type) comptime_int {
  return if (@is_same_type(T, i8)) 1
    else if (@is_same_type(T, i16)) 2
    else if (@is_same_type(T, i32)) 4
    else @comptime_assert(false, "not an int");
}

comptime {
  const word_type = i16;
  @comptime_print(size_of_int(word_type)); // 2
}

For a slightly more complex example, we can combine these two techniques to generically convert a float to its binary representation and extract the mantissa. The @comptime_asserts in helper functions also take care of validating that the type parameter is a float.

fn bits_type(comptime T: type) type {
  return if (@is_same_type(T, f16)) u16
    else if (@is_same_type(T, f32)) u32
    else @comptime_assert(false, "not a float");
}

fn mantissa_len(comptime T: type) comptime_int {
  return if (@is_same_type(T, f16)) 10
    else if (@is_same_type(T, f32)) 23
    else @comptime_assert(false, "not a float");
}

fn mantissa_mask(comptime T: type) comptime_int {
  return comptime (1 << mantissa_len(T)) - 1;
}

fn get_mantissa(x: anytype) bits_type(@type_of(x)) {
  const T = @type_of(x);
  const bits = @bitcast(bits_type(T), x);
  return bits & mantissa_mask(T);
}

comptime {
  var x: f16 = 1.5;
  @comptime_print(get_mantissa(x)); // 512 (== 0x200)
  @comptime_print(get_mantissa(@as(f32, x))); // 4194304 (== 0x400000)
}

The <math> library internally uses this pattern to generically implement IEEE floating point functions like isNaN, isInf, and even ceil and floor.