Friday, December 18, 2009

A Simple Example of Exception Processing




I l@ve RuBoard


A Simple Example of Exception Processing


Usually, processing algorithms use C++ flow control statements, mostly the if or switch statements, to separate normal processing of data from processing of erroneous or faulty data. For multistep algorithms, the segment of source code for main algorithm and for exceptional conditions are written in alternative branches of the same source code, and this often makes code harder to read梩he main line is lost in the multitude of exceptional and rare cases.



When something goes wrong in a function, the function might not know what to do about the error. Aborting the program might be a good solution in many situations, for example, trying to push an item onto a system stack that turns out to be full. On the other hand, aborting the program might not release resources held by the program, such as opened files or database locks.



Another approach is setting an error code or returning an error value for the caller to check and to take a recovery action if this action is possible. For example, when the client code makes an attempt to pop an item from an empty stack, returning an error value might be an attractive alternative. However, this is not always feasible. If any return value of the given type is legal for the pop function, there may be no special value to be returned to signal an exceptional condition to the caller.



When this approach is feasible, it imposes the obligation to always check for possible errors in client code. This increases the overall size of the program, results in awkward client code, and causes slower execution. In general, this approach is error prone. Some functions, such as C++ constructors, do not have returned values, and this approach cannot be used.



Setting a global variable, e.g., errno, to indicate an error does not work for concurrent programs. It is also hard to implement consistently for sequential programs because it requires that the client code diligently check the value of the global variable. These checks clog the client code and make it more difficult to understand.



By using library functions such as setjmp and longjmp, the program can transfer control to an action that would release external resources and perform error recovery, but this would unwind the stack without calling destructors for objects created on the stack before these functions were called. Hence, the resources held by these objects might not be properly released.



Let us consider a simple example and review the issues that exception processing techniques should resolve. Listing 18.1 shows a program that interactively prompts the user for the values of a numerator and denominator of the fraction and computes and prints the fraction's value. To compute the result, the program uses two server functions, inverse() and fraction(). The first function returns the inverse of its argument. It is called by the second function, fraction(), which multiplies its first argument by the value returned by inverse().



This is, of course, a somewhat convoluted design for such a simple computational problem. A simpler design would not let me demonstrate different options of exception processing. A more complex problem would justify a more complex design but would drown me (and you) in a mire of details.



In this problem, the zero value of the denominator is not acceptable and is rejected with a message. A negative value of the denominator is not acceptable either: If the fraction is negative, it is the numerator that should be made negative. The negative denominator value should be rejected with a message that also prints the offending value.



The input loop continues until the user enters a letter instead of numeric input data. The cout statement returns zero, and the break statement terminates the loop. The sample output of the program is shown in Figure 18-1.





Figure 18-1. Output for program in Listing 18.1.










Example 18.1. Example of a program with error processing in the client code.


#include <iostream>
using namespace std;

inline void inverse(long value, double& answer)
{ answer = 1.0/value; } // answer = 1/value

inline void fraction (long numer,long denom,double& result)
{ inverse(denom, result); // result = 1.0 / denom
result = numer * result; } // result = numer/denom

int main()
{
while (true) // infinite loop
{ long numer, denom; double ans; // numerator and denominator
cout << "Enter numerator and positive\n"
<< "denominator (any letter to quit): ";
if ((cin >> numer >> denom) == 0) break; // enter data
if (denom > 0) { // correct input
fraction(numer,denom,ans); // compute result
cout << "Value of the fraction: " << ans <<"\n\n";
}
else if (denom == 0) // invalid result
cout << "\nZero denominator is not allowed\n\n";
else
cout << "\nNegative denominator: " << denom <<"\n\n"; }
return 0;
}


In this example, both exceptional conditions (the zero denominator and the negative denominator) are discovered in the client code, and the errors are processed immediately in the place where they are discovered. Server functions inverse() and fraction() do not have a chance to deal with erroneous input data. This is why they compute their output unconditionally, without a test of the validity of input data.



