Typing in object-oriented languages

A fundamental design principle in object-oriented languages is supposed to be: "everything's an object". Unfortunately, this is usually not true: the fundamental types are treated differently. This means that while it is easy to create, say, an array class that contains objects, it is impossible to create an array of integers. This is not an unknown problem, and various ways of solving the problem have been used. The simplest solution is to create an integer object class using the fundamental type. C++ uses a more complex system of templates to introduce 'true polymorphism'. CINTOL uses a form of polymorphism akin to that used in functional languages such as ML.

The problem with creating new classes is twofold: there are now two types with the same purpose, that an object will usually be accessed through method calls rather than directly (if those calls are 'virtual', then it will also require more memory than the simple type), and that types can't always be checked at compile-time and sometimes have to be checked at run-time - leading to a potential for serious bugs to go uncaught, and requiring that the compiled program run more slowly. Using templates solves both these problems: you can just use the original storage class. However, templates have their own serious problem: they are so polymorphic that it is actually impossible to re-use compiled code; at the very least a compiler will produce a new set of functions for every different set of type parameters. This isn't always necessary: CINTOL's polymorphism looks superficially like C++'s templates. However, the range of operations on a variable with a variable type is limited to only those that apply to all types (basically assignment). This limitation means that the same compiled code can be re-used to create the object, and means that it's perfectly possible to create an array class that can consist of objects or fundamental types, with the additional benefit of compile-time type checking. The limitation that makes this possible is the problem, however: you could not add a method to this array class to sort the objects it contains - there is no way to discover a method to use to compare two items.

What is object-orientation, anyway?

The answer to this question is fundamental to developing a solution to this problem. Object-orientation is a form of polymorphism, with the design intent of increasing the amount of code re-use in a program: a function declared to act on a particular class will also act on that class's subclasses. Consider the ML function:

  fun f X = X;

(The type of which is fn: 'a -> 'a). This should be equivalent to an object-oriented function similar to:

  object f(object X) { return X; }

Where 'object' is the base class - obviously it is not in most OO languages, because there is no base class for the fundamental types. Object-oriented languages differ from ML in that their type system is divided into classes: there is no equivalent in ML to:

  number n(number X) { return X; }

That is, the identity function only applied to numbers (so that n(4) would be valid, but n("foo") would not). The use of this is not just type-safety: a class also defines the operations that may be performed on it, so this is possible:

  number add(number X, number Y) { return X+Y; }

This would not be possible without knowing that X and Y are numbers, and consequently there exists an addition operation for them. This raises a whole new issue - one not unique to object-oriented languages: what happens if X and Y are of differing types? What happens if you do add(1.4, 3), and does it differ from add(3, 1.4)? Object-oriented languages are naturally weakly-typed, and it is not unusual to encounter the problem of strengthening the type semantics of a function. One way is to introduce type schema, perhaps with a syntax similar to the following:

  number ofclass T add(number X ofclass T, number Y ofclass T)
    { return X+Y; }

Here, the compiler must check that the type condition - that T is the same for all variables - is true. C++'s templates allow you to do just this (at the cost of generating a new add function for every different value of T). In a language such as Java, this is usually done by checking the types at runtime. Now there is no mystery about what add() does: the previous definition could take two floating point numbers and return an integer. This definition must return a floating point number, or the compiler will reject the code.

To summarise, object-orientation is a form of explicit polymorphism that groups types into classes that share a common set of operations. Each class is itself a type, so ideally must have a superclass: to give an object-oriented language the capabilities of a language such as ML, there must be an ultimate superclass, which all types are a subclass of (object in my examples).

Issues with this language design

The main problem with this "everything's an object" approach is that to provide the flexibility that allows functions such as add(), the compiler must either implement an add for every possible type and combination of types, or a number object must be able to carry the information about how the '+' operation is implemented with it. One method leads to large code size, especially when the number of classes becomes large. The obvious solution to the other method leads to an item that must carry a lot of baggage around with it: at the very least, each number must know its true type in addition to its value. While the overhead of a table lookup and function call may be acceptable for a more complex type, the extra time cannot really be justified for a simple type such as an integer. This is why most object oriented languages treat these simple types seperately.

The solution is relatively simple, and can be applied to more than just the 'fundamental' types. The 'baggage' is unnecessary so long as the compiler knows the creation type of the object. In that case, the compiler knows the exact types of the operations associated with a function, so an object created as an integer can be treated as an integer would be in any other language, until it is required to act as an object.

This is easy to do when an object is only used within a single function. What if we have a function:

  integer addi(integer X, integer Y) { return X+Y; }

It is possible that the program will call this function with an object which is a subclass of integer, but it is still desirable that the 'optimised' form is used when both arguments are integers. There are a few possible solutions. Firstly, the compiler can produce multiple versions of the addi function. There are a total of 4 possible versions, with X and Y being one of a 'real' integer or a subclass. The compiler can choose which function to call based on its knowledge of the arguments. The issue is one of code bloat: this works fine when a function has a small number of arguments, but the number of 'new' functions required increases exponentially as the number of arguments increases. A compiler can address this by only generating the most commonly used forms, as the form where all arguments are considered to be possible subclasses fits all cases. Another possibility is to amend the language to have a 'final' form of a class method. In this case, if the + operator is 'final' in the integer class, then the compiler knows which method must be called for all cases of the function. Obviously, this technique can be combined with the first if required. A third technique, encouraged in C++, is function inlining. For simple functions, this can work well, not increasing the code size significantly, cutting out the need for a function call, and allowing the compiler to carry information about the types of the arguments into the function body. However, not all functions can be inlined, and code size can start to increase rapidly if large functions are inlined.

A note about the first technique: although the number of possible functions does increase exponentially, it still increases at a slower rate than in C++ if all the arguments were templates, as there are only two possible 'types' for each argument. Also, the use of type schema reduces the possibilities:

  integer ofclass T addi(integer X ofclass T, integer Y ofclass T)
    { return X+Y; }

In this case, only the type of 'T' may change, so there are only two possible functions to generate, not four.

... STILL BEING WRITTEN ...


Notes

object f(object X) { return X; } is not equivalent to the ML function in many object-oriented systems, because in these systems, objects are assigned by reference and not by copying. Obviously with the type system described here, assignment and return must always copy the object.


Written by Andrew Hunter, for no particular reason.