Flow, Loops, and Classes

Scratch notebook for this session: Open Python Apps Tutorial

1. Flow Control

1.1 Sequential Execution

In Python, code is executed one line at a time, from top to bottom, unless you use something that changes the flow (like a function call or a loop).

print("Step 1")
print("Step 2")
print("Step 3")
Step 1
Step 2
Step 3

Each line is executed in order.

1.2 Conditional Statements (if, elif, else)

Conditional statements let your code make decisions based on certain conditions.

1.2.1 Basic Structure:

if condition:
    # code runs if condition is True
elif other_condition:
    # code runs if other_condition is True
else:
    # code runs if none of the above are True
# Example:

age = 24

if age >= 21:
    print("You can legally drink alcohol in the U.S.")
elif age >= 18:
    print("You are an adult, but not old enough to drink.")
else:
    print("You are a minor.")
You can legally drink alcohol in the U.S.

1.2.2 Multiple Conditions:

You can also combine conditions using and, or, and not.

# Example

temperature_f = 75  # Fahrenheit
is_raining = False

if temperature_f > 70 and not is_raining:
    print("It's a great day for a baseball game!")
It's a great day for a baseball game!

1.3 Conditional (Ternary) Expressions

A conditional (ternary) expression lets you assign a value based on a condition in a single line.

Syntax: x = a if condition else b

# Example

age = 20
status = "can rent a car" if age >= 25 else "cannot rent a car yet"
print(status)
cannot rent a car yet

1.4 pass, break, continue

These keywords control how your code flows in loops and conditionals.

pass

pass does nothing. It’s used as a placeholder when code is required but you have nothing to write yet.

if True:
    pass  # Placeholder for future code

Suppose you’re building a menu system for a fast-food restaurant app, and you haven’t yet decided what to do if the customer selects a menu item that’s “coming soon.” You want your code to run without errors, but you’re not ready to implement that part yet.

menu_item = "Pumpkin Spice Latte"

if menu_item == "Pumpkin Spice Latte":
    # Feature coming soon!
    pass
else:
    print(f"Preparing your {menu_item}.")

break

break exits the loop immediately.

for i in range(5):
    if i == 2:
        break
    print(i)
0
1

Imagine you’re searching for a specific item (like your keys) in a list of rooms. As soon as you find the keys, you want to stop searching.

rooms = ["kitchen", "living room", "bedroom", "garage"]
for room in rooms:
    print(f"Searching in the {room}...")
    if room == "bedroom":
        print("Found the keys!")
        break
Searching in the kitchen...
Searching in the living room...
Searching in the bedroom...
Found the keys!

continue

continue skips the rest of the current loop iteration and moves to the next one.

for i in range(5):
    if i == 2:
        continue
    print(i)
0
1
3
4

Suppose you’re reading through a list of email subject lines. You want to print all of them except spam emails, which contain the word “SPAM.” You skip printing any email that’s spam.

emails = ["Meeting at 10am", "SPAM: Win a free iPhone", "Lunch plans?", "SPAM: Hot deals"]
for subject in emails:
    if "SPAM" in subject:
        continue  # Skip spam emails
    print(f"Important email: {subject}")
Important email: Meeting at 10am
Important email: Lunch plans?

2. Loops

2.1 for Loops

A for loop is used to repeat actions a certain number of times or to iterate over a sequence (like a list).

# Print numbers from 0 to 4
for i in range(5):
    print(i)
0
1
2
3
4

You can loop over the elements of a list like this:

animals = ['cat', 'dog', 'monkey']
for animal in animals:
    print(animal)
cat
dog
monkey

If you want access to the index of each element within the body of a loop, use the built-in enumerate() function:

animals = ['cat', 'dog', 'monkey']
for index, animal in enumerate(animals):
    print('#{}: {}'.format(index + 1, animal))
#1: cat
#2: dog
#3: monkey

2.2 while Loops

A while loop repeats actions as long as a condition is true.

count = 0
while count < 3:
    print(count)
    count += 1
0
1
2

2.3 Nested Loops

A nested loop is a loop inside another loop.

for i in range(2):
    for j in range(2):
        print(f"i={i}, j={j}")
i=0, j=0
i=0, j=1
i=1, j=0
i=1, j=1

2.4 Loops with else

The else part after a loop runs only if the loop was not stopped by a break.

for i in range(3):
    print(i)
else:
    print("Loop finished without break.")
0
1
2
Loop finished without break.

2.5 enumerate, zip

enumerate

enumerate gives you both the index and the value when looping over a list.

names = ["Alice", "Bob", "Charlie"]
for index, name in enumerate(names):
    print(index, name)
0 Alice
1 Bob
2 Charlie

zip

zip lets you loop over two (or more) lists at the same time.

fruits = ["apple", "banana", "cherry"]
colors = ["red", "yellow", "red"]
for fruit, color in zip(fruits, colors):
    print(fruit, color)
apple red
banana yellow
cherry red

2.6 List Comprehensions

A list comprehension is a short way to create a new list by looping over something.

[i for i in range(10)]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[i**2 for i in range(10)]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

We can even add some conditions.

[i for i in range(10) if i > 5]
[6, 7, 8, 9]

Multiples of 2.

[i for i in range(10) if i % 2 == 0]
[0, 2, 4, 6, 8]

Powers of 2.

[i**2 for i in range(10)]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

You can nest comprehensions.

[[[i, j] for i in range(5)] for j in range(5)]
[[[0, 0], [1, 0], [2, 0], [3, 0], [4, 0]],
 [[0, 1], [1, 1], [2, 1], [3, 1], [4, 1]],
 [[0, 2], [1, 2], [2, 2], [3, 2], [4, 2]],
 [[0, 3], [1, 3], [2, 3], [3, 3], [4, 3]],
 [[0, 4], [1, 4], [2, 4], [3, 4], [4, 4]]]

You can concatenate multiple comprehensions.

[[i, j] for i in range(5) for j in range(5) if i < j]
[[0, 1],
 [0, 2],
 [0, 3],
 [0, 4],
 [1, 2],
 [1, 3],
 [1, 4],
 [2, 3],
 [2, 4],
 [3, 4]]

3. Classes (OOP)

3.1 What is a Class? Why OOP?

In Python, a class is a code construct used to define a new type of object, grouping together data and functions that operate on that data. Classes provide a means of bundling data and functionality together. Creating a new class creates a new type of object, allowing new instances of that type to be made.

Object-Oriented Programming (OOP) is a programming paradigm based on the concept of “objects,” which can contain both data (attributes) and code (methods).

When you create an object using a class, that object is called an instance of the class.

Example from the Python Standard Tutorial

“Classes provide a means of bundling data and functionality together. Creating a new class creates a new type of object, allowing new instances of that type to be made. Each class instance can have attributes attached to it for maintaining its state. Class instances can also have methods (defined by its class) for modifying its state.” (Python Official Tutorial)

Simple Example

Here is a simple class that models a basic point in two-dimensional space:

class Point:
    """A class to represent a point in 2D space."""
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(2, 3)
print(p.x)  # Output: 2
print(p.y)  # Output: 3
2
3

In this example:

  • Point is a class.

  • p is an instance of Point, with specific x and y coordinates.

  • The init method is called the constructor and initializes the instance attributes.

3.2 Defining a Class

In Python, a class is defined using the class keyword, followed by the class name and a colon. The body of the class contains statements that define its attributes and methods.

Here is the simplest possible class definition:

class Dog:
    pass  # 'pass' is a placeholder indicating an empty block

This statement defines a new class named Dog. At this stage, the class has no attributes (data) or methods (actions). It serves as a minimal template from which instances can be created.

Note: By convention, class names in Python use the CapitalizedWords naming style (also known as CamelCase).

Explanation

  • class Dog: declares a class named Dog.
  • The pass statement is syntactically required because Python expects an indented block after the colon; here, it indicates that the class has no content yet.
  • This class can later be extended with attributes (e.g., name, age) and methods (e.g., bark()).

3.3 Constructor Method __init__

The constructor method __init__ is a special method in Python classes. It is called automatically when a new instance of the class is created. This method is commonly used to initialize (set up) the attributes of the new object.

  • The first parameter of __init__ is always self, which refers to the instance being created.
  • Additional parameters allow you to pass in initial values for the object’s attributes.
class Dog:
    def __init__(self, name, age):
        self.name = name  # Each dog has a name
        self.age = age    # Each dog has an age

In this example, every time a new Dog object is created, you must provide a name and an age, which are stored as attributes of the instance.

3.4 Creating Objects

To create (or instantiate) an object from a class, call the class as if it were a function, passing any required arguments to the constructor (__init__ method).

my_dog = Dog("Buddy", 3)
print(my_dog.name)  # Output: Buddy
print(my_dog.age)   # Output: 3
Buddy
3

Here,

  • my_dog is an instance of the Dog class, initialized with the name "Buddy" and age 3.
  • The attributes name and age are accessed using dot notation (for example, my_dog.name).

Summary: Instantiating a class creates a new object with its own unique set of data, as specified by the constructor.

3.5 Instance Methods

Instance methods are functions defined inside a class that operate on individual instances of that class. They can access and modify the data (attributes) that belong to the specific object.

  • Every instance method has self as its first parameter. self refers to the instance through which the method is called, allowing access to the object’s attributes.

Example:

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

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

