|
|
|
Having been written way back in 1991,
this memo is obviously not the last word on
Eiffel, C++, or object-oriented software!
Although the display subsystem is the most outwardly visible (and vocal) component of a TPOCC-based system, it is only the tip of the iceberg relative to the full functionality of a satellite control center and to the TPOCC software itself. Consequently, it seems that disproportionate amounts of time and effort are devoted to researching and evaluating advanced display capabilities, while the remaining TPOCC subsystems are relegated to the use of 20-year old software technology.
The T
in TPOCC stands for transportable. TPOCC
software is intended to be transportable, in the sense that it should be
reuseable and easily adapted to new missions. The TPOCC software is
programmed in C, a quasi-assembly language that is not conducive to
reuseability and maintainability. This memo presents an overview of
object-oriented programming (OOP) and proposes an investigation of
Eiffel, an OOP language that has the
potential of significantly increasing the reuseability of TPOCC software
without adversely affecting its performance.
The mission-independent nature of the generic TPOCC software is achieved through a heavy reliance on the database management system (DBMS). Unfortunately, doing so places limits on the performance and flexibility of the TPOCC software. For example, imagine decommutating the high-frequency, attitude data found in the LANDSAT-4/5 telemetry stream (TM sensor). There are 16 sets of 12-bit, X/Y/Z attitude data per minor frame of telemetry. Storing a location map of this data in the TPOCC DBMS would require 16*3*2 (= 96) entries, with a subcommutation depth of 1. (The factor of 2 occurs because each datum is 8+4 = 12 bits wide.)
96 entries will not bring the DBMS to its knees, but they do add 96*128 (= 12,288) processing points to the decommutation program's data structures. A processing point is the table-driven extraction of bits from a specified telemetry word in a specified minor frame. Using 12,288 processing points to extract just the attitude data from a 4-second major frame is wasteful of memory and CPU cycles.
A simpler, more efficient approach is to bypass the table altogether and to hard-code the attitude data extraction in the program. Once per minor frame, a simple loop is executed that extracts the 48 attitude values from the minor frame. Now, only 128 "processing points" are required to extract the attitude data from a major frame. Admittedly, a hard-coded "processing point" is more involved than a table-driven processing point, but it can be heavily optimized (e.g., coded in assembly language) to make it very fast. However much the table-based approach is optimized, it still must face the fact that it gets executed at least 12,288 times per major frame.
The generic TPOCC decommutation program contains a "hook" that allows a new mission to incorporate mission-specific processing functions into the final, compiled and linked program. After each minor frame is decommutated, the "hook" routine is called to apply any mission-specific processing to the minor frame. While this scheme can accommodate the attitude data extraction method described in the previous paragraph, imagine a scenario where the mission-specific processing must be performed before the minor frame is decommutated; e.g., to reverse pairs of bytes. Peppering the generic decommutation code with hooks is one means of building flexibility into the program, but where do you start and stop? Another alternative is to make mission-specific modifications to a copy of the generic software. Doing so is undesirable, however, because these modifications must be reviewed and made anew whenever a new TPOCC release arrives - a mission pressed for time might be reluctant to upgrade.
Object-oriented programming languages overcome the problem of flexibility by the manner in which they reuse code. Eiffel offers this flexibility, while approaching the performance of traditional languages such as C.
Eiffel is an object-oriented programming language and environment developed by Bertrand Meyer and Interactive Software Engineering. Eiffel has the safeguards of a strongly-typed language like Ada, the readability of a language like Pascal, the confidence-instilling simplicity of FORTRAN (as opposed to the worrisome complexity of Ada), and the performance of C (well, almost!). Eiffel is its own language, but it is easily integrated with software written in other languages (e.g., the existing TPOCC software).
Unlike pure object-oriented languages such as Smalltalk and hybrid languages such as C++, Eiffel was designed from the ground up to support four key concepts of object-oriented programming: inheritance, polymorphism, dynamic binding, and genericity. All of these contribute to the reuseability of Eiffel code; they will be described further in subsequent paragraphs.
Object-oriented design models a system in terms of the objects which compose the system. An object can correspond to a physical entity or an abstract concept. Graphical objects in display systems are frequently used as examples, but that's almost cheating, since the objects are so obvious. Applying object-oriented design techniques to a less trivial application (telemetry decommutation, for instance) is more of a challenge, but the potential benefits motivate the attempt.
An object is characterized by its state and its behavior, where behavior effects transitions to new states. In OOP languages, an object is represented by attributes which define the object's state and functions which query or modify the object's state. A human object that does nothing but eat and sleep all day might have a single attribute, a boolean variable eating; two state change functions, Eat() and Sleep(); and two state query functions, IsEating() and IsSleeping().
An object's class defines the type of the object; instantiating
the class produces an instance of the object. For example, human
objects are of class human
; Laurel and Hardy are instances of
that class. In conventional programming terms, the class of an
object is its data type; an instance is just a variable of the
given data type. Giving birth to Laurel and Hardy is a painless
exercise in Eiffel (see Listing 1).
A function is invoked on an object by simply calling the function
associated with the object. For example, Laurel.IsSleeping
returns true if Laurel is sleeping and false if he is awake. Other OOP
languages, such as Smalltalk, use a message passing paradigm, as well as
different terminology: attributes are known as instance variables
and functions are called methods. A method is invoked by sending a
message, specifying the method and any required arguments, to the object of
interest. With this approach, a Sleep
message sent to Laurel
would put Laurel to sleep.
Inheritance is a mechanism by which an object class inherits attributes and/or functions from a parent class. With multiple inheritance, an object class may inherit features from more than one parent. A simple example of multiple inheritance is a child who inherits his father's good looks and his mother's brains. (It is interesting to note that, in biological inheritance, characteristics are inherited from instances of objects; in object-oriented inheritance, characteristics are inherited from types of objects.) As you might guess, inheritance is a major factor contributing to the reuseability of object-oriented software.
Classes, subclasses, and superclasses - terms used by some OOP languages to describe an inheritance hierarchy - don't accurately convey the import of inheritance. Inheritance can either restrict or extend the functionality of a parent class. A novice ham radio operator, for instance, is a special type of ham radio operator with restricted privileges. A ham with an "extra class" license, on the other hand, is a ham radio operator with expanded privileges and capabilities. The ham class hierarchy looks as follows (note that this and subsequent diagrams show the logical relationships between types of objects and not physical links between instances of objects):
General Class License / \ Specialization / \ Extension / \ Novice Class Extra Class License License
In object-oriented programming, specialization is achieved by redefining or hiding attributes and functions inherited from a parent class; extension is achieved both by redefinition and by adding new attributes and functions to those inherited from the parent class(es).
Any discussion of inheritance brings up the notion of abstract classes.
Known as deferred classes in Eiffel and meta-classes in X
Windows, abstract classes serve as place-holders in an object hierarchy;
they are never instantiated. A deferred class is frequently used to define
the basic features of an object type without specifying the details of
their implementation. For example, a deferred class, MNF
, may
be declared that defines the attributes and functions of a generic minor
frame (see Listing 2). Create
is a
standard Eiffel function that instantiates an object; in this case,
Create
is redefined to allocate a buffer large enough to hold
a minor frame. Table-driven decommutation, as done in the current TPOCC
software, is performed by an internal routine,
TableDrivenExtraction
.
A mission-specific class, missionMNF
, would inherit the
features of the MNF
class, but redefine them or add to them in
order to capture the required mission-specific functionality (see
Listing 3). In this example,
missionMNF
inherits the attributes and functions of its
superclass, MNF
; MNF
's Create()
and
Decommutate()
functions are renamed so that they can be
referenced in missionMNF
's redefined functions. Two
new constants are defined and function definitions deferred in
MNF
are supplied in missionMNF
.
Note how theDecommutate()
function is redefined to first reverse the order of the bytes in the minor frame and then to call the parent class's decommutation function (which performs the table-driven extraction of data). No "hooks" are required to add mission-specific processing; just redefine the relevant functions and insert the mission-specific actions where you need them. LANDSAT'sDecommutate()
, for instance, might call the generic decommutation function before calling an external assembly language routine to extract the voluminous attitude data.
Although the diagram of a class hierarchy looks a lot like a structure chart, object classes are not usually developed in a top-down fashion. Frequently, a programmer writes specific object classes first, and then, noting the similarities between the classes, generalizes the common attributes and functions into higher-level, abstract classes. Thus, development proceeds bottom-up, from more specific classes to more general classes.
Multiple inheritance is drawing increasing attention in the object-oriented programming world. The earlier example of a child inheriting characteristics from both parents belies the difficulty of applying multiple inheritance in object-oriented programming. Single inheritance is typically used to represent a logical is-a relationship between a parent class and its children. Multiple inheritance can be used for two purposes: (i) to represent some logical relationship between the parent classes and a child, or (ii) to share code among classes.
The former use seems obvious, but there is a dearth of practical examples in the OOPS literature, a fact that has been noted in the same literature. My meager contribution is a radio:
Transmitter Receiver \ / is-a \ / is-a \ / Transceiver
A transceiver has all the attributes and functions of a transmitter and all the attributes and functions of a receiver. Note that a transceiver object is not simply a grouping of a transmitter object and a receiver object, it is an indivisible object in its own right.
Borrowing code from two or more parent classes is the most common use of multiple inheritance in practice. Called a "marriage of convenience" by Bertrand Meyer, this form of multiple inheritance has no qualms about stealing attributes and functions from logically-unrelated classes. Dr. Meyer gives the example of a noble, but financially destitute, English family that marries their daughter off to an unrefined, American oil baron of questionable lineage; the family estates are preserved at the expense of watering down the family tree.
Multiple inheritance for "convenience" has distinct advantages in object-oriented programming. First, it is a prime example of reusing code. Second, it can produce more efficient code, by reducing the level of indirection in an object's implementation. The latter reason borders on the "let's all use assembly language" philosopy, but it is worth keeping in mind. The code reuse properties of multiple inheritance, on the other hand, could be usefully applied to the TPOCC display program.
The TPOCC display subsystem is an X Windows-based program that manages the operator's interface to a TPOCC-based control center. Objects (e.g., numeric values, stripcharts, etc.) on a display page are animated by telemetry data points received from the TPOCC data server, which samples the data as it is decommutated. The X and OSF/Motif toolkits follow a particular methodology that simulates single inheritance using the standard C language. Objects (known in X as widgets) are represented by record structures, which contain attributes and pointers to functions. Inheritance is achieved by including the parent class's object structure in the child class's object structure.
An animated display object is known as a real-time widget; i.e., it is animated by real-time data received from the data server. To initiate a stream of data for an object, certain information must be passed to the data server identifying what data should be sent. These items of information are essentially attributes of a real-time display object and include the host name of the computer where the data is found, the process name and mnemonic of the desired telemetry point, the rate at which the data should be sampled, the data's data type, an array index if required, etc.
The display of simple text on the screen is done using Motif label
objects/widgets. Examples of textual displays include animated ASCII text,
numeric telemetry values, telemetry data quality indicators, discrete state
names, and GMT. To implement these objects, our display developer created
a TPOCC real-time label class, RTLabel
, that inherits the base
attributes and functions of the Motif label class, XmLabel
,
and adds in the attributes and functions required for a real-time display
object. The textual display objects, in turn, inherit the features of the
TPOCC real-time label class and add in their own individual attributes and
functions. The class hierarchy looks as follows:
XmLabel | RTLabel / / | \ \ Ascii Numeric QualityTag Range Time
More complex text objects, such as the spacecraft command buffer page
(CPAGE) or the events window, are displayed using Motif's scrolled window
object/widget. As with the label class, a real-time scrolling text class,
RTScrollT
, was created as an intermediary display class:
XmScrolledWindow | RTScrollT / \ Cpage EventW
(Actually, there's another class in between XmScrolledWindow
and RTScrollT
, but it can be ignored for our purposes.) The
intermediate RT classes were introduced solely to add the real-time
functionality to the Motif objects/widgets. Due to the limitations of
single inheritance, there is a great deal of code and data declarations
duplicated between the RTLabel
and RTScrollT
classes. Code reuse this is, but of the sledgehammer variety!
Factoring out the features of a generic real-time display object produces a multiple inheritance hierarchy that looks as follows:
XmLabel RTObject XmScrolledWindow \ / \ / Ascii Cpage Numeric EventW ...
(A "partial" class such as RTObject
, which is purposefully
designed as an abstract class for use in a multiple inheritance
hierarchy, is sometimes called a mixin.) By eliminating the
duplication of code and data, multiple inheritance offers more
readable and reliable software, increased programmer productivity,
and reduced maintenance costs.
In conventional programming languages, variables can be assigned values;
e.g., X = 23
. Strongly-typed languages such as FORTRAN
restrict the types of values that can be assigned to a particular variable.
For example, a floating point number can be stored in a floating point
variable, but not in an integer variable. Weakly-typed languages, usually
interpreted, relax these restrictions and allow you to assign any type of
value to a given variable. In TSTOL, for instance, a variable may, at
different points in its lifetime, be an integer variable (X = 23
),
a floating point variable (X = 0.314E+01
), or a string
variable (X = "Hello!"
).
Object-oriented programming languages also support variables, but the value assigned to a variable is actually an object, complete with all of its attributes and functions. Untyped languages such as Smalltalk allow you to assign objects of any class to a particular variable. This is known as polymorphism; i.e., the variable can take on "many forms".
In typed languages like Eiffel and C++, polymorphism must conform to the class hierarchy of the objects in a program. In other words, a variable of class type C can be assigned any object belonging to class C or to any subclasses derived from C (via inheritance or multiple inheritance). For example, consider the following class hierarchy:
MNF | LANDSAT_MNF / \ MSS_MNF TM_MNF
(The LANDSAT spacecraft has separate streams of telemetry data for its two
sensors: the Multi-Spectral Scanner and the Thematic Mapper.) A variable
declared of type MNF
can be assigned a generic
MNF
object, a LANDSAT_MNF
object, an
MSS_MNF
object (no high-frequency attitude data), or a
TM_MNF
object (with HF attitude data). It would be illegal to
assign HUMAN
s or Transceiver
s to a
MNF
variable.
An object carries along its attributes and its functions, so the code
fragment in Listing 4 simultaneously processes MSS and TM telemetry data.
GetNextMNF()
is a function that returns the next available
minor frame from either of the telemetry data streams, MSS or TM.
Cycle()
simply loops, reading and decommutating each minor
frame received.
How is sensor-specific decommutation performed? Although, in
Listing 4, thisMNF
is declared as a
generic MNF
variable, the function call,
"thisMNF.Decommutate
", is not simply a reference to the
generic MNF
class's decommutation function. Instead, the
function call is bound at run-time to the decommutation function defined in
the dynamically-assigned minor frame object; i.e., the decommutation
functions defined in the MSS_MNF
and TM_MNF
classes. Dynamic binding is the mechanism that ensures that a
function call invokes the appropriate function for the target object.
Dynamic binding is not as trivial as it may seem. For example,
the TM_MNF
class redefines the generic decommutation function so
that attitude data extraction is performed in addition to the
normal, table-driven decommutation. MSS minor frames, however,
require no special processing, so the MSS_MNF
class simply
inherits the generic MNF
decommutation function. For MSS minor
frames, then, the statement, "thisMNF.Decommutate
" really would
execute the generic MNF
Decommutate()
.
By writing software in terms of abstract class objects and letting polymorphism and dynamic binding resolve the details of specific class instances, an object-oriented programmer can produce flexible, reuseable code on a scale that is difficult to achieve with non-object-oriented languages. A frequently-used example of this flexibility is a loop that redisplays a list of objects. In a traditional language like C, case statements are needed to handle the polymorphic nature of display objects:
object = displayList ; while (object != NULL) { switch (typeOf (object)) { /* Redisplay object. */ case POINT: DisplayPoint (object) ; break ; case LINE: DisplayLine (object) ; break ; case CIRCLE: DisplayCircle (object) ; break ; } object = object->next ; /* Get next object. */ }
If a new type of graphical object (e.g., rectangle) is added to the display system, the code above must be modified by adding a new case statement for the new object type. OOP languages like Eiffel, on the other hand, eliminate case statements and code modification altogether:
object: GRAPHICAL_OBJECT ; from object := displayList.First until object = void loop object.Display ; -- Redisplay object. object := displayList.Next ; -- Get next object. end ;
Dynamic binding ensures that the appropriate display function is
called for each object type (e.g., point, line, circle, etc.).
Adding a new display type such as a rectangle is a simple matter
of defining a new object class, RECTANGLE
, that is a descendant of
the abstract GRAPHICAL_OBJECT
class. No changes need to be made
to the basic display loop. This example illustrates an excellent
observation made by Palsberg and Schwartzbach: inheritance permits
the reuse of definitions, while polymorphism allows the reuse of
applications.
The X Windows Toolkit, written in C, avoids case statements for
its object/widget types by implementing a semi-interpreted form of
dynamic binding. (The following is a simplified description of X
Toolkit callback procedures.) Basically, a resource name (e.g.,
"display") is associated with a field (e.g., a pointer to the
display()
function) in an object/widget structure. A program can
then ask the Toolkit to call a function, identified by its
resource name, for an arbitrary widget. The Toolkit looks up the
resource name, finds the function pointer in the target widget's
structure, and calls the function, passing it the address of the
target widget. This technique is clever, but slow.
Although adding a case statement for a new object is not an onerous task, even for a display programmer, what if a change has a more far-reaching, system-wide impact. For example, the TPOCC data services subsystem is responsible for supplying application programs with synchronous and sampled real-time data. The data server receives synchronous ("as-it-is-generated") data from one or more synchronous data sources; sampled data is periodically read from shared memory. Both types of data are served out to application clients that request the data. The following diagram shows a typical TPOCC configuration:
Telemetry Data ,-----> [6] Display [7] Decom [2] -----> [3] Server [5] +-----> [6] Reports [8] [1] [4] `-----> [6] TSTOL [9] | ^ | | `-----------------> Shared Memory
The telemetry decommutation program is a synchronous data source (and also the original source of sampled data); the display, reports, and TSTOL processes are application clients.
The data server handles many different types of data: integers, floating point numbers, character strings, and several types of telemetry current value table (CVT) entries. Each CVT is a structure containing a decommutation status word, a raw telemetry value, and an engineering units-converted value. The raw values themselves are of different data types: signed and unsigned integers, single- and double-precision reals, etc.
How do the TPOCC programs cope with these different data types (26 at last count)? With case statements, of course. The bracketed numbers in the diagram above identify places in the TPOCC software where data type-specific processing is performed:
Pity the poor programmer who has to add a new telemetry CVT type. The effects of the new addition, initially felt in the decommutation program, would propagate throughout the system: data server, display, reports, and TSTOL. Common library routines and table-driven processing alleviate the problem somewhat, but making the necessary modifications to all the affected subsystems is still a thankless job, tedious and prone to error. I should know - I had to add 6 new CVT types recently!
Would an object-oriented approach help here? Using an OOP language would simplify considerably the decommutation ([1] above), display ([7]), printing ([8]), and manipulation ([9]) of data objects, as well as the encoding of these objects for output on the network ([2] and [5]). It is not clear, however, how software that receives data could decode objects of different types without performing a case selection based on the object's data type.
An interesting problem in object-oriented programming has to do with
multiple polymorphism. The discussion above dealt with single
polymorphism, where the target object is polymorphic. Calling
Display()
for a graphical object will invoke the display
function bound to that object; e.g., display a line, display a circle, etc.
Suppose, however, the display function expects (as an argument or a global
variable) a polymorphic object representing the output device, e.g., a
video monitor, a laser printer, etc. Two levels of polymorphism are now
present, one for the object to be displayed and a second for the device on
which the object will be displayed:
Point ---v---> Video Monitor Line ---+---> Laser Printer Circle ---'
Where does the code to display a graphical object on a video
monitor reside? Device-specific code could be embedded in the
graphical object, with case statements selecting the different
display types. Adding a new output device would then require
changes to each of the implemented graphics types. A better
solution is to add graphics-specific display functions to the
device objects. If its Display()
function were invoked, a
graphical object would then pass itself (designated by the
reserved word Current
in Eiffel) to the device object's
Display<Graphic>()
function:
class CIRCLE_OBJECT export Display inherit GRAPHICAL_OBJECT redefine Display ; feature -- Display circle on specified device. Display (port: DISPLAY_DEVICE) is do port.DisplayCircle (Current) ; end ; end
The abstract DISPLAY_DEVICE
class
contains display functions for each of the available graphics types. The
display functions for primitive graphical objects (e.g., points and lines)
are device-dependent and, therefore, their definitions are deferred in the
abstract class. Subclasses for specific devices (e.g., video monitor or
laser printer) must provide actual drawing routines. Higher-level,
composite graphics can frequently be plotted using the graphics primitives,
so their display functions can be spelled out in the abstract class (and
redefined, if need be, in a subclass). For example, the
DisplayRectangle()
function
constructs a rectangle by drawing the 4 sides.
Adding a new output device has no effect on the existing graphical objects
and device objects. The new device class inherits its features from the
abstract DISPLAY_DEVICE
class and defines any deferred
functions. Adding a new graphical object requires only that a new
DisplayGraphic()
function be added to the abstract
DISPLAY_DEVICE
class and to each of the device subclasses.
If the new graphic can be drawn using existing primitives, the device
subclasses can simply inherit the new display function from the abstract
class.
TSTOL (TPOCC Systems Test and Operations Language) is the command language
interpreter for TPOCC-based systems. Among its many capabilities, TSTOL
can read and execute commands stored in TSTOL procedure files. TSTOL
procedures are similar to FORTRAN subroutines and, like them, can be
nested; e.g., procedure A
can call procedure B
,
B
can call C
, and so on.
TSTOL keeps track of procedure nesting using a number of stacks; two of them will be described here. The Procedure File Table is a stack of procedure file records, one record for each procedure that has been called in the current nesting. Each record contains information about a procedure: the name of the file containing the procedure, the number of lines in the file, the current line number, and the actual text read from the file. When a procedure is called, a new file record is created and pushed on the stack, the contents of the procedure file are input and stored in the record, and the current line number is set to 1. As commands are executed, the current line number is incremented. When a procedure returns, its file record is popped from the stack and discarded; execution then continues in the procedure that is now exposed on the stack.
TSTOL is a block-structured language that supports global variables and
procedure-local variables. TSTOL's scoping rules prevent a procedure
(e.g., C
) from accessing variables local to an ancestral
procedure (e.g., A
or B
) and vice-versa. To
manage local variables in nested procedures, TSTOL's symbol table is also
structured as a stack. When a procedure is called, a new block for the
procedure's local variables is created and pushed on the Symbol Table
Stack. If a variable reference is made in a procedure, the top block on
the stack is searched to see if the reference is to a local variable; if
not, the block reserved for global variables is then searched. When a
procedure returns, its local variable block is popped from the stack,
effectively deleting the procedure's local variables, and the local
variable block of the parent procedure is uncovered.
The procedure file stack and the symbol table stack are both stacks, with the same operations defined for each: push, pop, etc. The only difference between the two stacks is the type of the object pushed or popped: procedure file records on the procedure file stack and local variable blocks on the symbol table stack. After writing the software implementing one stack, a productive programmer would look for ways to reuse the code on the other stack. C offers a limited form of code reuse, called (by me) "cut-and-paste" reuse, in which the degree of code reuseability depends on how powerful your text editor is.
Eiffel and some non-object-oriented languages (especially Ada) offer a more
elegant mechanism for reusing code in situations like that described above.
Genericity is a means of parameterizing a unit of software, with
supplied parameters substituted in the definition of the unit when it is
compiled. Generic subprograms and packages are a much-touted feature of
Ada. Eiffel allows you to define generic object classes (not to be
confused with deferred classes like the generic MNF
). For
example, a generic STACK
class
could be defined with a single parameter, T
, specifying the
type of the objects on the stack.
TSTOL's procedure file and symbol tables can now be implemented as stacks,
without having to rewrite the stack operations for each table because of
the different element types. To illustrate, the design of the procedure
file table is presented in Listing 7. A
procedure file record contains the text read from
the procedure file (a private attribute) and keeps track of the current
line number in the file (a public attribute). Function
GetLine()
returns the text of the current line in the file and
increments the line number.
The class for the procedure file table declares a
private variable, procedureStack
, as a stack of procedure
file records. When a new procedure is started, function
PushFile()
is called to read the procedure file in and push
its record on the stack. Two functions, GetLine()
and
GoToLine()
, are then used to step through the current
procedure (on the top of the stack). When a procedure exits,
PopFile()
is called to pop its record from the stack.
The symbol table is organized in a similar fashion, but is too lengthy to
show here. Three classes are required. A SYMBOL
object is
simply a name/value pair. A SYMBOL_BLOCK
object is a list of
symbols, all of which belong to the same block. Finally, the
SYMBOL_TABLE
class contains a single SYMBOL_BLOCK
for global variables and a stack of SYMBOL_BLOCK
s (declared as
STACK[SYMBOL_BLOCK]
) for local variables. When a new
procedure is started, an empty block is pushed onto the local variable
stack. Variables declared within the procedure are then added to the top
block on the stack. Variable references are resolved by first searching
for the symbol in the current local variable block (on top of the stack)
and then, if the symbol was not found, the global variable block is
searched. When the procedure exits, its local variable block is popped
from the stack.
The generic STACK
class is an example of unconstrained
genericity; i.e., there are no restrictions on the type of object for which
the stack is instantiated. You can define a stack of integers, a stack of
procedure file records, or even a stack of stacks. Ada also offers what
Bertrand Meyer calls constrained genericity, in which a generic
subprogram or package restricts its parameterized types by requiring the
programmer to supply certain functions that operate on those types. For
example, a generic sort routine can only be instantiated on types for which
an ordering function, LessThan()
, is defined. Another example
is a matrix manipulation package that requires the programmer to specify
addition (+
) and multiplication (*
) operators for
the target data types, e.g., integers, reals, and complex numbers.
An ideal candidate for constrained genericity is the code that implements Sun MicroSystems' XDR protocol. XDR (eXternal Data Representation) defines standard representations for a number of different data types. For example, integers are 32 bits wide with the bytes ordered from most significant to least significant, floating point numbers follow the IEEE floating point format, etc. With the appropriate XDR conversion software on both sides, computers of dissimilar architectures can now exchange binary information in a standard way. All interprocess communications between programs in a TPOCC-based system are performed over network connections using the XDR protocol. Thus, the TPOCC processes can be distributed in an arbitrary topology on different machine types.
XDR-formatted data can also be exchanged via files or through shared
memory, not just over network connections. Consequently, the communication
medium serves as a useful abstraction for a generic XDR conversion package.
Listing 8 presents a generic Ada package (with the
specification and body combined) that handles XDR conversions on different
types of data streams. Three routines are defined that encode various
types of data for sending and decode the same types of data when received.
To use the XDR
package, the programmer must specify a type,
STREAM_TYPE
, that is used to identify communication channels
of the desired stream type, and two low-level I/O routines for that stream
type. For example, the XDR
package might be instantiated for
network connections by the following statement:
package NET_XDR is new XDR (SOCKET_TYPE, NET_READ, NET_WRITE) ;
where a variable of SOCKET_TYPE
is simply the file descriptor
for an open network connection; NET_READ
and
NET_WRITE
are functions that, respectively, read data from and
write data to a socket connection. Instantiating the package for a shared
memory stream would be accomplished by:
package SHM_XDR is new XDR (SHM_ACCESS, SHM_READ, SHM_WRITE) ;
A variable of type SHM_ACCESS
could be a structure containing
the addresses of the first and last locations of a queue in shared memory.
SHM_READ
and SHM_WRITE
would then adjust these
pointers as XDR-formatted data is added to or removed from the queue.
The genericity of the XDR
package is constrained because the
package can only be instantiated for stream types having defined read and
write functions. This is a purely syntactic constraint, since there are no
semantic constraints placed on the I/O functions. Consequently, it would
be perfectly legal to instantiate XDR
for BOOLEAN
streams, whatever those are:
package NONSENSE_XDR is new XDR (BOOLEAN, NULL_READ, NULL_WRITE) ;
NULL_READ
and NULL_WRITE
are dummy functions that
do nothing and return.
Unlike Ada, Eiffel does not directly support constrained genericity. Instead, the same effect is achieved using inheritance. Simulating constrained genericity using inheritance is accomplished in two easy steps:
To instantiate the group of new classes, subclasses of the deferred "parameter" classes must be defined for the desired target types; the "generic" class can be used as is.
The Ada XDR
package was converted to
Eiffel by following the procedure above. First, the Ada package parameter,
STREAM_TYPE
, was represented in Eiffel by an abstract
input/output object, IO_OBJECT
, with
two constraint functions, ReadData()
and
WriteData()
. The "generic"
XDR
object class was then written
using IO_OBJECT
s and the deferred functions,
ReadData()
and WriteData()
.
To instantiate the Eiffel XDR "package" for a particular type of I/O,
simply define a subclass of IO_OBJECT
for the desired I/O
mechanism; thanks to polymorphism and dynamic binding, no changes to the
"generic" XDR
class are required. Listing 9b presents
NET_OBJECT
, a subclass of IO_OBJECT
; objects of
this type read and write raw data over network connections. When coupled
with a NET_OBJECT
object, an XDR
object will read
and write data in XDR format on a network connection. The object hierarchy
appears as follows:
IO_OBJECT XDR / \ NET_OBJECT SHM_OBJECT
The code fragment found in Listing 10b shows the
interplay between a NET_OBJECT
and an XDR
object.
NET_OBJECT
's Create()
function is called to
establish a network connection; the resulting NET_OBJECT
,
stream
, is passed to XDR
's Create()
function and an XDR
object, handle
, is created.
An integer in XDR format is read from the network by calling
handle
's xdr_int()
function;
xdr_int()
, in turn, calls stream
's
ReadData()
function. In like manner,
xdr_string()
calls stream
's
WriteData()
function to output an XDR string.
Interestingly, Sun's implementation of XDR (written in C) uses an
object-based approach. An XDR data stream is identified by a handle, which
is just a pointer to an XDR structure. This structure contains pointers to
a number of primitive I/O functions: input N
bytes from the
data stream, output N
bytes to the data stream, and several
others. The various XDR routines (xdr_int()
,
xdr_string()
, etc.) are written in terms of these primitive
I/O functions (called indirectly through the handle structure) and have no
concern for or access to the underlying data stream. As with X Windows
widgets, varying the function pointers stored in the XDR handle varies the
behavior of an XDR "object". Specific functions are available to create
XDR handles for different types of I/O: xdrrec_create()
for
buffered network communications, xdrmem_create()
for
memory-to-memory transfers, and xdrstdio_create()
for
exchanging data via files.
The XDR
object class described above is a relatively simple
example of constrained genericity. Since the XDR
class was
defined in terms of its abstract parameter type, IO_OBJECT
,
there is no need to make changes to the XDR
class when adding
new forms of IO_OBJECT
s, e.g., for file I/O, for message
queues, etc. Other constrained generic classes are more complex, however.
Suppose we wish to define an ORDERED_LIST
class, each instance
of which is a sorted list of items. ORDERED_LIST
is a generic
class with a single parameter, the type of the items, subject to the
following constraint: any two items in the list must be capable of being
compared.
Following the procedure for simulating constrained genericity with
inheritance, we first define a deferred "parameter" class,
COMPARABLE
, with a deferred function, LessThan()
,
which compares two items. Subclasses of COMPARABLE
(and their
appropriate LessThan()
functions) must be defined for the
different types of elements that will be kept in ordered lists. Next, we
define the ORDERED_LIST
class itself, declaring the items in
the list as COMPARABLE
s (just like we did with the
XDR
class and IO_OBJECT
s). The
AddItem()
function in this class takes one argument, a
COMPARABLE
object. Everything is just peachy until an errant
program tries to add an orange to a list of apples. Since both apples and
oranges are COMPARABLE
s, AddItem()
can't
distinguish between the two and doesn't realize that comparing them is
meaningless. The solution is to subclass both the "parameter" class
and the "generic" class:
COMPARABLE ORDERED_LIST / \ / \ APPLE ORANGE LIST_OF_APPLES LIST_OF_ORANGES
In the ORDERED_LIST
subclasses for apples and oranges,
variables and functions declared of type COMPARABLE
are now
redeclared as APPLE
s or ORANGE
s. Thus, the
AddItem()
function in LIST_OF_APPLES
is passed
an APPLE
object; the same function in
LIST_OF_ORANGES
is passed an ORANGE
object.
Eiffel provides a mechanism called declaration by association, which
minimizes the rewriting necessary when creating subclasses, both of generic
classes and of non-generic classes. Declaration by association allows you
to say that the type of a variable or function is like the type of
another variable or function. To use this mechanism in
ORDERED_LIST
, for example, you would declare a single
variable, say anchor
, as being of type
COMPARABLE
; the remaining variables and functions of the same
type are declared as "like anchor
". To create the
subclass LIST_OF_APPLES
, you only need to redeclare
anchor
as an APPLE
; the other, "anchored"
variables and functions will automatically be retyped.
Why does Eiffel specifically support unconstrained genericity, but
not constrained genericity? Apparently, the complexity of
building constrained genericity into Eiffel outweighed any
potential benefits. Furthermore, constrained "parameter" classes
such as IO_OBJECT
turn out to be useful abstractions in their own
right.
Why support unconstrained genericity if it can be simulated using
inheritance? Unconstrained genericity can indeed be simulated using
inheritance, in the same manner as for constrained genericity. Since there
are no constraints, the deferred, "parameter" classes are empty (no
functions or attributes); in fact, each "parameter" class is essentially a
universal class that can apply to any type of object. For example, if the
generic STACK
class was simulated
using inheritance, a stack of procedure file records could no longer be
declared simply as "STACK
[PROC_FILE_RECORD
]".
Instead, a dummy class, STACKABLE
, would be defined for
STACK
's type parameter and all potentially stackable objects
(including primitive objects such as numbers and string) would have to be
defined as subclasses of STACKABLE
. The awkwardness of this
solution and the frequency with which unconstrained genericity was used in
practice inspired the development of a more convenient mechanism for
unconstrained genericity in Eiffel.
(After writing the previous section on constrained genericity, I have since learned that later versions of Eiffel now support constrained generic objects. TheORDERED_LIST
class can be declared as a true generic class:class ORDERED_LIST[T->COMPARABLE] ...where "T
->COMPARABLE
" means that type parameterT
must be a descendant ofCOMPARABLE
. A list of apples would then be declared as "ORDERED_LIST
[APPLE
]". Although this eliminates the need for subclassingORDERED_LIST
, the need forCOMPARABLE
and its descendants remain.)
Generic object classes have not received the attention they deserve in
object-oriented circles, largely because most OO research is performed
using interpreted, untyped languages such as Smalltalk and the various
flavors of object-oriented Lisp. In an untyped language, a class such as
STACK
is already generic: an element of a stack can be of any
type. Depending on your application, however, this may be a mixed
blessing, since additional effort is required to ensure that a stack is
homogenous.
Among typed, object-oriented languages, Eiffel and Trellis support genericity; in the latter, a generic class is called a type generator for parameterized types. C++ will eventually support it, although the simpler forms of genericity can already be simulated using the text substitution capabilities of the C macro preprocessor.
Eiffel supports the basic capabilities required for object-oriented programming: inheritance (both single and multiple), polymorphism, dynamic binding, and, for typed languages, genericity. In addition, Eiffel provides the programmer with a number of other capabilities, some unique to Eiffel. Foremost among these is the incorporation of assertions into Eiffel object classes. The support for assertions is a result of the important role abstract data types played in the design of the Eiffel language.
An abstract data type (ADT) is a description of a data structure. The data structure is essentially hidden inside a black box; the ADT defines the external interfaces to the data structure and the behavior of the data structure - as viewed solely through the external interfaces. In particular, an ADT says nothing about the implementation of its data structure. An abstract data type consists of 4 parts:
For example, the abstract data type specification for a spacecraft command buffer would have two types: the command buffer itself and the mission-specific commands. The functions that can be applied to a spacecraft command buffer include:
Preconditions on these functions would, for example, prevent adding a command to a full buffer or uplinking an empty buffer. Axioms for the spacecraft command buffer data type would specify the ordering of commands in the buffer, the order in which commands are uplinked, the automatic transmission of commands in one-step mode, the effects of cancelling a critical command, etc.
The important thing to remember about abstract data types is that they are
abstract - there should be no mention of implementation details in the ADT
specification. For example, the implementation of a spacecraft command
buffer could be as primitive as a simple card file. When the operator
enters a new command into the buffer, the computer prints out a 3"x5" index
card containing the command information. Another operator then adds the
card to a little card box (if the box was not already full) and manually
sorts the cards in the box. When the first operator enters a
/SEND
directive to uplink the commands, the second operator
phones up the receiving station and reads off the information on the cards
in the box. The receiving station then uplinks the commands to the
satellite, possibly by flipping thumbwheel switches on their transmitter,
after which the control center operator can throw his/her index cards out.
All the functions performed by the POCC operators meet the requirements
imposed by the preconditions and axioms of the spacecraft command buffer's
abstract data type.
As you might guess, abstract data types map naturally into Eiffel object classes:
Abstract Data Type Eiffel Class Types <------> Classes Functions <------> Exported Functions Preconditions <------> Preconditions Axioms <------> Postconditions and Class Invariants
Listing 11 presents the Eiffel class declaration for a simplified spacecraft command buffer. A function's preconditions, stated in the function's require clause, specify the conditions that must be met by clients invoking this function. Postconditions, expressed in a function's ensure clause, specify how the target object is transformed by calls to the function. The class invariants, found in the invariant clause at the end of the class declaration, are general assertions about the state of an object of the given class when the object is in a stable condition, e.g., after the object is created and after an external call to any function exported by the object. (Class invariants are essentially postconditions common to all exported functions.)
A distinctive feature of programming in Eiffel is that the preconditions and postconditions of a function represent a contract between a client invoking the function and the object servicing the function. If the client satisfies the requirements spelled out in the preconditions, the target object guarantees the results promised by the postconditions (and, implicitly, the class invariants). If the client fails to keep its half of the bargain, the target object is not bound to fulfill the other half.
Assertion checking can be enabled or disabled when an Eiffel program is compiled. For time-critical programs, checking is usually enabled during testing and disabled in the final product. If a precondition, postcondition, or class invariant fails during the execution of a program (and if assertion checking is enabled), an exception is generated. (Exceptions are described in greater detail later.) If the exception is not handled by the code, the program aborts with an error message indicating which assertion in what class was violated.
To prevent programs from unceremoniously crashing, clients of an object must assume the responsibility of verifying that all preconditions are satisfied before calling a function of that object. For example, a command processing program should explicitly check that the spacecraft command buffer is not full before adding a new command to it; the command buffer object should not have to make that check. On the surface, it would seem preferable to centralize the condition checking in the target class rather than forcing every client to perform its own checks. As Cline and Lea have pointed out, however, an optimizing compiler can eliminate unnecessary condition checks in the client, an optimization not available if the checks are confined to the target class. In the example above, for instance, there is no need to check that the command buffer is full when adding a new command immediately after clearing the buffer.
Like the preconditions and axioms of an abstract data type, the
preconditions, postconditions, and class invariants of a class specify the
behavior of objects belonging to that class. Furthermore, these
assertions, inherited along with the attributes and functions of the class,
provide semantic constraints for descendants of the class. In particular,
a function redefined in a child class must not violate the conditions
imposed on the original function in the parent class. Properly written
assertions in a parent class thus prevent the subclassing of children that
don't do what they're told. (Contrast Eiffel with Ada: in the earlier
discussion of genericity, a generic Ada package, XDR
, was
legally instantiated as NONSENSE_XDR
, with input/output
routines that didn't do anything!)
Although a child class cannot violate the assertions of its parent, it can modify them within certain limits. Recall that assertions represent a contract between a client and a target object. Because of polymorphism and dynamic binding, the target object of a contract may belong to the parent class or to a descendant class. In order to satisfy the terms of the contract in this context, a subclass cannot:
Consequently, a redefined function in a child class can relax the
preconditions of its parent (the preconditions defined in the child's
function are OR
ed with those defined in the parent's function)
or tighten up the postconditions (the postconditions defined in the child's
function are AND
ed with those defined in the parent's
function).
Class invariants are assertions about an object class as a whole. Preconditions and postconditions are assertions about individual functions. Eiffel also allows the programmer to embed assertions in a class at the statement level. Simple check statements:
check assertion 1 ; ... assertion N end ;
can be interleaved with normal program statements. When the execution of a program reaches a check statement, the enclosed assertions are evaluated; if any expression evaluates to false, an exception is raised.
One of the Holy Grails of computer science is the ability to formally prove a program correct, i.e., that the program meets its specifications. Although this remains an elusive goal for non- trivial programs, considerable success has been achieved in proving the correctness of loops (e.g., FORTRAN DO-loops and Pascal while-loops). A loop is correct if (i) the loop eventually terminates, and (ii) after it terminates, the loop has accomplished what it was supposed to.
To guarantee that a loop does not cycle forever, Eiffel lets you define a loop variant, an expression that initially evaluates to a positive integer and whose value is decremented by some amount on each loop iteration. Violations of the loop variant occur if its value is not decremented during an iteration or if its value drops below zero. Violating the loop variant produces an exception that, if not handled, aborts the program (very forcefully terminating the loop!).
Showing that a loop actually worked after it finished requires a loop invariant, an assertion about what the loop was supposed to do. A loop invariant must be true before the body of the loop is executed (but after any loop initialization), after each iteration, and after the loop terminates. Composing a logical assertion that captures the purpose of a loop and also holds true during intermediate iterations of the loop is, not surprisingly, a difficult exercise. Textbook examples of loop invariants are usually drawn from programs that compute mathematical functions. Unfortunately, mathematical functions are to formal proofs of program correctness what graphical objects are to object-oriented programming: it's not clear how to generalize from the obvious to the not-so-obvious.
Eiffel loops are constructed as follows:
from loopInitialization invariant loopInvariant variant loopVariant until exitCondition loop ... body of loop ... end ;
The invariant and the variant are optional. Violating either, if present, causes an exception to be generated.
An exception is an abnormal event in a program. In some cases, exceptions are detected by the computer hardware and an interrupt is generated; exceptions of this type include: illegal memory references, arithmetic overflow and underflow, floating point processor errors, etc. Other exceptions are detected by software, either in the operating system or in an application program, and generate "software" interrupts; examples of these include: domain and range violations in a math library, broken I/O connections, etc. Responding to and handling exceptions is an important attribute of robust, reliable software.
The VAX/VMS operating system implements an elegant, hierarchical exception handling mechanism. A call stack maintains a record of the nested invocations of procedures (i.e., functions and subroutines). When a procedure (in any language) is called, a frame is pushed on the call stack. The frame contains the new procedure's local variables, the return address in the parent procedure, a link back to the parent procedure's stack frame, and a slot reserved for the address of a condition (exception) handler.
If an exception occurs during the execution of a procedure, the operating system checks if a condition handler has been established for the procedure. If not, the operating system checks the parent procedure for a condition handler and, if necessary, continues to "walk" back up the call stack until a procedure with a condition handler is found. (A "last-chance" handler is set up before a program begins executing.)
Once a condition handler is found, the operating system calls the handler routine (just like any other procedure), passing it information about the context in which the exception occurred. The condition handler, at its disgression, can handle the exception or resignal it up to higher-level condition handlers. If it decides to take care of the exception itself, the condition handler can fix up the error and let the program resume execution in the procedure where the exception originally occurred, or the call stack can be "unwound" to the point at which the condition handler was declared. Unwinding the stack effectively aborts execution in the "unwound" procedures and returns control to the parent of the procedure that declared the condition handler.
The following example presents a FORTRAN-style function that computes a pseudo-telemetry value; the function returns a VMS-compatible status flag indicating the successful completion of the computation. A condition handler is declared and used to prevent arithmetic errors from crashing the decommutation program. The function and its condition handler, written in pseudo-code, appear as follows:
FUNCTION ComputePseudoPoint () CALL Lib$Establish() to establish a condition handler. Perform processing. Return normal completion status (SS$_NORMAL). END FUNCTION ConditionHandler (exception, registers) IF exception is a condition I'm handling THEN Unwind the call stack and return exception (SS$_XXX) as an error status. ELSE Signal exception back up the call stack. ENDIF END
Assume that routine Decom()
calls
ComputePseudoPoint()
:
Decom() | ComputePseudopoint() / | \ --> ConditionHandler() ... ... ...
If ComputePseudoPoint()
completes normally, a status code
indicating success is returned to Decom()
. If an exception
occurs in ComputePseudoPoint()
(or in any routines it
calls), ConditionHandler()
is invoked and, through the magic
of VMS exception handling, ComputePseudoPoint()
and any
descendant routines are popped from the call stack, the exception code is
set up as the return value of ComputePseudoPoint()
, and
execution returns to the calling routine, Decom()
.
Decom()
only sees a status code indicating success or failure
returned by ComputePseudoPoint()
; decom()
is
oblivious to the fact that an exception handler may or may not have been
invoked.
Several points should be noted with regards to the VMS exception handling mechanism:
move longword
) accomplishes this.
Exceptions can be handled with relative ease by programs running under
VAX/VMS. In contrast, exceptions can be handled only with great difficulty
in the UNIX/C environment. Under UNIX, exceptions are known as
signals. A handler function can be assigned to a signal using the
signal(3)
system call; establishing one or more handlers for
multiple signals requires multiple calls to signal(3)
.
When a signal occurs, the operating system calls the handler function, passing it the signal code and some implementation-dependent information about the cause of the signal. Once invoked, the signal handler can abort the program, ignore the signal, try to fix the error and continue processing, or escape from the current execution context. The latter approach, similar to unwinding the stack in VMS, is accomplished with a pair of system calls:
setjmp(3)
- saves the current execution context (e.g.,
registers, program counter, stack pointer, etc.).
longjmp(3)
- restores a saved execution context.
Typically, setjmp(3)
is called by the routine that declares a
signal handler. longjmp(3)
, called from within the signal
handler, pops intervening functions from the call stack and returns control
to the original routine.
The UNIX/C exception handling mechanism has the following characteristics:
signal(3)
must be made for each signal handled.
signal(3)
and
setjmp(3)
are costly operations.
signal(3)
- for each signal handled.
signal(3)
, provides
for 2 user-defined signals. An alternative mechanism for software signals,
ssignal(3)
, provides for an additional, 15 user-defined
signals.
The awkwardness of handling exceptions in the UNIX/C environment generally discourages programmers from doing so in their programs. It will come as no surpise that the exception handling facilities incorporated into newer languages and operating systems appear to follow the hierarchical VMS model.
Ada carries VMS-style exception handling down below the subprogram level to the block level. Exception handlers can be defined for packages, tasks, functions, procedures, and begin-end blocks. The exception handling code resides, not in a separate function, but in an exception clause at the end of the given program entity. Unlike their VMS counterparts, Ada exception handlers have only two processing options available: perform any clean-up chores and exit the function or block (or whatever), or resignal the exception. An Ada handler cannot cause execution to resume at the point where the exception occurred.
The pseudo-telemetry point calculation would, if coded in Ada, look somewhat like the following:
function COMPUTE_PSEUDO_POINT return STATUS is begin ... perform processing ... return success ; exception when NUMERIC_ERROR => return failure ; when others => raise ; -- Resignal exception. end ;
The Ada version of this function works just like its VMS FORTRAN cousin. If no exceptions occur, the function returns a status code indicating success. If a NUMERIC_ERROR exception occurs, the function returns a status code indicating failure. All other exceptions are resignalled up the call stack to higher-level exception handlers.
Although Bertrand Meyer originally considered exceptions a sign of poor programming (see his description of Eiffel in SIGPLAN Notices, 2/87), he eventually added exception handling to Eiffel. The syntax is very similar to Ada's, but the names have changed. A rescue clause found at the end of a function declaration or at the end of a class declaration contains code to handle exceptions. If an exception occurs in a function, the function's rescue clause is executed. If the function has no rescue clause, the class's rescue clause is executed. If neither the function nor the class have a rescue clause, the exception is raised in the client object (i.e., the object that invoked the function). The exception rises up through the run-time calling hierarchy (effectively unwinding the call stack) until a rescue clause that handles the exception is found.
After performing any preliminary processing (e.g., reporting errors, etc.), an Eiffel rescue clause has only two courses of action open to it: retry the function (with which the clause is associated) or resignal the exception to the client object. retrying a function literally restarts the function; control is transferred back to the beginning of the function. Local variables are not redeclared and reinitialized; they retain their values from the time of the exception (or later, if they're modified by the rescue clause). If a rescue clause decides not to handle an exception, it must restore the target object to a consistent state, i.e., one that satisfies the class invariant.
Because a rescue clause's choices are so limited, Eiffel programs that include exception handling tend to be less straightforward than the equivalent programs in Ada, as the Eiffel rewrite of our example function shows:
ComputePseudoPoint: STATUS is local errorOccurred: BOOLEAN ; do -- Compute value of pseudo-telemetry point. if errorOccurred then Result := failure ; else ... perform processing ... Result := success ; endif ; rescue if exception = Numerical_error then errorOccurred := true ; retry ; end end ;
When ComputePseudoPoint()
is first called, local variable
errorOccurred
is automatically initialized to false
and the else block is executed. Normally, the function completes
its processing and returns success
as its result. If a
numerical error occurs and an exception is raised, then the rescue clause
sets the errorOccurred
flag to true and restarts the
function. This time through, the if block is executed and
failure
is returned to the client object.
The good news about Eiffel exception handling is that hierarchical
exception handling is an integral part of the language. The bad news is
that Eiffel's exception handling is implemented using C's ungainly
setjmp(3)
/longjmp(3)
mechanism. The Eiffel
compiler generates C source files, not assembly language or machine code;
the host computer's C compiler builds the final executable program. Since
the Eiffel compiler has no knowledge of or access to the
architecture-specific features of the target system, it must rely on
standard library functions such as setjmp(3)
and
longjmp(3)
for exception handling. Unfortunately, Eiffel
programs pay a heavy price, performance-wise, to preserve the portability
of the Eiffel compiler. To make matters worse, it appears that every
function, whether or not it has a rescue clause, has an implicit traceback
handler established upon entry, thus incurring the cost of a call to
setjmp(3)
. VAX afficionados may be forgiven a certain amount
of smugness: VAX/VMS can generate traceback listings with no run-time
overhead during normal processing.
Exception handling in other object-oriented languages typically follows the
Ada model. Trellis, for instance, has except clauses. Smalltalk,
on the other hand, uses a different approach. When an exception occurs in
a method, an exception message is sent to the target object. If the target
object's class has defined a method that responds to that exception
message, then the method is invoked. Otherwise, a search is made up the
inheritance hierarchy for a method that does handle the exception.
Dony has proposed a hierarchical, invocation-based mechanism for exception
handling in Smalltalk, one more along the lines of VMS/Ada/Eiffel exception
handling. An exception handling mechanism is promised for C++, but,
meanwhile, C++ programmers must make do with the standard UNIX/C exception
handling facility: signal(3)
, setjmp(3)
, and
longjmp(3)
.
This tutorial/proposal is not complete yet and I'm not sure if or when I'll finish it. Because of a number of problems I've read about on USENET, Eiffel is looking less and less likely as a candidate for TPOCC software development. Consequently, my enthusiasm for the language and for finishing this paper has waned.
I had planned to discuss the advantages of Eiffel with respect to other object-oriented languages like C++ and SmallTalk. C++ is probably the most popular OOP language at this time - unfortunately! C is a programming language that, despite its recent resurgence, should have been retired long ago. With its emphasis on pointers and user-controlled memory allocation, C makes for very efficient, but very buggy, programs. We TPOCCers had the time to leisurely immerse (mire) ourselves in the intricacies of C and gain some mastery of the language; other projects, on tight schedules, cannot afford to give their programmers that opportunity. C++ adds significant new capabilities to C, while at the same time retaining the significant handicaps of C. As Bertrand Meyer says, "hybrid languages give hybrid results".
On the other hand, Eiffel is not quite as efficient or as simple as Dr. Meyer's 1988 book suggests. (I should point out that this is no fault of the book's. The book is about object-oriented programming, not Eiffel, and more up-to-date references about Eiffel are available from the company who makes the compiler.) USENET mail messages reveal that a number of enhancements and extensions have been made to Eiffel since 1988, thus increasing the complexity of the language. Still, Eiffel is more readable and less complex than C++. Furthermore, as SunWorld remarked, Eiffel's "focus is the correctness and robustness of the target program". C++ can't claim that - there are too many C loopholes.
The USENET mail traffic also highlighted performance problems with Eiffel, a fact that diminishes Eiffel's potential as a language for POCC software development. The performance of Eiffel-generated code is not as much of a problem as it might seem; some of the existing non-real-time TPOCC applications written in C (e.g., the X Windows-based display program) are already notorious hogs of CPU cycles and memory! Nevertheless, Eiffel doesn't appear to be the appropriate language for developing real-time software such as telemetry decommutation, etc.
Fortunately, there is now an alternative to Eiffel. The International Computer Science Institute, of the University of California at Berkeley, is performing research in a number of areas such as computer vision, etc., which typically require large, complex programs that run fast. C was considered too unmanageable for the level of complexity they foresaw, so ICSI evaluated several object-oriented languages as suitable replacements. C++ and Objective-C were tried, but found wanting in their support of memory management and encapsulation. CLOS (an object-oriented LISP) was too slow. The Institute finally settled on Eiffel as the language of choice. However, they had the same experiences with Eiffel that others had: Eiffel was evolving into a semantically complex language and the execution speed of Eiffel programs was insufficient for the processing-intensive applications the Institute was developing.
At a dead end, ICSI did the only thing they could: they developed a new OOP language called Sather. Sather is an Eiffel look-alike that combines the best features of Eiffel and C++. In terms of conforming to advanced software engineering practices, Sather programs are comparable to Eiffel programs. In terms of performance, Sather programs are comparable to C++ programs and, in some cases, faster. This was mostly achieved by:
Like the Eiffel compiler, the Sather compiler is UNIX-based and generates C code. Thus, like Eiffel, Sather should be highly portable among UNIX platforms. Sather also has a flexible interface to the C language, so Sather programs can easily call existing C code and existing C programs can easily call Sather code.
The best thing about Sather is that it is free. The compiler source code,
supporting libraries, and documentation were recently released by the
International Computer Science Institute and are available via anonymous
ftp from icsi.berkeley.edu
.
A special issue on object-oriented design, with articles covering object-oriented design and programming in general and C++, Eiffel, and Trellis in particular.
Recounts their experiences porting the Booch Ada utilities to C++.
A description of A++ (Annotated C++): C++ with embedded assertions and axioms.
A general discussion of exception handling and a proposed mechanism for Smalltalk.
The title says it all.
Multi-methods: how to handle M-by-N polymorphism in Smalltalk.
Integrating Linda, a concurrent programming language, with Eiffel, an object-oriented language.
A classic article about how a real company attempted to use Eiffel on a real project. The results were mixed. Noted gains in productivity were offset by problems with the compiler (an early version of Eiffel) and a lack of tools (debugger, etc.). Management problems contributed to the fiasco.
An in-depth evaluation of the performance of Sather-generated code. This report is included in the Sather distribution.
The first published description of Eiffel (although "Genericity vs. Inheritance" presented some aspects of the language). It was much simpler back then.
Genericity and inheritance in Ada and Eiffel.
The eminently readable classic on object-oriented programming and Eiffel.
Real-life experience with object-oriented programs, or "Why did I have to trace through 9 object classes in two source files to figure out what one lousy function call does?"
A full description of Sather, an object-oriented programming language that combines the best features of Eiffel and C++. This manual is included in the Sather distribution.
Treating any object class as a potentially generic object class.
An overview of object-oriented programming.
Another strongly-typed OOP language.
One of Goddard's own!
Do object-oriented capabilities need to be added to Ada? This article discusses how many features of object-oriented programming can be simulated using extensible record structures and access types.
A panel discussion on various facets of object-oriented programming.
class HUMAN export Eat, Sleep, IsEating, IsSleeping feature eating: BOOLEAN ; -- TRUE if eating, -- FALSE if sleeping. Eat is -- Begin eating. do eating := true end ; Sleep is -- Fall asleep. do eating := false end ; IsEating: BOOLEAN is do Result := eating end ; IsSleeping: BOOLEAN is do Result := not eating end ; end Laurel, Hardy: HUMAN ; Laurel.Create ; -- Instances of humans. Hardy.Create ; Laurel.Sleep ; -- Stanley sleeps ... Hardy.Eat ; -- while Ollie eats.
deferred class MNF export Decommutate, Discard, Fill, MnfID, Save feature buffer: ARRAY[INTEGER] ; Create (size: INTEGER) is -- Allocate buffer. do buffer.Create (1, size) ; end ; Decommutate is -- Extract data. do TableDrivenExtraction ; end ; Discard is -- Discard contents. do deferred end ; Fill is -- Fill with data. do deferred end ; MnfID: INTEGER is -- Returns MNF ID. do deferred end ; Save is -- Record in history file. do deferred end ; TableDrivenExtraction is -- Private routine. do ... extract data using DBMS-defined parameters ... end ; end
class mission_MNF export Decommutate, Discard, Fill, MnfID, Save inherit MNF rename Create as MnfCreate, Decommutate as MnfDecommutate redefine decommutate ; feature sizeOfMNF: INTEGER is 128 ; -- 128 bytes per MNF. locationOfID: INTEGER is 9 ; -- MNF ID in byte 9. Create is -- Allocate buffer. do MnfCreate (sizeOfMNF) ; end ; Decommutate is -- Extract data. do ReverseFrame ; MnfDecommutate ; end ; Discard is -- Discard contents. do -- Do nothing. end ; Fill is -- Fill with data. do ... get next minor frame from TNIF and store in frame buffer ... end ; MnfID: INTEGER is -- Returns MNF ID. do Result := buffer.item (locationOfID) ; end ; Save is -- Record in history file. do ... write current contents of buffer to log file ... end ; ReverseFrame is -- Private routine. do ... reverse bytes in frame buffer ... end ; end
cycle is thisMNF: MNF ; -- Generic MNF variable. do from startOfPass until endOfPass loop thisMNF := GetNextMNF ; -- MSS or TM. thisMNF.Decommutate ; end ; end ; GetNextMNF: LANDSAT_MNF is do ... returns next available MNF, either MSS or TM ... end ;
class DISPLAY_DEVICE export DisplayPoint, DisplayLine, DisplayCircle, DisplayRectangle feature DisplayPoint (point: POINT_OBJECT) is do deferred -- Plot a point. end ; DisplayLine (line: LINE_OBJECT) is do deferred -- Draw a line. end ; DisplayCircle (circle: CIRCLE_OBJECT) is do deferred -- Draw a circle. end ; DisplayRectangle (rectangle: RECTANGLE_OBJECT) is do -- Draw a rectangle. DisplayLine (rectangle.side_1) ; DisplayLine (rectangle.side_2) ; DisplayLine (rectangle.side_3) ; DisplayLine (rectangle.side_4) ; end ; end
class STACK[T] export Push, Pop, Top, IsEmpty feature IsEmpty: BOOLEAN is do ... returns TRUE if stack is empty ... end ; Pop is do ... pops top object from stack ... end ; Push (item: T) is do ... pushes item on stack ... end ; Top: T is do ... returns top object on stack ... end ; end
class PROC_FILE_RECORD export CurrentLine, GetLine feature currentLine: INTEGER ; text: ARRAY[STRING] ; Create (name: STRING) is do -- Create record. ReadFile (name) ; currentLine := 1 ; end ; GetLine: STRING is do -- Return text of current line. Result := text.item (currentLine) ; currentLine := currentLine + 1 ; end ; ReadFile (name: STRING) is do ... create TEXT array and read file into it ... end ; end ;
class PROC_FILE_TABLE export GetLine, GoToLine, PopFile, PushFile feature procedureStack: STACK[PROC_FILE_RECORD] ; top: PROC_FILE_RECORD ; Create is do -- Create table. procedureStack.Create ; end ; GetLine: STRING is do -- Returns text of current line. Result := top.GetLine ; end ; GoToLine (lineNumber: INTEGER) is do -- Position to line N in current file. top.currentLine := lineNumber ; end ; PopFile is do -- Pops current file from top of stack. procedureStack.Pop ; top := procedureStack.Top ; end ; PushFile (name: STRING) is do -- Push new file on stack. top.Create (name) ; procedureStack.Push (top) ; end ; end
-- -- Generic XDR I/O Package. -- generic type STREAM_TYPE is (<>) ; with procedure READ_DATA (STREAM: STREAM_TYPE, BUFFER: ADDRESS, SIZE: NATURAL) ; with procedure WRITE_DATA (STREAM: STREAM_TYPE, BUFFER: ADDRESS, SIZE: NATURAL) ; package XDR is -- -- Read/write an integer. -- procedure XDR_INT (IS_READ: BOOLEAN, STREAM: STREAM_TYPE, VALUE: in out INTEGER) is begin if (IS_READ) then READ_DATA (STREAM, VALUE'ADDRESS, VALUE'SIZE/8) ; else WRITE_DATA (STREAM, VALUE'ADDRESS, VALUE'SIZE/8) ; end if ; end ; -- -- Read/write a floating point number. -- procedure XDR_FLOAT (IS_READ: BOOLEAN, STREAM: STREAM_TYPE, VALUE: in out FLOAT) is begin if (IS_READ) then READ_DATA (STREAM, VALUE'ADDRESS, VALUE'SIZE/8) ; else WRITE_DATA (STREAM, VALUE'ADDRESS, VALUE'SIZE/8) ; end if ; end ; -- -- Read/write a character string. -- procedure XDR_STRING (IS_READ: BOOLEAN, STREAM: STREAM_TYPE, VALUE: in out STRING) is begin if (IS_READ) then READ_DATA (STREAM, VALUE'ADDRESS, VALUE'LENGTH) ; else WRITE_DATA (STREAM, VALUE'ADDRESS, VALUE'LENGTH) ; end if ; end ; end XDR ;
deferred class IO_OBJECT export ReadData, WriteData feature ReadData (buffer: ADDRESS, size: INTEGER) is deferred end ; WriteData (buffer: ADDRESS, size: INTEGER) is deferred end ; end
class NET_OBJECT export ReadData, WriteData inherit IO_OBJECT feature socket: INTEGER ; -- File descriptor for socket connection. Create (host: STRING, port: STRING) is do ... Establish socket connection to HOST/PORT ... end ; ReadData (buffer: ADDRESS, size: INTEGER) is do ... Read data from socket connection ... end ; WriteData (buffer: ADDRESS, size: INTEGER) is do ... Write data to socket connection ... end ; end
class XDR export xdr_int, xdr_float, xdr_string feature stream: IO_OBJECT ; Create (ioStream: IO_OBJECT) is do stream := ioStream ; end ; xdr_int (isRead: BOOLEAN, value: INTEGER) is do if isRead then stream.ReadData (value.address, value.size) ; else stream.WriteData (value.address, value.size) ; endif ; end ; ... and so on for XDR_FLOAT and XDR_STRING ... end
handle: XDR ; stream: NET_OBJECT ; value: INTEGER ; stream.Create ("host", "port") ; -- Establish connection. handle.Create (stream) ; -- Create XDR stream. handle.xdr_int (true, value) ; -- Read integer. handle.xdr_string (false, "Hello!") ; -- Write string.
class CommandBuffer export AddCommand, ClearBuffer, UplinkCommands feature maxCommands: INTEGER ; numCommands: INTEGER ; buffer: ARRAY[SC_COMMAND] ; Create (maxNumCommands: INTEGER) is do -- Create command buffer. maxCommands := maxNumCommands ; numCommands := 0 ; end ; AddCommand (command: SC_COMMAND) is require numCommands < maxCommands do -- Add command to buffer. numCommands := numCommands + 1 ; buffer.Enter (numCommands, command) ; end ; ClearBuffer is require numCommands > 0 do numCommands := 0 ; end ; UplinkCommands is require numCommands > 0 do ... uplink commands in buffer to spacecraft ... ClearBuffer ; ensure numCommands = 0 end ; invariant ... class invariants ... end