Constructors and the __init__ Method in Python

Week 3: Monday Morning Session

Lesson Overview

Welcome to our deep dive into constructors and the __init__ method in Python! This fundamental aspect of Object-Oriented Programming allows us to create and initialize objects with specific attributes and behaviors. By the end of this session, you'll understand how to properly design and implement constructors to create robust, flexible classes.

What Are Constructors?

In object-oriented programming, a constructor is a special method that gets called automatically when an object is created from a class. Its primary purpose is to initialize the new object with any attributes and setup it needs to function properly.

Real-world analogy: Think of a constructor like the setup process when you buy a new smartphone. Before you can use it, the phone needs initial configuration - setting the language, connecting to Wi-Fi, creating a user account, etc. This setup process ensures the phone is ready for use with your specific preferences. Similarly, a constructor prepares a new object with the specific attributes it needs to be useful.

In Python, the constructor functionality is implemented through a special method called __init__. This method is automatically invoked right after an object is created in memory, allowing you to initialize the object's attributes and perform any required setup.

The name __init__ stands for "initialize" and is one of Python's special methods (also called "dunder" methods because they're surrounded by double underscores).

The __init__ Method in Python

Let's look at the basic syntax of the __init__ method:

class MyClass:
    def __init__(self, param1, param2, ...):
        # Initialize attributes
        self.attribute1 = param1
        self.attribute2 = param2
        # Additional initialization code

Key points about the __init__ method:

Here's a simple example of a Person class with an __init__ method:

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

# Creating a Person object
alice = Person("Alice", 30)

# Using the initialized attributes
print(alice.name)  # Output: Alice
print(alice.age)   # Output: 30

In this example, the __init__ method takes two parameters (name and age) and assigns them to instance attributes. When we create a new Person object with alice = Person("Alice", 30), the __init__ method is automatically called with the provided arguments.

The self Parameter

One of the most important aspects of the __init__ method is the self parameter. This parameter is a reference to the instance being created, allowing the method to assign attributes to that specific instance.

Analogy: Think of self as "this specific object I'm working with right now." It's like saying "put this name on this form" rather than "put this name on a form." The self parameter gives the method a way to refer to the particular object being initialized.

While the convention is to name this parameter self, Python technically allows you to name it anything. However, using self is a strong convention that all Python programmers follow for readability and consistency.

# These two class definitions are functionally equivalent:

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

class AlsoPerson:
    def __init__(this_instance, name, age):  # 'this_instance' instead of 'self'
        this_instance.name = name
        this_instance.age = age

# Both work the same, but the first one follows convention

The self parameter is automatically passed by Python when you call a method on an object. When you write alice.some_method(), Python translates this to Person.some_method(alice) behind the scenes, passing the object as the first argument.

What Actually Happens When Creating an Object

To understand constructors fully, it helps to know what actually happens when you create a new object in Python. The process involves two special methods:

  1. __new__: Creates the object in memory (rarely overridden)
  2. __init__: Initializes the newly created object

Here's the sequence:

# When you write:
alice = Person("Alice", 30)

# Python does (roughly) this:
# 1. Create an empty Person object in memory
alice = Person.__new__(Person)
# 2. Initialize the object with __init__
Person.__init__(alice, "Alice", 30)

In most cases, you'll only need to implement __init__ and can leave __new__ to its default behavior. The __new__ method is only overridden in special cases, such as when implementing singleton patterns or working with immutable types.

Default Arguments and Flexible Constructors

Constructors become more powerful when you add default arguments, making them flexible enough to handle different initialization scenarios:

class BankAccount:
    def __init__(self, owner, account_number, balance=0, account_type="checking"):
        self.owner = owner
        self.account_number = account_number
        self.balance = balance
        self.account_type = account_type
        self.is_active = True

# Different ways to create accounts
account1 = BankAccount("Alice", "12345")  # Using defaults
account2 = BankAccount("Bob", "67890", 1000)  # Specifying balance
account3 = BankAccount("Charlie", "54321", 500, "savings")  # Specifying all parameters

print(account1.balance)      # Output: 0
print(account2.account_type)  # Output: checking
print(account3.account_type)  # Output: savings

Default arguments allow users of your class to specify only the parameters they care about, making your class more convenient to use. They can drastically reduce the amount of repetitive code needed to instantiate objects with common values.

Best practice: Place parameters without default values first, followed by parameters with default values. This follows Python's rule that non-default parameters cannot follow default parameters in function definitions.

Using *args and **kwargs in Constructors

For even more flexibility, you can use Python's *args and **kwargs syntax in your constructors:

class FlexiblePerson:
    def __init__(self, name, age, *args, **kwargs):
        self.name = name
        self.age = age
        self.additional_info = args  # A tuple of additional positional arguments
        self.properties = kwargs  # A dictionary of additional keyword arguments
        
        # We can also selectively extract specific kwargs
        self.email = kwargs.get('email', 'No email provided')

# Using the flexible constructor
person = FlexiblePerson("Alice", 30, "Developer", "New York", email="alice@example.com", 
                        department="Engineering", employee_id=12345)

print(person.name)  # Output: Alice
print(person.additional_info)  # Output: ('Developer', 'New York')
print(person.properties)  # Output: {'email': 'alice@example.com', 'department': 'Engineering', 'employee_id': 12345}
print(person.email)  # Output: alice@example.com

This pattern is particularly useful when:

Validating Inputs in Constructors

A key benefit of constructors is that they provide a centralized place to validate inputs before creating an object. This helps ensure that objects are always in a valid state:

class Rectangle:
    def __init__(self, width, height):
        # Validate inputs
        if not isinstance(width, (int, float)) or width <= 0:
            raise ValueError("Width must be a positive number")
        if not isinstance(height, (int, float)) or height <= 0:
            raise ValueError("Height must be a positive number")
        
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height

# This works fine
rect1 = Rectangle(5, 10)
print(rect1.area())  # Output: 50

# These will raise exceptions
try:
    rect2 = Rectangle(-5, 10)  # Negative width
except ValueError as e:
    print(e)  # Output: Width must be a positive number

try:
    rect3 = Rectangle("width", 10)  # Non-numeric width
except ValueError as e:
    print(e)  # Output: Width must be a positive number

By validating inputs in the constructor, you prevent invalid objects from being created in the first place. This is much better than allowing invalid objects to be created and then failing later when their methods are called.

Design principle: Follow the "fail fast" principle by validating all inputs as early as possible. This makes debugging easier by ensuring that errors occur close to their source.

Initialization vs. Declaration

An important distinction in Python is that not all attributes need to be set through constructor parameters. You can also declare attributes with fixed initial values:

class Counter:
    def __init__(self, start=0, step=1):
        # Parameters that can be customized
        self.count = start
        self.step = step
        
        # Fixed attributes - declared but not parameterized
        self.min_value = 0
        self.max_value = 100
        self.history = []
    
    def increment(self):
        if self.count + self.step <= self.max_value:
            self.count += self.step
            self.history.append(self.count)
        return self.count

counter = Counter(5, 10)
counter.increment()
print(counter.count)  # Output: 15
print(counter.history)  # Output: [15]

When deciding whether to make an attribute a parameter:

Calling Other Methods from __init__

The constructor can call other methods of the class to help with initialization:

class User:
    def __init__(self, username, password):
        self.username = username
        self._password_hash = None  # Private attribute
        self.set_password(password)  # Call another method for password handling
        self.login_attempts = 0
        self.is_active = True
        
        # Initialize other attributes
        self.reset_stats()
    
    def set_password(self, password):
        # In a real application, we would use a secure hashing algorithm
        if len(password) < 8:
            raise ValueError("Password must be at least 8 characters long")
        self._password_hash = hash(password)  # Simple hash for demonstration
    
    def reset_stats(self):
        # Initialize or reset user statistics
        self.last_login = None
        self.total_logins = 0
        self.created_date = self._get_current_date()
    
    def _get_current_date(self):
        # Helper method to get current date
        import datetime
        return datetime.datetime.now()

# Creating a user
user = User("alice_smith", "secure_password123")
print(user.username)  # Output: alice_smith
print(user.created_date)  # Output: current datetime

This pattern has several advantages:

Best practice: Keep your __init__ method relatively simple by delegating complex initialization tasks to helper methods. This improves readability and maintainability.

Constructor Overloading and Alternative Constructors

Unlike some languages, Python doesn't support constructor overloading (having multiple __init__ methods with different parameters). However, you can achieve similar flexibility through:

1. Default parameters (as shown earlier)

2. Class methods as alternative constructors

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

# Different ways to create Date objects
date1 = Date(2025, 4, 15)                # Standard constructor
date2 = Date.from_string("2025-05-20")   # From string
date3 = Date.today()                     # Today's date
date4 = Date.from_timestamp(1733986800)  # From Unix timestamp (May 1, 2025)

print(date1)  # Output: 2025-04-15
print(date2)  # Output: 2025-05-20
print(date3)  # Output: Current date
print(date4)  # Output: 2025-05-01

These alternative constructors (also called factory methods) provide clear, self-documenting ways to create objects from different types of inputs. They're especially useful when objects can be constructed from various data formats.

Naming convention: Alternative constructors typically use names that start with from_ to indicate they create objects from a specific format, or descriptive names like today() that clearly indicate their purpose.

Initialization in Inheritance

When working with inheritance, properly initializing objects becomes more complex. Child classes need to ensure that their parent classes are also properly initialized:

class Vehicle:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year
        self.is_running = False
    
    def start(self):
        self.is_running = True
        return f"{self.make} {self.model} started"
    
    def stop(self):
        self.is_running = False
        return f"{self.make} {self.model} stopped"

class Car(Vehicle):
    def __init__(self, make, model, year, fuel_type="gasoline", num_doors=4):
        # Initialize parent class
        super().__init__(make, model, year)
        
        # Initialize Car-specific attributes
        self.fuel_type = fuel_type
        self.num_doors = num_doors

class ElectricCar(Car):
    def __init__(self, make, model, year, battery_capacity, range_km):
        # Initialize parent with specific values
        super().__init__(make, model, year, fuel_type="electric", num_doors=4)
        
        # Initialize ElectricCar-specific attributes
        self.battery_capacity = battery_capacity
        self.range_km = range_km

# Creating instances
regular_car = Car("Toyota", "Corolla", 2025)
tesla = ElectricCar("Tesla", "Model 3", 2025, 75, 400)

print(regular_car.make, regular_car.fuel_type)  # Output: Toyota gasoline
print(tesla.make, tesla.fuel_type, tesla.battery_capacity)  # Output: Tesla electric 75
print(tesla.start())  # Output: Tesla Model 3 started (inherited method)

The super().__init__() call is crucial here - it ensures that the parent class's initialization code runs. Without it, parent class attributes won't be initialized, which can lead to errors or unexpected behavior.

Key point: In Python, parent class initialization is not automatic. If a child class defines an __init__ method, it must explicitly call the parent's __init__ method using super() if it needs the parent's initialization behavior.

Common Initialization Patterns

Let's explore some common patterns for object initialization:

1. Two-Phase Initialization

Sometimes, you might want to separate the declaration of an object from its complete initialization:

class DatabaseConnection:
    def __init__(self, host, username, password):
        self.host = host
        self.username = username
        self.password = password
        self.connection = None
        self.is_connected = False
        # Notice we don't actually connect yet
    
    # Second phase of initialization
    def connect(self):
        if self.is_connected:
            return False
            
        # Simulate establishing a database connection
        print(f"Connecting to database at {self.host}...")
        # In a real implementation, this would use a DB library
        self.connection = f"Connection to {self.host} as {self.username}"
        self.is_connected = True
        return True
    
    def execute_query(self, query):
        if not self.is_connected:
            raise RuntimeError("Must connect to database before executing queries")
        return f"Executing: {query}"

# Create the object without connecting
db = DatabaseConnection("db.example.com", "admin", "s3cret")

# Connect when ready
db.connect()

# Now we can use it
result = db.execute_query("SELECT * FROM users")
print(result)  # Output: Executing: SELECT * FROM users

This pattern is useful for:

2. Builder Pattern

For complex objects with many optional parameters, you can use a builder pattern:

class ReportBuilder:
    def __init__(self, title):
        self.title = title
        self.sections = []
        self.author = None
        self.date = None
        self.company_logo = None
        self.footer = None
        self.include_toc = False
        self.theme = "default"
    
    def with_author(self, author):
        self.author = author
        return self  # Return self for method chaining
    
    def with_date(self, date):
        self.date = date
        return self
    
    def with_company_logo(self, logo_path):
        self.company_logo = logo_path
        return self
    
    def with_footer(self, footer_text):
        self.footer = footer_text
        return self
    
    def with_table_of_contents(self):
        self.include_toc = True
        return self
    
    def with_theme(self, theme):
        self.theme = theme
        return self
    
    def add_section(self, section_title, section_content):
        self.sections.append({"title": section_title, "content": section_content})
        return self
    
    def build(self):
        # Here we'd actually build the report
        # For this example, we'll just return a description
        report_desc = f"Report: {self.title}\n"
        if self.author:
            report_desc += f"Author: {self.author}\n"
        if self.date:
            report_desc += f"Date: {self.date}\n"
        report_desc += f"Theme: {self.theme}\n"
        report_desc += f"Sections: {len(self.sections)}\n"
        report_desc += f"Table of Contents: {'Yes' if self.include_toc else 'No'}\n"
        return report_desc

# Using the builder pattern
report = ReportBuilder("Quarterly Sales Results") \
    .with_author("Alice Smith") \
    .with_date("2025-04-15") \
    .with_theme("corporate") \
    .with_table_of_contents() \
    .add_section("Executive Summary", "Sales increased by 15%...") \
    .add_section("Regional Breakdown", "North: $1.2M, South: $0.9M...") \
    .build()

print(report)
# Output:
# Report: Quarterly Sales Results
# Author: Alice Smith
# Date: 2025-04-15
# Theme: corporate
# Sections: 2
# Table of Contents: Yes

The builder pattern provides:

Advanced __init__ Techniques

1. Private Name Mangling

You can use name mangling for truly private attributes by prefixing with double underscores:

class SecureAccount:
    def __init__(self, owner_name, balance, pin):
        self.owner_name = owner_name
        self.balance = balance
        self.__pin = pin  # Private attribute (name mangled)
    
    def validate_pin(self, entered_pin):
        return self.__pin == entered_pin
    
    def withdraw(self, amount, entered_pin):
        if not self.validate_pin(entered_pin):
            return "Incorrect PIN"
        
        if amount > self.balance:
            return "Insufficient funds"
            
        self.balance -= amount
        return f"Withdrew ${amount}. New balance: ${self.balance}"

account = SecureAccount("Alice", 1000, "1234")

# This works
result = account.withdraw(500, "1234")
print(result)  # Output: Withdrew $500. New balance: $500

# This doesn't work
print(account.validate_pin("5678"))  # Output: False

# This will raise an AttributeError
try:
    print(account.__pin)
except AttributeError as e:
    print("Cannot access private attribute")  # Output: Cannot access private attribute

# However, name mangling is not true security
# The attribute can still be accessed with the mangled name
print(account._SecureAccount__pin)  # Output: 1234

Name mangling (with double underscores) is useful to prevent attribute name collisions in inheritance, but it's not a security feature. Python follows the principle of "we're all consenting adults here" - it doesn't prevent access, but it signals that an attribute is not meant to be accessed directly.

2. Property Initialization

You can use properties to control access to attributes initialized in the constructor:

class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius  # Use a protected attribute
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:  # Absolute zero
            raise ValueError("Temperature below absolute zero is not possible")
        self._celsius = value
    
    @property
    def fahrenheit(self):
        return (self._celsius * 9/5) + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5/9  # Convert to celsius

# Initialize with celsius value
temp = Temperature(25)
print(temp.celsius)     # Output: 25
print(temp.fahrenheit)  # Output: 77.0

# Change temperature
temp.celsius = 30
print(temp.fahrenheit)  # Output: 86.0

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

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

Using properties provides several benefits:

Common Constructor Mistakes and Best Practices

Common Mistakes

# BAD: Mutable default argument
class BadList:
    def __init__(self, initial_items=[]):  # This list is shared across all instances!
        self.items = initial_items
    
    def add_item(self, item):
        self.items.append(item)

# GOOD: Use None and create the list in the constructor
class GoodList:
    def __init__(self, initial_items=None):
        self.items = initial_items if initial_items is not None else []
    
    def add_item(self, item):
        self.items.append(item)

# Demonstrating the problem
bad1 = BadList()
bad2 = BadList()
bad1.add_item("item")
print(bad2.items)  # Output: ['item'] - Unexpected!

good1 = GoodList()
good2 = GoodList()
good1.add_item("item")
print(good2.items)  # Output: [] - As expected

Best Practices

class Circle:
    """
    A class representing a circle.
    
    Attributes:
        radius (float): The radius of the circle in units.
        color (str, optional): The color of the circle. Defaults to 'white'.
    """
    
    def __init__(self, radius, color='white'):
        """
        Initialize a new Circle.
        
        Args:
            radius (float): The radius of the circle. Must be positive.
            color (str, optional): The color of the circle. Defaults to 'white'.
            
        Raises:
            ValueError: If radius is not positive.
        """
        if not isinstance(radius, (int, float)) or radius <= 0:
            raise ValueError("Radius must be a positive number")
            
        self.radius = radius
        self.color = color
    
    @property
    def diameter(self):
        return self.radius * 2
    
    @property
    def area(self):
        import math
        return math.pi * self.radius ** 2
    
    @classmethod
    def from_diameter(cls, diameter, color='white'):
        """
        Create a Circle from its diameter.
        
        Args:
            diameter (float): The diameter of the circle. Must be positive.
            color (str, optional): The color of the circle. Defaults to 'white'.
            
        Returns:
            Circle: A new Circle instance.
            
        Raises:
            ValueError: If diameter is not positive.
        """
        if not isinstance(diameter, (int, float)) or diameter <= 0:
            raise ValueError("Diameter must be a positive number")
            
        return cls(diameter / 2, color)

# Well-documented, validating, and flexible

Practical Example: Building a Library System

Let's put everything together with a practical example of a simple library system, focusing on constructors and initialization:

class Book:
    def __init__(self, title, author, isbn, publication_year, num_copies=1):
        """Initialize a new Book."""
        self.title = title
        self.author = author
        self.isbn = isbn
        self.publication_year = publication_year
        self.num_copies = num_copies
        self.available_copies = num_copies
        self.current_borrowers = []
    
    def __str__(self):
        return f"{self.title} by {self.author} ({self.publication_year})"
    
    @classmethod
    def from_dict(cls, book_dict):
        """Create a Book from a dictionary representation."""
        return cls(
            title=book_dict['title'],
            author=book_dict['author'],
            isbn=book_dict['isbn'],
            publication_year=book_dict['publication_year'],
            num_copies=book_dict.get('num_copies', 1)
        )


class LibraryMember:
    def __init__(self, member_id, name, email=None):
        """Initialize a new library member."""
        self.member_id = member_id
        self.name = name
        self.email = email
        self.borrowed_books = {}  # isbn: due_date
    
    def __str__(self):
        return f"{self.name} (ID: {self.member_id})"


class Library:
    def __init__(self, name, location=None):
        """Initialize a new library."""
        self.name = name
        self.location = location
        self.books = {}  # isbn: Book object
        self.members = {}  # member_id: LibraryMember object
    
    def add_book(self, book):
        """Add a book to the library inventory."""
        if book.isbn in self.books:
            # If we already have this book, just increase the copy count
            self.books[book.isbn].num_copies += book.num_copies
            self.books[book.isbn].available_copies += book.num_copies
            return f"Added {book.num_copies} more copies of {book.title}"
        else:
            # Otherwise, add the new book
            self.books[book.isbn] = book
            return f"Added {book.title} to the library"
    
    def register_member(self, member):
        """Register a new library member."""
        if member.member_id in self.members:
            return f"Member with ID {member.member_id} already exists"
        
        self.members[member.member_id] = member
        return f"Registered {member.name} as a new member"
    
    def checkout_book(self, isbn, member_id, due_date):
        """Check out a book to a member."""
        if isbn not in self.books:
            return "Book not found"
        
        if member_id not in self.members:
            return "Member not found"
        
        book = self.books[isbn]
        member = self.members[member_id]
        
        if book.available_copies <= 0:
            return f"No available copies of {book.title}"
        
        if isbn in member.borrowed_books:
            return f"{member.name} already has this book checked out"
        
        # Update the book
        book.available_copies -= 1
        book.current_borrowers.append(member_id)
        
        # Update the member
        member.borrowed_books[isbn] = due_date
        
        return f"{book.title} checked out to {member.name} until {due_date}"
    
    def return_book(self, isbn, member_id):
        """Return a book from a member."""
        if isbn not in self.books:
            return "Book not found"
        
        if member_id not in self.members:
            return "Member not found"
        
        book = self.books[isbn]
        member = self.members[member_id]
        
        if isbn not in member.borrowed_books:
            return f"{member.name} has not borrowed this book"
        
        # Update the book
        book.available_copies += 1
        book.current_borrowers.remove(member_id)
        
        # Update the member
        del member.borrowed_books[isbn]
        
        return f"{book.title} returned by {member.name}"
    
    @classmethod
    def from_book_list(cls, name, book_list, location=None):
        """Create a library from a list of book dictionaries."""
        library = cls(name, location)
        for book_dict in book_list:
            book = Book.from_dict(book_dict)
            library.add_book(book)
        return library


# Using our library system
# 1. Creating books
book1 = Book("The Great Gatsby", "F. Scott Fitzgerald", "9780743273565", 1925, 3)
book2 = Book("To Kill a Mockingbird", "Harper Lee", "9780060935467", 1960, 2)
book3 = Book("1984", "George Orwell", "9780451524935", 1949, 5)

# 2. Creating members
alice = LibraryMember("A123", "Alice Smith", "alice@example.com")
bob = LibraryMember("B456", "Bob Johnson")

# 3. Creating a library
city_library = Library("City Public Library", "123 Main St")

# 4. Adding books and members
city_library.add_book(book1)
city_library.add_book(book2)
city_library.add_book(book3)
city_library.register_member(alice)
city_library.register_member(bob)

# 5. Checking out books
print(city_library.checkout_book("9780743273565", "A123", "2025-05-15"))
print(city_library.checkout_book("9780060935467", "B456", "2025-05-10"))

# 6. Checking status
print(f"Available copies of The Great Gatsby: {book1.available_copies}")  # Should be 2
print(f"Alice's borrowed books: {alice.borrowed_books}")  # Should show Gatsby

# 7. Returning a book
print(city_library.return_book("9780743273565", "A123"))
print(f"Available copies of The Great Gatsby: {book1.available_copies}")  # Should be 3

# 8. Using alternative constructor
book_list = [
    {"title": "Pride and Prejudice", "author": "Jane Austen", "isbn": "9780141439518", "publication_year": 1813, "num_copies": 2},
    {"title": "The Hobbit", "author": "J.R.R. Tolkien", "isbn": "9780547928227", "publication_year": 1937, "num_copies": 3}
]
branch_library = Library.from_book_list("Branch Library", book_list, "456 Oak St")
print(f"Books in Branch Library: {len(branch_library.books)}")  # Should be 2

This example demonstrates:

Conclusion

Constructors and the __init__ method are fundamental to Python's object-oriented programming model. They provide a way to ensure that objects start in a valid, usable state with all the attributes they need.

Key takeaways from this session:

As you design your own classes, pay special attention to the constructor. It sets the foundation for how objects of your class will be created and used. A well-designed constructor makes the rest of your class easier to implement and use correctly.

Practice Exercise

Design and implement a BankAccount class with the following features:

  1. Regular constructor for creating accounts with an owner name, account number, and optional initial balance
  2. Alternative constructor for creating joint accounts with multiple owners
  3. Validation to ensure initial balance is not negative
  4. A property for interest rate with appropriate validation
  5. Methods for deposit, withdrawal, and checking balance
  6. Appropriate string representation

Then create a few accounts and test the functionality.

Additional Resources