D Programming Language Tutorial
Ali Çehreli



İngilizce Kaynaklar

Diğer



Exceptions

Unexpected situations are parts of programs: user mistakes, programming errors, changes in the program environment, etc. Programs must be written in ways to avoid producing incorrect results when faced with such exceptional conditions.

Some of these conditions may be severe enough to stop the execution of the program. For example, a required piece of information may be missing or invalid, or a device may not be functioning correctly. The exception handling mechanism of D helps with stopping program execution when necessary, and to recover from the unexpected situations when possible.

As an example of a severe condition, we can think of passing an unknown operator to a function that knows only the four arithmetic operators, as we have seen in the exercises of the previous chapter:

    switch (operator) {

    case "+":
        writeln(first + second);
        break;

    case "-":
        writeln(first - second);
        break;

    case "x":
        writeln(first * second);
        break;

    case "/":
        writeln(first / second);
        break;

    default:
        throw new Exception(format("Invalid operator: %s", operator));
    }

The switch statement above does not know what to do with operators that are not listed on the case statements; so throws an exception.

There are many examples of thrown exceptions in Phobos. For example, to!int, which can be used to convert a string representation of an integer to an int value throws an exception when that representation is not valid:

import std.conv;

void main()
{
    const int value = to!int("hello");
}

The program terminates with an exception that is thrown by to!int:

