**Strings**, **lists**, **tuples**, **integers**, **floats**, and **dictionary** are some of the types that we have already encountered.

In this chapter, we will organize and classify them into fewer categories to make sense of the Python type system. This classification of Python built-in types is called **Standard Type Hierarchy**.

We can classify various built-in Python types under different categories. We can see one such classification in the figure 1.

We can classify the built-in types under,

**Singletons****Numbers****Collections****Callables**

We will look at each one of them, starting with **Singletons**.

## Singletons

The **Singletons** are built-in types that have a single value, and there exists only a single object having the type. The list of built-in singleton types is the list in the table 1.

Type | Name | Truth Value |
---|---|---|

NoneType | None | `False` |

NotImplementedType | NotImplemented | `True` |

elipsis | Elipsis or `...` |
`True` |

Let's look at the first singleton: **NoneType**.

### None

There exists only one object with the type NoneType in Python, and we can access it through the built-in name `None`

. We use the `None`

object to signify the absence of value in many cases.

Functions that **do not explicitly return anything** return the `None`

object by default. Let's check it out using a code sample.

As you might recall, the `print()`

function doesn't return any value. Therefore, if we assign its function call to a name, we will get a `NoneType`

object.

```
>>> greet = print("Hello")
Hello
>>> type(greet)
<class 'NoneType'>
```

As there is only one instance of `None`

object through Python, we can use the identity operator `is`

to verify.

```
>>> greet is None
True
```

If we try to assign a different object to the name `None`

, Python raises `SyntaxError`

. This is because `None`

is one of Python's keyword.

```
>>> None = "Something else"
File "<stdin>", line 1
SyntaxError: can't assign to keyword
```

A `NoneType`

is one of few built-in objects whose truth value is `False`

.

```
>>> bool(None)
False
```

What's the output of the code below?

```
>>> if not None:
print("Hello World")
```

`Hello World`

- raises
`NameError`

- raises
`ValueError`

- The
`if`

block doesn't execute

As the truth value of the `None`

is `False`

, `not None`

always executes as `True`

. Hence the code in the previous exercise always executes. The next singleton type is the `NotImplementedType`

object.

### NotImplemented

There exists only one object with the type `NotImplementedType`

in Python. We can access it through the built-in name `NotImplemented`

.

```
>>> type(NotImplemented)
<class 'NotImplementedType'>
```

Unlike `None`

, you can reassign the name `NotImplemented`

to something else.

```
>>> NotImplemented = "Something else"
>>> NotImplemented
'Something else'
```

Although Python wouldn't complain about this re-assignment, **you should never reassign or overwrite the name NotImplemented.**

The truth value of a `NotImplementedType`

object is `True`

.

```
>>> bool(NotImplemented)
True
```

We can return the `NotImplementd`

object for special binary methods such as `__eq__()`

, `__lt__()`

or `__add__()`

to i*indicate the operation is not implemented with respect to other type*. We will cover this in more detail when we cover creating custom objects in subsequent courses.

The next singleton type is `Ellipsis`

.

### Ellipsis

`Ellipsis`

type object has a single value, and there is only a single object in Python with this value. We can access this object through the literal `...`

or the built-in name Ellipsis. The truth value of an `Elipsis`

type object is `True`

.

One of the usages of the Ellipsis is to indicate incomplete function definition.

```
>>> def foo():
...
>>> foo()
```

We can use any string literal instead of `...`

Ellipsis.

For example, in the below code listing, we use a string object.

```
>>> def foo():
"Incomplete Code"
>>> foo()
```

However, many developers prefer using the Ellipsis object to indicate an incomplete function code as per a not-so-strict convention. Similarly, sometimes programmers use the `pass`

keyword to indicate an empty code block or future implementation.

```
>>> def foo():
pass # Empty function
```

In Python, the pass keyword is a **null statement**. Unlike comments, the python interpreter does not ignore a pass statement. However, nothing happens when Python executes the pass statement.

Python built-in types do not generally use the Ellipse object.

Like the None object, the `...`

object is also a keyword and cannot assign a new value. The truth value of Ellipsis is `True`

.

```
>>> ... = "Something Else"
File "<stdin>", line 1
SyntaxError: can't assign to Ellipsis
```

That brings us to the end of **Singleton** types. Next, we will look into the standard types in Python, which are used to represent numeric types.

## Numbers

**Integer literals**, **Floating-point literals** and **Imaginary number literals** to create numeric type objects.

Python uses four built-in numeric types: **Booleans**, **integers**, **complex numbers**, and **floating-point numbers** for representing numbers. Additional two numeric types: **Decimals** and **Fractions** are available upon import.

We can group Booleans and Integers as **Integral**, while we can group other numeric types under **Non-Integral**.

**Integral numeric types** represent elements from the mathematical set of integers (positive and negative). Let's start with Integers.

### Integer

These represent numbers in an unlimited range, subject to available (virtual) memory only. You can create an object with an integer type using **integer literal** or using the built-in **int()** function.

```
>>> a = 13 # Creating an integer object using integer literal
>>> b = int(12) # Creating an integer using `int()` function
```

The `int()`

function can convert a string literal containing integer to an integer object. For example,

```
>>> int("666")
666 # Integer
```

`int()`

function is also called a constructor. Can you guess why it is called so?
In Python, **constructors** are used to *construct* an object of specific **class** or **type**. The built-in `int()`

function is a *constructor*, which is used to create integer objects.

You can also convert integers from different number bases to integers in base-10. The built-in `int()`

function has the following function signature.

$$

int(x=0, base =10) \rightarrow \text{integer object} \tag{1}

$$

We can convert a number from a base other than $10$ by enclosing in a string or adding an appropriate prefix depicting the base.

```
>>> int() # without arguments
0
>>> int("1010", 2) # Convert the 1010 from base 2
10
>>> int("100", 8) # Convert 100 from base-8
64
>>> int("10E", 16) # Convert 10E from base-16
270
```

We can also directly convert a number is represented in other *base*, which has been prefixed to represent its base. To recall, table 2 shows some of the prefixes used to depict the base that the Python interpreter understands.

Base | Number | Prefix | Example |
---|---|---|---|

2 | Binary | `0b` |
`0b100` |

8 | Octal | `0o` |
`0o100` |

10 | Decimal | `No Prefix` |
`100` |

16 | Hexadecimal | `0x` |
`0x10E` |

You can create an integer literal directly using the base prefix, and Python will automatically convert to its _base-10_ counterpart. In the built-in function `int()`, if you use an integer literal with a base prefix, you don't have to specify the base.

```
>>> 0b1010 # Integer with Binary Prefix
10
>>> 0o100 # Integer with Octal Prefix
64
>>> 0x10E # Integer with Hexadecimal Prefix
270
>>> int(0b100) # Integer with Binary Prefix
10
>>> int(0o100) # Integer with Octal Prefix
64
>>> int(0x10E) # Integer with Hexadecimal Prefix
270
```

To convert numbers from base-10 to different bases, you can use the built-in functions `bin()`

, `oct()`

, and `hex()`

for binary, octal decimal, and hexadecimal, respectively.

For instance, to convert a decimal number fourteen to binary, you can pass the `14`

integer to the `bin()`

function, and Python will return the corresponding binary representation.

`hex(270)`

on Python?
In the previous code listing, we converted numbers from different bases to decimal base. We can see that the `0x10E`

is 270 in the decimal. Therefore, `hex(270)`

will be equal to be `0x10E`

.

We can verify this using the interpreter.

```
>>> bin(10)
'0b1010' # String object representing base-2
>>> oct(64)
'0o100' # String object representing base-8
>>> hex(270)
'0x10e' # String object representing base-16
```

In computing, **bit-length** is the number of bits required to represent an integer object in binary. You can use the built-in method `bit_length()`

of an integer.

```
>>> bin(24)
'0b11000' # 5 bits required to represent the number
>>>(24).bit_length() # Integer literal must be wrapped with parens
5
>>> a = 24
>>> a.bit_length()
5
```

What do you think will be the bit length of the number 29?

- 9
- 10
- 8
- 7

### Integer Caching

Earlier, we saw that when we create an object and assign it a name, the **reference count** of the object increases. However, something interesting happens when we assign names to integers. Let's take a look at the code sample below.

Let's create two objects with the value `257`

and name them `a`

and `b`

.

```
>>> a = 257 # Assign `a` to an integer object with value 257
>>> b = 257 # Assign `b` to an integer object with value 257
```

Now, let's if check both names refer to the same object.

```
>>> a is b # Check if both of them refer to the same object.
False
```

Therefore, Python creates different objects with the same value, `257`

.

Now, let's repeat the experiment with `256`

.

We will define two names, `c`

and `d`

, and assign them to `256`

.

```
>>> c = 256 # Assign `c` to an integer object with value 256
>>> d = 256 # Assign `d` to an integer object with value 256
```

Now, let's check if both the names (`c`

and `d`

) refer to the same object.

```
>>> c is d
True # Hmmm. Both are pointing to the same object.
```

So, what's going on here?

Python assigns names to the same object with the value `256`

while assigning names to different objects with integer value `257`

. This is not a bug.

Python **caches** small integers, which are integers between **-5** and **256**. Python stores the integers between `-5`

and `256`

, which are likely to be used repeatedly in a program. In Python, the cache of integers between -5 and 256 is called **small integer cache**. The official Python docs refer to this as follows:

The current implementation keeps an array of integer objects for all integers between -5 and 256. When you create an int in that range, you get back a reference to the existing object.

**Note:** Depending on your environment and Python compiler, you might get different results. Small integer cache is for understanding only, and do not try to use this in a program as the number of caches might change.

### Caching

**Caching** is a process where we store information that is likely to be frequently accessed for easier access.

When you visit a website, your browser takes pieces of the page and stores them on your computer's hard drive.

The browser stores assets such as Images (logos, pictures, backgrounds, etc.), HTML, `CSS`

and `Javascript`

file.

In short, it typically caches what are known as **static assets** - parts of a website that do not change from visit to visit.

Caching speeds up browsing a website. Once you've downloaded an asset, it is kept on your hard-drive storage unless you clear the cache.

The next time you visit the website, part of the website is loaded from your hard-drive than your remote server. Retrieving files from your storage will always be faster than retrieving them from a remote server.

Python does a similar thing with the **small-integer cache**. It already instantiates some of the integers that are most likely to be used. The next integral type is `Boolean`

.

### Boolean

The Boolean type has two possible values: `True`

and `False`

. Booleans are classified under *Integral types*.

The implication is that `Boolean`

inherits the characteristics of integers and essentially are *integers*. The type `bool`

is a **subclass** of type `int`

, i.e., Boolean **extend** from the integer `int`

type.

We can verify this using the built-in function `isinstance()`

.

The built-in `isinstance()`

function checks if a given object is an instance of a `class`

or a `subclass`

.

```
>>> type(False)
<class 'bool'>
>>> isinstance(False, int) # Check if False is an instance of type `int`
True
>>> isinstance(False, bool) # Check if `False` is an instance of type `bool`
True
```

The integer value of `True`

is 1, while for `False`

, it is 0.

```
>>> int(True)
1
>>> int(False)
0
```

As Boolean extend from type `int`

, they behave effectively as integers.

```
>>> True + 5
6
>>> False - 5
-5
```

You can use them as an *index key* to access an item in a container, and Python won't complain.

```
>>> a = [1,2,3,4]
>>> a[True] # same as a[1]
2
>>> a[False] # same as a[0]
1
```

However, it would be best not to use `True`

and `False`

as integers as it will confuse other developers.

Boolean can be constructed by:

- Boolean expressions using comparison operators such as
`5`

>`6`

- using built-in
`bool()`

function which we earlier used to test truth values such as`bool([1,2])`

The Booleans objects are constants and part of Python's reserved keyword, which means you cannot reassign their value to something else.

```
>>> True = "Not True"
File "<stdin>", line 1
SyntaxError: can't assign to keyword
```

In the next section, we will go into more details about *non-integral types*.

### Non-integral Types

*Floating-point numbers*, *decimals*, *complex numbers* and *fractions* are classified under **non-integral**. We will start with floating-point numbers.

### Floating Types

As you might recall, a **floating-point** value or **float** is a numerical literal that contains a *decimal point*. The floating-point type can store *fractional numbers*.

They can be defined either in **standard decimal notation** or in **exponential notation**.

```
>>> 1.50 # Standard Decimal Notation
1.5
>>> 15e-1 # Exponential Notation
1.5
```

We can create Floating-point number objects from floating-point literals or the `float()`

constructor.

```
>>> 3.4e3 # Float literal
3400.0
>>> float(3.4e3) # `float` constructor
3400.0
```

We can convert an integer to float using the `float()`

constructor.

```
>>> a = 2
>>> a # integer
2
>>> float(a)
2.0 # float
```

Similarly, we can convert a float into an integer with an `int()`

constructor. The `int()`

constructor removes the decimal part and returns the integer number.

```
>>> int(3.1451)
3
```

Upon division, numbers often produce floats with many decimal digits, which might not be what you want while printing.

```
>>> a = 45/7
>>> print("45 upon division by 7 yields ", a)
45 upon division by 7 yields 6.428571428571429
```

You can control the precision of a float by using the built-in `round()`

function.

```
>>> round(3.45871, 3) # Rounds off to 3 decimal places
3.459
>>> round(3.45871 , 2) # Rounds off to 2 decimal places
3.46
>>> round(3.45871 , 1) # Rounds off to 1 decimal places
3.5
```

How does Python evaluate the following expression?

```
>>> round(3.45871)
```

`Error`

`4`

`3.5`

`3`

You might recall that the built-in `round()`

function rounds off to the nearest integer when no precision is specified.

If you wish to get the nearest integer lower than the value or greater than the value, you can use the `floor()`

and `ceil()`

function from the `math`

module, respectively.

Let's see them in action.

We will first import the `ceil()`

and `floor()`

functions from the `math`

module.

```
>>> from math import ceil, floor
```

Now, let's get the nearest integers of `3.56.`

```
# Get the nearest integer lower than the value
>>> floor(3.56)
3
# Get the nearest integer higher the the value
>>> ceil(3.56)
4
```

You can use basic arithmetic operations on the floats with other floats or even integers.

```
>>> 3 + 0.5 # Addition
3.5
>>> 3 - 0.5 # Subtraction
2.5
>>> 3 / 0.5 # Division
6.0
>>> 3 * 0.5 # Multiplication
1.5
```

And compare with other floats or integers.

```
>>> 3 > 0.5
True
>>> -0.5 > 0.5
True
```

However, you should take a bit of caution when using equality *floats*. For example,

```
>>> 0.1 + 0.2 == 0.3
False
```

`0.1+0.2`

should be equal to `0.3`

. Why do you think Python gives the wrong result?
To put it simply, **a floating-point number doesn't always exactly represent the number it seems to be representing**. For instance, the actual value of float `0.1`

is slightly more than `0.1`

. To understand why we need to know how Python stores floating numbers.

#### Issues and Limitations of using Fractions in Python

Python implements the Floating-point numbers in the computer hardware as **binary fractions**. Earlier, we saw how can integers in base-10 be represented as base-2 numbers. Similarly, we can also represent fractions in the form of binary fractions.

In base-10, we can represent the decimal fraction of 0.125 as

$$

0.125 = 1\times10^{-1} + 2\times10^{-2} + 5\times10^{-3} \tag{2}

$$

Similarly, the binary fraction 0b0.001 can be expressed as following in decimal.

$$

\begin{equation}

\begin{split}

0\text{b}0.001 & = 0\times2^{-1} + 0\times2^{-2} + 1\times2^{-3} \\

& = \frac{0}{2} + \frac{0}{4} + \frac{1}{8} \\

& = 0 + 0 + 0.125 \\

& = 0.125

\end{split}

\end{equation}

\tag{3}

$$

Both 0.125 and 0b0.001 have the same values, although written in different notations. However, representing the fractions in binary poses a particular challenge.

We cannot precisely represent most decimal fractions as binary fractions.

To understand why, let's first look at the decimal value of the fraction `4/3`

in the decimal system.

$$

\frac{4}{3} = 1.33333333... \tag{4}

$$

The value of the fraction results in an infinitely repeating decimal part.

The fraction $4/3$ results in an infinitely repeating decimal; therefore, we cannot represent $4/3$ exactly in decimal representation.

We can round off or approximate the value of the fraction $4/3$ to finite digits of the decimal part to use the value. To do this, we will use the built-in `round()`

function.

The listing below shows the three different approximations of the fraction $4/3$.

```
>>> round(4/3, 2)
1.33
>>> round(4/3,3)
1.333
>>> round(4/3, 4)
1.3333
```

No matter how many digits you round off, the result will never be equal to the fraction $4/3$; rather will be an increasingly better approximation of $4/3$. Thus, we cannot represent the fraction $4/3$ in the form of a decimal number with a finite number of digits.

Similarly, no matter how many base-2 digits you use, the decimal value `0.1`

cannot be represented precisely as a binary fraction. In base-2, the $1/10$ fraction has an infinitely repeating fractional part.

$$

\frac{1}{10} = 0b0.000110011001100110011001100110011... \tag{5}

$$

Any higher number of bits will get you a closer approximation to the value of $0.1$ but never exact.

Representation errorrefers to the fact that most decimal fractions cannot be represented accurately as binary fractions.

Representation error is the chief reason why Python and other programming languages such as C++, Java often won't display the exact decimal number you expect.

** The Institute of Electrical and Electronics Engineers, Inc. (IEEE)** defined standards for floating-point representations and computational results to deal with the problem relating to the inexact conversion of decimal fractions into binary usually referred to as

**. Almost all machines use the**

*IEEE-754***IEEE-754**floating-point arithmetic, and almost all platforms map Python floats to

**IEEE-754**specified

*53-bit precision*.This means that when you input `0.1`

float in Python, the underlying machine strives to convert 0.1 to the *closest fraction* to the form $J/{2^N}$, where J is an integer containing exactly 53 bits.

$$

\text{decimal number} ~~~ \tilde= ~~~ \frac{J}{2^N} \tag{6}

$$

We can understand this better. Let's convert the decimal number 0.1 or $1/10$ to the closest figure that we can represent with 53-bit precision.

$$

\begin{align*}

\frac{J}{2^N} ~ ~ &\tilde= ~~\frac{1}{10}\\

J &= ~~\frac{2^N}{10}

\end{align*}

\tag{7}

$$

In equation 7, to find the value of J, we need to figure out N's value with the keeping in mind that it can represent the integer with exactly 53-bits.

We can use the built-in method integer method `bit_length()`

.

Earlier, we checked a built-in Python method, `bit_length()`

of integers, to check the numbers of bits required to represent a number. Let's check for different values of $N$ for which the value of $J$ will have exactly 53 bits.

```
>>> int(2**54/10).bit_length()
50
>>> int(2**55/10).bit_length()
52 # Less than 52
>>> int(2**56/10).bit_length()
53 # Equals 53
>>> int(2**57/10).bit_length()
54 # Exceeds 53
```

Thus $56$ is the only value for *N* that leaves *J* with exactly 53 bits.

Equation 7 can be written as

$$J=\frac{2^{56}}{10} \tag{8}$$

We can get the value of J in Python using the built-in `divmod()`

function. The `divmod()`

function takes number and divisor as arguments and returns a tuple containing the value quotient and remainder ( `x//y`

, `x%y`

).

```
>>> divmod(2**56, 10)
(7205759403792793, 6) # ( Quotient, Remainder )
```

The remainder $6$ is more than half of the divisor $10$, so to get the value of $J$, let's add $1$ to the quotient.

```
>>> J = 2**56 // 10 + 1
>>> J
7205759403792794
```

To get the approximate value of `0.1`

, we will divide J by 2^{56}.

```
>>> 2**56
72057594037927936
>>> J / 2**56
0.1 # The approximate value of 0.1 that computer stores
```

Therefore the best possible approximation to 1/10 in *IEEE-754 standard* is

$$

\begin{equation}

\begin{split}

\frac{1}{10} ~~~ &\tilde= ~~~ \frac{7205759403792794}{72057594037927936}\

\

&= \frac{3602879701896397}{36028797018963968}

\end{split}

\end{equation} \tag{9}

$$

To see the value of the fraction out to 55 decimal digits, we can multiply the fraction by $10^{55}$.

```
>>> 3602879701896397 * 10 ** 55 // 2 ** 55
1000000000000000055511151231257827021181583404541015625
```

The exact number stored in the computer for the float 0.1 is $0.1000000000000000055511151231257827021181583404541015625$.

`0.1`

, Python uses the above number instead of using exactly `0.1`

. Do you think it affects calculations involving fractions?
Fractions are stored approximately to the exact number. As you can see, the numbers are accurate to a lot of significant digits. Therefore, for most practical purposes, it won't affect your calculations.

Instead of displaying the full decimal value, many programming languages, including Python, round off the number to 17 significant digits. To verify, we can check the digits till 17 digits using the built-in `format()`

function.

The `format()`

function takes another argument that specifies the precision value. You can read more about the `format()`

using the `help()`

function.

```
>>> format(0.1, '.17f')
'0.10000000000000001'
```

We can see that Python interprets `0.1`

as something other than the fraction 1/10, which is a close approximation. For `0.3`

also, Python stores an approximation rather than the exact value.

```
>>> format(0.3, '.17f')
'0.29999999999999999'
```

Whenever you input `0.1`

into the interpreter, Python interprets to the closest approximation. Python rounds off the float to a single-digit giving an illusion that it's the same as the fraction when it's not.

To check which fraction is used to represent a decimal number, Python has a method `as_integer_ratio()`

for floats, which returns a pair of integers whose ratio is exactly or approximately equal to the original float and with a positive denominator.

```
>>> 0.1.as_integer_ratio()
(3602879701896397, 36028797018963968) # Same as we obtained
>>> 0.5.as_integer_ratio()
(1, 2) # 0.5 = 0b0.1
```

Now that we have covered how python stores and works with floats, it might be a bit clear why $0.1 + 0.1 + 0.1$ is not equal to $0.3$ in Python.

This limitation of Python impacts business applications and fields that require precise arithmetic calculations.

Fields such as *accounting* and *finance* mostly require positively require highly-precise and accurate calculation.

To overcome the limitation of the floating-point, Python has another numeric type, `Decimal`

, which can be used for exact addition. Decimal, for floating-point numbers with user-definable precision.

Let's look into them next.

### Decimals

We saw earlier that numbers like $0.1$ don't have an exact representation in binary floating-point numbers. In contrast to the built-in floating-point type number, the numeric type `Decimal`

can represent numbers accurately. As the `Decimal`

type can represent numbers accurately, the corresponding arithmetic operations are accurate as well.`Decimal`

type objects can be created by first importing the `Decimal`

constructor from the decimal module.

```
>>> from decimal import Decimal # Need to import
>>> a = Decimal('0.1')
```

We can check for earlier operations.

```
>>> 0.1 + 0.1 + 0.1 == 0.3
False # Inexact addition
>>> a + a + a == Decimal('0.3')
True # Exact
```

As you can see, the `Decimal`

type in Python lets us do exact addition in Python.

The decimal module also incorporates a notion of significant places. To preserve significance, the coefficient digits do not truncate trailing zeros.

```
>>> 1.30 + 1.50
2.8 # Discards 0
>>> Decimal('1.30') + Decimal('1.50')
Decimal('2.80') # Retains 0
>>> 2.60 * 2.50
6.5 # Discards 0
>>> Decimal('2.60') * Decimal('2.50')
Decimal('6.5000') # Four significant 0s
```

Contrary to the built-in floating-point arithmetic in Python, the Decimal type works how people expect it to work. This **Decimal Arithmetic Specification** on which the type is based on states that

... based on a floating-point model which was designed with people in mind, and necessarily has a paramount guiding principle – computers must provide an arithmetic that works in the same way as the arithmetic that people learn at school.

We can construct decimal instances from integers, strings, or floats. Construction from an integer or a float performs an exact conversion of that integer or float value.

```
>>> Decimal(1) # Decimal object from integer
Decimal('1')
>>> Decimal(0.5) # Decimal object from float
Decimal('0.5')
>>> Decimal(str(2**5)) # Decimal object from string
Decimal('32')
```

Earlier, to get higher precision of the float 0.1, we multiplied the stored fraction with $10^{55}$. We can create a decimal object from a floating-point.

```
>>> from decimal import Decimal
>>> a = Decimal.from_float(0.1) # Convert using built-in method
>>> a
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> Decimal(0.1) # Convert directly from a float
```

We can see that the exact conversion of 0.1 to decimal results in the actual value that Python interprets for the float.

We can always assign a new name to a function and refer to it. In Python, we can also rename a function import from a module. Thus, we can name `Decimal as D`

for convenience.

```
>>> from decimal import Decimal as D
>>> D('0.1') # Creating objects using name `D`
Decimal('0.1')
```

In the above listing, we are renaming the `Decimal()`

function directly at the time of import. This is also the recommended way to avoid name-clashes in Python.

Python implements both *binary* and *decimal floating-point* in terms of published standards. While the built-in float type exposes only a small portion of its capabilities, the `decimal`

module exposes all essential parts of the standard.

For instance, the `decimal`

module exports an object `getcontext()`

, which has several configurations in place which you can modify.

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

Right now, the *precision* is 28 (`prec=28`

) stored in the context object. Let's change it to `4`

.

```
>>> D(1)/D(3)
Decimal('0.3333333333333333333333333333') # 28 digits
>>> getcontext().prec = 4
>>> D(1)/D(3) # 4 digits
Decimal('0.3333')
```

For calculations in accounting and financial transactions, you might want tighter control on precision and rounding off values. You can check additional details in the python documentation for the `decimal`

module.

`Decimal`

types and `Floating-point`

numeric types in Python. Can you summarise the main difference between these two numeric types?
The `Decimal`

numeric types have some methods available to them.

You can check the complete list of methods available using the `help(Decimal)`

statement on the Python Interpreter after importing it from the `decimal`

module.

Some methods available in the decimal object are shown in the table 3.

Method | Description |
---|---|

`as_integer_ratio()` |
Return a pair `(n, d)` of integers that represent the given `Decimal` instance as a fraction, in lowest terms and with a positive denominator. |

`from_` float()`` |
Converts a float to a decimal number, exactly. |

`exp()` |
Return the value of the (natural) exponential function `e**x` at the given number. |

`normalize()` |
Normalise the number by stripping the rightmost trailing zeros and converting any result equal to `Decimal('0')` to `Decimal('0e0')` . |

`sqrt()` |
Return the square root of the argument to full precision. |

`to_integral_value()` |
Round to the nearest integer |

The below code sample shows the usage of these methods.

```
>>> Decimal('1.3').as_integer_ratio() # Fraction
(13, 10)
>>> Decimal.from_float(0.3) # Create Decimal from float
Decimal('0.299999999999999988897769753748434595763683319091796875')
>>> Decimal(1).exp() # e**1
Decimal('2.718281828459045235360287471')
>>> Decimal('144').sqrt() # Square root
Decimal('12')
>>> Decimal('12.5333').to_integral_value() # Get the nearest integer
Decimal('13')
```

We can check additional methods and their description in the Python documentation.

What is the value of the following expression?

```
>>> Decimal.from_float(10).sqrt().to_integral_value()
```

- 3
- 2
- 10
- Raises Error

### Fractions

Another numeric type that we often use in the real world is **fractions**.

In Python, the fractions module provides support for rational number arithmetic.

To create a fraction object in Python, we will first need to import the `Fraction`

function or constructor from the `fractions`

module.

```
>>> from fractions import Fraction
```

You can construct a fraction number from a pair of *integers*, another *fraction* number, *decimal* object, *float*, or *string*. The fraction returns the **numerator** and **denominator** in the lowest form.

```
>>> from fractions import Fraction
>>> Fraction(15, -6) # From pair of integers
Fraction(-5, 3)
>>> Fraction(0.5) # From float
Fraction(1, 2)
>>> Fraction() # Without arguments
Fraction(0, 1)
>>> Fraction('7e-6') # From string
Fraction(7, 1000000)
>>> from decimal import Decimal
>>> Fraction(Decimal('3.3')) # From Decimal Object
Fraction(33, 10)
```

Let's check the value of float `0.1`

in the form of a Fraction.

```
>>> Fraction(0.1) # Will return fraction used by python
Fraction(3602879701896397, 36028797018963968)
>>> Fraction(Decimal('0.1')) # Will return fraction 1/10
Fraction(1, 10)
```

You can see that the fraction returns the exact fraction approximate used to represent `0.1`

float in Python.

Like other numeric types, **Fractions** are **immutable**. The *numerator* and *denominator* are available as **attributes** to the fraction object.

```
>>> a = Fraction(1, 10)
>>> a.numerator
1
>>> a.denominator
10
```

What do you think is the result of the following expression?

```
>>> from fractions import Fraction
>>> Fraction(1, 10) + Fraction(9, 10)
```

`1`

`Fraction(11, 10)`

`Fraction(1, 1)`

- Raises Error

Fractions expectedly interact with the arithmetic operators.

You can add, subtract and multiply fractions with numbers, and it returns the result in the form of a `Fraction`

object. Let's add two fractions.

```
>>> Fraction(1, 10) + Fraction(9, 10) # Addition
Fraction(1, 1)
```

The fractions object is always returned in the lowest form.

```
>>> Fraction(7, 6) - Fraction(4,6 ) # Subtraction
Fraction(1, 2)
```

This also applies to multiplication.

```
>>> Fraction(1, 10) * 5 # Multiplication
Fraction(1, 2) # Lowest Form
```

And because Python does not store exact fractions in floats, it is recommended to create fractions from `Decimal`

objects.

```
>>> Fraction(11,10 ) + Fraction(Decimal('0.9'))
Fraction(2, 1)
```

You can view more methods of fractions in the documentation for the fractions module.

What do you think is the result of the following expression?

```
>>> (Fraction(2, 1)*Fraction(1, 2)) + Fraction(Decimal('0.5'))
```

`Fraction(3, 2)`

`Fraction(1, 1)`

`Fraction(1.5)`

`1.5`

### Complex Numbers

The last numeric type that we will look at is complex numbers in Python.

A *complex number* is a number that can be expressed in the form `a + bi`

, where a and b are real numbers, and `i`

is a solution of the equation `x² = −1`

. Because no real number satisfies this equation, `i`

is called an **imaginary number**.

$$

z = \overbrace{ \underbrace{a}*\text{real} + \underbrace{ib}*\text{imaginary} }^\text{complex number} \tag{10}

$$

Complex numbers can be constructed using a `complex`

constructor.

```
>>> complex(3, 4) # Using constructor
3 + 4j # Complex Number
>>> 3 + 4j # Imaginary Literal
3 + 4j # Complex Number
```

Some of the the **attributes** of the complex numbers are :

```
>>> z = 3 + 4j
>>> z.real # Real Part of Complex Number
3.0 # type: <class 'float'>
>>> z.imag # Imaginary part of Complex Number
4.0 # type: <class 'float'>
```

Thecomplex conjugateof a complex number is the number with anidentical real part and an imaginary part equal in magnitude but opposite in sign. For example, ifaandbis real, then the complex conjugate of $a + bi$ is $a - bi$.

We can write a function that takes a complex number as input and returns its conjugate.

```
>>> def conjugate(comp):
return complex(comp.real, -comp.imag)
```

We can use the above function in the following way.

```
>>> a = complex(1, 2)
>>> b = conjugate(a)
>>> b
(1.0, -2.0)
```

Fortunately, a complex number has a *built-in method* `conjugate()`

that gives the conjugate of any complex number.

```
>>> z = 3 + 4j
>>> z.conjugate()
( 3 - 4j)
```

The **magnitude of a complex** number is the square root of the sum of the square of its real and imaginary parts.

$$

\text{Magnitude of z} = \sqrt{a^2 + b^2} \tag{11}

$$

In Python, we can get the square root of any number using the `sqrt()`

from the `math`

module. We can write a function that returns the magnitude of a complex number in the following way.

```
>>> import math
>>> def magnitude(comp):
return math.sqrt(comp.real**2 + comp.imag**2)
```

So, we can use our custom defined function as:

```
>>> z = 3 + 4j
>>> magnitude(z)
5.0
```

You can use the built-in Python's `abs()`

to get the magnitude of a complex number. The `abs()`

function works similarly as to our `magnitude()`

function we defined above.

```
>>> z = 3 + 4j
>>> abs(z) # math.sqrt(a**2 + b**2 )
5.0
```

For performing calculations using complex numbers, python provides the `cmath`

module. You can check out the Python documentation to know more about it in detail.

What do you think is the output of the following expression?

```
>>> z1 = 3+4j
>>> z2 = 3-4j
>>> type(abs(z1 + z2))
```

`<class 'float'>`

`<class 'int'>`

`<class 'complex'>`

`<class 'decimal'>`

In the next section, we will look into *collections* type objects that are used to store other data types.

## Collections

Let's look into container objects available in Python.

We earlier encountered `lists`

, `tuples`

and `dictionaries`

.

The Collection types act as a container for storing references to other objects. We can classify the built-in python containers into three groups as shown in table 4.

