Overview

In this lecture we will continue our discussion of classes and object-oriented programming (OOP) in Python, by extending our Shape class with a lot of new methods.

Topics

  • Review basic OOP terminology

  • Extend the Square class by adding methods

Definitions and terminology

Recall from last time: An object is a data value that contains

  • attributes (data items)

  • methods (functions that act on the object)

Technically, methods are a special kind of attribute: they are attributes which happen to be functions ("attribute" is a Python term, not a general OOP term). We will usually refer to something as an "attribute" to mean a non-function attribute (data attribute) as opposed to a method. Data attributes are also often called fields for short.

A class is a description of what an object is, and contains the definition of all the methods of an object. [1] Every object is an instance of some class.

Classes and objects

Every object which is an instance of the same class normally contains

  • the exact same methods

  • the exact same data attribute names

Although the data attribute names are the same for all instances of that class, the values corresponding to a given data attribute name are generally different for different objects. [2] We can think of an object as a "bag of data attributes with methods".

Constructor methods

Every class has a constructor method (or constructor for short) called __init__ The constructor is called in order to create a new object of the class. The data attributes of an object are normally initialized in the constructor, though the values can be changed later. (You can also add more attributes later, though this is generally considered to be bad practice.)

You call a constructor by using the class name as if it were a function. For instance, for a class Thing where the constructor method __init__ takes one argument other than self, you would create new instances of the Thing class by calling the constructor like this:

t = Thing('Godzilla')

This creates a new (empty) Thing object, then calls the __init__ constructor method with the new object as the self argument to the constructor, along with whatever other arguments the constructor needs.

Unlike some object-oriented languages (*cough* Java), a Python class can only have a single constructor. However, you can have optional and/or keyword arguments in the constructor, so this normally isn’t a problem.

Methods

Method definitions look like regular Python function definitions, except that they are located inside a class statement. Method definitions have a first argument called self which represents the object being acted on. The name self is a convention, not a keyword. (You could call it something else, but nobody does.) Inside a method, you can access the attributes of the current object through the self argument using the dot syntax. So an attribute called x could be accessed by writing self.x.

When methods are called, the method call is missing the first (self) argument; that argument goes before the dot (.) in the dot syntax. So if you have a class Thing with an instance t and a method called get_value, then to call the get_value method you would write:

t.get_value()

Graphics example

There’s more to know about classes and objects, but that’s enough for now. Let’s recap the graphics example we discussed last time and extend it.

We want to create objects to represent shapes we can draw on tkinter canvasses (e.g. squares, circles). We have been defining a Square class to help us generate Square objects. Here’s what we have so far:

class Square:
    """Objects of this class represent a square on a tkinter canvas."""

    def __init__(self, canvas, center, size, color):
        """Initialize a `Square` object."""
        (x, y) = center
        x1 = x - size/2  # upper left
        y1 = y - size/2  # coordinates
        x2 = x + size/2  # lower right
        y2 = y + size/2  # coordinates
        self.handle = \
            canvas.create_rectangle(x1, y1, x2, y2, fill=color, outline=color)
        self.canvas = canvas
        self.center = center
        self.size = size
        self.color = color

Let’s build up our Square class method-by-method to make our Squares more powerful. We’ll put this code in a module called Square (i.e. in a file called Square.py) At the bottom of the file we’ll have code to create the root object, the canvas, and one or more Squares. Here it is, with some parts omitted:

"""<module docstring>"""

from tkinter import *

class Square:
    """<docstring>"""
    def __init__(self, canvas, center, size, color):
        # see above

    # ... other methods ...

if __name__ == '__main__':
    root = Tk()
    root.geometry('800x800')
    canvas = Canvas(root, height=800, width=800)
    canvas.pack()

    # Create a single red square of side length 50 pixels
    # centered at pixel coordinates (100, 100).
    s1 = Square(canvas, (100, 100), 50, 'red')

python -i

Our Square class only has a constructor so far. We can run our program and test it interactively by typing:

$ python -i Square.py

at the terminal prompt ($). [3] The -i tells Python that you want to run the module interactively. It’s just like running python3 Square.py, except that after it’s done you go back into the Python shell. When you do this, the program runs, displays a canvas with a red square, and you get the prompt:

>>>

At this point, you can play around with the objects you’ve created.

>>> s1
<__main__.Square object at 0x104fd0d60>

Let’s look at the Square object’s fields:

>>> s1.center
(100, 100)
>>> s1.size
50
>>> s1.color
'red'

The Square object stores these values, so every method of Square will be able to access them through the self argument.

Changing color

What if we want to change the color of a square?

>>> s1.color = 'blue'
>>> s1.color
'blue'

We’ve successfully changed the color field of the square. But if you run this code, you’ll see that the square has not changed color! Why?

To actually change the color of an object representing a shape on a canvas, it’s not enough to just set the color field to a different value. (That information is not communicated to the canvas object that actually is in charge of the square’s color.) Instead, we have to call a canvas method called itemconfig on the handle of the shape:

>>> canvas.itemconfig(s1.handle, fill='blue', outline='blue')

This will change the square’s color to blue.

This is tedious; you have to get the handle from square s1, and you have to separately specify the fill and the outline color, even though we always want them to be the same. Let’s write a method on Squares to do this all at once:

1def setColor(self, color):
2    """Changes this object's color to 'color'."""
3    self.canvas.itemconfig(self.handle, fill=color, outline=color)
4    self.color = color

Line 3 changes the color of the square on the canvas. Line 4 stores the new color into the Square object. With this method defined, we can simply do this:

>>> s1.setColor('blue')

and the color of the Square object s1 on the canvas turns to blue! And also:

>>> s1.color
'blue'

So the new color is recorded in the Square object s1.

Changing size: the set_size method

What if we want to change the square’s size? Let’s try something dumb:

>>> s1.size
50
>>> s1.size = 100
>>> s1.size
100

By now, you shouldn’t be surprised that this doesn’t change the size of the square on the canvas. To change the size of a shape on a canvas, we have to call a canvas method called coords on the handle of the shape:

>>> canvas.coords(s1.handle, x1, y1, x2, y2)

x1 and y1 are the (new) coordinates of the upper-left-hand corner. x2 and y2 are the (new) coordinates of the lower-right-hand corner. We need to compute x1, y1, x2, y2 given the square’s center coordinates and the new size value. We saw how to do this in the constructor (the __init__ method):

(x, y) = center
x1 = x - size/2
y1 = y - size/2
x2 = x + size/2
y2 = y + size/2

Let’s write a set_size method to change the size of a Square while keeping the center position fixed:

def set_size(self, size):
    """Change the size of this square."""
    (x, y) = self.center
    x1 = x - size/2
    y1 = y - size/2
    x2 = x + size/2
    y2 = y + size/2
    self.canvas.coords(self.handle, x1, y1, x2, y2)
    self.size = size

Note that we record the new size as self.size. Let’s call this method on square s1:

>>> s1.set_size(120)

Now s1 is 120 pixels on each side.

Changing size: the scale method

Let’s also write a scale method to change the size of a Square by some scaling factor (while keeping the center position fixed, of course):

def scale(self, scale_factor):
    """Scale the size of this square by a scaling factor."""
    self.set_size(self.size * scale_factor)

Now, if we do this to square s1:

>>> s1.scale(2.0)

the square will grow to twice its original size.

This method is particularly interesting, because we called another method of the same class to do all the real work. Since set_size already knows how to set the size of a Square to any particular size, there is no point in repeating that code. Instead, we simply calculate the desired size (which is trivial) and call the set_size method. This is the D.R.Y. principle in action again.

Re-use methods inside other methods when it will make the code more concise.

More methods!

Let’s add more methods! We want to be able to

  • move squares relative to their current position

  • move squares to some absolute position

  • lift squares to the top of the "stacking order"

  • delete squares

Moving squares

Canvases have a move method which can move a shape on a canvas given the handle of the shape. We would like to be able to move our squares around to new positions. Let’s define a move method which calls the canvas move method. Using the move method on squares will be much less tedious than using the move method on canvasses. Here’s our first try:

def move(self, x, y):
    """Move this square by (x, y)."""
    self.canvas.move(self.handle, x, y)

This will work, but it’s not enough. The square needs to know its location, which is stored in the center field of the Square object. We’ve changed the location, so we must also update the object’s center field.

def move(self, x, y):
    """Move this square by (x, y)."""
    self.canvas.move(self.handle, x, y)
    (cx, cy) = self.center
    self.center = (cx + x, cy + y)

If we call this method on square s1:

>>> s1.move(50, 150)

the square will move 50 pixels in the X (horizontal) direction and 150 pixels in the Y (vertical) direction.

