Understanding Numeric Data Types in Python

Topic: Data types Tags: Beginner, Data types


Introduction

Data types are a fundamental concept in programming. They form the foundational elements of code. Among them, numeric data types are especially important because they enable programmers to perform a wide range of mathematical operations, from simple arithmetic to complex scientific calculations.

In Python, this foundation is not only robust but also very versatile. The language offers a diverse set of numeric types, each designed to address specific mathematical needs.

In the following sections we will explore, in some detail, the most commonly used numeric data types in Python.

Integers

Of all numeric data types in Python, integers are the most straightforward. Because of Python's dynamic nature, creating integers is as simple as:

>>> a = 3

This simple line of code not only assigns the value '3' to a but also defines the internal type of the variable. You can confirm it by using the type function:

>>> a
3
>>> type(a)
<class 'int'>

Unsurprisingly, we can create positive or negative integers.

>>> x = 12
>>> y = -50
>>> z = 0

But, unlike some other programming languages, Python's integers have arbitrary precision. This means they can represent very large (or very small) numbers without any loss of precision.

>>> a = 1234567890123456789012345678901234567890
>>> a + 1
1234567890123456789012345678901234567891

Python also allows you to represent integers in different bases using prefixes.

>>> 0b1010  // Binary
10
>>> 0o0234  // Octal
156
>>> 0xFF    // Hexadecimal
255

And you can use bitwise operators for performing operations on individual bits:

>>> x = 5
>>> y = 3
>>> bin(x), bin(y)
('0b0101', '0b0011')

>>> bitwise_and = x & y   # 0b0001 (1 in decimal)
>>> bitwise_or = x | y    # 0b0111 (7 in decimal)
>>> bitwise_xor = x ^ y   # 0b0110 (6 in decimal)
>>> bitwise_not_x = ~x    # -6 (in decimal)
>>> left_shift = x << 1   # 0b1010 (10 in decimal)
>>> right_shift = x >> 1  # 0b0010 (2 in decimal)

Integers in Python are immutable. This means that their value cannot be changed after they are created. Any operation that seems to modify an integer actually creates a new integer (the id function returns the unique identity of x).

>>> x = 500
>>> id(x)
4331970512
>>> x = x + 1
>>> id(x)
4331970992

One curiosity in CPython is that integer variables within the range -5 to 256 share the same id. This is for performance reasons as small numbers are commonly used in many programs.

>>> a = 10
>>> b = 10
>>> id(a) == id(b) == id(10)
True

Tip: sys.int_info

In reality, there are some limits on how big (or small) your computer can handle int numbers. In the sys module you can find the int_info tuple which provides us with the limits of the platform.

>>> import sys
>>> sys.int_info
>>> sys.int_info
sys.int_info(bits_per_digit=30, sizeof_digit=4, 
    default_max_str_digits=4300, str_digits_check_threshold=640)

In my case Python cannot represent numbers with more than 4300 digits.

>>> pow(10, 4299)
1000....0               # Many digits omitted for brevity!
>>> pow(10, 4300)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: Exceeds the limit (4300 digits) for integer string conversion; 
   use sys.set_int_max_str_digits() to increase the limit 

Curiosity: CPython's implementation details

Let's take a look at how CPython efficiently handles small integers in its 'cache'. To start, notice the following numbers defined in pycore_global_objects.h:

#define _PY_NSMALLPOSINTS           257
#define _PY_NSMALLNEGINTS           5

Those numbers are used in longobject.c to determine if a number is considered a "small integer" (within -5 to 256):

#define IS_SMALL_INT(ival) 
    (-_PY_NSMALLNEGINTS <= (ival) && (ival) < _PY_NSMALLPOSINTS)

Finally, the get_small_int() function returns a pre-defined number from a static list (as pointer to PyObject):

static PyObject *
get_small_int(sdigit ival)
{
    assert(IS_SMALL_INT(ival));
    return (PyObject *)&_PyLong_SMALL_INTS[_PY_NSMALLNEGINTS + ival];
}

Basically, this function works as a cache, where small integers are already "precomputed" for fast access. It is used in several places in the C code, particularly when building new numbers, such as in PyLong_FromLong():

PyObject *
PyLong_FromLong(long ival)
{
    ...
    /* Handle small and medium cases. */
    if (IS_SMALL_INT(ival)) {
        return get_small_int((sdigit)ival);
    }
    if (-(long)PyLong_MASK <= ival && ival <= (long)PyLong_MASK) {
        return _PyLong_FromMedium((sdigit)ival);
    }
    ...
}    

The basic idea is that if a number is "small", it retrieves a pre-defined PyObject from the _PyLong_SMALL_INTS array. Otherwise, it will build it using the _PyLong_FromMedium() function or will do something else. Either way it will do some computations and memory allocations, which takes some time.

Floating-point

The second numeric data type worth talking about in Python are floating-point numbers. Floating-point numbers are numbers that have a decimal point or are expressed in scientific notation. They are represented using the float type.

>>> x = 3.14
>>> y = 1.5e6           # 1500000.0
>>> type(x)
<class 'float'>
>>> type(y)
<class 'float'>

Floating-point numbers can be converted to integer, and integers converted to floating-point. The conversion from floating-point to integer effectively truncates numbers.

>>> float(1)
1.0
>>> int(1.7)
1

You can also round a number to the nearest integer, or to a specific number of decimal digits:

>>> pi = 3.141592653589793
>>> round(pi)
3
>>> round(pi, 4)
3.1415

Unlike integers, due to the way floating-point numbers are stored in binary, there may be precision issues:

>>> 0.1 + 0.2
0.30000000000000004

Comparing floating-point numbers for equality can be tricky, therefore it's recommended to use tolerances to account for small variations:

>>> x = 0.1 + 0.2
>>> y = 0.3
>>> x == y                  # x is 0.30000000000000004, y is 0.3
False
>>> abs(x - y) < 0.00001    # are they within a tolerance of 1e-5?
True

You can also compare floating-point numbers for equality using the isclose function in the math module.

>>> import math
>>> math.isclose(0.1 + 0.2, 0.3)
True

There are special values that may be useful for handling extreme cases or errors in numerical computations:

>>> infinity = float('inf')
>>> not_a_number = float('nan')
>>> infinity > 1e100
True

Tip: the decimal module

In cases where precision is critical, you can use the decimal module, which provides more control over precision.

>>> from decimal import Decimal
>>> a = Decimal('0.1')
>>> b = Decimal('0.2')
>>> c = Decimal('0.3')
>>> a + b == c
True

Another tip: sys.float_info

Similarly to integers, there are limits on how your computer can handle float numbers. The float_info tuple in the sys module provides us with the limits of the platform.

>>> import sys
>>> sys.float_info
sys.float_info(max=1.7976931348623157e+308, max_exp=1024, 
        max_10_exp=308, min=2.2250738585072014e-308, min_exp=-1021, 
        min_10_exp=-307, dig=15, mant_dig=53, epsilon=2.220446049250313e-16, 
        radix=2, rounds=1)
>>> 1.7976931348623157e+308 * 1
1.7976931348623157e+308
>>> 1.7976931348623157e+308 * 2
inf

In my own case, the maximum float number it can represent is around 1.7976931348623157e+308.

Curiosity: CPython's implementation details

There are many interesting things to explore on floatobject.c, which is the C source code file in CPython that deals with floating-point numbers. There are lots of good examples there, but this is how CPython handles the sum of two floats:

static PyObject *
float_add(PyObject *v, PyObject *w)
{
    double a, b;
    CONVERT_TO_DOUBLE(v, a);
    CONVERT_TO_DOUBLE(w, b);
    a = a + b;
    return PyFloat_FromDouble(a);
}

Basically it extracts the double values of both PyObjects and returns a new PyObject.

The float_add function also shows the reason for the immutability of Python's floats, as the sum of two floats always returns a new PyObject.

>>> x = 3.14
>>> id(x)
4346874480
>>> x += 1.0
>>> id(x)
4346873936

Complex Numbers

Complex numbers are mathematical entities that extend the real numbers to include a new quantity, denoted as "i" or "j", which represents the square root of -1. They have profound applications in diverse domains such as electrical engineering (in AC circuit analysis), signal processing, quantum mechanics, control theory, and more.

In Python, a complex number is represented as a + bj where a is the real part and b the imaginary part. For example, 2 + 3j represents a complex number with a real part of 2 and an imaginary part of 3. We can also use the complex(real, imag) function to create complex numbers:

>>> z1 = 2 + 3j
>>> z2 = complex(4, -2)
>>> z1
(2+3j)
>>> z2
(4-2j)
>>> type(z1)
<class 'complex'>

Similarly to other numeric types, complex numbers support the standard arithmetic operations:

>>> z1 = 2 + 3j
>>> z2 = 4 - 2j
>>> z1 + z2
(6+1j)
>>> z1 - z2
(-2+5j)
>>> z1 * z2
(14+8j)
>>> z1 / z2
(0.1+0.8j)

If needed, we can extract the real and imaginary parts:

>>> z = 2 + 3j
>>> z.real
2.0
>>> z.imag
3.0

The conjugate of a complex number a + bj is a - bj. It can be obtained using the conjugate() method:

>>> z = 2 + 3j
>>> z.conjugate()
(2-3j)

We can also obtain the absolute value (or magnitude) of a complex number. The absolute value of a complex number a + bj is sqrt(a^2 + b^2) and can be obtained with the abs function:

>>> z = 3 + 4j
>>> abs(z)
5.0

Finally, we can also obtain the phase angle of a complex number in radians. It can be calculated using the phase() function in the cmath module.

>>> import cmath