Container | Examples |
---|---|

Sequences Types | `Lists` , `Tuples` , `Strings` |

Set Types | `Sets` and `Frozen Sets` |

Mapping Types | `Dictionary` |

There are many more collection type objects present in the Python standard library. For now, we will look into the ones which we are most likely to use.

### Sequence Types

Let's start with **sequence types**. Python objects stored in the sequences are *ordered sequentially*.

Sequentially ordered objects mean that Python sequentially assigns each object a unique position or index. The objects can be accessed using their position index in the sequence.

The three basic sequence types are **lists**, **tuples**, and **strings**. Sequences are built-in containers that store the **ordered set** of Python objects.

*Ordered sets of objects* can be accessed using the format `s[i]`

where `s`

is the sequence name while `i`

is the object's index. Accessing an item in a container object using *an index* is called **indexing**. The index starts from $0$ for the left-most item and increases by one as it moves towards the right. If you use a negative integer, Python will start from the rightmost element with the last element being $-1$, the second last being $-2$, so on.

We have already encountered strings, lists, and tuples. Let's look at them in more detail.

```
>>> a = ["Eena", "Meena", "Deeka"] # List Sequence
>>> a[0] # Accessing first element
'Eena'
>>> b = ("Daai", "Daam", "Nika") # Tuple Sequence
>>> b[-1] # Accessing last element
'Nika'
>>> c = "Maaka naaka naaka" # String Seqeunce
>>> c[3] # Accessing fourth element
'k'
```

What does the following expression evaluate to?

```
>>> z = [0, 1, 2, 3, 4, 5]
>>> z[2]
```

- 1
- 2
- 3
- 4

`z = [1, 2, 3]`

.
And you run the command `z[100]`

using the Python interpreter. How do you think Python will respond?
Python raises an `IndexError`

error with the message `list index out of range`

. You can think of an index as a reference that can be used to access the object. Let's check with an example.

We will create two string objects and check their reference counts.

```
>>> from sys import getrefcount
>>> first_ship, second_ship = "Going Merry", "Thousand Sunny"
>>> getrefcount(first_ship)
2
```

To recall, that `getrefcount()`

function provides the number of the references to a given object. The number returned is **one** greater than actual as the **function call temporarily references the object**.

Now, let's put both of our ships into a list container and check the reference count.

```
>>> ships = [first_ship, second_ship]
>>> ships[0]
'Going Merry'
>>> getrefcount(first_ship)
3
```

The reference count increased as we have another way to reference the string object using the list position index.

Another thing to keep in mind is that the *index* can change if the container's content is changed.

To understand, let's take the following example where we delete an element of the list. We can delete an element of a list by using the `del`

keyword.

```
>>> a = [0, 1, 2, 3, 4]
>>> a[2]
2
>>> del a[2] # delete the element at index 2
>>> a
[0, 1, 3, 4]
>>> a[2]
3
```

The index `2`

, which was earlier assigned to number `2`

, is reassigned to integer `3`

after integer `2`

is removed from the sequence.

What do you think is the final value of the name `z`

after the following operation?

```
>>> z = "Loud Musiic"
>>> del z[9]
```

`Loud Muiic`

`Loud Music`

`Loud Musii`

- Raises
`TypeError`

Unlike list objects, `strings`

and `tuple`

objects cannot be modified once created. Whether an object can be modified after creation relates to **mutability**. Next, let's look into the mutability of sequences.

### Mutability

**Immutable sequences** are containers whose values don't change after we create them. *Strings* and *Tuples* are **immutable sequences**, while *List* objects are **mutable sequences**.

We can assign items in a *list* object to a different value after it's creation.

```
>>> a = [10, 30, 30, 40] # List object created
>>> a[1] = 20
>>> a
[10, 20, 30, 40] # List object modifed
```

We cannot modify items in a *Tuples* sequence.

```
>>> b = (1, 2, 3)
>>> b[1] = 2 # Cannot modfiy a tuple sequence
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
```

Similarly, Strings also cannot be modified once created.

```
>>> c = "Hxllo World"
>>> c[1] = "e" # Cannot modify a string sequence/literal
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
```

Immutable sequences such as tuples and strings can be ** hashed** or used as keys for

*dictionaries*. In contrast, mutable sequences such as

*lists*cannot be used.

```
>>> a = { "name": "Foo", (1, 2) : "Bar" }
>>> a[(1,2)] # Using a tuple as dictionary key
'Bar'
>>> a["name"] # Using a string as dictionary key
'Foo'
```

Storing tuples as dictionary keys are useful in certain cases. Python will allow it **as long as each element of a tuple is immutable**.

If a tuple has a mutable element, Python will raise `TypeError`

.

```
>>> a = {(1, []) : "Foo"} # Empty list is mutable
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
```

The immutable objects are safer to pass around, knowing that they cannot be modified. On the other hand, mutable objects can be modified via any of their references, leading to an incorrect result.

Also, Python has many optimizations in place for immutable objects, which makes working with them, a lot faster. That's all you need to know about immutability for now.

### Common Operations for Basic sequences

There are several common operations applicable to the basic sequences.

Let's look into sequences in a bit more detail.

Operation | Syntax | Description |
---|---|---|

Indexing | `s[i]` |
Returns item stored in `i` th index |

Membership | `x in s` or `x not in s` |
`True` if an item of s is equal to x, else `False` |

Concatenation | `s + t` |
Adds up two equal type sequences |

Replication | `s * n` or `n * s` |
Equivalent to adding `s` to itself `n` times |

Length | `len(s)` |
Returns the total number of items |

Slicing | `s[start : stop : step]` |
Returns a slice of items from the sequence with step |

Counting | `s.count(x)` |
Returns the total instances of item `x` in `s` |

Searching | `s.index(x,[,i j]])` |
Returns the index of first instance `x` in `s` |

Comparison | `s1 < s2` or `s1 == s2` |
Lexicographically comparison of corresponding items |

Min-Max | `max(s)` or `min(s)` |
Smallest or largest item of `s` |

Sorting | `sort()` |
Sorts the elements in ascending or descending order |

Any-All | `any(s)` or `all(s)` |
`True` if any/all items are `True` |

Sum | `sum(s)` |
Returns the sum for a sequence of integers |

Unpacking | `x,y,z = s` |
corresponding item can assigned using unpacking |

We already looked at *Indexing* operation. Let's look into other operations with examples.

We can use the **membership operation**.

#### Membership

The `x in s`

operator tests whether the object `x`

is present in the sequence `s`

. For strings, `in`

and `not in`

operators accept *substrings*.

```
>>> 1 in (1,2,3) # Checking containment in Tuple
True
>>> 0 not in [1,2,3,0] # Checking containment in List
False
>>> 'gre' in 'great kindness' # Checking containment in String
True
```

We can use the **search or index** operation to get an element's position in a sequence. Python provides a built-in method `index()`

for retrieving the index of the first occurrence inside the sequence if it exists. If the element is not present inside the sequence, Python raises `ValueError`

.

Let's take an example.

```
>>> a = (1, 2, 1, 3, 2, 4, 5, 2)
>>> a.index(2)
1
```

The `index()`

method also takes optional arguments `i`

and `j`

for the start and end index of the sequence's` to search, which is similar to searching the sequence's sub-sequence.

Therefore, the statement `s.index(x, i, j)`

is equivalent to `s[i:j].index(x)`

.

```
>>> a = [1, 2, 1, 3, 2, 4, 5, 2]
>>> a.index(2, 5) # Start search from 5th item
7
>>> a.index(2, 2, 5) # Start search from 2nd item till 5th item
4
```

If no element exists inside the sequence, the `index`

method returns `ValueError`

.

```
>>> "Team".index("I") # There is no 'I' in 'Team'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: substring not found
```

We can get the total number of items in a sequence obtained using the built-in function `len()`

. Let's see a couple of examples.

Let's start with `tuples`

.

```
>>> a = (1,2,3) # Tuple
>>> len(a)
3
```

Similarly, we can get the length of `lists`

and `strings`

.

```
>>> b = [] # List
>>> len(b)
0
>>> first_name = "Rochinate" # String
>>> len(first_name)
9
```

Indexing in Python starts at `0`

. We can get the index of the last element in a sequence using the expression: `len(s) - 1`

. We can also use `-1`

to access the same.

We can use **concatenation** or **replication** to create new sequences from sequences. **Concatenation** is the operation of linking elements together in series. Let's look at both of them in detail.

#### Concatenation

The same type of sequences can be concatenated to form a **new objects**.

```
>>> (1,2) + (3,4,5) # Concatenating two tuples
(1,2,3,4,5)
>>> [1,2] + [3,4,5] # Concatenating two lists
[1,2,3,4,5]
>>> "1 2 3 " + "4 5" # Concatenating two strings
'1 2 3 4 5'
```

Concatenating immutable sequences always results in a new object.

#### Replication

When you multiply a number `n`

greater than `0`

to sequence, it is equivalent to adding the sequence to itself `n`

times.

```
>>> 5 * (1,2) # Replicating a tuple
(1, 2, 1, 2, 1, 2, 1, 2, 1, 2)
>>> [1,2,3] * 3 # Replicating a tuple
[1, 2, 3, 1, 2, 3, 1, 2, 3]
>>> "Da-da Na-da " * 3 # Replicating a tuple
'Da-da Na-da Da-da Na-da Da-da Na-da '
```

When you multiply a sequence with `0`

, it becomes an empty sequence.

```
>>> (1,2,3) * 0
()
```

Values of n less than 0 are treated as 0 and yields an empty sequence of the same type as s.

```
>>> (1,2,3) * -5 # Replicating with a negative number
() # Empty Tuple
```

What is the output of the below Python expressions?

```
>>> a = [1,2,3]
>>> b = [a]
>>> c = 4 * b # 4 copies of elements of b
>>> c
[[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]]
>>> a[0] = 100
>>>> c
```

`[[100, 2, 3], [100, 2, 3], [100, 2, 3], [100, 2, 3]]`

`[[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]]`

`[[4, 8, 12], [4, 8, 12], [4, 8, 12], [4, 8, 12]]`

- Raises
`ValueError`

The replicated elements here are **shallow copies** of sequence. If you change the reference element, the change will be reflected across all repeated elements.

To better understand, let's take an example.

```
>>> a = [1,2,3]
>>> b = [a]
>>> c = 4 * b # 4 copies of elements of b
>>> c
[[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]]
>>> a[0] = 100
>>>> c
[[100, 2, 3], [100, 2, 3], [100, 2, 3], [100, 2, 3]] # All items will change
```

So, keep in mind that replicating a sequence results in **shallow copies of the elements**. Next, we will look at the **slicing operation** in sequences.

#### Slicing

As sequence types contain ordered items, the slicing operation returns a set of items within the defined starting index and ending index.

$$

\text{Slice Operation} \rightarrow s[i : j : k] \tag{12} \where, \text{i is start index, j is end index and k is the step}

$$

For example, for a tuple `s`

with value `(0,1,2,3,4,5,6,7,8,9)`

,

$$

s[3:7:1] = \left( \overbrace{ 0, 1, 2, \underbrace{3, 4, 5, 6}_\text{slice} , 7, 8, 9 }^\text{indices} \right) = (3, 4, 5, 6) \ \\tag{13}

$$

The slicing operation returns a set of items from the *start index* to the *end index*, not including the end index. The *step* dictates which elements to take after the first one. By default, the *step* is one that means the slice returns consecutive elements. In contrast, for *step* 2, the slicing operation will return every alternate item with a start and end index.

When we use the optional step argument, other than 1, to slice a list, it is called an **extended slice**.

Figure 2 shows various slices of the string sequence `PRIMER`

.

We can test a few examples of slicing operations in Python.

```
>>> (0, 1, 2, 3, 4, 5, 6, 7, 8, 9)[3:7] # Slicing with step 1
(3, 4, 5, 6)
>>> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9][0:7:2] # Slicing with step 2
[0, 2, 4, 6]
>>> "IansNinsDasdIasaAas"[0:-1:4] # Slicing with step 4 and negative index
'INDIA'
```

What is the result of the following slicing operation?

```
>>> a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> a[::2]
```

`[0, 2, 4, 6, 8]`

`[1, 3, 5, 7, 9]`

`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`

`[1, 3, 5, 7]`

In the exercise above, list `a`

starts from 0, skips element, picks `2`

, skips element, picks `4`

, and so forth. Slicing can seem complicated and un-intuitive sometimes. Let's understand more in detail.

Step can be understood in the following way. When you wish to get the slice `s[i:j:k]`

of a sequence `s`

, python returns the items `s[i], s[i + k], s[i+2*k], ... , s[i + n*k] `

where `n*k`

is less than the length of the seqeunce `len(s)`

.

So, for a list `s = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`

, with the slice `s[0:7:2]`

, python returns the items `s[0], s[2], s[4], s[6]`

.

**The slicing operator s[i:j] extracts a subsequence from s consisting of the elements with index k, where i <= k < j.** If Python finds no such elements satisfying the condition, it returns an empty sequence of the same type.

The steps can also be negative.

- If the
`step`

is positive and if the starting index`i`

is omitted, Python assumes the start index to be 0. - If the
`step`

is positive and if the ending index`j`

is omitted, Python assumes the end index to the length of the sequence len(s) - If the
`step`

is negative and if the starting index`i`

is omitted, Python assumes sets the starting index to the length of the sequence or`len(s)`

. - If the
`step`

is negative and if the ending index`j`

is omitted, Python sets the end index to`0`

.

Few examples of slicing are shown in the below code listing.

```
>>> a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> a[:]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> a[::2]
[0, 2, 4, 6, 8]
>>> a[::-2]
[9, 7, 5, 3, 1]
>>> a[5::-1] # Negative Index
[5, 4, 3, 2, 1, 0]
>>> a[3:5:-1]
[]
```

What's the value of the following expression?

```
>>> a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> a[5::-1]
```

`[5, 4, 3, 2, 1, 0]`

`[4, 3, 2, 1, 0]`

`[6, 5, 4, 3, 2, 1, 0]`

`[]`

### Counting

`To count the number of occurrences of an element in a sequence, we can use the built-in ``count()`

method.

For strings, the `count()`

method can also take a substring. If the item doesn't exist, the `count()`

method simply returns `0`

. Let's take a look at some examples.

Let's create a tuple of elements.

```
>>> a = (0, 1, 2, 2, 2, 5, 6, 2, 8, 9)
>>> a.count(2)
4
```

If the item doesn't exist, the `count()`

method simply returns `0`

.

```
>>> a.count(99)
0 # No element exists
```

For `lists`

, it works similarly.

```
>>> [1,1,1,3,4,5].count(1)
3
```

For strings, the `count()`

method can also take a substring.

```
>>> "This is a very long sentence".count('e')
4
>>> "This is a very long sentence".count('is')
2
```

What is the output of the following Python code?

```
>>> "Mississippi".count("is")
```

- 0
- 1
- 2
- 3

#### Comparison

In an earlier chapter, we saw that we could compare string literals **lexicographically**.

**Lexicographic** order is ordering words based on the **alphabetical order** of their component letters.

Python *lexicographically* compares primitive sequences against other sequences. Let's take a look at how this works.

Lexicographical comparison between built-in collections works as follows:

For two collections to compare equally, they must be of the same type and same value.

```
>>> [1, 2, 3] == [1,2,3]
True
>>> (0, 0.1, 0.2) == (0, 0.1, 0.2)
True
```

Collections that support order comparison have the same order as their first unequal elements. For example, `[1,2,a] <= [1,2,b]`

has the same value as `a <= b`

.

```
>>> [1, 2, 80] > [1, 2, 81]
False
>>> (9, 8, 7) > (9, 8, 0)
True
```

If a corresponding element does not exist, the shorter collection is ordered first

```
>>> [1, 2] < [1, 2, 9]
True
```

In the following code, what is the value of `(A, B, C)`

?

```
>>> (7,) > (1,2)
(A)
>>> [1] == [1,0]
(B)
>>> "Hello" < "hello"
(C)
```

`True, True, True`

`True, False, True`

`True, False, False`

`False, False, False`

#### Min-Max

Previously, we saw that basic sequences could be compared against the same type of sequences. The built-in functions `min()`

and `max()`

allows the user to get the least or the maximum value of the item stored in a sequence. Let's take a look at some examples.

Let's create a tuple of numbers.

```
>>> a = (1, 2, 3, 4, 5, 6, 7, 8)
```

Now, let's call `max()`

and `min()`

on the tuple to get the maximum value and minimum value correspondingly.

```
>>> a = (1, 2, 3)
>>> max(a)
3
>>> min(a)
1
```

For the functions `min()`

and `max()`

, **all the items should be of the same type.**

```
>>> b = [1, "Hello", (1,2,3)]
>>> max(b)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: '>' not supported between instances of 'str' and 'int'
```

The maximum or minimum value for a string returns the character having the highest or lowest *character value*.

```
>>> max("Hello World")
'r'
>>> min("Hello World")
' '
>>>
```

What is the value of the following Python code?

```
>>> max([(1, 2, 3), (1,), (1, 2, 4), (1, 3, 1) ])
```

`(1, 2, 3)`

`(1,)`

`(1, 2, 4)`

`(1, 3, 1)`

### Sorting

Often you would require to sort the list in ascending or descending order of item values. Python provides a built-in function `sorted()`

to help you do just that.

The `sorted()`

creates a **new list object** from a sequence in which Python sorts items in **ascending** or **increasing** order.

```
>>> a = (100, 20, .3, -0.123 ) # Tuple
>>> sorted(a)
[-0.123, 0.3, 20, 100] # A new sorted List object in ascending order
```

The `sorted()`

function takes an optional keyword argument *reverse* with the default value `False`

. To get sorted items in descending order, you can include the keyword argument `reverse=True`

.

```
>>> b = ( 11, 2, 355, 123 ) # Tuple
>>> sorted(b, reverse=True)
[355, 123, 11, 2] # Sorted item in descending order.
```

For strings as arguments, the `sorted()`

function returns a lexicographically ordered list of characters.

```
>>> sorted("Hello World")
[' ', 'H', 'W', 'd', 'e', 'l', 'l', 'l', 'o', 'o', 'r']
```

For the **sorted** function to work, all the containing items in a sequence should be of *same types of items which can be compared against other stored items*.

```
>>> sorted([(), (1,2), (1,2,3), (1,23)]) # List of Tuples
[(), (1, 2), (1, 2, 3), (1, 23)]
```

For instance, the `sorted()`

function will raise `TypeError`

if you use a mix of strings and integers as we cannot compare them directly.

```
>>> sorted([1,2,3,"sas"])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'str' and 'int'
```

What's the output of the following expression?

```
>>> type(sorted("CABBAGE"))
```

`<class 'list'>`

`<class 'str'>`

`<class 'tuple'>`

`<class 'int'>`

### Any-All

Earlier, we mentioned that in Python, we could test every object for truth value. There are many programming cases where you would like to check if any item has a truth value `True`

or if all items have a truth value `True`

. For that purpose, python provides built-in functions `any()`

and `all()`

.

```
>>> a = [0, 0, 0]
>>> any(a) # Check if any of the items in a has truth value ``True``
False
>>> b = ("", [], ()) # Empty sequences have truth value `False`
>>> any(b)
False
>>> c = [1,2,0]
>>> all(c) # Check if all items in `c` have truth value ``True``
False
>>> d = (100, 200, 300)
>>> all(d)
True
```

What's the output of the following expression?

```
>>> e = ("", [], ())
>>> any(e)
```

`True`

`False`

As you might recall, all empty sequences have the truth value, `False`

. You can verify by checking the result of `bool("") or bool([])`

in the interpreter. Therefore, the above exercise results in `False`

.

Another operation that is often used with lists is summing up all of its elements. Let's look into it next.

#### Sum

Python contains a built-in function `sum()`

to get the sum of numeric items in a sequence. The `sum()`

*start* and the items of a *sequence* from left to right and returns the total.

For instance,

```
>>> a = [1, 2, 3, 4, 5]
>>> sum(a) # Sum all the elements of list `a`
15
```

We can do the same for a `tuple`

.

```
>>> b = (0.3, 0.4, 0.5)
>>> sum(b)
1.2
```

The `sum()`

requires each item type to support the addition operation `+`

with other item types. The `sum()`

function doesn't support string sequences. If you try to add strings, it will show a `TypeError`

.

```
>>> c = ["Hi", "There"]
>>> sum(c)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'
```

`TypeError: unsupported operand type(s) for +: 'int' and 'str'`

. But we didn't provide any integers to add. Can you guess why Python prints such a message?
When we sum all the list elements, we usually assume a starting value, usually called **accumulator**. This value is usually set at `0,`

and we keep elements adding to it. And when the list is exhausted, the accumulator's final value is returned as the total value.

The `sum()`

function allows you to modify the accumulator. It takes an accumulator argument `start`

whose default value is 0. You can initialize the `sum()`

to start from other numbers as well.

```
>>> c = [0.3, 0.6, 0.9]
>>> sum(c, 10) # add the sum of `c` list to 10
11.8
```

You can also sum tuples if you use a different starting value.

```
>>> e = [(3, 4), (5, 6)]
>>> sum(e, (1, 2))
(1, 2, 3, 4, 5, 6)
```

However, note that you still cannot add strings using the `sum()`

function.

### Unpacking

Next, we come to the *unpacking* of sequences.

When we put objects into containers, it can be thought of as packing objects inside a container. If we wish to refer to each item in a container, we can use *indexing* to access via their position.

Python provides a shorter syntax to assign to individual items in a stored container. This is referred to as **unpacking sequences**.

Let's create a list with tuples.

```
>>> a = [(1,2,3), (4, 5,6), (7,8,9)]
```

Now, we can obviously name individual `tuple`

items using the index. For instance,

```
>>> first = a[0] # (1,2,3)
>>> second = a[1] # (4,5,6)
>>> third = a[2] # (7,8,9)
```

However, Python provides a much concise way to extract and assign individual items in a container.

```
>>> first, second, third = a
>>> first
(1,2,3)
```

As I mentioned before, unpacking works when there is an equal number of names corresponding to the sequence items.

```
>>> a, b, c = (1, 2, 3, 4) # Won't work
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: too many values to unpack (expected 3)
```

You can also unpack characters from a string sequence.

```
>>> a, b, c, d, e = "hello"
>>> a
'h'
```

Next, we will look into Python lists in more detail.

## Lists

In the previous section, we saw the operations that are common to all the sequences. We will now look at operations for lists.

`list`

and `tuple`

?
In Python, built-in `list`

objects are mutable while `tuple`

is immutable. This difference leads to several operations allowed in `list`

while not permissible in `tuple`

. Let's look at the `list`

first.

In Python, you can use a pair of square brackets or use the `list()`

constructor to create lists. The list constructor can take other sequences to create a list object.

```
>>> a = (1, 2, 3) # Tuple iterable
>>> list(a) # Creating a list from a tuple
[1, 2, 3]
```

As strings are also *iterables*, we can construct lists from strings.

```
>>> b = "Hello" # String iterable
>>> list(b) # Creating a list from a string
['H', 'e', 'l', 'l', 'o']
```

Now let's look at some of the methods and operations of List object available in Python.

Operation | Syntax | Description |
---|---|---|

Item assignment | `s[i] = x` |
Item at `i` of `s` is replaced by `x` |

Slice assignment | `s[i:j:k] = t` |
Slice of `s` from `i` to `j` is replaced by sequence `t` |

Item Deletion | `del x[i]` |
Removes the item at `i` |

Slice Deletion | `del s[i:j:k]` |
Removes the items of slice from the list |

Append | `s.append(x)` |
Appends `x` to the end of the sequence |

Insertion | `s.insert(i, x)` |
Inserts `x` into `s` at the given index `i` |

Clear | `s.clear()` |
Removes all the items from `s` |

Copy | `s.copy()` |
Creates a shallow copy of `s` |

Extend | `s.extend(t)` |
Extends `s` with the contents of sequence `t` |

Pop | `s.pop([i])` |
Retrieves the item at `i` and removes it from `s` |

Reverse | `s.reverse()` |
Reverses the items of `s` |

Remove | `s.remove(x)` |
Remove the first item from `s` where `s[i]` is equal to x |

Replication | `s *= n` |
Updates `s` with its content repeated `n` times |

Sorting | `s.sort()` |
Updates `s` with sorted list of items |

#### Assignment and Deletion

Lists are mutable container objects which can be modified. We can modify the items directly by accessing the item using indexing and reassigning using the assignment operator (`=`

).

```
>>> a = [1, 3, 3]
>>> a[1] = 2 # Modify the second item
>>> a
[1, 2, 3]
```

Similar to Item modification, a **slice** of list object s can also be reassigned to other sequences t using the form,

$$

s[i:j] = t

$$

The slice assignment will replace the contents of the slice object with the contents of sequence `t`

.

```
>>> a = [10, 2, 3, 4 50, 60]
>>> a[1:4] = [20, 30, 40] # the step `k` in slice is default 1
>>> a
[10, 20, 30, 40, 50, 60]
```

We can replace the contents of the slice with any arbitrary length sequence. It doesn't need to be the same as the slice length.

```
>>> c = [1, 5, 6]
>>> c[1:3]
[5,6] # slice to be replace
>>> c[1:3] = [2, 3, 4, 5, 6, 7]
>>> c
[1, 2, 3, 4, 5, 6, 7]
```

Let's do a quick exercise to check if you understand the slice assignment. What's the output below?

```
>>> z = [3, 6, 9, 11, 14, 17, 21]
>>> z[3:6] = [12, 15, 18]
>>> z
```

`[3, 6, 9, 11, 14, 17, 21]`

`[3, 6, 9, 12, 15, 18, 21]`

`[3, 6, 9, 11, 15, 18, 17, 21]`

`[3, 6, 9, 12, 15, 18]`

So far, we have been assigning slices to the list. We can also assign content from sequences other than lists.

```
>>> b = [1, 5]
>>> b[0:1] = (1, 2, 3, 4) # Contents from sequence
>>> b
[1, 2, 3, 4, 5]
```

We can do the same with string sequences as well.

```
>>> c = ["g","t"]
>>> c[1:2] = "reat"
>>> c
['g', 'r', 'e', 'a', 't']
```

If you wish to insert two or items replacing a single item, using a slice will be the best approach.

```
>>> b = [1, 40, 50, 60, 70]
>>> b[0:1] = [10, 20, 30]
[10, 20, 30, 40, 50, 60, 70]
```

Slices with a step argument other than the default step`1`

are calledextended slices.

To assign to an **extended slice**, the length of the list to be assigned must be equal to the slice object's length. For instance,

Let's take a list.

```
# We would like to modify the 3rd, 5th, and last item
>>> c = [1, 5, 1, 9, 1, 13, 1]
```

Now, we wish to modify the 3rd, 5th, and the last item of the list. We can get the slice using a step size `2`

. Let's check if we can get our desired slice.

```
>>> c[2::2] # Get the slice object
[1, 1, 1]
```

Now that we have our desired slice let's assign it to our desired sequence.

```
>>> c[2::2] = [7, 11, 15] # Slice Assignment
>>> c
[1, 5, 7, 9, 11, 13, 15]
```

If you try to assign a sequence with a length different from the slice objects, Python will raise ValueError.

```
>>> c[2::2] = [7, 11, 15, 17]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: attempt to assign sequence of size 4 to extended slice of size 3
```

What is the value of the following statement?

```
>>> d = [1, 4, 3, 9]
>>> d[1::2] = [2, 4]
>>> d
```

`[1, 2, 3, 4, 9]`

`[1, 2, 3, 4]`

`[1, 2, 4, 3, 4]`

`[2, 4, 3, 9]`

#### Deletion

To delete multiple items, we can slice a list object and use the `del`

keyword or assign it to an empty sequence. Let's take a look at how we can do that.

```
>>> a = [1, 2, 3, 7, 4, 5, 6]
>>> del a[3] # Delete the 4th item
>>> a
[1, 2, 3, 4, 5, 6]
```

We can delete the slice object using either the `del`

keyword or assigning the slice to an empty list.

```
>>> b = [1, 19, 7, 5, 6, 2, 3, 4]
>>> b[1:5] # slice needed to be deleted
[19, 7, 5, 6]
>>> del b[1:5] # Same as b[1:5] = []
>>> b # list object modified
[1, 2, 3, 4]
```

Therefore, you can delete the slice object using either using the `del`

or assigning the slice to an empty list.

However, to delete an **extended slice**, we can use only the `del`

keyword.

```
>>> c = [1, 3, 2, 4, 3, 5, 4, 7, 5]
>>> c[1::2] # slice needed to be deleted
[3, 4, 5, 7]
>>> del c[1::2] # Delete the slice
>>> c # List object modified
[1, 2, 3, 4, 5]
```

What is the final value of `d`

in the following code?

```
>>> d = [1, 2, 3, 4, 5]
>>> d[1:4] = [5, 4, 3, 2]
>>> del d[2::2]
```

`[1, 5, 3, 5]`

`[1, 2, 4]`

`[1, 2, 3]`

`[1, 5, 4, 2]`

### Append, Insertion, and Extend

If you wish to add items to a list, you can use the `+`

operator to concatenate two lists. However, concatenation leaves the two lists unchanged. We can understand this in the code below.

```
>>> a = [1, 2, 3]
>>> b = [4, 5, 6]
>>> a + b
[1, 2, 3, 4, 5, 6]
>>> a
[1, 2, 3]
```

To modify the list object, Python provides a built-in method `append()`

for the list object, which adds items at the end of the list.

```
>>> c = [1, 2]
>>> c.append(3)
>>> c
[1, 2, 3] # list modified
```

What is the output from the following statements?

```
>>> d = [1, 2, 3]
>>> d.append((1, 2))
```

- Raises
`TypeError`

`[1, 2, 3, 1, 2]`

`[1, 1, 2]`

`[1, 2, 3, (1, 2)]`

The `append()`

method takes only a single argument; therefore, it can add only a single object at the end of the list. To add multiple values, lists have a built-in method `extend()`

, which adds another sequence's contents at the end of the list.

```
>>> d = [1, 2, 3]
>>> d.extend((1, 2)) # Add contents from the tuple to the list
>>> d
[1, 2, 3, 1, 2] # List modified
```

The `s.extend(t)`

method is the same as using the augmented assignment operator `+=`

for concatenation. For instance, we can extend a list using the following method.

```
>>> d = [1, 2, 3]
>>> d += [1, 2] # Same as d = d + [1, 2]
>>> d
[1, 2, 3, 1, 2]
```

We can extend the list from various sequences.

```
>>> c = [1, 2, 3]
>>> c.extend([4, 5, 6]) # Similar as c += [4,5,6]
>>> c
[1, 2, 3, 4, 5, 6]
>>> c.extend((7, 8)) # Extending from Tuples
>>> c
[1, 2, 3, 4, 5, 6, 7, 8]
>>> c.extend("NO") # Extending from Strings
>>> c
[1, 2, 3, 4, 5, 6, 7, 8, 'N', 'O']
```

There is a caveat that you need to know about the `extend`

operation and concatenation. To understand this, let's do an exercise.

What is the output of the following operation?

```
>>> x = y = z = [1, 2] # All names point to same list object
>>> x += [3, 4]
>>> y = y + [3, 4]
>>> x is y, x is z, z
```

`(True, True, [1, 2, 3, 4])`

`(False, True, [1, 2, 3, 4])`

`(False, True, [1, 2])`

`(True, True, [1, 2])`

Augmented assignment operator `+=`

along with `append()`

and `extend()`

modifies the same object. However, if we use the expanded form of `+=`

, the name is reassigned to a different object.

Let's take a list object `a`

.

```
>>> a = [1, 2, 3]
>>> id(a)
139958920005384
```

Let's modify the list using the augmented assignment operator (`+=`

).

```
>>> a += [4] # Add elements
>>> a
[1, 2, 3, 4]
>>> id(a)
139958920005384 # Same object
```

Now, let's modify the object using the expanded form of the assignment operator.

```
>>> a = a + [5]
>>> a
[1, 2, 3, 4, 5]
>>> id(a)
139958920005128 # Different object
```

This discrepancy can be explained by the way the operators work behind the scene.

To recall, **Magic** or **Special methods** allows objects to use various operators such as `+`

, `-`

, `+=`

, etc. Whenever you use an operator such as `+`

to add two objects, Python executes the function `__add__()`

behind the scene for the object.

The `+`

operator uses the `__add__`

magic method, which doesn't modify any operands. Therefore, the expression, s = s + [a], returns a new object and is reassigned to the name.

On the other hand, the `+=`

operator calls the `__iadd__`

magic method that modifies the arguments **in-place**. This is a tricky bit of information you should be keeping in mind.

For lists, it is easier to remember that`+=`

is a shorthand for`list.extend()`

to avoid such erroneous behavior.

**In-place** methods modify the objects which call it. For example, when we call when we append an element to a list, the list object is modified.

```
>>> a = [1, 2]
>>> a.append(3)
>>> a # list modified in-place
[1, 2, 3]
```

While **non-in-place methods** don't modify their object. Instead, they return a *copy* of that object.

