Note 7: Objects

Reading: Python Docs 9.1-9.3

Classes

We’ve seen different built-in Python objects that combine data and functionality: strings, for instance, are more than a collection of characters; they also have methods that return new strings, based on the original contents of the string:

s = "bureaucrats"
s = s.upper()
print(s)
BUREAUCRATS

Lists have methods that modify the list (and don’t return anything):

K = [6, 4, 5]
v = K.sort()
print("v:", v) # nothing is returned
print("K:", K) # the list was modified
v: None
K: [4, 5, 6]

Lists also have methods that modify the list and do return something:

K = [6, 4, 5]
v = K.pop() # pop removes the last element of the list and returns it
print("v:", v)
print("K:", K) # the list was modified
v: 5
K: [6, 4]

We can define our own classes and create new kinds of objects; this is useful whenever we want to combine data and functionality.

Class Definitions

Here is a basic class definition.

class Grid:
    def __init__(self, x:int, y:int) -> None:
        self.x:int = x
        self.y:int = y

Type hints are not required, although they help avoid errors related to type, and make our code more explicit when read by a human. Here is the same class definition, without type hints:

class Grid:
    def __init__(self, x, y):
        self.x = x
        self.y = y

Note:

  • The class definition using the keyword class
  • The class name, Grid – this is a name we choose, similar to a function name
    • It is a convention for user-defined classes to always be capitalized. It’s not required.
  • The constructor method, __init__ is executed whenever a new class is created.
    • This constructor takes three arguments: self, x, and y
    • Methods in classes always have, as their first positional argument, self
      • self refers to the instance of the object
    • __init__ never returns anything (its return type is always None)

self

The use of the word “self” is a convention. You could, in theory, choose a different name. Nonetheless, the first positional argument refers to the instance of the object, and you should not deviate from the self convention.

We can create a new object by calling the class name as if it were a function, which uses the constructor to create a new object. Because the constructor __init__ was defined as taking two positional arguments other than self, we must create a new object using two positional arguments. We can then access the x and y values for this Grid object:

A = Grid(3, 4)
print(A.x, A.y)
3 4

We can create multiple different instances of the Grid class. These different objects will have different instance variables x and y:

A = Grid(3, 4)
B = Grid(1, 5)
print(A.x, A.y)
print(B.x, B.y)
3 4
1 5

The visualizer illustrates that there are multiple instances of the Grid class (multiple objects).

Our constructor previously took input arguments (x and y) and created instance variables (self.x and self.y) from them, but the constructor __init__ is a function, which means we can put any functionality we want into it. Here, we also create instance variables self.size and self.shape:

class Grid:
    def __init__(self, x:int, y:int) -> None:
        self.x:int = x
        self.y:int = y
        self.size:int = x * y
        self.shape:tuple[int, int] = (x, y)

A = Grid(4, 6)
print(A.size)
print(A.shape)
24
(4, 6)

Class Methods

We have collected data (instance variables) in the Grid class, with one method, the constructor __init__. Classes can also include other methods that perform functionality: either arbitrary functionality that we define, or “built in” types of functionality.

First, we will specify a numbering scheme for grids of arbitrary dimensions:

  • The top-left corner is square 0
  • Squares are numbered left to right, top to bottom

Here are examples of a 3x3 grid and a 4x3 grid:

Now, let’s make a method that prints out a representation of a grid for any size.

class Grid:
    def __init__(self, x:int, y:int) -> None:
        self.x:int = x
        self.y:int = y
        self.size:int = x * y
        self.shape:tuple[int, int] = (x, y)
    
    def print_grid(self) -> None:
        grid_str:str = ""
        count:int = 0
        for j in range(self.y):
            for k in range (self.x):
                if count < 10:
                    grid_str += "[ " + str(count) + "]"
                else:
                    grid_str += "[" + str(count) + "]"
                count += 1
            grid_str += "\n"
        print(grid_str)

A = Grid(4, 6)
A.print_grid()
[ 0][ 1][ 2][ 3]
[ 4][ 5][ 6][ 7]
[ 8][ 9][10][11]
[12][13][14][15]
[16][17][18][19]
[20][21][22][23]

Note:

  • The Grid.print_grid method is defined with one argument, self
    • It is called with no arguments
    • self is always included in class method definitions
    • self is not part of the method calls

We wrote a method to print out the grid representation, but what happens when we try to print the grid object itself?

A = Grid(4, 6)
print(A)
<__main__.Grid object at 0x7858dd26cd10>

This is not particularly useful: it tells us that A is a Grid object, but we don’t know anything else about it. We can define a string representation for any class, which lets us (the human) inspect the object using the print function.

The default string representation uses a built-in method, __repr__. We “override” this by defining it ourselves:

class Grid:
    def __init__(self, x:int, y:int) -> None:
        self.x:int = x
        self.y:int = y
        self.size:int = x * y
        self.shape:tuple[int, int] = (x, y)
    
    def __repr__(self) -> str:
        grid_str:str = ""
        count:int = 0
        for j in range(self.y):
            for k in range (self.x):
                if count < 10:
                    grid_str += "[ " + str(count) + "]"
                else:
                    grid_str += "[" + str(count) + "]"
                count += 1
            grid_str += "\n"
        return grid_str

A = Grid(3, 4)
print(A)
[ 0][ 1][ 2]
[ 3][ 4][ 5]
[ 6][ 7][ 8]
[ 9][10][11]

Let’s add another method: this method will check if a given grid square is at a corner of the grid or not. Looking back at the illustrated diagram above, you’ll see that \(2\) is a corner of the 3x3 grid and not a corner of the 4x3 grid; \(3\) is a corner of the 4x3 grid and not a corner of the 3x3 grid.

