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:
- It's automatically called when an object is created
- The first parameter is always
self, which refers to the object being created - It can take additional parameters that provide initial values for object attributes
- It doesn't (and shouldn't) return any value - its job is just initialization
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:
__new__: Creates the object in memory (rarely overridden)__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:
- You want to create a class that can accept an arbitrary number of arguments
- You're creating a wrapper or subclass that needs to pass arguments to a parent class
- You want to make your class future-proof by allowing additional parameters to be added without breaking existing code
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:
- Make it a parameter if different instances might need different values
- Declare it directly if it's a constant or has a standard initial value for all instances
- Consider using class attributes for values shared across all instances (discussed in another session)
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:
- It helps keep the
__init__method cleaner and more focused - It allows code reuse when the same initialization logic might be needed elsewhere
- It follows the single responsibility principle by separating different aspects of initialization
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:
- Expensive resources that shouldn't be acquired until needed
- Connections that might fail and need special error handling
- Objects that might need different initialization paths depending on runtime conditions
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:
- A clear, fluent interface for configuring complex objects
- The ability to set only the options you need
- Better readability than constructors with many parameters
- The option to enforce required vs. optional parameters
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:
- The class's interface stays clean (attributes instead of getter/setter methods)
- You can add validation without changing how the class is used
- You can compute derived properties on-the-fly
- You can implement different types of validation for different attributes
Common Constructor Mistakes and Best Practices
Common Mistakes
- Forgetting to initialize base classes: Always call
super().__init__()in subclasses that override__init__ - Returning a value from
__init__: Constructors should never return a value - Performing too much work in
__init__: Keep constructors focused on initialization - Ignoring input validation: Always validate constructor parameters
- Circular dependencies: Avoid initializing objects that reference each other in their constructors
- Mutable default arguments: Never use mutable objects as default arguments
# 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
- Keep constructors simple: Initialize attributes and perform basic validation
- Use default arguments for optional parameters: Makes your class more flexible
- Validate inputs early: Catch errors at object creation time
- Consider alternative constructors: Use
@classmethodfor different creation patterns - Use properties for controlled access: Keep your public interface clean
- Document your constructor parameters: Use docstrings to explain parameters
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:
- Proper initialization with
__init__methods - Alternative constructors using
@classmethod - Default parameters for optional attributes
- Class relationships and object interactions
- Methods that build on the initialized state
- Well-documented constructors with docstrings
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:
- The
__init__method is Python's constructor mechanism - The
selfparameter refers to the instance being created - Constructors should validate inputs to ensure objects begin in a valid state
- Default arguments and alternative constructors provide flexibility
- In inheritance, child classes must explicitly call parent constructors
- Advanced techniques like property initialization can enhance your classes
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:
- Regular constructor for creating accounts with an owner name, account number, and optional initial balance
- Alternative constructor for creating joint accounts with multiple owners
- Validation to ensure initial balance is not negative
- A property for interest rate with appropriate validation
- Methods for deposit, withdrawal, and checking balance
- Appropriate string representation
Then create a few accounts and test the functionality.
Additional Resources
- Python Documentation on Classes
- Real Python: Python's super() Function
- Real Python: Inheritance and Composition
- Python Data Model: __init__
- Recommended Book: "Fluent Python" by Luciano Ramalho (Chapters on object creation)