```
>>> name= "luffy"
>>> name.upper() # returns a copy
'LUFFY'
>>> name
'luffy'
```

Both `append()`

and `extend()`

methods insert items at the end of the list.

One method we can use is the slice assignment. As the slice assignment removes the item at the start index, you can put the same in the sequence to be assigned. Let's take a look.

The slice assignment removes the item at the start index. Therefore, we need to ensure to keep the element we are replacing in the sequence content.

```
>>> a = [4, 1]
>>> a[0:1] = [a[0], 3, 2] # Insert multiple items at a given index
[4, 3, 2, 1]
```

This method allows us to insert multiple items at a given index.

To insert an element at a particular position or index, Python provides a built-in method `insert()`

. The `insert()`

method takes two arguments, `index`

and the `object`

, and inserts the `object`

before the `index`

.

```
>>> d = [1, 3, 4]
>>> d.insert(1, 2) # Insert `2` before the second item
>>> d
[1, 2, 3, 4]
>>> d.insert(len(d), 5) # Insert `5` at the end
>>> d
[1, 2, 3, 4, 5]
```

Like the `append()`

method, the `insert()`

method inserts only a single item. To insert multiple items, we can assign list items to slice.

The `append()`

and `extend()`

method can be replicated using a slice assignment.

```
>>> a = [1, 2, 3, 4]
>>> a[len(a):] = [5] # Same as a.append(5)
>>> a
[1, 2, 3, 4, 5]
>>> a[len(a):] = [6, 7, 8] # Same as a.extend([6, 7, 8])
[1, 2, 3, 4, 5, 6, 7, 8]
```

What is the output of the following code?

```
>>> z = [1, 2, 3]
>>> z[len(z):] = [4, 5, 6]
>>> z
```

`[1, 2, 3, 4, 5, 6]`

`[1, 2, 4, 5, 6]`

`[4, 5, 6]`

`[1, 2, 3, [4, 5, 6]]`

Python also provides methods to remove elements from the list. Earlier, we used the `del`

keyword to remove elements. Let's take a look at other ways to do the same.

#### Clear, Remove and Pop

To remove all the items of a given list `s`

, Python provides a built-in method `clear()`

for list-objects. This is the same as deleting the entire list slice using the `del`

keyword.

```
>>> a = [1, 2, 3]
>>> a.clear() # Same as del s[:]
>>> a
[]
```

To remove a given item without specifying its index, you can use the built-in method `remove()`

.

The method takes an `object`

as an argument and removes the *first item* in the list, which matches the object's value.

```
>>> a = [1, 2, 1, 2, 3, 4]
>>> a.remove(1) # [2, 1, 2, 3, 4]
>>> a.remove(2) # [1, 2, 3, 4]
>>> a
[1, 2, 3, 4]
```

The method `s.remove(x)`

raises `ValueError`

when `x`

is not found in the list `s`

.

Sometimes, you require to return an item and simultaneously remove it from the list. The built-in method `pop()`

takes the index of the element as an argument. It returns the element while removing it from the list.

```
>>> a = [1, 2, 3]
>>> a.pop(1) # Return the item at position 1
2
>>> a # Item removed from the list
[1, 3]
```

How does Python evaluate the last line in the below code?

```
>>> z = [1, 2, 1, 2, 1, 3, 4, 5, 6]
>>> z.pop()
6
>>> z.pop()
5
>>> z.remove(1)
>>> z.remove(4)
>>> z.remove(6)
```

- Raises
`ValueError`

`[2, 1, 2, 1, 3]`

`[2, 1, 2, 1, 3, 6]`

`[1, 2, 1, 3]`

#### Copy and Replication

Python provides a built-in method `copy()`

to copy the list. This is the same as the slice `s[:]`

.

Let's take a look. Let's create a list `a`

which value `[1, 2, 3]`

. Now, let's copy the list `a`

to the name `b`

.

```
>>> a = [1, 2, 3]
>>> b = a.copy() # Same as a[:]
>>> b
[1, 2, 3]
```

To replicate items in a list, we can the augmented assignment operator `*=`

.

```
>>> a = [1, 2, 3]
>>> a *= 3 # Same as a = a*3
>>> a
[1, 2, 3, 1, 2, 3, 1, 2, 3]
```

As we earlier mentioned, both *copy and replication* produces **shallow copy** of the items stored in the list.

What's the final value of the `a`

?

```
>>> a = [[], [1,2,3]]
>>> b = a.copy() # Create a list `b` with same contents as `a`
>>> b
[[], [1,2,3]]
>>> b[1][1] = 100 # Modify a item stored in `b`
>>> b
[[], [1, 100, 3]] # `b` is modified
>>> a
```

`[[], [1, 2, 3]]`

`[[], [1, 100, 3]]`

#### Reverse

Suppose you want to complete the reverse the list. This can be achieved using extended slice objects with the *step* $-1$.

```
>>> a = [1, 2, 3, 4]
>>> a[::-1] # Slice object with step -1
[4, 3, 2, 1]
>>> a
[1, 2, 3, 4] # The original list is intact
```

The thing to be noted is that the slice object returns another list.

```
>>> a = [1, 2, 3, 4]
>>> b = a[::-1]
>>> a is b
False
```

We can modify the same list object using a python built-in method `reverse()`

.

```
>>> a = [1, 2, 3, 4]
>>> a.reverse()
>>> a # The object is modified
[4, 3, 2, 1]
```

What is the output of the last line of the code below?

```
>>> a = [1, 2, 3, 4]
>>> b = a[::-1]
>>> a.reverse()
>>> a == b
```

`True`

`False`

#### Sorting

Earlier, we used the built-in function `sorted()`

to sort the items in a list. The `sorted()`

function doesn't change the order of the items in a list; rather, it returns a copy of the list with the items rearranged.

We can change the items of a list object using a built-in python method `sort()`

. The `sort()`

method, similar to the `sorted()`

function, accepts a keyword argument `reverse`

to get the items in descending order.

```
>>> a = [3, 5, 2, 1, 4]
>>> a.sort() # Sorts the list in ascending order
>>> a
[1, 2, 3, 4, 5]
>>> a.sort(reverse=True) # Sorts the list in descending order
[5, 4, 3, 2, 1]
```

### Tuples

What's the value of the sorting operation below?

```
>>> z = [(1, 2), (1, 2, 3), (3, 2, 1), (3, 2)]
>>> z.sort(reverse=True)
```

`[(3, 2, 1), (3, 2), (1, 2, 3), (1, 2)]`

`[(1, 2), (1, 2, 3), (3, 2), (3, 2, 1)]`

`[(1, 2), (1, 2, 3), (3, 2, 1), (3, 2)]`

`[(1, 2, 3), (1, 2), (3, 2), (3, 2, 1)]`

To make sense of the previous exercise, you might recall that the tuples are compared lexicographically. Next, let's look into tuples in more detail. However, since `tuple`

is immutable, there are not many specific operations for a tuple.

Tuples are **immutable sequences,** and we can create them in several ways:

- Using a pair of parentheses to denote the empty tuple:
`()`

- Using a trailing comma for a singleton tuple:
`a`

, or`(a,)`

- Separating items with commas:
`a, b, c`

or`(a, b, c)`

- Using the
`tuple()`

built-in constructor :`tuple()`

or`tuple(sequence)`

To illustrate, let's look at the following code sample.

```
>>> a = () # Empty Tuple
>>> b = 1, # Singleton tuple same as b = (1, )
>>> c = 1, 2, 3 # Tuple containing values b = (1, 2, 3)
>>> d = tuple([1, 2, 3]) # (1, 2, 3)
>>> e = tuple("Hello") # ('H', 'e', 'l', 'l', 'o')
```

The `tuple(iterable)`

constructor builds a `tuple`

whose items are the same and in the same order as iterable items. Tuple implements all the common sequence operations, as shown earlier.

Python reuses the same empty `tuple`

object throughout the program execution. Let's take an example to understand more.

```
>>> a = () # Empty Tuple 1
>>> b = () # Empty Tuple 2
>>> a is b
True # a and b both refer to the same object in memory
```

Since tuples are immutable, we cannot modify them after they have been created. Therefore, Python reuses the object. But then what happens with the empty mutable containers? Let's take a look.

For empty mutable sequences such as list, Python creates new objects.

```
>>> x = []
>>> y = []
>>> x is y
False # x and y both refer to the different object in memory
```

Tuples are immutable, so once created, their value doesn't change. In the next section, let's take a look at another immutable sequence: **Strings**.

## Strings

In chapter 1, we learned about **Unicode characters**.

Stringsare immutable sequences ofUnicode code pointsorcharacters.

We can create string sequences in several ways:

- String literal using single quotes
`''`

- String literal using double quotes
`""`

- Triple quoted string literal
`""" """`

- String constructor
`str()`

```
>>> a = "Can embed 'single quotes' " # Double quotes
>>> b = 'Can embed "double quotes" ' # Single quotes
>>> c= """Can span
multiple lines
of text
""" # Triple quote
>>> d = str(14.3) # Returns the string version of the object
```

String literals that have whitespace between them will be converted to a single string literal.

```
>>> e = "Hello" "World" # 'HelloWorld'
```

Although a string is composed of characters, there is no *character type* in Python.

A **single character** is referred to as a string with a length of $1$.

What is the output of the last line of the code below?

```
>>> a = "ABC"
>>> b = "ABC"
>>> a[0] is b[0]
```

`True`

`False`

Every character has a constant value that is shared among strings. Similar to `list`

objects, strings have specific methods for them. Let's take a look at them.

#### String Methods

String sequences implement all of the standard sequence operations and have additional methods of their own. Table 7 shows some of the essential methods categorized by their functionality.

Method Type | Description |
---|---|

Case Modifiers | Modifies the cases of the characters in a string |

Count and Search | Checks the count of characters or search for a substring |

Boolean Methods | Check if any or all digits are specified to a format |

Join and Split | Join or Splits the string using a delimiter |

Strip | Strips the string of a substring |

Format | Formats the string to include a `name` |

Let's check each of them in a bit more detail. We will start **case modifiers**.

##### Case Modifers

A string `str`

can change the case of some or all of its characters using built-in methods. Table 8 illustrates some of such methods.

Method | Description |
---|---|

`str.capitalize()` |
Return a copy of the string with its first character capitalized and the rest lowercase. |

`str.lower()` |
Return a copy of the string with all the cased characters converted to lowercase |

`str.swapcase()` |
Return a copy of the string with uppercase characters converted to lowercase and vice versa. |

`str.title()` |
Return a title case version of the string where words start with an uppercase character, and the remaining characters are lowercase |

`str.upper()` |
Return a copy of the string with all the cased characters converted to uppercase |

We can check the usage of a method in a code listing below.

```
>>> "hello world".capitalize() # 'Hello world'
>>> "HELLO WORLD".lower() # 'hello world'
>>> "Hello World".swapcase() # 'hELLO wORLD'
>>> "hello world".title() # 'Hello World'
>>> "hello world".upper() # 'HELLO WORLD'
```

`capitalize()`

and the `title()`

method?
The difference between the method `capitalize()`

and `title()`

is that the `capitalize()`

methods change the case of the first letter of the string. In contrast, the `title()`

method changes every word's first letter in a string.

#### Count and Search Methods

We can search for the position of a *substring* or count the substring instances using these built-in Python string methods.

Method | Description |
---|---|

`str.count(sub, start, end)` |
Returns count of non-overlapping occurrences of substring sub |

`str.find(sub, start, end)` |
Return the lowest index in the string where substring sub is found |

`str.rfind(sub, start, end)` |
Return the highest index in the string where substring sub is found |

`str.index(sub, start, end)` |
Same as `find()` but raises `ValueError` when string not found |

`str.rindex(sub, start, end)` |
Same as `rfind()` but raises `ValueError` when string not found |

Earlier, we saw the `count()`

method in the common operations of sequences. In strings, we can take substrings and count their *non-overlapping* occurrences. The non-overlapping occurrences mean that the substring doesn't share its characters with earlier found occurrences.

We can also use the optional start and end arguments to define a slice of string to search.

```
>>> "ananananana".count("ana")
3 # `ana` n `ana` n `ana`
>>> "ananananana".count("ana", 0, 4)
1
```

What is the value of the following code in Python?

```
>>> a = "She sells seashells by the sea-shore"
>>> a.count("se")
```

- 0
- 1
- 2
- 3

#### Find and Index

The `find()`

method searches for a string and returns the lowest position where the substring is found. You can also provide an optional `start`

and `end`

range to define a slice to search in.

```
>>> "ananananana".find("ana")
0
>>> "ananananana".find("ana", 4)
4
```

To get the highest position to find the substring, Python provides another method, `str.rfind(sub, start, end)`

.

```
>>> "ananananana".rfind("ana")
8
>>> "ananananana".rfind("ana", 4)
4
```

When a substring is not found in a given string, the method returns `-1`

.

```
>>> "ananananana".find("man")
-1
```

Unlike `find()`

and `rfind()`

, the built-in `index()`

and `rindex()`

method return a `ValueError`

when a substring is not found. Next, we will look into boolean methods associated with strings.

Functions which can return either `True`

or `False`

are called **Boolean Functions**.

String sequences in Python have methods that check if the given string satisfies a particular condition and return `True`

or `False`

. These methods are called **Boolean Methods**.

### Boolean Methods

Earlier, we saw the membership operator ( `in`

) in Python, which checks if a given substring exists in Python. Python strings also have `startswith()`

and `endswith()`

, which check if a substring exists in the beginning and end of the string, respectively.

Method | Description |
---|---|

`str.endswith(suffix, start, end)` |
Check if a suffix present at the end of the string |

`str.startswith(prefix, start, end)` |
Check if a prefix present at the beginning of the string |

Let's take an example to illustrate both the methods.

```
>>> a = "Happiness"
>>> "ess" in a
True
>>> a.endswith("ess")
True
>>> a.startswith("Ha")
True
```

You can use optional `start`

and `end`

arguments to select a slice of the string. We can also provide a tuple of suffixes and prefixes. The methods will return `True`

if one of the items in the tuple satisfies the condition.

```
>>> b = "Emptiness"
>>> b.endswith(("mess", "chess", "ness", "less"))
True
```

The other methods relate to the type of characters and their cases used to construct the string. Table 11 lists such methods.

Method | Description |
---|---|

`str.isalnum()` |
Return `True` if all characters in the string are alphanumeric and there is at least one character |

`str.isalpha()` |
Return `True` if all characters in the string are alphabetic and at least one character. |

`str.isascii()` |
Return `True` if the string is empty or all characters in the string are ASCII, `False` otherwise. |

`str.isdecimal()` |
Return `True` if all characters in the string are decimal characters and there is at least one character |

`str.isdigit()` |
Return `True` if all characters in the string are digits and there is at least one character. |

`str.isidentifier()` |
Return `True` if the string is a valid identifier according to the language definition. |

`str.islower()` |
Return `True` if all cased characters in the string are lowercase and at least one cased character. |

`str.isnumeric()` |
Return `True` if all characters in the string are numeric characters, and there is at least one character |

`str.isspace()` |
Return `True` if there are only whitespace characters in the string and there is at least one character, `False` otherwise. |

`str.istitle()` |
Return `True` if the string is a title-cased string and there is at least one character |

`str.isupper()` |
Return `True` if all cased characters in the string are uppercase and at least one cased character. |

What is the output of the following code?

```
>>> a = " ".isspace()
>>> b = "Hello World".istitle()
>>> c = "Hello World".isalpha()
>>> d = "helloworld".islower()
>>> a, b, c, d
```

`(True, True, True, True)`

`(True, False, False, True)`

`(True, False, True, False)`

`(True, True, False, True)`

Next, we will look into the **join** and **split methods** in Python.

### Join and Split

We can join the string objects stored in a list using the string method `str.join()`

. The method takes a sequence of string objects and joins them to create a new string object. The separator between the items in the sequence is the string object, which calls this method. For instance,

```
>>> a = ["This", "is", "ancient", "alien", "tech"]
>>> "-".join(a) # Join the items in a separated by `-`
'This-is-ancient-alien-tech'
```

We can also split a string into different substrings separated by a delimiter string.

For example,

```
>>> "Eena,Meena,Deeka".split(",") # Split the string by comma
['Eena', 'Meena', 'Deeka']
```

The split method accepts a positional argument, `maxsplit`

, which takes an integer determining how many splits should take place.

```
# Split once
>>> "Eena, Meena, Deeka, Daai, Daam, Nika".split(",", 1)
['Eena', 'Meena, Deeka, Daai, Daam, Nika']
# Split thrice
>>> "Eena, Meena, Deeka, Daai, Daam, Nika".split(",", 3)
['Eena', 'Meena', 'Deeka', ' Daai, Daam, Nika']
```

