Classes, Objects, and Encapsulation

Topics Covered

What Are Classes and Objects

Classes as Blueprints

Objects as Instances

Why Not Just Use Dictionaries or Structs?

Identity, State, and Behavior

Fields, Constructors, and Methods

Fields Store State

Constructors Enforce Invariants

Methods Define Behavior

Instance Methods vs. Class Methods vs. Static Methods

Method Signatures as Contracts

Encapsulation and Information Hiding

The Problem Encapsulation Solves

Interface vs. Implementation

Encapsulation in Different Languages

Access Control and Immutability

Access Modifiers in Practice

Immutability: Objects That Cannot Change

When to Choose Mutable vs. Immutable

Designing Clean Interfaces

Tell, Don't Ask

Minimize the Public Surface

Method Naming Conventions

Cohesion: Every Method Belongs

Practice Problems

Before you can design systems with objects, you need to understand why objects exist in the first place. The fundamental problem is this: real-world software models real-world things, bank accounts, users, shopping carts, sensors, and each of those things has both data (state) and behavior (operations on that state). Without objects, you end up with scattered variables and loose functions that have no clear ownership, making the code fragile and hard to reason about.

A class solves this by acting as a blueprint. It defines what data a thing holds and what operations it supports. An object is a specific instance of that blueprint: a concrete thing living in memory with its own values.

Object instantiation from class blueprint

Classes as Blueprints

Think of a class like an architectural blueprint for a house. The blueprint specifies that every house has a number of bedrooms, a square footage, and a color. But the blueprint itself is not a house: you cannot live in it. You must build (instantiate) a house from the blueprint to get something real.

python
1class Dog:
2    def __init__(self, name: str, breed: str, age: int):
3        self.name = name
4        self.breed = breed
5        self.age = age
6
7    def bark(self) -> str:
8        return f"{self.name} says Woof!"
9
10    def birthday(self) -> None:
11        self.age += 1

The Dog class defines the shape: every dog has a name, breed, and age, plus behaviors like barking and having birthdays. But no dog exists until you create one.

Objects as Instances

When you instantiate a class, you get an object: a self-contained unit with its own copy of the data.

python
1fido = Dog("Fido", "Labrador", 3)
2rex = Dog("Rex", "German Shepherd", 5)
3
4print(fido.bark())      # Fido says Woof!
5print(rex.bark())       # Rex says Woof!
6
7fido.birthday()
8print(fido.age)         # 4
9print(rex.age)          # 5 — unchanged

fido and rex are independent objects. Calling fido.birthday() changes Fido's age but leaves Rex untouched. Each object owns its own state. This independence is the entire point: you can have thousands of Dog objects, each tracking its own data, without any interference.

Interview Tip

A common interview mistake is conflating classes with objects. If an interviewer asks you to 'create a User,' they mean define the class. If they say 'create a user,' they mean instantiate an object. The distinction matters because design decisions happen at the class level (what fields and methods exist), while runtime behavior happens at the object level (what values those fields hold).

Why Not Just Use Dictionaries or Structs?

You could store a dog as a dictionary: {"name": "Fido", "breed": "Labrador", "age": 3}. This works for simple cases, but it breaks down quickly. Nothing prevents you from misspelling a key ("naem" instead of "name"), adding unexpected keys, or forgetting to include required fields. There is no place to put behavior: the bark() and birthday() functions float somewhere else, disconnected from the data they operate on.

Classes bind data and behavior together. The constructor enforces that every Dog has a name, breed, and age. The methods live on the class, so you always know where to find them. The type system (in typed languages) catches errors at compile time rather than runtime.

Identity, State, and Behavior

Every object has three defining characteristics:

  • Identity, what makes it unique (its memory address or an explicit ID). Two dogs can have the same name and breed but are still different objects.
  • State, the current values of its fields. Fido's age is 4 after his birthday.
  • Behavior, the operations it supports. Fido can bark and have birthdays.

Understanding this trio helps you design classes that are complete: if an object has state but no meaningful behavior, it might be better as a plain data structure. If it has behavior but no state, it might be better as a module of standalone functions.

Now that you understand what classes and objects are, the next question is: how do you structure the internals of a class? Every class has three building blocks: fields that store state, a constructor that initializes state, and methods that operate on state. Getting these right determines whether your class is easy to use correctly and hard to use incorrectly.

Fields Store State

Fields (also called attributes or instance variables) are the data each object carries. The key design decision is choosing which fields belong on the class. A good rule: a field belongs on a class if it describes an intrinsic property of the entity or is needed by the entity's methods.

python
1class Rectangle:
2    def __init__(self, width: float, height: float):
3        self.width = width
4        self.height = height

