## Classes and Objects Part 1

Introduction to Python

Module 8 of 11

### Introduction

In this module we will introduce the object oriented programming (OOP) paradigm - a means to model the world and our software applications as objects that interact with each other. Supported by hands-on examples in Python, we will explore the fundamental concepts in object oriented programming, including:

• Classes - classes, superclasses, subclasses, inheritance, and creating objects
• Class Attributes - class variables, instance variables, managing attributes and explicit constructor invocation
• Class Methods - defining and using class methods, the self parameter, the init method and the str method
• Inheritance - inheritance, overriding, single inheritance and multiple inheritance
• Constructors - writing and using constructors
• Introspection - dict, name, module and bases properties, and examining class structure

The source code for this module may be found on GitHub in the HyperLearning AI public repository for this course. Where interactive code snippets are provided in this module, you are strongly encouraged to type and execute these Python statements in your own Jupyter notebook instance.

In the Functions and Modules Part 1 module of this course, we introduced functional and imperative programming as context to understand when and why we may use lambda functions in Python. We will recap common programming paradigms over the following sub-sections, but this time as context to understand when, why and how we would use object oriented programming in Python.

##### 1.1. Imperative Programming

Imperative programming explicitly defines how tasks should be achieved, as opposed to declarative programming that defines only what needs to be done. Imperative programs consist of control flow and step-by-step instructions descrbing every event that needs to be undertaken to achieve the task in question. As such, imperative programs must manage state and promotes mutability of objects. For example consider the following Python program, written using the imperative approach to programming, to compute the sum of a given list of numbers:


# Write a program using imperative programming to calculate the sum of a given list of numbers
sum = 0
my_numbers = [1, 2, 3, 4, 5]
for number in my_numbers:
sum += number
print(f'The sum of the numbers in {my_numbers} is: {sum}')


In this program, the value assigned to the variable sum changes with each iteration of the for loop. Therefore we say that the variable sum has state which must be maintained in order for the program to work.

##### 1.2. Functional Programming

Functional programming is based on the concept of mathematical functions, which given an input will return an output. Programs written using the functional programming paradigm are made up of pure functions (a function, given the same inputs, will always return the same outputs) that avoid shared states (objects that are shared between different scopes such as global and local scopes are avoided), avoid mutating states (objects are immutable and cannot be modified after creation) and avoid side effects (any application state changes that are observable outside the called function are avoided, such as logging, displaying to the console, persisting to the file-system, and modifying a variable in an external scope). As such, functional programming is declarative i.e. it defines a set of instructions on what needs to be done rather than how it should be done.

