Attributes and Methods in Object-Oriented Programming

Week 3: Monday Morning Session

Lesson Overview

Welcome to our exploration of attributes and methods - the building blocks that give objects their characteristics and behaviors in Object-Oriented Programming (OOP). Today's session will demystify how we define and use these essential components to create powerful, flexible, and reusable code in Python.

Understanding Attributes and Methods

In Object-Oriented Programming, classes serve as blueprints for objects, and these objects are composed of two main elements:

Real-world analogy: Think of a car. A car has attributes like color, make, model, current speed, and fuel level. These are its properties or characteristics. A car can also perform actions like accelerate, brake, turn, and honk its horn. These are its methods or behaviors.

In programming terms:

  • Attributes are like the adjectives that describe an object
  • Methods are like the verbs that describe what an object can do

Let's explore each of these concepts in depth, examining how they work in Python's implementation of OOP.

Attributes in Python

Attributes are variables that store data within a class or instance. In Python, there are two main types of attributes:

Class Attributes

Class attributes belong to the class itself rather than any specific instance. They are defined directly within the class but outside of any methods. All instances of the class share the same class attributes, and if a class attribute is modified, the change affects all instances.

Instance Attributes

Instance attributes belong to a specific instance of a class. They are typically defined in the __init__ method and are prefixed with self. to indicate they belong to the instance being created. Each instance has its own copy of instance attributes, which can have different values across instances.

class Car:
    # Class attribute
    wheels = 4  # All cars typically have 4 wheels
    
    def __init__(self, make, model, color, fuel_level=100):
        # Instance attributes
        self.make = make
        self.model = model
        self.color = color
        self.fuel_level = fuel_level
        self.speed = 0  # Car starts at 0 speed

# Creating car instances
tesla = Car("Tesla", "Model 3", "Red")
toyota = Car("Toyota", "Corolla", "Blue", 75)

# Accessing class attributes
print(Car.wheels)       # Output: 4
print(tesla.wheels)     # Output: 4 (accessed through instance)
print(toyota.wheels)    # Output: 4 (accessed through instance)

# Accessing instance attributes
print(tesla.make)       # Output: Tesla
print(tesla.color)      # Output: Red
print(toyota.model)     # Output: Corolla
print(toyota.fuel_level)  # Output: 75

# Modifying instance attributes
tesla.speed = 60
print(tesla.speed)      # Output: 60
print(toyota.speed)     # Output: 0 (unchanged)

# Modifying class attribute (affects ALL instances)
Car.wheels = 6
print(Car.wheels)       # Output: 6
print(tesla.wheels)     # Output: 6
print(toyota.wheels)    # Output: 6

Important distinction: Class attributes are shared among all instances, while instance attributes are unique to each instance. Think of class attributes as traits common to the entire species, while instance attributes are characteristics unique to each individual.

Attribute Best Practices

When working with attributes in Python, consider these best practices:

# Caution with mutable class attributes
class BadExample:
    items = []  # This list is shared by ALL instances!
    
    def add_item(self, item):
        self.items.append(item)  # Modifies the class attribute

instance1 = BadExample()
instance2 = BadExample()

instance1.add_item("apple")
print(instance1.items)  # Output: ['apple']
print(instance2.items)  # Output: ['apple'] - Surprise! instance2's list has the item too!

# Better approach
class GoodExample:
    def __init__(self):
        self.items = []  # Each instance gets its own list
    
    def add_item(self, item):
        self.items.append(item)

instance1 = GoodExample()
instance2 = GoodExample()

instance1.add_item("apple")
print(instance1.items)  # Output: ['apple']
print(instance2.items)  # Output: [] - This is what we want!

Dynamic Attributes in Python

Unlike some OOP languages, Python allows the dynamic addition of attributes to instances after they've been created. This flexibility can be both powerful and dangerous if not used carefully.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

alice = Person("Alice", 30)

# Adding an attribute dynamically
alice.email = "alice@example.com"
print(alice.email)  # Output: alice@example.com

# But this only affects the 'alice' instance
bob = Person("Bob", 25)
# print(bob.email)  # This would raise an AttributeError

To restrict this behavior and enforce a more rigid attribute structure, you can use the __slots__ class variable:

class PersonWithSlots:
    __slots__ = ['name', 'age']  # Only these attributes are allowed
    
    def __init__(self, name, age):
        self.name = name
        self.age = age

charlie = PersonWithSlots("Charlie", 35)
# charlie.email = "charlie@example.com"  # This would raise an AttributeError

Using __slots__ also makes your classes more memory-efficient, which can be important when creating many instances.

Property Decorators: Controlled Attribute Access

In many cases, you want to control how attributes are accessed or modified. Python's property decorators let you define methods that act like attributes, giving you more control while maintaining a clean interface.

class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius  # Private-by-convention attribute
    
    # Getter method (called when accessing the attribute)
    @property
    def celsius(self):
        return self._celsius
    
    # Setter method (called when modifying the attribute)
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:  # Absolute zero check
            raise ValueError("Temperature below absolute zero is not possible")
        self._celsius = value
    
    # Another property that depends on celsius
    @property
    def fahrenheit(self):
        return (self._celsius * 9/5) + 32
    
    # Setter for fahrenheit
    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5/9  # Convert and use the celsius setter

# Using properties
temp = Temperature(25)  # 25°C

# Accessing properties
print(temp.celsius)      # Output: 25
print(temp.fahrenheit)   # Output: 77.0

# Setting properties with validation
temp.celsius = 30
print(temp.celsius)      # Output: 30

# Setting derived properties
temp.fahrenheit = 68
print(temp.celsius)      # Output: 20.0

# Validation in action
try:
    temp.celsius = -300  # Below absolute zero
except ValueError as e:
    print(e)  # Output: Temperature below absolute zero is not possible

Properties give you the benefits of encapsulation while maintaining a clean, attribute-like interface:

Analogy: Think of properties like a secure mailbox. From the outside, it looks like a simple way to drop off or pick up mail (get/set values). But behind the scenes, there's sophisticated processing happening - mail gets sorted, validated, and potentially transformed before it reaches its destination.

Methods in Python

Methods are functions defined inside a class that describe the behaviors of its instances. Methods define what objects can do and how they can interact with their attributes and the outside world.

In Python, there are several types of methods:

Instance Methods

These are the most common type of methods. They take self as their first parameter, which refers to the instance on which the method is called. Instance methods can access and modify the instance's attributes.

Class Methods

These methods are bound to the class rather than its instances. They take cls as their first parameter, which refers to the class itself. Class methods are defined using the @classmethod decorator and can access and modify class-level attributes.

Static Methods

These methods don't operate on either the instance or the class. They're defined using the @staticmethod decorator and don't take self or cls as their first parameter. They're essentially regular functions that are logically related to the class.

class MathOperations:
    pi = 3.14159  # Class attribute
    
    def __init__(self, value):
        self.value = value  # Instance attribute
    
    # Instance method
    def square(self):
        return self.value ** 2
    
    # Class method
    @classmethod
    def circle_area(cls, radius):
        return cls.pi * radius ** 2
    
    # Static method
    @staticmethod
    def add(x, y):
        return x + y

# Creating an instance
math_ops = MathOperations(4)

# Calling instance method
print(math_ops.square())  # Output: 16

# Calling class method
area = MathOperations.circle_area(5)
print(area)  # Output: 78.53975

# Calling static method
sum_result = MathOperations.add(10, 20)
print(sum_result)  # Output: 30

When to use each type of method:

  • Use instance methods when you need to access or modify instance-specific data
  • Use class methods when you need to access or modify class attributes, or when you want to create alternative constructors
  • Use static methods for utility functions that are related to the class's domain but don't need access to instance or class data

Method Implementation Patterns

Let's explore some common patterns for implementing methods in your classes:

Accessor Methods (Getters)

Methods that return the value of a specific attribute, often with some additional processing or formatting:

class User:
    def __init__(self, first_name, last_name, birth_year):
        self.first_name = first_name
        self.last_name = last_name
        self.birth_year = birth_year
    
    # Accessor method
    def get_full_name(self):
        return f"{self.first_name} {self.last_name}"
    
    # Accessor with calculation
    def get_age(self, current_year):
        return current_year - self.birth_year

