Teach Yourself Visual C++ 6 in 21 Days
- D -
Understanding and Exception Handling
by Jon Bates
Using Exceptions
An exception is an object that holds details about something that has gone
wrong. The clever thing about exception handling is that you can create an exception
when something goes wrong in a low-level function and have it automatically bubble
back up to a calling function that can deal with all such exceptions in one place.
Running Code and Catching the Errors
The system automatically detects certain error conditions and generates exceptions
for them. If you don't deal with them in your application, they will bubble back
out of your code and be handled by Windows's own exception-catching mechanisms. If
you want to see this in action, just add the following two lines to any of your code
and run it:
CDC* pNullDC = 0;
pNullDC->SetPixel(0,0,0);
The first line declares a device context pointer pNullDC and sets it to point
the memory address to zero (which isn't a good place for any object to be). Obviously
there isn't a valid object at this address, so when the following SetPixel() function
is called, the system tries to find the object at address zero. The memory management
hardware and software know that the program has no right being at this memory address
and raise a memory access violation exception.
If you run these lines of code from outside the Visual C++ debugger, you'll see
a dialog box familiar to all Windows users, as shown in Figure D.1.
FIGURE D.1. The
Windows memory access violation Exception dialog box.
However, if you run the application from the Visual C++ debugger, the debugger
first catches the exception for you and displays the Developer Studio dialog box
instead, as shown in Figure D.2.
FIGURE D.2. The
Developer Studio-handled memory access violation exception.
Memory access violations are very severe exceptions that will crash your program
without any chance to catch them. There are many less severe exceptions, such as
the file-handling exception CFileException. This exception is thrown when erroneous
file operations occur, such as attempting to seek to the beginning of an unopened
file:
CFile fileNotOpen;
fileNotOpen.SeekToBegin();
This results in a system-generated dialog box (see Figure D.3). If you click OK,
your program will continue as usual.
FIGURE D.3. The
Windows default dialog box for a file exception.
Rather than letting the system catch the exception, you can catch the exception
and deal with it yourself in a more graceful manner. To do this, you must use the
C++ try and catch keywords. You can use these by defining a block of code to try;
then, when a specified exception is raised, an action is defined in the catch block
(see Listing D.1).
LISTING D.1. LST29_1.CPP--USING A try AND catch BLOCK TO CATCH CFileExceptions.
1: // ** Try a block of code
2: try
3: {
4: CFile fileNotOpen;
5: fileNotOpen.SeekToBegin();
6: }
7: catch(CFileException* e) // Catch File Exceptions
8: {
9: // ** Check the cause of the exception
10: if (e->m_cause == CFileException::fileNotFound)
11: AfxMessageBox("Oops, forgot to open the file!");
12: e->Delete();
13: }
In Listing D.1, a try block is defined around the file operations at lines 4 and
5. If these lines don't raise an exception, the code will continue as normal. However,
if a CFileException is raised, it will be caught by the catch keyword in line 7,
and the variable e will point to the new exception. The CFileException object has
an m_cause code that defines exactly why the exception was raised. This is checked
on line 10, and if this was a CFileException::fileNotFound code, the message box
on line 11 is displayed.
Notice that the Delete() member function of the CException class (the base class
of CFileException) in line 12 will delete the exception for you. You must ensure
that exceptions are always deleted when you are finished with them.
The try block can include calls to other functions and could be used to catch
any specified exceptions raised in a large portion of the application, as shown in
Listing D.2.
LISTING D.2. LST29_2.CPP--A try BLOCK CAN INCLUDE MANY FUNCTION CALLS AND
CALLS FROM THOSE FUNCTIONS.
1: try
2: {
3: // ... Lots of code
4: DoLotsOfFileHandling();
5: // ... More code
6: EvenMoreFileHandling();
7: // ... And more Code
8: }
9: catch(CFileException* e) // Catch File Exceptions
10: {
11: // ** Check the cause of the exception
12: if (e->m_cause == CFileException::fileNotFound)
13: AfxMessageBox("Oops, forgot to open the file!");
14: e->Delete();
15: }
In Listing D.2 the DoLotsOfFileHandling() function on line 4 could implement some
file handling itself, as well as calls to other functions, as with EvenMoreFileHandling()
on line 6. Should a file exception arise through any of these file operations, the
exception will bubble back so that the same catch block will be executed in lines
9 through 13 with e pointing to the CFileException object. Finally the exception
is deleted in line 14.
If you want to catch two different exceptions from the try block, you can add
catch blocks to handle each different exception, as shown in Listing D.3.
LISTING D.3. LST29_3.CPP--HANDLING TWO DIFFERENT EXCEPTIONS WITH THE EXCEPTION-
SPECIFIC catch BLOCKS.
1: try
2: {
3: // ** This file operation is ok
4: CMemFile fileMemFile;
5: fileMemFile.SeekToBegin();
6:
7: // ** But you can't have two different system
8: // ** resources with the same name.
9: CMutex mutex1(0,"Same Name");
10: CSemaphore semaphore1(1,1,"Same Name");
11: }
12: catch(CFileException* e) // Catch File Exceptions
13: {
14: if (e->m_cause == CFileException::fileNotFound)
15: AfxMessageBox("Oops, forgot to open the file!");
16: e->Delete();
17: }
18: catch(CResourceException* e)
Â// Catch Resource Exceptions
19: {
20: // ** Report the Resource exception error
21: AfxMessageBox("Oops, duplicate resource name");
22: e->Delete();
23: }
In Listing D.3, the memory file is automatically created in line 4, so line 5
won't cause a file exception. However, naming two different system resources (a mutex
and a semaphore) with the same name does cause a CResourceException in line 10 that
is then caught by the second catch block in line 18, which displays the message box
in line 21. If you try this code yourself, remember to add an #include <afxmt.h>
line for the CMutex and CSemaphore definitions.
If you want to do a blanket exception catch, you don't need to have a catch block
for each type of exception; instead, you can catch the CException base class exception
from which all the other more specific exception classes are derived (see Listing
D.4).
LISTING D.4. LST29_4.CPP--USING THE catch BLOCK TO CATCH ALL TYPES OF EXCEPTIONS.
1: // ** Try a block of code
2: try
3: {
4: // ** Lots of code ...
5: }
6: catch(CException* e)
7: {
8: // ** General Error message, details in e
9: AfxMessageBox("Oops, something went wrong!");
10: e->Delete();
11: }
Notice that on line 6 the CException base class is used rather than a specific
exception such as CFileException or CResourceException. You can test which type of
exception was raised using the IsKindOf() function inside the catch block. For example,
to test whether a file exception has been raised, you might use the following lines:
if (e->IsKindOf(RUNTIME_CLASS(CFileException)))
AfxMessageBox("File Exception");
Because exceptions are derived from CObject, they support the MFC runtime class
information. By using DECLARE_DYNAMIC and IMPLEMENT_DYNAMIC, the class information
is bundled into the derived exception object so that the IsKindOf() function can
be used to check for a specific class type. The RUNTIME_CLASS macro turns class names
into a pointer to a CRuntimeClass object for the specified object. The IsKindOf()
member function will then return TRUE if the object being called is of that runtime
class.
The "MFC Exception Types" section later in this chapter covers how you
can determine exception-specific information from each type of MFC exception caught
in a catch block.
FREEING SYSTEM RESOURCES
This exception-catching principle becomes very useful when you want to detect
and handle errors arising from large portions of code. It can save coding lots of
individual error-checking lines, but you must still free up any system resources
that you've allocated in lines before the exception was raised.
Throwing Exceptions
You can throw exceptions yourself from code embedded in any enclosing try block
when an error condition arises. The corresponding catch block will then handle the
exception. Or you can throw the exception again from within a catch section to a
higher-level catch section, enclosing the first.
Several AfxThrow... functions will automatically generate and throw various types
of MFC exceptions up to the next catch level, such as AfxThrowFileException() or
AfxThrowMemoryException(). These are covered in detail in the "MFC Exception
Types" section. However, these functions create a new instance of a specific
CException-derived object for you--using the C++ new keyword and then the
throw keyword to raise an exception, as shown in the code fragment in Listing D.5.
LISTING D.5. LST29_5.CPP--RAISING AN EXCEPTION WITH THE throw KEYWORD.
1: try
2: {
3: DoSomeFileHandling();
4: }
5: catch(CFileException* e)
6: {
7: e->ReportError();
8: e->Delete();
9: }
10:
11: return TRUE;
12: }
13:
14: BOOL bSomeThingWentWrong = TRUE;
15:
16: void CExceptionalDlg::DoSomeFileHandling()
17: {
18: // ** ... File handling functions
19: if (bSomeThingWentWrong == TRUE)
20: {
21: CFileException* pException =
22: new CFileException(CFileException::generic);
23: throw(pException);
24: }
25:
26: // ** ... Yet More file handling
27: }
In Listing D.5 the try block encloses a call to the DoSomeFileHandling() function
in line 16. This function may implement some file-handling procedures and raises
an exception when the error condition on line 19 is found to be TRUE. Line 22 creates
a new CFileException object passing the CFileException::generic flag to its constructor
and then throws the new object in line 23 to be caught by the catch section in line
5.
This process of newing a CException-derived object and then using the throw keyword
is the basis of the exception-raising mechanism. The specific details indicating
the cause of the error can be attached to the CException object, or extra information
can be added by deriving a class from the CException base class and adding extra
variables to store more specific information.
Your catch block can then determine whether the error is too severe to be handled
at that level. If so, you might want to throw the exception out to a higher-level
enclosing catch block. You can use the throw keyword (with no parameters) from within
the catch block to rethrow the exception before you delete it. Instead of deleting
the exception, you could rethrow it to a higher level catch block by changing the
catch block shown in Listing D.5 to add the throw keyword like this:
e->ReportError();
throw;
Then after reporting the error, the exception will be thrown again for an enclosing
try block to catch. If you haven't implemented this nesting, the overall MFC outside
the catch block will catch it. You can use this nesting mechanism to determine the
error severity and implement appropriate recovery mechanisms at various hierarchical
levels in your program.
Deleting Exceptions
As you've seen, you are fundamentally responsible for new-ing exceptions and must
also delete these objects when you've handled them. If you delete one of the MFC
exceptions, you shouldn't use the normal C++ delete keyword (as you've seen) because
the exception might be a global object or a heap object. Instead, the CException
base class has a Delete() function that first checks to see whether the exception
should be deleted. The creator of the exception can specify whether the exception
should be deleted or not by passing TRUE into the b_AutoDelete parameter of the CException
class's constructor (which is the only parameter).
MFC Exception Types
The Microsoft Foundation Classes have several predefined CException-derived classes
that are used during different types of MFC operations. You've already seen CFileException
and CResourceException in use. The following section covers each of these various
classes and how it is raised in more detail. Each class is based on the CException
class and extends the functionality of CException for different types of exception
handling. You can also derive your own exception classes from CException, and a generic
CUserException is used for user-oriented application exceptions.
Using the CException Base Class
CException itself has a constructor that takes an AutoDelete flag as discussed
earlier, and is defined like this:
CException( BOOL b_AutoDelete );
If you new a CException or derived class, you should ensure that this is set to
TRUE so that it will be deleted with the C++ delete keyword. Otherwise, a global
or stack-based exception should pass TRUE so that it is deleted only when it goes
out of scope (at the end of a function or program that declares it).
The base class contains the Delete()function and two error-reporting functions.
GetErrorMessage() can be used to store the error message into a predefined buffer
and specify the ID of a help message to show the user context-specific help pertinent
to the error. Its first parameter is the address of a destination buffer to hold
the associated error message. The second parameter specifies the maximum size of
the buffer so that messages stored in the buffer don't over-spill outside the buffer
area. The third optional parameter can specify the context help ID as a UINT value.
You might use this function to help format an error message more relevant to your
application:
char msg[512];
e->GetErrorMessage(msg,sizeof(msg));
CString strMsg;
strMsg.Format("The following error occurred in
ÂMyApp: %s",msg);
AfxMessageBox(strMsg);
The sizeof() C++ operator in the GetErrorMessage() function returns the size of
an array or variable, so if the msg array is changed, you don't have to change any
other code. The message is then formatted into the strMsg CString object and displayed
in a message box.
The ReportError()function displays the message text directly in the familiar exception
message box and would be used from the catch block:
e->ReportError();
Using the Memory Exception
The CMemoryException is raised automatically when a C++ new keyword fails.
You can also raise it yourself using the AfxThrowMemoryException(); function. The
meaning of this exception is exclusively that Windows can't allocate any more memory
via its GlobalAlloc() or other memory allocation functions. This is a pretty dire
situation for any program; you would usually handle this exception by writing code
that lets your program die gracefully, freeing up memory and system resources as
it goes. There are rare cases in which you could take recovery action if you had
a large block of memory allocated and could free it without too much detriment to
the users' activities.
Due to the exclusivity of this exception, no other cause attributes or specific
functions extend the CException class's functionality.
You can watch new automatically raise a CMemoryException with these lines:
MEMORYSTATUS mem;
GlobalMemoryStatus(&mem);
BYTE* pBig = new BYTE[mem.dwAvailVirtual+1];
The mem.dwAvailVirtual structure member of MEMORYSTATUS will hold the total available
memory after the GlobalMemoryStatus() function retrieves the details. The new on
the next line requests one more byte than it could possibly have, thus throwing the
exception.
Using the Resource Exceptions
CResourceException is thrown in many places where system resources are compromised,
as you saw in the mutex and semaphore example in Listing D.3. If you want to throw
these exceptions yourself, use the corresponding AfxThrowResourceException() function.
Windows can't find or allocate the requested resource and doesn't give any more
specific guidance; hence it has no other functions or attributes.
Using the File and Archive Exceptions
You already looked at CFileException in Listing D.5. This is probably one of the
more sophisticated MFC exceptions because of the number of things that can go wrong
with file access. You can throw these yourself using the AfxThrowFileException()
function, which takes three parameters, one mandatory and the other two optional.
The first mandatory parameter, cause, is a cause code for the exception. This will
be placed in the file exception's m_cause member variable for interrogation in a
catch block.
Table D.1 shows a list of the various cause codes. The second parameter, lOsError,
can be used to specify an operating system error code to be placed in the file exception's
m_lOsError member variable. This long value can help clarify an error in more detail
by drawing on the operating system's own list of file access errors. The third parameter,
strFileName, is placed into the file exception's m_strFileName member string variable
to indicate the filename of the file that was being accessed when the error occurred.
TABLE D.1. THE CFileException m_cause CODES.
Cause Code |
Meaning |
CFileException::none |
There was no error. |
CFileException::generic |
No error code specified. |
CFileException::tooManyOpenFiles |
Too many concurrently open files. |
CFileException::fileNotFound |
Can't find the specified file. |
CFileException::badPath |
The path name specified is invalid. |
CFileException::invalidFile |
An attempt was made to use an invalid file handle. |
CFileException::badSeek |
The seek operation failed. |
CFileException::endOfFile |
The end of the file was reached. |
CFileException::diskFull |
There is no spare disk space. |
CFileException::hardIO |
A hardware error occurred. |
CFileException::accessDenied |
Permissions deny access to the file. |
CFileException::directoryFull |
The directory has too many files and can't add another. |
CFileException::removeCurrentDir |
Can't remove the current working directory. |
CFileException::lockViolation |
Can't lock an already locked region of the file. |
CFileException::sharingViolation |
A shared region is locked or can't be shared. |
There is also a ThrowOsError()static member function that throws and configures
a file exception based on an operating system error code. You must pass ThrowOsError()
the operating system error code as its first parameter and an optional filename as
its second parameter. Another member function, ThrowErrno(), does the same thing
but uses the UNIX-style errno error codes as its only parameter (from the Errno.h
header file). Because these are static functions, you would use them with static
scope to raise exceptions with lines like this:
CFileException::ThrowOsError(ERROR_BAD_PATHNAME);
Â// Invalid Path
CFileException::ThrowErrno (ENOSPC); // Disk Full
Another static member function, OsErrorToException(), automatically converts between
operating system error codes and CFileException cause codes. By passing an OS error
code, it will return the corresponding cause code. A corresponding function ErrnoToException()
does the same when passed an errno error code.
When using archives with the CArchive class, you normally handle both CFileExceptions
and CArchiveException cases in conjunction: Many of the CArchive operations are tied
in with their underlying file and file access functions. CArchiveException has its
own m_cause member to hold archive-specific cause codes, as shown in Table D.2. You
can raise archive exceptions yourself through the AfxThrowArchiveException() function,
which requires a cause code parameter and a lpszArchiveName string pointer for the
archive object throwing the exception.
TABLE D.2. THE CArchiveException m_cause CODE VALUES.
Cause Code |
Meaning |
CArchiveException::none |
No error occurred. |
CArchiveException::generic |
The specific cause wasn't specified. |
CArchiveException::badSchema |
The wrong version of an object was read. |
CArchiveException::badClass |
The class of the object being read was unexpected. |
CArchiveException::badIndex |
The file format is invalid. |
CArchiveException::readOnly |
Attempt to write on an archive opened for loading. |
CArchiveException::writeOnly |
Attempt to read on an archive opened for storing. |
CArchiveException::endOfFile |
The end of the file was reached unexpectedly while reading. |
Using the Database Exceptions
There are two database exception classes: CDBException is used for ODBC-based
database access, and CDAOException is used for DAO-based database access. You can
throw these exceptions yourself with the AfxThrowDBException() function, which needs
three parameters. The first, nRetCode, specifies one of a huge number of database
return codes to define the type of error (you should look in the ODBC documentation
for these). The second parameter, pDB, is a pointer to the database associated with
the exception, and the third parameter, hstmt, is an ODBC handle to the SQL statement
object that was executed, causing the exception.
The RETCODE type is available from the CDBException object via its m_nRetCode
member. You can also access a human-readable piece of error text from the m_strError
member string and the error text returned from the ODBC driver itself in the m_strStateNativeOrigin
member.
The CDAOException class has a corresponding AfxThrowDaoException() function that
can throw the DAO exception objects. This function needs just two optional parameters.
The first, nAfxDaoError, is a DAO-specific error code that indicates problems with
DAO itself (see Table D.3). The second parameter is an OLE SCODE value that is the
return code from a DAO-based OLE call (see the section "Using OLE Exceptions"
for a definition of SCODEs).
TABLE D.3. DAO COMPONENT-SPECIFIC ERROR CODES FROM nAfxDaoError.
Error Code |
Meaning |
NO_AFX_DAO_ERROR |
The exception was due to a DAO-specific problem; you should check the supplied CDaoErrorInfo
object and SCODE value. |
AFX_DAO_ERROR_ENGINE_INITIALIZATION |
The Microsoft Jet Engine database engine failed during initialization. |
AFX_DAO_ERROR_DFX_BIND |
A DAO record set field exchange address is invalid. |
AFX_DAO_ERROR_OBJECT_NOT_OPEN
|
The queried table hasn't been opened.
|
The CDAOException class has three member attributes: m_scode, which holds an asso-ciated
OLE SCODE value with the attempted operation; or S_OK, if the OLE operation was successful.
The m_nAfxDaoError member holds one of the DAO-specific values from Table D.3. The
m_pErrorInfo is a pointer to a CDaoErrorInfo structure that holds an error code,
descriptive error strings, and a help context ID that is defined like this:
struct CDaoErrorInfo
{
long m_lErrorCode;
CString m_strSource;
CString m_strDescription;
CString m_strHelpFile;
long m_lHelpContext;
};
By interrogating this structure, you can find most of the specific database error
details pertaining to the DAO exception.
DAO exceptions can describe more than one error at a time, so you can use the
GetErrorCount() member function to find out how many are being referenced. These
other errors can then be obtained by passing the GetErrorInfo() function a zero-based
index to the specific error. After calling GetErrorInfo() with a specific index in
the range returned by the GetErrorCount() function, m_pErrorInfo will be updated
to point to the specified object, and thus you can retrieve those values.
Using OLE Exceptions
There are two types of OLE exceptions, represented by two classes: the COleException
class, which is normally used for server-side or OLE-specific operations, and the
COleDispatchException class, which is used when dealing with client-side IDispatch-based
operations such as calling ActiveX object functions.
The simpler of the two is the COleException class, which can be generated by calling
the AfxThrowOleException() function passing an OLE SCODE value. An OLE SCODE is a
32-bit error code that is used to represent any kind of error arising from an OLE
function.
This value would probably arise from the return code of a function call to a function
on one of the interfaces of an OLE object. This SCODE value will then be stored in
the exception's m_sc member for analysis from within a catch block.
There is also a Process() static member function that is passed an exception object
and will turn that exception into an SCODE value to represent that exception.
The COleDispatchException class is used in conjunction with OLE IDispatch interfaces
and is thrown by the AfxThrowOleDispatchException() function. This function has two
forms, both with two mandatory parameters and an optional parameter. The first parameter
for both forms is a wCode WORD value that is an application-specific error code.
The second parameter is an lpszDescription string pointer in one form, or nDescriptionID
for a UINT resource code; both types represent either a verbal string or a string
resource code for a verbal string describing the error. The last optional parameter
is a help context ID.
These values are then available as member variables of the COleDispatchException
object via m_wCode, m_strDescription, and m_dwHelpContext. If a help context is specified
and a help file available, the framework will fill in an m_strHelpFile string identifying
the help file. The name of the application producing the error can also be sought
from the m_strSource attribute.
If you raise this exception from an OLE object such as an ActiveX control, Visual
Basic or any other application using the control or object will display these exception
details.
Using the Not Supported Exception
The CNotSupportedException class represents exception objects that are generated
when an unsupported MFC, operating system, or user-application-specific feature is
requested. If you want to raise this exception, use AfxThrowNotSupportedException(),
which doesn't required any parameters. There are also no extended members or functions
associated with this exception--it just means unsupported.
Using the User Exception
You can use the CUserException class to generate application-specific exception
objects. You might want to do this when your program is interacting with the user
to halt the process should she choose a certain option. For example, when you are
using the AppWizard, you can press Esc at any time to cancel the whole process. Microsoft
might have used CUserException to do this by detecting the Esc key and then raising
a user exception object.
This exception can be raised by a call to the AfxThrowUserException() function
and then caught in the usual try and catch blocks. There are some places in the MFC
where this exception is raised, such as during dialog box validation or if the file
is too big for an edit view.
Generating Your Own Custom Exception Classes
You can derive your own exception classes from CException and add your specific
extended functionality. Listing D.6 shows the class definition for such a custom
exception class that extends the normal functionality by adding a m_strMessage CString
variable to the exception, enabling you to specify your own message when constructing
the exception.
LISTING D.6. LST29_6.CPP--CLASS DEFINITION FOR CCustomException IMPLEMENTED
IN CustomException.h.
1: // ** CustomException.h
2: // ** Header file for CCustomException
3:
4: class CCustomException : public CException
5: {
6: DECLARE_DYNAMIC(CCustomException);
7:
8: public:
9: CCustomException(CString strMessage);
10:
11: CString m_strMessage;
12: };
In Listing D.6 the class is implemented in its own CustomException.h header file
and derives from CException in line 4. The DECLARE_DYNAMIC macro in line 6 supplies
the MFC CObject-derived runtime class information required for you to decide the
exception type in a catch-all catch block. The constructor definition in line 9 takes
a CString strMessage parameter to let you create the custom exception with the message
that will be stored in the m_strMessage CString variable declared in line 11.
The corresponding CCustomException class implementation is shown in Listing D.7.
LISTING D.7. LST29_7.CPP--IMPLEMENTATION OF THE CCustomException CLASS.
1: // ** CustomException.cpp
2: // ** Implementation for CCustomException exception
3:
4: #include "stdafx.h"
5: #include "CustomException.h"
6:
7: IMPLEMENT_DYNAMIC(CCustomException,CException);
8:
9: CCustomException::CCustomException(CString strMessage)
10: : m_strMessage(strMessage)
11: {
12: }
In Listing D.7 the usual header files are included, and the IMPLEMENT_DYNAMIC
macro is used in line 7 to implement the MFC runtime class information functions.
The constructor in line 9 takes the strMessage parameters and initializes the m_strMessage
member variable with this string value in line 10.
You can then use the custom exception class in your application, as shown in Listing
D.8.
LISTING D.8. LST29_8.CPP--USING THE NEW CCustomException CLASS.
1: try
2: {
3: // ** Something goes wrong
4: CCustomException* pCustomEx =
5: new CCustomException("My custom error occured");
6: throw(pCustomEx);
7: }
8: catch(CCustomException* e)
9: {
10: // ** Access the extended m_strMessage string
11: AfxMessageBox(e->m_strMessage);
12: e->Delete();
13: }
In Listing D.8 a new CCustomException object is created with the application-specific
error text in lines 4 and 5 and is thrown in line 6. This is then caught by the catch
keyword in line 8 and the custom information used by the message box in line 11.
The exception is then deleted in line 12.
If you try this, remember that the implementation code must also have an #include
for the CustomException.h header file to retrieve the class definition like this:
#include "CustomException.h"
© Copyright, Macmillan Computer Publishing. All
rights reserved.
|