Ch6 and Ch10: Designs with functions and classes

Fundamentals of Python: First Programs 3rd edition

Mark Ira Galang

PART 1: DESIGN WITH FUNCTIONS

Chapter 6 Overview

Objectives (1 of 2)

  • 6.1 Explain why functions are useful in structuring code
  • 6.2 Employ top-down design to assign tasks to functions
  • 6.3 Define a recursive function
  • 6.4 Explain the use of the namespace and exploit it effectively

Objectives (2 of 2)

  • 6.5 Define a function with required and optional parameters
  • 6.6 Use higher-order functions for mapping, filtering, and reducing

What is a Function?

  • A function packages an algorithm in a chunk of code you can call by name
  • Can be called from anywhere in a program, including within other functions
  • Can receive data from its caller via arguments
  • May have one or more return statements
def square(x):
    """Returns the square of x."""
    return x * x

result = square(5)  # result = 25

Functions as Abstraction Mechanisms

  • An abstraction hides detail
  • Allows viewing many things as just one thing
  • We use abstractions for everyday tasks:
    • “Doing my laundry” (hides washing, drying, folding)
    • “Driving to work” (hides steering, braking, accelerating)

Effective designers invent useful abstractions to control complexity

Examples of abstraction (1 of 4)

What does the following function do?

calculate_tax(income) 

Examples of abstraction (2 of 4)

Things that the calculate_tax(income) function could do:

  • Check Tax Brackets
  • Apply Deductions
  • Calculate Marginal Rates
  • Return tax amount

Examples of abstraction (3 of 4)

What does the following function do?

is_strong_password(password) 

Examples of abstraction (4 of 4)

Things that the is_strong_password(password) function could do:

  • Check how many digits
  • Check how many characters
  • Check for special characters
  • Calculate a score
  • Return how strong the password is

Functions Eliminate Redundancy

Functions eliminate repetitive code:

def summation(lower, upper):
    """Returns sum of numbers from lower through upper"""
    result = 0
    while lower <= upper:
        result += lower
        lower += 1
    return result

print(summation(1, 4))    
print(summation(50, 100))  
10
3825

One function, many uses!

Functions Hide Complexity

  • The idea of summing numbers is simple
  • The code for summation is more complex
  • Function call expresses the idea without wading through details
# Simple to understand:
total = summation(1, 100)
print(total)

# Instead of writing the loop every time
result = 0
lower = 1
while lower <= 100:
      result += lower
      lower += 1
print(result)
5050
5050

Functions Support General Methods

  • An algorithm solves a class of problems
  • Problem instances are individual problems in that class
  • Functions should be general enough for many instances
# General method - works for any range
def summation(lower, upper):
    # Works for (1,10), (5,20), (100,200), etc.

Functions Support Division of Labor

  • Each function performs a single coherent task
  • Functions collaborate to achieve a common goal
def get_user_input():
    # Just gets input
    
def process_data(data):
    # Just processes data
    
def display_results(results):
    # Just displays results
    
def main():
    data = get_user_input()
    results = process_data(data)
    display_results(results)

Problem Solving with Top-Down Design

Top-down design breaks a problem into smaller, manageable subproblems

  • Process known as problem decomposition
  • Each subproblem assigned to a function
  • Also called stepwise refinement

Structure Chart Example: Text Analysis

                    ┌─────────────┐
                    │    main     │
                    └─────────────┘
                           │
            ┌──────────────┼──────────────┐
            ▼              ▼              ▼
    ┌───────────┐  ┌───────────┐  ┌───────────┐
    │ readFile  │  │ analyze   │  │ display   │
    └───────────┘  └───────────┘  └───────────┘
                         │
                    ┌────┴────┐
                    ▼         ▼
              ┌─────────┐ ┌─────────┐
              │ count   │ │ find    │
              │ words   │ │ mode    │
              └─────────┘ └─────────┘
  • Each box = function name
  • Lines = data flow between functions

Structure Chart: Sentence Generator

                    ┌─────────────┐
                    │    main     │
                    └─────────────┘
                           │
                    ┌──────┴──────┐
                    ▼             ▼
            ┌───────────┐  ┌───────────┐
            │ sentence  │  │ get       │
            │ generator │  │ input     │
            └───────────┘  └───────────┘
                  │
        ┌─────────┼─────────┐
        ▼         ▼         ▼
    ┌───────┐ ┌───────┐ ┌───────┐
    │ noun  │ │ verb  │ │ prep  │
    │ phrase│ │ phrase│ │ phrase│
    └───────┘ └───────┘ └───────┘

