Skip to main content
Advertisement

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

NotationNameActual RestrictionPurpose
attrPublicNoneExternal API
_attrProtectedConventional (none)Internal use, subclasses allowed
__attrPrivateName manglingPrevent name collisions, strong encapsulation
__attr__DunderPython reservedMagic 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
Advertisement