Width and height are intrinsic to a rectangle. Color, position on screen, or who created it are extrinsic: they depend on context and usually belong elsewhere.

In Java, fields are declared with explicit types and access modifiers:

java
1public class Rectangle {
2    private double width;
3    private double height;
4
5    public Rectangle(double width, double height) {
6        this.width = width;
7        this.height = height;
8    }
9}

The private keyword means no code outside the class can directly read or modify these fields. This is intentional: you control access through methods.

Constructors Enforce Invariants

A constructor is the entry point for creating objects. Its most important job is not just assigning values: it is enforcing invariants. An invariant is a condition that must always be true for the object to be in a valid state.

python
1class BankAccount:
2    def __init__(self, owner: str, initial_balance: int = 0):
3        if not owner:
4            raise ValueError("Account must have an owner")
5        if initial_balance < 0:
6            raise ValueError("Initial balance cannot be negative")
7        self._owner = owner
8        self._balance = initial_balance

This constructor guarantees two things: every account has an owner, and no account starts with negative money. If you try to violate either rule, you get an error at creation time: not a mysterious bug later when some method assumes the balance is non-negative.

Key Insight

The constructor is your first line of defense. If an object can never be created in an invalid state, you eliminate an entire category of bugs. This is why experienced developers spend more time designing constructors than any other method: every other method can assume the invariants hold because the constructor guaranteed them.

Methods Define Behavior

Methods are functions that operate on the object's state. Well-designed methods maintain the object's invariants: they never leave the object in an invalid state, no matter what arguments are passed.

python
1class BankAccount:
2    def __init__(self, owner: str, initial_balance: int = 0):
3        if not owner:
4            raise ValueError("Account must have an owner")
5        if initial_balance < 0:
6            raise ValueError("Initial balance cannot be negative")
7        self._owner = owner
8        self._balance = initial_balance
9
10    def deposit(self, amount: int) -> bool:
11        if amount <= 0:
12            return False
13        self._balance += amount
14        return True
15
16    def withdraw(self, amount: int) -> bool:
17        if amount <= 0 or amount > self._balance:
18            return False
19        self._balance -= amount
20        return True
21
22    def get_balance(self) -> int:
23        return self._balance

Notice the pattern: every method that modifies state validates its input first. deposit() rejects non-positive amounts. withdraw() rejects amounts that would overdraft. The object can never reach a negative balance because both the constructor and every mutating method prevent it.

Instance Methods vs. Class Methods vs. Static Methods

Not every method needs access to an object's state:

python
1class Temperature:
2    def __init__(self, celsius: float):
3        self.celsius = celsius
4
5    # Instance method — operates on this specific object
6    def to_fahrenheit(self) -> float:
7        return self.celsius * 9/5 + 32
8
9    # Class method — operates on the class itself (factory pattern)
10    @classmethod
11    def from_fahrenheit(cls, fahrenheit: float) -> "Temperature":
12        return cls((fahrenheit - 32) * 5/9)
13
14    # Static method — no access to instance or class state
15    @staticmethod
16    def is_boiling(celsius: float) -> bool:
17        return celsius >= 100

Instance methods (self) are the default: they read or modify the object's state. Class methods (cls) are factories that create instances in alternative ways. Static methods are utility functions that logically belong to the class but do not need any state. If a static method does not use any class-specific logic, consider whether it truly belongs on the class or should be a standalone function.

Method Signatures as Contracts

The parameters and return type of a method form a contract with the caller. A good signature tells you what the method needs and what it promises to return: without reading the implementation.

java
1// Clear contract: takes an int, returns a boolean
2public boolean withdraw(int amount)
3
4// Unclear: what does the int mean? What does -1 signify?
5public int process(int x)

Prefer specific types over generic ones. Prefer descriptive names over abbreviations. A method called withdraw that returns a boolean communicates intent. A method called process that returns an int forces every caller to read the implementation.

You now know how to define classes with fields, constructors, and methods. But knowing how to build a class is different from knowing how to protect it. Encapsulation is the principle that makes your classes robust: it bundles data with the methods that operate on that data, and, critically, it hides the internal details so that outside code cannot tamper with them.

Why does this matter? Because the moment you expose a field directly, every piece of code that touches it becomes a dependency. Change the field's name, type, or validation rules, and you must update every caller. Hide it behind a method, and you can change the internals freely: the method's signature stays the same.

Encapsulation hiding internal state

The Problem Encapsulation Solves

Consider a class that exposes its fields directly:

python
class BankAccount:
    def __init__(self):
        self.balance = 0

Any code can do this:

python
account.balance = -500  # No validation, no protection

