9

Topics Covered

A summary of the class-based assignments and advanced concepts in this deck.

Core Class Assignments

  • Assignment 1-2: The `Book` Class & Methods
  • Assignment 3: The `LightSwitch` Class
  • Assignment 4: The `Product` Class
  • Assignment 5: The `Circle` Class
  • Assignment 6: Updating the `Student` Class
  • Assignment 7: Interacting Classes
  • Assignment 8: Conceptual Design Question

Advanced Class Concepts

  • Controlling Attribute Access (`_`, `__`)
  • Pythonic Getters/Setters: `@property`
  • Instance vs. Class Attributes
  • `@classmethod` & `@staticmethod`
  • Dunder Methods: `__str__`, `__add__`
  • Emulating Containers: `__len__`, `__getitem__`
1

Assignment 1: The `Book` Class

Create a class called Book. The __init__ method should take title, author, and pages as arguments and store them as attributes.

Then, instantiate two Book objects with the following details:

  • Title: "The Hobbit", Author: "J.R.R. Tolkien", Pages: 310
  • Title: "1984", Author: "George Orwell", Pages: 328

Finally, print the title of the first book and the author of the second book.

Python Interactive Notion Page

# Your code goes here
class Book:
    def __init__(self, title, author, pages):
        # TODO: Store the arguments as attributes
        pass

# TODO: Create the two book objects

# TODO: Print the required attributes
2

Assignment 2: Add a Method

Modify the Book class from Assignment 1.

Add a method called get_summary() that returns a string in the following format:

"Title by Author, X pages"

Create a Book object for "The Hobbit" and call the get_summary() method on it. Print the returned string.

class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages
    
    # TODO: Add the get_summary() method here
    def get_summary(self):
        pass

# TODO: Create a book object and print its summary
3

Assignment 3: The `LightSwitch`

Model a simple light switch by creating a class called LightSwitch.

  1. In __init__, create an attribute is_on and set its default to False.
  2. Create a method turn_on() that sets is_on to True and prints a confirmation.
  3. Create a method turn_off() that sets is_on to False and prints a confirmation.
  4. Create a method get_status() that prints the current state ("ON" or "OFF").

Instantiate a LightSwitch object and test all its methods.

# Your code goes here
class LightSwitch:
    def __init__(self):
        # TODO: Set the initial state
        pass
    
    # TODO: Add the turn_on, turn_off, 
    # and get_status methods


# TODO: Create a switch and test it
4

Assignment 4: The `Product` Class

Model a product for an e-commerce store by creating a class called Product.

  1. __init__ should accept name, price, and quantity_in_stock.
  2. Create a method calculate_total_value() that returns the price * quantity.
  3. Create a method sell(amount) that reduces the quantity. Ensure you can't sell more than you have in stock.

Create a "Laptop" product (price $1200, quantity 10) and test your methods.

# Your code goes here
class Product:
    def __init__(self, name, price, quantity):
        # TODO: Initialize attributes
        pass

    def calculate_total_value(self):
        # TODO: Implement calculation
        pass

    def sell(self, amount):
        # TODO: Implement sell logic with a check
        pass

# TODO: Create a product and test its methods
5

Assignment 5: The `Circle` Class

Create a class Circle that is initialized with a radius.

  1. The __init__ method should store the radius.
  2. Create a method calculate_area() that returns the area (π * r²).
  3. Create a method calculate_circumference() that returns the circumference (2 * π * r).

You can use 3.14159 for π. Create a Circle with a radius of 5 and print its area and circumference.

# Your code goes here
class Circle:
    def __init__(self, radius):
        self.radius = radius
        self.pi = 3.14159
    
    # TODO: Implement area method

    # TODO: Implement circumference method


# TODO: Create a circle and print its properties
6

Assignment 6: Update the `Student`

Add a new method to the Student class from our lecture.

  1. Start with the Student class that has name, age, and grade.
  2. Add a method set_grade(self, new_grade) to update a student's grade.
  3. The method should only update the grade if the new value is between 0 and 100. Otherwise, it should print an error.

Create a student, update their grade with a valid value, then try to update it with an invalid value to test your logic.

class Student:
    def __init__(self, name, age, grade):
        self.name = name
        self.age = age
        self.grade = grade
    
    def get_grade(self):
        return self.grade

    # TODO: Add the set_grade method here


# TODO: Test your new method
7

Assignment 7: Interacting Classes

Model the relationship between a pet and its owner using two classes.

Pet Class:

  • __init__ takes name and species.
  • get_info() returns a string like "Fido is a Dog".

Owner Class:

  • __init__ takes owner_name and creates an empty list called pets.
  • add_pet(pet_object) adds a Pet to the list.
  • show_pets() prints info for all pets.
class Pet:
    # TODO: Implement the Pet class
    pass

class Owner:
    # TODO: Implement the Owner class
    pass

# TODO: Create an owner and two pets.
# TODO: Add the pets to the owner.
# TODO: Ask the owner to show their pets.
8

Assignment 8: Conceptual Question

Imagine you are designing a simple role-playing game. Describe how you would create a Player class. This is a design question; no code is required.

  1. What attributes would a Player object need? (List at least four).

  2. What methods (actions) would a Player object be able to perform? (List at least three).

  3. How does using a Player class demonstrate the concept of Encapsulation?
# Write your answers here in plain text.
# 1. Attributes:
#    - 
#    - 
#
# 2. Methods:
#    - 
#    - 
#
# 3. Encapsulation:
#    (Your explanation here)
39

Controlling Attribute Access

How do we prevent users from setting invalid values, like a negative resistance?

Python doesn't have true "private" variables, but uses naming conventions:

  • `_protected`: A hint that an attribute is for internal use. You can still access it, but you shouldn't.
  • `__private`: The name is "mangled" (e.g., `__voltage` becomes `_PowerSupply__voltage`), making it much harder to access from outside the class. This helps avoid accidental modification in subclasses.
class PowerSupply:
    def __init__(self, voltage, current_limit):
        self._max_voltage = 24.0 # Protected
        self.__internal_temp = 25.0 # "Private"
        self.set_voltage(voltage) # Use a method for validation
        self.current_limit = current_limit
    def set_voltage(self, new_voltage):
        if 0 <= new_voltage <= self._max_voltage:
            self.voltage = new_voltage
            print(f"Voltage set to {self.voltage}V")
        else:
            print(f"Error: Voltage must be 0-{self._max_voltage}V")
psu = PowerSupply(5.0, 1.0)
psu.set_voltage(30.0) # Error: Voltage must be 0-24.0V
# You can still do this, but the underscore warns you not to.
# psu._max_voltage = 50.0
# This will cause an AttributeError due to name mangling.
# print(psu.__internal_temp)
40

The Pythonic Way: @property

Using getter/setter methods like `get_voltage()` and `set_voltage()` is common in other languages. The Pythonic way is to use the `@property` decorator.

This lets you use standard attribute access syntax (`resistor.resistance = 1000`) while running your validation code in the background. It's the best of both worlds: a clean interface with robust logic.

class Resistor:
    def __init__(self, resistance_ohms):
        # Use the setter property during initialization
        self.resistance = resistance_ohms
    @property
    def resistance(self):
        """The 'getter' for resistance."""
        return self._resistance
    @resistance.setter
    def resistance(self, value):
        """The 'setter' with validation."""
        if value < 0:
            raise ValueError("Resistance cannot be negative.")
        self._resistance = value
r1 = Resistor(1000)
print(f"Resistance: {r1.resistance} Ω") # Looks like simple access
r1.resistance = 2200 # The setter method is called automatically
print(f"New Resistance: {r1.resistance} Ω")
# This will raise a ValueError thanks to our setter.
# r1.resistance = -500


### Attrebuite-like methods

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def area(self):
        return 3.14159 * self._radius ** 2

# Usage
circle = Circle(5)
print(circle.area)  # No parentheses needed! Prints: 78.53975



### Read-only Property

class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name
    
    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"

person = Person("John", "Doe")
print(person.full_name)  # "John Doe"
# person.full_name = "Jane Smith"  # This would raise an error!

41

Instance vs. Class Attributes

Attributes can belong to an individual object (instance) or to the entire class.

  • Instance Attribute: Defined inside `__init__` with `self`. Each object gets its own copy (e.g., `self.resistance`).
  • Class Attribute: Defined directly inside the class. It is shared by all instances of that class. Useful for constants or shared state.
class Capacitor:
    # Class attribute: shared by all Capacitor objects
    DIELECTRIC_MATERIAL = "Ceramic"
    def __init__(self, capacitance_farads):
        # Instance attribute: unique to each capacitor
        self.capacitance = capacitance_farads
        print(Capacitor.DIELECTRIC_MATERIAL)
c1 = Capacitor(1e-6)  # 1 microfarad
c2 = Capacitor(10e-6) # 10 microfarads
# Both instances share the same class attribute
print(f"C1 Material: {c1.DIELECTRIC_MATERIAL}") # Ceramic
print(f"C2 Material: {c2.DIELECTRIC_MATERIAL}") # Ceramic
# But they have unique instance attributes
print(f"C1 Capacitance: {c1.capacitance}F") # 1e-06F
print(f"C2 Capacitance: {c2.capacitance}F") # 1e-05F
42

Class Methods

A class method is bound to the class, not the instance. It receives the class itself as the first argument, conventionally named `cls`.

They are often used as "factory methods" to create instances of the class in alternative ways.

This is useful for creating objects from different units or formats.

