Unit 4: More on OOP concepts

ECAP776 5 min read

I. Orientation — Polymorphism in Python

Polymorphism is the object-oriented principle by which one interface can represent different implementations or behaviors. In Python, it is supported by dynamic typing, inheritance, special methods, and runtime method resolution rather than by the compile-time mechanisms common in statically typed languages.

  • Core meaning: The word polymorphism means “many forms”; an operation such as obj.draw() can behave differently depending on the class of obj.
  • Dynamic binding: Python usually determines the method to execute at runtime from the actual object, not merely from the variable name or annotation.
  • Duck typing: An object is often accepted because it supports the required operation, regardless of its declared class—“if it behaves like the required object, it can be used as one.”
  • Class namespace convention: A class normally stores only one attribute under a given name. Defining calculate twice does not create two overloads; the later definition replaces the earlier one.
  • Special-method convention: Operators are connected to methods with double-underscore names, such as + to __add__() and == to __eq__().
  • Inheritance requirement: Method overriding occurs when a subclass supplies its own implementation of a method inherited from a superclass.
  • Related but distinct mechanisms:
    • Function overloading: One conceptual operation accepts different argument patterns.
    • Operator overloading: A class defines how operators act on its instances.
    • Method overriding: A subclass changes inherited behavior while retaining the method interface.

II. Function Overloading — One Operation, Multiple Call Patterns

A. Definition and Python’s Runtime Model

Function overloading is the use of one function name for operations that differ by the number, types, or arrangement of arguments. Python does not provide traditional compile-time overloading based solely on multiple function signatures; flexible parameters or runtime dispatch are used instead.

  • Traditional model: Languages with signature-based overloading may distinguish area(int) from area(float, float), but Python identifies a function in a namespace primarily by its name.
  • Replacement behavior: If two functions have the same name, the second assignment replaces the first.
PYTHON
def display(value):
    return f"Value: {value}"

def display(value, unit):
    return f"{value} {unit}"

# display(10) raises TypeError because only the second definition remains.
  • Error cause: After the second def, display refers only to the two-parameter function; the earlier function object is no longer bound to that name.
  • Runtime flexibility: A single Python definition can still support several valid call forms through default values, variable-length arguments, or explicit dispatch.

B. Function overloading

Python simulates function overloading by writing one function that interprets different argument patterns or by using a dispatch facility.

  1. Parameter-based techniques

    • Default arguments: A parameter receives a predefined value when the caller omits it. In power(base, exponent=2), base is the number being raised and exponent is its power.
    • Variable positional arguments: *args collects additional positional arguments into a tuple, allowing calls with different argument counts.
    • Variable keyword arguments: **kwargs collects additional named arguments into a dictionary.
    • Manual inspection: len(args), isinstance(), or pattern matching can select behavior, although excessive type checking weakens duck typing.
  2. Dispatch and annotation techniques

    • Single dispatch: functools.singledispatch selects an implementation according to the type of the first argument.
    • Static overload declarations: typing.overload describes alternative signatures to type checkers, but the decorated declarations do not perform runtime dispatch.
    • Concrete implementation: A sequence of @overload declarations must be followed by one ordinary implementation that handles every declared case.

Worked example: flexible argument counts

PYTHON
def total(*values):
    if not values:
        return 0
    return sum(values)

print(total())          # 0
print(total(4, 6))      # 10
print(total(1, 2, 3))   # 6
  • Symbol definitions: values is the tuple of supplied numbers; * packs positional arguments; sum(values) adds the tuple’s elements.
  • Overloaded effect: The same name supports zero, two, or three arguments without creating multiple definitions.
  • Interface benefit: Callers learn one operation, total, rather than separate names such as total_two and total_three.

C. Applications and Limitations

Function overloading is useful when several call forms express the same conceptual operation, but the accepted forms must remain understandable.

  • Suitable applications: Constructors, conversion functions, formatting functions, and numerical operations often need optional parameters or several input representations.
  • Readability condition: Each accepted signature should have a clear meaning; unrelated behaviors should use separate function names.
  • Ambiguity risk: A function containing many isinstance() branches may become difficult to test and extend.
  • Type limitation: singledispatch considers only the first argument’s runtime type, so it does not directly provide full multiple dispatch.
  • Error design: Unsupported combinations should raise an informative exception such as TypeError("expected one or two numeric arguments").
  • Mutable-default warning: Defaults such as items=[] are created once, not once per call; use items=None and create the list inside the function.

III. Operator Overloading — Custom Meaning for Built-in Operators

A. Definition and Special-Method Protocol

Operator overloading allows instances of user-defined classes to respond to operators such as +, -, *, <, and ==. Python translates an operator expression into calls to designated special methods.

  • Translation rule: For a + b, Python first attempts an addition method associated with the operands, principally a.__add__(b).
  • Common mappings:
    • a + b corresponds to __add__.
    • a - b corresponds to __sub__.
    • a * b corresponds to __mul__.
    • a == b corresponds to __eq__.
    • a < b corresponds to __lt__.
    • -a corresponds to __neg__.
  • Protocol-based design: Special methods are normally invoked through operators—write a + b rather than calling a.__add__(b) directly.
  • Semantic expectation: An overloaded operator should preserve an intuitive meaning; + should usually combine or add rather than perform an unrelated action.

