Skip to content

Latest commit

 

History

History
287 lines (207 loc) · 7.74 KB

File metadata and controls

287 lines (207 loc) · 7.74 KB
layout default
title Object-Oriented Python
parent Lessons
nav_order 5
permalink /lessons/object-oriented-python/
course_lesson true
course_index 05
previous_page /lessons/decorators-context-managers/
previous_title Decorators and Context Managers
next_page /lessons/dataclasses-data-model/
next_title Dataclasses and Data Model

05 - Object-Oriented Python

Classes group data and behavior. Use them when an object has a clear identity and responsibilities.

A simple picture

A class is a blueprint for a kind of object. An instance is one real object made from that blueprint. A Student class describes what every student object can know and do; Asha and Ravi are separate instances with separate data.

Classes and instances

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self.balance += amount


account = BankAccount("Asha", 500)
account.deposit(200)
print(account.balance)

BankAccount("Asha", 500) creates an instance. Python passes that new instance to __init__() as self. Each account receives its own owner and balance attributes.

Calling account.deposit(200) is roughly equivalent to BankAccount.deposit(account, 200). Python supplies self automatically in the normal method call.

Instance attributes and class attributes

class Student:
    school_name = "River School"  # shared class attribute

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

Use class attributes for values genuinely shared by every instance. Do not use a mutable class attribute such as students = [] for per-instance data.

Instance, class, and static methods

  • An instance method receives self and works with one object.
  • A class method receives cls and often creates an object in an alternative way.
  • A static method receives neither automatically and belongs in the class only when it is closely related.
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @classmethod
    def from_fahrenheit(cls, fahrenheit):
        celsius = (fahrenheit - 32) * 5 / 9
        return cls(celsius)

    @staticmethod
    def is_valid(celsius):
        return celsius >= -273.15

Encapsulation and properties

Encapsulation means keeping state changes and the rules protecting them together. It is not about hiding everything.

A leading underscore communicates “internal use” by convention:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

    @property
    def balance(self):
        return self._balance

Python does not make _balance truly private. Callers cooperate with the convention.

A property can expose a computed value like an attribute:

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def fahrenheit(self):
        return self.celsius * 9 / 5 + 32

Do not write a property only to imitate simple direct assignment. Use it when computation, validation, or compatibility makes it valuable.

A validated property

class Product:
    def __init__(self, price):
        self.price = price

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = value

The initializer uses the property setter, so the same rule protects initial and later values.

Inheritance and composition

Inheritance expresses an “is a” relationship. Composition combines objects and is often easier to change.

Inheritance and super()

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

    def describe(self):
        return self.name


class Developer(Employee):
    def __init__(self, name, language):
        super().__init__(name)
        self.language = language

    def describe(self):
        return f"{super().describe()} writes {self.language}"

Use inheritance when the child can safely be used wherever the parent is expected. Deep inheritance trees make behavior difficult to follow.

Composition and dependency injection

class EmailSender:
    def send(self, message):
        print(message)

class NotificationService:
    def __init__(self, sender):
        self.sender = sender

    def notify(self, message):
        self.sender.send(message)

The service has a sender. Passing the sender into the initializer is dependency injection. Tests can supply a small fake sender without sending real email.

class FakeSender:
    def __init__(self):
        self.messages = []

    def send(self, message):
        self.messages.append(message)

Python often uses duck typing: if an object provides the needed send() method, its concrete class does not matter.

Representing objects

__repr__() should produce a developer-friendly representation:

class Book:
    def __init__(self, title):
        self.title = title

    def __repr__(self):
        return f"Book(title={self.title!r})"

!r uses the representation of the value, making quotes and special characters visible.

Bug Hunter

Bug 1: all carts share one list

class Cart:
    items = []

    def add(self, item):
        self.items.append(item)

Bug 2: parent initialization is skipped

class Developer(Employee):
    def __init__(self, name, language):
        self.language = language

Bug 3: validation can be bypassed

account.balance = -1000
Show Bug Hunter fixes
  1. Create self.items = [] inside __init__() so each cart owns a list.
  2. Call super().__init__(name) before initializing child-specific state.
  3. Keep the balance in _balance and expose controlled deposit/withdraw methods or a validated property.

Practice

  1. Create a Student class.
  2. Add a method that validates a mark.
  3. Create a bank account with deposit and withdrawal.
  4. Add a property for a computed value.
  5. Compare inheritance and composition for notifications.
  6. Create a base Shape class and two implementations.
  7. Define a useful __repr__.
  8. Prevent invalid state inside a method.
  9. Inject a fake sender for testing.
  10. Refactor a procedural program into classes.
Show hints
  1. Store the student's name and marks.
  2. Reject values outside the valid range.
  3. Check balance before withdrawal.
  4. Use @property for read-only calculated data.
  5. Ask whether the relationship is “is a” or “uses”.
  6. Give each shape an area() method.
  7. Return a short useful description.
  8. Do not let callers directly bypass the rule.
  9. Pass an object with the same send() method.
  10. Keep each class small and focused.
Show solution ideas
  1. Define __init__ and a method such as average().
  2. Raise ValueError for invalid marks.
  3. Change balance only after checking the amount.
  4. @property def total(self): ....
  5. Composition makes the sender replaceable.
  6. Use a common interface and different formulas.
  7. return f"Student(name={self.name!r})".
  8. Keep state changes behind methods.
  9. Use a fake object in the test.
  10. Move related state and behavior together, not every function into a class.

Homework

Build a library domain model with Book, Member, and Library classes, validation, and tests.

Checkpoint

Explain instance, class, method, property, inheritance, composition, and dependency injection.