Featured image

Table of Contents Link to heading

Object-Oriented Programming Link to heading

Object-oriented programming (OOP) organises code around objects — entities that bundle related data (attributes) and behaviour (methods) together. This contrasts with procedural programming, which organises code as a sequence of functions operating on shared data.

OOP’s primary benefits in engineering contexts:

  • Encapsulation: Data and the functions that operate on it live together; internal implementation can change without affecting code that uses the class
  • Reusability: A class written once can be instantiated many times; subclasses can extend behaviour without duplicating code
  • Modelling: Complex real-world entities (network devices, users, services) map naturally to classes with attributes and methods

Python is a multi-paradigm language — OOP is available but not mandatory. Use classes when you have entities with shared state and behaviour; use functions when the task is purely transformation of data.

Classes and Objects Link to heading

A class is a blueprint — a definition of what attributes and methods instances of that type will have. An object (or instance) is a concrete realisation of a class with specific values.

class Router:
    """Represents a network router."""
    pass

# Create instances (objects) from the class
r1 = Router()
r2 = Router()

print(type(r1))       # <class '__main__.Router'>
print(isinstance(r1, Router))   # True

By convention, class names use PascalCase (Router, NetworkDevice, UserAccount). Each call to the class creates a new, independent object.

The Constructor: init Link to heading

The __init__ method (constructor) is called automatically when a new object is created. It receives the new object as its first argument (self) followed by any arguments passed to the class call.

class Router:
    def __init__(self, hostname, model, ip_address):
        self.hostname = hostname       # instance attribute
        self.model = model
        self.ip_address = ip_address
        self.interfaces = []           # default empty list per instance

r1 = Router("core-sw-01", "Catalyst 9300", "10.0.0.1")
r2 = Router("dist-sw-01", "Catalyst 9200", "10.0.0.2")

print(r1.hostname)      # core-sw-01
print(r2.ip_address)    # 10.0.0.2

self is the instance itself — it is the mechanism by which an instance method accesses the instance’s own attributes and other methods. It must always be the first parameter of instance methods, though Python passes it automatically when you call a method on an instance.

Instance Attributes vs Class Attributes Link to heading

Instance attributes belong to a specific object — each instance has its own copy. They are typically defined in __init__ by assigning to self.attribute_name.

Class attributes belong to the class itself and are shared by all instances. They are defined at the class level, outside any method.

class NetworkDevice:
    vendor = "Cisco"           # class attribute — shared by all instances
    device_count = 0

    def __init__(self, hostname):
        self.hostname = hostname   # instance attribute — unique per object
        NetworkDevice.device_count += 1

d1 = NetworkDevice("router-01")
d2 = NetworkDevice("router-02")

print(d1.vendor)            # Cisco (accessed via instance, found on class)
print(d2.vendor)            # Cisco
print(NetworkDevice.vendor) # Cisco
print(NetworkDevice.device_count)  # 2

NetworkDevice.vendor = "Juniper"   # changes for ALL instances
print(d1.vendor)            # Juniper
Warning
When a class attribute is mutable (e.g., a list or dictionary), all instances share the same object. Appending to it from one instance modifies the shared object. If you need per-instance lists, initialise them in __init__ with self.my_list = [] — not as a class attribute.

Instance Methods Link to heading

Instance methods are functions defined inside a class that operate on a specific instance. Their first parameter is always self.

class Router:
    def __init__(self, hostname, ip_address):
        self.hostname = hostname
        self.ip_address = ip_address
        self.interfaces = []

    def add_interface(self, interface_name):
        """Add an interface to the router."""
        self.interfaces.append(interface_name)
        return self

    def get_info(self):
        """Return a summary of router information."""
        return f"{self.hostname} ({self.ip_address}) — {len(self.interfaces)} interfaces"

    def ping(self, target):
        """Simulate a ping from this router."""
        print(f"Pinging {target} from {self.hostname}...")

r = Router("core-01", "10.0.0.1")
r.add_interface("GigabitEthernet0/0")
r.add_interface("GigabitEthernet0/1")
print(r.get_info())
# core-01 (10.0.0.1) — 2 interfaces

Returning self from a method enables method chaining:

