Part 3: Python Programming

Site: Learn at Merupu AI
Course: Programming Concepts
Book: Part 3: Python Programming
Printed by: Guest user
Date: Tuesday, 27 October 2026, 7:29 AM

Description

Part 3: Python Programming — read through each chapter in order. Diagrams, tables and worked examples included.

1. Meet Python

Meet Python

Python is a popular, general-purpose programming language created by Guido van Rossum and first released in 1991. It is prized by beginners and professionals alike because its syntax reads almost like plain English. You can use Python to build websites, analyse data, automate boring tasks, control robots, train machine-learning models, and much more. In this chapter you will install Python, learn two ways of running code, and write your very first program.

Why Python?

Three qualities make Python an excellent first language:

  • Readable: you write less punctuation and fewer symbols than in many other languages, so you spend more time thinking about ideas and less about syntax.
  • Batteries included: Python ships with a large standard library of ready-made tools, so common jobs need very little code.
  • Everywhere: it runs on Windows, macOS, and Linux, and a huge community means answers to your questions are easy to find.

Installing Python

Visit python.org and download the latest Python 3 installer for your operating system. On Windows, tick the box that says "Add Python to PATH" before clicking Install. On macOS you can also use the official installer. Once finished, open a terminal (Command Prompt on Windows, Terminal on macOS/Linux) and check the version:

PYTHON
python --version
▸ OUTPUT
Python 3.12.4

If you see a version number starting with 3, you are ready. (On some systems the command is python3 instead of python.)

The REPL versus a script

There are two common ways to run Python. The first is the REPL (Read-Eval-Print Loop), an interactive prompt where you type one line, press Enter, and immediately see the result. Start it by typing python with no file name. You will see a >>> prompt:

PYTHON
>>> 2 + 3
5
>>> print("Hi there")
Hi there

The REPL is perfect for quick experiments. Type exit() to leave it.

The second way is to save your instructions in a script, a plain text file ending in .py. This is how real programs are written, because you can save, edit, and re-run them.

Running a .py file

Create a file called hello.py with one line inside it, then run it from the terminal:

PYTHON
# hello.py
print("Hello, World!")
PYTHON
python hello.py
▸ OUTPUT
Hello, World!

The print() function displays whatever you put between its parentheses. The text in quotation marks is called a string. Printing "Hello, World!" is a tradition for the first program in almost every language.

How a .py file runs

When you type python hello.py, the Python interpreter reads your file line by line, translates it into instructions the computer understands, and runs them. The diagram below shows this journey.

hello.py your code Python interpreter Output Hello, World!

You now have Python installed and have run your first program two different ways. In the next chapters you will learn how to store information, do calculations, and make decisions. Experiment freely: the REPL cannot break your computer, so try typing expressions and see what happens.

2. Variables, Values & Dynamic Typing

Variables, Values & Dynamic Typing

A program is not very useful if it can only work with fixed numbers and text. We need a way to store information and refer to it later. That is what variables are for. A variable is a name that points to a value stored in the computer's memory.

Assignment

You create a variable by writing a name, an equals sign, and a value. This is called assignment:

PYTHON
age = 25
name = "Ada"
price = 9.99
print(age)
print(name)
print(price)
▸ OUTPUT
25
Ada
9.99

The equals sign does not mean "equal" as in mathematics. It means "take the value on the right and store it under the name on the left." You can read age = 25 as "age becomes 25."

Changing a variable

A variable can be reassigned at any time. Each new assignment replaces the old value:

PYTHON
score = 10
score = score + 5
print(score)
▸ OUTPUT
15

Here Python first works out score + 5 (which is 15) and then stores that result back into score.

Naming rules

Variable names must follow a few rules, and some conventions make your code easier to read:

  • Names may contain letters, digits, and underscores, but cannot start with a digit. total2 is fine; 2total is an error.
  • Names are case-sensitive: Age and age are two different variables.
  • You cannot use Python's reserved keywords (such as if, for, class) as names.
  • By convention, use lowercase words joined by underscores, like first_name or total_price. This style is called snake_case.
  • Choose meaningful names. n tells a reader little; number_of_students tells them a lot.

Dynamic typing and the type() function

Every value in Python has a type, which describes what kind of data it is. You can ask Python for the type of any value using the built-in type() function:

PYTHON
print(type(25))
print(type(9.99))
print(type("Ada"))
print(type(True))
print(type(None))
▸ OUTPUT
<class 'int'>
<class 'float'>
<class 'str'>
<class 'bool'>
<class 'NoneType'>

Python is dynamically typed. This means you never have to declare what type a variable will hold; Python figures it out from the value you assign. It also means the same variable can hold different types at different times:

PYTHON
x = 42
print(type(x))
x = "now I am text"
print(type(x))
▸ OUTPUT
<class 'int'>
<class 'str'>

This flexibility is convenient, but be careful: a variable you think holds a number might have been changed to text elsewhere.

The core built-in types

You will meet these five fundamental types constantly. Here they are with example values.

TypeNameExample valueMeaning
intInteger25, -3, 0Whole numbers, positive or negative
floatFloating point9.99, -0.5, 3.0Numbers with a decimal point
strString"Ada", 'hi'Text, written inside quotes
boolBooleanTrue, FalseA truth value, used for decisions
NoneTypeNoneNone"Nothing here" / absence of a value

Notice that True and False start with capital letters, and None is written with a capital N. These are special values built into the language.

With variables you can now label and reshape data as your program runs. Practice by storing your own name, age, and favourite number in variables, then printing each with its type.

3. Numbers, Operators & Math

Numbers, Operators & Math

Computers were invented to do arithmetic, and Python makes calculating easy. In this chapter you will learn the arithmetic operators, how Python decides the order of operations, and how the math module adds extra power.

The two number types

Python has int for whole numbers and float for numbers with a decimal point. Mixing them in a calculation produces a float:

PYTHON
print(4 + 2)
print(4 + 2.0)
▸ OUTPUT
6
6.0

Arithmetic operators

Here are the operators you will use most often.

OperatorNameExampleResult
+Addition7 + 310
-Subtraction7 - 34
*Multiplication7 * 321
/Division (always float)7 / 23.5
//Floor division7 // 23
%Modulo (remainder)7 % 21
**Exponent (power)2 ** 38

Floor division and modulo

Regular division with / always gives a float. Floor division // throws away the fractional part and keeps the whole number. Modulo % gives the remainder. These two are a great team:

PYTHON
total_minutes = 125
hours = total_minutes // 60
minutes = total_minutes % 60
print(hours, "hours and", minutes, "minutes")
▸ OUTPUT
2 hours and 5 minutes

The modulo operator is also the usual way to check whether a number is even: n % 2 == 0 is true for even numbers because they divide by 2 with no remainder.

Exponents

Use ** to raise a number to a power. It also works with fractional powers to find roots:

PYTHON
print(5 ** 2)
print(2 ** 10)
print(9 ** 0.5)
▸ OUTPUT
25
1024
3.0

Operator precedence

When an expression has several operators, Python follows the same precedence rules you learned in school, often remembered as PEMDAS: Parentheses first, then Exponents, then Multiplication and Division (left to right), then Addition and Subtraction (left to right).

PYTHON
print(2 + 3 * 4)
print((2 + 3) * 4)
print(2 ** 3 + 1)
▸ OUTPUT
14
20
9

