Skip to content

Encapsulation: keep the data safe inside the class

How to make a class that cannot be put in a wrong state: facts that must always be true, hidden fields, checks in every method, frozen value objects, and enums.

  • Lesson 10 of 169
  • 20 min read
  • Core
  • Free to read · No account needed
On this page
  1. Why interviewers ask this
  2. The idea in plain words
  3. Pause and predict
  4. Worked example
  5. Trade-offs
  6. Common misconceptions
  7. How to say it in the interview
  8. Check your understanding
  9. Answers
  10. Summary card: encapsulation
  11. Sources

Before you start

This lesson uses ideas from these earlier lessons. Read them first if any idea is new to you.

01Why interviewers ask this

In the last lesson the canteen card allowed asha.balance = -500. In a round, the hidden tests try exactly such things. Examples are a payment of Rs 0, a payment larger than the balance, and a top-up that goes over a limit. If your class allows them, tests fail, and the interviewer sees that any code can break your data.

A weak answer leaves every field open and puts the checks in many places outside the class, or nowhere. A strong answer makes the class guard itself, so a wrong state cannot be made at all. This idea is called encapsulation: the data is hidden inside the class, and the class decides how it changes.

At SDE-1 level you should hide the fields and check input in each method. At SDE-2 level you should also make small values that can never change, list fixed choices by name, and give out copies of inner lists. Interviewers often ask: "How do you make sure this object is always valid?"

02The idea in plain words

Think of a vending machine. You cannot reach inside and move the coins. You use the slot and the buttons. The slot checks the coin, and the machine gives a snack only if the money is enough. The machine is always in a sensible state, because every change goes through its own checks.

A class can work the same way. First we need a name for the thing the checks protect. An invariant is a fact about an object that must be true after every method call. For the canteen card the invariants are: the balance is never below 0, and the balance is never above the limit of the card type.

Hide the fields

If outside code can write card.balance = -500, no invariant can hold. So we hide the fields. In Python, a name that starts with an underscore, such as _balance, means "internal: outside code should not touch this". This is a convention, a habit that programmers agree on. Python does not force it. The Python tutorial says that real private variables do not exist in Python. Java and C++ have a keyword, private, that the compiler forces. In a Python round, the underscore is the normal way to show that a field is internal.

To let outsiders read a value, use a read-only property. A line that starts with @ above a method asks Python to add a feature to it. The line @property makes the method read like a field: card.balance works, but card.balance = 5 raises an error. Do not add a way to set a field, unless a rule needs one.

Check in the constructor and in every method that changes something

The object must be valid right after it is made, and right after every method. So check inside the constructor and inside every method that changes a field. When a check fails, use raise ValueError("message"). This is a stop signal: the method stops at that line, nothing after it runs, and the caller gets an error with your message. ValueError is the built-in name for "the value is wrong".

To see the message without stopping the whole program, put the call in a try block and add except ValueError as error. If the call raises, Python jumps to the except line, and as error gives the error a name, so you can print its message.

Check the rule first, then change the field. Then a refused call leaves the object as it was.

Ask the object, do not take its data

Outside code should say what it wants: card.pay(30). It should not read the balance, work out the new value and write it back. That would skip every check in pay. Keep the public surface small: only the methods that outside code needs to call.

Outside code sits above the class CanteenCard. A call to pay with 30 goes into the class and reaches the method pay.
Outside code reaches the hidden balance only through a method that checks first.
Outside code sits above the class CanteenCard. A call to pay with 30 goes into the class and reaches the method pay. The method pay checks first and then changes the hidden field _balance. A line that sets balance to 5 stops at the wall of the class, marked with a cross, because it is an error.

Return a copy, not the inner list

Suppose a method returns its inner list. The caller can then change the list, and the object has no say.

python
class Bad:
    def __init__(self):
        self._items = [1, 2]

    def items(self):
        return self._items

b = Bad()
b.items().clear()
print(b.items())

clear() removes every item from a list. The code prints []: the caller emptied the inside of the object. A sort or a clear on a returned list changes the object. The fix is to return a new tuple, a list that cannot be changed. tuple(self._items) makes a new tuple from the list. The caller can read the tuple but cannot add, remove or replace items. If the items themselves could change, copy them too.

Value objects: frozen dataclasses

