Classes, Instances, and Encapsulation
Classes, Instances, and Encapsulation
After reading this chapter, you will master class definition and instance instantiation, distinguish between class attributes and instance attributes, implement safe encapsulation using @property, and understand the mechanics of Python’s name mangling.
Mental model
In Python, classes are first-class objects created at runtime when the class block executes. An instance is a distinct heap-allocated object (PyInstanceObject) whose __class__ pointer references its type.
Class vs Instance Namespace:
Class: ServiceConfig
├── default_timeout = 30 (Stored in ServiceConfig.__dict__)
├── default_retries = 3
└── __init__, start, stop methods
▲
│ __class__ pointer
Instance: auth_service
├── name = "auth" (Stored in auth_service.__dict__)
└── port = 8080
When you access auth_service.default_timeout, Python searches: 1. auth_service.__dict__ (instance namespace) 2. If missing, ServiceConfig.__dict__ (class namespace) 3. If missing, parent classes up the MRO 4. If missing, raises AttributeError
Minimal example
Save as class_encapsulation.py:
# class_encapsulation.py
class ServerNode:
# Class attribute: shared across all instances
cluster_name: str = "us-east-cluster"
def __init__(self, hostname: str, port: int) -> None:
# Instance attributes: unique to each instance
self.hostname = hostname
self._port = port # Protected convention (one underscore)
self.__secret_token = f"tok_{hostname}_{port}" # Private mangled (two underscores)
@property
def port(self) -> int:
"""Getter property: read-only access to port."""
return self._port
@port.setter
def port(self, value: int) -> None:
"""Setter property: enforces validation boundaries."""
if not (1 <= value <= 65535):
raise ValueError(f"Invalid port {value}: Must be between 1 and 65535")
self._port = value
def describe(self) -> str:
return f"{self.hostname}:{self._port} (Cluster: {self.cluster_name})"
def main() -> None:
node1 = ServerNode("worker-01", 8080)
node2 = ServerNode("worker-02", 9090)
print(node1.describe())
print(node2.describe())
# Mutating property via validated setter
node1.port = 8443
print(f"Updated node1 port: {node1.port}")
try:
node1.port = 99999
except ValueError as err:
print(f"Validation rejected invalid port: {err}")
if __name__ == "__main__":
main()Run via uv run python class_encapsulation.py:
worker-01:8080 (Cluster: us-east-cluster)
worker-02:9090 (Cluster: us-east-cluster)
Updated node1 port: 8443
Validation rejected invalid port: Invalid port 99999: Must be between 1 and 65535
Worked examples
Case 1: Computed Properties & Caching
The @property decorator allows creating dynamically computed attributes that look like normal fields to callers:
# computed_metrics.py
import time
class StorageVolume:
def __init__(self, name: str, total_gb: float, used_gb: float) -> None:
self.name = name
self.total_gb = total_gb
self.used_gb = used_gb
@property
def free_gb(self) -> float:
"""Dynamically computed attribute."""
return self.total_gb - self.used_gb
@property
def usage_percent(self) -> float:
"""Percentage calculation with guard against division by zero."""
if self.total_gb <= 0:
return 0.0
return (self.used_gb / self.total_gb) * 100.0
@property
def status(self) -> str:
pct = self.usage_percent
if pct > 90.0:
return "CRITICAL"
elif pct > 75.0:
return "WARNING"
return "HEALTHY"
if __name__ == "__main__":
vol = StorageVolume("data-vol-01", total_gb=500.0, used_gb=410.0)
print(f"Volume : {vol.name}")
print(f"Free : {vol.free_gb:.1f} GB")
print(f"Usage : {vol.usage_percent:.1f}%")
print(f"Status : {vol.status}")Run:
uv run python computed_metrics.pyOutput:
Volume : data-vol-01
Free : 90.0 GB
Usage : 82.0%
Status : WARNING
Case 2: Demystifying Name Mangling (__var)
When an attribute starts with two leading underscores (and at most one trailing underscore), CPython transforms the name internally to _<ClassName>__<var> to prevent accidental overriding in subclasses:
# name_mangling_demo.py
class SecurityKey:
def __init__(self, key_id: str, raw_secret: str) -> None:
self.key_id = key_id
self.__secret = raw_secret # Name mangled
def verify(self, candidate: str) -> bool:
return self.__secret == candidate
if __name__ == "__main__":
sec = SecurityKey("auth-key-01", "super_secret_token_123")
print(f"Public ID: {sec.key_id}")
print("Verification result:", sec.verify("super_secret_token_123"))
# Direct access fails with AttributeError
try:
print(sec.__secret)
except AttributeError as err:
print(f"Direct access rejected: {err}")
# Inspecting the instance dictionary reveals the mangled name
print(f"Internal __dict__ keys: {list(sec.__dict__.keys())}")
print(f"Accessing mangled key directly: {sec._SecurityKey__secret}")Run:
uv run python name_mangling_demo.pyOutput:
Public ID: auth-key-01
Verification result: True
Direct access rejected: 'SecurityKey' object has no attribute '__secret'
Internal __dict__ keys: ['key_id', '_SecurityKey__secret']
Accessing mangled key directly: super_secret_token_123
Name mangling is not true cryptographic security (Python does not enforce private memory protection); it is an anti-collision mechanism to prevent subclasses from accidentally stomping on internal fields.
Pitfalls
Pitfall 1: Mutating Class Attributes via an Instance Reference
Assigning to instance.attr creates a new instance attribute in that instance’s dictionary, rather than updating the shared class attribute:
class Cluster:
nodes = [] # Dangerous mutable class attribute!
c1 = Cluster()
c2 = Cluster()
# Mutating in-place affects all instances because 'nodes' references the same list
c1.nodes.append("node-1")
print(c2.nodes) # ['node-1']
# Re-binding creates a new instance attribute on c1 only:
c1.nodes = ["node-new"]
print(c1.nodes) # ['node-new'] (stored in c1.__dict__)
print(c2.nodes) # ['node-1'] (still looks at Cluster.nodes)Always initialize mutable attributes (lists, dicts, sets) inside __init__ on self.
Exercises
- Create a class
NetworkInterfacewith attributesnameandip_address. Add a propertyis_loopbackthat returnsTrueif the IP address starts with"127.". - Implement a
TemperatureSensorclass that stores temperature in Celsius, but provides getter and setter properties for Fahrenheit with proper mathematical conversion (\(F = C \times \frac{9}{5} + 32\)). - Demonstrate the difference in
__dict__between an instance with single-underscore attributes (self._token) versus double-underscore attributes (self.__token). - Write a class that counts how many instances of itself have been created using a class attribute.
Further reading
- Python Documentation: Classes and Objects.
- Python Data Model: Customizing attribute access.
- PEP 8: Naming Conventions for class and instance attributes.