> "The key to controlling complexity is a good domain model... a model with rich objects that encapsulate process and data."
Learning Objectives
- Define classes with fields, methods, and constructors in Object Pascal
- Create objects (instances) and call methods on them
- Understand encapsulation and use public/private/protected visibility
- Write constructors (Create) and destructors (Destroy) properly
- Explain the difference between records and classes (reference vs. value semantics)
In This Chapter
- Spaced Review
- 16.1 Why Object-Oriented Programming?
- 16.2 From Records to Classes: The Conceptual Bridge
- 16.3 Defining a Class in Object Pascal
- 16.4 Creating and Using Objects
- 16.5 Constructors and Destructors
- 16.6 Encapsulation: Public, Private, Protected
- 16.7 Properties: Controlled Access
- 16.8 Methods: Procedures and Functions Inside Classes
- 16.9 Class vs. Record: Reference Semantics vs. Value Semantics
- 16.10 Self: The Current Object Reference
- 16.11 From Procedural PennyWise to OOP PennyWise
- 16.12 Project Checkpoint: PennyWise OOP
- 16.13 Chapter Summary
Chapter 16: Introduction to Object-Oriented Programming: Classes and Objects in Pascal
"The key to controlling complexity is a good domain model... a model with rich objects that encapsulate process and data." — Eric Evans, Domain-Driven Design
Something is about to change.
For the first fifteen chapters of this textbook, we have been writing procedural Pascal. We decomposed problems into procedures and functions. We organized data into records. We passed records to procedures, got results back, and called it a day. If you have been following along and building PennyWise, you have a working personal finance manager built on this procedural foundation: arrays of TExpenseRecord, standalone procedures like AddExpense and PrintReport, global state managed with care and discipline.
That approach works. It has carried us through real programs, real data structures, real problem-solving. We are not here to tell you that procedural programming is wrong — it is, in fact, the right place to start, and the procedural skills you have built are permanent. Every object-oriented program still has procedures and functions inside it. Every class still uses the control structures, types, and expressions you already know.
But we are here to tell you that procedural programming has limits. And as programs grow larger — as you move from hundreds of lines to thousands, from one developer to a team, from a simple utility to a complex system — those limits start to bite. This chapter is about understanding what those limits are, and learning a fundamentally different way of thinking about program design that addresses them.
Welcome to Part III. Welcome to Object Pascal. Welcome to object-oriented programming.
💡 Intuition: The Threshold You Are Crossing This chapter introduces a threshold concept — an idea that, once you truly understand it, permanently changes how you think about programming. Object-oriented design thinking is not just a new syntax to memorize. It is a new way of seeing the world: instead of asking "what steps do I need to perform?" you learn to ask "what things exist in my problem domain, and what can they do?" This shift is disorienting at first. That disorientation is normal and temporary. By the end of this chapter, you will have crossed the threshold.
Spaced Review
Before we dive into new territory, let us consolidate what we already know. These questions connect to material from earlier chapters that is directly relevant to what we are about to learn.
📦 From Chapter 14 — Pointers and Dynamic Memory:
What happens if you call Dispose on a pointer and then try to access the memory it pointed to? This matters because objects in Pascal live on the heap, managed through pointers. Understanding dangling references is essential for understanding object lifetimes.
📦 From Chapter 11 — Records: How is a record different from a class? You may not know the full answer yet, but think about what you do know about records: they group related fields, they are value types (copied on assignment), and they cannot contain procedures or functions. Hold that thought — we are about to see exactly how classes extend this idea.
📦 From Chapter 8 — Procedures, Functions, and Scope: How does lexical scope work in Pascal? When you nest a procedure inside another procedure, the inner one can access the outer one's variables. This concept of scope and visibility is the foundation of encapsulation, one of the core ideas in this chapter.
16.1 Why Object-Oriented Programming?
Let us start with an honest question: if procedural programming works, why do we need anything else?
The answer is not that procedural programming fails. It is that it fails to scale in specific, predictable ways. Let us examine three of them.
The Problem of Scattered Data and Behavior
Consider PennyWise as we have built it so far. We have a record type:
type
TExpenseRecord = record
Description: string;
Amount: Currency;
Category: string;
Date: TDateTime;
end;
And we have procedures that operate on this data:
procedure AddExpense(var Expenses: array of TExpenseRecord; var Count: Integer;
Desc: string; Amt: Currency; Cat: string; Dt: TDateTime);
procedure PrintExpense(const Expense: TExpenseRecord);
procedure TotalByCategory(const Expenses: array of TExpenseRecord; Count: Integer;
Cat: string; var Total: Currency);
Notice the pattern. The data (the record) and the behavior (the procedures) are separate. The record definition is in one place; the procedures might be in another unit entirely. There is nothing in the language that says "these procedures belong to this record." A new developer reading the code must manually trace which procedures operate on TExpenseRecord and which do not.
In a small program, this is manageable. In a program with fifty record types and three hundred procedures, it becomes a maintenance nightmare. Which procedures work with which data? Who is responsible for initializing a record correctly? Who ensures the invariants — the rules that must always be true, like "Amount must be positive" — are maintained?
The Problem of Unprotected Data
With records, any code anywhere in the program can reach in and modify any field:
Expenses[3].Amount := -999.99; { Nothing stops this }
Expenses[3].Category := ''; { Or this }
If Amount should never be negative and Category should never be empty, there is no way to enforce those rules at the type level. You must rely on every programmer, in every part of the codebase, remembering to validate data before modifying it. In a team of one, this is difficult. In a team of twenty, it is impossible.
The Problem of Rigid Extension
Suppose we want PennyWise to handle not just expenses but also income. The data is similar — a description, an amount, a date, a category — but not identical. Income might have a Source field; expenses might have a PaymentMethod field. In procedural code, we face an unpleasant choice:
- Option A: Create a single giant record with fields for both types, leaving some fields unused depending on whether the entry is income or expense. This is wasteful and error-prone.
- Option B: Create two completely separate record types with separate sets of procedures. This leads to massive code duplication.
- Option C: Use variant records (if you recall them from Chapter 11). This works but is clumsy and the compiler cannot help you catch mistakes.
None of these options is elegant. Object-oriented programming offers a fourth option that is clean, safe, and extensible. We will see it in Chapter 17 when we discuss inheritance, but the foundation starts right here, right now, with classes and objects.
⚠️ Important Clarification Object-oriented programming is not "better" than procedural programming in some absolute sense. It is better for certain kinds of problems — particularly problems involving complex, interrelated data with behavior that varies by type. Many excellent programs are entirely procedural. The skill is knowing when each paradigm serves you best. By the end of Part III, you will have that judgment.
A Brief History of OOP
Object-oriented programming did not appear out of thin air. It has a lineage that stretches back to the 1960s, and understanding that lineage helps explain why OOP works the way it does.
The first object-oriented language was Simula, created by Ole-Johan Dahl and Kristen Nygaard at the Norwegian Computing Center in 1967. Simula was designed for simulation — modeling real-world systems like traffic flow, factory operations, and queuing networks. Dahl and Nygaard realized that the most natural way to model a system was to represent each entity (a car, a machine, a customer) as a self-contained unit with its own data and behavior. They called these units objects, and the templates from which objects were created classes.
This insight — that programs are clearest when they mirror the structure of the problem domain — is the philosophical foundation of all OOP. A program about bank accounts should contain objects that represent bank accounts. A program about students should contain objects that represent students. A game should contain objects that represent rooms, items, and players.
Alan Kay, who coined the term "object-oriented programming" while developing Smalltalk at Xerox PARC in the 1970s, pushed the idea further. For Kay, the essence of OOP was not classes or inheritance but messaging — the idea that objects communicate by sending messages to each other, and that each object decides how to respond to a message based on its own internal logic. This is a subtle but important distinction: the emphasis is on interaction between autonomous units rather than on data structures with attached procedures.
Bjarne Stroustrup brought OOP to the mainstream with C++ in the 1980s, combining OOP with the performance of C. Anders Hejlsberg — who had previously created Turbo Pascal at Borland — later designed Delphi's Object Pascal (1995) and C# (2000), both of which have clean, well-designed OOP systems. The Object Pascal class model we use in Free Pascal descends directly from Hejlsberg's work on Delphi.
📜 Historical Context: Wirth and OOP Niklaus Wirth, Pascal's creator, had a complicated relationship with OOP. He believed strongly in data abstraction and modular design — ideas closely related to OOP — but he was skeptical of the complexity that OOP languages often introduced. His later languages (Modula-2, Oberon) included object-oriented features but in deliberately minimalist forms. The Object Pascal extensions we use today were developed primarily by Borland and later refined by the Free Pascal and Delphi communities, building on Wirth's foundation but going further than Wirth himself might have gone.
This history matters because it tells us something important: OOP is not a religious dogma. It is a set of practical techniques for managing complexity, born from the real-world experience of programmers who needed to model complex systems. When you use classes and objects, you are drawing on sixty years of accumulated wisdom about how to structure programs that remain comprehensible as they grow.
16.2 From Records to Classes: The Conceptual Bridge
If you understand records, you are already halfway to understanding classes. Let us build the bridge.
A record groups related data together:
type
TStudent = record
Name: string;
Grade: Integer;
end;
A class groups related data and behavior together:
type
TStudent = class
Name: string;
Grade: Integer;
procedure Display;
function IsPassing: Boolean;
end;
That is the core idea. A class is a record that also contains the procedures and functions that operate on its data. The data items are called fields (just as in records). The procedures and functions are called methods. Together, fields and methods are called members of the class.
This is not just syntactic sugar. It represents a fundamental shift in how we think about program design:
| Procedural Thinking | Object-Oriented Thinking |
|---|---|
| "I have data and I have procedures that operate on data." | "I have objects that contain both data and the operations on that data." |
| "What steps do I perform?" | "What things exist, and what can they do?" |
| "Procedures are the primary unit of organization." | "Classes are the primary unit of organization." |
| Data and behavior are separate concerns. | Data and behavior are unified in a single entity. |
💡 Intuition: The Object as a Self-Sufficient Unit Think of a record as a box of parts sitting on a workbench. To do anything with the parts, you need external tools (procedures) and you need to know which tools work with which box. A class, by contrast, is a box of parts that comes with its own tools built in. You pick up the box and everything you need is right there. This self-sufficiency is the heart of object-oriented design.
The Vocabulary
Before we go further, let us nail down the vocabulary. These terms have precise meanings in OOP, and using them correctly matters:
-
Class: A blueprint or template that defines what data (fields) and behavior (methods) a particular kind of object will have.
TStudentis a class. It describes what every student object will look like, but it is not itself a student. -
Object (Instance): A specific, concrete realization of a class, living in memory. If
TStudentis the blueprint, thenAliceandBobare objects — actual students with actual names and grades. We also say Alice and Bob are instances ofTStudent. -
Field (Instance Variable): A piece of data that belongs to an object. Each
TStudentobject has its ownNameand its ownGrade. -
Method: A procedure or function that belongs to a class and operates on the object's data.
DisplayandIsPassingare methods. -
Instantiation: The act of creating an object from a class. We instantiate
TStudentto get a specific student object.
16.3 Defining a Class in Object Pascal
Let us write our first real class. In Free Pascal, you must enable Object Pascal mode to use classes. You do this with the {$mode objfpc}` directive (or `{$mode delphi}). Throughout this textbook, we will use {$mode objfpc}` with `{$H+} for long strings:
{$mode objfpc}{$H+}
type
TStudent = class
private
FName: string;
FGrade: Integer;
public
constructor Create(const AName: string; AGrade: Integer);
destructor Destroy; override;
procedure Display;
function IsPassing: Boolean;
property Name: string read FName;
property Grade: Integer read FGrade write FGrade;
end;
There is a lot happening here. Let us break it down piece by piece.
The Class Declaration
TStudent = class
This declares a new type called TStudent. By convention in Pascal, class names begin with T (for "Type") — the same convention we used for records. The keyword class tells the compiler this is a class type, not a record.
In Object Pascal, every class implicitly descends from TObject, the root class of the entire class hierarchy. Writing TStudent = class is equivalent to writing TStudent = class(TObject). This means TStudent automatically inherits a set of basic capabilities from TObject — including a default constructor and destructor. We will use this inheritance in the next chapter; for now, just know that it is there.
Fields
private
FName: string;
FGrade: Integer;
Fields are declared exactly like variables in a record. The F prefix (for "Field") is a naming convention — not a language requirement — that helps distinguish fields from local variables and parameters. We will discuss the private keyword in Section 16.6.
Methods
public
procedure Display;
function IsPassing: Boolean;
Method declarations look like forward declarations of procedures and functions. They tell the compiler what methods the class has and what their signatures (parameters and return types) are. The actual implementation — the method body — is written separately, outside the class declaration.
Implementing Methods
The method bodies are written using a special syntax that ties the method to its class:
procedure TStudent.Display;
begin
WriteLn('Student: ', FName, ' | Grade: ', FGrade);
end;
function TStudent.IsPassing: Boolean;
begin
Result := FGrade >= 60;
end;
The TStudent. prefix is crucial. It tells the compiler that Display is not a standalone procedure — it is a method belonging to the TStudent class. Inside the method body, you can directly access the object's fields (FName, FGrade) without any special syntax. This is because every method implicitly receives a reference to the object it is operating on. We will see this explicitly when we discuss Self in Section 16.10.
📊 Key Difference from Standalone Procedures
In procedural code, you would write procedure Display(const Student: TStudentRecord) and pass the record as a parameter. In OOP, the object is implicit — the method "lives inside" the class, so it already knows which object's data to work with. You never need to pass the object as a parameter to its own methods.
Naming Conventions: A Summary
Object Pascal has well-established naming conventions that make code easier to read. You are not required to follow them — the compiler does not enforce them — but the entire Pascal community uses them, and you should too:
| Prefix | Meaning | Example |
|---|---|---|
T |
Type (class, record, enumeration) | TStudent, TExpenseList |
F |
Field (private instance variable) | FName, FBalance |
A |
Argument (parameter) | AName, AAmount |
I |
Interface type | ISerializable, IComparable |
E |
Exception class | EInvalidAmount, ENotFound |
These conventions serve a practical purpose: when you see FName in a method body, you immediately know it is a field of the current object. When you see AName, you know it is a parameter. When you see Name without a prefix, you know it is a property. This disambiguation eliminates an entire category of bugs where a local variable accidentally shadows a field.
The Complete Structure of a Class Unit
In practice, a class is typically defined in a unit (a separate file that can be used by other programs). The class declaration goes in the interface section, and the method implementations go in the implementation section:
unit StudentUnit;
{$mode objfpc}{$H+}
interface
type
TStudent = class
private
FName: string;
FGrade: Integer;
public
constructor Create(const AName: string; AGrade: Integer);
destructor Destroy; override;
procedure Display;
function IsPassing: Boolean;
property Name: string read FName;
property Grade: Integer read FGrade;
end;
implementation
constructor TStudent.Create(const AName: string; AGrade: Integer);
begin
inherited Create;
FName := AName;
FGrade := AGrade;
end;
destructor TStudent.Destroy;
begin
inherited Destroy;
end;
procedure TStudent.Display;
begin
WriteLn('Student: ', FName, ' | Grade: ', FGrade);
end;
function TStudent.IsPassing: Boolean;
begin
Result := FGrade >= 60;
end;
end.
Other programs then use this unit with the uses clause: uses StudentUnit;. This separation of interface from implementation is a powerful feature: users of the class see only the declaration (what the class offers), not the implementation (how it works internally). This is encapsulation at the file level, and it is one of the reasons Pascal excels at large-scale programming.
16.4 Creating and Using Objects
A class is a blueprint. To do anything useful, we need to create objects — actual instances of the class living in memory. Here is how:
var
Student: TStudent;
begin
Student := TStudent.Create('Alice Chen', 85);
try
Student.Display; { Output: Student: Alice Chen | Grade: 85 }
if Student.IsPassing then
WriteLn('Passing!')
else
WriteLn('Not passing.');
finally
Student.Free; { Always free what you create }
end;
end.
Let us unpack this carefully.
Declaration vs. Creation
var
Student: TStudent;
This declares a variable of type TStudent, but it does not create an object. After this declaration, Student is nil — it points to nothing. This is fundamentally different from records, where var S: TStudentRecord immediately reserves memory for all the record's fields.
⚠️ Critical Concept: Classes Use Reference Semantics A class-type variable is always a reference (essentially a pointer) to an object on the heap. Declaring the variable creates the reference; you must separately create the object. If you try to use a class variable before creating the object, you will get a runtime error (an access violation).
The Constructor Call
Student := TStudent.Create('Alice Chen', 85);
This calls the constructor — a special method that allocates memory for the object on the heap and initializes its fields. The result is assigned to Student, which now holds a reference to the newly created object. We will explore constructors in detail in Section 16.5.
Calling Methods
Student.Display;
The dot notation Student.Display means "call the Display method on the object that Student refers to." This is the fundamental syntax of OOP: object.method. The object to the left of the dot is the receiver — the object whose data the method will work with.
Freeing Objects
Student.Free;
Because objects live on the heap, they do not automatically disappear when the variable goes out of scope. You must explicitly free them. The Free method (inherited from TObject) first checks whether the reference is nil, and if not, calls the object's destructor and releases its memory.
The try...finally block ensures that Free is called even if an exception occurs. This pattern is essential in Object Pascal and you should use it every time you create an object:
Obj := TSomething.Create;
try
{ use Obj }
finally
Obj.Free;
end;
⚠️ Memory Leak Warning
If you create an object and forget to free it, the memory it occupies is never reclaimed (until the program exits). This is called a memory leak. In a short-running program, it may not matter. In a long-running server or desktop application, memory leaks can accumulate until the system runs out of memory. Always pair every Create with a Free.
Multiple Objects of the Same Class
One of the most powerful aspects of classes is that you can create as many objects as you need from a single class definition. Each object has its own independent copy of the fields:
var
Alice, Bob, Carol: TStudent;
begin
Alice := TStudent.Create('Alice Chen', 92);
Bob := TStudent.Create('Bob Martinez', 58);
Carol := TStudent.Create('Carol Williams', 75);
try
Alice.Display; { Alice Chen | Grade: 92 }
Bob.Display; { Bob Martinez | Grade: 58 }
Carol.Display; { Carol Williams | Grade: 75 }
{ Each object is independent: }
WriteLn(Alice.IsPassing); { True }
WriteLn(Bob.IsPassing); { False }
WriteLn(Carol.IsPassing); { True }
finally
Alice.Free;
Bob.Free;
Carol.Free;
end;
end.
Three objects, three independent memory blocks on the heap, three independent sets of fields. Changing Alice's grade does not affect Bob or Carol. The class defined the template once; we instantiated it three times.
This may seem obvious, but it is worth pausing to appreciate. In the procedural version, managing multiple students required parallel arrays or an array of records, with index variables to keep track of which student we were operating on. With classes, each student is a self-contained unit. You do not need to pass index numbers to methods. You do not need to remember which array slot belongs to which student. The object is the student.
FreeAndNil: A Safer Alternative
After freeing an object, the variable still holds the old (now invalid) address. If you accidentally use it, you get a crash. A safer pattern is FreeAndNil, which frees the object and sets the variable to nil:
uses SysUtils;
FreeAndNil(Student);
{ Now Student is nil. Any accidental use will trigger a clear }
{ "Access violation" at the nil address, rather than accessing }
{ random memory that may have been reallocated. }
FreeAndNil is especially useful when the variable might be checked for nil later in the program. It is a best practice in production code, though for brevity we will use Free in most examples.
16.5 Constructors and Destructors
Constructors: Building an Object
A constructor is a special method that initializes a new object. In Object Pascal, constructors are declared with the keyword constructor and are conventionally named Create:
constructor TStudent.Create(const AName: string; AGrade: Integer);
begin
inherited Create; { Call the parent class's constructor first }
FName := AName;
FGrade := AGrade;
end;
Several things to note:
-
inherited Createcalls the constructor of the parent class (TObject). This ensures that the base object infrastructure is properly initialized. You should always callinherited Createat the beginning of your constructor. Omitting it can cause subtle bugs. -
Parameter naming convention: The
Aprefix (for "Argument") distinguishes constructor parameters from fields.ANameis the argument;FNameis the field. This is a convention, not a language rule, but it eliminates ambiguity. -
A constructor can do more than assign fields. It can open files, establish network connections, allocate internal data structures — anything needed to put the object into a valid initial state.
Destructors: Tearing Down an Object
A destructor is the opposite of a constructor: it cleans up resources before the object's memory is released. In Object Pascal, destructors are declared with the keyword destructor and are conventionally named Destroy:
destructor TStudent.Destroy;
begin
{ Clean up any resources the object owns }
{ For TStudent, there's nothing extra to do — strings are managed automatically }
inherited Destroy; { Always call inherited Destroy LAST }
end;
The override directive in the class declaration is required because Destroy is a virtual method inherited from TObject:
destructor Destroy; override;
We will explain virtual methods and override fully in Chapter 17. For now, know that override is required on Destroy and that you should always call inherited Destroy at the end of your destructor (not the beginning — you want to clean up your own resources before the parent class cleans up its resources).
💡 Intuition: Constructor and Destructor as Bookends Think of a constructor as opening night of a play: you set the stage, place the props, dim the lights — everything needed before the performance can begin. The destructor is strike night: you take down the set, return the props, lock the theater. The performance (the object's lifetime) happens between these two events.
Resource Management Example
Here is a more interesting example where the destructor actually matters:
type
TLogFile = class
private
FFileName: string;
FHandle: TextFile;
FIsOpen: Boolean;
public
constructor Create(const AFileName: string);
destructor Destroy; override;
procedure WriteEntry(const AMessage: string);
end;
constructor TLogFile.Create(const AFileName: string);
begin
inherited Create;
FFileName := AFileName;
AssignFile(FHandle, FFileName);
Rewrite(FHandle);
FIsOpen := True;
end;
destructor TLogFile.Destroy;
begin
if FIsOpen then
CloseFile(FHandle);
inherited Destroy;
end;
procedure TLogFile.WriteEntry(const AMessage: string);
begin
WriteLn(FHandle, FormatDateTime('yyyy-mm-dd hh:nn:ss', Now), ' | ', AMessage);
end;
In this example, the constructor opens a file and the destructor closes it. If we forget to free the TLogFile object, the file handle leaks — the file stays open, potentially preventing other programs from accessing it. The constructor/destructor pattern ensures that resource acquisition and release are tied to the object's lifetime.
16.6 Encapsulation: Public, Private, Protected
Encapsulation is the practice of hiding internal details and exposing only a controlled interface. It is one of the three pillars of object-oriented programming (along with inheritance and polymorphism, which we will cover in Chapters 17 and 18).
In Object Pascal, encapsulation is achieved through visibility specifiers:
Private
private
FName: string;
FBalance: Currency;
Members declared in the private section are accessible only within the class itself (and, in Free Pascal's default mode, within the same unit — we will address this nuance shortly). No code outside the class can read or modify FName or FBalance directly.
Why would you hide data? Because hidden data is controlled data. If FBalance is private, the only way to change it is through the class's methods — and those methods can enforce rules:
procedure TBankAccount.Deposit(AAmount: Currency);
begin
if AAmount <= 0 then
raise Exception.Create('Deposit amount must be positive');
FBalance := FBalance + AAmount;
end;
Now it is impossible for any code to set the balance to a negative number by accident. The class protects its own invariants.
Public
public
constructor Create(const AOwner: string; AInitialBalance: Currency);
procedure Deposit(AAmount: Currency);
procedure Withdraw(AAmount: Currency);
function GetBalance: Currency;
Members declared in the public section are accessible from anywhere. These are the class's interface — the operations it exposes to the outside world.
Protected
protected
procedure RecalculateInterest;
Members declared in the protected section are accessible within the class and within any descendant classes (subclasses), but not from outside code. We will use protected members extensively in Chapter 17 when we discuss inheritance. For now, think of protected as "private, but my children can see it."
Strict Private and Strict Protected
Free Pascal (and Delphi) also support strict private and strict protected. These enforce visibility strictly — even code in the same unit cannot access strict private members:
type
TAccount = class
strict private
FPin: string; { Truly invisible outside the class }
private
FBalance: Currency; { Visible within the same unit }
public
{ ... }
end;
For this textbook, we will use private and note when strict private would be more appropriate in production code.
📊 Visibility Summary
| Specifier | Same class | Same unit | Descendants | Other units |
|---|---|---|---|---|
strict private |
Yes | No | No | No |
private |
Yes | Yes | Yes (same unit) | No |
strict protected |
Yes | No | Yes | No |
protected |
Yes | Yes | Yes | No |
public |
Yes | Yes | Yes | Yes |
✅ Design Principle: Minimize Visibility Make everything as private as possible. Only make a member public if external code genuinely needs to access it. This reduces the "surface area" of your class — the number of things that can go wrong when other code interacts with it. Start private; promote to protected or public only when you have a specific reason.
16.7 Properties: Controlled Access
What if you want external code to read a field but not write to it? Or you want writes to go through validation? This is where properties come in.
A property looks like a field to the code that uses it, but behind the scenes it can be backed by a getter method, a setter method, or both:
type
TStudent = class
private
FName: string;
FGrade: Integer;
procedure SetGrade(AValue: Integer);
public
property Name: string read FName; { Read-only }
property Grade: Integer read FGrade write SetGrade; { Read-write with validation }
end;
procedure TStudent.SetGrade(AValue: Integer);
begin
if (AValue < 0) or (AValue > 100) then
raise Exception.CreateFmt('Invalid grade: %d. Must be 0-100.', [AValue]);
FGrade := AValue;
end;
Now external code can write:
Student.Grade := 92; { Calls SetGrade(92) behind the scenes }
WriteLn(Student.Grade); { Reads FGrade directly }
WriteLn(Student.Name); { Reads FName directly }
Student.Name := 'Bob'; { COMPILE ERROR: Name is read-only }
Properties give you the best of both worlds: the clean syntax of field access with the safety of method-based validation. They are a signature feature of Object Pascal and are used extensively in professional code.
💡 Intuition: Properties as Gatekeepers Think of a property as a receptionist at a building entrance. When someone wants to enter (write), the receptionist checks their ID and decides whether to let them in. When someone wants to know who is inside (read), the receptionist can provide that information. The building's occupants (private fields) never deal with random visitors directly.
Direct Field Access vs. Methods
A property can use either direct field access or a method for its getter/setter:
property Grade: Integer read FGrade write SetGrade;
{ read → direct field access (fast, no validation on read) }
{ write → method call (slower, but can validate, log, notify) }
In practice, most properties use direct field access for reading and a method for writing. But you can use methods for both:
property FormattedName: string read GetFormattedName;
where GetFormattedName might return FLastName + ', ' + FFirstName.
Default Properties (Array Properties)
Object Pascal supports a special kind of property called a default array property. This lets you use bracket notation ([]) directly on an object:
type
TIntList = class
private
FItems: array of Integer;
FCount: Integer;
function GetItem(AIndex: Integer): Integer;
procedure SetItem(AIndex: Integer; AValue: Integer);
public
property Items[AIndex: Integer]: Integer read GetItem write SetItem; default;
property Count: Integer read FCount;
end;
The default keyword at the end of the property declaration means you can write:
WriteLn(MyList[3]); { Equivalent to MyList.Items[3] }
MyList[3] := 42; { Equivalent to MyList.Items[3] := 42 }
This is how the built-in TStringList and other collection classes in Free Pascal's class library work. We will create our own collection class with a default property in the PennyWise checkpoint later in this chapter.
Why Properties Matter for Future-Proofing
There is a pragmatic reason to use properties even when you do not currently need validation: future-proofing. Suppose you start with a public field:
public
Name: string; { Direct field — no property }
Hundreds of lines of code access Student.Name directly. Now you discover you need to add logging every time the name changes. You must convert the field to a property with a setter — and while the syntax of accessing a property is the same as accessing a field, you have to add the property declaration, write the getter/setter, rename the field, and ensure nothing breaks. If you had used a property from the start, you would simply add the setter method — no changes to any calling code.
This is why experienced Object Pascal programmers use properties by default, even for simple fields with no current validation need. The cost is one extra line in the class declaration. The benefit is that you can change the implementation at any time without affecting any code that uses the class.
16.8 Methods: Procedures and Functions Inside Classes
Methods are the behavior half of a class. Syntactically, they are procedures and functions that are declared inside a class and implemented with the ClassName.MethodName prefix. But methods have capabilities that standalone procedures do not.
Instance Methods
The most common kind of method operates on a specific object's data:
procedure TStudent.Display;
begin
WriteLn('Name: ', FName, ' | Grade: ', FGrade);
end;
When you call Alice.Display, the method executes with FName and FGrade referring to Alice's data. When you call Bob.Display, the same code executes but with FName and FGrade referring to Bob's data. The method is shared; the data is per-object.
Methods Can Call Other Methods
Methods can call other methods of the same class:
type
TBankAccount = class
private
FOwner: string;
FBalance: Currency;
public
constructor Create(const AOwner: string; ABalance: Currency);
procedure Deposit(AAmount: Currency);
procedure Withdraw(AAmount: Currency);
procedure Transfer(AAmount: Currency; ATarget: TBankAccount);
procedure PrintStatement;
end;
procedure TBankAccount.Transfer(AAmount: Currency; ATarget: TBankAccount);
begin
Withdraw(AAmount); { Calls Self.Withdraw }
ATarget.Deposit(AAmount); { Calls Deposit on the target account }
end;
Notice how Transfer calls Withdraw on the current object (no prefix needed) and Deposit on a different object (ATarget.Deposit). Methods can also receive other objects as parameters, enabling rich interactions between objects.
Class Methods (Static Methods)
Sometimes you want a method that belongs to the class itself rather than to any specific object. These are called class methods:
type
TMathHelper = class
public
class function Max(A, B: Integer): Integer;
class function Min(A, B: Integer): Integer;
end;
class function TMathHelper.Max(A, B: Integer): Integer;
begin
if A > B then
Result := A
else
Result := B;
end;
You call class methods on the class name, not on an instance:
WriteLn(TMathHelper.Max(10, 20)); { 20 — no object needed }
Class methods cannot access instance fields (since there is no specific object). They are useful for utility functions that logically belong to a class but do not need an instance.
16.9 Class vs. Record: Reference Semantics vs. Value Semantics
This section addresses one of the most important — and most frequently misunderstood — distinctions in Object Pascal. If you remember only one thing from this chapter, make it this.
Records: Value Semantics
When you assign one record to another, the entire contents are copied:
var
A, B: TPoint; { TPoint = record X, Y: Integer; end; }
begin
A.X := 10;
A.Y := 20;
B := A; { B gets a COPY of A }
B.X := 99; { Changing B does NOT affect A }
WriteLn(A.X); { Output: 10 — A is unchanged }
end;
After B := A, there are two independent copies of the data. Modifying one has no effect on the other. This is value semantics, and it is how records, integers, and other "simple" types work.
Classes: Reference Semantics
When you assign one class variable to another, only the reference (the pointer) is copied — not the object itself:
var
A, B: TStudent;
begin
A := TStudent.Create('Alice', 85);
B := A; { B now points to the SAME object as A }
B.Grade := 99; { This changes the object that BOTH A and B refer to }
WriteLn(A.Grade); { Output: 99 — because A and B are the same object! }
A.Free; { Frees the one object }
{ B is now a dangling reference — do NOT use it }
end;
After B := A, there is still only one object. Both A and B are references to it. This is reference semantics.
⚠️ The Aliasing Trap Reference semantics means that two variables can refer to the same object without you realizing it. If you modify the object through one variable, the change is visible through the other. If you free the object through one variable, the other becomes a dangling reference. This is powerful (it enables efficient data sharing) but dangerous (it enables subtle bugs). Always be clear about whether your variables are independent objects or aliases for the same object.
Side-by-Side Comparison
| Feature | Record | Class |
|---|---|---|
| Declared with | record |
class |
| Memory allocation | Stack (typically) | Heap (always) |
| Assignment | Copies all data (value) | Copies the reference (pointer) |
| Equality comparison | Compare field by field | Compare references (same object?) |
| Contains methods | Only in advanced records | Yes, naturally |
| Inheritance | No (in standard Pascal) | Yes |
| Must be freed | No | Yes (Free) |
nil possible |
No | Yes |
| Default parent | None | TObject |
💡 Intuition: The House and the Address A record is like a physical house — when you "copy" it, you build an entirely new house with the same layout and furniture. A class variable is like a piece of paper with a house's address on it. When you "copy" it, you just write the address on a second piece of paper. Both papers lead to the same house. Demolish the house, and both papers become useless.
When to Use Which
Use records when you have small, simple data that is naturally value-like: a point, a color, a date, a small data tuple. Copying is cheap and aliasing would be confusing.
Use classes when you have complex data with behavior, when you need inheritance, when objects have identity (two students with the same name are still different students), or when objects own resources that must be cleaned up.
Passing Objects to Procedures
Because class variables are references, passing a class variable to a procedure gives the procedure access to the same object — even without var:
procedure DisplayStudent(AStudent: TStudent);
begin
{ AStudent is a COPY of the reference — but it points to the SAME object }
AStudent.Display;
end;
procedure ChangeGrade(AStudent: TStudent; ANewGrade: Integer);
begin
{ This modifies the original object! }
AStudent.Grade := ANewGrade;
end;
This surprises many procedural programmers. With records, passing without var means the procedure gets a copy and cannot modify the original. With classes, passing without var copies the reference, not the object — so the procedure can modify the original object's fields through its copy of the reference.
When would you use var with a class parameter? Only when you want the procedure to change which object the variable points to — for example, to replace one object with another:
procedure ReplaceStudent(var AStudent: TStudent; const ANewName: string);
begin
AStudent.Free;
AStudent := TStudent.Create(ANewName, 0); { Caller's variable now points to the new object }
end;
Without var, the caller's variable would still point to the old (now freed) object — a dangling reference.
Ownership and Lifetime: A Critical Design Question
When one object holds a reference to another, you must answer a critical question: who is responsible for freeing it?
The two patterns are:
-
Ownership: The containing object created the other object (or had it given to it) and is responsible for freeing it. Example:
TBudgetowns itsTExpenseList. -
Borrowing: The containing object holds a reference to an object that someone else owns. It uses the object but does not free it. Example: A
TReportobject might reference aTBudgetto generate reports, but it does not own the budget.
Getting this wrong is one of the most common sources of bugs in Object Pascal:
- If two objects both try to own (and free) the same third object, you get a double-free crash.
- If no object owns a created object, you get a memory leak.
- If an object tries to use a borrowed reference after the owner has freed it, you get a use-after-free crash.
We will revisit ownership patterns throughout Part III. For now, adopt this rule: every object must have exactly one owner, and that owner is responsible for freeing it.
16.10 Self: The Current Object Reference
Inside a method, you sometimes need to refer to the current object explicitly. Object Pascal provides the keyword Self for this purpose:
procedure TStudent.EnrollIn(ACourse: TCourse);
begin
ACourse.AddStudent(Self); { Pass the current student object to the course }
end;
Self is an implicit parameter that the compiler passes to every instance method. It is a reference to the object the method was called on. Most of the time, you do not need to use Self explicitly — when you write FName inside a method, the compiler understands you mean Self.FName. But there are cases where Self is necessary:
- Passing the current object to another method (as shown above).
- Disambiguating when a parameter has the same name as a field (though our
Fprefix convention prevents this). - Returning the current object for method chaining:
function TQueryBuilder.Where(const ACondition: string): TQueryBuilder;
begin
FConditions.Add(ACondition);
Result := Self; { Return the current object so calls can be chained }
end;
{ Usage: Query.Select('*').Where('age > 18').Where('active = true') }
📊 Self vs. this
If you have seen other OOP languages, Self in Pascal is equivalent to this in Java, C#, and C++, and self in Python (though Python requires you to list it explicitly as a parameter). The concept is the same: it is the object on which the current method was called.
16.11 From Procedural PennyWise to OOP PennyWise
Now let us apply everything we have learned. We will take the procedural PennyWise design from earlier chapters and redesign it using classes. This is not just a syntactic translation — it is a rethinking of the program's architecture.
The Procedural Version (What We Had)
type
TExpenseRecord = record
Description: string;
Amount: Currency;
Category: string;
Date: TDateTime;
end;
var
Expenses: array[1..1000] of TExpenseRecord;
ExpenseCount: Integer;
procedure AddExpense(Desc: string; Amt: Currency; Cat: string; Dt: TDateTime);
procedure PrintAllExpenses;
procedure TotalByCategory(Cat: string; var Total: Currency);
function GetExpenseCount: Integer;
Problems: data and behavior are separated; no encapsulation; global state; rigid size limit; no validation.
The OOP Version (What We Are Building)
type
TExpense = class
private
FDescription: string;
FAmount: Currency;
FCategory: string;
FDate: TDateTime;
procedure SetAmount(AValue: Currency);
procedure SetCategory(AValue: string);
public
constructor Create(const ADesc: string; AAmount: Currency;
const ACat: string; ADate: TDateTime);
destructor Destroy; override;
procedure Display;
function ToString: string;
property Description: string read FDescription;
property Amount: Currency read FAmount write SetAmount;
property Category: string read FCategory write SetCategory;
property Date: TDateTime read FDate;
end;
TExpenseList = class
private
FExpenses: array of TExpense;
FCount: Integer;
function GetExpense(AIndex: Integer): TExpense;
public
constructor Create;
destructor Destroy; override;
procedure Add(AExpense: TExpense);
procedure Remove(AIndex: Integer);
function TotalByCategory(const ACat: string): Currency;
function TotalAll: Currency;
procedure DisplayAll;
property Count: Integer read FCount;
property Items[AIndex: Integer]: TExpense read GetExpense; default;
end;
TBudget = class
private
FExpenses: TExpenseList;
FBudgetLimit: Currency;
FOwnerName: string;
public
constructor Create(const AOwner: string; ALimit: Currency);
destructor Destroy; override;
procedure AddExpense(const ADesc: string; AAmount: Currency;
const ACat: string);
function IsOverBudget: Boolean;
function RemainingBudget: Currency;
procedure PrintSummary;
property OwnerName: string read FOwnerName;
property BudgetLimit: Currency read FBudgetLimit write FBudgetLimit;
property Expenses: TExpenseList read FExpenses;
end;
What Changed and Why
1. Data and behavior are unified. TExpense knows how to display itself. TExpenseList knows how to total itself. TBudget knows whether it is over budget. No external procedures needed.
2. Data is protected. FAmount is private; it can only be changed through SetAmount, which validates that the amount is positive. FDescription is read-only after creation — you cannot accidentally overwrite an expense's description.
3. No global state. Everything is encapsulated inside objects. You can have multiple TBudget objects, each with its own TExpenseList, completely independent. This makes the program testable and reusable.
4. Dynamic sizing. TExpenseList uses a dynamic array, growing as needed. No more arbitrary array[1..1000] limits.
5. Composability. TBudget contains a TExpenseList. Objects can contain other objects, forming natural hierarchies. This composition is one of the most powerful ideas in OOP.
6. Resource management. TBudget.Destroy frees the TExpenseList, which frees all the TExpense objects it contains. The cleanup chain is automatic once you free the top-level object.
Let us look at the key implementations:
constructor TExpense.Create(const ADesc: string; AAmount: Currency;
const ACat: string; ADate: TDateTime);
begin
inherited Create;
FDescription := ADesc;
SetAmount(AAmount); { Use the setter for validation }
SetCategory(ACat); { Use the setter for validation }
FDate := ADate;
end;
procedure TExpense.SetAmount(AValue: Currency);
begin
if AValue < 0 then
raise Exception.Create('Expense amount cannot be negative');
FAmount := AValue;
end;
procedure TExpense.SetCategory(AValue: string);
begin
if AValue = '' then
raise Exception.Create('Category cannot be empty');
FCategory := AValue;
end;
Notice how even the constructor uses the setter methods for validation. This ensures that a TExpense object is never in an invalid state — not even during construction.
destructor TBudget.Destroy;
begin
FExpenses.Free; { Free the expense list (which frees all expenses) }
inherited Destroy;
end;
procedure TBudget.AddExpense(const ADesc: string; AAmount: Currency;
const ACat: string);
var
Exp: TExpense;
begin
Exp := TExpense.Create(ADesc, AAmount, ACat, Now);
FExpenses.Add(Exp);
end;
function TBudget.IsOverBudget: Boolean;
begin
Result := FExpenses.TotalAll > FBudgetLimit;
end;
function TBudget.RemainingBudget: Currency;
begin
Result := FBudgetLimit - FExpenses.TotalAll;
end;
And here is how a user interacts with the OOP PennyWise:
var
Budget: TBudget;
begin
Budget := TBudget.Create('Alice', 500.00);
try
Budget.AddExpense('Groceries', 45.50, 'Food');
Budget.AddExpense('Bus pass', 80.00, 'Transport');
Budget.AddExpense('Concert tickets', 120.00, 'Entertainment');
Budget.AddExpense('Textbooks', 95.00, 'Education');
Budget.PrintSummary;
if Budget.IsOverBudget then
WriteLn('WARNING: Over budget!')
else
WriteLn('Remaining: $', Budget.RemainingBudget:0:2);
finally
Budget.Free;
end;
end.
Compare this to the procedural version. The OOP version is not shorter — it is actually longer. But it is clearer. Each object has a single, well-defined responsibility. The data is protected. The interface is clean. And when we want to extend PennyWise in future chapters — adding income, savings goals, multiple accounts — the OOP architecture gives us clean extension points instead of requiring us to rewrite existing code.
🔗 Cross-Reference: Progressive Project
The full OOP PennyWise implementation is in the project checkpoint file (code/project-checkpoint.pas). In Chapter 17, we will extend this design with inheritance to create TIncome as a sibling of TExpense, both descending from a common TTransaction class. In Chapter 18, we will add interfaces so that any object can be serializable or displayable. The OOP foundation you build here will carry the project through the rest of Part III.
16.12 Project Checkpoint: PennyWise OOP
Your PennyWise project has reached a major milestone. Here is what you should have after completing this chapter:
New Types
TExpense— a class representing a single expense, with private fields, validated properties, a constructor, a destructor, and aDisplaymethod.TExpenseList— a class that manages a dynamic collection ofTExpenseobjects, withAdd,Remove,TotalByCategory,TotalAll, andDisplayAllmethods.TBudget— a class that composes aTExpenseListwith a budget limit and owner name, providing high-level operations likeAddExpense,IsOverBudget, andPrintSummary.
Design Decisions
TExpensevalidates itsAmount(must be non-negative) andCategory(must be non-empty) through property setters.TExpenseListowns itsTExpenseobjects and frees them in its destructor.TBudgetowns aTExpenseListand frees it in its destructor.- All classes use
try...finallyblocks in client code to ensure proper cleanup.
Testing Your Implementation
Try the following scenarios to verify your code works correctly:
- Create a
TBudget, add several expenses, and print the summary. - Try to create an expense with a negative amount — verify it raises an exception.
- Try to create an expense with an empty category — verify it raises an exception.
- Create two separate
TBudgetobjects and verify they are independent. - Check that
IsOverBudgetreturnsTruewhen expenses exceed the limit.
16.13 Chapter Summary
This chapter has been a turning point. We have moved from procedural thinking — "what steps do I perform?" — to object-oriented thinking — "what things exist in my problem, and what can they do?" Let us recap the key ideas.
Classes are blueprints; objects are instances. A class defines the fields and methods that its objects will have. Creating an object (instantiation) allocates memory on the heap and initializes it. Every class in Object Pascal implicitly descends from TObject.
Fields hold data; methods define behavior. By placing both inside a class, we unify data and behavior into a single, self-contained unit. This is the fundamental idea of OOP.
Constructors initialize; destructors clean up. The constructor Create sets up the object's initial state. The destructor Destroy releases resources. Always call inherited Create at the beginning of your constructor and inherited Destroy at the end of your destructor.
Encapsulation protects invariants. By making fields private and exposing them through public methods or properties, we ensure that objects are always in a valid state. External code cannot corrupt internal data because it cannot access internal data directly.
Properties provide controlled access. A property looks like a field to the caller but can use getter and setter methods behind the scenes, enabling validation, computation, and logging without changing the external interface.
Classes use reference semantics; records use value semantics. Assigning one class variable to another copies the reference, not the object. Both variables then point to the same object. This is powerful for sharing but requires care to avoid aliasing bugs and memory leaks.
Self refers to the current object. Inside a method, Self is an implicit reference to the object the method was called on. Use it when you need to pass the current object to another method or disambiguate names.
The PennyWise redesign demonstrates the benefits. By converting PennyWise from procedural to OOP, we gained encapsulation, validation, composability, and clear separation of responsibilities — all without losing any functionality.
🔗 Looking Ahead
In Chapter 17, we will discover inheritance — the ability for one class to extend another, gaining its fields and methods while adding new ones. This will let us create a TTransaction base class with TExpense and TIncome as specialized descendants. In Chapter 18, we will learn about interfaces and abstract classes, which define contracts that multiple unrelated classes can fulfill. Together, these three chapters — classes, inheritance, and interfaces — form the core of object-oriented programming in Pascal.
"The real voyage of discovery consists not in seeking new landscapes, but in having new eyes." — Marcel Proust
You have new eyes now. Every program you look at — every problem you encounter — you will start to see not just the steps to perform but the objects that naturally exist in the problem domain. That shift in perception is the threshold concept of this chapter, and it will serve you for the rest of your programming career.