Some things have no life of their own. A receipt for Rs 30 never changes. Money amounts, ids and positions behave the same way: two of them with the same content are the same thing. These are value objects. They are best made so that they cannot change.

A dataclass is a class where Python writes the constructor, the text form and the equality check for you, from a list of fields. With frozen=True, Python blocks any change after the object is made. The object can then be used as a key of a dictionary (the name you look a value up by). The import line brings in code that Python already has.

python
from dataclasses import dataclass

@dataclass(frozen=True)
class Receipt:
    card_id: str
    amount: int
    balance_after: int

The lines such as amount: int name the fields. The int is a note for readers; Python does not check it.

Enum: a fixed list of choices

If a value comes from a short fixed list, such as the type of a card, do not use free text. A text value "studnt" with a typo goes through without a warning. An enum is a class that lists the allowed choices as names: CardType.STUDENT. A typo such as CardType.STUDNT fails at once.

python
from enum import Enum

class CardType(Enum):
    STUDENT = 200
    STAFF = 500

    @property
    def limit(self):
        return self.value

Each choice also carries a number: self.value is the number written after the name, 200 for STUDENT. It is the top balance in rupees (Rs). Assumption: a student card holds at most Rs 200, a staff card at most Rs 500.

In Java

Java does the same with keywords. A record is a frozen dataclass.

java
record Receipt(String cardId, int amount, int balanceAfter) {}
class CanteenCard {
    private final String cardId;
    private int balance;
    CanteenCard(String cardId, int balance) { this.cardId = cardId; this.balance = balance; }
    int getBalance() { return balance; }
    Receipt pay(int amount) {
        if (amount <= 0 || amount > balance) throw new IllegalArgumentException("refused");
        balance -= amount;
        return new Receipt(cardId, amount, balance);
    }
}

This box was compiled with Java 17, exactly as printed.

Want to check your understanding? Try explaining this idea in your own words. You can continue reading first, or ask the AI tutor a question. Sign up free to ask the tutor

03Pause and predict

Pause. Guess first.

A student card has Rs 100. The code runs pay(30) and then pay(100). What is the balance after each call? Is the balance unchanged after the second call?

Write your guess before you read on. The answer is in the worked example.

04Worked example

This is the canteen card, version 2. The invariants are: the balance is never below 0, and never above the limit of the card type. Money moves only through top_up and pay. To keep the class short, we leave out the holder name. Run with Python 3.11. The Receipt and CardType code is in the plain-words section above.

The first part of the class has the constructor and the readers. The text balance=0 means: if the caller gives no balance, use 0.

python
class CanteenCard:
    def __init__(self, card_id, card_type, balance=0):
        if balance < 0 or balance > card_type.limit:
            raise ValueError("starting balance is outside the allowed range")
        self._card_id = card_id
        self._card_type = card_type
        self._balance = balance
        self._receipts = []

    @property
    def balance(self):
        return self._balance

    def receipts(self):
        return tuple(self._receipts)

balance is a property because it is a stored value. receipts() is a method because it builds a new tuple each time. The methods that change the balance go inside the same class, after receipts. The helper _check_amount starts with an underscore, because outsiders have no use for it.

python
    def top_up(self, amount):
        self._check_amount(amount)
        if self._balance + amount > self._card_type.limit:
            raise ValueError("balance would go above the card limit")
        self._balance += amount

    def pay(self, amount):
        self._check_amount(amount)
        if amount > self._balance:
            raise ValueError("not enough balance")
        self._balance -= amount
        receipt = Receipt(self._card_id, amount, self._balance)
        self._receipts.append(receipt)
        return receipt

    def _check_amount(self, amount):
        if amount <= 0:
            raise ValueError("amount must be more than 0")

Now use it.

python
card = CanteenCard("C101", CardType.STUDENT, 100)
receipt = card.pay(30)
print(receipt, card.balance)
try:
    card.pay(100)
except ValueError as error:
    print("refused:", error)
print(card.balance)

The output is Receipt(card_id='C101', amount=30, balance_after=70) 70, then refused: not enough balance, then 70. This answers the predict box. The first call gives 100 - 30 = Rs 70. The second asks for 100, which is more than 70, so it raises before it changes anything. The balance is unchanged: Rs 70.

Now the limit, and the blocked changes. Here type(error).__name__ is the name of the error's kind.

python
card.top_up(120)
print(card.balance)
try:
    card.top_up(100)
except ValueError as error:
    print(error, card.balance)