The object is now in an invalid state. Every method that assumes a non-negative balance will produce wrong results. The bug is not in the BankAccount class: it is in whoever set the balance directly. But the symptoms appear inside the class, making debugging a nightmare.

Encapsulation prevents this by making the field private and forcing all access through methods that enforce the rules:

python
1class BankAccount:
2    def __init__(self):
3        self._balance = 0  # Convention: underscore means private
4
5    def deposit(self, amount: int) -> bool:
6        if amount <= 0:
7            return False
8        self._balance += amount
9        return True
10
11    def withdraw(self, amount: int) -> bool:
12        if amount <= 0 or amount > self._balance:
13            return False
14        self._balance -= amount
15        return True
16
17    def get_balance(self) -> int:
18        return self._balance

Now the only way to change the balance is through deposit() and withdraw(), both of which validate their input. The object can never reach an invalid state: not because callers are disciplined, but because the class makes invalid states impossible.

Interface vs. Implementation

Encapsulation creates a separation between what an object does (its interface) and how it does it (its implementation). The interface is the set of public methods. The implementation is everything else: private fields, internal helper methods, data structures, algorithms.

python
1class TemperatureLog:
2    def __init__(self):
3        self._readings = []  # Implementation detail
4
5    def add_reading(self, celsius: float) -> None:
6        self._readings.append(celsius)
7
8    def average(self) -> float:
9        if not self._readings:
10            return 0.0
11        return sum(self._readings) / len(self._readings)

Callers use add_reading() and average(). They do not know or care that readings are stored in a list. Tomorrow you could switch to a database, a ring buffer, or a running average: as long as add_reading() and average() behave the same way, no caller needs to change.

This is the real power of encapsulation: freedom to evolve the implementation without breaking callers. In large codebases with hundreds of consumers of your class, this freedom is worth everything.

Encapsulation in Different Languages

Languages enforce encapsulation differently:

Java uses access modifiers: private, protected, public, and package-private (default). The compiler enforces these boundaries.

java
1public class BankAccount {
2    private int balance;  // Compiler enforced — cannot access from outside
3
4    public int getBalance() {
5        return balance;
6    }
7}

Python uses convention: a single underscore _balance signals "private, do not touch." A double underscore __balance triggers name mangling, making accidental access harder but not impossible. Python trusts developers to follow the convention.

JavaScript (ES2022+) uses the # prefix for truly private fields:

javascript
1class BankAccount {
2    #balance = 0;  // Truly private — SyntaxError if accessed outside
3
4    getBalance() {
5        return this.#balance;
6    }
7}

The principle is the same across all languages: separate the public interface from the private implementation. Only the enforcement mechanism differs.

Encapsulation tells you to hide internal state. But how do you decide which parts to expose and which to lock down? And once you make a field private, should callers ever be able to change it at all? Access control and immutability are the tools that answer these questions.

Access Modifiers in Practice

Most object-oriented languages provide a gradient of visibility:

 
1publicAnyone can access
2protectedSubclasses and same package can access
3packageSame package only (Java default)
4privateOnly the class itself can access
Access modifier visibility

The default should be private. Make a field or method private first. Only widen access when you have a concrete reason. This is the principle of least privilege applied to code.

java
1public class User {
2    private String name;           // Only this class
3    private String passwordHash;   // Definitely only this class
4    private int loginCount;        // Internal bookkeeping
5
6    public String getName() {      // Callers need to read the name
7        return name;
8    }
9
10    // No setName() — name is set once in constructor
11    // No getPasswordHash() — never exposed
12    // No getLoginCount() — exposed only through a specific method
13
14    public boolean hasLoggedInRecently() {
15        return loginCount > 0;
16    }
17}

Notice what is missing: there is no setName(), no getPasswordHash(), no getLoginCount(). Each omission is intentional. The name is immutable after construction. The password hash is never exposed: only compared internally during authentication. The login count is abstracted into a meaningful question (hasLoggedInRecently) rather than leaked as a raw number.

Immutability: Objects That Cannot Change

An immutable object is one whose state cannot be modified after construction. Every field is set in the constructor and never changed.

Mutable vs immutable object lifecycle
python
1class Point:
2    def __init__(self, x: float, y: float):
3        self._x = x
4        self._y = y
5
6    @property
7    def x(self) -> float:
8        return self._x
9
10    @property
11    def y(self) -> float:
12        return self._y
13
14    def translate(self, dx: float, dy: float) -> "Point":
15        return Point(self._x + dx, self._y + dy)  # New object

The translate method does not modify the point: it returns a new Point. The original is untouched. This is the immutability pattern: operations produce new objects instead of modifying existing ones.

Why is this valuable?

Thread safety for free. If an object cannot change, multiple threads can read it simultaneously without locks, mutexes, or synchronization. No thread can corrupt the state because no thread can modify it.

