If you've done any coding in Python, there's a good chance that you've used a number in one of your programs. For instance, you may have used an integer to specify the index of a value in a list.
But there's much more to numbers in Python than just their raw values. Let's look at three things about numbers in Python that you might not be aware of.
#1 - Numbers Have Methods
Pretty much everything in Python is an object. One of the first objects you learn about in Python is the str
object for representing strings. Maybe you've seen how strings have methods, such as the .lower()
method, which returns a new string with all lower case characters:
>>> "HELLO".lower()
'hello'
Numbers in Python are also objects and, just like strings, have their own methods. For instance, you can convert an integer to a byte string using the .to_bytes()
method:
>>> n = 255
>>> n.to_bytes(length=2, byteorder="big")
b'\x00\xff'
The length
parameter specifies the number of bytes to use in the byte string, and the byteorder
parameter determines the order of the bytes. For example, setting byteorder
to " big"
returns a byte string with the most significant byte first, and setting byteorder
to " little"
puts the least significant byte first.
255 is the largest integer that can be represented as an 8-bit integer, so you can set length=1
in .to_bytes()
with no problem:
>>> n.to_bytes(length=1, byteorder="big")
b'\xff'
If you set length=1
in .to_bytes()
for 256, however, you get an OverflowError
:
>>> n = 256
>>> n.to_bytes(length=1, byteorder="big")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
OverflowError: int too big to convert
You can convert a byte string to an integer using the .from_bytes()
class method:
>>> int.from_bytes(b'\x06\xc1', byteorder="big")
1729
Class methods are called from a class name instead of a class instance, which is why the .from_bytes()
method is called on int
above.
"I [Hardy] remember once going to see him [Ramanujan] when he was ill at Putney. I had ridden in taxi cab number 1729 and remarked that the number seemed to me rather a dull one, and that I hoped it was not an unfavourable omen. 'No,' he replied, 'it is a very interesting number; it is the smallest number expressible as the sum of two cubes in two different ways.'"
One of the ways you can express 1729 as the sum of two cubes is 1³ + 12³. Can you find the other way?
Floating point numbers also have methods. Perhaps the most useful method for floats is .is_integer()
which is used to check whether or not a float has no fractional part:
>>> n = 2.0
>>> n.is_integer()
True
>>> n = 3.14
>>> n.is_integer()
False
One fun float method is the .as_integer_ratio()
method, which returns a tuple containing the numerator and denominator of the fraction representing the floating point value:
>>> n = 0.5
>>> n.as_integer_ratio()
(1, 2)
Thanks to floating point representation error, though, this method can return some unexpected values:
>>> n = 0.1
>>> n.as_integer_ratio()
(3602879701896397, 36028797018963968)
If you need to, you can call number methods on number literals by surrounding the literals with parentheses:
>>> (255).to_bytes(length=1, byteorder="big")
b'\xff'
>>> (3.14).is_integer()
False
If you don't surround an integer literal with parentheses, you'll see a SyntaxError
when you call a method — although, strangely, you don't need the parentheses with floating-point literals:
>>> 255.to_bytes(length=1, byteorder="big")
File "<stdin>", line 1
255.to_bytes(length=1, byteorder="big")
^
SyntaxError: invalid syntax
>>> 3.14.is_integer()
False
You can find a complete list of methods available on Python's number types in the docs.
#2 - Numbers Have a Hierarchy
In mathematics, numbers have a natural hierarchy. For example, all natural numbers are integers, all integers are rational numbers, all rational numbers are real numbers, and all real numbers are complex numbers.
The same is true for numbers in Python. This “numeric tower” is expressed through abstract types contained in the numbers
module.
The Numeric Tower
Every number in Python is an instance of the Number
class:
>>> from numbers import Number
>>> # Integers inherit from Number
>>> isinstance(1729, Number)
True
>>> # Floats inherit from Number
>>> isinstance(3.14, Number)
True
>>> # Complex numbers inherit from Number
>>> isinstance(1j, Number)
True
If you need to check if a value in Python is numeric, but you don't care what type of number the value is, use isinstance(value, Number)
.
Python comes with four additional abstract types whose hierarchy, beginning with the most general numeric type, is as follows:
- The
Complex
class is used to represent complex numbers. There is one built-in concreteComplex
type:complex
. - The
Real
class is used to represent real numbers. There is one built-in concreteReal
type:float
. - The
Rational
class is used to represent rational numbers. There is one built-in concreteRational
type:Fraction
. - The
Integral
class is used to represent integers. There are two built-in concreteIntegral
types:int
andbool
.
Wait. bool
values are numbers?! Yes! You can verify all of this in your REPL:
>>> import numbers
>>> # Complex numbers inherit from Complex
>>> isinstance(1j, numbers.Complex)
True
>>> # Complex numbers are not Real
>>> isinstance(1j, numbers.Real)
False
>>> # Floats are Real
>>> isinstance(3.14, numbers.Real)
True
>>> # Floats are not Rational
>>> isinstance(3.14, numbers.Rational)
False
>>> # Fractions are Rational
>>> from fractions import Fraction
>>> isinstance(Fraction(1, 2), numbers.Rational)
True
>>> # Fractions are not Integral
>>> isinstance(Fraction(1, 2), numbers.Integral)
False
>>> # Ints are Integral
>>> isinstance(1729, numbers.Integral)
True
>>> # Bools are Integral
>>> isinstance(True, numbers.Integral)
True
>>> True == 1
True
>>> False == 0
True
At first glance, everything seems right here — other than bool
values being numbers, perhaps.
bool
type is Integral
— in fact, bool
inherits directly from int
— you can do some pretty weird stuff with True
and False
.For instance, you can use
True
as an index to the get the second value of an iterable. If you divide a number by False
you get a ZeroDivisionError
error.Try running
"False"[True]
and 1 / False
in your REPL!Take a closer look, though, there are a couple of things that are a bit weird about Python's numeric hierarchy.
Decimals Don't Fit In
There are four concrete numeric types corresponding to the four abstract types in Pythons number tower: complex
, float
, Fraction
, and int
. But Python has a fifth number type, the Decimal
class, that is used to exactly represent decimal numbers and overcome limitations of floating-point arithmetic.
You might guess that Decimal
numbers are Real
, but you'd be wrong:
>>> from decimal import Decimal
>>> import numbers
>>> isinstance(Decimal("3.14159"), numbers.Real)
False
In fact, the only type that Decimal
numbers inherit from are Python’s Number
class:
>>> isinstance(Decimal("3.14159"), numbers.Complex)
False
>>> isinstance(Decimal("3.14159"), numbers.Rational)
False
>>> isinstance(Decimal("3.14159"), numbers.Integral)
False
>>> isinstance(Decimal("3.14159"), numbers.Number)
True
It makes sense that Decimal
doesn't inherit from Integral
. To some extent, it also makes sense that Decimal
doesn't inherit from Rational
. But why doesn't Decimal
inherit from Real
or Complex
?
The answer lies in the CPython source code:
Decimal has all of the methods specified by theReal
abc, but it should not be registered as aReal
because decimals do not interoperate with binary floats (i.e.Decimal('3.14') + 2.71828
is undefined). But, abstract reals are expected to interoperate (i.e. R1 + R2 should be expected to work if R1 and R2 are both Reals).
It all boils down to implementation.
Floats Are Weird
On the one hand, floats implement the Real
abstract base class and are used to represent real numbers. But thanks to finite memory constraints, floating-point numbers are merely finite approximations of real numbers. This leads to confusing examples, like the following:
>>> 0.1 + 0.1 + 0.1 == 0.3
False
Floating-point numbers get stored in memory as binary fractions, but this causes some problems. Just like the fraction \(\frac{1}{3}\) has no finite decimal representation — there are infinitely many threes after the decimal point — the fraction \(\frac{1}{10}\) has no finite binary fraction representation.
In other words, you can't store 0.1 on a computer with exact precision — unless that computer has infinite memory.
From a strictly mathematical standpoint, all floating-point numbers are rational — except for float("inf")
and float("nan")
. But programmers use them to approximate real numbers and treat them, for the most part, as real numbers.
float("nan")
is a special floating point value representing "not a number" values — often abbreviated as NaN
values. But since float
is a numeric type, isinstance(float("nan"), Number)
returns True
.That's right: "not a number" values are numbers.
Floats are weird.
#3 - Numbers Are Extensible
Python's abstract numeric base types allow you to create your own custom abstract and concrete numeric types.
As an example, consider the following class ExtendedInteger
which implements numbers of the form \(a + b\sqrt{p}\) where \(a\) and \(b\) are integers and \(p\) is prime (note that primality is not enforced by the class):
import math
import numbers
class ExtendedInteger(numbers.Real):
def __init__(self, a, b, p = 2) -> None:
self.a = a
self.b = b
self.p = p
self._val = a + (b * math.sqrt(p))
def __repr__(self):
return f"{self.__class__.__name__}({self.a}, {self.b}, {self.p})"
def __str__(self):
return f"{self.a} + {self.b}√{self.p}"
def __trunc__(self):
return int(self._val)
def __float__(self):
return float(self._val)
def __hash__(self):
return hash(float(self._val))
def __floor__(self):
return math.floor(self._val)
def __ceil__(self):
return math.ceil(self._val)
def __round__(self, ndigits=None):
return round(self._val, ndigits=ndigits)
def __abs__(self):
return abs(self._val)
def __floordiv__(self, other):
return self._val // other
def __rfloordiv__(self, other):
return other // self._val
def __truediv__(self, other):
return self._val / other
def __rtruediv__(self, other):
return other / self._val
def __mod__(self, other):
return self._val % other
def __rmod__(self, other):
return other % self._val
def __lt__(self, other):
return self._val < other
def __le__(self, other):
return self._val <= other
def __eq__(self, other):
return float(self) == float(other)
def __neg__(self):
return ExtendedInteger(-self.a, -self.b, self.p)
def __pos__(self):
return ExtendedInteger(+self.a, +self.b, self.p)
def __add__(self, other):
if isinstance(other, ExtendedInteger):
# If both instances have the same p value,
# return a new ExtendedInteger instance
if self.p == other.p:
new_a = self.a + other.a
new_b = self.b + other.b
return ExtendedInteger(new_a, new_b, self.p)
# Otherwise return a float
else:
return self._val + other._val
# If other is integral, add other to self's a value
elif isinstance(other, numbers.Integral):
new_a = self.a + other
return ExtendedInteger(new_a, self.b, self.p)
# If other is real, return a float
elif isinstance(other, numbers.Real):
return self._val + other._val
# If other is of unknown type, let other determine
# what to do
else:
return NotImplemented
def __radd__(self, other):
# Addition is commutative so defer to __add__
return self.__add__(other)
def __mul__(self, other):
if isinstance(other, ExtendedInteger):
# If both instances have the same p value,
# return a new ExtendedInteger instance
if self.p == other.p:
new_a = (self.a * other.a) + (self.b * other.b * self.p)
new_b = (self.a * other.b) + (self.b * other.a)
return ExtendedInteger(new_a, new_b, self.p)
# Otherwise, return a float
else:
return self._val * other._val
# If other is integral, multiply self's a and b by other
elif isinstance(other, numbers.Integral):
new_a = self.a * other
new_b = self.b * other
return ExtendedInteger(new_a, new_b, self.p)
# If other is real, return a float
elif isinstance(other, numbers.Real):
return self._val * other
# If other is of unknown type, let other determine
# what to do
else:
return NotImplemented
def __rmul__(self, other):
# Multiplication is commutative so defer to __mul__
return self.__mul__(other)
def __pow__(self, exponent):
return self._val ** exponent
def __rpow__(self, base):
return base ** self._val
You need to implement lots of dunder methods to ensure the concrete type implements the Real
interface. You also have to consider how methods like .__add__()
and .__mul__()
interact with other Real
types.
With ExtendedInteger
implemented, you can now do things like this:
>>> a = ExtendedInteger(1, 2)
>>> b = ExtendedInteger(2, 3)
>>> a
ExtendedInteger(1, 2, 2)
>>> # Check that a is a Number
>>> isinstance(a, numbers.Number)
True
>>> # Check that a is Real
>>> isinstance(a, numbers.Real)
True
>>> print(a)
1 + 2√2
>>> a * b
ExtendedInteger(14, 7, 2)
>>> print(a * b)
14 + 7√2
>>> float(a)
3.8284271247461903
Python's number hierarchy is quite flexible. But, of course, you should always take great care when implementing types derived from built-in abstract base types. You need to make sure they play well with others.
There are several tips in the docs for type implementors that you should read before implementing custom number types. It's also helpful to peruse the implementation of Fraction
.
Conclusion
So there you have it. Three things (plus a whole lot more, maybe) that you might not have known about numbers in Python:
- Numbers have methods just like pretty much every other object in Python.
- Numbers have a hierarchy, even if that hierarchy is abused a bit by
Decimal
andfloat
. - You can create your own numbers that fit into the Python number hierarchy.
I hope you learned something new!