Classes, Instances, and Encapsulation

Updated

September 7, 2026

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.py

Output:

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.py

Output:

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

  1. Create a class NetworkInterface with attributes name and ip_address. Add a property is_loopback that returns True if the IP address starts with "127.".
  2. Implement a TemperatureSensor class that stores temperature in Celsius, but provides getter and setter properties for Fahrenheit with proper mathematical conversion (\(F = C \times \frac{9}{5} + 32\)).
  3. Demonstrate the difference in __dict__ between an instance with single-underscore attributes (self._token) versus double-underscore attributes (self.__token).
  4. 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.