A09 Classes

Class As Blueprint

# Class as blueprint to create objects
# ------------------------------------------------------------------------------
# A class can act as a template from which many objects are built. This file
# defines a Person blueprint containing attributes and methods that every
# instance MUST have when created. Each instance of the Person class
# will have its own unique values for these attributes.

class Person(object):
    """The Person class serves as a blueprint for creating person objects."""

    # Person has what?
    def __init__(self, name, age):
        self.name = name
        self.age = age

    # Person can do what?
    def say_hello(self):
        print(f"Hello, my name is {self.name} and I am {self.age} years old.")

# Create concrete instances of the Person class
person1 = Person("Alice", 30)
person2 = Person("Bob", 25)

person1.say_hello()
# Output: Hello, my name is Alice and I am 30 years old.

person2.say_hello()
# Output: Hello, my name is Bob and I am 25 years old.

Class Outline

# Class structure outline
# ------------------------------------------------------------------------------
# This file outlines common elements found in many classes such as attributes,
# static methods and constructors. Organizing these pieces consistently makes
# new classes easier to understand and maintain.
#
# The internal state of a class is defined by its attributes, which can be
# class variables (shared across all instances) or instance variables (unique
# to each instance). Methods can be categorized as instance methods (which
# operate on instance variables), class methods (which operate on class
# variables), or static methods (which do not operate on either).

class ClassStructure(object):
    '""Class definition ....""'

    # Class attributes
    class_var = "Foo"

    def __new__(cls, *args, **kwargs):
        """This method is called to create a new instance of the class."""
        print("This is the constructor method")
        return object.__new__(cls)

    def __init__(self):
        """This method is called to initialize the instance after it is created."""
        print("This is the initialization method")
        self.inst_var = "John"

    def instance_method(self):
        """This method has access to both the class and instance variables."""
        print(self.class_var, self.inst_var)

    @classmethod
    def class_method(cls):
        """This method can access class variables but not instance variables."""
        print(cls.class_var)

    @staticmethod
    def static_method():
        """This method does not have access to the class or instance variables."""
        print(ClassStructure.class_var)

Instance Methods

# A class instance is as concrete realization of a class
# ------------------------------------------------------------------------------
# The code creates an instance of the Person class as a tangible object.
# The constructor assigns initial values to the instance. After creation, the
# object can call its methods and access stored data.

class Person(object):

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def say_hello(self):
        print("Hello, I am {} and my age is {}".format(self.name, self.age))


person = Person("John", 32)

# Access to the instance attributes
print(person.name)
print(person.age)
# Output:
# John
# 32

# Call the instance method
person.say_hello()
# Output:
# Hello, I am John and my age is 32

Class Methods

# Class method used to modify the class itself
# ------------------------------------------------------------------------------
# In Python, class methods can be used to modify the behavior of all instances
# of a class by changing class-level attributes. Class methods are defined
# using the `@classmethod` decorator and take the class itself as the first
# parameter, conventionally named `cls`.

class Male(object):

    NAME_PREFIX = "Mr."

    def __init__(self, name):
        self.name = name

    @classmethod
    def set_prefix(cls, prefix):
        cls.NAME_PREFIX = prefix

    @classmethod
    def get_prefix(cls):
        return cls.NAME_PREFIX

    def show_name(self):
        print(f"{Male.get_prefix()} {self.name}")


john = Male("John")
ivan = Male("Ivan")

# Show the initial name
john.show_name()
ivan.show_name()

# Modify the class-level attribute will affect all instances
Male.set_prefix("Mister")

# Show the modified name
john.show_name()
ivan.show_name()

Static Methods

# Static method are not bound to the class and can be used as utility functions
# ------------------------------------------------------------------------------
# Static methods operate without reference to a particular instance or class.
# They behave like regular functions that happen to live in the class's
# namespace and are often used for related utility tasks.

class Packet(object):

    def __init__(self, ip_addr='192.168.10.1', mask="255.255.255.0", payload=()):
        self.payload = payload
        self.ip_addr = ip_addr
        self.mask = mask

    @staticmethod
    def dot_to_bytes(val):
        return bytes(map(int, val.split('.')))

    @staticmethod
    def bytes_to_dot(val):
        return '.'.join(map(str, val))


