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 aFraction. In Python, when you callobj + other, ifobjis aMixedFraction, Python callsMixedFraction.__add__. If you do not override this method, it falls back toFraction.__add__, which explicitly returns a new instance of the base classFraction, effectively erasing the subclass identity. - Liskov Substitution Principle (LSP) Violations in Typing: When overriding
__add__, the developer attempted to change the return type fromFractiontoMixedFraction. 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 returningFraction, 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 sayadd() -> 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: ignorecomments 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
- Use
@overload: To satisfy Pylance, we use thetyping.overloaddecorator. This allows us to define multiple specialized signatures for the type checker while providing a single, generic implementation for the runtime. - 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). - Implement Reflected Operators: To handle
2 + MixedFraction(1, 3), we must implement__radd__. Without it, Python will try to callint.__add__, which doesn’t know about our class, and will return a standardintorFraction. - 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
Fractioninside aMixedFractionclass 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
Ais a subclass ofB, then any operation onAwill automatically “know” to return anA. 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: ignorerather than a structural warning about a violation of the type contract. - Single-Direction Thinking: They focus on
obj + otherbut forget aboutother + obj, missing the necessity of the reflected (dunder) methods.