Error recovery here is done by printing an error message and repeating the request for the next input data. The mainline code (a call to the fraction() server function) here is not separated from error-processing code, but it does not result in serious problems.



Often, an error can be discovered only after some processing, in the server code, far from the place where the error actually originated. Some of these errors could be processed at the place of their discovery, but some might require additional knowledge that might be absent in the server function that discovered the error. In this case, the information about the error should be passed back to the client code for processing and, if possible, recovery. I will model such a situation by moving the test of input data from client code to the server function inverse().




Listing 18.2 demonstrates this approach to error processing. Function inverse() computes the inverse of its argument. If the value of the argument is zero, inverse() uses the DBL_MAX constant (defined in the header file cfloat or float.h) as the inverse value. Then it checks the answer to determine the validity of the result and tells the caller what happened during the call.



If the answer is DBL_MAX, the inverse() function processes the error by printing an error message and returning the zero value to tell the caller about it. If the argument is negative, the inverse() function returns its value梩he client will figure that out and will process the error. Otherwise, inverse() returns 1, and this will tell the caller that the value of the formal argument answer is valid.



Function fraction() evaluates the return value of inverse(). If this value is 1 (the valid result), it computes the value of the fraction. If the returned value is negative (a negative denominator), it passes this value to its own client and sends to the client additional data for error processing (the message to be printed). The client code evaluates the return value of fraction(). If it is 1, the results are valid, and the main function displays the result. If the return value of fraction() is negative, the client code prints this value and the message it received from fraction(). Otherwise, the client code does not do anything because the error (zero denominator) was already processed in inverse(). The result of a sample run of the program in Listing 18.2 is shown in Figure 18-2.





Figure 18-2. Output for program in Listing 18.2.










Example 18.2. Example of a program with errors discovered by the server code.


#include <iostream>
#include <cfloat>
using namespace std;

inline long inverse(long value, double& answer)
{ answer = (value) ? 1.0/value : DBL_MAX;
if (answer==DBL_MAX)
{ cout << "\nZero denominator is not allowed\n\n";
return 0; } // zero denominator
else if (value < 0)
{ return value; } // negative denominator
else
return 1; } // valid denominator

inline long fraction (long n,long d,double& result,char* &msg)
{ long ret = inverse(d, result); // result = 1.0 / d
if (ret == 1) // valid denominator
{ result = n * result; } // result = n / d
if (ret < 0)
msg = "\nNegative denominator: ";
return ret; }

int main()
{
while (true)
{ long numer, denom; double ans; // numerator/denominator
char *msg; long ret; // error information
cout << "Enter numerator and positive\n"
<< "denominator (any letter to quit): ";
if ((cin >> numer >> denom) == 0) break; // enter data
ret = fraction(numer,denom,ans,msg); // compute answer
if (ret == 1) // valid answer
cout << "Value of the fraction: " << ans <<"\n\n";
else if (ret < 0)
cout << msg << ret << "\n\n"; } // negative value
return 0;
}


You see that the separation between the place of discovery of the error and the place of recovery from the error results in a more complex solution. The server functions have extra return values and extra parameters to deal with梥tronger coupling makes different parts of the program more dependent on each other. The client code has to abide by complex conventions on return values (in this example, returning 1 denotes the valid argument value, returning zero or a negative number denotes an invalid argument value) and behave differently for different return values. This makes the client code more complex and requires additional documentation so that client programmers and server programmers use common conventions successfully.



Another problem with this approach is that the server code, in this example, the inverse() and fraction() functions, is involved not only in error discovery but also in communications with the user about the causes of the error. For this simple example of three functions this is probably not a grave problem. In more complex programs, it is important to make sure that each function performs one function (pun intended) only. The function that computes the inverse of its argument should know how to compute the inverse of its argument and should not get into the user interface. The function that is responsible for the user interface should know what to tell the user and should not be involved in other computations. These responsibilities have to be separated.



