Encapsulation in Python — a clean, Java‑friendly path
In Java you rely on access modifiers (private, protected, public).
Python favors convention + properties. This guide shows how to express the same intent cleanly in Python, with side‑by‑side examples and a production‑ready starter template.
_single vs __double underscoreTL;DR — idiomatic mapping from Java → Python
private int x;getX()/setX()packageas boundary- Lombok for boilerplate
_x(non‑public by convention),__x(name‑mangled)@property/@x.setter- Module / package &
__all__ @dataclass(frozen for immutability)
1) Encapsulation basics in Python
Conventions
_attr: non‑public ("protected" by convention — don’t access from outside).__attr: triggers name mangling to_ClassName__attr(helps avoid accidental overrides in subclasses).- No getters/setters by default — use
@propertyto expose controlled access.
public final class BankAccount {
private BigDecimal balance = BigDecimal.ZERO;
public BigDecimal getBalance() { return balance; }
public void deposit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) <= 0)
throw new IllegalArgumentException("amount must be positive");
balance = balance.add(amount);
}
}
from decimal import Decimal
class BankAccount:
def __init__(self, balance: Decimal = Decimal("0")) -> None:
self._balance = balance # non-public
@property
def balance(self) -> Decimal:
return self._balance
def deposit(self, amount: Decimal) -> None:
if amount <= 0:
raise ValueError("amount must be positive")
self._balance += amount
Name‑mangling (__double underscore)
Use double underscore sparingly when you want to avoid collisions in subclasses. It’s not about security; it’s to prevent accidental access/override.
class Base:
def __init__(self) -> None:
self.__token = "secret" # becomes _Base__token
class Child(Base):
def leak(self):
# print(self.__token) # AttributeError
return dir(self) # you'll see _Base__token
Validation with @property
from dataclasses import dataclass
@dataclass
class Temperature:
_celsius: float
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = value
_single_underscore for non‑public, add @property only when you need validation, caching, or compatibility.
2) Encapsulation at module & package level
In Python, modules (files) and packages (directories with __init__.py) are the primary boundaries. You can hide helpers and limit the public API.
# mypkg/mathutil.py
__all__ = ["mean"] # exported API from this module
def _sum(xs: list[float]) -> float: # non-public helper
s = 0.0
for x in xs: s += x
return s
def mean(xs: list[float]) -> float:
if not xs: raise ValueError("empty")
return _sum(xs)/len(xs)
# consumer
from mypkg.mathutil import *
mean([1,2,3]) # OK
_sum([1,2,3]) # NameError (not exported)
3) Dataclasses, immutability & slots
@dataclass generates boilerplate (init, repr, eq). Prefer it over writing trivial classes.
from dataclasses import dataclass, replace
@dataclass(frozen=True, slots=True)
class Point:
x: float
y: float
p = Point(1, 2)
# p.x = 3 # FrozenInstanceError (immutable)
q = replace(p, x=3) # new instance ("builder-like")
slots=True to make attributes fixed and memory‑efficient, and to prevent accidentally creating new attributes (a subtle encapsulation win).4) Interfaces in Python: ABCs & Protocols
No interfaces keyword; use abc.ABC or structural typing via typing.Protocol.
from abc import ABC, abstractmethod
class Repository(ABC):
@abstractmethod
def save(self, obj) -> None: ...
class InMemoryRepo(Repository):
def __init__(self): self._data = []
def save(self, obj) -> None: self._data.append(obj)
from typing import Protocol
class SupportsSave(Protocol):
def save(self, obj) -> None: ...
# Any object with .save(obj) matches the Protocol — duck typing
5) Common pitfalls coming from Java
- Don’t write trivial getters/setters. Use
@propertyonly when needed. - Remember:
_attris a social contract. Tests can still access it — be disciplined. - Prefer immutability (
frozen=True) for value objects; usereplace()to evolve. - Use types (
from __future__ import annotations+mypy) to keep rigor. - Public API: define at package entry (
__init__.py) and hide internals with__all__.
6) Starter project — production‑ready scaffold
This template uses a virtual environment, pytest, ruff (linter), black (formatter) and mypy (type checking). It exposes a small domain with encapsulation.
Project structure
my_app/
├─ pyproject.toml
├─ README.md
├─ src/
│ └─ my_app/
│ ├─ __init__.py
│ ├─ domain.py # encapsulated entities, properties
│ └─ cli.py # entry point (main)
└─ tests/
└─ test_domain.py
pyproject.toml (PEP 621)
[project]
name = "my-app"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []
[project.optional-dependencies]
dev = ["pytest", "mypy", "black", "ruff"]
[project.scripts]
my-app = "my_app.cli:main"
[tool.ruff]
line-length = 100
[tool.mypy]
python_version = "3.11"
strict = true
[tool.black]
line-length = 100
src/my_app/domain.py
from __future__ import annotations
from dataclasses import dataclass
@dataclass(slots=True)
class _Balance: # non-public value object
amount: int
def deposit(self, cents: int) -> "_Balance":
if cents <= 0:
raise ValueError("amount must be positive")
return _Balance(self.amount + cents)
class Account:
def __init__(self, cents: int = 0) -> None:
self._balance = _Balance(cents)
@property
def cents(self) -> int:
return self._balance.amount
def deposit(self, cents: int) -> None:
self._balance = self._balance.deposit(cents)
src/my_app/cli.py
from __future__ import annotations
from my_app.domain import Account
def main() -> None:
acc = Account(100)
acc.deposit(50)
print(f"Balance: {acc.cents} cents")
if __name__ == "__main__":
main()
tests/test_domain.py
from my_app.domain import Account
def test_deposit_increases_balance():
acc = Account(100)
acc.deposit(25)
assert acc.cents == 125
Setup & run
py -3.11 -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install -e .[dev]
pytest -q
python -m my_app # or: my-app
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install -e .[dev]
pytest -q
python -m my_app # or: my-app
-e .)? It lets you import your package from src/ during development without re-installing after each change.
7) Java → Python: quick encapsulation cheat‑sheet
Java
private → Python _attr + @property.Java records → Python
@dataclass(frozen=True).Java packages → Python modules/packages +
__all__.Java
getX() → Python @property def x(self).Java
setX() → Python @x.setter with validation.Lombok builder →
dataclasses.replace() or attrs.evolve().8) Tooling essentials
- Formatting:
black - Linting:
ruff(also enforces underscore conventions) - Types:
mypy(strict mode recommended) - Tests:
pytest(tests/folder, functions start withtest_) - Pre-commit: optional hooks to run the above on each commit
# .pre-commit-config.yaml
repos:
- repo: https://github.com/psf/black
rev: 24.4.2
hooks: [{ id: black }]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.5
hooks: [{ id: ruff }, { id: ruff-format }]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks: [{ id: mypy }]
9) FAQ
Q: Can I truly hide fields in Python?
A: You can discourage access (_x) or mangle names (__x), but nothing beats tests + conventions. For hard boundaries, hide behind module APIs.
Q: Should I ever use explicit getX() methods?
A: Only if you need an API compatible with Java callers or you’re in a codebase that forbids properties. Otherwise use @property.
Q: What about performance?
A: Properties are fast enough for most apps. Use slots=True for hot paths and avoid per‑instance __dict__ bloat.