Figure 1: Standard Type Hierarchy for Python
In earlier chapters, we have encountered different types of Python objects. Can you name some of the types that we have already encountered?

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.

Table 1: Singleton types in Python
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.

We have already covered a use case where Python uses the None object to denote the absence of any value. Can you recall which use case?

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")
  1. Hello World
  2. raises NameError
  3. raises ValueError
  4. 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 iindicate 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()
In the above code, we use the Ellipsis literal (...) to denote incomplete function definition. Do you think we can use a string literal instead of Ellipsis?

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.

We have already covered some of the numeric literals used to create numbers. Can you recall which ones?

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
The 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.

Table 2: Base Prefix
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.

What do you think will be the output of the expression 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?

  1. 9
  2. 10
  3. 8
  4. 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.

Can you think of another instance in your day-to-day interaction with a computer where caching is done for easier access later on?

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.

What is the implication for boolean being 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.

What are some ways we can construct Boolean objects?

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

Can you recall some of the non-integral types of numbers mentioned earlier?

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)
  1. Error
  2. 4
  3. 3.5
  4. 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.

Is there any representation that can accurately represent the $4/3$ in the decimal notation?

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 error refers 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 IEEE-754. Almost all machines use the 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.

To find the value of $N$, we need to find an integer that can be represented using exactly 53-bits. How do you think we can figure out how many bits does an integer has?

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 256.

>>> 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$.

So, when we input 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.

Can you think of such fields that require highly 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.

As you can see, the Decimal numeric type is pretty handy. But typing out Decimal every time seems a bit tedious. Can you suggest a method to avoid typing Decimal every time we need to use it?

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.

So far, I hope you got a basic idea between 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.

Table 3: Methods for Decimal objects
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()
  1. 3
  2. 2
  3. 10
  4. 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. 1
  2. Fraction(11, 10)
  3. Fraction(1, 1)
  4. 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'))
  1. Fraction(3, 2)
  2. Fraction(1, 1)
  3. Fraction(1.5)
  4. 1.5

Complex Numbers

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

Can you recall what complex numbers are?

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'>
The complex conjugate of a complex number is the number with an identical real part and an imaginary part equal in magnitude but opposite in sign. For example, if a and b is 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))
  1. <class 'float'>
  2. <class 'int'>
  3. <class 'complex'>
  4. <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.

Can you recall some of the 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.

Table 4: Container Types in Python
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.

What do you think sequentially ordered objects mean?

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. 1
  2. 2
  3. 3
  4. 4
Suppose we have a list :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]
  1. Loud Muiic
  2. Loud Music
  3. Loud Musii
  4. 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'
What do you think are some of the advantages of immutable objects against mutable objects?

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.

Table 5: Common operations in sequences
Operation Syntax Description
Indexing s[i] Returns item stored in ith 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.

Suppose you have to check if an item is present in a sequence. Which of the operation from table 5 would you use?

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
Often, we wish to know the position of an element in a sequence. Which of the operation from table 5 can you use to get the index of the element?

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
Can you figure out how to get the length of the sequence in Python.

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
Given that you know how to get the length of a sequence, how can you find the index of the last element.

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 often need to add two or more sequences to create new sequences. Which operation(s) from the table 5 can be used to do this?

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
  1. [[100, 2, 3], [100, 2, 3], [100, 2, 3], [100, 2, 3]]
  2. [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]]
  3. [[4, 8, 12], [4, 8, 12], [4, 8, 12], [4, 8, 12]]
  4. 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.

Figure 2: Sequence Slicing Operation

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]
  1. [0, 2, 4, 6, 8]
  2. [1, 3, 5, 7, 9]
  3. [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
  4. [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]
  1. [5, 4, 3, 2, 1, 0]
  2. [4, 3, 2, 1, 0]
  3. [6, 5, 4, 3, 2, 1, 0]
  4. []

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")
  1. 0
  2. 1
  3. 2
  4. 3

Comparison

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

Can you rephrase what lexicographically means?

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)
  1. True, True, True
  2. True, False, True
  3. True, False, False
  4. 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. (1, 2, 3)
  2. (1,)
  3. (1, 2, 4)
  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"))
  1. <class 'list'>
  2. <class 'str'>
  3. <class 'tuple'>
  4. <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)
  1. True
  2. 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'
In the last example, the error message shows 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.

We have already encountered unpacking earlier. Why don't you take a guess what unpacking of sequences mean?

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.

Can you recall the key difference between a 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.

