0% found this document useful (0 votes)
30 views41 pages

Lesson 7. Intro To OO

The document introduces object-oriented programming concepts through an example of creating enemy objects in a game. It discusses representing enemies as dictionaries initially, then shows how to define an Enemy class with attributes like position and an init method to initialize new enemy objects. The class defines enemy objects with attributes like x, y, direction and an image, and a method to change an enemy's direction is demonstrated. Classes are introduced as a way to generically define object types with their attributes and methods.

Uploaded by

davidpotato45
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
30 views41 pages

Lesson 7. Intro To OO

The document introduces object-oriented programming concepts through an example of creating enemy objects in a game. It discusses representing enemies as dictionaries initially, then shows how to define an Enemy class with attributes like position and an init method to initialize new enemy objects. The class defines enemy objects with attributes like x, y, direction and an image, and a method to change an enemy's direction is demonstrated. Classes are introduced as a way to generically define object types with their attributes and methods.

Uploaded by

davidpotato45
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 41

Lesson 7.

Intro to OO

November 23, 2023

1 Intro to OO (object orientation)


• As we have seen, in addition to code reuse and implementation hiding, functions allow to
perform decomposition. For small programs decomposition into functions is usually enough
to have a clear and easy to modify code.
• But when working with complex programs decomposition into functions is not enough, we
need to perform an extra level of decomposition: we don’t usually put all the functions in a
single file, but divide them among several ones. There will be many files where functions are
declared and a main file where the functions are invoked. This allows to have several people
working on the same program and also makes programming easier.
• Each of these files is usually called a module
• There are two criteria that can be used to group functions and data into modules:
• Grouping by functionality: functions that perform similar tasks are put together,
along with the data they use. As an example, in the final project we would create a
module for all the functions moving characters, another one for functions drawing
things, another for functions checking collisions and so on. This is known as
structured programming or modular programming: functions are grouped
into modules taking into account what they do.
• Grouping by the entity they refer to. In the final project we would put together
all the functions and data related to Mario. Another module would contain all
the functions/data related to the enemies. Another one could contain all the
functions/data related to the board and so on. This is known as object oriented
programming (OO).
• OO can be seen as an evolution of structured programming, but it is a different approach:
we focus on entities/objects that are relevant. In OO we put together not only the functions
that are related to the same entity but also the variables related to it.
• In OO:
• each module is called a Class
• variables characterizing each object inside a class are called attributes or fields
• functions related to an object are called methods
• Advantages: OO provides a clear modular structure, easier to maintain because of the modular
design and easier to be reused (this can also be obtained with Structured, but OO makes it
easier)

1
• Following an OO approach we analyze the problem in terms of:
1. Entities that appear in our problem (classes/objects)
2. Characteristics of those entities or information we need to store related to them (data at-
tributes or fields)
3. Things that those entities can do (methods)
• This is known as encapsulation (variables + methods operating on them): we put together
data and functions working over them. OO is also inheritance and polymorphism (we’ll see
them later in this topic)

2 Prelude
As seen above, in OO we put together data and functions related to the entities of a given problem.
Let start focusing in data by having a look of the enemies of our game. As a first approach, the
data we need to store about each enemy could be: x position, y position, direction, image… Which
data structure can be used to store it? It could be a list, but it is better to use a dictionary:

[1]: enemy1 = {"x": 0, "y" : 0, "direction" : "left", "image" : "enemy.png"}


enemy2 = {"x": 10, "y" : 0, "direction" : "right", "image" : "enemy.png"}

As we need to generate multiple enemies, we could think about a function to do it:

[2]: def create_enemy(x: int, y: int, direc: str) -> dict:


""" Creates a dictionary with the information of an enemy
:param x: the x of the enemy
:param y: the y of the enemy
:param direc: the direction of the enemy, possible values are "right" or␣
↪"left"

:return : a dictionary with the former data"""


dic = {}
dic["x"] = x
dic["y"] = y
# Here the name of the parameter is not the name of the key
# just to show it can be done
dic["direction"] = direc
# We are assuming all the enemies have the same image,
# so we don't have a parameter for it
dic["image"] = "enemy.png"
return dic

[3]: # We can now create enemies


enemy3 = create_enemy(10,10,"left")
enemy4 = create_enemy(10,10,"left")
print("Data of an enemy", enemy3)

Data of an enemy {'x': 10, 'y': 10, 'direction': 'left', 'image': 'enemy.png'}
We could even thing on adding more functions, like one to change the direction of the enemies:

2
[4]: def change_direction(enemy : dict):
""" Changes the value of the 'direction' key of an enemy dictionary. As␣
↪dictionaries are

mutable, the value of the ditionary used to invoke the parameter changes
:param enemy: a dictionary with the information of an enemy"""
if enemy["direction"] == "right":
enemy["direction"] = "left"
else:
enemy["direction"] = "right"

[5]: change_direction(enemy3)
print("Data of an enemy", enemy3)

Data of an enemy {'x': 10, 'y': 10, 'direction': 'right', 'image': 'enemy.png'}

3 Classes
• The former example is not using OO, we can do something better using OO.
• As a first approach a class can be seen as a kind of generic dictionary.
• A class is like an skeleton to create objects: it is like a generic definition of the variables
(which we will call attributes or fields) and the functions (which we will call methods) that
an object will have. In other words, a class is a generic definition of all the objects of a given
type.
• A whole program will have two different kinds of ‘programs’: classes where fields and methods
of the objects are defined and implemented, and a main program where objects belonging to
those classes are used to build the algorithm. We define classes and work with objects.
• To define a new class we use class NameOfTheClass:
• Naming conventions for class names: upper camel case (see in the above line an example of
a correct class name)
• It is recommended to put the class in its own file, do not use the same file for different classes
nor include any other code in that file
• Once the class is defined I need to add attributes and methods to it. For the attributes the
recommended way is to use a special method named the init method.
• Most generic schema of a class: (except for the init method anything could be optional)
class NameOfTheClass:
""" Class documentation """

def __init__(self, params_of_init):


""" This method is used to declare the attributes"""
# Attribute declaration
self.attribute1 = value1
self.attribute2 = value2
...

3
@property
def attribute_name(self):
""" Properties and setters are used to check the values of the attributes """

@attribute.setter
def attribute_name(self, new_value):
""" Properties and setters are used to check the values of the attributes """

def more_methods(self, params_of_each_method):


""" I usually declare several methods inside a class """

4 The init method


• It is one of the special or magic methods (magic methods usually start and end by __)
• It is used to declare which are the attributes of the objects of this class and to give values to
them when an object is created.
• It must be called __init__
• In other languages it is known as constructor.
def __init__(self, param1, param2...)
""" Methods have at least 1 parameter, the 'self' that represents the whole object.
In addition, init usually has a parameter for each attribute, but there can be
attributes that do not need a param """
self.attribute1 = param1
self.attribute2 = param2
...

• Attributes are special variables declared inside the init by appending self.
to their names. Name rules for attributes names: same as for variables
(self.this_is_a_valid_attribute_name)
• As a general rule it must receive a parameter for each of the attributes of the object.
• But if the value of any attribute can be calculated using other attributes or it has
always a fixed initial value or it is random, a parameter for that attribute is not
needed.
• As in any other method, the parameters can have by default values (optional), which will be
the initial values of the attributes if no parameter is given when invoking the init method.

[6]: # An example of a class with init method