Move to absolute location

The move method will only move a square relative to its current location. We might want to move a square to an absolute location on the canvas e.g. to point (350, 450). [4] Let’s define a method to do that. We’ll call it move_to.

The trick in defining this method is this: if we knew the difference between our current location and the target location, we could call the move method with those numbers (difference in X position, difference in Y position). As we mentioned above with the scale method, when you have some basic methods defined, you can often define other methods in terms of them. This is a good thing, because you need to write less code overall.

Here’s our first version:

def move_to(self, x, y):
    """Move this square to the location (x, y) on its canvas."""
    (cx, cy) = self.center
    dx = x - cx
    dy = y - cy
    self.canvas.move(self.handle, dx, dy)
    self.center = (x, y)

Note that we need to record the new (x, y) position in the center field of the square object. If we call this method like this:

>>> s1.move_to(200, 200)

then the square s1 is now centered on the point (200, 200) of the canvas.

We can simplify this code by calling the move method instead of self.canvas.move, leading to the final version:

def move_to(self, x, y):
    """Move this square to the location (x, y) on its canvas."""
    (cx, cy) = self.center
    dx = x - cx
    dy = y - cy
    self.move(dx, dy)

Change the stacking order

When two shapes on a canvas overlap, only one can be visible in the overlapping region. The canvas object keeps track of "what is above what". This is called the stacking order of the canvas. Shapes on top of the stacking order cannot be covered up by other shapes. Every shape on a canvas has a place in the stacking order (even shapes that have no other objects overlapping with them).

Sometimes, we want to manually "lift" a canvas shape to the top of the stacking order in order to make the entire shape visible. The canvas object defines a lift method to do this. Let’s define such a method for our Squares:

def lift(self):
    """Lift this square to the top of the canvas stacking order."""
    self.canvas.lift(self.handle)

That’s all it takes! Now we can do (for square s1):

>>> s1.lift()

and s1 will pop up above any other shapes that overlap it.

Deleting squares

Let’s finish with an easy method: delete. This method will remove a shape from a canvas. However, it will not remove it from the Python program! (We’ll see how to do that later.) Here’s the method:

def delete(self):
    """Remove the square from the canvas."""
    self.canvas.delete(self.handle)

That’s it! Now, when this method is called, the square disappears from the canvas. However, if we’re in the Python interpreter (for instance, because we used python -i to start the program), we can see that the Square object is still there:

>>> s1.delete()
[square disappears from canvas]
>>> s1
<__main__.Square instance at 0x109a11d30>

The Square object is still there! How do we make the object s1 go away? To delete s1 entirely we need to use the del operator:

>>> del s1
>>> s1
NameError: name 's1' is not defined

It would be nice if we could combine these two kinds of "deletions" into one method.

Python allows objects to define a special method called __del__ It is called whenever del is applied to an object (i.e. del s1 for the square s1). It allows us to do additional clean-up before the object is deleted. (In the object-oriented programming world, this kind of method is called a finalizer.) Let’s define it:

def __del__(self):
    """Delete this square."""
    self.delete()

That’s it! Now, when a square s1 is visible and we type del s1, not only will the square disappear from the canvas, but the object s1 will disappear from Python!

Some interesting points about this method:

  • We are calling one method (delete) from inside another method (__del__).

  • This method doesn’t replace what del used to do; it gets run just before del destroys the object.

Summing up

Now our Square objects are pretty functional. We can create them, move them, change their color, change their stacking order, change their size, and delete them. If we thought up some other functionality that we’d like to have (say, the ability to rotate them around their centers), we could define a method to do that too.

By making Squares into objects instead of just internal components of tkinter canvasses identified by integer handles, we have changed the crude and annoying-to-write interface of the canvas methods that manipulate shapes into something much more pleasant and intuitive. Later, we’ll take advantage of this to write programs where we will manipulate large numbers of shapes with a small amount of code.


[End of reading]


1. This isn’t quite true. When we discuss inheritance, you’ll see why.
2. It’s also possible to add extra methods and data attributes to a specific object of a class without adding it to the other objects of that class. This is sometimes done, but it’s rare. Python is very flexible.
3. Depending on your Python installation, you might have to type python3 -i.
4. "Absolute" here means "absolute in terms of the canvas coordinates", although, as we’ve mentioned, the canvas coordinates are themselves relative coordinates with respect to the entire computer display.