Classes

A class is a named conglomoration of information and/or functions---that is, of other Python objects. We can define a class; we can use a class directly; and we can create any number of instances of a given class, and use them just as we would any other Python object (which is itself a class or an instance of a class).

Classes are something like modules. Remember, a module is a file with Python code in it. (More correctly, the module is the object resulting from importing the file.) That code can provide information, and it can include functions that know about, and work with, that information. The same is true of a class; but classes are more flexible, because they, and their instances, can be defined and modified as a program is executed, and there can be any number of classes defined in a given module. More importantly, there is only one copy of a given module, with its associated data, in use in a program at a given time; but there can be many different instances of a given class, each with its own specific information.

Modules and classes (and packages, for that matter) implement Python's "divide and conquer" approach to keeping programs writable, readable, and maintainable as the problems they address become increasingly complex. They break information and functionality down into independent, testable, documentable, resuable chunks, with well-defined inputs and outputs to connect the chunks.

Enough verbiage; let's make our own classes.

In [1]:
# define the class:
class Attribute_holder(object):
    pass

# Make a couple of instances of it, and give each
# one some attributes to hold:
c1 = Attribute_holder()
c1.some_attribute = "this is a test"
c1.some_other_attribute = 5
c1.y = 20

c2 = Attribute_holder()
c2.x = "the x attribute of this Attribute_holder instance"
c2.y = 50

print("c1 is an instance of", type(c1))
print(c1.some_attribute)
print(c2.y)
print(c1.some_other_attribute * c2.y)
print("c1.y is %d but c2.y is %d" % (c1.y, c2.y))
c1 is an instance of <class '__main__.Attribute_holder'>
this is a test
50
250
c1.y is 20 but c2.y is 50

Above we have an example of the simplest possible type of custom class, a container that is initially empty. We can make instances of it, and we can assign attributes to those instances. An attribute is simply an object in a class or instance that can be accessed using the "dot" syntax, as shown above.

Although it is not mandatory, it is advisable to define any class as inheriting from some pre-existing class. In the example above, we are inheriting from the general Python object type--use this if you don't need specific behavior from some more specialized class. (The terms "class" and "type" are now nearly synonymous in Python from a practical standpoint. "Type" is the proper term for built-in types such as the object, the list, and the dictionary. "Class" usually refers to a user-customized type. In the example above, we are customizing the object type--trivially, by copying it with a new name.)

Normally, a class definition includes one or more method definitions. A method is a function defined within a class, with one special characteristic: when the method is called, the instance to which the method belongs is prepended to its argument list. It is easier to see how this works with an example than with a description, so here is a minimal illustration:

In [2]:
class Demo_of_method(object):
    def return_self(self):
        return self

# make an instance:    
x = Demo_of_method()

# verify that return_self is returning the instance itself:
print(x.return_self() is x)
True

In the example above, notice that:

  • To make an instance of the class, we called the class itself (with parentheses).
  • To call the method, we used the dot syntax, and an empty argument list (in parentheses).
  • Even though we did not supply an argument when we called the method, the method got its self argument.
  • The last line of the cell above verifies that the self argument is the instance.

Here is another illustration; it is a little bit more realistic because it illustrates how self is used within a method to access an attribute of the instance; and how an attribute can be set when the instance is created:

In [3]:
class Named_demo(object):
    def __init__(self, name):
        self.name = name
        # Now "name" is an attribute of the instance.
        
    def __str__(self):
        return "Named_class instance with name " + self.name
        
    def show_name(self):
        print("My name is", self.name)
        
a = Named_demo("Harold")
b = Named_demo("Maud")

print("Variable 'a' is printed via its __str__ method as:\n  ", a)

a.show_name()
b.show_name()
Variable 'a' is printed via its __str__ method as:
   Named_class instance with name Harold
My name is Harold
My name is Maud

Things to notice:

  • Method names and attributes that start and end with two underscores are special.

    • __init__ is the method that creates a new instance of the class. It is called automatically when the class name is called--treated like a function--and it automatically returns the new instance--no explicit return statement is needed.
    • __str__ is the method that is called to make a string representation of the instance when printing, or when calling the built-in str() function.

In this example, __init__ takes two arguments: self, which is prepended automatically when it is called (just as with any other method), and name, which we supply when we create the instance.

There are other special "double underscore" methods. One of the most useful is __call__, which makes an instance of the class behave like a function. Here is an example:

In [4]:
class Polynomial(object):
    """
    Very simple class for evaluating polynomials.
    """
    def __init__(self, *args):
        """
        Arguments are successively higher order coefficients
        of the polynomial, starting from zero.
        """
        self.coefs = args        

    def __str__(self):
        s = []
        for i, a in enumerate(self.coefs):
            if a == 0:
                continue
            if i == 0:
                s.append(str(a))
            elif i == 1:
                s.append('%s*x' % (a,))
            else:    
                s.append('%s*x**%d' % (a, i))
        out = 'evaluates: ' + ' + '.join(s)
        return out
                     
    def __call__(self, *args):
        """
        Evaluate the polynomial at points given in one or more args.
        
        With one input, a scalar will be returned; otherwise
        a list will be returned.
        """
        out = []
        for x in args:
            y = 0
            for n, coef in enumerate(self.coefs):
                y += coef * x ** n
            out.append(y)
        if len(out) == 1:
            return out[0]
        return out


# Make an instance: a second order Hermite polynomial
hermite2 = Polynomial(-2, 0, 4)
            # (This called its __init__() method.)

print("The 'hermite2' instance", hermite2)  # calls its __str__() method

# and evaluate it at multiple points
print(hermite2(-1, -0.5, 0, 0.5, 1))

# or at a single point.
print(hermite2(0.25))
The 'hermite2' instance evaluates: -2 + 4*x**2
[2, -1.0, -2, -1.0, 2]
-1.75

Here is another example, in which we will define 3 classes. This illustrates how attributes and methods can be inherited from its parent by a subclass, or they can be overridden with customized replacements.

In [5]:
class AddressBase(object):
    state = None
    # subclass must override this "state" attribute
    
    def __init__(self, name=None, number="", street="", city=""):
        if name is None:
            raise ValueError("name must be specified")
        self.name = name
        self.number = number
        self.street = street
        self.city = city
        
    def __str__(self):
        return "%s, %s %s, %s %s" % (self.name, self.number, self.street, 
                                     self.city, self.state)
    
    def is_complete(self):
        """
        Return True if the address includes all fields, or False otherwise.
        """
        complete = self.number and self.street and self.city and self.state
        return complete

Things to notice:

  • We are starting here with a base class; we will define customized subclasses of it below, and will make instances only of those subclasses.
  • The class definition includes a field--a class attribute--that each instance will start out with. (Here, it is the state attribute.) It can be overridden in any given instance, or by subclassing, as we will illustrate shortly.
  • We used the built-in Python None object as the default value for the name argument, and then used that as a flag to show whether a value for this optional argument was supplied.
  • Minor formatting note: Within brackets or parentheses of any kind, lines may be split and indented to enhance readability.

Now let's subclass AddressBase by writing a simple class that inherits from it.

In [6]:
class HawaiiAddress(AddressBase):
    state = "HI"
    
    # Customize the __init__ method to provide a default city.
    def __init__(self, name=None, number="", street="", city="Honolulu"):
        AddressBase.__init__(self, name=name, number=number, 
                             street=street, city=city)

# That's it--that's all the customization we need here.
        
# Make a list of HawaiiAddress instances:        
addresses = [HawaiiAddress(name="Henry Smith", number="417a", 
                           street="Oak St"),
             HawaiiAddress("Lisa Chun", street="Kumu Hula Way"),
             HawaiiAddress("Sally Sato", number="223", street="Koa St", 
                           city="Wailuku")]
                     
# We might want to look up an address by name, so make a dictionary:
address_dict = dict()
for a in addresses:
    address_dict[a.name] = a
    
print(address_dict["Sally Sato"])

for a in addresses:
    if a.is_complete():
        print("Address for %s is complete" % a.name)
    else:
        print("Warning: address for %s is incomplete" % a.name)
Sally Sato, 223 Koa St, Wailuku HI
Address for Henry Smith is complete
Warning: address for Lisa Chun is incomplete
Address for Sally Sato is complete

Notice that we are inheriting almost everything, and just adding a little customization.

Now do the same thing for a different state, but with more customization: an additional method.

In [7]:
class CaliforniaAddress(AddressBase):
    state = "CA"
    
    def __init__(self, *args, **kwargs):
        # (alternative way of handling args and kwargs)
        kwargs.setdefault('city', 'Los Angeles')
        AddressBase.__init__(self, *args, **kwargs)
    
    def is_northern(self):
        return self.city in ['San Francisco', 'Sacramento']


# list some CA addresses:
addresses = [CaliforniaAddress(name="Hubert Smith", number="417a", 
                               street="Walnut St"),
             CaliforniaAddress("Lisa Chun", street="Google Way", 
                               city='San Francisco'),
             CaliforniaAddress("Sally Jones", number="223", 
                               street="Brown St", city="Sacramento")]    

# Test the methods, including the one we added for California
for a in addresses:
    print(a)
    if a.is_complete():
        print("Address for %s is complete" % a.name)
    else:
        print("Warning: address for %s is incomplete" % a.name)
    if a.is_northern():
        print("    It's in Northern California")
Hubert Smith, 417a Walnut St, Los Angeles CA
Address for Hubert Smith is complete
Lisa Chun,  Google Way, San Francisco CA
Warning: address for Lisa Chun is incomplete
    It's in Northern California
Sally Jones, 223 Brown St, Sacramento CA
Address for Sally Jones is complete
    It's in Northern California

That's probably enough introduction to classes. Now it is time to learn about numpy, so we can work efficiently with arrays of numbers, and about matplotlib, so we can plot them.