Exception handling explained

posted by jgauffin 7 months ago

This article explains what exception handling is and how it differs from traditional error handling with error codes. The article does not get into usage or exception classes, but only to explain their purpose.

Let's start with error codes.

A brief introduction to error codes

From the dawn of programming, error codes have been used to deal with errors in applications. An error code is used to indicate if the executed function was successful or not.

Here is a simple example:

public bool SaveDefaultAccount(string userName, string accountName)
{
    int userId = GetUserIdFromName(userName);
    if (userId == -1)
        return false;

    int accountId = GetAccountFromName(accountName);
    if (accountId == -1)
        return false;

    return StoreDefaultAccount(userId, accountId);
}

The example illustrates that when you use error codes, it is the API consumer that must abort if something fails. You might, but how about the rest of the team, or those who maintained the application before you? It is like you would retire the entire police force and expect all citizens to behave and be good law abiding citizens.

One developer could have been lazy and just written (or refactored) the code as:

public bool SaveDefaultAccount(string userName, string accountName)
{
    int userId = GetUserIdFromName("Arne");
    int accountId = GetAccountFromName("Savings account");
    return StoreDefaultAccount(userId, accountId);
}

The problem is that the code looks perfectly legal, but silently ignores errors. If you for instance mistakenly add a white space after all account names on the account selection page, the code above starts to fail silently. What's worse is that you will not know about it until code dependent upon the default account starts to fail, and that can be after a while. Tracking down that subsequent error can be challenging, primarily if the default account is set in different ways.

Error codes are easy to get started with and can be quite powerful. Even modern languages like Go-lang uses errors instead of exceptions. Here is a GO snippet:

    f, err := os.Open("filename.ext")
    if err != nil {
        log.Fatal(err)
    }

While errors are easy to understand and use, they have three significant drawbacks:

They do not convey context

This section is for programming languages that uses error codes only.*

For instance, error code 2 means "File Not Found" in Windows. There is no way to state which file nor other information that might help you to understand why the file was missing. Error codes are just that. Codes that indicate a specific error, without context or clues.

The problem with that is that it is hard to understand why or under what circumstances that the error occurred. The Windows API solves this by introducing a method called GetLastError() which is used to get more information about the error.

OFSTRUCT buffer;
HFILE hFile = OpenFile("d:\\sample.txt", &buffer, OF_READ);
if (hFile == HFILE_ERROR)
{
    // all this is required to get the error message.
    // you typically add it to a separate function

    // Get the error code, as hFile only indicates an error
    // but not which one.
    var errorCode = GetLastError();

    // Get the generic error message which
    // represents the above error code.
    LPVOID lpMsgBuf;
    LPVOID lpDisplayBuf;
    FormatMessage(
        FORMAT_MESSAGE_ALLOCATE_BUFFER | 
        FORMAT_MESSAGE_FROM_SYSTEM |
        FORMAT_MESSAGE_IGNORE_INSERTS,
        NULL,
        errorCode,
        MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
        (LPTSTR) &lpMsgBuf,
        0, NULL );
}

My point is that just an error code is rarely used when you want to solve the error.

The burden is on the function caller

It is safe to say that all applications have errors, it is exceptionally rare (pun intended) that errors can be ignored. Ignoring errors might seem to work, but sooner or later consequential problems will surface. It will be much harder to find the root cause since the found error is just a consequence of the first one. Any kind of "fix" is just a workaround which clutters the code base but does not prevent the root cause from happening again.

It is crucial that all errors are handled in your code. When you use error codes, it is so easy to ignore or forget errors. Had a tight deadline? Wrestled with an obscure bug or incomprehensible requirements? Those situations make it so easy to take a shortcut. If not all developers on your team have the same discipline, errors will get ignored.

Enter exceptions

Exceptions are for exceptional situations. If something didn't go as expected, you got an exceptional situation.

Sounds easy, huh?

But what does that mean?

An exceptional example

If you expect that something can fail, you should guard against that situation.

Here is an example:

Let's say that you love shopping the newest and hottest technical gadgets. When the new gadget is released, you want to be first.

blackfriday.jpg

Scenario 1

If you are like most of us, you can probably not just go on a shopping spree. You need to make sure that you have enough money.

  1. Check your bank account.
  2. Go shopping.
  3. Pay

Scenario 2

