Introduction
A class is a data structure than can contain both variables and functions within a single entity. In Python, such classes are first class objects - and by extension, once we create an instance of a class we say that we have created an instance object. Beyond this basic practical standpoint, object-oriented programming (OOP) embodies three defining principles: (1) encapsulation, (2) inheritance, and (3) polymorphism - and by utilising these design methods we can vastly reduce the amount of code duplication in our programmes, as well creating code that is conceptually easier to understand.
Without further qualification, here is the example code I’ll be discussing in this article:
class Person:
# A constructor taking 3 arguments (since 'self' is obligatory).
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 givePayRaise(self, percent):
self.pay = int(self.pay * (1+percent))
def __repr__(self):
return '[Person: %s, %s]' % (self.name, self.pay)
class Manager(Person):
# Inherit then redefine - to customize:
def __init__(self, name, pay):
Person.__init__(self, name, 'mgr', pay)
def givePayRaise(self, percent, bonus=0.1):
Person.givePayRaise(self, percent + bonus)
if __name__ == '__main__':
jack = Person('Jack Nicholson')
tony = Person('Tony Stark', job='dev', pay=100000)
print(jack.name, jack.pay)
print(tony.name, tony.pay)
print(jack.lastName(), tony.lastName())
tony.givePayRaise(0.1)
print(tony)
carol = Manager('Carol Danvers', 50000)
carol.givePayRaise(0.1)
print(carol.lastName())
print(carol)
Imagine this is saved in a file named person.py
Code Review
What we call instances and classes in Python programming can often serve the same roles as records and programs in more traditional database management system terms. For example, imagine the following two classes:
Person
- a class object that creates and processes information about people.Manager
- a customization ofPerson
(that is, a subclass) that modifies inherited behaviour.
But let’s not conclude that classes and OOP in Python is an especially different syntax from the rest of Python - or requires knowledge and application of, as noted, database management systems. If you already know how to use Python’s simple core types, you already know much of the content classes can contain. It is simply that classes add an extra layer of structure.
One reason we may choose to work with classes is if we aim to keep all related functions, classes, and code in the same module (Python file) so that it always makes sense to us simply because it is kept together … in a single file, with a single cohesive purpose. After all, as it is stated in the Python syntax, we can create any number of functions and classes within a single module file - so there is nothing stopping us from doing so.
In keeping with convention we save our example code above in a file (module) named person.py
, a name starting with a lowercase letter, and the classes inside named starting with uppercase letters (Person
and Manager
). Of course, you are not required to follow this convention by the language itself, but it’s so common that deviating might be confusing to someone who later reads your code.
Class Constructors
The __init__
magic method
Look back at the first 6 lines of our code example:
class Person:
# A constructor taking 3 arguments (since 'self' is obligatory).
def __init__(self, name, job=None, pay=0):
self.name = name
self.job = job
self.pay = pay
First we want to record basic information about people, that is to fill out record fields that relate to basic information about people instance objects that will be created later. These are known as instance object attributes and generally they are created by assignment to self
attributes in a class’s method functions.
Hence, the usual way to give instance attributes their first values is to assign them to self
in the __init__
constructor method, which is the magic method that contains code run automatically by Python each time an instance is created. Think of self
then as the new instance object.
Lines 3 through 6 is a very common coding pattern: we pass in the data to be attached to an instance as arguments to the constructor method and assign them to self
to retain them permanently. In OOP terms, self
is the newly created instance object, and name
, job
, and pay
become state information, that is descriptive data saved on an (instance) object for later use.
Notice that the argument names appear twice here: both in the signature of __init__
and in it’s function body:
- The
job
argument is a local variable in the scope of the__init__
function, butself.job
is an attribute of the instance that is the implied subject of the method call. - They are two different variables, which happen to have the same name … thus by assigning the
job
local to theself.job
attribute withself.job=job
, we save the passed-injob
on the instance object for later use.
Such a pattern reflect a typical Python idiom - that the position, in the script where a name is assigned, or what object it is assigned to, determines what that name means.
A closer look at __init__
The __init__
magic method is called automatically when an instance is made. Notice that it has a special (and obligatory) first argument, which is filled in by the Python interpreter automatically to refer to the instance object at hand. Therein as we assign values to attributes of self
we are also attaching them to the new instance object.
Though it is more of a technicality, but strictly speaking an instance object can also be refered to as a namespace object - that is each instance object has it’s own independent copy of the state information created by the class.
Beyond the above statements, __init__
behaves just like a normal function, thereby supporting all the syntactical features of any other Python function.
- For example we could, if we wish, provide defaults for some of its arguments, so that those argurments would not need to be provided in cases where their values were not immediately available or useful.
- Sure enough notice on line 3 that we make the
job
argument optional, it will default toNone
if no specific job is provided. And so on that basis it makes sense to havepay
default to0
, for consistency.
Class Methods
Context
If you think about it, classes are, in some sense, just an extra layer of structure around Python’s built-in simple core types. Therefore, we can add the same functionality (via existing built-in syntax tools) inside a class object as we would have used outside of the class.
With this in mind we can add functionality to our classes by including our own functions (which, in this context we refer to as methods) that act upon the instance attributes to complete whatever task we require.
def lastName(self):
return self.name.split()[-1]
def givePayRaise(self, percent):
self.pay = int(self.pay * (1+percent))
Again, the above methods would work just as well outside of a class (in some other context) - and would perform the same operation on any provided (and suitable) arguments - but by moving them into the class object, we make them available to any instance object created using our Person
class object - immediately reducing code duplication and the effort needed to maintain our code base.
Methods are simply normal functions that are attached to classes and designed to process instances of those classes. The instance is the subject of the method call and is passed to the method’s self
argument automatically. The method will fetch the name of the implied subject each time (automatically).
Encapsulation
And so we come to the first principle of object-oriented programming: encapsulation.
To properly create the behaviour needed we have actually employed encapsulation; that is, wrapping the operation logic behind interfaces, such that each operation is coded only once in our program.
It will also mean that, as long as the interface to the user doesn’t change, you can update the operation logic as much as needed without breaking the code that uses it - because you would be updating in one location only, rather than hunting down all instances of the functionality.
In terms of invoking a class method, note the following code snippet. The instance object is the subject of the method call and is passed to the method’s self
argument automatically:
tony.givePayRaise(0.1)
print(jack.lastName())
And once again, as noted above, the method will fetch the name of the implied subject each time (automatically).
Operator Overloading
As far as Python’s implementation of OOP is concerned, operator overloading refers to using Python’s built-in magic methods to automatically intercept processes when they occur. Thus we can code (magic) methods inside a class that will automatically intercept and process built-in operations whenever they are invoked on the class’s instances. It will of course be your custom requirements that are coded and processed - such that the notion of overloading is rather analogous to replacing whatever the default operation would have been with your custom functionality.
Indeed, if you think about it, we’ve already overloaded the __init__
magic method (constructor). It is run automatically when an instance object is created (we don’t invoke it by it’s name) and the overloading (specialization) is provided by the additional parameters we provide.
Refer back to lines 14 and 15 of our original code sample:
def __repr__(self):
return '[Person: %s, %s]' % (self.name, self.pay)
Here, the __repr__
(and __str__
) magic methods are run automatically every time an instance is converted to its print string. After all, this is what printing an object actually does - it displays whatever is returned by the object’s __repr__
or __str__
method. Only now, we can customize the functionality to return an output that is more reflective of our needs - again, invoked automatically whenever we print objects directly.
print(jack)
print(tony)
Inheritance: customizing classes with subclassing
Inherit to have attribute access
When we talk about inheritance, in a Python OOP context, think of it as customization by inheritance. Thus we come to lines 17 to 23 of our code sample above.
class Manager(Person):
# Inherit then redefine - to customize:
def __init__(self, name, pay):
Person.__init__(self, name, 'mgr', pay)
def givePayRaise(self, percent, bonus=0.1):
Person.givePayRaise(self, percent + bonus)
Of course, since instance objects already inherit methods from their classes - and instance attributes allow customization of instance objects - then we have in some sense already touched upon inheritance.
However, true OOP inheritance involves a hierarchy of classes - that is, superclasses and subclasses - whose relationships allows us to extend and customize class behaviour without adding more code to existing classes (and therefore reducing bloat). And so in our case, we’ll represent inheritance customization by creating a new class object, named Manager
.
From our class of Person
we define a subclass called Manager
. It’s defined in the same person.py
module, but inherits from Person
class object, which we now refer to as the superclass of Manager
. Then in this new Manager
subclass we’ll customize (that is replace) the inherited givePayRaise
method with a more specialized version - whose customization is appropriate for the particular needs of Manager
instance objects.
Note the signature of the Manager
class object, and thus the way in which it inherits Person
and has direct access to Person
attributes (name
, job
, and pay
) and methods.
Inherit to extend functionality
Once we inherit attributes and functionality from Person
we can then extend; that is take the attributes and/or methods from Person
and customize them. Thus when we inherit the givePayRaise
method from Person
we specialize it’s functionality by giving the instance objects an additional bonus of, say 10%, whenever the givePayRaise
method is invoked.
The key idea here is that of augmentation: we augment the original givePayRaise
method (the version within Person
). That way we aren’t copying and duplicating existing code and thus if there are any changes to be made to how raises are given to all staff (including managers), for example at a higher corporate level, we need only implement those changes once, in Person.givePayRaise
and all types of employee (including managers) are updated. This is clearly much more efficient from a programmers point of view than having to chase down all occurrences of the givePayRaise
method.
Finally then, when it comes to calling and using the givePayRaise
method we have two choices:
- Either call the method through an instance object:
instance.method(args, ...)
on the assumption you createdinstance
as aManager
instance object. - Or, through the class object, by passing the instance manually:
class.method(instance, args, ...)
where we provide theself
argument manually (as the first attribute).
Inherit to extend existing code
Note how, within the Manager
class, when we inherited methods and attributes from Person
we could just as well have created an entirely new method (and/or attributes) rather than just augmenting givePayRaise
. Such new methods could, if we wished, have been entirely unique to Manager
and thus not present in Person
at all.
In this way we can utilize inheritance to extend the functionality of a superclass, given that we would then both be inheriting and extending the functionality of that superclass.
Inheriting magic methods
The above examples of inheritance (and therefore customization) can be applied to Python’s built-in magic methods as well. For example in the code sample above notice how strange and unneccesary it is to have to provide the job
attribute to the Person
class whenever we create a Manager
instance. The job
attribute should be something set for us automatically, specifically in the case when we create a Manager
instance object, given that the value of job
e.g. mgr
is pretty strongly implied by the fact you’re creating a Manager
!
So, in lines 19 and 20, we follow the same inheritance pattern as before: create an __init__
magic method (constructor) for Manager
and inside there call the __init__
constructor of the Person
class - but now with the attribute job
set automatically for us.
class Manager(Person):
# Inherit then redefine - to customize:
def __init__(self, name, pay):
Person.__init__(self, name, 'mgr', pay)
def givePayRaise(self, percent, bonus=0.1):
Person.givePayRaise(self, percent + bonus)
Again, we’re using the same technique to augment the __init__
constructor here that we used for givePayRaise
method earlier: run the superclass version by calling through the class name directly and passing the self
instance along explicitly.
Calling superclass constructors from redefinitions this way turns out to be a very common coding pattern in Python. By itself, Python uses inheritance to look for and call only one __init__
method at construction time. That method being the lowest one in the class hierarchy tree. If you need higher __init__
methods to be run at construction time (just like in our Manager
example), you must call them manually, and usually through the superclass’s name (again, as we’ve done here: Person.__init__(...)
).
The upside to this is that you can be explicit about which argument to pass up to the superclass’s constructor and can choose to not call it at all: not calling the superclass constructor allows you to replace its logic altogether, rather than augmenting it.
Polymorphism
The customization by inheritance of the __init__
magic method (constructor), and our custom givePayRaise
method, are examples of polymorphism. Thus, whenever we pass any instance object (whether Person
or Manager
) to a function that calls givePayRaise
the appropriate version of givePayRaise
would be run by Python automatically. Python will recognize the instance object and run either Person.givePayRaise
or Manager.givePayRaise
as appropriate.
Testing classes
It’s particularly prudent to put our test code at the bottom of the same module file as the class (code) that we are testing. Thus we turn to lines 25 to 38 of our code sample above:
if __name__ == '__main__':
jack = Person('Jack Nicholson')
tony = Person('Tony Stark', job='dev', pay=100000)
print(jack.name, jack.pay)
print(tony.name, tony.pay)
print(jack.lastName(), tony.lastName())
tony.givePayRaise(0.1)
print(tony)
carol = Manager('Carol Danvers', 50000)
carol.givePayRaise(0.1)
print(carol.lastName())
print(carol)
Thus if the main purpose of a module file (.py
file) you’ve created is to house a class object then put tests of that class object at the bottom of the same module. This way, you carry the tests around with you so that in future there always available - and never split up from the code that they relate to.
Although we could split the test code off into a separate file, it’s often more convenient to code tests in the same file as the items to be tested. Of course, it would be better to arrange to run the test statements at the bottom only when the file is run for testing, not when the file is imported. And that is exactly what the module __name__
check is designed for. With this in place we’ll now get exactly the behaviour we need:
- Running the file as a top-level script will run the test scripts - because its
__name__
is__main__
. - But importing it as a library of classes later on will not run the testing scripts. And this includes whether you import with
import person
or withfrom person import Person
. In both cases just the class object will be available.
Conclusions
Python classes allow us to include the important concepts of OOP:
- Instance creation—filling out instance attributes.
- Behavior methods—encapsulating logic in a class’s methods.
- Operator overloading—providing behavior for built-in operations like printing.
- Customizing behavior—redefining methods in subclasses to specialize them.
- Customizing constructors—adding initialization logic to superclass steps.
Most of these concepts are based upon just three simple ideas:
- The inheritance search for attributes in object trees.
- The special self argument in methods.
- Operator overloading’s automatic dispatch to methods.
Along the way, we’ve also made our code easy to change in the future, by harnessing the class’s propensity for factoring code to reduce redundancy. For example, we wrapped up logic in methods and called back to superclass methods from extensions to avoid having multiple copies of the same code. Most of these steps were a natural outgrowth of the structuring power of classes.
By and large, that’s all there is to OOP in Python. Classes certainly can become larger than this, and there are some more advanced class concepts, such as decorators and metaclasses. But in terms of the basics our code sample at the beginning of the article already demonstrates the bulk of Python OOP functionality.