Object Orientated Programming in Python

Author

Government Analysis Function and ONS Data Science Campus

Data Science Campus and Analysis Function logos.

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.

Linear Regression dropdown.

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) 
['__abstractmethods__',
 '__annotations__',
 '__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__firstlineno__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__getstate__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__setstate__',
 '__sizeof__',
 '__sklearn_clone__',
 '__static_attributes__',
 '__str__',
 '__subclasshook__',
 '__weakref__',
 '_abc_impl',
 '_build_request_for_signature',
 '_check_feature_names',
 '_check_n_features',
 '_decision_function',
 '_doc_link_module',
 '_doc_link_template',
 '_doc_link_url_param_generator',
 '_estimator_type',
 '_get_default_requests',
 '_get_doc_link',
 '_get_metadata_request',
 '_get_param_names',
 '_get_tags',
 '_more_tags',
 '_parameter_constraints',
 '_repr_html_',
 '_repr_html_inner',
 '_repr_mimebundle_',
 '_set_intercept',
 '_validate_data',
 '_validate_params',
 'copy_X',
 'fit',
 'fit_intercept',
 'get_metadata_routing',
 'get_params',
 'n_jobs',
 'positive',
 'predict',
 'score',
 'set_fit_request',
 'set_params',
 'set_score_request']

2.1 Basic Class Structure

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:

# or

class 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.2 init

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.

2.3 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.
  2. Create a Coord object called location with a numerical x and a numerical y value.
  3. 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 = x
        self.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 = name
        
polly = 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 = name
        self.can_fly = can_fly
        
polly = 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 = name
        self.species = species
        self.plumage = 'Beautiful'
        self.can_fly = can_fly
            
    def 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.

The parrot can also now pine for the fjords, by calling the pine() method. Again this has the self variable passed into it, so the name can be included in the printed text. More information on the .format() method is widely available.

3.1 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 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.
  2. Add a z attribute with a default value of 0.
  3. 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 = x
        self.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 math

class Coord():
    
    def __init__(self, x, y):
        self.x = x
        self.y = y
              
    def 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 0
class Coord():
    
    def __init__(self, x, y, z=0):
        self.x = x
        self.y = y
        self.z = z
              
    def 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 = x
        self.y = y
        self.z = z
              
    def 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 = True
        self.nervous_system = True
        self.can_move = True
        
        
    def 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 = name
        self.species = species
        self.plumage = 'Beautiful'
        self.can_fly = can_fly
                    
    def 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 = name
        self.species = species
        self.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.

print(isinstance(polly, Parrot) == True)
print(isinstance(polly, Animal) == True)
True
True

4.1 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.
  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.
  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.

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 math

class Coord():
    
    def __init__(self, x, y):
        self.x = x
        self.y = y
              
    def 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 0
class Coord():
    
    def __init__(self, x, y, z=0):
        self.x = x
        self.y = y
        self.z = z
              
    def 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 = x
        self.y = y
        self.z = z
              
    def 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 math

class Coord():
    
    def __init__(self, x, y, z=0):
        self.x = x
        self.y = y
        self.z = z
        
    def 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 pd
df = 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.

import datetime

str(datetime.datetime.today())
'2024-11-25 14:40:34.621320'
repr(datetime.datetime.today())
'datetime.datetime(2024, 11, 25, 14, 40, 34, 627227)'

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 = name
        self.species = species
        self.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 = name
        self.species = species
        self.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 methods
print(polly.__class__.__name__)
Object is a Parrot called Polly with Beautiful plumage
Parrot

5.2 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.
  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.

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 math

class Coord():
    
    def __init__(self, x, y, z=0):
        self.x = x
        self.y = y
        self.z = z
        
    def 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 math

class 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 = x
        self.y = y
        self.z = z
        
    def 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. 
Coord(2,3,4)
Coord(2,3,4)
Velocity(5,1,2)
Velocity(5,1,2)

6 Comparisons and Combinations

6.1 Comparisons

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:

self.can_fly == other.can_fly & self.plumage == other.plumage

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.

polly.__dict__
{'name': 'Polly',
 'species': 'Norwegian Blue',
 'plumage': 'Beautiful',
 'can_fly': True,
 'breathe_oxygen': True,
 'nervous_system': True,
 'can_move': True}

Comparing via dicts means that simple one line methods can be used for the comparisons relevant to a parrot - eq and ne

def __eq__(self, other):
    
    return self.__dict__ == other.__dict__

def __ne__(self, other):
    
    return self.__dict__ != other.__dict__

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 = name
        self.species = species
        self.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
        """
        
        return self.__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
        """
        
        return self.__dict__ != other.__dict__
    
    def pine(self):
        print('{0} is pining for the fjords'.format(self.name))
               

polly = Parrot('Polly','Norwegian Blue')
polly_two = Parrot('Polly', 'Norwegian Blue')
iago = Parrot('Iago', 'Scarlet Macaw')
# should return True as functionally identical
polly == polly_two 
True
# should return False as name and species are different
polly == 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.

6.2 Exercise 5

  1. 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.
  2. 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.
  3. 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 math

class 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 = x
        self.y = y
        self.z = z
        
    def 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 math

class 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 = x
        self.y = y
        self.z = z
        
    def 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 equivalence
    
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
        
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
    
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return 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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
        
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)
        
###    
### Question 3 Below
###

Coord_1 = Coord(1,2,3)
Coord_2 = Coord(3,4,5)

print(Coord_1 > Coord_2) #expect False
print(Coord_1 < Coord_2) #expect True
print(Coord_1 == Coord_2) #expect False
print(Coord_1 != Coord_2) #expect True
print(Coord_1 >= Coord_2) #expect False
print(Coord_1 <= Coord_2) #expect True

vel = Velocity(4,5,6)

print(vel > Coord_2) #expect False
print(vel < Coord_2) #expect False
print(vel == Coord_2) #expect True
print(vel != Coord_2) #expect False
print(vel >= Coord_2) #expect False
print(vel <= Coord_2) #expect False
False
True
False
True
False
True
False
False
False
True
False
False

6.3 Combinations

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:

def radd(self, other): return self.__add__(other)

def iadd(self, other): return self.__add__(other)

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.