# Convert IP address in dot notation to bytes
addr_bytes = Packet.dot_to_bytes('192.168.1.1')
print(addr_bytes)

# Convert bytes to IP address in dot notation
addr_dot = Packet.bytes_to_dot(addr_bytes)
print(addr_dot)

Nested Classes

# Nested classes for constants, settings, etc.
# ------------------------------------------------------------------------------
# Classes can contain other classes that serve as containers for related
# constants or configuration. Nesting keeps these auxiliary definitions close to
# the code that uses them.

class Settings:

    LANG = "English"
    THEME = "Light"
    IP_ADDR = "192.168.210.10"
    PORT = 8080

    class AdvancedSettings:

        ENABLE_LOGGING = False
        MAX_CONNECTIONS = 10

    class ExperimentalSettings:

        ENABLE_NEW_FEATURE = False


# Access the basic settings
print(Settings.THEME)

# Access the b settings
print(Settings.AdvancedSettings.ENABLE_LOGGING)  # Output: False

# Access the experimental settings
print(Settings.ExperimentalSettings.ENABLE_NEW_FEATURE)  # Output: False

Object Construction

# How __new__ and __init__ cooperate in object creation
# ------------------------------------------------------------------------------
# Object creation begins with __new__, which allocates the instance. The fresh
# object is then passed to __init__ for further initialization. Separating these
# steps gives developers flexibility to customize how objects come into
# existence.

class Calculator(object):

    def __new__(cls):

        # Use the parent class to create the object
        obj = object.__new__(cls)

        # Return the object
        print("__new__ : Created object {}".format(obj))
        return obj

    def __init__(self):

        # Self is the object that was created by __new__
        print("__init__: Using object {}".format(self))

        # Add attributes to the object
        self.name = "Cool Calculator"


calc = Calculator()
print(calc.name)

Property Decorator

# Property used to encapsulate an instance variable
# ------------------------------------------------------------------------------
# The `@property` decorator exposes getter and setter functions as attribute
# access. This allows validation or computation while keeping the public
# interface simple as if it were a regular attribute accessed using dot
# notation.

class Person(object):

    def __init__(self, name, age):
        self._name = name
        self._age = age

    # -------- Getter and Setter for name --------
    def get_name(self):
        return self._name

    def set_name(self, value):
        if not isinstance(value, str):
            raise TypeError("Expected a string")
        self._name = value

    # -------- Getter/Setter acting as  --------
    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if not isinstance(value, str):
            raise TypeError("Expected a string")
        self._age = value


# Explicit getter/setter methods
person = Person("John", 30)
person.set_name("Jane")
print(person.get_name())
# Output: Jane

# Getter/setter as property (acting as an attribute)
person.age = 35
print(person.age)
# Output: 35

Named Constructors

# Named constructors provide alternative ways to create instances of a class
# ------------------------------------------------------------------------------
# A class can offer several named constructors for convenience. Each one accepts
# parameters tailored for a specific situation and returns a configured
# instance.

class Rectangle(object):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @classmethod
    def from_diagonal(cls, x1, y1, x2, y2):
        """Usecase: Alternative constructor parameters."""
        width = abs(x2 - x1)
        height = abs(y2 - y1)
        return cls(width, height)

    @classmethod
    def square(cls, size):
        """Usecase: Reduced constructor parameters."""
        return cls(size, size)

    @classmethod
    def from_file(cls, file):
        """Usecase: Create an instance from a file."""

        with open(file, 'r') as f:
            data = f.read()
            x1, y1, x2, y2 = data.split(',')

        return cls.from_diagonal(float(x1), float(y1), float(x2), float(y2))

    @classmethod
    def from_json(cls, data):
        """Usecase: Create an instance from JSON data object."""
        return cls(**data)

Linear Inheritance

# Multilevel inheritance
# ------------------------------------------------------------------------------
# Multilevel inheritance arranges classes in a linear hierarchy. Each
# subsequent class extends the one above it, accumulating behavior down the
# chain.

