Lesson 16 - Object-Oriented Programming
Learning Objective
- Define a class with
__init__and methods. - Extend a class through inheritance.
- Build objects out of other objects through composition.
Introduction
You’ve been using objects since Lesson 1 without necessarily naming them as such — every time you wrote:
with BonicBot(host=...) as bot:you created an instance of the BonicBot class.
A class is a blueprint; an object (or instance) is one specific thing built from that blueprint, with its own data.
This lesson has three parts:
- Writing a class from scratch.
- Extending a class through inheritance (an “is-a” relationship).
- Building a class out of other objects through composition (a “has-a” relationship).
Both inheritance and composition are patterns that bonicbot_bridge itself uses throughout its real code.
Part A — Classes and Objects
Code
class Student:
def __init__(self, name, scores):
self.name = name
self.scores = scores
def average(self):
return sum(self.scores) / len(self.scores)
def summary(self):
return f"{self.name}: average {self.average():.1f}"
aditi = Student("Aditi", [82, 91, 76])
rahul = Student("Rahul", [70, 65, 80])
print(aditi.summary())
print(rahul.summary())
students = [aditi, rahul]
for student in students:
print(student.name, student.average())Expected Output
Click to see expected output
Aditi: average 83.0
Rahul: average 71.7
Aditi 83.0
Rahul 71.66666666666667🔧 Under the Hood
What is self, really?
self, really?self is simply the specific object a method was called on.
When you write:
aditi.average()Python actually performs something equivalent to:
Student.average(aditi)behind the scenes.
Inside the method, self refers to aditi, which is why:
self.scoresreads:
[82, 91, 76]for aditi, but:
[70, 65, 80]for rahul.
Each instance has its own copy of the attributes assigned in __init__, even though all instances share the same class definition.
Part B — Inheritance (“is-a”)
A subclass inherits everything from its parent class and can override any piece of it.
Code
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound."
class Dog(Animal):
def speak(self):
return f"{self.name} says Woof!"
class Cat(Animal):
def speak(self):
return f"{self.name} says Meow!"
animals = [
Dog("Rex"),
Cat("Whiskers"),
Animal("Generic Creature")
]
for animal in animals:
print(animal.speak())
print(isinstance(Dog("Rex"), Animal))Expected Output
Click to see expected output
Rex says Woof!
Whiskers says Meow!
Generic Creature makes a sound.
True🔧 You’ve Already Used Inheritance — in Lesson 14
How inheritance appeared in the SDK
Dog never defines its own __init__, yet this still works:
Dog("Rex")because it automatically inherits Animal.__init__().
It only overrides:
speak()replacing the parent’s implementation for that subclass.
This is why:
isinstance(Dog("Rex"), Animal)returns True.
A Dog is an Animal.
Open exceptions.py in bonicbot_bridge and you’ll see the exact same pattern:
class ConnectionError(BonicBotError):
pass
class ServoError(BonicBotError):
passEvery custom exception inherits from BonicBotError.
That means:
except BonicBotError:would catch:
ConnectionErrorServoError- and every other SDK-specific exception.
In Lesson 14 you caught ConnectionError specifically, but the inheritance hierarchy was there the whole time.
Part C — Composition (“has-a”)
Composition means an object holds other objects as attributes rather than inheriting from them.
Code
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return f"Engine starting ({self.horsepower} HP)."
class Car:
def __init__(self, make, horsepower):
self.make = make
self.engine = Engine(horsepower)
def start(self):
return f"{self.make}: {self.engine.start()}"
my_car = Car("Tata", 120)
print(my_car.start())
print(my_car.engine.horsepower)Expected Output
Click to see expected output
Tata: Engine starting (120 HP).
120🔧 BonicBot Bridge Is Built Entirely Out of Composition
How composition powers the SDK
Car doesn’t inherit from Engine.
A car is not an engine.
A car has an engine:
self.engine = Engine(horsepower)That’s composition.
This is exactly how core.py builds BonicBot.
Inside connect() you’ll find code like:
self.motion = MotionController(self.ros)
self.servo = ServoController(self.ros)
self.sensors = SensorManager(self.ros)The robot:
- has a motion controller,
- has a servo controller,
- has a sensor manager.
Every shortcut method you’ve used, such as:
bot.move_forward(...)is really just delegating to:
self.motion.move_forward(...)Many methods in core.py literally state:
Delegates to: …
because that’s exactly what’s happening.
Student Challenge
Challenge 1 — Python Only
Combine both OOP patterns into a single working program:
- Create a base class
Shapewith anarea(self)method that returns0. - Create two subclasses that inherit from
Shape(Inheritance):Circle: takesradiusin__init__and overridesarea()to return $\pi \times r^2$ (math.pi * self.radius ** 2).Rectangle: takeswidthandheightin__init__and overridesarea()to returnwidth * height.
- Create a
Drawingclass that manages shapes (Composition):- In
__init__, initialize an empty listself.shapes = []. - Add an
add(self, shape)method to append a shape instance toself.shapes. - Add a
total_area(self)method that loops through all stored shapes and returns their combined area.
- In
- Instantiate a
Drawingobject, add aCircle(3)and aRectangle(4, 5), iterate throughdrawing.shapesto print each shape’s type (type(shape).__name__) and individual area, and print the calculated total area.
Hint
type(shape).__name__ reveals the actual subclass (Circle or Rectangle) even though every object is stored uniformly inside drawing.shapes.
Each object still runs its own overridden version of area().
Click to see solution
import math
class Shape:
def area(self):
return 0
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Drawing:
def __init__(self):
self.shapes = []
def add(self, shape):
self.shapes.append(shape)
def total_area(self):
total = 0
for shape in self.shapes:
total += shape.area()
return total
drawing = Drawing()
drawing.add(Circle(3))
drawing.add(Rectangle(4, 5))
for shape in drawing.shapes:
print(f"{type(shape).__name__}: {shape.area():.2f}")
print(f"Total area: {drawing.total_area():.2f}")Challenge 2 — Robot
Inspect how composition is used inside the bonicbot_bridge SDK by examining your BonicBot instance directly.
- Connect to your robot using
with BonicBot(host='192.168.0.188') as bot:. - Print the class name of the main
botinstance usingtype(bot).__name__. - Print the class names of its composed controller attributes:
type(bot.motion).__name__,type(bot.servo).__name__, andtype(bot.sensors).__name__. - Compare direct shortcut method execution with composed object method execution:
- Call
bot.move_forward(speed=0.2, duration=1.0) - Call
bot.motion.move_forward(speed=0.2, duration=1.0)
- Call
- Observe that both calls invoke the exact same movement behavior because top-level methods on
botdelegate directly tobot.motion.
Hint
Both calls execute the same underlying code:
bot.get_servo_angles()and
bot.servo.get_servo_angles()because core.py defines the shortcut as:
return self.servo.get_servo_angles()Click to see solution
from bonicbot_bridge import BonicBot
with BonicBot(host='192.168.0.188') as bot:
print(type(bot).__name__)
print(type(bot.motion).__name__)
print(type(bot.servo).__name__)
print(type(bot.sensors).__name__)
bot.move_forward(speed=0.2, duration=1.0)
bot.motion.move_forward(
speed=0.2,
duration=1.0
)
bot.stop()Challenge 3 — Robot (Inheritance in Practice)
Practice inheritance by creating a custom exception subclass that extends the SDK’s built-in error hierarchy.
- Define a custom exception
LowBatteryErrorthat inherits fromBonicBotError(imported frombonicbot_bridge.exceptions). - Write a safety check function
require_battery(bot, minimum_percent=30)that gets the current battery level withbot.get_battery(). If the battery level is lower thanminimum_percent,raise LowBatteryError(...)with an explanatory message. - In a
with BonicBot(...) as bot:block, callrequire_battery(bot, minimum_percent=30)inside atry/exceptblock:- If the battery check succeeds, print a success message and drive forward
0.3meters. - Add an
except LowBatteryError as exc:block to handle low battery gracefully by printing an abortion notice. - Add an
except BonicBotError as exc:block as a fallback catch-all for any other SDK exceptions.
- If the battery check succeeds, print a success message and drive forward
- Test your code by raising
minimum_percentto a high value like90to confirm your customLowBatteryErroris raised and caught properly.
Hint
LowBatteryError is a subclass you wrote, but it’s still a BonicBotError.
That’s why:
except BonicBotError:would catch it even without:
except LowBatteryError:appearing first.
Click to see solution
from bonicbot_bridge import BonicBot
from bonicbot_bridge.exceptions import BonicBotError
class LowBatteryError(BonicBotError):
"""Raised when the battery is too low to safely start a task."""
pass
def require_battery(bot, minimum_percent=30):
battery = bot.get_battery()
if battery < minimum_percent:
raise LowBatteryError(
f"Battery at {battery}% — "
f"need at least {minimum_percent}%."
)
return battery
with BonicBot(host='192.168.0.188') as bot:
try:
battery = require_battery(
bot,
minimum_percent=30
)
print(
f"Battery OK at {battery}% — "
f"starting patrol."
)
bot.drive_distance(
0.3,
speed=0.2
)
except LowBatteryError as exc:
print(f"Patrol aborted: {exc}")
except BonicBotError as exc:
print(
f"Some other robot error occurred: "
f"{exc}"
)Change minimum_percent to something high, such as 90, and confirm that LowBatteryError is raised instead of the patrol running.
OOP Quick Reference
Reflection Questions
bot.move_forward() and bot.motion.move_forward() do the exact same thing. Given what you now know about composition, why do you think core.py bothers defining move_forward() as a shortcut at all, instead of requiring everyone to always write bot.motion.move_forward()?
LowBatteryError inherits from BonicBotError — the same base class every built-in bonicbot_bridge exception uses. What did inheriting from BonicBotError give you for free that inheriting directly from Python’s plain Exception would not?