First, we’ll need the arithmetic to determine if a grid square is in a corner for any given grid:

  • A square n has an x-position given by n % x
  • A square n has a y-position given by n // x

A corner exists wherever:

  • The x-position is 0 or the width minus 1 and
  • The y-position is 0 or the height minus 1

We can now add a is_corner method to our class. Note that self.x and self.y have been renamed self.width and self.height to resolve ambiguity:

class Grid:
    def __init__(self, x:int, y:int) -> None:
        self.width:int = x
        self.height:int = y
        self.size:int = self.width * self.height
        self.shape:tuple[int, int] = (self.width, self.height)
    
    def __repr__(self) -> str:
        grid_str:str = ""
        count:int = 0
        for j in range(self.height):
            for k in range (self.width):
                if count < 10:
                    grid_str += "[ " + str(count) + "]"
                else:
                    grid_str += "[" + str(count) + "]"
                count += 1
            grid_str += "\n"
        return grid_str

    def is_corner(self, n) -> bool:
        x:int = n % self.width
        y:int = n // self.width
        if (x == 0 or x == self.width - 1) and (y == 0 or y == self.height - 1):
            return True
        return False

A = Grid(3, 4)
print(A)
print(2, A.is_corner(2))
print(3, A.is_corner(3))
print(4, A.is_corner(4))
print(9, A.is_corner(9))
[ 0][ 1][ 2]
[ 3][ 4][ 5]
[ 6][ 7][ 8]
[ 9][10][11]

2 True
3 False
4 False
9 True

Inheritance

One powerful feature of classes is the ability of a class to inherit from another class. We have already defined a basic Grid class: let’s extend the Grid class to add more functionality.

We will add the ability to “select” certain grid squares, and remember which squares are selected in a list. We will do this in a new SelectableGrid class:

class SelectableGrid(Grid):
    def __init__(self, x:int , y:int):
        super().__init__(x, y)
        self.selected:list = []

A = SelectableGrid(3, 5)
print(A.is_corner(2))
True
  • The class definition for SelectableGrid includes as argument Grid
    • SelectableGrid will automatically inherit all of the methods and variables from Grid
    • If don’t change any method that was in Grid, that method will be present in SelectableGrid and function identically
  • We “override” the constructor by defining a new __init__
    • We call super().__init__ to invoke the original constructor
    • The x and y arguments from our new constructor are passed through to the original constructor

A very simple example of inheritance, visualized:

Note that the B class does not have any constructor defined: if we want to inherit a method from the parent class without changing it, we don’t do anything: all of the parent’s methods are automatically inherited.

We can use the super() function to call parent methods when changing them:

Now we’ll add another method to SelectableGrid to “select” certain squares of the grid:

class SelectableGrid(Grid):
    def __init__(self, x:int , y:int):
        super().__init__(x, y)
        self.selected:list = []

    def select(self, grid_sq) -> None:
        self.selected.append(grid_sq)

A = SelectableGrid(3, 5)
A.select(5)
A.select(2)
print(A.selected)
[5, 2]

This sort of works, but isn’t an ideal implementation, logically. We’ll address the issues later, in the practice problems.

Class Variables

It’s possible to create class variables that persist with the entire class, rather than instances of the class. In the definition below, selected is a class variable rather than an instance variable.

class SelectableGrid(Grid):
    selected:list = []

    def select(self, grid_sq) -> None:
        self.selected.append(grid_sq)

A = SelectableGrid(3, 5)
A.select(5)
A.select(2)
B = SelectableGrid(4, 3)
print(A.selected)
print(B.selected)
[5, 2]
[5, 2]

A.selected and B.selected both reference the same class variable – which is why they are the same.

A simple example, visualized:

  • y is an instance variable: it is distinct between different instances of the class
  • x is a class variable: it is shared by every instance of the class!
    • Changes to x from any member of the class will change it for every member of the class
  • Internally, the same syntax is used for both instance variables and class variables:
    • self.y references instance variable y
    • self.x references class variable y

Class variables can be useful, but just as frequently, confusing class variables and instance variables can cause problems. Be careful!

Practice

Practice Problem 7.1

Practice Problem 7.1

Change the basic Grid object’s string representation to include more information about the Grid:

A = Grid(3, 4)
print(A)

should show

3x4 Grid:
[ 0][ 1][ 2]
[ 3][ 4][ 5]
[ 6][ 7][ 8]
[ 9][10][11]

Practice Problem 7.2

Practice Problem 7.2

Write a function shared_corner that takes two basic Grid objects as arguments. The function should return True if the grids “share” any non-zero corner, otherwise, return False.

  • shared_corner(Grid(3, 3), Grid(4, 3)) returns True (8 is a shared corner)
  • shared_corner(Grid(3, 3), Grid(3, 6)) returns True (2 is a shared corner)
  • shared_corner(Grid(3, 4), Grid(4, 5)) returns Fakse (No shared non-zero corner)

Practice Problem 7.3

Practice Problem 7.3

Modify the SelectableGrid object from the notes:

  • The object should still inherit from Grid
  • The select method should keep only unique selected grid squares:
    • If SelectableGrid.select is called on a grid square that has already been selected, do nothing
  • The select method should not be able to select grid squares that are not on the grid:
    • If SelectableGrid.select is called on a grid square that doesn’t exist in the grid, do nothing

Practice Problem 7.4

Practice Problem 7.4

Change the SelectableGrid object’s string representation to include more information about the Grid. Show selected squares using parentheses:

A = SelectableGrid(3, 4)
A.select(5)
A.select(7)
print(A)

should show

3x4 Grid:
[ 0][ 1][ 2]
[ 3][ 4]( 5)
[ 6]( 7)[ 8]
[ 9][10][11]

Try to use the string returned by super().__repr__