class A(object):

    @staticmethod
    def process():
        print("Root is processing... ")


class B(A):
    pass


class C(B):
    pass


# The method process is searched for until the first class having the method is found (here A)
test = C()
test.process()

Multiple Inheritance

# Multiple inheritance
# ------------------------------------------------------------------------------
# A single class may inherit behavior from several parents. Combining features
# this way can reduce duplication but requires careful design to avoid
# conflicts.

class A(object):

    @staticmethod
    def process():
        print("Class A is processing... ")


class B(object):

    @staticmethod
    def process():
        print("Class B is processing... ")


class C(A, B):
    pass


class D(B, A):
    pass


# The method process is searched for until the first class having the method is found (here A)
test = C()
test.process()

# The method process is searched for until the first class having the method is found (here B)
test = D()
test.process()

Superclass Keyword

# Call __new__ method of super class
# ------------------------------------------------------------------------------
# A subclass can override __new__ while still delegating part of the creation
# process to its parent. Calling the superclass method ensures base attributes
# are initialized correctly.

class Person(object):

    def __new__(cls, name):
        return super(Person, cls).__new__(cls)

    def __init__(self, name):
        self.name = name


class Employee(Person):

    def __new__(cls, name, id_number):
        return super(Employee, cls).__new__(cls, name)

    def __init__(self, name, id_number):
        super(Employee, self).__init__(name)
        self.id_number = id_number

    def get_id_number(self):
        return self.id_number



e = Employee("John", 1234)
print(e.name)
print(e.id_number)


e = Employee("John", 1234)
print(e.name)
print(e.id_number)


e = Employee("John", 1234)
print(e.get_id_number())

Method Resolution Order

# MRO (Method Resolution Order) - bottom first
# ------------------------------------------------------------------------------
# The method resolution order (MRO) in Python is the order in which Python
# looks for methods in a class hierarchy. It is particularly important in
# multiple inheritance scenarios, where a class inherits from multiple parent
# classes.
#
# The MRO is determined by the C3 linearization algorithm, which ensures that
# the order of method resolution is consistent and respects the order of
# inheritance. The MRO can be viewed using the `mro()` method of a class.


# Example of MRO in Python (bottom up, left to right)
class A(object):
    @staticmethod
    def process():
        print("A.process()")


class B(object):
    @staticmethod
    def process():
        print("B.process()")


class C(A, B):
    pass


class D(C, B):
    pass


test = D()
print(D.mro())
# Output:
# [<class '__main__.D'>, <class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>]

Diamond Problem

# Mro (method resolution order) - diamond problem
# ------------------------------------------------------------------------------
# A diamond inheritance pattern occurs when two classes share a common base
# class. The method resolution order ensures that the base is initialized only
# once. The classes here are organized so that each path to the base is
# considered without duplication.

class A(object):
    @staticmethod
    def process():
        print("A.process()")


class B(A):
    @staticmethod
    def process():
        print("B.process()")


class C(A):
    @staticmethod
    def process():
        print("C.process()")


class D(B, C):
    pass


d = D()
print(D.mro())
d.process()

Unresolved MRO

# Mro (method resolution order) - unresolved
# ------------------------------------------------------------------------------
# Some inheritance graphs produce conflicting search orders that Python cannot
# resolve. This file sets up such a conflict and triggers an error when the
# interpreter tries to build the method resolution order. Understanding this
# failure helps diagnose complex inheritance issues.

class Player(object):
    pass


class Enemy(Player):
    pass


class GameObject(Player, Enemy):
    pass


# Explanation (see MRO rules in the documentation):
#
# - MRO is GameObject -> Player -> Enemy -> Player (not monotonic as Player appears twice)
# - Rule 2 says Enemy should appear before Player
# - Rule 3 says Player should appear before Enemy
#
# Rules 2 and 3 are in conflict, so the MRO algorithm cannot be applied. This is called an
# "unresolvable inheritance graph" and Python will raise an exception in this case.

g = GameObject()
print(GameObject.mro())