python OOP classes objects object-oriented-programming

Object-Oriented Programming in Python

Learning objectives

In this in-depth guide you will learn to:

  • distinguish between a Class (the blueprint) and an Object (the instance);
  • use the __init__ constructor and the self parameter;
  • define methods to encapsulate logic;
  • use “Magic Methods” such as __str__;
  • apply OOP to create an IPAddress class for networking.

Core Concepts

Object-Oriented Programming (OOP) lets us structure code by grouping data and behaviour into a single container.

  • Class: The “blueprint” or template. It defines what attributes (data) and methods (actions) the object will have. Example: the blueprint of an IP address.
  • Object (Instance): A concrete object created from the class. Example: the IP 192.168.1.10.
  • Attributes: Variables that hold the object’s data (e.g. octets, mask).
  • Methods: Functions that operate on the object’s data (e.g. convert to binary).

Basic Syntax in Python

The __init__ Constructor

A special method called automatically when a new object is created. It initialises the attributes.

The self parameter

Represents the current instance of the object. When you call a method on an object, Python automatically passes the object itself as the first argument — that is why it must always be declared.

class Dog:
    # Constructor
    def __init__(self, name, breed):
        self.name = name    # Instance attribute
        self.breed = breed  # Instance attribute

    # Method
    def bark(self):
        return f"{self.name} says: Woof!"

# Creating objects (Instantiation)
fido = Dog("Fido", "Labrador")
rex  = Dog("Rex", "German Shepherd")

print(fido.bark())  # Fido says: Woof!

Encapsulation and Getters/Setters

In Python, encapsulation is not as strict as in other languages (e.g. Java or C++), but it is good practice not to access internal attributes directly when you want to control how they are read or modified.

Why use them?

Imagine you have an age attribute. If you let anyone do person.age = -5, you end up with invalid data. Using a setter, you can validate the value before assigning it.

The Pythonic solution: @property

Instead of writing get_age() and set_age() methods, Python uses decorators to make methods look like ordinary attributes.

class Student:
    def __init__(self, name, grade):
        self.name = name
        self._grade = grade  # _ signals "internal use" (convention)

    @property
    def grade(self):
        """Getter: called when you write student.grade"""
        return self._grade

    @grade.setter
    def grade(self, new_grade):
        """Setter: called when you write student.grade = 10"""
        if 0 <= new_grade <= 10:
            self._grade = new_grade
        else:
            print("Invalid grade! Must be between 0 and 10.")

s = Student("Mario", 6)
print(s.grade)   # Calls the getter -> 6
s.grade = 12     # Calls the setter -> Invalid grade!
s.grade = 8      # Calls the setter -> OK

Static Methods

Sometimes you need a function that logically belongs to a class but does not need access to self (i.e. to the data of a specific instance). These are static methods.

They are defined with the @staticmethod decorator and do not take self as their first argument.

class Maths:
    @staticmethod
    def add(a, b):
        return a + b

# No need to create an object!
print(Maths.add(5, 3))  # 8

Dataclasses

We often create classes just to hold data, writing a long __init__ and a repetitive __str__. Since Python 3.7, dataclasses automate all of this.

from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: float
    quantity: int = 1  # Default value

# __init__ and __str__ are generated automatically!
p = Product("Laptop", 999.99)
print(p)
# Output: Product(name='Laptop', price=999.99, quantity=1)

Case Study: The IPAddress Class

Instead of handling IP addresses and netmasks as plain strings scattered throughout the code, we create a class that encapsulates the networking logic.

Class Structure

We want to create an IP, display it, and (in the future) perform subnetting calculations.

class IPAddress:
    def __init__(self, ip_string, cidr):
        """
        Initialises the object.
        :param ip_string: e.g. "192.168.1.10"
        :param cidr: integer for the mask, e.g. 24 (for /24)
        """
        self.ip_string = ip_string
        self.cidr = cidr
        # We can calculate the netmask immediately or later
        self.netmask_string = self._cidr_to_netmask(cidr)

    def __str__(self):
        """
        Magic method for string representation.
        Called when you do print(object).
        """
        return f"IP: {self.ip_string}/{self.cidr} (Mask: {self.netmask_string})"

    def _cidr_to_netmask(self, cidr):
        """
        'Private' method (convention: _) to convert /24 to 255.255.255.0.
        """
        # Simplified example (for demonstration only):
        if cidr == 24: return "255.255.255.0"
        if cidr == 16: return "255.255.0.0"
        if cidr == 8:  return "255.0.0.0"
        return "Unknown"

    def to_binary(self):
        """Returns the IP address in binary format (32-bit string)."""
        # EXERCISE: Implement the conversion
        # Hint: split('.'), int(), bin(), zfill(8)
        pass

    def get_network_address(self):
        """Returns the network address."""
        # EXERCISE: Apply bitwise AND between IP and Mask
        pass

    def is_same_network(self, other_ip_object):
        """
        Checks whether this IP and another are on the same subnet.
        :param other_ip_object: Another IPAddress instance
        """
        # EXERCISE: Compare the network addresses
        pass

Using the Class

Here is how we will use our class once it is complete:

# Creating two IP objects
ip1 = IPAddress("192.168.1.50", 24)
ip2 = IPAddress("192.168.1.200", 24)
ip3 = IPAddress("192.168.2.10", 24)

print(ip1)
# Expected output: IP: 192.168.1.50/24 (Mask: 255.255.255.0)

# Network check (once you implement the methods)
# if ip1.is_same_network(ip2):
#     print("They are on the same local network!")

Practical Exercise

Goal: Complete the IPAddress class

Now it is your turn to implement the missing logic.

  1. Binary Conversion: Complete to_binary(). The address 192.168.1.1 must become a 32-bit string (or a list of 4 bytes).
  2. Mask Handling: Improve _cidr_to_netmask to handle any CIDR value (from /0 to /32), not just the standard ones.
  3. Network Address: Implement get_network_address(). Remember to perform the bitwise AND between the IP and the Netmask.
  4. Network Verification: Complete is_same_network(self, other). It should return True if the two IPs have the same network address, False otherwise.
  5. Bonus — Broadcast: Add a method to calculate the Broadcast address of the network.

Hint: To work with bits in Python, remember the bitwise operators: & (AND), | (OR), ~ (NOT). It is often convenient to convert the entire IP into a single 32-bit integer for calculations.