assert isinstance(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 = name
        self.species = species
        self.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
        """
        
        return self.__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
        """
        
        return self.__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 parrot
        assert isinstance(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 offspring
    
    def 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.

print(isinstance(manfred, Animal))
print(isinstance(manfred, Parrot)) #FALSE!
print(isinstance(polly, Animal))
print(isinstance(polly, Parrot))
True
False
True
True
Parent Child
Parent isinstance: True isinstance: False
Child isinstance: True isinstance: True

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.

assert isinstance(other, self.__class__),
“objects to be added must be {0}”.format(self.__class__.__name__) assert isinstance(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.

6.5 Exercise 6

  1. 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.
  2. 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.
  3. 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 math

class 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 = x
        self.y = y
        self.z = z
        
    def 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 equivalence
    
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
        
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
    
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return 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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
        
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)

    
###    
### Question 3 Below
###

Coord_1 = Coord(1,2,3)
Coord_2 = Coord(3,4,5)

print(Coord_1 > Coord_2) #expect False
print(Coord_1 < Coord_2) #expect True
print(Coord_1 == Coord_2) #expect False
print(Coord_1 != Coord_2) #expect True
print(Coord_1 >= Coord_2) #expect False
print(Coord_1 <= Coord_2) #expect True

vel = Velocity(4,5,6)

print(vel > Coord_2) #expect False
print(vel < Coord_2) #expect False
print(vel == Coord_2) #expect True
print(vel != Coord_2) #expect False
print(vel >= Coord_2) #expect False
print(vel <= Coord_2) #expect False
import math

class 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 = x
        self.y = y
        self.z = z
        
    def 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 equivalence
    
    def __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
        
        """
        
        assert isinstance(other, self.__class__), \
            "objects to be added must be {0}, rather than {1}".format(self.__class__.__name__, type(other))
        assert isinstance(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__
        """
        
        return self.__add__(other)
    
    def __iadd__(self, other):
        """
        Assignment addition - calls Coord.__add__
        """        
        return self.__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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
        
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
    
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return 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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return 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): return self.name

def set_name(self, new_name): self.name = new_name

And 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 = name
        
    def get_name(self):
        return name
    
    def set_name(self, new_name):
        assert isinstance(new_name, str), 'can only change names to strings!'
        self.name = new_name
        
        
alice = 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 = 132
print(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 = name
        
    def _get_name(self):
        print('running _get_name()!')
        return self._name
    
    def _set_name(self, new_name):
        print('running _set_name()!')
        assert isinstance(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 attribute
    def name(self):
        print('running _get_name()!')
        return self._name
    
    @name.setter #defines the setter for name, optional, the decorator references the above property name
    def name(self, new_name):
        print('running _set_name()!')
        assert isinstance(new_name, str), 'can only change names to strings!'
        self._name = new_name
        
alice = 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:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float
                
p = Point(3, 4)
p.x, p.y
(3, 4)

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 = y

    def __call__(self, x, y):
        """Change the position of the point."""
        print('point location changed!')
        self.x = x 
        self.y = y
    
p = 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.

7.5 Exercise 7

  1. Adapt the Coord class from Exercise 6 to use the dataclass decorator.
  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.
  3. 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 math

class 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 = x
        self.y = y
        self.z = z
        
    def 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 equivalence
    
    def __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
        
        """
        
        assert isinstance(other, self.__class__), \
            "objects to be added must be {0}, rather than {1}".format(self.__class__.__name__, type(other))
        assert isinstance(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__
        """
        
        return self.__add__(other)
    
    def __iadd__(self, other):
        """
        Assignment addition - calls Coord.__add__
        """        
        return self.__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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
        
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
    
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return 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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return 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 dataclass
import math

@dataclass
class 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

    @property
    def x(self):
        return self._x
    
    @property
    def y(self):
        return self._y
    
    @property
    def z(self):
        return self._z
    
    @x.setter
    def x(self, value):
        if isinstance(value, str):
            raise ValueError('x cannot be a string!')
        else:
            self._x = value
    
    @y.setter
    def y(self, value):
        if isinstance(value, str):
            raise ValueError('y cannot be a string!')
        else:
            self._y = value
    
    @z.setter
    def z(self, value):
        if isinstance(value, str):
            raise ValueError('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 = x
        self.y = y
        self.z = z
    
    
    def 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 equivalence
    
    def __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
        
    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
        
        """
        
        assert isinstance(other, self.__class__), \
            "objects to be added must be {0}, rather than {1}".format(self.__class__.__name__, type(other))
        assert isinstance(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__
        """
        
        return self.__add__(other)
    
    def __iadd__(self, other):
        """
        Assignment addition - calls Coord.__add__
        """        
        return self.__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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
        
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
    
    def __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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return 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()
        
        if self.__class__.__name__ != other.__class__.__name__:
            comparison_result = False
        
        return comparison_result
    
  
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))
 
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
(2, 3, 4)
(21, 31, 41)
ValueError: x cannot be a string!

8 Case Study

  • 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 datetime

class 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_file
        self._print_info = print_info
        self._print_warning = print_warning
        self._print_error = print_error
        
        
    @property
    def log_file(self):
        return self._log_file
    
    @log_file.setter
    def log_file(self, value):
        if isinstance(value, str):
            self._log_file = value
        else:
            raise ValueError('log_file must be a string')
    
    @property
    def print_info(self):
        return self._print_info
    
    @print_info.setter
    def print_info(self, value):
        if isinstance(value, bool):
            self._print_info = value
        else:
            raise ValueError('print_info must be a boolean')
                   
    @property
    def print_warning(self):
        return self._print_warning
    
    @print_warning.setter
    def print_warning(self, value):
        if isinstance(value, bool):
            self._print_warning = value
        else:
            raise ValueError('print_warning must be a boolean')
            
    @property
    def print_error(self):
        return self._print_error
    
    @print_error.setter
    def print_error(self, value):
        if isinstance(value, bool):
            self._print_error = value
        else:
            raise ValueError('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
        """
        
        if self._print_info:
            #generate full message
            full_info = "{0} INFO: {1}\n".format(datetime.datetime.now(), info_message)
            
            #print to console
            print(full_info)
            
            #print to log file
            with open(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
        """
        if self._print_warning:
            #generate full message
            full_warning = "{0} WARNING: {1}\n".format(datetime.datetime.now(), warning_message)
            
            #print to console
            print(full_warning)
            
            #print to log file
            with open(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
        """
        
        if self._print_error:
            #generate full message
            full_error = "{0} ERROR: {1}\n".format(datetime.datetime.now(), error_message)
            
            #print to console
            print(full_error)
            
            #print to log file
            with open(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.