> "The robustness of a program is proportional to the effort invested in handling the cases where things go wrong."
Learning Objectives
- Use try..except blocks to catch and handle exceptions
- Use try..finally blocks for guaranteed cleanup
- Create custom exception classes
- Apply exception handling best practices (don't catch everything, meaningful messages)
- Migrate from IOResult-style error handling to exception-based patterns
In This Chapter
- 19.1 What Are Exceptions?
- 19.2 try..except Basics
- 19.3 Exception Classes
- 19.4 try..finally for Cleanup
- 19.5 Nested try Blocks
- 19.6 Raising Exceptions
- 19.7 Custom Exception Classes
- 19.8 Exception Handling Best Practices
- 19.9 IOResult vs. Exceptions: Comparing Approaches
- 19.10 Project Checkpoint: PennyWise Gets Robust
- 19.11 Summary
- Key Terms Introduced in This Chapter
Chapter 19: Exception Handling: Writing Robust Code with TRY..EXCEPT..FINALLY
"The robustness of a program is proportional to the effort invested in handling the cases where things go wrong." — Niklaus Wirth
Programs fail. Files go missing. Users type "abc" where a number is expected. Network connections drop. Disks fill up. Memory runs out. A database driver returns an error code you have never seen before. This is not pessimism — this is the reality of software that runs on real hardware, reads real files, and serves real users.
Until now, our programs have been optimistic. They assume that every file exists, every user types valid input, and every operation succeeds. That optimism served us well while we were learning syntax and algorithms, but it produces fragile software — software that crashes the moment something unexpected happens.
This chapter teaches you exception handling: Pascal's structured mechanism for detecting, reporting, and recovering from errors. Exceptions let you separate the "happy path" — the code that runs when everything works — from the error-handling code that deals with failures. The result is programs that are both cleaner and more robust: the normal logic reads like a straight line, and the error handling is clearly demarcated in try..except and try..finally blocks.
By the end of this chapter, PennyWise will handle corrupted save files, invalid user input, and missing directories with grace instead of crashes. Rosa will no longer lose her data when she accidentally deletes a file, and Tomas will get helpful error messages instead of cryptic runtime errors.
Exception handling is one of those topics that separates educational programs from real programs. Everything we have written until now has been educational: if an error occurs, the program crashes. That is fine for learning, but it is unacceptable for software that real people use to manage their real finances. This chapter marks the transition from "programs that work when everything goes right" to "programs that work even when things go wrong." The difference is profound, and it is entirely about exception handling.
The discipline of exception handling transfers to every language. Java has try-catch-finally. Python has try-except-finally. C++ has try-catch. C# has try-catch-finally. JavaScript has try-catch-finally. Rust takes a different approach with Result<T, E> types, but the underlying concern — handling failure gracefully — is identical. Learn exception handling well in Pascal, and you have learned it for every language you will ever use. This is Theme 2 at its most practical.
19.1 What Are Exceptions?
An exception is an object that represents an error condition. When something goes wrong — a file is not found, a number conversion fails, an array index is out of range — Pascal creates an exception object and raises (or "throws") it. This immediately interrupts the normal flow of execution. The program jumps out of the current procedure, out of the procedure that called it, out of the procedure that called that, searching back up the call stack for code that knows how to handle the error.
If the program finds a handler — a try..except block — it executes the handler code and continues. If no handler is found anywhere in the call stack, the program terminates with an error message. This is what happens when you see "Runtime error 201 at address 0x00401234" — an exception was raised and nobody caught it.
The Old Way: Error Codes
Before exceptions, Pascal (and most languages) handled errors with return codes and special values. The IOResult function is the classic example:
{$I-} { Turn off automatic I/O checking }
AssignFile(F, 'data.txt');
Reset(F);
if IOResult <> 0 then
begin
WriteLn('Could not open file!');
Exit;
end;
ReadLn(F, Line);
if IOResult <> 0 then
begin
WriteLn('Could not read from file!');
CloseFile(F);
Exit;
end;
CloseFile(F);
{$I+}
This works, but it has serious problems:
- Every operation needs a check. The error-checking code is interleaved with the normal logic, making it hard to read either one.
- It is easy to forget a check. If you forget to test
IOResultafter an operation, the error is silently ignored and the program continues with corrupt or missing data. - Error propagation is manual. If a deeply nested procedure encounters an error, it must return an error code, which the caller must check, which its caller must check, all the way up the stack. At any link in this chain, someone can forget to check.
- Cleanup is fragile. If you open three resources and the second one fails, you must remember to close the first before exiting. The more resources, the more complex the cleanup logic.
The Exception Way
Exceptions solve all four problems:
try
AssignFile(F, 'data.txt');
Reset(F);
ReadLn(F, Line);
CloseFile(F);
except
on E: EInOutError do
WriteLn('File error: ', E.Message);
end;
The normal logic reads as a clean sequence with no error checks. If any operation raises an exception, control jumps immediately to the except block. You cannot accidentally ignore an exception — if you do not handle it, the program terminates rather than silently continuing with bad data. And exceptions propagate automatically up the call stack: if a deeply nested procedure raises an exception, it flies past every intervening caller until it finds a handler.
💡 Analogy: Think of exceptions like a fire alarm. The
IOResultapproach is like checking every room for fire before entering. The exception approach is: walk through the building normally, but if a fire starts anywhere, the alarm goes off and everyone evacuates to the designated assembly point (theexceptblock). The alarm approach is both safer and less intrusive.
Exceptions vs. Assertions
Before we dive into try..except, let us distinguish exceptions from a related concept: assertions. An assertion is a check for a condition that should always be true. If an assertion fails, it means your code has a bug — not that the user did something wrong or the file system failed.
{ Assertion: this should ALWAYS be true — a failure means a bug in our code }
Assert(Index >= 0, 'Index must not be negative');
Assert(List <> nil, 'List was not initialized');
{ Exception: this can happen in normal operation — a failure means bad input or environment }
if Amount < 0 then
raise EInvalidExpenseError.Create('Amount cannot be negative');
Assertions are checked only when compiled with {$C+} (the default) and are typically disabled in release builds for performance. Exceptions are always active and represent conditions the program must handle at runtime. Use assertions for programmer errors (bugs); use exceptions for environmental and user errors.
The Exception Object Lifecycle
When an exception is raised, Pascal creates an exception object on the heap. This object persists until the except block that handles it finishes executing, at which point the runtime frees it automatically. You should never call Free on an exception object inside an except block — the runtime handles that for you.
try
raise Exception.Create('Test error');
except
on E: Exception do
begin
WriteLn(E.Message); { Object is alive here }
{ Do NOT call E.Free — the runtime frees it when this block ends }
end;
{ E is freed here automatically }
end;
If you re-raise an exception with raise, the exception object's lifetime is extended — it is passed to the next handler up the call stack, which is responsible for its eventual deallocation.
19.2 try..except Basics
The try..except block is the fundamental exception-handling construct. It has two parts:
try
{ Code that might raise an exception }
except
{ Code that handles the exception }
end;
If no exception occurs in the try section, the except section is skipped entirely. If an exception occurs, execution immediately leaves the try section and enters the except section. After the except section completes, execution continues after the end.
Catching Specific Exception Types
The on clause lets you catch specific exception types:
try
Val := StrToInt(UserInput);
except
on E: EConvertError do
WriteLn('Invalid number: ', E.Message);
end;
The variable E is the exception object. Every exception has a Message property (a human-readable error description) and a ClassName (like EConvertError). The on E: EConvertError clause catches only EConvertError exceptions — other exception types pass through unhandled.
Multiple Exception Handlers
You can catch different exception types with different handlers:
try
ProcessFile(FileName);
except
on E: EInOutError do
WriteLn('File error: ', E.Message);
on E: EConvertError do
WriteLn('Data format error: ', E.Message);
on E: ERangeError do
WriteLn('Value out of range: ', E.Message);
end;
The handlers are checked in order. The first matching handler executes; the rest are skipped. Because of this, always list more specific exceptions before more general ones.
The Catch-All Handler
A bare except without on catches any exception:
try
DoSomethingRisky;
except
WriteLn('Something went wrong.');
end;
You can also use on E: Exception to catch any exception while still accessing the exception object:
try
DoSomethingRisky;
except
on E: Exception do
WriteLn('Error [', E.ClassName, ']: ', E.Message);
end;
⚠️ Best Practice: Avoid catching all exceptions unless you have a very good reason (such as a top-level error handler in a long-running application). Catching everything hides bugs. If you catch an
EAccessViolation(a null pointer dereference), your program has a serious bug that noexceptblock can fix — it just hides the symptom. Catch specific exceptions that you know how to handle; let everything else propagate.
The else Clause in try..except
After all on clauses, you can add an else that catches any remaining exceptions:
try
DoSomething;
except
on E: EConvertError do
WriteLn('Conversion error');
on E: EInOutError do
WriteLn('File error');
else
WriteLn('Some other error occurred');
end;
The else clause does not give you access to the exception object. If you need the object, use on E: Exception do as the final handler instead. Most programmers prefer the explicit on E: Exception do over else for this reason.
Nested try..except Blocks: Detailed Walkthrough
Before examining exception replacement, let us trace through a realistic nested try..except scenario step by step. Consider a program that processes a list of files:
procedure ProcessAllFiles;
var
i: Integer;
Files: array[0..2] of String = ('data1.csv', 'missing.csv', 'data3.csv');
begin
for i := 0 to 2 do
begin
WriteLn('--- Processing: ', Files[i], ' ---');
try
try
OpenFile(Files[i]);
try
ParseContents;
SaveResults;
except
on E: EConvertError do
WriteLn(' Parse error in ', Files[i], ': ', E.Message);
end;
finally
CloseFileIfOpen;
end;
except
on E: EInOutError do
WriteLn(' File error for ', Files[i], ': ', E.Message);
end;
WriteLn(' Done with ', Files[i]);
end;
end;
Trace for data1.csv (exists, valid data):
1. OpenFile succeeds.
2. ParseContents succeeds.
3. SaveResults succeeds.
4. Inner except is skipped (no exception).
5. finally runs: CloseFileIfOpen closes the file.
6. Outer except is skipped.
7. Prints "Done with data1.csv".
Trace for missing.csv (does not exist):
1. OpenFile raises EInOutError.
2. Inner except does not catch EInOutError (it only catches EConvertError). Exception propagates.
3. finally runs: CloseFileIfOpen handles the case where no file was opened.
4. Outer except catches EInOutError. Prints "File error for missing.csv: ...".
5. Prints "Done with missing.csv".
6. Loop continues with data3.csv.
Trace for data3.csv (exists, but has invalid data):
1. OpenFile succeeds.
2. ParseContents raises EConvertError on a bad line.
3. Inner except catches EConvertError. Prints "Parse error in data3.csv: ...".
4. finally runs: CloseFileIfOpen closes the file.
5. Outer except is skipped (the inner handler already dealt with the exception).
6. Prints "Done with data3.csv".
The key insight: the finally block runs in every scenario. The inner except handles parse errors locally. The outer except catches file errors that the inner handler cannot. And the loop continues after each file, regardless of what happened. This is the power of nested exception blocks — each layer handles errors at the appropriate level of abstraction.
Exception Replacement in Except Handlers
An interesting edge case occurs when an exception is raised inside an except handler. This is legal — the new exception replaces the one being handled:
try
raise Exception.Create('First error');
except
on E: Exception do
begin
WriteLn('Handling: ', E.Message);
raise Exception.Create('Second error'); { Replaces the first }
end;
end;
If a handler raises a new exception, the original exception is freed and the new one propagates. This is sometimes useful for wrapping low-level exceptions in higher-level ones:
try
LoadFromDatabase;
except
on E: ESQLError do
raise EPennyWiseError.CreateFmt('Cannot load expenses: %s', [E.Message]);
end;
Here, the SQL-specific exception is caught and replaced with an application-level exception that provides context meaningful to the caller.
Example: Safe String-to-Integer Conversion
function SafeStrToInt(const S: String; Default: Integer): Integer;
begin
try
Result := StrToInt(S);
except
on EConvertError do
Result := Default;
end;
end;
{ Usage }
var
Age: Integer;
begin
Write('Enter your age: ');
ReadLn(Input);
Age := SafeStrToInt(Input, -1);
if Age < 0 then
WriteLn('That was not a valid number.')
else
WriteLn('You are ', Age, ' years old.');
end;
This is a clean pattern: try the conversion, catch the specific exception that indicates invalid input, and return a default value. The calling code does not need to know about exceptions at all — it just gets a valid integer or the default.
19.3 Exception Classes
Object Pascal has a hierarchy of exception classes, all descending from the base class Exception (declared in the SysUtils unit). Knowing the hierarchy helps you catch the right exceptions.
The Exception Class Hierarchy
Exception { Base class — has Message property }
├── EAbort { Silent exception — halts without error }
├── EInOutError { File I/O errors (ErrorCode property) }
├── EConvertError { String conversion failures }
├── ERangeError { Value out of range }
├── EIntOverflow { Integer arithmetic overflow }
├── EDivByZero { Integer division by zero }
├── EInvalidOp { Invalid floating-point operation }
├── EZeroDivide { Floating-point division by zero }
├── EOverflow { Floating-point overflow }
├── EUnderflow { Floating-point underflow }
├── EAccessViolation { Invalid memory access (null pointer, etc.) }
├── EInvalidPointer { Freeing an invalid pointer }
├── EOutOfMemory { Memory allocation failure }
├── EStackOverflow { Stack overflow (often from infinite recursion) }
├── EExternalException { OS-level exception }
├── EStringListError { TStringList errors }
├── EListError { TList index errors }
└── (user-defined exceptions) { Your custom exception classes }
Common Exceptions You Will Encounter
EConvertError — Raised by StrToInt, StrToFloat, StrToDate, and similar conversion functions when the string does not match the expected format. This is by far the most common exception in programs that process user input.
try
Value := StrToFloat(UserInput);
except
on E: EConvertError do
WriteLn('Please enter a valid number.');
end;
EInOutError — Raised when a file operation fails: the file does not exist, permissions are denied, the disk is full. The ErrorCode property gives the OS error code.
try
AssignFile(F, 'data.txt');
Reset(F);
except
on E: EInOutError do
WriteLn('Cannot open file. Error code: ', E.ErrorCode);
end;
ERangeError — Raised when an array index or subrange value is out of bounds (only when range checking is enabled with {$R+}).
{$R+}
var
Arr: array[1..10] of Integer;
begin
try
Arr[11] := 42; { Out of bounds! }
except
on E: ERangeError do
WriteLn('Index out of range: ', E.Message);
end;
end;
EDivByZero — Raised when integer division by zero is attempted.
try
Result := Numerator div Denominator;
except
on E: EDivByZero do
WriteLn('Cannot divide by zero.');
end;
EAccessViolation — Raised when code tries to read or write memory through an invalid pointer (typically a nil pointer). This almost always indicates a bug in your code rather than a recoverable error condition.
📊 Exception Frequency in Typical Applications: -
EConvertError— very common (user input parsing) -EInOutError— common (file operations) -ERangeError— occasional (array bounds) -EDivByZero— rare (arithmetic) -EAccessViolation— indicates a bug, should not be caught in normal flow
Exception Class Properties
Every exception object has several useful properties inherited from the base Exception class:
try
SomeRiskyOperation;
except
on E: Exception do
begin
WriteLn('Class: ', E.ClassName); { The exception class name }
WriteLn('Message: ', E.Message); { Human-readable error description }
WriteLn('Help: ', E.HelpContext); { Help context ID (for GUI apps) }
end;
end;
The Message property is the most important — it contains the error description passed to Create or CreateFmt. Always provide meaningful messages when raising exceptions. A message like 'Error' is useless; a message like 'Cannot open file "expenses.csv": file not found in directory /home/rosa/data' tells the user exactly what happened and where.
The EInOutError.ErrorCode Property
EInOutError has an additional ErrorCode property that contains the operating system's error code:
try
AssignFile(F, '/nonexistent/path/file.txt');
Reset(F);
except
on E: EInOutError do
begin
WriteLn('I/O Error: ', E.Message);
WriteLn('OS Error Code: ', E.ErrorCode);
case E.ErrorCode of
2: WriteLn('File not found');
3: WriteLn('Path not found');
5: WriteLn('Access denied');
else
WriteLn('Unknown I/O error');
end;
end;
end;
This level of detail is invaluable for diagnosing problems in production. Rosa does not just see "file error" — she sees "path not found," which tells her exactly what to fix.
Silent Exceptions: EAbort
There is one special exception class worth knowing about: EAbort. When EAbort is raised, it silently cancels the current operation without displaying an error message (in GUI applications). It is used for operations like "Cancel" button clicks where the user intentionally stopped something:
procedure LoadDataWithCancel;
begin
if UserClickedCancel then
raise EAbort.Create(''); { Silently abort — no error dialog }
{ Otherwise, load data... }
end;
In console applications, EAbort behaves like any other exception. In Lazarus GUI applications, the default exception handler specifically does not show a dialog for EAbort. This makes it the right way to cancel long-running operations without alarming the user.
19.4 try..finally for Cleanup
The try..finally block serves a different purpose from try..except. It does not catch exceptions — it guarantees that cleanup code executes whether or not an exception occurs.
The Problem: Resource Leaks
Consider this code:
var
F: TextFile;
begin
AssignFile(F, 'data.txt');
Rewrite(F);
WriteLn(F, 'Important data');
{ What if an exception occurs here? }
WriteLn(F, CalculateResult); { Could raise an exception! }
CloseFile(F); { This line might never execute }
end;
If CalculateResult raises an exception, CloseFile(F) never executes. The file remains open, potentially locked, and the written data might not be flushed to disk.
The Solution: try..finally
var
F: TextFile;
begin
AssignFile(F, 'data.txt');
Rewrite(F);
try
WriteLn(F, 'Important data');
WriteLn(F, CalculateResult);
finally
CloseFile(F); { Always executes, exception or not }
end;
end;
The finally block executes in all three scenarios:
- No exception: The
trysection completes normally, then thefinallysection executes. - Exception raised: Execution leaves the
trysection, thefinallysection executes, and then the exception continues propagating up the call stack. - Exit or Break: If the code contains
Exit,Break, orContinueinside thetrysection, thefinallysection still executes before the jump.
This guarantee makes try..finally essential for resource management: files, database connections, network sockets, memory allocations — anything that must be cleaned up.
Object Cleanup Pattern
The most common use of try..finally in Object Pascal is freeing objects:
var
List: TStringList;
begin
List := TStringList.Create;
try
List.Add('Hello');
List.Add('World');
ProcessList(List);
finally
List.Free; { Always freed, even if ProcessList raises an exception }
end;
end;
This pattern is so fundamental that you should consider it the default way to work with objects in Pascal. Create, then immediately open a try..finally block, and put Free in the finally section.
💡 The Create-Try-Finally Pattern: Every time you write
SomeObject := TSomething.Create, the next line should betry, and thefinallyblock should containSomeObject.Free. This is not a suggestion — it is how professional Pascal code is written.
Multiple Resources
When you need to manage multiple resources, nest your try..finally blocks:
var
Input, Output: TFileStream;
begin
Input := TFileStream.Create('source.dat', fmOpenRead);
try
Output := TFileStream.Create('dest.dat', fmCreate);
try
CopyData(Input, Output);
finally
Output.Free;
end;
finally
Input.Free;
end;
end;
Each resource gets its own try..finally block. If Output creation fails, Input is still freed. If CopyData fails, both streams are freed. The nesting ensures proper cleanup order (last opened, first closed).
An alternative for multiple objects is the nil-initialization pattern:
var
Input, Output: TFileStream;
begin
Input := nil;
Output := nil;
try
Input := TFileStream.Create('source.dat', fmOpenRead);
Output := TFileStream.Create('dest.dat', fmCreate);
CopyData(Input, Output);
finally
Output.Free; { Free is safe to call on nil — it does nothing }
Input.Free;
end;
end;
This works because TObject.Free first checks whether Self is nil before calling Destroy. Calling Free on a nil reference is safe and does nothing.
19.5 Nested try Blocks
In real programs, you often need both exception handling and cleanup. You can nest try..except inside try..finally (or vice versa):
Pattern 1: try..finally with try..except Inside
var
F: TextFile;
begin
AssignFile(F, FileName);
Rewrite(F);
try
try
WriteLn(F, ProcessData);
except
on E: Exception do
WriteLn('Error processing data: ', E.Message);
end;
finally
CloseFile(F);
end;
end;
Here, the try..except handles errors during data processing, and the outer try..finally guarantees the file is closed regardless.
Pattern 2: try..except with try..finally Inside
try
var F: TextFile;
AssignFile(F, FileName);
Reset(F);
try
ReadLn(F, Data);
ProcessData(Data);
finally
CloseFile(F);
end;
except
on E: EInOutError do
WriteLn('File error: ', E.Message);
on E: Exception do
WriteLn('Processing error: ', E.Message);
end;
Here, the outer try..except catches any exception from either the file operations or the data processing. The inner try..finally ensures the file is closed even if an exception occurs. This is a robust pattern for file processing.
A Real-World Example: Processing a Configuration File
Here is a complete example showing nested try blocks in a realistic scenario — loading a configuration file with both error handling and cleanup:
procedure LoadConfiguration(const FileName: String);
var
F: TextFile;
Line, Key, Value: String;
LineNum: Integer;
SepPos: Integer;
begin
if not FileExists(FileName) then
begin
WriteLn('Configuration file not found. Using defaults.');
LoadDefaults;
Exit;
end;
AssignFile(F, FileName);
try
Reset(F);
try
LineNum := 0;
while not EOF(F) do
begin
Inc(LineNum);
ReadLn(F, Line);
Line := Trim(Line);
if (Line = '') or (Line[1] = '#') then
Continue; { Skip blank lines and comments }
SepPos := Pos('=', Line);
if SepPos = 0 then
begin
WriteLn(Format('Warning: line %d has no "=" separator. Skipping.', [LineNum]));
Continue;
end;
Key := Trim(Copy(Line, 1, SepPos - 1));
Value := Trim(Copy(Line, SepPos + 1, Length(Line)));
try
ApplyConfigValue(Key, Value);
except
on E: EConvertError do
WriteLn(Format('Warning: invalid value for "%s" on line %d: %s',
[Key, LineNum, E.Message]));
on E: ERangeError do
WriteLn(Format('Warning: value out of range for "%s" on line %d: %s',
[Key, LineNum, E.Message]));
end;
end;
finally
CloseFile(F);
end;
except
on E: EInOutError do
begin
WriteLn('Error reading configuration: ', E.Message);
WriteLn('Using default settings.');
LoadDefaults;
end;
end;
end;
This procedure demonstrates professional-grade error handling:
- File-level errors (cannot open) fall back to defaults.
- Line-level format issues (no separator) are warned and skipped.
- Value-level conversion errors are caught per-key, so one bad setting does not prevent loading the others.
- The file is always closed via try..finally.
Choosing the Right Nesting
The rule of thumb is:
- Outer
try..finallyfor cleanup, innertry..exceptfor handling: Use this when you want cleanup to happen unconditionally and error handling to happen only for specific operations. - Outer
try..exceptfor handling, innertry..finallyfor cleanup: Use this when you want a single exception handler for the entire block and guaranteed cleanup for resources.
Both patterns are common and correct. Choose the one that best matches your intent.
The try..except..else Construct
Free Pascal also supports an else clause in try..except blocks. The else clause catches any exception not matched by a preceding on clause:
try
ProcessFile(FileName);
except
on E: EInOutError do
WriteLn('File error: ', E.Message);
on E: EConvertError do
WriteLn('Data error: ', E.Message);
else
WriteLn('An unexpected error occurred.');
end;
The else clause is equivalent to a bare except after all on clauses. It catches anything not handled above. However, it does not give you access to the exception object. For this reason, many programmers prefer the explicit on E: Exception do catch-all over the else clause — it provides the same catch-all behavior while still giving access to E.Message and E.ClassName.
19.6 Raising Exceptions
You can raise exceptions yourself using the raise keyword. This is how you signal error conditions in your own code:
procedure SetAge(AAge: Integer);
begin
if (AAge < 0) or (AAge > 150) then
raise Exception.Create('Age must be between 0 and 150');
FAge := AAge;
end;
When raise executes, the exception object is created and thrown. Execution immediately leaves the current procedure and searches for a handler.
Raising Specific Exception Types
It is better practice to raise specific exception types rather than the generic Exception:
procedure SetAge(AAge: Integer);
begin
if AAge < 0 then
raise ERangeError.Create('Age cannot be negative');
if AAge > 150 then
raise ERangeError.Create('Age cannot exceed 150');
FAge := AAge;
end;
This lets callers catch ERangeError specifically without catching unrelated exceptions.
Re-Raising Exceptions
Re-raising is one of the most useful exception handling techniques. It lets you perform partial handling — logging, updating state, notifying the user — while still passing the exception to a higher-level handler that can make a broader recovery decision.
Inside an except block, you can re-raise the current exception by using raise with no arguments:
try
ProcessFile(FileName);
except
on E: EInOutError do
begin
WriteLn('Logging error: ', E.Message);
raise; { Re-raise the same exception }
end;
end;
This is useful for logging or partial handling: you note the error but then pass it up to a higher-level handler that can make a broader decision about recovery.
Re-raise vs. raise new: There is a critical difference between raise; (re-raise) and raise Exception.Create(...) (raise new). Re-raising preserves the original exception object, including its class type and any additional properties. Raising a new exception creates a fresh object and frees the original. Choose re-raise when you want higher-level handlers to see the exact original error. Choose raise-new when you want to wrap a low-level exception in a higher-level one:
try
LoadFromDatabase;
except
on E: ESQLError do
begin
LogError('Database load failed: ' + E.Message);
{ Option A: re-raise (preserves ESQLError type) }
raise;
{ Option B: wrap (converts to application exception) }
raise EPennyWiseError.CreateFmt('Cannot load expenses: %s', [E.Message]);
end;
end;
In practice, use Option A (re-raise) when the caller knows about database errors. Use Option B (wrap) when the caller should not know about the database layer — it should only see application-level errors. The wrapping pattern is how you enforce architectural boundaries: the UI layer never sees ESQLError, only EPennyWiseError.
Raising with Format
A common pattern is creating exception messages with Format:
raise Exception.CreateFmt('Cannot process file "%s": %s',
[FileName, Reason]);
The CreateFmt constructor works like Format — it accepts a format string and an array of arguments, producing a readable error message.
19.7 Custom Exception Classes
For any moderately complex application, you should define your own exception classes. Custom exceptions make your error handling more precise: callers can catch your specific exceptions without catching unrelated errors from other libraries.
Defining Custom Exceptions
Custom exceptions are classes that descend from Exception (or from a more specific exception class):
type
{ Base exception for the PennyWise application }
EPennyWiseError = class(Exception);
{ Specific exception types }
EInvalidExpenseError = class(EPennyWiseError);
EBudgetExceededError = class(EPennyWiseError);
EDataFileCorruptError = class(EPennyWiseError);
EDuplicateEntryError = class(EPennyWiseError);
By defining a hierarchy, you can catch errors at different levels of specificity:
try
AddExpense(NewExpense);
except
on E: EBudgetExceededError do
WriteLn('Warning: budget exceeded. ', E.Message);
on E: EInvalidExpenseError do
WriteLn('Invalid expense data. ', E.Message);
on E: EPennyWiseError do
WriteLn('PennyWise error: ', E.Message);
on E: Exception do
WriteLn('Unexpected error: ', E.Message);
end;
The handlers are checked from most specific to most general. If the expense is invalid, EInvalidExpenseError catches it. If the budget is exceeded, EBudgetExceededError catches it. Any other PennyWise error falls to EPennyWiseError. Truly unexpected errors fall to the generic Exception handler.
Custom Exceptions with Additional Data
Custom exceptions can carry additional information beyond the message string:
type
EBudgetExceededError = class(EPennyWiseError)
private
FCategory: String;
FBudgetLimit: Currency;
FActualAmount: Currency;
public
constructor Create(const ACategory: String;
ABudgetLimit, AActualAmount: Currency);
property Category: String read FCategory;
property BudgetLimit: Currency read FBudgetLimit;
property ActualAmount: Currency read FActualAmount;
end;
constructor EBudgetExceededError.Create(const ACategory: String;
ABudgetLimit, AActualAmount: Currency);
begin
inherited CreateFmt('Budget exceeded for %s: limit $%.2f, actual $%.2f',
[ACategory, ABudgetLimit, AActualAmount]);
FCategory := ACategory;
FBudgetLimit := ABudgetLimit;
FActualAmount := AActualAmount;
end;
Now the handler can access structured information:
except
on E: EBudgetExceededError do
begin
WriteLn('Over budget in: ', E.Category);
WriteLn('Limit: $', E.BudgetLimit:0:2);
WriteLn('Actual: $', E.ActualAmount:0:2);
WriteLn('Overage: $', (E.ActualAmount - E.BudgetLimit):0:2);
end;
end;
This is far more useful than parsing a message string. The exception object carries typed data that handlers can use directly.
Exception Classes as a Design Tool
Custom exception classes are more than error containers — they are a design tool that documents your application's failure modes. When you define EInvalidExpenseError, EBudgetExceededError, and EDataFileCorruptError, you are making an explicit statement about the three ways your application can fail. Anyone reading your exception hierarchy instantly understands the error landscape of your system.
Here is a well-designed exception hierarchy for a hypothetical banking application:
type
{ Base exception for the banking application }
EBankError = class(Exception);
{ Account-related errors }
EAccountError = class(EBankError);
EAccountNotFoundError = class(EAccountError);
EAccountLockedError = class(EAccountError);
EInsufficientFundsError = class(EAccountError)
private
FRequested: Currency;
FAvailable: Currency;
public
constructor Create(ARequested, AAvailable: Currency);
property Requested: Currency read FRequested;
property Available: Currency read FAvailable;
end;
{ Transaction-related errors }
ETransactionError = class(EBankError);
EDuplicateTransactionError = class(ETransactionError);
ETransactionLimitError = class(ETransactionError);
{ Authentication errors }
EAuthError = class(EBankError);
EInvalidCredentialsError = class(EAuthError);
ESessionExpiredError = class(EAuthError);
A handler at the UI level can catch EBankError to handle all banking errors generically. A handler in the transaction processing layer can catch EAccountError to handle all account errors. A specific handler can catch EInsufficientFundsError to display the exact shortfall. The hierarchy gives you precision at every level.
This pattern transfers directly to Java (which has a nearly identical checked exception hierarchy), C# (which uses the same inheritance-based exception taxonomy), and Python (which uses exception classes in the same way). If you can design a good exception hierarchy in Pascal, you can design one anywhere.
💡 Design Tip: Define a base exception class for your application (like
EPennyWiseError) and derive all your specific exceptions from it. This lets callers write a singleon E: EPennyWiseErrorto catch all your application's errors, while still being able to catch specific ones when needed. The hierarchy should be two to three levels deep — deeper than that usually signals over-engineering.
19.8 Exception Handling Best Practices
Exception handling is powerful, but it is easy to misuse. Here are the practices that distinguish robust code from fragile code with hidden bugs.
1. Do Not Catch Exceptions You Cannot Handle
The worst thing you can do with exceptions is catch them and do nothing:
{ NEVER DO THIS }
try
ProcessFile(FileName);
except
{ Silently ignore all errors }
end;
This is called "swallowing exceptions" and it creates bugs that are almost impossible to find. The file was not processed, but the program continues as if it was. Data is silently lost or corrupted. The user sees no error message. When the problem surfaces later, it is far from the original cause.
Rule: If you catch an exception, do something meaningful — log it, display it, recover from it, or re-raise it. Never silently ignore it.
Here is what the "swallowing exceptions" antipattern looks like in a real-world scenario. Rosa adds an auto-save feature to PennyWise that saves every 5 minutes. A developer writes:
procedure AutoSave;
begin
try
SaveExpenses('data.csv');
except
{ Ignore — auto-save is optional }
end;
end;
This looks harmless. But when the disk fills up, auto-save silently fails. Rosa works for two hours, then her computer crashes. She restarts and discovers that the last two hours of work are gone — the auto-save was failing silently the entire time. If the exception had been logged or displayed, she would have known about the disk space problem immediately and could have freed space.
The correct version:
procedure AutoSave;
begin
try
SaveExpenses('data.csv');
except
on E: EInOutError do
begin
WriteLn('Warning: Auto-save failed: ', E.Message);
WriteLn('Please save manually and check disk space.');
end;
end;
end;
Now Rosa sees the warning immediately and takes action.
2. Catch Specific Exceptions, Not Generic Ones
{ Avoid this — too broad }
try
Amount := StrToFloat(Input);
SaveToFile(Amount);
except
on E: Exception do
WriteLn('Error: ', E.Message);
end;
{ Better — specific handlers for specific errors }
try
Amount := StrToFloat(Input);
SaveToFile(Amount);
except
on E: EConvertError do
WriteLn('Invalid number format. Please try again.');
on E: EInOutError do
WriteLn('Could not save file: ', E.Message);
end;
Catching specific exceptions lets you provide meaningful error messages and appropriate recovery for each failure mode.
3. Use try..finally for All Resource Cleanup
Every resource acquisition (opening a file, creating an object, acquiring a lock) should be immediately followed by a try..finally block that releases the resource:
Stream := TFileStream.Create(FileName, fmCreate);
try
{ Use the stream }
finally
Stream.Free;
end;
This is non-negotiable. Without try..finally, any exception in the middle of your code causes a resource leak.
4. Provide Meaningful Error Messages
{ Bad: tells the user nothing useful }
raise Exception.Create('Error');
{ Good: tells the user what happened and what to do }
raise Exception.CreateFmt(
'Cannot open file "%s". Check that the file exists and you have read permission.',
[FileName]);
Good error messages answer three questions: What happened? Why? What can the user do about it?
5. Do Not Use Exceptions for Normal Flow Control
Exceptions should represent exceptional conditions — errors that are unusual and unexpected. Do not use them as a substitute for if statements:
{ BAD: Using exceptions for flow control }
try
Index := List.IndexOf(Item);
raise Exception.Create(''); { Always raises! }
except
on E: Exception do
WriteLn('Item not found');
end;
{ GOOD: Use a conditional check }
Index := List.IndexOf(Item);
if Index < 0 then
WriteLn('Item not found')
else
ProcessItem(List[Index]);
Exceptions have overhead — creating exception objects, unwinding the stack, searching for handlers. For conditions you expect to happen routinely, use normal control flow.
6. Clean Up Before Re-Raising
If you need to do partial cleanup before passing an exception to a higher handler, use try..finally or re-raise after cleanup:
procedure ProcessBatch(const Files: array of String);
var
i, SuccessCount: Integer;
begin
SuccessCount := 0;
for i := Low(Files) to High(Files) do
begin
try
ProcessFile(Files[i]);
Inc(SuccessCount);
except
on E: EInOutError do
begin
WriteLn('Skipping ', Files[i], ': ', E.Message);
{ Do not re-raise — continue with next file }
end;
on E: Exception do
begin
WriteLn('Fatal error processing ', Files[i]);
raise; { Re-raise for non-file errors }
end;
end;
end;
WriteLn('Processed ', SuccessCount, ' of ', Length(Files), ' files.');
end;
This procedure handles file errors gracefully (skip and continue) but re-raises unexpected errors (which might indicate a serious problem).
7. Document Which Exceptions Your Code Raises
In Pascal, unlike Java, there is no throws clause that forces callers to handle exceptions. This means it is your responsibility to document which exceptions your procedures can raise:
{ Raises:
EInvalidExpenseError — if Amount <= 0 or Category is empty
EBudgetExceededError — if adding this expense exceeds the budget
EInOutError — if the auto-save file cannot be written }
procedure AddExpense(AExpense: TExpense);
Good documentation turns runtime surprises into design-time decisions.
Common Anti-Patterns to Avoid
Beyond the practices above, here are specific anti-patterns that appear frequently in beginner code and should be recognized and corrected.
Anti-Pattern: Catch-and-return-false.
{ BAD: Hides the reason for failure }
function TryLoadFile(const FileName: String): Boolean;
begin
try
LoadFile(FileName);
Result := True;
except
Result := False; { What went wrong? Nobody knows. }
end;
end;
The caller gets False but has no idea why — was the file missing? Was it corrupt? Was access denied? Each cause requires a different response, but the function has erased the information. Either let the exception propagate, or return a structured error (like a TResult record with an error message).
Anti-Pattern: Exception as control flow.
{ BAD: Using exceptions for expected situations }
function GetUserAge: Integer;
begin
repeat
try
Write('Age: ');
ReadLn(Input);
Result := StrToInt(Input);
Break; { Success — exit loop }
except
WriteLn('Invalid.');
end;
until False;
end;
This works but is unnecessarily slow. A user typing "abc" is not exceptional — it is expected. The correct approach uses TryStrToInt, which returns a Boolean without raising an exception:
{ GOOD: No exceptions for expected input errors }
function GetUserAge: Integer;
var
Input: String;
begin
repeat
Write('Age: ');
ReadLn(Input);
if TryStrToInt(Input, Result) and (Result > 0) and (Result < 150) then
Exit;
WriteLn('Please enter a valid age (1-149).');
until False;
end;
Anti-Pattern: Catch-log-swallow.
{ BAD: Logs the error but then hides it }
try
ProcessImportantData;
except
on E: Exception do
WriteLn('Error logged: ', E.Message);
{ Exception is silently swallowed — caller thinks everything is fine }
end;
Logging is good; swallowing is bad. If you log, also re-raise (or take corrective action). The user or the calling code needs to know that processing failed.
8. Consider the Error-Handling Strategy at Architecture Level
In a large application, you need a consistent error-handling strategy. Here is a three-tier approach that works well:
Tier 1: Domain layer — Raise custom exceptions for business rule violations. EBudgetExceededError, EInvalidExpenseError, EDuplicateEntryError. These carry domain-specific data.
Tier 2: Service layer — Catch domain exceptions when appropriate, log them, and either handle them or re-raise wrapped versions. A service might catch EInOutError from the file system and re-raise it as EDataPersistenceError with additional context.
Tier 3: UI/Top level — Catch all remaining exceptions and present them to the user. In a console application, this is a top-level try..except around the main program. In a GUI application, this is the Application.OnException handler.
{ Top-level exception handler in a console application }
begin
try
RunApplication;
except
on E: EPennyWiseError do
begin
WriteLn('PennyWise Error: ', E.Message);
WriteLn('Please check your data and try again.');
ExitCode := 1;
end;
on E: Exception do
begin
WriteLn('Unexpected Error: [', E.ClassName, '] ', E.Message);
WriteLn('This is likely a bug. Please report it.');
ExitCode := 2;
end;
end;
end.
This tiered approach ensures that: - Domain errors get domain-specific handling - Infrastructure errors get translated into domain terms - Nothing escapes without being handled - The user always sees a meaningful message, never a raw stack trace
Exception Safety Levels
When designing functions and classes, think about the exception safety guarantee each one provides:
-
No-throw guarantee: The function never raises an exception. Destructors should provide this guarantee — if a destructor raises an exception during stack unwinding, the program terminates abnormally.
-
Strong guarantee: If an exception occurs, the program state is rolled back to what it was before the function was called. The "write-to-temp-then-rename" pattern provides this for file operations.
-
Basic guarantee: If an exception occurs, no resources are leaked and no invariants are violated, but the program state may have changed. Most well-written code provides at least this guarantee.
-
No guarantee: If an exception occurs, anything could happen — resources may leak, data may be corrupt. This is unacceptable in production code.
Aim for the strong guarantee where practical (especially for data modification operations) and the basic guarantee everywhere else.
Example of the strong guarantee: The "write-to-temp-then-rename" pattern we use in PennyWise's save procedure provides the strong guarantee. If writing to the temporary file fails, the original file is untouched — the program state is exactly as it was before SaveExpenses was called. The user can retry the save, fix the disk space issue, or continue working without data loss.
Example of the basic guarantee: A procedure that adds three expenses to a list. If the second addition raises an exception (out of memory), the first expense is already in the list. The program state has changed, but no resources are leaked and no invariants are violated — the list is consistent, just not in the state the caller expected.
Example of no guarantee: A procedure that directly overwrites a file without using a temporary. If the write fails halfway through, the file contains half old data and half new data — a corrupt state. This is unacceptable in production code and is the reason we always use the temp-then-rename pattern for critical data files.
19.9 IOResult vs. Exceptions: Comparing Approaches
In Chapter 13, we used {$I-} and IOResult for file error handling. Now we have exceptions. Which should you use? Let us compare them side by side.
The IOResult Approach
{$I-}
AssignFile(F, FileName);
Reset(F);
if IOResult <> 0 then
begin
WriteLn('Cannot open file.');
Exit;
end;
ReadLn(F, Header);
if IOResult <> 0 then
begin
WriteLn('Cannot read header.');
CloseFile(F);
Exit;
end;
ReadLn(F, Data);
if IOResult <> 0 then
begin
WriteLn('Cannot read data.');
CloseFile(F);
Exit;
end;
CloseFile(F);
{$I+}
The Exception Approach
try
AssignFile(F, FileName);
Reset(F);
try
ReadLn(F, Header);
ReadLn(F, Data);
finally
CloseFile(F);
end;
except
on E: EInOutError do
WriteLn('File error: ', E.Message);
end;
Comparison
| Criterion | IOResult | Exceptions |
|---|---|---|
| Code clarity | Error checks interleaved with logic | Clean separation of logic and error handling |
| Forgetting to check | Silently ignored | Automatically terminates or propagates |
| Cleanup | Manual, error-prone | Guaranteed with try..finally |
| Error detail | Numeric code only | Object with message, class name, extra data |
| Performance | No overhead on success | Small overhead on success; more overhead when exception occurs |
| Composability | Does not propagate through call stack | Automatically propagates to nearest handler |
| Legacy compatibility | Works in all Pascal compilers | Requires SysUtils unit |
When to Use Each
Use exceptions (the default recommendation) for: - All new code - Any operation where failure is unusual (file not found, conversion error) - Object-oriented codebases where classes raise errors - Any code that manages resources (files, objects, connections)
Use IOResult only for:
- Legacy code maintenance where changing to exceptions would require extensive refactoring
- Hot inner loops where even the small overhead of exception setup is unacceptable (extremely rare)
- Code that must compile without SysUtils (embedded or minimal environments)
For PennyWise and virtually all application code, exceptions are the right choice. They produce code that is cleaner, safer, and easier to maintain.
The Hybrid Approach
In practice, many programs use both approaches. The bulk of the code uses exceptions, but specific hot spots use IOResult or Try* functions for performance-critical paths:
{ Hybrid approach: exceptions for structure, Try* for performance }
procedure ImportCSVFile(const FileName: String);
var
F: TextFile;
Line: String;
Value: Currency;
Success: Boolean;
begin
AssignFile(F, FileName);
try
Reset(F); { Exception if file not found — this is exceptional }
try
while not EOF(F) do
begin
ReadLn(F, Line);
{ Use Try* for expected parsing failures — many lines may be invalid }
Success := TryStrToCurr(ExtractField(Line, 3), Value);
if Success then
ProcessValue(Value)
else
Inc(SkippedCount);
end;
finally
CloseFile(F);
end;
except
on E: EInOutError do
raise EPennyWiseError.CreateFmt('Cannot import "%s": %s',
[FileName, E.Message]);
end;
end;
The file-open uses exceptions (file not found is genuinely exceptional). The per-line parsing uses TryStrToCurr (invalid values are expected and common). This hybrid gives the best of both worlds: clean structure from exceptions, performance from Try* functions.
🔗 Cross-Reference: Chapter 13 covered
IOResultand{$I-}`/`{$I+}in detail. If you need to maintain legacy code that uses those techniques, refer back to that chapter. For all new code, use the exception-based patterns taught here.
19.10 Project Checkpoint: PennyWise Gets Robust
Let us apply exception handling to PennyWise. We will wrap file I/O operations in exception handlers, validate user input with exceptions, and define custom exception classes for application-specific errors.
Step 1: Define Custom Exceptions
unit PennyWise.Exceptions;
{$mode objfpc}{$H+}
interface
uses
SysUtils;
type
EPennyWiseError = class(Exception);
EInvalidExpenseError = class(EPennyWiseError);
EBudgetExceededError = class(EPennyWiseError)
private
FCategory: String;
FLimit: Currency;
FActual: Currency;
public
constructor Create(const ACategory: String; ALimit, AActual: Currency);
property Category: String read FCategory;
property Limit: Currency read FLimit;
property Actual: Currency read FActual;
end;
EDataFileCorruptError = class(EPennyWiseError);
implementation
constructor EBudgetExceededError.Create(const ACategory: String;
ALimit, AActual: Currency);
begin
inherited CreateFmt('Budget exceeded for "%s": limit $%.2f, spent $%.2f',
[ACategory, ALimit, AActual]);
FCategory := ACategory;
FLimit := ALimit;
FActual := AActual;
end;
end.
Step 2: Safe File Loading
function LoadExpenses(const AFileName: String): Boolean;
var
F: TextFile;
Line: String;
LineNum: Integer;
begin
Result := False;
if not FileExists(AFileName) then
begin
WriteLn('No saved data found. Starting fresh.');
Exit;
end;
AssignFile(F, AFileName);
try
Reset(F);
try
LineNum := 0;
while not EOF(F) do
begin
Inc(LineNum);
try
ReadLn(F, Line);
ParseAndAddExpense(Line);
except
on E: EConvertError do
WriteLn(Format('Warning: Skipping invalid data on line %d: %s',
[LineNum, E.Message]));
on E: EInvalidExpenseError do
WriteLn(Format('Warning: Invalid expense on line %d: %s',
[LineNum, E.Message]));
end;
end;
Result := True;
finally
CloseFile(F);
end;
except
on E: EInOutError do
WriteLn('Error reading file: ', E.Message);
end;
end;
Notice the careful design: individual line errors are caught and skipped (the file might have a few corrupt lines, but the rest is fine), while file-level I/O errors abort the entire load. This is the kind of nuanced error handling that exceptions make possible.
Step 3: Safe User Input
function ReadCurrencyInput(const APrompt: String): Currency;
var
Input: String;
Done: Boolean;
begin
Result := 0;
Done := False;
repeat
Write(APrompt);
ReadLn(Input);
try
Result := StrToCurr(Input);
if Result <= 0 then
raise EInvalidExpenseError.Create('Amount must be positive.');
Done := True;
except
on E: EConvertError do
WriteLn('Invalid amount. Please enter a number (e.g., 42.50).');
on E: EInvalidExpenseError do
WriteLn(E.Message);
end;
until Done;
end;
function ReadDateInput(const APrompt: String): TDateTime;
var
Input: String;
Done: Boolean;
begin
Result := 0;
Done := False;
repeat
Write(APrompt);
ReadLn(Input);
try
Result := StrToDate(Input);
Done := True;
except
on E: EConvertError do
WriteLn('Invalid date. Please use the format MM/DD/YYYY.');
end;
until Done;
end;
Step 4: Safe File Saving
procedure SaveExpenses(const AFileName: String);
var
F: TextFile;
TempFile: String;
begin
{ Write to a temporary file first, then rename — if writing fails,
we do not corrupt the existing data file }
TempFile := AFileName + '.tmp';
AssignFile(F, TempFile);
try
Rewrite(F);
try
WriteAllExpenses(F);
finally
CloseFile(F);
end;
{ If we got here, the temp file was written successfully }
if FileExists(AFileName) then
DeleteFile(AFileName);
RenameFile(TempFile, AFileName);
WriteLn('Data saved successfully.');
except
on E: EInOutError do
begin
WriteLn('Error saving data: ', E.Message);
WriteLn('Your unsaved data is still in memory.');
{ Clean up the temp file if it exists }
if FileExists(TempFile) then
DeleteFile(TempFile);
end;
end;
end;
This save procedure uses the "write-to-temp-then-rename" pattern — a standard technique for crash-safe file writing. If the write fails partway through, the original file is untouched.
Testing the Error Handling
begin
WriteLn('=== PennyWise — Chapter 19 Checkpoint ===');
WriteLn;
{ Test 1: Load from a non-existent file }
WriteLn('Test 1: Loading from non-existent file...');
LoadExpenses('nonexistent.dat');
WriteLn;
{ Test 2: Input validation }
WriteLn('Test 2: Reading expense amount...');
Amount := ReadCurrencyInput('Enter amount: ');
WriteLn('You entered: $', Amount:0:2);
WriteLn;
{ Test 3: Budget checking }
WriteLn('Test 3: Budget check...');
try
CheckBudget('Dining', 100.00, 145.50);
except
on E: EBudgetExceededError do
begin
WriteLn('Budget alert!');
WriteLn(' Category: ', E.Category);
WriteLn(' Limit: $', E.Limit:0:2);
WriteLn(' Spent: $', E.Actual:0:2);
end;
end;
WriteLn;
WriteLn('=== Checkpoint complete ===');
end.
✅ Checkpoint Status: PennyWise now handles file I/O errors gracefully, validates user input with retry loops, uses custom exception classes for domain-specific errors, and employs the write-to-temp-then-rename pattern for crash-safe saving. Rosa's data is safe even when things go wrong.
What Rosa and Tomas Experience
Before exception handling, Rosa's PennyWise would crash with a cryptic "Runtime error 2" when she accidentally deleted her data file. After Chapter 19, it says: "No saved data found. Starting fresh." She never loses data again because the save procedure writes to a temporary file first and only renames it after success.
Tomas, who types fast and carelessly, used to see "Runtime error 106" every time he entered "abc" instead of a number. Now the program says "Invalid amount. Please enter a number (e.g., 42.50)" and lets him try again. He has not seen a single crash since the exception handlers went in.
These are not cosmetic improvements. They are the difference between software that a real person can use and software that a real person will abandon after three crashes. Exception handling is what separates student programs from professional programs.
Exception Handling and Performance
A common concern about exceptions is performance. Let us address this directly.
When no exception occurs, the overhead is minimal. The compiler inserts a small amount of bookkeeping code at each try block entry (setting up the stack frame for unwinding), but this is negligible in practice — typically a few nanoseconds. You should not avoid try..except blocks for performance reasons in normal code.
When an exception does occur, the cost is significant: the runtime must unwind the call stack, search for handlers, create the exception object, and possibly execute multiple finally blocks. This can take microseconds to milliseconds depending on stack depth.
The performance conclusion is clear: exceptions are fast on the happy path and slow on the error path. Since errors should be exceptional (rare), this is the right tradeoff. You pay almost nothing when things work correctly, and you pay a bit more when they fail — but when they fail, correctness matters more than speed anyway.
The one place to be cautious is tight loops where you expect frequent failures. For example, parsing a large file where 50% of lines are invalid — using try..except around each parse would be noticeably slower than using TryStrToInt (which returns a Boolean instead of raising an exception). Free Pascal provides TryStrToInt, TryStrToFloat, TryStrToDate, and other Try* functions specifically for this purpose.
{ Slow: exception for expected failures }
for i := 0 to High(Lines) do
begin
try
Values[i] := StrToInt(Lines[i]);
except
Values[i] := 0;
end;
end;
{ Fast: no exception for expected failures }
for i := 0 to High(Lines) do
begin
if not TryStrToInt(Lines[i], Values[i]) then
Values[i] := 0;
end;
Use Try* functions when you expect frequent failures (parsing user input in bulk). Use try..except when failures are genuinely exceptional (file I/O, network operations, business rule violations).
19.11 Summary
This chapter transformed our approach to errors from "hope nothing goes wrong" to "handle it gracefully when something does."
try..except blocks catch exceptions and let you handle them — displaying messages, retrying operations, or taking alternative paths. Always catch specific exception types; avoid catching generic Exception unless you are writing a top-level error handler.
try..finally blocks guarantee that cleanup code executes whether or not an exception occurs. Use them for every resource acquisition: files, objects, database connections, locks. The Create-Try-Finally pattern is the standard way to manage object lifetimes in Pascal.
Raising exceptions with raise lets you signal error conditions in your own code. Prefer specific exception types over generic Exception, and provide meaningful messages that tell the user what happened and what to do.
Custom exception classes let you define application-specific error types with additional data (like the category, budget limit, and actual amount in EBudgetExceededError). Build a hierarchy with a base application exception and specific subtypes.
Best practices: Do not swallow exceptions. Do not use exceptions for normal flow control. Catch only exceptions you can handle. Document which exceptions your code raises. Log errors before re-raising.
IOResult vs. Exceptions: For new code, exceptions are almost always the right choice. They produce cleaner code, automatic propagation, and guaranteed cleanup. Use IOResult only for legacy code or highly constrained environments.
The discipline of exception handling transfers directly to every modern language: Java's try-catch-finally, Python's try-except-finally, C#'s try-catch-finally, C++'s try-catch — the syntax varies, but the concepts are identical. Learn them well in Pascal, and you learn them for every language.
In Chapter 20, we turn to generics — parameterized types that let you write code once and use it with any type. Where exceptions made our code robust against errors, generics will make our code safe against type confusion, eliminating a whole class of bugs that arise from unsafe typecasting.
Key Terms Introduced in This Chapter
| Term | Definition |
|---|---|
| Exception | An object representing an error condition, raised to interrupt normal execution |
try..except |
A block that catches exceptions and executes error-handling code |
try..finally |
A block that guarantees cleanup code executes regardless of exceptions |
raise |
Keyword to create and throw an exception |
| Exception class | A class descending from Exception that represents a specific type of error |
EConvertError |
Exception raised when string-to-number/date conversion fails |
EInOutError |
Exception raised when file I/O operations fail |
ERangeError |
Exception raised when a value exceeds its type's range |
EDivByZero |
Exception raised on integer division by zero |
EAccessViolation |
Exception raised on invalid memory access (often nil pointers) |
| Re-raising | Using raise with no arguments inside an except block to pass the exception upward |
| Swallowing exceptions | Catching exceptions and ignoring them — a dangerous antipattern |
| Create-Try-Finally pattern | Creating a resource, immediately entering try, and freeing in finally |
| Custom exception | A user-defined exception class carrying application-specific error data |