Yet another problem with this approach is that the components of the user interface are spread all over the code of the program. When the program has to be repackaged into French, Spanish, Russian, or another language, there is no specific place in the program that has to be changed梐ll program source code has to be inspected and modified. This is asking for trouble.




Listing 18.3 represents an attempt to eliminate the last two drawbacks. It also gives you an additional example of using static data members and static member functions. All output strings used by the program are moved into a class, MSG, as a private static array of strings. The class provides a public static function, msg(), whose argument indicates the index of the string to be used. If the index is incorrect, an error message is produced instead of the expected information.



You see that the server functions are not involved in the user interface anymore. The code that analyzes the situation, unfortunately, stays, but there was little that could be done about this. If the code is required to discover an error, the code should test some relevant values, and that makes the code more obscure.



You also see that all the components of the user interface are swept into one place. This facilitates not only the adaptation of the program to other languages but also maintenance of the user interface in general. If a prompt to a user has to be changed, it is only class MSG that changes. If a message has to be added or removed, the static array MSG::data[] is edited, and the number of array components in the MSG::msg() method changes accordingly. To avoid this change, the number of components in the array (defined as local in msg()) can be computed as sizeof(data)/sizeof(char*). Since the value of the number of messages is used only once, keeping it as a literal value is not dangerous.



Notice the elements of the utilization of static data and methods: the keyword static, initialization of data outside the class boundaries, the use of the class name in the initialization statement and in the calls to the static function, the absence of object class MSG in the application, the lack of name conflict between the function MSG::msg(), and a local variable msg in the client code.



The output of this version of the program is the same as the output for two previous versions of the application. This is why it is not shown again.





Example 18.3. Example of extensive communications between the client and the server code.


#include <iostream>
#include <cfloat>
using namespace std;

class MSG {
static char* data []; // internal static data
public:
static char* msg(int n) // public static method
{ if (n<1 || n > 5) // check index validity
return data[0];
else
return data[n]; } // return valid string
} ;

char* MSG::data [] = { "\nBad argument to msg()\n",
"\nZero denominator is not allowed\n\n", // depository of text
"\nNegative denominator: ",
"Enter numerator and positive\n",
"denominator (any letter to quit): ",
"Value of the fraction: "
} ;

inline long inverse(long value, double& answer, char* &msg)
{ answer = (value) ? 1.0/value : DBL_MAX;
if (answer==DBL_MAX)
{ msg = MSG::msg(1);
return 0; } // zero denominator
else if (value < 0)
{ msg = MSG::msg(2);
return value; } // negative denominator
else
return 1; } // valid denominator

inline long fraction (long n,long d,double& result,char* &msg)
{ long ret = inverse(d, result,msg); // result = 1.0 / d
if (ret == 1) // valid denominator
{ result = n * result; } // result = n / d
return ret; }

int main()
{
while (true)
{ long numer, denom; double ans; // numerator/denominator
char *msg; long ret; // error information
cout << MSG::msg(3) << MSG::msg(4); // prompt user for data
if ((cin >> numer >> denom) == 0) break; // enter data
ret = fraction(numer,denom,ans,msg); // compute answer
if (ret == 1)
cout << MSG::msg(5) << ans <<"\n\n"; // valid answer
else if (ret == 0)
cout << msg; // zero denominator
else
cout << msg << ret << "\n\n"; } // negative value
return 0;
}


You see that limiting the task of the inverse() function to error discovery and moving the task of error recovery (in this case, printing a message with data) increases coupling between clients and their servers. In Listing 18.3, function inverse() has an additional parameter, which is passed by its client fraction() to its own client, main(). In the case of the zero denominator, only this fact has to be reported. This information is passed up by the inverse() parameter msg. In the case of the negative denominator, the value of the denominator has to be reported, and inverse() uses both its parameter msg and its return value to communicate with its caller (and its caller's caller).



It is the use of extra parameters, return values, and complex calling conventions that C++ exceptions help to eliminate.







I l@ve RuBoard

No comments: