The Rational Class

Like other numbers, we think of a rational number as a unit, but all rational numbers can be most easily represented as a fraction, a ratio of two integers. We can create a class Rational, so

r = Rational(2, 3);

would create a new Rational number for the mathematical expression, 2/3.

Our previous simple class examples have mostly been ways of collecting related data, with limited methods beyond getters and setters. Rational numbers also have lots of obvious operations defined on them. Our Rational example we will use most of the concepts for object so far, and add a few more, to provide a rich practical class with a variety of methods.

Fraction notation is used as a way of expressing an underlying rational number. Recall that mathematically 10/15 and 2/3 are different representations of the same rational number. I presume that as a primary school student you were told in general to reduce to lowest terms, allowing an easy check of equality.

I propose that a Rational only stores a fraction in lowest terms: it does the simplification immediately inside the constructor. That will make equality testing more sane.

Thinking ahead to what we would like for our rational numbers, here is some testing code, some involving regular mathematical infix notation, and parsing string literals.

>>> Rational(4, -6)
Rational('-2/3')
>>> a = Rational('15/20')
>>> a
Rational('3/4')
>>> print a
3/4
>>> -4*(2*a + 5)/10
Rational('-13/5')
>>> Rational('2.5')
Rational('5/2')
>>> a < 1
True

We will gradually talk about how to implement the ideas for this. You should have read foppluc Ch 20, done the Animal lab, and read More Advanced Topics first.

Constructor

A Rational has a numerator and a denominator. We must remember that data. Each individual Rational that we use will have its own numerator and denominator, which we store as the instance variables.

Like other Python numeric types I want a Rational to be intended to be immutable. Though that is not possible, really, we can follow the Python convention for instance variables that should not be accessed from outside the class, with an initial underscore. We will use instance variables _num and _den for numerator and denominator.

We also want to store the number in lowest terms: Intermediate operations and initial constructor parameters will not always be in lowest terms. To reduce to lowest terms we need to divide the original numerator and denominator by their greatest common divisor. We include a static gcd method to do this via the Euclidean algorithm:

@staticmethod
def gcd(a,b): # note no self parameter for static method
    "Return the greatest common divisor of the pair (a,b)"
    while b != 0:  # Euclidean algorithm
        a, b = b, a%b
    return a

A second feature you probably used in grade school was that a negative fraction always had the negative sign in the numerator. We will assume that, too.

A simple constructor incorporating what we have said so far would be:

def __init__(self, numerator, denominator):
    '''Constructs a new Rational with numerator and denominator
    self._num and self._den.'''

    assert isinstance(numerator, int)
    assert isinstance(denominator, int)
    assert denominator != 0
    divisor = self.gcd(numerator, denominator) # simplify immediately
    if denominator // divisor < 0: # will make denominator be > 0
        divisor = -divisor         #     when reduced to lowest terms
    self._num = numerator // divisor     # lowest terms
    self._den = denominator // divisor   #    and self._den > 0

Note that we detect as many errors immediately as we can, with the assert statements.

We will actually use a considerably more sophisticated constructor. While we certainly want to construct from two integers, we may often want to convert an integer to a Rational, so making the formal parameter denominator default to 1, denominator=1, will be convenient.

We should be able to make a new Rational from another Rational, so we will allow a single Rational parameter. Also, like with int, we want to allow a string conversion. These make sense for a Rational:

We can do all these things building with methods we have already, and with some cleverness for some of the string conversions. You can delay getting into the details, but the result we will use are:

def __init__(self, numerator, denominator=1):
    """Constructs a new Rational.  If denominator is omitted or 1,
    the result can be copied from a numerator Rational or can be
    parsed from a numerator string which may contain either a
    division symbol '/' or a decimal point, but not both.
    Otherwise numerator and denominator must be of type int.
    An error is raised if denominator is 0. """
    if isinstance(numerator, Rational): # check type
        assert denominator == 1 # assert forces error if False
        self._num = numerator._num
        self._den = numerator._den
        return
    if isinstance(numerator, str):
        assert denominator == 1
        if '/' in numerator:
            [numStr, denomStr] = numerator.split('/')
            numerator = int(numStr)
            denominator = int(denomStr)
        else:  # string for int or float
            s = numerator.strip()
            i = s.find('.')
            if i >= 0:  # float literal with decimal
                denominator = 10**(len(s) - i - 1)
                numerator = int(s[:i]+s[i+1:])
            else: # denominator still 1
                numerator = int(numerator)
    assert isinstance(numerator, int)
    assert isinstance(denominator, int)
    assert denominator != 0
    divisor = self.gcd(numerator, denominator) # simplify immediately
    if denominator // divisor < 0: # will make denominator be > 0
        divisor = -divisor         #     when reduced to lowest terms
    self._num = numerator // divisor     # lowest terms
    self._den = denominator // divisor   #    and self._den > 0

The whole class is quite a mouthful. I will be explaining and displaying much smaller chunks in this document. You can look at or download the full code in rational.py.

__str__

We certainly want to be able to display a Rational as a string version:

def __str__(self):
    """Returns a simple string representation of the Rational. """
    if self._den == 1:
        return str(self._num)   # string for an integer
    else: # Uses the fact that a minus sign can only be in the numerator.
        return str(self._num) + '/' + str(self._den)

Arithmetic Methods

To begin, consider what it means to add two fractions together. Remember that you can only add fractions if they have the same denominator. The easiest way to find a common denominator is to multiply the two individual denominators together. Anything we do to the denominator needs to the done to the numerator. This gives us the following equation for fraction addition:

a/b + c/d = (ad + cb)/bd

We can do this with a regular method, acting on self and a Rational parameter:

def add(self, frac2):
    '''return the sum of self and another Rational, frac2'''
    return Rational(self._num * frac2._den + self._den * frac2._num,
                    self._den * frac2._den)

We could add numbers with code like:

fr1 = Rational('2/3')
fr2 = Rational(5)
fr3 = fr1.add(fr2)

This would work, but it is so awkward! We know we can manipulate integers with normal mathematical infix notation as in:

x = 3
y = 5
z = x + y

We would like to do the same thing with Rationals and have it make sense:

fr1 = Rational('2/3')
fr2 = Rational(5)
fr3 = fr1 + fr2

It turns out that there is a general mechanism, that applies to integers, too: Even an int is actually an object, and there is an int method __add__ to add. The interpreter actual transforms x + y into:

x.__add__(y)

We can define a method for Rationals with the same name, and the + will also be translated. So instead of add we can use __add__:

def __add__(self, frac2):
    '''return self + frac2, where frac2 is another Rational'''
    return Rational(self._num * frac2._den + self._den * frac2._num,
                    self._den * frac2._den)

Then this will make sense:

fr1 = Rational('2/3')
fr2 = Rational(5)
fr3 = fr1 + fr2  # calls fr1.__add__(fr2)

This call to Rational's __add__ depends on the fact that the left operand, coming before the + is a Rational. We only defined __add__ so far so the right operand must also be a Rational. Of course it also makes sense logically to add an int or a float to a Rational fr1.

Using the isinstance function, we can arrange to handle all of those! Here is a fancier __add__:

def __add__(self, frac2):
    """Returns self + frac2, as new Rational
    if frac2 is a Rational or an int."""
    if isinstance(frac2, int):
         frac2 = Rational(frac2)
    return Rational(self._num * frac2._den + self._den * frac2._num,
                    self._den * frac2._den)

How about evaluating v + fr, where v is an int and fr is in our Rational class? This case is trickier, since the full class int was defined well before our Rational: int's __add__ knows nothing about objects of type Rational.

In actual fact an __add__ method is not limited to returning a useful value or else causing an error: It can return a special object, NotImplemented. With int v and Rational fr, v.__add__(fr) should return NotImplemented. This special returtn value triggers the interpreter to next look to see if the method __radd__ for the type of the right operand is defined. The r suggests "right". In this example the interpreter would look to evaluate fr.__radd__(v). If the Rational class were to implement a __radd__ method (and it will), and its evaluation returned a value without error, then v + fr would be successfully evaluated.

Of course with addition being commutative, __radd__ should return the same thing as __add__.

All the other symbolic infix operators also can be implemented in a similar fashion. We will see many in the Rational class.

All the special methods for these operations start and end like __add__ and __str__ with double underscores. There is a name for the collection of all such methods: dunder methods, abbreviating Double UNDERscore.

There are other dunder functions that can operate on user-defined classes. Some are called when built-in functions are called with a user-defined object:

  • str(obj) calls obj.__str__()
  • int(obj) calls obj.__int__()
  • float(obj) calls obj.__float__()
  • bool(obj) calls obj.__bool__()
  • repr(obj) calls obj.__repr__()

We also define in the Rational class:

def __float__(self):
    """Returns a float approximating the current Rational.
    """
    return self._num / self._den

This is important when we do arithmetic with a Rational and a float: The assumption is that float results are inexact, so an operation with a float should be another inexact float.

Here is the final __add__ version that allows for _radd__, and for frac2 being a float. (It also allows for another numeric type for frac2: complex):

def __add__(self, frac2):
    """Returns self + frac2, as new Rational if possible."""
    if isinstance(frac2, (float, complex)): return float(self) + frac2
    if isinstance(frac2, int):
         frac2 = Rational(frac2)
    if not isinstance(frac2, Rational):
        return NotImplemented # need __radd__ for frac2 type
    return Rational(self._num * frac2._den + self._den * frac2._num,
                    self._den * frac2._den)

There are dunder methods for all the common arithmetic operators, __sub__, __mul__, __div__, __truediv__ (/), __pow__ (**), and the unary functions __neg__ (negation), __abs__ (abs(self)), and further type conversions, __int__, __bool__.

They use mostly the same ideas as in __add__. They are all in rational.py to have a completely usable class, but there are not important new ideas, so careful reading of them with their doc strings is totally optional.

Comparisons

We will want to compare Rationals and use the standard symbols, < > <= >= == !=. The dunder methods are respectively __lt__, __gt__, __le__, __ge__, __eq__, __ne__.

We can convert a comparison of two Rationals a/b and c/d into the same comparison of a/b - c/d and 0, or after multiplying by both positive denominators: compare ad - bc to 0. For example here is one implementation of __lt__:

def __lt__(self, frac2):
    ''' return  self < frac2'''
    if isinstance(frac2, float):
         return float(self) < frac2
    if isinstance(frac2, int):
         frac2 = Rational(frac2)
    if not isinstance(frac2, Rational): return NotImplemented
    # reduce to integer inequality, using fact denominators are positive
    return self._num * frac2._den < self._den * frac2._num

My final version shows some refactoring. I first wrote all the operation methods for ordering (< <= > >=) with a lot of duplicate code like in the implementation above. Then I noticed that the only difference was one integer comparison in the return lines (< replaced by >, <= or >=). Hence the introduction of the helping method _ineq in the final version, which also allows me to show off functions as parameters! See the op module discussion in More Advanced Topics:

def _ineq(self, frac2, op): # helper function for ordering dunders
    ''' return  self op frac2 for op among < > <= >='''
    if isinstance(frac2, float):
         return op(float(self), frac2)
    if isinstance(frac2, int):
         frac2 = Rational(frac2)
    if not isinstance(frac2, Rational): return NotImplemented
    # reduce to integer inequality, using fact: denominators are positive
    return op(self._num * frac2._den, self._den * frac2._num)

def __lt__(self, frac2):
    """Method translating < """
    return self._ineq(frac2, operator.lt)

Then __gt__, __le__ and __ge__ are also one-liners.

A special comparison is equality, ==, with dunder method __eq__. Many types only allow values of the same type to be equal. We will follow the approach that Python uses for other numerical types - only checking for mathematical equality, not also type equality, so 0.0 == 0 == Rational('0/1'). A number is never equal to a non-number: neither 0 nor Rational(0,1) is equal to the string '0'.

Two Rationals are equal if numerators and denominators match (since the constructor uniquely reduces to lowest terms). To compare to a float, we convert the Rational to a float. We can convert an int to a Rational to compare:

def __eq__(self, frac2):
    """Method translating self == frac2 and used by !=
    A Rational can equal an int.
    We choose to allow equality to float or complex and
    to make the comparison legal but False for other types."""
    if isinstance(frac2, (float, complex)): # good idea?
         return float(self) == frac2
    if isinstance(frac2, int):
         frac2 = Rational(frac2)
    return ( isinstance(frac2, Rational) and
             self._num == frac2._num and self._den == frac2._den )

The comparison operators do not need special __r...__ versions for right-hand operands. For instance the interpreter assumes f < n is equivalent to n > f, and n == f is equivalent to f == n.

In foppluc 16.1 on sorting they refer to "whatever the natural ordering is for the item type". With inequalities defined for Rationals, they have a "natural ordering", and so they can be sorted! The file sortrationals.py, copied below, shows how we can take a list of Rationals, and simply sort it, and use the built-in max and min functions. It also shows how __str__ and __repr__ are both used:

'''Illustrating that giving meaning to ordering operators
allows sort, max, and min to work.'''

from rational import Rational

def seqToStr(seq, sep = ' '):
    return sep.join([str(v) for v in seq])

line = input('Enter rational numbers on one line:\n'
             ' like:  2/3 42 7.12 -10/12\n')
rList = [Rational(v) for v in line.split()]
print('Reduced to lowest terms:\n', seqToStr(rList))

print('\nThe maximum and minimum values are', max(rList), min(rList))

rList.sort()
print('\nNow the list is sorted.  The values are:\n', seqToStr(rList))

print('\nNow see this list as printed directly by Python.')
print('Note the repr versions of the elements:')
print(rList)

Note the from form of the import statement: It allows the reference to Rational, not rational.Rational.

Actually sort only need __le__ (<=) to sort.

Repr

We also implement __repr__: If we had the Rational for mathematical -2/3, then its __str__ representation is '-2/3', and the constructor for Rational can parse that back to a Rational, so the __repr__ version could be 'Rational('-2/3')'.

In general:

def __repr__(self):
    """Returns a string representation that would evaluate to an
    equal Rational if typed into the shell.
    """
    return "Rational('{}')".format(self) # format uses __str__

Hash

We want to allow Rationals as dictionary keys and sets. You will learn much more about the speed issues for this in Comp 271, but a hash function is needed, implemented by the __hash__, dunder method, at the very end of the Rational source code.

Testing with unittest

The browser Python uses a module test that does not actually exist in the standard Python distribution, but the unittest module is available. It has methods much like the toy browser version. I test with the long program testrational.py.

There is a lot of functionality in the Rational class, so there are lots of tests! Notice that I test not only what should work, but also that things that should be errors do produce errors.

The testing system tests the test_... methods in order, and the output would mention the first or all with errors. (You could edit to force a mistake and see the output. It is boring with it all correct.)

It is important the the earliest things tested are things that need to be correct to test the later features.

The names should be reasonable and I include some documentation. This testing is really important, but I will not go into further details for Comp 170.

I have diddled with rational.py scores or hundreds of times, and many parts depend on other parts. I never need to worry that I have messed things up as long as I run the testing program quickly after changes: I believe my tests are quite exhaustive.

Looking Forward

This is a major enough example that we can show lots of comparisons when we get to Java!

Point class Operation Exercises

Consider the last version of the FOPPluc Point class, copied into point.py, discussed in the final FOPP Point class secion.

  1. We can think of a Point like a vector: They can be added, subtracted, and multiplied by a constant: If v1 = Point(1, 3), v2 = Point(5, 8), we want v1 + v2 to be Point(1+5, 3+8), and v1 * 10 to be Point(1*10, 3*10). We would like all of the following to make sense for Points v and w and integer or float c: v + w, v - w, c * v, v * c
    1. Make v + w and v * c work, defining the approproate dunder methods.
    2. Rewrite the halfway method using part a, and test it out.
    3. (optional) Make the remaining expressions in the introduction above work.
  2. Write an __eq__ method to compare two Points, and test it.
  3. Modify the constructor to also accept a tuple, like Point( (1, 2.1) ). (Note extra parentheses for the tuple.) Include an assert statement requiring the tuple to have length 2.
  4. Write a __repr__ satisfying Python's convention that the interpreter could parse the resulting string.