Problem structure often suggests program structure!

Structure Chart: Lab4e: Grade Reporter

                    ┌─────────────┐
                    │    main     │
                    └─────────────┘
                           │
                    ┌──────┴──────┐
                    ▼             ▼
            ┌───────────┐  ┌───────────┐
            │ calculate │  │ get       │
            │ grades    │  │ input     │
            └───────────┘  └───────────┘
                  │
            ┌─────┴─────┐
            ▼           ▼
    ┌───────────┐ ┌───────────┐
    │ return    │ │ return    │
    │ letter    | | file      |
    │ grade     │ | report    │
    └───────────┘ └───────────┘

RECURSIVE FUNCTIONS

What is Recursion?

  • A recursive function calls itself
  • Must contain a base case (stopping condition)
  • Must contain a recursive step (reduces problem size)
def countdown(n):
    if n <= 0:              # Base case
        print("Blastoff!")
    else:                   # Recursive step
        print(n)
        countdown(n - 1)

Converting Iteration to Recursion

Iterative version:

def displayRange(lower, upper):
    while lower <= upper:
        print(lower)
        lower += 1

Recursive version:

def displayRange(lower, upper):
    if lower <= upper:           # Base case check
        print(lower)
        displayRange(lower + 1, upper)  # Recursive call

Recursive Summation

def summation(lower, upper):
    """Returns sum of numbers from lower through upper"""
    if lower > upper:
        return 0                # Base case
    else:
        return lower + summation(lower + 1, upper)  # Recursive step

# summation(1, 4) = 1 + 2 + 3 + 4 = 10

Tracing a Recursive Function

def summation(lower, upper, margin):
    blanks = " " * margin
    print(blanks, lower, upper)
    
    if lower > upper:
        print(blanks, 0)
        return 0
    else:
        result = lower + summation(lower + 1, upper, margin + 4)
        print(blanks, result)
        return result

# Output for summation(1, 4, 0):
# 1 4
#     2 4
#         3 4
#             4 4
#                 5 4
#                 0
#             4
#         7
#     9
# 10

Recursive Definitions: Fibonacci

Recursive definition: - Fib(1) = 1 (base case) - Fib(2) = 1 (base case) - Fib(n) = Fib(n-1) + Fib(n-2) for n > 2

def fib(n):
    """Returns the nth Fibonacci number"""
    
    # Two base cases
    if n == 0:
        return 0 
    elif n == 1: 
        return 1
        
    # recursive
    else:
        return fib(n - 1) + fib(n - 2)  # Recursive case

print(fib(4)) # fib(3) + fib(2)
# fib(2) + fib(1) + fib(1) + fib(0) = 1 + 1 + 1 + 0
3

Recursion in Math Structure

def factorial(n):
    if n < 0:
        raise ValueError("Factorial is not defined for negative numbers")
    if n == 0:
        return 1
    return n * factorial(n - 1)

Recursion flows naturally from problem structure!

Infinite Recursion

Occurs when: - No base case is specified, OR - Problem size doesn’t decrease toward base case

def runForever(n):
    if n > 0:
        runForever(n)        # n never changes!
    else:
        runForever(n - 1)    # n is already not > 0

runForever(1)  # RecursionError!

Always ensure your recursion terminates!

Costs and Benefits of Recursion

Costs: - Each call creates a stack frame (uses memory) - Memory grows with problem size - Slower than iteration for same task

Benefits: - Elegant for naturally recursive problems - Matches problem structure - Easier to understand for some algorithms

Iteration Recursion
Uses less memory Uses more memory
Faster Slower
Loops Function calls
Good for simple repetition Good for tree-like problems

MANAGING NAMESPACES

Understanding Namespace

A program’s namespace is the set of its variables and their values

Three levels:

  • Module variables (global to file)
  • Parameters (passed to functions)
  • Temporary variables (inside functions)

Module Variables, Parameters, and Temporaries

replacements = {"I": "you", "me": "you"}  # Module variable

def changePerson(sentence):                # sentence = parameter
    words = sentence.split()              # words = temporary
    replyWords = []                        # replyWords = temporary
    for word in words:
        replyWords.append(replacements.get(word, word))
    return " ".join(replyWords)
  • Module variables: exist from introduction onward
  • Parameters: get values when function is called
  • Temporary variables: exist only during function execution