std.conv.ConvException@std/conv.d(38): std.conv(1157): Can't
convert value `hello' of type const(char)[] to type int

std.conv.ConvException at the beginning of the message is the type of the thrown exception object. We can tell from the name that the type is ConvException that is defined in the std.conv module.

The throw statement to throw exceptions

We've seen the throw statement both in the examples above and in the previous chapters.

throw throws an exception object and this terminates the current operation of the program. The expressions and statements that are written after the throw statement are not executed. This behavior is according to the nature of exceptions: they must be thrown when the program cannot continue with its current task.

Conversely, if the program could continue then the situation would not warrant throwing an exception. In such cases the function would find a way and continue.

The exception types Exception and Error

Only the types that are inherited from the Throwable class can be thrown. Throwable is almost never used directly in programs. The types that are actually thrown are types that are inherited from Exception or Error, which themselves are the types that are inherited from Throwable. For example, all of the exceptions that Phobos throws are inherited from either Exception or Error.

Error represents unrecoverable conditions and is not recommended to be caught. For that reason, most of the exceptions that a program throws are the types that are inherited from Exception. (Note: Inheritance is a topic related to classes. We will see classes in a later chapter.)

Exception objects are constructed with a string value that represents an error message. You may find it easy to create this message with the format() function from the std.string module:

import std.stdio;
import std.random;
import std.string;

int[] randomDiceValues(int count)
{
    if (count < 0) {
        throw new Exception(
            format("Invalid dice count: %s", count));
    }

    int[] values;

    foreach (i; 0 .. count) {
        values ~= uniform(1, 7);
    }

    return values;
}

void main()
{
    writeln(randomDiceValues(-5));
}
object.Exception...: Invalid dice count: -5

In most cases, instead of creating an exception object explicitly by new and throwing it explicitly by throw, the enforce() function is called. For example, the equivalent of the error check above is the following enforce() call:

    enforce(count >= 0, format("Invalid dice count: %s", count));

We will see the differences between enforce() and assert() in a later chapter.

Thrown exception terminates all scopes

We have seen that the program execution starts from the main function and branches into other functions from there. This layered execution of going deeper into functions and eventually returning from them can be seen as the branches of a tree.

For example, main() may call a function named makeOmelet, which in turn may call another function named prepareAll, which in turn may call another function named prepareEggs, etc. Assuming that the arrows indicate function calls, the branching of such a program can be shown as in the following function call tree:

main
  |
  +--▶ makeOmelet
  |      |
  |      +--▶ prepareAll
  |      |          |
  |      |          +-▶ prepareEggs
  |      |          +-▶ prepareButter
  |      |          +-▶ preparePan
  |      |
  |      +--▶ cookEggs
  |      +--▶ cleanAll
  |
  +--▶ eatOmelet

The following program demonstrates the branching above by using different levels of indentation in its output. The program doesn't do anything useful other than producing an output suitable to our purposes:

import std.stdio;

void indent(in int level)
{
    foreach (i; 0 .. level * 2) {
        write(' ');
    }
}

void entering(in char[] functionName, in int level)
{
    indent(level);
    writeln("▶ ", functionName, "'s first line");
}

void exiting(in char[] functionName, in int level)
{
    indent(level);
    writeln("◁ ", functionName, "'s last line");
}

void main()
{
    entering("main", 0);
    makeOmelet();
    eatOmelet();
    exiting("main", 0);
}

void makeOmelet()
{
    entering("makeOmelet", 1);
    prepareAll();
    cookEggs();
    cleanAll();
    exiting("makeOmelet", 1);
}

void eatOmelet()
{
    entering("eatOmelet", 1);
    exiting("eatOmelet", 1);
}

void prepareAll()
{
    entering("prepareAll", 2);
    prepareEggs();
    prepareButter();
    preparePan();
    exiting("prepareAll", 2);
}

void cookEggs()
{
    entering("cookEggs", 2);
    exiting("cookEggs", 2);
}

void cleanAll()
{
    entering("cleanAll", 2);
    exiting("cleanAll", 2);
}

void prepareEggs()
{
    entering("prepareEggs", 3);
    exiting("prepareEggs", 3);
}

void prepareButter()
{
    entering("prepareButter", 3);
    exiting("prepareButter", 3);
}

void preparePan()
{
    entering("preparePan", 3);
    exiting("preparePan", 3);
}

The program produces the following output:

▶ main, first line
  ▶ makeOmelet, first line
    ▶ prepareAll, first line
      ▶ prepareEggs, first line
      ◁ prepareEggs, last line
      ▶ prepareButter, first line
      ◁ prepareButter, last line
      ▶ preparePan, first line
      ◁ preparePan, last line
    ◁ prepareAll, last line
    ▶ cookEggs, first line
    ◁ cookEggs, last line
    ▶ cleanAll, first line
    ◁ cleanAll, last line
  ◁ makeOmelet, last line
  ▶ eatOmelet, first line
  ◁ eatOmelet, last line
◁ main, last line

The functions entering and exiting are used to indicate the first and last lines of functions with the help of the and characters. The program starts with the first line of main(), branches down to other functions, and finally ends with the last line of main.

Let's modify the prepareEggs function to take the number of eggs as a parameter. Since certain values of this parameter would be an error, let's have this function throw an exception when the number of eggs is less than one:

void prepareEggs(int count)
{
    entering("prepareEggs", 3);

    if (count < 1) {
        throw new Exception(
            format("Cannot take %s eggs from the fridge", count));
    }

    exiting("prepareEggs", 3);
}

In order to be able to compile the program, we must modify other lines of the program to be compatible with this change. The number of eggs to take out of the fridge can be handed down from function to function, starting with main(). The parts of the program that need to change are the following. The invalid value of -8 is intentional to show how the output of the program will be different from the previous output when an exception is thrown:

// ...

void main()
{
    entering("main", 0);
    makeOmelet(-8);
    eatOmelet();
    exiting("main", 0);
}

void makeOmelet(int eggCount)
{
    entering("makeOmelet", 1);
    prepareAll(eggCount);
    cookEggs();
    cleanAll();
    exiting("makeOmelet", 1);
}

// ...

void prepareAll(int eggCount)
{
    entering("prepareAll", 2);
    prepareEggs(eggCount);
    prepareButter();
    preparePan();
    exiting("prepareAll", 2);
}

// ...

When we start the program now, we see that the lines that used to be printed after the point where the exception is thrown are missing:

▶ main, first line
  ▶ makeOmelet, first line
    ▶ prepareAll, first line
      ▶ prepareEggs, first line
object.Exception: Cannot take -8 eggs from the fridge

When the exception is thrown, the program execution exits the prepareEggs, prepareAll, makeOmelet and main() functions in that order, from the bottom level to the top level. No additional steps are executed as the program exits these functions.

The rationale for such a drastic termination is that a failure in a lower level function would mean that the higher level functions that needed its successful completion should also be considered as failed.

The exception object that is thrown from a lower level function is transferred to the higher level functions one level at a time and causes the program to finally exit the main() function. The path that the exception takes can be shown as the red path in the following tree:

  
  |
main ◀---+
  |      |
  +--▶ makeOmelet ◀-+
  |      |          |
  |      +--▶ prepareAll ◀------------+
  |      |          |                 |exception
  |      |          +-▶ prepareEggs X-+
  |      |          +-▶ prepareButter
  |      |          +-▶ preparePan
  |      |
  |      +--▶ cookEggs
  |      +--▶ cleanAll
  |
  +--▶ eatOmelet

The point of the exception mechanism is precisely this behavior of exiting all of the layers of function calls right away. Sometimes it makes sense to catch the thrown exception to find a way to continue the execution of the program. I will introduce the catch keyword below.

When to use throw

Use throw in situations when it is not possible to continue. For example, a function that reads the number of students from a file may throw an exception if this information is not available or incorrect.

On the other hand, if the problem is caused by some user action like entering invalid data, it may make more sense to validate the data instead of throwing an exception. Displaying an error message and asking the user to re-enter the data is more appropriate in many cases.

The try-catch statement to catch exceptions

As we've seen above, a thrown exception causes the program execution to exit all functions and this finally terminates the whole program.

The exception object can be caught by a try-catch statement at any point on its path as it exits the functions. The try-catch statement models the phrase "try to do something, and catch exceptions that may be thrown." Here is the syntax of try-catch:

    try {
        // the code block that is being executed, where an
        // exception may be thrown

    } catch (an_exception_type) {
        // expressions to execute if an exception of this
        // type is caught

    } catch (another_exception_type) {
        // expressions to execute if an exception of this
        // other type is caught

    // ... more catch blocks as appropriate ...

    } finally {
        // expressions to execute regardless of whether an
        // exception is thrown
    }

Let's start with the following program that does not use a try-catch statement at this state. The program reads the value of a die from a file and prints it to the standard output:

import std.stdio;

int readDieFromFile()
{
    auto file = File("the_file_that_contains_the_value", "r");

    int die;
    file.readf(" %s", &die);

    return die;
}

void main()
{
    const int die = readDieFromFile();

    writeln("Die value: ", die);
}

Note that the readDieFromFile function is written in a way that ignores error conditions, expecting that the file and the value that it contains are available. In other words, the function is dealing only with its own task instead of paying attention to error conditions. This is a benefit of exceptions: many functions can be written in ways that focus on their actual tasks, rather than focusing on error conditions.

Let's start the program when the_file_that_contains_the_value is missing:

std.exception.ErrnoException@std/stdio.d(286): Cannot open
file `the_file_that_contains_the_value' in mode `r' (No such
file or directory)

