7
By the end of this chapter, you will be able to:
This lesson introduces object-oriented programming as implemented in Python. We also cover classes and methods, as well as overriding methods and inheritance.
A programming paradigm is a style of reasoning about programming problems. Problems, in general, can often be solved in multiple ways; for example, to calculate the sum of 2 and 3, you can use a calculator, you can use your fingers, you can use a tally mark, and so on. Similarly, in programming, you can solve problems in different ways.
At the beginning of this book, we mentioned that Python is multi-paradigm, as it supports solving problems in a functional, imperative, procedural, and object-oriented way. In this chapter, we will be diving into object-oriented programming in Python.
Object-oriented Programming (OOP) is a programming paradigm based on the concept of objects. Objects can be thought of as capsules of properties and procedures/methods. In an interview with Rolling Stone magazine, Steve Jobs, co-founder of Apple, once explained OOP in the following way:
"Objects are like people. They're living, breathing things that have knowledge inside them about how to do things and have memory inside them so that they can remember things. And rather than interacting with them at a very low level, you interact with them at a very high level of abstraction…"
Steve Jobs; Rolling Stone; June 16, 1994
An example of an object you can consider is a car. A car has multiple different attributes. It has a number of doors, a color, and a transmission type (for example, manual or automatic). A car, regardless of the type, also has specific behaviors: it can start, accelerate, decelerate, and change gears. Regardless of how these behaviors are implemented, the only thing we, the users of the car, care about, is that the aforementioned behaviors, such as acceleration, actually work.
In OOP, reasoning about data as objects allows us to abstract the actual code and think more about the attributes of the data and the operations around the data. OOP offers the following advantages:
With the benefits it confers, OOP is a powerful tool in a programmer's tool box.
In the next section, we'll be looking at how OOP is used in Python.
Classes are a fundamental building block of object-oriented programming. They can be likened to blueprints for an object, as they define what properties and methods/behaviors an object should have.
For example, when building a house, you'd follow a blueprint that tells you things such as how many rooms the house has, where the rooms are positioned relative to one another, or how the plumbing and electrical circuitry is laid out. In OOP, this building blueprint would be the class, while the house would be the instance/object.
In the earlier lessons, we mentioned that everything in Python is an object. Every data type and data structure you've encountered thus far, from lists and strings to integers, functions, and others, are objects. This is why when we run the type function on any object, it will have the following output:
>>> type([1, 2, 3])
<class 'list'>
>>> type("foobar")
<class 'str'>
>>> type({"a": 1, "b": 2})
<class 'dict'>
>>> def func(): return True
...
>>> type(func)
<class 'function'>
>>>
You'll note that calling the type function on each object prints out that it is an instance of a specific class. Lists are instances of the list class, strings are instances of the str class, dictionaries are instances of the dict class, and so on and so forth.
Each class is a blueprint that defines what behaviors and attributes objects will contain and how they'll behave; for example, all of the lists that you create will have the lists.append() method, which allows you to add elements to the list.
Here, we are creating an instance of the list class and printing out the append and remove methods. It tells us that they are methods of the list object we've instantiated:
>>> l = [1, 2, 3, 4, 5] # create a list object
>>> print(l.append)
<built-in method append of list object at 0x10dd36a08>
>>> print(l.remove)
<built-in method remove of list object at 0x10dd36a08>
>>>
In this chapter, we will use the terms instance and object synonymously.
In our example, we'll be creating the blueprint for a person. Compared to most languages, the syntax for defining a simple class is very minimal in Python.
In this exercise, we will create our first class, called Person. The steps are as follows:
>>> class Person:
... pass
...
>>>
>>> type(Person)
<class 'type'>
>>>
This is a bit confusing, but what this means is just as there are data structures of type list or dict, we've also, in a sense, extended the Python language to include a new kind of data structure called Person. In Python, a class and a type are synonymous. This Person structure can encapsulate different attributes and methods that will be specific to that object. We'll look at this in more depth further down the line.
Having a blueprint for building something is a great first step. However, blueprints aren't very useful if you can't build what they describe. Instantiating an object of a class is the act of building what the blueprint/class describes.
From the Person class we've defined, we'll instantiate a Person object. The steps are as follows:
>>> jack = Person()
>>> jill = Person()
>>> jack is jill
False
You will find that they are. This is because whenever we instantiate an object, it creates a brand-new object.
>>> jack2 = jack
>>> jack2 is jack
True
Assigning another variable to jack simply points it to whatever object jack is pointing to, and so they are the same object and thus identical.
An attribute is a specific characteristic of an object.
In Python, you can add attributes dynamically to an already instantiated object by writing the name of the object followed by a dot (.) and the name of the attribute you want to add, and assigning it to a value:
>>> person1 = Person()
>>> person1.name = "Gol D. Roger"
However, setting attributes in this manner is a bad practice, since it leads to hard-to-read code that's hard to debug. We'll see the appropriate way of setting attributes in the next section.
You can get the value of an attribute by using a similar syntax:
>>> person1.name
'Gol D. Roger'
>>>
Every object in Python comes with built-in attributes, such as __dict__, which is a dictionary that holds all of the attributes of the object:
>>> person1 = Person()
>>> person1.__dict__
{}
>>> person1.name = "Gol D. Roger"
>>> person1.age = 53
>>> person1.height_in_cm = 180
>>> person.__dict__
{'age': 53, 'height_in_cm': 180, 'name': 'Gol D. Roger'}
>>> print(person1.name, person1.age, person1.height_in_cm)
Gol D. Roger 53 180
>>>
The appropriate way to add attributes to an object is by defining them in the object's constructor method. A constructor method resides in the class and is called to create an object. It often takes arguments that are used in setting attributes of that instantiated object.
In Python, the constructor method for an object is named __init__(). As its name suggests, it is called when initializing an object of a class. Because of this, you can use it to pass the initial attributes you want your object to be constructed with.
The hasattr() function checks whether an object has a specific attribute or method. When we call the hasattr() function on the Person class to check whether it has an __init__ method, it returns True. This applies for all instances of the class, too:
>>> hasattr(Person, '__init__')
True
>>> person1 = Person()
>>> hasattr(person1, '__init__')
True
>>>
This method is here because it is inherited. We'll be taking a closer look at what inheritance is in later in this chapter.
We can define this constructor method in our class just like a function and specify attributes that will need to be passed in when instantiating an object:
>>> class Person:
... def __init__(self, name):
... self.name = name
...
>>>
Earlier on, we likened classes to blueprints for objects. In the preceding example, we're adding more details to that blueprint, and stating that every Person object that will be created should have a name attribute.
We have the arguments self and name in the __init__ method signature. The name argument refers to the person's name, while self refers to the object we're currently in the process of creating.
Remember, the __init__ method is called when instantiating objects of the Person class, and since every person has a different name, we need to be able to assign different values to different instances. Therefore, we attach our current object's name attribute in the line self.name = name.
Let's test this out.
In this exercise, we will add attributes to our Person class. The steps are as follows:
>>> person1 = Person()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: __init__() missing 1 required positional argument: 'name'
>>>
Python throws us an error since now we need to pass in a name argument when instantiating a Person object. This argument is passed to the __init__ method when the object is being instantiated.
>>> person1 = Person("Bon Clay")
>>> person1.name
'Bon Clay'
>>>
>>> class Person:
... def __init__(self, name, age, height_in_cm):
... self.name = name
... self.age = age
... self.height_in_cm = height_in_cm
...
>>>
>>> person1 = Person("Cubert", 62, 180)
>>> print(person1.name, person1.age, person1.height_in_cm)
Cubert 62 180
>>>
Suppose you are a backend developer for a tech news platform. You have been asked to design a templating system for their news articles. To do this, you will need to run some proof of concepts.
Define the MobilePhone class in a file named mobile_phone1.py so that the following code runs without error:
# Class definition goes here
pearphone = MobilePhone(5.5, "3GB", "yOS 11.2")
simsun = MobilePhone(5.4, "4GB", "Cyborg 8.1")
print(f"The new Pear phone has a {pearphone.display_size}"
f" inch display. {pearphone.ram} of RAM and runs on "
f"the latest version of {pearphone.os}. Its biggest competitor is "
f"the Simsun phone which sports a similar AMOLED {simsun.display_size} "
f"inch display, {simsun.ram} of RAM and runs {simsun.os}."
)
After defining the class, running the preceding code should yield the following output:
Solution for this activity can be found at page 288.
In this topic, we will look at class methods in detail.
So far, we've seen how to add attributes to an object. As we mentioned earlier, objects are also comprised of behaviors known as methods. Now we will take a look at how to add our own methods to classes.
We'll rewrite our original Person class to include a speak() method. The steps are as follows:
class Person:
def __init__(self, name, age, height_in_cm):
self.name = name
self.age = age
self.height_in_cm = height_in_cm
def speak(self):
print("Hello!")
The syntax for defining an instance method is familiar. We pass the argument self which, as in the __init__ method, refers to the current object at hand. Passing self will allow us to get or set the object's attributes inside our function. It is always the first argument of an instance method.
>>> adam = Person("Adam", 47, 193)
>>> adam.speak()
Hello!
>>>
class Person:
def __init__(self, name, age, height_in_cm):
self.name = name
self.age = age
self.height_in_cm = height_in_cm
def speak(self):
print(f"Hello! My name is {self.name}. I am {self.age} years old.")
>>> adam = Person("adam", 47, 193)
>>> lovelace = Person("Lovelace", 24, 178)
>>> lucre = Person("Lucre", 13, 154)
>>> adam.speak()
Hello! My name is Adam. I am 47 years old.
>>> lovelace.speak()
Hello! My name is Lovelace. I am 24 years old.
>>> lucre.speak()
Hello! My name is Lucre. I am 13 years old.
>>>
As you can see, the output is dependent on the object we're calling the method on.
Just as with normal functions, you can pass arguments to methods in a class. Let's now learn how to pass arguments to instance methods.
In this exercise, we'll create a new method called greet() that takes in an argument, person, which is a Person object.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def speak(self):
print(f"Hello! My name is {self.name}. I am {self.age} years old.")
def greet(self, person):
print(f"Hi {person.name}")
We do not have to specify the method return type as you would in statically typed languages such as Java.
>>> joe = Person("Josef", 31)
>>> gabby = Person("Gabriela", 32)
>>> joe.greet(gabby)
Hi Gabriela
>>>
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def speak(self):
print(f"Hello! My name is {self.name}. I am {self.age} years old.")
def greet(self, person):
if person.name == "Rogers":
print("Hey neighbour!")
else:
print(f"Hi {person.name}")
>>> joe = Person("Josef", 31)
>>> john = Person("John", 5)
>>> rogers = Person("Rogers", 46)
>>> john.greet(rogers)
Hey neighbour!
>>> john.greet(joe)
Hi Josef
We'll create a birthday() method that increments the person's age.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def speak(self):
print(f"Hello! My name is {self.name}. I am {self.age} years old.")
def birthday(self):
self.age += 1
>>> diana = Person("Diana", 28)
>>> diana.age
28
>>> diana.birthday()
>>> diana.age
29
>>>
Congratulations, you can now define and use classes. You can add methods and attributes to them, as well as instantiate objects and use them. While there's more to learn, you have the necessary tools to build basic object-oriented programs.
You are part of a team building a program to help children learn math. Currently, you're building a module on shapes, more specifically, calculating the circumference and area of circles.
The formula for calculating the circumference of a circle is 2*π*r. The formula for calculating the area of a circle is π*r*r.
Write a Python class named Circle, constructed by a radius and two methods, which will calculate the circumference and the area of a circle. The script should ask for the user's input for the radius, create a Circle object, and print out its area and circumference. It should ask for input again after it prints the area and circumference each time. Our aim here is to practice defining methods in a class.
The steps are as follows:
Solution for this activity can be found at page 288.
In the previous section, we had an introduction to classes and attributes. The attributes we've seen defined up until this point are instance attributes. This means that they are bound to a specific instance. Initializing an object with specific attributes applies/binds those attributes to only that object, but not to any other object initialized from that class.
In this exercise, we'll declare a WebBrowser class that has the attributes for history, the current page, and a flag that shows whether it's incognito or not. It can be initialized with a page.
The attributes that we will declare inside the constructor will be added as instance attributes The binding of the attributes to the instance happens in the __init__ method, where we add attributes to self.
class WebBrowser:
def __init__(self, page):
self.history = [page]
self.current_page = page
self.is_incognito = False
>>> firefox = WebBrowser("google.com")
>>> chrome = WebBrowser("facebook.com")
>>>
>>> firefox.current_page
'google.com'
>>> chrome.current_page
'facebook.com'
>>>
We can also define attributes at the class level. Class attributes are bound to the class itself and are shared by all instances as opposed to being bound to each instance.
In this exercise, we'll add a class attribute to our WebBrowser class. The syntax for this is just like defining a variable. You simply define it in the class body. The steps are as follows:
class WebBrowser:
connected = True
def __init__(self, page):
self.history = [page]
self.current_page = page
self.is_incognito = False
>>> firefox = WebBrowser("google.com")
>>> iceweasel = WebBrowser("facebook.com")
>>> firefox.connected
True
>>> iceweasel.connected
True
>>>
>>> WebBrowser.connected
True
>>>
>>> iceweasel.__dict__
{'history': ['facebook.com'], 'current_page': 'facebook.com', 'is_incognito': False}
>>> firefox.__dict__
{'history': ['google.com'], 'current_page': 'google.com', 'is_incognito': False}
>>>
>>> WebBrowser.__dict__
mappingproxy({'__module__': '__main__', 'connected': True, '__init__': <function WebBrowser.__init__ at 0x10cc6ad08>, '__dict__': <attribute '__dict__' of 'WebBrowser' objects>, '__weakref__': <attribute '__weakref__' of 'WebBrowser' objects>, '__doc__': None})
>>>
Since instances retrieve the attribute from the class, when we change this class attribute through the class, it'll reflect on all existing instances.
>>> firefox.connected = False
>>>
>>> firefox.__dict__
{'history': ['google.com'], 'current_page': 'google.com', 'is_incognito': False, 'connected': False}
>>>
>>> WebBrowser.connected
True
>>>
Our aim here is to thoroughly understand class attributes. In this exercise, we're going to create a counter that will be incremented each time a new WebBrowser object is instantiated:
class WebBrowser:
number_of_web_browsers = 0
connected = True
def __init__(self, page):
self.history = [page]
self.current_page = page
self.is_incognito = False
class WebBrowser:
number_of_web_browsers = 0
connected = True
def __init__(self, page):
self.history = [page]
self.current_page = page
self.is_incognito = False
WebBrowser.number_of_web_browsers += 1
Let's test it out:
>>> WebBrowser.number_of_web_browsers
0
>>>
>>> opera = WebBrowser("opera.com")
>>> WebBrowser.number_of_web_browsers
1
>>>
>>> edge = WebBrowser("microsoft.com")
>>> WebBrowser.number_of_web_browsers
2
>>>
Besides the use cases we've seen, class attributes should be used when you have variables that are common to all instances, such as constants for the class.
Suppose you are designing a piece of software for an elevator company. A part of the software involves a safety mechanism to prevent the elevator from being used when it's filled past its capacity.
Define a class called Elevator, which will have a maximum occupancy of 8. The elevator should be initialized with the number of occupants. If the number of occupants exceeds the limit during initialization, it should print out a message indicating that the limit has been exceeded and only initialize how many occupants should step off the elevator.
The steps are as follows:
elevator1 = Elevator(6)
print("Elevator 1 occupants:", elevator1.occupants)
elevator2 = Elevator(10)
print("Elevator 2 occupants:", elevator2.occupants)
We can then test out our script by running python elevator.py in the terminal. The output should look like this:
Solution for this activity can be found at page 289.
In this section, we will take a brief look at instance methods and cover class methods in detail.
In this exercise, we will implement the navigate() and clear_history() methods for the WebBrowser class we defined in the previous section:
class WebBrowser:
def __init__(self, page):
self.history = [page]
self.current_page = page
self.is_incognito = False
def navigate(self, new_page):
self.current_page = new_page
if not self.is_incognito:
self.history.append(new_page)
Any call to navigate will the set the browser's current page to the new_page argument and then add it to the history if we're not in incognito mode (incognito mode in browsers prevents browsing history from being recorded).
>>> vivaldi = WebBrowser("gocampaign.org")
>>> vivaldi.current_page
'gocampaign.org'
>>> vivaldi.navigate("reddit.com")
>>> vivaldi.current_page
'reddit.com'
>>> vivaldi.history
['gocampaign.org', 'reddit.com']
>>>
class WebBrowser:
def __init__(self, page):
self.history = [page]
self.current_page = page
self.is_incognito = False
def navigate(self, new_page):
self.current_page = new_page
if not self.is_incognito:
self.history.append(new_page)
def clear_history(self):
self.history[:-1] = []
The clear_history method removes everything from the history list up to the last element, which is our current page. This leaves only our current page on the list.
>>> chrome = WebBrowser("example.net")
>>> chrome.navigate("example2.net")
>>> chrome.navigate("example3.net")
>>> chrome.history
['example.net', 'example2.net', 'example3.net']
>>> chrome.current_page
'example3.net'
>>> chrome.clear_history()
>>> chrome.history
['example3.net']
>>>
We mentioned in the previous chapter that instance methods must receive self as the first argument. This is because self refers to the current instance in the context. Despite not passing it as an argument when calling instance methods, our method calls execute without any error. How does this work?
Python passes the self argument implicitly. In the preceding code snippet, when we call chrome.clear_history(), Python essentially passes chrome in as an argument to the method; therefore, we don't need to explicitly pass in a value for self.
Such a method, one that takes an instance (self) as the first parameter, is referred to as a bound method. They are bound to that specific instance when it is created. In a sense, it can be thought of as every instance of a class having its own copy of the method that was defined in the class. If we print out the instance method of any instance, we'll see the following output:
>>> chrome.navigate
<bound method WebBrowser.navigate of <__main__.WebBrowser object at 0x107a9a390>>
>>> opera = WebBrowser("foobar.com")
>>> opera.navigate
<bound method WebBrowser.navigate of <__main__.WebBrowser object at 0x107a9a400>>
>>>
The output for chrome.navigate tells us that it is a bound method of an object in the memory location 0x107a9a390. The output of opera.navigate tells us that it is a bound method of an object at a different object at memory location 0x107a9a400. This shows us that the two instance methods are tied/bound to different objects.
This brings us to class methods. Class methods differ from instance methods in that they are bound to the class itself and not the instance. As such, they don't have access to instance attributes. Additionally, they can be called through the class itself and don't require the creation of an instance of the class.
Regarding instance methods, we saw that the first parameter is always an instance; with class methods, the first parameter is always the class itself, as we'll see in our examples.
One common use case for class methods is when you're making factory methods. A factory method is one that returns objects. They can be used for returning objects of a different type or with different attributes. Let's add a class method called with_incognito() to our WebBrowser class that initializes a web browser object in incognito mode:
class WebBrowser:
def __init__(self, page):
self.history = [page]
self.current_page = page
self.is_incognito = False
def navigate(self, new_page):
self.current_page = new_page
if not self.is_incognito:
self.history.append(new_page)
def clear_history(self):
self.history[:-1] = []
@classmethod
def with_incognito(cls, page):
instance = cls(page)
instance.is_incognito = True
instance.history = []
return instance
Our function definition begins with a peculiar piece of syntax, @classmethod. We won't go into the details on it, but all we need to know right now is that it tells Python to add the function below it as a class method. On the next line, we declare our function, which takes two arguments, cls and page. The cls argument refers to our WebBrowser class in this context. All class methods must have the class as the first argument. The name can be cls, which is the convention, or anything else, whether it be class_ or foobar. All that matters is that the first argument of the class method is reserved.
We pass the page argument during the instantiation of our WebBrowser object. In the function's body, we instantiate an object which we assign the name instance. We then change the incognito value of that instance to True and clear the history list. Finally, we return the instance we've created.
In this exercise, we'll try out our factory method:
>>> WebBrowser.with_incognito
<bound method WebBrowser.with_incognito of <class '__main__.WebBrowser'>>
>>> chrome = WebBrowser.with_incognito("shady-website.com")
>>> chrome.is_incognito
True
>>> chrome.current_page
'shady-website.com'
>>> chrome.history
[]
>>>
>>> opera = WebBrowser("foobar.com")
>>> netscape = opera.with_incognito("secret.net")
>>> netscape.current_page
'secret.net'
>>> netscape.is_incognito
True
>>>
You should only call class methods through an instance in situations where it won't raise any confusion as to what kind of method it is you're calling (instance or class method).
Class methods also have access to class attributes. They can get and set class attributes. Most browsers today have a geolocation API. We will simulate this functionality in our class.
In this exercise, we will create a geo_coordinates attribute in the WebBrowser class that holds the current latitude and longitude. We will also add a class method called change_geo_coordinates() that will change the coordinates when called:
class WebBrowser:
geo_coordinates = {"lat": -4.764813, "lng": 16.131331 }
def __init__(self, page):
self.history = [page]
self.current_page = page
self.is_incognito = False
def navigate(self, new_page):
self.current_page = new_page
if not self.is_incognito:
self.history.append(new_page)
def clear_history(self):
self.history[:-1] = []
@classmethod
def with_incognito(cls, page):
instance = cls(page)
instance.is_incognito = True
instance.history = []
return instance
@classmethod
def change_geo_coordinates(cls, new_coordinates):
if new_coordinates["lat"] > 90 or new_coordinates["lat"] < -90:
print("Invalid value for latitude. Should be within the"
" range from -90 to 90 degrees.")
return None
if new_coordinates["lng"] > 180 or new_coordinates["lng"] < -180:
print("Invalid value for longitude. Should be within the"
" range from -180 to 180 degrees.")
return None
cls.geo_coordinates = new_coordinates
Our class method, change_geo_coordinates, takes the new_coordinates parameter, which is a dictionary. It checks whether the latitude and longitude provided in the parameters are valid and then changes the class attribute geo_coordinates to reflect the new coordinates that have been provided. We can test this out.
>>> firefox = WebBrowser("www.org")
>>> firefox.geo_coordinates
{'lat': -4.764813, 'lng': 16.131331}
>>> WebBrowser.change_geo_coordinates({"lat": 31, "lng": 123})
>>> firefox.geo_coordinates
{'lat': 31, 'lng': 123}
>>> WebBrowser.change_geo_coordinates({"lat": 31, "lng": 190})
Invalid value for longitude. Should be within the range from -180 to 180 degrees.
>>> WebBrowser.change_geo_coordinates({"lat": -100, "lng": 123})
Invalid value for latitude. Should be within the range from -90 to 90 degrees.
>>>
One of the key concepts of OOP is encapsulation. Encapsulation is the bundling of data with the methods that operate on that data. It's used to hide the internal state of an object by bundling together and providing methods that can get and set the object state through an interface. This hiding of the internal state of an object is what we refer to as information hiding.
With our WebBrowser class, when we called the navigate method as users, all we cared about was that it changed the current page. The class was a bundle of data and logic that gave us access to a uniform browser interface. The same is true for a real web browser. As users, we simply type in the URL and hit the Enter key, and it takes us to the new page. We don't care to know that the browser had to make a request to the server, wait for the response, render the resulting markup, apply styling, and download accompanying media along with it. The browser acts as a simple interface that allows us to interact with the internet. The processes behind all the steps it takes are hidden away from the users.
We use information hiding to abstract away irrelevant details about the class from users to prevent them from changing them, which would affect the functionality of our class.
In Python, information hiding is accomplished by marking attributes as private or protected:
By default, all attributes in Python are public.
In most languages, these attribute access modifiers are denoted by the keyword private, public, or protected. Python, however, simply implements these in the attribute names themselves.
All Python attributes are public by default and need no special naming or declaration:
class Car:
def __init__(self):
self.speed = 300
self.color = "black"
For protected attributes, we prefix the attribute name with an underscore, _, to show that it's protected:
class Car:
def __init__(self):
self._speed = 300
self._color = "black"
Doing this doesn't change the class user's ability to change the attribute. It's simply a marker letting them know not to access or change the attribute from outside the class or its children. The interpreter enforces no actual restrictions to enforce this. You can still change protected attributes:
>>> car = Car()
>>> car._speed
300
>>> car._speed = 400
>>> car._speed
400
>>>
While it may seem that marking attribute names as protected is useless since it doesn't impose any restrictions, it is good practice to do it to let the users of the class know it's a protected attribute that is only meant to be used internally. It is up to them to follow convention and not assign or access protected attributes.
For private attributes, we prefix the attribute name with a double underscore __. This renders the attribute inaccessible from outside the class. The attribute can only be gotten and set from within the class:
class Car:
def __init__(self):
self.__speed = 300
self.__color = "black"
def change_speed(self, new_speed):
self.__speed = new_speed
def get_speed(self):
return self.__speed
If we try accessing any of these attributes from outside the class, we'll get an error:
>>> car = Car()
>>> car.__speed
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Car' object has no attribute '__speed'
>>>
To change the private attribute __speed, we need to use the defined setter method change_speed. Similarly, we can use the get_speed getter method to get the speed attribute from outside the class if need be:
>>> car.get_speed()
300
>>> car.change_speed(120)
>>> car.get_speed()
120
>>>
Suppose you work for an electronics company that has a new MusicPlayer device that it wants to release to the market. The software for this device needs to support over-the-air updates that enables users to listen to their favorite tunes painlessly.
Create a class that represents a portable music player, MusicPlayer. The MusicPlayer class should have a play method, which sets the first track from the list of tracks as currently playing. The list of tracks should be a private attribute. Additionally, it should have a firmware version attribute and an update firmware class method that updates the firmware version.
The steps are as follows:
player = MusicPlayer()
print("Tracks currently on device:", player.list_tracks())
MusicPlayer.update_firmware(2.0)
print("Updated player firmware version to", player.firmware_version)
player.play()
print("Currently playing", f"'{player.current_track}'")
We can run the script by running python musicplayer.py in the terminal. The output should look like this:
Solution for this activity can be found at page 290.
The following table compares instance attributes with class attributes:
The following table compares instance methods with class methods:
A key feature of object-oriented programming is inheritance. Inheritance is a mechanism that allows for a class's implementation to be derived from another class's implementation. This subclass/derived/child class inherits all of the attributes and methods of the superclass/base/parent class:
A practical real-world example of inheritance can be thought of with big cats. Cheetahs, leopards, tigers, and lions are all cats. They all share the same properties that are common to cats such as mass, lifespan, speed, and behaviors such as making vocalizations and hunting, among others. If we were to implement a Leopard, Cheetah, or Lion class, we would define one Cat class that has all of these properties and then derive the Leopard, Lion, and Cheetah classes from this Cat class since they all share these same properties. This would be inheritance.
We use inheritance because it confers the following benefits:
The Python syntax for inheritance is very minimal. You define the class as usual, but then you can pass in the base class as a parameter. As we'll see later on, you can pass multiple base classes for cases where you want multiple inheritance, like so:
class Subclass(Superclass):
pass
In this exercise, we'll define the Cat class from which we'll derive our other big cats. The class will have the methods vocalize and print_facts, and the attributes mass, lifespan, and speed.
The constructor method will take the arguments mass, lifespan, and speed from which it will add the attributes mass_in_kg, lifespan_in_years, and speed_in_kph to the object.
The vocalize method will print out Chuff, a non-threatening vocalization that's common to several big cats. The print_facts method will print out facts about the cat:
class Cat:
def __init__(self, mass, lifespan, speed):
self.mass_in_kg = mass
self.lifespan_in_years = lifespan
self.speed_in_kph = speed
def vocalize(self):
print("Chuff")
def print_facts(self):
print(f"The {type(self).__name__.lower()} "
f"weighs {self.mass_in_kg}kg,"
f" has a lifespan of {self.lifespan_in_years} years and "
f"can run at a maximum speed of {self.speed_in_kph}kph.")
The line type(self).__name__ means that we want the name of the current class of the object, in this case, Cat. We then call str.lower() on the name in our example.
>>> cat = Cat(4, 18, 48)
>>> cat.vocalize()
Chuff
>>> cat.print_facts()
The cat weighs 4kg, has a lifespan of 18 years and can run at a maximum speed of 48kph.
>>>
class Cat:
def __init__(self, mass, lifespan, speed):
self.mass_in_kg = mass
self.lifespan_in_years = lifespan
self.speed_in_kph = speed
def vocalize(self):
print("Chuff")
def print_facts(self):
print(f"The {type(self).__name__.lower()} "
f"weighs {self.mass_in_kg}kg,"
f" has a lifespan of {self.lifespan_in_years} years and "
f"can run at a maximum speed of {self.speed_in_kph}kph.")
class Cheetah(Cat):
pass
class Lion(Cat):
pass
class Leopard(Cat):
pass
Despite not adding any methods or attributes to these new classes, if we instantiate them, we'll need to pass in the same arguments that we do when instantiating the Cat class. The methods and attributes our instance will have will be identical to a Cat class instance:
>>> cheetah = Cheetah(72, 12, 120)
>>> lion = Lion(190, 14, 80)
>>> leopard = Leopard(90, 17, 58)
>>> cheetah.print_facts()
The cheetah weighs 72kg, has a lifespan of 12 years and can run at a maximum speed of 120kph.
>>> lion.print_facts()
The lion weighs 190kg, has a lifespan of 14 years and can run at a maximum speed of 80kph.
>>> leopard.print_facts()
The leopard weighs 90kg, has a lifespan of 17 years and can run at a maximum speed of 58kph.
>>>
As you can see, our subclasses have automatically inherited all of the attributes and methods of the Cat class. We have a slight issue on our hands, though.
>>> cheetah.vocalize()
Chuff
>>> lion.vocalize()
Chuff
>>> leopard.vocalize()
Chuff
>>>
In reality, cheetahs make a chirrup, bird-like sound, while lions and leopards roar. We can rectify this by overriding the method in our class. Overriding means redefining the implementation of a method defined in a superclass to add or change a subclass's functionality.
class Cat:
def __init__(self, mass, lifespan, speed):
self.mass_in_kg = mass
self.lifespan_in_years = lifespan
self.speed_in_kph = speed
def vocalize(self):
print("Chuff")
def print_facts(self):
print(f"The {type(self).__name__.lower()} "
f"weighs {self.mass_in_kg}kg,"
f" has a lifespan of {self.lifespan_in_years} years and "
f"can run at a maximum speed of {self.speed_in_kph}kph.")
class Cheetah(Cat):
def vocalize(self):
print("Chirrup")
class Lion(Cat):
def vocalize(self):
print("ROAR")
class Leopard(Cat):
def vocalize(self):
print("Roar")
>>> cheetah.vocalize()
Chirrup
>>> lion.vocalize()
ROAR
>>> leopard.vocalize()
Roar
>>>
We'll be taking a look at overriding in more depth in the next section.
In the previous topic, we overrode the vocalize() method of our Cat base class in our Cheetah, Lion, and Leopard subclasses. In this topic, we'll see how to override the __init__() method.
A lot of big cats have a pattern in their coat; they have spots or stripes. Let's add this to our Cheetah subclass.
In this exercise, we'll override the __init__ method and add a spotted_coat attribute:
class Cat:
def __init__(self, mass, lifespan, speed):
self.mass_in_kg = mass
self.lifespan_in_years = lifespan
self.speed_in_kph = speed
def vocalize(self):
print("Chuff")
def print_facts(self):
print(f"The {type(self).__name__.lower()} "
f"weighs {self.mass_in_kg}kg,"
f" has a lifespan of {self.lifespan_in_years} years and "
f"can run at a maximum speed of {self.speed_in_kph}kph.")
class Cheetah(Cat):
def __init__(self, mass, lifespan, speed):
self.spotted_coat = True
def vocalize(self):
print("Chirrup")
Unfortunately, this overwrites the previous implementation and replaces it with our new one; so, when we initialize the Cheetah subclass, it won't add the mass_in_kg, lifespan_in_years, and speed_in_kph attributes. It will only add the spotted_coat attribute to the instance.
>>> cheetah = Cheetah(72, 12, 120)
>>> cheetah.mass_in_kg
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Cheetah' object has no attribute 'mass_in_kg'
>>> cheetah.lifespan_in_years
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Cheetah' object has no attribute 'lifespan_in_years'
>>> cheetah.speed_in_kph
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Cheetah' object has no attribute 'speed_in_kph'
>>> cheetah.spotted_coat
True
>>>
What we can do is invoke the __init__ method of the Cat class inside the Cheetah subclass's __init__ method before adding the spotted_coat attribute. To do this, we can call Cat.__init__(self, mass, lifespan, speed), which calls the superclass's initializer with the required arguments.
class Cheetah(Cat):
def __init__(self, mass, lifespan, speed):
Cat.__init__(self, mass, lifespan, speed)
self.spotted_coat = True
def vocalize(self):
print("Chirrup")
However, doing this hardcodes the superclass name, and in case we need to change the name of the Cat class, we'd have to change it in multiple places. Python provides a cleaner way of doing this through the built-in super() method. We use super() to access inherited methods from a parent class that has been overwritten in the child class.
class Cheetah(Cat):
def __init__(self, mass, lifespan, speed):
super().__init__(mass, lifespan, speed)
self.spotted_coat = True
def vocalize(self):
print("Chirrup")
>>> cheetah = Cheetah(72,12,120)
>>> cheetah.print_facts()
The cheetah weighs 72kg, has a lifespan of 12 years and can run at a maximum speed of 120kph.
>>>
At the same time, our new implementation is also added:
>>> cheetah.spotted_coat
True
>>>
As you may have noticed, special methods in Python classes are always prefixed and suffixed with double underscores, for example, __init__. They are known as Dunder (double underscore) or magic methods.
Besides the __init__ method, there are other magic methods in Python that you can override to customize your class and add custom functionality, such as changing what the output of your printed object looks like or how your classes are compared.
We will only be going over the method that defines what is output when print is called on your object, __str__(), and the method that's called when an object is destroyed, __del__().
Special methods in Python classes are always prefixed and suffixed with double underscores. You can find the documentation for the rest of the special methods Python provides at https://docs.python.org/3/reference/datamodel.html.
The __str__() Method
Every object in Python has the __str__() method by default. It is called every time print() is called on an object in Python to retrieve the string containing the readable representation of the object.
Let's replace the print_facts() method of the Cat class with this method:
class Cat:
def __init__(self, mass, lifespan, speed):
self.mass_in_kg = mass
self.lifespan_in_years = lifespan
self.speed_in_kph = speed
def vocalize(self):
print("Chuff")
def __str__(self):
return f"The {type(self).__name__.lower()} "
f"weighs {self.mass_in_kg}kg,"
f" has a lifespan of {self.lifespan_in_years} years and "
f"can run at a maximum speed of {self.speed_in_kph}kph."
Now, when we call print() on any Cat instance or Cat subclass instance, it should have the same result as when we were calling print_facts():
>>> cheetah = Cheetah(72, 12, 120)
>>> print(cheetah)
The cheetah weighs 72kg, has a lifespan of 12 years and can run at a maximum speed of 120kph.
>>>
The __del__() Method
The __del__() method is the destructor method. The destructor method is called whenever an object gets destroyed:
class Cheetah(Cat):
def __init__(self, mass, lifespan, speed):
super().__init__(mass, lifespan, speed)
self.spotted_coat = True
def vocalize(self):
print("Chirrup")
def __del__(self):
print("No animals were harmed in the deletion of this instance")
If we call del on a Cheetah instance, it should print out that message:
>>> cheetah = Cheetah(72, 12, 120)
>>> del cheetah
No animals were harmed in the deletion of this instance
>>> cheetah
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'cheetah' is not defined
>>>
Suppose you work for a wildlife conservation organization. You are working on creating a system to educate the general public about different animals and get them more interested in conservation.
Create a Tiger class that inherits from the Cat class and has a new coat pattern attribute. Change the behavior of instances of the Tiger class to include this coat pattern fact when they're printed.
The steps are as follows:
The tiger weighs 310kg, has a lifespan of 26 years and can run at a maximum speed of 65kph. It also has a striped coat.
Multiple inheritance is a feature that allows you to inherit attributes and methods from more than one class. The most common use case for multiple inheritance is for mixins. Mixins are classes that have methods/attributes that are meant to be used by other functions. For example, a Logger class would have a log() method that writes to a logfile, and when added to your classes as a mixin, would give them that same capability.
The following is the syntax for multiple inheritance:
class Subclass(Superclass1, Superclass2):
pass
The subclass inherits all of the features of both superclasses.
In the real world, lions and tigers can naturally mate to create a hybrid known as a liger or a tigon. Ligers are much larger than either lions or tigers, they are social like lions, have stripes, and, just like tigers, they like swimming. We're going to create a Liger class that inherits from both the Lion and Tiger class we're going to define.
In this exercise, we will learn how to implement multiple inheritance:
class Cat:
def __init__(self, mass, lifespan, speed):
self.mass_in_kg = mass
self.lifespan_in_years = lifespan
self.speed_in_kph = speed
def vocalize(self):
print("Chuff")
def __str__(self):
return f"The {type(self).__name__.lower()} "
f"weighs {self.mass_in_kg}kg,"
f" has a lifespan of {self.lifespan_in_years} years and "
f"can run at a maximum speed of {self.speed_in_kph}kph."
class Lion(Cat):
def __init__(self, mass=190, lifespan=14, speed=80):
super().__init__(mass, lifespan, speed)
self.is_social = True
def vocalize(self):
print("ROAR")
class Tiger(Cat):
def __init__(self, mass=310, lifespan=26, speed=65):
super().__init__(mass, lifespan, speed)
self.coat_pattern = "striped"
def swim(self):
print("Splash splash")
def vocalize(self):
print("ROAR")
class Liger(Lion, Tiger):
pass
>>> liger = Liger()
>>> liger.swim()
Splash splash
>>> liger.is_social
True
>>> liger.coat_pattern
'striped'
>>>
It's the year 2000. You're working for a mobile phone company and have been tasked with modeling out the software for a cutting-edge phone that will have a built-in camera: a camera phone.
Create a class called Camera and a class called MobilePhone that will be the base classes of a derived class called CameraPhone. The CameraPhone class should be initialized with the memory attribute and should have a take_picture() method that prints out the message, Say cheese!.
The steps are as follows:
Say cheese!
200KB
Solution for this activity can be found at page 292.
In this chapter, we have begun our journey into OOP. OOP makes code more reusable; it makes it easier to design software; it makes code easier to test, debug, and maintain; and it adds a form of security to the data in an application. The behaviors of an object are known as methods, and you can add a method to a class by defining a function inside it. To be bound to your objects, this function needs to take in the argument self. We also covered class attributes and class methods in detail. We also took a look at encapsulation and the keywords that enable information hiding in Python. Information hiding is used to abstract away irrelevant details about the class from users. This chapter also covered inheritance in detail. We saw how to have a derived class inherit from a single base class, as well as multiple base classes. We also saw how to override methods: specifically, the __init__(), __str__(), and __del__() methods. This chapter completes our journey into object-oriented programming with Python.
In the next chapter, we will cover Python modules and packages in detail. We will also take a look at how to handle different types of files and related file operations.