Objects First |
Design at this stage means "create a formal software specification".
Decide on a name for the class, say Thing, and create a specification file Thing.h.
A good strategy is to take the users' requirements and go mechanically through them, checking off each one as you confirm that the requirement has been met by the software model that you've specified.
Only when we're satisfied with the specification ..
Usually the problem specification will tell you what the initial attributes should be. It will contain statements like:
The size and mass of a thing are known when it is created. Size refers to the maximum dimension in any direction; it's the minimum size of a circular opening through which a thing will pass. Things always have positive size and mass. Initially it will be in an undamaged state.
|
This tells you that the size and mass should be parameters of the
constructor. It also tells you that there is some state of a Thing
which will take values like undamaged, damaged, ... .
Thus the constructor will have the specification:
|
Other states like lost, destroyed, .. will be described elsewhere in the specification. It's your job as the class designer to read the problem specification and determine all the states that a Thing can be in! |
|
a. those which are specified when an object is constructed
and | These become the formal parameters of the constructor. |
| b. those which assume some specified default value. | These are set to their default values inside the constructor. |
At every point in a program, you should be able to make clear statements about the state of every object.
Thing CloneThing( Thing t ); /* Pre-condition: t is a valid Thing Post-condition: returned Thing is a clone of t */In software specifications, we might want to specify sets of constructors in which different sets of attributes take default values and need to be specified at construction. For example, our factory's production line occasionally makes damaged Thing's which we send on to a recovery section, so we add another constructor:
Thing ConsThingDamaged( double size, double mass );which is the same as ConsThing except that the initial state is set to damaged. Alternatively, we could allow the initial state to be set on construction:
Thing ConsThingWithState( double size, double mass, ThingState s );Either of these approaches is acceptable: the better solution will depend on the problem and there is the possibility that there are conflicting, but equal weight, arguments in favour of both!
| Aside | |
|
There is a certain amount of "art" in good software design.
There's generally no "best" in software design. However, there are plenty of "don't"s! |
Generally, it's possible to identify practices which are
clearly good or bad. Apply criteria like:
|
double ThingMass( Thing t ); double ThingSize( Thing t ); ThingState CurrentState( Thing t );Most of these are simple methods, consisting perhaps of no more than an assertion and a return statement:
double ThingMass( Thing t ) {
assert( t != NULL );
return t->mass;
}
However, remember that these methods are important in hiding
details of our class' implementation from its users.
By hiding these details, it allows us to change the internal
attributes as needs change without affecting programs that
manipulate a class.
For example, suppose that our needs expand and we have to expand the
Thing class to include volume and density attributes.
Since the mass, volume and density are inter-related, we only need
to store two of the three.
Suppose we decided to store volume and density:
this means that the ThingMass method needs to
be changed:
double ThingMass( Thing t ) {
assert( t != NULL );
return t->volume * t->density;
}
but the code that uses or manipulates Things needs no
changes! This is an important contribution to reducing the
cost of maintaining software and increasing its reliability.
Fewer changes mean fewer opportunities for error!
typedef enum (undamaged, damaged, lost, .. ) ThingState;But where do we define this type?
Formally, it should be considered a new class and have a
pair of specification and implementation files
(ThingState.h, ThingState.c) to itself.
However, practically, ThingState is never used without
Thing,
so it seems more reasonable to define this additional type in the
specification for Thing,
ie add it to Thing.h:
/* Thing.h - Specification for class: Thing */
typedef enum (undamaged, damaged, lost, .. ) ThingState;
typedef struct thing *Thing;
Thing ConsThing( .. );
...
This departure from the formal steps for building a class is justified
by efficiency and compactness considerations: it doesn't make sense
to create two - essentially trivial - files to define the
ThingState class
when a one line addition to the Thing.h
file will neatly solve the problem!
(However, a formal approach with ThingState in a separate
file could not be considered wrong.
Experience will soon teach you when departure from the rules makes
sense (usually because it makes it easier to understand
a program) and when it will make your program more complex and
difficult to work with.)
void SetMass( Thing t, double new_mass ); void SetSize( Thing t, double new_size );These methods have straight-forward implementations, eg
void SetMass( Thing t, double new_mass ) {
assert( t != NULL );
assert( new_mass > 0 );
t->mass = new_mass;
}
double ThermalLoss( Thing t, double temperature );
/* Heat a thing to temperature and calculate the mass lost through thermal
fracturing of the surface
Pre-cond: t is a valid Thing
temperature > 0.0
Post-cond: Returns mass lost
(mass lost + ThingMass(t)) = old ThingMass(t)
*/
Observe the post-condition here: this is an example of a really useful post-condition -
one that checks that the method has not violated some basic rule - in this case, the
physical conservation of mass law.
void HitThing( Thing t, double force );
/* Pre-condition: t is a valid Thing,
force >= 0
Post-condition: if ( force < damage_threshold ) t is unchanged,
else {
a piece whose size is proportional
to force is broken off
(mass and size changed appropriately) and
t's state is changed to damaged.
}
*/
void HitThing( Thing t, double force );
However, we can easily imagine much more complex methods:
Again we note that we can extend the capabilities of the class
to model real world objects in this way without affecting code
which only needed to deal with simpler Things.
We leave all the original methods
(making changes only where we have no choice) and
simply add some new ones to model the new capabilities.
Since the majority of maintenance of software is probably the
extension and refinement of capabilities,
this design strategy, which permits tested systems to be
gradually improved or refined in a way which doesn't affect
their original properties,
contributes significantly to the reliability of software systems.
The extra typing needed to add all the
assert()
statements may seem like a lot of unnecessary work,
but my experience has shown that it is well worth it.
Whenever an assertion is raised, it is extremely simple to
trace back from the point at which the assertion was raised
to the real source of the error.
This is much quicker than trying to infer
the source of the error from some erroneous
output from a program.
int ThingsColliding( Thing a, double vel_a, Thing b, double vel_b,
Thing chips[], double chip_vel[] );
/* Things a and b collide and produce an
array of chips
Pre-condition:
(a != NULL) && (b != NULL) &&
(vel_a >= 0) && (vel_b >= 0)
Post-condition:
if the collision produces any chips,
this method creates an array of new Things - chips
and an array of velocities, chip_vel.
The number of chips produced is returned as the
function value
*/
In this method, two objects interact with each other and
create a set of new objects.
This method will update a and
b,
as well as calling the Thing constructor to create
the "chips".
Dependent Classes
Classes often depend on other classes,
eg a point in two-dimensional space is composed
of two doubles,
a rectangle is defined in terms of two points and
a map of a building (used by a robot for navigating around it)
may consist of arrays of rectangles defining areas which it
cannot enter.
Classes can also be defined which are specialisations
of other classes,
eg we could define a class of geometric
Shapes
which have attributes such as position, colour, etc.
This class can be specialised into circles, rectangles
(which are in turn specialised into squares) and so on.
Methods which operate on Shapes, eg
Colour( s ), Position( s ) also operate on
circles, rectangles and squares.
In the formal theory of object oriented design, this
is known as inheritance.
We will not attempt to treat these dependencies and inheritance
here with any rigour.
It will suffice to note that in the specification of a class,
we will often need to refer to another class.
For example, a constructor that constructs objects by reading
data from a text file:
Thing ReadThing( FILE *f );
/* Pre-condition: f is open
*/
depends on the stream class which defines FILE.
So we must include its specification in the specification for Things:
#include <stdio.h>
....
Thing ReadThing( FILE *f );
/* Pre-condition: f is open
*/
However, if the implementation depends on other classes, then
import them into the implementation file.
Specifications should be imported on a
"need-to-know" basis.
For example, the calculations for ThingsColliding will
certainly need the standard mathematical function library,
<math.h>.
However, functions which call
ThingsColliding
probably don't need to do such calculations so shouldn't
be burdened unnecessarily with
<math.h>.
Spelling Mistakes
The "need-to-know" inclusion principle is a good strategy for
catching careless spelling mistakes in function names.
An ANSI C compiler will produce a warning if it can't see
a prototype for a function.
Suppose that you intended to call a
function, sign( x ).
You accidentally type sin( x ).
Because you've unnecessarily added
#include <math.h>
to your file,
the compiler doesn't pick up your error and the program
compiles, links and runs to completion.
However the output is rubbish and you're
now faced with the problem of tracing
back through the whole program to find a single letter typing mistake!
Implementation
Attributes
An important design decision in building the implementation is
to name and set types for the attributes of an object.
The attributes which will be needed can be determined by examining
the description of the problem: it will contain information about
the properties of objects which need to be modelled.
Each distinct property will need to be included as a class
attribute.
Sometimes it will be obvious:
initially our Things had size and mass, so
we included attributes with type double
(float would have been fine if the problem's
precision was low).
We also noted the need to classify objects as
damaged, etc, so we added an attribute state
of the enumerated type ThingState.
struct thing {
double mass, size;
ThingState state;
};
Later, we discovered that we needed volume and density too,
so we decided to replace mass with
volume
and density.
struct thing {
double volume, density, size;
ThingState state;
};
(Alternatively, we could have simply added volume
and calculated density as we needed it.)
Pre-conditions
Pre-conditions in the specifications are transferred to the
implementation as assert statements.
For example:
double ThingMass( Thing t ) {
assert( t != NULL );
return t->mass;
}
int ThingsColliding( Thing a, double vel_a, Thing b, double vel_b,
Thing chips[], double chip_vel[] ) {
..
assert(a != NULL);
assert(b != NULL);
assert(vel_a >= 0);
assert(vel_b >= 0);
...
Note that every method (other than constructors) should routinely
check that it has not been passed a NULL pointer as the handle of
the object on which it operates.
In addition, all other method parameters should generally be
checked for legal or reasonable values.
For example, negative velocities are not allowed.
This is a clear legal value constraint,
another one is that velocities must be less than 3x108ms-1!
We might also like to add a reasonableness constraint,
such as:
assert( vel_a < 20.0 ); /* Max reasonable velocity is 20m/s */
The major value of these assertions is that they "catch"
errors made in other parts of the program,
eg a user typed in an extra 0 when entering a velocity
and the programmer responsible for the code reading the input
forgot to
add code at the input point to request a better value from the
user.