user = User("Ada", "Lovelace", 1815)
print(user.get_full_name())  # Output: Ada Lovelace
print(user.get_age(2025))    # Output: 210

Mutator Methods (Setters)

Methods that modify the state of an object by changing its attributes, often with validation:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance  # Private-by-convention
    
    # Mutator method with validation
    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self._balance += amount
        return self._balance
    
    # Another mutator with validation
    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
        return self._balance
    
    # Accessor method
    def get_balance(self):
        return self._balance

account = BankAccount("Alice", 1000)
print(account.get_balance())  # Output: 1000
account.deposit(500)
print(account.get_balance())  # Output: 1500
account.withdraw(200)
print(account.get_balance())  # Output: 1300

Helper Methods

These are internal methods that help other methods do their job. They're typically prefixed with an underscore to indicate they're not meant to be called directly:

class TextProcessor:
    def __init__(self, text):
        self.text = text
    
    # Main public method
    def get_word_count(self):
        words = self._clean_and_split()
        return len(words)
    
    # Helper method (private by convention)
    def _clean_and_split(self):
        # Remove punctuation, convert to lowercase, and split into words
        cleaned_text = ''.join(c if c.isalnum() or c.isspace() else ' ' for c in self.text.lower())
        return cleaned_text.split()
    
    # Another public method that uses the helper
    def get_unique_words(self):
        words = self._clean_and_split()
        return len(set(words))

text = "Hello, world! This is a sample text. Hello again."
processor = TextProcessor(text)
print(processor.get_word_count())    # Output: 9
print(processor.get_unique_words())  # Output: 8

Alternative Constructor Methods

These class methods create instances in a different way than the standard __init__ method:

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
    
    # Standard string representation
    def __str__(self):
        return f"{self.year}-{self.month:02d}-{self.day:02d}"
    
    # Alternative constructor from string
    @classmethod
    def from_string(cls, date_string):
        year, month, day = map(int, date_string.split('-'))
        return cls(year, month, day)
    
    # Another alternative constructor
    @classmethod
    def from_timestamp(cls, timestamp):
        import datetime
        dt = datetime.datetime.fromtimestamp(timestamp)
        return cls(dt.year, dt.month, dt.day)

# Standard initialization
date1 = Date(2023, 12, 31)
print(date1)  # Output: 2023-12-31

# Using alternative constructors
date2 = Date.from_string("2025-04-15")
print(date2)  # Output: 2025-04-15

date3 = Date.from_timestamp(1609459200)  # January 1, 2021
print(date3)  # Output: 2021-01-01

Method Chaining

Method chaining is a pattern where methods return self (the instance), allowing multiple method calls to be chained together in a single statement. This creates more readable, fluid interfaces:

class StringBuilder:
    def __init__(self, initial=''):
        self.content = initial
    
    def append(self, text):
        self.content += text
        return self  # Return self for chaining
    
    def append_line(self, text):
        self.content += text + '\n'
        return self  # Return self for chaining
    
    def clear(self):
        self.content = ''
        return self  # Return self for chaining
    
    def __str__(self):
        return self.content

# Using method chaining
builder = StringBuilder("Hello")
result = builder.append(", ").append("World").append_line("!").append("How are you?")

print(result)
# Output:
# Hello, World!
# How are you?

# Without chaining, it would look like:
# builder = StringBuilder("Hello")
# builder.append(", ")
# builder.append("World")
# builder.append_line("!")
# builder.append("How are you?")

Method chaining is common in libraries like Pandas and SQLAlchemy, where it allows for expressive, readable code.

Analogy: Think of method chaining like giving a series of instructions to a chef: "Take the dough, add cheese, add toppings, bake for 15 minutes, and serve." Each step builds on the previous one, and the result flows naturally from one operation to the next.

Special Methods (Dunder Methods)

Python has special method names that start and end with double underscores ("dunder" = "double underscore"). These methods allow your classes to integrate with Python's built-in functions and operators.

