Class Variables and Class Methods

Beginner

This tutorial is from open-source community. Access the source code

Introduction

In this lab, you will learn about class variables and class methods in Python. You'll understand their purpose and usage, and learn how to define and use class methods effectively.

Moreover, you'll implement alternative constructors using class methods, explore the relationship between class variables and inheritance, and create flexible data reading utilities. The files stock.py and reader.py will be modified during this lab.

This is a Guided Lab, which provides step-by-step instructions to help you learn and practice. Follow the instructions carefully to complete each step and gain hands-on experience. Historical data shows that this is a beginner level lab with a 100% completion rate. It has received a 100% positive review rate from learners.

Understanding Class Variables and Class Methods

In this first step, we're going to dive into the concepts of class variables and class methods in Python. These are important concepts that will help you write more efficient and organized code. Before we start working with class variables and class methods, let's first take a look at how instances of our Stock class are currently created. This will give us a baseline understanding and show us where we can make improvements.

What are Class Variables?

Class variables are a special type of variables in Python. They are shared among all instances of a class. To understand this better, let's compare them with instance variables. Instance variables are unique to each instance of a class. For example, if you have multiple instances of a class, each instance can have its own value for an instance variable. On the other hand, class variables are defined at the class level. This means that all instances of that class can access and share the same value of the class variable.

What are Class Methods?

Class methods are methods that work on the class itself, not on individual instances of the class. They are bound to the class, which means they can be called directly on the class without creating an instance. To define a class method in Python, we use the @classmethod decorator. And instead of taking the instance (self) as the first parameter, class methods take the class (cls) as their first parameter. This allows them to operate on class-level data and perform actions related to the class as a whole.

Current Approach to Creating Stock Instances

Let's first see how we currently create instances of the Stock class. Open the file stock.py in the editor to observe the current implementation:

class Stock:
    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

    def cost(self):
        return self.shares * self.price

Instances of this class are typically created in one of these ways:

  1. Direct initialization with values:

    s = Stock('GOOG', 100, 490.1)

    Here, we're directly creating an instance of the Stock class by providing the values for the name, shares, and price attributes. This is a straightforward way to create an instance when you know the values upfront.

  2. Creating from data read from a CSV file:

    import csv
    with open('portfolio.csv') as f:
        rows = csv.reader(f)
        headers = next(rows)  ## Skip the header
        row = next(rows)      ## Get the first data row
        s = Stock(row[0], int(row[1]), float(row[2]))

    When we read data from a CSV file, the values are initially in string format. So, when creating a Stock instance from CSV data, we need to manually convert the string values to the appropriate types. For example, the shares value needs to be converted to an integer, and the price value needs to be converted to a float.

Let's try this out. Create a new Python file called test_stock.py in the ~/project directory with the following content:

## test_stock.py
from stock import Stock
import csv

## Method 1: Direct creation
s1 = Stock('GOOG', 100, 490.1)
print(f"Stock: {s1.name}, Shares: {s1.shares}, Price: {s1.price}")
print(f"Cost: {s1.cost()}")

## Method 2: Creation from CSV row
with open('portfolio.csv') as f:
    rows = csv.reader(f)
    headers = next(rows)  ## Skip the header
    row = next(rows)      ## Get the first data row
    s2 = Stock(row[0], int(row[1]), float(row[2]))
    print(f"\nStock from CSV: {s2.name}, Shares: {s2.shares}, Price: {s2.price}")
    print(f"Cost: {s2.cost()}")

Run this file to see the results:

cd ~/project
python test_stock.py

You should see output similar to:

Stock: GOOG, Shares: 100, Price: 490.1
Cost: 49010.0

Stock from CSV: AA, Shares: 100, Price: 32.2
Cost: 3220.0

This manual conversion works, but it has some drawbacks. We need to know the exact format of the data, and we have to perform the conversions each time we create an instance from CSV data. This can be error-prone and time-consuming. In the next step, we'll create a more elegant solution using class methods.

Implementing Alternative Constructors with Class Methods

In this step, we're going to learn how to implement an alternative constructor using a class method. This will allow us to create Stock objects from CSV row data in a more elegant way.

What is an Alternative Constructor?

In Python, an alternative constructor is a useful pattern. Usually, we create objects using the standard __init__ method. However, an alternative constructor gives us an additional way to create objects. Class methods are very suitable for implementing alternative constructors because they can access the class itself.

Implementing the from_row() Class Method

We'll add a class variable types and a class method from_row() to our Stock class. This will simplify the process of creating Stock instances from CSV data.

Let's modify the stock.py file by adding the highlighted code:

## stock.py

class Stock:
    types = (str, int, float)  ## Type conversions to apply to CSV data

    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

    def cost(self):
        return self.shares * self.price

    @classmethod
    def from_row(cls, row):
        """
        Create a Stock instance from a row of CSV data.

        Args:
            row: A list of strings [name, shares, price]

        Returns:
            A new Stock instance
        """
        values = [func(val) for func, val in zip(cls.types, row)]
        return cls(*values)

## The rest of the file remains unchanged

Now, let's understand what's happening in this code step by step:

  1. We defined a class variable types. It's a tuple that contains type conversion functions (str, int, float). These functions will be used to convert the data from the CSV row to the appropriate types.
  2. We added a class method from_row(). The @classmethod decorator marks this method as a class method.
  3. The first parameter of this method is cls, which is a reference to the class itself. In normal methods, we use self to refer to an instance of the class, but here we use cls because it's a class method.
  4. The zip() function is used to pair each type conversion function in types with the corresponding value in the row list.
  5. We use a list comprehension to apply each conversion function to the corresponding value in the row list. This way, we convert the string data from the CSV row to the appropriate types.
  6. Finally, we create a new instance of the Stock class using the converted values and return it.

Testing the Alternative Constructor

Now, we'll create a new file called test_class_method.py to test our new class method. This will help us verify that the alternative constructor works as expected.

## test_class_method.py
from stock import Stock

## Test the from_row() class method
row = ['AA', '100', '32.20']
s = Stock.from_row(row)

print(f"Stock: {s.name}")
print(f"Shares: {s.shares}")
print(f"Price: {s.price}")
print(f"Cost: {s.cost()}")

## Try with a different row
row2 = ['GOOG', '50', '1120.50']
s2 = Stock.from_row(row2)

print(f"\nStock: {s2.name}")
print(f"Shares: {s2.shares}")
print(f"Price: {s2.price}")
print(f"Cost: {s2.cost()}")

To see the results, run the following commands in your terminal:

cd ~/project
python test_class_method.py

You should see output similar to this:

Stock: AA
Shares: 100
Price: 32.2
Cost: 3220.0

Stock: GOOG
Shares: 50
Price: 1120.5
Cost: 56025.0

Notice that now we can create Stock instances directly from string data without having to manually perform type conversions outside the class. This makes our code cleaner and ensures that the responsibility for data conversion is handled within the class itself.

Class Variables and Inheritance

In this step, we're going to explore how class variables interact with inheritance and how they can serve as a mechanism for customization. In Python, inheritance allows a subclass to inherit attributes and methods from a base class. Class variables are variables that belong to the class itself, not to any specific instance of the class. Understanding how these work together is crucial for creating flexible and maintainable code.

Class Variables in Inheritance

When a subclass inherits from a base class, it automatically gets access to the base class's class variables. However, a subclass has the ability to override these class variables. By doing so, the subclass can change its behavior without affecting the base class. This is a very powerful feature as it allows you to customize the behavior of a subclass according to your specific needs.

Creating a Specialized Stock Class

Let's create a subclass of the Stock class. We'll call it DStock, which stands for Decimal Stock. The main difference between DStock and the regular Stock class is that DStock will use the Decimal type for price values instead of float. In financial calculations, precision is extremely important, and the Decimal type provides more accurate decimal arithmetic compared to float.

To create this subclass, we'll create a new file named decimal_stock.py. Here's the code you need to put in this file:

## decimal_stock.py
from decimal import Decimal
from stock import Stock

class DStock(Stock):
    """
    A specialized version of Stock that uses Decimal for prices
    """
    types = (str, int, Decimal)  ## Override the types class variable

## Test the subclass
if __name__ == "__main__":
    ## Create a DStock from row data
    row = ['AA', '100', '32.20']
    ds = DStock.from_row(row)

    print(f"DStock: {ds.name}")
    print(f"Shares: {ds.shares}")
    print(f"Price: {ds.price} (type: {type(ds.price).__name__})")
    print(f"Cost: {ds.cost()} (type: {type(ds.cost()).__name__})")

    ## For comparison, create a regular Stock from the same data
    s = Stock.from_row(row)
    print(f"\nRegular Stock: {s.name}")
    print(f"Shares: {s.shares}")
    print(f"Price: {s.price} (type: {type(s.price).__name__})")
    print(f"Cost: {s.cost()} (type: {type(s.cost()).__name__})")

After you've created the decimal_stock.py file with the above code, you need to run it to see the results. Open your terminal and follow these steps:

cd ~/project
python decimal_stock.py

You should see output similar to this:

DStock: AA
Shares: 100
Price: 32.20 (type: Decimal)
Cost: 3220.0 (type: Decimal)

Regular Stock: AA
Shares: 100
Price: 32.2 (type: float)
Cost: 3220.0 (type: float)

Key Points about Class Variables and Inheritance

From this example, we can draw several important conclusions:

  1. The DStock class inherits all the methods from the Stock class, such as the cost() method, without having to redefine them. This is one of the main advantages of inheritance, as it saves you from writing redundant code.
  2. By simply overriding the types class variable, we've changed how data is converted when creating new instances of DStock. This shows how class variables can be used to customize the behavior of a subclass.
  3. The base class, Stock, remains unchanged and still works with float values. This means that the changes we made to the subclass don't affect the base class, which is a good design principle.
  4. The from_row() class method works correctly with both the Stock and DStock classes. This demonstrates the power of inheritance, as the same method can be used with different subclasses.

This example clearly shows how class variables can be used as a configuration mechanism. Subclasses can override these variables to customize their behavior without having to rewrite the methods.

Design Discussion

Let's consider an alternative approach where we place the type conversions in the __init__ method:

class Stock:
    def __init__(self, name, shares, price):
        self.name = str(name)
        self.shares = int(shares)
        self.price = float(price)

With this approach, we can create a Stock object from a row of data like this:

row = ['AA', '100', '32.20']
s = Stock(*row)

Although this approach might seem simpler at first glance, it has several drawbacks:

  1. It combines two different concerns: object initialization and data conversion. This makes the code harder to understand and maintain.
  2. The __init__ method becomes less flexible because it always converts the inputs, even if they're already in the correct type.
  3. It restricts how subclasses can customize the conversion process. Subclasses would have a harder time changing the conversion logic if it's embedded in the __init__ method.
  4. The code becomes more brittle. If any of the conversions fail, the object can't be created, which can lead to errors in your program.

On the other hand, the class method approach separates these concerns. This makes the code more maintainable and flexible, as each part of the code has a single responsibility.

Creating a General-Purpose CSV Reader

In this final step, we're going to create a general-purpose function. This function will be able to read CSV files and create objects of any class that has implemented the from_row() class method. This shows us the power of using class methods as a uniform interface. A uniform interface means that different classes can be used in the same way, which makes our code more flexible and easier to manage.

Modifying the read_portfolio() Function

First, we'll update the read_portfolio() function in the stock.py file. We'll use our new from_row() class method. Open the stock.py file and change the read_portfolio() function like this:

def read_portfolio(filename):
    '''
    Read a stock portfolio file into a list of Stock instances
    '''
    import csv
    portfolio = []
    with open(filename) as f:
        rows = csv.reader(f)
        headers = next(rows)  ## Skip header
        for row in rows:
            portfolio.append(Stock.from_row(row))
    return portfolio

This new version of the function is simpler. It gives the responsibility of type conversion to the Stock class, where it really belongs. Type conversion means changing the data from one type to another, like turning a string into an integer. By doing this, we make our code more organized and easier to understand.

Creating a General-Purpose CSV Reader

Now, we'll create a more general-purpose function in the reader.py file. This function can read CSV data and create instances of any class that has a from_row() class method.

Open the reader.py file and add the following function:

def read_csv_as_instances(filename, cls):
    '''
    Read a CSV file into a list of instances of the given class.

    Args:
        filename: Name of the CSV file
        cls: Class to instantiate (must have from_row class method)

    Returns:
        List of class instances
    '''
    records = []
    with open(filename) as f:
        rows = csv.reader(f)
        headers = next(rows)  ## Skip header
        for row in rows:
            records.append(cls.from_row(row))
    return records

This function takes two inputs: a filename and a class. It then returns a list of instances of that class, created from the data in the CSV file. This is very useful because we can use it with different classes, as long as they have the from_row() method.

Testing the General-Purpose CSV Reader

Let's create a test file to see how our general-purpose reader works. Create a file named test_csv_reader.py with the following content:

## test_csv_reader.py
from reader import read_csv_as_instances
from stock import Stock
from decimal_stock import DStock

## Read portfolio as Stock instances
portfolio = read_csv_as_instances('portfolio.csv', Stock)
print(f"Portfolio contains {len(portfolio)} stocks")
print(f"First stock: {portfolio[0].name}, {portfolio[0].shares} shares at ${portfolio[0].price}")

## Read portfolio as DStock instances (with Decimal prices)
decimal_portfolio = read_csv_as_instances('portfolio.csv', DStock)
print(f"\nDecimal portfolio contains {len(decimal_portfolio)} stocks")
print(f"First stock: {decimal_portfolio[0].name}, {decimal_portfolio[0].shares} shares at ${decimal_portfolio[0].price}")

## Define a new class for reading the bus data
class BusRide:
    def __init__(self, route, date, daytype, rides):
        self.route = route
        self.date = date
        self.daytype = daytype
        self.rides = rides

    @classmethod
    def from_row(cls, row):
        return cls(row[0], row[1], row[2], int(row[3]))

## Read some bus data (just the first 5 records for brevity)
print("\nReading bus data...")
import csv
with open('ctabus.csv') as f:
    rows = csv.reader(f)
    headers = next(rows)  ## Skip header
    bus_rides = []
    for i, row in enumerate(rows):
        if i >= 5:  ## Only read 5 records for the example
            break
        bus_rides.append(BusRide.from_row(row))

## Display the bus data
for ride in bus_rides:
    print(f"Route: {ride.route}, Date: {ride.date}, Type: {ride.daytype}, Rides: {ride.rides}")

Run this file to see the results. Open your terminal and use the following commands:

cd ~/project
python test_csv_reader.py

You should see output that shows the portfolio data loaded as both Stock and DStock instances, and the bus route data loaded as BusRide instances. This proves that our general-purpose reader works with different classes.

Key Benefits of This Approach

This approach shows several powerful concepts:

  1. Separation of concerns: Reading data is separate from creating objects. This means that the code for reading the CSV file is not mixed with the code for creating objects. It makes the code easier to understand and maintain.
  2. Polymorphism: The same code can work with different classes that follow the same interface. In our case, as long as a class has the from_row() method, our general-purpose reader can use it.
  3. Flexibility: We can easily change how data is converted by using different classes. For example, we can use Stock or DStock to handle the portfolio data differently.
  4. Extensibility: We can add new classes that work with our reader without changing the reader code. This makes our code more future-proof.

This is a common pattern in Python that makes code more modular, reusable, and maintainable.

Final Notes on Class Methods

Class methods are often used as alternative constructors in Python. You can usually tell them apart because their names often have the word "from" in them. For example:

## Some examples from Python's built-in types
dict.fromkeys(['a', 'b', 'c'], 0)  ## Create a dict with default values
datetime.datetime.fromtimestamp(1627776000)  ## Create datetime from timestamp
int.from_bytes(b'\x00\x01', byteorder='big')  ## Create int from bytes

By following this convention, you make your code more readable and consistent with Python's built-in libraries. This helps other developers understand your code more easily.

Summary

In this lab, you have learned about two crucial Python features: class variables and class methods. Class variables are shared among all class instances and can be used for configuration. Class methods operate on the class itself, marked with the @classmethod decorator. Alternative constructors, a common use of class methods, offer different ways to create objects. Inheritance with class variables enables subclasses to customize behavior by overriding them, and using class methods can achieve flexible code design.

These concepts are powerful for creating well - organized and flexible Python code. By placing type conversions within the class and providing a uniform interface via class methods, you can write more general - purpose utilities. To extend your learning, you can explore more use cases, create class hierarchies, and build complex data processing pipelines using class methods.