Classes & OOP: Generic Programming

Roger PoonBy Roger Poon

JS++ Designer and Project Lead

In JS++, generic programming allows you to create classes, interfaces, and functions with type parameters, which allow classes and algorithms to be defined once for all applicable data types.

As we explored in Chapter 10, containers like dictionaries can be declared with a special syntax:

Dictionary<int> dict;

In particular, you will notice the data type 'int' inside the 'Dictionary' data type enclosed with angle brackets (<...>). The angle brackets are used to specify type arguments.

To understand this concept, imagine implementing the 'Dictionary' class yourself:

    class Dictionary
    {
        /* ... */
    }
    

Now, if we want our dictionary to contain string keys and integer values:

    class Dictionary
    {
        string[] keys;
        int[] values;
    }
    

However, what if we want string keys and string values?

    class Dictionary
    {
        string[] keys;
        string[] values;
    }
    

What if we want Boolean values? As you can see, we would have to constantly change the code in the class to support different value data types. Instead, we can use generics:

    class Dictionary<T>
    {
        string[] keys;
        T[] values;
    }
    

In the above code, we declared a generic class and 'T' is a type parameter. As a result, we can now only declare variables of Dictionary<T> by passing a type argument to 'T'. Type arguments can be any valid data type:

    Dictionary<int> intDict;
    Dictionary<string> stringDict;
    Dictionary<bool> boolDict;
    

It should be noted that generic programming, type parameters, and type arguments are compile-time concepts. Your type parameters and type arguments must be code that can be evaluated at compile time (such as data types like 'int').

Shorthands

As we explored in Chapter 10, dictionaries and arrays have special shorthands.

For arrays:

        Array<int> arr = new Array<int>([ 1, 2, 3 ]);
        // is the same as:
        Array<int> arr = [ 1, 2, 3 ];
        // is the same as:
        int[] arr = [ 1, 2, 3 ];
        

For dictionaries:

        Dictionary<int> dict = new Dictionary<int>({ "a": 1, "b": 2 });
        // is the same as:
        Dictionary<int> dict = { "a": 1, "b": 2 };
        

These shorthands are implemented at the language level. For user-defined generic classes, such shorthands are not available:

        class Foo<T> {}

        Foo<int> foo = new Foo<int>();
        

However, this can get repetitive. In our variable declaration, we have to type in 'Foo<int>' twice. Fortunately, JS++ provides a shorthand for this too via the 'auto' keyword:

        class Foo<T> {}

        auto foo = new Foo<int>(); // 'foo' has type 'Foo<int>'
        

The 'auto' keyword provides "local-variable type inference" where the variable type will be inferred based on the data type of the initializer expression. Since our variable initializer instantiates the class 'Foo' with type argument 'int', the data type for our variable will be inferred as 'Foo<int>'.

If you think the 'Dictionary' class name takes too long to type, 'auto' can also be used for dictionaries or even plain classes:

        import System; // provides the 'Dictionary' class

        class Foo {} // plain, non-generic class

        auto dict = { "a": 1, "b": 2 }; // 'dict' has type 'Dictionary<int>'
        auto foo = new Foo(); // 'foo' has type 'Foo'
        

Multiple Type Parameters

So far, we've learned how to declare a generic class with one type parameter, 'T'. ('T' is a naming convention and can be any valid identifier name. See the naming conventions documentation, which also provides guidance for generic programming.)

However, generic classes can actually be declared with any number of type parameters. For example, let's say we wanted to create a custom hash map class. Unlike our dictionaries, the hash map class should accept any data type for its keys and any data type for its values. Such a hash map class may look like this:

        class HashMap<K, V>
        {
            K[] keys;
            V[] values;
        }
        

Constraints

JS++ allows us to specify constraints for our generic class type parameters. Constraints will check that only a specific data type and its descendants are passed as type arguments.

Here's an example:

        class Animal
        {
        }

        class Lion : Animal {}
        class Tiger : Animal {}
        class Cup {}

        class Zoo<T: Animal>
        {
        }

        auto lionZoo = new Zoo<Lion>();
        auto tigerZoo = new Zoo<Tiger>();
        // auto cupZoo = new Zoo<Cup>(); // Error
        

As seen in our example, we restricted our 'Zoo' class to only accept type arguments that are 'Animal' types. (The constraint is expressed with the ':' colon syntax.) Thus, we can have the compiler check that we don't create a zoo full of 'Cup' objects.

'external' Type Argument

Now that we have an understanding of generic programming basics, there is one useful feature to understand.

One of the reasons JS++ is so useful is its compatibility with JavaScript. You can mix 'var' and 'function' with safe code seamlessly. Generic programming in JS++ extends this and allows you to specify 'external' as a type argument:

        var a = 1, b = false, c = "str";
        Array<external> arr = [ a, b, c ];
        

The concept may initially be difficult to wrap your head around, but it's useful. Essentially, you have an internal JS++ 'Array' with 'external' elements. You can use any of the JS++ methods defined for 'Array', but not the methods exclusive to JavaScript arrays. The elements are 'external' so any operation permitted for 'external' will be allowed on the individual elements.

Another example is the 'Dictionary' class:

        var a = 1, b = false, c = "str"; 
        Dictionary<external> = { "a": a, "b": b, "c": c };
        