my_dog = Dog("Buddy")
my_dog.bark()  # Output: Buddy says woof!
Buddy says woof!

In this example:

  • bark is an instance method.
  • It uses self.name to access data specific to that instance.
  • When you call my_dog.bark(), the method prints "Buddy says woof!".

Summary: Instance methods enable each object created from a class to perform behaviors that may depend on its own data.

3.6 Class Attributes vs. Instance Attributes

Class attributes are shared by all objects of the class. Instance attributes belong only to one specific object.

class Dog:
    species = "Canine"  # Class attribute

    def __init__(self, name):
        self.name = name  # Instance attribute

dog1 = Dog("Buddy")
dog2 = Dog("Bella")

print(dog1.species)  # Output: Canine
print(dog2.species)  # Output: Canine

print(dog1.name)     # Output: Buddy
print(dog2.name)     # Output: Bella
Canine
Canine
Buddy
Bella
  • species is a class attribute. It is shared by all instances of the class Dog.
  • name is an instance attribute. Each object has its own separate value for name.

When you access dog1.species or dog2.species, both will return "Canine", because species is shared.

When you access dog1.name and dog2.name, they return "Buddy" and "Bella" respectively, because name is specific to each object.

3.7 Inheritance

Inheritance is a fundamental feature of object-oriented programming. It allows you to define a new class (called a subclass or derived class) based on an existing class (called a superclass or base class). The subclass inherits all the attributes and methods of the superclass, and can also introduce its own or override existing ones.

This enables code reuse and logical hierarchy between classes.

Example:

class Animal:
    def speak(self):
        print("This animal makes a sound.")

class Dog(Animal):
    def bark(self):
        print("Woof!")

my_dog = Dog()
my_dog.speak()  # Output: This animal makes a sound.
my_dog.bark()   # Output: Woof!
This animal makes a sound.
Woof!

Explanation:

  • Animal is the superclass.
  • Dog is the subclass, which inherits from Animal.
  • Dog automatically has the speak method from Animal, and also defines its own bark method.
  • When you create an instance of Dog, you can call both speak (inherited) and bark (defined in Dog).

Summary: Inheritance lets you build classes that share common behaviors, reducing code duplication and making code easier to maintain.

3.8 Method Overriding

Method overriding allows a subclass to provide a specific implementation of a method that is already defined in its superclass. This enables a subclass to change or extend the behavior of inherited methods.

Example:

class Animal:
    def speak(self):
        print("Animal sound")

class Dog(Animal):
    def speak(self):
        print("Bark!")  # Overrides the speak method

my_dog = Dog()
my_dog.speak()  # Output: Bark!
Bark!

Explanation:

  • The Animal class defines a method called speak.
  • The Dog subclass defines its own speak method, which overrides the one inherited from Animal.
  • When speak() is called on a Dog instance, the version in Dog is executed instead of the version in Animal.

Summary: Method overriding allows subclasses to modify or completely replace behaviors inherited from a parent class.

3.9 Magic Methods (Dunder Methods)

Magic methods (also known as dunder methods, because they start and end with double underscores) are special methods in Python that allow you to define how your objects interact with Python’s built-in functions and operators.

Some commonly used magic methods include:

  • __init__ — called when a new object is created (the constructor)
  • __str__ — defines what should be returned when the object is printed with print()

Example:

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

    def __str__(self):
        return f"This dog's name is {self.name}."

my_dog = Dog("Buddy")
print(my_dog)  # Output: This dog's name is Buddy.
This dog's name is Buddy.

Explanation:

  • The __init__ method initializes the name attribute when a new Dog object is created.
  • The __str__ method returns a string representation of the object, which is used when you call print() on the object.

Summary: Magic methods let you customize how your objects behave with standard Python operations, such as printing, addition, comparison, and more.

3.10 Class Methods and Static Methods

  • Class methods affect the class itself, not just one object.

  • Static methods are like normal functions, but live inside the class.

class Dog:
    dogs_count = 0  # Class attribute

    def __init__(self, name):
        self.name = name
        Dog.dogs_count += 1

    @classmethod
    def get_dogs_count(cls):
        return cls.dogs_count

    @staticmethod
    def bark():
        print("Woof!")

dog1 = Dog("Buddy")
dog2 = Dog("Bella")
print(Dog.get_dogs_count())  # Output: 2
Dog.bark()  # Output: Woof!
2
Woof!

Explanation:

  • dogs_count is a class attribute, shared by all instances of the Dog class.
  • The @classmethod decorator is used to define a class method. Class methods receive the class itself as the first argument, conventionally named cls.
    • get_dogs_count returns the current count of all Dog instances.
  • The @staticmethod decorator is used to define a static method. Static methods do not receive an implicit first argument (neither the class nor the instance).
    • bark can be called on the class itself, and behaves like a regular function, but lives inside the class’s namespace.

