Lecture#
Introduction to OOP#
Core principles of object-oriented programming.
What Is OOP?
Object-Oriented Programming is a paradigm that models software as a collection of objects that interact with one another. Each object bundles data (attributes) and behavior (methods) together.
Python is a multi-paradigm language: it supports procedural, functional, and object-oriented styles. OOP features appear throughout the standard library and most major frameworks, so familiarity with OOP is essential for effective Python development.
Core Principles
Encapsulation – Bundles related data and methods into a single object. In Python, access control is by convention (e.g., leading underscores) rather than enforced by the language.
Abstraction – Exposes only essential features through a well-defined interface (e.g., abstract base classes) while hiding the underlying implementation.
Inheritance – Enables new classes to reuse and extend the functionality of existing ones.
Polymorphism – Allows different objects to be used interchangeably when they share a common interface. Python achieves this naturally through duck typing.
Note
Python is a multi-paradigm language. You can mix procedural, object-oriented, and functional styles in the same program.
Benefits and Trade-offs
Advantages
Modularity – Decomposes complex problems into manageable, self-contained components.
Reusability – Promotes code reuse through inheritance and composition.
Flexibility – Enables dynamic behavior via duck typing and interchangeable implementations.
Maintainability – Facilitates localized changes and clearer code organization.
Trade-offs
Learning Curve – Requires understanding abstract concepts and design patterns.
Design Overhead – Demands more upfront planning and structure.
Verbosity – Object-oriented programs can be more verbose than procedural or functional alternatives.
Warning
OOP is not a one-size-fits-all solution. Not all problems map naturally to objects and classes. Modern Python emphasizes composition over inheritance: prefer combining simple objects rather than building deep class hierarchies.
Design Phase#
Translating a real-world problem into a workable structure before writing any code.
Design Workflow
From Problem to Code
The design phase bridges the gap between a real-world problem and working code. The workflow is iterative: you will revisit earlier steps as you discover new information.
Requirement Analysis – Understand what the system must do.
Business Rules – Capture the constraints and invariants the system must enforce.
Noun/Verb Analysis – Extract candidate classes, attributes, and methods from the requirements.
Modeling – Visualize structure (class diagrams) and behavior (sequence and activity diagrams).
Implementation – Translate the design into code.
Note
This workflow is iterative. Your first pass will not be perfect. Revisit and refine as you learn more about the domain.
Requirement Analysis
Step 1: Understanding What the System Must Do
Requirement analysis identifies what the system must do (functional requirements) and how well it must do it (non-functional requirements).
Competition Domain
A robotics competition involves teams of robots collaborating to complete tasks in an arena. We are building a competition management system: the software an organizer would use to coordinate teams, assign tasks, track battery levels, and record results.
Key Functional Requirements
Track which robots belong to which teams and their operational status.
Assign, prioritize, and schedule tasks for robots.
Monitor robot attributes such as battery level and sensor configuration.
Model the arena layout, including obstacles and target zones.
Record competition progress and results.
Note
See the Design Phase PDF (
DesignPhase_ENPM605_Spring2026.pdf) for the complete requirement analysis, clarifying questions, and use case descriptions. This document demonstrates what a real design document looks like: it captures domain context, functional requirements, and behavioral scenarios in a structured format that serves as the blueprint for implementation.
Business Rules
Step 2: Constraints the System Must Enforce
Business rules define what is allowed, required, and prohibited. They directly influence class design: which validations go into methods, which attributes need constraints, and which relationships must be enforced.
Competition Domain Rules
A robot’s battery level must be between 0 and 100.
A robot cannot accept a task unless its status is “active”.
Each robot belongs to exactly one team; a team has at most
max_robotsrobots.A task’s priority must be one of: “low”, “medium”, “high”, “critical”.
When battery drops below 10%, status changes to “needs_recharge”.
When a task is completed, the team’s score is updated automatically.
Battery drain = task duration x drain rate.
Team score = sum of points for each completed task.
Note
See the Design Phase PDF for the full set of business rules organized by category (constraints, triggers, computations, authorization).
Noun/Verb Analysis
Step 3: From Natural Language to OOP
Noun/verb analysis extracts candidate classes, attributes, and methods from a problem description. Nouns map to classes or attributes; verbs map to methods; adjectives map to attribute values; relationships (“has a”, “is a”) map to composition or inheritance.
“A robotics competition involves teams of robots collaborating to complete tasks in an arena. Each robot is equipped with sensors to perceive its environment and must navigate, pick up objects, and deliver them to target zones.”
Class |
Attributes |
Methods |
Relationships |
|---|---|---|---|
Robot |
name, battery, status |
move(), pick_up(), deliver(), recharge() |
has Sensors, belongs to Team |
Sensor |
type, range, accuracy |
read(), calibrate() |
belongs to Robot |
Task |
name, priority, duration |
assign(), complete(), cancel() |
assigned to Robot |
Arena |
width, height, obstacles, zones |
add_obstacle(), get_zone() |
contains Robots |
Team |
name, score, max_robots |
add_robot(), remove_robot() |
has Robots |
Note
Not every noun becomes a class and not every verb becomes a method. See the Design Phase PDF for the filtering rationale and the full extraction walkthrough.
Modeling
Step 4: Visualizing Structure and Behavior
UML (Unified Modeling Language) provides standardized diagrams to capture different aspects of the system. We use three diagram types in this course:
Class Diagrams – Show classes, their attributes and methods, and relationships (composition, aggregation, association, inheritance).
Sequence Diagrams – Show how objects interact over time by exchanging method calls.
Activity Diagrams – Show the flow of control through a process (similar to flowcharts, but with support for concurrency and swimlanes).
Competition Domain Relationships
Composition – A
Robotowns itsSensor(s). Destroying the robot destroys its sensors.Aggregation – A
TeamhasRobot(s). Dissolving the team does not destroy the robots.Association – A
Robotis assigned aTask. Neither owns the other.Inheritance (L7) –
MobileRobotandManipulatorRobotare types ofRobot.
Note
See the Design Phase PDF for UML notation details, the full class diagram, sequence diagrams, and activity diagrams for the competition domain.
From Design to Implementation
Step 5: Translating the Design into Code
For this lecture, we implement the Robot and Sensor classes. The remaining classes (Task, Team, Arena) and their relationships will be covered in L7.
Project Structure
robotics_competition/
robot.py # Robot class
sensor.py # Sensor class
task.py # Task class (L7)
team.py # Team class (L7)
arena.py # Arena class (L7)
main.py # Entry point
Note
Each class lives in its own module. This promotes modularity and makes the code easier to maintain, test, and extend. The design phase is iterative: revisit and refine as you implement.
Implementation Phase: Classes and Objects#
Translating the design into working code.
Refer to L6_classes_objects.py to follow along with the examples below.
What Is a Class?
A class is a blueprint that defines the attributes (data) and methods (functions) that its objects will have.
class ClassName:
"""Docstring describing the class."""
def __init__(self, param1: type, param2: type = default):
self.attribute1 = param1
self.attribute2 = param2
def method1(self, arg: type):
# Use self.attribute1, self.attribute2, arg, etc.
...
def method2(self):
# Operate on the object's attributes
...
The
classkeyword starts the definition, followed by the class name in CamelCase.By default, every class implicitly inherits from
object.Inside the class body we define methods. The first parameter of every method is
self, which refers to the object calling the method.
What Is an Object?
An object (or instance) is a concrete realization of a class. Multiple objects can be created from the same blueprint, each with its own attribute values.
# Creating objects from the class
obj1 = ClassName(value1, value2)
obj2 = ClassName(value3) # value4 uses the default
# Calling methods on each object
obj1.method1(some_arg) # self refers to obj1
obj2.method1(other_arg) # self refers to obj2
# Accessing attributes
print(obj1.attribute1) # value1
print(obj2.attribute1) # value3
Each object is independent: modifying
obj1does not affectobj2.Each object maintains its own copy of the attributes defined in
__init__.All objects share the same method definitions. When you call
obj1.method1(arg), Python passesobj1asselfautomatically.Access attributes and methods using dot notation:
obj.attributeorobj.method().
From Blueprint to Instances
The blueprint specifies what every robot arm will have and what it can do. Each arm built from that blueprint is an independent instance with its own state.
class RobotArm:
"""Blueprint for a robot arm."""
def __init__(self, station: int):
self.station = station
self.joint_angle = 0.0
self.gripping = False
def pick_up(self):
self.gripping = True
def rotate(self, angle: float):
self.joint_angle += angle
Creating Objects
Each call to the class creates a new, independent object. All objects share the same structure but maintain their own state.
# Three arms built from the same blueprint
arm_1 = RobotArm(station=1)
arm_2 = RobotArm(station=2)
arm_3 = RobotArm(station=3)
arm_1.pick_up() # arm_1 is gripping, others are not
arm_2.rotate(45.0) # arm_2 rotated, others unchanged
print(arm_1.gripping) # True
print(arm_2.joint_angle) # 45.0
print(arm_3.gripping) # False
Note
arm_1, arm_2, and arm_3 are three objects (instances) created from the same class (blueprint). Modifying one does not affect the others.
How self Works
The self parameter refers to the instance of the class. When you call a method on an instance, Python automatically passes the instance as the first argument. By convention this parameter is named self.
What You Write
def rotate(self, angle: float):
self.joint_angle += angle
arm_1 = RobotArm(station=1)
arm_1.rotate(45.0)
What Python Does
def rotate(arm_1, angle: float):
arm_1.joint_angle += angle
arm_1 = RobotArm(station=1)
RobotArm.rotate(arm_1, 45.0)
Note
Key Insight: arm_1.rotate(45.0) is syntactic sugar for RobotArm.rotate(arm_1, 45.0). Python automatically fills in self for you.
The Constructor: __init__
__init__ is a special method (a “dunder” method) called automatically when a new instance is created. It initializes the object’s attributes.
class RobotArm:
def __init__(self, station: int):
self.station = station
self.joint_angle = 0.0
self.gripping = False
if __name__ == "__main__":
arm = RobotArm(station=1)
print(arm.station) # 1
print(arm.joint_angle) # 0.0
print(arm.gripping) # False
When
RobotArm(station=1)is called, Python creates a new instance and passes it asselfto__init__.Inside
__init__, we set instance variables usingself.attribute = value.It is not mandatory to define
__init__, but it is best practice to initialize all attributes there.
Warning
__init__ is not a constructor in the strict sense. It is an initializer. The actual constructor is __new__, which is rarely overridden.
Initialize All Attributes in ``__init__``
To make your code less error-prone, initialize all attributes in the __init__ method, even if you set them to empty values or None.
class RobotArm:
def __init__(self, station: int):
self.station = station
self.joint_angle = 0.0
self.gripping = False
self.log: list[str] = [] # Always initialize here
def pick_up(self, item: str):
self.gripping = True
self.last_item = item # Bad: new attribute outside __init__
Warning
Creating new attributes outside __init__ (like self.last_item above) is discouraged. It makes the class harder to understand and undermines encapsulation.
Instance Attributes
Instance attributes belong to a specific object. Each object maintains its own copy, so modifying one object does not affect any other.
class RobotArm:
def __init__(self, station: int):
self.station = station # Instance attribute
self.joint_angle = 0.0 # Instance attribute
self.gripping = False # Instance attribute
if __name__ == "__main__":
arm_1 = RobotArm(station=1)
arm_2 = RobotArm(station=2)
arm_1.joint_angle = 45.0
print(arm_1.joint_angle) # 45.0
print(arm_2.joint_angle) # 0.0 (unaffected)
Instance attributes are created inside
__init__usingself.attribute = value.Each object gets its own copy:
arm_1.joint_angleandarm_2.joint_angleare independent.Instance attributes cannot be accessed through the class itself:
RobotArm.stationraisesAttributeError.
Class Attributes
Class attributes are defined directly in the class body, outside any method. They are shared by all instances and accessed through the class name.
class RobotArm:
total_arms = 0 # Class attribute (shared)
def __init__(self, station: int):
self.station = station # Instance attribute (unique)
self.joint_angle = 0.0 # Instance attribute (unique)
self.gripping = False # Instance attribute (unique)
RobotArm.total_arms += 1
if __name__ == "__main__":
arm_1 = RobotArm(station=1)
arm_2 = RobotArm(station=2)
print(RobotArm.total_arms) # 2 (accessed via class)
print(arm_1.total_arms) # 2 (accessed via instance)
print(arm_2.total_arms) # 2
Class attributes are defined outside
__init__, directly in the class body.All instances share the same class attribute. Modifying it via
RobotArm.total_armsis visible everywhere.Always modify class attributes through the class name (
RobotArm.total_arms += 1), not throughself. Usingself.total_arms += 1creates a new instance attribute that shadows the class attribute.
Implementation Phase: Dunder Methods#
Customizing how your objects interact with built-in Python operations.
Refer to L6_classes_objects.py to follow along with the examples below.
What Are Dunder Methods?
Dunder methods (short for “double underscore”) are special methods with names like __name__. They allow your objects to interact with built-in Python operations such as print(), len(), +, ==, in, and more.
Method Overriding
When you write a dunder method in your class, you override the default behavior inherited from object. This lets you customize how Python treats your objects.
class Robot:
def __init__(self, name: str, battery: int = 100):
self.name = name
self.battery = battery
scout = Robot("Scout")
print(scout) # <__main__.Robot object at 0x7fe4dc66bbb0>
# Not very useful! Let's fix this by overriding __str__
The __str__ Method
__str__ is called by print() and str(). It should return a human-readable string intended for end users, log messages, or display output.
class Robot:
def __init__(self, name: str, battery: int = 100):
self.name = name
self.battery = battery
def __str__(self) -> str:
return f"{self.name} (Battery: {self.battery}%)"
scout = Robot("Scout")
print(scout) # Scout (Battery: 100%)
print(str(scout)) # Scout (Battery: 100%)
If
__str__is not defined, Python falls back to__repr__.Focus on readability: this is what the user sees.
Think of
__str__as the “pretty” representation.
The __repr__ Method
__repr__ is called by repr() and used in the REPL, debugger, and when objects appear inside containers. The string it returns should look like the code you would type to create that object. That way, a developer reading a log or debugging output can immediately see how to reproduce it.
class Robot:
def __init__(self, name: str, battery: int = 100):
self.name = name
self.battery = battery
def __repr__(self) -> str:
return f"Robot(name='{self.name}', battery={self.battery})"
scout = Robot("Scout")
print(repr(scout)) # Robot(name='Scout', battery=100)
# You could copy that output and paste it as valid Python:
# scout_copy = Robot(name='Scout', battery=100)
robots = [Robot("Scout"), Robot("Hauler", 80)]
print(robots) # [Robot(name='Scout', battery=100), ...]
When you print a
list, Python calls__repr__on each element, not__str__.A good
__repr__looks like a valid constructor call:Robot(name='Scout', battery=100).If you only implement one, implement
__repr__. It serves as the fallback for__str__.
__str__ vs. __repr__
|
|
|
|---|---|---|
Audience |
End users |
Developers |
Called by |
|
|
Inside containers |
Not used |
Used when objects are inside lists, dicts, etc. |
Goal |
Human-readable |
Should look like the code you would write to create the object |
Fallback |
Falls back to |
Falls back to default |
scout = Robot("Scout", 80)
print(scout) # Scout (Battery: 80%) __str__
print(repr(scout)) # Robot(name='Scout', battery=80) __repr__
print(f"Bot: {scout}") # Bot: Scout (Battery: 80%) __str__
print([scout]) # [Robot(name='Scout', battery=80)] __repr__
Note
Rule of thumb: Always implement __repr__. Add __str__ when you need a friendlier output for end users.
What Is Operator Overloading?
Python already knows how + works for integers and strings. Operator overloading lets you teach Python what + (or any operator) should do when applied to your own classes. This is different from method overriding, where a parent class already provides a version of a method, but the child class provides its own version to replace it.
Example
Same operator, different types. The behavior depends on the operands.
# + already works for int and str
print(3 + 4) # 7
print("ab" + "cd") # abcd
# We can teach + to work for Sensor objects too
print(lidar + camera) # Sensor(...)
Operators and Their Dunder Methods
Every Python operator corresponds to a dunder method. When you use an operator with your objects, Python calls the corresponding method.
Operator |
Dunder Method |
Example |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Comparison Operators
class Sensor:
def __init__(self, sensor_type: str, range_m: float):
self.sensor_type = sensor_type
self.range_m = range_m
def __eq__(self, other) -> bool:
if isinstance(other, Sensor):
return self.range_m == other.range_m
return NotImplemented
def __gt__(self, other) -> bool:
if isinstance(other, Sensor):
return self.range_m > other.range_m
return NotImplemented
lidar = Sensor("lidar", 50.0)
camera = Sensor("camera", 30.0)
print(lidar == camera) # False
print(lidar > camera) # True
Arithmetic Operators
class Sensor:
def __init__(self, sensor_type: str, range_m: float):
self.sensor_type = sensor_type
self.range_m = range_m
def __add__(self, other):
if isinstance(other, Sensor):
return Sensor("fused", self.range_m + other.range_m)
elif isinstance(other, (int, float)):
return Sensor(self.sensor_type, self.range_m + other)
return NotImplemented
def __repr__(self) -> str:
return f"Sensor('{self.sensor_type}', {self.range_m})"
lidar = Sensor("lidar", 50.0)
camera = Sensor("camera", 30.0)
print(lidar + camera) # Sensor('fused', 80.0)
print(lidar + 10.0) # Sensor('lidar', 60.0)
Note
Return NotImplemented (not raise NotImplementedError) when the operand type is unsupported. This tells Python to try the reflected operation on the other operand.
Making Objects Iterable, Searchable, and Callable
class Robot:
def __init__(self, name: str, battery: int = 100):
self.name = name
self.battery = battery
self.log: list[str] = []
def __iter__(self):
return iter(self.log)
def __contains__(self, task_name: str):
return task_name in self.log
def __call__(self, task_name: str):
self.log.append(task_name)
print(f"{self.name} assigned: {task_name}")
scout = Robot("Scout")
scout("pick widget") # Scout assigned: pick widget
scout("navigate to zone B") # Scout assigned: navigate to zone B
for entry in scout: # Uses __iter__
print(entry) # pick widget, then navigate to zone B
print("pick widget" in scout) # True (uses __contains__)
Implementation Phase: Abstraction and Encapsulation#
Controlling access to an object’s internal state.
Refer to L6_classes_objects.py to follow along with the examples below.
What Is Abstraction?
Abstraction refers to hiding the complex implementation details of an object and showing only the essential features. The user interacts with an interface without needing to know how operations are implemented internally.
Real-World Analogy
Think of a robot’s move() method: you call scout.move("north") without needing to know about motor controllers, PID loops, wheel encoders, or path planning algorithms. The robot hides its internal complexity behind a simple interface.
Python Example
Python built-in functions like len() are abstractions. You know len() returns the number of items in a sequence, but you do not need to know how it is implemented internally. The developers can change the internal implementation without affecting your code.
Note
Separation of Concerns: Abstraction separates what an object does from how it does it. This makes code easier to maintain and extend.
How Do We Achieve Abstraction?
Abstraction in Python is achieved at multiple levels. In this lecture we focus on the first two. The third level uses inheritance, which we cover in L7.
class Robot:
"""A robot that performs tasks.
The caller only needs to know that recharge() restores
the battery. How it works internally is hidden.
"""
def __init__(self, name: str, battery: int = 100):
self.name = name
self.battery = battery
def recharge(self):
"""Restore battery to full."""
# Internal details hidden:
# - validate current state
# - reset drain counter
# - log the recharge event
self.battery = 100
if __name__ == "__main__":
scout = Robot("Scout")
scout.recharge() # Simple interface
print(scout.battery) # 100
Level 1: Documentation
A docstring describes what a class or method does without exposing how.
The end-user reads the docstring, not the source code.
Level 2: Public Interface
Public methods (e.g.,
recharge()) define what the object can do.Internal logic (validation, logging, state management) is hidden inside the method body.
The end-user only sees a simple interface.
Level 3: Abstract Classes (L7)
Python’s
abcmodule lets you define abstract methods that subclasses must implement.This enforces a contract: every subclass guarantees the same interface.
We will cover this in Lecture 7.
What Is Encapsulation?
Encapsulation is the bundling of data (attributes) with the methods that operate on that data. The goal is to allow an object to manage its own state and prevent external code from modifying it in invalid ways.
The Problem Without Encapsulation
class Robot:
def __init__(self, name: str, battery: int = 100):
self.name = name
self.battery = battery
def perform_task(self, task_name: str):
self.battery -= 10
if __name__ == "__main__":
scout = Robot("Scout")
scout.battery = "full" # No validation!
scout.perform_task("pick") # TypeError: unsupported operand type(s)
Warning
Without encapsulation, external code can set battery to a string, causing methods to break. Encapsulation prevents this by controlling how attributes are accessed and modified.
How to Achieve Encapsulation
Prefix all attributes with a leading underscore to signal they are non-public.
Provide controlled access through getter and setter decorators (
@property).
Public vs. Non-Public Members
Python uses naming conventions (not access modifiers like Java or C++) to signal whether attributes are intended to be public or non-public.
name– Public. Freely accessible from outside the class._name– Non-public by convention. Signals “do not access directly”. Python does not enforce this, but violating the convention is considered bad practice.__name– Name mangling. Python renames it to_ClassName__nameto avoid accidental access. Rarely needed.
class Robot:
def __init__(self, name: str, battery: int = 100):
self._name = name # Non-public by convention
self._battery = battery # Non-public by convention
scout = Robot("Scout")
scout._battery = "full" # Still possible, but violates the convention
Note
The underscore prefix is a social contract: it tells other developers “this attribute is internal; use the provided interface instead.”
Traditional Getters and Setters
In languages like Java, you write explicit getter and setter methods. Python supports this, but it is not the preferred approach.
class Robot:
def __init__(self, name: str, battery: int = 100):
self._name = name
self._battery = battery
def get_battery(self) -> int:
return self._battery
def set_battery(self, battery: int):
if isinstance(battery, int) and 0 <= battery <= 100:
self._battery = battery
else:
raise ValueError("Battery must be an integer between 0 and 100")
scout = Robot("Scout")
scout.set_battery(80) # Works
# scout.set_battery("full") # ValueError
print(scout.get_battery()) # 80
Note
This works, but it is not Pythonic. The @property decorator provides a cleaner solution.
The @property Decorator
The Pythonic Way
The @property decorator transforms a method into a read-only attribute. Combined with a corresponding setter, it allows you to add validation and control over how an attribute is accessed and modified, all without changing the external interface.
class Robot:
def __init__(self, name: str, battery: int = 100):
self._name = name # non-public attribute
self._battery = battery # non-public attribute
Attributes prefixed with
_signal that they should not be accessed directly.External code interacts with these attributes through properties instead.
Defining a Getter
Applying @property to a method turns it into a getter. When external code accesses robot.battery, Python automatically calls this method and returns its result.
class Robot:
def __init__(self, name: str, battery: int = 100):
self._name = name
self._battery = battery
@property
def battery(self) -> int:
"""The battery level of the robot."""
return self._battery
The method name (
battery) becomes the attribute name used by external code.Accessing
robot.batterycalls this method behind the scenes.Without a setter, the attribute is read-only.
Defining a Setter with Validation
The @battery.setter decorator defines what happens when external code assigns a value to robot.battery. This is where you enforce constraints on incoming data.
@battery.setter
def battery(self, value: int):
if not isinstance(value, int) or not (0 <= value <= 100):
raise ValueError(
"Battery must be an integer between 0 and 100"
)
self._battery = value
The decorator name must match the property:
@battery.setter.Invalid values raise an exception before the attribute is modified.
The non-public attribute
_batteryis only updated if validation passes.
Using Properties
From the caller’s perspective, properties look and feel exactly like regular attributes. The validation logic is completely hidden behind the assignment syntax.
if __name__ == '__main__':
scout = Robot("Scout")
print(scout.battery) # 100 (calls the getter)
scout.battery = 80 # OK (calls the setter with validation)
print(scout.battery) # 80 (calls the getter)
# scout.battery = "full" # ValueError!
scout.batteryon the right side of an expression triggers the getter.scout.battery = 80on the left side triggers the setter.Assigning an invalid value such as
"full"raises aValueError.The caller never needs to know that validation is happening.
Read-Only Properties
If you define only the getter (no setter), the property becomes read-only.
class Robot:
def __init__(self, name: str):
self._name = name
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value):
raise AttributeError("Cannot rename a robot after creation")
scout = Robot("Scout")
print(scout.name) # Scout
# scout.name = "Bob" # AttributeError: Cannot rename a robot after creation
Encapsulation Summary
Prefix non-public attributes with an underscore (
_name).Use
@propertyfor controlled access instead of explicit getters/setters.While the
property()built-in function works, the decorator syntax is preferred.
Putting It All Together#
This section combines the concepts from the entire lecture into a comprehensive exercise.
Summary#
OOP Fundamentals – Objects bundle data and behavior; Python is multi-paradigm
Design Phase – Identify objects, define classes, establish relationships, model behavior
Classes and Objects – Classes are blueprints; objects are instances with their own state
``self`` and ``__init__`` –
selfrefers to the instance;__init__initializes attributes
Attributes – Instance attributes are per-object; class attributes are shared
Dunder Methods – Customize
print(), operators,in,for, and callable behaviorAbstraction – Hide implementation details behind a clean interface
Encapsulation – Use
_prefixconvention and@propertyto protect state
Concept |
Mechanism |
Use Case |
|---|---|---|
Class definition |
|
Blueprint for objects |
Constructor |
|
Initialize attributes |
String representation |
|
Human/debug output |
Operators |
|
Custom arithmetic/comparison |
Encapsulation |
|
Controlled attribute access |
Note
Reminder: Review and experiment with all provided code before next class.
Preview: What’s Next in L7#
Class methods and static methods
Relationships: association, aggregation, composition
Inheritance (
MobileRobot,ManipulatorRobot) andsuper()Polymorphism and duck typing
Abstract base classes (
Taskinterface)Data classes
Note
Today’s lecture gives you the OOP fundamentals that are essential for understanding relationships, inheritance, and polymorphism in the next lecture.
Appendix: Exception Handling#
This appendix introduces exception handling, a prerequisite for understanding how classes validate their own data. The raise statement and try/except blocks are used throughout the OOP implementation to enforce business rules and keep objects in a valid state.
What Happens When Things Go Wrong?
Programs encounter errors at runtime: invalid input, missing files, impossible calculations. Without a mechanism to handle these errors, the program crashes immediately.
def compute_speed(distance: float, time: float) -> float:
return distance / time
speed = compute_speed(100.0, 0.0) # ZeroDivisionError!
print(f"Speed: {speed}") # This line never runs
Python uses exceptions to signal that something went wrong. An unhandled exception terminates the program and prints a traceback.
Common Built-in Exceptions
Exception |
Raised When |
|---|---|
|
Dividing by zero |
|
Wrong type for an operation |
|
Right type but invalid value |
|
List index out of range |
|
Dictionary key not found |
|
File does not exist |
|
Accessing a nonexistent attribute |
|
Method not yet implemented |
Note
All exceptions inherit from BaseException. The ones we typically catch inherit from Exception.
The try/except Block
A try/except block lets you catch an exception and respond to it instead of letting the program crash.
def compute_speed(distance: float, time: float) -> float:
return distance / time
try:
speed = compute_speed(100.0, 0.0)
print(f"Speed: {speed}")
except ZeroDivisionError:
print("Error: time cannot be zero")
Code inside
tryruns normally until an exception occurs.If the exception matches the type in
except, that block runs instead of crashing.If no exception occurs, the
exceptblock is skipped entirely.
Accessing the Exception Object
You can capture the exception object using as to inspect its message or pass it along.
values = [10, 20, 30]
try:
print(values[5])
except IndexError as e:
print(f"Caught an error: {e}")
The variable
eholds the exception instance.Printing
edisplays the error message.This is useful for logging errors or displaying user-friendly messages.
Handling Multiple Exception Types
You can handle different exceptions separately, or group them in a single except clause.
Separate Handlers
try:
result = int("abc") / 0
except ValueError:
print("Invalid value")
except ZeroDivisionError:
print("Cannot divide by 0")
Grouped Handler
try:
result = int("abc") / 0
except (ValueError, ZeroDivisionError) as e:
print(f"Error: {e}")
Warning
Avoid catching bare except: or except Exception: unless you have a good reason. Overly broad handlers can hide bugs by silently swallowing unexpected errors.
The else Clause
The else clause runs only when the try block completes without raising an exception. It separates the code that might fail from the code that should only run on success.
def parse_battery(value: str) -> int:
try:
level = int(value)
except ValueError:
print(f"Cannot parse '{value}' as an integer")
return -1
else:
print(f"Successfully parsed battery level: {level}")
return level
parse_battery("85") # Successfully parsed battery level: 85
parse_battery("full") # Cannot parse 'full' as an integer
Code in
elseonly executes iftryraised no exception.This keeps the
tryblock minimal: only wrap the code that might fail.Without
else, you would place the success logic insidetry, which risks accidentally catching exceptions from that logic too.
The finally Clause
The finally clause runs unconditionally, whether an exception occurred or not. It is used for cleanup actions that must always execute.
def read_config(filename: str):
file = None
try:
file = open(filename)
data = file.read()
print(f"Loaded {len(data)} characters")
except FileNotFoundError:
print(f"'{filename}' not found")
finally:
if file is not None:
file.close()
print("Cleanup complete")
read_config("robot.cfg")
# 'robot.cfg' not found
# Cleanup complete
finallyexecutes aftertry,except, andelse, regardless of the outcome.Common use cases: closing files, releasing network connections, resetting hardware.
The
finallyblock runs even if areturnstatement is reached insidetryorexcept.
The Full try Statement
All four clauses can be combined. The execution order is always: try, then except or else, then finally.
def read_sensor(value: str) -> float:
try:
reading = float(value)
except ValueError:
print(f"Invalid: {value}")
reading = 0.0
else:
print(f"Valid: {reading}")
finally:
print("Read attempt complete")
return reading
read_sensor("42.5")
# Valid: 42.5
# Read attempt complete
read_sensor("bad")
# Invalid: bad
# Read attempt complete
Note
Rule of thumb: Use else to keep the try block minimal. Use finally for cleanup that must happen regardless of success or failure. Neither clause is required, but both improve code clarity when used appropriately.
The raise Statement
The raise statement lets you signal an error explicitly when your code detects an invalid condition. This is how you enforce rules and constraints in your own functions and classes.
def set_battery(level: int):
if not isinstance(level, int):
raise TypeError("Battery level must be an integer")
if not (0 <= level <= 100):
raise ValueError("Battery level must be between 0 and 100")
print(f"Battery set to {level}%")
set_battery(80) # Battery set to 80%
set_battery("full") # TypeError: Battery level must be an integer
set_battery(150) # ValueError: Battery level must be between 0 and 100
raise ExceptionType("message")creates an exception object and immediately stops normal execution.Choose the exception type that best describes the problem:
TypeErrorfor wrong types,ValueErrorfor invalid values.The caller can catch these exceptions with
try/except.
Why raise Matters for OOP
Later in this lecture, we write classes that validate their own data. The raise statement is the mechanism that makes this possible.
class Robot:
def __init__(self, name: str, battery: int = 100):
if not isinstance(battery, int) or not (0 <= battery <= 100):
raise ValueError("Battery must be an integer between 0 and 100")
self._name = name
self._battery = battery
robot = Robot("Scout", 80) # OK
robot = Robot("Scout", 200) # ValueError!
Note
Key Idea: Classes use raise to reject invalid data at the point of entry. This keeps objects in a valid state and prevents bugs from propagating through the rest of the program.
return NotImplemented vs. raise NotImplementedError
These look similar but serve completely different purposes.
``return NotImplemented``
NotImplementedis a value, not an exception. Youreturnit, neverraiseit.Used inside dunder methods (
__eq__,__gt__,__add__, etc.).When Python receives
NotImplemented, it tries the reflected method on the other operand (e.g.,other.__eq__(self)).
class Sensor:
def __init__(self, sensor_type: str, range_m: float):
self.sensor_type = sensor_type
self.range_m = range_m
def __eq__(self, other):
if isinstance(other, Sensor):
return self.range_m == other.range_m
return NotImplemented
if __name__ == "__main__":
lidar = Sensor("lidar", 50.0)
camera = Sensor("camera", 50.0)
ultrasonic = Sensor("ultrasonic", 30.0)
print(lidar == camera) # True
print(lidar == ultrasonic) # False
print(lidar == "lidar") # False
``raise NotImplementedError``
NotImplementedErroris an exception. Youraiseit, neverreturnit.Used in methods that are meant to be overridden by subclasses (covered in L7).
Calling the method without overriding it crashes immediately with a clear error message.
class Robot:
def __init__(self, name: str):
self.name = name
def move(self, direction: str):
raise NotImplementedError("Subclasses must implement move()")
if __name__ == "__main__":
robot = Robot("Base")
robot.move("north")
# NotImplementedError: Subclasses must implement move()
Warning
A common mistake is writing raise NotImplemented (without “Error”). This technically works but raises a confusing TypeError instead. Always use raise NotImplementedError.