Scope (1 of 2)

Scope = area where a name refers to a given value

  • Temporary variables: restricted to function body
  • Parameters: invisible outside function definition
  • Module variables: entire module below introduction point
x = 5                    # Module variable (global)

def f():
    x = 10               # Creates NEW temporary x
    print(x)             # Prints 10

f()                      # Call function
print(x)                 # Prints 5 (global unchanged!)

Scope (2 of 2)

Functions can READ module variables but cannot normally ASSIGN to them

count = 0                # Module variable

def increment():
    # This creates a NEW local variable!
    count = count + 1    # Error: local 'count' referenced before assignment
    return count

# To modify global variable:
def increment_global():
    global count         # Tell Python we mean the global one
    count += 1
    return count

Lifetime of Variables

Lifetime = period when variable has memory storage

Variable Type Lifetime
Module variable Entire program execution
Parameter During function call
Temporary During function call
  • Memory allocated when variable “comes into existence”
  • Memory reclaimed when variable “goes out of existence”

OPTIONAL AND DEFAULT ARGUMENTS

Default Arguments

Specify optional arguments with default values:

Print always defaults to \n

print("Hello World!")
print("Hello World!", end = " ") # change default from "\n" to " "

For custom functions

def greeting(name, place="UOG"):
    """Converts string representation to integer"""
    print(f"Hello {name}! Welcome to {place}")

greeting("Mark Ira")
greeting("Mark Ira", "Chili's")
Hello Mark Ira! Welcome to UOG
Hello Mark Ira! Welcome to Chili's

Multiple Default Arguments

def example(required, option1=2, option2=3):
    print(required, option1, option2)

example(1)                 # 1 2 3 (all defaults)
example(1, 10)             # 1 10 3 (override first)
example(1, 10, 20)         # 1 10 20 (override both)
example(1, option2=20)     # 1 2 20 (override by name)
example(1, option2=20, option1=10)  # 1 10 20 (any order)
1 2 3
1 10 3
1 10 20
1 2 20
1 10 20

Keywords allow skipping defaults!

HIGHER-ORDER FUNCTIONS

Functions as First-Class Objects

Functions can be:

  • Assigned to variables
  • Passed as arguments
  • Returned from other functions
  • Stored in data structures
import math
abs
f = abs              
f(-4)                
funcs = [abs, math.sqrt]  
funcs[1](16)         
4.0

Passing Functions as Arguments

def example(funcArg, dataArg):
    return funcArg(dataArg)

print(example(abs, -4))        
print(example(math.sqrt, 16))   
4
4.0

Functions are just data!

Mapping

Mapping applies a function to each value in a sequence and returns results

# Convert list of strings to list of integers
words = ["231", "20", "-45", "99"]
numbers = list(map(int, words))
print(numbers)   

# Original list unchanged
print(words)   
[231, 20, -45, 99]
['231', '20', '-45', '99']

Mapping with Custom Function

replacements = {"I": "you", "me": "you", "my": "your"}

def changePerson(sentence):
    def getWord(word):
        return replacements.get(word, word)
    
    return " ".join(map(getWord, sentence.split()))

print(changePerson("I love my cat"))
you love your cat

Filtering

Filtering applies a predicate (Boolean function) to each value - If predicate returns True, value is kept - If False, value is dropped

def odd(n):
    return n % 2 == 1

odds = list(filter(odd, range(10)))
print(odds)   

# Or with lambda:
odds = list(filter(lambda n: n % 2 == 1, range(10)))
[1, 3, 5, 7, 9]

Reducing

Reducing repeatedly applies a function to accumulate a single value

from functools import reduce

def add(x, y):
    return x + y

def multiply(x, y):
    return x * y

data = [1, 2, 3, 4]

print(reduce(add, data))       
print(reduce(multiply, data))  
10
24

Lambda Functions (Anonymous)

Lambda creates an anonymous function on the fly

Syntax: lambda args: expression

# Traditional function
def add(x, y):
    return x + y

# Lambda equivalent
add = lambda x, y: x + y

# Used directly in reduce
data = [1, 2, 3, 4]
print(reduce(lambda x, y: x + y, data))      
print(reduce(lambda x, y: x * y, data))      
10
24

Lambda must be a single expression (no statements)!

Lambda in Summation

def summation(lower, upper):
    if lower > upper:
        return 0
    else:
        return reduce(lambda x, y: x + y, range(lower, upper + 1))

