# Encapsulation

Course: [Python](https://www.devtutorials.ai/learn/python) · Section: Object Oriented Programming
Source: https://www.devtutorials.ai/learn/python/oop/encapsulation

> Keeping data inside the class: data hiding, getters and setters, validation, and why Python uses @property instead.

Think about a bank account.

Your balance is a number the bank holds. You cannot reach in and set it to a million. You deposit, you withdraw, and the bank checks each one: enough money, a sensible amount, a real account.

The number is protected, and every way of changing it goes through a check.

**Encapsulation** is that idea in code: keep the data behind the class, and let it be changed only through methods that can say no.

```python runnable title="A value that protects itself"
class Student:
    def __init__(self, name):
        self.name = name
        self._marks = 0

    def set_marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError("marks must be between 0 and 100")
        self._marks = value

    def get_marks(self):
        return self._marks

asha = Student("Asha")
asha.set_marks(78)

print(asha.get_marks())

try:
    asha.set_marks(150)
except ValueError as error:
    print("Refused:", error)
```

Encapsulation means keeping data inside the class and controlling every way it can be changed.

## What You Will Learn

In this lesson, you will learn:

- What encapsulation is, and what goes wrong without it
- How Python marks data as internal, and what that does and does not stop
- How to write getters and setters, and how to check values on the way in
- Why Python prefers `@property` to the getter and setter methods other languages use

By the end of this lesson, you will be able to protect class data, write methods that check and validate changes, use properties to control access, and understand why Python's approach to encapsulation is better than the getter-setter methods found in other languages.

## Encapsulation

### What Is Encapsulation?

**Encapsulation** means keeping data inside a class and controlling how it is changed. It has two parts:

- The data and the methods that work on it live together in one class
- The data is only reached through those methods, not directly

The second part is what matters: by forcing all changes through methods, you can check each one. This prevents impossible values from ever entering an object.

### Data Hiding

When you write a class without encapsulation, anything can put anything into an object. Watch what happens:

```python runnable title="Nothing stops a nonsense value"
class Student:
    def __init__(self, name, marks):
        self.name = name
        self.marks = marks

asha = Student("Asha", 78)

asha.marks = 5000
asha.marks = -20
asha.marks = "absent"

print(asha.name, asha.marks)
```

Three impossible values went in without a word. The object is now broken, and the program will fail somewhere far away — in a function that tries to add up marks, or draw a chart, or compare a grade. The failure will not be at the line that caused the damage; it will be far away, making the bug hard to find.

This separation between the bad line and the failure is the real problem:

```python runnable expect-error title="The failure happens somewhere else entirely"
class Student:
    def __init__(self, name, marks):
        self.name = name
        self.marks = marks

asha = Student("Asha", 78)
asha.marks = "absent"

print("The bad assignment caused no error at all")

print("Average:", asha.marks / 1)
```

The bad assignment happened on line 7 and caused no error. Line 9 printed normally. The error appeared on line 11, four lines later, when the code tried to divide text by 1. By then, it is hard to trace the problem back to its source.

### Benefits of Encapsulation

<div className="tscroll">

| Benefit | What it means |
|---|---|
| Values stay valid | every change goes through a check |
| Errors arrive early | the bad line fails, not some later one |
| Freedom to change | the inside can be rewritten without breaking callers |
| One place to look | all the rules about marks live in one class |
| A smaller surface | callers see a few methods, not every attribute |

</div>

### Encapsulation Examples

Here is a class that protects itself. Every change goes through a method, so the balance can never go negative and the history can never skip a transaction:

```python runnable title="A class that keeps itself consistent"
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance
        self._history = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be more than zero")
        self._balance += amount
        self._history.append(f"deposit {amount}")

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("not enough money")
        self._balance -= amount
        self._history.append(f"withdraw {amount}")

    def balance(self):
        return self._balance

    def statement(self):
        return list(self._history)

account = BankAccount("Asha", 100)
account.deposit(50)
account.withdraw(30)

print("Balance:", account.balance())
print("History:", account.statement())

try:
    account.withdraw(1000)
except ValueError as error:
    print("Refused:", error)
```

Notice that `statement()` returns `list(self._history)` — a copy of the history, not the history itself. If we handed back the real list, a caller could change it from outside, breaking the encapsulation.

Here is why that matters:

```python runnable title="Why the copy matters"
class BankAccount:
    def __init__(self):
        self._history = ["deposit 50"]

    def leaky_statement(self):
        return self._history

    def safe_statement(self):
        return list(self._history)

account = BankAccount()

account.leaky_statement().append("deposit 1000000")
print("After the leaky one:", account.safe_statement())

account.safe_statement().append("deposit 1000000")
print("After the safe one: ", account.safe_statement())
```

The first call reaches the real list and changes it. The second call gets a copy, changes the copy, and leaves the account alone. This shows why the copy is essential.

## Getters and Setters

### Getter Methods

A **getter** is a method that hands back a value. The simplest getters just return what is stored:

```python runnable title="A getter"
class Student:
    def __init__(self, name, marks):
        self._name = name
        self._marks = marks

    def get_name(self):
        return self._name

    def get_marks(self):
        return self._marks

asha = Student("Asha", 78)

print(asha.get_name())
print(asha.get_marks())
```

A getter can also work something out rather than just return a stored value:

```python runnable title="A getter that calculates"
class Student:
    def __init__(self, marks):
        self._marks = marks

    def get_average(self):
        return round(sum(self._marks) / len(self._marks), 1)

    def get_grade(self):
        average = self.get_average()
        return "A" if average >= 80 else "B" if average >= 60 else "C"

asha = Student([78, 85, 60])

print(asha.get_average())
print(asha.get_grade())
```

### Setter Methods

A **setter** changes a value. More importantly, it is where you check the value before storing it:

```python runnable title="A setter that checks"
class Student:
    def __init__(self, name):
        self._name = name
        self._marks = 0

    def set_marks(self, value):
        if not isinstance(value, int):
            raise TypeError("marks must be a whole number")
        if not 0 <= value <= 100:
            raise ValueError("marks must be between 0 and 100")
        self._marks = value

    def get_marks(self):
        return self._marks

asha = Student("Asha")

asha.set_marks(78)
print(asha.get_marks())

for bad in [150, -20, "absent"]:
    try:
        asha.set_marks(bad)
    except (ValueError, TypeError) as error:
        print(f"{bad!r} refused: {error}")

print("Marks are still:", asha.get_marks())
```

Three bad values were refused with clear error messages. The object never held an impossible value, not even for an instant.

### Data Validation

The key idea is that the checking lives in one place, so it cannot be forgotten. Every way of changing the data must go through the setter:

```python runnable title="One rule, enforced everywhere"
class Student:
    def __init__(self, name, marks=0):
        self._name = name
        self._marks = 0
        self.set_marks(marks)

    def set_marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError(f"marks must be 0 to 100, got {value}")
        self._marks = value

    def raise_marks(self, points):
        self.set_marks(self._marks + points)

    def get_marks(self):
        return self._marks

asha = Student("Asha", 95)
print(asha.get_marks())

asha.raise_marks(3)
print(asha.get_marks())

try:
    asha.raise_marks(10)
except ValueError as error:
    print("Refused:", error)
```

Notice that `__init__` calls `self.set_marks(marks)` instead of assigning directly. This means the check runs at creation. Similarly, `raise_marks` also goes through the setter. So there is only one place where marks can be set, and the rule is enforced everywhere.

### Controlling Attribute Access

Getters and setters as written above are how Java and C# do it. **Python has a better approach.**

The problem with explicit getters and setters is noise. Every value needs two methods, and every read or write needs brackets:

```python runnable title="The verbose way"
class Student:
    def __init__(self, marks):
        self._marks = marks

    def get_marks(self):
        return self._marks

    def set_marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError("marks must be 0 to 100")
        self._marks = value

asha = Student(78)

asha.set_marks(asha.get_marks() + 5)

print(asha.get_marks())
```

`@property` lets you keep the checking but use plain attribute syntax:

```python runnable title="The Python way"
class Student:
    def __init__(self, marks):
        self.marks = marks

    @property
    def marks(self):
        return self._marks

    @marks.setter
    def marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError("marks must be 0 to 100")
        self._marks = value

asha = Student(78)

asha.marks += 5

print(asha.marks)
```

To the reader, `asha.marks` looks like a plain attribute. Behind the scenes, it runs a method. `asha.marks += 5` reads the getter and writes through the setter, so the check still runs.

The stored value must not have the same name as the property. If you write `self.marks = value` inside the setter, you are assigning to the property, which calls the setter again, which calls the setter again, until Python stops with `RecursionError`. By convention, store it as `self._marks`.

Notice the constructor says `self.marks = marks`, not `self._marks = marks`. Using the property means the check runs at creation. If a bad value is passed to the constructor, it is caught there:

```python runnable expect-error title="The check applies at creation too"
class Student:
    def __init__(self, marks):
        self.marks = marks

    @property
    def marks(self):
        return self._marks

    @marks.setter
    def marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError("marks must be 0 to 100")
        self._marks = value

Student(150)
```

A property with a getter and no setter is read-only. This is how you expose a value that is worked out from others:

```python runnable expect-error title="A read-only property"
class Student:
    def __init__(self, marks):
        self._marks = marks

    @property
    def average(self):
        return round(sum(self._marks) / len(self._marks), 1)

asha = Student([78, 85, 60])

print(asha.average)

asha.average = 100
```

`AttributeError` — `average` can be read and not set, which is correct. It is worked out from the marks, so setting it would make no sense.

The best part about `@property` is that you can add it later without breaking any code that uses the class:

```python runnable title="Adding a rule without breaking callers"
class Student:
    def __init__(self, marks):
        self.marks = marks

    @property
    def marks(self):
        return self._marks

    @marks.setter
    def marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError("marks must be 0 to 100")
        self._marks = value

asha = Student(78)
print(asha.marks)

asha.marks = 85
print(asha.marks)
```

Every line using `asha.marks` is written exactly as it was when `marks` was a plain attribute. Python code can start with plain attributes and add a property only when a rule appears. There is no cost to waiting.

Start with a plain public attribute. Add `@property` the day a rule appears. Writing getters and setters for every value "just in case" is a habit from languages where changing your mind later would break every caller.

<div className="tscroll">

| | `get_x()` / `set_x()` | `@property` |
|---|---|---|
| Reading | `obj.get_x()` | `obj.x` |
| Writing | `obj.set_x(5)` | `obj.x = 5` |
| Checking on write | yes | yes |
| Read-only values | leave out the setter | leave out the setter |
| Can be added later | no — callers must change | yes — callers are untouched |
| Usual in Python | no | yes |

</div>

Here is everything together:

```python runnable title="Everything together"
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = 0
        self.balance = balance

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

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("balance cannot be negative")
        self._balance = value

    @property
    def is_overdrawn(self):
        return self._balance == 0

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be more than zero")
        self.balance = self._balance + amount

account = BankAccount("Asha", 100)

print(account.balance, account.is_overdrawn)

account.deposit(50)
print(account.balance)

try:
    account.balance = -5
except ValueError as error:
    print("Refused:", error)
```

`balance` is checked on every write, `is_overdrawn` is read-only and worked out when asked, and the caller writes plain attribute syntax throughout.

## Common mistakes

Each row is one mistake. The middle column is what you will actually see on screen.

<div className="tscroll">

| Mistake | What you see | Why it happens | Fix |
|---|---|---|---|
| A public attribute with rules about it | Impossible values stored, failing later | Nothing checks a plain assignment | Use `@property` with a setter |
| `self._marks = marks` in `__init__` with a property | Bad values accepted at creation | It skips the setter | `self.marks = marks` |
| Naming the property and the attribute the same | `RecursionError: maximum recursion depth exceeded` | The setter calls itself for ever | Store in `self._marks` |
| Returning the real list from a getter | A caller changes your data | Lists are shared, not copied | Return `list(self._items)` |
| Expecting `_marks` to be protected | Anyone can read and write it | One underscore is a convention only | Accept it, or use a property |
| Expecting `__marks` to be secure | `_Class__marks` is reachable | Name mangling renames, it does not lock | Treat it as a convention |
| Getters and setters for every attribute | Noisy code with no benefit | A habit from other languages | Start with plain attributes |
| A setter that does not check anything | All the noise, none of the protection | It just copies the value | Either check, or use a plain attribute |
| Validating in several places | One route gets missed | The rule is written more than once | Make every route go through the setter |

</div>

## What to Remember

- **Encapsulation means keeping data inside the class and controlling every way it can be changed.**
- Without it, an impossible value goes in silently and the failure appears somewhere else entirely.
- One underscore means "internal, please leave alone", and Python does not enforce it.
- A **getter** hands a value back; a **setter** changes it and is the place to check it.
- Put the rule in one place and route every change through it, including the constructor.
- **`@property` is the Python way**: attribute syntax outside, a method inside.
- The stored value needs a different name — `self._marks` — or the setter calls itself for ever.
- With a property, write `self.marks = marks` in `__init__` so creation is checked too.
- A property with no setter is **read-only**, which is right for a value worked out from others.
- Start with plain attributes and add a property when a rule appears. Callers never have to change.
- Return `list(self._items)` from a getter, so a caller cannot change your data from outside.

Next: **Advanced OOP**