JS++ dictionaries are not JavaScript objects. In JS++, dictionaries are just key-value pairs with string keys. You can't define a 'prototype' property on 'Dictionary<external>' or perform prototypal inheritance.

Covariance and Contravariance

Covariance describes subtyping relationships (such as "Cat" is "Animal"), and contravariance describes supertype relationships ("Animal" is an ancestor of "Cat").

Covariance and contravariance are important to understand in generic programming. Oftentimes, generic classes are used for specifying containers. Containers, like arrays, suffer from a problem like the following:

        import System;

        class Animal {}
        class Tiger : Animal {}

        class Pet : Animal {}
        class Dog : Pet {}
        class Cat : Pet {}

        Array<Dog> dogs = [ new Dog ];
        Array<Animal> animals = dogs; // error
        

If you compile the above code, you might encounter the following error:

[ ERROR ] JSPPE5000: Cannot convert `System.Array<Dog>' to `System.Array<Animal>' at line 11 char 24

The reason this happens is because type arguments are invariant by default. In other words, Array<T> != Array<E>; in this case, Array<Dog> != Array<Animal>. Yet, we know an array of dogs is an array of animals, but JS++ considers this operation potentially unsafe because you may then push a 'Cat' to the array. In order to safely permit this operation, you must specify the variance.

We know that 'Dog' descends from 'Animal'. Thus, to make the operation safe, all we have to do is specify that:

Array<descend Animal> animals = dogs;

The 'descend' keyword specifies "covariance." Covariant generic types are read-only. You cannot push a 'Cat' into the new 'animals' array. While you cannot modify the array, you can still iterate over it and read the values. For example, using a 'foreach' loop:

        import System;

        class Animal {}
        class Tiger : Animal {}

        class Pet : Animal {}
        class Dog : Pet {}
        class Cat : Pet {}

        Array<Dog> dogs = [ new Dog ];
        Array<descend Animal> animals = dogs;
        foreach(Animal a in animals) {
                Console.log(a);
        }
        

We can extend this understanding further to deal with reads and writes. A mnemonic for generic programming is PECS (Producer Extends, Consumer Super). In other words, producers should accept covariant type relationships, and consumers should accept contravariant type relationships.

This is best illustrated with an example:

        import System;

        class Animal {}
        class Tiger : Animal {}

        class Pet : Animal {}
        class Dog : Pet {}
        class Cat : Pet {}

        class AnimalArray
        {
                Array<Animal> animals = [];

                void pushAllFrom(Array<descend Animal> src) {
                        foreach(Animal a in src) {
                                animals.push(a);
                        }
                }

                void popAllInto(Array<ascend Animal> dst) {
                        for(int i = animals.length; i --> 0; ) {
                                dst.push(animals.pop());
                        }
                }
        }

        AnimalArray animals = new AnimalArray();
        Array<Dog> producer = [ new Dog ];
        animals.pushAllFrom(producer);
        Array<Object> consumer = [];
        animals.popAllInto(consumer);

        Console.log(consumer);
        

In our example, we create a custom array, 'AnimalArray'. The 'AnimalArray' encapsulates a private 'Array<Animal>' array for its internal representation. In addition, 'AnimalArray' provides two methods: 'pushAllFrom' and 'popAllInto'.

'pushAllFrom' is a producer so it uses a covariance relationship ("extends"). 'pushAllFrom' is a producer because it populates ("produces") the internal 'Array<Animal>' array. Consequently, it should accept all subtypes of 'Animal' since an array of 'Animal' objects should accept 'Dog' and 'Cat' objects. Thus, 'pushAllFrom' is able to accept an array of 'Dog' objects:

        Array<Dog> producer = [ new Dog ];
        animals.pushAllFrom(producer);
        

Unsurprisingly, covariant generic types in JS++ are read-only. We only need to be able to read over the array passed in as an argument to 'pushAllFrom'. We should not be able to write to it and modify the array by pushing a 'Cat' object into it. Otherwise, our 'producer' variable (of type 'Array<Dog>') will contain a 'Cat' object!

In contrast, 'popAllInto' is a consumer so it uses a contravariance relationship ("super"). 'popAllInto' is a consumer because it returns data to be consumed elsewhere. Therefore, it should use a contravariance relationship. As seen in the code example:

        Array<Object> consumer = []; 
        animals.popAllInto(consumer);
        

In the example, our consumer uses the superclass of all JS++ classes: System.Object (or just 'Object' if we're using partial qualification since we've imported the 'System' module). Since System.Object is a supertype of 'Animal', this is an acceptable relationship. If we changed our class to encapsulate a 'CatArray' or 'DogArray', it would likewise follow that the 'popAllInto' method should allow 'Cat' or 'Dog' objects to be written into an 'Array<Animal>' variable.

While covariant generic types are read-only, contravariant generic types are write-only. In fact, the 'popAllInto' method does mutate the input array by calling the 'pop' method on each iterated element.

Upcasting and Downcasting with Variance

With generic programming, the variance must be specified when upcasting and downcasting. For example, the following will fail:

        import System;

        class Parent {}
        class Child : Parent {}

        Array<Parent> obj = new Array<Child>(); // ERROR
        (Array<Child>) obj; // ERROR
        

However, if we specify the variance, the cast will succeed:

        import System;

        class Parent {}
        class Child : Parent {}

        Array<descend Parent> obj = new Array<Child>();
        (Array<Child>) obj;