print(summation(1, 10))  # 55
55

Clean, functional style!

Jump Tables (Dictionary of Functions)

def insert():
    print("Inserting...")

def replace():
    print("Replacing...")

def remove():
    print("Removing...")

# Jump table maps commands to functions
jumpTable = {
    "1": insert,
    "2": replace,
    "3": remove
}

def runCommand(command):
    jumpTable[command]()  # Call the function

runCommand("2")  
Replacing...

Eliminates long if-elif chains!

PART 2: DESIGN WITH CLASSES

Chapter 9 Overview

Objectives (1 of 2)

  • 9.1 Determine attributes and behavior of a class
  • 9.2 List methods with parameters and return types
  • 9.3 Choose data structures for attributes
  • 9.4 Define constructor, instance variables, and methods
  • 9.5 Recognize need for class variables

Objectives (2 of 2)

  • 9.6 Define __str__ method
  • 9.7 Define methods for equality and comparisons
  • 9.8 Exploit inheritance and polymorphism
  • 9.9 Transfer objects to/from files

From Functions to Classes

Procedural (Functions):

def create_student(name, scores):
    return {"name": name, "scores": scores}

def get_average(student):
    return sum(student["scores"]) / len(student["scores"])

Object-Oriented (Classes):

class Student:
    def __init__(self, name):
        self.name = name
        self.scores = []
    
    def get_average(self):
        return sum(self.scores) / len(self.scores)

What is a Class?

  • A class is a blueprint for creating objects
  • Defines what data an object has (attributes)
  • Defines what an object can do (methods)
  • Objects are instances of a class
┌─────────────────────┐
│      Student        │  ← Class (Blueprint)
├─────────────────────┤
│ - name              │
│ - scores            │
├─────────────────────┤
│ + getName()         │
│ + getAverage()      │
│ + setScore()        │
└─────────────────────┘

Objects as Abstractions

Objects package state and methods in a single entity

# Creating objects from the Student class
student1 = Student("Maria", 5)
student2 = Student("Juan", 5)

# Each object has its own state
student1.setScore(1, 100)  # Maria's first score = 100
student2.setScore(1, 85)   # Juan's first score = 85

# Same method, different objects, different results
print(student1.getAverage())  # 20.0
print(student2.getAverage())  # 17.0

Class Definition Syntax

class <class name>(<parent class>):
    """Docstring describing the class"""
    
    def method1(self, arg1):
        """Method docstring"""
        # method body
    
    def method2(self, arg2):
        # method body
  • Class names typically capitalized
  • object is the root parent class
  • Every method has self as first parameter

First Example: Student Class

class Student(object):
    """Represents a student with test scores"""
    
    def __init__(self, name, number):
        """Creates a student with given name and number of scores"""
        self.name = name
        self.scores = []
        for count in range(number):
            self.scores.append(0)
    
    def getName(self):
        """Returns the student's name"""
        return self.name
    
    def getScore(self, i):
        """Returns the ith score (1-indexed)"""
        return self.scores[i - 1]

Using the Student Class

>>> from student import Student
>>> s = Student("Maria", 5)
>>> print(s.getName())
Maria
>>> s.setScore(1, 100)
>>> print(s.getScore(1))
100
>>> print(s.getAverage())
20.0
>>> print(s.getHighScore())
100

Class Hierarchy

        ┌─────────────┐
        │   object    │  ← Root of all classes
        └─────────────┘
               ▲
               │
        ┌─────────────┐
        │   Student   │  ← Our class
        └─────────────┘
               ▲
               │
        ┌─────────────┐
        │ GradStudent │  ← Subclass (inherits from Student)
        └─────────────┘

Terminology: Subclass, Parent class, Superclass

The __init__ Method (Constructor)

  • Runs automatically when object is created
  • Initializes instance variables
  • self refers to the object being created
def __init__(self, name, number):
    """All scores are initially 0"""
    self.name = name           # Instance variable
    self.scores = []           # Instance variable
    for count in range(number):
        self.scores.append(0)

# Called automatically:
s = Student("Maria", 5)        # __init__ runs here

Instance Variables

  • Represent object attributes (state)
  • Store data unique to each object
  • Prefix with self.
  • Scope is entire class
class Student:
    def __init__(self, name, number):
        self.name = name        # Each student has own name
        self.scores = []        # Each student has own scores
    
    def someMethod(self):
        # Can access instance variables anywhere in class
        print(self.name)        # OK
        print(self.scores)      # OK

