Access Control
Python does not provide private, protected, or public keywords like Java or C++. Instead, it uses naming conventions and a mechanism called name mangling to implement access control. Python's philosophy is "We're all consenting adults here" — trust the developer to follow conventions.
Public: The Default Access Level
Attributes and methods declared without a prefix are public. They can be accessed freely from anywhere.
class BankAccount:
def __init__(self, owner: str, balance: float):
self.owner = owner # public attribute
self.balance = balance # public attribute
def deposit(self, amount: float) -> None: # public method
self.balance += amount
def get_info(self) -> str: # public method
return f"{self.owner}: ${self.balance:,.2f}"
acc = BankAccount("Alice", 1000)
print(acc.owner) # Alice — direct access is possible
print(acc.balance) # 1000 — direct access is possible
acc.balance = 999 # Direct modification is also possible (risk of unintended changes!)
acc.deposit(500)
print(acc.get_info())
_protected: Conventional Private
Attributes/methods beginning with a single underscore (_) are protected. There is no actual restriction, but it signals: "This is for internal use — don't access it from outside."
class DataProcessor:
def __init__(self, data: list):
self.data = data # public
self._processed = False # protected — internal state
self._cache: dict = {} # protected — cache
def process(self) -> list:
"""Public API — called from outside"""
if self._is_already_processed():
return self._cache.get("result", [])
result = self._do_processing()
self._cache["result"] = result
self._processed = True
return result
def _do_processing(self) -> list:
"""protected — internal implementation detail"""
return [x * 2 for x in self.data]
def _is_already_processed(self) -> bool:
"""protected — internal helper method"""
return self._processed
dp = DataProcessor([1, 2, 3, 4, 5])
print(dp.process()) # [2, 4, 6, 8, 10]
# Accessing _-prefixed attributes is possible but conventionally discouraged
print(dp._processed) # True — possible but not recommended
dp._processed = False # Possible but don't do it!
Using _protected in Subclasses
class Animal:
def __init__(self, name: str, sound: str):
self.name = name
self._sound = sound # protected — accessible in subclasses
def _make_sound(self) -> str:
"""Protected method that subclasses can override"""
return self._sound
def speak(self) -> str:
return f"{self.name}: {self._make_sound()}"
class Dog(Animal):
def __init__(self, name: str):
super().__init__(name, "Woof")
def _make_sound(self) -> str: # Override protected method
return f"{self._sound}! {self._sound}!"
d = Dog("Rex")
print(d.speak()) # Rex: Woof! Woof!
__private: Name Mangling
Attributes/methods beginning with a double underscore (__) have name mangling applied by Python, making direct external access difficult. __attr is renamed to _ClassName__attr.
class SecureAccount:
def __init__(self, owner: str, password: str, balance: float):
self.owner = owner # public
self.__password = password # private — name mangling applied
self.__balance = balance # private
def verify_password(self, password: str) -> bool:
return self.__password == password
def get_balance(self, password: str) -> float:
if not self.verify_password(password):
raise PermissionError("Incorrect password.")
return self.__balance
def deposit(self, amount: float, password: str) -> None:
if not self.verify_password(password):
raise PermissionError("Incorrect password.")
self.__balance += amount
acc = SecureAccount("Alice", "secret123", 1_000)
# Attempt to access __password directly
try:
print(acc.__password) # AttributeError!
except AttributeError as e:
print(f"Access denied: {e}")
# It is actually stored with the mangled name
print(acc._SecureAccount__password) # secret123 (bypass possible but don't do it)
print(acc._SecureAccount__balance) # 1000
# Correct access method
print(acc.get_balance("secret123")) # 1000
# Check the actual attribute names on the instance
print(acc.__dict__)
# {'owner': 'Alice', '_SecureAccount__password': 'secret123', '_SecureAccount__balance': 1000}
How Name Mangling Works
class Parent:
def __init__(self):
self.__private = "Parent's private" # _Parent__private
self._protected = "Parent's protected"
def parent_method(self) -> str:
return self.__private # Normal access inside the class
class Child(Parent):
def __init__(self):
super().__init__()
self.__private = "Child's private" # _Child__private (a different attribute!)
def child_method(self) -> str:
return self.__private # Accesses the child's private
child = Child()
print(child.parent_method()) # Parent's private
print(child.child_method()) # Child's private
# Confirm the result of name mangling
print(child.__dict__)
# {'_Parent__private': "Parent's private",
# '_protected': "Parent's protected",
# '_Child__private': "Child's private"}
# Key insight: __private guarantees no name collision in inheritance
When Name Mangling is Needed
class Validator:
"""Base validator class"""
def __init__(self):
self.__errors: list[str] = [] # Prevent subclasses from accidentally overwriting
def _add_error(self, msg: str) -> None:
"""Protected method callable from subclasses"""
self.__errors.append(msg)
def is_valid(self) -> bool:
return len(self.__errors) == 0
def get_errors(self) -> list[str]:
return list(self.__errors)
class EmailValidator(Validator):
def validate(self, email: str) -> bool:
self.__errors = [] # This is _EmailValidator__errors — a separate attribute!
if "@" not in email:
self._add_error("Missing @ symbol.")
if "." not in email.split("@")[-1]:
self._add_error("Invalid domain.")
return self.is_valid()
v = EmailValidator()
print(v.validate("invalid")) # False
print(v.get_errors()) # ['Missing @ symbol.', 'Invalid domain.']
print(v.validate("user@example.com")) # True
__slots__: Restricting Attributes and Optimizing Memory
Defining __slots__ in a class explicitly restricts which attributes are allowed on instances, and uses a fixed-size array instead of __dict__, saving memory.
import sys
class PointWithDict:
"""Regular class: uses __dict__"""
def __init__(self, x: float, y: float):
self.x = x
self.y = y
class PointWithSlots:
"""Using __slots__: no __dict__"""
__slots__ = ("x", "y")
def __init__(self, x: float, y: float):
self.x = x
self.y = y
# Memory comparison
p1 = PointWithDict(1.0, 2.0)
p2 = PointWithSlots(1.0, 2.0)
print(f"With __dict__: {sys.getsizeof(p1.__dict__)} bytes (dict overhead)")
print(f"With __slots__: no __dict__")
print(hasattr(p1, "__dict__")) # True
print(hasattr(p2, "__dict__")) # False
# __slots__ only allows the declared attributes
try:
p2.z = 3.0 # AttributeError!
except AttributeError as e:
print(f"Error: {e}")
# Correct usage
p2.x = 10.0
print(p2.x) # 10.0
The Effect of __slots__ at Scale
import sys
import time
class RecordWithDict:
def __init__(self, id: int, name: str, value: float):
self.id = id
self.name = name
self.value = value
class RecordWithSlots:
__slots__ = ("id", "name", "value")
def __init__(self, id: int, name: str, value: float):
self.id = id
self.name = name
self.value = value
N = 100_000
# __dict__ version
start = time.perf_counter()
records_dict = [RecordWithDict(i, f"item_{i}", float(i)) for i in range(N)]
time_dict = time.perf_counter() - start
mem_dict = sum(sys.getsizeof(r) + sys.getsizeof(r.__dict__) for r in records_dict)
# __slots__ version
start = time.perf_counter()
records_slots = [RecordWithSlots(i, f"item_{i}", float(i)) for i in range(N)]
time_slots = time.perf_counter() - start
mem_slots = sum(sys.getsizeof(r) for r in records_slots)
print(f"__dict__ creation time: {time_dict:.4f}s, memory: {mem_dict / 1024:.1f} KB")
print(f"__slots__ creation time: {time_slots:.4f}s, memory: {mem_slots / 1024:.1f} KB")
print(f"Memory saved: {(1 - mem_slots / mem_dict) * 100:.1f}%")
__slots__ and Inheritance
class Base:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
class Extended(Base):
__slots__ = ("z",) # Parent's slots + child's slots
def __init__(self, x, y, z):
super().__init__(x, y)
self.z = z
e = Extended(1, 2, 3)
print(e.x, e.y, e.z) # 1 2 3
# Note: if a parent class has no __slots__, __dict__ is also created
class NoSlotParent:
pass # has __dict__
class SlotChild(NoSlotParent):
__slots__ = ("value",)
obj = SlotChild()
obj.value = 1
obj.extra = "This is stored in __dict__" # Parent allows __dict__, so it's possible
Python's Access Control Philosophy
Python's community motto: "We're all consenting adults here"
# Python's access control is about trust, not enforcement
# Design principles:
# 1. Make the public API clear
class Config:
def __init__(self):
self.name = "app" # public: readable and writable from outside
self._internal = {} # protected: for internal use, subclasses may access
self.__secret = "xyz" # private: only this class uses it, avoids name collision in inheritance
# 2. Use __all__ to declare the module-level public API
# (controls what is exported when doing `from module import *`)
# 3. Use @property to provide controlled access
class Temperature:
def __init__(self, celsius: float):
self._celsius = celsius # protected, controlled via @property
@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 is impossible")
self._celsius = value
# 4. Use __slots__ only when performance matters
# Not needed for typical classes
Access Level Summary
| Notation | Name | Actual Restriction | Purpose |
|---|---|---|---|
attr | Public | None | External API |
_attr | Protected | Conventional (none) | Internal use, subclasses allowed |
__attr | Private | Name mangling | Prevent name collisions, strong encapsulation |
__attr__ | Dunder | Python reserved | Magic methods/attributes |
Expert Tips
1. Using _ as a throwaway variable or "ignore" marker
# Ignore loop variable
for _ in range(5):
print("Hello!")
# Ignore unwanted values during unpacking
first, *_, last = [1, 2, 3, 4, 5]
print(first, last) # 1 5
# In the REPL: _ holds the result of the last expression
2. Declare the module-level public API with __all__
# mymodule.py
__all__ = ["PublicClass", "public_function"]
class PublicClass:
pass
class _InternalClass: # Excluded when doing `from mymodule import *`
pass
def public_function():
pass
def _internal_function(): # Excluded
pass
3. Choosing the right access level
# When to use what?
class MyClass:
def __init__(self):
# If it's an external API → public
self.public_data = []
# If it's internal implementation but subclasses need it → _protected
self._internal_state = {}
# If even subclasses should not access it → __private
self.__sensitive_data = "secret"
# But the most Pythonic approach is @property + _protected
self._temperature = 0.0
@property
def temperature(self) -> float:
return self._temperature
@temperature.setter
def temperature(self, value: float) -> None:
# Validate before setting
if value < -273.15:
raise ValueError("Invalid temperature")
self._temperature = value
Summary
- public: No prefix, external API
- _protected: Conventionally private, accessible in subclasses
- __private: Name mangling applied, strong encapsulation and name collision prevention
__slots__: Restrict allowed attributes + memory optimization, useful for large numbers of instances- Python's philosophy: trust and convention over enforcement