class Date:
""" This class describes the information I need to store about dates. It
will be like a skeleton that I will use to create dates"""
# In addition to self, init usually has parameters with values for each of␣
↪the

# attributes. The name of the parameter is usually the same


# name than the attribute, but it is not compulsory

4
def __init__(self, day:int, m: str, year: int):
""" This method both declares the attributes of the class and
receives the initial value for all them
:param day: the day
:param m: the month
:param year: the year
"""
# Attributes must be declared with self.name_of_attribute
# In the init we usually just copy the value of each parameter into its
# corresponding attribute
# We use self.name to store each of the data
self.day = day
self.month = m
self.year = year

Exercise: Create a class for the enemy dictionaries seen above where the direction will be randomly
chosen.

[6]: import random


class Enemy:
""" This class represents an enemy"""
def __init__(self, x: int, y: int):
""" Init method of the class.
:param x: the x of the enemy
:param y: the y of the enemy"""
# All the self.something are attributes
self.x = x
self.y = y
# direct is a local variable
direct = random.randint(0, 1)
if direct == 1:
self.direction = "right"
else:
self.direction = "left"
self.image = "image.png"

5 Objects
• Classes just define the characteristics of the objects, to use a class we need to create an object.
• An object is a variable of a Class. In other words, an object is a variable whose type is a
class.
• To create an object: variable_name = ClassName(values_for_the_attributes). When
doing it, we are really invoking the init method.
• Values of the fields of the new object must be specified when creating it.
• To access or change the value of an attribute of an object I use dot notation:
object.attribute to see the value or object.attribute = value to change its value.
• What happens if we try to access a non-existing field? -> Error

5
• What happens if we try to assign a value to a non-existing field? -> The field is created (as
in dictionaries)
• Do not create fields on the fly. It is a bad programming practice as all the objects of a class
must have the same fields. It is very difficult to manage programs where objects of a class
can have different attributes. Totally forbidden this course.

[9]: # This is the way to declare a variable belonging to the Date class
# Any variable belonging to a class is called an object: my_date is
# an object of the Date class
my_date = Date(day = 12, m = "october", year = 2022)
# To see the values to the attributes I use the so called "dot notation"
# object.attribute
# To recover the value of the day
print("The day is", my_date.day)
# Assigning a new value for the day
my_date.day = 22
print("The day is", my_date.day)
# For the month
my_date.month = "November"
print("The month is", my_date.month)
# For the year
my_date.year = 2021
print("The year is", my_date.year)
# This is another object of the Date class, notice we usually give values
# to parameters by their position, we don't use the names
another_date = Date(22, "october", 2022)
# We specified day attribute is an integer, but this is only a
# suggestion, anything can be stored into it. Don't do it!!!
another_date.day = "hello"
print("The day is", another_date.day)

The day is 12
The day is 22
The month is November
The year is 2021
The day is hello

[8]: # We can do it also with enemies


enemy5 = Enemy(10,10)
print(enemy5.direction)

right

5.1 Working with objects in an IDE


• The right way to work with objects is to create another program and import the class.
• First way: importing the file import <file>
• Second way (recommended): importing the class from <file> import <class>

6
• The Class must be in our folder. To be able to import from another folder, we should create
packages (we will not cover that but material is uploaded to Aula Global)

5.2 Some properties of objects


• What do we get if we print an object? -> <file.Class> object at <memory_address>
We’ll see how to change it
• What happens if we assign one object to another one? -> They are the same object (as in
the case of lists) Don’t do it
• What happens if we compare two objects -> False, unless they are actually the same object.
It compares the pointers not their contents. We’ll see how to change it

[6]: # If I print an object I get module_name.Class at memory address


# Not very interesting doing it
print(my_date)
print(another_date)
# Currently we can only print field after field
print(my_date.day,my_date.month, my_date.year)
# Copying objects
obj1 = my_date
my_date.year = 2023
print("The year of obj1 is", obj1.year, "it changes too")
# Comparing objects
var4 = Date(22,"November", 2023)
print(var4.day,var4.month, var4.year)
# Are my_date and var4 equal?
print("Are my_date and var4 equal?", var4 == my_date)

<__main__.Date object at 0x0000021423205790>


<__main__.Date object at 0x00000214232059A0>
22 November 2022
The year of obj1 is 2023 it changes too
22 November 2023
Are my_date and var4 equal? False

6 Setters and properties


• Using an init method (constructor) we give the attributes an initial value. But nothing
prevents us from giving to them values that make no sense or even values of a different type.
• All languages have mechanisms to avoid this.
• Other languages use private attributes (we will see them later) and methods to read their
values and to change them (usually called get and set methods)

[8]: # Creating objects with values for the attributes that make no sense
d = Date("hello", 4, "bye")
print(d.day, d.month, d.year)
# And I can also do it after creating them
d2 = Date(1, "november", 2022)

7
d2.day = "hello how are you?"
print(d2.day, d2.month, d2.year)

hello 4 bye
hello how are you? november 2022
• Setters and properties are the way Python has to avoid the user of a class to give wrong
values to the attributes.
• To use setters/properties in Python we need to do the following:
1. In the init we create the attribute in the regular way self.attribute = value.
We don’t perform any checking of the value. We will also use the attribute in the
regular way in any other method except for the property and the setter methods
that we will create for each attribute.
2. For each attribute for which we want to control the values, we create a new
method with the same name of the attribute. This magic method is called setter.
Its header will be def attribute(self, value) and it must be decorated with
@attribute.setter. Inside this method I should check that the type of value
is correct and also that its value is correct. If they are not right, exceptions will
be risen. If the value is correct I will assign it to the attribute, but instead of
doing self.attribute = value I must put two underscores before the name of
the attribute: self.__attribute = value. If I don’t do it I will have an error.
This magic method will be automatically invoked any time I try to change the
value of the attribute, either in the class or in the main program.
3. If I created a setter like in the previous point, I am forced to create a property. A
property is also a magic method. It must have the name of the attribute too and
will receive only self as parameter (def attribute(self)). It must be decorated
with @property (exactly like this). In this special method all I can do is to return
the value of the attribute adding two underscores before the name of the attribute:
self.__attribute. This will allow to read the value of the attribute but not to
change it. The property must be placed before the setter.
• In @property and @setter I can also use self._attribute (only one underscore) instead of
self.__attribute but if I do so, in addition to attribute, _attribute will be also visible
outside, which is a non-desired side effect.
• We’ll see later that we can have a property without a setter, but setters can never be alone,
if a setter is created a property must be defined first.

[1]: # A Date class with setters and properties to avoid giving a wrong value to the␣
↪fields

class Date:
"""A class to store a date information. We will use properties/setters to
check the values provided are good. It has also by default values for
the parameters"""

def __init__(self, day: int = 1, month: str = "january",


year: int = 1900):

8
""" This method both declares the attributes of the class and
receives the initial value for all them
:param day: the day
:param m: the month
:param year: the year
"""
# No checking is done here, everything is delegated to the setters
# As I have a setter for year, this is totally equivalent to an␣
↪invocation

# to the year method: self.year(value)


self.year = year
# This also invokes the month setter
self.month = month
# As we need the month and the year to check the values of the day,
# the day attribute must be declared after the year and month ones
self.day = day

# This @property keyword is called a 'decorator'


