Many people have wondered why programming is such a difficult thing. In order to answer this question, I would like to reveal some basic facts first.
There is only one relation between two things. But, there are Cn2 relations among ‘n’ things.
Yes, it’s the main reason why software design is so complex. If things are not controlled well, with the software growing in a linear size, the complexity grows in a high order. It is simply infeasible to keep adding more and more resources to adapt to it. To localize logics or segregate relationships is the primary means to solve the issue.
Sounds a basic idea, right? If we carefully review the variety of programming methods available, no matter how sophisticated a program is, most effort that people have ever made is focused on this. And many times, people just forget this fundamental rule later.
The Von Neumann Architecture computer system has another attribute: the static code does not quantify how many running paths it may have, but the possible execution paths are confined by the design. In other words, relationships are not always explicitly written in code, but often occur during runtime. How do we segregate these runtime relationships? The answer is to make your code vertical. Vertical code has high reusability and is well segregated.
OK, bear these in mind. Let’s review some practical questions: first of all, the ‘exception’. An error happened in a method, how should I notify the caller, raise an exception, or return an error code? I was asked this question many times. I know many people insist in raising an exception, because they believe:
- It centralizes the error handling logic.
- In some languages, it enforces the caller to handle it (or explicitly forward it).
- In some cases, it’s the only way to pass the error to the caller.
- It supports multiple types while the error code can only be returned as one type.
The original thinking of the exception mechanism is to centralize the error handling logic. Why should I check the same error code again and again after each time I call the same function? Why should I pass the error code from layer to layer although I do not care about it? All right, that is how the exception was invented.
But, exception is not given without cost. Let us take an example, how many possible execution paths are there in following three line C++ methods?
String EvaluateSalaryAndReturnName( Employee e )
if( e.Title() == "CEO" || e.Salary() > 100000 )
cout << e.First() << " " << e.Last() << " is overpaid" << endl;
return e.First() + " " + e.Last();
This example comes from Herb Sutter’s book “Exceptional C++: 47 Engineering Puzzles, Programming Problems, and Solutions” (Sutter99). According to Herb, there are 23 possible execution paths. In short, besides those regular running paths, each method (no matter if it appears as a ‘constructor’ or as an ‘operator’) possibly throwing exceptions could alter the running path and immediately leave the function. In many cases, this would surprise the programmer and increase the complexity.
Operator overloading and copy constructors blur things more. The intent of supporting them in C++ is to hide those details and make the code look neat and succinct, so that people can focus on real things. But it also hides problems here. (I believe that’s the main reason why Java discards the operator overloading at all). One of the ways to reveal these hiding methods is to compare the source code with the assembly code. It’s my little trick when I am not certain what kind of native code it will finally generate. Over criticizing exceptions in this example is unfair. In old days, without exceptions, most errors were just buried in the code.
However, exceptions do introduce additional complexity as it breaks the regular running path. The complexity accumulates when exceptions are raised or caught by different methods along the thread's invocation stack. This is called exception pollution. It makes writing safe code trickier and less intuitive. Many articles today introduce how to write exception-safe or exception-neutral code. But the fact is, these complexities do not come from the task itself but are introduced by the language feature. To pay such a high price does not sound to be a good deal.
Let us go back to our principle: to localize logics or segregate relationships is the key to reducing the complexity. In general, errors should be handled as close as possible. A close handling gives the program more precise context and more opportunities to recover. More importantly, it segregates the logic.
These are some notes.
First of all, ‘close’ should be considered logical-wise instead of physical-wise. For example, a container class should not handle errors raised by its elements although it seems to directly hold them, because they are logically well segregated. Those who put these elements in and utilize the container are more likely to handle the error properly, because it is more knowledgeable to those elements.
Another exception is for some fatal errors. They are independent and logically equivalent to any part of your code. Distance from the occurred place does not make much difference. These errors are better to be handled centralized, so that you can focus on your real business. For example, if the system runs out of memory, there is not too much you can do, using a centralized runtime error handler would be better than checking it everywhere.
Some trivial errors have a similar aspect. Within the same function, it may occur in many places, but you handle them in the same way. You do not want to check them again and again. You need a centralized error handler in this function.
In all these cases, exception is a good candidate. It splits the error handling logic from the normal running logic.
If we think raising exceptions is a type of ‘handle or forward’ model, then returning an error code is in a ‘handle or ignore’ model. One misconception is that every error must not be ignored, and exceptions help us to capture errors we have missed. This is not true at all. All errors that might affect your logic should be handled as complete as possible during the design phase. If we happen to forget one, you had better ignore it. In most cases, to hand it up likely makes things worse.
In some languages, exceptions must be explicitly handled or be declared in the function signature. In this case, exceptions would not silently fly all over the place. However, exception pollution will still exist if you do not follow the close-handling principle. In addition, the function signature should reflect the function interface but not the implementation, while some exceptions are the result of the implementation. It makes things hard for both the function design and the function modification.
To believe explicit exceptions can prevent from missing error handling is also not true. Only different exception types will be enforced to be handles. Errors using the same exception type but different contents still rely on your cautious.
If an error is directly relevant to the current logic (which I believe, in most cases they will be), then prefer return codes to exceptions. Especially, when the error is an obvious possible result of your function, then it should be returned by error code. For example, a function called
OpenDatabase(), may or may not successfully open a database. To handle such a possible failure is a normal job of the caller. An error code makes both the callee’s and the caller’s life easy.
If the error is not directly relevant to the current logic and likely surprises the caller, then you may choose to raise an exception in an exception explicit language. In an exception implicit language, still prefer return codes, but clearly state all possible errors.
Only when the error is independent from the current logic and you want to handle it centralized, you should prefer exception to error code. This could be a system error, a trivial error from a utility function that may occur in numerous places, or an error from a delegated class.
The last suggestion is for operator overloading and copy constructors. The best practice is not to produce any errors from them at all. If errors are unavoidable, then prefer a regular method. To handle exceptions from operators or invisible constructors looks goofy anyway.