In the first line, 3 * 4 happens before the addition, giving 14. Parentheses let you override the default order, as the second line shows. When in doubt, add parentheses to make your intent clear, even if they are not strictly required.

The math module

For more advanced operations, Python includes a math module. You bring it in with an import statement, then use its functions and constants with a dot:

PYTHON
import math

print(math.sqrt(16))
print(math.pi)
print(math.floor(3.7))
print(math.ceil(3.2))
print(round(3.14159, 2))
▸ OUTPUT
4.0
3.141592653589793
3
4
3.14

Notice that round() is a built-in function (no import needed) that rounds to a chosen number of decimal places, while math.floor and math.ceil always round down and up to whole numbers. You will learn much more about modules later.

Try a small calculation of your own: compute the area of a circle using math.pi * radius ** 2. Notice how precedence handles the exponent before the multiplication automatically.

4. Strings in Python

Strings in Python

A string is a sequence of characters: letters, digits, spaces, and symbols. Strings represent all the text your programs handle, from names to whole documents. This chapter covers how to create strings, pick them apart, and transform them.

Creating strings

You write a string inside matching single or double quotes. Both are equal; choose whichever avoids clashing with quotes inside the text. For text spanning several lines, use triple quotes:

PYTHON
a = 'hello'
b = "she said \"hi\""
c = """This string
spans two lines."""
print(a)
print(b)
print(c)
▸ OUTPUT
hello
she said "hi"
This string
spans two lines.

Indexing and slicing

Each character has a position called an index, starting at 0 for the first character. Negative indexes count from the end, where -1 is the last character. A slice [start:stop] returns the characters from start up to but not including stop.

PYTHON
word = "python"
print(word[0])
print(word[-1])
print(word[1:4])
print(word[:3])
print(word[3:])
▸ OUTPUT
p
n
yth
pyt
hon
p y t h o n 012 345 -6-5-4 -3-2-1

f-strings

The cleanest way to build a string that includes variable values is the f-string. Put the letter f before the opening quote and write expressions inside curly braces:

PYTHON
name = "Ada"
age = 25
print(f"{name} is {age} years old.")
print(f"Next year she will be {age + 1}.")
▸ OUTPUT
Ada is 25 years old.
Next year she will be 26.

Common string methods

Strings come with many built-in methods, called with a dot. Here are the ones you will use most.

MethodWhat it doesExampleResult
.upper()All uppercase"hi".upper()"HI"
.lower()All lowercase"HI".lower()"hi"
.strip()Remove surrounding spaces" hi ".strip()"hi"
.split()Break into a list"a b c".split()['a','b','c']
.replace()Swap text"cat".replace("c","h")"hat"
.find()Index of first match (-1 if none)"hello".find("l")2
PYTHON
line = "  Hello, World  "
print(line.strip().upper())
print("one,two,three".split(","))
▸ OUTPUT
HELLO, WORLD
['one', 'two', 'three']

Immutability

Strings are immutable: once created, they cannot be changed in place. Every method that seems to "change" a string actually returns a brand-new string, leaving the original untouched:

PYTHON
s = "cat"
s.upper()
print(s)
s = s.upper()
print(s)
▸ OUTPUT
cat
CAT

The first s.upper() created "CAT" but we did not store it, so s was unchanged. Only when we reassign s does the variable point to the new value. Remember this pattern: use the return value.

5. Input & Output & Formatting

Input & Output & Formatting

Programs become interactive when they can talk to the user. In this chapter you will read what someone types, convert it to the right type, and control exactly how your output looks.

Reading input with input()

The input() function pauses your program, shows an optional prompt, waits for the user to type something and press Enter, then hands back what they typed as a string:

PYTHON
name = input("What is your name? ")
print("Hello, " + name + "!")
▸ OUTPUT (user typed Ada)
What is your name? Ada
Hello, Ada!

Converting input to numbers

Because input() always returns a string, you must convert it before doing arithmetic. Use int() for whole numbers and float() for decimals. Forgetting this is one of the most common beginner mistakes:

PYTHON
age_text = input("Your age? ")
age = int(age_text)
print(f"In ten years you will be {age + 10}.")
▸ OUTPUT (user typed 25)
Your age? 25
In ten years you will be 35.

If we had skipped int(), then age + 10 would have tried to add the number 10 to a string and raised a TypeError. You can also combine the two calls on one line: age = int(input("Your age? ")).

Controlling print() with sep and end

The print() function can take several values separated by commas. By default it joins them with a space and adds a newline at the end. Two keyword arguments change that behaviour:

  • sep sets what goes between the values.
  • end sets what goes after the last value instead of a newline.
PYTHON
print("2024", "10", "21", sep="-")
print("Loading", end="...")
print("done")
▸ OUTPUT
2024-10-21
Loading...done

Because the second print ended with "..." instead of a newline, the word "done" appeared on the same line.

Formatting numbers with f-strings

f-strings can also format values. After the expression, add a colon and a format specification. This is invaluable for money, percentages, and tidy tables:

PYTHON
price = 3.5
print(f"Total: ${price:.2f}")

ratio = 0.846
print(f"Score: {ratio:.1%}")

n = 42
print(f"[{n:5}]")
print(f"[{n:<5}]")
▸ OUTPUT
Total: $3.50
Score: 84.6%
[   42]
[42   ]

Here .2f means "a float with two decimal places," .1% means "a percentage with one decimal place" (it multiplies by 100 and adds the sign), and the number 5 sets a minimum width so values line up in columns. The < aligns to the left.

A small interactive program

Putting it together, here is a program that asks for two numbers and reports their sum neatly:

PYTHON
a = float(input("First number: "))
b = float(input("Second number: "))
print(f"{a} + {b} = {a + b:.2f}")
▸ OUTPUT (user typed 2 and 3)
First number: 2
Second number: 3
2.0 + 3.0 = 5.00

You now have the tools to build friendly, interactive programs. Always remember the golden rule of input: convert the string to the number type you need before calculating with it.

6. Conditionals

Conditionals

Real programs must make decisions: show a discount only to members, warn when a battery is low, mark an answer right or wrong. Python makes decisions with the if statement, which runs a block of code only when a condition is true.

The basic if statement

Write the keyword if, a condition, and a colon. The code that runs when the condition is true is indented underneath. Indentation (usually four spaces) is how Python knows which lines belong to the block; it is not optional:

PYTHON
temperature = 38
if temperature > 37.5:
    print("You may have a fever.")
▸ OUTPUT
You may have a fever.

else and elif

Add else for what to do when the condition is false, and elif (short for "else if") to test further conditions in order. Python checks each condition from top to bottom and runs the first block whose condition is true, then skips the rest:

PYTHON
score = 72
if score >= 90:
    grade = "A"
elif score >= 80:
    grade = "B"
elif score >= 70:
    grade = "C"
else:
    grade = "F"
print(grade)
▸ OUTPUT
C
score >= 90 ? grade = A score >= 80 ? score >= 70 ? grade = C else: grade = F True False

Comparison and logical operators

Conditions are built from comparisons that produce True or False. The comparison operators are == (equal), != (not equal), <, >, <=, and >=. Note the double equals for comparison; a single = is assignment. Combine conditions with the logical operators and, or, and not:

PYTHON
age = 20
has_ticket = True
if age >= 18 and has_ticket:
    print("Entry allowed.")
if not has_ticket:
    print("Please buy a ticket.")
▸ OUTPUT
Entry allowed.

and is true only when both sides are true; or is true when at least one side is true; not flips a value.

Truthiness

Every value in Python can be treated as true or false in a condition. Zero, empty strings, empty lists, and None are "falsy"; almost everything else is "truthy". This lets you write natural checks:

PYTHON
name = input("Name? ")
if name:
    print(f"Hi, {name}")
else:
    print("You did not enter a name.")
▸ OUTPUT (user pressed Enter without typing)
Name?
You did not enter a name.

Nesting

An if can live inside another if. Increase the indentation for each level, but avoid nesting too deeply because it quickly becomes hard to read:

PYTHON
logged_in = True
is_admin = False
if logged_in:
    if is_admin:
        print("Welcome, admin.")
    else:
        print("Welcome, user.")
▸ OUTPUT
Welcome, user.

Conditionals give your programs the power of choice. Practice by writing a program that reads a number and prints whether it is negative, zero, or positive using if, elif, and else.

7. Loops

Loops

Computers excel at repetition. A loop runs a block of code many times without you writing it out again and again. Python has two loops: the for loop, which repeats once for each item in a sequence, and the while loop, which repeats as long as a condition stays true.

The for loop and range()

A for loop walks through the items of a sequence, giving you one item per pass. The range() function generates a sequence of numbers, which is the usual way to repeat a fixed number of times. range(5) produces 0, 1, 2, 3, 4 (it stops before 5):

PYTHON
for i in range(5):
    print(i)
▸ OUTPUT
0
1
2
3
4

range can take a start, stop, and step: range(2, 11, 2) gives 2, 4, 6, 8, 10.

Iterating over sequences

You can loop directly over the characters of a string or, as you will see later, the items of a list:

PYTHON
for letter in "cat":
    print(letter.upper())
▸ OUTPUT
C
A
T

The while loop

A while loop keeps going as long as its condition is true. You must make sure something inside the loop eventually makes the condition false, or the loop will run forever:

PYTHON
count = 3
while count > 0:
    print(count)
    count = count - 1
print("Lift off!")
▸ OUTPUT
3
2
1
Lift off!
start count = 3 count > 0 ? print, count = count - 1 Lift off! True False

break and continue

Two keywords give you finer control. break leaves the loop immediately. continue skips the rest of the current pass and jumps to the next one:

PYTHON
for n in range(1, 10):
    if n == 5:
        break
    if n % 2 == 0:
        continue
    print(n)
▸ OUTPUT
1
3

Odd numbers 1 and 3 print. Even numbers 2 and 4 are skipped by continue, and when n reaches 5 the break stops the loop entirely.

The loop else clause

Both loops may have an else block that runs only if the loop finished normally without hitting a break. It is handy for search loops:

PYTHON
for n in [4, 6, 8]:
    if n % 2 != 0:
        print("Found an odd number")
        break
else:
    print("All numbers were even")
▸ OUTPUT
All numbers were even

Choosing between for and while

Use a for loop when...Use a while loop when...
You know how many times to repeatYou repeat until a condition changes
You are walking through a sequenceThe number of repeats is unknown in advance
Example: process each studentExample: keep asking until input is valid

Loops turn a few lines of code into enormous amounts of work. Practice by printing a multiplication table for a number using a for loop and range.

8. Lists & Tuples

Lists & Tuples

So far each variable has held a single value. Often you need to hold many values together: a shopping list, exam scores, a playlist. Python's list is the workhorse for ordered collections, and the tuple is its unchangeable cousin.

Creating and reading a list

Write a list with square brackets and commas. Items can be any type, and a list keeps them in order. Access items by index, exactly like string characters, and slice them the same way:

PYTHON
fruits = ["apple", "banana", "cherry"]
print(fruits[0])
print(fruits[-1])
print(fruits[0:2])
print(len(fruits))
▸ OUTPUT
apple
cherry
['apple', 'banana']
3
"apple" "banana" "cherry" index 0 index 1 index 2

Lists are mutable

Unlike strings, lists can be changed in place. You can replace an item by index and use methods to grow or shrink the list.

MethodWhat it doesExample on [1, 2]
.append(x)Add x to the end[1, 2, 3]
.insert(i, x)Insert x at index i.insert(0, 9) gives [9, 1, 2]
.pop()Remove and return last itemreturns 2, list is [1]
.remove(x)Remove first matching value.remove(1) gives [2]
.sort()Sort the list in place[2, 1].sort() gives [1, 2]
PYTHON
scores = [70, 90, 55]
scores.append(80)
scores.sort()
print(scores)
top = scores.pop()
print(top, scores)
▸ OUTPUT
[55, 70, 80, 90]
90 [55, 70, 80]

Tuples and immutability

A tuple looks like a list but uses round brackets and cannot be changed after creation. Use a tuple when the collection should stay fixed, such as a coordinate pair or a date:

PYTHON
point = (3, 4)
print(point[0])
print(point[1])
# point[0] = 9   # this would raise a TypeError
▸ OUTPUT
3
4

Because tuples cannot change, Python can handle them slightly faster and they are safe to use as fixed records. You can also unpack a tuple into several variables at once: x, y = point gives x = 3 and y = 4.

A gentle look at list comprehensions

A very common task is building a new list by transforming each item of an old one. The long way uses a loop:

PYTHON
squares = []
for n in range(1, 6):
    squares.append(n * n)
print(squares)
▸ OUTPUT
[1, 4, 9, 16, 25]

Python offers a shorter form called a list comprehension that does the same thing in one line:

PYTHON
squares = [n * n for n in range(1, 6)]
print(squares)

evens = [n for n in range(10) if n % 2 == 0]
print(evens)
▸ OUTPUT
[1, 4, 9, 16, 25]
[0, 2, 4, 6, 8]

Read it as "collect n * n for each n in the range." You can add an if at the end to keep only some items. Comprehensions are concise and popular, but a normal loop is just as correct when it reads more clearly.

Lists and tuples let you manage collections of data with ease. Practice by making a list of your five favourite numbers, then printing their total with the built-in sum() function.

9. Dictionaries & Sets

Dictionaries & Sets

Lists store items by position. Sometimes you would rather look things up by a meaningful label, like a phone book that maps names to numbers. Python's dictionary does exactly that, and the set stores a collection of unique items.

Creating a dictionary

A dictionary holds key-value pairs. Write it with curly braces, each key followed by a colon and its value. Keys are usually strings or numbers and must be unique; values can be anything:

PYTHON
student = {"name": "Ada", "age": 25, "passed": True}
print(student["name"])
print(student["age"])
▸ OUTPUT
Ada
25

You access a value by putting its key in square brackets. Asking for a key that does not exist raises a KeyError; to avoid that, use .get(), which returns None (or a default you supply) when the key is missing.

Adding, updating, and deleting

Dictionaries are mutable. Assigning to a new key adds it; assigning to an existing key updates it; del removes a pair:

PYTHON
student["grade"] = "A"
student["age"] = 26
del student["passed"]
print(student)
print(student.get("email", "no email"))
▸ OUTPUT
{'name': 'Ada', 'age': 26, 'grade': 'A'}
no email