An exception of type ErrnoException is thrown and the program terminates without printing "Die value: ".

Let's add an intermediate function to the program that calls readDieFromFile from within a try block and let's have main() call this new function:

import std.stdio;

int readDieFromFile()
{
    auto file = File("the_file_that_contains_the_value", "r");

    int die;
    file.readf(" %s", &die);

    return die;
}

int tryReadingFromFile()
{
    int die;

    try {
        die = readDieFromFile();

    } catch (std.exception.ErrnoException exc) {
        writeln("(Could not read from file; assuming 1)");
        die = 1;
    }

    return die;
}

void main()
{
    const int die = tryReadingFromFile();

    writeln("Die value: ", die);
}

When we start the program again when the_file_that_contains_the_value is still missing, this time the program does not terminate with an exception:

(Could not read from file; assuming 1)
Die value: 1

The new program tries executing readDieFromFile in a try block. If that block executes successfully, the function ends normally with the return die; statement. If the execution of the try block ends with the specified std.exception.ErrnoException, then the program execution enters the catch block.

The following is a summary of events when the program is started when the file is missing:

catch is to catch thrown exceptions presumably to find a way to continue executing the program.

As another example, let's go back to the omelet program and add a try-catch statement to its main() function:

void main()
{
    entering("main", 0);

    try {
        makeOmelet(-8);
        eatOmelet();

    } catch (Exception exc) {
        write("Failed to eat omelet: ");
        writeln('"', exc.msg, '"');
        writeln("Shall eat at the neighbor's...");
    }

    exiting("main", 0);
}