The __str__ Method

  • Returns string representation of object
  • Called by str() and print()
  • Essential for debugging
def __str__(self):
    """Returns string representation of the student"""
    result = "Name: " + self.name + "\n"
    result += "Scores: " + " ".join(map(str, self.scores))
    return result

# Usage:
s = Student("Maria", 5)
print(s)  # Calls __str__ automatically
# Output:
# Name: Maria
# Scores: 0 0 0 0 0

Accessors vs Mutators

Accessors: Observe state without changing it

def getName(self):
    return self.name           # Just returns value

def getAverage(self):
    return sum(self.scores) / len(self.scores)

Mutators: Modify object’s state

def setScore(self, i, score):
    self.scores[i - 1] = score   # Changes state

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

Tip: Don’t create mutators for attributes that shouldn’t change!

Complete Student Class Example

class Student(object):
    """Represents a student with test scores"""
    
    def __init__(self, name, number):
        self.name = name
        self.scores = []
        for count in range(number):
            self.scores.append(0)
    
    def __str__(self):
        return "Name: " + self.name + "\nScores: " + \
               " ".join(map(str, self.scores))
    
    def getName(self):
        return self.name
    
    def getScore(self, i):
        return self.scores[i - 1]
    
    def setScore(self, i, score):
        self.scores[i - 1] = score
    
    def getAverage(self):
        return sum(self.scores) / len(self.scores)
    
    def getHighScore(self):
        return max(self.scores)

Rules of Thumb for Defining Classes

  1. Think first: Identify behavior and attributes
  2. Choose name: Capitalized, meaningful
  3. Write a script: Show how class should be used
  4. Choose data structures: For attributes
  5. Fill template: Add __init__ and __str__
  6. Implement incrementally: Test as you go
  7. Document: Add docstrings

DATA MODELING EXAMPLES

Example 1: Rational Numbers

Represent fractions like 1/2, 2/3, etc.

class Rational(object):
    """Represents a rational number (numerator/denominator)"""
    
    def __init__(self, numerator, denominator):
        self.numer = numerator
        self.denom = denominator
    
    def __str__(self):
        return str(self.numer) + "/" + str(self.denom)

# Usage:
oneHalf = Rational(1, 2)
oneSixth = Rational(1, 6)
print(oneHalf)   # 1/2

Operator Overloading

Python allows overloading operators for custom classes

Operator Method Name
+ __add__
- __sub__
* __mul__
/ __truediv__
== __eq__
< __lt__
> __gt__

x + y becomes x.__add__(y)

Adding Rational Numbers

def __add__(self, other):
    """Returns sum of two rational numbers"""
    newNumer = self.numer * other.denom + other.numer * self.denom
    newDenom = self.denom * other.denom
    return Rational(newNumer, newDenom)

# Usage:
oneHalf = Rational(1, 2)
oneSixth = Rational(1, 6)
print(oneHalf + oneSixth)  # 8/12 (could be reduced)

Equality for Rational Numbers

def __eq__(self, other):
    """Tests for equality"""
    if self is other:                    # Same object?
        return True
    elif type(self) != type(other):      # Different types?
        return False
    else:
        # Compare cross-multiplied values
        return self.numer * other.denom == other.numer * self.denom

# Usage:
oneHalf = Rational(1, 2)
twoFourths = Rational(2, 4)
print(oneHalf == twoFourths)  # True (1/2 == 2/4)

Comparison Methods

def __lt__(self, other):
    """Less than comparison"""
    return self.numer * other.denom < other.numer * self.denom

def __le__(self, other):
    """Less than or equal"""
    return self < other or self == other

def __gt__(self, other):
    """Greater than"""
    return not (self < other or self == other)

# Usage:
print(Rational(1, 2) > Rational(1, 3))   # True
print(Rational(1, 2) < Rational(1, 3))   # False

Example 2: SavingsAccount

class SavingsAccount(object):
    """Represents a bank account with owner, PIN, and balance"""
    
    RATE = 0.02  # Class variable - same for all accounts!
    
    def __init__(self, name, pin, balance=0.0):
        self.name = name
        self.pin = pin
        self.balance = balance
    
    def __str__(self):
        result = "Name: " + self.name + "\n"
        result += "PIN: " + self.pin + "\n"
        result += "Balance: " + str(self.balance)
        return result

Class Variables vs Instance Variables

Class variable: Shared by ALL instances

class SavingsAccount:
    RATE = 0.02        # Class variable - one copy
    
    def computeInterest(self):
        # Access via class name
        interest = self.balance * SavingsAccount.RATE
        return interest

Instance variable: Unique to each instance

def __init__(self, name, pin, balance):
    self.name = name     # Instance variable - separate per object
    self.pin = pin       # Instance variable
    self.balance = balance

Account Methods

def deposit(self, amount):
    """Deposits amount and returns None"""
    self.balance += amount
    return None

def withdraw(self, amount):
    """Withdraws amount, returns None or error message"""
    if amount < 0:
        return "Amount must be >= 0"
    elif self.balance < amount:
        return "Insufficient funds"
    else:
        self.balance -= amount
        return None

def computeInterest(self):
    """Computes, deposits, and returns interest"""
    interest = self.balance * SavingsAccount.RATE
    self.deposit(interest)
    return interest

Example 3: Bank (Collection of Accounts)

class Bank(object):
    """Represents a collection of savings accounts"""
    
    def __init__(self, fileName=None):
        self.accounts = {}      # Dictionary: PIN → Account
        self.fileName = fileName
    
    def add(self, account):
        """Adds account to bank"""
        self.accounts[account.getPin()] = account
    
    def get(self, name, pin):
        """Returns account if name/pin match, else None"""
        for account in self.accounts.values():
            if account.getName() == name and account.getPin() == pin:
                return account
        return None

Using the Bank

>>> from bank import Bank
>>> from savingsaccount import SavingsAccount
>>> bank = Bank()
>>> bank.add(SavingsAccount("Wilma", "1001", 4000.00))
>>> bank.add(SavingsAccount("Fred", "1002", 1000.00))
>>> print(bank)
Name: Fred
PIN: 1002
Balance: 1000.00
Name: Wilma
PIN: 1001
Balance: 4000.00
>>> account = bank.get("Wilma", "1001")
>>> account.deposit(25.00)
>>> print(account)
Name: Wilma
PIN: 1001
Balance: 4025.00

Pickling: Saving Objects to Files

Pickling converts objects to storable format

import pickle

def save(self, fileName=None):
    """Saves pickled accounts to a file"""
    if fileName != None:
        self.fileName = fileName
    elif self.fileName == None:
        return
    
    fileObj = open(self.fileName, "wb")  # 'wb' = write binary
    for account in self.accounts.values():
        pickle.dump(account, fileObj)
    fileObj.close()

Loading Objects with try-except

def __init__(self, fileName=None):
    self.accounts = {}
    self.fileName = fileName
    
    if fileName != None:
        fileObj = open(fileName, "rb")   # 'rb' = read binary
        while True:
            try:
                account = pickle.load(fileObj)
                self.add(account)
            except EOFError:              # End of file reached
                fileObj.close()
                break

try-except catches exceptions (like EOFError)!

Example 4: Playing Cards

class Card(object):
    """A playing card with suit and rank"""
    
    RANKS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13)
    SUITS = ("Spades", "Diamonds", "Hearts", "Clubs")
    
    def __init__(self, rank, suit):
        self.rank = rank
        self.suit = suit
    
    def __str__(self):
        if self.rank == 1:
            rank = "Ace"
        elif self.rank == 11:
            rank = "Jack"
        elif self.rank == 12:
            rank = "Queen"
        elif self.rank == 13:
            rank = "King"
        else:
            rank = self.rank
        return str(rank) + " of " + self.suit

Using Card Class

>>> threeOfSpades = Card(3, "Spades")
>>> jackOfSpades = Card(11, "Spades")
>>> print(jackOfSpades)
Jack of Spades
>>> threeOfSpades.rank < jackOfSpades.rank
True
>>> print(jackOfSpades.rank, jackOfSpades.suit)
11 Spades

Card is a container for two data values (rank and suit)

Deck Class

import random

class Deck(object):
    """A deck of 52 playing cards"""
    
    def __init__(self):
        self.cards = []
        for suit in Card.SUITS:
            for rank in Card.RANKS:
                self.cards.append(Card(rank, suit))
    
    def shuffle(self):
        """Shuffles the deck"""
        random.shuffle(self.cards)
    
    def deal(self):
        """Removes and returns the top card"""
        return self.cards.pop()
    
    def __len__(self):
        """Returns number of cards remaining"""
        return len(self.cards)

Using the Deck