We've already seen __init__, but there are many others:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    # String representation (used by str() and print())
    def __str__(self):
        return f"({self.x}, {self.y})"
    
    # Developer representation (used in debugging)
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"
    
    # Addition (v1 + v2)
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    # Subtraction (v1 - v2)
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)
    
    # Multiplication (v * scalar)
    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)
    
    # Length (len(v))
    def __len__(self):
        return int((self.x**2 + self.y**2)**0.5)
    
    # Equality (v1 == v2)
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

# Creating vectors
v1 = Vector(3, 4)
v2 = Vector(1, 2)

# Using special methods through operators and functions
print(v1)                  # Output: (3, 4) - uses __str__
print(repr(v1))            # Output: Vector(3, 4) - uses __repr__
v3 = v1 + v2               # Uses __add__
print(v3)                  # Output: (4, 6)
v4 = v1 - v2               # Uses __sub__
print(v4)                  # Output: (2, 2)
v5 = v2 * 3                # Uses __mul__
print(v5)                  # Output: (3, 6)
print(len(v1))             # Output: 5 - uses __len__
print(v1 == Vector(3, 4))  # Output: True - uses __eq__

Some other useful special methods include:

Python's philosophy: By implementing these special methods, you allow your custom objects to behave like Python's built-in types, following the "duck typing" principle: "If it walks like a duck and quacks like a duck, then it probably is a duck." This creates a consistent, intuitive interface for your classes.

Practical Example: Building a Product Inventory System

Let's apply our knowledge of attributes and methods to build a more complex example: a product inventory system. This will demonstrate how attributes and methods work together to create a functional application.

class Product:
    # Class attribute - tracks all products
    all_products = []
    
    def __init__(self, name, price, quantity=0):
        # Validate inputs
        assert price > 0, f"Price {price} must be positive"
        assert quantity >= 0, f"Quantity {quantity} must be non-negative"
        
        # Instance attributes
        self.name = name
        self.price = price
        self.quantity = quantity
        self._is_active = True
        
        # Add to class list
        Product.all_products.append(self)
    
    # Instance method
    def calculate_total_value(self):
        return self.price * self.quantity
    
    # Property for active status
    @property
    def is_active(self):
        return self._is_active
    
    # Method to discontinue product
    def discontinue(self):
        self._is_active = False
        return f"{self.name} has been discontinued"
    
    # Method to restock
    def restock(self, amount):
        if amount <= 0:
            raise ValueError("Restock amount must be positive")
        self.quantity += amount
        return f"{amount} units of {self.name} added to inventory"
    
    # Method to sell
    def sell(self, amount):
        if amount <= 0:
            raise ValueError("Sell amount must be positive")
        if amount > self.quantity:
            raise ValueError(f"Cannot sell {amount} units, only {self.quantity} available")
        self.quantity -= amount
        return f"{amount} units of {self.name} sold"
    
    # String representation
    def __str__(self):
        status = "active" if self._is_active else "discontinued"
        return f"{self.name}: ${self.price:.2f} ({self.quantity} in stock, {status})"
    
    # Class method to find product by name
    @classmethod
    def find_by_name(cls, name):
        for product in cls.all_products:
            if product.name.lower() == name.lower():
                return product
        return None
    
    # Class method to get active products
    @classmethod
    def get_active_products(cls):
        return [p for p in cls.all_products if p.is_active]
    
    # Class method to get out-of-stock products
    @classmethod
    def get_out_of_stock(cls):
        return [p for p in cls.all_products if p.quantity == 0 and p.is_active]
    
    # Static method to convert currency
    @staticmethod
    def convert_price(price, exchange_rate):
        return price * exchange_rate

# Using our Product class
# Create some products
laptop = Product("Laptop", 1200, 5)
phone = Product("Smartphone", 800, 10)
tablet = Product("Tablet", 300, 8)
headphones = Product("Wireless Headphones", 150, 15)

# Using instance methods
print(laptop)  # Output: Laptop: $1200.00 (5 in stock, active)
laptop.sell(2)
print(laptop)  # Output: Laptop: $1200.00 (3 in stock, active)
print(f"Total value of laptop inventory: ${laptop.calculate_total_value()}")  # Output: $3600.00

# Using properties
print(f"Is the phone active? {phone.is_active}")  # Output: True
phone.discontinue()
print(f"Is the phone active? {phone.is_active}")  # Output: False