What is the output of the following code?

```
>>> a = ["Hey", "there", "Delilah", "!"]
>>> b = "-".join(a)
>>> c = b.split("-", 2)
>>> c
```

`['Hey', 'there', 'Delilah-!']`

`['Hey', 'there-Delilah-!']`

`['Hey-there-Delilah-!']`

`['Hey', 'there', 'Delilah', '!']`

Next, we will look at *stripping* strings.

#### Strip

The `str.strip(chars)`

method returns a copy of the string object with the leading and trailing characters removed. It takes an optional `chars`

argument, which specifies the set of characters to be removed. If omitted or `None`

, the method removes **whitespace**.

```
>>> "--| Great Wall of China |--" .strip("-| ") # Notice the space char as well
'Great Wall of China'
```

The method removes characters from the leading end until reaching a string character that is not present in the set of characters in `chars`

. A similar action takes place on the **trailing end**.

If you wish to strip only leading or trailing characters, you can use methods `str.lstrip(chars)`

or `str.rstrip(chars),`

respectively.

```
>>> "--| Great Wall of China |--".lstrip("-| ")
'Great Wall of China |--'
>>> "--| Great Wall of China |--".rstrip("-| ")
'--| Great Wall of China'
```

What's the output of the below code?

```
>>> a = "*_* ()_() Brown (o)_(o) Flower ()_() *_*"
>>> a.strip("_()*")
```

`' ()_() Brown (o)_(o) Flower ()_() '`

`'Brown (o)_(o) Flower'`

`Brown 0 0 Flower`

`()_() Brown (o)_(o) Flower ()_()`

#### Formatting

One of the most useful operations on string objects is **formatting**. To understand this, let's take an example of the printing powers of a given number and some descriptive text.

```
>>> print("The product of 2 and 5 is", 2*5)
The product of 2 and 5 is 10
```

For some other numbers, we can write.

```
>>> print("The product of 4 and 8 is", 4*8)
The product of 4 and 8 is 32
```

You might notice that while we are repeating the text and changing the number. This is a case where the formatting string is effective.

```
>>> text = "The product of {} and {} is {}"
>>> text.format(2, 5, 2*5)
'The product of 2 and 5 is 10'
>>> text.format(4, 8, 4*8)
'The product of 4 and 8 is 32'
```

Formatting lets you define some **replacement fields** inside the string literal which you can fill up values from objects later on.

What's the output of the below code?

```
>>> text = "The {} {} {} jumps over the {} {}"
>>> text.format("quick", "brown","fox","lazy","dog")
```

`'The quick brown fox jumps over the lazy dog'`

`'The brown quick fox jumps over the lazy dog'`

`'The fox brown quick jumps over the lazy dog'`

`'The quick brown dog jumps over the lazy fox'`

### String Formatting

String formatting is an important part of programming in Python. Python provides several ways to format a string.

We will start with formating simple string literals.

```
>>> text = "{} apple a {}, keeps the {} away"
>>> text.format('An', 'day', 'doctor')
'An apple a day, keeps the doctor away'
```

The `{}`

closed curly braces define the **replacement fields** or **placeholders**, which expect value from the **argument tuple** provided to the `format()`

method. The position of the arguments corresponds to the position of the replacement fields. The *first argument* goes to the *first replacement field*, the *second* goes to the *second*, so on and so forth.

If the length of the argument tuple to the `format()`

method is lesser than the replacement fields, Python will raise `IndexError`

.

```
>>> text.format('An', 'apple')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: tuple index out of range
```

#### Positional Placeholders

When the position of the arguments corresponds to the replacement fields' position, the replacement fields are said to bepositional placeholders.

You can define the replacement fields with a position to specify which item from the argument tuple is to be taken.

```
>>> text = "{3} you {0}, {2} shall you {1} !"
>>> text.format("sow", "reap", "so", "As")
'As you sow, so shall you reap !'
```

If there is a mismatch of index specified and the argument tuple, Python will raise an error. If you wish to reuse the same variable again, you can do it by specifying positional placeholders.

```
>>> text = "O {0}! my {0}! our fearful trip is done."
>>> text.format("Captain")
'O Captain! my Captain! our fearful trip is done.'
```

`named placeholder`

. Do you want to guess as to what a named placeholder?
#### Named Placeholders

Placeholder with keys or names is callednamed placeholders.

The `str.format()`

method also accepts keyword arguments. We can specify the placeholders with keys and provide the `format()`

method named arguments against those keys.

```
>>> text= "My best friends are {fr1} and {fr2}"
>>> text.format(fr1="Dinesh", fr2="Gilfoyle")
'My best friends are Dinesh and Gilfoyle'
```

We can use a mix of named and positional placeholders. Just keep in mind while providing method arguments that **positional argument cannot follow keyword arguments**.

```
>>> text = """We are here to {}
at the odds
and live our lives
so well that {}
will {} to take us.
{first} {last}
"""
>>> formatted_text = text.format("laugh", "Death", "tremble",
first="Charles",
last="Bukowski")
>>> print(formatted_text)
We are here to laugh
at the odds
and live our lives
so well that Death
will tremble to take us.
Charles Bukowski
```

What's the output of the following code?

```
>>> text = "The {0} brown {2} {verb} {1} the lazy {noun}"
>>> text.format("quick","over", "fox", verb="jumps", noun="dog")
```

`'The quick brown fox jumps over the lazy dog'`

`'The quick brown over jumps fox the lazy dog'`

`'The quick brown fox dog over the lazy jumps'`

- Raises
`IndexError`

#### Number Formatting

You can format the numbers using a set of **placeholder** **formats** in a string literal. We can define the precision of floating points in the string literal using the following syntax:

$$

:(width). (precision)f

$$

Here the **width** specifies the number of places the decimal should occupy. If the width exceeds the number of digits, the position is filled with space. The **precision** specifies the number of significant digits to display in the string literal. For instance,

```
>>> "{}".format(22/7)
'3.142857142857143'
>>> "{:5.1f}".format(22/7) # Occupy 5 places and 1 decimal digit
' 3.1'
>>> "{:5.3f}".format(22/7) # Occupy 5 places with 3 decimal digit
'3.143'
```

If the length of the precision is greater than the width, then the precision becoms the width.

```
>>> "{:1.7f}".format(22/7) # Occupy 1 place with 7 decimal digits
'3.1428571' # Occupies 9 places instead of 2 digit
```

The integers can also be formatted to display a number with a *comma separator*.

```
>>> "{:,}".format(1e9)
'1,000,000,000.0' # Comma Separated Value
```

#### Format values to different bases

A number can be formatted to be represented in different number bases such as decimal (`d`

), hexadecimal (`x`

), octal ( `o`

), or binary ( `b`

).

```
>>> print("Decimal:{0:d} Hex:{0:x} Octal:{0:o} Binary:{0:b} ".format(124))
Decimal:124 Hex:7c Octal:174 Binary:1111100
```

We can see additional details about string formatting on the Python documentation page.

What's the output of the following code?

```
>>> from math import pi
>>> "Value of pi: {2:5f}".format(pi)
```

- 'Value of pi: 3.14159'
- 'Value of pi: 3.1415'
- 'Value of pi: 3.141'
- 'Value of pi: 3.142'

There is another way to format strings, which is much more convenient to use: **f-strings**.

#### F-Strings

Aformatted string literalorf-stringis a string literal that is prefixed with $f$ or $F$.

**F-strings** was introduced from Python 3.6 to make string formatting easier. The f-strings tend to lot less verbose than using the `str.format()`

method. Let's see a couple of examples to understand more.

```
>>> first_name = "Monkey"
>>> middle_name = "D."
>>> last_name = "Luffy"
>>> f"Hello, {first_name} {middle_name} {last_name} !"
'Hello Monkey D. Luffy !'
```

We can also use expressions inside the replacement fields in *f-string*.

```
>>> f"{45*5}" # Can evaluate expressions directly
'225'
```

And you can also call functions or methods inside the replacement fields in an *f-string*.

```
>>> name = "MONKEY D. LUFFY"
>>> f"Hello, {name.title()} !" # Can call method directly
'Hello, Monkey D. Luffy !'
```

You can also write multi-line *f-strings*.

```
>>> name, age, dream = "Monkey D. Luffy", "16", "Pirate King"
>>> text = (
... f"Hi, I am {name}! "
... f" I am {age} years old."
... f" I will be the {dream}."
... )
>>> text
'Hi, I am Monkey D. Luffy! I am 16 years old. I will be the Pirate King. '
```

You have to prefix each line with *f* to write multi-line strings using double or single-quoted strings.

You can also use triple-quoted f-strings.

```
>>> text = f"""
... Hi, I am {name}!
... I am {age} years old.
... I will be the {dream}.
..." ""
>>> print(text)
... Hi, I am Monkey D. Luffy!
... I am 16 years old.
... I will be the Pirate King.
```

What's the output of the below code?

```
>>> pirate = {
'name': 'Luffy',
'age': 16
}
>>> f'The pirate named {pirate['name']} is {pirate['age']} years old. '
```

`'The pirate named Luffy is 16 years old. '`

`'The pirate named Luffy is {pirate['age']} years old. '`

`'The pirate named {pirate['name']} is {pirate['age']} years old. '`

- Raises
`SyntaxError`

You should avoid using the same type of quotations around the dictionary keys as you do with f-strings.

This brings us to the end of the section of strings and sequences. Let's look at sets in the next section.

## Set Types

You might have read about sets and set theory at some point while learning mathematics. The basics of set theory are quite simple and can be easily understood by everyone.

In the real world, a **set** is a group or collection of things that belong together or resemble one another or are usually found together—for example, A set of teeth.

Mathematically, it's the definition is more precise.

Asetis a collection ofdistinct objects. These distinct objects are referred to asmembersof the set.

Previously we saw sequences are ordered collections of objects. A **set** is an **unordered collection** of unique objects. Unlike sequences, sets do not provide any indexing or slicing operations. **The elements of the set must be of an immutable type.**

We can create Python sets in several ways,

- Built-in set constructor which takes an iterable,
`set(<iter>)`

- Using curly braces,
`{1,2,3}`

For instance,

```
>>> a = set([1, 2, 3, 2, 4, 5 ,6, 6 ,1 ,3 ,5]) # List with duplicate items
>>> a
{1, 2, 3, 4, 5, 6} # Set with distinct items
```

When you construct a set from an iterable, Python removes duplicate items and creates a set of unique items from the given list. This is also applicable to string sequences.

```
# Distinct characters in the string
>>> b = set("mississippi missouri")
{'o', 'i', 'u', 'r', 'm', 'p', ' ', 's'}
```

You can notice that the strings' resulting set is **unordered**, and the original order specified by the sequence is not necessarily preserved.

What is the output of the below code?

```
>>> c = {1, 2, 3, 2, 4, 5 ,6, 6 ,1 ,3 ,5}
>>> c
```

`{1, 2, 3, 4, 5, 6}`

`{1, 2, 3, 2, 4, 5, 6, 6, 1, 3, 5}`

`{}`

`{1, 2, 3, 2, 4, 5, 6}`

The code listing in the above exercise demonstrates the construction of sets using curly braces.

When you define a set using curly braces `{}`

, Python automatically removes the duplicate objects. However, unlike the `set()`

constructor, the iterable is not broken down to their constituent objects while being defined using curly braces `{}`

. The objects inside curly braces `{}`

are placed into the set intact, even if they are iterable.

```
>>> set("Hello") # Creating sets using built-in set() func
{'e', 'o', 'l', 'H'}
>>> {'Hello'} # Creating sets using curly braces
{'Hello'}
```

A set can be empty. However, you cannot define an empty set using curly braces `{}`

`{}`

?
The reason is Python interprets empty curly braces as empty dictionary objects.

```
>>> a = {}
>>> type(a)
<class 'dict'>
```

Therefore, the only way to define an empty set is by using the `set()`

constructor.

```
>>> b = set()
>>> type(b)
<class 'set'> # Set
>>> b
set() # Empty Set
```

The truth value of an empty set is `False`

.

```
>>> c = set()
>>> c or 1
1
>>> c and 1
set()
```

To verify an empty set's truth value, we can use the `bool()`

function.

```
>>> bool(set()) # Truth Value
False
```

As we mentioned above, **the set elements must be immutable**. Mutable containers such as Lists and Dictionaries are not allowed in a set. At the same time, tuples are allowed in sets.

So, the below code is a perfectly acceptable set.

```
>>> a = {(1,2,3),"Hello", 1, 1}
>>> a
{1, 'Hello', (1, 2, 3)}
```

While the below set is unacceptable as it includes a mutable dictionary.

```
>>> b = {{}, 1, 2 }
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'dict'
```

Similarly, Python raises an error for the below set as it includes a `list`

object.

```
>>> c = {[1, 2, 3], 1, 2}
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
```

What's the result of the following code?

```
>>> set([1, 2, 3, 4, (1, 2, 3)])
```

`{1, 2, 3, 4, (1, 2, 3)}`

`{1, 2, 3, 4, [1, 2, 3]}`

`Raises TypeError`

`{1, 2, 3, 4}`

#### Membership and Copy Operation on Sets

Sets in Python also support several built-in functions and operations. Let's start with getting the length of a set.

Like sequences, we can determine the number of members in a set by using the built-in `len()`

function.

```
>>> x = {'a', 'b', 'c'}
>>> len(x)
3
```

To check if a member exists in a set, the operators `in`

and `not in`

can be used.

```
>>> 'a' in x
True
>>> 'c' not in x
False
```

You can also create a copy of a set using a built-in method `s.copy()`

, which returns a shallow copy of the set items.

```
>>> s1 = {1, 2, 3}
>>> s2 = s.copy()
>>> s2
{1, 2, 3}
```

What's the output of the below code?

```
>>> s1 = set("Hello World")
>>> " " in s1, len(s1)
```

`(True, 8)`

`(True, 7)`

`(False, 7)`

`(False, 8)`

### Mathematical Operations on Sets

We can implement certain mathematical operations on sets of its built-in method. The following mathematical operations are usually performed on sets:

- Union
- Difference
- Intersection
- Symmetric Difference
- Disjoint
- Superset
- Subset

We will understand these operations along with the description of each operation. Let's start with **Union operation**.

#### Union

For two sets $s1$, $s2$, a set of all objects that a member of $s1$or $s2$ or both is referred to asunion of sets.

In Python, the union of two or more sets can be computed using the built-in union operator `|`

or built-in `union()`

method.

```
>>> s1, s2, s3 = {1, 2, 3}, {4, 5}, {6, 7}
>>> s1 | s2 | s3 # Union of sets s1, s2 and s3 using operator
{1, 2, 3, 4, 5, 6, 7}
>>> s1.union(s2, s3) # Union using method
{1, 2, 3, 4, 5, 6, 7}
```

The resulting set comprises all elements present in any of the specified sets.

What's the output of the code below?

```
>>> len(set("Hello") | set("World"))
```

- 7
- 8
- 9
- 6

#### Difference

Theset differenceof $s1$ and $s2$ is the set of all members of $s1$ that are not members of $s2$.

In Python, the **difference** of two sets can be obtained either using operator `-`

or built-in set method `difference()`

.

```
>>> s1, s2 = {1, 2, 3}, {1, 3}
>>> s1 - s2 # Difference using operator
{2}
>>> s1.difference(s2) # Difference using method
{2}
```

The resulting set from the $s1 - s2$ operation returns the set containing members present in $s1$ but not $s2$.

When there are multiple sets defined, the operation is performed from **left to right**.

```
>>> s1, s2, s3, s4 = {1, 2, 5, 7}, {1, 3}, {2, 3}, {1, 5}
>>> s1 - s2 - s3 - s4 # Difference using operator
{7}
>>> s1.difference(s2, s3, s4) # Difference using method
{7}
```

In the above code listing, the operation $s1 - s2$ is performed first, which results in ${2, 5, 7}$. Then $s3$ is subtracted from the resulting set to obtain ${5, 7}$. Finally $s4$ is subtracted from ${5, 7}$ to get ${7}$.

What's the output of the code below?

```
>>> text1 = "the five boxing wizards jump quickly"
>>> text2 = "the quick brown fox jumps over the lazy dog"
>>> len(set(text1) - set(text2))
```

- 0
- 10
- 13
- 5

In the previous exercise, both the sentences are **pangrams**[^5]. Now, let's take a look at **intersection operation** on sets.

#### Intersection

Intersectionof the sets $s1$ and $s2$ is the set of all objects that are members of both $s1$ and $s2$.

In Python, the intersection of two or more sets can be obtained using the operator `&`

or the built-in set method `intersection()`

.

