Generic Programming

Generic programming in JS++ allows you to define classes, interfaces, and functions with "type parameters." This enables classes and algorithms to be defined once for all applicable data types.

The classic example of generic programming is containers. In the JS++ Standard Library, there are generic container classes such as System.Array<T> and System.Dictionary<T>. Instantiating these classes might look like this:

1
2
3
4
5
6
7
8
9
10
11
12
System.Dictionary<int> intDict = {
    foo: 1,
    bar: 2,
    baz: 3
};
 
System.Dictionary<string> stringDict = {
    "China": "Beijing",
    "Japan": "Tokyo",
    "USA": "Washington, D.C.",
    "UK" : "London"
};

Notice that we declared two different types. intDict had the type System.Dictionary<int> and stringDict had the type System.Dictionary<string>. Both are using the System.Dictionary container, but their type arguments differ. intDict provided the int type argument to System.Dictionary and stringDict provided the string type argument to System.Dictionary. Passing type arguments to System.Dictionary is allowed because System.Dictionary is a generic class that takes a type parameter:

1
2
3
4
5
6
7
module System
{
    class Dictionary<T>
    {
        // ...
    }
}

This allows us to define one Dictionary class that works on all types.

From here, we might further define a generic method as follows:

1
2
3
4
5
6
7
8
9
10
11
class Dictionary<T>
{
    public T[] values() {
        T[] result = [];
 
        // Get all values in dictionary and store in 'result'
        // ...
 
        return result;
    }
}

We can then instantiate the generic class and use the method as follows:

1
2
3
4
5
6
import System;
 
auto dict = new Dictionary<string>({
    foo: "bar"
});
string[] values = dict.values();

Notice that the values() method was defined to return T[] (an array of type parameter T). When we instantiated Dictionary, we provided the type argument string. Therefore, all methods inside the instantiated Dictionary will have all T types replaced with string. When we call the values() method, it returns the replaced type string[] (and not T[]).

We can also instantiate a new dictionary with a different type argument:

1
2
3
4
5
6
import System;
 
auto dict = new Dictionary<int>({
    foo: 1
});
int[] values = dict.values();

This time, the Dictionary was instantiated with the int type argument, and, therefore, the values() method will return int[] (instead of string[] as in the previous example).

As illustrated, generic programming allows us to support multiple different data types for classes and functions while only having to define the class or function once.

Generic Interfaces

In addition to classes, interfaces can also be generic:

1
2
3
4
interface IFoo<T>
{
    // ...
}

Since a single class can implement multiple interfaces, this can be useful with generic interfaces. For example, with the Standard Library's IComparable<T> interface, this can enable a class to be compared with multiple different types:

1
2
3
4
5
6
7
8
9
10
11
import System;
 
class Dog : IComparable<Cat>, IComparable<Rabbit>
{
    public final Comparison compare(Cat that) {
        return Comparison.EQUAL;
    }
    public final Comparison compare(Rabbit that) {
        return Comparison.LESS_THAN;
    }
}

(Please note that implementations of the IComparable interface affects sorting behavior, e.g. for System.Array<T>.sort).

Generic Functions