(Note: The .msg property will be explained below.)

That try block contains two lines of code. Any exception thrown from either of those lines would be caught by the catch block.

▶ main, first line
  ▶ makeOmelet, first line
    ▶ prepareAll, first line
      ▶ prepareEggs, first line
Failed to eat omelet: "Cannot take -8 eggs from the fridge"
Shall eat at the neighbor's...
◁ main, last line

As can be seen from the output, the program doesn't terminate because of the thrown exception anymore. It recovers from the error condition and continues executing normally till the end of the main() function.

catch blocks are looked up sequentially

The type Exception, which we have used so far in the examples is a general exception type. This type merely specifies that an error occurred in the program. It also contains a message that can explain the error further, but it does not contain information about the type of the error.

ConvException and ErrnoException that we have seen earlier in this chapter are more specific exception types: the former is about a conversion error, and the latter is about a system error. Like many other exception types in Phobos and as their names suggest, ConvException and ErrnoException are both inherited from the Exception class.

Exception and its sibling Error are further inherited from Throwable, the most general exception type.

Although possible, it is not recommended to catch objects of type Error and objects of types that are inherited from Error. Since it is more general than Error, it is not recommended to catch Throwable either. What should normally be caught are the types that are under the Exception hierarchy, including Exception itself.

           Throwable (not recommended to catch)
             ↗   ↖
    Exception     Error (not recommended to catch)
     ↗    ↖        ↗    ↖
   ...    ...    ...    ...

Note: I will explain the hierarchy representations later in the Inheritance chapter. The tree above indicates that Throwable is the most general and Exception and Error are more specific.

It is possible to catch exception objects of a particular type. For example, it is possible to catch an ErrnoException object specifically to detect and handle a system error.

Exceptions are caught only if they match a type that is specified in a catch block. For example, a catch block that is trying to catch a SpecialExceptionType would not catch an ErrnoException.

The type of the exception object that is thrown during the execution of a try block is matched to the types that are specified by the catch blocks, in the order in which the catch blocks are written. If the type of the object matches the type of the catch block, then the exception is considered to be caught by that catch block, and the code that is within that block is executed. Once a match is found, the remaining catch blocks are ignored.

Because the catch blocks are matched in order from the first to the last, the catch blocks must be ordered from the most specific exception types to the most general exception types. Accordingly, and if it makes sense to catch that type of exceptions, the Exception type must be specified at the last catch block.

For example, a try-catch statement that is trying to catch several specific types of exceptions about student records must order the catch blocks from the most specific to the most general as in the following code:

    try {
        // operations about student records that may throw ...

    } catch (StudentIdDigitException exc) {

        // an exception that is specifically about errors with
        // the digits of student ids

    } catch (StudentIdException exc) {

        // a more general exception about student ids but not
        // necessarily about their digits

    } catch (StudentRecordException exc) {

        // even more general exception about student records

    } catch (Exception exc) {

        // the most general exception that may not be related
        // to student records

    }
The finally block

finally is an optional block of the try-catch statement. It includes expressions that should be executed regardless of whether an exception is thrown or not.

To see how finally works, let's look at a program that throws an exception 50% of the time:

import std.stdio;
import std.random;

void throwsHalfTheTime()
{
    if (uniform(0, 2) == 1) {
        throw new Exception("the error message");
    }
}

void foo()
{
    writeln("the first line of foo()");

    try {
        writeln("the first line of the try block");
        throwsHalfTheTime();
        writeln("the last line of the try block");

    // ... there may be one or more catch blocks here ...

    } finally {
        writeln("the body of the finally block");
    }

    writeln("the last line of foo()");
}

void main()
{
    foo();
}

The output of the program is the following when the function does not throw:

the first line of foo()
the first line of the try block
the last line of the try block
the body of the finally block
the last line of foo()

The output of the program is the following when the function does throw:

the first line of foo()
the first line of the try block
the body of the finally block
object.Exception@deneme.d: the error message