Summary: - Class methods can modify or access class-level data that is shared across all instances. - Static methods are utility functions that have a logical connection to the class, but do not access or modify class or instance data.

3.11 Simple AI Assistant Class

This example builds a basic AI Assistant class. The assistant can answer questions, remember a history of questions, and keep track of how many questions it has been asked.

class AIAssistant:
    total_questions = 0  # Class attribute to count total questions asked to all assistants

    def __init__(self, name):
        self.name = name
        self.question_history = []  # Instance attribute to store asked questions

    def answer(self, question):
        """Answer a question and save it to history."""
        AIAssistant.total_questions += 1
        self.question_history.append(question)
        print(f"{self.name}: You asked, '{question}'")
        # Very basic response logic:
        if "weather" in question.lower():
            print("I'm not connected to the internet, but I hope it's sunny!")
        elif "name" in question.lower():
            print(f"My name is {self.name}.")
        else:
            print("That's an interesting question!")

    def show_history(self):
        """Show all questions this assistant has been asked."""
        print(f"Questions asked to {self.name}:")
        for q in self.question_history:
            print(f"- {q}")

    @classmethod
    def show_total_questions(cls):
        """Show how many questions have been asked to all AI assistants."""
        print(f"Total questions asked to all assistants: {cls.total_questions}")

# Example usage:
ai1 = AIAssistant("Alexa")
ai2 = AIAssistant("Siri")

ai1.answer("What's the weather today?")
ai1.answer("What's your name?")
ai2.answer("Can you help me with my homework?")

ai1.show_history()
ai2.show_history()
AIAssistant.show_total_questions()
Alexa: You asked, 'What's the weather today?'
I'm not connected to the internet, but I hope it's sunny!
Alexa: You asked, 'What's your name?'
My name is Alexa.
Siri: You asked, 'Can you help me with my homework?'
That's an interesting question!
Questions asked to Alexa:
- What's the weather today?
- What's your name?
Questions asked to Siri:
- Can you help me with my homework?
Total questions asked to all assistants: 3

4. Exceptions

What Is an Exception?

An exception is a special object that signals an error or an unexpected situation in your program. For example, dividing by zero or trying to open a file that does not exist will cause an exception.

Python uses a try/except block to handle exceptions, so your program doesn’t crash when something goes wrong.

Basic Structure

Here is the basic structure of handling exceptions in Python:

try:
    # Code that might cause an exception
    pass
except SomeException:
    # Code that runs if that specific exception happens
    pass

Full Example

Let’s see how it works in practice:

try:
    # Try to do something risky
    number = 1 / 1  # Change this to 1 / 0 to see an exception
except ZeroDivisionError as e:
    print("Caught a ZeroDivisionError!")
    print(e)
except Exception as e:
    print("Caught a general exception.")
    print(e)
else:
    print("There were no exceptions.")
finally:
    print("This is always executed (cleanup, closing files, etc.).")
There were no exceptions.
This is always executed (cleanup, closing files, etc.).

Common Exceptions

Here are some common Python exceptions you might see:

  • ZeroDivisionError – trying to divide by zero

  • ValueError – wrong value type (e.g., int(“abc”))

  • TypeError – wrong type used (e.g., adding a string to a number)

  • FileNotFoundError – file doesn’t exist

  • IndexError – list index out of range

Why Handle Exceptions?

If you don’t handle exceptions, your program will crash as soon as it encounters an error. Handling exceptions lets your program keep running or fail gracefully.

Real-life analogy: Exception handling is like wearing a seatbelt. If something unexpected happens, you stay safe instead of getting hurt!

Practice Exercise

Try dividing by zero and catch the exception:

try:
    result = 10 / 0
except ZeroDivisionError:
    print("Oops! You can't divide by zero.")
Oops! You can't divide by zero.

5. Generators (Iterators)

What Is an Iterator?

An iterator is an object that lets you access items in a collection, one at a time. You can get the next item from an iterator using the next() function.

  • Lists, tuples, dictionaries, and strings are all iterable, meaning you can loop through them using a for loop.

The yield statement allows you to create your own iterator, called a generator. Generators are a memory-efficient way to produce values one at a time, only when you need them.

def range_custom(n):
    i = 0
    while i < n:
        yield i
        i += 1

Create the generator.

gen = range_custom(3)

The type of the variable named gen is generator.

type(gen)
generator

Iterate through the generator values.

next(gen)
0
next(gen)
1
next(gen)
2

The generator raises an exception when it reaches the end. If you keep calling next() after the generator is finished, you will get a StopIteration error.

# the following call will raise an exception
# next(gen)