Government Analysis Function and ONS Data Science Campus
1 Object Orientated Programming
Everything in Python is an object
Object Orientated Programming is a fundamental part of Python, and one that every Python user, from those learning it for the first time, to those experienced performing complex data analysis and writing software packages, will have used - whether they know it or not.
The first time a new learner runs .sort() or .pop() on a list, they are running code that is defined as part of the list object. When someone new to DataFrames and pandas looks at the .shape of a DataFrame, they are finding fundamental information that is part of their specific DataFrame object.
1.1 Methods and Attributes
Methods and attributes belong to objects themselves. They are first defined when the object class is created, and then have some effect on any instance of that object. A method acts like a function specific to the object - .sort() will take the items in a list, and sort them as best it can - reordering the list object. An attribute acts like a variable specific to that object - A DataFrame will have a .shape attribute that just states, as a tuple, the number of columns and number of rows that are included in the data. This attribute will be overwritten anytime more rows are appended, or extra columns are added, as the DataFrame class updates it anytime the dimensions change.
1.2 General Examples
Everything in Python is an object - any variable type will have some methods and attributes associated with it. For example:
List
.pop() - method to remove an item and return it for use outside of the list
.sort() - method to sort a list
.count() - method to count the number of occurrences of a value passed into the method
String
.upper() - method to convert the string to upper case
.split() - method to split the string by a specified character
**.__class__** - returns the class of the object - in this case str
DataFrame
.shape - attribute containing the size of the DataFrame
.append() - method to appends rows or columns to the DataFrame
.head() - method to display the first rows of the DataFrame
2 Classes
Creating our own classes allows us to model complicated real world entities by creating custom data types - for events, activities, customers. Connections between these entities can be built in - a customer could attend an event, and complete a specific activity at the event. When the class is created, the behaviour of entity can be defined, and code created to handle a variety of tasks.
For manipulating data, classes can be created to collate methods into an easy to understand structure. Popular packages used for analysis lean heavily into this - scikit-learn below has a LinearRegression class, which is a useful container for all the functions you might want to perform on a linear regression. Rather than having a variety of functions within sklearn with tediously long names to be fully descriptive - linear_model_linear_regression_coef_, linear_model_linear_regression_copy_X, a user can create a single linear regression lr object which contains all of the methods that might be needed.
Running dir() on the object created below shows all the methods available to it - below the ones beginning with underscores which are magic methods and are covered later, are those from the LinearRegression class.
from sklearn.linear_model import LinearRegression #requires scikit-learn for cell to run. lr = LinearRegression()dir(lr)
The keyword class is used to define a class in Python - much like def is used to define a function. At a glance, the syntax to define a class looks very similar to defining a function - the keyword class, followed by the name of the class, an optional open-close parentheses pair, and a colon:
class Parrot:# orclass Parrot():
Inside the brackets, an existing class name can be passed - this allows for the class being defined to inherit features from a parent class. If the class is not inheriting, the parentheses will generally be left out.
Class names should generally use the CapWords convention with an initial capital letter, which helps distinguish them from functions.
Once a class has been created, instances of it can be created and stored as variables. As with built-in classes, any number of instances of user-created classes can be created. Since there are no limitations on how many lists, dictionaries, or DataFrames that can be created the same is true for these.
polly = Parrot() molly = Parrot()
Here, two objects have been created - one called polly and one called molly. They are both created from the Parrot class, but are distinct objects. As with two separate lists, changes can be made to one parrot without effecting the other.
2.2init
There are a range of different things that can be added to a class, but arguably the most important is the Magic Method: init. This is an in-built Python command that is used to initialise the class - it is called when an object is first created.
init() works like any other function. Arguments can be passed into it, it can define variables, it can run other functions and do calculations. As it is called when an object is first initialised, it can be used to set up any fundamental properties of the class - things which are essential, and all examples of the class must have.
class Parrot():def__init__(self, name):""" initialises the object and sets some object wide attributes """self.name = name
Within this example, init() has two parameters, self, and name. self is a key part of classes, and allows for defined variables to be accessed within the wider class object, rather than being limited in scope to the function they are defined within. Generally, all methods within a class will have an inital parameter of self.
The second parameter, name, allows for a user to define their parrot object with a specific name. The line self.name = name takes the argument that the user defines, and stores it in the class wide self.name attribute, rather than their name being only usable within the init() method.
Note that in the code above a simple docstring has been added - the text between the triple sets of speech marks “““. Docstrings are good to include for all functions and methods.
Create a Coord class, where the initialisation method should take an x and y value. 1.1. Ensure that the initialised x and y values are stored as self. variables.
Create a Coord object called location with a numerical x and a numerical y value.
Print the value of \(x^2 + y^2\).
#1. Create a **`Coord`** class, where the initialisation method should take an x and y value.# 1.1. Ensure that the initialised x and y values are stored as **`self.`** variables.class Coord():def__init__(self, x, y):self.x = xself.y = y#2. Create a **`Coord`** object called **`location`** with a numerical **`x`** and a numerical **`y`** value. location = Coord(2,3)#3. Print the value of $x^2 + y^2$.print(location.x**2+ location.y**2)# or:print(pow(location.x,2) +pow(location.y, 2))
13
13
2.4 Self
Within the init() method, we can include any number of parameters which need to be specified on creation of a parrot object. Like other functions, these can have default values assigned to them, which are then optionally overwritten.
This is done in the exact same way as it is for a function, adding extra parameters and assigning to them a default value with an equals sign. Like with functions, any argument without a default value must come before those with default values.
class Parrot():def__init__(self, name, can_fly=True):self.name = namepolly = Parrot('polly')
But, unless we add a self. line for these values, we cannot access them! The cell below will not run, because can_fly is currently limited in scope to the init method. Without a self.can_fly = can_fly line, the variable is not much use, and the parrot won’t be able to fly.
polly.can_fly #doesn't work - there is no class level can_fly attribute
class Parrot():def__init__(self, name, can_fly=True):""" initialises the object and sets some object wide attributes """self.name = nameself.can_fly = can_flypolly = Parrot('polly')polly.can_fly #does work due to line 8
True
3 Attributes and Methods
Classes can have both attributes and methods associated to them, where attributes are primarily facts about the object - such as its name, its colour, or its age - and methods are things that the object can do, or that can be done to the object.
Taking a common object, the pandas DataFrame, it has attributes such as shape, dtypes, and columns. These are all things that describe parts of the DataFrame - the number of rows and columns, the data types of the columns, and the names of the columns respectively.
The DataFrame also has methods such as merge, drop_duplicates, and dropna - to combine DataFrames, remove any non-distinct rows, and to remove null values. These all do things to the DataFrame, and generally have names with verbs in them. When used, they require a pair of parentheses () and have a number of mandatory and optional parameters.
User-defined classes can equally have any number of attributes and methods. In the earlier examples, the parrot has a name attribute, and then the can_fly attribute was created with a default value. As with Python in general, the only limit to the number of attributes and methods is the amount of computational power available, and the patience to write the code.
class Parrot():def__init__(self, name, species, can_fly=True):""" initialises the object and sets some object wide attributes """self.name = nameself.species = speciesself.plumage ='Beautiful'self.can_fly = can_flydef pine(self):print('{0} is pining for the fjords'.format(self.name))polly = Parrot('Polly','Norwegian Blue')
polly.pine()
Polly is pining for the fjords
In the above example a few more attributes have been set. The user can now specify the species when creating a parrot instance, and the plumage is set to ‘Beautiful’ for all parrots - with currently no way to change that. As with function definitions, any parameters with default arguments must come after those without, so can_fly has been pushed to the end.
Expand the Coord class created in Exercise 1 by adding a method called hypotenuse. 1.1. This should return the value of \(\sqrt{x^2 + y^2}\) - the distance from the origin. 1.2. NOTE: The default math library includes a sqrt() function, import math will be needed to use it. 1.3. Create an object with an x of 5, a y of 12, and display the hypotenuse. It should return 13.
Add a z attribute with a default value of 0.
Include this value in the hypotenuse calculation - \(\sqrt{x^2 + y^2 + z^2}\). 3.1. Create an object with x and y values of 4, and a z of 7, and display the hypotenuse. It should return 9.
Below is the solution code from Exercise 1:
#1. Create a **`Coord`** class, where the initialisation method should take an x and y value.# 1.1. Ensure that the initialised x and y values are stored as **`self.`** variables.class Coord():def__init__(self, x, y):self.x = xself.y = y#2. Create a **`Coord`** object called **`location`** with a numerical **`x`** and a numerical **`y`** value. location = Coord(2,3)#3. Print the value of $x^2 + y^2$.print(location.x**2+ location.y**2)# or:print(pow(location.x,2) +pow(location.y, 2))
#1. Expand the Coord class created in Exercise 1 by adding a method called hypotenuse.# 1.1. This should return the value of $\sqrt{x^2 + y^2}$ - the distance from the origin.# 1.2. NOTE: The default math library includes a sqrt() function. import mathclass Coord():def__init__(self, x, y):self.x = xself.y = ydef hypotenuse(self):return math.sqrt(self.x**2+self.y**2)# 1.3. Create an object with an x of 5, a y of 13, and display the hypotenuse. location = Coord(5, 12)print(location.hypotenuse())#2. Add a z attribute with a default value of 0class Coord():def__init__(self, x, y, z=0):self.x = xself.y = yself.z = zdef hypotenuse(self):return math.sqrt(self.x**2+self.y**2)#3. Include this value in the hypotenuse calculation - $\sqrt{x^2 + y^2 + z^2}$class Coord():def__init__(self, x, y, z=0):self.x = xself.y = yself.z = zdef hypotenuse(self):return math.sqrt(self.x**2+self.y**2+self.z**2)# 3.1. Create an object with x and y values of 4, and a z of 7, and display the hypotenuse. location = Coord(4, 4, 7)print(location.hypotenuse())
13.0
9.0
4 Inheritance
Our parrot is an animal, and there are a number of things which all living creatures can do. If a dog class was needed as well, the commonalities between dog and parrot could be coded in twice - once within each class, but that leads to the standard issue with duplicating code - it becomes very hard to maintain, and very easy to update one and not the other leading to diverging code bases.
Instead, a single animal class can be created as a parent class, which both the dog and parrot classes inherit from. All parent attributes and methods are available to objects of the child classes.
All living things breathe oxygen, have central nervous systems, and can move. This information could be added to every individual animal-type class manually, or a central Animal class can be created with these things set.
The following Animal class is currently fairly bare-boned - it just sets three attributes to True, and has a single move() method, but could easily be expanded. A mammal class could inherit from it and add things such as birth_live_young(), and warm_blooded = True.
class Animal():def__init__(self):""" initialises the object and sets some object wide attributes """self.breathe_oxygen =Trueself.nervous_system =Trueself.can_move =Truedef move(self, distance, direction):print('Move {0}{1}'.format(distance, direction))
manfred = Animal()manfred.nervous_system
True
Inheriting has a few important things to note, which could easily catch out the unaware. All methods will carry across without issue - an inheriting parrot will be able to move(). In the example below, the dog can both wag it’s tail, and has a move() method, and the three boolean attributes are set - clifford can breathe_oxygen and has a nervous_system. The dog class however doesn’t have it’s own init method, so instead it runs the parent’s version, and gets access to all of the parent attributes and methods.
class Dog(Animal):def wag_tail(self):print('Tail wagged')clifford = Dog()clifford.wag_tail()if clifford.can_move: clifford.move('5m', 'North')
Tail wagged
Move 5m North
However, if the child class has it’s own init method, the parent’s init will not be run by default!
class Parrot(Animal):def__init__(self, name, species, can_fly=True):""" initialises the object and sets some object wide attributes """self.name = nameself.species = speciesself.plumage ='Beautiful'self.can_fly = can_flydef pine(self):print('{0} is pining for the fjords'.format(self.name))polly = Parrot('Polly','Norwegian Blue')
Polly can run the move() method perfectly fine because it is not defined in the Animal initialisation method, but none of the attributes are set! Trying to call on them will return an AttributeError.
polly.move('500m', 'North')
Move 500m North
polly.can_move # This will generate an error!
To fully initialise the parent animal init attributes, the parrot init can call the animal init. Looking at the resulting tabbed list of options, or using dir, gives the full set of attributes and methods from both the animal class, and the parrot class:
class Parrot(Animal):def__init__(self, name, species, can_fly=True):""" initialises the object and sets some object wide attributes """self.name = nameself.species = speciesself.plumage ='Beautiful'self.can_fly = can_fly Animal.__init__(self) #including this line will set the three attributes set up in the animal __init__def pine(self):print('{0} is pining for the fjords'.format(self.name))polly = Parrot('Polly','Norwegian Blue')if polly.can_move: polly.move('500m', 'North')
Move 500m North
There are two ways to run the parent init:
Animal.__init__(self) super().__init__()
The first as used in the cell above calls the name of the parent class, and requires the self value to be passed in. The second uses the generic super() function, which doesn’t require self to be passed, and doesn’t require the name of the parent class to be known.
A child class is also considered by python to have multiple types. An object of the parrot class when tested with isinstance(polly, Parrot) will return True. However, it is also considered to be of the same type as the parent class - so isinstance(polly, Animal) will also return True.
Expand the Coord class from Exercise 2 by adding a method called values. 1.1. This should just return the values of x, y, and z.
Create a new class called Velocity. 2.1. This should inherit from the Coord class, and it’s initialisation should call that of the parent class.
Create a Velocity object called vel with values of 2, 4, and 6 for x, y, and z respectively. 3.1. Call the values method of vel to check the numbers have been assigned correctly.
Below is the solution code from Exercise 2:
#1. Expand the Coord class created in Exercise 1 by adding a method called hypotenuse.# 1.1. This should return the value of $\sqrt{x^2 + y^2}$ - the distance from the origin.# 1.2. NOTE: The default math library includes a sqrt() function. import mathclass Coord():def__init__(self, x, y):self.x = xself.y = ydef hypotenuse(self):return math.sqrt(self.x**2+self.y**2)# 1.3. Create an object with an x of 5, a y of 13, and display the hypotenuse. location = Coord(5, 12)print(location.hypotenuse())#2. Add a z attribute with a default value of 0class Coord():def__init__(self, x, y, z=0):self.x = xself.y = yself.z = zdef hypotenuse(self):return math.sqrt(self.x**2+self.y**2)#3. Include this value in the hypotenuse calculation - $\sqrt{x^2 + y^2 + z^2}$class Coord():def__init__(self, x, y, z=0):self.x = xself.y = yself.z = zdef hypotenuse(self):return math.sqrt(self.x**2+self.y**2+self.z**2)# 3.1. Create an object with x and y values of 4, and a z of 7, and display the hypotenuse. location = Coord(4, 4, 7)print(location.hypotenuse())
# 1. Expand the Coord class from Exercise 2 by adding a method called values.# 1.1. This should just return the values of x, y, and z.import mathclass Coord():def__init__(self, x, y, z=0):self.x = xself.y = yself.z = zdef hypotenuse(self):return math.sqrt(self.x**2+self.y**2+self.z**2)def values(self):return(self.x, self.y, self.z)# 2. Create a new class called Velocity.# 2.1. This should inherit from the Coord class, and it's initialisation should call that of the parent class. class Velocity(Coord):def__init__(self, x, y, z): Coord.__init__(self, x, y, z)# 3. Create a Velocity object called vel with values of 2, 4, and 6 for x, y, and z respectively.# 3.1. Call the values method of vel to check the numbers have been assigned correctly. vel = Velocity(2, 4, 6) vel.values()
(2, 4, 6)
5 Magic Methods
The syntax of the init method looks a bit strange with the surrounding double underscores. This is called a Magic Method, and it has a distinct set of functionality within a class. It behaves in a predefined way - in this case it runs whenever an object of the containing class is spun up.
There are a range of other Magic Methods which also behave in predefined ways. Some are useful for every user-defined class, others have slightly more niche uses, but are still powerful and useful to be aware of.
In general, magic methods aren’t called directly by programmers, rather they provide specific functionality when certain actions are applied to the class; for example the add magic method will be called if the + operator is used.
5.1 Displaying objects with repr and str
What happens if print() is run on a pandas DataFrame? Or just calling a DataFrame by itself? It displays information about the columns and the rows - showing some of the data contained within.
There are also functions str() and repr() that do similar things - they will display information about the object at hand.
import pandas as pddf = pd.DataFrame({'a':[1,2,3], 'b':[5,1,4]})print(df)
a b
0 1 5
1 2 1
2 3 4
df
a
b
0
1
5
1
2
1
2
3
4
If the same print() function is run on our parrot object, a fairly unhelpful line of text appears with the memory address of the object is displayed. Nothing particularly useful is displayed.
This can be fixed by including one of two Magic Methods, repr or str. By setting one, or both, of these, the results of running any of the three functions above, or just calling the name of the object, can be altered. There is a fair amount of overlap between the effects of these methods, which can be seen in the table below which highlights what happens if any combination of repr and str are included in a class definition.
repr
str
print()
str()
repr()
method call
Yes
Yes
Output of str
Output of str
Output of repr
Output of repr
Yes
No
Output of repr
Output of repr
Output of repr
Output of repr
No
Yes
Output of str
Output of str
Memory address
Memory address
No
No
Memory address
Memory address
Memory address
Memory address
Given all functions will return something useful if just repr is included, while only two of them will if only str is, it’s recommended to always use at least repr.
To be more specific about the difference, it’s useful to take a look at the effects on a datetime object.
The str function displays a human-readable version, useful for including in log messages, or to provide key information at a glance. repr on the other hand provides a version that is more technical, and more useful for developers - it provides the line of code that can be used to recreate the object in its entirety. It is a more formal display compared to the informality of str.
As with init, these methods can be added to the class in the usual way functions are defined, and should return a string that represents the object. Include in the string anything useful to define the instance of the object, such as it’s name, or key features of it - using string formatting to include self attributes is very useful.
Note also, that extended docstrings have been added where methods either have non-self parameters, or have a return.
class Parrot(Animal):def__init__(self, name, species, can_fly=True):""" Initialises the object and sets some object wide attributes Parameters ---------- name: str Given name of the parrot in question species: str Type of species of the parrot can_fly: boolean, optional Whether the parrot can fly. Defaults to True Notes ----- Calls parent animal class init method. Sets object wide 'plumage' variable to 'Beautiful' """self.name = nameself.species = speciesself.plumage ='Beautiful'self.can_fly = can_fly Animal.__init__(self) def__repr__(self): """ Specialised technical string to allow the reproduction of the object. Returns ------- str: call needed to re-create object """return"Parrot({0}, {1})".format(self.name, self.species)def__str__(self):""" More casual returned object describing the object. Returns ------- str: text describing the object """return"Object is a parrot called {0} with {1} plumage".format(self.name,self.plumage)def pine(self):""" Method to allow the parrot to pine for fjords """print('{0} is pining for the fjords'.format(self.name))polly = Parrot('Polly','Norwegian Blue')print(polly)
Object is a parrot called Polly with Beautiful plumage
repr(polly)
'Parrot(Polly, Norwegian Blue)'
The difference between the output of running print() and repr() is quite large.
In the above additions to the Parrot class, the word “parrot” has had to be hardcoded into both of the repr and str methods. While fine in this case, if the name of the class were changed, the descriptive methods would be out of date - another easy to miss manual fix that would be required.
Thankfully, the name of the class itself - Parrot - can be extracted using the code **self.__class__.__name__** - some clear usage of magic methods. Including this in the method calls will reduce the risk of code falling out of sync.
class Parrot(Animal):def__init__(self, name, species, can_fly=True):""" Initialises the object and sets some object wide attributes Parameters ---------- name: str Given name of the parrot in question species: str Type of species of the parrot can_fly: boolean, optional Whether the parrot can fly. Defaults to True Notes ----- Calls parent animal class init method. Sets object wide 'plumage' variable to 'Beautiful' """self.name = nameself.species = speciesself.plumage ='Beautiful'self.can_fly = can_fly Animal.__init__(self) def__repr__(self): """ Specialised technical string to allow the reproduction of the object. Returns ------- str: call needed to re-create object """return"{0}({1}, {2})".format(self.__class__.__name__,self.name, self.species)def__str__(self):""" More casual returned object describing the object. Returns ------- str: text describing the object """return"Object is a {0} called {1} with {2} plumage".format(self.__class__.__name__,self.name,self.plumage)def pine(self):print('{0} is pining for the fjords'.format(self.name))polly = Parrot('Polly','Norwegian Blue')print(polly)#can also be called directly on the object rather than within the class methodsprint(polly.__class__.__name__)
Object is a Parrot called Polly with Beautiful plumage
Parrot
Expand the Coord class from Exercise 3 by adding docstrings where methods either take inputs which are not self, or return an output. Do this for Velocity, and all future methods going forwards.
Further expand Coord by adding a repr method. 2.1. This should just return the code required to recreate the object.
Create both a Coord and a Velocity object. Test running str() and print on them both. 3.1. Does the Velocity object return the correct values? If not, try using **self.__class__.__name__** in the repr method.
Below is the solution code from Exercise 3:
# 1. Expand the Coord class from Exercise 2 by adding a method called values.# 1.1. This should just return the values of x, y, and z.import mathclass Coord():def__init__(self, x, y, z=0):self.x = xself.y = yself.z = zdef hypotenuse(self):return math.sqrt(self.x**2+self.y**2+self.z**2)def values(self):return(self.x, self.y, self.z)# 2. Create a new class called Velocity.# 2.1. This should inherit from the Coord class, and it's initialisation should call that of the parent class. class Velocity(Coord):def__init__(self, x, y, z): Coord.__init__(self, x, y, z)# 3. Create a Velocity object called vel with values of 2, 4, and 6 for x, y, and z respectively.# 3.1. Call the values method of vel to check the numbers have been assigned correctly. vel = Velocity(2, 4, 6) vel.values()
# 1. Expand the Coord class from Exercise 3 by adding docstrings where methods either take inputs which are not self, or return an output. Do this for Velocity, and all future methods going forwards.# 2. Further expand Coord by adding a __repr__ method.# 2.1. This should just return the code required to recreate the object.import mathclass Coord():def__init__(self, x, y, z=0):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float value along x axis y: float value along y axis z: float, optional value along z axis. Defaults to 0 """self.x = xself.y = yself.z = zdef hypotenuse(self):""" Returns the hypotenuse calculated from the Coordinate points Returns ------- float: sqrt(x^2 + y^2 + z^2) """return math.sqrt(self.x**2+self.y**2+self.z**2)def values(self): """ Returns the values of the Coordinate Returns ------- tuple(float, float, float): (x,y,z) """return(self.x, self.y, self.z)def__repr__(self):""" Specialised technical string to allow the recreation of the object. Returns ------- str: call needed to recreate object """return("{0}({1},{2},{3})".format(self.__class__.__name__, self.x, self.y, self.z)) class Velocity(Coord):def__init__(self, x, y, z):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float speed in x direction y: float speed in y direction z: float speed in z direction Notes ----- Calls parent Coord class init method. """ Coord.__init__(self, x, y, z)# 3. Create both a Coord and a Velocity object. Test running str() and print on them both. # 3.1. Does the Velocity object return the correct values? If not, try using self.__class__.__name__ in the __repr__ method. pos = Coord(2,3,4)vel = Velocity(5,1,2)print(str(pos))print(pos)print(str(vel))print(vel)### By using self.__class__.__name__ in the __repr__method, python handles inheritance very well. ### While __class__.__name__ gives the value of Coord while used in the Coord class, when inherited, ### the name of the child class takes precedence and is used instead. This allows the single repr method### to handle both the parent Coord, and the child Velocity, classes.
There are a number of magic methods which can be used to both compare objects, and combine them together. It is trivial to understand how to compare two strings or integers - if the strings are the same, string_a == string_b will return true, likewise for an integer. Greater than and less than behave well - they compare numbers on a numerical scale, and compare strings alphabetically. This comparison can be expanded to user-defined classes as well with a little bit of work.
The comparison methods available are:
eq - defines behaviour for equivalence - ==
ne - defines behaviour for non equivalence - !=
lt - defines behaviour for less than - <
gt - defines behaviour for greater than - >
le - defines behaviour for less than or equal to - <=
ge - defines behaviour for greater than or equal to - >=
It might not always be suitable to include all methods in a specific class. It would be difficult to quantify which of two parrots was greater and which was lesser, however it is easy to see whether two parrots are identical, or not identical, by comparing their attributes - if all attributes are the same, then the parrots are fundamentally identical.
Comparing all attributes could be time consuming - writing code such as:
for all attributes is tedious, and as before, prone to error. Even using the all() function to save typing the comparisons doesn’t help much. Thankfully, there is another magic_method called dict which can help us out. This displays as a dictionary all attributes of an object - with the attribute names as keys, and the stored value as values. Caution is needed when using this method on built-in objects however, as some have the method overwritten. For user-defined classes this is not a problem as the code can easily be interrogated to see if its functionality has been altered.
Straightforwardly, these should return True if the specified condition is met - for eq if the two objects are the same, while for ne if the objects are not the same.
The Parrot definition below has these two new methods and docstrings added.
class Parrot(Animal):def__init__(self, name, species, can_fly=True):""" Initialises the object and sets some object wide attributes Parameters ---------- name: str Given name of the parrot in question species: str Type of species of the parrot can_fly: boolean, optional Whether the parrot can fly. Defaults to True Notes ----- Calls parent animal class init method. Sets object wide 'plumage' variable to 'Beautiful' """self.name = nameself.species = speciesself.plumage ='Beautiful'self.can_fly = can_fly Animal.__init__(self) def__repr__(self): """ Specialised technical string to allow the recreation of the object. Returns ------- str: call needed to recreate object """return"{0}({1}, {2})".format(self.__class__.__name__,self.name, self.species)def__str__(self):""" More casual returned object describing the object. Returns ------- str: text describing the object """return"Object is a {0} called {1} with {2} plumage".format(self.__class__.__name__,self.name,self.plumage)def__eq__(self, other):""" Method to compare object with another and test for equivalency Parameters ---------- other: obj object to compare to Returns ------- boolean: True if object is the same as the comparison, False otherwise """returnself.__dict__ == other.__dict__def__ne__(self, other):""" Method to compare object with another and test for non-equivalency Parameters ---------- other: obj object to compare to Returns ------- boolean: True if object is NOT the same as the comparison, False otherwise """returnself.__dict__ != other.__dict__def pine(self):print('{0} is pining for the fjords'.format(self.name))polly = Parrot('Polly','Norwegian Blue')
# should return True as functionally identicalpolly == polly_two
True
# should return False as name and species are differentpolly == iago
False
There is a drawback with using the dict values for comparison - if a second class was created, which had the same attributes as the first, but with different methods, it would not be impossible for two objects to appear as identical, despite the actual differences. This however is a very niche issue, and is unlikely to arise.
Expand the Coord class from Exercise 4 by adding magic methods for eq and ne. 1.1. These should return True if the x, y, and z, values match for eq, and the oppposite for ne. 1.2. They should also return False if the names of the two objects don’t match - use **self.__class__.__name__** to compare.
Add methods for the following comparison operators to the Coord class: less than, <, less than or equal to, <=, greater than, >, and greater than or equal to >= 2.1. These should compare the absolute distance of the points under consideration. 2.2. They should return False if the names of the two objects don’t match as in 1.2 - comparing a Velocity with a Coord is not a reasonable thing to do.
Test the classes. 3.1. Create two Coord objects with different numerical values. Test out the comparison methods created above. 3.2. Create a Velocity object. Test comparing it with the Coord objects.
Below is the solution code from Exercise 4:
# 1. Expand the Coord class from Exercise 3 by adding docstrings where methods either take inputs which are not self, or return an output. Do this for Velocity, and all future methods going forwards.# 2. Further expand Coord by adding a __repr__ method.# 2.1. This should just return the code required to recreate the object.import mathclass Coord():def__init__(self, x, y, z=0):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float value along x axis y: float value along y axis z: float, optional value along z axis. Defaults to 0 """self.x = xself.y = yself.z = zdef hypotenuse(self):""" Returns the hypotenuse calculated from the Coordinate points Returns ------- float: sqrt(x^2 + y^2 + z^2) """return math.sqrt(self.x**2+self.y**2+self.z**2)def values(self): """ Returns the values of the Coordinate Returns ------- tuple(float, float, float): (x,y,z) """return(self.x, self.y, self.z)def__repr__(self):""" Specialised technical string to allow the recreation of the object. Returns ------- str: call needed to recreate object """return("{0}({1},{2},{3})".format(self.__class__.__name__, self.x, self.y, self.z)) class Velocity(Coord):def__init__(self, x, y, z):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float speed in x direction y: float speed in y direction z: float speed in z direction Notes ----- Calls parent Coord class init method. """ Coord.__init__(self, x, y, z)# 3. Create both a Coord and a Velocity object. Test running str() and print on them both. # 3.1. Does the Velocity object return the correct values? If not, try using self.__class__.__name__ in the __repr__ method. pos = Coord(2,3,4)vel = Velocity(5,1,2)print(str(pos))print(pos)print(str(vel))print(vel)### By using self.__class__.__name__ in the __repr__method, python handles inheritance very well. ### While __class__.__name__ gives the value of Coord while used in the Coord class, when inherited, ### the name of the child class takes precedence and is used instead. This allows the single repr method### to handle both the parent Coord, and the child Velocity, classes.
import mathclass Coord():def__init__(self, x, y, z):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float value along x axis y: float value along y axis z: float, optional value along z axis. Defaults to 0 """self.x = xself.y = yself.z = zdef hypotenuse(self):""" Returns the hypotenuse calculated from the Coordinate points Returns ------- float: sqrt(x^2 + y^2 + z^2) """return math.sqrt(self.x**2+self.y**2+self.z**2)def values(self):""" Returns the values of the Coordinate Returns ------- tuple(float, float, float): (x,y,z) """return(self.x, self.y, self.z)def__repr__(self):""" Specialised technical string to allow the recreation of the object. Returns ------- str: call needed to recreate object """#return("Coord({0},{1},{2})".format(self.x, self.y, self.z))return("{0}({1},{2},{3})".format(self.__class__.__name__, self.x, self.y, self.z)) ### ### Question 1 Below###def__eq__(self, other):""" Method to handle == comparisons. Returns True if attributes are the same AND if the class name is the same Parameters ---------- other: Coord Secondary Coordinate to compare to. Returns ------- bool: Whether two objects are identical Notes ----- comparing a Coord with a child of Coord will return False, regardless of the numbers involved. """ equivalence = (self.__dict__ == other.__dict__) and\ (self.__class__.__name__== other.__class__.__name__)return equivalencedef__ne__(self, other):""" Method to handle != comparisons. Returns True if attributes are not the same OR if the class name is not the same Parameters ---------- other: Coord Secondary Coordinate to compare to. Returns ------- bool: Whether two objects are not identical Notes ----- comparing a Coord with a child of Coord will return True, regardless of the numbers involved. """ equivalence = (self.__dict__ != other.__dict__) or\ (self.__class__.__name__!= other.__class__.__name__)return equivalence### ### Question 2 Below###def__lt__(self, other):""" Handles comparisons between self and other for less than Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is less than abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() < other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultdef__le__(self, other):""" Handles comparisons between self and other for less than or equal to Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is less than or equal to abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() <= other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultdef__gt__(self, other):""" Handles comparisons between self and other for greater than Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is greater than abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() > other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_result def__ge__(self, other):""" Handles comparisons between self and other for greater than or equal to Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is greater than or equal to abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() >= other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultclass Velocity(Coord):def__init__(self, x, y, z):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float speed in x direction y: float speed in y direction z: float speed in z direction Notes ----- Calls parent Coord class init method. """ Coord.__init__(self, x, y, z)### ### Question 3 Below###Coord_1 = Coord(1,2,3)Coord_2 = Coord(3,4,5)print(Coord_1 > Coord_2) #expect Falseprint(Coord_1 < Coord_2) #expect Trueprint(Coord_1 == Coord_2) #expect Falseprint(Coord_1 != Coord_2) #expect Trueprint(Coord_1 >= Coord_2) #expect Falseprint(Coord_1 <= Coord_2) #expect Truevel = Velocity(4,5,6)print(vel > Coord_2) #expect Falseprint(vel < Coord_2) #expect Falseprint(vel == Coord_2) #expect Trueprint(vel != Coord_2) #expect Falseprint(vel >= Coord_2) #expect Falseprint(vel <= Coord_2) #expect False
There are also a set of methods available to allow for objects to be combined via standard operators such as + and *****. There are a vast number of these, and again, not all will be useful for every object. Some of them are listed below:
add - defines behaviour for addition - +
sub - defines behaviour for subtraction - -
mul - defines behaviour for the multiplication - *****
pow - defines behaviour for the power operator - __**__
abs - defines behaviour for the abs function
round - defines behaviour for the round function
Some of these methods take an extra argument - for the value being added, subtracted or multiplied - but others do not - finding the absolute value requires no secondary arguments.
When considering methods such as add, it is important to think about commutativity. This is the principle that \(a\times b=b\times a\) - the order of operations does not matter. For numbers, addition and multiplication clearly are commutative, while subtraction and division are not. In python, string combination using the addition operator is not commutative - “a” + “b” == “ab”, while “b” + “a” == “ba”. For user-defined classes, should they be commutative? Again, it will depend on the class in question.
Within a user-defined class, if add is included, the class will be able to have things added to it - object + other_thing. However, other_thing + object will by default not work, as the class definition of other_thing will be called, and its add method used to handle the addition, which almost certainly won’t work for the other class.
Conveniently, there are magic methods available to handle this and allow us to add our objects to things, rather than just adding things to our objects. These are called reflected operators, and are generally the same as the normal ones above, but with an r at the beginning:
radd - defines reflected behaviour for addition - +
rsub - defines reflected behaviour for subtraction - -
rmul - defines reflected behaviour for the multiplication - *****
rpow - defines reflected behaviour for the power operator - __**__
There are also some methods for handling operators such as += - for assignment coupled with addition. These are generally the same as the normal ones above, but instead have an i at the beginning - i standing for in-place:
iadd - defines behaviour for addition with assignment - +=
isub - defines behaviour for subtraction with assignment - -=
imul - defines behaviour for the multiplication with assignment - *=
ipow - defines behaviour for the power operator with assignment - __**=__
If the processes in the original method are commutative, then generally the reflective and assignation operators can simply return the core method to save having to duplicate code:
With all these methods, thought should be given to how they handle unexpected data types. Putting an assert statement testing the variable type using isinstance at the beginning will make sure only expected data types are used and will avoid odd behaviour that could arise.
Using **self.__class__** as the comparison will again allow the overall class name to change in future without having to make changes throughout - it returns the class of the containing object, in this case, Parrot.
assertisinstance(other, self.__class__), “method can only handle objects of type {0}”.format(self.__class__.__name__)
class Parrot(Animal):def__init__(self, name, species, can_fly=True):""" Initialises the object and sets some object wide attributes Parameters ---------- name: str Given name of the parrot in question species: str Type of species of the parrot can_fly: boolean, optional Whether the parrot can fly. Defaults to True Notes ----- Calls parent animal class init method. Sets object wide 'plumage' variable to 'Beautiful' """self.name = nameself.species = speciesself.plumage ='Beautiful'self.can_fly = can_fly Animal.__init__(self) def__repr__(self): """ Specialised technical string to allow the recreation of the object. Returns ------- str: call needed to recreate object """return"{0}({1}, {2})".format(self.__class__.__name__,self.name, self.species)def__str__(self):""" More casual returned object describing the object. Returns ------- str: text describing the object """return"Object is a {0} called {1} with {2} plumage".format(self.__class__.__name__,self.name,self.plumage)def__eq__(self, other):""" Method to compare object with another and test for equivalency Parameters ---------- other: obj object to compare to Returns ------- boolean: True if object is the same as the comparison, False otherwise """returnself.__dict__ == other.__dict__def__ne__(self, other):""" Method to compare object with another and test for non-equivalency Parameters ---------- other: obj object to compare to Returns ------- boolean: True if object is NOT the same as the comparison, False otherwise """returnself.__dict__ != other.__dict__def__add__(self, other):""" Allows the addition of parrots to generate more parrot objects Parameters ---------- other: parrot secondary parrot to combine with Returns ------- parrot: a third parrot with a name and species made up of concatenations of the original """# check to see if other is of the same type as this - another parrotassertisinstance(other, self.__class__), 'can only add parrots to parrots'# new name is name of first parrot minus the last character, plus# name of second parrot, minus the first character combined_name =self.name[:-1] + other.name[1:]# new species is first word of first parrot species, plus second word # of second parrot species combined_species =self.species.split(' ')[0] +' '+\ other.species.split(' ')[1] offspring =self.__class__(combined_name, combined_species)return offspringdef pine(self):print('{0} is pining for the fjords'.format(self.name))polly = Parrot('Polly','Norwegian Blue')iago = Parrot('Iago', 'Scarlet Macaw')polly + iago
Parrot(Pollago, Norwegian Macaw)
iago + polly
Parrot(Iagolly, Scarlet Blue)
The assertion will block attempts to add a basic Animal object to a Parrot. Also, as only parrots can be added to parrots, there is no need to add a radd method, as reordering the parrots still works, it just uses the add of whichever parrot comes first - this addition is not commutative due to the way in which the offspring name and species are generated. It also doesn’t make much sense to add a iadd method either, as a new parrot is being generated, rather than an old one being overwritten.
Were an assignment addition method added, the results of polly += iago would be stored in polly - if it were run a few more times, the resulting object would have a name along the lines of Pollagagagago as each time the name will be extended with another ago!
Methods can also return self - this is seen often in pandas, where methods on a DataFrame often return a DataFrame object, to allow for methods to be chained together.
6.4 Checking Validity
The above uses isinstance() to see whether something can be combined with a Parrot - only other parrots. This gets a bit trickier if the add magic method is in the parent class, as a Parrot is considered to be an instance of an Animal. If the Animal class has the magic methods, any of its child classes will be considered instances of it, which would allow child objects to be added to the parent as the assert would unfortunately pass.
Generally the leading diagonal of the table is unimportant, as if the two objects are of the same class - either both the parent or both the child - they should be combinable. If they are different though, the fact that a child is an instance of a parent, coupled with the parent NOT being an instance of the child, is enough to distinguish them. Two assert statements can be used, one stating that the object being added, the other object, must of the same type as the containing class, and the other stating that the containing class must be of the same type as the other object.
The asserts are not commutative; the parent is not the same type as the child, while the child IS the same type as the parent.
assertisinstance(other, self.__class__),“objects to be added must be {0}”.format(self.__class__.__name__) assertisinstance(self, other.__class__),“objects to be added must not be children of {0},they must be {0}”.format(self.__class__.__name__)
The first assert will handle any variables being combined which are not of either the parent or child, and the second will make sure that the they are not parent and child, but either both parent, or both child.
Two parents will pass both asserts, two children will also pass both asserts, but if one is a parent and one a child, they will pass only one of the two asserts, and the other will fail, no matter which way round the objects are.
Expand the Coord class from Exercise 5 by adding a magic method for add. 1.1. This should accept one other argument, another object of the same class, and return a new object - the original values added to those of the other object 1.2. Ensure that the method will only be able to add coords to coords, and velocities to velocities, by adding in assert statements as above. 1.3. Add radd and iadd methods which call the main add method. While radd at current isn’t very useful by itself as only the coord family of classes can be added, if the original add method was expanded to allow numerical collections of length 3 as an input, reflecting the original method will allow addition in either direction.
Expand the Velocity class to be able to multiply it with a singular numerical time value using mul. 2.1. This should return a coordinate with values equal to those of the original velocity object multiplied by the time.
Test the classes. 3.1. Create two Coord objects with different numerical values. Test adding them together. 3.2. Create a Velocity object. Test adding it to the Coord objects. 3.3. Test the addition of a Coord object, with the results of multiplying the Velocity object by a time value.
Below is the solution code from Exercise 5:
import mathclass Coord():def__init__(self, x, y, z):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float value along x axis y: float value along y axis z: float, optional value along z axis. Defaults to 0 """self.x = xself.y = yself.z = zdef hypotenuse(self):""" Returns the hypotenuse calculated from the Coordinate points Returns ------- float: sqrt(x^2 + y^2 + z^2) """return math.sqrt(self.x**2+self.y**2+self.z**2)def values(self):""" Returns the values of the Coordinate Returns ------- tuple(float, float, float): (x,y,z) """return(self.x, self.y, self.z)def__repr__(self):""" Specialised technical string to allow the recreation of the object. Returns ------- str: call needed to recreate object """#return("Coord({0},{1},{2})".format(self.x, self.y, self.z))return("{0}({1},{2},{3})".format(self.__class__.__name__, self.x, self.y, self.z)) ### ### Question 1 Below###def__eq__(self, other):""" Method to handle == comparisons. Returns True if attributes are the same AND if the class name is the same Parameters ---------- other: Coord Secondary Coordinate to compare to. Returns ------- bool: Whether two objects are identical Notes ----- comparing a Coord with a child of Coord will return False, regardless of the numbers involved. """ equivalence = (self.__dict__ == other.__dict__) and\ (self.__class__.__name__== other.__class__.__name__)return equivalencedef__ne__(self, other):""" Method to handle != comparisons. Returns True if attributes are not the same OR if the class name is not the same Parameters ---------- other: Coord Secondary Coordinate to compare to. Returns ------- bool: Whether two objects are not identical Notes ----- comparing a Coord with a child of Coord will return True, regardless of the numbers involved. """ equivalence = (self.__dict__ != other.__dict__) or\ (self.__class__.__name__!= other.__class__.__name__)return equivalence### ### Question 2 Below###def__lt__(self, other):""" Handles comparisons between self and other for less than Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is less than abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() < other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultdef__le__(self, other):""" Handles comparisons between self and other for less than or equal to Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is less than or equal to abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() <= other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultdef__gt__(self, other):""" Handles comparisons between self and other for greater than Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is greater than abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() > other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_result def__ge__(self, other):""" Handles comparisons between self and other for greater than or equal to Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is greater than or equal to abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() >= other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultclass Velocity(Coord):def__init__(self, x, y, z):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float speed in x direction y: float speed in y direction z: float speed in z direction Notes ----- Calls parent Coord class init method. """ Coord.__init__(self, x, y, z)### ### Question 3 Below###Coord_1 = Coord(1,2,3)Coord_2 = Coord(3,4,5)print(Coord_1 > Coord_2) #expect Falseprint(Coord_1 < Coord_2) #expect Trueprint(Coord_1 == Coord_2) #expect Falseprint(Coord_1 != Coord_2) #expect Trueprint(Coord_1 >= Coord_2) #expect Falseprint(Coord_1 <= Coord_2) #expect Truevel = Velocity(4,5,6)print(vel > Coord_2) #expect Falseprint(vel < Coord_2) #expect Falseprint(vel == Coord_2) #expect Trueprint(vel != Coord_2) #expect Falseprint(vel >= Coord_2) #expect Falseprint(vel <= Coord_2) #expect False
import mathclass Coord():def__init__(self, x, y, z):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float value along x axis y: float value along y axis z: float, optional value along z axis. Defaults to 0 """self.x = xself.y = yself.z = zdef hypotenuse(self):""" Returns the hypotenuse calculated from the Coordinate points Returns ------- float: sqrt(x^2 + y^2 + z^2) """return math.sqrt(self.x**2+self.y**2+self.z**2)def values(self):""" Returns the values of the Coordinate Returns ------- tuple(float, float, float): (x,y,z) """return(self.x, self.y, self.z)def__repr__(self):""" Specialised technical string to allow the recreation of the object. Returns ------- str: call needed to recreate object """#return("Coord({0},{1},{2})".format(self.x, self.y, self.z))return("{0}({1},{2},{3})".format(self.__class__.__name__, self.x, self.y, self.z)) def__eq__(self, other):""" Method to handle == comparisons. Returns True if attributes are the same AND if the class name is the same Parameters ---------- other: Coord Secondary Coordinate to compare to. Returns ------- bool: Whether two objects are identical Notes ----- comparing a Coord with a child of Coord will return False, regardless of the numbers involved. """ equivalence = (self.__dict__ == other.__dict__) and\ (self.__class__.__name__== other.__class__.__name__)return equivalencedef__ne__(self, other):""" Method to handle != comparisons. Returns True if attributes are not the same OR if the class name is not the same Parameters ---------- other: Coord Secondary Coordinate to compare to. Returns ------- bool: Whether two objects are not identical Notes ----- comparing a Coord with a child of Coord will return True, regardless of the numbers involved. """ equivalence = (self.__dict__ != other.__dict__) or\ (self.__class__.__name__!= other.__class__.__name__)return equivalence### ### Question 1 Below###def__add__(self, other):""" Method to add a second set of Coordinates Parameters ---------- other: Coord Secondary Coordinate to add on Returns ------- Coord: sum of self and other Notes ----- Will thrown an assertion error if two objects are not both Coords, or identical children of Coord """assertisinstance(other, self.__class__), \"objects to be added must be {0}, rather than {1}".format(self.__class__.__name__, type(other))assertisinstance(self, other.__class__), \"objects to be added must not be children of {0}, they must be {0}".format(self.__class__.__name__) delta_x, delta_y, delta_z = other.values()return(Coord(self.x + delta_x, self.y + delta_y, self.z + delta_z))def__radd__(self, other):""" Reflective addition - calls Coord.__add__ """returnself.__add__(other)def__iadd__(self, other):""" Assignment addition - calls Coord.__add__ """returnself.__add__(other)def__lt__(self, other):""" Handles comparisons between self and other for less than Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is less than abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() < other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultdef__le__(self, other):""" Handles comparisons between self and other for less than or equal to Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is less than or equal to abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() <= other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultdef__gt__(self, other):""" Handles comparisons between self and other for greater than Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is greater than abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() > other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_result def__ge__(self, other):""" Handles comparisons between self and other for greater than or equal to Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is greater than or equal to abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() >= other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_result### ### Question 2 Below###class Velocity(Coord):def__init__(self, x, y, z):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float speed in x direction y: float speed in y direction z: float speed in z direction Notes ----- Calls parent Coord class init method. """ Coord.__init__(self, x, y, z)def__mul__(self, t):""" Method for handling multiplication of object Parameters ---------- t: float/int time value to multiple Velocity by to get a position Returns ------- Coord: Coordinate = Velocity * time """return(Coord(self.x * t, self.y * t, self.z * t))### ### Question 3 Below### Coord_1 = Coord(1,2,3)Coord_2 = Coord(3,4,5)print((Coord_1 + Coord_2).values())vel = Velocity(4,5,6)print((vel *5+ Coord_2).values()) print((vel + Coord_2).values()) #expect an AssertionError
(4, 6, 8)
(23, 29, 35)
AssertionError: objects to be added must be Velocity, rather than <class '__main__.Coord'>
7 Changing Objects
7.1 Getters and Setters
Objects once defined can be changed, in a number of ways. Python doesn’t have public and private methods in the same way that many other languages such as Java or C# do, but it does have some conventions. In general, any functions, or class based variables which have a name beginning with an underscore are considered to be private, and should only be accessed from other methods inside the object. They will not show up in any autofill lists provided by IDEs, but as it is just convention, they can be used outside, despite it being inadvisable.
As any attribute is accessible within an object, changing it can be as simple as running:
polly.name ='Penelope'
There is no need, again unlike Java, to create get or set methods - methods to call an attribute of a class, and to change its value. It is straightforward to do so, by just adding a couple of extra methods to the class:
def get_name(self): returnself.namedef set_name(self, new_name): self.name = new_nameAnd then the following methods will work absolutely fine.python polly.get_name() #returns polly polly.set_name('Penelope') #overwrites name
However, this is often unnecessary, and it is both clunkier for the user, and less intuitive. Adding get or set methods could be useful if some validation is needed, for example ensuring that a new name is text rather than an integer, but thankfully Python provides us with a useful tool for allowing the definition of methods that act like the above, but directly affect the output when attributes are referred to directly - the property function.
In the toy example below, a new Person class is added, which has a name attribute. Some validation is required, as above, that the name MUST be a str, so adding in a get_, set_ pair makes some level of sense. However, this example does not block a user from accessing the name attribute directly!
class Person:def__init__(self, name):self.name = namedef get_name(self):return namedef set_name(self, new_name):assertisinstance(new_name, str), 'can only change names to strings!'self.name = new_namealice = Person('Alice')alice.set_name(132)
AssertionError: can only change names to strings!
set_name does not run if alice.name is assigned directly! The validation step is useless!
#set_name only works as validation, if it is used!alice.name =132print(alice.name)
132
7.2 Properties
This can be fixed, using property(). This function takes three arguments, a get method, a set method, and a third delete method. Not all have to be defined, but its advisable to include both get and set, as otherwise it can be very difficult to access attributes.
In the following class, the original name attribute has been hidden, by adding an underscore to the beginning of it - this applies to all of the methods. Likewise, the get and set methods have preceding underscores, as they no longer need to be called directly. The last line in the class definition is the key one: python name = property(_get_name, _set_name)
This creates a class wide name attribute, with specific behaviour specified by the methods passed in. Now, any attempt to assign directly to, or call directly from, the attribute, will result in the predefined methods being called instead!
class Person:def__init__(self, name):self._name = namedef _get_name(self):print('running _get_name()!')returnself._namedef _set_name(self, new_name):print('running _set_name()!')assertisinstance(new_name, str), 'can only change names to strings!'self._name = new_name name =property(_get_name, _set_name)alice = Person('Alice')print(alice.name)alice.name =132
running _get_name()!
Alice
running _set_name()!
AssertionError: can only change names to strings!
Calling the attribute directly runs the get method, and assigning to it directly calls the set method. A savvy user could still get past the validation, as there is indeed still a ._name hidden attribute with no validation, but fundamentally that is not going to be an issue.
As all of the methods that users shouldn’t have access to are hidden, the only option for this toy class when autocomplete is attempted, is the name property! Indeed, as it is the only thing that they can easily do, pressing tab after typing alice. will autofill the name.
alice.
7.3 Decorators
An alternate way of setting properties is using decorators. Decorators are a way to wrap a function, class, or method, in a another function, to expand their capability. The @property decorator is used to set up a getter, and then an optional setter property can be created from the getter.
As before, the name must be set to a string. Direct assignment uses the name method, where the validation can be found, so an assertion error is expected below.
class Person:def__init__(self, name):self._name = name@property#defines name, accessible as any attributedef name(self):print('running _get_name()!')returnself._name@name.setter#defines the setter for name, optional, the decorator references the above property namedef name(self, new_name):print('running _set_name()!')assertisinstance(new_name, str), 'can only change names to strings!'self._name = new_namealice = Person('Alice')print(alice.name)alice.name =132
running _get_name()!
Alice
running _set_name()!
AssertionError: can only change names to strings!
There are many other decorators which might be of interest to those who are creating their own classes. One notable one is the dataclass from the dataclasses module. This can make simple classes far easier to write. Imagine that a class is needed to represent a 2-d point on a plane. To define the point, an x and y position need to be specified for every object. Given what has been covered, this would be written as the following:
class Point:"""Class to represent a location. Callable to update the location's position."""def__init__(self, x, y):self.x = x self.y = y p = Point(2, 3)p.x, p.y
(2, 3)
Using a dataclass makes the definition of this class incredible simple:
The decorator does all of the work of defining the initialisation method. The two required attributes have been created, with a lot less code to write, or for other developers to parse. Other methods can be added to the class as above, the decorator just simplifies the initialisation of objects. The : float is essential to make the decorator work, but does not actually enforce the datatype on the attribute.
7.4 Callable objects
Objects can also be made callable. This gives objects the ability to behave like functions - they can be called using the magic method call. This method should be thought of as the effect when object(…) is run. It usefully provides an easy and elegant way to change the state of an object which might need to be altered frequently, such as in the following Point class. A standard initiator is followed by the call method, the body of which is in this case exactly the same as that of the initiator.
class Point:"""Class to represent a location. Callable to update the location's position."""def__init__(self, x, y):self.x = x self.y = ydef__call__(self, x, y):"""Change the position of the point."""print('point location changed!')self.x = x self.y = yp = Point(2, 3)print(p.x, p.y)p(3, 4)print(p.x, p.y)
2 3
point location changed!
3 4
As always with python, there is no one way of performing a task, and changing the values of attributes of a class is no different.
Adapt the Coord class from Exercise 6 to use the dataclass decorator.
Convert x, y, and z to be properties with suitable get and set methods. 2.1. The set method should check to make sure the value being set is not a string.
Adapt the Coord class to allow the values to change when called by implementing a call method.
Below is the solution code from Exercise 6:
import mathclass Coord():def__init__(self, x, y, z):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float value along x axis y: float value along y axis z: float, optional value along z axis. Defaults to 0 """self.x = xself.y = yself.z = zdef hypotenuse(self):""" Returns the hypotenuse calculated from the Coordinate points Returns ------- float: sqrt(x^2 + y^2 + z^2) """return math.sqrt(self.x**2+self.y**2+self.z**2)def values(self):""" Returns the values of the Coordinate Returns ------- tuple(float, float, float): (x,y,z) """return(self.x, self.y, self.z)def__repr__(self):""" Specialised technical string to allow the recreation of the object. Returns ------- str: call needed to recreate object """#return("Coord({0},{1},{2})".format(self.x, self.y, self.z))return("{0}({1},{2},{3})".format(self.__class__.__name__, self.x, self.y, self.z)) def__eq__(self, other):""" Method to handle == comparisons. Returns True if attributes are the same AND if the class name is the same Parameters ---------- other: Coord Secondary Coordinate to compare to. Returns ------- bool: Whether two objects are identical Notes ----- comparing a Coord with a child of Coord will return False, regardless of the numbers involved. """ equivalence = (self.__dict__ == other.__dict__) and\ (self.__class__.__name__== other.__class__.__name__)return equivalencedef__ne__(self, other):""" Method to handle != comparisons. Returns True if attributes are not the same OR if the class name is not the same Parameters ---------- other: Coord Secondary Coordinate to compare to. Returns ------- bool: Whether two objects are not identical Notes ----- comparing a Coord with a child of Coord will return True, regardless of the numbers involved. """ equivalence = (self.__dict__ != other.__dict__) or\ (self.__class__.__name__!= other.__class__.__name__)return equivalence### ### Question 1 Below###def__add__(self, other):""" Method to add a second set of Coordinates Parameters ---------- other: Coord Secondary Coordinate to add on Returns ------- Coord: sum of self and other Notes ----- Will thrown an assertion error if two objects are not both Coords, or identical children of Coord """assertisinstance(other, self.__class__), \"objects to be added must be {0}, rather than {1}".format(self.__class__.__name__, type(other))assertisinstance(self, other.__class__), \"objects to be added must not be children of {0}, they must be {0}".format(self.__class__.__name__) delta_x, delta_y, delta_z = other.values()return(Coord(self.x + delta_x, self.y + delta_y, self.z + delta_z))def__radd__(self, other):""" Reflective addition - calls Coord.__add__ """returnself.__add__(other)def__iadd__(self, other):""" Assignment addition - calls Coord.__add__ """returnself.__add__(other)def__lt__(self, other):""" Handles comparisons between self and other for less than Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is less than abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() < other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultdef__le__(self, other):""" Handles comparisons between self and other for less than or equal to Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is less than or equal to abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() <= other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultdef__gt__(self, other):""" Handles comparisons between self and other for greater than Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is greater than abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() > other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_result def__ge__(self, other):""" Handles comparisons between self and other for greater than or equal to Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is greater than or equal to abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() >= other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_result### ### Question 2 Below###class Velocity(Coord):def__init__(self, x, y, z):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float speed in x direction y: float speed in y direction z: float speed in z direction Notes ----- Calls parent Coord class init method. """ Coord.__init__(self, x, y, z)def__mul__(self, t):""" Method for handling multiplication of object Parameters ---------- t: float/int time value to multiple Velocity by to get a position Returns ------- Coord: Coordinate = Velocity * time """return(Coord(self.x * t, self.y * t, self.z * t))### ### Question 3 Below### Coord_1 = Coord(1,2,3)Coord_2 = Coord(3,4,5)print((Coord_1 + Coord_2).values())vel = Velocity(4,5,6)print((vel *5+ Coord_2).values()) print((vel + Coord_2).values()) #expect an AssertionError
#1. Adapt the Coord class to use the dataclass decorator. from dataclasses import dataclassimport math@dataclassclass Coord():# x : float #for question 1 this is perfectly fine# y : float# z : float#2. Convert x, y, and z to be properties with suitable get and set methods.# 2.1. The set method should check to make sure the value being set is not a string. _x : float#for question 2 onwards, having these as hidden works better for the properties _y : float _z : float@propertydef x(self):returnself._x@propertydef y(self):returnself._y@propertydef z(self):returnself._z@x.setterdef x(self, value):ifisinstance(value, str):raiseValueError('x cannot be a string!')else:self._x = value@y.setterdef y(self, value):ifisinstance(value, str):raiseValueError('y cannot be a string!')else:self._y = value@z.setterdef z(self, value):ifisinstance(value, str):raiseValueError('z cannot be a string!')else:self._z = value #3. Adapt the Coord class to allow the values to change when called by implementing a __call__ method. def__call__(self, x, y, z):""" Used to reassign the values of the object, method allows object(x,y,z) to overwrite existing values """self.x = xself.y = yself.z = zdef hypotenuse(self):""" Returns the hypotenuse calculated from the Coordinate points Returns ------- float: sqrt(x^2 + y^2 + z^2) """return math.sqrt(self.x**2+self.y**2+self.z**2)def values(self):""" Returns the values of the Coordinate Returns ------- tuple(float, float, float): (x,y,z) """return(self.x, self.y, self.z)def__repr__(self):""" Specialised technical string to allow the recreation of the object. Returns ------- str: call needed to recreate object """return("{0}({1},{2},{3})".format(self.__class__.__name__, self.x, self.y, self.z)) def__eq__(self, other):""" Method to handle == comparisons. Returns True if attributes are the same AND if the class name is the same Parameters ---------- other: Coord Secondary Coordinate to compare to. Returns ------- bool: Whether two objects are identical Notes ----- comparing a Coord with a child of Coord will return False, regardless of the numbers involved. """ equivalence = (self.__dict__ == other.__dict__) and\ (self.__class__.__name__== other.__class__.__name__)return equivalencedef__ne__(self, other):""" Method to handle != comparisons. Returns True if attributes are not the same OR if the class name is not the same Parameters ---------- other: Coord Secondary Coordinate to compare to. Returns ------- bool: Whether two objects are not identical Notes ----- comparing a Coord with a child of Coord will return True, regardless of the numbers involved. """ equivalence = (self.__dict__ != other.__dict__) or\ (self.__class__.__name__!= other.__class__.__name__)return equivalencedef__add__(self, other):""" Method to add a second set of Coordinates Parameters ---------- other: Coord Secondary Coordinate to add on Returns ------- Coord: sum of self and other Notes ----- Will thrown an assertion error if two objects are not both Coords, or identical children of Coord """assertisinstance(other, self.__class__), \"objects to be added must be {0}, rather than {1}".format(self.__class__.__name__, type(other))assertisinstance(self, other.__class__), \"objects to be added must not be children of {0}, they must be {0}".format(self.__class__.__name__) delta_x, delta_y, delta_z = other.values()return(Coord(self.x + delta_x, self.y + delta_y, self.z + delta_z))def__radd__(self, other):""" Reflective addition - calls Coord.__add__ """returnself.__add__(other)def__iadd__(self, other):""" Assignment addition - calls Coord.__add__ """returnself.__add__(other)def__lt__(self, other):""" Handles comparisons between self and other for less than Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is less than abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() < other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultdef__le__(self, other):""" Handles comparisons between self and other for less than or equal to Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is less than or equal to abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() <= other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultdef__gt__(self, other):""" Handles comparisons between self and other for greater than Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is greater than abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() >= other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_result def__ge__(self, other):""" Handles comparisons between self and other for greater than or equal to Parameters ---------- other: Coord Secondary Coordinate to compare to Returns ------- bool: True if abs(self) is greater than or equal to abs(other) Notes ----- Evaluates to False if names of classes are different """ comparison_result =self.hypotenuse() >= other.hypotenuse()ifself.__class__.__name__!= other.__class__.__name__: comparison_result =Falsereturn comparison_resultclass Velocity(Coord):def__init__(self, x, y, z):""" Initialises the object and sets some object wide attributes Parameters ---------- x: float speed in x direction y: float speed in y direction z: float speed in z direction Notes ----- Calls parent Coord class init method. """ Coord.__init__(self, x, y, z)def__mul__(self, t):""" Method for handling multiplication of object Parameters ---------- t: float/int time value to multiple Velocity by to get a position Returns ------- Coord: Coordinate = Velocity * time """return(Coord(self.x * t, self.y * t, self.z * t))c = Coord(1,2,3)c(2,3,4)print(c.values())c(21,31,41)print(c.values())c('21','31','41') #expect ValueError
The logger should have at least 3 methods - info, warning, and error.
Each method should take in a string message, and write it out as a log message.
It should be initialised to a user defined log.txt file.
On initialisation, the user should be able to optionally have each type of info, warning, and error messages not write.
The logger should write to both the console, and the log file defined.
Messages should take the form of: timestamp LOGTYPE: message e.g. 2020-01-01 16:05:22.144991 INFO: This is an informative message
Like all good functions and classes, docstrings should be included
Think about the magic methods explored above. Could any be included? Are there any attributes that could be properties?
The datetime package has a datetime class which can be used to get the current timestamp
Writing out to a text file is slightly different than writing a dataframe to a CSV
The basic method is to open a file, write some content to it, then close it afterwards. If the file isn’t closed, it can cause issues when trying to write to it again
Python can handle opening and automatically closing by using the with statement. While inside the indented block, the file will be open. When finished, the file will close.
In the example below, the open() function is used with the filepath to write to, and setting the mode to a for append. It is saved as the variable f, and then f.write() is passed a string to write to the file.
with open(‘log.txt’, mode=‘a’) as f: f.write(‘text to write out goes here’)
When combining the log message, adding a newline character at the end will ensure each entry to the log file will be entered on a new line.
import datetimeclass Logger():''' Simple logger - writes to console and to file at three levels of importance. Attributes ---------- log_file : str Location of file to log to print_info : bool (optional) Whether to print out INFO statements (default True) print_warning : bool (optional) Whether to print out WARN statements (default True) print_error : bool (optional) Whether to print out ERROR statements (default True) Methods ------- info(info_message) Prints an Info level message to the console and the log file. This is of the form `timestamp INFO: message` warn(warning_message) Prints a Warning level message to the console and the log file. This is of the form `timestamp WARN: message` error(error_message) Prints an Error level message to the console and the log file. This is of the form `timestamp ERROR: message` Notes If print_info, print_warning, and print_error are all set to False, nothing will be logged. '''def__init__(self, log_file, print_info =True, print_warning =True, print_error =True):""" Parameters ---------- log_file : str Location of file to log to print_info : bool (optional) Whether to print out INFO statements (default True) print_warning : bool (optional) Whether to print out WARN statements (default True) print_error : bool (optional) Whether to print out ERROR statements (default True) """self._log_file = log_fileself._print_info = print_infoself._print_warning = print_warningself._print_error = print_error@propertydef log_file(self):returnself._log_file@log_file.setterdef log_file(self, value):ifisinstance(value, str):self._log_file = valueelse:raiseValueError('log_file must be a string')@propertydef print_info(self):returnself._print_info@print_info.setterdef print_info(self, value):ifisinstance(value, bool):self._print_info = valueelse:raiseValueError('print_info must be a boolean')@propertydef print_warning(self):returnself._print_warning@print_warning.setterdef print_warning(self, value):ifisinstance(value, bool):self._print_warning = valueelse:raiseValueError('print_warning must be a boolean')@propertydef print_error(self):returnself._print_error@print_error.setterdef print_error(self, value):ifisinstance(value, bool):self._print_error = valueelse:raiseValueError('print_error must be a boolean')def__str__(self):""" Returns a high level description of the object. """return"A logger object, used to write messages to console and file, of different levels of importance"def__repr__(self):""" Returns a string that can be used to recreate the object. """return"{0}('{1}',{2},{3},{4})".format(self.__class__.__name__,self.log_file,self.print_info,self.print_warning,self.print_error)def info(self, info_message):"""Logs an info level message. If print_info is False, has no effect. Parameters ---------- info_message : str The message to log """ifself._print_info:#generate full message full_info ="{0} INFO: {1}\n".format(datetime.datetime.now(), info_message)#print to consoleprint(full_info)#print to log filewithopen(self.log_file, mode ='a') as f: f.write(full_info)def warning(self, warning_message):"""Logs a warning level message. If print_warning is False, has no effect. Parameters ---------- warning_message : str The message to log """ifself._print_warning:#generate full message full_warning ="{0} WARNING: {1}\n".format(datetime.datetime.now(), warning_message)#print to consoleprint(full_warning)#print to log filewithopen(self.log_file, mode ='a') as f: f.write(full_warning)def error(self, error_message):"""Logs an error level message. If print_error is False, has no effect. Parameters ---------- error_message : str The message to log """ifself._print_error:#generate full message full_error ="{0} ERROR: {1}\n".format(datetime.datetime.now(), error_message)#print to consoleprint(full_error)#print to log filewithopen(self.log_file, mode ='a') as f: f.write(full_error)log = Logger('log.txt')log.error('Here is an error message! Something catastrophic has occurred!')log.warning('Here is a warning message. Something strange is happening, that you should be aware of.')log.info('Here is an informative message. Processing has reached a certain point.')
9 Summary
Learners should now:
Have a better understanding of previously experienced Python objects
Be able to create simple classes to hold data
Be able to augment classes with complicated methods and powerful tools
Be familiar with:
inheriting objects
comparing objects
combining objects
decorating objects
Please complete the Post-Course survey on the Learning Hub to complete your learning.