B. Operator overloading

A class overloads an operator by implementing the corresponding special method and returning either an appropriate result or NotImplemented.

Worked example: vector addition

PYTHON
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return Vector(self.x + other.x, self.y + other.y)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(2, 3)
v2 = Vector(4, 1)
print(v1 + v2)  # Vector(6, 4)
  • Symbol definitions: v1 and v2 are two-dimensional vectors; x is the horizontal component, y is the vertical component, and other is the right-hand operand.
  • Component rule: If (v_1=(x_1,y_1)) and (v_2=(x_2,y_2)), their sum is:
TEXT
v1 + v2 = (x1 + x2, y1 + y2)
  • New-object behavior: __add__() returns a new Vector, leaving both operands unchanged.
  • Unsupported operands: Returning NotImplemented lets Python attempt a reflected operation such as the right operand’s __radd__() before raising TypeError.
  • Reflected methods: __radd__(), __rsub__(), and similar methods handle cases where the class instance appears on the operator’s right side.
  • In-place methods: __iadd__() supports a += b; it may mutate a or return a replacement object.

C. Applications and Limitations

Operator overloading makes domain objects concise and expressive, but careless definitions can make code misleading.

  • Natural applications: Vectors, matrices, complex numbers, dates, monetary values, sets, and symbolic expressions have familiar operator semantics.
  • Consistency requirement: If a == b is true, hashing and ordering behavior should obey the relevant Python contracts; mutable value objects generally should not be hashable.
  • Comparison support: __eq__() defines equality, while ordering may require __lt__(), __le__(), __gt__(), and __ge__().
  • No new operators: Classes may customize existing Python operators but cannot invent symbols such as <>+<>.
  • Precedence limitation: Overloading does not change grammatical precedence; a + b * c still evaluates multiplication before addition.
  • Maintainability rule: When no conventional operator meaning exists, a named method such as merge() or convert() is clearer.

IV. Method Overriding — Specialized Inherited Behavior

A. Definition and Method Resolution

Method overriding occurs when a subclass defines a method with the same name as an inherited method, causing subclass instances to use the specialized implementation. The selected method is found through the class’s method resolution order, or MRO.

  • Inheritance condition: There must be a superclass-subclass relationship; two unrelated classes with equally named methods do not override one another.
  • Runtime selection: If animal refers to a Dog, then animal.speak() invokes Dog.speak() when that override exists.
  • Interface continuity: The overriding method should usually accept arguments compatible with the parent method so callers can use subclass objects safely.
  • MRO inspection: ClassName.mro() shows the order in which Python searches classes for an attribute.
  • Multiple inheritance: Python uses the C3 linearization algorithm to produce a consistent MRO for classes with several parents.

B. Method overriding

Overriding lets a subclass preserve a common operation while changing its implementation to suit a more specific type.

Worked example: polymorphic method calls

PYTHON
class Shape:
    def area(self):
        raise NotImplementedError("Subclasses must define area()")

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

    def area(self):
        return self.width * self.height

shape = Rectangle(5, 3)
print(shape.area())  # 15
  • Symbol definitions: width is the rectangle’s horizontal length, height is its vertical length, and area() returns their product.
  • Override point: Rectangle.area() has the same method name as Shape.area() but supplies concrete rectangle behavior.
  • Dynamic dispatch: Although Rectangle inherits from Shape, the call shape.area() resolves to the subclass implementation.
  • Abstract intent: Raising NotImplementedError communicates that the base implementation is incomplete; formal abstract classes can instead use abc.ABC and @abstractmethod.
  • Parent cooperation: An override may extend rather than replace inherited behavior by calling super().method(...).
  • super() meaning: It delegates to the next implementation in the MRO, which is especially important in cooperative multiple inheritance.

C. Applications and Limitations

Method overriding supports extensible class hierarchies when subclasses genuinely satisfy the contract established by their base class.

  • Typical applications: Framework callbacks, graphical components, serializers, payment types, and shape hierarchies use a common method name with type-specific behavior.
  • Substitution principle: A subclass object should remain usable wherever its superclass is expected; an override should not impose surprising extra requirements.
  • Signature compatibility: Narrowing accepted inputs or changing the return meaning can break polymorphic callers even though Python permits the definition.
  • State requirement: The override must preserve necessary superclass invariants, often by calling super().__init__() during subclass initialization.
  • Name-mangling limitation: Methods beginning with two underscores, such as __process, are name-mangled and are not overridden in the ordinary way.
  • Design boundary: If subclasses repeatedly violate the parent contract, composition—placing one object inside another—may be more appropriate than inheritance.