Skills Mapping
Dr J. G-H-Cater - 09/12/2024

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)]
List Exercise Template

Modify the add_student(...) function so that it can be used to add a new student and grade to the list.

You need to append (cough cough) a tuple that is made up of the provided name and grade to the list. Tuples are created using normal brackets, e.g. ('I', 'am', 'a', 'tuple.'), and they can be populated with any data types.

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])
Example multi-valued For using indexing.

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)
Example multi-valued For using unpacking.

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…

Selection Disabled
>>> student_grades = [("Alice", 85), ("Bob", 72), ("Charlie", 90)]
>>> # Assume we have defined the function find_high_scorers(...)
>>> find_high_scorers(80)
["Alice", "Charlie"]
Example usage of find_high_scorers() function in the terminal.

For each (student, grade) pair in the student_grades list, you will need to compare the grade against the threshold and append the student name to an internally created high_scorers list if the grade is greater than the threshold.

Finally, you will need to return the high_scorers list.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
############################################################################################
#   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, defining the data structure type
student_grades = []

# Define list functions
def add_student(name, grade):
    student_grades.append((name, grade)) # Inner brackets are creating a tuple pair!

def find_high_scorers(threshold):
    # Start with an empty list - telling python that it will be a list of strings.
    high_scorers = []
    
    # For each student and grade tuple in the student_grades list, unpack the tuple and...
    for student, grade in student_grades:
        # ... check the grade against the threshold.
        if grade > threshold:
            high_scorers.append(student)
            
    return high_scorers
            

# Add students to list
add_student("Alice", 85)
add_student("Bob", 72)
add_student("Charlie", 90)

# Print the list as-is
print(student_grades)

# Test the high_scorers function
high_scorers = find_high_scorers(80)
print(high_scorers)
List Exercise Solution

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

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>]
List Comprehension Syntax

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]
List Comprehension

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.

1
2
3
4
prices = [12.4, 10.2, 4, 10, 100]
# NOTE: No condition was needed, as we wanted to use every entry in prices.
prices_with_VAT = [round(price * 1.2, 2) for price in prices]
print(prices_with_VAT)
List Comprehension Solution

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)]
A sub-optimal way to group data.

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 or None 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}
}
A better way to group data with dictionaries.

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.
Accessing a specific grade from a dictionary.

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}}
]
Representing student grades with dictionaries in a list.

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

The UML Diagram showing the classes for the start of a really basic text-based adventure game.
The UML Diagram showing the classes for the start of a really basic text-based adventure game.

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
The start of the player class, which implements none of the methods outlined in the UML diagram.

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:

Selection Disabled
Player John has the following stats:
    Health: 100
    Strength: 10
    Stamina: 50
    Mana: 30
Code snippet

There are several ways that you could achieve this. The most obvious is probably to print each line at a time, using the special character \t to create tabs in the terminal. For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
        
    def get_stats(self):
        print(f'Player {self.name} has the following stats:')
        print(f'\tHealth: {self.health}')
        print(f'\tStrength: {self.strength}')
        print(f'\tStamina: {self.stamina}')
        print(f'\tMana: {self.mana}')
The Player class can now print the stats, using several print commands.

Alternatively, googling for ways to print multi-line statements in python may yield a result that describes the function cleandoc() found in the insepct library. This function lets us pass a multi-line string (with tabination to keep it tidy) and produces a multi-line string that has been left-aligned to the least tabbed line.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from inspect import cleandoc

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
        
    def get_stats(self):
        stats_message = f'''
            Player {self.name} has the following stats:
                Health: {self.health}
                Strength: {self.strength}
                Stamina: {self.stamina}
                Mana: {self.mana}
        '''
        print(cleandoc(stats_message))
The Player class can now print the stats, using a third party cleandoc() function to filter tabs from the front of a multi-line f'string.

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

  1. Define a new Potion class, under the Player 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).
  2. Add a method drink() to the Potion class which takes a Player 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.
Code snippet

...
20
21
22
23
24
25
class Potion:
    def __init__(self, name):
        self.name = name

    def drink(self, player):
        print(f"{player.name} drinks {self.name}.")
Code snippet

Now that we have Potions 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 Players own self variable as the argument into the drink() method.

class Player:
    ...
    def drink_potion(self, potion):
        potion.drink(self)
...
Code snippet

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):

Selection Disabled
>>> # 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
Code snippet

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 UML Diagram showing how potion types can inherit from the parent Potion class in our basic text-based adventure game.
A UML Diagram showing how potion types can inherit from the parent Potion class in our basic text-based adventure game.

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}")
An example of how a new HealthPotion class could be created by expanding the base Potion class.

Note how this new HealthPotion class uses the super() command to call the inherited Potion methods before adding its own function.

Using the code in as a reference, add an additional child class called ManaPotion that increases the Mana stat of the drinker.

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()
Code snippet

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.

A UML Diagram showing how potions could be composed from a set of potion effects.
A UML Diagram showing how potions could be composed from a set of potion effects.

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# Import the cleandoc function from Pythons built-in inspect library
from inspect import cleandoc

class Player: 
    def __init__(
            self, 
            name, 
            health, 
            strength, 
            stamina, 
            mana
        ): # Arguments are split over several lines to avoid spilling off screen

        # Store the provided arguments as class parameters
        self.name = name
        self.health = health
        self.strength = strength
        self.stamina = stamina
        self.mana = mana

    def drink_potion(self, potion):
        potion.drink(self)
        
    def get_stats(self):
        # Prepare the output as a multi-line string.
        stats_message = f'''
            Player {self.name} has the following stats:
                Health: {self.health}
                Strength: {self.strength}
                Stamina: {self.stamina}
                Mana: {self.mana}
        '''
        # Use cleandoc to remove the leading tabs from the multi-line string.
        # This just means we can make the code tabbing tidier without affecting the output.
        print(cleandoc(stats_message))


# Base class for all potions
class Potion:
    def __init__(self, name):
        self.name = name

    def drink(self, player):
        print(f"{player.name} drinks {self.name}.")
An example implementation of the Player and Potion class as it stands following the inheritance chapter. The HealthPotion and ManaPotion classes have been removed.

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!
An example of implementing an abstract class in Python with a common interface for animals.

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

  1. Now modify your Potion class __init__() method to accept an additional argument called effects. This argument should take a list of PotionEffects and store them as a property of the Potion class.
  2. 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 the effects list and calls the effects apply() 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}")
An example of a HealthPotionEffect class implementing the PotionEffect interface.

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
An example demonstrating the creation of potions with multiple effects and a player interacting with them.

Creating New Potions Challenge

Using the HealthPotionEffect example for reference, add classes to implement the following potion effects:

  1. A ManaPotionEffect that increases the mana stat by a set amount.
  2. A HealthPoison that decreases health by some amount.
  3. 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.

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