Collections and Classes
Functions, Lists, Classes and Composition
Functions and Collections
In programming, collections are essential tools for organizing and manipulating groups of data. They allow the storage of multiple associated values or data in a single variable, making it easier to perform operations like searching, filtering and iterating through the data. In the lectures, we have seen that Python offers versatile collection types like lists, sets, tuples and dictionaries. Each is powerful, easy to use and has specific application.
In this section, you’ll learn the basics of working with collections in Python. We’ll start by creating and exploring lists, a dynamic data structure used to store and access items by their position. Next, we’ll introduce dictionaries, which are perfect for situations where you need to associate or label data using unique keys. Finally, we’ll cover list comprehensions, an efficient way to generate new lists on the fly using concise, expressive syntax. By mastering these concepts, you’ll be better equipped to solve real-world problems with structured data in Python.
Lists
Lists are one of the most commonly used data structures in Python, designed to store collections of items in a specific order. They are versatile and allow you to work with data in a way that’s both simple and efficient. Whether you’re managing a sequence of numbers, a collection of words, or a mix of different data types, lists provide the flexibility to add, remove, and manipulate items with ease.
These exercises will reinforce your understanding of lists by requiring you to create, modify, and interact with them in meaningful ways. You’ll practice using loops, list indexing, and conditional statements in Python.
Creating and Appending to Lists
Start with the code in below and read through each line to ensure that you understand what it is doing.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
###################################################################################################
# Filename: Lists.py
# Description: A simple example, using lists and for loops.
# Date: 03/12/2024
# Author: Jonathan G-H-Cater
###################################################################################################
# Create an empty list for the student grades
student_grades = []
# Define list functions
def add_student(name, grade):
## <WRITE YOUR CODE HERE> ##
# Test list functions
add_student("Alice", 85)
add_student("Bob", 72)
add_student("Charlie", 90)
print(student_grades)
# Should output: [("Alice", 85), ("Bob", 72), ("Charlie", 90)]
Modify the add_student(...)
function so that it can be used to add a new student and grade to the list.
Cycling Through Lists (of Tuples)
You may notice that our student_grades
list contains another collection, meaning that if we were to cycle over the list using a for
loop, it would return a tuple of the form (name, grade)
each time.
It is likely that we will need to access the name or grade for each student separately, and there are actually two different ways that we can handle this…
First, we could explicitly identify which part of the tuple we want to reference using indexing. For example:
list_of_tuples = [("Test",2.0),("New",3.0)]
for a_tuple in list_of_tuples:
print(a_tuple[0])
print(a_tuple[1])
Alternatively, we can actually give python two variables to store the returned tuple in, for example:
list_of_tuples = [("Test",2.0),("New",3.0)]
for label, number in list_of_tuples:
print(label)
print(number)
In this case, Python will unpack the collection for you, and store each element in a separate variable.
Whenever using unpacking in a for
loop, it is critical that you ensure you have the exact right number of variable names to store the unpacked values.
Add an additional function under your add_student()
function called find_high_scorers()
.
This new function should take an integer threshold as an argument and return a list of any student names who have scored above the threshold.
For example, if student_grades
contains: [(“Alice”, 85), (“Bob”, 72), (“Charlie”, 90)] then…
>>> student_grades = [("Alice", 85), ("Bob", 72), ("Charlie", 90)]
>>> # Assume we have defined the function find_high_scorers(...)
>>> find_high_scorers(80)
["Alice", "Charlie"]
List Comprehension
It is actually possible to create the list of students who have scored above a given threshold using a single line of code using something known as list comprehension.
...25
26
def find_high_scorers(threshold):
return [student for student, grade in student_grades if grade > threshold]
List comprehension is a really useful syntax that allows us to create a new list based on the contents of an existing collection. The list comprehension syntax always takes the following form, where values inside ‘<>’ are to be replaced:
newlist = [<expression> for <item> in <iterable_collection> if <condition>]
This causes Python to cycle through the <iterable_collection>
, one entry at a time. If the <condition>
is met, the current entry under consideration will be stored in the <item>
variable. The <expression>
can then do anything with the <item>
data, and the result of the <expression>
will be stored as a new entry in newlist
. The if <condition>
is optional as without it every item in <iterable_collection>
will be used.
Given the code below in , add additional Python that uses list comprehension to create a new list called prices_with_VAT
that contains the prices with VAT added (assume VAT should be a 20% increase on the current price). For example, if a the price is $12$, the price with VAT added is given by $12 + (0.2 \times 12)$.
1
prices = [12.4, 10.2, 4, 10, 100]
You may wish to round the answer to 2 D.P. using the round()
function, which takes a float as the first argument and the number of decimal places to round to as the second.
Dictionaries
The previous example used a tuple pairing to associate students with their grades. While this approach works for small datasets, it quickly becomes unwieldy when tracking multiple exam grades. Keeping track of which position in the tuple corresponds to which particular exam could quickly lead to confusion.
1
student_grades = [("Alice", 85, 72, 33), ("Bob", 72, 44, 89), ("Charlie", 90, 98, 12)]
This approach becomes especially problematic when students take different exams from one another. In such cases, we’d need to either:
- Track not only the grades but also which exam each grade corresponds to by adding an additional exam name entry into our tuple, or…
- Fill in the tuple with a large number of
0
orNone
values so that there is a designated space for every possible exam.
Both solutions make the data more complex and therefore harder to interpret and work with.
Python dictionaries provide a more structured and flexible solution. They allow us to store related information in a clear and structured way, using descriptive keys to associate each data entry with some label. By using descriptive keys, we can associate each student with their grades and clearly label each exam. This makes the data easier to interpret and modify.
1
2
3
4
5
student_grades = {
"Alice": {"Circuits": 85, "Signals": 72, "Semiconductors": 33},
"Bob": {"Circuits": 72, "Signals": 44, "Semiconductors": 89},
"Charlie": {"Circuits": 90, "Signals": 98, "Electromagnetics": 12}
}
The example in is actually a dictionary of dictionaries. The outer dictionary contains a set of student names as keys, each of which point to a dictionary that contains the grades for that student - this time using the unit name as the key. You may notice that this approach has let us record a different set of exam grades for ‘Charlie’ without running into any issues keeping track of what the grade actually represents.
To access a specific student’s grade for a particular exam using this approach, we now simply use the student’s name (key in the outer dictionary) and the exam title (key in the inner dictionary).
1
2
3
grade = student_grades["Alice"]["Signals"]
print(f"Alice's grade for Signals is {grade}.")
# Prints: Alice's grade for Signals is 85.
Dictionaries are great for associating data with some label or key, however each key must be unique from all others. This means that this solution would not work if any students had the same first name.
We can combine collections in may different ways, each yielding a slightly different data-structure, for example, the same data could be structured as follows:
1
2
3
4
5
student_grades = [
{"name": "Alice", "exam_grades": {"Circuits": 85, "Signals": 72, "Semiconductors": 33}},
{"name": "Bob", "exam_grades": {"Circuits": 72, "Signals": 44, "Semiconductors": 89}},
{"name": "Charlie", "exam_grades": {"Circuits": 90, "Signals": 98, "Electromagnetics": 12}}
]
The exact data structure that is needed will be task specific, so playing around with these concepts and getting used to the strengths and shortcomings of each structure is advised.
Objects and Classes
In Python, objects are structures that combine data and behavior, allowing us to model real-world entities in an intuitive way. At the heart of this concept are classes, which act as blueprints for creating objects. A class defines the attributes (data) and methods (functions) that its objects will have, giving you the tools to encapsulate functionality and organize code more effectively.
In this section, you’ll learn the basics of working with classes and objects in Python. We’ll explore how to define a class, create objects, and use methods to interact with them. These concepts are foundational for writing scalable and reusable code, enabling you to model complex systems like circuits, mechanical components, or even an entire engineering simulation. By the end of this section, you’ll understand how Python’s object-oriented features allow you to build programs that are both flexible and easy to maintain.
It is assumed that you have reviewed all lecture content delivered prior to this lab.
Creating Objects
From here on, we will create part of a simple text based adventure game. This game will track a number of stats about players including their name, health, strength, stamina and mana. While these stats will not actually do anything, you can imagine that they could go on to influence other elements later on in development.
Our task is to build out a system to allow players to drink potions that will modify these stats in some way (usually by increasing or decreasing them by some fixed amount). The UML diagram for the initial Class structure proposal may be found below in
Printing Stats
Some of the code has already been written for the game, found below in .
1
2
3
4
5
6
7
class Player:
def __init__(self, name, health, strength, stamina, mana):
self.name = name
self.health = health
self.strength = strength
self.stamina = stamina
self.mana = mana
Modify this code by adding a new method get_stats()
to the Player
class that prints out each of the players stats one line at a time - for example, if we created a player with the name ‘John’ who had 100 health, 10 strength, 50 stamina, and 30 mana, then the output should appear as follows:
Player John has the following stats:
Health: 100
Strength: 10
Stamina: 50
Mana: 30
Before we can implement the drink_potion()
method for our player, we need to first define a Potion
class that our player can interact with.
Creating Potions
- Define a new
Potion
class, under thePlayer
class that supports a name property which can be set to a string value when instantiating an object of the class (see the player for a hint on how to do this). - Add a method
drink()
to thePotion
class which takes aPlayer
object as an argument and prints the message:
‘<Player Name> drinks <Potion Name>.’
For example, once complete your code should produce something like:
>>> player_john = Player(name = "John", health = 100, strength = 10, stamina = 50, mana = 30)
>>> health_potion = Potion(name="Minor Health Potion")
>>> health_potion.drink(player_john)
John drinks Minor Health Potion.
Now that we have Potion
s and they can be ‘drunk’, we need to create the Player
method that triggers the Drink()
call on a specified Potion
object.
Drinking Potions
Add a method to the Player
class named drink_potion()
that takes a potion as an argument. This method should call the drink()
method that belongs to the potion that has been passed, providing the Player
s own self variable as the argument into the drink()
method.
By the end of this section, you should be able to run the following commands (identified with »>, while the expected output will be shown below the command without the python prompt):
>>> # Create a new player and a couple of potions
>>> player = Player(name="John", health=100, strength=10, stamina=50, mana=30)
>>> health_potion = Potion(name="Health Potion")
>>> health_and_mana_potion = Potion(name="Health & Mana Potion")
>>>
>>> # Check that player has been created properly
>>> player.get_stats()
Player John has the following stats:
Health: 100
Strength: 10
Stamina: 50
Mana: 30
>>>
>>> # Player drinks various potions
>>> player.drink_potion(health_potion)
John drinks Health Potion.
>>> player.drink_potion(health_and_mana_potion)
John drinks Health & Mana Potion
Inheritance
We have now made it so that our players can drink potions - however the potions have no affect on the players stats. In this section we will use inheritance to create several specific potions that modify the players stats accordingly.
The easiest way to achieve this would be through inheritance, where each new potion type inherits the underlying behaviors of the Potion
class, but adds their own behaviors.
A Health Potion, for example, could be implemented using the code shown below in . This new child class adds the ability to heal the Player who triggers the drink()
action.
...34
35
36
37
38
39
40
41
42
43
# Inherited class HealthPotion
class HealthPotion(Potion):
def __init__(self, name, heal_amount):
super().__init__(name) # Call the base class constructor
self.heal_amount = heal_amount
def drink(self, player):
super().drink(player) # Call the base class drink method
player.health += self.heal_amount
print(f"Player healed by {self.heal_amount}. Current health: {player.health}")
Note how this new HealthPotion
class uses the super()
command to call the inherited Potion
methods before adding its own function.
Once done, it should be possible to instantiate a new Player and then modify their health and mana stats using potions.
Test your code by adding the following script to the end of your file:
# Create player and potions
player = Player(name="John", health=100, strength=10, stamina=50, mana=30)
health_potion = HealthPotion(name="Health Potion", heal_amount=50)
mana_potion = ManaPotion(name="Mana Potion", mana_restore=20)
# Display player starting stats
player.get_stats()
# Make player drink various potions
player.drink_potion(health_potion)
player.drink_potion(mana_potion)
player.drink_potion(health_and_mana_potion)
# Display player finishing stats
player.get_stats()
Check that your code produces an output that makes sense to you.
Composition
It may seem that we can now add new potion effects easily, by simply creating new children of the base Potion class. However this is not as flexible as it could be. For example, what if we wanted to have a potion that could increase both Health and Mana? Well, we could inherit from HealthPotion
and then add the Mana effects manually. But what about a potion that could increase Strength? What about all three?
Hopefully you can see that this does not produce a very scalable code base, as we have to keep implementing the same features for each combination of effects. This is exactly where composition comes in. Composition encourages thinking about the class relationships with a ‘has-a’ mindset instead of an ‘is-a’ mindset. A healing potion ‘is-a’ potion, but it ‘has-a’ healing effect. The issues come about when we create hybrid potions: A healing and mana potion ‘is-a’ healing potion and also ‘is-a’ mana potion, but it ‘has-a’ healing effect and also ‘has-a’ mana restoring effect. The ‘is-a’ mindset creates a contradiction, where we ideally need to inherit from two different classes. The ‘has-a’ mindset is simply additive.
In practice, each potion could have a mix or collection of potion effects. Drinking the potion should apply these effects, one after the other. With such a structure, we can focus on defining new potion effects, and these can then easily be mixed in with existing potion effects without requiring new supporting code.
In order to ensure that we can support a range of possible effects, it is important to define an interface for the Potion effects (see the PotionEffect
abstract class in ). This interface establishes a contract between the Potion
class and any given Effect
, ensuring that the method naming and arguments are consistent.
Naturally, this change will require a slight refactor of our code - however the changes should be constrained to just the Potion
class, as the Player
-Potion
interaction has not changed.
An example of the code (without the HealthPotion
or ManaPotion
classes which we are about to replace) may be found below in the checkpoint. While you are encouraged to continue using your own code in the next section, this checkpoint may help if things are not working as expected.
Abstract Classes
In order to create the PotionEffect
Interface, we require the ability to create an Abstract class. Abstract Classes are blueprints for other classes, containing methods that are declared but not implemented. They force all their subclasses to define these methods. They cannot be instantiated on their own and serve to define a common interface or shared functionality for all their subclasses.
This will ensure that no code can instantiate a PotionEffect
object directly (and indeed it would do nothing even if it could), but must instead create children of PotionEffect
for instantiation.
In Python, abstract classes are implemented using the abc
(Abstract Base Class) module. An abstract class is defined by inheriting from ABC
and can include abstract methods, marked with the @abstractmethod
decorator. These abstract methods provide a blueprint for methods that must be implemented in any subclass. An example of these principles may be found below in . You will notice that this code imports the ABC
base class and abstractmethod
decorator from the abc
library included in Python. Decorators are modifiers for functions or methods, and are placed before the function definition using an @
symbol.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from abc import ABC, abstractmethod
# Define an abstract class
class Animal(ABC):
@abstractmethod
def make_sound(self):
pass # Subclasses must implement this method
# Subclass implementing the abstract method
class Dog(Animal):
def make_sound(self):
print("Woof!")
# Subclass implementing the abstract method
class Cat(Animal):
def make_sound(self):
print("Meow!")
# Using the subclasses
dog = Dog()
dog.make_sound() # Output: Woof!
cat = Cat()
cat.make_sound() # Output: Meow!
In this example, the Animal
class is the Abstract Interface, and it defines a method make_sound()
that must be implemented by all children.
The @abstractmethod
decorator tells Python that children must override the make_sound()
method to be valid.
Creating The Interface Challenge
Using the example above as reference, create a PotionEffect
interface as an Abstract base class, with a single abstract method apply()
that takes a player as an argument.
Modifying the Potion
Class Challenge
- Now modify your
Potion
class__init__()
method to accept an additional argument calledeffects
. This argument should take a list ofPotionEffect
s and store them as a property of thePotion
class. - Modify the
drink()
method of the potion class so that, after printing a message to say the player is drinking the potion, it cycles through each effect in theeffects
list and calls the effectsapply()
method - passing the player as an argument.
Now that we have a PotionEffect
interface, and the Potion
class uses this interface, we can finally implement new Potion effects.
For example, we could create a healing effect that increases the players health by some fixed amount, as follows:
...56
57
58
59
60
61
62
class HealthPotionEffect(PotionEffect):
def __init__(self, heal_amount):
self.heal_amount = heal_amount
def apply(self, player):
player.health += self.heal_amount
print(f"Player healed by {self.heal_amount}. Current health: {player.health}")
We can now create health potions by simply adding the HealthPotionEffect
to a Potion
at point of instantiation. For example:
...64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# Create effect
minor_health_effect = HealthPotionEffect(heal_amount=50)
health_effect = HealthPotionEffect(heal_amount=100)
# Create potion
small_health_potion = Potion(name="Minor Health Potion", effects=[minor_health_effect])
big_health_potion = Potion(name="Health Potion", effects=[health_effect])
# Create a player
player = Player(name="John", health=100, strength=10, stamina=50, mana=30)
player.get_stats()
# Yields:
# Player John has the following stats:
# Health: 100
# Strength: 10
# Stamina: 50
# Mana: 30
# Drink the potions.
player.drink_potion(small_health_potion)
player.drink_potion(big_health_potion)
player.get_stats()
# Yields:
# Player John has the following stats:
# Health: 250
# Strength: 10
# Stamina: 50
# Mana: 30
Creating New Potions Challenge
Using the HealthPotionEffect
example for reference, add classes to implement the following potion effects:
- A
ManaPotionEffect
that increases the mana stat by a set amount. - A
HealthPoison
that decreases health by some amount. - A
StrengthPoison
that decreases strength by some amount.
Finally, instantiate some new Potion
objects that combine these effects, resulting in a wacky and wonderful adventure for our unsuspecting player.
Conclusion
In this section of the lab, we explored two fundamental object-oriented programming paradigms: composition and inheritance. By implementing a potion-drinking system for a player character, we demonstrated how these paradigms enable flexible and reusable designs in Python.
- Composition:
- Composition allows us to build complex behaviors by combining smaller, independent components.
- In the potion composition example, the PotionEffect objects encapsulated specific effects, enabling potions to be dynamically constructed with multiple effects.
- This approach emphasized flexibility and modularity, making it easy to extend functionality by creating new effect classes without altering existing code.
- Inheritance:
- Inheritance provides a way to model relationships between classes, enabling code reuse and specialization.
- By creating a hierarchy of potion classes, we demonstrated how shared behaviors can be inherited from a base class while allowing subclasses to introduce or modify behavior.
- The inheritance approach prioritized hierarchical structure and shared behavior but introduced constraints on flexibility compared to composition.
Both approaches are essential tools for designing robust and maintainable software systems. By completing this lab, you have gained hands-on experience with both paradigms and their trade-offs, equipping you with the skills to make informed design decisions in your future programming projects.