Skip to content

Make Fraction more subclassing-friendly #136096

Closed as not planned
Closed as not planned

Description

@skirpichev

Feature or enhancement

Proposal:

Currently, most methods of the Fraction class subclasses return an instance of the Fraction class. That happens for arithmetic methods as well:

>>> from fractions import Fraction
>>> class MyFraction(Fraction):
...     pass
... a = MyFraction(1, 2)
... b = MyFraction(2, 3)
... 
>>> a+b
Fraction(7, 6)

I would guess, it was intentional.

On another hand, this makes subclassing of the Fraction - less useful. For example, what if we want to use something other than builtin int's for components of a fraction? Currently, it's possible, but... not quite:

>>> from gmpy2 import mpz
>>> from fractions import Fraction
>>> class mpq(Fraction):
...     def __new__(cls, numerator=0, denominator=None):
...         self = super(mpq, cls).__new__(cls, numerator, denominator)
...         self._numerator = mpz(numerator)
...         self._denominator = mpz(denominator)
...         return self
...         
>>> a, b = mpq(1, 2), mpq(3, 4)
>>> c = a + b
>>> c._numerator  # subclass instances use fast integer arithmetic
mpz(5)
>>> c._denominator
mpz(4)
>>> c  # but it's still an instance of the Fraction!
Fraction(5, 4)

Attached patch fixes this.

To better support such subclasses, I think we could also add Fraction.gcd class attribute to override the math.gcd().

Of course, this is a compatibility break (patch breaks our CI tests). Though, looking on the GitHub search for subclasses of Fraction, I don't think this will really break some people code.

A quick patch
diff --git a/Lib/fractions.py b/Lib/fractions.py
index cb05ae7c20..d81be2f90e 100644
--- a/Lib/fractions.py
+++ b/Lib/fractions.py
@@ -411,8 +411,9 @@ def limit_denominator(self, max_denominator=1000000):
 
         if max_denominator < 1:
             raise ValueError("max_denominator should be at least 1")
+        cls = self.__class__
         if self._denominator <= max_denominator:
-            return Fraction(self)
+            return cls(self)
 
         p0, q0, p1, q1 = 0, 1, 1, 0
         n, d = self._numerator, self._denominator
@@ -430,9 +431,9 @@ def limit_denominator(self, max_denominator=1000000):
         # the distance from p1/q1 to self is d/(q1*self._denominator). So we
         # need to compare 2*(q0+k*q1) with self._denominator/d.
         if 2*d*(q0+k*q1) <= self._denominator:
-            return Fraction._from_coprime_ints(p1, q1)
+            return cls._from_coprime_ints(p1, q1)
         else:
-            return Fraction._from_coprime_ints(p0+k*p1, q0+k*q1)
+            return cls._from_coprime_ints(p0+k*p1, q0+k*q1)
 
     @property
     def numerator(a):
@@ -782,38 +783,41 @@ def reverse(b, a):
 
     def _add(a, b):
         """a + b"""
+        cls = a.__class__
         na, da = a._numerator, a._denominator
         nb, db = b._numerator, b._denominator
         g = math.gcd(da, db)
         if g == 1:
-            return Fraction._from_coprime_ints(na * db + da * nb, da * db)
+            return cls._from_coprime_ints(na * db + da * nb, da * db)
         s = da // g
         t = na * (db // g) + nb * s
         g2 = math.gcd(t, g)
         if g2 == 1:
-            return Fraction._from_coprime_ints(t, s * db)
-        return Fraction._from_coprime_ints(t // g2, s * (db // g2))
+            return cls._from_coprime_ints(t, s * db)
+        return cls._from_coprime_ints(t // g2, s * (db // g2))
 
     __add__, __radd__ = _operator_fallbacks(_add, operator.add)
 
     def _sub(a, b):
         """a - b"""
+        cls = a.__class__
         na, da = a._numerator, a._denominator
         nb, db = b._numerator, b._denominator
         g = math.gcd(da, db)
         if g == 1:
-            return Fraction._from_coprime_ints(na * db - da * nb, da * db)
+            return cls._from_coprime_ints(na * db - da * nb, da * db)
         s = da // g
         t = na * (db // g) - nb * s
         g2 = math.gcd(t, g)
         if g2 == 1:
-            return Fraction._from_coprime_ints(t, s * db)
-        return Fraction._from_coprime_ints(t // g2, s * (db // g2))
+            return cls._from_coprime_ints(t, s * db)
+        return cls._from_coprime_ints(t // g2, s * (db // g2))
 
     __sub__, __rsub__ = _operator_fallbacks(_sub, operator.sub)
 
     def _mul(a, b):
         """a * b"""
+        cls = a.__class__
         na, da = a._numerator, a._denominator
         nb, db = b._numerator, b._denominator
         g1 = math.gcd(na, db)
@@ -824,13 +828,14 @@ def _mul(a, b):
         if g2 > 1:
             nb //= g2
             da //= g2
-        return Fraction._from_coprime_ints(na * nb, db * da)
+        return cls._from_coprime_ints(na * nb, db * da)
 
     __mul__, __rmul__ = _operator_fallbacks(_mul, operator.mul)
 
     def _div(a, b):
         """a / b"""
         # Same as _mul(), with inversed b.
+        cls = a.__class__
         nb, db = b._numerator, b._denominator
         if nb == 0:
             raise ZeroDivisionError('Fraction(%s, 0)' % db)
@@ -846,7 +851,7 @@ def _div(a, b):
         n, d = na * db, nb * da
         if d < 0:
             n, d = -n, -d
-        return Fraction._from_coprime_ints(n, d)
+        return cls._from_coprime_ints(n, d)
 
     __truediv__, __rtruediv__ = _operator_fallbacks(_div, operator.truediv)
 
@@ -858,6 +863,7 @@ def _floordiv(a, b):
 
     def _divmod(a, b):
         """(a // b, a % b)"""
+        cls = a.__class__
         da, db = a.denominator, b.denominator
         div, n_mod = divmod(a.numerator * db, da * b.numerator)
         return div, Fraction(n_mod, da * db)
@@ -866,8 +872,9 @@ def _divmod(a, b):
 
     def _mod(a, b):
         """a % b"""
+        cls = a.__class__
         da, db = a.denominator, b.denominator
-        return Fraction((a.numerator * db) % (b.numerator * da), da * db)
+        return cls((a.numerator * db) % (b.numerator * da), da * db)
 
     __mod__, __rmod__ = _operator_fallbacks(_mod, operator.mod, False)
 
@@ -881,21 +888,22 @@ def __pow__(a, b, modulo=None):
         """
         if modulo is not None:
             return NotImplemented
+        cls = a.__class__
         if isinstance(b, numbers.Rational):
             if b.denominator == 1:
                 power = b.numerator
                 if power >= 0:
-                    return Fraction._from_coprime_ints(a._numerator ** power,
-                                                       a._denominator ** power)
+                    return cls._from_coprime_ints(a._numerator ** power,
+                                                  a._denominator ** power)
                 elif a._numerator > 0:
-                    return Fraction._from_coprime_ints(a._denominator ** -power,
-                                                       a._numerator ** -power)
+                    return cls._from_coprime_ints(a._denominator ** -power,
+                                                  a._numerator ** -power)
                 elif a._numerator == 0:
                     raise ZeroDivisionError('Fraction(%s, 0)' %
                                             a._denominator ** -power)
                 else:
-                    return Fraction._from_coprime_ints((-a._denominator) ** -power,
-                                                       (-a._numerator) ** -power)
+                    return cls._from_coprime_ints((-a._denominator) ** -power,
+                                                  (-a._numerator) ** -power)
             else:
                 # A fractional power will generally produce an
                 # irrational number.
@@ -923,15 +931,18 @@ def __rpow__(b, a, modulo=None):
 
     def __pos__(a):
         """+a: Coerces a subclass instance to Fraction"""
-        return Fraction._from_coprime_ints(a._numerator, a._denominator)
+        cls = a.__class__
+        return cls._from_coprime_ints(a._numerator, a._denominator)
 
     def __neg__(a):
         """-a"""
-        return Fraction._from_coprime_ints(-a._numerator, a._denominator)
+        cls = a.__class__
+        return cls._from_coprime_ints(-a._numerator, a._denominator)
 
     def __abs__(a):
         """abs(a)"""
-        return Fraction._from_coprime_ints(abs(a._numerator), a._denominator)
+        cls = a.__class__
+        return cls._from_coprime_ints(abs(a._numerator), a._denominator)
 
     def __int__(a, _index=operator.index):
         """int(a)"""
@@ -977,10 +988,11 @@ def __round__(self, ndigits=None):
         # See _operator_fallbacks.forward to check that the results of
         # these operations will always be Fraction and therefore have
         # round().
+        cls = self.__class__
         if ndigits > 0:
-            return Fraction(round(self * shift), shift)
+            return cls(round(self * shift), shift)
         else:
-            return Fraction(round(self / shift) * shift)
+            return cls(round(self / shift) * shift)
 
     def __hash__(self):
         """hash(self)"""
diff --git a/Lib/test/test_fractions.py b/Lib/test/test_fractions.py
index d1d2739856..0e57478f1d 100644
--- a/Lib/test/test_fractions.py
+++ b/Lib/test/test_fractions.py
@@ -851,7 +851,7 @@ def testMixedMultiplication(self):
         self.assertTypedEquals(0.1 + 0j, (1.0 + 0j) * F(1, 10))
 
         self.assertTypedEquals(F(3, 2) * DummyFraction(5, 3), F(5, 2))
-        self.assertTypedEquals(DummyFraction(5, 3) * F(3, 2), F(5, 2))
+        self.assertTypedEquals(DummyFraction(5, 3) * F(3, 2), DummyFraction(5, 2))
         self.assertTypedEquals(F(3, 2) * Rat(5, 3), Rat(15, 6))
         self.assertTypedEquals(Rat(5, 3) * F(3, 2), F(5, 2))
 
@@ -881,7 +881,7 @@ def testMixedDivision(self):
         self.assertTypedEquals(10.0 + 0j, (1.0 + 0j) / F(1, 10))
 
         self.assertTypedEquals(F(3, 2) / DummyFraction(3, 5), F(5, 2))
-        self.assertTypedEquals(DummyFraction(5, 3) / F(2, 3), F(5, 2))
+        self.assertTypedEquals(DummyFraction(5, 3) / F(2, 3), DummyFraction(5, 2))
         self.assertTypedEquals(F(3, 2) / Rat(3, 5), Rat(15, 6))
         self.assertTypedEquals(Rat(5, 3) / F(2, 3), F(5, 2))
 
@@ -927,7 +927,7 @@ def testMixedIntegerDivision(self):
         self.assertTypedTupleEquals(divmod(-0.1, float('-inf')), divmod(F(-1, 10), float('-inf')))
 
         self.assertTypedEquals(F(3, 2) % DummyFraction(3, 5), F(3, 10))
-        self.assertTypedEquals(DummyFraction(5, 3) % F(2, 3), F(1, 3))
+        self.assertTypedEquals(DummyFraction(5, 3) % F(2, 3), DummyFraction(1, 3))
         self.assertTypedEquals(F(3, 2) % Rat(3, 5), Rat(3, 6))
         self.assertTypedEquals(Rat(5, 3) % F(2, 3), F(1, 3))

Has this already been discussed elsewhere?

No response given

Links to previous discussion of this feature:

No response

Activity

  1. self-assigned this
    on Jun 29, 2025
  2. added
    type-featureA feature request or enhancement
    stdlibStandard Library Python modules in the Lib/ directory
    on Jun 29, 2025
  3. skirpichev commented on Jun 29, 2025

    @skirpichev
    MemberAuthor
  4. serhiy-storchaka commented on Jun 29, 2025

    @serhiy-storchaka
    Member

    It is the same for builtin numeric types: int, float, complex. For subclasses, we cannot guarantee that constructor has expected signature and semantic.

    >>> class I(int): pass
    ... 
    >>> type(I(1)+I(2))
    <class 'int'>
  5. skirpichev commented on Jun 29, 2025

    @skirpichev
    MemberAuthor

    Yes, I expected that argument, though I wasn't sure if it was the major reason to not have closure property for arithmetic in subclasses (other candidates: (a) legacy, (b) speed).

    But I'm not sure if this is a strong argument, as in mentioned examples (and for Fraction) we have very specific base classes: they correspond to some well-defined mathematical objects. And we don't have a constraint, that instance methods (like arithmetic dunders) must use the class constructor. E.g. Fraction's methods use _from_coprime_ints() class method.

    I think - mentioned closure property is more important than a hypothetical possibility to have an incompatible signature in some Fraction's child class. Ditto for other numeric types.

  6. rhettinger commented on Jun 30, 2025

    @rhettinger
    Contributor

    It is the same for builtin numeric types: int, float, complex. For subclasses, we cannot guarantee that constructor has expected signature and semantic.

    Yes, this is our long-standing rule. I don't think it should be changed.

    Recommend that this be closed.

  7. added
    pendingThe issue will be closed if no feedback is provided
    on Jun 30, 2025
  8. skirpichev commented on Jun 30, 2025

    @skirpichev
    MemberAuthor

    Yes, this is our long-standing rule. I don't think it should be changed.

    With this, subclassing any numeric types in the stdlib is useless, isn't?

  9. serhiy-storchaka commented on Jul 1, 2025

    @serhiy-storchaka
    Member

    It is possible, but it has limited use. For example, IntEnums are int subclasses with representation "by name". They serve as compatible replacement for integer constants. You can do arithmetic with them, but the result is bare int. bool is other similar example. It overrides | and & operators to return bool, but other operators return int.

    If you implement something completely different, like gmpy2-based fractions, you rather create a class that implements all necessary protocols than subclass a builtin type.

  10. skirpichev commented on Jul 2, 2025

    @skirpichev
    MemberAuthor

    If you implement something completely different, like gmpy2-based fractions

    I don't think it's that different - actually Fraction supports this out of the box. The only problem is the constructor, that not casts integer literals to the gmpy2.mpz.

    create a class that implements all necessary protocols than subclass a builtin type.

    I.e. - not use the stdlib. Sounds odd.

    Another example of customization: what if we want to override some arithmetic method to use a different algorithm?

  11. removed their assignment
    on Jul 3, 2025
  12. removed
    pendingThe issue will be closed if no feedback is provided
    on Jul 9, 2025
  13. serhiy-storchaka commented on Jul 9, 2025

    @serhiy-storchaka
    Member

    Something similar was done for collections.abc.Set. I cannot say that I satisfied with that solution. If private method _from_iterable is a part of the protocol, then builtin set does not conform it. If it is not a part of protocol, where is the border between the protocol and the abstract class?

    This is not a simple issue. Some classes allow to return and instance of the subclass, others always return an instance of the base class. There is no strict rule, it just happened that for numeric types it is a base class.

  14. skirpichev commented on Jul 9, 2025

    @skirpichev
    MemberAuthor

    Now we have from_number() class methods, returning subclasses. Just another reason to not keep current constraints for arithmetic methods.

  15. self-assigned this
    on Jul 20, 2025
  16. skirpichev commented on Jul 20, 2025

    @skirpichev
    MemberAuthor

    Another good example, where subclassing currently impossible is odd behavior of the int's true division. People e.g. in the SymPy - reinvent the whole int type just to get a rational number, instead of a float.

  17. skirpichev commented on Mar 10, 2026

    @skirpichev
    MemberAuthor

    JFR, DPO discussion: https://discuss.python.org/t/support-subclassing-extending-numeric-classes/106437/. Note also, that stdlib behavior is not coherent here. See #10902.

    BTW, I forgot details. SymPy people reinvented the Fraction class twice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

stdlibStandard Library Python modules in the Lib/ directorytype-featureA feature request or enhancement

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions