class Dog:
"""A simple attempt to model a dog."""
def __init__(self, name, age):
"""Initialize name and age attributes."""
self.name = name
self.age = age
def sit(self):
"""Simulate a dog sitting in response to a command."""
print(f"{self.name} is now sitting.")
def roll_over(self):
"""Simulate rolling over in response to a command."""
print(f"{self.name} rolled over!")
8 Object Oriented Programming and Classes
8.1 Introduction
In the first lecture, we mentioned that everything in Python
is an object, so you’ve been using objects constantly. Object Oriented Programming (OOP) is a programming paradigm that allows you to group variables (data/attributes) and functions (methods) into new data types called classes, from which you can create objects (instance). When you write a class, you define the general behavior that a whole category of objects can have.
When you create individual objects from the class, each object is automatically equipped with the general behavior; you can then give each object whatever unique traits you desire. Making an object from a class is called instantiation, and you work with instances of a class. You’ve already used lots of classes created by other people (int
, str
, float
, list
, dict
, etc.); these are designed to represent simple pieces of information, such as the cost of an apple, the name of a student. What if you want to represent something more complex? In this chapter, you’ll learn how to create your custom classes.
Learning about OOP will help you see the world as a programmer does. Knowing the logic behind classes will train you to think logically, so you can write programs that effectively address almost any problem you encounter.
8.2 Creating and Using a Class
You can model almost anything using classes. Let’s start by writing a simple class, Dog
, that represents a dog — not one dog in particular, but any dog. What do we know about most pet dogs? Well, they all have a name
and an age
. We also know that most dogs sit
and roll over
. Those two pieces of information (name
and age
) and those two behaviors (sit
and roll over
) will go in our Dog
class because they’re common to most dogs.
8.2.1 Creating the Dog
Class
Each instance created from the Dog
class will store a name
and an age
, and we’ll give each dog the ability to sit()
and roll_over()
:
We first define a class called Dog
with the class
keyword. By convention, capitalized names refer to classes in Python. We then write a docstring describing what this class does.
There are no parentheses in the class definition here because we’re creating this class from scratch
8.2.1.1 The __init__()
Method
A function that’s part of a class is a method. The
__init__()
method is a special method thatPython
runs whenever we create a new instance based on theDog
class. This method has two leading underscores and two trailing underscores, a convention that helps preventPython
’s default method names from conflicting with your method names.The
self
parameter is required in the method definition, and it must come first before any other parameters. It must be included in the definition because whenPython
calls this method later (to create an instance ofDog
), the method call will automatically pass theself
argument. The two variables defined in the body of the__init__()
method each have the prefixself
. Any variable prefixed withself
(refer as instance attributes) is available to every method in the class, and we’ll also be able to access these variables through any instance created from the class, which can be different between instances.The line
self.name = name
takes the value associated with the parametername
and assigns it to the variablename
, which is then attached to the instance being created. The same process happens withself.age = age
. Variables that are accessible through instances like this are called (instance) attributes.
The Dog
class has two other methods defined: sit()
and roll_over()
. Because these methods don’t need additional information to run, we define them to have one parameter, self
, so that the instances we create later will have access to these methods. In other words, they’ll be able to sit and roll over.
8.2.1.2 Making an Instance from a Class
When we make an instance of Dog
, Python
will call the __init__()
method from the Dog
class. We’ll pass Dog()
a name
and an age
as arguments; self
is passed automatically, so we don’t need to pass it.
Here, we tell Python
to create a dog whose name is ‘Willie’ and whose age is 6, which is known as constructor expression. When Python
reads this line, it calls the __init__()
method in Dog
with the arguments ‘Willie’ and 6. The __init__()
method creates an instance representing this particular dog and sets the name
and age
attributes using the values we provided. Python
then returns an instance representing this dog. We assign that instance to the variable my_dog
. To access the attributes of an instance, you use dot notation. After we create an instance from the class Dog
, we can use dot notation to call any method defined in Dog
.
To call a method, give the name of the instance (in this case, my_dog
) and the method you want to call, separated by a dot agian.
The class constructor of
Python
is actually divided into two steps. Check out here for more information. There is also a destructor__del__
, but in Python, destructors are not needed as much as in C++ because Python has a garbage collector that handles memory management automatically.
8.2.1.3 Creating Multiple Instances
Once you create a class, you can use it to create different objects.
my_dog = Dog('Willie', 6)
your_dog = Dog('Lucy', 3)
# Even though my_dog and your_dog are both instances of the Dog class, they represent two distinct objects in memory.
print(my_dog == your_dog)
print(f"My dog's name is {my_dog.name}.")
print(f"My dog is {my_dog.age} years old.")
my_dog.sit()
print(f"\nYour dog's name is {your_dog.name}.")
print(f"Your dog is {your_dog.age} years old.")
your_dog.sit()
In this example, we create a dog named Willie and a dog named Lucy. Each dog is a separate instance with its own attributes, capable of the same set of actions.
8.3 Working with Classes and Instances
Once you write a class, you’ll spend most of your time working with instances created from that class. One of the first tasks you’ll want to do is modify the attributes associated with a particular instance. You can modify the attributes of an instance directly or write methods that update attributes in specific ways.
8.3.1 The Car
Class
Here, we create another class that Car
with four instance attributes:
class Car:
"""A simple attempt to represent a car."""
def __init__(self, make, model, year):
"""Initialize attributes to describe a car."""
self.make = make
self.model = model
self.year = year
self.odometer_reading = 0
def get_descriptive_name(self):
"""Return a neatly formatted descriptive name."""
long_name = f"Created in {self.year}, {self.make} {self.model}"
return long_name.title()
def read_odometer(self):
"""Print a statement showing the car's mileage."""
print(f"This car has {self.odometer_reading} miles on it.")
In the Car
class, we define the __init__()
method with the self
parameter first, just like we did with the Dog
class. We also give it other parameters: make
, model
, year
and odometer_reading
. The __init__()
method takes in these parameters and assigns them to the attributes associated with instances made from this class. When we make a new Car
instance, we’ll need to specify a make
, model
, and year
for our instance. We define a method called get_descriptive_name()
that puts a car’s year
, make
, and model
into one string neatly describing the car. To work with the attribute values in this method, we use self.make
, self.model
, and self.year
.
When an instance is created, attributes can be defined without being passed in as parameters. These attributes can be defined in the __init__()
method, which assigns a default value. In the above example, an attribute called odometer_reading
always starts with a value of 0. Finally, there is a method read_odometer()
that helps us read each car’s odometer.
Outside of the class, we make an instance from the Car
class and assign it to the variable my_new_car
. Then we call get_descriptive_name()
to show what kind of car we have! Our car starts with a mileage of 0:
Not many cars are sold with exactly 0 miles on the odometer, so we need a way to change the value of this attribute.
8.3.1.1 Modifying Attribute Values
You can change an attribute’s value in different ways: you can change the value directly through an instance, set the value through a method, or increment the value (add a certain amount to it) through a method. The simplest way to modify the value of an attribute is to access the attribute directly through an instance.
It can be helpful to have methods that update certain attributes for you. Instead of accessing the attribute directly, you pass the new value to a method that handles the updating internally.
class Car:
"""A simple attempt to represent a car."""
def __init__(self, make, model, year):
"""Initialize attributes to describe a car."""
self.make = make
self.model = model
self.year = year
self.odometer_reading = 0
def get_descriptive_name(self):
"""Return a neatly formatted descriptive name."""
long_name = f"Created in {self.year}, {self.make} {self.model}"
return long_name.title()
def read_odometer(self):
"""Print a statement showing the car's mileage."""
print(f"This car has {self.odometer_reading} miles on it.")
## We add these there methods!
def update_odometer(self, mileage):
"""
Set the odometer reading to the given value.
Reject the change if it attempts to roll the odometer back.
"""
if mileage >= self.odometer_reading:
self.odometer_reading = mileage
else:
print("You can't roll back an odometer!")
def increment_odometer(self, miles):
"""Add the given amount to the odometer reading."""
self.odometer_reading += miles
def fill_gas_tank(self):
"""Filling the gas tank."""
print("The gas tank is now full!")
The only modification to Car
is the addition of update_odometer()
. This method takes in a mileage value and assigns it to self.odometer_reading
. It also checks that the new reading makes sense before modifying the attribute. If the value provided for mileage is greater than or equal to the existing mileage, self.odometer_reading
, you can update the odometer reading to the new mileage. If the new mileage is less than the existing mileage, you’ll get a warning that you can’t roll back an odometer! In addition, we also define the new method increment_odometer()
takes in a number of miles and adds this value to self.odometer_reading
. Finally, a method fill_gas_tank()
is also added to the class.
You can use methods like this to control how users use your program by including additional logic.
8.3.1.2 __repr__
and __str__
method
Notice that when you evaluate the my_new_car
, it will return a message that returns the address of the object:
When writing your classes, it’s a good idea to have a method that returns a string containing useful information about a class instance. You can change this behavior by adding a special function __repr__
:
class Car:
"""A simple attempt to represent a car."""
def __init__(self, make, model, year):
"""Initialize attributes to describe a car."""
self.make = make
self.model = model
self.year = year
self.odometer_reading = 0
def get_descriptive_name(self):
"""Return a neatly formatted descriptive name."""
long_name = f"Created in {self.year} {self.make} {self.model}"
return long_name.title()
def read_odometer(self):
"""Print a statement showing the car's mileage."""
print(f"This car has {self.odometer_reading} miles on it.")
def update_odometer(self, mileage):
"""
Set the odometer reading to the given value.
Reject the change if it attempts to roll the odometer back.
"""
if mileage >= self.odometer_reading:
self.odometer_reading = mileage
else:
print("You can't roll back an odometer!")
def increment_odometer(self, miles):
"""Add the given amount to the odometer reading."""
self.odometer_reading += miles
def fill_gas_tank(self):
"""Filling the gas tank."""
print("The gas tank is now full!")
## We add these two methods!
def __repr__(self):
return f'Car(make={self.make}, model={self.model}, year={self.year})'
def __str__(self):
return self.get_descriptive_name()
The Python
documentation indicates that __repr__
returns the “official” string representation of the object. We also define the __str__
special method that is used to replace the behavior of __repr__
in some cases. This method is called when you convert an object to a string with the built-in function str()
, such as when you print an object or call str()
explicitly.
Special methods like
__init__()
,__str__()
and__repr__
are called dunder methods (Double UNDERscore). There are many dunder methods that you can use to customize classes i
8.3.2 Exercise 1: Create a
Pokemon
class with three instance attributes:name
, which stores the name of the Pokemon as a string,type
which stores the type of Pokemon (e.g., “Fire”, “Water”, “Grass”, etc.) as string andtotal_species
as an integer. In addition, add the__str__()
method to the class so that it can print out meaningful information as follows:
Pikachu (Electric, total species 320)
Complete the following class and execute the code cell to see which six Pokemon you get.
import random
import json
import time
import requests
def slow_print(text, delay=0.05):
for char in text:
print(char, end='', flush=True)
time.sleep(delay)
print()
## 1. Download the data
url = 'https://raw.githubusercontent.com/fanzeyi/pokemon.json/master/pokedex.json'
response = requests.get(url)
if response.status_code == 200:
with open('pokedex.json', 'w', encoding = "utf-8") as f:
f.write(response.text)
else:
print(f"Failed to download the file. Status code: {response.status_code}")
with open('Pokedex.json', 'r' , encoding = "utf-8") as file:
pokemon_data = json.load(file)
## 2. Randomly pick 6 pokemon
random.shuffle(pokemon_data)
picks = pokemon_data[:6]
## 3. Print the pokemon you got!
print("The pokemon you got are: ")
for i in range(6):
pokemon = Pokemon(picks[i]['name']['english'], picks[i]['type'][0], sum([picks[i]['base'][key] for key in picks[i]['base'] if key != "name"]))
slow_print(str(pokemon))
8.4 Inheritance
You don’t always have to start from scratch when writing a class. If the class you’re writing is a specialized version of another class you wrote, you can use inheritance which is called ” is a” releationship. When one class inherits from another, it takes on the attributes and methods of the first class. The original class is called the parent class, and the new class is the child class. The child class can inherit any or all of the attributes and methods of its parent class, but it’s also free to define new attributes and methods of its own.
8.4.1 The __init__()
method for a Child Class
When writing a new class based on an existing class, we will often want to call the __init__()
method from the parent class. This will initialize any attributes that were defined in the parent __init__()
method and make them available in the child class. As an example, let’s model an electric car. An electric car is just a specific kind of car, so we can base our new ElectricCar
class on the Car
class we wrote about earlier. Then we’ll only have to write code for the attributes and behaviors specific to electric cars.
class ElectricCar(Car):
"""Represent aspects of a car, specific to electric vehicles."""
def __init__(self, make, model, year):
"""
Initialize attributes of the parent class.
Then initialize attributes specific to an electric car.
"""
super().__init__(make, model, year) # Call the constructor of parent class
self.battery_size = 40
def describe_battery(self):
"""Print a statement describing the battery size."""
print(f"This car has a {self.battery_size}-kWh battery.")
When you create a child class, the parent class must be part of the current file and appear before the child class. We then define the child class,
ElectricCar
. The name of the parent class must be included in parentheses in the definition of a child class.The
__init__()
method takes in the information required to make aCar
instance. Thesuper()
function is a special function that allows you to call a method from the parent class. This line tells Python to call the__init__()
method fromCar
, which gives anElectricCar
instance all the attributes defined in that method. The namesuper
comes from a convention of calling the parent class a superclass (base class) and the child class a subclass (derived class).We also add a new attribute specific to electric cars (a
battery
) and a method to report on this attribute. We’ll store the battery size and write a method that prints a description of the battery. This attribute/method will be associated with all instances created from theElectricCar
class but won’t be associated with any instances ofCar
.
We make an instance of the ElectricCar
class and assign it to my_leaf
.
When we need to know the type of an object, we can pass the object to the built-in type()
function. But if we’re doing a type check of an object, it’s a better idea to use the more flexible isinstance()
built-in function. The isinstance()
function will return True
if the object is of the given class or a subclass of the given class.
8.4.1.1 Overriding Methods from the Parent Class
You can override any method from the parent class that doesn’t fit what you’re trying to model with the child class. To do this, you define a method in the child class with the same name as the method you want to override in the parent class. Say the class Car
had a method called fill_gas_tank()
. This method is meaningless for an all-electric vehicle, so you might want to override this method. Here’s one way to do that:
class ElectricCar(Car):
"""Represent aspects of a car, specific to electric vehicles."""
def __init__(self, make, model, year):
"""
Initialize attributes of the parent class.
Then initialize attributes specific to an electric car.
"""
super().__init__(make, model, year)
self.battery_size = 40
def describe_battery(self):
"""Print a statement describing the battery size."""
print(f"This car has a {self.battery_size}-kWh battery.")
## We overide the method here
def fill_gas_tank(self):
"""Electric cars don't have gas tanks."""
print("This car doesn't have a gas tank!")
Now if someone tries to call fill_gas_tank()
with an electric car, Python
will ignore the method fill_gas_tank()
in Car
and run this code instead.
When you use inheritance, you can make your child classes retain what you need and override anything you don’t need from the parent class.
8.4.1.2 Use composition
to organize the code
When modeling something from the real world in code, you may add more detail to a class. You’ll find that you have a growing list of attributes and methods and that your files are becoming lengthy. In these situations, you might recognize that part of one class can be written as a separate class. You can break your large class into smaller classes that work together; this approach is called composition, which is sometimes referred to as the “has a” releationship.
For example, if we continue adding detail to the ElectricCar
class, we might notice that we’re adding many attributes and methods specific to the car’s battery. When we see this happening, we can stop and move those attributes and methods to a separate class called Battery
. Then we can use a Battery
instance as an attribute in the ElectricCar
class:
class Battery:
"""A simple attempt to model a battery for an electric car."""
def __init__(self, battery_size=40):
"""Initialize the battery's attributes."""
self.battery_size = battery_size
def describe_battery(self):
"""Print a statement describing the battery size."""
print(f"This car has a {self.battery_size}-kWh battery.")
def get_range(self):
"""Print a statement about the range this battery provides."""
if self.battery_size == 40:
range = 150 # Class attributes
elif self.battery_size == 65:
range = 225
print(f"This car can go about {range} miles on a full charge.")
class ElectricCar(Car):
"""Represent aspects of a car, specific to electric vehicles."""
def __init__(self, make, model, year):
"""
Initialize attributes of the parent class.
Then initialize attributes specific to an electric car.
"""
super().__init__(make, model, year)
self.battery = Battery()
We define a new class called Battery
that doesn’t inherit from any other class. The __init__()
method has one parameter, battery_size
, in addition to self
. This optional parameter sets the battery’s size to 40 if no value is provided. The method describe_battery()
has been moved to this class as well. A new method, get_range()
, performs some simple analysis and is also added.
In the ElectricCar
class, we now add an attribute called self.battery
. This tells Python
to create a new instance of Battery
(with a default size of 40) and assign that instance to the attribute self.battery
. Any ElectricCar
instance will now have a Battery
instance created automatically. When we want to describe the battery, we need to work through the car’s battery attribute:
Inheritance and composition are two essential concepts in object-oriented programming that model the relationship between two classes. Check out here for more in-depth comparisons.
8.5 Encapsulation - Attributes for data access
Most object-oriented programming languages enable you to encapsulate (or hide) an object’s data from the code. Such data in these languages are said to be private data. Python
does not have private data. Instead, you use naming conventions to design classes that encourage correct use. By convention, Python programmers know that any attribute name beginning with an underscore (_
) is for a class’s internal use only. Code should use the class’s methods to interact with each object’s internal-use data attributes. Attributes whose identifiers do not begin with an underscore (_
) are considered publicly accessible.
However, even when we use these conventions, attributes are always accessible.
Let’s develop a Time
class that stores the time in 24-hour clock format with hours
in the range 0–23 and minutes
and seconds
each in the range 0–59:
class Time:
"""Class Time with read-write attributes."""
def __init__(self, hour=0, minute=0, second=0):
"""Initialize each attribute."""
self.set_hour(hour) # 0-23, note that this line calls the setter method hour
self.set_minute(minute) # 0-59, note that this line calls the setter method minute
self.set_second(second) # 0-59, note that this line calls the setter method second
#getter
def get_hour(self):
"""Return the hour."""
print("getter is called")
return self._hour # Private data
#setter
def set_hour(self, hour):
"""Set the hour."""
print("setter is called")
if not (0 <= hour < 24):
raise ValueError(f'Hour ({hour}) must be 0-23')
self._hour = hour
#getter
def get_minute(self):
"""Return the minute."""
return self._minute # Private data
#setter
def set_minute(self, minute):
"""Set the minute."""
if not (0 <= minute < 60):
raise ValueError(f'Minute ({minute}) must be 0-59')
self._minute = minute
#getter
def get_second(self):
"""Return the second."""
return self._second # Private data
#setter
def set_second(self, second):
"""Set the second."""
if not (0 <= second < 60):
raise ValueError(f'Second ({second}) must be 0-59')
self._second = second
Class
Time
’s__init__
method specifieshour
,minute
andsecond
parameters, each with a default argument of 0. The statements containingself.set_hour()
,self.set_minute()
andself.set_second()
call methods that implement the class’s setter. Those methods then create attributes named_hour
,_minute
and_second
that is meant for use only inside the class!Lines 10–21 define methods that manipulate a data attribute named
_hour
. The single-leading-underscore (_) naming convention indicates that we should not access_hour
directly. We define a getter method which gets (that is, returns) a data attribute’s value and a setter method, which sets a data attribute’s value.
Here is how we initialize an object:
The following code expression invokes the getter method:
The following code expression invokes the setter by assigning a value to the attribute:
Class Time
’s getter and setter define the class’s public interface — that is, the set of attributes programmers should use to interact with objects of the class. Just like the private attributes above, not all methods need to serve as part of a class’s interface. Some serve as utility methods used only inside the class and are not intended to be part of the class’s public interface used by others. Such methods should be named with a single leading underscore. In other object-oriented languages like C++, Java and C#, such methods typically are implemented as private methods.
In Python, there is a more Pythonic way to define the getter and setter using
properties
. You can check out here for more information.
Note that although we define the public interface, the internal attribute can still be accessed.
8.5.1 Simulating “Private” Attributes
In programming languages such as C++, Java and C#, classes state explicitly which class members are publicly accessible. Class members that may not be accessed outside a class definition are private and visible only within the class that defines them. Python
programmers often use “private” attributes for data or utility methods that are essential to a class’s inner workings but are not part of the class’s public interface.
Rather than _hour
, we can name the attribute __hour
with two leading underscores. This convention indicates that __hour
is “private” and should not be accessible to the class’s clients.
When we attempt to access __private_data
directly, we get an AttributeError
indicating that the class does not have an attribute by that name:
Even with double-underscore (__) naming, we can still access and modify
__private_data
, because we know that Python renames attributes simply by prefixing their names with ‘_ClassName
’
8.6 Class Methods
Class methods are associated with a class rather than individual objects like regular methods are. You can recognize a class method in code when you see two markers: the @classmethod
decorator before the method’s def
statement and the use of cls
as the first parameter, as shown in the following example:
TypeError: exampleRegularMethod() missing 1 required positional argument: 'self'
The cls
parameter acts like self
except self
refers to an object, but the cls
parameter refers to an object’s class. This means that the code in a class method cannot access an individual object’s attributes or call an object’s regular methods. Class methods can only call other class methods or access class attributes. We often call class methods through the class, as in ExampleClass.exampleClassMethod()
. But we can also call them through any object of the class, as in obj.exampleClassMethod()
.
Class methods aren’t commonly used. The most frequent use case is to provide alternative constructor methods besides __init__()
. For example, what if a constructor function could accept either a string
of data the new object needs or a string
of a filename that contains the data the new object needs? We don’t want the __init__()
method’s parameters to be lengthy and confusing. Instead, let’s use class methods to return a new object. For example, let’s create an AsciiArt
class:
%%writefile pokeball.txt
⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⣀⣴⡶⠿⠛⠛⠛⠛⠛⠛⠻⠷⣦⣄⡀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⣠⡾⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⣦⡀⠀⠀⠀
⠀⠀⢠⣾⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣆⠀⠀
⠀⢠⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣇⠀
⠀⣾⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⡄
⢠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇
⠸⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⡇
⠀⣿⣿⣦⣀⠀⠀⠀⠀⠀⣠⣴⣶⢶⣦⣤⡀⠀⠀⠀⠀⢀⣠⣾⣿⠇
⠀⠸⣷⡈⠛⠿⣶⣦⣤⣼⠟⡡⠒⠒⢢⠙⣿⣤⣤⣶⠾⠟⠋⣰⡟⠀
⠀⠀⠹⣷⡄⠀⠀⠀⠉⣿⣄⠣⣀⢀⡠⢀⣿⠏⠁⠀⠀⢀⣴⡟⠀⠀
⠀⠀⠀⠈⠻⣦⣄⠀⠀⠈⠻⢷⣦⣤⣶⠿⠋⠀⠀⢀⣤⡾⠋⠀⠀⠀
⠀⠀⠀⠀⠀⠈⠛⠷⣦⣤⣀⣀⠀⠀⣀⣀⣠⣤⡶⠟⠋⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠛⠛⠛⠛⠋⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀
Overwriting pokeball.txt
class AsciiArt:
def __init__(self, characters):
""" Approach1: Initialize it with string """
self._characters = characters
@classmethod
def fromFile(cls, filename):
""" Approach2: Initialize it with filename """
with open(filename) as fileObj:
characters = fileObj.read()
return AsciiArt(characters) # This calls the __init__ function, notice the use of return statement
def display(self):
print(self._characters)
# Other AsciiArt methods would go here...
ball1 = AsciiArt("""⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⣀⣴⡶⠿⠛⠛⠛⠛⠛⠛⠻⠷⣦⣄⡀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⣠⡾⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⣦⡀⠀⠀⠀
⠀⠀⢠⣾⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣆⠀⠀
⠀⢠⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣇⠀
⠀⣾⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⡄
⢠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇
⠸⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⡇
⠀⣿⣿⣦⣀⠀⠀⠀⠀⠀⣠⣴⣶⢶⣦⣤⡀⠀⠀⠀⠀⢀⣠⣾⣿⠇
⠀⠸⣷⡈⠛⠿⣶⣦⣤⣼⠟⡡⠒⠒⢢⠙⣿⣤⣤⣶⠾⠟⠋⣰⡟⠀
⠀⠀⠹⣷⡄⠀⠀⠀⠉⣿⣄⠣⣀⢀⡠⢀⣿⠏⠁⠀⠀⢀⣴⡟⠀⠀
⠀⠀⠀⠈⠻⣦⣄⠀⠀⠈⠻⢷⣦⣤⣶⠿⠋⠀⠀⢀⣤⡾⠋⠀⠀⠀
⠀⠀⠀⠀⠀⠈⠛⠷⣦⣤⣀⣀⠀⠀⣀⣀⣠⣤⡶⠟⠋⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠛⠛⠛⠛⠋⠉⠁⠀⠀⠀⠀⠀"""
)
ball1.display()
ball2 = AsciiArt.fromFile('pokeball.txt')
ball2.display()
⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⣀⣴⡶⠿⠛⠛⠛⠛⠛⠛⠻⠷⣦⣄⡀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⣠⡾⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⣦⡀⠀⠀⠀
⠀⠀⢠⣾⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣆⠀⠀
⠀⢠⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣇⠀
⠀⣾⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⡄
⢠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇
⠸⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⡇
⠀⣿⣿⣦⣀⠀⠀⠀⠀⠀⣠⣴⣶⢶⣦⣤⡀⠀⠀⠀⠀⢀⣠⣾⣿⠇
⠀⠸⣷⡈⠛⠿⣶⣦⣤⣼⠟⡡⠒⠒⢢⠙⣿⣤⣤⣶⠾⠟⠋⣰⡟⠀
⠀⠀⠹⣷⡄⠀⠀⠀⠉⣿⣄⠣⣀⢀⡠⢀⣿⠏⠁⠀⠀⢀⣴⡟⠀⠀
⠀⠀⠀⠈⠻⣦⣄⠀⠀⠈⠻⢷⣦⣤⣶⠿⠋⠀⠀⢀⣤⡾⠋⠀⠀⠀
⠀⠀⠀⠀⠀⠈⠛⠷⣦⣤⣀⣀⠀⠀⣀⣀⣠⣤⡶⠟⠋⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠛⠛⠛⠛⠋⠉⠁⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⣀⣴⡶⠿⠛⠛⠛⠛⠛⠛⠻⠷⣦⣄⡀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⣠⡾⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⣦⡀⠀⠀⠀
⠀⠀⢠⣾⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣆⠀⠀
⠀⢠⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣇⠀
⠀⣾⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⡄
⢠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇
⠸⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⡇
⠀⣿⣿⣦⣀⠀⠀⠀⠀⠀⣠⣴⣶⢶⣦⣤⡀⠀⠀⠀⠀⢀⣠⣾⣿⠇
⠀⠸⣷⡈⠛⠿⣶⣦⣤⣼⠟⡡⠒⠒⢢⠙⣿⣤⣤⣶⠾⠟⠋⣰⡟⠀
⠀⠀⠹⣷⡄⠀⠀⠀⠉⣿⣄⠣⣀⢀⡠⢀⣿⠏⠁⠀⠀⢀⣴⡟⠀⠀
⠀⠀⠀⠈⠻⣦⣄⠀⠀⠈⠻⢷⣦⣤⣶⠿⠋⠀⠀⢀⣤⡾⠋⠀⠀⠀
⠀⠀⠀⠀⠀⠈⠛⠷⣦⣤⣀⣀⠀⠀⣀⣀⣠⣤⡶⠟⠋⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠛⠛⠛⠛⠋⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀
The AsciiArt
class has an __init__()
method that can be passed the text characters of the image as a string. It also has a fromFile()
class method that can be passed the filename string of a text file containing the ASCII art. Both methods create AsciiArt
objects. Note that the last line of fromFile()
calls __init__()
method. This is a trick you can use to follow the “Don’t Repeat Yourself (DRY)” principle. If we rename this class or modify the content of the constructor (__init__
) at some point, we won’t have to remember to update the class method.
8.6.1 Class Attributes
A class attribute is a variable that belongs to the class rather than to an object. We create class attributes inside the class but outside all methods, just like we can create global variables in a .py
file but outside all functions. Here’s an example of a class attribute named count
, which keeps track of how many CreateCounter
objects have been created:
class CreateCounter:
count = 0 # This is a class attribute.
def __init__(self):
CreateCounter.count += 1
print('Objects created:', CreateCounter.count) # Prints 0.
a = CreateCounter()
b = CreateCounter()
c = CreateCounter()
print('Objects created:', CreateCounter.count) # Prints 3.
Objects created: 0
Objects created: 3
The CreateCounter
class has a single class attribute named count
. All CreateCounter
objects share this attribute rather than having their own separate count
attributes. This is why the CreateCounter.count += 1
line in the constructor function can keep count of every CreateCounter
object created.
8.6.2 Static Methods
A static method doesn’t have a self
or cls
parameter. Static methods are effectively just functions, because they can’t access the attributes or methods of the class (using cls
) or its objects (using self
).
We define static methods by placing the @staticmethod
decorator before their def
statements. Here is an example of a static method:
class ExampleClassWithStaticMethod:
@staticmethod
def sayHello():
print('Hello!')
# Note that no object is created, the class name precedes sayHello():
ExampleClassWithStaticMethod.sayHello()
obj = ExampleClassWithStaticMethod()
obj.sayHello()
Hello!
Hello!
Static methods are more common in other languages that don’t have Python’s flexible language features. Python’s inclusion of static methods imitates the features of other languages but doesn’t offer much practical value.
You’ll rarely need class methods, class attributes, and static methods, and they’re also prone to overuse. If you’re thinking, “Why can’t I just use a function or global variable instead?” this is a hint that you probably don’t need to use a class method, class attribute, or static method. The only reason we cover them is so you can recognize them when you encounter them in code. You can find more information about the discussion here.
8.7 Polymorphism
Polymorphism allows objects of one type to be treated as objects of another type (class).
For example, the
len()
function returns the length of the argument passed to it. You can pass astring
tolen()
to see how many characters it has, but you can also pass alist
ordictionary
tolen()
to see how many items or key-value pairs it has, respectively. This polymorphism of function is called generic functions or method/function overloading because it can handle objects of many different types.Polymorphism also includes operator overloading, where operators (such as
+
or*
) can behave differently based on the type of objects they’re operating on. For example, the+
operator does mathematical addition when operating on twointeger
orfloat
values, but it doesstring
concatenation when operating on two strings.
In Python
, we can achieve method polymorphism by defining a method in a base class and then overriding it in the derived classes. Each derived class can then provide its implementation of the method. For example:
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
pass # Don't do anything and prevent error by using this keyword
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
def speak(animal):
print(animal.speak())
animals = [Dog("Rufus"), Cat("Whiskers"), Dog("Buddy")]
# method overloading
for animal in animals:
print(f'{animal.name} : {animal.speak()}')
# function overloading
for animal in animals:
speak(animal)
Rufus : Woof!
Whiskers : Meow!
Buddy : Woof!
Woof!
Meow!
Woof!
In this example, the Animal
class defines the speak
method as a pass
statement, meaning it does nothing. However, both Dog
and Cat
classes override the method with their implementation of the method. This is called method overriding and is also a form of polymorphism. The speak()
function accepts any object that implements the speak()
method, meaning it can handle animals of different types. Here, we can pass both Dog
and Cat
objects to the speak()
function, as they both inherit the speak()
method from the Animal
class.
8.7.1 Operator overloading
Python
has several dunder method. You’re already familiar with the __init__()
dunder method name, but Python
has several more. We often use them for operator overloading — that is, adding custom behaviors that allow us to use objects of our classes with Python
operators, such as +
or >=
. Other dunder methods let objects of our classes work with Python
’s built-in functions, such as len()
. These methods are documented online in the official Python
documentation at here.
p1 = Point(1, 2)
p2 = Point(3, 4)
p3 = p1 + p2
p4 = Point(4, 6)
print(p3.x, p3.y) # Output: 4 6
print(p3 == p4)
4 6
True
In this example, we define the __add__
and __eq__
method in the Point
class to implement the addition and equality of two Point
objects. When we use the +
and =
operators with two Point
objects, the __add__
and __eq__
methods are called automatically to perform the addition and comparison.
For more information about overloading, see here.
8.7.2 Exercise 2: Inherit from
Pokemon
class to create new classes,firePokemon
andwaterPokemon
, that accept the same parameters when constructed. Add a new methodattack()
for the two derived classes that recieve a single parameterattack_type
and print out the message like this:
Magmortar is attacking with flamethrower
In addition, define a function PokemonAttack()
, which receives a Pokemon object and an attack_type
, then call the method attack()
. Complete the following class/function and execute the code cell.
class Pokemon:
def __init__(self, name, type, total_specis):
self.name = name
self.type = type
self.total_specis = total_specis
def __str__(self):
return f"{self.name} ({self.type}, total specis {self.total_specis})"
class firePokemon(Pokemon):
# Your code here
def __init__(self, name, type, total_specis):
self.name = name
self.type = type
self.total_specis = total_specis
# Your code here
def attack(self, attack_type):
print(f"{self.name} is attacking with {attack_type}")
class waterPokemon(Pokemon):
# Your code here
def __init__(self, name, type, total_specis):
super().__init__(name, type, total_specis)
# Your code here
def attack(self, attack_type):
print(f"{self.name} is attacking with {attack_type}")
def PokemonAttack(pokemon, attack_type):
# Your code here
pokemon.attack(attack_type)
import random
import json
import time
import requests
def slow_print(text, delay=0.05):
for char in text:
print(char, end='', flush=True)
time.sleep(delay)
print()
## 1. Download the data
url = 'https://raw.githubusercontent.com/fanzeyi/pokemon.json/master/pokedex.json'
response = requests.get(url)
if response.status_code == 200:
with open('pokedex.json', 'w', encoding = "utf-8") as f:
f.write(response.text)
else:
print(f"Failed to download the file. Status code: {response.status_code}")
with open('Pokedex.json', 'r', encoding = "utf-8") as file:
pokemon_data = json.load(file)
## 2. Get the pokemon
random.shuffle(pokemon_data)
getpokemon = []
i = 0
while True:
if len(getpokemon) == 6:
break
if pokemon_data[i]['type'][0] != "Fire" and pokemon_data[i]['type'][0] != "Water":
i += 1
continue
else:
if pokemon_data[i]['type'][0] == "Fire":
pokemon = firePokemon(pokemon_data[i]['name']['english'], pokemon_data[i]['type'][0], sum([pokemon_data[i]['base'][key] for key in pokemon_data[i]['base'] if key != "name"]))
else:
pokemon = waterPokemon(pokemon_data[i]['name']['english'], pokemon_data[i]['type'][0], sum([pokemon_data[i]['base'][key] for key in pokemon_data[i]['base'] if key != "name"]))
getpokemon.append(pokemon)
i += 1
# 3. Print the pokemon and attack!
for i in range(6):
if getpokemon[i].type == "Fire":
attack = 'flamethrower'
else:
attack = 'hydro pump'
PokemonAttack(getpokemon[i], attack)
Cloyster is attacking with hydro pump
Tentacruel is attacking with hydro pump
Torracat is attacking with flamethrower
Lotad is attacking with hydro pump
Gastrodon is attacking with hydro pump
Finneon is attacking with hydro pump
8.8 Data Class
Data classes are among Python 3.7’s most important new features. They help you build classes faster by using more concise notation and by autogenerating “boilerplate” code that’s common in most classes. For instance:
- A data class autogenerates
__init__
,__repr__
and__eq__
, saving you time. - A data class can autogenerate the special methods that overload the
<
,<=
,>
and>=
comparison operators. - When you change data attributes defined in a data class, then use it in a script or interactive session, the autogenerated code updates automatically. So, you have less code to maintain and debug.
- Some static code analysis tools and IDEs can inspect variable annotations and issue warnings if your code uses the wrong type. This can help you locate logic errors in your code before you execute it.
Check out here for more details.
8.9 Summary
Object-oriented programming is a programming paradigm that provides a means of structuring programs so that attributes and behaviors are bundled into individual objects. The discussion of OOP is centered around inheritance, encapsulation and Polymorphism:
Inheritance promotes code reusability and organization by allowing derived classes to inherit attributes and methods from parent classes.
Encapsulation improves maintainability and security by bundling data and methods within objects and controlling access to their internal state.
Polymorphism enhances flexibility and extensibility by enabling a single interface to represent different types, allowing for interchangeable objects and easier code modification.
However, Python
implements object-oriented features slightly differently than other OOP languages, such as Java or C++. For example, Python
lets you overload its operators via its dunder methods, which begin and end with double underscore characters. These methods provide a way for Python’s built-in operators to work with objects of the classes you create.