Python Notes ,Part 2
Functions • File I/O & Error Handling • OOP • Tricky Areas
the stuff that separates beginners from real programmers
1. Functions
🎓 If you're writing the same block of code more than once, you've already made a mistake.
That's what functions are for, write it once, use it everywhere. This is the single most
important concept in all of programming.
What Is a Function, Really?
A function is a named, reusable block of code. You define it once. You call it as many times as
you want, from anywhere in your program, with different inputs each time. That's it. That's the
whole idea.
Without functions, every program would be a giant waterfall of code ,impossible to read,
impossible to fix, impossible to build on. Functions are how we break big problems into
manageable pieces.
Defining a Function-the def keyword
The syntax is dead simple. def, a name, parentheses, a colon, then an indented block of code:
def greet():
print("Hello, student!")
# nothing happens until you CALL the function
greet() # now it runs
greet() # runs again ,same function, called twice
📌 Note: Defining a function does not run it. Calling it does. This trips up almost every
beginner once.
Parameters vs Arguments ,know the difference
Parameters are the variable names in the function definition ,they're placeholders. Arguments
are the actual values you pass when calling the function. Most people use these words
interchangeably and that's fine in conversation, but when you're reading error messages,
knowing the distinction matters.
# 'name' and 'score' are PARAMETERS ,just placeholders
def print_result(name, score):
print(f"{name} scored {score}/100")
# 'Alice' and 85 are ARGUMENTS ,the real values
print_result('Alice', 85)
print_result('Bob', 92) # same function, different arguments
return vs print ,one of the most misunderstood things in Python
🎓: I have seen students fail interviews because they didn't understand return vs print. This is
not a small thing. Read this section twice.
print() shows something on the screen. That's ALL it does. The value is gone after that ,you
can't use it anywhere else.
return sends a value BACK to wherever the function was called from. You can store it, use it in
calculations, pass it to another function. It's how functions communicate with the rest of your
program.
# using print inside a function ,the result is LOST
def add_wrong(a, b):
print(a + b) # displays it, but the value disappears
result = add_wrong(3, 4) # prints 7
print(result) # None ,there's nothing to store!
# using return ,the result is KEPT
def add_right(a, b):
return a + b # sends the value back to the caller
result = add_right(3, 4) # result is now 7
print(result) # 7
print(result * 2) # 14 ,we can use it!
⚠️Watch out: A function that doesn't have a return statement always returns None. Always.
If you call a function and store the result but get None, a missing return is the first thing to
check.
Default Parameters
Sometimes you want a parameter to have a fallback value if the caller doesn't provide one.
Default parameters handle this ,define them with = in the function signature:
def greet_student(name, greeting='Hello'):
print(f"{greeting}, {name}!")
greet_student('Alice') # Hello, Alice!
greet_student('Bob', 'Good morning') # Good morning, Bob!
greet_student('Charlie', 'Yo') # Yo, Charlie!
📌 Note: Required parameters (no default) must come BEFORE default parameters. def
f(x=1, y) is a SyntaxError.
*args ,when you don't know how many arguments there will be
The * before a parameter name tells Python: collect all the extra positional arguments into a
tuple and call it args. The name 'args' is just convention ,what matters is the *.
def total(*args):
print(f'received: {args}') # args is a tuple
return sum(args)
print(total(10, 20)) # received: (10, 20) → 30
print(total(1, 2, 3, 4, 5)) # received: (1, 2, 3, 4, 5) → 15
print(total(100)) # received: (100,) → 100
**kwargs ,keyword arguments
The ** collects all extra keyword arguments into a dictionary. kwargs stands for keyword
arguments ,again, the name is just convention:
def build_profile(**kwargs):
for key, value in [Link]():
print(f' {key}: {value}')
build_profile(name='Mellow', age=20, major='CS')
# name: Mellow
# age: 20
# major: CS
# combining all three
def show_all(required, *args, **kwargs):
print(f'required: {required}')
print(f'extras: {args}')
print(f'keywords: {kwargs}')
show_all('hello', 1, 2, 3, x=10, y=20)
💡 Pro tip: The order must always be: normal params → *args → **kwargs. Python enforces
this strictly.
2. File I/O and Error Handling
🎓: Every real-world program reads or writes files. Every real-world program also
crashes ,unless you handle errors properly. These two topics are where you stop writing toy
programs and start writing real ones.
Reading and Writing Files ,open()
The open() function opens a file and returns a file object you can read from or write to. It takes a
filename and a mode:
Mode Meaning What happens if file doesn't exist
"r" Read (default) Raises FileNotFoundError
"w" Write (overwrites) Creates the file
"a" Append (adds to end) Creates the file
"r+" Read + Write Raises FileNotFoundError
Writing to a File
# always use 'with' ,it automatically closes the file when done
with open('[Link]', 'w') as file:
[Link]('Alice: 85\n')
[Link]('Bob: 92\n')
[Link]('Charlie: 78\n')
# the file is now closed automatically ,no need to call [Link]()
📌 Note: The \n is a newline character ,it moves to the next line. Without it, everything gets
written on one line.
Reading from a File
# read the entire file as one big string
with open('[Link]', 'r') as file:
content = [Link]()
print(content)
# read line by line ,better for large files
with open('[Link]', 'r') as file:
for line in file:
print([Link]()) # .strip() removes trailing \n
# read all lines into a list
with open('[Link]', 'r') as file:
lines = [Link]() # ['Alice: 85\n', 'Bob: 92\n', ...]
Appending to a File
# 'a' mode adds to the end ,doesn't erase existing content
with open('[Link]', 'a') as file:
[Link]('Diana: 95\n')
# 'w' mode would have ERASED the file first
# never use 'w' when you want to keep existing data
try / except ,Catching Errors
🎓: Here's the truth about errors: they will happen. A file won't exist. A user will type a letter
when you expected a number. A network request will time out. The question isn't whether
errors happen ,it's whether YOUR CODE handles them gracefully or crashes spectacularly.
try/except is how Python handles errors (called exceptions) without crashing:
try:
# code that MIGHT fail goes here
number = int(input('enter a number: '))
result = 100 / number
print(f'100 / {number} = {result}')
except ValueError:
# runs if the user typed something that isn't a number
print('that is not a valid number.')
except ZeroDivisionError:
# runs if the user typed 0
print('you cannot divide by zero.')
Common Exception Types
Exception When it happens
ValueError Wrong type of value ,e.g. int('hello')
TypeError Wrong type ,e.g. 'text' + 5
FileNotFoundError File doesn't exist when opening with 'r'
ZeroDivisionError Dividing by zero
IndexError List index out of range ,e.g. list[99]
KeyError Dictionary key doesn't exist ,e.g. d['missing']
AttributeError Method doesn't exist on that type
try / except / else / finally ,the full picture
try:
file = open('[Link]', 'r')
content = [Link]()
except FileNotFoundError:
# runs ONLY if an error occurred
print('file not found!')
content = ''
else:
# runs ONLY if NO error occurred
print(f'file loaded: {len(content)} characters')
finally:
# runs NO MATTER WHAT ,error or not
# perfect for cleanup: closing files, database connections, etc.
print('done attempting to open file.')
💡 Pro tip: The 'with' statement handles finally automatically for files ,it always closes the file
even if an error occurs. Always use 'with open(...)' over manual open/close.
Raising Your Own Exceptions
You can trigger exceptions yourself using raise ,useful for enforcing rules in your own functions:
def set_score(score):
if score < 0 or score > 100:
raise ValueError(f'score must be 0-100, got {score}')
return score
try:
set_score(150)
except ValueError as e:
print(f'invalid input: {e}')
# invalid input: score must be 0-100, got 150
3. Object-Oriented Programming (OOP)
🎓: OOP is not a Python thing. It's a way of thinking about software. Once it clicks, you'll
never look at code the same way again. Everything becomes objects ,things with properties
and behaviours. Your entire operating system is built on this idea.
The Core Idea ,What Is a Class?
Think about a student. Every student has a name, an age, a student ID, a GPA. Every student
can enrol in courses, submit assignments, graduate. These are the student's properties (data)
and behaviours (actions).
A class is a blueprint that defines what a student looks like and what a student can do. An object
is one specific student built from that blueprint. You can have a thousand students ,a thousand
objects ,all built from the same class.
# class = the blueprint
class Student:
pass # empty for now
# object = a specific instance built from the blueprint
alice = Student()
bob = Student()
# alice and bob are two separate objects, both of type Student
__init__ ,The Constructor
__init__ is a special method that runs automatically the moment you create a new object. It's
where you set up the object's initial data. The double underscores (called 'dunder') mean Python
calls this method automatically ,you never call __init__ directly.
class Student:
def __init__(self, name, age, student_id):
# [Link] means 'this object's name'
[Link] = name
[Link] = age
self.student_id = student_id
[Link] = 0.0 # default value ,not passed in
[Link] = [] # start with empty list
# creating objects ,__init__ runs automatically
alice = Student('Alice', 20, 'S001')
bob = Student('Bob', 22, 'S002')
print([Link]) # Alice
print(bob.student_id) # S002
print([Link]) # []
self ,what is it?
self is a reference to the specific object being worked with. When you call [Link](), Python
automatically passes alice as the first argument ,that's self. It's how the method knows which
object's data to use.
📌 Note: You must always include self as the first parameter of every method. But you never
pass it yourself when calling ,Python does that automatically.
class Student:
def __init__(self, name, gpa):
[Link] = name
[Link] = gpa
def introduce(self): # self = the specific student calling this
print(f'Hi, I am {[Link]} with a GPA of {[Link]}')
def is_honour_roll(self):
return [Link] >= 3.5
alice = Student('Alice', 3.8)
[Link]() # Hi, I am Alice with a GPA of 3.8
print(alice.is_honour_roll()) # True
Attributes vs Methods
Term What it is Example
Attribute Data stored on an object ,a [Link], [Link]
variable
Method A function defined inside a class [Link](), [Link]()
,an action
Class attribute Shared by ALL objects of that [Link] = 'BIUST'
class
Instance attribute Belongs to ONE specific object [Link] set in __init__
class Student:
university = 'BIUST' # class attribute ,same for ALL students
def __init__(self, name):
[Link] = name # instance attribute ,different per student
def enrol(self, course): # method ,an action
print(f'{[Link]} enrolled in {course}')
alice = Student('Alice')
bob = Student('Bob')
print([Link]) # BIUST ,from the class
print([Link]) # BIUST ,same shared value
print([Link]) # Alice ,her specific name
[Link]('CS101') # Alice enrolled in CS101
Inheritance ,Reusing and Extending Classes
🎓: Inheritance is one of the most powerful ideas in OOP. Instead of rewriting everything
from scratch, you take an existing class and say 'build on top of this'. The child class gets
everything the parent has ,for free.
Here's the syntax: class Child(Parent). The child inherits all of the parent's attributes and
methods, and can add its own or override the parent's:
# Parent class
class Person:
def __init__(self, name, age):
[Link] = name
[Link] = age
def introduce(self):
print(f'I am {[Link]}, age {[Link]}')
# Child class ,inherits from Person
class Student(Person):
def __init__(self, name, age, student_id):
super().__init__(name, age) # calls Person's __init__
self.student_id = student_id
[Link] = []
# new method ,only Students have this
def enrol(self, course):
[Link](course)
print(f'{[Link]} enrolled in {course}')
# override ,Student's version replaces Person's
def introduce(self):
print(f'I am {[Link]}, student #{self.student_id}')
class Lecturer(Person):
def __init__(self, name, age, department):
super().__init__(name, age)
[Link] = department
def teach(self, course):
print(f'Dr. {[Link]} is teaching {course}')
alice = Student('Alice', 20, 'S001')
dr_k = Lecturer('Kgosi', 45, 'Computer Science')
[Link]() # I am Alice, student #S001 (overridden)
[Link]('CS101')
dr_k.introduce() # I am Kgosi, age 45 (from Person)
dr_k.teach('CS101')
📌 Note: super() refers to the parent class. super().__init__() calls the parent's
constructor ,you do this so the parent's setup code runs too.
💡 Pro tip: A good rule: if two things share properties and behaviours, one probably inherits
from the other. Student and Lecturer both share name and age ,they both inherit from
Person.
4. Commonly Tricky Areas for Beginners
🎓: These are the topics that will silently break your code in ways that make no sense ,until
you understand what's actually happening underneath. Master these and you'll debug 80%
faster than the average beginner.
Mutable vs Immutable Types
This is probably the most important concept you didn't know you needed to know. Every value
in Python is either mutable (can be changed in place) or immutable (cannot be changed ,a new
object is always created instead).
Immutable (cannot be Mutable (can be changed in place)
changed)
int, float, bool list
str dict
tuple set
Why does this matter? Because when you assign a mutable object to a new variable, you're not
making a copy ,you're creating a second name pointing to the SAME object in memory:
# IMMUTABLE ,assignment creates a NEW object
a = 10
b = a
b = 20 # b now points to a new int object
print(a) # 10 ,a is completely unaffected
# MUTABLE ,assignment creates a second name for the SAME object
list1 = [1, 2, 3]
list2 = list1 # list2 is NOT a copy ,it IS list1
[Link](4) # changing list2 also changes list1!
print(list1) # [1, 2, 3, 4] ,surprise!
# to make a real copy, use .copy()
list3 = [Link]()
[Link](99)
print(list1) # [1, 2, 3, 4] ,list1 is safe now
⚠️Watch out: This catches everyone. If you pass a list to a function and the function
modifies it, the original list is modified too ,because they're the same object. Use .copy()
when you need independence.
List Comprehensions
A list comprehension is a compact, readable way to create a new list by transforming or filtering
an existing one. Once you get used to them, writing a for loop just to build a list feels clunky.
The formula: [ expression for item in iterable if condition ]
# the traditional way ,4 lines
squares = []
for x in range(1, 6):
[Link](x ** 2)
# squares = [1, 4, 9, 16, 25]
# comprehension ,same result, 1 line
squares = [x ** 2 for x in range(1, 6)]
# with a filter ,only even numbers
evens = [x for x in range(20) if x % 2 == 0]
# transform strings
names = ['alice', 'bob', 'charlie']
upper = [[Link]() for name in names]
# ['ALICE', 'BOB', 'CHARLIE']
# filter a list of dicts
students = [
{'name': 'Alice', 'score': 85},
{'name': 'Bob', 'score': 45},
{'name': 'Sara', 'score': 91},
]
passed = [s['name'] for s in students if s['score'] >= 60]
# ['Alice', 'Sara']
💡 Pro tip: If your comprehension needs more than one if condition or gets longer than one
comfortable line, just write a regular loop. Readability always wins.
Scope ,Local vs Global
Scope is about WHERE a variable exists and WHERE it can be accessed. Variables created
inside a function only exist inside that function ,they die when the function ends. Variables
created outside all functions are global ,visible everywhere.
name = 'Global Mellow' # global variable ,exists everywhere
def show_name():
name = 'Local Mellow' # local variable ,only inside this function
print(name) # Local Mellow (local shadows global)
show_name() # Local Mellow
print(name) # Global Mellow ,global is untouched
# trying to MODIFY a global variable from inside a function
count = 0
def increment():
count += 1 # ❌ UnboundLocalError!
# Python treats count as local when you try to assign
to it
def increment_fixed():
global count # tell Python: I mean the global count
count += 1 # ✅ now it works
increment_fixed()
print(count) # 1
⚠️Watch out: Using global is generally considered bad practice. It makes code hard to
track ,you never know what changed count. The better approach is to pass count as an
argument and return the new value.
# the GOOD way ,pass in, return out
def increment(count):
return count + 1
count = 0
count = increment(count) # count is now 1
count = increment(count) # count is now 2
== vs is ,equality vs identity
These look like they should do the same thing. They do not.
Operator Question it answers Compares
== Do these two have the same The contents
VALUE?
is Are these two the SAME The memory address
OBJECT in memory?
# == checks value ,are the contents equal?
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True ,same contents
print(a is b) # False ,two separate list objects in memory
# is checks identity ,is it literally the same object?
c = a # c points to the SAME object as a
print(a is c) # True ,same object, same memory address
# the only safe use of 'is' for values: checking for None
value = None
if value is None: # ✅ correct way to check for None
print('nothing here')
if value == None: # works but not recommended
print('nothing here')
⚠️Watch out: Never use 'is' to compare strings, numbers, or lists. Python caches small
integers and short strings, which makes 'is' appear to work sometimes ,but it's unreliable
and will eventually bite you in unexpected ways.
# this looks right but is unreliable
x = 256
y = 256
print(x is y) # True ,Python caches small integers (happens to work)
x = 1000
y = 1000
print(x is y) # False ,large integers aren't cached
# lesson: use == for values, is for None/True/False only
Quick Reference Part 2
Topic Key Syntax The One Thing to Remember
def def name(params): Definition does nothing ,calling it does.
return return value Return sends a value back. print() just displays
it.
Default params def f(x=10): Defaults go at the end of the parameter list.
*args def f(*args): Collects extra positional args into a tuple.
**kwargs def f(**kwargs): Collects extra keyword args into a dict.
File write open('f', 'w') Always use 'with'. 'w' overwrites, 'a' appends.
File read open('f', 'r') FileNotFoundError if file doesn't exist.
try/except try: ... except E: Catch specific exceptions ,not bare except:.
finally finally: Runs no matter what. Use for cleanup.
class class Name: Blueprint. Defines what objects look like.
__init__ def __init__(self): Runs on object creation. Sets up attributes.
self [Link] Always the first param. Points to THIS object.
Inheritance class Child(Parent): Child gets everything Parent has.
super() super().__init__() Call parent's constructor from child.
Mutable list, dict, set Assignment copies the REFERENCE, not the
data.
Immutable int, str, tuple Changes always create a NEW object.
Comprehension [x for x in list] Compact list creation ,add 'if' to filter.
Scope global keyword Prefer passing args and returning values over
global.
== vs is == for values Use 'is' only for None, True, False checks.