## 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!