Common dictionary operations

OperationMeaningExample
d[key]Read a valuestudent["name"]
d[key] = vAdd or updatestudent["grade"] = "A"
del d[key]Delete a pairdel student["age"]
key in dTest membership"name" in student
d.get(key, default)Safe readstudent.get("x", 0)
d.keys() / d.values()All keys / valuesstudent.keys()

Iterating over a dictionary

Looping over a dictionary gives you the keys. The .items() method is the usual way to get both the key and value each pass:

PYTHON
prices = {"apple": 0.5, "bread": 1.2, "milk": 0.9}
for item, cost in prices.items():
    print(f"{item} costs ${cost:.2f}")
▸ OUTPUT
apple costs $0.50
bread costs $1.20
milk costs $0.90

Sets and uniqueness

A set is an unordered collection that automatically removes duplicates. Create one with curly braces (but with no colons) or the set() function. Sets are great for membership tests and for removing repeats from a list:

PYTHON
numbers = [1, 2, 2, 3, 3, 3]
unique = set(numbers)
print(unique)
print(3 in unique)
▸ OUTPUT
{1, 2, 3}
True

Note that a set has no indexes because it has no order, so you cannot write unique[0].

Set operations

Sets support the mathematical operations of union, intersection, and difference. These answer questions like "what do two groups share?" quickly and clearly:

PYTHON
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b)
print(a & b)
print(a - b)
▸ OUTPUT
{1, 2, 3, 4, 5, 6}
{3, 4}
{1, 2}

The | operator gives the union (everything in either set), & gives the intersection (items in both), and - gives the difference (items in the first but not the second).

Dictionaries and sets round out Python's core data structures. Reach for a dictionary whenever you want to look data up by name, and for a set whenever uniqueness or fast membership checks matter. Practice by counting how many times each letter appears in a word using a dictionary.

10. Functions

Functions

As programs grow, repeating the same lines in many places becomes error-prone. A function is a named, reusable block of code. You define it once and call it whenever you need it, often with different inputs. Functions make programs shorter, clearer, and easier to fix.

Defining and calling a function

Use the keyword def, a name, parentheses for any inputs, and a colon. The body is indented. Call the function by writing its name followed by parentheses:

PYTHON
def greet():
    print("Hello!")

greet()
greet()
▸ OUTPUT
Hello!
Hello!

Parameters and return values

The names in the parentheses are parameters; the values you pass in when calling are arguments. The return statement sends a value back to whoever called the function, so you can use the result:

PYTHON
def add(a, b):
    return a + b

total = add(3, 4)
print(total)
print(add(10, 20))
▸ OUTPUT
7
30

A function without a return statement automatically returns None. The difference between print and return matters: print only shows text on screen, while return gives a value your program can keep using.

a = 3, b = 4 add(a, b) return a + b 7 inputs return

Default and keyword arguments

A parameter can have a default value, used when the caller does not supply one. You may also pass arguments by name, which makes a call self-explanatory and lets you skip earlier optional values:

PYTHON
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print(greet("Ada"))
print(greet("Max", "Welcome"))
print(greet(name="Sam", greeting="Hi"))
▸ OUTPUT
Hello, Ada!
Welcome, Max!
Hi, Sam!

Scope

Variables created inside a function are local: they exist only while the function runs and cannot be seen from outside. This keeps functions independent and prevents them from accidentally interfering with each other:

PYTHON
def compute():
    result = 42
    return result

print(compute())
# print(result)   # NameError: result is not defined here
▸ OUTPUT
42

Docstrings

A docstring is a string placed as the first line inside a function to describe what it does. Tools and the built-in help() function can read it, and it reminds future readers (including you) of the function's purpose:

PYTHON
def area(width, height):
    """Return the area of a rectangle."""
    return width * height

print(area(3, 5))
print(area.__doc__)
▸ OUTPUT
15
Return the area of a rectangle.

A gentle look at *args and **kwargs

Sometimes you want a function to accept any number of arguments. Writing *args collects extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dictionary:

PYTHON
def total(*numbers):
    return sum(numbers)

print(total(1, 2, 3))
print(total(10, 20, 30, 40))
▸ OUTPUT
6
100

You will not need these every day, but they power many flexible library functions. For now, remember the core idea: a function takes inputs, does work, and returns a result.

Functions are the main way programmers organise ideas. Practice by writing a function that takes a temperature in Celsius and returns it in Fahrenheit using the formula c * 9 / 5 + 32.

11. Modules & the Standard Library

Modules & the Standard Library

No one writes every program from scratch. Python comes with a huge standard library of ready-made code, grouped into modules. A module is simply a file of Python definitions you can bring into your program. This chapter shows how to import modules, tours a few useful ones, and explains how to install extra packages.

Import forms

There are three common ways to import. The first brings in the whole module; you then use its contents with a dot. The second imports specific names directly. The third renames a module to a shorter alias:

PYTHON
import math
print(math.sqrt(25))

from math import sqrt, pi
print(sqrt(25), pi)

import statistics as st
print(st.mean([2, 4, 6]))
▸ OUTPUT
5.0
5.0 3.141592653589793
4

Prefer import math over from math import * (which imports everything): keeping the module name makes it obvious where a function came from.

The random module

The random module produces random numbers and choices, perfect for games and simulations. (Your exact numbers will differ each run because they are random.)

PYTHON
import random

print(random.randint(1, 6))
print(random.choice(["red", "green", "blue"]))

cards = [1, 2, 3, 4, 5]
random.shuffle(cards)
print(cards)
▸ OUTPUT (example, varies each run)
4
green
[3, 1, 5, 2, 4]

randint(1, 6) returns a whole number between 1 and 6 inclusive, like rolling a die. choice picks one item from a list, and shuffle rearranges a list in place.

The datetime module

The datetime module works with dates and times. Here we get today's date and format it nicely:

PYTHON
from datetime import date

today = date(2024, 10, 21)
print(today)
print(today.year)
print(today.strftime("%d %B %Y"))
▸ OUTPUT
2024-10-21
2024
21 October 2024

In a real program you would call date.today() to get the current date automatically. The strftime method turns a date into text using format codes such as %Y for the four-digit year and %B for the month name.

pip and third-party packages

Beyond the standard library, the community has published hundreds of thousands of free packages on the Python Package Index (PyPI). You install them with pip, Python's package installer, from your terminal (not inside a Python script):

PYTHON
pip install requests

Once installed, you import it just like a standard module:

PYTHON
import requests
# response = requests.get("https://example.com")
# print(response.status_code)

Popular packages include requests for web requests, pandas for data analysis, and pygame for games. Installing packages per project, inside a virtual environment, is a good habit you will learn as you progress.

Splitting your own code into modules

Any .py file you write is itself a module that other files can import. Suppose you save this as tools.py:

PYTHON
# tools.py
def double(n):
    return n * 2

Then in another file in the same folder you can use it:

PYTHON
# main.py
import tools
print(tools.double(21))
▸ OUTPUT
42

Splitting code into modules keeps each file focused and lets you reuse your work across projects. As your programs grow, organising them into several small modules is far easier to manage than one enormous file.

The standard library is sometimes called "batteries included" because so much is ready to use. Whenever you face a common task, search the documentation first; the tool you need may already exist.

12. File Handling

File Handling