```
>>> s1, s2, s3 = {1, 2, 3, 5}, {3, 5}, {1, 3, 5, 7}
>>> s1 & s2 & s3 # Intersection using operator
{3, 5}
>>> s1.intersection(s2, s3) # Intersection using method
{3, 5}
```

What's the output of the code below?

```
>>> s1 = set([1, 2, 3]).intersection(set([1, 4, 5]), set([2, 3, 6]))
```

`set()`

`{3}`

`{3, 1}`

`{1, 2, 3}`

#### Symmetric Difference-

Thesymmetric differenceof sets $s1$ and $s2$ is the set of all objects that are a member of precisely one of $s1$ and $s2$ and not in both sets.

In Python, the symmetric difference of two or more sets can be determined using the operator `^`

or the `symmetric_difference()`

method.

```
>>> s1, s2 = {1, 2, 3}, {4, 5, 2, 3 }
>>> s1 ^ s2 # Symmetric Difference using operator
{1, 4, 5}
>>> s1.symmetric_difference(s2)
{1, 4, 5} # Symmetric Difference using method
```

What's the output of the code below?

```
>>> s1, s2 = {1, 2, 3, 6}, {7, 3, 2, 1}
>>> s1 ^ s2
```

`{1, 2, 3}`

`{6, 7}`

`set()`

`{1, 2, 3}`

#### Disjoint

Two sets s1 and s2, are said to be **disjoint** sets if they have no common element.

As disjoint sets have no common members, the intersection results in an **empty set**.

We can also state that two sets are said todisjointif theintersectionresults in anempty set.

Python provides a built-in method for set type objects, `isdisjoint()`

, to check two or more sets are **disjoint**.

```
>>> s1, s2 = {"Apples", "Berries"}, {"Grapes", "Peach"}
>>> s1.isdisjoint(s2) # Check if sets are disjoint
True
>>> s1 & s2 # Intersection should be empty
set()
```

No operator corresponds to the built-in set method `isdisjoint()`

.

What's the output of the code below?

```
>>> s1, s2 = set([1, 2, 3]), set([2,3, 4])
>>> s3 = s1 - s2
>>> s3.isdisjoint(s1)
```

`True`

`False`

#### Subset

A set $s1$ is considered asubsetof another set $s2$ if every object of $s1$ is a member of $s2$.

In Python, you can check if a set $s1$ is a subset of another set $s2$, using either the built-in set method `s1.issubset(s2)`

or using the operation $s1 <= s2$.

```
>>> s1 , s2 = {1, 2, 3}, {4, 1, 2, 3, 5}
>>> s1 <= s2 # Checking subset using operator
True
>>> s1.issubset(s2) # Checing subset using method
True
```

A set $s1$ can be considered a subset of itself because it contains every member of itself. We can check this in Python as well.

```
>>> s1 = {1, 2, 3}
>>> s1 <= s1 # s1 can be subset of itself
True
```

Aproper subsetof a set $s1$ is defined as a subset that is not identical to the set $s1$.

So, a set $s1$ is considered a proper subset of $s2$ if every element of $s1$ is a member of $s2$, and $s1$ and $s2$ are not equal. We can check if a set is a proper subset of another using the operator `<`

.

```
>>> s1, s2 = {1, 2, 3}, {1, 2, 3, 4}
>>> s1 < s2
True
```

What's the output of the code below?

```
>>> s1 = {1, 2, 3}
>>> s1 < s1
```

`True`

`False`

A set cannot be a **proper subset** of itself. Next, we will take a look at a superset.

#### Superset

A set $s1$ is considered asupersetof another set $s2$ if $s1$ contains every element of $s2$.

A superset is the reverse of a **subset**. In Python, we can check if a set $s1$ is a superset of a set $s2$ using the operator `>=`

or the built-in set object method `s1.superset(s2)`

. A set is also considered a superset of itself.

```
>>> s1, s2 = {1, 2, 3, 4}, {1, 2, 3}
>>> s1 >= s2 # Checking superset using operator
True
>>> s1.superset(s2) # Checking superset using method
True
>>> s1 >= s1 # s1 can be a superset of itself
True
```

A **proper superset** is the same as a superset except that the two sets cannot be identical. In Python, we can check if a set is a superset of another set using the operator >.

```
>>> s1, s2 = {1, 2, 3, 4}, {1, 2, 3}
>>> s1 > s2 # Checking proper superset
True
>>> s1 > s1 # s1 can not be a superset of itself
False
```

What's the output of the code below?

```
>>> s1 = {1, 2, 3}
>>> s1 > s1
```

`True`

`False`

Once again, a set cannot be a **proper superset** of itself.

Next, we will look at the ways how to modify a set.

### Modifying Sets

Even though sets only contain immutable objects, sets themselves can be modified to include or remove items or members. We can use the operations we listed in the earlier sections to update an existing set using **augmented assignment operators**. Table 12 shows set update methods.

Operation Name | Method | Augmented Operator |
---|---|---|

Union Update | `s1.update(s2)` |
`|=` |

Intersection Update | `s1.intersection_update(s2)` |
`&=` |

Difference Update | `s1.difference_update(s2)` |
`-=` |

Symmetric Difference Update | `s1.symmetric_difference(s2)` |
`^=` |

The augmented operator, as you might recall, is the short-hand form of the expanded form. This is shown in the table 13.

Augmented Operation | Expanded Operation |
---|---|

`s1 |= s2 | s3` |
`s1 = s1 | s2 | s3` |

`s1 &= s2 & s3` |
`s1 = s1 & s2 & s3` |

`s1 -= s2 - s3` |
`s1 = s1 - s2 - s3` |

`s1 ^= s2 ^ s3` |
`s1 = s1 ^ s2 ^ s3` |

Let's start with the ** Union Update Method**.

#### Union Update Method

Let's say you wish to update a set $s1$ with its union with other sets $s2$ and $s3$. We can do that using the assignment operator.

```
>>> s1, s2 = {1, 2} ,{3, 4, 5, 6}
>>> s1 = s1 | s2 # Update s1 to union of sets of s1 and s2
>>> s1
{1, 2, 3, 4, 5, 6}
```

Python provides an augmented operator `|=`

to make this assignment.

```
>>> s1, s2 = {1, 2} ,{3, 4, 5, 6}
>>> s1 |= s2 # Update s1 using augment assignment
>>> s1
{1, 2, 3, 4, 5, 6}
```

Python also provides a built-in set object method, `update()`

to update a set to a set's union.

```
>>> s1, s2 = {1, 2} ,{3, 4, 5, 6}
>>> s1.update(s2) # Update s1 using method
>>> s1
{1, 2, 3, 4, 5, 6}
```

You can include multiple sets, and it will take to update the set to the union of all provided sets.

```
>>> s1, s2, s3, s4 = {1}, {2}, {3, 4}, {5, 6}
>>> s1.update(s2, s3, s4) # Same as s1 |= s2 | s3 | s4
>>> s1
{1, 2, 3, 4, 5, 6}
```

What's the output of the below code?

```
>>> s1, s2, s3 = set("aligned"), set("dealing"), set("leading")
>>> s1 |= s2 | s3
>>> s1 == s2, s1 == s3
```

`(True, True)`

`(False, True)`

`(True, False)`

`(False, False)`

In the previous exercise, the three words `aligned, dealing, leading`

are **anagrams** of each other. Therefore, even after updating the set $s1$ with union with $s2$ and $s3$, it leads to no change in the set.

Next, let's check out the **intersection update method**.

#### Intersection Update Method

A set $s1$ can be updated with intersections with other sets using augmented assignment `&=`

or built-in set object method `intersection_update()`

. The intersection update `s1.intersection_update(s2)`

or `s1 &= s2`

updates $s1$, resulting in $s1$ having objects found in both $s1$ and $s2$. Let's take an example.

```
>>> s1, s2 = {1, 2, 3}, {2, 3, 4}
>>> s1.intersection_update(s2) # Same as s1 &= s2
>>> s1
{2, 3}
```

What's the output of the following code?

```
>>> s1, s2, s3 = set("aligned"), set("dealing"), set("leading")
>>> s1 &= s2 & s3
>>> s1 == s2, s1 == s3
```

`(True, True)`

`(False, True)`

`(True, False)`

`(False, False)`

The previous exercise' answer shouldn't be surprising if you recall that the three words are anagrams.

Next, we will take a look at the **difference update method**.

#### Difference Update Method

A set $s1$ can be updated with the differences with other sets using augmented assignment operator `-=`

or built-in set object method `difference_update()`

. The difference update `s1.difference_update(s2)`

or `s1 -= s2`

results in removing objects from $s1$ which are found in $s2$ as well.

```
>>> s1, s2 = {1, 2, 3}, {2, 3, 4}
>>> s1.difference_update(s2) # Same as s1 -= s2
>>> s1
{1}
```

What's the output of the code below?

```
>>> s1, s2, s3 = set("aligned"), set("dealing"), set("leading")
>>> s1.difference_update(s2, s3)
>>> s1 == s2, s1 == s3, s2 == s3
```

`(True, True, True)`

`(False, True, True)`

`(True, False, True)`

`(False, False, True)`

Once again, as the three words are anagrams, their difference update results in an empty set.

Next, we will look at the **symmetric difference update** method.

#### Symmetric Difference Update Method

A set $s1$ can be updated with **symmetric differences** with other sets using augment assignment operator `^=`

or built-in set object method `symmetric_difference()`

.

The symmetric difference update `s1.symmetric_difference(s2)`

or `s1 ^= s2`

results in a set `s1`

updated to a set with objects either in `s1`

or `s2`

but not both.

```
>>> s1, s2 = {1, 2, 3}, {2, 3, 4}
>>> s1.symmetric_difference(s2) # Same as s1 ^= s2
>>> s1
{1, 4}
```

What's the output of the code below?

```
>>> s1, s2, s3 = {1, 2, 3}, {2, 3, 4}, {3, 4, 5}
>>> s1 ^= s2 ^ s3
>>> s1
```

`set()`

`{2}`

`{3}`

`{4}`

As the `3`

is the only element common in all three sets, the set `s1`

gets updated to `{3}`

. Apart from the above methods, the set objects have additional methods. Let's take a look at them.

### Additional Methods for Set

Table 14 below lists some additional methods available on a *set* object.

Method | Description |
---|---|

`s.add(<obj>)` |
Adds an immutable to a set |

`s.remove(<obj>)` |
Remove the object from the set. Raises `KeyError` if element not found in the list |

`s.discard(<obj>)` |
Same as `remove` method but no Exception is raised when object not found |

`s.pop()` |
Randomly returns an object from a set and removes it |

`s.clear()` |
Removes all objects from the set |

#### Adding Items to a set

The method s.add() adds an immutable object to a set.

```
>>> s = {1, 2, 3, 4}
>>> s.add(5)
>>> s
{1, 2, 3, 4, 5}
```

#### Removing Items from a set

The `s.remove(obj)`

method removes the object from the set and raises `KeyError`

if the object is not present inside the set.

```
>>> s = {1, 2, 3, 4, 5, 6}
>>> s.remove(2) # {1, 3, 4, 5, 6}
>>> s.remove(56) # Object missing, will raise Keyerror
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 56
```

The `s.discard(obj)`

method works the same as `s.remove(obj)`

, but it doesn't raise any `KeyError`

when the object is not found in the set.

```
>>> s.discard(56) # Object missing, will not raise error
```

Like *list* objects, Set objects also have a `pop()`

method, which retrieves an object from the set while simultaneously removing it from the set.

```
>>> s.pop() # {2, 4, 5}
1 # Might be different for you.
```

#### Removing all elements from a set

Python provides the `s.clear()`

method for setting objects to remove all the elements in a set.

```
>>> s.clear()
>>> s
set()
```

What's the most likely final value of the set `s`

?

```
>>> s = set("zebra")
>>> s.discard("z")
>>> s.pop()
'e'
>>> s.add("t")
>>> s.add("r"); s.clear();
>>> s
```

`set()`

`{'b', 'a', 'r', 't'}`

`{'b', 'a', 'r', "e"}`

`{'b', 'a', 'r'}`

#### Frozen Sets

The sets in Python are mutable objects. Python also provides another built-in type called **frozenset**, which is pretty much similar to **set** object except a **frozenset** is immutable. We can define a frozenset using the `frozenset(<iter>)`

constructor.

```
>>> f = frozenset([1, 2, 3, 4])
>>> f
frozenset({1, 2, 3, 4})
```

All non-mutating operations for a set are also applicable to a **frozenset**.

```
>>> f.add(3)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'frozenset' object has no attribute 'add'
>>> f.remove(2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'frozenset' object has no attribute 'remove'
```

The set object methods `s.add()`

, `s.remove(<obj>)`

, `s.discard(<obj>)`

, `s.pop()`

and `s.clear()`

are not present for *frozenset* objects.

Also the update methods of set such `s.update()`

, `s.intersection_update()`

, `s.difference_update()`

and `s.symmetric_update()`

are not available for frozenset objects as frozensets are immutable.

```
>>> f1, f2 = frozenset("hello"), frozenset("world")
>>> f1.update(f2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'frozenset' object has no attribute 'update'
```

What's the output of the following?

```
>>> s1, s2 = frozenset("hello"), frozenset("world")
>>> s1 |= s2
>>> s1
```

`frozenset({'e', 'o', 'h', 'l', 'w', 'r', 'd'})`

`frozenset({'e', 'o', 'h', 'l', 'w', 'r', 'd'})`

`set({'e', 'o', 'h', 'l', 'w'})`

`frozenset({'e', 'o', 'h', 'l', 'w'})`

`s.update()`

, `s.intersection_update()`

etc) are not available on `frozenset`

. Although the corresponding augmented operators work fine. Can you guess why this so?
The augmented update operators return a new object and reassign the set name to the newly returned set objects. Let me elaborate.

Let's take a look at what happens when we use the augmented update operator on frozen sets.

```
>>> f1 = f2 = frozenset([1, 2, 3]) # Two names for the same object
>>> f3 = frozenset([2, 4, 5])
>>> f2 |= f3 # Union Update
>>> f2
frozenset({1, 2, 3, 4, 5}) # f2 gets reassigned
>>> f1
frozenset({1, 2, 3}) # Original object is intact
```

In the above code sample, the object referenced by `f2`

didn't get updated or modified; rather, the name `f2`

got reassigned to the *union of the sets* `f2`

and `f3`

.

As sets are mutable, they cannot be used as keys for a dictionary. Frozensets, being immutable, can be used as keys for a dictionary.

It isimpossible to create sets of setsas sets are themselves mutable.

In this case, **frozensets** can be used to create set objects.

For instance,

```
>>> f1, f2, f3 = frozenset([1, 2]), frozenset([4]), frozenset([3])
>>> s = {f1, f2, f3}
>>> s
{frozenset({3}), frozenset({4}), frozenset({1, 2})}
```

### Comparisons

Both set and frozenset support set to set comparisons. We can compare sets in the following way:

- Two sets are equal if every element of each set is contained in the other, which means each is a subset of the other.
- A set is less than another set if and only if the first set is a proper subset of the second set.
- A set is greater than another set if and only if the first set is a proper superset of the second set.

What's the output of the following code?

```
>>> s1, s2, s3 = set([1, 2, 3]), set([1, 2]), set([1])
>>> s = [s1, s2, s3]
>>> sorted(s)
```

`[{1}, {1, 2}, {1, 2, 3}]`

`[{1, 2, 3}, {1, 2}, {1}]`

`[{1, 2, 3}, {1}, {1, 2}]`

`[{1, 2}, {1}, {1, 2, 3}]`

With this, we have come to the end of sets in Python. In the next section, we will cover dictionaries.

## Dictionary

In this section, we will take a look at the **dictionary** object.

Dictionaries are used for **storing objects against custom keys**.

Adictionaryis astandard mapping objectin Python used for storingvaluesagainstkeys.

Python provides one standard mapping object, the **dictionary**. We can create a dictionary in several ways:

Comma-separated list of `key: value`

pairs within braces

```
>>> d1 = {'a': 1, 'b' : 2, 'c' : 3} # Using curly braces
```

Using `dict()`

constructor

The built-in `dict()`

constructor take one of the following form for arguments:

- Keyword arguments,
`a = 1`

,`b = 2`

```
>>> d2 = dict(a = 1, b = 2, c = 3) # Using keyword arguments
```

2. Mapping object, `{'a':1, 'b':1}`

```
>>> d3 = dict({ 'a': 1, 'b' : 2, 'c' : 3 }) # Using Mapping
```

3. Iterable object with each item also being iterable with length two, `[(a, 1), (b,2)]`

```
>>> d4 = dict([('a', 1), ('b', 2), ('c', 3)]) # Using Iterable
```

