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)
https://online.visual-paradigm.com/images/features/main/01-online-class-diagram-example.png

__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