Instance vs. Class Variables in Python

Week 3: Monday Morning Session

Lesson Overview

Understanding the difference between instance variables and class variables is crucial for effective Object-Oriented Programming in Python. These two types of variables serve different purposes and behave differently, and mixing them up can lead to subtle bugs. In this session, we'll explore both types in depth, learn when to use each, and examine common pitfalls to avoid.

The Fundamental Difference

Let's start with the basic definitions:

Real-world analogy: Think of a car manufacturing company. Every car (instance) has its own unique VIN number, color, and mileage (instance variables). However, all cars share the same manufacturer name, company logo, and warranty policy (class variables).

Let's see this distinction in Python code:

class Car:
    # Class variable - shared by all instances
    manufacturer = "TechAuto Inc."
    
    def __init__(self, model, color):
        # Instance variables - unique to each instance
        self.model = model
        self.color = color
        self.mileage = 0

# Creating car instances
car1 = Car("Sedan", "Blue")
car2 = Car("SUV", "Red")

# Accessing instance variables
print(car1.model)     # Output: Sedan
print(car2.model)     # Output: SUV
print(car1.color)     # Output: Blue
print(car2.color)     # Output: Red

# Accessing class variable
print(car1.manufacturer)  # Output: TechAuto Inc.
print(car2.manufacturer)  # Output: TechAuto Inc.
print(Car.manufacturer)   # Output: TechAuto Inc.

As you can see:

Where and How to Define Variables

Class Variables

Class variables are defined at the class level, outside of any method. They're typically placed at the top of the class definition for visibility:

class Student:
    # Class variables
    school_name = "Python High"
    total_students = 0
    
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade
        Student.total_students += 1  # Updating a class variable

Instance Variables

Instance variables are typically defined in the __init__ method, but they can also be created in any other instance method. They must be prefixed with self. to associate them with the instance:

class Student:
    school_name = "Python High"
    
    def __init__(self, name, grade):
        # Instance variables defined in __init__
        self.name = name
        self.grade = grade
        self.enrolled = True
    
    def update_grade(self, new_grade):
        # Instance variable modified in another method
        self.grade = new_grade
    
    def add_attendance(self, date, present):
        # Creating a new instance variable dynamically
        if not hasattr(self, 'attendance'):
            self.attendance = {}
        self.attendance[date] = present

Key point: Instance variables can actually be created anywhere an instance method has access to self, not just in __init__. This is one of Python's flexible but potentially confusing features.

Accessing Variables

Accessing Class Variables

Class variables can be accessed through:

Accessing Instance Variables

Instance variables can only be accessed through an instance: instance.variable

class Counter:
    # Class variable
    total_counters = 0
    
    def __init__(self, start=0):
        # Instance variable
        self.count = start
        Counter.total_counters += 1

# Create counters
counter1 = Counter()
counter2 = Counter(10)

# Access class variable
print(Counter.total_counters)    # Output: 2
print(counter1.total_counters)   # Output: 2

# Access instance variables
print(counter1.count)            # Output: 0
print(counter2.count)            # Output: 10

# This would raise an AttributeError
# print(Counter.count)  # Error: type object 'Counter' has no attribute 'count'

Explanation: We can access total_counters through both the class and instances because it's a class variable. But we can only access count through instances because it's an instance variable specific to each counter.

Modifying Variables

The behavior when modifying variables can sometimes be surprising and is important to understand:

Modifying Class Variables

To modify a class variable properly, you should access it through the class name:

class BankAccount:
    # Class variable
    interest_rate = 0.02  # 2% interest rate for all accounts
    
    def __init__(self, owner, balance=0):
        # Instance variables
        self.owner = owner
        self.balance = balance

# Create accounts
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 2000)

# Access class variable
print(account1.interest_rate)    # Output: 0.02
print(account2.interest_rate)    # Output: 0.02

# Modify class variable - affects all instances
BankAccount.interest_rate = 0.03
print(account1.interest_rate)    # Output: 0.03
print(account2.interest_rate)    # Output: 0.03

The Instance Variable Shadow Effect

