Day 18
It is easy
to become focused on the syntax of C++ and to lose sight of how and why you use
these techniques to build programs. Today you will learn
How to analyze problems from an
object-oriented perspective.
How to design your program from an object-oriented perspective.
How to design for reusability and extensibility.
Many
volumes have been written about the development cycle. Some propose a
"waterfall" method, in which designers determine what the program
should do; architects determine how the program will be built, what classes
will be used, and so forth; and then programmers implement the design and
architecture. By the time the design and architecture is given to the
programmer, it is complete; all the programmer needs to do is implement the
required functionality.
Even if the
waterfall method worked, it would probably be a poor method for writing good
programs. As the programmer proceeds, there is a necessary and natural feedback
between what has been written so far and what remains to be done. While it is
true that good C++ programs are designed in great detail before a line of code
is written, it is not true that that design remains unchanged throughout the
cycle.
The amount
of design that must be finished "up front," before programming
begins, is a function of the size of the program. A highly complex effort,
involving dozens of programmers working for many months, will require a more
fully articulated architecture than a quick-and-dirty utility written in one
day by a single programmer.
This
chapter will focus on the design of large, complex programs which will be
expanded and enhanced over many years. Many programmers enjoy working at the
bleeding edge of technology; they tend to write programs whose complexity
pushes at the limits of their tools and understanding. In many ways, C++ was
designed to extend the complexity that a programmer or team of programmers
could manage.
This
chapter will examine a number of design problems from an object-oriented perspective.
The goal will be to review the analysis process, and then to understand how you
apply the syntax of C++ to implement these design objectives.
A
simulation is a computer model of a part of a real-world system. There are many
reasons to build a simulation, but a good design must start with an
understanding of what questions you hope the simulation will answer.
As a
starting point, examine this problem: You have been asked to simulate the alarm
system for a house. The house is a center hall
colonial with four bedrooms, a finished basement, and an under-the-house
garage.
The
downstairs has the following windows: three in the kitchen, four in the dining
room, one in the half-bathroom, two each in the living room and the family
room, and two small windows next to the door. All four bedrooms are upstairs,
each of which has two windows except for the master bedroom, which has four.
There are two baths, each with one window. Finally, there are four half-windows
in the basement, and one window in the garage.
Normal
access to the house is through the front door. Additionally, the kitchen has a
sliding glass door, and the garage has two doors for the cars and one door for
easy access to the basement. There is also a cellar door in the backyard.
All the
windows and doors are alarmed, and there are panic buttons on each phone and
next to the bed. The grounds are alarmed as well, though these are carefully
calibrated so that they are not set off by small animals or birds.
There is a central
alarm system in the basement, which sounds a warning chirp when the alarm has
been tripped. If the alarm is not disabled within a setable
amount of time, the police are called. If a panic button is pushed, the police
are called immediately.
The alarm
is also wired into the fire and smoke detectors and the sprinkler system, and
the alarm system itself is fault tolerant, has its own internal backup power
supply, and is encased in a fireproof box.
You begin
by asking, "What questions might this simulation answer?" For
example, you might be able to use the simulation to answer the questions,
"How long might a sensor be broken before anyone notices?" or
"Is there a way to defeat the window alarms without the police
being notified?"
Once you
understand the purpose of the simulation you will know what parts of the real
system the program must model. Once that is well understood, it becomes much
easier to design the program itself.
One way to
approach this problem is to set aside issues relating to the user interface and
to focus only on the components of the "problem space." A first
approximation of an object-oriented design might be to list the objects that
you need to simulate, and then to examine what these objects "know"
and "do."
--------------------------------------------------------------------------------
New Term:
The problem space is the set of problems and issues your program is trying to
solve. The solution space is the set of possible solutions to the problems.
--------------------------------------------------------------------------------
For
example, clearly you have sensors of various types, a central alarm system,
buttons, wires, and telephones. Further thought convinces you that you must also
simulate rooms, perhaps floors, and possibly groups of people such as owners
and police.
The sensors
can be divided into motion detectors, trip wires, sound detectors, smoke
detectors, and so forth. All of these are types of sensors, though there is no
such thing as a sensor per se. This is a good indication that sensor is an
abstract data type (ADT).
As an ADT, the class sensor would provide the complete interface
for all types of sensors, and each derived type would provide the
implementation. Clients of the various sensors would use them without regard to
which type of sensor they are, and they would each "do the right
thing" based on their real type.
To create a
good ADT, you need to have a complete understanding
of what sensors do (rather than how they work). For example, are sensors
passive devices or are they active? Do they wait for some element to heat up, a
wire to break, or a piece of caulk to melt, or do they probe their environment?
Perhaps some sensors have only a binary state (alarm state or okay), but others
have a more analog state (what is the current
temperature?). The interface to the abstract data type should be sufficiently
complete to handle all the anticipated needs of the myriad derived classes.
The design
continues in this way, teasing out the various other classes that will be
required to meet the specification. For example, if a log is to be kept,
probably a timer will be needed; should the timer poll each sensor or should
each sensor file its own report periodically?
The user is
going to need to be able to set up, disarm, and program the system, and so a
terminal of some sort will be required. You may want a separate object in your
simulation for the alarm program itself.
As you
solve these problems, you will begin to design your classes. For example, you
already have an indication that HeatSensor will
derive from Sensor. If the sensor is to make periodic reports, it may also
derive via multiple inheritance from Timer, or it may
have a timer as a member variable.
The HeatSensor will probably have member functions such as CurrentTemp()
and SetTempLimit() and will probably inherit
functions such as SoundAlarm() from its base class,
Sensor.
A frequent
issue in object-oriented design is that of encapsulation. You could imagine a
design in which the alarm system has a setting for MaxTemp.
The alarm system asks the heat sensor what the current temperature is, compares
it to the maximum temperature, and sounds the alarm if it is too hot. One could
argue that this violates the principle of encapsulation. Perhaps it would be
better if the alarm system didn't know or care what the details are of
temperature analysis; arguably that should be in the HeatSensor.
Whether or
not you agree with that argument, it is the kind of decision you want to focus
on during the analysis of the problem. To continue this analysis, one could
argue that only the Sensor and the Log object should know any details of how
sensor activity is logged; the Alarm object shouldn't know or care.
Good
encapsulation is marked by each class having a coherent and complete set of
responsibilities, and no other class having the same responsibilities. If the
sensor is responsible for noting the current temperature, no other class should
have that responsibility.
On the
other hand, other classes might help deliver the necessary functionality. For
example, while it might be the responsibility of the Sensor class to note and
log the current temperature, it might implement that responsibility by
delegating to a Log object the job of actually recording the data.
Maintaining
a firm division of responsibilities makes your program easier to extend and
maintain. When you decide to change the alarm system for an enhanced module,
its interface to the log and to the sensors will be narrow and well defined.
Changes to the alarm system should not affect the Sensor classes, and vice
versa.
Should the HeatSensor have a ReportAlarm() function? All sensors will need the ability to report an
alarm. This is a good indication that ReportAlarm() should be a virtual method of Sensor, and that Sensor may
be an abstract base class. It is possible that HeatSensor
will chain up to Sensor's more general ReportAlarm() method; the overridden function would just fill in the
details it is uniquely qualified to supply.
When your
sensors report an alarm condition, they will want to provide a lot of
information to the object that phones the police and to the log. It may well be
that you'll want to create a Condition class, whose constructor takes a number
of measurements. Depending on the complexity of the measurements, these too
might be objects, or they might be simple scalar values such as integers.
It is
possible that Condition objects are passed to the central Alarm object, or that
Condition objects are subclassed into Alarm objects,
which themselves know how to take emergency action. Perhaps there is no central
object; instead there might be sensors, which know how to create Condition objects.
Some Condition objects would know how to log themselves; others might know how
to contact the police.
A
well-designed event-driven system need not have a central coordinator. One can
imagine the sensors all independently receiving and sending message objects to
one another, setting parameters, taking readings, and monitoring the house.
When a fault is detected, an Alarm object is created, which logs the problem
(by sending a message to the Log object) and takes the appropriate action.
To simulate
such an event-driven system, your program needs to create an event loop. An
event loop is typically an infinite loop such as while(1),
which gets messages from the operating system (mouse clicks, keyboard presses,
and so on) and dispatches them one by one, returning to the loop until an exit
condition is satisfied. Listing 18.1 shows a rudimentary event loop.
View Code
1: // Listing 18.1
2:
3: #include <iostream.h>
4:
5: class Condition
6: {
7: public:
8: Condition() {
}
9: virtual ~Condition()
{}
10: virtual void Log()
= 0;
11: };
12:
13: class
14: {
15: public:
16:
17: virtual ~Normal()
{}
18: virtual void Log()
{ cout << "Logging normal
conditions...\n"; }
19: };
20:
21: class Error :
public Condition
22: {
23: public:
24: Error()
{Log();}
25: virtual ~Error()
{}
26: virtual void Log()
{ cout << "Logging error!\n"; }
27: };
28:
29: class Alarm :
public Condition
30: {
31: public:
32: Alarm ();
33: virtual
~Alarm() {}
34: virtual void Warn()
{ cout << "Warning!\n"; }
35: virtual void Log()
{ cout << "General Alarm log\n"; }
36: virtual void Call()
= 0;
37:
38: };
39:
40: Alarm::Alarm()
41: {
42: Log();
43: Warn();
44: }
45: class FireAlarm : public Alarm
46: {
47: public:
48: FireAlarm(){Log();};
49: virtual ~FireAlarm() {}
50: virtual void Call()
{ cout<< "Calling Fire Dept.!\n"; }
51: virtual void Log()
{ cout << "Logging fire call.\n"; }
52: };
53:
54: int main()
55: {
56: int input;
57: int okay = 1;
58: Condition * pCondition;
59: while (okay)
60: {
61: cout
<< "(0)Quit (1)
62: cin
>> input;
63: okay = input;
64: switch (input)
65: {
66: case 0: break;
67: case 1:
68: pCondition
= new
69: delete pCondition;
70: break;
71: case 2:
72: pCondition
= new FireAlarm;
73: delete pCondition;
74: break;
75: default:
76: pCondition = new
Error;
77: delete pCondition;
78: okay = 0;
79: break;
80: }
81: }
82: return 0;
83: }
Output:
(0)Quit (1)
Logging
normal conditions...
(0)Quit
(1)
General
Alarm log
Warning!
Logging fire call.
(0)Quit (1)Normal (2)Fire: 0
Analysis:
The simple loop created on lines 59-80 allows the user to enter input
simulating a normal report from a sensor and a report of a fire. Note that the
effect of this report is to spawn a Condition object whose constructor calls
various member functions.
Calling
virtual member functions from a constructor can cause confusing results if you
are not mindful of the order of construction of objects. For example, when the FireAlarm object is created on line 72, the order of
construction is Condition, Alarm, FireAlarm.
The Alarm constructor calls Log, but it is Alarm's Log(),
not FireAlarm's, that is invoked, despite Log() being
declared virtual. This is because at the time Alarm's constructor runs, there
is no FireAlarm object. Later, when FireAlarm itself is constructed, its constructor calls Log() again, and this time FireAlarm::Log()
is called.
Here's
another problem on which to practice your object-oriented analysis: You have
been hired by Acme Software, Inc., to start a new software project and to hire
a team of C++ programmers to implement your program. Jim Grandiose,
vice-president of new product development, is your new boss. He wants you to
design and build PostMaster, a utility to read
electronic mail from various unrelated e-mail providers. The potential customer
is a businessperson who uses more than one e-mail product, for example
Interchange, CompuServe,
The
customer will be able to "teach" PostMaster
how to dial up or otherwise connect to each of the e-mail providers, and PostMaster will get the mail and then present it in a
uniform manner, allowing the customer to organize the mail, reply, forward
letters among services, and so forth.
PostMasterProfessional, to be developed as version 2 of PostMaster,
is already anticipated. It will add an Administrative Assistant mode, which
will allow the user to designate another person to read some or all of the
mail, to handle routine correspondence, and so forth. There is also speculation
in the marketing department that an artificial intelligence component might add
the capability for PostMaster to pre-sort and prioritize the mail based on subject and content keywords
and associations.
Other
enhancements have been talked about, including the ability to handle not only
mail but discussion groups such as Interchange discussions, CompuServe forums,
Internet newsgroups, and so forth. It is obvious that Acme has great hopes for PostMaster, and you are under severe time constraints to
bring it to market, though you seem to have a nearly unlimited budget.
You set up
your office and order your equipment, and then your first order of business is
to get a good specification for the product. After examining the market, you
decide to recommend that development be focused on a single platform, and you
set out to decide among DOS; UNIX; the Macintosh; and Windows, Windows NT, and
OS/2.
You have
many painful meetings with Jim Grandiose, and it becomes clear that there is no
right choice, and so you decide to separate the front end, that is the user
interface or UI, from the back end, the communications and database part. To
get things going quickly, you decide to write for DOS first, followed by Win32,
the Mac, and then UNIX and OS/2.
This simple
decision has enormous ramifications for your project. It quickly becomes
obvious that you will need a class library or a series of libraries to handle
memory management, the various user interfaces, and perhaps also the
communications and database components.
Mr.
Grandiose believes strongly that projects live or die by having one person with
a clear vision, so he asks that you do the initial architectural analysis and
design before hiring any programmers. You set out to analyze
the problem.
It quickly
becomes obvious that you really have more than one problem to solve. You divide
the project into these significant sub-projects:
1.
Communications: the ability for the software to dial into the e-mail provider
via modem, or to connect over a network.
2.
Database: the ability to store data and to retrieve it from disk.
3. E-mail:
the ability to read various e-mail formats and to write new messages to each
system.
4. Editing:
providing state-of-the-art editors for the creation and manipulation of
messages.
5. Platform
issues: the various UI issues presented by each platform (DOS, Macintosh, and
so on).
6.
Extensibility: planning for growth and enhancements.
7.
Organization and scheduling: managing the various developers and their code
interdependencies. Each group must devise and publish schedules, and then be
able to plan accordingly. Senior management and marketing need to know when the
product will be ready.
You decide
to hire a manager to handle item 7, organization and scheduling. You then hire
senior developers to help you analyze and design, and
then to manage the implementation of the remaining areas. These senior
developers will create the following teams:
1.
Communications: responsible for both dial-up and network communications. They
deal with packets, streams, and bits, rather than with e-mail messages per se.
2. Message
format: responsible for converting messages from each e-mail provider to a
canonical form (PostMaster standard) and back. It is
also their job to write these messages to disk and to get them back off the
disk as needed.
3. Message
editors: This group is responsible for the entire UI of the product, on each
platform. It is their job to ensure that the interface between the back end and
the front end of the product is sufficiently narrow that extending the product
to other platforms does not require duplication of code.
You decide
to focus on the message format first, setting aside the issues relating to
communications and user interface. These will follow once you understand more
fully what it is you are dealing with. There is little sense in worrying about
how to present the information to the user until you understand what
information you are dealing with.
An
examination of the various e-mail formats reveals that they have many things in
common, despite their various differences. Each e-mail message has a point of
origination, a destination, and a creation date. Nearly all such messages have
a title or subject line and a body which may consist of simple text, rich text
(text with formatting), graphics, and perhaps even sound or other fancy additions.
Most such e-mail services also support attachments, so that users can send
programs and other files.
You confirm
your early decision that you will read each mail message out of its original
format and into PostMaster format. This way you will
only have to store one record format, and writing to and reading from the disk
will be simplified. You also decide to separate the "header"
information (sender, recipient, date, title, and so on) from the body of the
message. Often the user will want to scan the headers without necessarily
reading the contents of all the messages. You anticipate that a time may come
when users will want to download only the headers from the message provider,
without getting the text at all, but for now you intend that version 1 of PostMaster will always get the full message, although it
may not display it to the user.
This
analysis of the messages leads you to design the Message class. In anticipation
of extending the program to non-e-mail messages, you derive EmailMessage
from the abstract base Message. From EmailMessage you
derive PostMasterMessage, InterchangeMessage,
CISMessage, ProdigyMessage,
and so forth.
Messages
are a natural choice for objects in a program handling mail messages, but
finding all the right objects in a complex system is the single greatest
challenge of object-oriented programming. In some cases, such as with messages,
the primary objects seem to "fall out" of your understanding of the
problem. More often, however, you have to think long and hard about what you
are trying to accomplish to find the right objects.
Don't
despair. Most designs are not perfect the first time. A good starting point is
to describe the problem out loud. Make a list of all the nouns and verbs you
use when describing the project. The nouns are good candidates for objects. The
verbs might be the methods of those objects (or they may be objects in their
own right). This is not a foolproof method, but it is a good technique to use
when getting started on your design.
That was
the easy part. Now the question arises, "Should the message header be a
separate class from the body?" If so, do you need parallel hierarchies, CompuServeBody and CompuServeHeader,
as well as ProdigyBody and ProdigyHeader?
Parallel
hierarchies are often a warning sign of a bad design. It is a common error in
object-oriented design to have a set of objects in one hierarchy, and a
matching set of "managers" of those objects in another. The burden of
keeping these hierarchies up-to-date and in sync with each other soon becomes
overwhelming: a classic maintenance nightmare.
There are
no hard-and-fast rules, of course, and at times such parallel hierarchies are
the most efficient way to solve a particular problem. Nonetheless, if you see
your design moving in this direction, you should rethink the problem; there may
be a more elegant solution available.
When the
messages arrive from the e-mail provider, they will not necessarily be
separated into header and body; many will be one large stream of data, which
your program will have to disentangle. Perhaps your hierarchy should reflect
that idea directly.
Further
reflection on the tasks at hand leads you to try to list the properties of
these messages, with an eye towards introducing capabilities and data storage
at the right level of abstraction. Listing properties of your objects is a good
way to find the data members, as well as to "shake out" other objects
you might need.
Mail
messages will need to be stored, as will the user's preferences, phone numbers,
and so forth. Storage clearly needs to be high up in the hierarchy. Should the
mail messages necessarily share a base class with the preferences?
There are
two overall approaches to inheritance hierarchies: you can have all, or nearly
all, of your classes descend from a common root class, or you can have more
than one inheritance hierarchy. An advantage of a common root class is that you
often can avoid multiple inheritance; a disadvantage
is that many times implementation will percolate up into the base class.
--------------------------------------------------------------------------------
New Term: A
set of classes is rooted if all share a common ancestor. Non-rooted hierarchies
do not all share a common base class.
--------------------------------------------------------------------------------
Because you
know that your product will be developed on many platforms, and because
multiple inheritance is complex and not necessarily
well supported by all compilers on all platforms, your first decision is to use
a rooted hierarchy and single inheritance. You decide to identify those places
where multiple inheritance might be used in the
future, and to design so that breaking apart the hierarchy and adding multiple
inheritance at a later time need not be traumatic to your entire design.
You decide
to prefix the name of all of your internal classes with the letter p so that
you can easily and quickly tell which classes are yours and which are from other
libraries. On Day 21, "What's Next," you'll
learn about name spaces, which can reinforce this idea, but for now the initial
will do nicely.
Your root
class will be pObject; virtually every class you
create will descend from this object. pObject
itself will be kept fairly simple; only that data which absolutely every item
shares will appear in this class.
If you want
a rooted hierarchy, you'll want to give the root class a fairly generic name
(like pObject) and few capabilities. The point of a
root object is to be able to create collections of all its descendants and
refer to them as instances of pObject. The trade-off
is that rooted hierarchies often percolate interface up into the root class.
You will pay the price; by percolating these interfaces up into the root
object, other descendants will have interfaces that are inappropriate to their
design. The only good solution to this problem, in single inheritance, is to
use templates. Templates are discussed tomorrow.
The next
likely candidates for top of the hierarchy status are pStored
and pWired. pStored
objects are saved to disk at various times (for example when the program is not
in use), and pWired objects are sent over the modem
or network. Because nearly all of your objects will need to be stored to disk,
it makes sense to push this functionality up high in the hierarchy. Because all
the objects that are sent over the modem must be stored, but not all stored
objects must be sent over the wire, it makes sense to derive pWired from pStored.
Each derived
class acquires all the knowledge (data) and functionality (methods) of its base
class, and each should add one discrete additional ability.
Thus, pWired may add various methods, but all are in
service of adding the ability to be transferred over the modem.
It is
possible that all wired objects are stored, or that all stored objects are
wired, or that neither of these statements is true. If only some wired objects
are stored, and only some stored objects are wired, you will be forced either
to use multiple inheritance or to "hack
around" the problem. A potential "hack" for such a situation
would be to inherit, for example, Wired from Stored, and then for those objects
that are sent via modem, but are never stored, to make the stored methods do
nothing or return an error.
In fact,
you realize that some stored objects clearly are not wired: for example, user
preferences. All wired objects, however, are stored, and so your inheritance
hierarchy so far is as reflected in Figure 18.1.
It is important at this stage of designing
your product to avoid being concerned with implementation. You want to focus
all of your energies on designing a clean interface among the classes and then
delineating what data and methods each class will need.
It is often
a good idea to have a solid understanding of the base classes before trying to
design the more derived classes, so you decide to focus on pObject,
pStored, and pWired.
The root
class, pObject, will only have the data and methods
that are common to everything on your system. Perhaps every object should have
a unique identification number. You could create pID (PostMaster ID) and
make that a member of pObject; but first you must ask
yourself, "Does any object that is not stored and not wired need such a
number?" That begs the question, "Are there any objects that are not
stored, but that are part of this hierarchy?"
If there
are no such objects, you may want to consider collapsing pObject
and pStored into one class; after all, if all objects
are stored, what is the point of the differentiation? Thinking this through,
you realize that there may be some objects, such as address objects, that it
would be beneficial to derive from pObject, but that
will never be stored on their own; if they are stored, they will be as part of
some other object.
That says
that for now having a separate pObject class would be
useful. One can imagine that there will be an address book that would be a
collection of pAddress objects, and while no pAddress will ever be stored on its own, there would be
utility in having each one have its own unique identification number. You
tentatively assign pID to pObject, and this means that pObject,
at a minimum, will look like this:
class pObject
{
public:
pObject();
~pObject();
pID GetID()const;
void SetID();
private:
pID
itsID;
}
There are a
number of things to note about this class declaration. First, this class is not
declared to derive from any other; this is your root class. Second, there is no
attempt to show implementation, even for methods such as GetID() that are likely to
have inline implementation when you are done.
Third,
const methods are already identified; this is part of the interface, not the
implementation. Finally, a new data type is implied: pID. Defining pID
as a type, rather than using, for example, unsigned long, puts greater
flexibility into your design.
If it turns
out that you don't need an unsigned long, or that an unsigned long is not
sufficiently large, you can modify pID.
That modification will affect every place pID
is used, and you won't have to track down and edit every file with a pID in it.
For now,
you will use typedef to declare pID to be ULONG, which in
turn you will declare to be unsigned long. This raises the question: Where do
these declarations go?
When
programming a large project, an overall design of the files is needed. A
standard approach, one which you will follow for this project, is that each
class appears in its own header file, and the implementation for the class
methods appears in an associated CPP file. Thus, you
will have a file called OBJECT.HPP and another called
OBJECT.CPP. You anticipate having other files such as
MSG.HPP and MSG.CPP, with
the declaration of pMessage and the implementation of
its methods, respectively.
--------------------------------------------------------------------------------
NOTE: Buy
it or write it? One question that you will confront throughout the design phase
of your program is which routines might you buy and
which must you write yourself. It is entirely possible that you can take
advantage of existing commercial libraries to solve some or all of your
communications issues. Licensing fees and other non-technical concerns must
also be resolved. It is often advantageous to purchase such a library, and to
focus your energies on your specific program, rather than to "reinvent the
wheel" about secondary technical issues. You might even want to consider
purchasing libraries that were not necessarily intended for use with C++, if
they can provide fundamental functionality you'd otherwise have to engineer
yourself. This can be instrumental in helping you hit your deadlines.
--------------------------------------------------------------------------------
For a
project as large as PostMaster, it is unlikely that
your initial design will be complete and perfect. It would be easy to become
overwhelmed by the sheer scale of the problem, and trying to create all the
classes and to complete their interface before writing a line of working code
is a recipe for disaster.
There are a
number of good reasons to try out your design on a prototype--a quick-and-dirty
working example of your core ideas. There are a number of different types of
prototypes, however, each meeting different needs.
An
interface design prototype provides the chance to test the look and feel of
your product with potential users.
A
functionality prototype might be designed that does not have the final user
interface, but allows users to try out various features, such as forwarding
messages or attaching files without worrying about the final interface.
Finally, an
architecture prototype might be designed to give you a chance to develop a
smaller version of the program and to assess how easily your design decisions
will "scale up," as the program is fleshed out.
It is
imperative to keep your prototyping goals clear. Are you examining the user
interface, experimenting with functionality, or building a scale model of your
final product? A good architecture prototype makes a poor user interface
prototype, and vice versa.
It is also
important to keep an eye on over-engineering the prototype, or becoming so
concerned with the investment you've made in the prototype that you are
reluctant to tear the code down and redesign as you progress.
A good
design rule of thumb at this stage is to design for those things that 80
percent of the people want to do 80 percent of the time, and to set aside your
concerns about the remaining 20 percent. The "boundary conditions"
will need to be addressed sooner or later, but the core of your design should
focus on the 80/80.
In the face
of this, you might decide to start by designing the principal classes, setting
aside the need for the secondary classes. Further, when you identify multiple
classes that will have similar designs with only minor refinements, you might
choose to pick one representative class and focus on that, leaving until later
the design and implementation of its close cousins.
--------------------------------------------------------------------------------
NOTE: There
is another rule, the 80/20 rule, which states that "the first 20% of your
program will take 80% of your time to code, and the remaining 80% of your
program will take the other 80% of your time!"
--------------------------------------------------------------------------------
Designing
the PostMasterMessage Class
In keeping
with these considerations, you decide to focus on PostMasterMessage.
This is the class that is most under your direct control.
As part of
its interface, PostMasterMessage will need to talk
with other types of messages, of course. You hope to be able to work closely
with the other message providers and to get their message format
specifications, but for now you can make some smart guesses just by observing
what is sent to your computer as you use their services.
In any
case, you know that every PostMasterMessage will have
a sender, a recipient, a date, and a subject, as well as the body of the
message and perhaps attached files. This tells you that you'll need accessor methods for each of these attributes, as well as
methods to report on the size of the attached files, the size of the messages,
and so forth.
Some of the
services to which you will connect will use rich text--that is, text with
formatting instructions to set the font, character size, and attributes, such
as bold and italic. Other services do not support these attributes, and those
that do may or may not use their own proprietary scheme for managing rich text.
Your class will need conversion methods for turning rich text into plain ASCII,
and perhaps for turning other formats into PostMaster
formats.
An
Application Program Interface (API) is a set of documentation and routines for
using a service. Many of the mail providers will give you an API so that PostMaster mail will be able to take advantage of their
more advanced features, such as rich text and embedding files. PostMaster will also want to publish its own API so that
other providers can plan for working with PostMaster
in the future.
Your PostMasterMessage class will want to have a well-designed
public interface, and the conversion functions will be a principal component of
PostMaster's API. Listing 18.2 illustrates what PostMasterMessage's interface looks like so far.
1: class PostMasterMessage : public MailMessage
2: {
3: public:
4: PostMasterMessage();
5: PostMasterMessage(
6: pAddress
Sender,
7: pAddress
Recipient,
8: pString
Subject,
9: pDate creationDate);
10:
11: // other constructors here
12: // remember to include copy constructor
13: // as well as constructor from storage
14: // and constructor from wire format
15: // Also include constructors from other
formats
16: ~PostMasterMessage();
17: pAddress& GetSender()
const;
18: void SetSender(pAddress&);
19: // other member accessors
20:
21: // operator methods here, including
operator equals
22: // and conversion routines to turn PostMaster messages
23: // into messages of other formats.
24:
25: private:
26: pAddress itsSender;
27: pAddress itsRecipient;
28: pString itsSubject;
29: pDate itsCreationDate;
30: pDate itsLastModDate;
31: pDate itsReceiptDate;
32: pDate itsFirstReadDate;
33: pDate itsLastReadDate;
34: };
Output:
None.
Analysis:
Class PostMasterMessage is declared to derive from MailMessage. A number of constructors will be provided,
facilitating the creation of PostMasterMessages from
other types of mail messages.
A number of
accessor methods are anticipated for reading and
setting the various member data, as well as operators for turning all or part
of this message into other message formats. You anticipate storing these
messages to disk and reading them from the wire, so accessor
methods are needed for those purposes as well.
Even this
preliminary architecture is enough to indicate how the various development
groups ought to proceed. The communications group can go ahead and start work
on the communications back end, negotiating a narrow interface with the message
format group.
The message
format group will probably lay out the general interface to the Message
classes, as was begun above, and then will turn its attention to the question
of how to write data to the disk and read it back. Once this disk interface is
well understood, they will be in a good position to negotiate the interface to
the communications layer.
The message
editors will be tempted to create editors with an intimate knowledge of the
internals of the Message class, but this would be a bad design mistake. They
too must negotiate a very narrow interface to the Message class; message editor
objects should know very little about the internal structure of messages.
As the
project continues, you will repeatedly confront this basic design issue: In
which class should you put a given set of functionality (or information)?
Should the Message class have this function, or should the Address class? Should the editor store this information, or should the message
store it itself?
Your
classes should operate on a "need to know" basis, much like secret
agents. They shouldn't share any more knowledge than is absolutely necessary.
As you
progress with your program, you will face hundreds of design issues. They will
range from the more global questions, "What do we want this to do?"
to the more specific, "How do we make this work?"
While the
details of your implementation won't be finalized until you ship the code, and
some of the interfaces will continue to shift and change as you work, you must
ensure that your design is well understood early in the process. It is
imperative that you know what you are trying to build before you write the
code. The single most frequent cause of software dying on the vine must be that
there was not sufficient agreement early enough in the process about what was
being built.
To get a feel for what the design process is
like, examine this question, "What will be on the menu?" For PostMaster, the first choice is probably "new mail
message," and this immediately raises another design issue: When the user
presses New Message, what happens? Does an editor get created, which in turn
creates a mail message, or does a new mail message get created, which then
creates the editor?
The command
you are working with is "new mail message," so creating a new mail
message seems like the obvious thing to do. But what happens if the user hits Cancel after starting to write the message? Perhaps it would
be cleaner to first create the editor and have it create (and own) the new message.
The problem
with this approach is that the editor will need to act differently if it is
creating a message than if it is editing the message, whereas if the message is
created first and then handed to the editor, only one set of code need exist: Everything
is an edit of an existing message.
If a
message is created first, who creates it? Is it created by the menu command
code? If so, does the menu also tell the message to edit itself, or is this
part of the constructor method of the message?
It makes
sense for the constructor to do this at first glance; after all, every time you
create a message you'll probably want to edit it. Nonetheless, this is not a
good design idea. First, it is very possible that the premise is wrong: You may
well create "canned" messages (that is, error messages mailed to the
system operator) that are not put into an editor. Second, and more important, a
constructor's job is to create an object; it should do no more and no less than
that. Once a mail message is created, the constructor's job is done; adding a
call to the edit method just confuses the role of the constructor and makes the
mail message vulnerable to failures in the editor.
What is
worse, the edit method will call another class, the editor, causing its constructor
to be called. Yet the editor is not a base class of the message, nor is it
contained within the message; it would be unfortunate if the construction of
the message depended on successful construction of the editor.
Finally,
you won't want to call the editor at all if the message can't be successfully
created; yet successful creation would, in this scenario, depend on calling the
editor! Clearly you want to fully return from the message's constructor before
calling Message::Edit().
--------------------------------------------------------------------------------
DO look for
objects that arise naturally out of your design. DO redesign as your
understanding of the problem space improves. DON'T share more information among
the classes than is absolutely necessary. DO look for opportunities to take
advantage of C++'s polymorphism.
--------------------------------------------------------------------------------
One
approach to surfacing design issues is to create a driver program early in the
process. For example, the driver program for PostMaster
might offer a very simple menu, which will create PostMasterMessage
objects, manipulate them, and otherwise exercise some of the design.
--------------------------------------------------------------------------------
New Term: A
driver program is a function that exists only to demonstrate or test other
functions.
--------------------------------------------------------------------------------
Listing
18.3 illustrates a somewhat more robust definition of the PostMasterMessage
class and a simple driver program.
View Code
1: #include <iostream.h>
2: #include <string.h>
3:
4: typedef unsigned
long pDate;
5: enum SERVICE
6: { PostMaster,
Interchange, CompuServe, Prodigy, AOL, Internet };
7: class String
8: {
9: public:
10: // constructors
11: String();
12: String(const
char *const);
13: String(const
String &);
14: ~String();
15:
16: // overloaded operators
17: char & operator[](int offset);
18: char operator[](int offset) const;
19: String operator+(const
String&);
20: void operator+=(const
String&);
21: String & operator= (const String
&);
22: friend ostream&
operator<<
23: ( ostream& theStream,String&
theString);
24: // General accessors
25: int GetLen()const
{ return itsLen; }
26: const char * GetString() const { return itsString; }
27: // static int
ConstructorCount;
28: private:
29: String (int); // private constructor
30: char * itsString;
31: unsigned short itsLen;
32:
33: };
34:
35: // default constructor creates string of 0
bytes
36: String::String()
37: {
38: itsString =
new char[1];
39: itsString[0] = `\0';
40: itsLen=0;
41: // cout
<< "\tDefault string constructor\n";
42: // ConstructorCount++;
43: }
44:
45: // private (helper) constructor, used only
by
46: // class methods for creating a new string
of
47: // required size. Null filled.
48: String::String(int
len)
49: {
50: itsString =
new char[len+1];
51: for (int i = 0; i<=len;
i++)
52: itsString[1] = `\0';
53: itsLen=len;
54: // cout
<< "\tString(int) constructor\n";
55: // ConstructorCount++;
56: }
57:
58: // Converts a character array to a String
59: String::String(const char * const cString)
60: {
61: itsLen = strlen(cString);
62: itsString =
new char[itsLen+1];
63: for (int i = 0; i<itsLen;
i++)
64: itsString[i] = cString[i];
65: itsString[itsLen]='\0';
66: // cout
<< "\tString(char*) constructor\n";
67: // ConstructorCount++;
68: }
69:
70: // copy constructor
71: String::String
(const String & rhs)
72: {
73: itsLen=rhs.GetLen();
74: itsString =
new char[itsLen+1];
75: for (int i = 0; i<itsLen;i++)
76: itsString[i] = rhs[i];
77: itsString[itsLen] = `\0';
78: // cout
<< "\tString(String&) constructor\n";
79: // ConstructorCount++;
80: }
81:
82: // destructor, frees allocated memory
83: String::~String ()
84: {
85: delete [] itsString;
86: itsLen = 0;
87: // cout
<< "\tString destructor\n";
88: }
89:
90: // operator equals, frees existing memory
91: // then copies string and size
92: String& String::operator=(const String & rhs)
93: {
94: if (this == &rhs)
95: return *this;
96: delete [] itsString;
97: itsLen=rhs.GetLen();
98: itsString =
new char[itsLen+1];
99: for (int i = 0; i<itsLen;i++)
100: itsString[i] = rhs[i];
101: itsString[itsLen] = `\0';
102: return *this;
103: // cout
<< "\tString operator=\n";
104: }
105:
106: //non constant offset operator, returns
107: // reference to character so it can be
108: // changed!
109: char & String::operator[](int offset)
110: {
111: if (offset > itsLen)
112: return itsString[itsLen-1];
113: else
114: return itsString[offset];
115: }
116:
117: // constant offset operator for use
118: // on const objects (see copy constructor!)
119: char String::operator[](int offset) const
120: {
121: if (offset > itsLen)
122: return itsString[itsLen-1];
123: else
124: return itsString[offset];
125: }
126:
127: // creates a new string by adding current
128: // string to rhs
129: String String::operator+(const String& rhs)
130: {
131: int totalLen = itsLen + rhs.GetLen();
132: int i,j;
133: String temp(totalLen);
134: for ( i = 0; i<itsLen; i++)
135: temp[i] = itsString[i];
136: for ( j = 0; j<rhs.GetLen(); j++, i++)
137: temp[i] = rhs[j];
138: temp[totalLen]='\0';
139: return temp;
140: }
141:
142: void String::operator+=(const String& rhs)
143: {
144: unsigned short rhsLen
= rhs.GetLen();
145: unsigned short totalLen
= itsLen + rhsLen;
146: String temp(totalLen);
147: for (int i = 0; i<itsLen; i++)
148: temp[i] = itsString[i];
149: for (int j = 0; j<rhs.GetLen(); j++, i++)
150: temp[i] = rhs[i-itsLen];
151: temp[totalLen]='\0';
152: *this = temp;
153: }
154:
155: // int String::ConstructorCount = 0;
156:
157: ostream&
operator<<( ostream&
theStream,String& theString)
158: {
159: theStream
<< theString.GetString();
160: return theStream;
161: }
162:
163: class pAddress
164: {
165: public:
166: pAddress(SERVICE theService,
167: const String& theAddress,
168: const String& theDisplay):
169: itsService(theService),
170: itsAddressString(theAddress),
171: itsDisplayString(theDisplay)
172: {}
173: // pAddress(String, String);
174: // pAddress();
175: // pAddress
(const pAddress&);
176: ~pAddress(){}
177: friend ostream&
operator<<( ostream&
theStream, pAddress& theAddress);
178: String& GetDisplayString() { return itsDisplayString; }
179: private:
180: SERVICE itsService;
181: String itsAddressString;
182: String itsDisplayString;
183: };
184:
185: ostream&
operator<<( ostream&
theStream, pAddress& theAddress)
186: {
187: theStream
<< theAddress.GetDisplayString();
188: return theStream;
189: }
190:
191: class PostMasterMessage
192: {
193: public:
194: // PostMasterMessage();
195:
196: PostMasterMessage(const pAddress& Sender,
197: const pAddress&
Recipient,
198: const String& Subject,
199: const pDate&
creationDate);
200:
201: // other constructors here
202: // remember to include copy constructor
203: // as well as constructor from storage
204: // and constructor from wire format
205: // Also include constructors from other
formats
206: ~PostMasterMessage(){}
207:
208: void Edit(); //
invokes editor on this message
209:
210: pAddress& GetSender()
const { return itsSender; }
211: pAddress& GetRecipient()
const { return itsRecipient; }
212: String& GetSubject() const { return itsSubject; }
213: // void SetSender(pAddress& );
214: // other member accessors
215:
216: // operator methods here, including
operator equals
217: // and conversion routines to turn PostMaster messages
218: // into messages of other formats.
219:
220: private:
221: pAddress itsSender;
222: pAddress itsRecipient;
223: String itsSubject;
224: pDate itsCreationDate;
225: pDate itsLastModDate;
226: pDate itsReceiptDate;
227: pDate itsFirstReadDate;
228: pDate itsLastReadDate;
229: };
230:
231: PostMasterMessage::PostMasterMessage(
232: const pAddress&
Sender,
233: const pAddress&
Recipient,
234: const String& Subject,
235: const pDate&
creationDate):
236: itsSender(Sender),
237: itsRecipient(Recipient),
238: itsSubject(Subject),
239: itsCreationDate(creationDate),
240: itsLastModDate(creationDate),
241: itsFirstReadDate(0),
242: itsLastReadDate(0)
243: {
244: cout << "Post Master Message created. \n";
245: }
246:
247: void PostMasterMessage::Edit()
248: {
249: cout
<< "PostMasterMessage edit function
called\n";
250: }
251:
252:
253: int main()
254: {
255: pAddress Sender(PostMaster, "jliberty@PostMaster", "Jesse Liberty");
256: pAddress Recipient(PostMaster, "sl@PostMaster","Stacey Liberty");
257: PostMasterMessage
PostMessage(Sender, Recipient, "Saying Hello", 0);
258: cout <<
"Message review... \n";
259: cout <<
"From:\t\t" << PostMessage.GetSender()
<< endl;
260: cout <<
"To:\t\t" << PostMessage.GetRecipient()
<< endl;
261: cout <<
"Subject:\t" << PostMessage.GetSubject()
<< endl;
262: return 0;
263: }
--------------------------------------------------------------------------------
WARNING: If
you receive a "can't convert" error, remove the const keywords from
lines 210-212.
--------------------------------------------------------------------------------
Output:
Post Master Message created.
Message
review...
From: Jesse Liberty
To: Stacey Liberty
Subject: Saying Hello
Analysis:
On line 4, pDate is type-defined to be an unsigned
long. It is not uncommon for dates to be stored as a long integer (typically as
the number of seconds since an arbitrary starting date such as
On line 5,
an enumerated constant, SERVICE, is defined to allow the Address objects to
keep track of what type of address they are, including PostMaster,
CompuServe, and so forth.
Lines 7-161
represent the interface to and implementation of String, along much the same
lines as you have seen in previous chapters. The String class is used for a
number of member variables in all of the Message classes and various other
classes used by messages, and as such it is pivotal in your program. A full and
robust String class will be essential to making your Message classes complete.
On lines
162-183, the pAddress class is declared. This
represents only the fundamental functionality of this class, and you would
expect to flesh this out once your program is better understood. These objects
represent essential components in every message: both the sender's address and
that of the recipient. A fully functional pAddress
object will be able to handle forwarding messages, replies, and so forth.
It is the pAddress object's job to keep track of the display string
as well as the internal routing string for its service. One open question for
your design is whether there should be one pAddress
object or if this should be subclassed for each
service type. For now, the service is tracked as an enumerated constant, which
is a member variable of each pAddress object.
Lines
191-229 show the interface to the PostMasterMessage
class. In this particular listing, this class stands on its own, but very soon
you'll want to make this part of its inheritance hierarchy. When you do
redesign this to inherit from Message, some of the member variables may move
into the base classes, and some of the member functions may become overrides of
base class methods.
A variety
of other constructors, accessor functions, and other
member functions will be required to make this class fully functional. Note
that what this listing illustrates is that your class does not have to be 100
percent complete before you can write a simple driver program to test some of
your assumptions.
On lines
247-250, the Edit() function is "stubbed
out" in just enough detail to indicate where the editing functionality will
be put once this class is fully operational.
Lines
253-263 represent the driver program. Currently this program does nothing more
than exercise a few of the accessor functions and the
operator<< overload. Nonetheless, this gives you the starting point for
experimenting with PostMasterMessages and a framework
within which you can modify these classes and examine the impact.
Today you
saw a review of how to bring together many of the elements of C++ syntax and
apply them to object-oriented analysis, design, and programming. The
development cycle is not a linear progression from clean analysis through
design and culminating in programming; rather, it is cyclical. The first phase
is typically analysis of the problem, with the results of that analysis forming
the basis for the preliminary design.
Once a
preliminary design is complete, programming can begin, but the lessons learned
during the programming phase are fed back into the analysis and design. As
programming progresses, testing and then debugging begins. The cycle continues,
never really ending; although discrete points are reached, at which time it is
appropriate to ship the product.
When analyzing a large problem from an object-oriented
viewpoint, the interacting parts of the problem are often the objects of the
preliminary design. The designer keeps an eye out for process, hoping to
encapsulate discrete activities into objects whenever possible.
A class
hierarchy must be designed, and fundamental relationships among the interacting
parts must be established. The preliminary design is not meant to be final, and
functionality will migrate among objects as the design solidifies.
It is a
principal goal of object-oriented analysis to hide as much of the data and
implementation as possible and to build discrete objects that have a narrow and
well-defined interface. The clients of your object should not need to
understand the implementation details of how they fulfill
their responsibilities.
Q. In what
way is object-oriented analysis and design fundamentally different from other
approaches?
A. Prior to
the development of these object-oriented techniques, analysts and programmers
tended to think of programs as functions that acted on data. Object-oriented
programming focuses on the integrated data and functionality as discrete units
that have both knowledge (data) and capabilities (functions). Procedural
programs, on the other hand, focus on functions and how they act on data. It
has been said that Pascal and C programs are collections of procedures and C++
programs are collections of classes.
Q. Is
object-oriented programming finally the silver bullet that will solve all
programming problems?
A. No, it
was never intended to be. For large, complex problems, however, object-oriented
analysis, design, and programming can provide the programmer with tools to
manage enormous complexity in ways that were previously impossible.
Q. Is C++
the perfect object-oriented language?
A. C++ has
a number of advantages and disadvantages when compared with alternative
object-oriented programming languages, but it has one killer advantage above
and beyond all others: It is the single most popular object-oriented
programming language on the face of the Earth. Frankly, most programmers don't
decide to program in C++ after an exhaustive analysis of the alternative
object-oriented programming languages; they go where the action is, and in the
1990s the action is with C++. There are good reasons for that; C++ has a lot to
offer, but this book exists, and I'd wager you are reading it, because C++ is
the development language of choice at so many corporations.
Q. Where
can I learn more about object-oriented analysis and design?
A. Day 21
offers some further suggestions, but it is my personal opinion that there are a
number of terrific object-oriented analysis and design books available. My
personal favorites include:
Object-Oriented
Analysis and Design with Applications by Grady Booch
(2nd Edition). Published by Benjamin/Cummings Publishing
Company, Inc., ISBN: 0-8053-5340-2.
Object-Oriented
Modeling and Design by Rumbaugh,
Blaha, Premerlani, Eddy,
and Lorenson. Published by
Prentice-Hall, ISBN 0-13-629841-9.
There are
many other excellent alternatives. Also be sure to join one of the newsgroups
or conferences on the Internet, Interchange, or one of the alternative dial-up
services.
The
Workshop provides quiz questions to help you solidify your understanding of the
material covered and exercises to provide you with experience in using what
you've learned. Try to answer the quiz and exercise questions before checking
the answers in Appendix D, and make sure you understand the answers before
continuing to the next chapter.
1. What is
the difference between object-oriented programming and procedural programming?
2. To what
does "event-driven" refer?
3. What are
the stages in the development cycle?
4. What is
a rooted hierarchy?
5. What is
a driver program?
6. What is
encapsulation?
1. Suppose
you had to simulate the intersection of
What kinds
of objects should be modeled in the simulation? What
would the classes be for the simulation?
2. Suppose
the intersection from Exercise 1 were in a suburb of
Locals, who continue to drive through intersections after the light
turns red; tourists, who drive slowly and cautiously (in a rental car,
typically); and taxis, who have a wide variation of driving patterns, depending
on the kinds of passengers in the cabs.
Also,
Finally,
How do
these considerations change the model?
3. You are
asked to design a group scheduler. The software allows you to arrange meetings
among individuals or groups and to reserve a limited number of conference
rooms. Identify the principal subsystems.
4. Design
and show the interfaces to the classes in the room reservation portion of the
program discussed in Exercise 3.