>>> z = 2 + 3j
>>> cmath.phase(z)
0.982793723247329

Tip: the cmath module

Besides the phase() function, the cmath module provides access to other mathematical functions for complex numbers.

>>> import cmath

>>> z = 2 + 3j
>>> cmath.polar(z)
(3.605551275463989, 0.982793723247329)
>>> cmath.exp(z)
(-7.315110094901103+1.0427436562359045j)
>>> cmath.log10(z)
(0.5569716761534184+0.42682189085546657j)
...

Curiosity: CPython's implementation details

Operations on complex objects are implemented on complexobject.c. There are lots of good examples there, but this is how CPython handles the sum of two complex numbers:

Py_complex
_Py_c_sum(Py_complex a, Py_complex b)
{
    Py_complex r;
    r.real = a.real + b.real;
    r.imag = a.imag + b.imag;
    return r;
}

Quite easy to understand, hopefully!

Interesting numeric modules

In this section we will explore briefly some numeric and mathematical modules in Python. You can find these and others in the Python documentation.

The math module

The math module is one of the most used mathematical modules in Python. It provides access to mathematical functions defined by the C standard. This is a very extensive module, so I'll just show a couple of simple things you can do with it.

You can round numbers up or down:

>>> math.pi
3.141592653589793
>>> math.ceil(math.pi)
4
>>> math.floor(math.pi)
3

Compute factorials and the greatest common divisor:

>>> math.factorial(4)
24
>>> math.gcd(10,6)
2

Do permutations and combinations:

>>> math.comb(20,6)
38760
>>> math.perm(20,6)
27907200

Compute power, logarithms and use trigonometric functions:

>>> math.pow(10,3)
1000.0
>>> math.sqrt(4)
2.0
>>> math.log10(5)
0.6989700043360189
>>> math.sin(1)
0.8414709848078965

And do angular conversions:

>>> angle = 90
>>> math.radians(angle)
1.5707963267948966
>>> math.degrees(1.5707963267948966)
90.0

The decimal module

The decimal module provides a way to perform precise arithmetic with decimal numbers. This is particularly important in financial and scientific applications where accuracy is crucial. The decimal module is extensively documented, and has many examples, so I'll just show some simple things that can be done with it.

The most import class in this module is the Decimal:

>>> from decimal import Decimal

>>> d1 = Decimal('0.1')
>>> d2 = Decimal('0.2')
>>> type(d1)
<class 'decimal.Decimal'>

As shown before, it has better precision than float:

>>> 0.1 + 0.2 == 0.3    # Float
False
>>> Decimal('0.1') + Decimal('0.2') == Decimal('0.3')
True

We can control many aspects of a Decimal number, like, for instance, the precision:

>>> from decimal import getcontext
>>> getcontext()
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=999999, capitals=1, 
    clamp=0, flags=[], traps=[InvalidOperation, DivisionByZero, Overflow])

>>> Decimal(2).sqrt()
Decimal('1.414213562373095048801688724')
>>> getcontext().prec = 2
>>> Decimal(2).sqrt()
Decimal('1.4')

We can use arithmetic functions on decimals:

>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3')
>>> Decimal('0.1') - Decimal('0.2')
Decimal('-0.1')
>>> Decimal('0.1') * Decimal('0.2')
Decimal('0.02')
>>> Decimal('0.1') / Decimal('0.2')     # Regular division
Decimal('0.5')
>>> Decimal('10') // Decimal('3')       # Integer division
Decimal('3')

And similarly to regular numbers, we can also use other mathematical functions:

>>> d = Decimal(math.pi)
>>> d.log10()   
Decimal('0.4971498726941338374217231246')
>>> d.sqrt()
Decimal('1.772453850905515992751519103')

The fractions module

The fractions module provides support for rational number arithmetic, and can be built by passing a numerator and denominator:

>>> from fractions import Fraction
>>> f = Fraction(1, 3)
>>> f.numerator
1
>>> f.denominator
3

There are other ways of building fractions, although using float and Decimal may lose precision:

>>> Fraction('1/3')
Fraction(1, 3)
>>> Fraction(1/3)
Fraction(6004799503160661, 18014398509481984)
>>> Fraction(Decimal('1') / Decimal('3'))
Fraction(3333333333333333333333333333, 10000000000000000000000000000)

Similar to other numbers, you can do basic arithmetic with fractions:

>>> f1 = Fraction(1, 3)
>>> f2 = Fraction(1, 6)
>>> f1 + f2
Fraction(1, 2)
>>> f1 - f2
Fraction(1, 6)
>>> f1 * f2
Fraction(1, 18)
>>> f1 / f2
Fraction(2, 1)
>>> f1 // f2        # Integer division
2

And compare fractions if needed:

>>> f1 = Fraction(1, 3)
>>> f2 = Fraction(1, 6)
>>> f1 > f2
True

And we finish this article here. Hope you learned something from it, keep exploring and have fun with Python!