>>> deck = Deck()
>>> deck.shuffle()
>>> len(deck)
52
>>> card = deck.deal()
>>> print(card)
Queen of Hearts
>>> len(deck)
51
>>> while len(deck) > 0:
...     print(deck.deal())

Example 5: Two-Dimensional Grid

class Grid(object):
    """Represents a 2D grid with rows and columns"""
    
    def __init__(self, rows, columns, fillValue=None):
        """Creates a grid with given dimensions"""
        self.data = []
        for row in range(rows):
            self.data.append([fillValue] * columns)
    
    def __str__(self):
        """Returns string representation"""
        result = ""
        for row in self.data:
            result += " ".join(map(str, row)) + "\n"
        return result
    
    def __getitem__(self, index):
        """Returns the row at given index"""
        return self.data[index]

Using the Grid

>>> grid = Grid(3, 4, 0)
>>> print(grid)
0 0 0 0
0 0 0 0
0 0 0 0

>>> grid[1][2] = 99
>>> print(grid)
0 0 0 0
0 0 99 0
0 0 0 0

>>> for row in range(grid.getHeight()):
...     for col in range(grid.getWidth()):
...         print(grid[row][col])

INHERITANCE AND POLYMORPHISM

What is Inheritance?

Inheritance allows a class to reuse and extend code from another class

        ┌─────────────────┐
        │   SavingsAccount │  ← Parent class (superclass)
        └─────────────────┘
                   ▲
                   │ inherits from
        ┌─────────────────┐
        │RestrictedAccount│  ← Child class (subclass)
        └─────────────────┘

Subclass gets ALL methods and attributes of parent!

Inheritance Syntax

class RestrictedSavingsAccount(SavingsAccount):
    """Account with limited monthly withdrawals"""
    
    def __init__(self, name, pin, balance=0.0):
        super().__init__(name, pin, balance)  # Call parent constructor
        self.withdrawals = 0                  # Add new attribute
        self.MAX_WITHDRAWALS = 3
    
    def withdraw(self, amount):
        """Override withdraw with limit check"""
        if self.withdrawals >= self.MAX_WITHDRAWALS:
            return "No more withdrawals this month"
        else:
            self.withdrawals += 1
            return super().withdraw(amount)   # Call parent method
    
    def resetCounter(self):
        """Resets withdrawal counter"""
        self.withdrawals = 0

Using Inherited Class

>>> account = RestrictedSavingsAccount("Ken", "1001", 500.00)
>>> print(account)               # Inherited __str__
Name: Ken
PIN: 1001
Balance: 500.0

>>> account.getBalance()         # Inherited method
500.0

>>> for count in range(3):
...     account.withdraw(100)    # Works fine (3 times)
>>> account.withdraw(50)         # Fourth withdrawal fails
'No more withdrawals this month'

>>> account.resetCounter()       # New method
>>> account.withdraw(50)         # Works again!

The super() Function

super() gives access to parent class methods

class Child(Parent):
    def __init__(self, arg1, arg2, extra_arg):
        # Call parent constructor first
        super().__init__(arg1, arg2)   # Initialize parent part
        self.extra = extra_arg          # Initialize new part
    
    def overridden_method(self):
        # Do something new
        result = super().overridden_method()  # Call parent version
        return result + something_extra

Always call super().__init__() in subclass constructors!

Polymorphism

Polymorphism = many forms (same method name, different behaviors)

class Animal:
    def speak(self):
        return "???"

class Dog(Animal):
    def speak(self):
        return "Woof!"

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

class Cow(Animal):
    def speak(self):
        return "Moo!"

# Polymorphism in action
animals = [Dog(), Cat(), Cow()]
for animal in animals:
    print(animal.speak())  # Different output for each!
# Woof!
# Meow!
# Moo!

Why Use Polymorphism?

Without polymorphism: (long if-elif chains)

def make_sound(animal):
    if type(animal) == Dog:
        return "Woof!"
    elif type(animal) == Cat:
        return "Meow!"
    elif type(animal) == Cow:
        return "Moo!"

With polymorphism: (clean, extensible)

def make_sound(animal):
    return animal.speak()   # Works for any Animal subclass!

Add new types without changing existing code!