Programs often need to remember information after they stop running, or to read data someone else prepared. Files on disk make this possible. Python can create, read, and write text files with just a few lines of code.

Opening a file

The open() function connects your program to a file. You give it a file name and a mode that says what you intend to do. The mode is a short string.

ModeMeaningIf file exists
"r"Read (the default)Reads it; error if missing
"w"WriteErases and overwrites it
"a"AppendAdds to the end
"x"Create newError if it already exists

The with statement

The recommended way to work with a file is the with statement. It opens the file, gives you a variable to use inside the indented block, and automatically closes the file afterwards, even if an error occurs. Forgetting to close files is a classic bug that with prevents:

PYTHON
with open("notes.txt", "w") as f:
    f.write("First line\n")
    f.write("Second line\n")
print("File written.")
▸ OUTPUT
File written.

The \n is a newline character; without it, both pieces of text would run together on one line.

Reading a whole file

To read the file back, open it in read mode. The .read() method returns the entire contents as one string:

PYTHON
with open("notes.txt", "r") as f:
    content = f.read()
print(content)
▸ OUTPUT
First line
Second line

Reading line by line

For larger files it is better to process one line at a time. You can loop directly over the file object, which yields each line in turn, or use .readlines() to get a list of all lines:

PYTHON
with open("notes.txt", "r") as f:
    for line in f:
        print(line.strip())
▸ OUTPUT
First line
Second line

We call .strip() on each line to remove the trailing newline so print does not add a second blank line. .readlines() would instead give ['First line\n', 'Second line\n'].

Appending to a file

Open in append mode to add to a file without erasing what is already there:

PYTHON
with open("notes.txt", "a") as f:
    f.write("Third line\n")

with open("notes.txt", "r") as f:
    print(f.read())
▸ OUTPUT
First line
Second line
Third line

A note about CSV files

A very common file format is CSV (comma-separated values), used by spreadsheets. Each line is a row and commas separate the columns. You could split lines yourself, but Python's built-in csv module handles the tricky details for you:

PYTHON
import csv

