Generic Constraints

Generic constraints enable us to specify the operations and capabilities of type parameters in a generic function, class, interface, enum, or struct declaration. The member functions of type variables can be called only if the appropriate constraints are declared. Generic parameters need to be constrained in many scenarios. The following uses the id function as an example:

func id<T>(a: T) {
    return a
}

Only the function parameter a can be returned, rather than a + 1 or println("${a}"), because the type argument may be any type. For example, id<Bool> would have type (Bool) -> Bool, so its parameter could not be added to an integer. Likewise, the println function cannot print values of arbitrary types. But if constraints are put on the type parameter, more operations on the values of that type are allowed within the body of the generic entity.

Constraints can be roughly categorized into interface constraints and subtype constraints. The syntax is to insert the keyword where followed by one or more comma-separated constraints before the body of a function or type declaration. A constraint in turn has the same syntax as the one used to denote inheritance and interface implementation, with a type variable to the left of <:. For example, if we have declared generic parameters T1 and T2, we can use where T1 <: Interface, T2 <: Type to declare generic constraints on them. Multiple constraints can be put on a type variable using ampersands &, as in where T1 <: Interface1 & Interface2.

Here is an example. Suppose the println function of Cangjie is not overloaded and only accepts values of the type String as its argument. We would naturally want to write a generic wrapper function that would convert its argument to a string and send it to the standard output via println. That wrapper, however, would only work for types that support conversion to a String. We could achieve what we want by constraining the respective type parameter with the ToString interface defined in core:

package core // `ToString` is defined in core.

public interface ToString {
    func toString(): String
}

We can use that constraint when defining a function named genericPrint.

func genericPrint<T>(a: T) where T <: ToString {
    println(a)
}

main() {
    genericPrint<Int64>(10)
    return 0
}

The output is:

10

If the type argument of the genericPrint function does not implement the ToString interface, the compiler reports an error. This example attempts passing a function as a parameter.

func genericPrint<T>(a: T) where T <: ToString {
    println(a)
}

main() {
    genericPrint<(Int64) -> Int64>({ i => 0 })
    return 0
}

If we compile the above program, the compiler would issue an error message about a generic type argument that does not satisfy the constraints. This is because the generic type argument of the genericPrint function does not satisfy the (Int64) -> Int64 <: ToString constraint.

In addition to representing constraints through interfaces, we can also use subtypes to constrain a generic type parameter. In the below example, we want to declare a zoo type Zoo<T>, where T is an Animal and must be constrained accordingly. That is, T must be a subclass of the abstract animal class Animal that declares a member function run. We then define concrete subclasses Dog and Fox implementing the run member function. In this way, in the Zoo<T> type, we can call the run member function for the animal instances stored in the animals array list.

import std.collection.*

abstract class Animal {
    public func run(): String
}

class Dog <: Animal {
    public func run(): String {
        return "dog runs"
    }
}

class Fox <: Animal {
    public func run(): String {
        return "fox runs"
    }
}

class Zoo<T> where T <: Animal {
    var animals: ArrayList<Animal> = ArrayList<Animal>()
    public func addAnimal(a: T) {
        animals.append(a)
    }

    public func allAnimalsRun() {
        for(a in animals) {
            println(a.run())
        }
    }
}

main() {
    var zoo: Zoo<Animal> = Zoo<Animal>()
    zoo.addAnimal(Dog())
    zoo.addAnimal(Fox())
    zoo.allAnimalsRun()
    return 0
}

The program outputs the following.

dog runs
fox runs