Start Lecture #12
Think of a routine to sort an array of integers using heapsort. Now imagine you need a routine to sort an array of
The sorting logic remains the same in all cases. For the first three cases, you just want to change the type of some variables from int to float to string. For the last case, you need to change the definition of < as well.
Generics are good for this.
Generics are perhaps most widely used for containers, structures that contain items, but whose structure doesn't depend on the type of the items contained. Examples include, stacks, queues, dequeues, etc. Normally, the containers are homogeneous, that is, all of the items in a given container are of the same type. We don't want to re-implement the container for each possible item type.
Inheritance is also used for containers, indeed, containers formed one of our chief examples (lists, queues, and dequeues).
The the comments that follow are inspired by words in section 9.4.4 (part of the chapter on inheritance). I deferred it to here since we did inheritance before generics so could better compare the two when doing generics.
When studying inheritance, we considered a gp_list_node class and derived from that int_list_node, float_list_node, etc. This seemed pretty good since all the list properties (predecessor, successor, etc) were defined in the gp_list_node class and thus inherited by each *_list_node class.
int_list_node *q, *r; ... r = q->successor(); // error: type clash
gp_list_node *p = q->successor(); cout << p.val; // error: gp_list_nodes have no val
r = (int_list_node*) q->successor(); cout << r.val // OK, but scary
r = dynamic_cast<int_list_node*> q->successor() cout << r.val // OK if have vtable
int_list_node* successor() { return (int_list_node*) gp_list_node::successor(); }
However, problems remain. The first frame represents what I would consider natural code for marching down a list built of int_list_nodes. It fails because successor() returns a gp_list_node*, which cannot be nakedly assigned to an int_list_node*.
The second frame shows the result of quickly applying the motto
fix it now; understand it later
.
The third frame shows a real fix. However, a nonconverting type cast is always scary.
The fourth frame also gives a real fix. However, it wouldn't work for the specific code we had since there were no virtual functions and hence no vtable. We certainly could have added a virtual function and, more importantly, a large project would very likely have one.
Clients of our class would not be happy with this awkward solution
and hence we would likely revise int_list_node
to redefine
successor() as shown in the next frame.
This last solution seems the best of the possibilities, but is far from wonderful since we needed to redefine methods whose functionality did not change. We have lost some of the reuse advantages of inheritance!
template<class V> class list_node { list_node<V>* prev; list_node<V>* next; list_node<V>* head_node; public: V val; list_node() // SAME code as in orig list_node<V>* predecessor() { // SAME code as in orig if (prev == this || prev == head_node) return 0; return prev; } list_node<V>* successor() {...} void insert_before(list_node<V> new_node) {...} ... }; template<class V> class list { list_node<V> header; public: list_node<V>* head() {...} void append(list_node<V> *new_node) {} ... }; typedef list_node<int> int_list_node; typedef list<int> int_list; ... int_list numbers; // list<int> without typedef int_list_node *p; ... p = numbers.head(); ... p = p->successor(); // no cast required!
The Solution with Generics On the right we see the solution with generics. The classes list_node and list have been parameterized with the type V. Think of instantiating this with V=int and then instantiating it again with V=float.
With V=int we see that the first public field becomes int val, which is perfect (i.e., is exactly what it was for int_list_node previously).
The prev field is of type list_node<int>*, which is reasonably close to int_list_node.
In order to permit the clients to use int_list_node, they need only include a typedef as we have done.
Finally, note that the code to march down a list is the natural linked list code. All in all this use of generics turned out quite well.
Having shown by an example that inheritance, which we have already studied, does not by itself eliminate the need for generics, we turn our attention to a proper treatment of generics itself.
We just looked at a linked list example where the type of the data
items (the val field) can vary.
For now just think of int and float.
If we were to explicitly declare the data item, as is needed in
languages like Pascal and Fortran, then we would need separate
routines for ints and floats.
The difficulty is not in producing the multiple versions (copy/paste
does most of the work), but rather in keeping them consistent.
In particular, it is a maintenance headache to need to remember to
apply each change to every copy
.
We could have the lists contain pointers to the data item rather than the items themselves (cf. reference vs. value semantics). This would be the likely solution in C. Of course a pointer to an int, i.e., an int* is not type compatible with a float*, so we play the merry game of casts and kiss off the advantages of compile time type checking. An alternative in C is to use the macro facility to produce all the different versions. As we have seen when studying call-by-name, macro expansion requires care to prevent unintended consequences.
We could use implicit parametric polymorphism as we have seen in Scheme and ML. The disadvantage of the Scheme approach (shared with many scripting languages) is that type checking is deferred to run time. ML is indeed statically typed, but this does cause the compiler to be substantially slower and more complicated (not noticeable for the tiny programs we wrote). Moreover, ML has been forced to adopt structural type equivalence instead of the currently favored name equivalence. Finally, we saw early in the course that ML's treatment of redeclaration differs from that chosen by most languages supporting the feature.
Language | Model |
---|---|
C | Macros (textual substitution) or unsafe casts |
Ada | Generic units and instantiations |
C++, Java, and C# | Templates |
ML | Parameteric polymorphism, functors |
In this section we will study explicit polymorphism in the form of generics (called templates in C++). The different models used for generics are summarized in the table on the right.
Ada generics were in the original design and Ada 83 implementations included generics. Generics were envisioned for C++ early in its evolution, but only added officially in 1990. C# generics were also planned from the beginning but didn't appear until 2004 (release 2.0). Java, in contrast, had generics omitted by design; they were added (to Java version 5) only after strong demand from the user community.
As we have stated a generic facility is used to have the same code apply in a variety of different circumstances. In particular, it is to be applied for different values of the parameters of the generic.
Let first consider a simple case. If we had a generic array, the natural parameters would be the array bounds and the type of the components. Indeed the definition of a (1-dimensional, constrained) array in Ada is of the form
<array_name> : array <discrete_subtype> of <component_type> ;The discrete subtype is characterized by its bounds. Higher dimensional arrays simply have more <discrete_subtypes>. (Ada also has unconstrained array types, that we are not discussing).
So for a given array declaration, we supply the index bounds and the type of the components; these are the parameters.
An (ordinary) subprogram is also generic in that it can be applied to various values for its arguments; those arguments are the parameters.
Construct | Parameter(s) |
---|---|
array | bounds, element type |
subprogram | values (the arguments) |
Ada generic package | values, types, packages |
Ada generic subprogram | values, types |
C++ class template | values, types |
C++ function template | values, types |
Java generic | classes |
ML function | values (including other functions) |
ML type constructor | types |
ML functor | values, types, structures |
In all cases it is important to distinguish the parameters from the construct being parameterized. For example, we parameterize a subprogram by supplying values as parameters.
Turning to the new constructs, we see that in many cases types can be parameters. So an Ada generic subprogram for sorting would take a type parameter and thus can serve as an integer sorter, a float sorter, etc.
There is a weird (mis)naming convention you should understand and sadly must deal with. Recall from above the C++ phrase template<class V>. The requirement is that V must be a type. Moreover, there is an alternative syntax template<typename V> that is perfectly legal and completely descriptive. However, probably from habit, it is not normally used. I found a clear explanation here.
generic type T is private; procedure swap (A : in out T; B : in out T);
procedure swap (A: in out T; B : in out T) is Temp : T; begin Temp := A; A :=B ; B := Temp; end swap;
with swap; procedure Main is procedure SwapInt is new Swap(Integer); X : Integer := 3; Y : Integer := 4; begin SwapInt(X,Y); end Main;
On the right is the familiar three part Ada example: specification, body, use.
We offer to clients a generic procedure Swap; the genericity is parameterized by a type T (private is explained below).
The body implements Swap, which is trivial. Note that here the type T is treated as a normal type.
The client instantiates a new procedure SwapInt, which is the generic Swap, with the generic class T replaced by the specific class Integer. The instantiated procedure SwapIntis used just as if it were defined normally.
What about the private in the specification? The purpose of this private (or of other possibilities) is can be seen from two viewpoints: the client or the author of the body. From the author's viewpoint, client is saying what properties of T can be used. The private classification means that variables can be declared of type T and can be assigned and compared for equality. No other properties can be assumed. For example, the author cannot ask if one type T value is less than another (so we couldn't write a maximum function). A more restrictive possibility would be limited private in which case assignment and equality comparison are forbidden as well. So, from the author's viewpoint, the more restrictive the requirement, the less functionality is available.
The more the author is constrained, the less the less the client is constrained since whatever functionality the author can assume about type T, the client must guarantee his specific type supplies.
generic type T is private; with function ">" (X,Y : T) return Boolean; function max (A,B : T) return T;
function Max (A,B : T) return T is begin if A > B then return A; end if; return B; end Max;
with Max; procedure Main1 is function MaxInt is new Max(Integer,">"); X : Integer; begin X := MaxInt(3,4); end Main1;
As just mentioned, the generic type parameter cannot, by default, be assumed to supply a > operator. So to implement a generic max, we need to pass in the > as a parameter.
The first frame again has the specification. We see that the second generic parameter is >. Don't read too much into with; it is there to prevent a syntactic ambiguity.
Frame 2 is a standard maximum implementation.
In the last frame we see that we are supplying > for >. Since this is so common, there is extra notation possible in the specification to make it the default. In any event, when MaxInt is instantiated the compiler checks to see that there is a unique operator visible at that point that matches the signature in the specification.
As a not-recommended curiosity, I point out
function MinInt is new Max(Integer, "<");
generic MaxSize: Positive; type ItemType is private; package Stack is procedure Push(X: ItemType); function Pop return ItemType; end Stack;
package body Stack is S: array(0..MaxSize-1) of ItemType; Top: Integer range 0..MaxSize; procedure Push(X: ItemType) is begin S(Top) := X; Top := Top+1; end Push; function Pop return ItemType is begin Top := Top-1; return S(Top); end Pop; begin Top := 0; end Stack;
with Stack; procedure Main2 is package MyStack is new Stack(100,Float); X: Float; begin MyStack.Push(6.5); X := MyStack.Pop; end Main2;
Here we see a generic parameter that is not a type. The parameter MaxSize is a value, in this case a positive integer. The third possibility in which a package is a parameter to another package is not illustrated.
The specification has the generic prefix and then looks like a stack package. It offers the standard push and pop subroutines. The MaxSize parameter is not used here; it is needed in the body.
The package body is completely unremarkable. Indeed, if you just substitute some positive value for MaxSize and some type for ItemType, it is a normal stack package body. It can certainly be criticized for not checking for stack overflow and underflow.
The client code is also not surprising, but does have a curiosity. MyClient.Push looks like OOP. It isn't really since MyClient is a package not an object, but it does look that way. Indeed, you could remove both generic parameters, changing the specification and body to use say 100 and float and still create several new packages MyStack1, Mystack2, etc. Then you would have MyStack1.Push(5.2);, X:=MyStack2.pop;, etc.
template<typename itemType, int maxSize> class stack { int top; itemType S[maxSize]; public: stack() : top(0) {} void push (itemType x) { S[top++] = x; } itemType pop() { return S[--top]; } };
#include <iostream> #include "stack.cpp" int main(int argc, char *argv[]) { stack<float, 50> myStack; float x; myStack.push(4.5); x = myStack.pop(); }
SameGeneric Stack Package in C++
On the right is the C++ analogue of the Ada stack package above. The first frame is the body (implementation), the specification (interface) is not shown. It is clearly much shorter than the Ada version. The only functionality missing is the range specification for Top; the rest is the difference between relative emphasis the two languages place on readability vs conciseness.
The client code in the next frame is almost the same as the Ada version.
public class Stack<T> { final int maxSize; int top; T[] S; public Stack(int size) { maxSize = size; top = 0; S = (T[]) new Object[maxSize]; } public void push(T x) { S[top++] = x; } public T pop() { return S[--top]; } }
public class MainClass { public static void main(String args[]) { Stack<Float> myStack = new Stack<Float>(10); Float X; myStack.push(3.2F); X = myStack.pop(); } }
SameGeneric Stack Package in Java
There is a difference in the Java implementation. The generic parameters must be classes; in particular the integer maxSize cannot be specified as a generic parameter. Instead we pass the size as an argument to the constructor. Also, as usual, the reference semantics of Java means that declaring a stack only gives a reference so we need to call new.
The biggest change is that Java does not permit us to say new T so we must produce an Object and cast it to T using an unsafe cast.
The client code has a change as well. Note the capital F in Float; this is a class, rather that the primitive type float, which would be illegal.
public class Stack<T> { readonly int maxSize; int Top; T[] S; public Stack(int size) { maxSize = size; top = 0; S = new T[m_Size]; } public void Push(T x) { S[top++] = x; } public T Pop() { return S[top--]; } }
public class MainClass { public static void Main(String args[]) { Stack<float> myStack = new Stack<float>(10); float X; myStack.push(3.2F); X = myStack.pop(); } }
SameGeneric Stack Package in C#
C# looks
like Java, but there are differences.
In a sense C# is between
C++ and Java.
The keyword final has become readonly and, in the
client code, Main is capitalized.
These are trivialities.
Much more significantly two of the Java quirks are missing.
We now write new T directly, eliminating the unsafe cast
and the client can use the primitive type float rather than
a class that acts as a wrapper (and increases overhead).
In summary, we can say that for simple cases like the above stack, the C++, C#, Java, and Ada solutions are basically the same from the programmer's viewpoint (with the exception of no primitive types or new Tin Java). We shall see that the implementations are different.
In Ada and C++, each instantiation of a generic is separate. So
In contrast to the separate instantiation characteristic of C++ and Ada, all instantiations of a given Java generic will share the same code. One reason this is possible is that, due to its reference semantics, all the elements are of the same size (a pointer). Another reason is that only classes, and not primitive types or values, can be used as generic parameters. In the next section, we will see that the strong compatibility requirements of Java have limited the power of its generics facility.
The C# language, like Java, has much code sharing, but without the
limitations caused by Java's type erasure
implementation
described below.
C# does permit primitive types as generic parameters.
A C# generic class does share code between all instantiations using
classes as parameters (the only possibility for Java), but produces
specialize code for each primitive type instantiated.
As with Java, the C# generic parameters must be types, not values.
The designers of Java version 5 very much wanted to maintain strong compatibility with previous versons of Java that, of course, did not include generics. Indeed, the compatibility requirements were not only that legacy Java would run together with Java 5 code, but also that the JVM would not need to change. As a result they implemented generics using type erasure.
The idea of type erasure, as the name suggests, is that the generic type is, in essence, erased from the generic class. First, the compiler removes the <T> from the header. Then all occurrences of T in the generic body are replaced by object, the most general class in Java. Finally, the compiler inserts casts back to T whenever an Object is returned from a generic method.
These casts are reminiscent of the best
of the non-generic
solutions for a linked-list that we discussed
previously.
A significant advantage of type erasure over the non-generic
solution is that the generic-routine programmer does not have to
write the casts and the generic-routine reader does not have see
them.
Type erasure has its own disadvantages. For example, a Java Class C<T> cannot execute new T. This is a serious limitation, but the Java 5 designers felt that the resulting strong compatibility was worth it.
Another consequence of type erasure is that C<T1>
and C<T2> do not produce different types.
Instead, they both yield the raw
type Gen.
The above discussion assumed only one generic parameter. However, multiple generic parameters are permitted and the comments made generalize fairly easily.
First, for those like me who didn't know what reify meant the
definition from the Webster online dictionary is to regard
(something abstract) as a material on concrete thing
.
In C# each instantiation of Class C<T> with a
different T yields a distinct type.
I suppose this matches the definition of reification by having
the something abstract
be the parameterized type and having the
concrete thing
bed the new, distinct type.
With each instantiated type (having distinct generic parameter
values) as a different type, C# reification does not lead to the
restrictions mentioned above for type erasure.
(Don't forget that in original Java generics were
omitted by design
and the Java 5 implementers had extremely
strong compatibility constraints.)
generic type T is private; with function "<"(X,Y:T) return Boolean; function Smaller (X,Y:T) return T;
function Smaller (X,Y:T) return T is begin if X<Y then return X; end if; return Y; end Smaller;
with Smaller; with Ada.Text_IO; use Ada.Text_IO; with Ada.Integer_Text_IO; use Ada.Integer_Text_IO; with Ada.Float_Text_IO; use Ada.Float_Text_IO; procedure Main is type R is record I: Integer; A,B: Float; end record; R1: R := (1, 0.0, 0.0); R2: R := (1, 2.0, 3.0); R3: R; function "<"(X,Y:R) return Boolean is begin if X.I < Y.I then return True; end if; if X.I > Y.I then return False; end if; return (X.A+X.B)<(Y.A+Y.B); end "<"; function SmallerR is new Smaller(R,"<"); begin R3 := SmallerR(R1,R2); Put ("The smaller is: ("); Put (R3.I); Put (", "); Put(R3.A); Put (", "); Put(R3.B); end Main;
Let us restrict attention to the case of just one generic parameter as the generalization to multiple parameters is straightforward. Also, let us consider only the case of a type parameter since those are permitted in all the languages (in Java it must be class not a primitive type).
Recall that the Ada generic Stack package began
generic MaxSize: Positive; type ItemType is private; package Stack isand recall that a private type can only be assumed to support, assignment, equality testing, and accessing a few standard attributes (e.g., size). In place of private a few other designations can be used, but they do not come close to giving all useful possible restrictions on (or, equivalently, requirements of) the type.
We mentioned using with to add other requirements on the
type and mentioned that, given the appropriate definition, we could
compare records containing one integer and two floats, by first
comparing the integers and, if there is a tie, comparing the sum of
the floats.
On the right is an implementation, in the usual three-part Ada
style.
Note, in particular the overload resolution of <
in the
instantiation of the generic Smaller into the specific
SmallerT.
The C++ generic programmer can omit explicit constraints and write a header as simple as
template<typename T> void sort(T A[], int A_size) {...}Then, when the generic component is instantiate, the compiler checks to ensure that the supplied type has all the properties used in the generic body. This is certainly concise, but does sacrifice clarity in that a user of the generic component, must themselves ensure that the type they supply has all the needed properties. Worse, the type supplied might indeed supply the property, but the default property may not do what the user wanted and expected.
For these reasons, many users shun implicit constraints and make the requirements explicit. The Ada technique above can be employed: the comparison operator can be specified as a method of type T. However, rather than needing to explicitly list all the operators needed, the Java/C# method below can also be used in C++.
Java and C# make use of inheritance to specify the needed constraints for generic parameters. A good example is sorting
public static <T extends Comparable<T>> void sort(T A[] {This generic header says that this sort routing will use type T and that T is required to have all the methods found in the Comparable interface. (Recall that an interface has only virtual methods). In this case Comparable is a Java standard interface and contains CompareTo, which compares two values returning -1, 0, +1 for the three cases of <, =, and >. The generic sort routine then contains a line like
if (A[i].compareTo(A[j]) ≥ 0)which holds precisely if, according to type T, A[i]≥A[j].
In other examples the interface the parameter T extends, will be user defined and contain several virtual functions, all of which any type used to instantiate T is required to have.
C# has the same concept, but with slightly different syntax.
All four languages we studied require explicit instantiation for generic packages/classes. However, they differ for generic routines.
In Ada, a generic routine must be explicitly instantiated. Our most recent Ada example illustrated the instantiation of the generic routine Smaller into the specific routine SmallerR by replacing the generic type T with the specific type R.
In contrast C++, C#, and Java use implicit instantiation. That is they treat the generic version as an overloaded name that is resolved at usage. Thus after writing a generic sort routine, the programmer simply invokes it with arguments of type R and the system automatically (implicitly) instantiates a concrete version for type R (and for C# and Java has it share code with other instantiated versions).
Some of the material from this section (including the CD portion) has been incorporated in the above. The rest is being omitted.
ML generics are called functors (a term from mathematics). You might ask, given the parametric polymorphic functions for which ML is justly famous, and also the type constructors, which we have not seen, why are generics needed at all?
Iterators are entities used for accessing (iterating over) the elements of a container (list, array, tree, queue, etc). The C++ STL is a large collection of generic classes used mostly for containers. It provides a large collection of Iterators.
Homework: CYU 27, 29, 30, 31, 31.
Homework: Using the code in the notes for section 8.4.2, produce a generic sort in Ada (a trivial O(N2) sort, like bubble sort is fine). Instantiate it to sort type R items using the integer as the primary key and the sum of the floats to break ties.