with open("people.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["name", "age"])
    writer.writerow(["Ada", 25])

with open("people.csv", "r") as f:
    reader = csv.reader(f)
    for row in reader:
        print(row)
▸ OUTPUT
['name', 'age']
['Ada', '25']

Notice that every value read from a CSV file comes back as a string, so you would convert row[1] with int() if you needed the age as a number.

With files, your programs can save results, keep logs, and read data prepared elsewhere. Always prefer the with statement so your files are closed safely, and be careful with "w" mode because it erases existing content.

13. Exceptions & Error Handling

Exceptions & Error Handling

Things go wrong: a file is missing, a user types "cat" where a number was expected, a calculation divides by zero. When Python hits such a problem it raises an exception, and unless you handle it, your program stops with an error message. This chapter shows how to catch exceptions and respond gracefully.

Why handle errors?

An unhandled exception crashes your program and shows a scary traceback to the user. Handling errors lets your program recover, ask the user to try again, or save their work. Consider this crash:

PYTHON
number = int("hello")
▸ OUTPUT
ValueError: invalid literal for int() with base 10: 'hello'

try and except

Wrap risky code in a try block. If an exception occurs, Python jumps to the matching except block instead of crashing:

PYTHON
text = "hello"
try:
    number = int(text)
    print(number)
except ValueError:
    print("That was not a valid number.")
▸ OUTPUT
That was not a valid number.

It is best to catch the specific exception you expect, like ValueError, rather than catching everything. That way genuine, unexpected bugs still surface instead of being silently swallowed.

Common exception types

ExceptionHappens when...Example
ValueErrorRight type, wrong valueint("abc")
TypeErrorWrong type used"a" + 1
ZeroDivisionErrorDividing by zero5 / 0
IndexErrorList index out of range[1, 2][5]
KeyErrorMissing dictionary key{}["x"]
FileNotFoundErrorOpening a missing fileopen("no.txt")

else and finally

A full error-handling statement can have four parts. The else block runs only if no exception occurred. The finally block always runs, whether or not there was an error, which is ideal for cleanup:

PYTHON
try:
    value = 10 / 2
except ZeroDivisionError:
    print("Cannot divide by zero.")
else:
    print(f"Success: {value}")
finally:
    print("Done checking.")
▸ OUTPUT
Success: 5.0
Done checking.

Catching more than one exception

You can handle several exception types, each with its own response, or group them:

PYTHON
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return "Cannot divide by zero"
    except TypeError:
        return "Please use numbers"

print(safe_divide(10, 2))
print(safe_divide(10, 0))
print(safe_divide(10, "x"))
▸ OUTPUT
5.0
Cannot divide by zero
Please use numbers

Raising your own exceptions

Sometimes your own code discovers a problem and should signal it. Use the raise keyword to raise an exception with a helpful message. This lets callers handle the problem the same way they handle built-in errors:

PYTHON
def set_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")
    return age

try:
    set_age(-5)
except ValueError as e:
    print("Error:", e)
▸ OUTPUT
Error: Age cannot be negative

Writing except ValueError as e captures the exception object in e, so you can print its message. A common real-world use is validating user input: ask again until the value makes sense.

Good error handling is the difference between a fragile program and a robust one. Catch the specific errors you expect, give the user a clear message, and let unexpected bugs surface so you can fix them.

14. Classes & Objects (OOP)

Classes & Objects (OOP)

So far we have organised code with functions. Object-oriented programming (OOP) offers another powerful way: bundle related data and the functions that act on it into a single unit called an object. A class is the blueprint; an object (or instance) is a specific thing built from that blueprint.

Defining a class

Use the keyword class and a capitalised name by convention. The special method __init__ runs automatically when you create a new object; it sets up the object's starting data. The first parameter, always called self, refers to the particular object being worked on:

PYTHON
class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def bark(self):
        return f"{self.name} says woof!"

rex = Dog("Rex", 3)
print(rex.name)
print(rex.bark())
▸ OUTPUT
Rex
Rex says woof!

The variables self.name and self.age are attributes: data belonging to each object. bark is a method: a function that belongs to the class and can use the object's attributes through self.

Many objects from one class

From a single class you can create as many independent objects as you like. Each carries its own attribute values:

PYTHON
a = Dog("Rex", 3)
b = Dog("Bella", 5)
print(a.bark())
print(b.bark())
▸ OUTPUT
Rex says woof!
Bella says woof!
class Dog name, age, bark() rex name="Rex", age=3 bella name="Bella", age=5

A worked example: a bank account

Classes shine when data and behaviour belong together. A bank account has a balance (data) and operations like deposit and withdraw (behaviour) that must keep the balance sensible:

PYTHON
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            print("Insufficient funds")
        else:
            self.balance -= amount

    def show(self):
        return f"{self.owner}: ${self.balance}"

acc = BankAccount("Ada", 100)
acc.deposit(50)
acc.withdraw(30)
acc.withdraw(999)
print(acc.show())
▸ OUTPUT
Insufficient funds
Ada: $120

The account started at 100, gained 50, lost 30, and refused the oversized withdrawal, ending at 120. Notice how the withdraw method protects the data by checking before subtracting. Keeping such rules inside the class is one of OOP's main benefits.

A brief look at inheritance

Inheritance lets a new class build on an existing one, reusing its code and adding or changing behaviour. The new class lists the parent in parentheses:

PYTHON
class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return "some sound"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says meow"

c = Cat("Milo")
print(c.name)
print(c.speak())
▸ OUTPUT
Milo
Milo says meow

Cat inherits the __init__ method from Animal, so it already knows how to store a name, but it provides its own speak method, replacing the parent's version. This ability to reuse and specialise code keeps large programs manageable.

Object-oriented programming takes practice to appreciate fully. For now, remember the core idea: a class groups related data (attributes) and actions (methods) into one tidy package, and you create as many objects from it as you need.

The four pillars of OOP

Once you can define a class and create objects, the next step is understanding why OOP is organised the way it is. Almost everything you will meet from here on is an expression of four core ideas, usually called the four pillars of object-oriented programming. You do not need to memorise the names, but recognising them helps you see the pattern behind the syntax.

PillarOne-line meaningEveryday analogy
EncapsulationBundle data with the code that works on it, and control who can touch it.A medicine capsule: the contents are sealed inside; you take the pill, you don't repackage the powder.
InheritanceA new class can reuse and extend an existing one ("is-a").A sports car is a car: it has everything a car has, plus extras.
PolymorphismThe same method name behaves correctly for different types.Pressing "play" works on a phone, a TV and a car stereo, even though each does it differently.
AbstractionExpose a simple interface and hide the messy details.Driving a car: you use the steering wheel and pedals without knowing how the engine burns fuel.

The rest of this chapter walks through each pillar with runnable code. Keep the table in mind: every feature below is a tool for one of these four goals.

Instance attributes vs class attributes

There are two places data can live in a class, and mixing them up is one of the most common beginner bugs.

  • Instance attributes are created inside __init__ using self. Each object gets its own private copy.
  • Class attributes are defined directly in the class body, outside any method. There is only one copy, shared by every instance of the class.
PYTHON
class Dog:
    species = "Canis familiaris"   # class attribute - shared by all dogs

    def __init__(self, name):
        self.name = name           # instance attribute - unique per dog

d1 = Dog("Rex")
d2 = Dog("Bella")

print(d1.name, "-", d1.species)
print(d2.name, "-", d2.species)

Dog.species = "Canis lupus"        # change it once, on the class
print(d1.species)
print(d2.species)
▸ OUTPUT
Rex - Canis familiaris
Bella - Canis familiaris
Canis lupus
Canis lupus

Notice that changing Dog.species affected both existing dogs instantly, because they all read the same shared value. Instance attributes like name stay independent. Class attributes are perfect for constants and defaults that genuinely belong to the whole type.

The mutable class-attribute trap. Problems appear when a shared class attribute is something changeable, like a list. Watch what happens:

PYTHON
class Dog:
    tricks = []                    # shared mutable list - BUG waiting to happen

    def __init__(self, name):
        self.name = name

    def add_trick(self, trick):
        self.tricks.append(trick)

a = Dog("Rex")
b = Dog("Bella")
a.add_trick("roll over")
b.add_trick("play dead")
print(a.tricks)
print(b.tricks)
▸ OUTPUT
['roll over', 'play dead']
['roll over', 'play dead']

Rex somehow knows Bella's trick! Because tricks is one shared list, every dog appends to the same object. The fix is to make the list an instance attribute, created fresh for each dog inside __init__:

PYTHON
class Dog:
    def __init__(self, name):
        self.name = name
        self.tricks = []          # a brand-new list for every dog

    def add_trick(self, trick):
        self.tricks.append(trick)

a = Dog("Rex")
b = Dog("Bella")
a.add_trick("roll over")
b.add_trick("play dead")
print(a.tricks)
print(b.tricks)
▸ OUTPUT
['roll over']
['play dead']

Rule of thumb: use class attributes for constants shared by every object; put anything each object should own separately (especially mutable things like lists and dicts) in __init__ via self.

Three kinds of methods: instance, class, and static

So far every method you wrote took self. That is just one of three method types Python offers, each with a different job.

  • Instance method — takes self; works with one specific object's data. This is the default.
  • Class method — marked with @classmethod, takes cls (the class itself). Great as an alternative constructor / factory.
  • Static method — marked with @staticmethod, takes neither self nor cls. It is just a plain utility function that logically belongs inside the class.
PYTHON
class Pizza:
    def __init__(self, radius, toppings):
        self.radius = radius
        self.toppings = toppings

    # instance method - needs a specific pizza (self)
    def describe(self):
        return f"{self.radius}cm pizza with {', '.join(self.toppings)}"

    # class method - a factory that builds a common pizza
    @classmethod
    def margherita(cls):
        return cls(15, ["tomato", "mozzarella"])

    # static method - a helper that needs no pizza at all
    @staticmethod
    def area(radius):
        return 3.14159 * radius ** 2

p = Pizza.margherita()          # build via the factory
print(p.describe())
print(f"Area: {Pizza.area(15):.1f} cm2")
▸ OUTPUT
15cm pizza with tomato, mozzarella
Area: 706.9 cm2

Why use cls instead of writing Pizza(...) directly in the factory? Because cls respects inheritance: if a subclass calls margherita(), it builds an instance of the subclass, not of Pizza. The static area method does not read any object state, so it takes neither self nor cls and can be called straight off the class.

Encapsulation: controlling access

Python does not have true "private" keywords like some languages. Instead it uses naming conventions plus one mild technical trick:

  • name — public: part of the official interface, use freely.
  • _name — protected (single underscore): "internal, please don't touch from outside." A convention only; Python does not stop you.
  • __name — private (double underscore): Python performs name mangling, rewriting it to _ClassName__name so it is hard to hit by accident.
PYTHON
class Account:
    def __init__(self, owner):
        self.owner = owner          # public
        self._bank = "ACME"         # protected (convention)
        self.__pin = "1234"         # private (name-mangled)

a = Account("Mia")
print(a.owner)                      # fine - public
print(a._bank)                      # works, but you shouldn't rely on it
try:
    print(a.__pin)                  # looks private...
except AttributeError:
    print("Cannot access __pin directly")
print(a._Account__pin)              # ...but mangling means it's still reachable
▸ OUTPUT
Mia
ACME
Cannot access __pin directly
1234

So __pin is not truly hidden; it has merely been renamed to _Account__pin. The point of the double underscore is to avoid accidental clashes and signal "really internal," not to provide security.

The clean way to guard data: @property. A property lets an attribute look like a plain variable from the outside while secretly running a getter and setter. This is how you validate data without forcing callers to use ugly get_x() / set_x() methods.

PYTHON
class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius

    @property
    def celsius(self):              # the getter
        return self._celsius

    @celsius.setter
    def celsius(self, value):       # the setter, with validation
        if value < -273.15:
            raise ValueError("Below absolute zero!")
        self._celsius = value

    @property
    def fahrenheit(self):           # a read-only computed property
        return self._celsius * 9 / 5 + 32

t = Temperature(25)
print(t.celsius)                    # reads via the getter
print(t.fahrenheit)
t.celsius = 100                     # writes via the setter
print(t.fahrenheit)
try:
    t.celsius = -300                # triggers validation
except ValueError as e:
    print("Rejected:", e)
▸ OUTPUT
25
77.0
212.0
Rejected: Below absolute zero!

From the caller's point of view, t.celsius = 100 is just an assignment. Under the hood the setter runs, rejecting impossible values. fahrenheit has only a getter, so it is effectively read-only and always computed from the current Celsius. This is encapsulation done the Pythonic way: start with plain public attributes, and reach for @property only when you need validation or a computed value.

Inheritance in depth

Inheritance models an is-a relationship: a Dog is an Animal. The child class automatically gets the parent's attributes and methods, and can add new ones or replace (override) existing ones. Use super().__init__() to run the parent's constructor so you don't rewrite its setup.

Animal Dog Cat is-a is-a

The arrows point from the specific class up to the general one: "Dog is-a Animal." Here is a three-level hierarchy where each level overrides speak():

PYTHON
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "..."

    def describe(self):
        return f"{self.name} says {self.speak()}"

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)       # reuse Animal's constructor
        self.breed = breed

    def speak(self):                 # override
        return "Woof"