Here's where things get tricky. If you modify a class variable through an instance, Python creates a new instance variable with the same name, which "shadows" the class variable:

# Continuing from the previous example

# Modify through an instance (creates instance variable)
account1.interest_rate = 0.04

# Now account1 has its own interest_rate instance variable
print(account1.interest_rate)    # Output: 0.04
print(account2.interest_rate)    # Output: 0.03 (still using class variable)
print(BankAccount.interest_rate) # Output: 0.03 (class variable unchanged)

# Modify class variable again
BankAccount.interest_rate = 0.05
print(account1.interest_rate)    # Output: 0.04 (using instance variable)
print(account2.interest_rate)    # Output: 0.05 (using updated class variable)

Important warning: When you access instance.class_variable, Python first looks for an instance variable with that name. If not found, it looks for a class variable. If you assign a value to instance.class_variable, you create an instance variable that has priority over the class variable.

Use Cases for Class Variables

Class variables are particularly useful in several scenarios:

1. Keeping count of instances

class User:
    user_count = 0
    
    def __init__(self, username):
        self.username = username
        User.user_count += 1
    
    @classmethod
    def display_user_count(cls):
        return f"Total users: {cls.user_count}"

# Creating users
user1 = User("alice")
user2 = User("bob")
user3 = User("charlie")

print(User.display_user_count())  # Output: Total users: 3

2. Sharing constants among all instances

class Circle:
    PI = 3.14159  # Mathematical constant shared by all circles
    
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        return Circle.PI * self.radius ** 2
    
    def circumference(self):
        return 2 * Circle.PI * self.radius

circle1 = Circle(5)
circle2 = Circle(10)

print(circle1.area())         # Output: 78.53975
print(circle2.circumference())  # Output: 62.8318

3. Default values that can be overridden per instance

class EmailClient:
    server = "smtp.example.com"  # Default server for all instances
    port = 587                   # Default port
    use_ssl = True               # Default SSL setting
    
    def __init__(self, username, password, **kwargs):
        self.username = username
        self.password = password
        
        # Override class defaults with any provided kwargs
        if 'server' in kwargs:
            self.server = kwargs['server']
        if 'port' in kwargs:
            self.port = kwargs['port']
        if 'use_ssl' in kwargs:
            self.use_ssl = kwargs['use_ssl']
    
    def get_connection_string(self):
        protocol = "ssl" if self.use_ssl else "tls"
        return f"{protocol}://{self.username}@{self.server}:{self.port}"

# Using default settings
default_client = EmailClient("user1", "pass123")
print(default_client.get_connection_string())
# Output: ssl://user1@smtp.example.com:587

# Overriding defaults
custom_client = EmailClient("user2", "pass456", server="mail.custom.com", port=465)
print(custom_client.get_connection_string())
# Output: ssl://user2@mail.custom.com:465

4. Implementing class methods that work with shared data

class TemperatureConverter:
    scales = {
        'celsius': {'freezing': 0, 'boiling': 100},
        'fahrenheit': {'freezing': 32, 'boiling': 212},
        'kelvin': {'freezing': 273.15, 'boiling': 373.15}
    }
    
    def __init__(self, temperature, scale):
        self.temperature = temperature
        self.scale = scale
    
    @classmethod
    def add_scale(cls, name, freezing_point, boiling_point):
        cls.scales[name] = {'freezing': freezing_point, 'boiling': boiling_point}
    
    def convert_to(self, target_scale):
        # First convert to celsius as a common base
        source_scale = self.scales[self.scale]
        target_scale_values = self.scales[target_scale]
        
        # Calculate percentage between freezing and boiling
        range_source = source_scale['boiling'] - source_scale['freezing']
        position = (self.temperature - source_scale['freezing']) / range_source
        
        # Apply that percentage to target scale
        range_target = target_scale_values['boiling'] - target_scale_values['freezing']
        return target_scale_values['freezing'] + position * range_target

# Add a new temperature scale
TemperatureConverter.add_scale('rankine', 0, 671.67)