We can verify that all four dictionary objects created above have the same value.

```
>>> d1
{'a': 1, 'b': 2, 'c': 3}
>>> d1 == d2 == d3 == d4
True
```

Creating a dictionary using a `dict()`

constructor with keyword arguments requires that the keys be valid python identifiers.

```
>>> x = dict(for=1) # Will raise error as `for` is a reserved name
File "<stdin>", line 1
x = dict(for=1)
^
SyntaxError: invalid syntax
>>> x = {"for" : 1} # No error
```

This limitation is not imposed on other methods of creating a dictionary.

```
>>> x = {"for" : 1} # No error
```

What's the result of the below code?

```
>>> d = dict([(1, Harry), (2, Luffy), (3, Ted)])
```

- Raises
`NameError`

`{1: 'Harry', 2: 'Luffy', 3: 'Ted'}`

`{3: 'Harry', 2: 'Luffy', 1: 'Ted'}`

`{1: 'Harry', 3: 'Luffy', 2: 'Ted'}`

For the previous exercise, keep in mind that the string characters must be wrapped in quotes.

We have now learned how to create dictionary objects. Let's now look at how access values in a dictionary object.

#### Accessing Dictionary Values

Let's create a dictionary object with some countries along with their capitals.

```
>>> capitals = {
"Afganistan" : "Kabul",
"Bhutan" : "Thimpu",
"Canada" : "Ottawa",
"Denmark" : "Copenhagen",
"Estonia": "Tallinn"
}
```

We can verify that this is indeed a dictionary.

```
>>> capitals
{'Afganistan': 'Kabul', 'Bhutan': 'Thimpu', 'Canada': 'Ottawa', 'Denmark': 'Copenhagen', 'Estonia': 'Tallinn'}
>>> type(capitals)
<class 'dict'>
```

We can retrieve objects stored in sequences such as **lists** and **tuples** using the index position.

In contrast, we retrieve objects stored in dictionaries by the **key** against which the object is stored. We have stored the *capitals* against the *country names*. Therefore, the capital are **values** while the country names are **keys** against which values are stored.

We will use the *country name* as the key in a square bracket notation to access individual countries' capitals.

```
>>> capitals["Bhutan"] # Get the value associated with Bhutan
'Thimpu'
```

Note that we mentioned earlier that sequences contain ordered elements, while elements in sets and dictionaries are unordered. From Python 3.7, Python guarantees the dictionaries item to be stored in the insertion order.

What's the value of the code below?

```
>>> capitals
{'Afganistan': 'Kabul', 'Bhutan': 'Thimpu', 'Canada': 'Ottawa', 'Denmark': 'Copenhagen', 'Estonia': 'Tallinn'}
>>> capitals[0]
```

`Kabul`

`Thimpu`

`Afganistan`

- Raises
`KeyError`

In the above exercise, since there are no keys with the value `0`

, Python raises `KeyError`

.

In the code listing in the previous exercise, the keys are stored as strings. We can use other immutable objects such as *tuples*, *frozen sets*, or *numbers*, etc. in Python, to be used as a dictionary key to store objects in a dictionary. The keys of a dictionary need not be of the same type.

```
>>> a, b = (0, 1) , frozenset([0, 1])
>>> c = {
1: "Number",
a: "Tuple",
b: "Frozen Set"
}
>>> c[1] # Key type : Number
'Number'
>>> c[a] # Key type : Tuple
'Tuple'
>>> c[b] # Key type : Frozenset
'Frozen Set'
```

We can store tuples and frozen sets as dictionary keys.

What's the output of the code below?

```
>>> a = (1, 2, 0)
>>> d = {0: "Zero", 1: "One", 2: "Two", a: "Tuple"}
>>> d[a[0]]
```

`'Zero'`

`'One'`

- Raises
`KeyError`

`'Tuple'`

The key `a[0]`

first evaluates to $1$, then the expression becomes `d[1]`

, which is `One`

. We can also add new `key: value`

pairs or modify existing values in a dictionary. Let's take a look at how.

#### Adding and updating dictionary values

Earlier, we created a dictionary object named `capitals`

. We can add more `key: value`

pairs to the already created object by using the assignment operator.

```
>>> capitals["Fiji"] = "Suva"
>>> capitals["Germany"] = "Berlin"
>>> capitals # Dictionary object updated
{'Afganistan': 'Kabul', 'Bhutan': 'Thimpu', 'Canada': 'Ottawa', 'Denmark': 'Copenhagen', 'Estonia': 'Tallinn', 'Fiji': 'Suva', 'Germany': 'Berlin'}
```

You can also create an **empty dictionary** and add values to it afterward. You can add dictionaries or lists or tuples as objects too.

```
>>> person = {}
>>> person["name"] = "John Doe"
>>> person["friends"] = ["Eena", "Meena", "Deeka"]
>>> person["food"] = {"Beverage" : "Lemonade", "Indian" : "Dosa"}
>>> person
{'name': 'John Doe', 'friends': ['Eena', 'Meena', 'Deeka'], 'food': {'Beverage': 'Lemonade', 'Indian': 'Dosa'}}
```

To access a **sublist** or **subdictionary**, an additional index or key is required.

```
>>> person["food"]["Indian"]
'Dosa'
>>> person["friends"][2]
'Deeka'
```

If you try to assign to a key that is already present in the dictionary keys, Python overwrites the previous value stored.

```
>>> person = { "name": "Jane Doe"}
>>> person
{'name': 'Jane Doe'}
>>> person["name"] = "James Doe" # key:value pair is updated
>>> person
{'name': 'James Doe'}
```

*There cannot be two identical keys in a dictionary.*

What's the output of the code below?

```
>>> person = {'name': 'Luffy' }
>>> person["age"] = 16
>>> person = {'ship': 'Thousand Sunny'}
>>> person
```

`{'ship': 'Thousand Sunny'}`

`{'name': 'Luffy'}`

`{'name': 'Luffy', 'age': 16}`

`{'age': 16}`

Earlier, we saw that immutable objects could be keys to a dictionary. The limitation imposed by Python is that only those **hashable** can be used as dictionary keys.

An object ishashableif it has ahashvalue, which never changes during its lifetime and can be compared to other objects.

To check the hash value of an object, we can use Python's built-in `hash()`

function. The `hash()`

function returns the hash-value of a hashable object and raises `TypeError`

when an object is *unhashable*.

For instance, for an immutable tuple:

```
>>> a = (1, 2)
>>> hash(a)
3713081631934410656 # Different for you
```

While for instance for the mutable list,

```
>>> a = [] # Mutable Container
>>> hash(a)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
```

This is the same `TypeError`

when you try to use a list as a dictionary key.

```
>>> person, a = {}, []
>>> person[a] = "Something"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
```

All the built-in immutable objects we have covered so far are *hashable*. **Immutable containers such as tuples and frozensets are hashable, only if their elements are hashable**.

So, for now, you can assume that immutable objects can be used as dictionary keys while mutable objects cannot.

Unlike the limitation of using only hashable and non-identical objects for keys, no such restrictions are imposed on the values stored in a dictionary. **You can put any objects as the value inside a dictionary corresponding to a key.**

In the below code, what's the value of __A__ for which Python prints Nami?

```
>>> a, b, c, d = [0, 1, 2, 3]
>>> d = {a: "Luffy", b: "Zorro", c: "Sanji", d: "Nami"}
>>> __A__ # What's the expression?
'Nami'
```

`d[a]`

`d[d]`

`d[3]`

`d[2]`

### Built-in Methods of Dictionary

Similar to sequences, dictionary objects also contain some built-in methods which are applicable only for dictionary objects. Let's take a look at them in detail.

### Retrieving Dictionary Items

Earlier, we saw that we could **access** or **retrieve** a dictionary item using the key of the value stored in square brackets.

```
>>> person = {"name" : "John Doe", "age" : 26}
>>> person["name"]
'John Doe'
```

If we use any key for which no value has been stored in the dictionary, Python raises an error.

```
>>> person['weight']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'weight'
```

The dictionary objects also expose a method `d.get(<key>, <default>)`

, which retrieves the item us and fails silently if the key doesn't exist in the dictionary.

```
>>> person.get("name") # Retrieving item using get method
'John Doe'
>>> person.get("weight") # Fails silently
>>>
```

The built-in method `d.get(k, d)`

accepts an optional argument that can return a default value if no key is found.

```
>>> person.get("weight", "No record of weight available for the person.")
'No record of weight available for the person.'
```

What's the output of the below code?

```
>>> pirate = dict(name="Luffy", age="15")
>>> pirate["Ship"] = "Going Merry"
>>> pirate.get("ship", "Sorry")
```

`'Sorry'`

`'Going Merry'`

`Raises KeyError`

Python has additional built-in methods to retrieves the entire list of keys, values, or both of a given dictionary. Table 15 below shows additional methods for retrieving objects from a dictionary.

Method | Description |
---|---|

`d.keys()` |
Returns a dict-view of dictionary's keys |

`d.values()` |
Returns a dict-view of dictionary's values |

`d.items()` |
Returns a dict-view of dictionary's items (`key` , `value` ) pairs |

Let's create a dictionary to see how the above methods work.

```
>>> person = {
"name" : "John",
"age" : 26,
}
```

The `d.keys()`

returns all the dictionary keys, while the `d.values()`

return all the dictionary values. The `d.items()`

method returns all key-value tuple.

```
# Get all the keys of the dictionay
>>> person.keys()
dict_keys(['name', 'age'])
# Get all items in list of tuples
>>> person.items()
dict_items([('name', 'John'), ('age', 26)])
# Get all the values of the dictionary
>>> person.values()
dict_values(['John', 26])
```

The objects returned by `dict.key()`

, `dict.values()`

and `dict.items()`

are **view objects**. They provide a dynamic view of the dictionary objects, which means any changes to the source dictionary result in changes to the view objects.

What's the output of the following code?

```
>>> person = {
"name" : "John",
"age" : 26,
}
>>> a = person.keys() # Name the view object `a`
>>> a
dict_keys(['name', 'age'])
>>> person["weight"] = 125 # Dictionary modified
>>> a # Check the view again
```

`dict_values(['John', 26])`

`dict_keys(['name', 'age'])`

`dict_keys(['name', 'age', 'weight'])`

`dict_values(['John', 26, 125])`

As I mentioned earlier, the view objects are dynamic and provide the most updated view of the dictionary objects. However, often we want to work with common containers such as `lists`

and `tuples`

instead of **view objects.**

We can convert dictionary view objects to list, tuples, or sets using their corresponding constructors. Let's continue with the `person`

dictionary from the last exercise.

```
>>> list(person.items())
[('name', 'John'), ('age', 26), ('weight', 125)]
>>> set(person.keys())
('name', 'age', 'weight')
>>> tuple(person.values())
('John', 26, 125)
```

As you can notice, the view objects get converted to the corresponding containers.

The dictionary view object also supports membership operation. You can use the built-in `len()`

function to get the length.

```
>>> len(person.items())
3
>>> 'age' in person.keys()
True
>>> 126 not in person.values()
True
>>> ('age', 26) in person.items()
True
```

We can also use the membership and length operation directly on dictionaries. At the same time, while checking membership on dictionaries, python checks if the given object exists in the dictionary keys.

```
>>> len(person)
3
>>> "age" in person # 'age' exists in d.keys()
True
>>> 26 in person # 26 is present in values not keys
False
```

What's the output of the below code?

```
>>> scores = {
"A": 26,
"B": 21,
"C": 23
}
>>> "B" in scores , 26 in scores.values(), (23, "C") in scores.items()
```

`(True, True, False)`

`(True, False, True)`

`(False, True, False)`

`(True, True, True)`

The `d.items()`

returns items in the form of `(key, value)`

tuple. Next, let's look into how we can modify the dictionary items.

#### Modify and Remove Dictionary Items

Earlier, we reassigned dictionary items using curly square brackets. For example

```
>>> person = {"age" : 26} # Create a dictionary
>>> person["age"] = 36 # Assign the `age` key to 36 instead
>>> person # Dictionary modified
{'age': 36}
```

A dictionary object can also be updated using its built-in object method `d.update()`

. The `update()`

method accepts

- a keyword argument
- iterable of key/value pairs of tuples or other iterable of length two

Let's check out how a dictionary can be updated using an iterable consisting of key/value pairs. First, let's recreate the `person`

dictionary.

```
>>> person = {"name" : "John", "age" : 36, "weight": 125}
>>> person
{'name': 'John', 'age': 36, 'weight': 125}
```

Let's store the new key/value pairs.

```
>>> new_values = [('name', "James"), ('age', 28), ('weight', 136), ('city', 'Berlin')]
```

Now, let's modify the `person`

dictionary items using `d.update()`

method.

```
>>> person.update(new_values)
>>> person
{'name': 'James', 'age': 28, 'weight': 136, 'city': 'Berlin'} # Dictionary updated
```

The below code listing shows a dictionary can be updated using keyword arguments.

```
>>> person = {"name" : "John", "age" : 36, "weight": 125}
>>> person.update(name="John", age=28, weight=136, city="Berlin")
>>> person
{'name': 'John', 'age': 28, 'weight': 136, 'city': 'Berlin'} # Dict updated
```

The `d.update()`

method is useful to update as many items of a dictionary simultaneously.

Like previous containers, the items in a dictionary can be removed using the `del`

keyword and `clear()`

method. Let's take a look.

To remove a single item, we can use the `del`

keyword. For instance,

```
>>> person
{'name': 'John', 'age': 28, 'weight': 136, 'city': 'Berlin'}
>>> del person["city"] # Remove the key `city`
>>> person # `city` object removed
{'name': 'John', 'age': 28, 'weight': 136}
```

To remove every item stored in a dictionary, Python provides a built-in method, `d.clear()`

.

```
>>> person
{'name': 'John', 'age': 28, 'weight': 136}
>>> person.clear() # Remove all items stored
>>> person
{}
```

One obvious way is to assign the **new key** to the value stored in the **old key** in the dictionary and then remove the old key. However, there is a much better way which we will look at next.

#### Retreive and Modify Dictionary

Similar to sequences, dictionaries in Python also have the `d.pop(<key>, <default>)`

method, which retrieves while removing the item at the same time.

```
>>> d = {1: 'Apple', 2: 'Ball', 3: 'Carrots' }
>>> d.pop(2)
'Ball'
>>> d
{1: 'Apple', 3: 'Carrots'}
```

If a key is not found in the dictionary, then the method `pop()`

raises `KeyError`

.

```
>>> d.pop(4)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 4
```

The `pop()`

method accepts a default argument which can be returned instead of `Exception`

when a key is not found in the dictionary.

```
>>> d.pop(5, "Key is not present.")
'Key is not present.'
```

Like the method `pop()`

, dictionary objects also have a method `d.popitem()`

, which returns and removes an arbitrarily selected item from the dictionary.

```
>>> d = {1:"Apple", 2: "Ball", 3:"Carrots", 4:"Dogs"}
>>> d.popitem()
(4, 'Dogs')
>>> d
{1: 'Apple', 2: 'Ball', 3: 'Carrots'}
```

If the dictionary is empty, the `popitem()`

method raises `KeyError`

.

`d.pop`

and `d.popitem()`

, can you think of a better way to rename a key in the Python dictionary.
We can rename a key by using : `d["new_key"] = d.pop("old_key")`

. The `pop()`

method returns the value while deleting the item at the same time.

So far, we covered the different built-in types of collections objects, such as - **sequences**, **mappings**, and **set** objects. The built-in **collections module** also provides several collections objects which can be used as container objects. Let's take a look.

#### Specialized Containers

Some of the specialized container type presents are as follows:

**Namedtuple**is useful for creating tuple subclasses with named fields**Deque**is a list-like container that can pop on either end**ChainMap**is a dictionary-like object for creating a single view of multiple mappings**Counter**is used for counting hashable objects**Defaultdict**is like a dictionary, but unspecified keys have a user-specified default value

You can read more about their usage in the official python documentation of the collections module.

Next, let's look into another category of built-in types: **Callables**.

## Callables

A **callable** is anything you can **call using parenthesis** and optionally passing arguments.

**Functions** and **Methods** are some of the callables that we have seen.

Callable types are objects that support the function call operation. The list of callable type in Python is as follows:

**User-defined functions****Built-in methods****Built-in functions****Generators****Class Instances****Classes****Instance Methods**

In the previous chapter, we covered functions and methods in general. In this course, we won't be covering details about **classes** and instances.

We will learn about Generators in detail in Chapter 6.

Apart from these types, Python has several other types that are used for internal purposes. We would not be covering those.

In the next chapter, we will dive deep into the **Program Structure and Control Flow.**