# Using class methods
active_products = Product.get_active_products()
print(f"Active products: {len(active_products)}")  # Output: 3

# Sell all tablets to test out-of-stock functionality
tablet.sell(8)
out_of_stock = Product.get_out_of_stock()
print(f"Out of stock products: {len(out_of_stock)}")  # Output: 1
print(out_of_stock[0])  # Output: Tablet: $300.00 (0 in stock, active)

# Using static method
euro_price = Product.convert_price(laptop.price, 0.85)
print(f"Laptop price in euros: €{euro_price:.2f}")  # Output: €1020.00

This example demonstrates:

Best Practices for Attributes and Methods

Naming Conventions

Design Principles

Common Mistakes to Avoid

Principle: Think of the "public interface" of your class as a contract with others who will use it. Design attributes and methods to be intuitive, consistent, and resistant to misuse.

Attributes and Methods in the Python Ecosystem

Understanding attributes and methods will help you work with many Python libraries and frameworks:

Built-in Types

Python's built-in types have methods you've likely used:

Pandas and Data Science

Pandas DataFrames use methods extensively for data manipulation:

Web Frameworks

Django and Flask rely on classes with attributes and methods:

Real-world application: When using Django (which we'll cover later in the course), you'll define model classes where attributes become database fields and methods define business logic:

from django.db import models

class Order(models.Model):
    # Attributes become database fields
    customer = models.ForeignKey('Customer', on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)
    status = models.CharField(max_length=20, choices=[
        ('pending', 'Pending'),
        ('paid', 'Paid'),
        ('shipped', 'Shipped'),
        ('delivered', 'Delivered')
    ])
    total_amount = models.DecimalField(max_digits=10, decimal_places=2)
    
    # Methods provide business logic
    def mark_as_paid(self):
        if self.status == 'pending':
            self.status = 'paid'
            self.save()
            return True
        return False
    
    def mark_as_shipped(self):
        if self.status == 'paid':
            self.status = 'shipped'
            self.save()
            return True
        return False
    
    # Property provides derived data
    @property
    def is_complete(self):
        return self.status == 'delivered'
    
    # Class method provides a way to find orders
    @classmethod
    def get_pending_orders(cls):
        return cls.objects.filter(status='pending')

Advanced Topics

Descriptors

Descriptors provide a powerful way to customize attribute access at the class level. They implement a combination of __get__, __set__, and __delete__ methods.

class PositiveNumber:
    def __init__(self):
        self.name = None  # Will be set in __set_name__
    
    def __set_name__(self, owner, name):
        self.name = name
    
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self.name]
    
    def __set__(self, instance, value):
        if value <= 0:
            raise ValueError(f"{self.name} must be positive")
        instance.__dict__[self.name] = value

class Product:
    price = PositiveNumber()
    quantity = PositiveNumber()
    
    def __init__(self, name, price, quantity):
        self.name = name
        self.price = price      # This will use the descriptor
        self.quantity = quantity  # This will use the descriptor
    
    def total_value(self):
        return self.price * self.quantity

# Using the class with descriptors
product = Product("Laptop", 1200, 5)
print(product.price)  # Output: 1200

try:
    product.price = -100  # This will raise an error
except ValueError as e:
    print(e)  # Output: price must be positive

Attribute Lookup Customization

You can customize attribute lookup with __getattr__ and __setattr__ methods:

class FlexibleObject:
    def __init__(self, **kwargs):
        # Initialize with provided attributes
        for key, value in kwargs.items():
            setattr(self, key, value)
        
        # Track attribute access
        self._accessed = set()
    
    def __getattr__(self, name):
        # Called when an attribute isn't found normally
        print(f"Attempting to access undefined attribute: {name}")
        return None
    
    def __setattr__(self, name, value):
        # Called for ALL attribute assignments
        if name != "_accessed" and not name.startswith("_"):
            if hasattr(self, "_accessed"):
                self._accessed.add(name)
        
        # Actually set the attribute
        super().__setattr__(name, value)
    
    def get_accessed_attributes(self):
        return list(self._accessed)

# Using the flexible object
obj = FlexibleObject(name="Test", value=42)
print(obj.name)       # Output: Test
print(obj.value)      # Output: 42
print(obj.missing)    # Output: Attempting to access undefined attribute: missing
                     # Output: None

obj.new_attr = "Hello"
print(obj.get_accessed_attributes())  # Output: ['name', 'value', 'new_attr']

Hands-on Exercise: Building a Student Management System

Let's put our knowledge of attributes and methods into practice with a hands-on exercise. Your task is to build a Student class that manages student information and grades.

Requirements:

  1. Store student name, ID, and courses with grades
  2. Calculate GPA (on a 4.0 scale)
  3. Add and remove courses
  4. Track the number of students created
  5. Find students by ID

Here's a solution:

class Student:
    # Class attributes
    all_students = []
    grade_points = {"A": 4.0, "B": 3.0, "C": 2.0, "D": 1.0, "F": 0.0}
    
    def __init__(self, name, student_id):
        # Instance attributes
        self.name = name
        self.student_id = student_id
        self.courses = {}  # course_name: grade
        
        # Add to class list
        Student.all_students.append(self)
    
    # Instance methods
    def add_course(self, course_name, grade):
        if grade not in Student.grade_points:
            raise ValueError(f"Invalid grade '{grade}'. Must be one of {list(Student.grade_points.keys())}")
        self.courses[course_name] = grade
        return f"Added {course_name} with grade {grade}"
    
    def remove_course(self, course_name):
        if course_name in self.courses:
            del self.courses[course_name]
            return f"Removed {course_name}"
        return f"{course_name} not found in student's courses"
    
    @property
    def gpa(self):
        if not self.courses:
            return 0.0
        
        total_points = sum(Student.grade_points[grade] for grade in self.courses.values())
        return total_points / len(self.courses)
    
    def get_courses(self):
        if not self.courses:
            return "No courses registered"
        
        result = f"Courses for {self.name} (ID: {self.student_id}):\\n"
        for course, grade in self.courses.items():
            result += f"- {course}: {grade}\\n"
        return result
    
    # String representation
    def __str__(self):
        return f"{self.name} (ID: {self.student_id}, GPA: {self.gpa:.2f})"
    
    # Class methods
    @classmethod
    def find_by_id(cls, student_id):
        for student in cls.all_students:
            if student.student_id == student_id:
                return student
        return None
    
    @classmethod
    def get_all_students(cls):
        return cls.all_students
    
    @classmethod
    def get_student_count(cls):
        return len(cls.all_students)

# Using the Student class
alice = Student("Alice Smith", "A123")
bob = Student("Bob Johnson", "B456")
charlie = Student("Charlie Brown", "C789")

# Adding courses
alice.add_course("Python Programming", "A")
alice.add_course("Database Systems", "B")
alice.add_course("Web Development", "A")

bob.add_course("Python Programming", "C")
bob.add_course("Mathematics", "A")

# Checking GPA
print(alice)  # Output: Alice Smith (ID: A123, GPA: 3.67)
print(bob)    # Output: Bob Johnson (ID: B456, GPA: 3.50)
print(charlie)  # Output: Charlie Brown (ID: C789, GPA: 0.00)

# Removing a course
alice.remove_course("Database Systems")
print(alice.gpa)  # Output: 4.0 (now only has A grades)

# Using class methods
print(f"Total students: {Student.get_student_count()}")  # Output: 3

found_student = Student.find_by_id("B456")
print(found_student)  # Output: Bob Johnson (ID: B456, GPA: 3.50)

print(alice.get_courses())
# Output:
# Courses for Alice Smith (ID: A123):
# - Python Programming: A
# - Web Development: A

This example demonstrates:

Conclusion

In this session, we've explored the essential components of Object-Oriented Programming in Python:

We've learned about:

These concepts form the building blocks of object-oriented design. As you continue in the course, you'll see how these fundamentals enable more advanced OOP features like inheritance, polymorphism, and abstraction.

Practice Exercise

Extend the Student Management System to include:

  1. A method to calculate class standing (Freshman, Sophomore, Junior, Senior) based on credits completed
  2. A way to track attendance for each course
  3. A class method to find all students with a GPA above a certain threshold

Remember to apply the principles we've learned about proper attribute and method design!

Additional Resources