Step 4: Customizing Behavior by Subclassing

At this point, our class captures much of the OOP machinery in Python: it makes instances, provides behavior in methods, and even does a bit of operator overloading now to intercept print operations in __repr__. It effectively packages our data and logic together into a single, self-contained software component, making it easy to locate code and straightforward to change it in the future. By allowing us to encapsulate behavior, it also allows us to factor that code to avoid redundancy and its associated maintenance headaches.

The only major OOP concept it does not yet capture is customization by inheritance. In some sense, we’re already doing inheritance, because instances inherit methods from their classes. To demonstrate the real power of OOP, though, we need to define a superclass/subclass relationship that allows us to extend our software and replace bits of inherited behavior. That’s the main idea behind OOP, after all; by fostering a coding model based upon customization of work already done, it can dramatically cut development time.

Coding Subclasses

As a next step, then, let’s put OOP’s methodology to use and customize our Person class by extending our software hierarchy. For the purpose of this tutorial, we’ll define a subclass of Person called Manager that replaces the inherited giveRaise method with a more specialized version. Our new class begins as follows:

class Manager(Person):                          # Define a subclass of Person

This code means that we’re defining a new class named Manager, which inherits from and may add customizations to the superclass Person. In plain terms, a Manager is almost like a Person (admittedly, a very long journey for a very small joke...), but Manager has a custom way to give raises.

For the sake of argument, let’s assume that when a Manager gets a raise, it receives the passed-in percentage as usual, but also gets an extra bonus that defaults to 10%. For instance, if a Manager’s raise is specified as 10%, it will really get 20%. (Any relation to Persons living or dead is, of course, strictly coincidental.) Our new method begins as follows; because this redefinition of giveRaise will be closer in the class tree to Manager instances than the original version in Person, it effectively replaces, and thereby customizes, the operation. Recall that according to the inheritance search rules, the lowest version of the name wins:[55]

class Manager(Person):                          # Inherit Person attrs
    def giveRaise(self, percent, bonus=.10):    # Redefine to customize

Augmenting Methods: The Bad Way

Now, there are two ways we might code this Manager customization: a good way and a bad way. Let’s start with the bad way, since it might be a bit easier to understand. The bad way is to cut and paste the code of giveRaise in Person and modify it for Manager, like this:

class Manager(Person):
    def giveRaise(self, percent, bonus=.10):
        self.pay = int(self.pay * (1 + percent + bonus))   # Bad: cut and paste

This works as advertised—when we later call the giveRaise method of a Manager instance, it will run this custom version, which tacks on the extra bonus. So what’s wrong with something that runs correctly?

The problem here is a very general one: anytime you copy code with cut and paste, you essentially double your maintenance effort in the future. Think about it: because we copied the original version, if we ever have to change the way raises are given (and we probably will), we’ll have to change the code in two places, not one. Although this is a small and artificial example, it’s also representative of a universal issue—anytime you’re tempted to program by copying code this way, you probably want to look for a better approach.

Augmenting Methods: The Good Way

What we really want to do here is somehow augment the original giveRaise, instead of replacing it altogether. The good way to do that in Python is by calling to the original version directly, with augmented arguments, like this:

class Manager(Person):
    def giveRaise(self, percent, bonus=.10):
        Person.giveRaise(self, percent + bonus)            # Good: augment original

This code leverages the fact that a class’s method can always be called either through an instance (the usual way, where Python sends the instance to the self argument automatically) or through the class (the less common scheme, where you must pass the instance manually). In more symbolic terms, recall that a normal method call of this form:

instance.method(args...)

is automatically translated by Python into this equivalent form:

class.method(instance, args...)

where the class containing the method to be run is determined by the inheritance search rule applied to the method’s name. You can code either form in your script, but there is a slight asymmetry between the two—you must remember to pass along the instance manually if you call through the class directly. The method always needs a subject instance one way or another, and Python provides it automatically only for calls made through an instance. For calls through the class name, you need to send an instance to self yourself; for code inside a method like giveRaise, self already is the subject of the call, and hence the instance to pass along.

Calling through the class directly effectively subverts inheritance and kicks the call higher up the class tree to run a specific version. In our case, we can use this technique to invoke the default giveRaise in Person, even though it’s been redefined at the Manager level. In some sense, we must call through Person this way, because a self.giveRaise() inside Manager’s giveRaise code would loop—since self already is a Manager, self.giveRaise() would resolve again to Manager.giveRaise, and so on and so forth recursively until available memory is exhausted.

This “good” version may seem like a small difference in code, but it can make a huge difference for future code maintenance—because the giveRaise logic lives in just one place now (Person’s method), we have only one version to change in the future as needs evolve. And really, this form captures our intent more directly anyhow—we want to perform the standard giveRaise operation, but simply tack on an extra bonus. Here’s our entire module file with this step applied:

# Add customization of one behavior in a subclass

class Person:
    def __init__(self, name, job=None, pay=0):
        self.name = name
        self.job  = job
        self.pay  = pay
    def lastName(self):
        return self.name.split()[-1]
    def giveRaise(self, percent):
        self.pay = int(self.pay * (1 + percent))
    def __repr__(self):
        return '[Person: %s, %s]' % (self.name, self.pay)

class Manager(Person):
    def giveRaise(self, percent, bonus=.10):           # Redefine at this level
        Person.giveRaise(self, percent + bonus)        # Call Person's version

if __name__ == '__main__':
    bob = Person('Bob Smith')
    sue = Person('Sue Jones', job='dev', pay=100000)
    print(bob)
    print(sue)
    print(bob.lastName(), sue.lastName())
    sue.giveRaise(.10)
    print(sue)
    tom = Manager('Tom Jones', 'mgr', 50000)           # Make a Manager: __init__
    tom.giveRaise(.10)                                 # Runs custom version
    print(tom.lastName())                              # Runs inherited method
    print(tom)                                         # Runs inherited __repr__

To test our Manager subclass customization, we’ve also added self-test code that makes a Manager, calls its methods, and prints it. When we make a Manager, we pass in a name, and an optional job and pay as before—because Manager had no __init__ constructor, it inherits that in Person. Here’s the new version’s output:

[Person: Bob Smith, 0]
[Person: Sue Jones, 100000]
Smith Jones
[Person: Sue Jones, 110000]
Jones
[Person: Tom Jones, 60000]

Everything looks good here: bob and sue are as before, and when tom the Manager is given a 10% raise, he really gets 20% (his pay goes from $50K to $60K), because the customized giveRaise in Manager is run for him only. Also notice how printing tom as a whole at the end of the test code displays the nice format defined in Person’s __repr__: Manager objects get this, lastName, and the __init__ constructor method’s code “for free” from Person, by inheritance.

Polymorphism in Action

To make this acquisition of inherited behavior even more striking, we can add the following code at the end of our file temporarily:

if __name__ == '__main__':
    ...
    print('--All three--')
    for obj in (bob, sue, tom):               # Process objects generically
        obj.giveRaise(.10)                    # Run this object's giveRaise
        print(obj)                            # Run the common __repr__

Here’s the resulting output, with its new parts highlighted in bold:

[Person: Bob Smith, 0]
[Person: Sue Jones, 100000]
Smith Jones
[Person: Sue Jones, 110000]
Jones
[Person: Tom Jones, 60000]
--All three--
[Person: Bob Smith, 0]
[Person: Sue Jones, 121000]
[Person: Tom Jones, 72000]

In the added code, object is either a Person or a Manager, and Python runs the appropriate giveRaise automatically—our original version in Person for bob and sue, and our customized version in Manager for tom. Trace the method calls yourself to see how Python selects the right giveRaise method for each object.

This is just Python’s notion of polymorphism, which we met earlier in the book, at work again—what giveRaise does depends on what you do it to. Here, it’s made all the more obvious when it selects from code we’ve written ourselves in classes. The practical effect in this code is that sue gets another 10% but tom gets another 20%, because giveRaise is dispatched based upon the object’s type. As we’ve learned, polymorphism is at the heart of Python’s flexibility. Passing any of our three objects to a function that calls a giveRaise method, for example, would have the same effect: the appropriate version would be run automatically, depending on which type of object was passed.

On the other hand, printing runs the same __repr__ for all three objects, because it’s coded just once in Person. Manager both specializes and applies the code we originally wrote in Person. Although this example is small, it’s already leveraging OOP’s talent for code customization and reuse; with classes, this almost seems automatic at times.

Inherit, Customize, and Extend

In fact, classes can be even more flexible than our example implies. In general, classes can inherit, customize, or extend existing code in superclasses. For example, although we’re focused on customization here, we can also add unique methods to Manager that are not present in Person, if Managers require something completely different (Python namesake reference intended). The following snippet illustrates. Here, giveRaise redefines a superclass’s method to customize it, but someThingElse defines something new to extend:

class Person:
    def lastName(self): ...
    def giveRaise(self): ...
    def __repr__(self): ...

class Manager(Person):                       # Inherit
    def giveRaise(self, ...): ...            # Customize
    def someThingElse(self, ...): ...        # Extend

tom = Manager()
tom.lastName()             # Inherited verbatim
tom.giveRaise()            # Customized version
tom.someThingElse()        # Extension here
print(tom)                 # Inherited overload method

Extra methods like this code’s someThingElse extend the existing software and are available on Manager objects only, not on Persons. For the purposes of this tutorial, however, we’ll limit our scope to customizing some of Person’s behavior by redefining it, not adding to it.

OOP: The Big Idea

As is, our code may be small, but it’s fairly functional. And really, it already illustrates the main point behind OOP in general: in OOP, we program by customizing what has already been done, rather than copying or changing existing code. This isn’t always an obvious win to newcomers at first glance, especially given the extra coding requirements of classes. But overall, the programming style implied by classes can cut development time radically compared to other approaches.

For instance, in our example we could theoretically have implemented a custom giveRaise operation without subclassing, but none of the other options yield code as optimal as ours:

  • Although we could have simply coded Manager from scratch as new, independent code, we would have had to reimplement all the behaviors in Person that are the same for Managers.

  • Although we could have simply changed the existing Person class in place for the requirements of Manager’s giveRaise, doing so would probably break the places where we still need the original Person behavior.

  • Although we could have simply copied the Person class in its entirety, renamed the copy to Manager, and changed its giveRaise, doing so would introduce code redundancy that would double our work in the future—changes made to Person in the future would not be picked up automatically, but would have to be manually propagated to Manager’s code. As usual, the cut-and-paste approach may seem quick now, but it doubles your work in the future.

The customizable hierarchies we can build with classes provide a much better solution for software that will evolve over time. No other tools in Python support this development mode. Because we can tailor and extend our prior work by coding new subclasses, we can leverage what we’ve already done, rather than starting from scratch each time, breaking what already works, or introducing multiple copies of code that may all have to be updated in the future. When done right, OOP is a powerful programmer’s ally.



[55] And no offense to any managers in the audience, of course. I once taught a Python class in New Jersey, and nobody laughed at this joke, among others. The organizers later told me it was a group of managers evaluating Python.