# Convert between scales
temp = TemperatureConverter(100, 'celsius')
print(temp.convert_to('fahrenheit'))  # Output: 212.0
print(temp.convert_to('kelvin'))      # Output: 373.15
print(temp.convert_to('rankine'))     # Output: 671.67

Use Cases for Instance Variables

Instance variables are the most common type of variables in OOP and are used for:

1. Data unique to each object

class Person:
    def __init__(self, name, age, occupation):
        self.name = name
        self.age = age
        self.occupation = occupation
        self.friends = []
    
    def add_friend(self, friend_name):
        self.friends.append(friend_name)
    
    def describe(self):
        friend_text = ", ".join(self.friends) if self.friends else "none"
        return f"{self.name} is {self.age} years old, works as a {self.occupation}, and has friends: {friend_text}"

alice = Person("Alice", 28, "Software Engineer")
bob = Person("Bob", 32, "Data Scientist")

alice.add_friend("Charlie")
alice.add_friend("Diana")
bob.add_friend("Eve")

print(alice.describe())
# Output: Alice is 28 years old, works as a Software Engineer, and has friends: Charlie, Diana
print(bob.describe())
# Output: Bob is 32 years old, works as a Data Scientist, and has friends: Eve

2. State that changes throughout an object's lifetime

class BankAccount:
    def __init__(self, owner, initial_balance=0):
        self.owner = owner
        self.balance = initial_balance
        self.transaction_history = []
    
    def deposit(self, amount):
        if amount <= 0:
            return "Deposit amount must be positive"
        
        self.balance += amount
        self.transaction_history.append(f"Deposit: +${amount}")
        return f"Deposited ${amount}. New balance: ${self.balance}"
    
    def withdraw(self, amount):
        if amount <= 0:
            return "Withdrawal amount must be positive"
        
        if amount > self.balance:
            return "Insufficient funds"
        
        self.balance -= amount
        self.transaction_history.append(f"Withdrawal: -${amount}")
        return f"Withdrew ${amount}. New balance: ${self.balance}"
    
    def get_statement(self):
        statement = f"Account Statement for {self.owner}\n"
        statement += f"Current Balance: ${self.balance}\n"
        statement += "Transaction History:\n"
        for transaction in self.transaction_history:
            statement += f"- {transaction}\n"
        return statement

account = BankAccount("Alice", 1000)
print(account.deposit(500))
print(account.withdraw(200))
print(account.deposit(50))
print(account.get_statement())

3. Object-specific configuration or settings

class DatabaseConnection:
    default_timeout = 30  # Class variable - default for all connections
    
    def __init__(self, host, username, password, **options):
        # Required connection parameters
        self.host = host
        self.username = username
        self.password = password
        
        # Optional configuration with defaults
        self.port = options.get('port', 3306)
        self.database = options.get('database', 'default')
        self.timeout = options.get('timeout', self.default_timeout)
        self.use_ssl = options.get('use_ssl', True)
        self.connection_pool_size = options.get('pool_size', 5)
        
        # State variables
        self.is_connected = False
        self.connection = None
        self.last_query_time = None
    
    def connect(self):
        # Simulating connection logic
        self.is_connected = True
        connection_string = f"{self.username}@{self.host}:{self.port}/{self.database}"
        self.connection = f"Simulated connection to {connection_string}"
        return f"Connected to {self.host}"

# Creating connections with different configurations
default_conn = DatabaseConnection("db.example.com", "user", "pass123")
custom_conn = DatabaseConnection(
    "db.company.com", 
    "admin", 
    "secure_pass", 
    port=5432, 
    database="customers", 
    timeout=60,
    pool_size=10
)

print(default_conn.timeout)  # Output: 30 (using default)
print(custom_conn.timeout)   # Output: 60 (custom setting)
print(default_conn.connect())  # Output: Connected to db.example.com

Common Pitfalls and How to Avoid Them

Pitfall 1: Mutable Class Variables

One of the most common pitfalls is using mutable objects as class variables:

class BadShoppingCart:
    # Class variable that's a mutable list - DANGER!
    items = []
    
    def add_item(self, item):
        self.items.append(item)

