Classes
Classes are just another (more advanced) way to store data. The good thing about them is that they can store a lot of information within only one variable (like Lists or Dictionaries). The advantages are it is easier to access the data and classes can also define custom functions (called methods) which are sometimes refered to as behaviours that each object will possess. More on methods later.
Classes vs. objects
The most common confusion is the distinction between a class and an object. They are intimately tied together, however they are not the same thing.
Class: the general thing. e.g., “Human”
In our programs, we get to define what a Human is. We get to say, “every human, in general, will have attributes like eye colour, height, weight, and a name”.
For every Human object we create, we want to store that same information for all of them.
We can also define a set of behaviours (functions called methods) that every Human object we create will be able to perform.
Object: A specific instance of a thing. e.g., “Jeff”
In our program we get to create Humans and modify (set) their attributes according to their specific eye colour, height, weight and name.
Jeff (object) is a Human (class)
Attributes and Behaviours
Classes are defined to give their objects attributes and behaviours.
Object Attributes
These are really just variables stored for each instance of a class (each object). We can also call these attributes fields.
So for a particular object, the specific information we store about it like age and eye-color, is called:
an attribute
a variable
a field
You can use these terms interchangably.
Object Behaviours
Every object of a particular class is given abilities or “behaviours”. These are called “methods”. Methods are just functions that belong to every instance (object) of a class.
Here are some examples of some methods:
p = Person("Jeff", 56)
p.jump()
p.laugh()
p.throw(ball)
Those could all be “behaviours” that the Person
named Jeff can do by virtue of being a Person
object, if we decided
to create those methods in the class.
Unified Modeling Language (UML)
User # Name of the class
----
name: str # list the attributes first
password: str
email: str
----
post(message: str) -> None # behaviours (methods)
__init__ method
The init method is what is called when you create an object. It will attach values and data to a single object using the self
pointer.
class User:
def __init__(self, username, password, email):
self.username = username
self.password = password
self.email = email
Accessing Object Attributes
user1 = User("EraserMan", "password123", "blah@gmail.com")
print(user1.username)
print(user1.password)
print(user1.email)
user1.username = "MrBlah"
print()
print(user1.username)
print(user1.password)
print(user1.email)
Output:
EraserMan
password123
blah@gmail.com
MrBlah
password123
blah@gmail.com
Another example:
class Person:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
jeff = Person("Jeff", 35)
print(f"This is {jeff.name}, they are {jeff.age} years old!")
Object pointers
For primitive datatypes, literal values are stored in variables.
a = 5
b = a # COPY the VALUE of `a` and store it in `b`
print(a) # 5
print(b) # 5
a += 3
print(a) # 8
print(b) # 5
For, objects the memory location (pointer) is stored in variables.
class Person:
pass
a = Person() # create a person object, store the pointer in variable `a`
a.name = "Frank"
print(a.name) # "Frank"
b = a # Copies the POINTER, not the data
print(b.name) # "Frank"
print(a) # <__main__.Person object at 0x0000016E2BE44550>
print(b) # <__main__.Person object at 0x0000016E2BE44550> (same memory location)
a.name = "Sally"
print(a.name) # "Sally"
print(b.name) # "Sally"
Both variables a
and b
point to the same object.
Loop through a list of objects
class Person:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
# --- OK ---
# Store each person object in a vriable, then add them to a list.
# This has its limitations.
jeff = Person("Jeff", 35)
sally = Person("Sally", 30)
ben = Person("Ben", 22)
people = [jeff, sally, ben]
# --- BETTER ---
# Create the person objects directly in a list
people = [Person("Jeff", 35), Person("Sally", 30), Person("Ben", 22)]
for p in people:
print(f"This is {p.name}, they are {p.age} years old!")
Instance method
Each instance (object) of a class (with instance methods) will have certain “behaviours” or actions that they will be able to perform. This is different from using a normal function on an object. With instance methods, we think of the object itself performing the action.
p = Person("Jeff")
# Fundtion: THe object being acted UPON
greeting = get_introduction(p)
# Method: The object ITESELF performing the action
greeting = p.get_introduction()
The end result is the same, it’s just a matter of organizing our code. The function will be outside the class, where the method will be contained within the class. It may be best to write all functions related as methods within the class itself. This helps keep all related code in the same place.
Two examples of instance methods. One without parameters, one with.
class Person:
def __init__(self, name, height, strength):
self.name = name
def introduce(self):
print(f"Hello, my name is {self.name}.")
def complement(self, person):
print(f"{person.name}, I love your hair!")
# Usage
p1 = Person("Jeff")
p2 = Person("Sally")
p1.introduce()
p2.complement(p1)
# output:
# "Hello, my name is Jeff."
# "Jeff, I love your hair!"
Note
Technically all instance methods have at least one parameter. The self
pointer. In Python, we must explicitly
define each instance method to include it.
Encapsulation
Warning
Encapsulation section under construction. Proceed at your own risk.
What is an API?
At some point in your career you will create code, not for a general end-user, but for other software developers.
Imagine you love coding in Python but you wish you could write 2D games. There are no easy ways to do it, but you set out to create to create a clone of space invaders. You notice some common patterns like drawing circles and rectangles and create some functions to be able to draw a rectangle with ease. Pygame is born. As time goes on, you add more features and functions that make creating a game as easy as possible. You upload your source code to GitHub, and now hundreds of people are using the code you wrote.
All the functions that people are using to draw shapes, create sprites, and perform collision detection IS the API.
The API hides all the complicated and unsightly details of drawing a polygon. If you wanted to draw a polygon, it would
require roughly 30 lines of code.
The API allowes people to simply use pygame.draw.polygon(surface, color, points, width)
when they want to draw a polygon.
What makes a good API?
A good API:
hides complex code and complex processes from the developer.
minimizes the number of lines of code someone needs to write to accomplish something.
ensures the code looks as close to english as possible.
Building an API
Pick a particular thing that operates within your program and bring yourself through the following steps.
For illustrative purposes, we will consider a simple WaterBottle
.
State
What about the object can change?
What about the object cannot change?
Every WaterBottle
will have the following attributes.
capacity: int
contents: int
Capicity will refer to the maximum volume the bottle can hold and contents refers to what volume it is currently holding.
For example, a bottle can have a maximum capacity of 350 mL
, but only currently holding 90 mL
of water.
Some attributes throughout the life of the bottle can be changed (contents
), and somethings should not be (capacity
).
At the end of the day, the developer makes the decision concerning what attributes can change or not.
Behaviours
What does the object do?
What can be done to the object?
How do these behaviours modify or alter the object’s state?
Do any of these behaviours depend on outside information?
Do they return a value?
Our WaterBottle
will need the ability to be filled up and poured out. Those can be made into methods.
fill()
pour()
How would these abilities alter or modify the bottle’s state? They would change the current capacity.
Do these methods require outside information? Yes, we need to know how much to fill and how much to pour out.
fill(amount: int)
pour(amount: int)
Do these behaviours return anything? Perhaps. If you wanted to, you could pour out some water into a cup.
# hypothetical, speculative code
bottle = Bottle()
cup = Cup()
cup.contents = bottle.pour(50)
# or
cup.fill(bottle.pour(50))
Verification
How is someone supposed to use your code?
- Come up with a few lines of code showing how to interact with the API.
Create an object
Use some methods
What is the result supposed to be?
Do you like the way the code looks? Is is easy to use and remember?
If you need to modify the API, do so.
Note
older stuff below.
When writing code professionally, one person may write a class or a function and many other people will use it. This is why we need to document our code properly.
Additionally, the person who creates the class (or function) is the one who knows about all the little behind-the-scenes details of how things work. They will also know certain methods can only work with a specific set of data.
Let’s imagine we have a Person
class with a name
and an age
:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def greet(self):
return f"Hello, I am {self.name} and I'm {self.age} years old."
def can_vote(self):
return self.age >= 18
Once we have a Person
object created,
whoever uses our Person
class will be able to change
the age to whatever they want. They can change it to something
silly at best or break the program at worst.
1person = Person("Johnny", 34)
2person.name # "Johnny"
3person.age # 34
4person.greet() # "Hello, I am Johnny and I'm 34 years old."
5
6person.age = "Blah Blah Blah"
7
8# silly
9person.greet() # "Hello, I am Johnny and I'm Blah Blah Blah years old."
10
11# broken
12person.can_vote() # TypeError!!!!!!!
The problem is NOT with the can_vote()
method which was called on line 12
. The problem is that
the data for age
was allowed to be changed to something invalid in the first place (line 6
).
For Java, this specific problem would not happen, but, other bad data can sneak in. For example, if we try to set the age to a negative number, this would not be valid data for someone’s age. So even in Java, we would need to encapsulate the attribute to ensure it never is allowed to be given something invalid.
So how do we fix this? There are three steps:
Make the attribute private
Make a getter and setter method
In the setter method, validate the data
Making attributes private
The first thing to do is “hide” the atribute or make it private. In Python, we simply underscore the object attribute’s name:
class Person:
def __init__(self, name, age):
self.name = name
self._age = age
# The internal methods that use the age attribute
# need to be modified because we put an underscore.
def greet(self):
return f"Hello, I am {self.name} and I'm {self._age} years old."
def can_vote(self):
return self._age >= 18
In Python nothing is truly ever private, be we can do things to indicate to other programmers that they should not touch the attribute without potential negative consequences. In Java, declaring a fieid “private” absoultely hides it from other programmers.
Make getters and setters
Once we have “hidden” the attribute, we need to provide other programmers access to it with an intermediary methods called setters and getters. Setters “set” the attribute value for the programmer, and Getters fetch and return the attribute value to the programmer.
All setters start with
set_
All getters start with
get_
class Person:
def __init__(self, name, age):
self.name = name
self._age = age
def get_age(self):
return self._age
def set_age(self, new_value):
self._age = new_value
Now we can access the attribute by using the following methods:
person = Person("Johnny", 34)
person.name # "Johnny"
person.get_age() # 34
person.greet() # "Hello, I am Johnny and I'm 34 years old."
person.set_age("Blah Blah Blah")
# silly
person.greet() # "Hello, I am Johnny and I'm Blah Blah Blah years old."
# broken
person.can_vote() # TypeError STILL!!!!!!!
As you can see, we provided encapsulation but we still haven’t ensured that only valid data is saved to the attribute.
Provide data validation
So the problem is now that the .set_age()
method allowed the age to be set to an
invalid value. We can fix that:
class Person:
def __init__(self, name, age):
self.name = name
self._age = age
def set_age(self, new_value):
assert new_value >= 0, "The age value cannot be negative."
self._age = new_value
If we call the method with bad data like person.set_age(-20)
, the assertion fails,
the program will break and will display:
AssertionError: The age value cannot be negative.
Additionally if we call the method with a non integer (person.set_age("twelve")
), the program will raise a TypeError
for us:
TypeError: '>=' not supported between instances of 'str' and 'int'
So you might be thinking, “So what? There’s still is an error. Now it just happens sooner”. The key distinction is now the error happens at the point of the actual error, which was setting the value to some invalid data.
In reality, the .can_vote()
method can possibly be called three months after the bad data was set.
This is a code time-bomb ready to go off. If we ever write software for a bank or some other
critical business, we can’t afford to write code that will explode at some unforseen time or
have undesired side-effects or consequences in the future.
Aggregate class
An Aggregate class is one which contains, or is made up of another class. The aggregate class contains components that are other classes.
class Engine:
def __init__(self, size: float):
self.size = size
class Car:
"""Aggregate class. It contains an Engine object."""
def __init__(self, color: str, engine_size: float):
self.color = color
self.engine = Engine(engine_size)
car = Car("blue", 4.0)
print(car.color) # "blue"
print(car.engine.size) # 4.0
Class field (variable)
from typing import List
class Pizza:
num_pizzas = 0 # class field (variable)
def __init__(self, name: str, toppings: List[str]):
self.name = name
self.toppings = toppings
self.id = Pizza.num_pizzas
Pizza.num_pizzas += 1 # update the class field
def __str__(self) -> str:
return f"{self.name}, toppings: {self.toppings}, id: {self.id}"
def main():
pepperoni = Pizza("Pepperoni", ["cheese", "pepperoni"])
print(pepperoni)
print(Pizza.num_pizzas) # 1
if __name__ == "__main__":
main()
Class method
1from typing import List
2
3
4class Pizza:
5 num_pizzas = 0
6
7 def __init__(self, name: str, toppings: List[str]):
8 self.name = name
9 self.toppings = toppings
10 self.id = Pizza.num_pizzas
11 Pizza.num_pizzas += 1
12
13 def __str__(self) -> str:
14 return f"{self.name}, toppings: {self.toppings}, id: {self.id}"
15
16 @classmethod
17 def pepperoni(cls):
18 return cls("Pepperoni", ["cheese", "pepperoni"])
19
20 @classmethod
21 def cheese(cls):
22 return cls("Cheese", ["cheese"])
23
24
25def main():
26 pepperoni = Pizza.pepperoni()
27 print(pepperoni)
28
29 cheese = Pizza.cheese()
30 print(cheese)
31
32 cheese_another = Pizza.cheese()
33 print(cheese_another)
34
35 print(Pizza.num_pizzas)
36
37
38if __name__ == "__main__":
39 main()
Inheritance
1class Animal:
2 def __init__(self, name: str):
3 self.name = name
4
5 def __str__(self):
6 return self.name
7
8 def make_sound(self):
9 print("<generic animal sound>")
10
11
12class Dog(Animal): # Dog inherits from Animal
13 def __init__(self, name: str, breed: str):
14 super().__init__(name)
15 self.breed = breed
16
17 def __str__(self):
18 return f"{self.name} the {self.breed}"
19
20 def make_sound(self):
21 print("Woof!")
22
23
24class Squirrel(Animal): # Squirrel inherits from Animal
25 def make_sound(self):
26 print("Squeek")
27
28
29class Cat(Animal): # Cat inherits from Animal
30 pass
31
32
33def main():
34 d = Dog("Rover", "Dalmatian")
35 s = Squirrel("Sammy")
36 c = Cat("Bella")
37
38 print(d) # Rover the Dalmatian
39 print(s) # Sammy
40 print(c) # Bella
41
42 d.make_sound() # Woof!
43 s.make_sound() # Squeek
44 c.make_sound() # <generic animal sound>
45
46
47if __name__ == "__main__":
48 main()
Polymorphism
Polymorphism is when a sub-class overrides an inherited method.
1class Animal:
2 def __init__(self, name: str):
3 self.name = name
4
5 def __str__(self):
6 return self.name
7
8 def make_sound(self):
9 print("<generic animal sound>")
10
11
12class Dog(Animal):
13 def __init__(self, name: str, breed: str):
14 super().__init__(name)
15 self.breed = breed
16
17 def __str__(self): # overrides inherited method
18 return f"{self.name} the {self.breed}"
19
20 def make_sound(self): # overrides inherited method
21 print("Woof!")
22
23
24class Squirrel(Animal):
25 def make_sound(self): # overrides inherited method
26 print("Squeek")
27
28
29class Cat(Animal):
30 pass
31
32
33def main():
34 d = Dog("Rover", "Dalmatian")
35 s = Squirrel("Sammy")
36 c = Cat("Bella")
37
38 print(d) # Rover the Dalmatian
39 print(s) # Sammy
40 print(c) # Bella
41
42 d.make_sound() # Woof!
43 s.make_sound() # Squeek
44 c.make_sound() # <generic animal sound>
45
46
47if __name__ == "__main__":
48 main()
Refactor multiple classes
Function/Method Overloading
# Method overloading (Python version)
# optional/default parameters
# Key-word arguments
# Allows us to create one function or method with different
# ways to call it.
def draw_rectangle(x, y, w=100, h=100, color=arcade.color.BLUE):
pass
draw_rectangle(10, 50) # draws rectangle at 50, 50 of width 100, height 100
draw_rectangle(100, 200, 5)
draw_rectangle(500, 200, 50, 50)
draw_rectangle(10, 10, 5, 5, arcade.color.WHITE)
draw_rectangle(75, 10, color=arcade.color.WHITE)
Docstrings (Classes)
For more information on Docstrings in general, check out function docstrings.
For classes, this is the way we will be wrirting our docstrings:
from typing import List, Optional
class ExampleClass:
"""The summary line for a class docstring should fit on one line.
If the class has public attributes, they may be documented here
in an ``Attributes`` section and follow the same formatting as a
function's ``Args`` section. Alternatively, attributes may be documented
inline with the attribute's declaration (see __init__ method below).
Attributes:
attr1 (str): Description of `attr1`.
attr2 (:obj:`int`, optional): Description of `attr2`.
"""
def __init__(self, param1: str, param2: Optional[int] = None, param3: List[str]):
"""Example of docstring on the __init__ method.
The __init__ method may be documented in either the class level
docstring, or as a docstring on the __init__ method itself.
Either form is acceptable, but the two should not be mixed. Choose one
convention to document the __init__ method and be consistent with it.
Note:
Do not include the `self` parameter in the ``Args`` section.
Args:
param1: Description of `param1`.
param2 (optional): Description of `param2`. Multiple
lines are supported.
param3: Description of `param3`.
"""
self.attr1 = param1
self.attr2 = param2
self.attr3 = param3
def example_method(self, param1: int, param2: str) -> bool:
"""Class methods are similar to regular functions.
Note:
Do not include the `self` parameter in the ``Args`` section.
Args:
param1: The first parameter.
param2: The second parameter.
Returns:
True if successful, False otherwise.
"""
return True