class Puppy(Dog):
    def speak(self):                 # override again
        return "Yip"

d = Dog("Rex", "Labrador")
p = Puppy("Bella", "Poodle")
print(d.describe())
print(p.describe())
print(p.breed)                       # inherited all the way from Dog.__init__
▸ OUTPUT
Rex says Woof
Bella says Yip
Poodle

Look closely at describe(): it is defined only on Animal, yet it prints the right sound for each animal. That is because it calls self.speak(), and self looks up speak on the actual object's class first. The Puppy never defines its own __init__, so it transparently uses Dog's, which is why p.breed works.

Two built-in functions let you ask about relationships at runtime:

PYTHON
print(isinstance(d, Dog))       # is d a Dog?
print(isinstance(d, Animal))    # is d also an Animal? (yes - is-a)
print(isinstance(p, Dog))       # a Puppy is-a Dog
print(issubclass(Puppy, Animal))# is the Puppy class below Animal?
print(issubclass(Dog, Puppy))   # is Dog below Puppy? (no)
▸ OUTPUT
True
True
True
True
False

Multiple inheritance and the MRO (briefly). Python allows a class to inherit from more than one parent at once, e.g. class D(B, C). When you call a method, Python searches the parents in a fixed order called the Method Resolution Order (MRO), which you can inspect with ClassName.__mro__. It generally goes left-to-right, depth-first, with each class appearing once:

PYTHON
class A:
    def greet(self):
        return "A"
class B(A):
    def greet(self):
        return "B"
class C(A):
    def greet(self):
        return "C"
class D(B, C):
    pass

print(D().greet())
print([cls.__name__ for cls in D.__mro__])
▸ OUTPUT
B
['D', 'B', 'C', 'A', 'object']

Multiple inheritance is powerful but easy to misuse; for most programs, prefer single inheritance (or composition, covered later) and keep hierarchies shallow.

Polymorphism and duck typing

Polymorphism ("many forms") means the same method name can be called on different types and each does the right thing. Because of this, you can loop over a mixed collection of objects and call one method without caring which exact class each one is:

PYTHON
class Dog:
    def speak(self):
        return "Woof"

class Cat:
    def speak(self):
        return "Meow"

class Duck:
    def speak(self):
        return "Quack"

for animal in [Dog(), Cat(), Duck()]:
    print(animal.speak())
▸ OUTPUT
Woof
Meow
Quack

Notice these three classes do not share a parent. Python does not check their type; it only checks that each object has a speak() method. This is called duck typing: "if it walks like a duck and quacks like a duck, treat it like a duck." What matters is the behaviour an object offers, not its label.

PYTHON
def make_it_speak(thing):
    print(thing.speak())          # works on ANYTHING with a speak() method

class Robot:
    def speak(self):
        return "Beep boop"

make_it_speak(Dog())
make_it_speak(Robot())            # a Robot is not an Animal, but it still fits
▸ OUTPUT
Woof
Beep boop

The Robot has nothing to do with animals, yet make_it_speak accepts it happily. Duck typing keeps Python code flexible: you program to a capability, not to a rigid type hierarchy.

Abstraction with abstract base classes

Sometimes you want to guarantee that every subclass implements a particular method, and you want to forbid creating the vague "base" type on its own. The abc module (Abstract Base Classes) provides exactly this. You inherit from ABC and mark required methods with @abstractmethod.

PYTHON
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...                          # no body - subclasses MUST provide one

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    def area(self):
        return 3.14159 * self.radius ** 2

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    def area(self):
        return self.width * self.height

for s in [Circle(2), Rectangle(3, 4)]:
    print(f"{type(s).__name__}: {s.area():.2f}")

try:
    s = Shape()                      # cannot build the abstract base directly
except TypeError:
    print("Cannot instantiate abstract class Shape")
▸ OUTPUT
Circle: 12.57
Rectangle: 12.00
Cannot instantiate abstract class Shape

Shape describes what every shape must be able to do (compute an area) without saying how. Trying to create a bare Shape() raises TypeError, because it has an unimplemented abstract method. If you wrote a Triangle(Shape) and forgot to define area, Python would refuse to instantiate it too. This is abstraction enforced by the language: the base class defines the contract, concrete subclasses fulfil it.

Special (dunder) methods

Methods with double underscores on both sides (__init__, __str__, ...) are called dunder ("double underscore") or magic methods. Python calls them automatically in response to built-in operations like print(), ==, len(), + and sorting. Defining them makes your objects feel like native Python types.

By default, printing an object is unhelpful:

PYTHON
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p)
▸ OUTPUT
<__main__.Point object at 0x7f9c1a2b3d40>

(The exact hexadecimal address varies every run.) Two dunders fix this. __str__ gives a friendly, human-readable form used by print() and str(); __repr__ gives an unambiguous, developer-facing form used in the interactive shell, in containers, and as a fallback when __str__ is missing.

PYTHON
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):                # for developers / debugging
        return f"Point({self.x}, {self.y})"

    def __str__(self):                 # for end users
        return f"({self.x}, {self.y})"

p = Point(1, 2)
print(p)          # uses __str__
print(str(p))     # uses __str__
print(repr(p))    # uses __repr__
print([p])        # a list shows the __repr__ of each element
▸ OUTPUT
(1, 2)
(1, 2)
Point(1, 2)
[Point(1, 2)]

A good habit: always define at least __repr__. If you only define one, make it __repr__, because __str__ falls back to it.

Value equality with __eq__. By default == compares identity (are these the same object in memory?). Define __eq__ to compare by value instead:

PYTHON
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

print(Point(1, 2) == Point(1, 2))     # same values
print(Point(1, 2) == Point(3, 4))     # different values
▸ OUTPUT
True
False

Without __eq__, the first line would print False, because the two points are distinct objects even though their values match.

Making objects sortable with __lt__. The sorted() function and < operator use __lt__ ("less than"). Define it and your objects sort automatically:

PYTHON
class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade
    def __repr__(self):
        return f"{self.name}({self.grade})"
    def __lt__(self, other):
        return self.grade < other.grade

students = [Student("Ann", 88), Student("Bob", 72), Student("Cy", 95)]
print(sorted(students))
▸ OUTPUT
[Bob(72), Ann(88), Cy(95)]

Length with __len__, and operators with __add__. __len__ lets len() work on your object; __add__ lets + combine two objects:

PYTHON
class Playlist:
    def __init__(self, songs):
        self.songs = songs
    def __len__(self):
        return len(self.songs)