As can be seen, although "the last line of the try block" and "the last line of foo()" are not printed, the content of the finally block is still executed when an exception is thrown.

When to use the try-catch statement

The try-catch statement is useful to catch exceptions to do something special about them.

For that reason, the try-catch statement should be used only when there is something special to be done. Do not catch exceptions otherwise and leave them to higher level functions that may want to catch them.

Exception properties

The information that is automatically printed on the output when the program terminates due to an exception is available as properties of exception objects as well. These properties are provided by the Throwable interface:

We saw that finally blocks are executed when leaving scopes due to exceptions as well. (As we will see in later chapters, the same is true for scope statements and destructors as well.)

Naturally, such code blocks can throw exceptions as well. Exceptions that are thrown when leaving scopes due to an already thrown exception are called collateral exceptions. Both the main exception and the collateral exceptions are elements of a linked list data structure, where every exception object is accessible through the .next property of the previous exception object. The value of the .next property of the last exception is null. (We will see null in a later chapter.)

There are three exceptions that are thrown in the example below: The main exception that is thrown in foo() and the two collateral exceptions that are thrown in the finally blocks of foo() and bar(). The program accesses the collateral exceptions through the .next properties.

Some of the concepts that are used in this program will be explained in later chapters. For example, the continuation condition of the for loop that consists solely of exc means as long as exc is not null.

import std.stdio;

void foo()
{
    try {
        throw new Exception("Exception thrown in foo");

    } finally {
        throw new Exception(
            "Exception thrown in foo's finally block");
    }
}

void bar()
{
    try {
        foo();

    } finally {
        throw new Exception(
            "Exception thrown in bar's finally block");
    }
}

void main()
{
    try {
        bar();

    } catch (Exception caughtException) {

        for (Throwable exc = caughtException;
             exc;    // ← Meaning: as long as not 'null'
             exc = exc.next) {

            writefln("error message: %s", exc.msg);
            writefln("source file  : %s", exc.file);
            writefln("source line  : %s", exc.line);
            writeln();
        }
    }
}

The output:

error message: Exception thrown in foo
source file  : deneme.d
source line  : 6

error message: Exception thrown in foo's finally block
source file  : deneme.d
source line  : 9

error message: Exception thrown in bar's finally block
source file  : deneme.d
source line  : 20
Types of errors

We have seen how useful the exception mechanism is. It enables both the lower and higher level operations to be aborted right away, instead of the program continuing with incorrect or missing data, or behaving in any other incorrect way.

This does not mean that every error condition warrants throwing an exception. There may be better things to do depending on the types of errors.

User errors

Some of the errors are caused by the user. As we have seen above, the user may have entered a string like "hello" even though the program has been expecting a number. It may be more appropriate to display an error message and ask the user to enter appropriate data again.

Even so, it may be fine to accept and use the data directly without validating the data up front; as long as the code that uses the data would throw anyway. What is important is to be able to notify the user why the data is not suitable.

For example, let's look at a program that takes a file name from the user. There are at least two ways of dealing with potentially invalid file names:

Programmer errors

Some errors are caused by programmer mistakes. For example the programmer may think that a function that has just been written will always be called with a value greater than or equal zero, and this may be true according to the design of the program. The function having still been called with a value less than zero would indicate either a mistake in the design of the program or in the implementation of that design. Both of these can be thought of as programming errors.

It is more appropriate to use assert instead of the exception mechanism for errors that are caused by programmer mistakes. (Note: We will cover assert in a later chapter.)

void processMenuSelection(int selection)
{
    assert(selection >= 0);
    // ...
}

void main()
{
    processMenuSelection(-1);
}

The program terminates with an assert failure:

core.exception.AssertError@deneme.d(3): Assertion failure

assert validates program state and prints the file name and line number of the validation if it fails. The message above indicates that the assertion at line 3 of deneme.d has failed.

Unexpected situations

For unexpected situations that are outside of the two general cases above, it is still appropriate to throw exceptions. If the program cannot continue its execution, there is nothing else to do but to throw.

It is up to the higher layer functions that call this function to decide what to do with thrown exceptions. They may catch the exceptions that we throw to remedy the situation.

Summary