This lecture covers the following contents:
Suppose you're developing a turn-based video game consisting of a player that fights various kinds of monsters (turn-based means that the player and the monsters take turns performing actions, such as attacking each other). Perhaps you have a Player class that looks like this:
player.pyclass Player:
_hp: int
def __init__(self) -> None:
# The player starts out with 100 HP. When it drops to zero,
# they lose the game.
self._hp = 100
# Getter for _hp
def get_hp(self) -> int:
return self._hp
# Causes the player to take some damage. Used by the monster
# class and its subclasses when they attack the player.
def take_damage(self, amount: int) -> None:
# Forbid the amount of damage from being negative
if amount < 0:
raise ValueError(f'Error: Expected amount to be '
f'non-negative, but got {amount}')
# Damage the player
self._hp -= amount
# Preserve the class invariant that _hp must be non-negative
if self._hp < 0:
self._hp = 0
To represent the various kinds of monsters, perhaps you have a Monster base class from which various subclasses (e.g., Zombie, Vampire, etc) will inherit:
monster.pyfrom player import Player
class Monster:
_hp: int
def __init__(self, hp: int) -> None:
# Different monsters will start out with different amounts
# of hp, so we pass the monster's starting HP as an argument
# to this constructor's hp parameter. It then stores it in
# the self._hp attribute.
self._hp = hp
# Attacks the player
def attack(self, p: Player) -> None:
# Reduce the player's HP by 1
p.take_damage(1)
There could be dozens of classes derived from the Monster base class. To illustrate, I'll provide three examples. First, the Zombie class:
zombie.pyfrom monster import Monster
from player import Player
# The Zombie class inherits from the Monster class
class Zombie(Monster):
# Extension: The Zombie class additionally has a _sanity attribute
_sanity: float
def __init__(self) -> None:
# Zombies get 20 HP. Pass this to the Monster constructor
# to be stored in the private _hp attribute
super().__init__(20)
self._sanity = 1.0 # All zombies start out with 1.0 sanity
# Override the attack() method. This is what zombies will do when
# they attack the player, instead of executing the "default" /
# boring Monster class's attack() method.
def attack(self, p: Player) -> None:
if self._sanity > 0.0:
# If this zombie still has some sanity, then they won't
# damage the player this turn, but they will lose 0.5
# sanity.
self._sanity -= 0.5
# If sanity became negative, clip it to zero
if self._sanity < 0.0:
self._sanity = 0.0
else:
# Otherwise, this zombie has gone insane, so they attack
# the player, damaging them by 2
p.take_damage(2)
Second, the Vampire class:
vampire.pyfrom monster import Monster
from player import Player
# The Vampire class inherits from the Monster class
class Vampire(Monster):
# Extension: The Vampire class additionally has a _strength
# attribute
_strength: int
def __init__(self) -> None:
# Vampires get 15 HP. Pass this to the Monster constructor
# to be stored in the private _hp attribute
super().__init__(15)
self._strength = 1 # All vampires start out with 1 strength
# Private helper method that increases a vampire's strength by a
# specified amount without letting it exceed 3
def _increase_strength(self, amount: int) -> None:
# Forbid amount from being negative
if amount < 0:
raise ValueError(f'Expected amount to be non-negative, '
f'but got {amount}')
# Increase strength, clipping it down to a max of 3
self._strength += amount
if self._strength > 3:
self._strength = 3
# Override the attack() method. This is what vampires will do when
# they attack the player, instead of executing the "default" /
# boring Monster class's attack() method.
def attack(self, p: Player) -> None:
# Attack the player, dealing damage equal to the vampire's
# strength
p.take_damage(self._strength)
# When vampires attack the player, they suck the player's
# blood, gaining 1 strength in the process. We'll use
# self._increase_strength() for this (which prevents it
# from exceeding the max of 3)
self._increase_strength(1)
Third, the Goblin class:
goblin.pyfrom monster import Monster
# The Goblin class inherits from the Monster class
class Goblin(Monster):
def __init__(self) -> None:
# Goblins get 5 HP. Pass this to the Monster constructor
# to be stored in the private _hp attribute
super().__init__(5)
# The Goblin class does not override the attack() method, so when
# goblins attack the player, they simply execute the Monster
# class's attack() method (which the Goblin class inherits)
Let's take a moment to remind ourselves about method overrides. Notice that the Zombie and Vampire classes override the attack() method, but the Goblin class does not. This means that Zombie and Vampire objects each have two attack() methods: the one inherited from the Monster class, and the override defined in the respective derived class (Zombie or Vampire). In contrast, Goblin objects only have a single attack() method: the one inherited from the Monster class.
Hence, if you were to create a Zombie or Vampire object and tell it to attack the player (e.g., some_zombie.attack(the_player), or some_vampire.attack(the_player)), it would execute the respective derived class's attack() method. In contrast, if you were to create a Goblin object and tell it to attack the player (e.g., some_goblin.attack(the_player)), it would execute the Monster class's attack() method.
This is a common pattern. In essence, the Monster class's attack() method serves as a sort of "default" behavior for a monster attacking the player. If a derived class chooses not to override the attack() method, then objects of that class will execute the Monster class's attack() method when they're told to attack the player. But if a derived class does choose to override the attack() method, then objects of that class will instead execute the derived class's override when they're told to attack the player. Again, this is why it's referred to as "overriding"—the derived-class method overrides (takes precedence over) the base-class method (unless the super() function is used). If the derived class does not provide its own attack() method (e.g., as in the Goblin class), then no overriding takes place, and the "default" (base-class) method is used instead.
Again, this is just an illustration. In practice, there might be dozens of subclasses. Many of them might override the attack() method with their own, interesting behaviors (similar to the Zombie and Vampire classes). But also, many of them might choose not to (similar to the Goblin class).
Finally, here's the actual program that uses these classes:
game.pyfrom player import Player
from zombie import Zombie
from vampire import Vampire
from goblin import Goblin
# This function is the main "game loop". In other words, it contains
# the loop that runs the monsters' turns over and over again until the
# game ends (i.e., until the player loses)
def game_loop(
p: Player,
zombies: list[Zombie],
vampires: list[Vampire],
goblins: list[Goblin]) -> None:
# Until the player loses, keep running turns of the game
turn_counter = 1 # Keeps track of what turn it is
while p.get_hp() > 0:
# The zombies attack the player
for z in zombies:
z.attack(p)
# The vampires attack the player
for v in vampires:
v.attack(p)
# The goblins attack the player
for g in goblins:
g.attack(p)
# Print the player's remaining HP
print(f"After turn {turn_counter}, the player's remaining "
f"HP is {p.get_hp()}")
# Update the turn counter
turn_counter += 1
def main() -> None:
# Create the player object
p = Player()
# Suppose we want the game to have 3 zombies, 4 vampires, and 5
# goblins. Let's create a list for each (we should use list
# comprehensions in practice, but they're beyond the scope of
# this course):
zombies = []
for _ in range(3):
zombies.append(Zombie())
vampires = []
for _ in range(4):
vampires.append(Vampire())
goblins = []
for _ in range(5):
goblins.append(Goblin())
# Now run the game loop, executing turns until the game is over
game_loop(p, zombies, vampires, goblins)
if __name__ == '__main__':
main()
(This is a very bare-bones game. In fact, it's arguably not even a game. The player doesn't actually get to do anything, other than sit there as the monsters attack them. We could implement some player interaction, but that'd lengthen the demonstration, and it's not the point of this lecture.)
Let's trace the game loop.
On the first turn, the zombies will not attack the player since they'll still have 1 sanity. But they'll each lose 0.5 sanity, leaving them with 0.5 remaining. The vampires will attack the player for 1 damage each since they initially only have 1 strength. This will increase their strength to 2. The goblins will attack the player for 1 damage each. In total, the player will lose 9 HP this turn (4 from the vampires, 5 from the goblins). This will leave them with 91 HP.
On the second turn, the zombies still won't attack the player since they'll still each have 0.5 sanity. But they'll each lose that 0.5 sanity, leaving them with 0 sanity. The vampires will attack the player for 2 damage each since they'll each have 2 strength at this point. This will increase their strength to 3. The goblins will attack the player for 1 damage each. In total, the player will lose 13 HP this turn (8 from the vampires, 5 from the goblins). This will leave them with 78 HP.
On each subsequent turn, the zombies will attack the player for 2 damage each since they will have no sanity remaining; the vampires will attack the player for 3 damage each (their strength cannot exceed 3); and the goblins will attack the player for 1 damage each. So, on each turn starting from turn 3, the player will lose 23 HP (6 from the zombies, 12 from the vampires, 5 from the goblins). After turn 3, the player will have 55 HP. After turn 4, the player will have 32 HP. After turn 5, the player will have 9 HP. After turn 6, the player will have 0 HP (their HP cannot drop below zero), ending the game.
Indeed, running the above program produces the following output:
After turn 1, the player's remaining HP is 91
After turn 2, the player's remaining HP is 78
After turn 3, the player's remaining HP is 55
After turn 4, the player's remaining HP is 32
After turn 5, the player's remaining HP is 9
After turn 6, the player's remaining HP is 0
The above code works as intended. However, there's a potential maintainability concern. Recall that the three subclasses in the above demonstration (Zombie, Vampire, and Goblin) are just an illustration. In a real video game, there might be dozens of such subclasses. That's problematic. As written, our code currently has a separate list for each of the subclasses. Each of those lists is passed into the game_loop() function. The game_loop() function then iterates through each of these lists one at a time. That's to say, each subclass has a corresponding list, function argument, function parameter, and for loop.
There are only 3 subclasses in the above code, but if there were actually dozens of subclasses (as there would likely be in a real video game), then there would have to be substantially more code. In fact, as currently designed, almost the entire codebase grows proportionally with the number of subclasses. If we wanted to double the number of subclasses (from 3 to 6), we'd have to write nearly twice as much code. If we wanted to multiply it by 10 (from 3 to 30), we'd have to write nearly ten times as much code.
Some people, myself included, might consider that to be a big deal. First of all, introducing new kinds of monsters into the game should probably be a fairly routine change. After all, if we want the game to be interesting, we probably want to create many kinds of monsters, adding new ones regularly as the game is updated. If it's meant to be a routine change, then it probably shouldn't be a difficult thing to do, or else most of our entire job becomes difficult. Currently, introducing a new kind of monster requires creating a new subclass, creating a list to store objects of that subclass, creating a new parameter in the game_loop() function to accept that list, passing the list as an argument, and creating another for loop in the game_loop() function to execute those new monsters' turns. That's too much work for a routine change.
Second, The more code we write, the more code we have to maintain. If we ever decide to change, say, how the various kinds of monster objects are stored (e.g., replacing the lists with some other kind of data structure, such as trees, linked lists, dictionaries, etc), then we'd have to apply that change for each subclass (because we have a list for each subclass). If we had 30 subclasses, we'd have to apply that change 30 times. If this all reminds you of our lecture on coupling, good—this is essentially a coupling problem that results from a whole bunch of code duplication.
(I actually wrote most of game.py by creating a few lines of code to deal with zombies, copying and pasting those lines of code two more times, and then using Vim's find-and-replace feature to replace "zombie" with "vampire" or "goblin". Copy + paste + find-and-replace is classic code duplication.)
So, what's the solution? Well, it's actually very simple. So simple, in fact, that after I show it to you, most of the rest of this lecture will just be about theory and principles—the code itself will only take a moment to explain.
The solution is to combine two techniques: upcasting and polymorphism.
Let's start with upcasting. Upcasting is a form of type casting, but it works a bit differently in the way that we'll use it. Recall that type casting means to convert the value of one expression into a new type, producing a new expression in the process. For example, int(input('Enter your favorite number: ')) asks the user for their favorite number and then takes their string input (the actual characters that they typed on the keyboard, as returned by the input() function) and converts it, or type-casts it, into an integer. That is, the expression input('Enter your favorite number: ') is a string expression, and its value is whatever string is typed in by the user. By wrapping that expression in the explicit int() syntax, we convert it into an integer. So the inner expression is a string, but the whole, outer expression is an integer.
That's an example of explicit type casting, but it can often done implicitly as well. For example, x: float = 1 creates a variable named x of annotated type float, but it stores an object of type int inside it.
Implicit type casting works a bit differently from explicit type casting. Explicit type casting actually creates a new object of a new type by type-converting the original object. Implicit type casting doesn't do that; rather, implicit type casting simply stores an object of one type in a variable of another type. Again, x: float = 1 is an example of implicit type casting; it stores an object of type int in a variable of type float.
This might be surprising to you. In the past, I've said that every variable has a single type, and its type can't be changed. Well, it turns out that there are some exceptions to this rule, and implicit type casting is one of them. To be clear, though, implicit type casting isn't always possible. It's only possible when the two types are "compatible with" one another, in some sense. int and float are compatible such that an int value can be implicitly type-casted into a float (but not the other way around).
Now, what's upcasting? Upcasting is specifically a form of type casting wherein an object of a derived-class type is casted (converted) into its base-class type. As it turns out, this is always possible. And maybe that makes sense; if every zombie "is a" monster, then perhaps it's reasonable that a Zombie object can be casted into a Monster object.
However, not only is upcasting always possible, it can always be done implicitly. That's to say, every derived class is always "compatible with" its base class insofar as derived-class objects can be implicitly upcasted into their base-class types.
For example, suppose you have a variable whose type is annotated as Monster. In such a case, it's possible to store a Zombie object inside that variable (e.g., m: Monster = z, where z is a Zombie object). In case you don't realize how big of a deal this is, let me show you:
game.pyfrom player import Player
from monster import Monster # Needed for static typing list[Monster]
from zombie import Zombie
from vampire import Vampire
from goblin import Goblin
# This function is the main "game loop". In other words, it contains
# the loop that runs the monsters' turns over and over again until the
# game ends (i.e., until the player loses). Notice: It only needs
# a single list parameter now, which is a list of Monster objects.
# Each Monster object in the list will have been upcasted from a
# more specific derived-class object (a Zombie, Vampire, or Goblin).
# See main() below for details.
def game_loop(
p: Player,
monsters: list[Monster]) -> None:
# Until the player loses, keep running turns of the game
turn_counter = 1 # Keeps track of what turn it is
while p.get_hp() > 0:
# ALL of the monsters attack the player. Notice: Just a single
# for loop now.
for m in monsters:
m.attack(p)
# Print the player's remaining HP
print(f"After turn {turn_counter}, the player's remaining "
f"HP is {p.get_hp()}")
# Update the turn counter
turn_counter += 1
def main() -> None:
# Create the player object
p = Player()
# Suppose we want the game to have 3 zombies, 4 vampires, and 5
# goblins. However, we do NOT need to create a separate list for
# each. Rather, let's create a single list of monsters, meaning a
# variable whose (static) type is list[Monster]. Because zombies,
# vampires, and goblins can all be type-casted into monsters
# (i.e., upcasting), we can store ALL of these things---zombies,
# vampires, and goblins---in a single list of type list[Monster].
# In instances like this, Mypy needs some help determining the
# type of the list. We want it to be of type list[Monster], so
# we have to tell Mypy that explicitly (see the Python Basics
# lecture notes for more information about explicit type
# annotations of local variables).
monsters: list[Monster] = []
# Now add all the zombies, vampires, and goblins to it
for _ in range(3):
# Create a Zombie object, implicitly upcast it into a Monster
# object, and then store it in our list of Monster objects
monsters.append(Zombie())
for _ in range(4):
# Create a Vampire object, implicitly upcast it into a Monster
# object, and then store it in our list of Monster objects
monsters.append(Vampire())
for _ in range(5):
# Create a Goblin object, implicitly upcast it into a Monster
# object, and then store it in our list of Monster objects
monsters.append(Goblin())
# Now run the game loop, executing turns until the game is over.
# This time, we only need to pass in a single list: monsters
game_loop(p, monsters)
if __name__ == '__main__':
main()
(There are more lines of code than before, but that's just because I added many comments to explain the upcasting and type annotations. The number of logical lines of code is noticeably fewer than before, and the effect would be extremely obvious if there were, say, 30 subclasses instead of just 3.)
Indeed, implicit upcasting allows us to create a single list of type list[Monster]; implicitly upcast Zombie, Vampire, and Goblin objects all into the Monster type; and store those upcasted objects in the list. This allows us to store all of our monsters in a single last rather than needing a separate list for each subclass.
Again, this sort of type casting is a bit different from what you've seen before. When you cast a float to an int via int(3.14), its value actually changes from 3.14 to 3. But in this case, the type casting is essentially only performed on the annotated types of the objects. This means that, from MyPy's perspective, the elements in the list are all of type Monster, but from the interpreter's perspective, the elements in the list are of the various subtypes—Zombie, Vampire, and Goblin. That's to say, the zombies in the list are still zombies, the vampires are still vampires, and the goblins are still goblins; they aren't actually converted to the Monster type (in contrast to 3.14 being converted to 3 in the case of explicit type casting). But MyPy treats them as if they were of the Monster type (and, because of the inheritance hierarchy and is-a relationships, they sort of are monsters, even though their actual types are more specific than that).
In essence, we've created a heterogeneous list (a list containing elements of various types), but in a way that makes MyPy happy. The details will become clear once we've discussed dynamic and static types.
This greatly reduces the amount of duplicated code (and therefore unnecessary coupling). Yes, we still need a for loop in the main() function for each subclass in order to actually create the objects, but after that point—once we've created them, upcasted them, and put them in the list—we no longer need to duplicate any of our code on a per-subclass basis. For example, we only need a single for loop in the game_loop() function rather than one per subclass. Similarly, the game_loop() function only needs a single list parameter and corresponding argument rather than one per subclass.
The benefit becomes more obvious with more subclasses. For example, the difference between three for loops and a single for loop may not seem like a big deal, but the difference between thirty for loops and a single for loop is a very big deal.
The benefit also becomes more obvious with a more complex codebase in general. The above program only has a single function (game_loop()) that interacts with the monsters once they've been created. A more complex program, though, might have several functions that all interact with the monsters, each for a different reason (e.g., consider that a real game might display the monsters in the terminal, allow the player to interact with the monsters, and so on; each of these things might be done by separate function that iterates through all the monsters in the game). Having a single list of upcasted monsters, as opposed to a separate list for each subclass, could reduce the complexity of most if not all of those functions.
Now, I said that we needed to employ two techniques: upcasting and polymorphism. What's polymorphism?
Well, we're actually already using polymorphism in the above program. In Python, polymorphism basically happens automatically whenever you use upcasted objects that could have been upcasted from any one of several derived classes. But in many other programming languages, it must be enabled manually through explicit syntax, so it's important that you understand it nonetheless.
Polymorphism means "many forms". The original definition of polymorphism described the case wherein a function's parameter can be any one of several different types. Nowadays, the most common definition is a bit looser: polymorphism simply describes the case wherein a given expression can be any one of several different types.
Take a look at this line from the above game.py, for example:
m.attack(p)That line of code appears inside the for loop in the game_loop() function. m is the iterating variable—it iterates through the list, monsters.
But that list—monsters—technically contains several different kinds of objects in it. Yes, they've all been upcasted to the type Monster, but that doesn't change the fact that they're actually objects of type Zombie, Vampire, and Goblin.
This means that the variable m in the above line of code is polymorphic. Depending on the iteration of the for loop, m might be a Zombie, or it might be a Vampire, or it might be a Goblin. Indeed, m is an expression whose type could be one of several different things. In fact, m's type could be any class that inherits from the Monster class, or it could even simply be the Monster class itself (our program doesn't actually create regular Monster objects, but there's no reason that it couldn't).
Again, Python makes polymorphism very easy. Any time you upcast objects, you're probably doing polymorphism. But in some other programming languages (e.g., C++), upcasting by itself does not enable polymorphism. In those languages, other explicit syntax is required in addition to upcasting in order to get polymorphism to work properly (e.g., to deal with object slicing and static binding). We'll discuss this in more detail shortly.
(To be clear: Python is not the only language that makes polymorphism easy and automatic. However, making polymorphism easy usually requires sacrificing performance.)
In the past, I've said that, generally, Mypy does not allow a variable's type to change throughout the duration of a program. Polymorphism inherently violates this general rule. As in my previous example, the variable m changes types from Zombie to Vampire to Goblin throughout the for loop of the game_loop() function.
The reason is that the general rule—that a variable's type may not change—is not precise enough. To be more precise, every variable (actually, every expression) essentially has two types: a static type, and a dynamic type. Static types may not change, but dyanmic types may.
A static type is a declared type, be it declared explicitly (via a type annotation) or implicitly (e.g., as inferred by Mypy). The static type of a variable or expression can easily be deduced by simply looking at the surrounding code. For example, in the game_loop() function, the static type of monsters is list[Monster]. You can see this clearly and plainly by looking at its declaration in the parameter list. This also means that the static type of m is Monster. Indeed, if monsters has a static type of list[Monster], and m is one of the elements of that list, then its static type must be Monster (m is not explicitly declared to be of static type Monster, but you can still infer as much by looking at the code).
The dynamic type of a variable or expression is its "actual type" at a given point during the program's execution. For example, consider the very first iteration of the for loop in the game_loop() function. During that iteration, the static type of m is Monster, but its dynamic type is Zombie (because the first three elements in the list are zombies, as appended in main()). In the fourth iteration of that very same for loop, the static type of m is still Monster, but its dynamic type is Vampire. In the eighth iteration, the static type of m is still Monster, but its dynamic type is Goblin. Indeed, because the dynamic type of a variable or expression depends on its actual type at a given point during the program's execution, the dynamic type of a variable or expression may change.
However, a given expression's dynamic type must always be "compatible with" its static type. One example of compatibility is given by implicit upcasting: if the static type of an expression is a base class, then its dynamic type may be that same base class, or it may be any class that inherits from that base class. Hence, the dynamic type of m is allowed to change from Zombie to Vampire to Goblin because all of those dynamic types are compatible with its static type, Monster.
Static types cannot change during a program's execution because they're not a property of an expression's value at runtime, but rather a property of the expression itself, meaning the code that contextualizes and constitutes the expression. I repeat: dynamic types are a property of the program's runtime state, whereas static types are a property of the program's source code. And, clearly, source code cannot change during the program's execution (for the most part...).
Hopefully this isn't too surprising. I've said in the past that dynamic means "at runtime", whereas static means "before runtime". Anything that can be inferred before runtime must be a property of the code itself. Also, I've said that Mypy is a static analysis tool, and that it performs static type checking. This means that Mypy knows nothing about dynamic types—it knows only of static types. And of course that's the case; Mypy works by simply looking at the code, and only static types can be inferred by looking at the code.
In our example program, the Goblin class does not override the attack() method, so when a goblin's attack() method is called, it simply executes the attack() method inherited from the Monster class.
Again, the Monster class's attack() method essentially serves as the "default" behavior to be exceuted when a given monster attacks the player. Overriding that behavior requires overriding the attack() method in the given derived class.
But in many cases, there is no reasonable "default" behavior for such methods. In fact, even in our example program, the Monster class's attack() method is not very interesting. It simply damages the player by 1 HP. In a real video game, a monster simply damaging the player by 1 HP would be quite boring. It'd be especially boring if many different kinds of monsters did this when they attack the player. So, in a real video game, it's unlikely that you'd have many monster classes that all do this same, boring thing (unless you want your game to be boring, and I assume you don't). But if few or none of the subclasses use this boring behavior when they attack, then it doesn't really make sense for it to be the "default" behavior, now does it?
For example, suppose that the Goblin class does, indeed, override the attack() method, just like the Zombie and Vampire classes:
goblin.pyfrom monster import Monster
from player import Player
# The Goblin class inherits from the Monster class
class Goblin(Monster):
# Goblins now alternate between dealing 1 and 2 damage on their
# turns. We'll keep track of the damage that a goblin should
# do on their next turn by flipping this boolean back and
# forth between true and false.
_one_damage: bool
def __init__(self) -> None:
# Goblins get 5 HP. Pass this to the Monster constructor
# to be stored in the private _hp attribute
super().__init__(5)
# Goblins do 1 damage on their first turn, so we'll initialize
# self._one_damage to True
self._one_damage = True
def attack(self, p: Player) -> None:
# Goblins alternate between dealing 1 and 2 damage on each
# of their turns.
if self._one_damage:
p.take_damage(1)
else:
p.take_damage(2)
# If self._one_damage is True, change it to False. If it's
# False, change it to True. We can do this in the single
# following statement:
self._one_damage = not self._one_damage
At this point, there's essentially no reason for the Monster class's attack() method to even exist, right? After all, nowhere in our program do we ever create plain-old Monster objects—only Zombie, Vampire, and Goblin objects. Each of the subclasses override the attack() method with their own, special definition. Hence, nowhere in the entire program do we ever actually call the Monster class's attack() method, nor do we intend to. This is what I mean—sometimes, there's simply no reasonable definition for the "default" behavior of a method. Sometimes, you simply intend to override it in every derived class.
In a case where you don't really have a reasonable definition for the "default" behavior for a method—that is, in a case where in you simply intend to override the method in each and every derived class—you can actually choose to not define a default behavior for the method whatsoever. In this case, that means that we can choose to not define the Monster class's attack() method. However, it's not as simple as getting rid of the method altogether. After all, Mypy needs to know that all monsters are capable of attacking in order to verify the correctness of m.attack(p) within the game_loop() function. No, we can't get rid of the Monster class's attack() method, but we can choose not to define it. We do this by making it an abstract method. However, only abstract classes are allowed to have abstract methods. This means that we must make the Monster class abstract as well.
The syntax for creating an abstract class, and then defining an abstract method within it, is as follows:
from abc import ABC, abstractmethod
class <class_name>(ABC):
... # Declare attributes, define non-abstract methods, etc
@abstractmethod
def <method_name>(<parameters>):
passIn other words, to make a class abstract, you must import ABC from the abc package, and then the class must inherit from ABC (ABC is itself a class; it stands for "abstract base class"). Once the class is abstract (i.e., once it inherits from ABC), it may then be given abstract methods. To make a method abstract, you must import abstractmethod from the abc package, and then the decorator @abstractmethod must be written directly above the method's definition. The actual definition should have an empty body (i.e., it must simply say pass).
In our case, we'd like to make the Monster class's attack() method abstract since we no longer have a useful definition for it. This means that we also have to make the Monster class itself abstract. Here's the updated code:
monster.pyfrom abc import ABC, abstractmethod
from player import Player
# The Monster class is now abstract because it inherits from ABC
# (Abstract Base Class)
class Monster(ABC):
_hp: int
def __init__(self, hp: int) -> None:
# Different monsters will start out with different amounts
# of hp, so we pass the monster's starting HP as an argument
# to this constructor's hp parameter. It then stores it in
# the self._hp attribute.
self._hp = hp
# There's no useful default behavior for this method. We intend
# to override it in each and every derived class. So there's no
# good definition to give to it here. Hence, we make it abstract,
# which allows us to choose to not define it whatsoever (or,
# rather, to leave its definition empty)
@abstractmethod
def attack(self, p: Player) -> None:
# Python requires empty scopes to have the 'pass' keyword
# present, signifying that it's intentionally empty
pass
The program does the same thing as before, but now we've gotten away with not defining the attack() method in the Monster class. That's good; it was a useless definition that our program never actually executed, so there's no reason for us to provide it.
So, an abstract method is essentially just a method that's declared but not defined in the base class; it's then defined via overridden in each of the derived classes. However, abstract methods can only exist within abstract classes. Abstract classes themselves also have a very important property: they may not be instantiated. In other words, you may not create an object whose dynamic type is that of an abstract base class.
For example, our Monster class is now abstract, which means that we cannot instantiate the Monster class. To be clear, we're still allowed to create Zombie, Vampire, and Goblin objects, and we're still allowed to upcast those objects to the static type Monster. However, we are not allowed to create plain-old Monster objects (with the dynamic type Monster). Consider the following simple program as an example:
badinstantiation.pyfrom monster import Monster
def main() -> None:
# This is illegal. Mypy detects it as a syntax error. If
# executed, it throws a TypeError.
my_monster = Monster(10)
if __name__ == '__main__':
main()
This is the error reported by Mypy:
(env) $ mypy .
badinstantiation.py:6: error: Cannot instantiate abstract class "Monster" with abstract attribute "attack" [abstract]
Found 1 error in 1 file (checked 6 source files)
And this is the error that occurs when the above program is executed:
(env) $ python badinstantiation.py
Traceback (most recent call last):
File "/home/alex/instructor/static-content/guyera.github.io/code-samples/polymorphism/bad-instantiation/badinstantiation.py", line 9, in <module>
main()
~~~~^^
File "/home/alex/instructor/static-content/guyera.github.io/code-samples/polymorphism/bad-instantiation/badinstantiation.py", line 6, in main
my_monster = Monster(10)
TypeError: Can't instantiate abstract class Monster without an implementation for abstract method 'attack'
The reason for this is relatively simple. The Monster class provides the abstract attack() method to make it clear (both to Mypy and the interpreter) that all monsters are capable of attacking the player (e.g., so that Mypy can verify that m.attack(p) is a valid statement). However, it doesn't provide a definition for what the attack() method should actually do. In some sense, the Monster class is "incomplete". It defines a concept—the concept of a monster, which is capable of attacking the player—but it does not complete that concept by defining how monsters attack the player. Instead, it leaves it up to the subclasses to define how the player should be attacked by overriding the attack() method (and each subclass can provide its own, special override).
Put another way, the Monster class provides an interface. It states that the attack() method exists, and it states what it should do (it accepts a player and attacks them). However, it doesn't provide an implementation. It doesn't state how the player should be attacked. Indeed, this is very common terminology: abstract base classes provide interfaces, and derived classes implement those interfaces.
If abstract classes provide interfaces but not implementations—if they're essentially "incomplete"—then hopefully it makes sense that they cannot be instantiated.
But it's okay that abstract classes can't be instantiated. After all, in our game, we don't instantiate the Monster class at any point, and we have no intention of ever doing that. And if we did have an intention of doing that, then we must also have some idea of what m.attack(p) should do in the case that m has the dynamic type Monster. And if we have an idea of what that should do, then we don't need the Monster class's attack() method to be abstract—we could define it to do that thing. And in that case, the Monster class wouldn't need to be abstract, either, and we'd be able to instantiate it as needed.
I said that subclasses "complete the concept", or "implement the interface", of their abstract base class by overriding all of the inherited abstract methods. But what if a derived class doesn't override one of its inherited abstract methods? Well, if a derived class inherits an abstract method from an abstract base class, but it doesn't override that abstract method, then the derived class, too, is abstract. Only once it overrides all of the abstract methods that it has inherited will it be considered not abstract. For example, if we had made the Monster class's attack() method abstract without overriding it in the Goblin class, then the Goblin class would also be abstract. That would have made it impossible to instantiate the Goblin class, which our main() function needs to be able to do.
It's also important that you override abstract methods correctly. In particular, when a derived class overrides an abstract method inherited from its base class, the override must have the exact same name as that of the inherited method, but also the exact same parameter list and return type. All of these things must match exactly. In our case, the abstract method is named attack, it accepts a single Player parameter, and it returns None. All of these things must also be true of the attack method's overrides in each of the derived classes (and, indeed, that is the case).
Failure to override an abstract method correctly can result in various kinds of errors. For example, if the Goblin class's attack() override accepted two parameters instead of one, then m.attack(p) would fail when m has the dynamic type Goblin (it'd be calling a method with two parameters but only passing in one argument). Moreover, if the name of the override wasn't written correctly (e.g., due to a typo), then Mypy and the interpreter would treat it as a different method altogether, in which case the Goblin class would be abstract due to faliing to override the attack() method.
To protect yourself from these sorts of mistakes, the typing module provides a special decorator: @override. Once imported, it can be used to annotate a method override by writing it directly above the override's definition. This tells Mypy that the intention is to override a method from the base class. Mypy will then carefully check that the override does, indeed, match all the specifications of the base class method (including the name, parameter list, and return type). If any of the specifications are not a match, it will immediately throw an error, pointing you to your mistake.
For example:
goblin.pyfrom typing import override # Import the override decorator
from monster import Monster
from player import Player
# The Goblin class inherits from the Monster class
class Goblin(Monster):
# Goblins now alternate between dealing 1 and 2 damage on their
# turns. We'll keep track of the damage that a goblin should
# do on their next turn by flipping this boolean back and
# forth between true and false.
_one_damage: bool
def __init__(self) -> None:
# Goblins get 5 HP. Pass this to the Monster constructor
# to be stored in the private _hp attribute
super().__init__(5)
# Goblins do 1 damage on their first turn, so we'll initialize
# self._one_damage to True
self._one_damage = True
# This tells Mypy and the interpreter that this method is meant to
# be an override of an inherited method from the Monster class. If
# we were to make a mistake when defining this override, such as
# misspelling its name, giving it the wrong kinds of parameters, or
# giving it the wrong return type, then Mypy will raise errors
# and point out our mistake.
@override
def attack(self, p: Player) -> None:
# Goblins alternate between dealing 1 and 2 damage on each
# of their turns.
if self._one_damage:
p.take_damage(1)
else:
p.take_damage(2)
# If self._one_damage is True, change it to False. If it's
# False, change it to True. We can do this in the single
# following statement:
self._one_damage = not self._one_damage
In the above example, the method is overridden correctly, so Mypy will not throw any errors. But if there were anything wrong with the override, even just typo in its name, Mypy would notify us. That's useful. Ordinarily, if there was just a typo in the method's name, Mypy wouldn't notice. It'd just think that we're trying to define a different method. We wouldn't see any issues in such a case until we try to instantiate the Goblin class.
(In practice, we should use the @override decorator in the Zombie and Vampire classes as well, but I'll leave that as an exercise to the reader.)
In Python, polymorphism basically happens automatically whenever you upcast anything (and in some other cases). But as I said earlier, this isn't the case in all programming languages. In C++, for example, getting polymorphism to work requires some extra syntax and attention to detail. This isn't a Python course. Rather, Python is merely a tool through which we're exploring computer science. So, I'd be remiss if I didn't explain how to accomplish polymorphism in other languages like C++ as well.
When a function call is executed, such as m.attack(p), your computer must somehow associate that function call with a certain function definition. That is, it must somehow figure out which body of code to jump to and execute. The process of associating the name of a symbol (such as the name of a function) with the definition of that symbol is referred to as binding.
There are two ways that function names can be bound to their definitions: static binding, and late binding. Static binding is based on static types of expressions. Dynamic binding is based on dynamic types of expressions.
For example, under static binding, m.attack(p) would always execute the Monster class's attack() method (which wouldn't be possible in our current program since it's undefined, but suppose that it was defined). That's because the static type of m is Monster, and static binding works through static types. In contrast, under dynamic binding, m.attack(p) would execute the correct override of the attack() method depending on the dynamic type of m (e.g., if the dyanmic type of m was Zombie, then it would execute the Zombie class's attack() method override).
In Python, all function calls are bound dynamically. There is no such thing as static function binding in Python. This is why we didn't need to do anything special to get m.attack(p) to call the correct derived-class overrides.
That sounds convenient, but dynamic binding is slower than static binding because it requires indirection / function lookups at runtime. For example, in C++, dynamic binding works via virtual tables and virtual pointers. These mechanisms require your computer to jump around from one place in memory to another several times in order to execute the target function definition. Static binding, in contrast, does not require any such indirection. Static binding happens at build time—the compiler simply translates the line of code containing the function call to a machine instruction that tells the computer to jump to a very specific definition at a fixed (relative) location in memory.
(Dynamic binding is even slower in Python than in C++ because of how objects are associated with their members according to Python's object model.
Hopefully this isn't surprising. Remember: "dynamic" means "at runtime", and anything that must happen at runtime is, of course, going to slow down the program when it's running. "Static" means "before runtime", meaning that static things can be done before the program even starts. So it should make sense that dynamic binding is slower than static binding.
In languages that support both static and dynamic binding, static binding is typically the default binding mechanism. If you want to achieve dynamic binding of a certain function, then, you must enable it manually, often through a special keyword (such as C++'s virtual keyword, which is used to annotate the method in the base class, enabling dynamic binding of that method).
Once you've done that, you might still run into issues getting polymorphism to work, depending on the programming language. Another common issue is known as object slicing. In some languages (again, such as C++), upcasting an object causes the object to lose its derived-class-specific attributes and methods. For example, in C++, upcasting a Zombie object into a Monster object would cause it to lose its attack() override and _sanity attribute because both of those things are defined in the Zombie class. That is, in C++, when a Zombie object is upcasted into a Monster object, it's essentially no longer a Zombie object anymore—it just becomes a plain-old Monster object (and, unsurprisingly, this isn't allowed if the Monster class is abstract). It's called "object slicing" because the derived-class-specific attributes and methods are "sliced" off, leaving you with nothing more than a plain-old Monster object.
Avoiding object slicing usually involves upcasting pointers or references rather than upcasting objects directly (we don't have time to discuss why this solves the problem, but it does).
Python doesn't have an object slicing problem because, in Python, everything is a reference (we discussed this in a past lecture), so when you upcast something in Python, you're inherently upcasting a reference. But, as with dynamic binding, this can cause performance issues.