class Money:
    def __init__(self, cents):
        self.cents = cents
    def __add__(self, other):
        return Money(self.cents + other.cents)
    def __repr__(self):
        return f"${self.cents / 100:.2f}"

print(len(Playlist(["a", "b", "c"])))
print(Money(150) + Money(350))
▸ OUTPUT
3
$5.00
Dunder methodWhat it enables
__init__Set up a new object (the constructor).
__str__Friendly text for print() / str().
__repr__Unambiguous text for debugging / containers.
__eq__Compare two objects with == by value.
__lt__Order objects with < and sorted().
__len__Make len(obj) work.
__add__Define what obj1 + obj2 does.
__getitem__Enable indexing, e.g. obj[0].

Composition vs inheritance

Inheritance models is-a ("a Dog is an Animal"). Composition models has-a ("a Car has an Engine"): instead of inheriting, one object simply holds another object as an attribute and delegates work to it.

PYTHON
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower
    def start(self):
        return f"Engine ({self.horsepower}hp) starting..."

class Car:
    def __init__(self, model, horsepower):
        self.model = model
        self.engine = Engine(horsepower)   # Car HAS-A Engine

    def start(self):
        return f"{self.model}: {self.engine.start()}"   # delegate to the engine

c = Car("Falcon", 300)
print(c.start())
▸ OUTPUT
Falcon: Engine (300hp) starting...

A Car is clearly not a kind of Engine, so inheritance would be wrong here; the car simply contains one. Composition is more flexible: you could swap in an ElectricEngine, or give the car several components (wheels, radio, battery) without tangling them into one inheritance chain.

Rule of thumb: if you can say "X is a Y," consider inheritance; if you can say "X has a Y," prefer composition. When unsure, favour composition — it leads to looser coupling and fewer surprises.

Best practices & common pitfalls

  • One responsibility per class. A class should model one clear thing. If it is doing networking and maths and file I/O, split it up.
  • Prefer composition for "has-a". Reach for inheritance only when there is a genuine is-a relationship, and keep hierarchies shallow.
  • Don't overuse inheritance. Deep chains and multiple inheritance make code hard to follow. Most designs need only one or two levels.
  • Use @property for validated access. Expose plain attributes by default; switch to a property when you need to validate input or compute a value, rather than littering code with get_x()/set_x().
  • Never forget self. Every instance method's first parameter is self, and you access object data through it (self.name, not just name).
  • Avoid mutable defaults and mutable class attributes. Create lists/dicts inside __init__ so each object owns its own, instead of sharing one by accident.
  • Define __repr__ (and often __str__). Readable objects make debugging and logging far easier.
  • Keep methods short and named for what they do. account.withdraw(50) reads better than one giant do_everything() method.

Quick reference

FeatureSyntaxPurpose
Define a classclass Dog:Create a new type.
Constructordef __init__(self, ...):Set up a new object's data.
Current objectselfRefers to the instance inside its methods.
Class attributespecies = "..." (in class body)Value shared by all instances.
Class method@classmethod / def m(cls):Factory or alternative constructor.
Static method@staticmethod / def m():Utility that needs no object state.
Validated attribute@property + @x.setterGetter/setter that looks like an attribute.
Inheritanceclass Dog(Animal): + super().__init__()Reuse and extend a parent class.
Abstract method@abstractmethod (class inherits ABC)Force subclasses to implement a method.
Readable text__str__ / __repr__Control how the object prints.
Value equality__eq__Compare objects with ==.

15. Putting It Together

Putting It Together

You have learned variables, operators, strings, conditionals, loops, data structures, functions, modules, files, error handling, and classes. That is everything you need to build real programs. In this final chapter you will combine these ideas into three small projects, then plan how to keep growing as a programmer.

Project 1: Number-guessing game

The computer picks a secret number and you guess until you find it. This project uses the random module, a while loop, conditionals, and input conversion with error handling:

PYTHON
import random

secret = random.randint(1, 20)
guesses = 0

print("I'm thinking of a number from 1 to 20.")
while True:
    try:
        guess = int(input("Your guess: "))
    except ValueError:
        print("Please type a whole number.")
        continue
    guesses += 1
    if guess < secret:
        print("Too low.")
    elif guess > secret:
        print("Too high.")
    else:
        print(f"Correct! You took {guesses} guesses.")
        break
▸ OUTPUT (example session)
I'm thinking of a number from 1 to 20.
Your guess: 10
Too high.
Your guess: 5
Too low.
Your guess: 7
Correct! You took 3 guesses.

The while True loop runs forever until break stops it on a correct guess. The try/except keeps a bad entry from crashing the game.

Project 2: Temperature converter

This project turns the conversion formulas into reusable functions, then offers the user a simple menu. It practises functions, f-strings, and clean output formatting:

PYTHON
def c_to_f(c):
    return c * 9 / 5 + 32

def f_to_c(f):
    return (f - 32) * 5 / 9

choice = input("Convert C to F or F to C? ").strip().upper()
value = float(input("Enter the temperature: "))

if choice == "C TO F":
    print(f"{value}C = {c_to_f(value):.1f}F")
elif choice == "F TO C":
    print(f"{value}F = {f_to_c(value):.1f}C")
else:
    print("Unknown choice.")
▸ OUTPUT (user chose C to F and typed 100)
Convert C to F or F to C? c to f
Enter the temperature: 100
100.0C = 212.0F

Because we called .upper() on the choice, the user can type in any mix of upper and lower case. Each formula lives in its own function, so you could reuse them in another program.

Project 3: Simple to-do list

This project stores tasks in a list and lets the user add, view, and remove them in a loop. It brings together lists, a menu-driven while loop, and functions:

PYTHON
tasks = []

def show(tasks):
    if not tasks:
        print("(no tasks)")
    for i, task in enumerate(tasks, start=1):
        print(f"{i}. {task}")

while True:
    command = input("add / list / quit: ").strip().lower()
    if command == "add":
        tasks.append(input("Task: "))
    elif command == "list":
        show(tasks)
    elif command == "quit":
        print("Goodbye!")
        break
    else:
        print("Unknown command.")
▸ OUTPUT (example session)
add / list / quit: add
Task: Buy milk
add / list / quit: add
Task: Call Sam
add / list / quit: list
1. Buy milk
2. Call Sam
add / list / quit: quit
Goodbye!

The built-in enumerate function gives both a counter and each item, which is perfect for numbered lists. As an extension, you could save the tasks to a file so they survive after the program closes, using the file-handling skills from an earlier chapter.

How to keep learning

You now have a solid foundation. To grow further:

  • Build things. Pick small projects you find useful: a tip calculator, a password generator, a quiz. Building cements knowledge far better than reading alone.
  • Read the official docs. The Python documentation and its tutorial at docs.python.org are excellent and free.
  • Learn to debug. Read error messages carefully, print values to see what your program is really doing, and change one thing at a time.
  • Explore libraries. When ready, try pandas for data, flask for web apps, or pygame for games.
  • Practice regularly. A little coding most days beats an occasional marathon. Sites with small exercises are great for keeping sharp.

Congratulations on reaching the end of this course. Programming is a craft you improve for a lifetime, and you have taken the most important step: starting. Keep writing code, stay curious, and enjoy the power to turn your ideas into working programs.