class Capacitor:
    DIELECTRIC_MATERIAL = "Ceramic"
    def __init__(self, capacitance_farads):
        self.capacitance = capacitance_farads
    @classmethod
    def from_microfarads(cls, uF):
        """Factory to create a Capacitor from microfarads."""
        # 'cls' is the Capacitor class here
        capacitance = uF * 1e-6
        return cls(capacitance)
    @classmethod
    def from_nanofarads(cls, nF):
        """Factory to create a Capacitor from nanofarads."""
        capacitance = nF * 1e-9
        return cls(capacitance)
# Create instances using the standard constructor
c1 = Capacitor(0.000001)
# Create instances using our new class methods
c2 = Capacitor.from_microfarads(1) # Same as c1
c3 = Capacitor.from_nanofarads(100)
print(f"{c2.capacitance:.7f} F") # 0.0000010 F
print(f"{c3.capacitance:.7f} F") # 0.0000001 F
43

Static Methods

A static method doesn't receive the instance (`self`) or the class (`cls`) as an argument. It's essentially a regular function that is namespaced within the class.

Use them for utility functions that are logically related to the class but don't need to access any class or instance data.

class CircuitUtils:
    @staticmethod
    def parallel_resistance(resistors):
        """Calculate total resistance for resistors in parallel."""
        # Note: no 'self' or 'cls' needed
        if not resistors: return 0
        inverse_sum = sum(1 / r for r in resistors)
        return 1 / inverse_sum
    @staticmethod
    def ohms_law_voltage(i, r):
        """Calculate voltage using Ohm's Law (V=IR)."""
        return i * r
# Call the static method directly on the class
r_parallel = [1000, 2000, 1000]
total_r = CircuitUtils.parallel_resistance(r_parallel)
print(f"Total Parallel Resistance: {total_r:.2f} Ω") # 400.00 Ω
voltage = CircuitUtils.ohms_law_voltage(i=0.05, r=100) # 5A * 100Ω
print(f"Voltage: {voltage}V") # 5.0V
44

Making Classes Printable: `__str__`

Special methods, or "dunder" (double underscore) methods, let your objects integrate with Python's built-in behavior.

  • `__str__(self)`: Called by `str(obj)` and `print(obj)`. Should return a "user-friendly" string representation.
class Resistor:
    def __init__(self, resistance_ohms):
        self.resistance = resistance_ohms
    def __str__(self):
        # User-friendly output
        if self.resistance >= 1e6:
            return f"{self.resistance/1e6:.1f} MΩ Resistor"
        if self.resistance >= 1e3:
            return f"{self.resistance/1e3:.1f} kΩ Resistor"
        return f"{self.resistance} Ω Resistor"
r_kilo = Resistor(2200)
r_mega = Resistor(4700000)
print(r_kilo)      # Calls __str__: 2.2 kΩ Resistor
print(str(r_mega)) # Calls __str__: 4.7 MΩ Resistor
45

Operator Overloading: Custom Math

You can define how standard operators like `+`, `-`, `*` work on your objects by implementing their corresponding dunder methods.

This can make your code incredibly intuitive. For example, what should `resistor1 + resistor2` mean? It could represent connecting them in series!

  • `__add__(self, other)`: Implements the `+` operator.
  • `__mul__(self, other)`: Implements the `*` operator.
class Resistor:
    def __init__(self, resistance):
        self.resistance = resistance
    def __add__(self, other):
        """Overloads the + operator for series connection."""
        if not isinstance(other, Resistor):
            return NotImplemented
        # Resistors in series: R_total = R1 + R2
        new_resistance = self.resistance + other.resistance
        return Resistor(new_resistance)
r1 = Resistor(1000)
r2 = Resistor(2200)
# This now calls the __add__ method we defined!
series_r = r1 + r2
print(f"R1: {r1}") # Resistor(1000)
print(f"R2: {r2}") # Resistor(2200)
print(f"R1 and R2 in series: {series_r}") # Resistor(3200)
46

Emulating Containers

You can make your objects behave like lists or dictionaries.

  • `__len__(self)`: Called by `len(obj)`. Should return the length of the object.
  • `__getitem__(self, key)`: Called by `obj[key]` (indexing or slicing). Allows your object to be accessed like a sequence.

Let's model a digital signal as a collection of samples.

class DigitalSignal:
    def __init__(self, samples):
        # samples is a list of 0s and 1s
        self._samples = list(samples)
    def __len__(self):
        """Return the number of samples in the signal."""
        return len(self._samples)
    def __getitem__(self, index):
        """Allow accessing samples by index."""
        return self._samples[index]
# A simple clock signal
clk_signal = DigitalSignal([0, 1, 0, 1, 0, 1, 0, 1])
print(f"Signal has {len(clk_signal)} samples.") # Calls __len__
print(f"Sample at index 3 is: {clk_signal[3]}") # Calls __getitem__
# We can even iterate over it because it has __getitem__!
for sample in clk_signal:
    print(sample, end=' ')