Table 6: Operations on a List object
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
Can you tell which operation can be used to replace the elements of a given list?

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
  1. [3, 6, 9, 11, 14, 17, 21]
  2. [3, 6, 9, 12, 15, 18, 21]
  3. [3, 6, 9, 11, 15, 18, 17, 21]
  4. [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]
We can also assign content to an extended slice of a list. Do you recall what an extended slice is?
Slices with a step argument other than the default step 1 are called extended 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. [1, 2, 3, 4, 9]
  2. [1, 2, 3, 4]
  3. [1, 2, 4, 3, 4]
  4. [2, 4, 3, 9]
What if we wish to delete multiple items from a list? How do you think we can do that?

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. [1, 5, 3, 5]
  2. [1, 2, 4]
  3. [1, 2, 3]
  4. [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))
  1. Raises TypeError
  2. [1, 2, 3, 1, 2]
  3. [1, 1, 2]
  4. [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
  1. (True, True, [1, 2, 3, 4])
  2. (False, True, [1, 2, 3, 4])
  3. (False, True, [1, 2])
  4. (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.

Can you recall what magic methods are?

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.

How can we insert elements at a particular position inside 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. [1, 2, 3, 4, 5, 6]
  2. [1, 2, 4, 5, 6]
  3. [4, 5, 6]
  4. [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)
  1. Raises ValueError
  2. [2, 1, 2, 1, 3]
  3. [2, 1, 2, 1, 3, 6]
  4. [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. [[], [1, 2, 3]]
  2. [[], [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
  1. True
  2. 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)
  1. [(3, 2, 1), (3, 2), (1, 2, 3), (1, 2)]
  2. [(1, 2), (1, 2, 3), (3, 2), (3, 2, 1)]
  3. [(1, 2), (1, 2, 3), (3, 2, 1), (3, 2)]
  4. [(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
Why do you think Python reuses the same empty tuple object throughout the program?

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

Before we start, can you define string sequences?

In chapter 1, we learned about Unicode characters.

Strings are immutable sequences of Unicode code points or characters.

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]
  1. True
  2. 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.

Table 7: Standard operations in String objects
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.

Table 8: Case modifiers in Strings
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'
What is the difference between the 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.

Table 9: Count and Search 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")
  1. 0
  2. 1
  3. 2
  4. 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.

Can you recall what boolean functions are?

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.

Table 10: Substring checking methods
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.

Table 11: Boolean Methods in String Objects
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
  1. (True, True, True, True)
  2. (True, False, False, True)
  3. (True, False, True, False)
  4. (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
  1. ['Hey', 'there', 'Delilah-!']
  2. ['Hey', 'there-Delilah-!']
  3. ['Hey-there-Delilah-!']
  4. ['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("_()*")
  1. ' ()_() Brown (o)_(o) Flower ()_() '
  2. 'Brown (o)_(o) Flower'
  3. Brown 0 0 Flower
  4. ()_() 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")
  1. 'The quick brown fox jumps over the lazy dog'
  2. 'The brown quick fox jumps over the lazy dog'
  3. 'The fox brown quick jumps over the lazy dog'
  4. '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
Like function arguments, the replacement fields or placeholders can be positional or named. Can you guess as to what is a positional placeholder?

Positional Placeholders

When the position of the arguments corresponds to the replacement fields' position, the replacement fields are said to be positional 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.'
The other placeholder is named placeholder. Do you want to guess as to what a named placeholder?

Named Placeholders

Placeholder with keys or names is called named 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")
  1. 'The quick brown fox jumps over the lazy dog'
  2. 'The quick brown over jumps fox the lazy dog'
  3. 'The quick brown fox dog over the lazy jumps'
  4. 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)
  1. 'Value of pi: 3.14159'
  2. 'Value of pi: 3.1415'
  3. 'Value of pi: 3.141'
  4. 'Value of pi: 3.142'

There is another way to format strings, which is much more convenient to use: f-strings.

F-Strings

A formatted string literal or f-string is 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. '
  1. 'The pirate named Luffy is 16 years old. '
  2. 'The pirate named Luffy is {pirate['age']} years old. '
  3. 'The pirate named {pirate['name']} is {pirate['age']} years old. '
  4. 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.

Before we dive deep into sets, can you define a set used in the real world?

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.

A set is a collection of distinct objects. These distinct objects are referred to as members of 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. {1, 2, 3, 4, 5, 6}
  2. {1, 2, 3, 2, 4, 5, 6, 6, 1, 3, 5}
  3. {}
  4. {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 {}

Why doesn't Python allow you to 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. {1, 2, 3, 4, (1, 2, 3)}
  2. {1, 2, 3, 4, [1, 2, 3]}
  3. Raises TypeError
  4. {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)
  1. (True, 8)
  2. (True, 7)
  3. (False, 7)
  4. (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:

  1. Union
  2. Difference
  3. Intersection
  4. Symmetric Difference
  5. Disjoint
  6. Superset
  7. Subset

We will understand these operations along with the description of each operation. Let's start with Union operation.

Union

Figure 3: Set Union

For two sets $s1$, $s2$, a set of all objects that a member of $s1$or $s2$ or both is referred to as union 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"))
  1. 7
  2. 8
  3. 9
  4. 6

Difference

The set difference of $s1$ and $s2$ is the set of all members of $s1$ that are not members of $s2$.
Figure 4: Set Difference

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))
  1. 0
  2. 10
  3. 13
  4. 5

In the previous exercise, both the sentences are pangrams[^5]. Now, let's take a look at intersection operation on sets.

Intersection

Figure 5: Set Intersection
Intersection of 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]))
  1. set()
  2. {3}
  3. {3, 1}
  4. {1, 2, 3}

Symmetric Difference-

The symmetric difference of 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. {1, 2, 3}
  2. {6, 7}
  3. set()
  4. {1, 2, 3}

Disjoint

Two sets s1 and s2, are said to be disjoint sets if they have no common element.

What can you say about the intersection of disjoint sets?

As disjoint sets have no common members, the intersection results in an empty set.

We can also state that two sets are said to disjoint if the intersection results in an empty 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)
  1. True
  2. False

Subset

A set $s1$ is considered a subset of 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
A proper subset of 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
  1. True
  2. 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 a superset of 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
  1. True
  2. 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.

Table 12: 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.

Table 13: Augmented & Expanded Operations in set
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
  1. (True, True)
  2. (False, True)
  3. (True, False)
  4. (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
  1. (True, True)
  2. (False, True)
  3. (True, False)
  4. (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
  1. (True, True, True)
  2. (False, True, True)
  3. (True, False, True)
  4. (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
  1. set()
  2. {2}
  3. {3}
  4. {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.

Table 14: Additional methods 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
  1. set()
  2. {'b', 'a', 'r', 't'}
  3. {'b', 'a', 'r', "e"}
  4. {'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
  1. frozenset({'e', 'o', 'h', 'l', 'w', 'r', 'd'})
  2. frozenset({'e', 'o', 'h', 'l', 'w', 'r', 'd'})
  3. set({'e', 'o', 'h', 'l', 'w'})
  4. frozenset({'e', 'o', 'h', 'l', 'w'})
Updates related methods (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.

Let's recall that we cannot include mutable objects in sets. Given that sets are themselves mutable, how can you create a set of sets?
It is impossible to create sets of sets as 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}, {1, 2}, {1, 2, 3}]
  2. [{1, 2, 3}, {1, 2}, {1}]
  3. [{1, 2, 3}, {1}, {1, 2}]
  4. [{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.

Can you state what dictionaries are used for?

Dictionaries are used for storing objects against custom keys.

A dictionary is a standard mapping object in Python used for storing values against keys.

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:

  1. 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)])
  1. Raises NameError
  2. {1: 'Harry', 2: 'Luffy', 3: 'Ted'}
  3. {3: 'Harry', 2: 'Luffy', 1: 'Ted'}
  4. {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]
  1. Kabul
  2. Thimpu
  3. Afganistan
  4. 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]]
  1. 'Zero'
  2. 'One'
  3. Raises KeyError
  4. '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
  1. {'ship': 'Thousand Sunny'}
  2. {'name': 'Luffy'}
  3. {'name': 'Luffy', 'age': 16}
  4. {'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 is hashable if it has a hash value, 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'
  1. d[a]
  2. d[d]
  3. d[3]
  4. 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")
  1. 'Sorry'
  2. 'Going Merry'
  3. 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.

Table 15: 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
  1. dict_values(['John', 26])
  2. dict_keys(['name', 'age'])
  3. dict_keys(['name', 'age', 'weight'])
  4. 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()
  1. (True, True, False)
  2. (True, False, True)
  3. (False, True, False)
  4. (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.

Next, we will look at how to remove elements of a dictionary. Can you guess how we can remove elements from a dictionary?

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
{}
We can change the value of a key in a Python dictionary. Can you think of a way to rename the key in a Python dictionary?

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.

Now that we have covered 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

Can you take a guess what callables are?

A callable is anything you can call using parenthesis and optionally passing arguments.

What are some callables we have already encountered so far?

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.