Function Argument Passing Conventions

Beginner

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

Introduction

In this lab, you will learn about Python function argument passing conventions. You'll also create a reusable structure for data classes and apply object-oriented design principles to simplify your code.

This exercise aims to rewrite the stock.py file in a more organized manner. Before you start, copy your existing work in stock.py to a new file named orig_stock.py for reference. The files you'll create are structure.py and stock.py.

Understanding Function Argument Passing

In Python, functions are a fundamental concept that allows you to group a set of statements together to perform a specific task. When you call a function, you often need to provide it with some data, which we call arguments. Python offers different ways to pass these arguments to functions. This flexibility is incredibly useful as it helps you write cleaner and more maintainable code. Before we start applying these techniques to our project, let's take a closer look at these argument passing conventions.

Creating a Backup of Your Work

Before we start making changes to our stock.py file, it's a good practice to create a backup. This way, if something goes wrong during our experimentation, we can always go back to the original version. To create a backup, open a terminal and run the following command:

cp stock.py orig_stock.py

This command uses the cp (copy) command in the terminal. It takes the stock.py file and creates a copy of it named orig_stock.py. By doing this, we ensure that our original work is safely preserved.

Exploring Function Argument Passing

In Python, there are several ways to call functions with different types of arguments. Let's explore each of these methods in detail.

1. Positional Arguments

The simplest way to pass arguments to a function is by position. When you define a function, you specify a list of parameters. When you call the function, you provide values for these parameters in the same order as they are defined.

Here's an example:

def calculate(x, y, z):
    return x + y + z

## Call with positional arguments
result = calculate(1, 2, 3)
print(result)  ## Output: 6

In this example, the calculate function takes three parameters: x, y, and z. When we call the function with calculate(1, 2, 3), the value 1 is assigned to x, 2 is assigned to y, and 3 is assigned to z. The function then adds these values together and returns the result.

2. Keyword Arguments

In addition to positional arguments, you can also specify arguments by their names. This is called using keyword arguments. When you use keyword arguments, you don't have to worry about the order of the arguments.

Here's an example:

## Call with a mix of positional and keyword arguments
result = calculate(1, z=3, y=2)
print(result)  ## Output: 6

In this example, we first pass the positional argument 1 for x. Then, we use keyword arguments to specify the values for y and z. The order of the keyword arguments doesn't matter, as long as you provide the correct names.

3. Unpacking Sequences and Dictionaries

Python provides a convenient way to pass sequences and dictionaries as arguments using the * and ** syntax. This is called unpacking.

Here's an example of unpacking a tuple into positional arguments:

## Unpacking a tuple into positional arguments
args = (1, 2, 3)
result = calculate(*args)
print(result)  ## Output: 6

In this example, we have a tuple args that contains the values 1, 2, and 3. When we use the * operator before args in the function call, Python unpacks the tuple and passes its elements as positional arguments to the calculate function.

Here's an example of unpacking a dictionary into keyword arguments:

## Unpacking a dictionary into keyword arguments
kwargs = {'y': 2, 'z': 3}
result = calculate(1, **kwargs)
print(result)  ## Output: 6

In this example, we have a dictionary kwargs that contains the key-value pairs 'y': 2 and 'z': 3. When we use the ** operator before kwargs in the function call, Python unpacks the dictionary and passes its key-value pairs as keyword arguments to the calculate function.

4. Accepting Variable Arguments

Sometimes, you may want to define a function that can accept any number of arguments. Python allows you to do this using the * and ** syntax in the function definition.

Here's an example of a function that accepts any number of positional arguments:

## Accept any number of positional arguments
def sum_all(*args):
    return sum(args)

print(sum_all(1, 2))           ## Output: 3
print(sum_all(1, 2, 3, 4, 5))  ## Output: 15

In this example, the sum_all function uses the *args parameter to accept any number of positional arguments. The * operator collects all the positional arguments into a tuple named args. The function then uses the built-in sum function to add up all the elements in the tuple.

Here's an example of a function that accepts any number of keyword arguments:

## Accept any number of keyword arguments
def print_info(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_info(name="Python", year=1991)
## Output:
## name: Python
## year: 1991

In this example, the print_info function uses the **kwargs parameter to accept any number of keyword arguments. The ** operator collects all the keyword arguments into a dictionary named kwargs. The function then iterates over the key-value pairs in the dictionary and prints them.

These techniques will help us create more flexible and reusable code structures in the following steps. To get more comfortable with these concepts, let's open the Python interpreter and try some of these examples.

python3

Once you're in the Python interpreter, try entering the examples above. This will give you hands-on experience with these argument passing techniques.

Creating a Structure Base Class

Now that we have a good understanding of function argument passing, we're going to create a reusable base class for data structures. This step is crucial because it helps us avoid writing the same code over and over again when we create simple classes that hold data. By using a base class, we can streamline our code and make it more efficient.

The Problem with Repetitive Code

In the earlier exercises, you defined a Stock class as shown below:

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

Take a close look at the __init__ method. You'll notice that it's quite repetitive. You have to manually assign each attribute one by one. This can become very tedious and time - consuming, especially when you have many classes with a large number of attributes.

Creating a Flexible Base Class

Let's create a Structure base class that can automatically handle attribute assignment. First, open the WebIDE and create a new file named structure.py. Then, add the following code to this file:

## structure.py

class Structure:
    """
    A base class for creating simple data structures.
    Automatically populates object attributes from _fields and constructor arguments.
    """
    _fields = ()

    def __init__(self, *args):
        ## Check that the number of arguments matches the number of fields
        if len(args) != len(self._fields):
            raise TypeError(f"Expected {len(self._fields)} arguments")

        ## Set the attributes
        for name, value in zip(self._fields, args):
            setattr(self, name, value)

This base class has several important features:

  1. It defines a _fields class variable. By default, this variable is empty. This variable will hold the names of the attributes that the class will have.
  2. It checks if the number of arguments passed to the constructor matches the number of fields defined in _fields. If they don't match, it raises a TypeError. This helps us catch errors early.
  3. It sets the attributes of the object using the field names and the values provided as arguments. The setattr function is used to dynamically set the attributes.

Testing Our Structure Base Class

Now, let's create some example classes that inherit from the Structure base class. Add the following code to your structure.py file:

## Example classes using Structure
class Stock(Structure):
    _fields = ('name', 'shares', 'price')

class Point(Structure):
    _fields = ('x', 'y')

class Date(Structure):
    _fields = ('year', 'month', 'day')

To test if our implementation works correctly, we'll create a test file named test_structure.py. Add the following code to this file:

## test_structure.py
from structure import Stock, Point, Date

## Test Stock class
s = Stock('GOOG', 100, 490.1)
print(f"Stock name: {s.name}, shares: {s.shares}, price: {s.price}")

## Test Point class
p = Point(3, 4)
print(f"Point coordinates: ({p.x}, {p.y})")

## Test Date class
d = Date(2023, 11, 9)
print(f"Date: {d.year}-{d.month}-{d.day}")

## Test error handling
try:
    s2 = Stock('AAPL', 50)  ## Missing price argument
    print("This should not print")
except TypeError as e:
    print(f"Error correctly caught: {e}")

To run the test, open your terminal and execute the following command:

python3 test_structure.py

You should see the following output:

Stock name: GOOG, shares: 100, price: 490.1
Point coordinates: (3, 4)
Date: 2023-11-9
Error correctly caught: Expected 3 arguments

As you can see, our base class is working as expected. It has made it much easier to define new data structures without having to write the same boilerplate code repeatedly.

Improving Object Representation

Our Structure class is useful for creating and accessing objects. However, it currently doesn't have a good way to represent itself as a string. When you print an object or view it in the Python interpreter, you want to see a clear and informative display. This helps you understand what the object is and what its values are.

Understanding Python's Object Representation

In Python, there are two special methods that are used to represent objects in different ways. These methods are important because they allow you to control how your objects are displayed.

  • __str__ - This method is used by the str() function and the print() function. It provides a human-readable representation of the object. For example, if you have a Stock object, the __str__ method might return something like "Stock: GOOG, 100 shares at $490.1".
  • __repr__ - This method is used by the Python interpreter and the repr() function. It gives a more technical and unambiguous representation of the object. The goal of __repr__ is to provide a string that can be used to recreate the object. For instance, for a Stock object, it might return "Stock('GOOG', 100, 490.1)".

Let's add a __repr__ method to our Structure class. This will make it easier to debug our code because we can clearly see the state of our objects.

Implementing a Good Representation

Now, you need to update your structure.py file. You'll add the __repr__ method to the Structure class. This method will create a string that represents the object in a way that can be used to recreate it.

def __repr__(self):
    """
    Return a representation of the object that can be used to recreate it.
    Example: Stock('GOOG', 100, 490.1)
    """
    ## Get the class name
    cls_name = type(self).__name__

    ## Get all the field values
    values = [getattr(self, name) for name in self._fields]

    ## Format the fields and values
    args_str = ', '.join(repr(value) for value in values)

    ## Return the formatted string
    return f"{cls_name}({args_str})"

Here's what this method does step by step:

  1. It gets the class name using type(self).__name__. This is important because it tells you what kind of object you're dealing with.
  2. It retrieves all the field values from the instance. This gives you the data that the object holds.
  3. It creates a string representation with the class name and values. This string can be used to recreate the object.

Testing the Improved Representation

Let's test our enhanced implementation. Create a new file called test_repr.py. This file will create some instances of our classes and print their representations.

## test_repr.py
from structure import Stock, Point, Date

## Create instances
s = Stock('GOOG', 100, 490.1)
p = Point(3, 4)
d = Date(2023, 11, 9)

## Print the representations
print(repr(s))
print(repr(p))
print(repr(d))

## Direct printing also uses __repr__ in the interpreter
print(s)
print(p)
print(d)

To run the test, open your terminal and enter the following command:

python3 test_repr.py

You should see the following output:

Stock('GOOG', 100, 490.1)
Point(3, 4)
Date(2023, 11, 9)
Stock('GOOG', 100, 490.1)
Point(3, 4)
Date(2023, 11, 9)

This output is much more informative than before. When you see Stock('GOOG', 100, 490.1), you immediately know what the object represents. You could even copy this string and use it to recreate the object in your code.

The Benefit of Good Representations

A good __repr__ implementation is very helpful for debugging. When you're looking at objects in the interpreter or logging them during program execution, a clear representation makes it easier to identify issues quickly. You can see the exact state of the object and understand what might be going wrong.

Restricting Attribute Names

Currently, our Structure class allows any attribute to be set on its instances. For beginners, this might seem convenient at first, but it can actually lead to a lot of problems. When you're working with a class, you expect certain attributes to be present and used in a specific way. If users misspell attribute names or try to set attributes that weren't part of the original design, it can cause errors that are hard to find.

The Need for Attribute Restriction

Let's look at a simple scenario to understand why we need to restrict attribute names. Consider the following code:

s = Stock('GOOG', 100, 490.1)
s.shares = 50      ## Correct attribute name
s.share = 60       ## Typo in attribute name - creates a new attribute instead of updating

In the second line, there's a typo. Instead of shares, we wrote share. In Python, instead of raising an error, it will simply create a new attribute called share. This can lead to subtle bugs because you might think you're updating the shares attribute, but you're actually creating a new one. This can make your code behave unexpectedly and be very difficult to debug.

Implementing Attribute Restriction

To solve this problem, we can override the __setattr__ method. This method is called every time you try to set an attribute on an object. By overriding it, we can control which attributes can be set and which ones can't.

Update your Structure class in structure.py with the following code:

def __setattr__(self, name, value):
    """
    Restrict attribute setting to only those defined in _fields
    or attributes starting with underscore (private attributes).
    """
    if name.startswith('_'):
        ## Allow setting private attributes (starting with '_')
        super().__setattr__(name, value)
    elif name in self._fields:
        ## Allow setting attributes defined in _fields
        super().__setattr__(name, value)
    else:
        ## Raise an error for other attributes
        raise AttributeError(f'No attribute {name}')

Here's how this method works:

  1. If the attribute name starts with an underscore (_), it's considered a private attribute. Private attributes are often used for internal purposes in a class. We allow these attributes to be set because they're part of the class's internal implementation.
  2. If the attribute name is in the _fields list, it means it's one of the attributes defined in the class design. We allow these attributes to be set because they're part of the expected behavior of the class.
  3. If the attribute name doesn't meet either of these conditions, we raise an AttributeError. This tells the user that they're trying to set an attribute that doesn't exist in the class.

Testing Attribute Restriction

Now that we've implemented the attribute restriction, let's test it to make sure it works as expected. Create a file named test_attributes.py with the following code:

## test_attributes.py
from structure import Stock

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

## This should work - valid attribute
print("Setting shares to 50")
s.shares = 50
print(f"Shares is now: {s.shares}")

## This should work - private attribute
print("\nSetting _internal_data")
s._internal_data = "Some data"
print(f"_internal_data is: {s._internal_data}")

## This should fail - invalid attribute
print("\nTrying to set an invalid attribute:")
try:
    s.share = 60  ## Typo in attribute name
    print("This should not print")
except AttributeError as e:
    print(f"Error correctly caught: {e}")

To run the test, open your terminal and enter the following command:

python3 test_attributes.py

You should see the following output:

Setting shares to 50
Shares is now: 50

Setting _internal_data
_internal_data is: Some data

Trying to set an invalid attribute:
Error correctly caught: No attribute share

This output shows that our class now prevents accidental attribute errors. It allows us to set valid attributes and private attributes, but it raises an error when we try to set an invalid attribute.

The Value of Attribute Restriction

Restricting attribute names is very important for writing robust and maintainable code. Here's why:

  1. It helps catch typos in attribute names. If you make a mistake when typing an attribute name, the code will raise an error instead of creating a new attribute. This makes it easier to find and fix errors early in the development process.
  2. It prevents attempts to set attributes that don't exist in the class design. This ensures that the class is used as intended and that the code behaves predictably.
  3. It avoids the accidental creation of new attributes. Creating new attributes can lead to unexpected behavior and make the code harder to understand and maintain.

By restricting attribute names, we make our code more reliable and easier to work with.

Rewriting the Stock Class

Now that we have a well - defined Structure base class, it's time to rewrite our Stock class. By using this base class, we can simplify our code and make it more organized. The Structure class provides a set of common functionalities that we can reuse in our Stock class, which is a great advantage for code maintainability and readability.

Creating the New Stock Class

Let's start by creating a new file named stock.py. This file will contain our rewritten Stock class. Here's the code you need to put in the stock.py file:

## stock.py
from structure import Structure

class Stock(Structure):
    _fields = ('name', 'shares', 'price')

    @property
    def cost(self):
        """
        Calculate the cost as shares * price
        """
        return self.shares * self.price

    def sell(self, nshares):
        """
        Sell a number of shares
        """
        self.shares -= nshares

Let's break down what this new Stock class does:

  1. It inherits from the Structure class. This means that the Stock class can use all the features provided by the Structure class. One of the benefits is that we don't need to write an __init__ method ourselves because the Structure class takes care of attribute assignment automatically.
  2. We define _fields which is a tuple that specifies the attributes of the Stock class. These attributes are name, shares, and price.
  3. The cost property is defined to calculate the total cost of the stock. It multiplies the number of shares by the price.
  4. The sell method is used to reduce the number of shares. When you call this method with a number of shares to sell, it subtracts that number from the current number of shares.

Testing the New Stock Class

To make sure our new Stock class works as expected, we need to create a test file. Let's create a file named test_stock.py with the following code:

## test_stock.py
from stock import Stock

## Create a stock
s = Stock('GOOG', 100, 490.1)

## Check the attributes
print(f"Stock: {s}")
print(f"Name: {s.name}")
print(f"Shares: {s.shares}")
print(f"Price: {s.price}")
print(f"Cost: {s.cost}")

## Sell some shares
print("\nSelling 20 shares...")
s.sell(20)
print(f"Shares after selling: {s.shares}")
print(f"Cost after selling: {s.cost}")

## Try to set an invalid attribute
print("\nTrying to set an invalid attribute:")
try:
    s.prices = 500  ## Invalid attribute (should be 'price')
    print("This should not print")
except AttributeError as e:
    print(f"Error correctly caught: {e}")

In this test file, we first import the Stock class from the stock.py file. Then we create an instance of the Stock class with the name 'GOOG', 100 shares, and a price of 490.1. We print out the attributes of the stock to check if they are set correctly. After that, we sell 20 shares and print out the new number of shares and the new cost. Finally, we try to set an invalid attribute prices (it should be price). If our Stock class is working correctly, it should raise an AttributeError.

To run the test, open your terminal and enter the following command:

python3 test_stock.py

The expected output is as follows:

Stock: Stock('GOOG', 100, 490.1)
Name: GOOG
Shares: 100
Price: 490.1
Cost: 49010.0

Selling 20 shares...
Shares after selling: 80
Cost after selling: 39208.0

Trying to set an invalid attribute:
Error correctly caught: No attribute prices

Running Unit Tests

If you have unit tests from previous exercises, you can run them against your new implementation. In your terminal, enter the following command:

python3 teststock.py

Note that some tests might fail. This could be because they expect specific behaviors or methods that we haven't implemented yet. Don't worry about it! We'll continue to build on this foundation in future exercises.

Review of Our Progress

Let's take a moment to review what we've achieved so far:

  1. We created a reusable Structure base class. This class:

    • Automatically handles attribute assignment, which saves us from writing a lot of repetitive code.
    • Provides a good string representation, making it easier to print and debug our objects.
    • Restricts attribute names to prevent errors, which makes our code more robust.
  2. We rewrote our Stock class. It:

    • Inherits from the Structure class to reuse the common functionality.
    • Only defines the fields and domain - specific methods, which keeps the class focused and clean.
    • Has a clear and simple design, making it easy to understand and maintain.

This approach has several benefits for our code:

  • It is more maintainable because we have less repetition. If we need to change something in the common functionality, we only need to change it in the Structure class.
  • It is more robust because of the better error checking provided by the Structure class.
  • It is more readable because the responsibilities of each class are clear.

In future exercises, we'll continue to build on this foundation to create a more sophisticated stock portfolio management system.

Summary

In this lab, you have learned about function argument passing conventions in Python and applied them to build a more organized and maintainable codebase. You explored Python's flexible argument - passing mechanisms, created a reusable Structure base class for data objects, and improved object representation for better debugging.

You also added attribute validation to prevent common errors and rewrote the Stock class using the new structure. These techniques showcase key object - oriented design principles such as inheritance for code reuse, encapsulation for data integrity, and polymorphism through common interfaces. By applying these principles, you can develop more robust and maintainable code with less repetition and fewer errors.