However, if you are fortunate enough to have plenty of money, you can go shopping directly:

  1. Go shopping.
  2. Pay

The difference

In the first scenario, we have a known issue that we need to deal with: A money limit. Therefore, we always need to check that we have enough money. In the second scenario, we should have enough money.

What happens if someone has hacked us in scenario 2:

  1. Someone hacked us
  2. Go shopping
  3. Pay <-- Failed, no money

In that case, we got an exception, since it is a case that shouldn't happen since we expect to have enough money.

That is an exceptional situation.

Why can't we always write the code like in scenario 1?

First, it's about communicating intent. We mostly get a set of business requirements that we should fulfill. They tell us what to expect when implementing different use cases. If we go and add many checks for things that might, but should not, happen we lose the connection between our use cases and the code. It will be hard to tell the intent of the code, which in turn lead to assumptions and in the end decreased code quality.

Second, if we add many checks we are coding workarounds as the real problem is that our account was hacked, not that we could not pay. By adding checks and abort instead of paying we are hiding that fact.

What exceptions are

Exceptions are for situations that wasn't considered when defining what the application should do. When writing a messaging library for message queues you expect to receive complete messages, but when you write one for TCP you expect to receive partial messages. What's exceptional in one case doesn't necessarily have to be exceptional in another.

If you would open a file there are several errors that can happen. Non-existent directory, file is missing, access denied, partial file, etc. All those errors are known to most programmers, so they are not exceptions, right?

Wrong. In most cases, they are exceptions. Because you typically do expect that a file exists and that it's complete and readable. Well, if you are writing a data forensics application, most of those errors are expected and should be dealt with (and therefore not exceptional cases).

Exceptions exist to prevent your application from doing something stupid.

It's crucial for you to understand that. Don't think of exceptions as something you can use to control your application when coding. Think of exceptions as a mechanism to guard against your application doing something wrong/unexpected.

Exceptions exist to help you fix problems in the future

Since exceptions are not a flow control mechanism, they do add little value when executing your code (previously described point excluded).

However, writing informative exception messages makes it much easier to correct future bugs, since they add context to the error. Always try to do so, your future self will thank you for it.

Code example

Let's take the same code as was found in the beginning of this article, but changed to use exception handling instead.

public void SaveDefaultAccount(string userName, string accountName)
{
    int userId = GetUserIdFromName("Arne");
    int accountId = GetAccountFromName("Savings account");
    StoreDefaultAccount(userId, accountId);
}

As you can see, the method now returns void as it does not need to indicate that everything went successfully. Nor does it need to validate error codes from the called methods. One can safely assume that an exception abort the processing if the expected result cannot be guaranteed.

In fact, in most cases, we do not have to care if exceptions are thrown at all. Remember, exceptions are used to communicate that something unexpected happened. If we cannot predict it, how on earth could we be able to handle the exception?

Let's look at the StoreDefaultAccount method. The most important thing to understand is that the method says that a default account should be stored successfully. Since that is the method promise, we must throw an exception every time we find something that would prevent the default account from being stored.

public void StoreDefaultAccount(int userId, int accountId)
{
    if (userId <= 0)
        throw new ArgumentOutOfRangeException(nameof(userId), userId,
            "A valid user id must be specified.");
    if (accountId <= 0)
        throw new ArgumentOutOfRangeException(nameof(accountId), accountId,
            "A valid account id must be specified.");

    var account = _accountRepository.GetById(accountId);
    if (account.OwnerId != accountId)
        throw new InvalidOperationException($"User {userId} do not own account {accountId}.");

    var user = _userRepository.GetById(userId);
    user.DefaultAccount = accountId;
    _userRepository.Update(user);
}

The first two exceptions are used to mitigate errors like parse errors or invalid data.

The third exception is for a business rule. We may only use the user's own accounts as default accounts.

_accountRepository.GetById(accountId); will in turn throw an exception if the given accountId do not exist in the database since the method name states that an account should be fetched.

Summary

The purpose of exceptions is not to allow you to take different actions depending on if something failed or not. i.e. they are not a flow control mechanism. Instead, exceptions are used to make sure that your application delivers the expected result (or die trying).

With that in mind, I hope that you find them as useful as we do. With the right mindset (and using exceptions) you can save much time since you do not have to track down why your database has a lot of data inconsistencies (which leads to bugs later).