Easier reasoning. When you pass an immutable object to a function, you know it will come back unchanged. No defensive copying needed. No surprises.

Safe hash keys. Mutable objects used as dictionary keys or set elements are dangerous: if you mutate the object, its hash changes, and the dictionary can no longer find it. Immutable objects have stable hashes.

Common Pitfall

A common pitfall is creating an 'almost immutable' object: all fields are private and there are no setters, but one field is a list that callers can mutate through a getter. If getName() returns a String (immutable), that is safe. But if getItems() returns a List, callers can call getItems().add(x) and modify the internal state. Always return defensive copies of mutable collections, or use immutable collection types.

When to Choose Mutable vs. Immutable

Immutable objects are ideal for value types: things like points, colors, dates, money amounts, and configuration settings. These represent values, not entities. A dollar amount of $5 does not change: you create a new amount.

Mutable objects are appropriate for entity types: things like user accounts, shopping carts, and database connections. These represent things with ongoing lifecycles. A shopping cart adds and removes items over time: creating a new cart on every change would be impractical.

The decision framework:

  • Does this object represent a value or an entity?
  • Will this object be shared across threads?
  • Will this object be used as a dictionary key?
  • How frequently does the state change?

If it is a value, shared, used as a key, or rarely changed: make it immutable. If it is an entity with a lifecycle and frequent state changes: make it mutable with careful encapsulation.

You have learned to create classes, protect their state with encapsulation, and choose between mutability and immutability. The final skill is designing the public interface: the set of methods that callers actually use. A clean interface makes your class easy to use correctly and hard to use incorrectly. A messy interface creates confusion, bugs, and frustration.

Tell, Don't Ask

The most common interface mistake is designing classes that expose data and let callers make decisions based on that data. This is called the "ask" pattern: and it scatters logic across the codebase.

python
1# Bad: Ask pattern — caller queries state and makes decisions
2if account.get_balance() >= amount:
3    account.set_balance(account.get_balance() - amount)
4
5# Good: Tell pattern — object owns the decision
6account.withdraw(amount)

In the "ask" pattern, the overdraft logic lives wherever the caller is. If 20 different places withdraw money, the overdraft check is (hopefully) duplicated 20 times. In the "tell" pattern, the logic lives inside withdraw(): one place, one implementation, one set of tests.

The "tell, don't ask" principle says: instead of asking an object for its data and then acting on it, tell the object what you want done and let it figure out how.

Minimize the Public Surface

Every public method is a promise. Once callers depend on it, you cannot remove it, rename it, or change its behavior without breaking things. So expose as few methods as possible.

python
1class Stack:
2    def __init__(self):
3        self._items = []
4
5    # Public interface — only what callers need
6    def push(self, value) -> None:
7        self._items.append(value)
8
9    def pop(self):
10        if not self._items:
11            return -1
12        return self._items.pop()
13
14    def peek(self):
15        if not self._items:
16            return -1
17        return self._items[-1]
18
19    def size(self) -> int:
20        return len(self._items)
21
22    def is_empty(self) -> bool:
23        return len(self._items) == 0

Notice what is not exposed: the internal _items list. There is no get_items() method. Callers interact with the stack through stack operations (push, pop, peek): not list operations (insert, slice, index). The stack's interface matches the stack abstraction.

Method Naming Conventions

Good method names communicate intent without reading the implementation:

  • Verbs for actions: withdraw(), deposit(), send(), validate()
  • Questions for queries: is_empty(), has_permission(), can_withdraw()
  • Nouns for computed properties: balance, size, average

Avoid generic names like process(), handle(), do_thing(), or run(). These names force callers to read the implementation to understand what happens. A method named calculate_shipping_cost() tells you exactly what it does. A method named process() tells you nothing.

Cohesion: Every Method Belongs

A class has high cohesion when every method operates on the same set of fields. If a method does not use any of the class's fields, it probably does not belong on the class.

python
1class Order:
2    def __init__(self, items, customer):
3        self._items = items
4        self._customer = customer
5
6    def total(self) -> float:       # Uses self._items ✓
7        return sum(item.price for item in self._items)
8
9    def item_count(self) -> int:    # Uses self._items ✓
10        return len(self._items)
11
12    # Does NOT belong here:
13    def send_email(self, message):  # Does not use any Order fields
14        smtp.send(self._customer.email, message)

send_email does not use order data: it only uses the customer's email. It belongs on a NotificationService or EmailSender class, not on Order. When a class accumulates unrelated methods, it becomes a grab bag: hard to understand, hard to test, hard to maintain.

Practice Problems

Apply the encapsulation concepts you just learned.

Loading problem...

Loading problem...