Improve support for unnormalized Fractions

I’ve wanted a way to create fractions like 2/6 which haven’t been reduced to lowest form for the longest time, and today I learned that Fraction already supports this!

Fraction has a lovely little hidden feature: by passing a private keyword argument to the constructor, we can create unnormalized fractions where the numerator and denominator are not reduced to lowest form:

>>> from fractions import Fraction
>>> a = Fraction(2, 6)  # By default, fractions are normalised to lowest form.
>>> b = Fraction(2, 6, _normalize=False)
>>> print(a, b)
1/3 2/6

This can occasionally be useful:

  • Ratios and proportions.
  • When working with fractions without reduction to lowest term, e.g. for primary school fractions.
  • For mediants.

I was surprised to learn that there is no obvious supported way to then normalise such a fraction. There is no normalize() or reduce() method. I thought that the unary + sign might work, but it doesn’t.

Let’s improve handling of such unnormalised fractions:

  • Make unnormalised fractions official, by promoting the private _normalize parameter to public, or by adding a new alternate constructor.
  • Add support to normalise fractions to lowest form, I suggest the unary + operator.


1 Like

I’m afraid that no, Fraction does not already support this. That’s not what the _normalize keyword is for. It’s for situations where you already know that the numerator and denominator you’re supplying are in lowest terms (with positive denominator) and you want to skip the expensive extra gcd computation that would otherwise be required to normalise.

Some of the various methods on Fraction require the fraction to be normalised in order to produce correct results. limit_denominator is one example, equality checks are another, but there are almost certainly others. Bits of it may happen to “work” with unnormalised fractions, but if they do then that’s by accident rather than by design.


Meta: this is a beautiful example of Hyrum’s law (see also xkcd: Workflow) in action, and the reason why changes like that proposed in gh-101773: Optimize creation of Fraction's in private methods by skirpichev · Pull Request #101780 · python/cpython · GitHub are potentially dangerous.

1 Like

What operations would you do on a non-reduced fraction? Would it be practical to spin up a class that looks broadly like this:

class AbnormalFraction: # or denormal or unnormal or whatever
    def __init__(self, n, d):
        self.numerator = n; self.denominator = d
    def __add__(self, other):
        return Fraction(self) + other
    def __mul__(self, other):
        return AbnormalFraction(self.numerator * other, self.denominator)
# etc

where every operation you’re interested in is implemented either directly, or on top of fractions.Fraction?

1 Like

No, it does not work.

>>> from fractions import Fraction
>>> a = Fraction(2, 6)  # By default, fractions are normalised to lowest form.
>>> b = Fraction(2, 6, _normalize=False)
>>> a == b
>>> b = Fraction(12, 6, _normalize=False)
>>> b.is_integer()
>>> b = Fraction(2, -6, _normalize=False)
>>> b < 0

The math of unnormalized fractions is weird. If you want to get some reasonable results you need to add an additional code and slow down normalized fractions. I am not even sure that that there is a non-contradictional system.

1 Like

I think it should be possible without too extra overhead with an additional sticky flag (e.g. is_normalized). If it’s true - go for a quick path in equality/relational ops and so on. If not - proceed with an unnormalized version.
But I doubt the proposed feature does make sense for the Fraction class. In fact, probably every method should be adapted in the above way, even arithmetic methods, that “works” now. That’s because current arithmetic methods actually do a partial normalization, e.g.:

>>> a, b = Q(2, 4, _normalize=False), Q(-4, -8, _normalize=False)
>>> a+b
Fraction(-2, -2)

instead of -32/-32.
So, probably this should be a new alternate class for fractions. Not sure it worth.