@property
def year(self) -> int:
""" This special method will return the value of the year
:return : the year"""
# Here I must return my attribute preceded by __
# If I don't do it it will not work
return self.__year

# As this method's name is equal to the previous one, to avoid replacing


# the previous one, it needs to be decorated with @attribute.setter
@year.setter
def year(self, year: int):
""" This method allows to change the value of the year
:param year: the value of the year, if 0 is given an error is raisen"""
# If the type is not correct we raise an exception
if type(year) != int:
raise TypeError("The year must be an int")
# Here I need to use __year again
elif year != 0:
self.__year = year
else:
# In any other case, I raise an exception
raise ValueError("Year must be not equal to 0")

#We create properties and setters also for day and month
@property
def month(self) -> str:
""" :return : the name of the month in small caps"""
return self.__month

9
@month.setter
def month(self, month: str):
# Notice that inside a method we can use local variables as we did with
# functions. Do not use self. with them or they will become attributes
# and they will be visible outside the class. We only want this variable
# to be used inside this method.
days_in_month = {'january':31, 'february': 28, "march": 31, "april":30,
"may": 31, "june": 30, "july": 31, "august": 31,
"september": 30, "october": 31, "november": 30,
"december":31}
if type(month) != str:
raise TypeError("The month must be a string")
elif month.lower() in days_in_month:
self.__month = month.lower()
else:
raise ValueError("Valid months are " + str(list(days_in_month)))

@property
def day(self) -> int:
return self.__day

@day.setter
def day(self, day: int):
# We cannot use a variable defined in another method, so we need to␣
↪repeat

# it here. Later we will see how to avoid this.


days_in_month = {'january':31, 'february': 28, "march": 31, "april":30,
"may": 31, "june": 30, "july": 31, "august": 31,
"september": 30, "october": 31, "november": 30,
"december":31}
# First checking the days of February
# I change the days if leap year
if self.year % 4 == 0 and (self.year % 100 != 0 or self.year % 400 ==␣
↪0):

days_in_month['february'] = 29
if type(day) != int:
raise TypeError("The day must be an integer")
elif day > 0 and day <= days_in_month[self.month]:
self.__day = day
else:
if self.month == 2:
raise ValueError("February has only 28 days in " + str(self.
↪year))

else:
raise ValueError(self.month + " has only " +␣
↪str(days_in_month[self.month])

10
+ " days")

[2]: # If we try to create a wrong month, we get an error


# The same would happen for day or year
my_date = Date(18, "movember", 2021)

---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[2], line 3
1 # If we try to create a wrong month, we get an error
2 # The same would happen for day or year
----> 3 my_date = Date(18, "movember", 2021)

Cell In[1], line 20, in Date.__init__(self, day, month, year)


18 self.year = year
19 # This also invokes the month setter
---> 20 self.month = month
21 # As we need the month and the year to check the values of the day,
22 # the day attribute must be declared after the year and month ones
23 self.day = day

Cell In[1], line 71, in Date.month(self, month)


69 self.__month = month.lower()
70 else:
---> 71 raise ValueError("Valid months are " + str(list(days_in_month)))

ValueError: Valid months are ['january', 'february', 'march', 'april', 'may',␣


↪'june', 'july', 'august', 'september', 'october', 'november', 'december']

[3]: my_date = Date(18,"november",2021)


# This will also raise an error
my_date.day = 33

---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[3], line 3
1 my_date = Date(18,"november",2021)
2 # This will also raise an error
----> 3 my_date.day = 33

Cell In[1], line 97, in Date.day(self, day)


95 raise ValueError("February has only 28 days in " + str(self.year))
96 else:
---> 97 raise ValueError(self.month + " has only " + str(days_in_month[self.
↪month])

98 + " days")

11
ValueError: november has only 30 days

6.1 Raising exceptions


• In the previous example when a wrong value is received for an attribute we have risen an
exception. Another possibility is setting it to a safe value.
• Raising an exception implies that an error will appear in my program and the program will
finish.
• Whether to use one or another depends on the design: for some attributes it is better to
provide safe values, for other ones it is more convenient to stop as giving a safe value can lead
to problems latter if the user is not aware of it (this is the recommended option)
• The most common raised exceptions when the value is wrong are:
• raise TypeError(error message): the type of the value is not the expected one
• raise ValueError(error message: the type is correct, but the value is not
• The error message is optional.
• Exceptions make the program to stop, unless a try-except block is included (not covered in
this course)

7 Methods
• A method is a function that I declare inside a class
• Methods have always at least one parameter: the self parameter (we can call it whichever
name we want, but self is the recommended one)
• This first and compulsory parameter of a method is a special one, because it represents the
whole object. It contains a link to the attributes of the object so we can work with them
inside the method
• To invoke a method I use object_name.method_name(parameters without considering
the self)

[6]: # Adding a print method to our Date class


class Date:
"""A class to store a date information. We will use properties/setters to
check the values provided are good. It has also by default values for
the parameters"""

def __init__(self, day: int = 1, month: str = "january",


year: int = 1900):
""" This method both declares the attributes of the class and
receives the initial value for all them
:param day: the day
:param m: the month
:param year: the year
"""
# No checking is done here, everything is delegated to the setters

12
# As I have a setter for year, this is totally equivalent to an␣
invocation

# to the year method: self.year(value)


self.year = year
# This also invokes the month setter
self.month = month
# As we need the month and the year to check the values of the day,
# the day attribute must be declared after the year and month ones
self.day = day

# This @property keyword is called a 'decorator'


@property
def year(self) -> int:
""" This special method will return the value of the year
:return : the year"""
# Here I must return my attribute preceded by __
# If I don't do it it will not work
return self.__year

# As this method's name is equal to the previous one, to avoid replacing


# the previous one, it needs to be decorated with @attribute.setter
@year.setter
def year(self, year: int):
""" This method allows to change the value of the year
:param year: the value of the year, if 0 is given an error is raisen"""
# If the type is not correct we raise an exception
if type(year) != int:
raise TypeError("The year must be an int")
# Here I need to use __year again
elif year != 0:
self.__year = year
else:
# In any other case, I raise an exception
raise ValueError("Year must be not equal to 0")

#We create properties and setters also for day and month
@property
def month(self) -> str:
""" :return : the name of the month in small caps"""
return self.__month

@month.setter
def month(self, month: str):
# Notice that inside a method we can use local variables as we did with
# functions. Do not use self. with them or they will become attributes
# and they will be visible outside the class. We only want this variable
# to be used inside this method.

13
days_in_month = {'january':31, 'february': 28, "march": 31, "april":30,
"may": 31, "june": 30, "july": 31, "august": 31,
"september": 30, "october": 31, "november": 30,
"december":31}
if type(month) != str:
raise TypeError("The month must be a string")
elif month.lower() in days_in_month:
self.__month = month.lower()
else:
raise ValueError("Valid months are " + str(list(days_in_month)))

@property
def day(self) -> int:
return self.__day

@day.setter
def day(self, day: int):
# We cannot use a variable defined in another method, so we need to␣
↪repeat

# it here. Later we will see how to avoid this.


days_in_month = {'january':31, 'february': 28, "march": 31, "april":30,
"may": 31, "june": 30, "july": 31, "august": 31,
"september": 30, "october": 31, "november": 30,
"december":31}
# First checking the days of February
# I change the days if leap year
if self.year % 4 == 0 and (self.year % 100 != 0 or self.year % 400 ==␣
↪0):

days_in_month['february'] = 29
if type(day) != int:
raise TypeError("The day must be an integer")
elif day > 0 and day <= days_in_month[self.month]:
self.__day = day
else:
if self.month == 2:
raise ValueError("February has only 28 days in " + str(self.
↪year))

else:
raise ValueError(self.month + " has only " +␣
↪str(days_in_month[self.month])

+ " days")

def max_days_in_month(self) -> int:


""" A method that returns the maximum days of this month"""
# We cannot use a variable defined in another method, so we need to␣
↪repeat

# it here. Later we will see how to avoid this.

14
days_in_month = {'january':31, 'february': 28, "march": 31, "april":30,
"may": 31, "june": 30, "july": 31, "august": 31,
"september": 30, "october": 31, "november": 30,
"december":31}
# I change the days of February if leap year
if self.year % 4 == 0 and (self.year % 100 != 0 or self.year % 400 ==␣
0):

days_in_month['february'] = 29
return days_in_month[self.month]

[8]: # Creating an object


da = Date(21, "november", 2023)
# We invoke the new method
print(da.max_days_in_month())

30
Exercise: create properties, setters and a change_direction method for the Enemy class.

[4]: import random


class Enemy:
""" This class represents an enemy"""
def __init__(self, x: int, y: int):
""" Init method of the class.
:param x: the x of the enemy
:param y: the y of the enemy"""
# All the self.something are attributes
self.x = x
self.y = y
# direct is a local variable
direct = random.randint(0, 1)
if direct == 1:
self.direction = "right"
else:
self.direction = "left"
self.image = "image.png"

@property
def x(self) -> int:
return self.__x

@property
def y(self) -> int:
return self.__y

@property
def direction(self) -> str:
return self.__direction

15
@property
def image(self) -> str:
return self.__image

@x.setter
def x(self, x: int):
if type(x) != int:
raise TypeException ("The coordinate x must be an int")
elif x < 0:
raise ValueException("No negative coordinates allowed")
else:
self.__x = x

@y.setter
def y(self, y: int):
if type(y) != int:
raise TypeException ("The coordinate y must be an int")
elif y < 0:
raise ValueException("No negative coordinates allowed")
else:
self.__y = y

@direction.setter
def direction(self, direction: str):
if type(direction) != str:
raise TypeError("The direction must be string")
elif direction.lower() != "left" and direction.lower() != "right":
raise ValueError("The valid directions are 'left' or 'right'")
else:
self.__direction = direction.lower()

@image.setter
def image(self, image: str):
if type(image) != str:
raise TypeError("The image must be a file name (string)")
else:
self.__image = image

def change_direction(self):
""" Changes the value of the 'direction' attribute of an enemy"""
if self.direction == "right":
self.direction = "left"
else:
self.direction = "right"

16
[5]: enem = Enemy(20,30)
print("The direction is", enem.direction)
enem.change_direction()
print("The direction is", enem.direction)

The direction is left


The direction is right

8 More magic methods


• In addition to __init__ there are many other magic methods, we usually implement some of
them in all our classes.
• All of them are named __name__
• The __str__(self) method allows to specify what should be shown if an object is printed
• The __repr__(self) method allows to specify what should be shown if the name of an object
in typed in the interpreter.
• The __eq__(self, another_object) method allows to compare two objects using ==. If it
is not implemented Python compares the memory addresses.

[7]: # Adding more magic methods to our Date class


class Date:
"""A class to store a date information. We will use properties/setters to
check the values provided are good. It has also by default values for
the parameters"""

def __init__(self, day: int = 1, month: str = "january",


year: int = 1900):
""" This method both declares the attributes of the class and
receives the initial value for all them
:param day: the day
:param m: the month
:param year: the year
"""
# No checking is done here, everything is delegated to the setters
# As I have a setter for year, this is totally equivalent to an␣
↪invocation

# to the year method: self.year(value)


self.year = year
# This also invokes the month setter
self.month = month
# As we need the month and the year to check the values of the day,
# the day attribute must be declared after the year and month ones
self.day = day

# This @property keyword is called a 'decorator'


@property
def year(self) -> int:

17
""" This special method will return the value of the year
:return : the year"""
# Here I must return my attribute preceded by __
# If I don't do it it will not work
return self.__year

# As this method's name is equal to the previous one, to avoid replacing


# the previous one, it needs to be decorated with @attribute.setter
@year.setter
def year(self, year: int):
""" This method allows to change the value of the year
:param year: the value of the year, if 0 is given an error is raisen"""
# If the type is not correct we raise an exception
if type(year) != int:
raise TypeError("The year must be an int")
# Here I need to use __year again
elif year != 0:
self.__year = year
else:
# In any other case, I raise an exception
raise ValueError("Year must be not equal to 0")

#We create properties and setters also for day and month
@property
def month(self) -> str:
""" :return : the name of the month in small caps"""
return self.__month

@month.setter
def month(self, month: str):
# Notice that inside a method we can use local variables as we did with
# functions. Do not use self. with them or they will become attributes
# and they will be visible outside the class. We only want this variable
# to be used inside this method.
days_in_month = {'january':31, 'february': 28, "march": 31, "april":30,
"may": 31, "june": 30, "july": 31, "august": 31,
"september": 30, "october": 31, "november": 30,
"december":31}
if type(month) != str:
raise TypeError("The month must be a string")
elif month.lower() in days_in_month:
self.__month = month.lower()
else:
raise ValueError("Valid months are " + str(list(days_in_month)))

@property
def day(self) -> int:

18
return self.__day

@day.setter
def day(self, day: int):
# We cannot use a variable defined in another method, so we need to␣
↪repeat

# it here. Later we will see how to avoid this.


days_in_month = {'january':31, 'february': 28, "march": 31, "april":30,
"may": 31, "june": 30, "july": 31, "august": 31,
"september": 30, "october": 31, "november": 30,
"december":31}
# First checking the days of February
# I change the days if leap year
if self.year % 4 == 0 and (self.year % 100 != 0 or self.year % 400 ==␣
↪0):

days_in_month['february'] = 29
if type(day) != int:
raise TypeError("The day must be an integer")
elif day > 0 and day <= days_in_month[self.month]:
self.__day = day
else:
if self.month == 2:
raise ValueError("February has only 28 days in " + str(self.
↪year))

else:
raise ValueError(self.month + " has only " +␣
↪str(days_in_month[self.month])

+ " days")

def max_days_in_month(self) -> int:


""" A method that returns the maximum days of this month"""
# We cannot use a variable defined in another method, so we need to␣
↪repeat

# it here. Later we will see how to avoid this.


days_in_month = {'january':31, 'february': 28, "march": 31, "april":30,
"may": 31, "june": 30, "july": 31, "august": 31,
"september": 30, "october": 31, "november": 30,
"december":31}
# I change the days of February if leap year
if self.year % 4 == 0 and (self.year % 100 != 0 or self.year % 400 ==␣
↪0):

days_in_month['february'] = 29
return days_in_month[self.month]

def __str__(self) -> str:


"""This method is invoked if I print the object. It must

19
be called like this, no changes in name, no extra parameters.
It must return the string I want to be shown when printing"""
sufix = "th"
if self.day == 1 or self.day == 21 or self.day == 31:
sufix = "st"
elif self.day == 2 or self.day == 22:
sufix = "nd"
elif self.day == 3 or self.day == 23:
sufix = "rd"
return self.month + " " + str(self.day) + sufix +", " + str(self.year)

def __repr__(self) -> str:


"""This method is invoked if I write the name of the object in
the interpreter. It must
be called like this, no changes in name, no extra parameters.
It must return the string I want to be shown"""
# I just invoke the str
return self.__str__()

def __eq__(self, another) -> bool:


""" This is the method Python will invoke if I try to compare
two Date objects. In addition to self it must receive the other
object. It must return True or False"""
# If the type of the other object is not Date we will raise
# a NotImplemented excpetion
if type(another) == Date:
# Two dates are equal if the values of all their attributes are
# equal
return (self.day == another.day and self.month == another.month
and self.year == another.year)
else:
raise NotImplementedError("== can only compare Date objects")

[8]: d7 = Date(24, "november", 2020)


# Notice that if I print the object, the __str_ method is executed
print(d7)
d8 = Date(24, "november", 2020)
print(d7 == d8)
d9 = Date(24, "november", 2021)
print(d7 == d9)
# This will execute __repr__
d7
# As this comparison is not implemented, it performs the default behavior,
# which is checking the memory positions
print(d7 == enem)

november 24th, 2020


True

20
False
False
Exercise: Create magic methods for the Enemy class

[46]: import random


class Enemy:
""" This class represents an enemy"""
def __init__(self, x: int, y: int):
""" Init method of the class.
:param x: the x of the enemy
:param y: the y of the enemy"""
# All the self.something are attributes
self.x = x
self.y = y
# direct is a local variable
direct = random.randint(0, 1)
if direct == 1:
self.direction = "right"
else:
self.direction = "left"
self.image = "image.png"

@property
def x(self) -> int:
return self.__x

@property
def y(self) -> int:
return self.__y

@property
def direction(self) -> str:
return self.__direction

@property
def image(self) -> str:
return self.__image

@x.setter
def x(self, x: int):
if type(x) != int:
raise TypeException ("The coordinate x must be an int")
elif x < 0:
raise ValueException("No negative coordinates allowed")
else:
self.__x = x

21
@y.setter
def y(self, y: int):
if type(y) != int:
raise TypeException ("The coordinate y must be an int")
elif y < 0:
raise ValueException("No negative coordinates allowed")
else:
self.__y = y

@direction.setter
def direction(self, direction: str):
if type(direction) != str:
raise TypeError("The direction must be string")
elif direction.lower() != "left" and direction.lower() != "right":
raise ValueError("The valid directions are 'left' or 'right'")
else:
self.__direction = direction.lower()

@image.setter
def image(self, image: str):
if type(image) != str:
raise TypeError("The image must be a file name (string)")
else:
self.__image = image

def change_direction(self):
""" Changes the value of the 'direction' attribute of an enemy"""
if self.direction == "right":
self.direction = "left"
else:
self.direction = "right"

def __str__(self) -> str:


return "Enemy at " + str(self.x) + ", " + str(self.y) + " moving to the␣
↪" + str(self.direction)

def __repr__(self) -> str:


return self.__str__()

def __eq__ (self, another) -> bool:


if type(another) == Enemy:
return self.x == another.x and self.y == another.y and self.
↪direction == another.direction and self.image == another.image

else:
raise NotImplementedError("Only enemies can be compared")

22
[47]: enem1 = Enemy(10,10)
enem2 = Enemy(10,10)
print(enem1)
print(enem2)
print(enem1 == enem2)

Enemy at 10, 10 moving to the left


Enemy at 10, 10 moving to the left
True

9 Encapsulation: Information hiding


In OO we put together all the information related to objects of one type along with the methods
needed to work with them. This is called encapsulation and it is one of the three characteristics of
OO (the other two ones are inheritance and polymorphism).
Another advantage of OO is that we can hide the way classes are implemented, hiding some at-
tributes and methods so they are not visible outside the class or making some attributes to be read
only (they cannot be changed)

9.1 Read-only attributes


It is quite common to have some attributes whose value we want to read but we don’t want to
allow any program to change them.
In Python we get this behavior by a having a property without setter. This allows us to have new
attributes whose values are calculated using other attributes.

[23]: # A Date class with read-only attribute


class Date:
"""A class to store a date information. We will use properties/setters to
check the values provided are good. It has also by default values for
the parameters"""

def __init__(self, day: int = 1, month: str = "january",


year: int = 1900):
""" This method both declares the attributes of the class and
receives the initial value for all them
:param day: the day
:param m: the month
:param year: the year
"""
# No checking is done here, everything is delegated to the setters
# As I have a setter for year, this is totally equivalent to an␣
↪invocation

# to the year method: self.year(value)


self.year = year
# This also invokes the month setter
self.month = month

23
# As we need the month and the year to check the values of the day,
# the day attribute must be declared after the year and month ones
self.day = day

# This @property keyword is called a 'decorator'


@property
def year(self) -> int:
""" This special method will return the value of the year
:return : the year"""
# Here I must return my attribute preceded by __
# If I don't do it it will not work
return self.__year

# As this method's name is equal to the previous one, to avoid replacing


# the previous one, it needs to be decorated with @attribute.setter
@year.setter
def year(self, year: int):
""" This method allows to change the value of the year
:param year: the value of the year, if 0 is given an error is raisen"""
# If the type is not correct we raise an exception
if type(year) != int:
raise TypeError("The year must be an int")
# Here I need to use __year again
elif year != 0:
self.__year = year
else:
# In any other case, I raise an exception
raise ValueError("Year must be not equal to 0")

#This is a read only field that we can read inside the class or outside it
@property
def leap_year(self) -> bool:
""" The value of leap_year is calculated using other fields"""
return self.year % 4 == 0 and (self.year % 100 != 0 or self.year % 400␣
↪== 0)

#We create properties and setters also for day and month
@property
def month(self) -> str:
""" :return : the name of the month in small caps"""
return self.__month

@month.setter
def month(self, month: str):
# Notice that inside a method we can use local variables as we did with
# functions. Do not use self. with them or they will become attributes
# and they will be visible outside the class. We only want this variable

24
# to be used inside this method.
days_in_month = {'january':31, 'february': 28, "march": 31, "april":30,
"may": 31, "june": 30, "july": 31, "august": 31,
"september": 30, "october": 31, "november": 30,
"december":31}
if type(month) != str:
raise TypeError("The month must be a string")
elif month.lower() in days_in_month:
self.__month = month.lower()
else:
raise ValueError("Valid months are " + str(list(days_in_month)))

@property
def day(self) -> int:
return self.__day

@day.setter
def day(self, day: int):
# We cannot use a variable defined in another method, so we need to␣
↪repeat

# it here. Later we will see how to avoid this.


days_in_month = {'january':31, 'february': 28, "march": 31, "april":30,
"may": 31, "june": 30, "july": 31, "august": 31,
"september": 30, "october": 31, "november": 30,
"december":31}
# First checking the days of February
# I change the days if leap year
if self.leap_year:
days_in_month['february'] = 29
if type(day) != int:
raise TypeError("The day must be an integer")
elif day > 0 and day <= days_in_month[self.month]:
self.__day = day
else:
if self.month == 2:
raise ValueError("February has only 28 days in " + str(self.
↪year))

else:
raise ValueError(self.month + " has only " +␣
↪str(days_in_month[self.month])

+ " days")

def max_days_in_month(self) -> int:


""" A method that returns the maximum days of this month"""
# We cannot use a variable defined in another method, so we need to␣
↪repeat

# it here. Later we will see how to avoid this.

25
days_in_month = {'january':31, 'february': 28, "march": 31, "april":30,
"may": 31, "june": 30, "july": 31, "august": 31,
"september": 30, "october": 31, "november": 30,
"december":31}
# I change the days of February if leap year
if self.leap_year:
days_in_month['february'] = 29
return days_in_month[self.month]

def __str__(self) -> str:


"""This method is invoked if I print the object. It must
be called like this, no changes in name, no extra parameters.
It must return the string I want to be shown when printing"""
sufix = "th"
if self.day == 1 or self.day == 21 or self.day == 31:
sufix = "st"
elif self.day == 2 or self.day == 22:
sufix = "nd"
elif self.day == 3 or self.day == 23:
sufix = "rd"
leap = " (non leap)"
if self.leap_year:
leap = " (leap)"
return self.month + " " + str(self.day) + sufix +", " + str(self.year)␣
↪+ leap

def __repr__(self) -> str:


"""This method is invoked if I write the name of the object in
the interpreter. It must
be called like this, no changes in name, no extra parameters.
It must return the string I want to be shown"""
# I just invoke the str
return self.__str__()

def __eq__(self, another) -> bool:


""" This is the method Python will invoke if I try to compare
two Date objects. In addition to self it must receive the other
object. It must return True or False"""
# If the type of the other object is not Date we will raise
# a NotImplemented excpetion
if type(another) == Date:
# Two dates are equal if the values of all their attributes are
# equal
return (self.day == another.day and self.month == another.month
and self.year == another.year)
else:
raise NotImplementedError("== can only compare Date objects")

26
[22]: # Creating the object and printing it
obj = Date(14, "november", 2022)
print(obj)
# We can read the value of leap_year
print(obj.leap_year)
# But if we try to set it, we have an error
obj.leap_year = 12

november 14th, 2022 (non leap)


False

---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In[22], line 7
5 print(obj.leap_year)
6 # But if we try to set it, we have an error
----> 7 obj.leap_year = 12

AttributeError: property 'leap_year' of 'Date' object has no setter

Exercise. Convert the image attribute of the Enemy class into a read only one

[48]: import random


class Enemy:
""" This class represents an enemy"""
def __init__(self, x: int, y: int):
""" Init method of the class.
:param x: the x of the enemy
:param y: the y of the enemy"""
# All the self.something are attributes
self.x = x
self.y = y
# direct is a local variable
direct = random.randint(0, 1)
if direct == 1:
self.direction = "right"
else:
self.direction = "left"
# self.image is no longer here as it will be read only

@property
def x(self) -> int:
return self.__x

@property
def y(self) -> int:
return self.__y

27
@property
def direction(self) -> str:
return self.__direction

@property
def image(self) -> str:
""" A read only attribute without setter"""
return "image.png"

@x.setter
def x(self, x: int):
if type(x) != int:
raise TypeException ("The coordinate x must be an int")
elif x < 0:
raise ValueException("No negative coordinates allowed")
else:
self.__x = x

@y.setter
def y(self, y: int):
if type(y) != int:
raise TypeException ("The coordinate y must be an int")
elif y < 0:
raise ValueException("No negative coordinates allowed")
else:
self.__y = y

@direction.setter
def direction(self, direction: str):
if type(direction) != str:
raise TypeError("The direction must be string")
elif direction.lower() != "left" and direction.lower() != "right":
raise ValueError("The valid directions are 'left' or 'right'")
else:
self.__direction = direction.lower()

# No setter for image

def change_direction(self):
""" Changes the value of the 'direction' attribute of an enemy"""
if self.direction == "right":
self.direction = "left"
else:
self.direction = "right"

def __str__(self) -> str:

28
return "Enemy at " + str(self.x) + ", " + str(self.y) + " moving to the␣
↪ " + str(self.direction)

def __repr__(self) -> str:


return self.__str__()

def __eq__ (self, another) -> bool:


if type(another) == Enemy:
return self.x == another.x and self.y == another.y and self.
↪direction == another.direction and self.image == another.image

else:
raise NotImplementedError("Only enemies can be compared")

[49]: enem3 = Enemy(2,2)


print(enem3)
print(enem3.image)

Enemy at 2, 2 moving to the right


image.png

9.2 Private attributes and private methods


• Attributes have two main properties:
• They can be used by any method of the class by using the keyword self.attribute
• They can be read/written outside the class. When I create an object I can use
obj.attribute to read/write it (if property/setter have been defined for them, we
are really executing the property to read it and the setter to write it, but this is
transparent to the user of the class)
• As we have seen if I create a property but not a setter, I will have a kind of read-only attribute,
which can be read but not changed.
• If I need some variable to be used by any method of the class, but I don’t want it to be
read/written outside the class I can make it a private attribute.
• The way to do it is to convert it into an attribute (it will be part of self.) but name it with
self.__attribute (unlike in the magic methods we don’t finish the name with __, actually
if we do self.__attribute__ it is no longer a private one)
• Private attributes allow for information hiding: I hide the way I store the data inside my
class, I only show you the information I want you to see, not all the attributes I have inside
the class.
• Rules for the values stored in a class and the types of variables I should create for them:
• If I need a variable to be read/written outside the class, it should be declared as
an attribute (with properties and setters if I need to control its valid values)
• If I need a variable to be read outside the class, but I don’t want it to be written
outside, I use a property without setter

29
• If I need a variable to be used only internally but by more than one method, it
should be declared as a private attribute
• If variable is going to be used only by one method, it should be declared as a local
variable
• I can also have private methods def __method(self, ...) they can be used inside the class
but not outside it.

[1]: # A Date class with a private attribute


class Date:
"""A class to store a date information. We will use properties/setters to
check the values provided are good. It has also by default values for
the parameters"""

def __init__(self, day: int = 1, month: str = "january",


year: int = 1900):
""" This method both declares the attributes of the class and
receives the initial value for all them
:param day: the day
:param m: the month
:param year: the year
"""
# I will use this attribute to set month and day but I don't
# want it to be seen outside, so I declare it as a private attribute.
# Notice the leading __ that marks it is private
self.__days_in_month = {'january':31, 'february': 28, "march": 31,
"april":30, "may": 31, "june": 30, "july": 31,
"august": 31, "september": 30, "october": 31,
"november": 30, "december":31}
# No checking is done here, everything is delegated to the setters
# As I have a setter for year, this is totally equivalent to an␣
↪invocation

# to the year method: self.year(value)


self.year = year
# This also invokes the month setter
self.month = month
# As we need the month and the year to check the values of the day,
# the day attribute must be declared after the year and month ones
self.day = day

# This @property keyword is called a 'decorator'


@property
def year(self) -> int:
""" This special method will return the value of the year
:return : the year"""
# Here I must return my attribute preceded by __
# If I don't do it it will not work
return self.__year

30
# As this method's name is equal to the previous one, to avoid replacing
# the previous one, it needs to be decorated with @attribute.setter
@year.setter
def year(self, year: int):
""" This method allows to change the value of the year
:param year: the value of the year, if 0 is given an error is raisen"""
# If the type is not correct we raise an exception
if type(year) != int:
raise TypeError("The year must be an int")
# Here I need to use __year again
elif year != 0:
self.__year = year
# We also change the value of the days of February
if self.leap_year:
self.__days_in_month['february'] = 29
else:
self.__days_in_month['february'] = 28
else:
# In any other case, I raise an exception
raise ValueError("Year must be not equal to 0")

#We create properties and setters also for day and month
@property
def month(self) -> str:
""" :return : the name of the month in small caps"""
return self.__month

#This is a read only field that we can read inside the class or outside it
@property
def leap_year(self) -> bool:
""" The value of leap_year is calculated using other fields"""
return self.year % 4 == 0 and (self.year % 100 != 0 or self.year % 400␣
↪== 0)

@month.setter
def month(self, month: str):
if type(month) != str:
raise TypeError("The month must be a string")
elif month.lower() in self.__days_in_month:
self.__month = month.lower()
else:
raise ValueError("Valid months are " + str(list(self.
↪__days_in_month)))

@property
def day(self) -> int:

31
return self.__day

@day.setter
def day(self, day: int):
if type(day) != int:
raise TypeError("The day must be an integer")
elif day > 0 and day <= self.__days_in_month[self.month]:
self.__day = day
else:
if self.month == 2:
raise ValueError("February has only 28 days in " + str(self.
↪year))

else:
raise ValueError(self.month + " has only " + str(self.
↪__days_in_month[self.month])

+ " days")

def max_days_in_month(self) -> int:


""" A method that returns the maximum days of this month"""
return self.__days_in_month[self.month]

def __str__(self) -> str:


"""This method is invoked if I print the object. It must
be called like this, no changes in name, no extra parameters.
It must return the string I want to be shown when printing"""
sufix = "th"
if self.day == 1 or self.day == 21 or self.day == 31:
sufix = "st"
elif self.day == 2 or self.day == 22:
sufix = "nd"
elif self.day == 3 or self.day == 23:
sufix = "rd"
leap = " (non leap)"
if self.leap_year:
leap = " (leap)"
return self.month + " " + str(self.day) + sufix +", " + str(self.year)␣
↪+ leap

def __repr__(self) -> str:


"""This method is invoked if I write the name of the object in
the interpreter. It must
be called like this, no changes in name, no extra parameters.
It must return the string I want to be shown"""
# I just invoke the str
return self.__str__()

def __eq__(self, another) -> bool:

32
""" This is the method Python will invoke if I try to compare
two Date objects. In addition to self it must receive the other
object. It must return True or False"""
# If the type of the other object is not Date we will raise
# a NotImplemented excpetion
if type(another) == Date:
# Two dates are equal if the values of all their attributes are
# equal
return (self.day == another.day and self.month == another.month
and self.year == another.year)
else:
raise NotImplementedError("== can only compare Date objects")

[20]: # I create the object


obj = Date(1, "december", 2021)
# I can read and change the values of attributes
print(obj)
obj.month = "february"
print(obj)
print(obj.max_days_in_month())
# If I change the year, the number of days of February changes too
obj.year = 2024
print(obj)
print(obj.max_days_in_month())
# As I converted days_in_month into an attribute, now I could read or
# change it in my program, which is something I don't want. That's
# the reason why I marked it as a private attribute. Now it cannot
# be read neither changed. The following code will raise an error
#print(obj.__days_in_month)
#obj.__days_in_month["february"] = 31
#print(obj.days_in_month)

december 1st, 2021


february 1st, 2021
28
29

10 Composition
• In OOP there are two ways to create a class using another class: composition and inheritance.
• In composition one of the attributes of a class is an object of another class

[59]: class Student:


""" A class to represent a student. It uses composition"""
def __init__(self, name: str, surname: str, birthday: Date):
self.name = name
self.surname = surname

33
# birthday must be an object of Date class
# we should add a property and a setter for it
self.birthday = birthday

# I don't create properties for name and surname, everything will


# be considered a valid name, even a number
# But for birthday I need to check it is a Date object
@property
def birthday(self) -> Date:
return self.__birthday

@birthday.setter
def birthday(self, bd):
if type(bd) != Date:
raise TypeError("The birthday must be a date")
else:
self.__birthday = bd

def __str__(self) -> str:


return self.name + " " + self.surname + " born on " + str(self.birthday)

def __repr__(self) -> str:


return self.__str__()

def __eq__(self, other) -> bool:


if type(other) == Student:
return self.name == other.name and self.surname == other.surname␣
↪and self.birthday == other.birthday

else:
raise NotImplementedError()

[60]: # First way to create a composed object


# We use an auxiliary variable
d = Date(1, "december", 1984)
st = Student("Pepe", "Perez", d)
# The name of the student
print("The name of the student is", st.name)
# The day the student was born
print("His born day is", st.birthday.day)
# Second way to create a composed object, on the fly
st2 = Student("Pepe", "Perez", Date(1, "december", 2002))
print(st2)
print("Are", st, "and", st2, "equal?", st == st2)

The name of the student is Pepe


His born day is 1
Pepe Perez born on december 1st, 2002 (non leap)
Are Pepe Perez born on december 1st, 1984 (leap) and Pepe Perez born on december

34
1st, 2002 (non leap) equal? False

11 Inheritance
• Second way to create a new class from an existing one (different from composition)
• We want to create a new class that has everything that another class has plus some new
attributes and new methods and maybe we would like also to modify the implementation of
some of the methods.
• Inheritance is like copying the code of one class (called mother class or super class) into
another one (named child class or subclass), but it has some advantages:
• No need to copy the code, it is done automatically
• Any change done to the mother class will be automatically inherited by the child
class (no need to copy it again)
• If I want to add new attributes I need to put them in the new init method and rely on the
inherited one for the old ones using super()
• In general if I want to change a method I need to overwrite it, but if I want I can use the old
version (the one from the mother class) to do it. In this case the keyword super() is used

[72]: # Example of a mother class


class Product:
""" This class represents a product in an online store"""
def __init__(self, name: str, price: float):
self.name = name
self.price = price

@property
def price(self) -> float:
return self.__price

@price.setter
def price(self, price: float):
if type(price) != float and type(price) != int:
raise TypeError("The price must be a number")
elif price < 0:
raise ValueError("Do you really want a negative price?")
else:
self.__price = price

def __str__(self) -> str:


return self.name + ": " + str(self.price)+"€"

def __repr__(self) -> str:


return self.__str__()

def __eq__(self, other) -> bool:

35
""" Two products are equal if they have the same name, despite of the␣
↪price"""
if type(other) == Product:
return self.name == other.name
else:
raise NotImplementedError("Cannot compare " + str(type(self)) + "␣
↪and " + str(type(other)))

def sale(self) -> str:


""" Reduces the price by a 50%"""
self.price = round(self.price / 2, 2)

[73]: # Creating an object


p = Product("toy",34.99)
print(p)
# Invoking the sale() method
p.sale()
print(p)

toy: 34.99€
toy: 17.5€

[74]: # This is an example of inheritance, I don't copy the code of the


# Product class
# I put (Product) in the class header to use inheritance
# That indicates that Product is the mother/super class and FreshProduct the
# child/sub class
# The new class adds a new method to the inherited ones
class FreshProduct(Product):
""" This class is a Fresh Product, it has a new method"""
def rotten_sale(self):
""" When the fresh product is rotten we give it for free"""
self.price = 0

[75]: # I can create objects of the subclass


fp = FreshProduct("apples", 1.2)
# The __str__ of the superclass is inherited
print(fp)
# Invoking a method of the superclass
fp.sale()
print(fp)
fp.rotten_sale()
print(fp)
# I have even the properties of the superclass
fp.price = 1
print(fp)
fp.price = -1

36
apples: 1.2€
apples: 0.6€
apples: 0€
apples: 1€

---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[75], line 13
11 fp.price = 1
12 print(fp)
---> 13 fp.price = -1

Cell In[72], line 17, in Product.price(self, price)


15 raise TypeError("The price must be a number")
16 elif price < 0:
---> 17 raise ValueError("Do you really want a negative price?")
18 else:
19 self.__price = price

ValueError: Do you really want a negative price?

[82]: # Subclass that adds a new method and some attributes to the superclass
# It will overwrite __init__ and __str_ using the equivalent methods
# of the superclass
class FreshProduct(Product):

# I need to rewrite the init method of the superclass if I want


# to add new attributes
def __init__(self, name: str, price: float, expiration: Date):
# I am reusing the superclass __init__ to avoid rewritting it
# from scratch
# That means go to the super class, find the init method and
# pass to it the values of the parameters
super().__init__(name, price)
# Creating the new attribute
self.expiration = expiration

@property
def expiration(self) -> Date:
return self.__expiration

@expiration.setter
def expiration(self, d):
if type(d) != Date:
raise TypeError("The expiration must be a date")
else:

37
self.__expiration = d

# A new method is added to the child class


def rotten_sale(self):
""" When the fresh product is rotten we give it for free"""
self.price = 0

# Overwritting the __str__ to include the information of the new attribute


def __str__(self):
# I reuse the __str__ of the mother class
return super().__str__() + " expires on " + str(self.expiration)

# No need to do it with repr as it actually invokes str

# Overwriting the eq
def __eq__(self, other) -> bool:
""" Two Fresh Products are equal if they have the same name and␣
↪expiration date"""

if type(other) == FreshProduct:
return super().__eq__(other) and self.expiration == other.expiration
else:
raise NotImplementedError("Cannot compare " + str(type(self)) + "␣
↪and " + str(type(other)))

[78]: fp4 = FreshProduct("banana", 1.2, Date(12, "january", 2023))


print(fp4)
fp4

banana: 1.2€ expires on january 12th, 2023 (non leap)

[78]: banana: 1.2€ expires on january 12th, 2023 (non leap)

[79]: fp5 = FreshProduct("banana", 3.2, Date(12, "january", 2023))


# They should be equal, but an exception appears!!!
print("Are", fp4, "and", fp5, "equal?", fp4 == fp5)
# Why? Because the eq of Product checks that the type of the other must be␣
↪Product

# and actually both are FreshProduct

---------------------------------------------------------------------------
NotImplementedError Traceback (most recent call last)
Cell In[79], line 3
1 fp5 = FreshProduct("banana", 3.2, Date(12, "january", 2023))
2 # They should be equal, but an exception appears!!!
----> 3 print("Are", fp4, "and", fp5, "equal?", fp4 == fp5)
4 # Why? Because the eq of Product checks that the type of the other must␣
↪be Product

5 # and actually both are FreshProduct

38
Cell In[77], line 44, in FreshProduct.__eq__(self, other)
42 """ Two Fresh Products are equal if they have the same name and␣
↪expiration date"""

43 if type(other) == FreshProduct:
---> 44 return super().__eq__(other) and self.expiration == other.expiration
45 else:
46 raise NotImplementedError("Cannot compare " + str(type(self)) + "␣
↪and " + str(type(other)))

Cell In[72], line 32, in Product.__eq__(self, other)


30 return self.name == other.name
31 else:
---> 32 raise NotImplementedError("Cannot compare " + str(type(self)) + "␣
↪and " + str(type(other)))

NotImplementedError: Cannot compare <class '__main__.FreshProduct'> and <class␣


↪'__main__.FreshProduct'>

12 Polymorphism
• Is the fact that an object of the child class is also an object of the mother class
• We can see it with the difference between type() and isinstance()

[28]: p = Product("toy", 22.99)


fp = FreshProduct("apples", 1.2, Date(12, "february", 2021))
# The types are Product and FreshProduct, respectively
print("The type of", p, "is:", type(p))
print("The type of", fp, "is:", type(fp))
# Checking if they are instances of their respective classes
print("Is", p, "a Product?", isinstance(p, Product))
print("Is", fp, "a FreshProduct?", isinstance(fp, FreshProduct))
# Thanks to polymorphism this is true too
print("Is", fp, "a Product?", isinstance(fp, Product))
# Of course the other way is false
print("Is", p, "a FreshProduct?", isinstance(p, FreshProduct))

The type of toy: 22.99€ is: <class '__main__.Product'>


The type of apples: 1.2€ expires on february 12th, 2021 (non leap) is: <class
'__main__.FreshProduct'>
Is toy: 22.99€ a Product? True
Is apples: 1.2€ expires on february 12th, 2021 (non leap) a FreshProduct? True
Is apples: 1.2€ expires on february 12th, 2021 (non leap) a Product? True
Is toy: 22.99€ a FreshProduct? False

39
[80]: # Rewriting the Product class to use isinstance in the eq instead of type
class Product:
""" This class represents a product in an online store"""
def __init__(self, name: str, price: float):
self.name = name
self.price = price

@property
def price(self) -> float:
return self.__price

@price.setter
def price(self, price: float):
if type(price) != float and type(price) != int:
raise TypeError("The price must be a number")
elif price < 0:
raise ValueError("Do you really want a negative price?")
else:
self.__price = price

def __str__(self) -> str:


return self.name + ": " + str(self.price)+"€"

def __repr__(self) -> str:


return self.__str__()

def __eq__(self, other) -> bool:


""" Two products are equal if they have the same name, despite of the␣
↪price"""

if isinstance(other, Product):
return self.name == other.name
else:
raise NotImplementedError("Cannot compare " + str(type(self)) + "␣
↪and " + str(type(other)))

def sale(self) -> str:


""" Reduces the price by a 50%"""
self.price = round(self.price / 2, 2)

[83]: # Now the if I run again the cell where FreshProduct is declared, its eq works␣
↪properly

fp4 = FreshProduct("banana", 1.2, Date(12, "january", 2023))


fp5 = FreshProduct("banana", 3.2, Date(12, "january", 2023))
print("Are", fp4, "and", fp5, "equal?", fp4 == fp5)

Are banana: 1.2€ expires on january 12th, 2023 (non leap) and banana: 3.2€
expires on january 12th, 2023 (non leap) equal? True

40
13 Multiple inheritance
• In Python a class can inherit from several classes: class A(B,C,D)
• If I use super() it looks first for the method at B, if it does not find it, it will look at C, and
so on
• If there are attributes with the same name I will keep only the one that changed last
• If I have several methods with the same name, I keep the one of the first mother class in the
list of mother classes
• But I can also invoke that method of any other mother class by writing:
MotherClass.method(object, parameters)

14 Summary: characteristics of OOP


• Encapsulation, inheritance and polymorphism
• Encapsulation: dividing my program in modules that contain some data and the algorithms
that work with those data. It can be achieved both by OOP (attributes and methods) and by
structured programming (global variables and functions). OOP allows also for information
hiding: private attributes and properties (getters) and setters

41

You might also like