Lecture Notes in Computer Science Edited by G. Goos, J. Hartmanis and J. van Leeuwen
1628
3 Berlin Heidelberg New York Barcelona Hong Kong London Milan Paris Singapore Tokyo
Rachid Guerraoui (Ed.)
ECOOP ’99 – Object-Oriented Programming 13th European Conference Lisbon, Portugal, June 14-18, 1999 Proceedings
13
Series Editors Gerhard Goos, Karlsruhe University, Germany Juris Hartmanis, Cornell University, NY, USA Jan van Leeuwen, Utrecht University, The Netherlands
Volume Editor Rachid Guerraoui Swiss Federal Institute of Technology Computer Science Department CH-1015 Lausanne, Switzerland E-mail:
[email protected] Cataloging-in-Publication data applied for Die Deutsche Bibliothek - CIP-Einheitsaufnahme Object-oriented programming : 13th European conference ; proceedings / ECOOP ’99, Lisbon, Portugal, June 14 - 18, 1999. Rachid Guerraoui (ed.). Berlin ; Heidelberg ; New York ; Barcelona ; Hong Kong ; London ; Milan ; Paris ; Singapore ; Tokyo : Springer, 1999 (Lecture notes in computer science ; Vol. 1628) ISBN 3-540-66156-5
CR Subject Classification (1998): D.1-3, H.2, F.3, C.2, K.4 ISSN 0302-9743 ISBN 3-540-66156-5 Springer-Verlag Berlin Heidelberg New York This work is subject to copyright. All rights are reserved, whether the whole or part of the material is concerned, specifically the rights of translation, reprinting, re-use of illustrations, recitation, broadcasting, reproduction on microfilms or in any other way, and storage in data banks. Duplication of this publication or parts thereof is permitted only under the provisions of the German Copyright Law of September 9, 1965, in its current version, and permission for use must always be obtained from Springer-Verlag. Violations are liable for prosecution under the German Copyright Law. c Springer-Verlag Berlin Heidelberg 1999 Printed in Germany
Typesetting: Camera-ready by author SPIN 10703317 06/3142 – 5 4 3 2 1 0
Printed on acid-free paper
Preface “My tailor is Object-Oriented”. Most software systems that have been built recently are claimed to be Object-Oriented. Even older software systems that are still in commercial use have been upgraded with some OO flavors. The range of areas where OO can be viewed as a “must-have” feature seems to be as large as the number of fields in computer science. If we stick to one of the original views of OO, that is, to create cost-effective software solutions through modeling physical abstractions, the application of OO to any field of computer science does indeed make sense. There are OO programming languages, OO operating systems, OO databases, OO specifications, OO methodologies, etc. So what does a conference on Object-Oriented Programming really mean? I honestly don’t know. What I do know is that, since its creation in 1987, ECOOP has been attracting a large number of contributions, and ECOOP conferences have ended up with high-quality technical programs, featuring interesting mixtures of theory and practice. Among the 183 initial submissions to ECOOP’99, 20 papers were selected for inclusion in the technical program of the conference. Every paper was reviewed by three to five referees. The selection of papers was carried out during a twoday program committee meeting at the Swiss Federal Institute of Technology in Lausanne. Papers were judged according to their originality, presentation quality, and relevance to the conference topics. The accepted papers cover various subjects such as programming languages, types, distribution, and formal specifications. Beside the 20 papers selected by the program committee, this volume also contains four invited papers. Three of the invited papers are from ECOOP’99 keynote speakers, C.A.R. Hoare, B. Liskov, and J. Waldo. The fourth paper is from the banquet speaker at ECOOP’98: A.P. Black. I would like to express my deepest appreciation to the authors of submitted papers, the program committee members, the external referees, Romain Boichat for smoothly handling the paper printing process, and Richard van de Stadt for his excellent job in managing the electronic submissions of papers and reviews. I would also like to thank the numerous people who have been involved in the organization of ECOOP’99 and, in particular, the General Organizing Chair, Vasco Vasconcelos, the Tutorial Chair, Rui Oliveira, the Workshop Chair, Ana Moreira, the Panel Chair, Lu´ıs Caires, the Demonstration Chair, Ant´ onio Rito Silva, the Poster Chair, Carlos Baquero, the Exhibit Chair, M´ ario J. Silva, and all the volunteers for their tremendous work. April 1999
Rachid Guerraoui ECOOP’99 Program Chair
VI
Organization
Organization ECOOP’99 was organized by the Department of Computer Science of the University of Lisbon, under the auspices of AITO (Association Internationale pour les Technologies Objets).
Executive Committee Organizing Chair: Program Chair: Tutorials: Workshops: Panels: Demonstrations: Posters: Exhibits: Submission Site:
Vasco T. Vasconcelos (Universidade de Lisboa) Rachid Guerraoui (Swiss Federal Institute of Technology) Rui Oliveira (Universidade do Minho) Ana Moreira (Universidade Nova de Lisboa) Lu´ıs Caires (Universidade Nova de Lisboa) Ant´ onio Rito Silva (INESC/IST) Carlos Baquero (Universidade do Minho) M´ ario J. Silva (Universidade de Lisboa) Richard van de Stadt (University of Twente)
Sponsoring Institutions and Companies
Association Internationale pour les Technologies Objets
AITO (Association Internationale pour les Technologies Objets) http://iamwww.unibe.ch/ECOOP/AITO/
The official sponsor of ECOOP
Air Portugal http://www.tap.pt/en/index1.html IBM http://www.ibm.com/
Organization
Program Committee Mehmet Ak¸sit (University of Twente) Suad Alagi´c (Wichita State University) Paulo S´ergio Almeida (Universidade do Minho) Elisa Bertino (University of Milan) Jean-Pierre Briot (Laboratoire d’Informatique de Paris 6) Alberto Coen-Porisini (Politecnico di Milano) Pierre Cointe (Ecole des Mines de Nantes) Klaus Dittrich (University of Zurich) Erich Gamma (Object Technology International) Yossi Gil (Technion, Haifa) Rachid Guerraoui (Swiss Federal Institute of Technology) Gorel Hedin (Lund University) Kohei Honda (Queen Mary and Westfield College) Mehdi Jazayeri (Vienna University of Technology) Eric Jul (University of Copenhagen) Karl Lieberherr (Northeastern University) Jørgen Lindskov Knudsen (University of Aarhus) Klaus-Peter L¨ohr (University of Berlin) Cristina Lopes (Xerox Palo Alto Research Center) Satoshi Matsuoka (Tokyo Institute of Technology) Hanspeter M¨ossenb¨ock (University of Linz) Oscar Nierstrasz (University of Berne) Linda Northrop (Carnegie Mellon University) Jens Palsberg (Purdue University) Markku Sakkinen (University of Jyv¨ askyl¨ a) Santosh Shrivastava (University of Newcastle) Clemens Szyperski (Queensland University of Technology)
VII
VIII
Organization
Referees Franz Achermann Ulf Asklund Isabelle Attali Dave Baken Carlos Baquero Luciano Baresi Gilles Barthe Andreas Behm Klaas van den Berg Luis Blando G˝ unther Blaschek Mario A. Bochicchio Boris Bokowski Lars Bratthall Mathias Braux Pim van den Broek Gerald Brose Kim Bruce M.C. Little Massimo Cafaro Luca Cardelli Juan Carlos Cruz Denis Caromel Giuseppe Castagna Silvana Castano S.J. Caughey Steve Caughey Craig Chambers Shigeru Chiba Philippe Codognet Jean-Louis Cola¸co Aino Cornils Gianpaolo Cugola Markus Dahm Jean-Daniel Fekete Philippe Darche Serge Demeyer Ruxandra Domenig Anne Doucet R´emi Douence Karel Driesen Sophia Drossopoulou St´ephane Ducasse Roland Ducournau
Andrew Duncan Susan Eisenbach Marc Evers Matthias Felleisen Lu´ıs Ferreira Pires R´emy Foisel Bertil Folliot Marko Forsell Hans Fritschi Svend Frolund Nobuhisa Fujinami Jean-Marc Geib Andreas Geppert David Gitchell Giovanna Guerrini Zahia Guessoum Christian Heide Damm Roger Henriksson Chris Ho-Stuart Urs H¨olzle Markus Hof Atsushi Igarashi Anders Ive Jean-Marc J´ez´equel Dirk Jonscher Gerti Kappel Wayne Kelly Gregor Kiczales Yechiel Kimchi Graham Kirby Masaru Kitsuregawa Fabrice Kordon Kai Koskimies Svetlana Kouznetsova Kresten Krab Thorup Phillip Kutter John Lamping Doug Lea Gary Leavens Thomas Ledoux Ole Lehrmann Madsen Mauri Lepp¨ anen Xavier Leroy M.C. Little
Organization
Reed Little David Lorenz Munenori Maeda Eva Magnusson Brahim Mammass Dino Mandrioli Klaus Marius Hansen Hidehiko Masuhara Kai-Uwe M˝atzel Walter Merlat Isabella Merlo Mira Mezini Philippe Mulet Amedeo Napoli Susumu Nishimura Jos´e Nuno Oliveira Martin Odersky Lennart Ohlsson Rui Oliveira Tamiya Onodera Jos´e Orlando Pereira Alessandro Orso Johan Ovlinger Marc Pantel Francois Pennaneach Jean-Fran¸cois Perrot Jonas Persson Patrik Persson Dick Quartel Christian Queinnec Stefan Rausch-Schott Arend Rensink Werner Retschitzegger Tamar Richner Dˇzenan Ridjanovi´c Matthias Rieger
Dirk Riehle Helena Rodrigues Paul Roe Henrik Røn Houari A. Sahraoui Johannes Sametinger Prahladavaradan Sampath Jean-Guy Schneider Martin Sch¨ onhoff Marten van Sinderen Jonas Skeppstedt Andr´e Spiegel Martin Steffen Christoph Steindl Mario S¨ udholt Peter Sweeney Toshiyuki Takahashi Jean-Pierre Talpin Bedir Tekinerdo˘ gan Josef Templ Michael Thomsen Sander Tichelaar Dimitrios Tombros Mads Torgersen Anca Vaduva Vasco Vasconcelos Athanasios Vavouras Jari Veijalainen Juha Vihavainen Jan Vitek Ken Wakita Dan Wallach Andre Weinand Stuart Wheater Mikal Ziane Job Zwiers
IX
Contents Invited Paper 1 A Trace Model for Pointers and Objects . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 C.A.R Hoare (Oxford University) J. He (United Nations University)
Mixins Synthesizing Objects. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18 Krzysztof Czarnecki (DaimlerChrysler AG) Ulrich W. Eisenecker (University of Applied Sciences, Heidelberg) A Core Calculus of Classes and Mixins . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43 Viviana Bono (University of Torino) Amit Patel and Vitaly Shmatikov (Stanford University) Propagating Class and Method Combination . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67 Erik Ernst (University of Aarhus)
Debugging and Garbage Collection A Study of the Allocation Behavior of the SPECjvm98 Java Benchmarks. . . 92 Sylvia Dieckman and Urs H¨ olzle (University of California, Santa Barbara) Visualizing Reference Patterns for Solving Memory Leaks in Java . . . . . . . . . 116 Wim De Pauw and Gary Sevitski (IBM T.J. Watson Research Center) Dynamic Query-Based Debugging . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 135 Raimondas Lencevicius, Urs H¨ olzle, and Ambuj K. Singh (University of California, Santa Barbara)
Type Checking Foundations for Virtual Types . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161 Atsushi Igarashi and Benjamin C. Pierce (University of Pennsylvania) Unifying Genericity . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 186 Kresten Krab Thorup and Mads Torgersen (University of Aarhus) An Object-Oriented Effects System . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 205 Aaron Greenhouse (Carnegie Mellon University) John Boyland (University of Wisconsin-Milwaukee)
XII
Contents
Invited Paper 2 Providing Persistent Objects in Distributed Systems . . . . . . . . . . . . . . . . . . . . . . 230 Barbara Liskov, Miguel Castro, Liuba Shrira and Atul Adya (Massachusetts Institute of Technology)
Virtual and Multi-methods Inlining of Virtual Methods . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 258 David Detlefs and Ole Agesen (Sun Microsystems Laboratories) Modular Statically Typed Multimethods . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 279 Todd Millstein and Craig Chambers (University of Washington) Multi-method Dispatch Using Multiple Row Displacement . . . . . . . . . . . . . . . . 304 Candy Pang, Wade Holst, Yuri Leontiev and Duane Szafron (University of Alberta)
Adaptive Programming Internal Iteration Externalized . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 329 Thomas K¨ uhne (Staffordshire University) Type-Safe Delegation for Run-Time Component Adaptation . . . . . . . . . . . . . . 351 G¨ unter Kniesel (University of Bonn) Towards Automatic Specialization of Java Programs . . . . . . . . . . . . . . . . . . . . . . 367 Ulrik Pagh Schultz, Julia L. Lawall, Charles Consel and Gilles Muller (IRISA, Rennes)
Classification and Inheritance Wide Classes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 391 Manuel Serrano (University of Nice Sophia-Antipolis) An Approach to Classify Semi-structured Objects . . . . . . . . . . . . . . . . . . . . . . . . . 416 Elisa Bertino (University of Milan) Giovanna Guerrini and Isabella Merlo (University of Genova) Marco Mesiti (Bell Communications Research)
Contents
XIII
Invited Paper 3 Object-Oriented Programming on the Network. . . . . . . . . . . . . . . . . . . . . . . . . . . . 441 Jim Waldo (Sun Microsystems, Inc.)
Distributed Objects Providing Fine-Grained Access Control for Java Programs . . . . . . . . . . . . . . . . 449 Raju Pandey and Brant Hashii (University of California, Davis) Formal Specification and Prototyping of CORBA Systems . . . . . . . . . . . . . . . . 474 R´emi Bastide, Ousmane Sy and Philippe Palanque (University of Toulouse) A Process Algebraic Specification of the New Asynchronous CORBA Messaging Service . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 495 Mauro Gaspari and Gianluigi Zavattaro (University of Bologna)
Invited Paper 4 Object-Oriented Programming: Regaining the Excitement. . . . . . . . . . . . . . . . . 519 Andrew P. Black (Oregon Graduate Institute of Science & Technology) Author Index . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 529
A Trace Model for Pointers and Objects C.A.R. Hoare1 and He Jifeng2 1
Oxford University Computing Laboratory Wolfson Building, Parks Road, Oxford, OX1 3QD
[email protected] 2 United Nations University International Institute for Software Technology P.O. Box 3058, Macau
[email protected] Abstract. Object-oriented programs [Dahl, Goldberg, Meyer] are notoriously prone to the following kinds of error, which could lead to increasingly severe problems in the presence of tasking 1. 2. 3. 4. 5.
Following a null pointer Deletion of an accessible object Failure to delete an inaccessible object Interference due to equality of pointers Inhibition of optimisation due to fear of (4)
Type disciplines and object classes are a great help in avoiding these errors. Stronger protection may be obtainable with the help of assertions, particularly invariants, which are intended to be true before and after each call of a method that updates the structure of the heap. This note introduces a mathematical model and language for the formulation of assertions about objects and pointers, and suggests that a graphical calculus [Curtis, Lowe] may help in reasoning about program correctness. It deals with both garbage-collected heaps and the other kind. The theory is based on a trace model of graphs, using ideas from process algebra; and our development seeks to exploit this analogy as a unifying principle.
1
Introduction: The Graph Model
Figure 1.0 shows a rooted edge-labelled graph. Its nodes are represented by circles and its edges by arrows from one node to another. The letter drawn next to each arrow is its label. The set of allowed labels is called the alphabet of the graph. A double-shafted arrow singles out a particular node as the root of the graph. Figure 1.0 (Rooted edge-labelled graph)
a
0
v
-
A A w A A A AU
b
2
c
-
4
KA A
A
1
d
Aa A A - 3
d
-
Rachid Guerraoui (Ed.): ECOOP’99, LNCS 1628, pp. 1–18, 1999. c Springer-Verlag Berlin Heidelberg 1999
5
2
2
C.A.R. Hoare, He Jifeng
Such a graph can be defined less graphically as a tuple G = (AG , NG , EG, rootG ), where AG NG EG rootG
is is is is
the the the the
alphabet of labels set of nodes set of edges with their labels, i.e., a subset of NG × AG × NG node selected as the root
We use variables G, G0 to stand for graphs, l, m, n to stand for nodes, x, y, z to stand x for general labels and s, t, u to stand for sequences of labels (traces). We write l → G m to mean (l, x, m) ∈ EG . Where only one graph is in question, we omit the subscript G. The smallest graph (called 0A ) with given alphabet A consists only of the root node, with no edges, i.e. (A, {root}, {}, root). Another small but interesting graph is 1A =df {(A, {root}, ({root} × A × {root}), root). Example 1.1 (Tuple from graph) The graph of Figure 1.0 is coded as the mathematical structure defined by the following equations A = {v, w, a, b, c, d} N = {0, 1, 2, 3, 4, 5} E = {(0, v, 2), (0, w, 1), (2, a, 2), (2, b, 1), (1, d, 3), (2, c, 4), (3, a, 2), (3, d, 5)} root = 0
2
The awkward feature of this encoding is the arbitrary selection of the first six natural numbers to serve as the nodes. Any other six distinct values would have done just as well. We are only interested in properties of graphs that are preserved by one-one transformations (isomorphisms) of the node-set. The use of isomorphism in place of mathematical equality is an inconvenience. We aim to avoid it by constructing a canonical representation for the nodes of a graph. For this, we will have to restrict the theory to graphs satisfying certain healthiness conditions. Rooted edge-labelled graphs are useful in the study of many branches of computing science, of which data diagrams and heap storage are relevant to object-oriented programming. Example 1.2 (Automata theory) A graph defines the behaviour of an automaton. The nodes stand for states, with the root as the initial state. The labels stand x for events, and the presence in E of an edge l → m means that event x happens as the automaton passes from state l to state m. 2 Example 1.3 (Data diagrams) In a data diagram, a node stands for a set of values, e.g., a type or a class of objects. The labels stand for functions, and the x presence of an edge l → m means that x maps values of type l to results of type m. The root is somewhat artificial: the labels on arrows leading from the root can be regarded as the names of the types that they point to. 2 Example 1.4 (Control flow) In a control flow graph, the nodes represent basic x blocks, i.e sections of program code with no internal label. The edge l → m represents the presence in block l of a jump to a label x which is placed at the beginning of block m. The root is the main block of the program. The same analysis applies when the jumps are procedure calls and the nodes are procedure bodies. 2 Example 1.5 (Heap storage) A graph can describe the instantaneous content of the entire heap at a particular point in the execution of an object-oriented program. The nodes stand for the objects, and the labels are the names for the attributes. x An edge l → m means that m is the value of the x-attribute of the object l. 2
A Trace Model for Pointers and Objects
3
When used to model objects and heaps, the labelled graph is both simple and general, in that it allows more complex concerns to be treated separately. For example, 1. Simple values (e.g., like 5, which is printable) can be treated in the usual way as sinks of the graph, i.e. as nodes from which no pointer can ever point. A method local to an object can be similarly represented as a value of one of its attributes. 2. The labels on pointers from the unique root represent the directly accessible program variables. There is no restriction on pointing from the heap into declared program workspace; such pointers are often used in legacy code for cyclic representations of chains, even if their use is deprecated or forbidden in higher level languages. 3. Absence of a pointer from an object in which space has been allocated for it is often represented by filling the space with a nil value. The model allows this; another representation permitted by our model is to introduce a special nil object, with special properties, e.g. all arrows from it lead back to itself. 4. The model describes the statics and dynamics of object storage, and is quite independent of the class declarations and inheritance structure of the source language in which a program has been written. In fact, the relationship between the run-time heap and a data diagram is a special case of an invariant assertion, that remains true throughout the execution of the program. The invariant is elegantly formalised with the aid of graph homomorphisms, as described in Definition 1.10. The main operation for updating the value of the heap is written l → a := m. It causes the a-labelled arrow whose tail rests at node l to point to node m, instead of what it pointed to before. The operation changes only the edges of the graph, leaving the nodes, the alphabet, and the root unchanged. Definition 1.6 (Pointer swing) (l → a := m) :=df (E := (E − {l} × {a} × N ) ∪ {(l, a, m)}), where l, m ∈ N 2 Example 1.7 (Pointer swing) After execution of 1 → d := 4, the graph of Figure 1.0 would appear, as follows
a
0
v
-
A A w A A A AU
b
2
KA A
c
d
A A a A A
- 4 3
1
3
d
-
5
2
Further operations are needed for deleting an edge and for creating a new node. Node creation introduces an arbitrary new object into the node-set and swings a pointer to point to it. Deletion of a node is more problematic, and will be treated later.
4
C.A.R. Hoare, He Jifeng
Definition 1.8 (Edge deletion, Node creation) (l → a := nil) =df E := E − {l} × {a} × N ∼ (l → a := new) =df N := N + {m}; l → a := m, where m ∈ N
2
There are two problems with the above definitions of operations on the heap. The first is that an object-oriented program has no means of directly naming the objects l and m. These references have to be made indirectly by quoting the sequence of labels on a path which leads from the root to the desired object. Thus the assignment in Example 1.7 might have been written w → d := w → d → a → c The second problem is that, after the assignment, two of the nodes (3 and 5) have become inaccessible: the program will never again be able to refer to those nodes by any path. In a garbage-collected heap, such nodes are subject to disappearance at any time. In a non-collected heap they could represent a storage leak. Our trace model of object-orientation will solve all these problems, with the help of a canonical representation of the graph.
When a graph is used as a data diagram it specifies the classes of object to which each variable and attribute is allowed to point. A compiler can therefore allocate to each object only just enough store to hold all its permitted attributes. The compiler will also check all the operations of a program to ensure that all the rules have been observed. As a result, at all times during execution it is possible to ascribe each object in the heap to a node in the data diagram representing the object class to which it belongs. This can be pictured by drawing a polygon around all nodes belonging to the same particular class. Each polygon is then contracted to a single node, dragging the heads and tails of the arrows with it. The result will be a data diagram, which will match the intended structure of class declarations.
Figure 1.9 (Object classes)
HH a H H A v A c - A A K A A A A w A A A A A A a A b A A A A A A AU d d A A H HH AA H
A Trace Model for Pointers and Objects
a d c
v
W
w
?
6
O
a
b
5
d
W -
2
The informal description of the transformation of a heap structure to a class diagram is formalised in the mathematical definition of a homomorphism. This is a function from the nodes of one graph to the nodes of another that preserves the root and the labels on the edges. Definition 1.10 (Homomorphism) Let G = (A, N, E, root) and G0 = (A0 , N 0 , E 0 , root0 ). Let f be a total function from N to N 0 . The triple f : G → G0 is called a homomorphism if A ⊆ A0 , and for all x in A 1. f(root) = root0 x x → 2. m → G n implies f(m) G0 f(n), for all x in A.
2
Examples 1.11 (Homomorphisms) From every graph G with alphabet A, there is just one homomorphism to 1A ; from 0A to G, there are as many homomorphisms as nodes in G. 2 Homomorphisms can also be used to define the relationship between a subclass and its parent class in a class hierarchy [Cardelli, Cook]. For this, we will later introduce a method of reducing the alphabet of labels to match that of the target of the homomorphism. Multiple inheritance is simply modelled by asserting the existence of more than one homomorphism from the heap to several different data diagrams. Different languages enforce differing conventions and rules, to ensure that the invariance of such assertions at run time is checkable by compiler. Our theory is claimed to be sufficiently expressive to describe all such checkable rules in any language. It can also formulate much more general assertions, whose truth cannot be checked at compile time, but only at run time or by proof. Another important role for a homomorphism is to select from a large graph a smaller subgraph for detailed consideration. The shape of the subgraph is specified by the source of the homomorphism, and the target specifies which particular subgraph of that shape is selected. For example, consider the graph
ma mc m -
-
A subgraph of this shape occurs just twice in Figure 1.0; there is only one injective homomorphism from it into the Figure, and one that is non-injective. This kind of subgraph homomorphism has to be redefined to allow for absence of a root. The remaining role of the homomorphism is to define the concept of an isomorphism of graphs, and so specify what it means for two graphs with different node sets to be essentially the same. Definition 1.12 (Isomorphism) Let f : G → G0 be a homomorphism. This is said to be an isomorphism if f is invertible, and f −1 : G0 → G is also a homomorphism. G and G0 are isomorphic if there is an isomorphism from one to the other.
2
6
C.A.R. Hoare, He Jifeng
This rather indirect definition represents the very simple intuitive idea of laying one graph on top of another, and ensuring that it has nodes and edges and labels in all the same places. Like congruent triangles in geometry, they are just two copies of the same graph!
2
The Trace Model
The problem of inaccessible objects is the same as that of inaccessible states in automata theory; and the solution that we adopt is the same: calculate the language of traces that are generated by the graph. A trace of an automaton is a sequence of consecutive events that can occur during its evolution. A trace can be read from the graph by starting at node l and following a path of consecutive edges leading from each node to the next, along a path of directed edges. The trace is extracted as the sequence of labels encountered on the path up to its last node m. The existence of s such a trace s is denoted l −→ m. A formal definition uses recursion on the length of the trace. Definition 2.0 (Traces)
l −→ m iff l −→ m iff sbt l −→ m iff ∗ l −→ m =df traces (l) =df
l=m (l, a, m) ∈ E s
t
∃n • l −→ n ∧ n −→ m s {s | l −→ m} ∗ root −→ l
2
Example 2.1 (Figure 1.0) From the graph of Figure 1.0 the sets of traces of each of the six nodes are given by the following six regular expressions n0 n1 n2 n3 n4 n5
=ε = w + n2 b = v + n2 a + n3 a = n1 d = n2 c = n3 d
2
In the canonical trace model of a graph, each node l is represented by the set traces(l), containing all traces on paths to it from the root. The set of nodes is therefore a family N of sets of traces (N ⊆ PA∗ ). The labelled edges and the root of the graph can be defined in terms of this family. Definition 2.2 (Canonical representation) Let G = b =df N b E =df rb =df b =df G
(A, N, E, r) {traces(n) | n ∈ N } x b ∧ l b< x > ⊆ m} {l → m | l, m ∈ N b containing . the unique n in N b , E, b b (A, N r)
Theorem 2.3 b and X ⊆ A∗ For all l, m, n ∈ N ∗ (1) (l b X) ⊆ m iff X ⊆ (l −→ b m) G
(2)
∗
∗
∗
(l −→ b m)b(m −→ b n) ⊆ (l −→ b n) G G G
2
A Trace Model for Pointers and Objects
(3)
7
∗
(b r −→ b m) = m G
Proof: (1) From the fact that for all s ∈ A∗ s
l→ b m G (2) From the associativity of the catenation operator and the Galois connection (1) (l b s) ⊆ m
iff
X ⊆ LHS
(3) ≡ {(1)}
rb b X ⊆ m
⇒ {∈ rb}
X ⊆ RHS
≡ {let m = traces(n)}
s ∀s ∈ X • (root −→ n) G
⇒ {∀t ∈ rb • (root −→ root)} G
tbs ∀t ∈ rb, s ∈ X • (root −→ n) G
≡ {def of traces}
(b r b X) ⊆ traces(n) = m
≡ {(1)}
X ⊆ LHS
t
2
In the theory of deterministic automata, the language generated by the automaton is just the union of the set of traces of all its states S language (G) = {traces(l) | l ∈ NG } The great advantage of this is that an inaccessible state has no traces at all, and so makes no contribution to the language. Two automata are therefore decreed to be identical if they have the same language. Example 2.4 (Identical automata)
a
6
a
?
,
is the same as
a
2
because in both cases the language is {a}∗
For automata, the purpose of this identification is to allow automatic minimisation of the number of states needed to generate or recognise a specified language. But in object-oriented programming, such identification of objects would be wholly inappropriate. The reason is that the pointer swinging operation (not considered by automata theory) distinguishes graphs which automata theory says should be the same. Example 2.5 (after swing) After the assignment a → a := nil, the two graphs of example 2.4 now look like a
and
?
,
2
8
C.A.R. Hoare, He Jifeng
Even in automata theory, these two graphs are distinct. For this reason, we cannot model a heap simple as a set of traces, and we have to go up one level in complexity b. to model it as a set of sets of traces, as shown in the definition of N Nevertheless, there are many interesting analogies between our trace model the process algebra of non-deterministic automata. For example, as in CSP [Hoare], the entire set of valid traces is prefix-closed. Theorem 2.6 (Prefix closure) contains s.
S
b is non-empty; and if it contains s b t, it also N
2
An important property of the b operator, transforming a graph to its canonical representation, is that it leaves unchanged an argument that is already canonical. Theorem 2.7 (Idempotence) b b =G b G Proof
traces(traces(n)) = {def of traces} * traces(root) −→ b (traces(n)) G = {Theorem 2.3(3)} traces(n)
2
Note that this is an equality, not just an isomorphism. But the claim that the reb is not even sult is canonical for all graphs is not justified: there are G such that G isomorphic to G. Counterexamples 2.8 (Disappearing nodes)
b
b = {{}, {}} N
-
@@ @@ b b = {{}, {< b >}, {< b, a >}} N @@ R @ a -
2 In choosing to study the canonical representation, we exclude such counterexamples from consideration. The remainder of this section will define and justify the exclusion.
A Trace Model for Pointers and Objects
9
An important property in object-oriented programming is that each name should uniquely denote a single object. This is assured if the graph is deterministic. Definition 2.9 (Determinism) A graph is deterministic if for each l and x there x is at most one m such that l → m. This is necessary for determinism of the corresponding automaton. In a data diagram, determinism permits automatic resolution of the polymorphic use of the same label to denote different functions on different data types. In object-oriented programming, it states the obvious fact that each attribute of each object can have only one value. 2 b satisfies an additional If the original graph is deterministic, its canonical node-set N property familiar from process algebra — it is a bisimulation [Milner]. Definition 2.10 (Bisimulation) A family N of sets of traces is a bisimulation if it is a partial equivalence which is respected by trace extension. More formally, for all p, q in N p=q s, t ∈ p
∨ p ∩ q = {} ∧ tbu ∈q ⇒ sbu ∈q
2
Determinism ensures that any two distinct objects will have distinct trace sets, except in the extreme case that both have empty trace sets. Such objects can never be accessed by a program, so they might as well not exist.
Definition 2.11 (Accessibility) A node n is accessible if traces(n) is non-empty. ∼ b . For automata, inaccessible A graph is accessible if all its nodes are, i.e. {} ∈ N nodes represent unreachable states, which can and should be ignored in any comparison between them. In a heap, they represent unusable storage, which can and should be garbage-collected (or otherwise explicitly deleted). 2 At last we can show that we can model all the graphs that we are interested in by simply considering canonical graphs; furthermore, we can assume that N is always a prefix-closed bisimulation. Theorem 2.12 (Representability) If G is deterministic and accessible, it is isob morphic to G Proof
Letf(n) =df traces(n) for all n ∈ N . Because G is deterministic
(n 6= m) ⇒ (traces(n) 6= traces(m)) Futhermore we have x
f(n) −→ b f(m) G b ≡ {def of E} traces(n)b < x > ⊆ traces(m) ≡ {traces(n) 6= {} and G is deterministic} x
n → m G
2
We can now solve the problems of graph representation left open in the previous section: objects will be named by traces, and inaccessible objects will disappear. We will assume that the heap G is at all times held in its canonical representation; and redefine each operation of object-oriented programming as an operation on the trace sets N .
10
C.A.R. Hoare, He Jifeng
Edge deletion t → x := nil now has to remove not only the single edge x, but also all traces that include this edge. Every such trace must begin with a trace of the object t itself, i.e. a trace which is equivalent to t by the equivalence N . The trace to be removed must of course contain an occurrence of < x >, the edge to be removed. It ends with a trace leading to some other node n in N . The traces removed from n are therefore exactly defined by the set ∗
[t] b< x > b (t b< x >→ n) We use the usual square bracket notation [t]N that contains t to denote the equivalence class (or more simply just [t]). Of course, x may occur more than once in the trace, either before or after the occurrence shown explicitly above. In the following definition, the removal of the edge is followed by removal of any set that becomes empty – a simple mathematical implementation of garbage-collection. Re-definition 2.13 (Edge deletion, Node creation) ∗
(t → x := nil) =df N := {n − [t] b< x > b(t b < x >→ n) | n ∈ N } − {{}} (t → x := new) =df t → x := nil; N := N + {[t] b< x >}
2
Unfortunately, pointer swing is even more complicated than this. We consider first the effect of tb< y >:= s, in the case where y is a new label, occurring nowhere else in the graph. The question now is, what are all the new traces introduced as a result of insertion of this new and freshly labelled edge? As before, every such trace must start with a trace from [t], followed by the first occurrence of y. But now we must consider explicitly the possibility that the new edge occurs many times in a loop. The trace that completes the loop from the head of y back to its tail must ∗ be a path leading from s to t in the original graph, i.e. a member of (s → t). After ∗ any number of repetitions of ((s → t)b< y >), the new trace concludes with a path from s to some node n. The traces added to an arbitrary equivalence class n are exactly defined by the set ∗
∗
[t] b < y > b ((s → t) b < y >)∗ b (s → n) ∗
Note that in many cases (s → n) will be empty, because there is no path from s to n. Then by definition of b between sets, the whole of the set described above is empty, and no new traces are added to n. After inserting these new traces, it is permissible and necessary to remove the original edge x from the original graph and from the newly added traces too. Finally, the freshly named new edge y can be safely renamed as x. Re-definition 2.14 (Pointer swing) (t → x := s) =df let y be a fresh label in ∗
∗
N := {n + [t] b < y > b ((s → t) b < y >)∗ b (s → n) | n ∈ N }; t → x := nil; rename y to x
2
Note that it is not permissible to delete the edge x before adding the new edge: this could make inaccessible some of the objects that need to be retained because they are accessible through s. The problem is clearly revealed in the simplest case: t → x := t → x. The necessary complexity of the pointer swing is a serious, perhaps a crippling disadvantage of the trace model of pointers and objects.
A Trace Model for Pointers and Objects
3
11
Applications
The purpose of our investigations is not just to contribute towards a fully abstract denotational semantics for an object-oriented programming language. We also wish to provide assistance in reasoning about the correctness of such programs, and to clarify the conditions under which they can be validly optimised. Both objectives can be met with the aid of assertions, which describe useful properties of the values of variables at appropriate times in the execution of the program. To formulate clear assertions (unclear ones do not help), we need an expressive language; and to prove the resulting verification conditions, we need a toolkit of powerful theorems. This section makes a start on satisfying both these needs. Two important properties of an individual node are defined as follows. It is acyclic if the only path leading from itself to itself is the empty path. ∗
n is acyclic =df (n −→ n) = {} A graph is acyclic if all its nodes are. A node is a sink if there is no node accessible from it except itself ∗
n is a sink =df ∀m • n −→ m ⊆ {} These definitions can be qualified by a subset B of the alphabet, e.g. ∗
n is a B-sink =df ∀m • (n −→ m) ∩ B ∗ ⊆ {} Two important relationships between nodes are connection and dominance. Connection is defined by the existence of a path between the nodes; and this path may be required to use only labels from B ∗
B
m =⇒ n =df (n −→ m) ∩ B ∗ 6= {} B
=⇒ is clearly a pre-order, i.e., transitive, and reflexive, but it is antisymmetric only in acyclic graphs. The root is the bottom of any accessible graph. The superscript B is omitted when it is the whole alphabet of the graph under discussion. The relation of dominance between objects is stronger than connection. One object l in a graph dominates an object m if every path to m leads through l ∗
l v m =df l b (l → m) = m Deletion of a dominating object makes a dominated object inaccessible. So this relationship is very important in proving that a graph remains accessible and/or acyclic after a pointer swing. Its properties are similar to those of the prefix ordering over simple traces. Theorem 3.0 (Dominance ordering) v is a partial order with the root as a bottom and the empty set as its top. The dominators of any node are totally ordered, i.e. if l v n and m v n then l v m or m v l Proof see appendix.
2
For non-empty nodes, dominance implies connection. If a node has only one trace, then every node that connects to it will dominate it. If all nodes have this property, the graph is called a divergent tree — divergent because all its pointers point away from the root and towards its sinks (i.e. the leaves).
12
C.A.R. Hoare, He Jifeng
In a language without garbage-collection, there is a grave danger that a pointer swing will leave an object that has no other pointer pointing to it. Such an object can never again be accessed by the program, and the storage space that it occupies will never be reused. This phenomenon is known as a space leak. In order to prevent it, the programmer must accept the obligation to ensure that a certain precondition is satisfied before each pointer swing s → x := t. The relevant precondition is expressed as non-dominance ¬s v s b< x > In a language without garbage-collection, the only way in which heap storage can be recovered for reuse is by an explicit command in the program, declaring that a particular object will never be accessed again. We will treat the simplest form of atomic deletion, as for example the delete command in PASCAL. This command must be given at the same time that the last pointer to the object is deleted by s → x := nil. The precondition of such a deletion is the opposite of that for an assignment s v s b< x > In fact, a stronger precondition is necessary. All the objects accessible through s b< x > must be accessible through some other object as well (otherwise their space would leak anyway). The full precondition for deletion is ∀y • (s b< x >v s b< x >b y iff y =). The complexity of these preconditions may explain why control of space leaks is a difficult problem in practice. A heap as represented in the store of a computer must be described as a single variable, even though its value is of great size and complexity. Any pointer in the heap can at any time be swung to point to any other object whatsoever. To control this complexity, a programmer usually constrains the use of a heap in a highly disciplined way. The heap is understood to be split into a number of component subgraphs, satisfying invariant properties that limit the connections within and between the components. A component of a graph can readily be selected in two ways: by restricting the alphabet, or by concentration on a single branch. Definition 3.1 (Subgraphs) N |\ B =df {n ∩ B ∗ | n ∈ N } − {{}} N |\ B is a canonical graph with alphabet B, containing just those objects nameable by chains of labels drawn wholly from B. ∗
N/n = {n −→ m | m ∈ N } − {{}}, where n ∈ N N/n is a canonical graph, isomorphic to the subgraph of all nodes accessible from n. It consists of just that part of the heap that is seen by a method local to n. 2 These subgraph operations obey laws identical to those found in process algebra N |\A (N |\ B) |\C N |\{} N/ (N/s)/t
= N, if A is the alphabet of the graph = N |\ (B ∩ C) = 0{} =N = N/(sbt)
A Trace Model for Pointers and Objects
13
The purpose of this paper has been to provide a conceptual framework for formalisation of invariant assertions about data structures represented as objects in a heap. Class and type declarations serve the same purpose; they are also carefully designed to enjoy the additional advantage that their validity can be checked by compiler. Assertions have more expressive power, but they can be tested only at run time, and they can be validated only by proof. In the remainder of this section we explore the power of the trace model in the formulation of assertions, and suggest that a diagram may be helpful in visualising them. Definition 3.2 (Chain) Consider a pair of labels B = {base, next }. This defines a chain if C = (N/base) |\{next} is invariantly acyclic.
root
base -
next -
next -
• • •
6
ind
next-
next
A variable ind is an index into this chain if invariantly base =⇒ ind. A last-pointer is an index that always points to a next-sink. A final segment of the chain, chopped off at a given index, is C /ind. 2 A chain is often used to scan a set of objects of interest. A good example is a convergent tree — convergent because the pointers point away from the leaves towards the root (a sink). Without a chain through them, the leaves (and indeed the whole tree) would be inaccessible.
Figure 3.3 (Convergent tree)
Q Q b Q Q
p ,, , ,
I @ 6 @ p @ @
Q Q s Q
p
6
n
?
,
p ,, ,
@ p I @ @ @ n -
n
The attribute p points from an offspring to its parent in the tree; the attribute n constructs the leaf chain starting at a declared base variable b. 2 We wish to formalise the properties shared by all such trees, without restriction on size or shape, and without defining which other attributes besides p and n may be pointing to or from the nodes. Let us first confine attention to the subgraph T
14
C.A.R. Hoare, He Jifeng
of interest T =df (N/b) |\ {p, n} The aim is to formulate the desired invariant properties of T as a conjunction of simple conditions that can be checked separately, and reused in different combinations for different purposes. The first condition has already been given a formal definition 1. T is acyclic p
n
p
2. Every object on the chain has a parent: ifj −→ k then(∃l•k → l)∧ (∃l•j → l) p
n
n
3. No parent is an object on the chain: if l −→ m then (¬ k → m) ∧ (¬ m → k) p
p
4. Any two nodes on the tree share a common ancestor: ∀j, k ∃l • j ⇒ l ∧ k ⇒ l There is one more property that is usually desired of a leaf-chain: it should visit the leaves in some reasonable order, for example, close relatives should appear close in the chain. In particular, the following picture should not appear in the graph.
& % p
anc
p
ch1
-
A K p A A ch2
n
A K p A A
rel
6
n
Note that ch1 and ch2 are more closely related to each other than to rel, which therefore should not separate them in the chain. The requirement is formalised p
p
n
p
n
5. If ch1 ⇒ anc ∧ ch2 ⇒ anc ∧ ch1 ⇒ rel ∧ rel ⇒ ch2 then rel ⇒ anc. These invariants are expressed in the predicate S calculus, using variables that have either implicit quantification over all traces in T , or explicit existential quantification. The invariants can also be conveniently represented pictorially in the graphical calculus [Curtis and Lowe]. The simpler invariants directly prohibit occurrence in the heap of any subgraph of a certain shape. For example, condition (2) prohibits any occurrence of the two shapes
-
p
-
n
n ?
and
-
p
A Trace Model for Pointers and Objects
15
More formally, there is no homomorphism from either of these graphs into the heap. The acyclic condition (1) can be pictured by using a single arrow to represent a complete (non-empty) trace drawn from the specified alphabet; the following is prohibited
{p, n}+
The more complicated invariants take the form of an implication, whose consequent has existentially quantified variables. These are drawn as dotted lines rather than the solid lines that represent variables universally quantified over the whole formula. So the condition (2) would be drawn
6 p
n
-
6 p
n
and
-
The condition (4) combines this convention with the path convention
p∗
-
p∗
The fifth condition is the most elaborate
I 6@ , @ p∗ p∗ , @ , @ , ∗ p
@ ∗ n@ @ R @
, ,∗ n ,
,
The meaning of the dotted line convention illustrated above can be formalised, again in terms of graph homomorphisms. The picture states that every homomorphism from the graph drawn as solid lines and nodes can be extended to a homomorphism from the whole picture, including dotted lines and nodes. The extension must not
16
C.A.R. Hoare, He Jifeng
change the mapping of any of the solid components. Even more formally, the diagram defines an obvious injective homomorphism j : solid → diagram from its solid components to the whole diagram. It states that for all h : solid → T there exists an h0 : diag → T such that h = j; h0 (h factors through j). In plainer words, perhaps the programmer’s instinct to draw pictures when manipulating pointers can be justified by appeal to higher mathematics.
4
Conclusion
The ideas reported in this paper have not been pursued to any conclusion. Perhaps, in view of the difficulties described at the end of section 2, they never will be. Their interest is mainly as an example of the construction of a generic mathematical model to help in formalisation of assertions about interesting and useful data structures. Such assertions can be helpful in designing and maintaining complicated class libraries, and in testing the results of changes, even if they are never used for explicit program proof. Other published approaches to reasoning about pointer structures have been much better worked out. An early definition of a tree-structured machine with an equivalence relation for sharing was given in [Landin]. The closest in spirit to the trace model is described in [Morris] and applied to the proof of an ingenious graph marking algorithm. A similar approach using nice algebraic laws was taken in [Nelson]. Another promising approach [M¨ oller] exploits the proof obligation as a driver for the design of the algorithm in a functional style. It models each label as a function from addresses to values or other addresses contained in the addressed location. Other authors too have been deterred by the complexity of sharing structures introduced by pointer swing [Suzuki]. Acknowledgements For useful comments on earlier drafts we thank Frances Page, Bernhard M¨oller, Manfred Broy, Jay Misra, Paul Rudin, Ralph Steinbr¨ uggen, Zhou Yu Qian.
References 1. M. Abadi and L. Cardelli. A theory of objects. Springer (1998). 2. L. Cardelli. A semantics of multiple inheritance. Information and Computation 76: 138–164 (1988). 3. W. Cook and J. Palsberg. A denotational semantics of inheritance and its correctness. Information and Computation 114(2), 329–350 (1994). 4. S. Curtis and G. Lowe, A graphical calculus. In B. M¨ oller (ed) Mathematics of Program Construction LNCS 947 Springer (1995) 5. O. Dahl and K. Nygaard. Simula, an Algol-based simulation language. Communications of the ACM 9(9) 671–678 (1966). 6. A. Goldberg and D. Robson. Smalltalk-80. The language and its implementation. Addison-Wesley (1983). 7. C.A.R. Hoare, Communicating Sequential Processes. Prentice-Hall (1985). 8. S.N. Kamin and U.S. Reddy. Two semantic models of object-oriented languages. In C.A. Gunter and J.C. Mitchell (eds): Theoretical Aspects of Object-Oriented Programming, 463–495, MIT Press, (1994). 9. P.J. Landin, A correspondence between ALGOL 60 and Church’s lambda-notation Part 1. Communications ACM 8.2 (1965) 89-101 10. B. Meyer. Object-oriented Software Construction, Prentice-Hall second edition (1997). 11. R. Milner. Communication and Concurrency, Prentice Hall (1987) 12. B. M¨ oller, Towards pointer algebra. Science of Computer Programming 21 (1993), 57-90. 13. B. M¨ oller, Calculating with pointer structures. Proceedings of Mathematics for Software Construction, Chapman and Hall (1997), 24-48.
A Trace Model for Pointers and Objects
17
14. J.M.Morris, A general axiom of assignment, Assignment and linked data structure, A proof of the Schorr-Waite algorithm. In M Broy and G. Schmidt (eds.) Theoretical Foundations of Programming Methodology, 25-51, Reidel 1982 (Proceedings of the 1981 Marktoberdorf Summer School). 15. G. Nelson, Verifying reachability invariants of linked structures. Proceedings of POPL (1983), ACM Press, 38-47. 16. N. Suzuki, Analysis of pointer rotation. Communications ACM vol 25 No 5, May (1982), 330-335.
Appendix Proof of Theorem 3.0 First we are going to show that dominance is a partial order. (reflexive)
l = {sb = s} lb{} ∗
⊆ {∈ (l → l)} ∗
lb(l → l) ⊆ {Theorem 2.3.(1)} l (antisymmetric) Assume that l v m and m v l. If l = {} then ∗
∗
m = lb(l → m) = {}b(l → m) = {} = l Assume that l 6= {}. From the fact that ∗
∗
l = lb(l → m)b(m → l) and l 6= {} we conclude that ∗
∗
∈ (l → m)b(m → l) ≡ {sbt = iff s = t =} ∗
∗
∈ (l → m) ∩ (m → l) ∗
∗
⇒ {l = mb(m → l) and m = lb(l → m)} (l ⊆ m) ∧ (m ⊆ l) ≡ l=m (transitive) Assume that l v m and m v n. n ⊇ {Theorem 2.3(1)} ∗
lb(l → n) ⊇ {Theorem 2.3(2)} ∗
∗
lb(l → m)b(m → n) = {l v m and m v n} n Let n be a non-empty node. Assume that l v n and m v n
18
C.A.R. Hoare, He Jifeng
For any subset X of A∗ we define sht(X) as the set of shortest traces of X sht(X) =df {s ∈ X | ∀t ∈ X • t ≤ s ⇒ t = s} From the fact that n 6= {} we conclude that neither sht(l) nor sht(m) is empty. Consider the following cases: (1) sht(l) ∩ sht(m) 6= {}: From the determinacy it follows that l=m (2) sht(l) ∩ sht(m) = {}: From the assumption that l v n and m v n it follows that ∀u ∈ sht(l)∃v ∈ sht(m) • (u ≤ v ∨ v ≤ u) and
∀v ∈ sht(m)∃u ∈ sht(l) • (u ≤ v ∨ v ≤ u)
(2a) ∀u ∈ sht(l)∃v ∈ sht(m) • v ≤ u: From the bisimulation property it follows that mvl (2b) ∀v ∈ sht(m)∃u ∈ sht(l) • u ≤ v: In this case we have lvm (2c) There exist u, u ˆ ∈ sht(l) and v, vˆ ∈ sht(m) such that u < v and vˆ < uˆ Let ∗
j =df min{length(s) | s ∈ sht(l → n)} ∗
k =df min{length(t) | t ∈ sht(m → n)} From u ≤ v we conclude that j > k, and from vˆ < u ˆ we have j < k, which leads to contradiction. From the above case analysis we conclude that l v m or m v l
2
Synthesizing Objects Krzysztof Czarnecki1 and Ulrich W. Eisenecker2 1
DaimlerChrysler AG Research and Technology, Ulm, Germany
[email protected] 2
University of Applied Sciences Heidelberg, Germany
[email protected] Abstract. This paper argues that the current OO technology does not support reuse and configurability in an effective way. This problem can be addressed by augmenting OO analysis and design with feature modeling and by applying generative implementation techniques. Feature modeling allows capturing the variability of domain concepts. Concrete concept instances can then be synthesized from abstract specifications. Using a simple example of a configurable list component, we demonstrate the application of feature modeling and how to implement a feature model as a generator. We introduce the concepts of configuration repositories and configuration generators and show how to implement them using object-oriented, generic, and generative language mechanisms. The configuration generator utilizes C++ template metaprogramming, which enables its execution at compile-time.
1 Introduction In the early days of OO, there used to be the belief that objects are reusable by their very nature and that reusable OO software simply “falls out” as a byproduct of application development. Today, the OO community widely recognizes that nothing could be further from the truth. Reusable OO software has to be carefully engineered and engineering for reuse requires a substantial investment. One of the weaknesses of current OO Analysis and Design (OOA/D) is the inadequate support for variability modeling. As suggested by the work in the reuse community (e.g. [CN98, GFA98, Cza98]), this problem can be addressed by augmenting OOA/D with feature modeling. Feature modeling was originally introduced in the Feature-Oriented Domain Analysis (FODA) method [KCH+90] as a technique for modeling commonalities and variabilities within a domain. Since reusable models may contain a significant amount of variability, rigid class hierarchies are inappropriate for implementing such models. They are more adequately implemented as flexible, highly parameterized component classes. Concrete component configurations can be synthesized from abstract descriptions by a configuration generator. An important aspect of such designs is the separation between the components and the configuration knowledge, which is achieved using a configuration repository. We demonstrate these concepts using a concrete example and also show how to implement them using object-oriented and generic language mechanisms in C++. The configuration generator utilizes C++ template metaprogramming, which enables its execution at compile-time. Rachid Guerraoui (Ed.): ECOOP’99, LNCS 1628, pp. 18-42, 1999. Springer-Verlag Berlin Heidelberg 1999
Synthesizing Objects
19
The rest of the paper is organized as follows. Section 2 discusses the weaknesses of OOA/D in the context of reuse and configurability. Section 3 introduces the concept of feature models. Section 4 makes the connection between feature models and object synthesis. Section 5 contains a concrete example starting with a feature diagram and concluding with a configuration generator. Section 6 lists extensions not included in the example due to space limitations. Section 7 discusses two libraries implemented using the techniques from this paper. Section 8 discusses related work. Section 9 concludes by making the connection to active libraries and Generative Programming.
2 Problems of Object Technology in the Context of Software Reuse Two important areas of OO technology addressing reuse are frameworks and design patterns. A framework embodies an abstract design for a family of related systems in the form of collaborating classes. Similarly, design patterns provide reusable solutions to recurring design problems across different systems. Patterns, as a documentation form, also proved useful in capturing reusable solutions in other areas such as analysis, architecture, and organizational issues. Unfortunately, only very few OOA/D methods provide any support for the development of frameworks.1 Similarly, there is little systematic support in both finding and applying patterns. Most OOA/D methods focus on developing single systems rather than families of systems. Given this goal, these methods are inadequate for developing reusable software, which requires focusing on classes of systems rather than single systems. A comparison between Domain Engineering methods (e.g. ODM [SCK+96] or FODA [KCH+90]), which are designed for engineering system families, and OOA/D methods reveals the following deficiencies of the latter: •
No distinction between engineering for reuse and engineering with reuse: Taking reuse into account requires splitting the OO software engineering process into engineering for reuse (i.e. Domain Engineering) and engineering with reuse (i.e. Application Engineering). OOA/D methods come closest to Application Engineering, with the difference that Application Engineering focuses on reusing available assets produced during Domain Engineering.
•
No domain scoping phase: Since OOA/D methods focus on engineering single systems, they lack a domain scoping phase, where the target class of systems is selected. Also, OOA/D focuses on satisfying “the customer” of a single system rather than analyzing and satisfying stakeholders (including potential customers) of a class of systems.
•
Inadequate modeling of variability: The only kind of variability modeled in current OOA/D is intra-application variability, e.g. variability of certain objects
1
As of writing, OOram [Ree96] is the only OOA/D method known to the authors which truly recognizes the need for a specialized engineering process for reuse. The method includes a domain scoping activity involving the analysis of different classes of consumers. However, the method does not incorporate feature modeling.
20
Krzysztof Czarnecki and Ulrich W. Eisenecker
over time and the use of different variants of an object at different locations within an application. Domain Engineering, on the other hand, focuses on variability across different systems in a domain for different users and usage contexts. Since modeling variability is fundamental to Domain Engineering, Domain Engineering methods provide specialized notations for expressing variability. Thus, a general problem of all OOA/D methods is inadequate modeling of variability. Although the various modeling techniques used in OOA/D methods support variability mechanisms (e.g. inheritance, aggregation, and static parameterization), OOA/D methods do not include an abstract and concise model of commonality, variability, and dependencies. There are several reasons for providing such a model: •
Since the same variability may be implemented using different variability mechanisms in different models, we need a more abstract representation of variability (cf. Section 3).
•
The user of reusable software needs an explicit and concise representation of available features and variability.
•
The developer of reusable software needs to be able to answer the question: why is a certain feature or variation point included in the reusable software?
The lack of domain scoping and explicit variability modeling may cause two serious problems: •
relevant features and variation points are missing; and
•
many features and variation points are included but never used; this causes unnecessary complexity and cost (both development and maintenance cost).
Covering the right features and variation points requires a careful balancing between current and future needs. Thus, we need an explicit model that summarizes the features and the variation points and includes the rationale and the stakeholders for each of them. In Domain Engineering, this role is played by a feature model. A feature model captures the reusability and configurability aspect of reusable software.
3 Feature Models Domain concepts that we try to model in reusable software are inherently complex. Even the implementation of a simple container object requires a multitude of design decisions, e.g. type of elements, what kind of iterators it provides, ownership (i.e. whether the container keeps the original elements or their copies and whether it is responsible for deallocating the elements or not), memory allocation (on the stack, on the heap, in a persistent store, etc.), memory management (e.g. whether growing is possible or not), error detection (e.g. whether bounds checking is done or not), synchronization of concurrent access, etc. If the container is to be reusable, many of these decisions have to be changeable since different usage contexts will have different requirements. The changeable design decisions span a design space containing variation points [JGJ98] at which different design alternatives and options may be selected.
Synthesizing Objects
21
In Domain Engineering, each of the alternative design decisions is represented by a feature.2 Following the conceptual modeling perspective, a feature is defined as an important property of a concept. For example, the features of a list may include ordered, singly linked, keeps track of its size, can contain elements of different types, etc. In the context of Domain Engineering, features represent reusable, configurable requirements and each feature has to make a difference to someone, e.g. a stakeholder or a client program. For example, when we build an order processing system, one of the features of the pricing component could be aging pricing strategy, e.g. you pay less for older merchandise. This pricing strategy might be particularly interesting to companies selling perishable goods. Features are organized into feature diagrams [KCH+90], which reveal the kinds of variability contained in the design space. An example of a feature diagram is shown in Fig. 1. It describes a simple model of a car. The root of the diagram represents the concept car. The remaining nodes are features. The diagram contains four kinds of features (see [Cza98] for other kinds of features and a full description of the feature diagram notation):3 •
Mandatory features: Mandatory features are pointed to by simple edges ending with a filled circle. The features car body, transmission, and engine are mandatory and thus part of any car.
•
Optional features: Optional features are pointed to by simple edges ending with an empty circle, e.g. pulls trailer. A car may pull a trailer or not.
•
Alternative features: Alternative features are pointed to by edges connected by an arc, e.g. automatic and manual. Thus, a car may have an automatic or manual transmission.
•
Or-features: Or-features are pointed to by edges connected by a filled arc, e.g. electric and gasoline. Thus, a car may have an electric engine, a gasoline engine, or both.
A feature diagram is usually accompanied by additional information, such as short semantic description of each feature, rationale for each feature, stakeholders and client programs interested in each feature, examples of systems with a given feature, constraints between features (e.g. which feature combinations are illegal and which features imply the selection of which other features), default dependency rules (i.e. which feature suggests the selection of which other features), availability sites (i.e. where, when, and to whom a feature is available), binding modes (e.g. whether a feature is bound dynamically or statically), open/closed attributes (i.e. whether new subfeatures are expected), and priorities (i.e. how important is a feature). All this information together constitutes a feature model (see [Cza98] for a detailed description).
2
Features and feature modeling have been propagated by the Feature-Oriented DomainAnalysis (FODA) method [KCH+90]. 3
Or-features are not part of the notation in [KCH+90]. They were introduced in [Cza98].
22
Krzysztof Czarnecki and Ulrich W. Eisenecker
FDU
FDUERG\
WUDQVPLVVLRQ
DXWRPDWLF
HQJLQH
PDQXDO
HOHFWULF
SXOOVWUDLOHU
JDVROLQH
Fig. 1. A sample feature diagram of a car ManualTransmission alternative transmissions AutomaticTransmission
transmission
CarBody
Car 0..1
pulls➨ 0..1
transmission
Trailer
transmission
ElectricCar
GasolineCar
1
1
ElectricEngine
transmission
ElectricGasolineCar
1
1 GasolineEngine
Fig. 2. One possible implementation of the simple car from Fig. 1 using a UML class diagram
A feature model describes the configurability aspect of a concept at a high level of abstraction. Indeed, feature models are more abstract than object diagrams. Fig. 2 shows one possible implementation of the car feature diagram in UML. In order to be able to represent our car as an UML class diagram, we had to make a number of concrete decisions about which variability mechanisms to use. The implementation in Fig. 2 uses static parameterization for the transmission, inheritance for the engine, and
Synthesizing Objects
23
an association with the cardinality 0..1 for the trailer. Of course, other implementation choices are also possible. For example, we could also use inheritance or dynamic parameterization for the transmission. The fundamental flaw of the OOA/D methodology in the context of software reuse is to start modeling in terms of variability implementation mechanisms such as inheritance, dynamic parameterization, and static parameterization rather than more abstract variability representations. For this reason, as suggested by various researchers [CN98, GFA98, Cza98], we use feature models in addition to other OO models.
4 Feature Models and Object Synthesis We can implement the functionality represented by a feature model using a set of implementation components. The idea is that the implementation components can be configured to yield the different systems covered by a feature model. We can utilize different technologies for implementing the components. One possibility is to use objects. As stated, object-oriented designs support variability using different mechanisms and techniques. Nearly every design pattern listed in [GHJV95] is about making some part of a design variable, e.g. bridge lets you vary the implementation of an object; strategy turns an algorithm into a parameter; state allows you to vary behavior depending on the state; template method provides a way to vary computation steps while keeping the algorithm structure constant. Most of the standard implementations of these patterns utilize dynamic parameterization allowing parameters to vary at runtime. However, reusable models are also full of static variation points, i.e. ones that vary from application to application rather than within one application at runtime. Such variation points are better implemented using static parameterization. We will see concrete examples in Section 5.2. What is the relationship between features and the implementation components? In this context, we differentiate between three kinds of features: •
Concrete features: A concrete feature is directly implemented by one component, e.g. sorting can be directly implemented by a sorting component.
•
Aspect features: An aspect feature is implemented as an aspect in the sense of Aspect-Oriented Programming [KLM+97]. An aspect is a kind of modularity which affects many other components, e.g. a declarative description of the synchronization of a number of components. Aspects are usually implemented using an aspect weaver, i.e. a language processor which automatically implements the aspect by coordinating (e.g. merging or interpretatively scheduling) the component code and the aspect code.
•
Abstract features: Abstract features do not have direct implementations whatsoever. They are implemented by an appropriate combination of components and aspects. Examples of abstract features are performance requirements, such as optimize for speed or space or accuracy.
24
Krzysztof Czarnecki and Ulrich W. Eisenecker
We say that features make up the problem space, whereas the implementation components and aspects constitute the solution space. Both spaces have different structures: abstract features have no directly corresponding components or aspects in the solution space, and there may be some “implementation-detail” components and aspects that have no direct correspondence in the problem space. Both spaces are also driven by different, usually conflicting goals: The problem space consists of high-level concepts and features which application programmers would like to work with, while the components in the solution space are designed •
as elementary components combinable in as many ways as possible,
•
to avoid any code duplication, and possibly
•
to be reusable across many product lines.
The separation between problem and solution space allows us to satisfy both the problem space goals and the solution space goals. It also promotes software evolution since both spaces may be modified (to a certain degree) independently. The mapping between the problem and the solution space is facilitated by configuration knowledge, which consists of •
constraints specifying which feature combinations are illegal and which features require the selection of which other features,
•
default dependency rules specifying which feature suggests the selection of which other features, and
•
mapping rules specifying which feature combinations require which combinations of components and aspects.
Since some of the variation points in the problem space are static, we need a way to evaluate the configuration knowledge and compose the appropriate components and aspects at compile time. What does this mean if we use objects to implement the solution space? We need a metaprogramming facility which synthesizes objects according to abstract featural descriptions at compile time. As with any new programming concept, direct support in a programming language is desirable. Surprisingly, the concepts outlined above can be implemented utilizing the OO and generic features of C++. We demonstrate the necessary techniques in the following section.
5 Example: Synthesizing a List Container 5.1 Feature Model Suppose we want to develop a reusable singly linked list. First, we need to analyze the requirements different applications may have for a list in areas such as type of elements, element traversal, storage layout, ownership, memory allocation and memory management, error detection, synchronization of concurrent access, etc. We document the variable features in different areas using feature diagrams. Finally, we should prioritize the features according to project goals (e.g. target customers and applications). For the purpose of this presentation, we show the implementation of a
Synthesizing Objects
25
list covering the features shown in Fig. 3. ElementType is the type of the elements stored in the list and is a free parameter (i.e. any type can be substituted for ElementType), as indicated by the square brackets. Ownership indicates whether the list keeps references to the original elements and is not responsible for element deallocation (i.e. external reference), or keeps references and is responsible for element deallocation (i.e. owned reference), or keeps copies of the original elements and is responsible for their allocation and deallocation (i.e. copy). Morphology describes whether the list is monomorphic (i.e. all elements have the same type) or polymorphic (i.e. can contain elements of different types). Each list element may also contain a length counter allowing for a length operation of a constant time complexity. LengthType is the type of the counter. Finally, the list may optionally trace its operation, e.g. by logging operation calls to the console. (Of course, this diagram could be extended with further features.) /LVW
>(OHPHQW7\SH@
2ZQHUVKLS
H[WHUQDO
RZQHG
UHIHUHQFH
UHIHUHQFH
FRS\
0RUSKRORJ\
/HQJWK&RXQWHU
PRQR
SRO\
PRUSKLF
PRUSKLF
7UDFLQJ
>/HQJWK7\SH@
LQW
VKRUW
ORQJ
Fig. 3 Feature diagram of a simple list container
5.2 Implementation Components As stated, we can apply different OO techniques to implement the variability contained in the list feature diagram. Here we show an implementation using static parameterization (including parameterized inheritance). Our implementation consists of the following components: •
PtrList, which implements the basic list functionality including the accessing operations for the head (head() and setHead()) and tail (tail() and setTail());
•
LenList, which is a wrapper for adding a length counter to a list;
•
TracedList, which is a wrapper for adding tracing to a list.
26
Krzysztof Czarnecki and Ulrich W. Eisenecker
Additionally, there are three sets of small components for implementing ownership and morphology: •
destroyers, which deallocate memory;
•
type checkers, which check the type of the elements added to the list;
•
copiers, which copy the elements. 7UDFLQJ/D\HU
7UDFHG/LVW
&RXQWHU/D\HU
/HQ/LVW
%DVLF/LVW
3WU/LVW
&RQILJ
(OHPHQW7\SH
>(OHPHQW7\SH@
'HVWUR\HU
(PSW\'HVWUR\HU_(OHPHQW'HVWUR\HU
7\SH&KHFNHU
'\QDPLF7\SH&KHFNHU_(PSW\7\SH&KHFNHU
&RSLHU
3RO\PRUSKLF&RSLHU_0RQRPRUSKLF&RSLHU_ (PSW\&RSLHU
/HQJWK7\SH
LQW_VKRUW_ORQJ_
7KLV7\SH
Fig. 4 Target architecture of the list container List : OptCounterList : BasicList : Config : ElementType : Destroyer : TypeChecker : Copier : LengthType : ReturnType //the
TracedList[OptCounterList] | OptCounterList LenList[BasicList] | BasicList PtrList[Config] [ElementType] EmptyDestroyer | ElementDestroyer DynamicTypeChecker | EmptyTypeChecker PolymorphicCopier | MonomorphicCopier | EmptyCopier int | short | long | ... final list type
Fig. 5 GenVoca grammar of the list container
The implementation components constitute a layered architecture (or a GenVoca architecture [BO92]) shown in Fig. 4. Each rectangle represents a layer. A layer drawn on top of another layer refines the latter by adding new functionality. In general, a layer may contain more than one alternative component. A component from one layer takes another component from the layer below as its parameter, e.g. LenList may take PtrList as its parameter. A layer containing a dashed rectangle is optional (see [Cza98] for details of this notation). The bottom layer is referred to as a configuration repository (abbr. Config). A configuration repository provides types needed by the other layers under standardized names. It works as a kind of registry, which components may use to retrieve configuration information from and also to exchange such information among each other. A configuration repository allows us to separate the configuration knowledge from the components. It minimizes dependencies
Synthesizing Objects
27
between components since the components do not exchange types and constants directly, but through the repository. As we show later, we implement a configuration repository as a so-called traits class [Mye95]. The use of trait classes as configuration repositories was first proposed in [Eis96]. Alternatively, the list architecture can be described as a so-called GenVoca grammar (see Fig. 5). The vertical bar separates alternatives and parameters are enclosed in square brackets, e.g. List is either TracedList parameterized by OptCounterList or OptCounterList. The names exported by the configuration repository Config are listed below it. ReturnType is the final type of a configured list. We can implement PtrList in C++ as follows:4 template class PtrList { public: //make Config available as a member type typedef Config_ Config; private: //retrieve needed types from the configuration repository typedef typename Config::ElementType ElementType; typedef typename Config::SetHeadElementType SetHeadElementType; typedef typename Config::ReturnType ReturnType; typedef typename Config::Destroyer Destroyer; typedef typename Config::TypeChecker TypeChecker; typedef typename Config::Copier Copier; public: PtrList(SetHeadElementType& h, ReturnType *t = 0) : head_(0), tail_(t) { setHead(h); } ~PtrList() { Destroyer::destroy(head_); } void setHead(SetHeadElementType& h) { TypeChecker::check(h); head_ = Copier::copy(h); } ElementType& head() { return *head_; } void setTail(ReturnType *t) { tail_ = t; } ReturnType *tail() const { return tail_; } private: ElementType* head_; ReturnType *tail_;
4
The keyword typename is required by ANSI C++ to tell the compiler that a member of a template parameter is expected to be a type.
28
Krzysztof Czarnecki and Ulrich W. Eisenecker
};
PtrList has two instance variables: head_ and tail_. head_ points to the head element and tail_ points to the rest of the list. Please note that the type of tail_ is ReturnType, which is the final type of the list. We cannot use PtrList as the type of tail_ since we will derive a list with counter and a list with tracing from PtrList. Whenever we derive classes from PtrList and want to create instances of the most refined type, tail_ has to be of the most refined type. Since this type is unknown in PtrList, PtrList retrieves it from the configuration repository (which is passed to PtrList as the parameter Config_).5 The next interesting point about PtrList is that methods setting the head (i.e. the constructor and setHead()) use the type SetHeadElementType&. This type should be either ElementType& or const ElementType&, depending whether the list stores references to elements or copies of elements. Since this is unknown in PtrList, PtrList retrieves SetHeadElementType from the configuration repository. Finally, PtrList delegates some of its work to other components: The destructor delegates its job to the type name Destroyer, which is retrieved from the configuration repository. Similarly, setHead() delegates type checking and copying to TypeChecker and Copier, respectively. The type names Destroyer, TypeChecker, and Copier may point to different components, as specified in Fig. 5. We have two destroyer components: ElementDestroyer and EmptyDestroyer. ElementDestroyer deletes an element and is used if a list keeps element copies or owned references. It is implemented as a struct rather than a class since it defines only one public operation and struct members are public by default: template struct ElementDestroyer { static void destroy(ElementType *e) { delete e; } };
EmptyDestroyer is used if a list keeps external references to the original elements. EmptyDestroyer does nothing. Since its destroy() method is implemented inline, an optimizing compiler will remove any calls to this method. template struct EmptyDestroyer { static void destroy(ElementType *e) {} //do nothing };
DynamicTypeChecker is used to assure that a monomorphic list contains elements of one type only: template struct DynamicTypeChecker { static void check(const ElementType& e) 5
Our example demonstrates how configuration repositories can help in typing recursive classes, i.e. classes that are used directly or indirectly in their own definition (e.g. a list).
Synthesizing Objects {
29
assert(typeid(e)==typeid(ElementType)); }
};
EmptyTypeChecker is used in a polymorphic list and it does nothing: template struct EmptyTypeChecker { static void check(const ElementType& e) {} };
PolymorphicCopier copies an element by calling the virtual function clone(): template struct PolymorphicCopier { static ElementType* copy(const ElementType& e) { return e.clone(); } //call a virtual clone() };
MonomorphicCopier copies an element by calling its copy constructor: template struct MonomorphicCopier { static ElementType* copy(const ElementType& e) { return new ElementType(e); } //call copy constructor };
Finally, EmptyCopier simply returns the original element: template struct EmptyCopier { static ElementType* copy(ElementType& e) //pass by non-const //reference! { return &e; } //simply return the original };
Next, we need the two wrappers LenList and TracedList implementing length counter and tracing, respectively. LenList is implemented as an inheritance-based wrapper, i.e. a template class derived from its parameter.6 It overrides the method setTail() to keep track of the list length and adds the method length(). Please note that the component retrieves the configuration repository from its parameter: template class LenList : public BaseList { public: //retrieve the configuration repository typedef typename BaseList::Config Config; private: //retrieve the necessary types from the repository typedef typename Config::ElementType ElementType; typedef typename Config::SetHeadElementType SetHeadElementType; typedef typename Config::ReturnType ReturnType; typedef typename Config::LengthType LengthType; public: LenList(SetHeadElementType& h, ReturnType *t = 0) : BaseList(h,t), length_(computedLength()) {} void setTail(ReturnType *t) 6
Inheritance-based wrappers are useful whenever we only want to override few methods and inherit the remaining ones. Otherwise, we can use aggregation-based wrappers.
30
Krzysztof Czarnecki and Ulrich W. Eisenecker {
BaseList::setTail(t); length_ = computedLength();
} const LengthType& length() const { return length_; } private: LengthType computedLength() const { return tail() ? tail()->length()+1 : 1; } LengthType length_; };
TracedList is also implemented as an inheritance-based wrapper: template class TracedList : public BaseList { public: typedef typename BaseList::Config Config; private: typedef typename Config::ElementType ElementType; typedef typename Config::SetHeadElementType SetHeadElementType; typedef typename Config::ReturnType ReturnType; public: TracedList(SetHeadElementType& h, ReturnType *t = 0) : BaseList(h,t) { } void setHead(SetHeadElementType& h) { cout
ElementType; SetHeadElementType; Destroyer; TypeChecker; Copier;
typedef PtrList ReturnType; }; typedef RefPolyBaseListConfig::ReturnType RefPolyBaseList;
Writing down the configuration repositories for all 24 list variants (we would parameterize ElementType and LengthType for this purpose) is rather tedious.
32
Krzysztof Czarnecki and Ulrich W. Eisenecker
The situation is worse if we have concepts with more variable features. For example, we implemented a configurable matrix component, which covers 1840 different matrix variants (see Section 7). Writing down all configuration repositories in this case is impracticable. An alternative would be to let the application programmer write the configuration repositories for the types he or she needs. This approach has its drawbacks. Writing the lengthy configuration repositories is error prone and tedious. Furthermore, configuration repositories mention implementation detail which is not relevant at the level of the feature diagram in Fig. 3. For example, we have to explicitly define SetHeadElementType, although it is not part of the feature diagram. Similarly, selecting the appropriate destroyer, type checker, and copier is an implementation detail. These choices, although they automatically follow from the selected abstract features, have to be programmed manually! A much better solution is to generate the configuration repositories from abstract specifications. This is described in the following section. 5.4 Generating Lists from Abstract Specifications Our goal now is to generate list configurations from abstract specifications, i.e. sets of features defined in Fig. 3. This requires writing some code which is executed at compile time. We can use template metaprogramming for this purpose [Vel95a, CE98, Cza98]. Template metaprograms consist of class templates operating on numbers and/or types as data.7 Algorithms are expressed using template recursion as a looping construct and class template specialization as a conditional construct. Template recursion involves the direct or indirect use of a class template in the construction of its own member type or member constant. In other words, C++ templates constitute a Turing-complete sublanguage of C++, which is interpreted by the compiler at compile time. As an example, consider a template which computes the factorial of a non-negative integral number: template struct Factorial { enum { RET = Factorial::RET * n }; }; //the following template specialization terminates the recursion template struct Factorial { enum { RET = 1 }; };
We can use this class template as follows: void main() { cout ::RET Destroyer_; typedef IF, EmptyTypeChecker<ElementType_> >::RET TypeChecker_; typedef IF >::RET, EmptyCopier<ElementType_> >::RET Copier_; typedef IF::RET SetHeadElementType_; typedef PtrList List; typedef IF::RET List_with_counter_or_not; typedef
Synthesizing Objects
35
IF<doesTracing, TracedList, List_with_counter_or_not >::RET List_with_tracing_or_not; public: typedef List_with_tracing_or_not RET; struct Config { typedef ElementType_ typedef SetHeadElementType_ typedef Destroyer_ typedef TypeChecker_ typedef Copier_ typedef LengthType_ typedef RET };
ElementType; SetHeadElementType; Destroyer; TypeChecker; Copier; LengthType; ReturnType;
};
LIST_GENERATOR evaluates the input flags, computes the types for the configuration repository, wraps PtrList (if necessary), and returns the final list type in RET. The last part of LIST_GENERATOR is the configuration repository. Please note that we pass Generator to PtrList as parameter. Since Config is a member of Generator, PtrList can retrieve Config from Generator. For this reason, we need to slightly modify two lines of PtrList (the modifications are highlighted): template class PtrList { public: typedef typename Generator::Config Config; //the rest as previously //...
An important aspect of the separation between the problem-space-oriented feature description and the implementation components is that we can make useful extensions to the components (e.g. modify the component structure or add new components) without the need to change existing client code. This is certainly possible as long as the abstract feature space can still be mapped on the new components. Moreover, we can even make certain extensions to the feature space without the need to change existing client code. For example, we could append new parameters, e.g. memory allocation, to the parameter list expected by the generator. By choosing appropriate defaults for the new parameters, existing calls to the generator will still work properly. Similarly, if we model all features as template structs, we can also add new nested features (cf. the following section).
6 Extensions The previous section demonstrated the basic techniques using a very simple example. Applying these techniques to larger problems requires several extensions: •
Nested features: Our sample generator expects a flat list of features although feature diagrams are trees. This was acceptable for this small example, but in general configuration generators accept tree-like structures. We can represent tree-like feature structures in C++ using types and templates. For example, we
36
Krzysztof Czarnecki and Ulrich W. Eisenecker
could model counterFlag as a type parameter with the values no_counter and with_counter. no_counter would be a struct and with_counter a template struct expecting LengthType as its parameter. •
Multistage configuration generators: Large feature models may contain many constraints and default dependency rules. In this case, a configuration generator consists of several stages: specification completion stage (computes defaults for the unspecified features based on default dependency rules and constraints), feature combination checking stage (checks whether the feature combinations satisfy the constraints), and component assembly stage (assembles components into the final type). The dependency rules and constraints are specified using a kind of decision tables. For this reason, we implemented a table evaluation metafunction, which allows us to directly type in the tables in the C++ source code [Cza98, Kna98]. This function utilizes both IF and template recursion.
•
Nested configuration repositories: Avoiding name clashes in the configuration repository may require introducing separate name scopes within the repository. For example, two different components retrieve the type name ElementType, but each of them should be supplied a different type. This can be resolved by providing a separate name scope for each of these components in the repository. We can model such nested name scopes in C++ as nested classes.
•
Metafunctions as part of configuration repositories: Sometimes one component is used more than once in a configuration and each instance needs different configuration parameters. In this case, the component does not retrieve the required type from the configuration repository directly, but it retrieves a metafunction. Each instance can then supply a different parameter to the metafunction to compute the needed type. In C++, class templates can be defined as members of other classes. This way it is possible to pass around a metafunction as a type, which corresponds to the idea of higher-order metafunctions.
•
Configuration repository as a part of the generated type: Each component exports the configuration repository under the name Config. Thus, we can also retrieve Config from the final type. e.g. MyList::Config. This feature can be used by other generators, e.g. for generating customized algorithms operating on the generated types. An algorithm generator can retrieve the properties of a type from its Config (e.g. ElementType, Ownership, etc.) and use this information to generate optimized algorithms. For example, we have used the configuration repository of a matrix type in order to generate optimized matrix operation code using expression templates [Cza98].
•
Traits templates for encoding metainformation: Metainformation about types can be encoded as traits templates [Mye95], which basically correspond to metafunctions taking types as parameter and returning their properties. For example, our polymorphic copier in Section 5.2 assumes that the element type provides the virtual clone() method. If a particular element type does not provide this method, we can use an adapter. In this case, we could use a traits template on element type to retrieve the appropriate (user-provided) adapter.
Synthesizing Objects
37
Furthermore, we could use a traits template to make sure that DynamicTypeChecker is used only for types having a virtual function table. We used the above extensions in the implementation of two applications described in the following section. The generator approach described here can be also used to synthesize frameworks. Frameworks can be modeled as compositions of collaborations and the latter can be implemented as mixin layers [SB98]. In C++, a mixin layer may be implemented as a class containing a number of nested classes. Each of the nested classes implements a particular role. The nested classes can inherit from their parameters, so that they can be used to extend other classes. A mixin layer takes another layer as its parameter, accesses the classes nested in the parameter and uses them as superclasses of some of its own nested classes (see [SB98] for details). Just as we used our configuration generator to compose parameterized classes, we can also use it to compose mixin layers.
7 Applications The techniques described in previous sections were use to develop two medium size libraries demonstrating their applicability to generating efficient abstract data types and algorithms: •
Generative Matrix Computation Library (GMCL) [GMCL, Cza98, Neu98] contains a matrix generator (in the style of our list generator from Section 5.4) able to generate matrices with a selected combination of features such as element types (real numbers), density (dense and sparse), storage formats (row- and column-wise, several sparse formats), memory allocation (dynamic and static), error checking (bounds, compatibility, memory allocation), and operations (addition, subtraction, multiplication). GMCL contains another generator for generating efficient implementations of matrix expressions (e.g. “(A+B)*(C+D)”). The expression generator is based on expression templates [Vel95b]. It reads out the properties of the operands from their configuration repositories in order to generate optimized code. The C++ implementation of the matrix component comprises 7500 lines of C++ code (6000 lines for the configuration generator and the matrix components and 1500 lines for the operations). The matrix configuration feature diagram covers more than 1840 different kinds of matrices. Despite the large number of provided matrix variants, the performance of the generated code is comparable with the performance of manually coded variants. This is achieved by the exclusive use of static binding, which is often combined with inlining.
•
Generative matrix factorization library [Kna98] contains a generator synthesizing different instances of the LU factorization algorithm family (e.g. Gauss, Cholesky, LDLT) with different pivoting strategies (e.g. partial, full, symmetric, diagonal) and for different matrix shapes. The different parts of the algorithms are implemented as methods of class templates organized in a layered architecture. The templates are configured by a configuration generator.
38
Krzysztof Czarnecki and Ulrich W. Eisenecker
8 Related Work Variability modeling The need for variability modeling in framework design has been recognized in the form of “hot spots” [Pre95]. Hot spots represent variation points. Unfortunately, they are not supported in current OOA/D methods. Furthermore, they make no distinction between different kinds of variation points (e.g. dimensions, dimensions with optional features, extension points, etc. [Cza98]) and do not model the information contained in feature models. Reuse-Driven Software Engineering Business (RSEB) [JGJ98] extends UML with the concept of variation points and defines a reuse-driven development process. However, it still lacks feature modeling. Feature modeling is the corner stone of Domain Engineering (DE) and efforts aimed at integrating OO and DE methods [CN98, GFA98, Cza98] augment OO modeling techniques with feature modeling. Layered Designs and Fragment-Based Component Models GenVoca is a layered architecture model based on parameterized layers of refinement [BO92]. Recent work by Smaragdakis and Batory [SB98] views GenVoca layers as so-called mixin layers, i.e. layers containing classes whose superclasses are parameters. Parameterized inheritance has also been used to express collaboration-based designs [VHN96]. The technique of exchanging types between components at compile time is extensively used in the Standard Template Library (STL) [MS96]. Fragment-based designs have been studied in the context of OO, among others, in [Pre97, Mez97, ML98]. Our work extends these approaches with configuration repositories, which among others provide an effective approach for typing synthesized recursive classes. Furthermore, our configuration generator is capable of synthesizing layered and fragment-based designs from abstract specifications. Metaprogramming There is a large body of work on static metaprogramming for code composition. Most of this work is has been done in the context of procedural languages such as C (e.g. [SG97, Eng97]). There are also examples of static metaprogramming systems for C++, e.g. Open C++ [Chi95] and MPC++ [IHS+96]. All of these systems require special language extensions. Our approach, on the other hand, uses standard C++ language features and thus is widely available. Template metaprogramming has been used to develop a number of libraries including Blitz++ [Vel97], POOMA [POOMA], and MTL [SL98]. Unfortunately, it lacks debugging and error-reporting facilities. An example of a commercial metaprogramming environment supporting the full development cycle is Intentional Programming (IP) [Sim96]. IP is currently under development at Microsoft Research.
9 Conclusions and Outlook The development of truly reusable software requires the parameterization of a multitude of design decisions. We showed that modeling the variability of domain concepts is inadequately supported by current OO modeling notations. We also showed that this problem can be addressed by using feature models in addition to OO models. Furthermore, we demonstrated that the implementation of feature models requires three ingredients: domain-level interface (e.g. a domain-specific language, a domain-specific GUI, etc.) allowing the application programmer to describe concepts
Synthesizing Objects
39
at the abstract domain level, configuration knowledge mapping between abstract descriptions and concrete component configurations, and elementary implementation components, which can be configured in a vast number of ways. We showed that the design of the problem and the solution space starts with feature modeling and the solution space is structured according to some appropriate architecture (in our example, we used a layered architecture). We also found it important to have a direct programming language support for these concepts. We showed concrete examples in C++; however, the concepts are not limited to C++. Indeed, the examples demonstrate the importance of parameterized inheritance in avoiding rigid inheritance hierarchies and the value of generic, STL-style techniques for implementing efficient and highly configurable components. The newly introduced concept of configuration repositories allows the separation between the components and the configuration knowledge and also facilitates an efficient approach to typing recursive classes. Finally, the built-in metaprogramming capabilities of C++ allow the configuration generators to be part of the same library as the implementation components. The presented example concentrated on static configuration and binding, but similar designs based on dynamic configuration and binding can be implemented in C++ and in other languages (e.g. Smalltalk, CLOS, Java9). Indeed, we want to parameterize configuration time and binding time. The latter is easily done in C++ [Eis97], but the former requires the ability to write metacode which can be executed both by the compiler and at runtime – a feature not supported by current languages. One of the conclusions of our work is the need for industrial strength metaprogramming environments. Template metaprogramming (TM) has the important advantage that is readily available to users as a built-in part of C++. But since TM is a child of accident rather than the result of conscious language design, it suffers from several deficiencies in the areas of debugging and error-reporting, code readability, long compilation times, various compiler limits, and portability [Cza98, Neu98, Kna98]. Currently, the size of template metaprograms is limited by compiler limits, compilation times, and debugging problems. However, compiler limits and portability problems will decrease as more and more compiler vendors adopt the new C++ ISO standard. Adequate metaprogramming support opens new possibilities to raise “the level” of programming using domain-specific abstractions. For example, domain-specific abstractions in Intentional Programming [Sim96] are active at any time (including programming and compilation time) and generate efficient, optimized code. This perspective forces us to redefine the conventional interaction between compilers, libraries, and applications and to acknowledge the need for active libraries [CEG+98], which “are not passive collections of routines or objects, as are traditional libraries, but take an active role in generating code. Active libraries provide abstractions and can optimize those abstractions themselves. They may generate components, specialize algorithms, optimize code, automatically configure and tune themselves for a target machine, and check source code for correctness. They may also describe themselves to tools such as profilers and debuggers in an intelligible way.” The effective application of metaprogramming in software engineering requires new analysis and design 9
Java does not support parameterized inheritance. Therefore, whenever we need a parameterized relationship in Java, we have to use dynamic parameterization.
40
Krzysztof Czarnecki and Ulrich W. Eisenecker
approaches. The integration of modeling and implementation technologies based on domain-specific abstractions and metaprogramming into a coherent paradigm is the goal of the emerging area of Generative Programming [CE99, CEG+98, Eis97]. Note: The source code for the list example is available at http://nero.prakinf.tuilmenau.de/~czarn/ecoop99
References [AM97]
M. Aksit and S. Matsuoka, (Eds.). Proceedings of 11th European Conference on Object-Oriented Programming (ECOOP ’97), Springer-Verlag 1997
[BO92]
D. Batory and S. O’Malley. The Design and Implementation of Hierarchical Software Systems with Reusable Components. In ACM Transactions on Software Engineering and Methodology, vol. 1, no. 4, October 1992, pp. 355-398
[CE98]
K. Czarnecki and U. Eisenecker. Template-Metaprogramming, http://home.tonline.de/home/Ulrich.Eisenecker/meta.htm
[CE99]
K. Czarnecki and U. Eisenecker. Generative Programming: Methods, Techniques, and Applications. To appear, Addison-Wesley, 1999
[CEG+98]
K. Czarnecki, U. Eisenecker, R. Glück, D. Vandevoorde, and T. Veldhuizen. Generative Programming and Active Libraries. Submitted for publication, 1998
[Chi95]
S. Chiba. A Metaobject Protocol for C++. In Proceedings of the 10th Annual Conference on Object-Oriented Programming, Systems, Languages and Applications (OOPSLA’95), ACM SIGPLAN Notices, vol. 30, no. 10, 1995, pp. 285-299, http://www.softlab.is.tsukuba.ac.jp/~chiba/openc++.html
[CN98]
S. Cohen and L. M. Northrop. Object-Oriented Technology and Domain Analysis. In [DP98], pp. 86-93
[Cza98]
K. Czarnecki. Generative Programming: Principles and Techniques of Software Engineering Based on Automated Configuration and Fragment-Based Component Models. Ph.D. thesis, Technische Universität Ilmenau, Germany, 1998, see http://nero.prakinf.tu-ilmenau.de/~czarn/
[DP98]
P. Devanbu and J. Poulin, (Eds.). Proceedings of the Fifth International Conference on Software Reuse (Victoria, Canada, June 1998). IEEE Computer Society Press, 1998
[Eis96]
U. Eisenecker. Generatives Programmieren mit C++. In OBJEKTspektrum, No. 6, November/December 1996, pp. 79-84
[Eis97]
U. Eisenecker. Generative Programming (GP) with C++. In Proceedings of Modular Programming Languages (JMLC’97, Linz, Austria, March 1997), H. Mössenböck, (Ed.), Springer-Verlag, Heidelberg 1997, pp. 351-365
[Eng97]
D. R. Engler. Incorporating application semantics and control into compilation. In Proceedings USENIX Conference on Domain-Specific Languages (DSL’97), 1997
[GFA98]
M. L. Griss, J. Favaro, and M. d’Alessandro. Integrating Feature Modeling with the RSEB. In [DP98], pp. 76-85, see http://www.intecs.it
[GHJV95]
E. Gamma, R. Helm, R. Johnson, and J. Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1995
Synthesizing Objects
41
Computation
at
[GMCL]
Homepage of the Generative Matrix http://nero.prakinf.tu-ilmenau.de/~czarn/gmcl/
Library
[Gog96]
J. A. Goguen. Parameterized Programming and Software Architecture. In Proceedings of the Fourth International Conference on Software Reuse, April 23-26, Orlando, Florida. IEEE Computer Society Press, Los Alamitos, California, 1996, pp. 2-10
[IHS+96]
Y. Ishikawa, A. Hori, M. Sato, M. Matsuda, J. Nolte, H. Tezuka, H. Konaka, M. Maeda, and K. Kubota. Design and Implementation of Metalevel Architecture in C++ – MPC++ approach. In Proceedings of Reflection’96, 1996
[JGJ98]
I. Jacobson, M. Griss, and P. Jonsson. Software Reuse: Architecture, Process and Organization for Business Success. Addison Wesley Longman, May 1997
[Jul98]
E. Jul, (Ed.). Proceedings of the 12th European Conference Object-Oriented Programming (ECOOP’98), LNCS 1445, Springer-Verlag, 1998
[KCH+90]
K. Kang, S. Cohen, J. Hess, W. Nowak, and S. Peterson. Feature-Oriented Domain Analysis (FODA) Feasibility Study. Technical Report, CMU/SEI-90-TR21, Software Engineering Institute, Carnegie Mellon University, Pittsburgh, Pennsylvania, November 1990
[KLM+97]
G. Kiczales, J. Lamping, A. Mendhekar, C. Maeda, C. V. Lopes, J.-M. Loingtier, and J. Irwin. Aspect-Oriented Programming. In [AM97], pp. 220-242
[Kna98]
J. Knaupp. Algorithm Generators: A First Experience, see http://nero.prakinf.tuilmenau.de/~czarn/generate/stja98/knaupp.zip
[Mez97]
M. Mezini. Dynamic Object Evolution Without Name Collisions. In [AM97], pp. 190-219
[ML98]
M. Mezini and K. Lieberherr. Adaptive Plug-and-Play Components for Evolutionary Software Development. In Proceedings of the Conference on Object-Oriented Programming Languages and Applications (OOPSLA '98), 1998
[MS96]
D. R. Musser and A. Saini. STL Tutorial and Reference Guide. Addison-Wesley, Reading, Massachusetts, 1996
[Mye95]
N.C. Myers. Traits: a new and useful template technique. In C++ Report, June 1995, see http://www.cantrip.org/traits.html
[Neu98]
T. Neubert. Anwendung von generativen Programmiertechniken am Beispiel der Matrixalgebra. Diplomarbeit, Technische Universität Chemnitz, 1998, also see [GMCL]
[POOMA]
POOMA: Parallel Object-Oriented Methods and Applications. A framework for scientific computing applications on parallel computers. Available at http://www.acl.lanl.gov/pooma
[Pre95]
W. Pree. Design Patterns for Object-Oriented Software Development. AddisonWesley, 1995
[Pre97]
C. Prehofer. Feature-Oriented Programming: A Fresh Look at Objects. In [AM97], pp. 419-443
[Ree96]
T. Reenskaug with P. Wold and O.A. Lehne. Working with Objects: The OOram Software Engineering Method. Manning, 1996
[SB98]
Y. Smaragdakis and D. Batory. Implementing Layered Designs with Mixin Layers. In [Jul98], pp. 550-570
42
Krzysztof Czarnecki and Ulrich W. Eisenecker
[SCK+96]
M. Simos, D. Creps, C. Klinger, L. Levine, and D. Allemang. Organization Domain Modeling (ODM) Guidebook, Version 2.0. Informal Technical Report for STARS, STARS-VC-A025/001/00, June 14, 1996, see http://direct.asset.com
[SG97]
J. Stichnoth and T. Gross. Code composition as an implementation language for compilers. In Proceedings USENIX Conference on Domain-Specific Languages (DSL’97), 1997
[Sim96]
C. Simonyi. Intentional Programming — Innovation in the Legacy Age. Position paper presented at IFIP WG 2.1 meeting, June 4, 1996, see http://www.research.microsoft.com/research/ip/
[SL98]
J. G. Siek and A. Lumsdaine. A Rational Approach to Portable High Performance: The Basic Linear Algebra Instruction Set (BLAIS) and the Fixed Algorithm Size Template (FAST) Library. In Proceedings of the ECOOP’98 Workshop on Parallel Object-Oriented Computing (POOSC’98), 1998, see http://www.lsc.nd.edu/
[Str94]
B. Stroustrup. The Design and Evolution of C++. Addison-Wesley, 1994
[Unr94]
E. Unruh. Prime number computation. ANSI X3J16-94-0075/ISO WG21-462, 1994
[Vel95a]
T. Veldhuizen. Using C++ template metaprograms. In C++ Report, vol. 7, no. 4, May 1995, pp. 36-43, see http://monet.uwaterloo.ca/blitz/
[Vel95b]
T. Veldhuizen. Expression Templates. In C++ Report, vol. 7 no. 5, June 1995, pp. 26-31, see http://monet.uwaterloo.ca/blitz/
[Vel97]
T. Veldhuizen. Scientific Computing: C++ versus Fortran. In Dr. Dobb’s Journal, November 1997, pp. 34-41, see http://monet.uwaterloo.ca/blitz/
[VHN96]
M. VanHilst and D. Notkin. Using Role Components to Implement Collaboration-Based Designs. In Proceedings of the 1996 ACM Conference on Object-Oriented Programming Systems, Languages and Applications (OOPSLA’96), 1996, pp. 359-369
A Core Calculus of Classes and Mixins Viviana Bono1 , Amit Patel2 , and Vitaly Shmatikov2 1
Dipartimento di Informatica dell’Universit` a di Torino, C.so Svizzera 185 10149 Torino, Italy,
[email protected] (currently at the School of Computer Science, The University of Birmingham Birmingham B15 2TT, United Kingdom
[email protected]) 2 Computer Science Department, Stanford University Stanford, CA 94305-9045, U.S.A., {amitp,shmat}@cs.stanford.edu
Abstract. We develop an imperative calculus that provides a formal model for both single and mixin inheritance. By introducing classes and mixins as the basic object-oriented constructs in a λ-calculus with records and references, we obtain a system with an intuitive operational semantics. New classes are produced by applying mixins to superclasses. Objects are represented by records and produced by instantiating classes. The type system for objects uses only functional, record, and reference types, and there is a clean separation between subtyping and inheritance. Keywords: Object-oriented language, mixin, class, inheritance, calculus, operational semantics, type system.
1
Introduction
Mixins (classes parameterized over superclasses) have become a focus of active research both in the software engineering [43, 41, 25] and programming language design [11, 12, 10, 35, 30] communities. Mixin inheritance has been shown to be an expressive alternative to multiple inheritance and a powerful tool for implementing reusable class hierarchies. However, there has been a dearth of formal calculi to provide a theoretical foundation for mixin inheritance and, in particular, few attempts have been made to use mixins as the basic inheritance construct in the core calculus. Although mixin inheritance is easy to formalize in an untyped setting, static type checking of mixins at the time of declaration (as opposed to the time of mixin use) is more difficult. In addition, many approaches to mixins do not address the modular construction of objects, including initialization of fields. While popular object-oriented languages such as C++ [42] and Java [3] are overwhelmingly class-based, most previous core calculi for object-oriented languages were based on objects. In our framework, classes and mixins are basic constructs. The decision to directly include classes in a core calculus reflects many years of struggle with object-based calculi. In simple terms, there is a fundamental conflict between inheritance and subtyping of object types [20, 14, 6, 28]. Our calculus resolves this conflict by supporting class inheritance without class subtyping and object subtyping without object extension. The separation between Rachid Guerraoui (Ed.): ECOOP’99, LNCS 1628, pp. 43–66, 1999. c Springer-Verlag Berlin Heidelberg 1999
44
V. Bono, A. Patel, V. Shmatikov
inheritance (an operation associated with classes) and run-time manipulation of objects allows us to represent objects by records and keep the type system for objects simple, involving only functional, record, and reference types. In particular, we do not need polymorphic object types or recursive MyType. An important advantage of our type system is that it gives types to mixin declarations and mixin applications separately. The actual class to which the mixin is applied may have a “richer” type than that expected by the mixin. For example, it may have more methods, or the types of its methods may be subtypes of those assumed when typing the mixin. This facilitates modular development of class hierarchies, promotes reuse of mixins, and enables the programmer to use a single mixin to add the same functionality to a wide variety of classes. Name clashes between mixins and classes to which they are applied are detected and resolved at the time of mixin application. We discuss design motivations and tradeoffs, and give a brief overview of the core calculus in section 2. We then present the syntax of the calculus (section 3), its operational semantics (section 4), and the type system (section 5). Finally, we compare our calculus with other object-oriented calculi and indicate directions for future research. A simpler version of the calculus described in this paper was presented at MFPS ’99 [7]. The calculus of [7] supports conventional single inheritance instead of mixins.
2
Design of the Core Calculus
In this section, we present our design motivations, discuss tradeoffs involved to designing calculi for object-oriented languages, give a short overview of our calculus, and present an example illustrating mixin usage. 2.1
Design Motivations
Our goal is to design a simple class-based calculus that correctly models the basic features of popular class-based languages and reflects modular programming techniques commonly employed by working programmers. Modular program development in a class-based language involves minimizing code dependencies such as those between a superclass and its subclasses, and between a class implementation and object users. Our calculus minimizes dependencies by directly supporting data encapsulation, mixin inheritance, structural subtyping, and modular object creation. Data encapsulation. We use the C++ terminology (private, protected, and public) for levels of encapsulation. Unlike C++ and some approaches to encapsulation in object calculi such as existential types, our levels of encapsulation describe visibility, and not merely accessibility. For example, in our calculus even the names of private items are invisible outside the class in which they are defined. We believe that this is a better approach since no information about data representation is revealed — not even the number and names of fields. One of the
A Core Calculus of Classes and Mixins
45
benefits of using visibility-based encapsulation is that no conflicts arise if both the superclass and the subclass declare a private field of the same name. Among other advantages, this allows the same mixin to be applied twice (see the example in section 2.4). Mixin inheritance. A mixin is a class definition parameterized over the superclass. The decomposition of ordinary inheritance into mixins plus mixin application is similar to the decomposition of let binding into functions plus function application. A mixin can be viewed as a function that takes a class and derives a new subclass from it. The same mixin can be applied to many classes, obtaining a family of subclasses with the same set of methods added and/or replaced. By providing an abstraction mechanism for inheritance, mixins remove the dependency of the subclass on the superclass, enabling modular development of class hierarchies — e.g., a subclass can be implemented before its superclass has been implemented. Mixin inheritance can be used to model single inheritance and many common forms of multiple inheritance [11, 9]. Mixins were first introduced in the Flavors system [38] and CLOS [33], although as a programming idiom rather than a formal language construct. Our calculus is an attempt to formalize mixins as the basic mechanism underlying all inheritance. To ensure that mixin inheritance can be statically type checked, our calculus employs constrained parameterization. From each mixin definition the type system infers a constraint specifying to which classes the mixin may be applied so that the resulting subclass is type-safe. The constraint includes both positive (which methods the class must contain) and negative (which methods the class may not contain) information. The actual class to which the mixin is applied does not have to match the constraint exactly. It may have more methods than required by the positive part of the constraint, and the types of the required methods may be different from those specified by the constraint as long as the resulting subclass is type-safe. We believe that new and redefined methods should be distinguished in the mixin implementation. From the implementor’s viewpoint, a new method may have arbitrary behavior, while the behavior of a redefined method must be “compatible” with that of the old method it replaces. Having this distinction in the syntax of our calculus helps mixin implementors avoid unintentional redefinitions of superclass methods and facilitates generation of the mixin’s superclass constraint (see section 4). It also helps resolve name clashes when the mixin is applied. Suppose the mixin and the class to which it is applied both define a method with the same name. If the mixin method is marked as redefined, then it is put in the resulting subclass (subject to type compatibility with the replaced method). If the mixin method is marked as new, the type system signals an error. Structural subtyping. As in most popular object-oriented languages, objects in our calculus can only be created by instantiating a class. In contrast to C++, where an object’s type is related to the class from which it was instantiated and subtyping relations apply only to object instantiated from the same class hierarchy, we made a deliberate design decision to use structural subtyping in
46
V. Bono, A. Patel, V. Shmatikov
order to remove the dependency of object users on class implementation. Objects created from unrelated classes can be substituted for each other if their types satisfy the subtyping relation. Modular object construction. Class hierarchies in a well-designed object-oriented program must not be fragile: if a superclass implementation changes but the specification remains intact, the implementors of subclasses should not have to rewrite subclass implementations. This is only possible if object creation is modular. In particular, a subclass implementation should not be responsible for initializing inherited fields when a new object is created, since some of the inherited fields may be private and thus invisible to the subclass. Also, the definitions of inherited fields may change when the class hierarchy changes, making the subclass implementation invalid. Instead, the object construction system should call a class constructor to provide initial values only for that class’s fields, call the superclass constructor to provide initial values for the superclass fields, and so on for each ancestor class. This approach is used in many object-oriented programming languages, including C++ and Java. Unlike many theoretical calculi for object-oriented languages, our calculus directly supports modular object construction. The mixin implementor only writes the local constructor for his own mixin. Mixin applications are reduced to generator functions which call all constructors in the inheritance chain in correct order, producing a fully initialized object (see section 4). 2.2
Design Tradeoffs
In this section, we explain the design decisions and tradeoffs chosen in our calculus. Our goal was to sacrifice as little expressive power as possible while keeping the type system simple and free of complicated types such as polymorphic object types and recursive MyType. Classes. Even in purely object-based calculi, the conflict between inheritance and subtyping usually requires that two sorts of objects be distinguished [28]. “Prototype objects” do not support full subtyping but can be extended with new methods and fields and/or have their methods redefined. “Proper objects” support both depth and width subtyping but are not extensible. Without this distinction, special types with extra information are required to avoid adding a method to an object in which a method with the same name is hidden as a consequence of subtyping (e.g., labeled types of [6]). In our calculus, the class construct plays the role of a “prototype” (extensible but not subtypable), while objects — represented by records of methods — are subtypable but not extensible. Objects. Records are an intuitive way to model objects since both are collections of name/value pairs. The records-as-objects approach was in fact developed in the pioneering work on object-oriented calculi [19], in which inheritance was modeled by record subtyping. Unlike records, however, object methods should be able to modify fields and invoke sibling methods [21]. To be capable of updating the object’s internal state, methods must be functions of the host object
A Core Calculus of Classes and Mixins
47
(self ). Therefore, objects must be recursive records. Moreover, self must be appropriately updated when a method is inherited, since new methods and fields may have been added and/or old ones redefined in the new host object. In our calculus, reduction rules produce class generators that are carefully designed so that methods are given a (recursive) reference to self only after inheritance has been resolved and all methods and fields contained in the host object are known. Object updates. If all object updates are imperative, self can be bound to the host object when the object is instantiated from the class. We refer to this approach as early self binding. Self then always refers to the same record, which is modified imperatively in place by the object’s methods. The main advantage of early binding is that the fixed-point operator (which gives the object’s methods reference to self ) has to be applied only once, at the time of object instantiation. If functional updates must be supported — which is, obviously, the case for purely functional object calculi — early binding does not work (see, for example, [1], where early binding is called recursive semantics). With functional updates, each change in the object’s state creates a new object. If self in methods is bound just once, at the time of object instantiation, it will refer to the old, incorrect object and not to the new, updated one. Therefore, self has to be bound each time a method is invoked. We refer to this approach as late self binding. Object extension. Object extension in an object-based calculus is typically modeled by an operation that extends objects by adding new methods to them. There are two constraints on such an operation: (i) the type system must prevent addition of a method to an object which already contains a method with the same name, and (ii) since an object may be extended again after method addition, the actual host object may be larger than the object to which the method was originally added. The method body must behave correctly in any extension of the original host object, therefore, it must have a polymorphic type with respect to self . The fulfillment of the two constraints can be achieved, for instance, via polymorphic types built on row schemes [5] that use kinds to keep track of methods’ presence. Even more complicated is the case when object extension must be supported in a functional calculus. In the functional case, all methods modifying an object have self as their return type. Whenever an object is extended or has its methods redefined (overriden), the type given to self in all inherited methods must be updated to take into account new and/or redefined methods. Therefore, the type system should include the notion of MyType (a.k.a. SelfType) so that the inherited methods can be specialized properly. Support for MyType generally leads to more complicated type systems, in which forms of recursive types are required. This can be accomplished by using row variables combined with recursive types [27, 26, 28], match-bound type variables [18, 4], or by means of special forms of second-order quantifiers such as the Self quantifier of [1]. Tradeoffs. Our goal is to achieve a reasonable tradeoff between expressivity and simplicity. We do not support functional updates because we believe that imperative updates combined with early self binding provide such a tradeoff.
48
V. Bono, A. Patel, V. Shmatikov
Without functional updates, we can use early binding of self . Early binding eliminates the main need for recursive object types. There is also no need for polymorphic object types in our calculus since inheritance is modeled entirely at the class level and there are no object extension operations. This choice allows us to have a simple type system and a straightforward form of structural subtyping, in contrast with the calculi that support MyType specialization [28, 18]. There are at least two possible drawbacks to our approach. Although methods that return a modified self can be modeled in our calculus as imperative methods that modify the object and return nothing, methods that accept a MyType argument cannot be simulated in our system without support for MyType. We therefore have no support for binary methods of the form described in [15]. Also, the type system of our calculus does not directly support implementation types (i.e., types that include information about the class from which the object was instantiated and not just the object’s interface). We believe that a form of implementation types can be provided by extending our type system with existential types. 2.3
Design of the Core Calculus
The two main concepts in object-oriented programming are objects and classes. In our calculus, objects are records of methods. Methods are represented as functions with a binding for self (the host record) and field (the private field). Since records, functions, and λ-binding are standard, we need not introduce new operational semantics or type rules for objects. Instead, we introduce new constructs and rules for mixins and classes only. The new constructs are: class values (representing complete classes obtained as a result of mixin application), mixin expressions (containing definitions of methods, fields, and constructors), and instantiation expressions (representing creation of objects from classes). A class value is a tuple containing the generator function, the set of public method names, and the set of protected method names. The generator produces a function from self to a record of methods. When the class is instantiated, the fixed-point operator is applied to the generator’s result to bind self in the methods’ bodies, creating a full-fledged object. Mixins — i.e., classes parameterized over the superclass — are represented by mixin expressions. Inheritance is modeled by the evaluation rule that applies a mixin to a class value representing the superclass, producing a new class value. The generator of the new class takes the record of superclass methods built by the superclass generator and modifies it by adding and/or replacing methods as specified by the mixin. Only class values can be instantiated; mixins are used solely for building class hierarchies. For simplicity, the core calculus supports only private fields and public and protected methods. Private methods can be modeled by private fields with a function type; public or protected fields can be modeled by combining private fields with accessor methods. Instead of putting encapsulation levels into object types, we express them using subtyping and binding. Protected methods are treated in the same way as public methods except that they are excluded from
A Core Calculus of Classes and Mixins
49
the type of the object returned to the user. Private fields are not listed in the object type at all, but are instead bound in each method body. In the core calculus each class has exactly one private field, which may have a record type. Each method body takes the class’s private field as a parameter. 2.4
An Example of Mixin Inheritance
Mixin inheritance can be a powerful tool for constructing class hierarchies. In this section, we give a simple example that demonstrates how a mixin can be implemented in our calculus and explain some of the uses of mixins. For readability, the example uses functions with multiple arguments even though they are not formalized explicitly in the calculus. Mixin definition. Following is the definition of Encrypted mixin that implements encryption functionality on top of any stream class. Note that the class to which the mixin is applied may have more methods than expected by the mixin. For example, Encrypted can be applied to Socket Object where Object is the root of all class hierarchies, even though Socket Object has other methods besides read and write. let File = mixin method write = . . . method read = . . . ... end in
let Socket = mixin method method method method ... end in
write = . . . read = . . . hostname = . . . portnumber = . . .
let Encrypted = mixin redefine write = λ next. λ key. λ self . λ data. next (encrypt(data, key)); redefine read = λ next. λ key. λ self . λ . decrypt(next (), key); constructor λ (key, arg). {fieldinit=key, superinit=arg}; protect []; end in . . .
Mixin expressions contain new methods (marked by the method keyword), redefined methods (redefine keyword), and constructors. The names of protected methods should be listed following the protect keyword. Instead of introducing a special field construct, every mixin contains a single private field which is λ-bound in each method body (λ key. · · ·). Methods can access the host object through the self parameter, which is λ-bound in each method body to avoid introducing special keywords. Redefined methods can access the old method body inherited from the superclass via the next parameter. Constructors are simply functions returning a record of two components. The fieldinit value is used to initialize the private field. The superinit value is passed as an argument to the superclass constructor.
50
V. Bono, A. Patel, V. Shmatikov
From the definition of Encrypted, the type system infers the constraint that must be satisfied by any class to which Encrypted is applied. The class must contain write and read methods whose types must be supertypes of those given to write and read, respectively, in the definition of Encrypted. Mixin usage. To create an encrypted stream class, one must apply the Encrypted mixin to an existing stream class. In our calculus, the notation for applying mixin M to class C is M C. For example, Encrypted FileStream is an encrypted file class. The power of mixins can be seen when we apply Encrypted to a family of different streams. For example, we can construct Encrypted NetworkStream, which is a class that encrypts data communicated over a network. In addition to single inheritance, we can express many uses of multiple inheritance by applying more than one mixin to a class. For example, PGPSign UUEncode Encrypt Compress FileStream produces a class of files that are compressed, then encrypted, then uuencoded, then signed. In addition, mixins can be used for forms of inheritance that are not possible in most single and multiple inheritancebased systems. In the above example, the result of applying Encrypted to a stream satisfies the constraint required by Encrypted itself, therefore, we can apply Encrypted more than once: Encrypted Encrypted FileStream is a class of files that are encrypted twice. In our system, private fields of classes do not conflict even if they have the same name, so each application of Encrypted can have its own encryption key. Unlike most forms of multiple inheritance, it is easy to specify the order and number of times the mixins are applied. A note on an implementation. Our calculus uses structural object types that retain no connection to the class from which the object was instantiated. Since unrelated classes may use different layouts for the method dictionary, the compiler cannot use the object’s static type to determine the exact position of a method in the dictionary in order to optimize method lookup as is done in C++. Adding mixins in this environment does not impose an extra overhead. It is possible to support efficient method lookup by introducing a separate hierarchy of mixin interfaces similar to the one analyzed by Flatt et al. [30] and requiring that the order of methods in a mixin’s dictionary match that given in the interface implemented by the mixin. However, a separate interface hierarchy would make the calculus significantly more complicated.
3
Syntax of the Core Calculus
The syntax of our calculus is fundamentally class-based. There are four expressions involving classes: classval, mixin, (mixin application), and new. Classrelated expressions and values are treated as any other expression or value in the calculus. They can be passed as arguments, put into data structures, and so on. However, class values and object values are not intended to be written directly; instead, these expression forms are used only to define the semantics of programs. Class values can be created by mixin application, and object values can be created by class instantiation.
A Core Calculus of Classes and Mixins
Expressions:
e::= | | |
Values:
v : : = const | x | λx.e | fix | ref| ! | : = | : = v | {xi = vi }i∈I | classvalhvg , [mi ]i∈Meth , [p` ]`∈Prot i | mixin method mj = vmj ; (j∈New ) redefine mk = vmk ; (k∈Redef ) protect [p`]; (`∈Prot) constructor vc ; end
51
const | x | λx.e | e1 e2 | fix | ref | ! | : = {xi = ei }i∈I | e.x | H h.e | new e classvalhvg , [mi]i∈Meth , [p` ]`∈Prot i mixin method mj = vmj ; (j∈New) redefine mk = vmk ; (k∈Redef ) protect [p` ]; (`∈Prot) constructor vc ; end | e1 e2
Fig. 1. Syntax of the core calculus
Let Var be an enumerable set of variables (otherwise referred to as identifiers), and Const be a set of constants. Expressions E and values V (with V ⊂ E) of the core calculus are as in Fig.1, where const ∈ Const ; x, xi , mi , mj ∈ Var ; fix is the fixed-point operator; ref, !, : = are operators;1 {xi = ei }i∈I is a record; e.x is the record selection operation; h is a set of pairs h : : = {hx, vi∗ } where x ∈ Var and v is a value (first components of the pairs are all distinct); [mi ], [p`] are sets of identifiers; and I, J, K, L, Meth, Prot, New , Redef ⊂ IN. Our calculus takes a standard calculus of functions, records, and imperative features and adds new constructs to support classes and mixins. We chose to extend Reference ML [45], in which Wright and Felleisen analyze the operational soundness of a version of ML with imperative features. Our calculus does not include let expressions as primitives since we do not need polymorphism to model our objects. We do rely on the Wright-Felleisen idea of store, which we call heap, in order to evaluate imperative side effects. The expression Hhx1 , v1 i . . . hxn , vn i.e associates reference variables x1 , . . . xn with values v1 , . . . , vn . H binds x1 , . . . xn in v1 , . . . , vn and in e. The set of pairs h in the expression Hh.e represents the heap, where the results of evaluating imperative subexpressions of e are stored. The intuitive meaning of the class-related expressions is as follows: 1
Introducing ref, !, : = as operators rather than standard forms such as refe, !e, : =e1 e2 , simplifies the definition of evaluation contexts and proofs of properties. As noted in [45], this is just a syntactic convenience, as is the curried version of : =.
52
V. Bono, A. Patel, V. Shmatikov
– classvalhvg , [mi ]i∈Meth , [p`]`∈Prot i is a class value, i.e., the result of mixin application. It is a triple, containing one function and two sets of variables. The function vg is the generator for the class. The [mi ] set contains the names of all methods defined in the class, and the [p`] set contains the names of protected methods. – mixin method mj = vmj ; (j∈New ) redefine mk = vmk ; (k∈Redef ) protect [p`]; (`∈Prot) constructor vc ; end is a mixin, in which mj = vmj are definitions of new methods, and mk = vmk are method redefinitions that will replace methods with the same name in any class to which the mixin is applied. Each method body vmj,k is a function of self , which will be bound to the newly created object at instantiation time, and of the private field. In method redefinitions, vmk is also a function of next, which will be bound to the old, redefined method from the superclass. The vc value in the constructor clause is a function that returns a record of two components. When evaluating a mixin application, vc is used to build the generator as described in section 4. – e1 e2 is an application of mixin e1 to class value e2 . It produces a new class value. Mixin application is the basic inheritance mechanism in our calculus. – new e uses generator vg of the class value to which e evaluates to create a function that returns a new object, as described in section 4. Programs and answers are defined as follows: p::=e where e is a closed expression a : : = v | H h.v Finally, we define the root of the class hierarchy, class Object, as a predefined class value: 4 Object = classvalh λ .λ .{}, [ ], [ ] i The root class is necessary so that all other classes can be treated uniformly. Intuitively, Object is the class whose object instances are empty objects. It is the only class value that is not obtained as a result of mixin application. The calculus can then be simplified by assuming that any user-defined class that does not need a superclass is obtained by applying a mixin containing all of the class’s method definitions to Object. Throughout this paper, we will use let x = e1 in e2 in terms and examples as a more readable equivalent of (λx.e2 )e1 . Also, we use unit as an abbreviation for the empty record or type {}, instead of having a new unit value and type. We will use the word “object” when the record in question represents an object. To avoid name capture, we apply α-conversion to binders λ and H.
A Core Calculus of Classes and Mixins
4
53
Operational Semantics
const v → (λx.e) v → fix (λx.e) → {. . . , x = v, . . .}.x → refv → Hhx, vih.R[!x] → Hhx, vih.R[: =xv 0 ] → R[H h.e] → H h.H h0 .e → new classvalhg, M, Pi →
δ(const, v) if δ(const, v) is defined [v/x] e [fix(λx.e)/x]e v Hhx, vi.x Hhx, vih.R[v] Hhx, v 0 ih.R[v 0 ] H h.R[e], R 6= [ ] H h h0 .e λv.Sub M∪P→M (fix(g v))
(δ) (βv ) (fix) (select) (ref) (deref) (assign) (lift) (merge) (new)
jk ∈∈ New, Redef ,
mixin ` ∈ Prot method mj = vmj ; (mixin) redefine mk = vmk ; protect [p ]; classvalhg, M, Pi → ` classvalhGen, [mj ] ∪ M, [p` ] ∪ Pi constructor c; end if [mj ] ∩ M = ∅, [mk ] ⊂ M, and [p`] ⊂ [mj ] ∪ M; Gen is defined below
Fig. 2. Reduction Rules The operational semantics for our calculus extends that of Reference ML [45]. Reduction rules are given in Fig.2, where R are reduction contexts [22, 24, 37]. Expression Gen is defined below. Relation → → is the reflexive, transitive, contextual closure of →, with respect to contexts C, as defined (in a standard way) in appendix A. Reduction contexts are necessary to provide a minimal relative linear order among the creation, dereferencing and updating of heap locations, since side effects need to be evaluated in a deterministic order. Reduction contexts R are defined as follows: R : : = [ ] | R e | v R | R.x | new R | R e | v R | {m1 = v1 , . . . , mi−1 = vi−1 , mi = R, mi+1 = ei+1 , . . . , mn = en }1≤i≤n
To abstract from a precise set of constants, we only assume the existence of a partial function δ : Const × ClosedVal * ClosedVal that interprets the application of functional constants to closed values and yields closed values. See section 5 for the δ typability condition. (βv ) and (select) rules are standard. (ref), (deref) and (assign) rules evaluate imperative expressions following the linear order given by the reduction context R and acting on the heap. They are
54
V. Bono, A. Patel, V. Shmatikov
formulated after [45]: (ref) generates a new heap location where the value v is stored, (deref) retrieves the contents of the location x, (assign) changes the value stored in a heap location. (lift) and (merge) rules combine inner local heaps with outer ones whenever a dereference operator or an assignment operator cannot find the needed location in the closest local heap. (mixin) rule evaluates mixin application expressions which represent inheritance in our calculus. A mixin is applied to a superclass value classvalhg, M, Pi. M is a set of all method names defined in the superclass; P is an annotation listing the names of protected methods in the superclass. The resulting class value is classvalhGen, [mj ] ∪ M, [p`] ∪ Pi where Gen is the generator function defined below, [mj ] ∪ M is the set of all method names, and [p` ] ∪ P is an annotation listing protected method names. Using a class generator delays full inheritance resolution until object instantiation time when self becomes available. Gen is the class generator. It takes a single argument x which is used by the constructor subexpression c to compute the initial value for the field of the new object, and returns a function from self to a record of methods. When the fixedpoint operator is applied to the function returned by the generator, it produces a recursive record of methods representing a new object (see the (new) rule). 4
Gen = λx.λself . let t = c(x) in let supergen = g(t.superinit) in m ∈ M \ [mk ] t.fieldinit self y i mj = λy.vmj mk = λy.vmk (supergen self ).mk t.fieldinit self y mi = λy. (supergen self ).mi y In the mixin expression, the constructor subexpression c is a function of one argument which returns a record of two components: one is the initialization expression for the field (fieldinit), the other is the superclass generator’s argument (superinit). Gen first calls c(x) to compute the initial value of the field and the value to be passed to the superclass generator g. Gen then calls the superclass generator g, passing argument t.superinit, to obtain a function supergen from self to a record of superclass methods. Finally, Gen builds a function from self that returns a record containing all methods — from both the mixin and the superclass. To understand how the record is created, recall that method bodies take parameters field, self , and, if it’s a redefinition, next. Methods mj are the new mixin methods: they appear for the first time in the current mixin expression. Gen has to bind field and self for them. Methods mi ∈ M \ [mk ] are the inherited superclass methods: they are taken intact from the superclass’s object (supergen self ). Methods mk are redefined in the mixin. Their bodies can refer to the old methods through the next parameter, which is bound to (supergen self ).mi by Gen. They also receive a binding for field and self . For all three sorts of methods, the method bodies are wrapped inside λy. · · · y to delay evaluation in our call-by-value calculus.
A Core Calculus of Classes and Mixins
55
(fix) rule is standard. (new) rule builds a function that can create a new object. The resulting function can be thought of as the composition of three functions: Sub ◦ fix ◦ g. Given an argument v, it will apply generator g to argument v, creating a function from self to a record of methods. Then the fixed-point operator fix (following [21]) is applied to bind self in method bodies and create a recursive record. Finally, we apply SubM∪P→M , a coercion function from records to records that hides all components which are in M ∪ P but not in M. The resulting record contains only public methods, and can be returned to the user as a fully formed object.
5
Type System
Our types are standard and the typing rules are fairly straightforward. The complexity of typing object-oriented programs in our system is limited exclusively to classes and mixins. Method selection, which is the only operation on objects in our calculus, is typed as ordinary record component selection. Since methods are typed as ordinary functions, method invocation is simply a function application. Types are as follows: τ : : = ι | τ1 → τ2 | τ ref | {xi : τi }i∈I | classhτ, {mi :τi }, [p`]ii∈I,`∈L | mixinhτ1 , τ2 , {mj :τj }, {mk :τk }, [p`]ij∈J,k∈K,`∈L where ι is a constant type; → is the functional type operator; τ ref is the type of locations containing a value of type τ ; {xi :τi }i∈I is a record type; and I, J, K, L ⊂ IN. In class types, {mi : τi } is a record type and [p`] is a set of names, where [p`] ⊆ [mi ]. In mixin types, {mj : τj }, {mk : τk } are record types and [p`] is a set of names, where [p`] ⊆ ([mj ] ∪ [mk ]). Although record expressions and values are ordered so that we can fix an order of evaluation, record types are unordered. We also assume we have a function typeof from constant terms to types that respects the following typability condition [45]: for const ∈ Const and value v, if typeof (const) = τ 0 → τ and ∅ ` v : τ 0 , then δ(const, v) is defined and ∅ ` δ(const, v) : τ . Our type system supports structural subtyping (the z.out_count 18. Test5 z.
z.x < 0
19. TestHash5 th; TestHash1 th1.
th.i == th1.i
20. TestHash5 th; TestHash1 th1.
th.i < th1.i
(1x20 hash join)
(1x1 join)
228 40,000,000
•
• • •
(1x20 join)
930
quently. It has 100 molecules divided among Molecule1, Molecule2 and Molecule3 classes. The application performs 8,000 simulation steps. Queries 2 and 14 check the Decaf Java subset compiler, a medium size program developed for a compiler course at UCSB. The Token domain contains up to 120,000 objects. Query 3 checks the Jess expert system, program from the SPECjvm98 suite [SPEC98]. Queries 4–10, and 16–17 check the compress program from the SPECjvm98 suite. Our queries reference frequently updated fields of compress. Queries 11–12 and 15 check the ray tracing program from the SPECjvm98 suite. The Point domain contains up to 85,000 objects; the IntersectPt domain has up to 8,000 objects.
146
R. Lencevicius, U. Hoelzle, A.K. Singh
• Queries 18–20 check artificial microbenchmarks. These microbenchmarks stress test debugger performance by executing tight loops that continuously update object fields. Table 2. Application sizes and execution times Application 1. Compress 2. Jess
Size (Kbytes) 17.4
Execution time (s) 50
387.2
22
3. Ray tracer
55.7
17
4. Decaf
55
15
5. Ideal gas tank
14.3
57
Structurally, queries can be divided into the following classes: • Queries 1–12 and 18 are simple one-constraint selection queries with a wide range of constraint complexities. For example, query 4 has a very simple low-cost constraint that compares an object field to an integer. The more costly constraint in query 5 invokes a method to retrieve an object field. Another costly alternative constraint (query 6) invokes a comparison method that takes a value as a parameter. Finally, the most costly constraint in query 7 performs expensive mathematical operations before performing a comparison. Queries 8 and 9 have very similar constraints, but differ 4.8 times in debugger invocation frequency. In this paper, by “debugger invocation frequency” we mean the frequency of events in the original program that would trigger a debugger invocation, i.e., the invocation frequency for a debugger with no overhead. Query 12 compares the parameter of the method to the distance of a point to the origin. This query combines costly mathematical operations with increased debugger invocation frequency, because its result depends on all three coordinates of Point objects. • Queries 13–17 and 19–20 are join queries. Queries 13–16 and 19 can be evaluated using hash joins. The evaluation of queries 17 and 20 has to use nested-loop joins. For join queries, the slowdown depends both on the debugger invocation frequency and sizes of the domains. Queries 13–14 have low invocation frequencies; queries 15–17, 19–20 have high invocation frequencies. Queries 14 and 15 have large domains. In the next section, we discuss the performance of these queries. Section 4.3 then discusses the efficiency benefits of incremental evaluation, custom selection code, and unnecessary assignment detection. 4.2 Execution Time Figure 8 shows the program execution slowdown for application programs when queries are enabled. The slowdown is the ratio of the running time with the query active to the running time without any queries. For example, the slowdown of query 3 indicates that the Jess expert system ran 25% slower when the query was enabled. Overall the results are encouraging. All selection queries except query 7 have overheads of less than a factor of 2. The median slowdown is 1.24. We expect overheads of common practical selection queries to be in the same range as our experimental queries; the performance model discussed in section 5 supports this prediction.
Dynamic Query-Based Debugging
147
5.83
3.5 3
Slowdown
2.5 2 1.5 1 0.5 0 1
2
3
4
5
6 7 8 9 10 11 12 13 14 Query number
Figure 8. Program slowdown (queries 15—20 not shown) The slowdown is the ratio of the running time with the query active to the running time without any queries. For example, the slowdown of query 3 indicates that the Jess expert system ran 25% slower when the query was enabled.
Join queries have overheads ranging from 2.13 to 229 for applications. Hash queries (which can be used for equality joins) are efficient for queries 13–14, and other joins are practical for query 13 in which the domains contain only 33 objects each. Queries 15– 17 have large overheads because of frequent invocations (e.g., 2.6 million times per second for query 16) and large domains. Join query performance is acceptable if join domains are small, and the program invokes the debugger infrequently. For large domains and frequently invoked queries, the overhead is significant. Microbenchmark stress-test queries 18–20 show the limits of the dynamic query-based debugger. The benchmark updates a single field in a loop 40 million times per second. When queries depend on this field, the program slowdown is significant. Selection query 18 has a slowdown factor of 6.4, the hash-join evaluation has a slowdown of 228 times, and the slower nested-loop join that checks twenty object combinations in each evaluation has a slowdown of 930 times. Though the microbenchmark results indicate that in the worst case the debugger can incur a large slowdown, these programs represent a hypothetical case. Such frequent field updates are possible only with a single assignment in a loop. Adding a few additional operations inside the loop drops the field update frequency to 3 million times per second which is more in line with the highest update frequencies in real programs. For such update frequencies, the slowdown is much lower as indicated by query 4. We discuss the likelihood of high update frequencies in section 5. Figure 9 shows the components of the overhead: • Loading time, the difference between the time it takes to load and instrument classes using a custom class loader, and the time it takes to load a program during normal execution. • Garbage collection time, the difference between the time spent for garbage collection in the queried program and the GC time in the original program.
148
R. Lencevicius, U. Hoelzle, A.K. Singh
• First evaluation time, the time it takes to evaluate the query for the first time. For join queries, the first query is the most expensive, because it sets up data structures needed for future query reevaluations. We separate this time from the rest of the query evaluation time, because it is a fixed overhead incurred only once. • Evaluation time, the time spent evaluating the query. This component does not include the first evaluation time. The first evaluation time and the evaluation time together compose the total evaluation time. 100 Evaluation
90
First evaluation
Overhead percentage
80
GC
70
Loading
60 50 40 30 20 10 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
Query number
Figure 9. Breakdown of query overhead as a percentage of total overhead For example, 3% of query 14 overhead is spent on instrumentation, 34% on garbage collection, 3% in the first evaluation, and 60% in subsequent reevaluations.
Figure 9 shows the components of the overhead. For example, 3% of the overhead of query 14 is spent on instrumentation, and 34% on garbage collection. The total evaluation time is 63% of the overhead, with 3% spent in the first evaluation, and 60% spent in subsequent reevaluations. On average, the largest part of the overhead is the evaluation time (75.5%), while loading takes only 17% and garbage collection has a negligible overhead (less than 7%) in most cases*. The loading overhead becomes a significant factor when the loaded class hierarchy is large, as in query 3 on the Jess system. The loading overhead also takes a larger proportion of time when query reevaluations are infrequent or fast as in queries 1, 2, 9, and 11. Garbage collection was not a significant factor except in query 14 which creates 120,000 token objects, and in query 1 which has such a small absolute overhead that even a slight increase in GC and loading time becomes a large part of the overhead. Since the evaluation component dominates the overhead, especially in high-overhead, long-running queries, evaluation optimizations are very important for good performance. We discuss some optimizations already reflected in this graph in the next section. 4.3 Optimizations To evaluate the benefit of optimizations implemented in the dynamic query-based debugger, we performed a number of experiments by turning off selected optimizations. 4.3.1 Incremental Reevaluation The dynamic query debugger benefits considerably from the incremental evaluation of queries. We disabled incremental query evaluation and reran all queries. Table 3 shows * Experiments
were run with 128M heap, a factor that decreased the GC overhead.
Dynamic Query-Based Debugging
149
Table 3. Overhead of non-incremental evaluation Slowdown versus noninstrumented
Query 1. Molecule1 z. 2. Id x.
z.x > 350
1.19
x.type < 0
3. spec.benchmarks._202_jess.jess.Token z.
z.sortcode == -1
Slowdown versus optimized
1.16
613
554
7135
5,725
4. spec.benchmarks._201_compress.Output_Buffer z.
z.OutCnt < 0
475
402
5. spec.benchmarks._201_compress.Output_Buffer z.
z.count() < 0
474
373
6. spec.benchmarks._201_compress.Output_Buffer z.
z.lessOutCnt(0)
587
428
7. spec.benchmarks._201_compress.Output_Buffer z.
z.complexMathOutCnt(0)
513
88 233
8. spec.benchmarks._201_compress.Compressor z.
z.in_count < 0
275
9. spec.benchmarks._201_compress.Compressor z.
z.out_count < 0
37
10. spec.benchmarks._201_compress.Compressor z.
z.complexMathOutCount(0)
40
33.8 21.8
11. spec.benchmarks._205_raytrace.Point p.
p.x == 1
10,500
8,496
12. spec.benchmarks._205_raytrace.Point p.
p.farther(100000000)
17,800
8,972
13. Molecule1 z; Molecule2 z1. z.x == z1.x && z.y == z1.y && z.dir == z1.dir && z.radius == z1.radius (33x33 hash join) 14. Lexer l; Token t.
l.token == t && t.type == 27
(120,000x600 hash join)
15. spec.benchmarks._205_raytrace.Point p; spec.benchmarks._205_raytrace.IntersectPt ip. p.z == ip.t && p.z < 0 (85,000x8,000 hash join) 16. spec.benchmarks._201_compress.Input_Buffer z; spec.benchmarks._201_compress.Output_Buffer z1. z1.OutCnt == z.InCnt && z1.OutCnt < 100 && z.InCnt > 0
z.x < 0
19. TestHash5 th; TestHash1 th1.
th.i == th1.i
20. TestHash5 th; TestHash1 th1.
th.i < th1.i
(1x20 hash join) (1x20 join)
10.3
1,973
576
12,400
54
1,708
11
697
9
5,213
821
(1x1 hash join)
17. spec.benchmarks._201_compress.Compressor z; spec.benchmarks._201_compress.Output_Buffer z1. z1.OutCnt < 100 && z.out_count > 1 && z1.OutCnt / 10 > z.out_count 18. Test5 z.
21.96
(1x1 join)
1,491
6.6
5,602
6.02
the results of this experiment. The first column of numbers in the table shows the ratio of non-incremental query running time to the running time of the original program. The second column shows the ratio of non-incremental query running time to the running time of fully optimized incremental query evaluation. For example, query 2 had a factor of 613 overhead and ran for 2.5 hours. In contrast, the same query ran 554 times faster using the incremental reevaluation, had only 11% overhead and finished in 16.4 seconds. Query 1 was the only query that the non-incremental debugger could evaluate in a reasonable time. The overheads of all other queries were enormous; some programs would have run for more than a day. (For queries 3–12 and 14–17, we stopped query reevaluation after the first 100,000 evaluations and estimated the total overhead.) Despite the large overall overhead, the individual non-incremental query evaluations are reasonably fast. For example, even for large join queries 14 and 15, a single query evaluation only took about 50 ms.
150
R. Lencevicius, U. Hoelzle, A.K. Singh
The join queries on compress have an overhead of only 9–11 compared to the incremental optimized version. These joins did not benefit much from incremental evaluation and its optimizations because the domains of these joins contain only a single object. Overall, the experiments with non-incremental evaluation of queries show that incremental evaluation is imperative, greatly reducing the overhead and making a much larger class of dynamic queries practical for debugging. 4.3.2 Custom Generated Selection Code To estimate the benefit of generating custom code as discussed in section 3.4.2, we ran all selection queries with the optimization disabled. The results of the experiment are shown in Table 4. The first column of numbers shows the slowdown of the unoptimized version compared to the original program. The second column indicates the slowdown of the unoptimized version compared to the optimized version. For example, query 4 ran 68.5 times slower than the original program and 58 times slower than the optimized query. Table 4. Benefit of custom selection code (selection queries only) Slowdown versus noninstrumented
Query 1. Molecule1 z. 2. Id x.
z.x > 350
x.type < 0
3. spec.benchmarks._202_jess.jess.Token z.
z.sortcode == -1
Slowdown versus optimized
1.05
1.03
1.46
1.34
11.70
9.26
4. spec.benchmarks._201_compress.Output_Buffer z.
z.OutCnt < 0
68.5
58
5. spec.benchmarks._201_compress.Output_Buffer z.
z.count() < 0
64
51
6. spec.benchmarks._201_compress.Output_Buffer z.
z.lessOutCnt(0)
65
47
7. spec.benchmarks._201_compress.Output_Buffer z.
z.complexMathOutCnt(0)
69.6
12
43.6
37
8. spec.benchmarks._201_compress.Compressor z.
z.in_count < 0
9. spec.benchmarks._201_compress.Compressor z.
z.out_count < 0
10. spec.benchmarks._201_compress.Compressor z.
z.complexMathOutCount(0)
10.5
9.6
11
6
11. spec.benchmarks._205_raytrace.Point p.
p.x == 1
21
15
12. spec.benchmarks._205_raytrace.Point p.
p.farther(100000000)
61
31
1,952
307
13. Test5 z.
z.x < 0
The ideal gas tank applet and Decaf compiler queries did not benefit from this optimization, because these programs reevaluate the query infrequently, and the optimization benefit is masked by variations in start-up overhead. All other queries show significant speedups with the optimization enabled. The benefit of the optimization increases with the frequency of debugger invocations; overall, custom generated selection code produces a median speedup of 15. 4.3.3 Same Value Assignment Test Before evaluating a query after a field assignment, the debugger checks whether the value being assigned to the object field is equal to the value previously held by the field.
Dynamic Query-Based Debugging
151
Such assignments do not change the result of the query and can be ignored by the debugger. Table 5 shows that the number of unnecessary assignments differs highly depending on the programs or fields. While some programs and fields do not have them at all, others have from 7% to 95% of such assignments. Only the ideal gas tank simulation, the Jess expert system, and the ray tracing application have unnecessary assignments to the queried fields. Table 5. Unnecessary assignment test optimization (excluding queries with no unnecessary assignments) Query 1. Molecule1 z.
z.x > 350
2. spec.benchmarks._202_jess.jess.Token z.
Slowdown versus optimized 0.99
z.sortcode == -1
% unnecessary assignments
95%
0.997
7%
3. spec.benchmarks._205_raytrace.Point p.
p.x == 1
0.988
15%
4. spec.benchmarks._205_raytrace.Point p.
p.farther(100000000)
1.16
40%
5. Molecule1 z; Molecule2 z1. z.x == z1.x && z.y == z1.y && z.dir == z1.dir && z.radius == z1.radius (33x33 hash join)
1.61
54%
6. spec.benchmarks._205_raytrace.Point p; spec.benchmarks._205_raytrace.IntersectPt ip. p.z == ip.t && p.z < 0 (85,000x8,000 hash join)
1.02
15%
To check the efficiency of the same-value test, we disabled it while leaving all other optimizations enabled. The results show that the test does not make much of a difference in query evaluation for most queries. For selections that can be evaluated fast, the cost of the same-value test is similar to the cost of the full selection evaluation. Only when the selection constraint is costly (as in query 4), does the same-value test reduce the overhead. For joins, the cost reduction is significant for the ideal gas tank query that contains 54% unnecessary assignments. For other joins, the percentage of unnecessary assignments is too low to make a difference. To summarize, the test whether an assignment changes a value of a field costs only one extra comparison per debugger invocation. It does not change the overhead for most programs, but saves time when the number of unnecessary assignments is large or the query expression is expensive.
5
Performance Model
To better predict debugger performance for a wide class of queries, we constructed a query performance model. The slowdown depends on the frequency of debugger invocations and on the individual query reevaluation time. This relationship can be expressed as follows: T = Toriginal (1 + Tnochange * Fnochange + Tevaluate * Fevaluate)
This formula relates the total execution time of the program being debugged T and the execution time of the original program Toriginal using frequencies of field assignments in the program and individual reevaluation times. The model divides field assignments into two classes:
152
R. Lencevicius, U. Hoelzle, A.K. Singh
Table 6. Frequencies and individual evaluation times Fevaluate
Query 1. Molecule1 z. 2. Id x.
(assignments per second)
z.x > 350
N/A
x.type < 0
16,000
3. spec.benchmarks._202_jess.jess.Token z.
z.sortcode == -1
169,000
4. spec.benchmarks._201_compress.Output_Buffer z.
z.OutCnt < 0
5. spec.benchmarks._201_compress.Output_Buffer z.
z.count() < 0
6. spec.benchmarks._201_compress.Output_Buffer z.
z.lessOutCnt(0)
Tevaluate (µs) N/A 3.73 3 0.140 0.208
1,900,000 7. spec.benchmarks._201_compress.Output_Buffer z.
0.286
z.complexMathOutCnt(0)
8. spec.benchmarks._201_compress.Compressor z.
z.in_count < 0
9. spec.benchmarks._201_compress.Compressor z.
z.out_count < 0
3.7 933,000
0.193 0.488
196,000 10. spec.benchmarks._201_compress.Compressor z.
z.complexMathOutCount(0)
11. spec.benchmarks._205_raytrace.Point p.
p.x == 1
12. spec.benchmarks._205_raytrace.Point p.
p.farther(100000000)
13. Molecule1 z; Molecule2 z1. z.x == z1.x && z.y == z1.y && z.dir == z1.dir && z.radius == z1.radius (33x33 hash join) 14. Lexer l; Token t.
l.token == t && t.type == 27
(120,000x600 hash join)
15. spec.benchmarks._205_raytrace.Point p; spec.benchmarks._205_raytrace.IntersectPt ip. p.z == ip.t && p.z < 0 (85,000x8,000 hash join) 16. spec.benchmarks._201_compress.Input_Buffer z; spec.benchmarks._201_compress.Output_Buffer z1. z1.OutCnt == z.InCnt && z1.OutCnt < 100 && z.InCnt > 0
787,000
0.486
2,300,000
0.461
N/A
N/A
25,000
56.8
350,000
546
1,500,000
60
2,600,000
51
(1x1 hash join)
17. spec.benchmarks._201_compress.Compressor z; spec.benchmarks._201_compress.Output_Buffer z1. z1.OutCnt < 100 && z.out_count > 1 && z1.OutCnt / 10 > z.out_count (1x1 join) 18. Test5 z.
4.26
z.x < 0
42,000,000
19. TestHash5 th; TestHash1 th1.
th.i == th1.i
20. TestHash5 th; TestHash1 th1.
th.i < th1.i
(1x20 hash join)
0.131 5.7
40,000,000 (1x20 join)
23
• Assignments that do not change the value of a field. These assignments do not change the result of the query. The debugger has to perform only two comparisons in this case—a domain test and the value equality test, so it spends a fixed amount of time (Tnochange) in such invocations independent of the query. We calculated Tnochange by running a query on a program that repeatedly assigned the same value to the queried field; for the machine/JVM combination we used, Tnochange = 66 ns. • Assignments that lead to the reevaluation of a query. The time to reevaluate a query Tevaluate for such an assignment depends on the query structure and on the cost of the query constraint expression. For each query, we calculate Tevaluate by dividing the additional time it takes to run a program with a query into the number of debugger invocations. This calculation gives an exact result for programs that have no unnecessary assignments (Fnochange = 0). For example, for query 18 Tevaluate is 131ns. Tevaluate for query 4 is 140 ns, which is close to the time to evaluate a similar query in a microbenchmark. When constraints are more costly,
Dynamic Query-Based Debugging
153
Tevaluate increases; for example, for the highest cost selection query (query 10) it
is 4.26 µs. It is even higher for join queries where it depends on the size of domains in joins; for example, for query 16 it is 60 µs, and for query 15 which has large domains, it is 546 µs. Using the values of reevaluation times and the frequency of assignments to the fields of the change set, we can estimate the debugging overhead. First, we determine the typical field assignment frequency. 5.1 Debugger Invocation Frequency Debugger invocation frequency is an important factor in the slowdown of programs during debugging. The program invokes the debugger after object creation and after field assignments. For most queries, the field assignment component dominates the debugger invocation frequency. To find the range of field assignment frequencies in programs, we examined the microbenchmarks and the SPECjvm98 application suite. We instrumented the applications to record every assignment to a field. Table 7 shows results of these measurements. Table 7. Maximum field assignment frequencies Application 1. Compress 2. Jess 3. Db
Maximum frequency (field assignments per second)
Original program execution time (s)
1,900,000
50.4
169,000
22.45
254
75
4. Javac
217,000
38
5. Mpegaudio
495,000
57.4
6. Jack
27,000
27
7. Ray tracer
787,000
17
8. Decaf
56,000
15
9. Ideal gas tank
23,150
57
10. Microbenchmark
40,000,000
2.4
The maximum field assignment frequency in microbenchmarks is 40 million assignments per second, but that would be difficult to reach in an application because the microbenchmarks contain a single assignment inside a loop. The compress program has the highest field assignment frequency in the SPECjvm98 application suite, 1.9 million assignments per second. Other SPEC applications, as well as the Decaf compiler and the ideal gas tank applet, have much lower maximum field assignment frequencies. Figure 10 shows the frequency distribution of field assignments in the SPECjvm98 applications. The left graph indicates how many fields have an assignment frequency in the range indicated on the x axis. For example, only four fields are assigned between one million and two million times per second. The right graph shows the cumulative percentage of fields that have assignment frequencies lower than indicated on the x axis; 95% of all fields have fewer than 100,000 assignments per second. To predict the overhead of a typical selection query, we can now calculate the overhead as a function of invocation frequency. Figure 11 uses the minimum (130 ns) and
154
R. Lencevicius, U. Hoelzle, A.K. Singh
100 Cumulative percentage of fields
250
Number of fields
200
150
100
50
80 70 60 50 40 30 20 10 0.1 0.5 1 5 10 50 100 500 1000 5000 10K 50K 100K 500K 1M 2M
0 0.1 0.5 1 5 10 50 100 500 1000 5000 10K 50K 100K 500K 1M 2M
0
90
Field assignment frequency
Field assignment frequency
Figure 10. Field assignment frequency in SPECjvm98 maximum (4.26 µs) values of Tevaluate from Table 6 to plot the estimated selection query overhead for a range of invocation frequencies. For example, a selection query on a field updated 500,000 times per second would have an overhead of 6.5% if its reevaluation time was 130 ns. If the reevaluation time was 4.26 µs, the overhead will be a factor of 3.13. The graph reveals that selection queries on fields assigned less than 100,000 times a second—95% of fields—have a predicted overhead of less than 43% even for the most costly selection constraint. For less costly selections, the query overhead is acceptable for all fields. 10 Low cost
9
High cost
8
Slowdown
7 6 5 4 3 2 1 2M
500K 1M
10K 50K 100K
500 1000 5000
5 10 50 100
0.1 0.5 1
0
Field assignment frequency
Figure 11. Predicted slowdown The graph shows the predicted overhead as a function of update frequency. For example, the predicted overhead of a low-cost selection query on a field updated 500,000 times per second is 6.5%; the predicted overhead of a high-cost query with the same frequency is a factor of 3.13.
In the current model, the evaluation time Tevaluate models all sources of query overhead. This time includes the actual reevaluation time as well as the additional garbage collection time, the class instrumentation cost, and the first evaluation cost. It would be more exact to model each of these overheads separately. However, for long running programs the evaluation time dominates the total cost, so the values of Tevaluate are likely to fall in the range we have covered.
Dynamic Query-Based Debugging
155
In summary, the performance model predicts that most selection queries will have less than 43% overhead. The model can be used as a framework for concrete overhead predictions and future model refinements.
6
Queries with Changing Results
So far we discussed using dynamic queries for debugging, where the program stops as soon as the query returns a non-empty result. However, programmers can also use queries to monitor program behavior. For example, in the ideal gas tank simulation, users may want to monitor all molecule near-collisions with a query: Molecule* m1 m2.
m1.closeTo(m2) && m1 != m2
Programmers may use this information to check the frequency of near-collisions, to find out if near-collisions are handled in a special way by the program, or to check the correspondence of program objects with the visual display of the simulation. In this case, the debugger should not stop after the result becomes non-empty, but instead should continue executing the program and updating the query result as it changes. Such monitoring, perhaps coupled with visualization of the changing result, can help users understand abstract object relationships in large programs written by other people. How can a debugger support continuous updating of query results while the program executes? Table 8. Benchmark queries with non-empty results Query 1. Molecule1 z. 2. Id x.
Slowdown
z.x < 200
1.05
x.type == 0
1.23
3. spec.benchmarks._202_jess.jess.Token z.
z.sortcode == 0
4. spec.benchmarks._201_compress.Compressor z.
z.OutCnt == 0
5. spec.benchmarks._201_compress.Compressor z.
z.out_count == 0
6. Molecule1 z; Molecule2 z1. 7. Lexer l; Token t.
z.x < z1.x && z.y < z1.y
l.token == t && t.type == 0
1.3 1.19 1.09
(33x33 join)
1.47
(120,000x600 hash join)
4.09
8. spec.benchmarks._205_raytrace.Point p; spec.benchmarks._205_raytrace.IntersectPt ip. (p.z == ip.t) && (p.z > 100) (85,000x8,000 hash join) 9. spec.benchmarks._201_compress.Compressor z; spec.benchmarks._201_compress.Output_Buffer z1.
z1.OutCnt == z.out_count
10. spec.benchmarks._201_compress.Input_Buffer z; spec.benchmarks._201_compress.Output_Buffer z1.
z1.OutCnt < z.InCnt
11. Test5 z.
z.x % 2 == 0
(1x1 hash join)
(1x1 join)
212.4 9.07 127 45
The dynamic query-based debugger described above needs only a few changes to support monitoring queries. The basic scheme and the implementation of the dynamic query-based debugger discussed in section 3 remain the same. The only new component of the debugger is a module that maintains the current query result. As discussed in section 3.4.1, the debugger reevaluates only the changed part of the query. Consequently, the result handling module must store the query result from the previous evaluation and then merge it with the new partial result. To achieve that, after query execution
156
R. Lencevicius, U. Hoelzle, A.K. Singh
the debugger deletes all tuples from the previous result that contain the changed domain object and inserts the new tuples generated by the incremental reevaluation. Experiments with queries similar to the ones in Table 1 show that adding the query result update functionality does not significantly change the query evaluation overhead (Table 8). The only exception is the microbenchmark selection query 11 which updates the query result during each reevaluation. Consequently, the overhead of the selection increases from 6.4 times to 45 times, although part of this increase can be attributed to the more costly selection constraint. However, such frequent result updates are unlikely for most monitoring queries: programmers can only absorb infrequent result changes, so, if results change rapidly, the display will be unintelligible unless it is artificially slowed down or used off-line. To summarize, monitoring queries are useful for understanding and visualizing program behavior. With slight modifications our debugger supports monitoring queries. Unless the result changes very rapidly, the additional overhead of monitoring query execution is insignificant when compared to similar debugging queries.
7
Related Work
We are unaware of other work that directly corresponds to dynamic query-based debugging. The query-based debugging model and its non-dynamic implementation are presented in a previous paper [LHS97]. Extensions of object-oriented languages with rules as in R++ [LMP97] provide a framework that allows users to execute code when a given condition is true. However, R++ rules can only reference objects reachable from the root object, so R++ would not help to find the javac error we discussed. Due to restrictions on objects in the rule, R++ also does not handle join queries. Sefika et al. [SSC96] implemented a system allowing limited, unoptimized selection queries about high-level objects in the Choices operating system. The system dynamically shows program state and run-time statistics at various levels of abstraction. Unlike our dynamic query-based debugger, the tool uses instrumentation specific to the application (Choices). While no one has investigated the query-based debugging specifically, various researchers have proposed a variety of enhancements to conventional debugging [And95, Cop94, DHKV93, GH93, GWM89, KRR94, Laf97, LM94, LN97, WG94]. The debuggers most closely related to dynamic query-based debugging visualize object relationships—usually references or an object call graph. Duel [GH93] displays data structures by using user script code. HotWire [LM94] allows users to specify custom object visualizations in constraint language. Look! [And95], Object Visualizer [DHKV93], PV [KRR94], and Program Explorer [LN97] provide numerous graphical and statistical run-time views with class-dependent filtering but do not allow general queries. Our debugger can gather statistical data through queries with non-empty results (“How many lists of size greater than 500 exist in the program?”) but does not display animated statistical views. Visualizing debuggers gather information by either instrumenting the source code [DHKV93, LM94] or by using program traces [KRR94, LN97]. A port of our debugger to C++ would have to use one of these techniques. Laffra [Laf97] discusses visual
Dynamic Query-Based Debugging
157
debugging in Java using source code instrumentation or JVM changes. We opted for the third method—class file instrumentation at load time. Consens et al. [CHM94, CMR92] use the Hy+ visualization system to find errors using post-mortem event traces. De Pauw et al. [DLVW98] and Walker et al. [WM+98] use program event traces to visualize program execution patterns and event-based object relationships, such as method invocations and object creation. This work is complementary to ours because it focuses on querying and visualizing run-time events while we query object relationships. Dynamic query-based debugging extends work on data breakpoints [WLG93]—breakpoints that stop a program whenever an object field is assigned a certain value. Pre-/ postconditions and class invariants as provided in Eiffel [Mey88] can be thought of as language-supported dynamic queries that are checked at the beginning or end of methods. Unlike dynamic queries, they are not continuously checked, they cannot access objects unreachable by references from the checked class, nor can they invoke arbitrary methods. Dynamic queries could be used to implement class assertions for languages that do not provide them. The current implementation of dynamic queries cannot use the “old” value of a variable, as can be done in postconditions. We view the two mechanisms as complementary, with queries being more suitable for program exploration as well as specific debugging problems. Software visualization systems such as BALSA [Bro88], Zeus [Bro91], TANGO/ XTANGO/POLKA [Sta90], Pavane [Rom92], and others [HKWJ95, Mos97, RC93] offer high-level views of algorithms and associated data structures. Software visualization systems aim to explain or illustrate the algorithm, so their view creation process emphasizes vivid representation. Hart et al. [HKR97] use Pavane for query-based visualization of distributed programs. However, their system only displays selected attributes of different processes and does not allow more complicated queries. Dynamic queries are related to incremental join result recalculation in databases [BC79, BLT86]. We use the basic insights of this work to implement the incremental query evaluation scheme. Coping with inter-object constraints in the extended ODMG model [BG98] may require methods similar to dynamic query-based debugging. Slicing [Wei81, Tip95] determines the program statements that affect a certain program point. It could be modified to determine the change sets of queries.
8
Conclusions
The cause-effect gap between the time when a program error occurs and the time when it becomes apparent to the programmer makes many program errors hard to find. The situation is further complicated by the increasing use of large class libraries and complicated pointer-linked data structures in modern object-oriented systems. A misdirected reference that violates an abstract relationship between objects may remain undiscovered until much later in the program’s execution. Conventional debugging methods offer only limited help in finding such errors. Data breakpoints and conditional breakpoints cannot check constraints that use objects unreachable from the statement containing the breakpoint. We have described a dynamic query-based debugger that allows programmers to ask queries about the program state and updates query results whenever the program changes an object relevant to the query, helping programmers to discover object
158
R. Lencevicius, U. Hoelzle, A.K. Singh
relationship failures as soon as they happen. This system combines the following novel features: • An extension of query-based debugging to include dynamic queries. Not only does the debugger check object relationships, but it determines exactly when these relationships fail while the program is running. This technique closes the cause-effect gap between the error’s occurrence and its discovery. • Implementation of monitoring queries. The debugger helps users to watch the changes in object configurations through the program’s lifetime. This functionality can be used to better understand program behavior. The implementation of the query based debugger has good performance. Selection queries are efficient with less than a factor of two slowdown for most queries measured. We also measured field assignment frequencies in the SPECjvm98 suite, and showed that 95% of all fields in these applications are assigned less than 100,000 times per second. Using these numbers and individual evaluation time estimates, our debugger performance model predicts that selection queries will have less than 43% overhead for 95% of all fields in the SPECjvm98 applications. Join queries are practical when domain sizes are small and queried field changes are infrequent. Good performance is achieved through a combination of two optimizations: • Incremental query evaluation decreases query evaluation overhead by a median factor of 160, greatly expanding the class of dynamic queries that are practical for everyday debugging. • Custom code generation for selection queries produces a median speedup of 15, further improving efficiency for commonly occurring selection queries. We believe that dynamic query-based debugging adds another powerful tool to the programmer’s tool chest for tackling the complex task of debugging. Our implementation of the dynamic query-based debugger demonstrates that dynamic queries can be expressed simply and evaluated efficiently. We hope that future mainstream debuggers will integrate a similar functionality, simplifying the difficult task of debugging and facilitating the development of more robust object-oriented systems.
9
Acknowledgments
We thank the anonymous reviewers and Amer Diwan, Karel Driesen, Sylvie Dieckmann, Andrew Duncan, and Jeff Bogda for valuable comments on earlier versions of this paper. This work was funded in part by Sun Microsystems, the State of California MICRO program, and by the National Science Foundation under CAREER grant CCR96–24458 and grants CCR92–21657 and CCR95–05807.
10 References [And95] [BC79] [BG98]
Anderson E., Dynamic Visualization of Object Programs Written in C++, Objective Software Technology Ltd., http://www.objectivesoft.com/, 1995. Buneman, O.P.; Clemons E.K., Efficiently Monitoring Relational Databases. ACM Transactions on Database Systems, 4(3), pp. 368-382, September 1979. Bertino, E., Guerrini, G., Extending the ODMG Object Model with Composite Objects, Proceedings of OOPSLA’98, pp. 259-270, Vancouver, October 1998. Published as SIGPLAN Notices 33(10), October 1998.
Dynamic Query-Based Debugging
159
[BLT86] Blakeley, J.A.; Larson P.-A.; Tompa F. Wm.; Efficiently Updating Materialized Views. Proceedings of the ACM SIGMOD Conference on Management of Data, pp. 61-71, Washington, D.C., USA, May 1986. Published as SIGMOD Record 15(2), June 1986. [Bro88] Brown, M.H., Exploring Algorithms Using Balsa-II, IEEE Computer 21(5), pp. 1436, May 1988. [Bro91] Brown, M.H., Zeus: A System for Algorithm Animation and Multi-View Editing, Proceedings of IEEE Workshop Visual Languages, pp. 4-9, IEEE CS Press, Los Alamitos, CA., 1991. [CHM94] Consens, M. P., Hasan M.Z., Mendelzon A.O., Debugging Distributed Programs by Visualizing and Querying Event Traces, Applications of Databases, First International Conference, ADB-94, Vadstena, Sweden, June 21-23, 1994, Proceedings in Lecture Notes in Computer Science, Vol. 819, Springer, 1994. [CMR92] Consens, M.; Mendelzon, A.; Ryman, A., Visualizing and Querying Software Structures, International Conference on Software Engineering, Melbourne, Australia, May 11-15, 1992, ACM Press, IEEE Computer Science, p. 138-156, 1992. [Cop94] Coplien, J.O., Supporting Truly Object-Oriented Debugging of C++ Programs., In: Proceedings of the 1994 USENIX C++ Conference, Cambridge, MA, USA, 11-14 April 1994. pp. 99-108, Berkley, CA, USA: USENIX Assoc, 1994. [DHKV93] De Pauw, W.; Helm, R.; Kimelman, D.; Vlissides, J. Visualizing the Behavior of Object-Oriented Systems. In Proceedings of the 8th Annual ACM Conference on Object-Oriented Programming Systems, Languages, and Applications, OOPSLA 1993, Washington, DC, USA, 26 Sept.-1 Oct. 1993. SIGPLAN Notices, Oct. 1993, vol.28, (no.10):326-37. [DLVW98]de Pauw, W.; Lorenz, D.; Vlissides, J.; Wegman, M. Execution Patterns in ObjectOriented Visualization. Proceedings of the Fourth USENIX Conference on ObjectOriented Technologies and Systems, Sante Fe, NM, USA, 27-30 April 1998, USENIX Association, 1998. pp. 219-34. [Eis97] Eisenstadt, M., My Hairiest Bug War Stories, Communications of the ACM, Vol. 40., No. 4, pp. 30–38, April 1997. [GH93] Golan, M.; Hanson, D.R. Duel-A Very High-Level Debugging Language. In: USENIX Association. Proceedings of the Winter 1993 USENIX Conference. San Diego, CA, 25-29 Jan. 1993. Berkley, CA, USA: USENIX Assoc, 1993. p. 107-17. [GJS96] Gosling, J., Joy, B., Steele, G., The Java Language Specification, Addison-Wesley 1996. [GWM89] Gamma E., Weinand A., Marty R., Integration of a Programming Environment into ET++ - a Case Study, Proceedings ECOOP’89 (Nottingham, UK, July 10-14), pp. 283-297, S. Cook, ed. Cambridge University Press, Cambridge, 1989. [HKR97] Hart D., Kraemer E., Roman G.-C., Interactive Visual Exploration of Distributed Computations. Proceedings of the 11th International Parallel Processing Symposium, Geneva, Switzerland, pp.121-127, April 1997. [HKWJ95]Hao, M.C.; Karp, A.H.; Waheed, A.; Jazayeri, M., VIZIR: An Integrated Environment for Distributed Program Visualization. Proceedings of the Third International Workshop on Modeling, Analysis, and Simulation of Computer and Telecommunication Systems, MASCOTS ‘95, pp.288–92, Durham, NC, USA, January 1995. [Kes90] Kessler, P., Fast Breakpoints: Design and Implementation. Proceedings of ACM SIGPLAN conference on Programming Language Design and Implementation 1990, Published as SIGPLAN Notices 25(6), pp. 78–84, ACM Press, June 1990. [KH98] Keller, R., Hölzle, U.; Binary Component Adaptation, Proceedings ECOOP’98, Springer Verlag Lecture Notes on Computer Science, Brussels, Belgium, July 1998. [KRR94] Kimelman D., Rosenburg B., Roth T., Strata-Various: Multi-Layer Visualization of Dynamics in Software System Behavior, Proceedings of Visualization’94, pp. 172178, IEEE 1994.
160
R. Lencevicius, U. Hoelzle, A.K. Singh
[Laf97] [LB98]
[LHS97]
[LM94] [LMP97]
[LN97] [Mey88] [Mos97]
[RC93] [Rom92]
[SPEC98] [SSC96]
[Sun99] [Sta90] [Tip95] [Wei81]
[WLG93]
[WG94]
[WM+98]
Laffra C., Advanced Java: Idioms, Pitfalls, Styles and Programming Tips, pp. 229252, Prentice Hall 1997. Liang, S., Bracha, G.; Dynamic Class Loading in the JavaTM Virtual Machine, Proceedings of OOPSLA’98, pp. 36-44, Vancouver, October 1998. Published as SIGPLAN Notices 33(10), October 1998. Lencevicius, R.; Hölzle, U.; Singh, A.K., Query-Based Debugging of Object-Oriented Programs, Proceedings of OOPSLA’97, pp. 304-317, Atlanta, GA, October 1997. Published as SIGPLAN Notices 32(10), October 1997. Laffra C., Malhotra A., HotWire: A Visual Debugger for C++, Proceedings of the USENIX C++ Conference, pp. 109-122, Usenix Association 1994. Litman D.; Mishra A.; Patel-Schneider P.F., Modeling Dynamic Collections of Interdependent Objects Using Path-Based Rules, Proceedings of OOPSLA’97, pp. 77-92, Atlanta, GA, October 1997. Published as SIGPLAN Notices 32(10), October 1997. Lange, D.B., Nakamura Y. Object-Oriented Program Tracing and Visualization, IEEE Computer, vol. 30, no. 5, pp. 63–70, May 1997. Meyer B., Object-Oriented Software Construction, pp. 111 - 163, Prentice-Hall, 1988. Mössenböck, H., Films as Graphical Comments in the Source Code of Programs. Proceedings of the International Conference on Technology of Object Oriented Systems and Languages, TOOLS-23, pp. 89-98, Santa Barbara, CA, USA, JulyAugust 1997. Roman G.-C., Cox K.C., A Taxonomy of Program Visualization Systems, IEEE Computer 26(12), pp. 11-24, December 1993. Roman, G.-C. et al., Pavane: A System for Declarative Visualization of Concurrent Computations, Journal of Visual Languages and Computing, Vol. 3, No. 2, pp. 161193, June 1992. Standard Performance Evaluation Corporation, SPEC JVM98 Benchmarks, http://www.spec.org/osg/jvm98/, 1998. Sefika M., Sane A., Campbell R.H., Architecture-Oriented Visualization, In Proceedings of OOPSLA’96, pp. 389-405, San Jose, CA, October 1996. Published as SIGPLAN Notices 31(10), October 1996. JavaTM 2 SDK Production Release, http://www.sun.com/solaris/, 1999. Stasko, J., TANGO: A Framework and System for Algorithm Animation, IEEE Computer 23(9), pp. 27-39. Tip, F., A Survey of Program Slicing Techniques. Journal of Programming Languages, vol.3, (no.3) pp. 121-89, Sept. 1995. Weiser, M., Program Slicing. In: 5th International Conference on Software Engineering, San Diego, CA, USA, 9-12 March 1981. New York, NY, USA, pp. 439-49, IEEE, 1981. Wahbe R., Lucco S., Graham S.L., Practical Data Breakpoints: Design and Implementation. Proceedings of ACM SIGPLAN conference on Programming Language Design and Implementation 1993, Albuquerque, June 1993. ACM Press 1993. Weinand, A.; Gamma, E. ET++-a portable, homogenous class library and application framework. In: Computer Science Research at UBILAB, Strategy and Projects. Proceedings of the UBILAB Conference ‘94, Zurich, Switzerland, 1994. pp. 66-92. Edited by: Bischofberger, W.R.; Frei, H.-P. Konstanz, Switzerland: Universitätsverlag Konstanz, 1994. Walker, R.J., Murphy, G.C., Freeman-Benson, B., Wright, D., Swanson, D., Isaak, J., Visualizing Dynamic Software System Information through High-level Models, Proceedings of OOPSLA’98, pp. 271-283, Vancouver, October 1998. Published as SIGPLAN Notices 33(10), October 1998.
Foundations for Virtual Types Atsushi Igarashi and Benjamin C. Pierce Department of Computer & Information Science University of Pennsylvania 200 South 33rd St. Philadelphia, PA 19104, USA {igarasha,bcpierce}@saul.cis.upenn.edu
Abstract. Virtual types have been proposed as a notation for generic programming in object-oriented languages—an alternative to the more familiar mechanism of parametric classes. The tradeoffs between the two mechanisms are a matter of current debate: for many examples, both appear to offer convenient (indeed almost interchangeable) solutions; in other situations, one or the other seems to be more satisfactory. However, it has proved difficult to draw rigorous comparisons between the two approaches, partly because current proposals for virtual types vary considerably in their details, and partly because the proposals themselves are described rather informally, usually in the complicating context of full-scale language designs. Work on the foundations of object-oriented languages has already established a clear connection between parametric classes and the polymorphic functions found in familiar typed lambda-calculi. Our aim here is to explore a similar connection between virtual types and dependent records. We present, by means of examples, a straightforward model of objects with embedded type fields in a typed lambda-calculus with subtyping, type operators, fixed points, dependent functions, and dependent records with both “bounded” and “manifest” type fields (this combination of features can be viewed as a measure of the inherent complexity of virtual types). Using this model, we then discuss some of the major differences between previous proposals and show why some can be checked statically while others require run-time checks. We also investigate how the partial “duality” of virtual types and parametric classes can be understood in terms of translations between universal and (dependent) existential types.
1
Introduction
Language support for generic programming plays an important role in the development of reusable libraries. In object-oriented languages, two different approaches to genericity have been considered. The more familiar one—based closely on the classical parametric polymorphism of functional languages such as ML and Haskell—can be found, for example, in the template mechanism of C++ [32] and the parametric classes in a number of proposed extensions to Rachid Guerraoui (Ed.): ECOOP’99, LNCS 1628, pp. 161–185, 1999. c Springer-Verlag Berlin Heidelberg 1999
162
A. Igarashi, B.C. Pierce
Java [26, 25, 2, 3, 12, etc.]. An alternative approach, commonly called virtual types (or virtual classes), allows classes and objects to contain types as members, along with the usual fields and methods.1 Virtual types were originally developed in Beta [23] and have recently been proposed for Java [33]. The static typing of virtual types is not yet clearly understood. Indeed, early proposals were statically unsafe, requiring extra runtime checks; more recent work has produced several proposals for type-safe variants [35, 5]. These proposals vary substantially in their details, and have generally been presented in rather informal terms—and in the complicating context of full-scale language designs—making them difficult to evaluate and compare. Our goal in this paper is to establish a rigorous setting in which to understand and discuss the basic mechanisms of virtual types. Following a long line of past work on foundations for object-oriented programming (see [4] for history and citations), we model objects and classes with virtual types as a particular style of programming in a fairly standard typed lambda-calculus. On this basis, we examine (1) the type-theoretic features that seem to be required for modeling virtual types, (2) the similarities and differences between existing proposals, and (3) the type-theoretic intuitions behind the much-discussed “overlap” between virtual types and parametric classes in practice. The rest of the paper is organized as follows. Section 2 reviews the idea of virtual types by means of a standard example, the animal/cow class hierarchy of Shang [31]. Section 3 sketches the main features of the typed lambdacalculus that forms the setting for our model. (The calculus is defined in full in Appendix A, for expert readers.) Section 4 develops the encoding of the Animal/Cow example in detail. Section 5 discusses the relation between virtual types and parametric classes as mechanisms for generic programming. Section 6 reviews previous work on virtual types in the light of our model. Section 7 sketches some directions for future work. Our presentation is self-contained, but somewhat technical at times. Familiarity with past work on modeling objects in typed lambda-calculi (e.g., [29], [19], [4], or Chapter 18 of [1]) will help the reader interested in following in detail. Another useful source of background is Harper and Lillibridge [18, 21] and Leroy’s [20] papers on modeling module systems using dependent records with “manifest” bindings.
2
Virtual Types
We begin by reviewing the notion of virtual types through an example. This example, used throughout the paper, is a variant of the animal/cow example of Shang [31]. (Our notation is Java-like, but does not exactly correspond to any of the existing proposals for virtual types in Java.) We begin by defining a generic class of animals, along with its interface. 1
Referring to this approach with the phrase “virtual types” is somewhat confusing, since—as we will see—these type members may or may not be “virtual” in the sense of virtual or abstract methods. But the terminology is standard.
Foundations for Virtual Types interface AnimalI { type FoodType