# Create shopping carts
cart1 = BadShoppingCart()
cart2 = BadShoppingCart()

# Add item to first cart
cart1.add_item("Laptop")
print(cart1.items)  # Output: ['Laptop']

# Surprise! The item shows up in cart2 as well
print(cart2.items)  # Output: ['Laptop']

# That's because both carts are sharing the same class variable list

The solution is to initialize mutable collections as instance variables in __init__:

class GoodShoppingCart:
    def __init__(self):
        # Initialize the list as an instance variable
        self.items = []
    
    def add_item(self, item):
        self.items.append(item)

# Create shopping carts
cart1 = GoodShoppingCart()
cart2 = GoodShoppingCart()

# Add item to first cart
cart1.add_item("Laptop")
print(cart1.items)  # Output: ['Laptop']

# Second cart's items are separate
print(cart2.items)  # Output: []

Rule of thumb: Never use mutable objects (lists, dictionaries, sets, etc.) as class variables unless you specifically want all instances to share the same collection. If each instance needs its own collection, initialize it in __init__.

Pitfall 2: The Instance Variable Shadow Effect

As demonstrated earlier, modifying a class variable through an instance creates a new instance variable that shadows the class variable. This can lead to confusing behavior:

class Configuration:
    debug = False  # Class variable
    
    def enable_debug(self):
        self.debug = True  # Creates instance variable!

# Create configurations
config1 = Configuration()
config2 = Configuration()

# Enable debug on config1
config1.enable_debug()
print(config1.debug)  # Output: True
print(config2.debug)  # Output: False

# Change the class variable
Configuration.debug = True
print(config1.debug)  # Output: True (still using instance variable)
print(config2.debug)  # Output: True (using updated class variable)

To avoid this, explicitly access class variables through the class name or self.__class__:

class BetterConfiguration:
    debug = False  # Class variable
    
    @classmethod
    def enable_debug_for_all(cls):
        cls.debug = True  # Properly modifies class variable
    
    def is_debug_enabled(self):
        return self.__class__.debug  # Explicitly access class variable

# Create configurations
config1 = BetterConfiguration()
config2 = BetterConfiguration()

# Check initial debug status
print(config1.is_debug_enabled())  # Output: False
print(config2.is_debug_enabled())  # Output: False

# Enable debug for all
BetterConfiguration.enable_debug_for_all()
print(config1.is_debug_enabled())  # Output: True
print(config2.is_debug_enabled())  # Output: True

Pitfall 3: Modifying Class Variables from Instance Methods

When you need to update a class variable from an instance method, always access it through the class name or self.__class__:

class Counter:
    count = 0  # Class variable for tracking total count
    
    def __init__(self, name):
        self.name = name
        self.value = 0  # Instance variable
        Counter.count += 1  # Properly increment class variable
    
    def increment(self):
        self.value += 1
    
    @classmethod
    def get_total_count(cls):
        return cls.count

# Create counters
counter1 = Counter("First")
counter2 = Counter("Second")
print(Counter.get_total_count())  # Output: 2

# Adding more counters increments the class variable
counter3 = Counter("Third")
print(Counter.get_total_count())  # Output: 3

Class Variables in Inheritance

Class variables behave in interesting ways in inheritance hierarchies:

class Animal:
    species_count = 0
    kingdom = "Animalia"
    
    def __init__(self, name):
        self.name = name
        Animal.species_count += 1

class Dog(Animal):
    species = "Canis familiaris"
    dog_count = 0
    
    def __init__(self, name, breed):
        super().__init__(name)
        self.breed = breed
        Dog.dog_count += 1
    
    def bark(self):
        return "Woof!"

class Cat(Animal):
    species = "Felis catus"
    cat_count = 0
    
    def __init__(self, name, color):
        super().__init__(name)
        self.color = color
        Cat.cat_count += 1
    
    def meow(self):
        return "Meow!"

# Create some animals
fido = Dog("Fido", "Golden Retriever")
whiskers = Cat("Whiskers", "Tabby")
rex = Dog("Rex", "German Shepherd")

# Access class variables
print(Animal.species_count)  # Output: 3
print(Dog.dog_count)         # Output: 2
print(Cat.cat_count)         # Output: 1

# Inherited class variables
print(Dog.kingdom)           # Output: Animalia
print(fido.kingdom)          # Output: Animalia

# Subclass-specific class variables
print(Dog.species)           # Output: Canis familiaris
print(Cat.species)           # Output: Felis catus

# Modifying inherited class variables
Dog.kingdom = "Modified by Dog"
print(Dog.kingdom)           # Output: Modified by Dog
print(Cat.kingdom)           # Output: Animalia (unchanged)
print(Animal.kingdom)        # Output: Animalia (unchanged)

Key point: Subclasses inherit class variables from their parent classes. If a subclass modifies an inherited class variable, it creates its own version of that variable, which doesn't affect the parent class or other subclasses.

Best Practices

When to Use Class Variables

When to Use Instance Variables

Naming Conventions

class FileParser:
    # Constants (class variables that won't change)
    MAX_FILE_SIZE = 1024 * 1024 * 10  # 10MB
    SUPPORTED_FORMATS = ['csv', 'json', 'xml', 'yaml']
    
    # Regular class variables
    default_encoding = 'utf-8'
    parser_version = '1.0.0'
    
    def __init__(self, file_path, encoding=None):
        # Public instance variables
        self.file_path = file_path
        self.encoding = encoding or self.default_encoding
        
        # Private instance variables
        self._parsed_data = None
        self._last_parsed = None
    
    def parse(self):
        # Simulated parsing logic
        extension = self.file_path.split('.')[-1].lower()
        
        if extension not in self.SUPPORTED_FORMATS:
            return f"Unsupported format: {extension}"
        
        # ... parsing logic would go here ...
        
        import datetime
        self._parsed_data = {"result": f"Parsed {self.file_path} with {self.encoding} encoding"}
        self._last_parsed = datetime.datetime.now()
        
        return self._parsed_data

# Using the class with proper variable access
parser = FileParser("data.csv")
print(FileParser.SUPPORTED_FORMATS)  # Accessing class constant
result = parser.parse()
print(result)

Using Descriptors and Properties with Class and Instance Variables

For more advanced control over variable access, you can use descriptors and properties:

class PositiveValue:
    """A descriptor that only allows positive values."""
    
    def __init__(self, name):
        self.name = name
        self.private_name = f"_{name}"
    
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.private_name, 0)
    
    def __set__(self, instance, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f"{self.name} must be a number")
        if value < 0:
            raise ValueError(f"{self.name} must be positive")
        setattr(instance, self.private_name, value)

class Product:
    # Class variables
    product_count = 0
    tax_rate = 0.1
    
    # Descriptors for instance variables
    price = PositiveValue("price")
    stock = PositiveValue("stock")
    
    def __init__(self, name, price, stock=0):
        self.name = name
        self.price = price  # Uses the descriptor
        self.stock = stock  # Uses the descriptor
        Product.product_count += 1
    
    @property
    def total_value(self):
        return self.price * self.stock
    
    @property
    def price_with_tax(self):
        return self.price * (1 + self.tax_rate)

# Create products
laptop = Product("Laptop", 1200, 5)
phone = Product("Smartphone", 800, 10)

print(laptop.price)          # Output: 1200
print(laptop.price_with_tax)  # Output: 1320.0
print(laptop.total_value)    # Output: 6000

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

This approach combines class variables, instance variables, descriptors, and properties to create a robust, well-validated class.

Practical Example: Building a Library System

Let's apply our understanding of class and instance variables to build a simple library system:

class LibrarySystem:
    # Class variables (shared by all libraries)
    MAX_CHECKOUT_DAYS = 14
    LATE_FEE_PER_DAY = 0.25
    BOOK_CATEGORIES = ["Fiction", "Non-Fiction", "Reference", "Children", "Textbook"]
    
    # To track all library branches
    branches = []
    
    @classmethod
    def get_all_branches(cls):
        return cls.branches
    
    @classmethod
    def find_branch_by_name(cls, name):
        for branch in cls.branches:
            if branch.name == name:
                return branch
        return None
    
    def __init__(self, name, location):
        # Instance variables
        self.name = name
        self.location = location
        self.books = {}  # isbn: Book object
        self.members = {}  # member_id: Member object
        self.is_open = False
        self._opening_hours = "9 AM - 5 PM"
        
        # Add this branch to the class-level tracking
        LibrarySystem.branches.append(self)
    
    def add_book(self, book):
        self.books[book.isbn] = book
        return f"Added '{book.title}' to {self.name} Library"
    
    def add_member(self, member):
        self.members[member.id] = member
        return f"Added member {member.name} to {self.name} Library"
    
    def open(self):
        self.is_open = True
        return f"{self.name} Library is now open"
    
    def close(self):
        self.is_open = False
        return f"{self.name} Library is now closed"
    
    @property
    def opening_hours(self):
        return self._opening_hours
    
    @opening_hours.setter
    def opening_hours(self, hours):
        self._opening_hours = hours
    
    def get_available_books(self):
        return [book for book in self.books.values() if book.is_available]
    
    def checkout_book(self, isbn, member_id):
        if not self.is_open:
            return "Sorry, the library is currently closed"
        
        if isbn not in self.books:
            return "Book not found in this library"
            
        if member_id not in self.members:
            return "Member not found in this library"
            
        book = self.books[isbn]
        member = self.members[member_id]
        
        if not book.is_available:
            return f"'{book.title}' is currently unavailable"
            
        if len(member.checked_out_books) >= member.checkout_limit:
            return f"{member.name} has reached the checkout limit of {member.checkout_limit} books"
            
        # Process checkout
        book.checkout(member_id)
        member.add_book(isbn)
        
        return f"'{book.title}' has been checked out to {member.name} for {self.MAX_CHECKOUT_DAYS} days"


class Book:
    # Class variables
    total_books = 0
    
    def __init__(self, title, author, isbn, category, copies=1):
        # Validate category
        if category not in LibrarySystem.BOOK_CATEGORIES:
            raise ValueError(f"Invalid category. Must be one of: {LibrarySystem.BOOK_CATEGORIES}")
        
        # Instance variables
        self.title = title
        self.author = author
        self.isbn = isbn
        self.category = category
        self.copies = copies
        self.available_copies = copies
        self.checkout_history = []
        
        # Increment class counter
        Book.total_books += 1
    
    @property
    def is_available(self):
        return self.available_copies > 0
    
    def checkout(self, member_id):
        if self.is_available:
            self.available_copies -= 1
            import datetime
            checkout_date = datetime.datetime.now()
            self.checkout_history.append({
                "member_id": member_id,
                "checkout_date": checkout_date,
                "due_date": checkout_date + datetime.timedelta(days=LibrarySystem.MAX_CHECKOUT_DAYS),
                "return_date": None
            })
            return True
        return False
    
    def return_book(self, member_id):
        # Find the matching checkout record
        for record in self.checkout_history:
            if record["member_id"] == member_id and record["return_date"] is None:
                import datetime
                record["return_date"] = datetime.datetime.now()
                self.available_copies += 1
                
                # Calculate late fee if applicable
                days_overdue = max(0, (record["return_date"] - record["due_date"]).days)
                late_fee = days_overdue * LibrarySystem.LATE_FEE_PER_DAY
                
                return {"success": True, "late_fee": late_fee}
        
        return {"success": False, "message": "No matching checkout record found"}


class LibraryMember:
    # Class variables for member types and their checkout limits
    MEMBER_TYPES = {
        "standard": {"checkout_limit": 5, "renewal_limit": 1},
        "premium": {"checkout_limit": 10, "renewal_limit": 3},
        "student": {"checkout_limit": 7, "renewal_limit": 2}
    }
    
    total_members = 0
    
    def __init__(self, name, id, member_type="standard"):
        # Validate member type
        if member_type not in self.MEMBER_TYPES:
            raise ValueError(f"Invalid member type. Must be one of: {list(self.MEMBER_TYPES.keys())}")
        
        # Instance variables
        self.name = name
        self.id = id
        self.member_type = member_type
        self.checked_out_books = {}  # isbn: checkout_date
        self.fine_balance = 0.0
        
        # Get checkout limit from class variable
        self.checkout_limit = self.MEMBER_TYPES[member_type]["checkout_limit"]
        self.renewal_limit = self.MEMBER_TYPES[member_type]["renewal_limit"]
        
        # Increment member counter
        LibraryMember.total_members += 1
    
    def add_book(self, isbn):
        import datetime
        self.checked_out_books[isbn] = datetime.datetime.now()
    
    def remove_book(self, isbn):
        if isbn in self.checked_out_books:
            del self.checked_out_books[isbn]
            return True
        return False
    
    def pay_fine(self, amount):
        if amount > self.fine_balance:
            return f"Payment amount exceeds fine balance of ${self.fine_balance:.2f}"
        
        self.fine_balance -= amount
        return f"Paid ${amount:.2f}. Remaining balance: ${self.fine_balance:.2f}"
    
    def add_fine(self, amount):
        self.fine_balance += amount
        return f"Added ${amount:.2f} fine. Total balance: ${self.fine_balance:.2f}"


# Using our library system
# Create libraries
main_library = LibrarySystem("Main Branch", "123 Main St")
downtown_library = LibrarySystem("Downtown Branch", "456 Center Ave")

# Create books
book1 = Book("The Great Gatsby", "F. Scott Fitzgerald", "9780743273565", "Fiction", 3)
book2 = Book("To Kill a Mockingbird", "Harper Lee", "9780060935467", "Fiction", 2)
book3 = Book("Python Crash Course", "Eric Matthes", "9781593276034", "Textbook", 1)

# Create members
alice = LibraryMember("Alice Smith", "M001", "premium")
bob = LibraryMember("Bob Johnson", "M002")
charlie = LibraryMember("Charlie Brown", "M003", "student")

# Add books and members to libraries
main_library.add_book(book1)
main_library.add_book(book2)
downtown_library.add_book(book3)

main_library.add_member(alice)
main_library.add_member(bob)
downtown_library.add_member(charlie)

# Open libraries
main_library.open()
downtown_library.open()

# Perform checkouts
print(main_library.checkout_book("9780743273565", "M001"))
print(downtown_library.checkout_book("9781593276034", "M003"))

# Check availability
print(f"Is 'The Great Gatsby' available? {book1.is_available}")  # Should show True (2 copies left)
print(f"Is 'Python Crash Course' available? {book3.is_available}")  # Should show False (0 copies left)

# Get statistics
print(f"Total books in system: {Book.total_books}")
print(f"Total members in system: {LibraryMember.total_members}")
print(f"Library branches: {[branch.name for branch in LibrarySystem.get_all_branches()]}")

# Find a specific branch
branch = LibrarySystem.find_branch_by_name("Downtown Branch")
if branch:
    print(f"Found branch: {branch.name} at {branch.location}")

In this comprehensive example:

Conclusion

Understanding the difference between instance and class variables is crucial for effective Python programming. Let's summarize what we've learned:

Key Differences

Feature Class Variables Instance Variables
Definition Location In the class body, outside methods Usually in __init__ with self prefix
Scope Shared by all instances Unique to each instance
Access Through Class name or instance Instance only
Memory Usage One copy for the class One copy per instance
Best For Constants, shared data, counters Object state, unique attributes

Best Practices

With this knowledge, you can design more effective and efficient classes in Python, avoiding common pitfalls while leveraging the flexibility that Python's variable system provides.

Practice Exercise

Design a University class system with the following requirements:

  1. The University class should track all departments and students using class variables
  2. The Department class should have department-specific constants and track all courses
  3. The Course class should track enrollment and have a maximum capacity
  4. The Student class should track enrolled courses and calculate GPA

Make sure to use class and instance variables appropriately, and include methods to manage the relationships between these classes.

Additional Resources