Table of Contents Link to heading
- Object-Oriented Programming
- Classes and Objects
- The Constructor: init
- Instance Attributes vs Class Attributes
- Instance Methods
- Encapsulation
- The str and repr Methods
- Practical Example: Network Device Class
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
__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