try:
    card.balance = 500
except AttributeError as error:
    print(type(error).__name__)
try:
    receipt.amount = 5
except AttributeError as error:
    print(type(error).__name__)
print(len(card.receipts()))

It prints 190, then balance would go above the card limit 190, then AttributeError and FrozenInstanceError, then 1. The arithmetic: 70 + 120 = 190, which is within the Rs 200 limit. Then 190 + 100 = 290 is above 200, so the call is refused and the balance stays 190. Setting balance from outside fails because the property is read-only. Changing the frozen receipt fails too. The history holds one receipt, and receipts() returns a tuple, so the caller cannot empty it.

Last, a frozen receipt behaves as a value. In lesson 9 two cards with the same data were not equal. Two receipts with the same data are.

python
r1 = Receipt("C101", 30, 70)
r2 = Receipt("C101", 30, 70)
print(r1 == r2, r1 == receipt)
print({r1: "ok"})

It prints True True, because receipt also has the fields C101, 30 and 70. The second line prints the dictionary {Receipt(card_id='C101', amount=30, balance_after=70): 'ok'}, so a frozen receipt works as a dictionary key.

05Trade-offs

Encapsulation solves wrong states. With the checks in one place, tests with bad amounts cannot break the card, and a rule change has one place to go. It costs more code: a property for each readable field, checks in each method, and a small public surface that outsiders may find strict. Python also does not force the underscore. I would hide fewer things when the object is only a group of values with no rule. Then a frozen dataclass is enough. I would add checks only for rules the problem states.

06Common misconceptions

"The underscore makes the field private." In Python it is only a convention. Code can still write card._balance = -500. In a round, say that it is a convention, and keep outside code away from it. Java's private is forced by the compiler.

"I validate in the constructor, so the object is safe." Only at the start. A later pay(-30) that adds money breaks the invariant unless pay checks too. Every method that changes the state must check.

"Returning the inner list is fine, because callers will not change it." One caller will, by mistake. A sort or a clear on the returned list changes the object. Return a tuple or a copy.

07How to say it in the interview

Say it like this

I decide which facts must always be true, hide the fields, and check them in the constructor and in every method that changes the state. Values like receipts are frozen, fixed choices are enums, and I return copies, so outside code cannot break the object.

08Check your understanding

  1. What is an invariant? Name the two invariants of the canteen card.
  2. A student card has Rs 150. The code runs top_up(40) and then top_up(20). What is the balance after each call?
  3. A teammate adds set_balance(self, value) "for the admin screen" and changes receipts() to return self._receipts. What goes wrong, and what do you do?

09Answers

Show the answers
  1. An invariant is a fact about an object that must be true after every method call. The balance is never below 0, and never above the limit of the card type (Rs 200 for a student card).
  2. 150 + 40 = 190, which is within Rs 200. Then 190 + 20 = 210, which is above 200, so the call is refused and the balance stays Rs 190.
  3. set_balance lets any code set a negative balance or a balance above the limit, so both invariants can break. Remove it and use top_up and pay. If the admin screen really needs a correction, add one method that checks the same rules. Returning self._receipts lets a caller empty the history. Return tuple(self._receipts) again.

10Summary card: encapsulation

One-page summary

  • Idea. An invariant is a fact that is always true. The class guards it, so a wrong state cannot be made.
  • How. Hide fields with _name. Read with @property. Check in the constructor and in every changing method. raise ValueError stops the method.
  • Tell, do not take. Call card.pay(30). Do not read the balance and write it back.
  • Copies. Return a tuple or a copy, never the inner list.
  • Values. Use @dataclass(frozen=True) for receipts, ids and amounts. Use Enum for fixed choices.
  • Trade-off. It solves wrong states. It costs more code. Python does not force _name.
  • Trap. Checking only in the constructor.

11Sources

Try what you just learned

Put this idea into practice with a short exercise or a relevant tool. Free to try. No sign-up needed.

More practice. This needs a free account.

Continue learning with the lessons that build on this one.

Practise this idea

Have a question about this lesson?

Ask the AI tutor to explain a difficult idea, walk through an example or check your understanding. It uses the lesson to help you work through the topic.

A free account includes 10 AI tutor questions a day. The lesson remains free to read without an account.

All lessons in Module 2: Low-level design and machine coding

Prefer another topic? Browse all 169 lessons.