Consider the following Python programs, both written using the functional approach to programming (one with a normal Python function, and the other with a lambda function, that also compute the sum of given numbers:


from functools import reduce

# Write a program using functional programming to calculate the sum of a given list of numbers
return x + y
print(sum)

# Write a program using functional programming and a lambda function to calculate the sum of a given list of numbers
sum = reduce(lambda x, y: x + y, my_numbers)
print(sum)


In these programs, no data is being mutated (i.e. the value assigned to the sum variable is not changed after the variable is created) and there are no step-by-step instructions defining how the sum calculation should be performed, just that the sum should be calculated.

To learn more about functional programming, including a detailed exploration of the fundamental principles of functional programming and the core differences between the declarative and imperative approaches, please refer to this great article on Medium by Eric Elliott.

##### 1.3. Object Oriented Programming

Object oriented programming (OOP) is based on the powerful concept of modelling the world as objects that contain data (attributes), and where those same objects may be managed and manipulated through functions (methods) that can change that data. In object oriented programming, an object's attributes and methods are co-located together within the object, where objects are generally mutable. Object oriented programming is a significantly different approach to programming, and is extremely intuitive with enough experience. Whether you use functional programming, object oriented programming or indeed another programming paradigm is very much dependent on your specific use-case, and there is no one-size fits all approach. The ongoing debate between programming paradigms is beyond the scope of this course, but thankfully languages such as Python support imperative, functional and object oriented programming alike.

Almost everything in Python is an object, with associated attributes/properties and methods (a type of function that can access and manipulate object attributes). For example, Python strings are immutable objects, whose methods include str.lower() and str.upper(). Python lists are also objects (mutable in this case), whose methods include list.append() and list.sort().

### 2. OOP Fundamentals

In the following sub-sections, we will introduce the key concepts in object oriented programming. These will be supported by example Python modules, the source code of which you can find in the GitHub repository for this course, and specifically in the Python project examples/formulapy.

In the next module Classes and Objects Part 2, we will consolidate and apply our knowledge of object oriented programming together with what we have learnt from the other modules in this course thus far to create a car racing game written in Python (hence the reason the project is called formulapy!)

##### 2.1. Classes

The fundamental building block of object oriented programming is a class. A class is a blueprint from which objects are created. Imagine a factory designed to manufacture cars. Cars are not manufactured randomly - instead each car is manufactured against a blueprint that describes what a car is i.e. all cars should have wheels, an engine and a chassis. All cars should also have a registration number, make, model, year of manufacture and current mileage (i.e. its attributes) but the values assigned to each of these attributes may be different across different individual cars. Furthermore, each car should have the ability to accelerate, brake, turn left and turn right (i.e. its methods). In the case of accelerate, this will increase the mileage of the car - this is an example of a method that has the ability to access and manipulate an attribute. Finally, both the set of attributes and the set of methods are defined together in the same blueprint or class.

In Python, we create a class using the class keyword followed by the name of the class and then the : colon operator. In the following example, we create a Car class in Python (the source code of which may be found in the Python module examples/formulapy/example) consisting of the following attributes:

• number_wheels - number of wheels (int)
• number_doors - number of doors (int)
• registration_number - unique registration number (str)
• make - make e.g. Mercedes, Ferrari (str)
• model - model e.g. AMG F1 W10 EQ Power+, SF1000 (str)
• year_manufactured - year of manufacture (int)
• maximum_speed - maximum speed of the car in miles per hour (int)
• acceleration_rate - constant rate of acceleration in miles per second (int)
• deceleration_rate - constant rate of deceleration in miles per second (int)
• mileage_miles - current mileage in miles (int)
• speed_mph - current speed in miles per hour (int)

And our Car class also consists of the following methods:

• accelerate - acceleration has the effect of increasing the values assigned to mileage_miles and speed_mph attributes respectively
• brake - brake has the effect of decreasing the value assigned to speed_mph
• turn_left - turn left
• turn_right - turn right
• log_speed - display the current speed

By convention, the name of a class in Python should be written in the CamelCase format (i.e. the first letter of each word should be capitalized). Thereafter, the attribute and method names should follow the convention that we have already encountered for variables and function in Python, namely all lowercase with words separated by the underscore character to improve readability. To learn more about naming conventions in Python, please refer to PEP 8 - Style Guide for Python Code.

Also note that the standard in Python is to place related classes in the same Python module, rather than have a separate module for each class (as we would, for example, in Java where each class resides in its own file). Thereafter the name of the Python module should not be the same as the class name, but rather a name that describes the logical collection of the classes in question.


#!/usr/bin/env python3
"""Example Car Class.

This module demonstrates how to define (and document) a class in Python, in
this case one where instances of the class are car objects. This class
demonstrates how to use the __init__() and __str__() methods respectively,
as well as how to define custom class methods including usage of the self
parameter. Finally, it demonstrates the difference between class and instance
variables.

"""

import time
from datetime import datetime

class Car:
"""Example car class.

This class is used to demonstrate how to define (and document) classes
in Python. This class demonstrates the __init__(), __str__() and custom
methods, as well as the self parameter, and the difference between class
one line summary followed by a more detailed description. The docstring
goes on to describe class attributes. Class methods are documented with
their own docstring i.e. function docstrings.

Attributes:
number_doors (int): The number of doors.
registration_number (str): The unique car registration number.
make (str): The car make, for example McLaren.
model (str): The car model, for example P1.
year_manufactured (int): The year of manufacture.
maximum_speed (int): The maximum speed of the car, in miles per hour.
acceleration_rate (int): The constant acceleration rate in miles/second.
deceleration_rate (int): The constant deceleration rate in miles/second.

"""

number_wheels = 4

def __init__(self, number_doors, registration_number, make,
model, year_manufactured, maximum_speed,
acceleration_rate, deceleration_rate):
"""Example of a docstring on the __init__() method.

The __init__() method, just like any other function in Python, should
be documented with a docstring, starting with a one line summary
followed by a more detailed description. Following the detailed
description should follow a description of each of the arguments,
but not including the self parameter.

Args:
number_doors (int): The number of doors.
registration_number (str): The unique car registration number.
make (str): The car make, for example McLaren.
model (str): The car model, for example P1.
year_manufactured (int): The year of manufacture.
maximum_speed (int): The maximum speed of the car in miles per hour.
acceleration_rate (int): Constant acceleration rate in miles/second.
deceleration_rate (int): Constant deceleration rate in miles/second.
"""

self.number_doors = number_doors
self.registration_number = registration_number
self.make = make
self.model = model
self.year_manufactured = year_manufactured
self.maximum_speed = maximum_speed
self.acceleration_rate = acceleration_rate
self.deceleration_rate = deceleration_rate
self.mileage_miles = 0
self.speed_mph = 0

def __str__(self):
"""Override the __str__() method to return the class name followed
by the string representation of the object's namespace dictionary.
"""
return type(self).__name__ + str(vars(self))

def accelerate(self):
"""A method to model car acceleration.

This method models the acceleration of a car. For the purposes of this
course, the modelling of acceleration is trivial where the speed of the
car increases by a constant rate every second until the maximum speed
of the car is reached.

Returns:
None
"""

self.log_speed()
while self.speed_mph < self.maximum_speed:
time.sleep(1)
if (self.speed_mph + self.acceleration_rate) > self.maximum_speed:
self.speed_mph = self.maximum_speed
self.log_speed()
break
else:
self.speed_mph += self.acceleration_rate
self.log_speed()

def brake(self):
"""A method to model a car braking.

This method models a car braking. For the purposes of this course,
the modelling of braking is trivial where the speed of the car
decreases by a constant rate every second until the speed of the car is
zero.

Returns:
None
"""

self.log_speed()
while self.speed_mph > 0:
time.sleep(1)
if (self.speed_mph - self.deceleration_rate) < 0:
self.speed_mph = 0
self.log_speed()
break
else:
self.speed_mph -= self.deceleration_rate
self.log_speed()

def turn_left(self):
"""A method to model a car turning left.

This method models a car turning left. For the time being, and in this
module, this method is just a placeholder and does nothing.

Returns:
None

"""
pass

def turn_right(self):
"""A method to model a car turning right.

This method models a car turning right. For the time being, and in this
module, this method is just a placeholder and does nothing.

Returns:
None

"""
pass

def log_speed(self):
"""A method to log the current speed of the car.

This method logs the current speed of the car. For the purposes of this
course, the logging of speed is trivial and is implemented by the
print() function displaying a message to standard out.

Returns:
None

"""

print(f'{datetime.now().strftime("%d/%m/%Y %H:%M:%S")}: '
f'{self.make} {self.model} current speed: '
f'{self.speed_mph}mph')



Let us examine each component in our Car class in further detail.

###### 2.1.1. Class Variables

Generally speaking, class variables are those attributes that are shared by all instances of the class. In our case, number_wheels is a class variable and is assigned the value of 4, as we assume that all cars have 4 wheels for the sake of simplicity.

###### 2.1.2. Instance Variables

Generally speaking, instance variables are those attributes whose values may be different across different instances of the class. In our case, number_doors, registration_number, make, model, year_manufactured, maximum_speed, acceleration_rate, deceleration_rate, mileage_miles and speed_mph are all instance variables as these values may change across different cars.

###### 2.1.3. Class Instantiation

When an instance of a class (i.e. an object) is created, we say that object has been instantiated, and the process is called class instantiation. The __init__() method (that is the word 'init' with two leading and two trailing underscore characters) contains the code that is automatically executed every time an instance of a class is created. As such, the __init__() method is used to assign values to object attributes, as well as any other operations specific to instances of that class. In our case, our __init__() method expects to be given values for the arguments number_doors, registration_number, make, model, year_manufactured, maximum_speed, acceleration_rate and deceleration_rate respectively when the object is first created. These argument values will then be assigned to the relevant object attribute at the time of instantiation. In the case of the mileage_miles and speed_mph attributes, these are set to a default value of 0, as we assume that when a Car object is first created, it will have done zero miles and will be stationery.

###### 2.1.4. Self Parameter

The self parameter (which is called self by convention rather than a mandatory requirement, and as such may be called something else as long as it is the first parameter in the methods defined in the class) is a reference to the current instance of the class, and as such is used to access variables that belong to the class in the context of the current instance. As such, self is used in the __init__() method to assign the given values to the current instance of the class at the time of object instantiation.

###### 2.1.5. Class Methods

Methods are a type of function that can access and optionally manipulate object attributes, and are defined together with attributes in the same class. In our class, we defined five methods:

• accelerate is used to increase the current speed of the car object. Our implementation is trivial, whereby the current speed of the car object is increased by a constant rate every 1 second until the maximum speed of the car is reached. In our case, the constant rate of acceleration is the value assigned to acceleration_rate at the time of instantiation.

• brake is used to decrease the current speed of the car object. Our implementation is trivial, whereby the current speed of the car object is decreased by a constant rate every 1 second until it reaches zero. In our case, the constant rate of deceleration is the value assigned to the deceleration_rate at the time of instantiation.

• turn_left is used to make the car turn left. For now, this method is a placeholder and as such the body contains the keyword pass so that the Python interpreter does not raise a SyntaxError.

• turn_right is used to make the car turn right. For now, this method is a placeholder and as such the body contains the keyword pass so that the Python interpreter does not raise a SyntaxError.

• log_speed is used to display the current speed of the car object.
###### 2.1.6. String Representation

The __str__() method (that is the word 'str' with two leading and two trailing underscore characters) is invoked when the Python str() function is called on an object of that class (note that the Python print() function implicitly calls the str() function). Thus the __str__() method is generally used to provide a human-readable string representation of the object. In our case, we simply call the Python vars() function that returns the object's __dict__ property, which in turn is a dictonary object that stores an object's mutable attributes. We then prefix this with the class name, which we can retrieve by using the type() function given self and extracting the __name__ attribute.

##### 2.2. Objects

Objects are instances of classes. In our case, our objects represent cars, as they are instantiated from the Car class.

###### 2.2.1. Creating Objects

In the following example, we create two new instances of the Car class and assign the resultant object to the variables mercedes_f1 and ferrari_f1 respectively. Note that because the __init__() function in our class expects to be passed arguments, we must define those argument values at the time of object creation. If our __init__() function did not expect to be passed arguments, then we could have simply used mercedes_f1 = Car() to create a new car object.


# Update sys.path so that it can find our example module
import sys
sys.path.append('examples/formulapy')

# Import our example module containing our Car class definition
from example import Car

# Try to create a new car object without the required arguments
my_car = Car()

# Create a new car object
mercedes_f1 = Car(number_doors = 0,
registration_number = 'MERC 123',
make = 'Mercedes',
model = 'AMG F1 W10 EQ Power+',
year_manufactured = 2019,
maximum_speed = 200,
acceleration_rate = 20,
deceleration_rate = 50)

# Print the type of object that this is i.e. the class that was used to instantiate this object
print(type(mercedes_f1))

# Print a string representation of the car object
print(mercedes_f1)

# Create another new car object
ferrari_f1 = Car(number_doors = 0,
registration_number = 'MON 888',
make = 'Ferrari',
model = 'SF1000',
year_manufactured = 2020,
maximum_speed = 200,
acceleration_rate = 15,
deceleration_rate = 60)

# Print a string representation of the car object
print(ferrari_f1)

###### 2.2.2. Accessing Attributes

We can access the value assigned to a specific attribute belonging to an existing object by using the . operator on the object followed by the relevant attribute, as follows:


# Access and display the maximum speed of the Mercedes car object
print(mercedes_f1.maximum_speed)

# Access and display the registration number of the Ferrari car object
print(ferrari_f1.registration_number)

###### 2.2.3. Modifying Attributes

We can modify the value assigned to a specific attribute belonging to an existing object by simply accessing and then assigning a new value to the relevant attribute, as follows:


# Modify the maximum speed of the Mercedes car object
mercedes_f1.maximum_speed = 220
print(mercedes_f1)

# Modify the registration number of the Ferrari car object
ferrari_f1.registration_number = 'SCUD 888'
print(ferrari_f1)

###### 2.2.4. Deleting Attributes

We can delete a specific attribute from an existing object by using the del keyword following by the object and relevant attribute, as follows (note that the del keyword will return the value assigned to the attribute after it is deleted):


# Delete the number of doors attribute belonging to the Mercedes car object
print(mercedes_f1)
print(mercedes_f1.number_doors)
del mercedes_f1.number_doors
print(mercedes_f1)
print(mercedes_f1.number_doors)

###### 2.2.5. Deleting Objects

We can delete an existing object entirely from memory by using the del keyword forllowed by the object, as follows:


# Create a new car object
redbull_f1 = Car(number_doors = 0,
registration_number = 'RB 999',
make = 'Red Bull',
model = 'RB9',
year_manufactured = 2013,
maximum_speed = 210,
acceleration_rate = 18,
deceleration_rate = 60)

# Print a string representation of the car object
print(redbull_f1)

# Delete the previously created car object
del redbull_f1

# Try to print a string representation of the deleted car object
print(redbull_f1)

###### 2.2.6. Introspection

Almost everything in Python is an object, whether they are functions, strings, collections and so forth (confusingly, even a Python class is a type of 'object' in Python). And there exists in Python a range of special attributes which can be accessed in the same way as normal object attributes, but which either help manage an object or expose useful information and metadata about the object. These special attributes consist of two leading and two trailing underscore characters, and include:

• __dict__ - this special attribute contains the object's attribute references stored in a dictionary object. In other words, when we access an object's attribute using . dot notation, for example Car.maximum_speed, this is translated to Car.__dict__["maximum_speed"]. The __dict__ special attribute is therefore said to manage the object's namespace, and is implemented by a dictionary object.

# Access an object's attribute references
print(mercedes_f1.__dict__)


• __name__ - this special attribute is assigned the name of the given object. When applied to classes, it will be assigned the name of the class. We have also seen in Functions and Modules Part 2 that, when applied to Python modules, if a Python module is executed as a standalone program, then the Python interpreter will assign to __name__ the string literal "__main__" (the world 'main' with two leading underscore and two trailing underscore characters). However if the module is only being imported, then the Python interpreter will assign to __name__ the module name as a string.

# Access a class's name
print(Car.__name__)

# Access an object's class name (or type name) from which it was instantiated
print(mercedes_f1.__class__)
print(mercedes_f1.__class__.__name__)


• __module__ - this special attribute is assigned the name of the Python module that the object was defined in, or None if unavailable.

# Access the name of the module that a class was defined in
print(Car.__module__)
print(mercedes_f1.__class__.__module__)


• __bases___ - this special attribute, when applied to classes, is a tuple containing the base classes that a given class is inherited from. We will discuss base classes in further detail when we explore the concept of inheritance later on in this module. All classes in Python are inherited from the Python object class, so even if we define a class with no explicit inheritance (such as our example Car class), it will still implicitly be inherited from the object class, as follows:

# Access a class's base classes
print(Car.__bases__)
print(mercedes_f1.__class__.__bases__)


To learn more about the Python data model, including these special attributes and how the values assigned to these special attributes may change dependent on the object type, please refer to the Python Data Model language reference resource.

Unlike some other programming languages such as Java, we can dynamically add new attributes to a class instance at runtime. This means that even if we have not defined a specific attribute in our class definition, we can still add it to an object after it has been created. We can do this using the setattr() function which takes three parameters - the object, the name of the attribute, and the value to assign to that attribute. Where that attribute does not exist in the class definition, then setattr() will create a new attribute, as long as the object in question implements __dict__ to manage its namespace, as follows:


# Add a completely new attribute to one of our car objects that was not defined in the Car class definition
print(mercedes_f1)
setattr(mercedes_f1, 'height_mm', 950)
setattr(mercedes_f1, 'width_mm', 2000)
setattr(mercedes_f1, 'weight_kg', 743)
setattr(mercedes_f1, 'power_kw', 750)
print(mercedes_f1)

###### 2.2.8. Invoking Methods

We can invoke a specific method on an existing object by using the . operator on the object followed by the relevant method (and passing any required arguments), as follows:


# Invoke the Car.accelerate() method on an existing car object
ferrari_f1.accelerate()

##### 2.3. Inheritance

So far we have defined a Car class that describes the attributes and methods that all car objects (i.e. instances of the Car class) should have. However there are many different types of vehicles, some of which may have attributes and methods in common with our Car class. The following class diagram, expressed using Unified Modelling Language (UML), provides just one non-exhaustive way that we could structure and describe the domain of modern vehicles.

Note that this is just one example of a way that we can describe the domain of modern vehicles. There may be many others - for example we could group vehicles by whether they have wheels or not, whether they are petrol powered or not, or whether they are land-based or not. The point is that there are many different ways to describe a domain, and there is no one-size-fits-all data model. How you design your data model is dependent on the use-case and problem that you are trying to solve, and is informed by user research, business analysis, service design, data architecture, technical architecture and software engineering principles. However the advantage of using UML to describe your data model is that it provides a common language that may be used to communicate across all members of a multi-disciplinary team, and which is why UML applied to object oriented programming is such as powerful and intuitive concept.

In our data model describing the domain of modern vehicles, we start with a general Vehicle class. This is because all vehicles have shared attributes and methods i.e. they all have an engine and chassis, and they can all accelerate, brake, turn left and turn right. Thereafter we divide vehicles by their mode i.e. whether they are a RoadVehicle, RailVehicle, AmphibiousVehicle or an Aircraft - but no matter what their mode, they are all still a type of Vehicle. And finally, we further divide our modes of vehicles into their specific forms - for example Bus, Car and Motorbike are all child classes of the RoadVehicle class because they are all for use on the road.

Inheritance refers to the ability for a class to inherit attributes and methods from a parent class, just like in our vehicles example. The following sub-sections describe how inheritance can be modelled and implemented in Python.

###### 2.3.1. Superclasses

When a class inherits attributes and methods from a parent class, that parent class is called a superclass or base class. In our modern vehicles data model, Vehicle, RoadVehicle, RailVehicle, AmphibiousVehicle and Aircraft are all superclasses. Also recall that all classes in Python are inherited from the Python object class, so even if a class is defined that has no explicit superclass, it still implicitly inherits from the object superclass. Any class can be a superclass, so the syntax to create a superclass in Python is exactly the same as creating any other class.

###### 2.3.2. Subclasses

A class that inherits attributes and methods from a parent class is called a subclass or child class. In our modern vehicles data model, RoadVehicle, RailVehicle, AmphibiousVehicle, Aircraft, Bus, Car, Motorbike, Train, Tram, Hovercraft, AmphibiousTruck, Plane and Helicopter are all subclasses.

Any class can be a subclass (except the object class). In order to define a class as a subclass in Python, we reference the superclass in parentheses when defining the subclass. To continue with our vehicles example, let us define a Vehicle class. We will then define a RoadVehicle subclass that inherits from the Vehicle superclass. Finally, we will refactor (i.e. update) our Car class so that it inherits from the RoadVehicle superclass and consequently becomes a subclass, as follows:

The source code for these classes can be found in the GitHub repository for this course, and specifically in the Python module found at examples/formulapy/model.py, which will be used to describe our modern vehicles data model.

Vehicle Superclass

In our model, we require that all vehicles have an engine, or multiple engines, and each engine provides power that can be measured in horsepower. We also require that all vehicles have a chassis (i.e. body frame) with a specified height, width and depth, and have a make, model, year of manufacture, maximum speed, and can accelerate and decelerate at a given rate (i.e. its attributes). Finally we assume that all vehicles have the ability to accelerate, brake, turn left and turn right (i.e. its methods).


import time
from datetime import date, datetime

class Vehicle:

def __init__(self, number_engines, engine_horsepower_kw,
chassis_height_mm, chassis_width_mm, chassis_depth_mm,
make, model, year_manufactured, maximum_speed_mph,
acceleration_rate_mps, deceleration_rate_mps):
"""Initialise instances of the Vehicle class."""

self.number_engines = number_engines
self.engine_horsepower_kw = engine_horsepower_kw
self.chassis_height_mm = chassis_height_mm
self.chassis_width_mm = chassis_width_mm
self.chassis_depth_mm = chassis_depth_mm
self.make = make
self.model = model
self.year_manufactured = year_manufactured
self.maximum_speed_mph = maximum_speed_mph
self.acceleration_rate_mps = acceleration_rate_mps
self.deceleration_rate_mps = deceleration_rate_mps
self.mileage_miles = 0
self.speed_mph = 0

def __str__(self):
"""Override the __str__() method to return the class name followed
by the string representation of the object's namespace dictionary.
"""
return type(self).__name__ + str(vars(self))

def accelerate(self):
"""A method to model vehicle acceleration.

This method models the acceleration of a vehicle. For the purposes of
this course, the modelling of acceleration is trivial where the speed
of the vehicle increases by a constant rate every second until the
maximum speed of the vehicle is reached.

Returns:
None
"""

self.log_speed()
while self.speed_mph < self.maximum_speed_mph:
time.sleep(1)
if (self.speed_mph + self.acceleration_rate_mps) \
> self.maximum_speed_mph:
self.speed_mph = self.maximum_speed_mph
self.log_speed()
break
else:
self.speed_mph += self.acceleration_rate_mps
self.log_speed()

def brake(self):
"""A method to model a vehicle braking.

This method models a vehicle braking. For the purposes of this course,
the modelling of braking is trivial where the speed of the vehicle
decreases by a constant rate every second until the speed of the
vehicle is zero.

Returns:
None
"""

self.log_speed()
while self.speed_mph > 0:
time.sleep(1)
if (self.speed_mph - self.deceleration_rate_mps) < 0:
self.speed_mph = 0
self.log_speed()
break
else:
self.speed_mph -= self.deceleration_rate_mps
self.log_speed()

def turn_left(self):
"""A method to model a vehicle turning left."""
pass

def turn_right(self):
"""A method to model a vehicle turning right."""
pass

def log_speed(self):
"""A method to log the current speed of a vehicle.

This method logs the current speed of a vehicle. For the purposes of
this course, the logging of speed is trivial and is implemented by the
print() function displaying a message to standard out.

Returns:
None

"""

print(f'{datetime.now().strftime("%d/%m/%Y %H:%M:%S")}: '
f'{self.make} {self.model} current speed: '
f'{self.speed_mph}mph')



In our model, RoadVehicle is a subclass of the Vehicle superclass, and as such should inherit all the attributes and methods associated with a vehicle. We define RoadVehicle as a subclass of the Vehicle superclass in Python by referencing the Vehicle superclass in parentheses in the RoadVehicle subclass definition i.e. class RoadVehicle(Vehicle). We also define attributes and methods that are specific to the RoadVehicle class - our model requires that road-based vehicles should have a fixed number of wheels, a registration number, and the date of its last MOT road worthiness check (for the purposes of this example, we assume that a road vehicle's first MOT check is undertaken at the time that it is created). Finally, we assume that all road vehicles can signal and reverse, which other vehicles such as aircraft may not be able to do.



def __init__(self, number_engines, engine_horsepower_kw,
chassis_height_mm, chassis_width_mm, chassis_depth_mm,
make, model, year_manufactured, maximum_speed_mph,
acceleration_rate_mps, deceleration_rate_mps,
number_wheels, registration_number):
"""Initialise instances of the RoadVehicle class."""

super().__init__(number_engines, engine_horsepower_kw,
chassis_height_mm, chassis_width_mm, chassis_depth_mm,
make, model, year_manufactured, maximum_speed_mph,
acceleration_rate_mps, deceleration_rate_mps)
self.number_wheels = number_wheels
self.registration_number = registration_number
self.last_mot_date = date.today().strftime("%d/%m/%Y")

def signal(self):
"""A method to model a road vehicle signalling."""
pass

def reverse(self):
"""A method to model a road vehicle reversing."""
pass



Car Subclass

In our model, Car is a subclass of the RoadVehicle superclass, and as such should inherit all the attributes and methods associated with a road vehicle (and in turn inherit all the attributes and methods associated with a general vehicle). We define Car as a subclass of the RoadVehicle superclass in Python by referencing the RoadVehicle superclass in parentheses in the Car subclass definition i.e. class Car(RoadVehicle). We also define attributes and methods that are specific to the Car class - our model assumes that all cars have 4 fixed wheels and as such number_wheels is defined as a class variable. Furthermore, we assume that all cars have the ability to perform a handbrake turn, which other road vehicles such as motorbikes may not be able to do.



number_wheels = 4

def __init__(self, number_engines, engine_horsepower_kw,
chassis_height_mm, chassis_width_mm, chassis_depth_mm,
make, model, year_manufactured, maximum_speed_mph,
acceleration_rate_mps, deceleration_rate_mps,
registration_number):
"""Initialise instances of the Car class."""

super().__init__(number_engines, engine_horsepower_kw,
chassis_height_mm, chassis_width_mm, chassis_depth_mm,
make, model, year_manufactured, maximum_speed_mph,
acceleration_rate_mps, deceleration_rate_mps,
self.number_wheels, registration_number)

def handbrake_turn(self):
"""A method to model a car making a handbrake turn."""
pass

def avoid_collision(self):
"""A method to model a car avoiding an oncoming collision."""
super().turn_left()
super().brake()


###### 2.3.3. Super Function

In the RoadVehicle and Car class definitions above, you may have noticed the use of the super() function in the class __init__() method. By using the super() function, our subclass automatically inherits all attributes and methods from its superclass. This way, we do not need to re-define attributes and methods such as make, model, accelerate() and brake() in the subclass definition, as these attributes and methods (and their logic) are automatically inherited from the superclass. As such we can use the super() function to call superclass methods in a subclass. For example, the avoid_collision() method defined in our Car class implements a trivial means to avoid a collision - it invokes the turn_left() and brake() methods respectively, and does so using the super() function since these methods have been defined in the Vehicle superclass.

In the following example, we create a new car object (i.e. a new instance of the Car class) and access attributes and invoke methods that are defined in the RoadVehicle and Vehicle superclass definitions respectively, but which are available to the car object through inheritance.


# Import our new vehicle domain data model module
from model import Vehicle, RoadVehicle, Car

# Create a new car object
mclaren_p1 = Car(number_engines = 1,
engine_horsepower_kw = 673,
chassis_height_mm = 1188,
chassis_width_mm = 1946,
chassis_depth_mm = 4588,
make = 'McLaren',
model = 'P1',
year_manufactured = 2013,
maximum_speed_mph = 217,
acceleration_rate_mps = 20,
deceleration_rate_mps = 50,
registration_number = 'MCL P1')

# Print a string representation of the car object
print(mclaren_p1)

# Access an attribute that is set in the Vehicle superclass
print(mclaren_p1.maximum_speed_mph)

# Access an attribute that is set in the RoadVehicle superclass
print(mclaren_p1.last_mot_date)

# Access an attribute that is set in the Car subclass
print(mclaren_p1.number_wheels)

# Invoke a method that is defined in the Vehicle superclass
print(mclaren_p1.accelerate())

# Invoke a method that is defined in the Car class that itself invokes methods defined in the Vehicle superclass
print(mclaren_p1.avoid_collision())

###### 2.3.4. Overriding Methods

We have seen how inheritance enables object oriented programming to model a system as related classes and objects that can inherit characteristics and behaviour from each other. This is an extremely powerful mechanism, as any software system is ultimately a model of a real-world system composed of interacting entities. Another powerful feature of inheritance is the ability for subclasses to not only inherit characteristics from parent or superclasses, but to change certain behaviour specific to instances of that subclass.

Overriding refers to the ability of subclasses to inherit methods from a superclass, but to then change the implementation logic of those methods so that the behaviour is specific to that subclass. To override an inherited method, we simply define a method in the subclass with the same name as the inherited method but with a different implementation in the method body. Thus when we call that method on instances of the subclass, the method defined in the subclass is invoked rather than the original method defined in the superclass.

For example let us define a new class called Aircraft which, from our modern vehicles class diagram and data model, is a subclass of the Vehicle superclass. As such, Aircraft will inherit all attributes and methods from Vehicle. However we will override the brake() method and introduce a new implementation for aircraft such that the airspeed never drops to zero but instead a given minimum speed to prevent the aircraft from stalling.


class Aircraft(Vehicle):

def __init__(self, number_engines, engine_horsepower_kw,
chassis_height_mm, chassis_width_mm, chassis_depth_mm,
make, model, year_manufactured, maximum_speed_mph,
acceleration_rate_mps, deceleration_rate_mps,
minimum_speed_mph):
"""Initialise instances of the Aircraft class."""

super().__init__(number_engines, engine_horsepower_kw,
chassis_height_mm, chassis_width_mm, chassis_depth_mm,
make, model, year_manufactured, maximum_speed_mph,
acceleration_rate_mps, deceleration_rate_mps)
self.minimum_speed_mph = minimum_speed_mph

def brake(self):
"""A method to model an aircraft braking.

This method overrides the brake() method in the Vehicle class and
instead provides a trivial model of an aircraft braking where the
speed of the aircraft decreases by a constant rate every second until
the speed of the aircraft reaches its minimum speed to maintain flight.

Returns:
None
"""

self.log_speed()
while self.speed_mph >= self.minimum_speed_mph:
time.sleep(1)
if (self.speed_mph - self.deceleration_rate_mps) \
< self.minimum_speed_mph:
self.speed_mph = self.minimum_speed_mph
self.log_speed()
break
else:
self.speed_mph -= self.deceleration_rate_mps
self.log_speed()



Now when we call the brake() method on aircraft objects, it will invoke the method defined in the Aircraft subclass that overrides the brake() method originally defined in the Vehicle superclass, as follows:


# Create a new aircraft object
airbus_a380 = Aircraft(number_engines = 4,
engine_horsepower_kw = 670,
chassis_height_mm = 24100,
chassis_width_mm = 79800,
chassis_depth_mm = 72700,
make = 'Airbus',
model = 'A380',
year_manufactured = 2005,
maximum_speed_mph = 736,
acceleration_rate_mps = 30,
deceleration_rate_mps = 30,
minimum_speed_mph = 150)

# Print a string representation of the aircraft object
print(airbus_a380)

# Invoke a method that is defined in the Vehicle superclass
print(airbus_a380.accelerate())

# Invoke a method that is defined in the Aircraft class that overrides a method defined in the Vehicle superclass
print(airbus_a380.brake())


In object oriented programming, Polymorphism refers to the ability for a single object, method or operator to take on diffent forms. Method overriding is a feature made possible by polymorphism, that is the ability for subclasses to inherit methods from a superclass, but to then change the implementation of those methods so that the behaviour is specific to that subclass.

###### 2.3.5. Multiple Inheritance

So far we have studied examples of single inheritance, that is subclasses that inherit from one superclass. It is also possible for a subclass to inherit from multiple base or superclasses simultaneously, in what is known as multiple inheritance.

Let us consider the example of a seaplane. By referencing our modern vehicles class diagram and data model, a seaplane can be considered both an amphibious vehicle (i.e. it is capable of travelling on, taking off and landing on water) and an aircraft. In this case, we would use multiple inheritance so that it can inherit attributes and methods from both the AmphibiousVehicle and Aircraft superclasses respectively, as follows:

In order for a subclass to inherit from multiple superclasses in Python, we simply provide a comma-separated list of superclasses when defining the subclass, as follows:


# Define an AmphibiousVehicle subclass that inherits from the Vehicle superclass
class AmphibiousVehicle(Vehicle):
pass

# Define an Aircraft subclass that inherits from the Vehicle superclass
class Aircraft(Vehicle):
pass

# Define a Seaplane subclass that inherits from both the AmphibiousVehicle and Aircraft superclasses
class Seaplane(AmphibiousVehicle, Aircraft):
pass


Recall that any class can be a superclass, even the class Seaplane which itself inherits from multiple superclasses. This feature of inheritance is called multilevel inheritance and allows our data models and class diagrams to have indefinite depth. In practice however, our class diagrams would stop at a certain depth based on the data architecture of the use case in question.

###### 2.3.6. Method Resolution Order

Multiple and multilevel inheritance provisions us with the tools to design flexible data models with custom depth. However it does introduce a problem of precedence, as highlighted in the following example:


# Define a simple superclass
class ClassA:

var1 = 'A'

def __init__(self, a1, a2, a3):
self.attr1 = a1
self.attr2 = a2
self.attr3 = a3

def method1(self):
return self.attr1 + self.attr2

# Define a simple class derived from ClassA
class ClassB(ClassA):

var1 = 'B'

def __init__(self, b1, b2, b3):
super().__init__(b1, b2, b3)
self.attr4 = b2 * b3

def method1(self):
return self.attr1 * self.attr2

# Define another simple class derived from ClassA
class ClassC(ClassA):

def __init__(self, c1, c2, c3):
super().__init__(c1, c2, c3)
self.attr4 = c2 - c3

def method1(self):
return self.attr1 - self.attr2

# Define a class that is derived from both ClassB and ClassC
class ClassD(ClassB, ClassC):
pass


In this example, ClassD is derived from both ClassB and ClassC which are in turn derived from ClassA. The problem is that ClassB and ClassC assign different values to attr4, as well as having different implementations of the method1() overriding method. When we create instances of ClassD, which value will it assign to attr4, and what value will be returned by invoking the method1() method?

This problem is known as the Diamond Problem because, represented as a class diagram, the shape looks similar to a diamond, as follows:

Python solves this problem by using the Method Resolution Order (MRO), which determines the search order for inherited methods (and attributes). MRO dictates that the Python interpreter should first look for the relevant method or attribute in the current class (in this case ClassD). If it is not defined in the current class, then it should next look in the first superclass that the current class is derived from, in a left-right order (so in this case ClassB). If it is not defined in the first superclass, then it should next look in the first superclass that the current superclass is derived froom (so in this case ClassA). Finally, if it is not defined in this superclass, then it should next look in the second superclass that the original subclass is derived from (so in this case ClassC). Note that MRO dictates that the same class cannot be searched twice, so ClassA would not be searched again.

In otherwords, MRO takes a depth-first (starting at the bottom of the tree), left-right approach to class precedence, as denoted in the previous class diagram by numbered circles, and as illustrated in the following examples:


# Create and test an instance of ClassB
class_b_object = ClassB(1, 2, 3)
print(f'ClassB attr1: {class_b_object.attr1}')
print(f'ClassB attr2: {class_b_object.attr2}')
print(f'ClassB attr3: {class_b_object.attr3}')
print(f'ClassB attr4: {class_b_object.attr4}')
print(f'ClassB var1: {class_b_object.var1}')
print(f'ClassB method1(): {class_b_object.method1()}')

# Create and test an instance of ClassC
class_c_object = ClassC(1, 2, 3)
print(f'ClassC attr1: {class_c_object.attr1}')
print(f'ClassC attr2: {class_c_object.attr2}')
print(f'ClassC attr3: {class_c_object.attr3}')
print(f'ClassC attr4: {class_c_object.attr4}')
print(f'ClassC var1: {class_c_object.var1}')
print(f'ClassC method1(): {class_c_object.method1()}')

# Create and test an instance of ClassD
class_d_object = ClassD(1, 2, 3)
print(f'ClassD attr1: {class_d_object.attr1}')
print(f'ClassD attr2: {class_d_object.attr2}')
print(f'ClassD attr3: {class_d_object.attr3}')
print(f'ClassD attr4: {class_d_object.attr4}')
print(f'ClassD var1: {class_d_object.var1}')
print(f'ClassD method1(): {class_d_object.method1()}')


As demonstrated in these examples, when we access the values assigned to the class_d_object attribute attr4 and class variable var1, and the value returned by invoking the method1() method, we can see that the operations undertaken in ClassB take precedence over ClassC.

Conveniently, Python allows us to access the resolved MRO via the __mro__ attribute for classes, as follows:


# Access the resolved MRO for each class
print(f'ClassA MRO:\n{ClassA.__mro__}\n')
print(f'ClassB MRO:\n{ClassB.__mro__}\n')
print(f'ClassC MRO:\n{ClassC.__mro__}\n')
print(f'ClassD MRO:\n{ClassD.__mro__}\n')


Though multilevel inheritance is supported in Python 3, there are a few edge cases that you should be aware of that may cause the Python interpreter to raise an error, or return different results compared to what you may have expected. This great article on The Wonders of Cooperative Inheritance and using Super in Python 3 by Michele Simionato explains some of those edge cases, including dealing with incompatible signatures in cooperative hierarchies.

###### 2.3.7. Finding Subclasses

We can use the __name__ special attribute, as introduced in the introspection section of this module, along with the __subclasses__() class method to return a list of names of all subclasses that are directly derived from a given class (or simply use the __subclasses__() method along to return the subclasses themselves), as follows:


# List the names of all the subclasses directly derived from the Vehicle class
print([cls.__name__ for cls in Vehicle.__subclasses__()])

# List all the subclasses directly derived from the Vehicle class
print(Vehicle.__subclasses__())


To identify and list all subclasses of a given class in the tree recursively i.e. both direct subclasses and those that are derived from the given class as a result of multilevel inheritance, we can define and implement a recursive function using a lambda expression, as follows:


# Create a function to list all direct and indirect subclasses of a given class
def find_all_subclasses(cls):
return set(cls.__subclasses__()).union(
[subclass for c in cls.__subclasses__() for subclass in find_all_subclasses(c)])

# List all the subclasses that are both directly and indirectly derived from the Vehicle class
print(find_all_subclasses(Vehicle))

##### 2.4. Constructors

In object oriented programming, a constructor is a special type of method whose method body is executed when a new instance of a class is instantiated. In Python, a constructor is defined using the __init__() function that we introduced earlier in the Class Instantiation section of this module. Note that constructors cannot return any value, but are instead generally used to assign initial values to instance variables.

###### 2.4.1. Parameterized Constructor

Constructors can either be parameterized (i.e. define specific arguments that are required to be passed to the constructor when creating new instances of the class) or non-parameterized (i.e. no arguments are required in order to create new instances of the class). Our original Car class, introduced in the Classes section of this module, is an example of a class containing a paramterized constructor i.e. new instances of the Car class will need to provide specific arguments in order for a car object to be created, as follows:


# Example Car Class
class Car:

def __init__(self, number_doors, registration_number, make,
model, year_manufactured, maximum_speed,
acceleration_rate, deceleration_rate):

self.number_doors = number_doors
self.registration_number = registration_number
self.make = make
self.model = model
self.year_manufactured = year_manufactured
self.maximum_speed = maximum_speed
self.acceleration_rate = acceleration_rate
self.deceleration_rate = deceleration_rate
self.mileage_miles = 0
self.speed_mph = 0

# Create a new car object
mercedes_f1 = Car(number_doors = 0,
registration_number = 'MERC 123',
make = 'Mercedes',
model = 'AMG F1 W10 EQ Power+',
year_manufactured = 2019,
maximum_speed = 200,
acceleration_rate = 20,
deceleration_rate = 50)

###### 2.4.2. Non-Parameterized Constructor

A non-parameterized constructor in Python is defined by simply using the __init__() method defined with the self parameter and no other parameters, as follows:


# Define a class with a non-parameterized constructor
class ClassX:

def __init__(self):
print(f'Creating an instance of {type(self).__name__}...')

def sum(self, a, b):
return a + b

# Create a new instance of ClassX
class_x_object = ClassX()

# Invoke a method on the new object of type ClassX
print(class_x_object.sum(100, 38))

###### 2.4.3. Default Constructor

All objects in Python are created using a constructor. If we create a class but do not define an explicit constructor (whether parameterized or not) then Python will use the default constructor which does not accept any arguments and does not perform any explicit operations, as follows:


# Define a class without an explicit constructor
class ClassY:

def product(self, a, b):
return a * b

# Create a new instance of ClassY
class_y_object = ClassY()

# Invoke a method on the new object of type ClassY
print(class_y_object.product(13, 20))

###### 2.4.4. Explicit Invocation

We saw in the Super Function section of this module that a subclass can invoke methods defined in a superclass from which it is derived using the super() function, including invoking the superclass __init__() method. Alternatively, we can explicitly invoke the constructor of any class (whether a superclass or not) using the Class.__init__() syntax, as follows:


# Define a class with a constructor that explicitly invokes the constructor of another class
class ClassZ:

def __init__(self):
ClassX.__init__(self)

def modulus(self, a, b):
return a % b

# Create a new instance of ClassZ
class_z_object = ClassZ()

# Invoke a method on the new object of type ClassZ
print(class_z_object.modulus(103, 4))


Note that in some programming languages such as Java, defining multiple constructors in a single class is supported via a technique called method overloading. Method overloading enables the definition of multiple methods of the same name but with different parameters in the same class. However Python does not support method overloading, and hence does not support defining multiple constructors in a single class.

##### 2.5. Name Mangling

Programming languages such as Java support access modifiers and as a consequence private variables. Private variables in these languages are variables that can only be accessed by instances of the specific class in which those private variables were defined. This means that instances of any other class, including instances of subclasses, cannot access these private variables.

However Python does not support private variables. Instead there is a convention whereby any class member (i.e. an attribute or a method) whose identifier is prefixed with the _ underscore character should be considered by other developers as a non-public member of that class, and with the subsequent understanding that it may be subject to change without notice (i.e. if it is changed without notice, then this should have no impact on projects that depend upon the class as they should not be using these class members in any event).

Name mangling extends this convention by textually replacing any class member identifier containing at least two leading underscore characters, and at most one trailing underscore character, with _classname__identifier. This way, the class member cannot be accessed directly outside of the class via its identifier. Instead it can only be accessed outside of the class indirectly through its mangled replacement. In effect, name mangling has the effect of obfuscating relevant class members rather than truly hiding them, as follows:


# Define a class that contains 'private' variables
class User:

def __init__(self, fname, lname, email, dob, postal_address):
self.fname = fname
self.lname = lname
self.email = email
self.__dob = dob

def display_dob(self):
print(self.__dob)

# Create a new user
barack_obama_user = User('Barack', 'Obama', 'bobama@whitehouse.gov', '04/08/1961', '1600 Pennsylvania Avenue')

# Display the user's private dob using the display_dob() method
barack_obama_user.display_dob()

# Display the user's private dob by directly accessing the dob attribute using dot notation - this will return an AttributeError error
print(barack_obama_user.dob)

# Display the user's private dob indirectly by accessing the mangled dob attribute
print(barack_obama_user._User__dob)

##### 2.6. Common Functions

To finish our exploration of object oriented programming in Python, we will introduce a non-exhaustive list of common functions associated with classes and methods, as follows:

• hasattr(object, attribute) - tests whether a given object has a given attribute, returning True if so, False otherwise.
• type(object) - returns the type of a given object i.e. the class from which it was instantiated.
• issubclass(object, subclass) - tests whether a given object is a subclass of another given object, returning True if so, False otherwise.
• isinstance(object, type) - tests whether a given object is of the given type, returning True if so, False otherwise.

# Create a new aircraft object
airbus_a380 = Aircraft(number_engines = 4,
engine_horsepower_kw = 670,
chassis_height_mm = 24100,
chassis_width_mm = 79800,
chassis_depth_mm = 72700,
make = 'Airbus',
model = 'A380',
year_manufactured = 2005,
maximum_speed_mph = 736,
acceleration_rate_mps = 30,
deceleration_rate_mps = 30,
minimum_speed_mph = 150)

# hasattr()
print( hasattr(airbus_a380, 'engine_horsepower_kw') )
print( hasattr(airbus_a380, 'last_mot_date') )
print()

# type()
print( type(airbus_a380) )
print( type([1, 2, 3]) )
print( type(('a', 'b', 'c')) )
print( type({1: 'a', 2: 'b', 3: 'c'}) )
print( type(Vehicle) )
print( type(str) )
print( type(int) )
print( type(str) )
print( type('Hello World!') )
print( type(38) )
print( type(0.5) )
print( type(True) )
print( type(False) )
print( type(47 & 55) )
print( type(None) )
print()

# issubclass()
print( issubclass(type(airbus_a380), Vehicle) )
print( issubclass(Aircraft, Vehicle) )
print( issubclass(Vehicle, Aircraft) )
print( issubclass(Vehicle, Vehicle) )
print( issubclass(Vehicle, object) )
print( issubclass(str, object) )
print( issubclass(list, object) )
print( issubclass(tuple, object) )
print( issubclass(dict, object) )
print( issubclass(type(ClassD(1, 2, 3)), ClassA) )
print( issubclass(type(None), ClassA) )
print( issubclass(type(None), object) )
print()

# isinstance()
print( isinstance(airbus_a380, Aircraft) )
print( isinstance(airbus_a380, Vehicle) )
print( isinstance(airbus_a380, object) )
print( isinstance(airbus_a380, Car) )
print( isinstance(None, object) )


### Summary

In this module we have explored the fundamental concepts in the object oriented programming (OOP) paradigm, including classes, objects, attributes, methods, inheritance, multilevel inheritance and constructors. We understand the high-level difference between imperative, functional and object oriented programming, and have the ability to use Python to build applications that implement object oriented programming principles in order to model real-world systems that are composed of objects that share characteristics and behaviour, and that interact with each other.

### Homework

Please write Python programs for the following exercises. There may be many ways to solve the challenges below - first focus on writing a working Python program, and thereafter look for ways to make it more efficient using the techniques discussed both in this module and over this course thus far.

1. Python Classes - Deck of Cards
Write, and properly document, custom Python classes designed to model a standard 52-card deck. You should create two classes, namely Card and Deck, where a deck of cards is assumed to be 52 cards, and a given card has a suit (i.e. Hearts, Diamonds, Clubs or Spades) and a value (i.e. Ace, 2 - 10, Jack, Queen or King) associated with it, where no two cards are the same. Your Deck class should include the following methods:

• Shuffle - the ability to randomize the order of the cards in the deck, and subsequently display the randomised order.
• Pick a Card - the ability to pick and return the value of a random card.
• Deal - the ability to deal the cards across N given players, where each player has the same number of cards, and where N is passed to the method as an integer argument. This method should then display the cards that each player has in their hand.

2. Python Classes - Blackjack
Using the Deck and Card classes that you created in question 1, design and code a simple implementation of the card game 21/Blackjack. The rules of our simple implementation are as follows:

• There should be 2 players - the dealer (computer) and the player (you).
• The dealer shuffles the cards.
• The dealer then deals two cards to itself, and two cards to the player.
• One of the dealer cards is displayed, but both of the player cards are displayed.
• The dealer then asks (hint: input) the player whether they want another card, or want to sit on what they have. If the player decides that they want another card, a new card is dealt to the player. This step is repeated until the player either decides to sit on what they have, or the total value of the player cards is greater than or equal to 21. If the total value of the player cards exceeds 21 at any moment, then the player goes bust and the dealer automatically wins without having to play.
• If the player decides to sit on what they have, and the total value of the player cards does not exceed 21, then it is the turn of the dealer.
• The dealer starts off by revealing his second card to the player.
• The dealer then chooses whether it wants another card or wants to sit on what it has. If the total value of the dealer cards is less than or equal to 16, then it must take another card. If the total value of the dealer cards is 17 or higher, then it randomly chooses whether it wants another card or not. This step is repeated until the dealer either randomly decides to sit on what it has, or the total value of the dealer cards is greater than or equal to 21.
• The possible outcomes are as follows:

1. If the player exceeds 21, then the dealer automatically wins the game without having to play (as above). In all the other outcomes below, the player is still in the game and has a total hand of 21 or less.
2. If both the player and the dealer have 21, then whomever reached that total in the fewest cards wins the game. If they both took the same number of cards to reach 21, then it is a drawn game.
3. If the dealer has 21 and the player does not, then the dealer wins the game.
4. If the dealer exceeds 21, then the player wins the game.
5. In all other cases, whomever is closest to 21 wins the game.
6. If both the player and the dealer have an equal total, then whomever reached that total in the fewest cards wins the game. If they both reached the same total with the same number of cards, then it is a drawn game.

### What's Next

In the next module, we will consolidate and apply our knowledge of object oriented programming principles together with what we have learnt from the other modules throughout this course thus far to create a car racing game written in Python!