Codetail

Article 3 of 8

Encapsulation

Control what the outside world can touch.

18 min read

What encapsulation actually is

Here is a bank account. Notice what is missing:

Python
1class BankAccount:
2 def __init__(self, owner, balance=0):
3 self.owner = owner
4 self.balance = balance # fully public
5
6account = BankAccount("Alice", 100)
7
8# Anyone can do this
9account.balance = -999999 # no validation, no history, just chaos

No error. The balance is a plain attribute. Any code anywhere can reach in and set it to anything. That line bypasses all validation. There is nothing to bypass, because there is no validation.

Now add a requirement: log every deposit. Enforce a minimum balance. Round to two decimal places. Every piece of code that touches account.balance directly becomes a place you have to find and update. You cannot add the logic in one spot because the logic has no spot to live.

That is the problem encapsulation solves. Not security. Not privacy in the Java sense. The ability to change how something works inside without breaking the code that uses it from outside.

Encapsulation is not a lock. It is a seam. You control what goes through it, so you can change what is behind it without touching anything else.

Private by convention, not by lock

Java and C++ have private keywords that the compiler enforces. Python does not. In Python, privacy is a convention communicated through naming.

Single underscore: "please don't touch this"

A name starting with a single underscore is a signal to other developers: this is an internal detail. You can still access it, Python will not stop you, but you are on your own if you do and the behavior changes in a future version.

Python
1class BankAccount:
2 def __init__(self, owner, balance=0):
3 self.owner = owner
4 self._balance = balance # internal, not part of the public API
5
6 def deposit(self, amount):
7 if amount <= 0:
8 raise ValueError("deposit must be positive")
9 self._balance += amount
10
11 def get_balance(self):
12 return self._balance
13
14account = BankAccount("Alice", 100)
15account.deposit(50)
16print(account.get_balance()) # 150
17
18# You can still access _balance, but you're bypassing validation
19account._balance = -999 # works, but you've broken the contract

Double underscore: name mangling

A name starting with two underscores (and not ending with two underscores) triggers name mangling. Python renames the attribute from __balance to _ClassName__balance under the hood. This makes it harder to access accidentally and protects it from being overridden by a subclass using the same name.

Python
1class BankAccount:
2 def __init__(self, owner, balance=0):
3 self.owner = owner
4 self.__balance = balance # name mangled to _BankAccount__balance
5
6 def deposit(self, amount):
7 self.__balance += amount
8
9 def get_balance(self):
10 return self.__balance
11
12account = BankAccount("Alice", 100)
13account.deposit(50)
14print(account.get_balance()) # 150
15
16# Trying to access directly fails
17try:
18 print(account.__balance)
19except AttributeError as e:
20 print(e)
21
22# The real name after mangling
23print(account._BankAccount__balance) # 150 -- it's still there, just renamed

In practice, most Python code uses a single underscore. The double underscore is reserved for cases where you genuinely need to protect a name from subclass collisions. Do not use it as a stronger version of private access control. That is not what it is for.

@property: a controlled public interface

Here is a problem: you ship a class where account.balance is a plain attribute. Thousands of lines of code read it that way. Later you need to add validation when balance is set. If you change it to get_balance(), you break all that code.

The @property decorator solves this. It lets you expose an attribute-style interface (account.balance) while controlling exactly what happens when that name is read or written.

Python
1class BankAccount:
2 def __init__(self, owner, balance=0):
3 self.owner = owner
4 self._balance = balance
5
6 @property
7 def balance(self):
8 return self._balance
9
10 @balance.setter
11 def balance(self, amount):
12 if amount < 0:
13 raise ValueError("balance cannot be negative")
14 self._balance = amount
15
16account = BankAccount("Alice", 100)
17
18# Reads like a plain attribute
19print(account.balance) # 100
20
21# Writes go through the setter
22account.balance = 200
23print(account.balance) # 200
24
25# Validation kicks in
26account.balance = -50 # raises ValueError

All the code that reads account.balance still works unchanged. But now you control what happens. The internal storage (_balance) is separate from the public name.

Computed properties

Properties do not have to read from a stored attribute. You can compute a value on the fly and expose it as if it were an attribute:

Python
1class Circle:
2 def __init__(self, radius):
3 self.radius = radius
4
5 @property
6 def area(self):
7 import math
8 return math.pi * self.radius ** 2
9
10 @property
11 def diameter(self):
12 return self.radius * 2
13
14c = Circle(5)
15print(c.area) # 78.53... computed, not stored
16print(c.diameter) # 10 computed, not stored
17
18c.radius = 10
19print(c.area) # 314.15... automatically correct

The caller has no idea whether area is stored or computed. They just read it. That is the point of encapsulation: the implementation can change without breaking the interface.