r.add_interface("Gi0/0").add_interface("Gi0/1").add_interface("Gi0/2")

Encapsulation Link to heading

Encapsulation hides internal implementation details and exposes a controlled interface. Python uses naming conventions to signal access intent — there is no hard enforcement like Java’s private keyword:

Convention Meaning
attribute Public — accessible from anywhere
_attribute Protected — intended for internal use or subclasses; external access is discouraged
__attribute Private — name mangled to _ClassName__attribute; discourages direct external access
class NetworkDevice:
    def __init__(self, hostname, password):
        self.hostname = hostname          # public
        self._config = {}                 # protected (internal use)
        self.__password = password        # private (name-mangled)

    def authenticate(self, provided_password):
        return provided_password == self.__password

d = NetworkDevice("router-01", "s3cr3t")
print(d.hostname)          # router-01 (public — fine)
print(d._config)           # {} (protected — works but discouraged)
# print(d.__password)      # AttributeError — name mangled
print(d._NetworkDevice__password)  # s3cr3t (name mangling revealed)

For attributes that should have controlled read/write access, use the @property decorator:

class Router:
    def __init__(self, hostname):
        self._hostname = hostname

    @property
    def hostname(self):
        return self._hostname

    @hostname.setter
    def hostname(self, value):
        if not isinstance(value, str) or len(value) == 0:
            raise ValueError("Hostname must be a non-empty string")
        self._hostname = value.lower()

r = Router("CORE-01")
print(r.hostname)       # core-01 (setter lowercased it)
r.hostname = "DIST-01"
print(r.hostname)       # dist-01

The str and repr Methods Link to heading

__str__ defines the human-readable string representation returned by str() and print(). __repr__ defines the developer-facing representation returned by repr() and used in the interactive interpreter.

class Router:
    def __init__(self, hostname, model):
        self.hostname = hostname
        self.model = model

    def __str__(self):
        return f"Router: {self.hostname} ({self.model})"

    def __repr__(self):
        return f"Router(hostname='{self.hostname}', model='{self.model}')"

r = Router("core-01", "Catalyst 9300")
print(r)         # Router: core-01 (Catalyst 9300)
print(repr(r))   # Router(hostname='core-01', model='Catalyst 9300')

__repr__ should ideally return a string that, when evaluated with eval(), recreates the object — though this is not always practical.

Practical Example: Network Device Class Link to heading

class NetworkDevice:
    """Base class for network infrastructure devices."""

    device_count = 0

    def __init__(self, hostname, device_type, ip_address, location=""):
        self.hostname = hostname
        self.device_type = device_type
        self.ip_address = ip_address
        self.location = location
        self.interfaces = {}
        self._is_online = False
        NetworkDevice.device_count += 1

    def add_interface(self, name, description=""):
        self.interfaces[name] = {"description": description, "state": "down"}

    def set_interface_state(self, name, state):
        if name not in self.interfaces:
            raise KeyError(f"Interface {name} not found")
        self.interfaces[name]["state"] = state

    @property
    def is_online(self):
        return self._is_online

    @is_online.setter
    def is_online(self, value):
        if not isinstance(value, bool):
            raise TypeError("is_online must be a boolean")
        self._is_online = value

    def status_summary(self):
        state = "ONLINE" if self.is_online else "OFFLINE"
        return (f"[{state}] {self.hostname} ({self.device_type}) "
                f"@ {self.ip_address}{len(self.interfaces)} interfaces")

    def __str__(self):
        return self.status_summary()

    def __repr__(self):
        return (f"NetworkDevice(hostname='{self.hostname}', "
                f"device_type='{self.device_type}', "
                f"ip_address='{self.ip_address}')")


# Usage
sw = NetworkDevice("access-sw-01", "Catalyst 9200", "192.168.1.10", "Building A")
sw.add_interface("Gi1/0/1", "Uplink to distribution")
sw.add_interface("Gi1/0/2", "Workstation port")
sw.set_interface_state("Gi1/0/1", "up")
sw.is_online = True

print(sw)
# [ONLINE] access-sw-01 (Catalyst 9200) @ 192.168.1.10 — 2 interfaces

print(NetworkDevice.device_count)   # 1