Free functions (functions that don't belong to a class) and static class methods can also be generic.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import System;
 
bool equals<T1: IComparable<T2>, T2>(T1 a, T2 b) { // Free function
    return a.compare(b) == Comparison.EQUAL;
}
 
class Bar {}
class Foo : IComparable<Bar>
{
    public final Comparison compare(Bar that) {
        return Comparison.EQUAL;
    }
}
 
Console.log(equals(new Foo(), new Bar()));

Generic parameters are hoisted so, in the code above, T2 can be accessed before it is declared.

Generic Constraints

By default, generic parameters are not constrained to any type. In order to limit the type arguments a generic class can accept, a constraint can be specified:

1
class Foo<T: System.Object> {}

As illustrated above, constraints are defined using the colon (:) syntax followed by the type to constrain the generic parameter to. In addition, the where keyword can also be used for defining constraints:

1
class Foo<T> where T: System.Object {}

The alternative where keyword syntax can be used in some cases to enhance readability.

By default, JS++ constraints are subtype constraints. In other words, only the class defined as the constraint and its subclasses (but not superclasses) can be passed as arguments for the constrained generic parameter.

Wildcard Constraint

It's often desirable to pass primitive types (such as string, int, or even external) as arguments. Passing primitive types as arguments is possible in the Standard Library containers such as System.Array and System.Dictionary.

The ability to accept primitive types as type arguments can be enabled using the "wildcard" constraint:

1
2
class Foo<T: *> {}
auto foo = new Foo<string>();

By default, generic classes have no constraints on type arguments. Therefore, the following code is equivalent:

1
2
3
4
import System;
 
class Foo<T> {}
class Foo<T: *> {}

Type arguments with the wildcard constraint have a special toString() method that is always available (regardless of the argument passed in). It is conceivable how this functionality is made available for internal types (e.g. System.Object.toString) and even for external types (JavaScript's Object.prototype.toString). However, there are corner cases where a toString method would not be available (such as ActiveX). In such cases, System.Exceptions.UnsupportedException will be thrown.

Multiple Constraints

JS++ supports multiple constraints for a generic type parameter using the && syntax:

1
2
3
4
5
6
7
8
class Baz {}
interface IFoo {}
interface IBar {}
class GenericClass<T: Baz && IFoo && IBar> {}
 
class Foo : IFoo, IBar, Baz {}
 
new GenericClass<Foo>();

All constraints must be satisifed when using multiple constraints. In the example above, if Foo does not inherit from all of IFoo, IBar, and Baz then it will be an error.

Covariant and Contravariant Types

Most type relationships in JS++ are expressed as subtype relationships. For example, a Dog might be defined as a subtype of Animal, and all variables declared as Animal will accept instances of Dog. However, this subtyping relationship becomes complicated with generic types and containers.

Consider arrays:

1
2
3
4
5
abstract class Animal {}
class Dog : Animal {}
class Cat : Animal {}
 
Animal[] animals = [ new Dog, new Cat ];

In the above code, we have an array of type Animal. At index 0, we have a Dog instance. At index 1, we have a Cat instance.

We can get elements from the above array by always declaring variables to have type Animal, but this isn't always ideal. Sometimes, we want to narrow the type. Other times, we want to use an ancestor type (superclass). This is when covariance and contravariance are needed.

A covariance relationship describes subtyping relationships (e.g. Dog is Animal). A contravariance relationship describes the opposite, a supertype relationship (e.g. Animal is an ancestor of Dog).

This allows us to work with arrays and other generic types with more refined types. By extending our array example, this is what we want to ideally achieve:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import System;
 
abstract class Animal {}
class Tiger : Animal {}
 
abstract class Pet : Animal {}
class Dog : Pet {}
class Cat : Pet {}
 
// For covariance
Array<Pet> animals = [ new Dog ];
animals.push(new Cat); // Yes, because Cat descends from Pet
// animals.push(new Tiger); // ERROR, because Tiger does NOT descend from Pet
 
// For contravariance
Animal dog = animals[0]; // Yes, because Animal is a supertype of Pet
// Tiger tiger = animals[0]; // ERROR, because Tiger is NOT a supertype of Pet

A useful mnemonic for determining when to use covariance versus contravariance is "Producer Extends, Consumer Super" (PECS, from Effective Java by Joshua Bloch). As seen above, a producer (System.Array.push) accepts "extends" relationships (subtype); while a consumer (reading from the animals array at index 0) uses a "super" (supertype) relationship.

By extending the PECS intuition, we can begin to define well-typed generic interfaces. In JS++, the descend keyword is used for specifying covariance (subtype) and the ascend keyword is used for specifying contravariance. We can extend our previous examples with animals and pets by defining a PetCollection class which act on arrays of pets. Following PECS, we must specify producer/write operations that are covariant (descend) and consumer/read operations that are contravariant (ascend):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import System;
 
abstract class Animal {}
class Tiger : Animal {}
 
abstract class Pet : Animal {}
class Dog : Pet {}
class Cat : Pet {}
 
class PetCollection
{
    Pet[] data = [];
 
    void insert(descend Pet[] pets) {
        foreach(Pet pet in pets) {
            this.data.push(pet);
        }
    }
 
    ascend Pet[] get() {
        return this.data;
    }
}
 
auto myPets = new PetCollection();
 
// Write operations (descend, covariance)
myPets.insert([ new Dog, new Cat ]);
// myPets.insert([ new Tiger ]); // not allowed
 
// Read operations (ascend, contravariance)
Pet[] getPets = [];
Animal[] getAnimals = [];
ascend Pet[] tmp = myPets.get(); // read here
foreach(Pet pet in tmp) { // but we still need to put them back into our "result" arrays
    getPets.push(pet);
    getAnimals.push(pet);
}
 
// Now we can modify the arrays we read into above
getPets.push(new Dog);
getAnimals.push(new Dog);
getAnimals.push(new Tiger);
// getPets.push(new Tiger); // ERROR

As illustrated in the examples above, type safety is always preserved by using covariant and contravariant generic types.

As shown in the code above, we specify variance using use-site variance; in other words, variance is defined exactly where it is used. Declaration-site variance specifies variance for an entire class on its generic type parameters and is not available for JS++.

Upcasting/Downcasting

Correct upcasting and downcasting with generic types are possible, but variances must be specified.

For example, the following code will result in a compiler error:

1
2
3
4
5
6
7
import System;
 
class Parent {}
class Child : Parent {}
 
Array<Parent> obj = new Array<Child>(); // ERROR
(Array<Child>) obj; // ERROR

Meanwhile, if variance is specified, the code will compile without errors:

1
2
3
4
5
6
7
import System;
 
class Parent {}
class Child : Parent {}
 
Array<descend Parent> obj = new Array<Child>();
(Array<Child>) obj;

The above example shows downcasting. It's also possible to upcast:

1
2
3
4
5
6
7
import System;
 
class Parent {}
class Child : Parent {}
 
Array<ascend Child> obj = new Array<Child>();
(Array<Parent>) obj;

Share

HTML | BBCode | Direct Link