Why subclassing fractions.Fraction loses custom behavior

Summary

A developer attempted to extend the built-in fractions.Fraction class to implement a custom __str__ method for mixed fraction representation. While the string formatting worked in isolation, any arithmetic operation (e.g., MixedFraction(1, 3) + 2) caused the object to downgrade back to a standard Fraction. This resulted in a loss of the custom formatting, breaking the developer’s primary goal. Furthermore, attempting to fix this by overriding arithmetic methods caused type-hinting conflicts with static analysis tools like Pylance.

Root Cause

The failure stems from two distinct technical phenomena:

  • Type Erasure via Parent Class Implementation: The Fraction.__add__ method is defined to return a Fraction. In Python, when you call obj + other, if obj is a MixedFraction, Python calls MixedFraction.__add__. If you do not override this method, it falls back to Fraction.__add__, which explicitly returns a new instance of the base class Fraction, effectively erasing the subclass identity.
  • Liskov Substitution Principle (LSP) Violations in Typing: When overriding __add__, the developer attempted to change the return type from Fraction to MixedFraction. Static type checkers like Pylance/Pyright flag this because a subclass method should ideally return the same type as the parent method (or a subtype) to maintain type safety. However, the parent’s type signature is strictly defined as returning Fraction, creating a type mismatch when the developer tries to be “more specific.”

Why This Happens in Real Systems

In complex production systems, this is known as the “Subclassing Trap.”

  • Library Inheritance: Many high-performance libraries (like NumPy or specialized math libraries) use C-extensions. When you subclass these, the underlying C-code often returns the base type, completely bypassing your Python-level overrides.
  • Type Narrowing vs. Type Widening: Developers often want to “narrow” a return type (returning a more specific object), but type systems are designed to ensure that any code expecting the parent class can safely handle the result. If a library’s type stubs say add() -> Fraction, and you say add() -> MixedFraction, the type checker sees a potential break in the contract.

Real-World Impact

  • Silent Logic Errors: The system doesn’t crash; it simply returns the wrong type. This leads to “Type Drift,” where a value starts as a specialized object and slowly reverts to a generic one as it passes through various mathematical functions.
  • Brittle Codebases: Developers end up adding isinstance() checks throughout the application to ensure they are working with the correct subtype, which violates the Open/Closed Principle.
  • Developer Friction: Constant # type: ignore comments clutter the codebase and mask actual type errors, defeating the purpose of using static analysis.

Example or Code

from __future__ import annotations
from fractions import Fraction
from typing import overload, Union

class MixedFraction(Fraction):
    def __str__(self) -> str:
        sign = 1 if self.numerator >= 0 else -1
        pos_frac = abs(self)
        whole = pos_frac.numerator // pos_frac.denominator
        frac_part = pos_frac - whole

        if whole == 0:
            return str(sign * frac_part)

        whole_val = whole * sign
        if frac_part == 0:
            return str(whole_val)
        return f"{whole_val} {frac_part}"

    @overload
    def __add__(self, other: MixedFraction) -> MixedFraction: ...

    @overload
    def __add__(self, other: Fraction) -> MixedFraction: ...

    @overload
    def __add__(self, other: int) -> MixedFraction: ...

    def __add__(self, other: Union[Fraction, int]) -> MixedFraction:
        # We call the parent method to do the math, 
        # then wrap the result in our subclass.
        result = super().__add__(other)
        return MixedFraction(result.numerator, result.denominator)

    @overload
    def __radd__(self, other: MixedFraction) -> MixedFraction: ...

    @overload
    def __radd__(self, other: Fraction) -> MixedFraction: ...

    @overload
    def __radd__(self, other: int) -> MixedFraction: ...

    def __radd__(self, other: Union[Fraction, int]) -> MixedFraction:
        return self.__add__(other)

# Testing the fix
mf = MixedFraction(-7, 3)
print(f"Input: {mf}")
print(f"Result of addition: {mf + 2}")  # Should be -1 1/3
print(f"Result of type: {type(mf + 2)}")

How Senior Engineers Fix It

  1. Use @overload: To satisfy Pylance, we use the typing.overload decorator. This allows us to define multiple specialized signatures for the type checker while providing a single, generic implementation for the runtime.
  2. Manual Re-wrapping: Since the parent class’s arithmetic methods return a Fraction, we must explicitly intercept the result and pass it into our constructor: return MixedFraction(result.numerator, result.denominator).
  3. Implement Reflected Operators: To handle 2 + MixedFraction(1, 3), we must implement __radd__. Without it, Python will try to call int.__add__, which doesn’t know about our class, and will return a standard int or Fraction.
  4. Composition over Inheritance: If the inheritance becomes too complex (e.g., needing to override 20+ mathematical methods), a senior engineer would stop subclassing and instead use Composition. We would wrap a Fraction inside a MixedFraction class and delegate calls to it, only overriding the specific behaviors we care about.

Why Juniors Miss It

  • Assumption of Automatic Propagation: Juniors often assume that if A is a subclass of B, then any operation on A will automatically “know” to return an A. They miss the fact that methods are explicitly defined in the parent class.
  • Ignoring the Type Checker: When Pylance shows a red squiggly line, juniors often treat it as a nuisance to be silenced with type: ignore rather than a structural warning about a violation of the type contract.
  • Single-Direction Thinking: They focus on obj + other but forget about other + obj, missing the necessity of the reflected (dunder) methods.

Leave a Comment