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(int level) { foreach (i; 0 .. level * 2) { write(' '); } } void entering(string functionName, int level) { indent(level); writeln("▶ ", functionName, "'s first line"); } void exiting(string functionName, 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:
import std.string; // ... 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 highlighted path in the following tree:
▲ │ │ main ◀───────────┐ │ │ │ │ ├──▶ makeOmelet ◀─────┐ │ │ │ │ │ │ │ ├──▶ prepareAll ◀──────────┐ │ │ │ │ │ │ │ │ │ │ ├─▶ prepareEggs X thrown exception │ │ ├─▶ 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; import std.exception; 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:
- like in the previous program, a
std.exception.ErrnoException
object is thrown (byFile()
, not by our code), - this exception is caught by
catch
, - the value of 1 is assumed during the normal execution of the
catch
block, - and the program continues its normal operations.
catch
is to catch thrown exceptions 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("Will eat at 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" Will eat at 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 considered 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:
-
.file
: The source file where the exception was thrown from -
.line
: The line number where the exception was thrown from -
.msg
: The error message -
.info
: The state of the program stack when the exception was thrown -
.next
: The next collateral exception
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 exc is 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
Kinds 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 kinds 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:
- Validating the data before use: We can determine whether the file with the given name exists by calling
exists()
of thestd.file
module:if (exists(fileName)) { // yes, the file exists } else { // no, the file doesn't exist }
This gives us the chance to be able to open the data only if it exists. Unfortunately, it is still possible that the file cannot be opened even if
exists()
returnstrue
, if for example another process on the system deletes or renames the file before this program actually opens it.For that reason, the following method may be more useful.
- Using the data without first validating it: We can assume that the data is valid and start using it right away, because
File
would throw an exception if the file cannot be opened anyway.import std.stdio; import std.exception; import std.string; void useTheFile(string fileName) { auto file = File(fileName, "r"); // ... } string read_string(string prompt) { write(prompt, ": "); return strip(readln()); } void main() { bool is_fileUsed = false; while (!is_fileUsed) { try { useTheFile( read_string("Please enter a file name")); /* If we are at this line, it means that * useTheFile() function has been completed * successfully. This indicates that the file * name was valid. * * We can now set the value of the loop flag to * terminate the while loop. */ is_fileUsed = true; writeln("The file has been used successfully"); } catch (std.exception.ErrnoException exc) { stderr.writeln("This file could not be opened"); } } }
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 to 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(2): 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 2 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
- When faced with a user error either warn the user right away or ensure that an exception is thrown; the exception may be thrown anyway by another function when using incorrect data, or you may throw directly.
- Use
assert
to validate program logic and implementation. (Note:assert
will be explained in a later chapter.) - When in doubt, throw an exception with
throw
orenforce()
. (Note:enforce()
will be explained in a later chapter.) - Catch exceptions if and only if you can do something useful about that exception. Otherwise, do not encapsulate code with a
try-catch
statement; instead, leave the exceptions to higher layers of the code that may do something about them. - Order the
catch
blocks from the most specific to the most general. - Put the expressions that must always be executed when leaving a scope, in
finally
blocks.