Inheritance Hierarchy Example

                    ┌──────────────┐
                    │    object    │
                    └──────────────┘
                           ▲
                           │
                    ┌──────────────┐
                    │ PhysicalObject│
                    └──────────────┘
                           ▲
                           │
                    ┌──────────────┐
                    │ LivingThing  │
                    └──────────────┘
                           ▲
              ┌────────────┼────────────┐
              │            │            │
        ┌──────────┐ ┌──────────┐ ┌──────────┐
        │  Animal  │ │   Plant  │ │  Fungus  │
        └──────────┘ └──────────┘ └──────────┘
              ▲
        ┌─────┼─────┐
        │     │     │
    ┌─────┐ ┌─────┐ ┌─────┐
    │ Dog │ │ Cat │ │Cow  │
    └─────┘ └─────┘ └─────┘

Each level adds more specific details!

Blackjack Game Example

class Blackjack(object):
    """Manages a game of Blackjack"""
    
    def play(self):
        """Runs one game of Blackjack"""
        deck = Deck()
        deck.shuffle()
        
        player = Player()
        dealer = Dealer()
        
        # Deal initial cards
        player.addCard(deck.deal())
        player.addCard(deck.deal())
        dealer.addCard(deck.deal())
        
        # Player's turn
        while player.getPoints() < 21:
            print("Player:", player)
            print("Points:", player.getPoints())
            
            if input("Hit? (y/n): ").lower() != 'y':
                break
            
            player.addCard(deck.deal())
        
        # Dealer's turn
        while dealer.getPoints() < 17:
            dealer.addCard(deck.deal())
        
        # Determine winner
        self.determineWinner(player, dealer)

Benefits of Object-Oriented Programming

Aspect Benefit
Encapsulation Data and methods together; hide implementation
Inheritance Reuse code; build hierarchies
Polymorphism Same interface, different behaviors
Modularity Objects are independent units
Extensibility Add new types without breaking existing code

Costs of Object-Oriented Programming

Concern Description
Over-engineering Simple problems don’t need OOP
Complexity More code, more files
Performance Slightly slower than procedural
Learning curve Harder for beginners
Overuse Not everything needs to be a class

Use OOP when modeling real-world entities with state and behavior!

COMPARISON: FUNCTIONS VS CLASSES

When to Use Functions

Use functions when: - Task has no persistent state - Simple transformation of inputs to outputs - Utility operations (math, string manipulation) - Standalone algorithms

def calculate_tax(income):
    if income < 50000:
        return income * 0.10
    else:
        return income * 0.20

When to Use Classes

Use classes when: - Need to maintain state between operations - Modeling real-world entities - Multiple similar objects with same behavior - Data and operations naturally belong together

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
    
    def deposit(self, amount):
        self.balance += amount
    
    def withdraw(self, amount):
        if amount <= self.balance:
            self.balance -= amount

Comparison Table

Aspect Functions Classes
State No persistent state Maintains state
Data Passed as arguments Stored in object
Organization By task/operation By entity/concept
Reuse Call the function Instantiate objects
Complexity Lower Higher
Best for Transformations, calculations Modeling, stateful systems

Putting It All Together: Student System

Functions approach:

def create_student(name):
    return {"name": name, "grades": []}

def add_grade(student, grade):
    student["grades"].append(grade)

def get_average(student):
    return sum(student["grades"]) / len(student["grades"])

Classes approach:

class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []
    
    def add_grade(self, grade):
        self.grades.append(grade)
    
    def get_average(self):
        return sum(self.grades) / len(self.grades)

Chapter Summary (Functions)

  • Functions provide abstraction and reusability
  • Top-down design breaks problems into manageable pieces
  • Recursive functions call themselves (base case + recursive step)
  • Namespace includes module, parameter, and temporary variables
  • Default arguments make parameters optional
  • Higher-order functions (map, filter, reduce) operate on functions
  • Lambda creates anonymous functions

Chapter Summary (Classes)

  • Classes are blueprints for creating objects
  • Instance variables store object state
  • __init__ is the constructor
  • __str__ provides string representation
  • Class variables are shared by all instances
  • Operator overloading customizes operators for classes
  • Inheritance allows code reuse (subclasses extend superclasses)
  • Polymorphism allows same method name, different behaviors
  • Pickling saves objects to files

Key Takeaways

Concept Functions Classes
Purpose Process data Model entities
Reuse Call function Instantiate object
State Stateless Stateful
Organization Task-based Entity-based
Complexity Simple Powerful

Choose the right tool for the job!

Questions?

  • Functions or classes? It depends on your problem!
  • Recursion or iteration? Consider memory and clarity!
  • Inheritance or composition? Prefer composition when possible!

Thank you!