|
From The Well-Grounded Python Developer by Doug Farrell This article delves into using the OOP coding paradigm in the Python language. |
Take 40% off The Well-Grounded Python Developer by entering fccfarrell2 into the discount code box at checkout at manning.com.
Object-oriented
The ability to place functions into modules provides many opportunities for how you structure an API. The type and order of the parameters passed to the functions which make up an API offer possibilities to make your API more apparent and useful.
Breaking up functions to stay with the concept of single responsibility and manageable function length also makes it more likely your API consists of multiple functions. Users of the API pass data structures and state information into those functions, getting the updated structures and state modified returned by those functions, and pass them on to other functions to arrive at the final result.
Often the data structures passed between functions are collection objects, lists, sets, or dictionaries, for example. These objects are powerful, and taking advantage of what they offer is important in Python development.
By themselves, data structures don’t do anything, the functions they’re passed to know what to do with the data structures they receive as input.
Because everything in Python is an object, creating objects of your own is available to you using object-oriented programming. One of the goals of creating objects is to encapsulate data and the methods that act on that data into one thing. Conceptually you’re creating something having functionality you provided. Doing this creates something you can think about as an object or thing, having behavior. Creating classes is how you design these objects, assigning data, and functionality to them.
Class Definition
Python provides object-oriented programming by defining classes which can be instantiated into objects when needed. Instantiation is the act of taking something from a definition (the class) to reality. You could say the blueprint for a house is the class definition, and building the house instantiates it.
Here’s a simple class definition for a Person class from the CH_05/example_01.py
example code:
class Person: """Defines a person by name """ def __init__(self, fname: str, mname: str = None, lname: str = None): self.fname = fname self.mname = mname self.lname = lname def full_name(self) -> str: """This method returns the person's full name """ full_name = self.fname if self.mname is not None: full_name = f"{full_name} {self.mname}" if self.lname is not None: full_name = f"{full_name} {self.lname}" return full_name
This class definition creates a template to create a Person object instance containing a person’s first, middle, and last names. It also provides the full_name()
method to get the person’s full name based on the information passed to the object by its constructor, the __init__()
method.
This class can be represented visually in UML as well. UML (Unified Modeling Language) is a standardized way to visually present the design of systems. It’s unnecessary to use UML diagrams when designing and building a system, but they can be useful to introduce abstract concepts which are difficult to present as text. Here’s the UML diagram for the Person class:
The UML diagram for the Person class shows the name of the class, the attributes it contains, and the methods it provides.
The plus sign ‘+’ character in front of the attribute and method names indicates they’re public. In Python, attributes and methods of a class have no notion of public, protected, or private access.
Python’s class design is based on the idea “we’re all adults here,” and the developers who use your classes behave accordingly. Using plain attributes should be the default when designing your classes. You’ll see later how class properties can be used to gain control of how attributes are accessed and used.
A simple use case for the Person class is presented in the CH_05/example_01.py
application:
# Create some people people = [ Person("John", "George", "Smith"), Person("Bill", lname="Thompson"), Person("Sam", mname="Watson"), Person("Tom"), ] # Print out the full names of the people for person in people: print(person.full_name())
This code creates four instances of the Person class, each representing a different person and exercising all the variations of the constructor. The for loop iterates through the list of Person object instances and calls the full_name()
method of each.
Drawing With Class
The rest of the examples you’re going to create are object-oriented applications which animate some shapes on screen. Each example expands on the one previous to present the following concepts:
- Inheritance – parent/child relationships between classes
- Polymorphism – using an object as if it had multiple forms
- Composition – using composition instead of inheritance to give attributes and behavior to a class
To create the drawing application, you’ll be using the arcade module available in the Python Package Index (pypi.org). This module provides the framework to create a drawing surface on the computer screen and draw and animate those objects.
The first thing to do is to define a class for a rectangle to draw on the screen. Starting with the UML diagram for the Rectangle class:
The UML diagram shows the attributes encapsulated in the class necessary to render a rectangle on-screen. All of these attributes are initialized during the construction of a Rectangle object:
- x, y, width, height – define the position of the Rectangle on screen and the dimensions to use when drawing it
- pen_color, fill_color – define the colors used to outline the Rectangle and fill it
- dir_x, dir_y – the direction of movement relative to the screen x and y axes, these are either 1 or -1
- speed_x, speed_y – the speed at which the Rectangle is moving in pixels per update
- The diagram also includes the definition of three methods the class supports:
- set_pen_color() – provides a mechanism to set the pen color used to draw the rectangle instance object
- set_fill_color() – provides a mechanism to set the fill color used to fill a rectangle instance object
- draw() – draws a Rectangle object instance on the screen
This UML diagram can be turned into a Python class definition in code. Here’s the Rectangle class based on the above diagram from CH_05/example_02.py
:
class Rectangle: """This class defines a simple rectangle object """ def __init__( self, x: int, y: int, width: int, height: int, pen_color: tuple = COLOR_PALETTE[0], fill_color: tuple = COLOR_PALETTE[1], dir_x: int = 1, dir_y: int = 1, speed_x: int = 1, speed_y: int = 1 ): self.x = x self.y = y self.width = width self.height = height self.pen_color = pen_color self.fill_color = fill_color self.dir_x = 1 if dir_x > 0 else -1 self.dir_y = 1 if dir_y > 0 else -1 self.speed_x = speed_x self.speed_y = speed_y def set_pen_color(self, color: tuple) -> Rectangle: """Set the pen color of the rectangle Arguments: color {tuple} -- the color tuple to set the rectangle pen to Returns: Rectangle -- returns self for chaining """ self.pen_color = color return self def set_fill_color(self, color: tuple) -> Rectangle: """Set the fill color of the rectangle Arguments: color {tuple} -- the color tuple to set the rectangle fill to Returns: Rectangle -- returns self for chaining """ self.fill_color = color return self def draw(self): """Draw the rectangle based on the current state """ arcade.draw_xywh_rectangle_filled( self.x, self.y, self.width, self.height, self.fill_color ) arcade.draw_xywh_rectangle_outline( self.x, self.y, self.width, self.height, self.pen_color, 3 )
This class defines a simple Rectangle
object. The object is initialized with the lower-left corner of the object x, and y coordinates, the width and height, pen and fill colors the direction and speed of motion of the Rectangle. In the arcade module, the screen origin is in the lower-left corner, which is how most of us think about x and y axes on paper but are different than other screen rendering tools.
Modifying the values of the x and y attributes moves the Rectangle around the screen maintained by arcade and the instance of the Window class in the application. The Window class has two methods used to animate the objects on the screen; on_update()
and on_draw()
. The first updates the position of all the objects to present on the screen and the second draws those updated objects on the screen. The on_update()
method is called every refresh iteration and it’s where the application modifies the position of the rectangles that were appended to the self.rectangles
collection. The on_update()
method looks like this:
def on_update(self, delta_time): """Update the position of the rectangles in the display """ for Rectangle in self.rectangles: rectangle.x += rectangle.speed_x rectangle.y += rectangle.speed_y
This code iterates through the collection of rectangles and updates the position of each one by its x and y speed values, changing its position on the screen.
The updated rectangles are drawn on the screen by the Window instance method on_draw()
, which looks like this:
def on_draw(self): """Called whenever you need to draw your window """ # Clear the screen and start drawing arcade.start_render() # Draw the rectangles for Rectangle in self.rectangles: rectangle.draw()
Every time the on_draw()
method is called, the screen is cleared and the self.rectangles
collection is iterated through, and each Rectangle has its draw() method called.
The Rectangle class has behavior defined by the methods set_pen_color(),
set_fill_color()
and draw()
. These methods use and alter the data encapsulated by the class definition. They provide the API you interact with when using the class.
Look at the set_pen_color()
and set_fill_color()
methods and you’ll see they return self. Returning self can be useful to chain methods of the class together into a series of operations. Here’s an example from CH_05/example_02.py
using the Rectangle class. This shows the pen and fill colors being changed when the arcade schedule functionality calls this code every second:
def change_colors(self, interval): """This function is called once a second to change the colors of all the rectangles to a random selection from COLOR_PALETTE Arguments: interval {int} – interval passed in from the arcade schedule function """ for Rectangle in self.rectangles: rectangle.set_pen_color(choice(COLOR_PALETTE)).set_fill_color( choice(COLOR_PALETTE) )
The change_colors
() method of the Window instance is called by an arcade schedule function every second. It iterates through the collection of rectangles and calls the set_pen_color()
and set_fill_color
() in a chained manner to set random colors picked from the globally defined COLOR_PALETTE
list.
When the CH_05/example02.py
application is run, it creates a window on the screen and animates a vertically aligned rectangle upwards at a 45-degree angle. It also changes the pen and fill colors of the Rectangle every second the application runs. Here’s a screenshot of the running application:
Properties
As mentioned above, direct access to the attributes of a class should often be the default. The Rectangle example above follows this practice. In some situations you’ll want more control over how the attributes of a class are used or changed.
The definition of the Rectangle class includes attributes for the x and y origin of the Rectangle, which helps draw it in the window. That window has dimensions, and if you ran the CH_05/example_02.py
application long enough, you saw the Rectangle move off the screen.
Currently, the origin of a Rectangle instance is set to any integer value. No known screen has a resolution as large as the range of integer values, and none at all deal with negative numbers directly. The window declared in the application has a width of 600 and a height of 800 in pixels.
Where rectangle objects can be drawn should be constrained to within the boundaries of the screen dimensions. Constraining the values of x and y means having functionality in place to limit the values they can be assigned. Your goal is to make the rectangle bounce around within the screen window.
If you’ve come from other languages supporting object-oriented programming, you might be familiar with getters and setters. These are methods provided by the developer to control access to attributes of a class instance. Those methods also give the developer a place to insert behavior when the attributes are retrieved or modified.
Adding getter and setter methods to the Rectangle x and y attributes could be done by defining methods like this:
def get_x(self): def set_x(self, value): def get_y(self): def set_y(self, value):
Using these getter and setter functions also means changing the example code from this:
rectangle.x += 1
rectangle.y += 1
to this:
rectangle.set_x(rectangle.get_x() + 1) rectangle.set_y(rectangle.get_y() + 1)
In my opinion, the changes to use the getters and setters work but reduces the readability of the code significantly from the direct attribute access version.
By using Python property decorators, you can control how class attributes are accessed and modified while still using the direct attribute access syntax. The Rectangle class can be modified to use property decorators offering this behavior. The updated portion of the Rectangle class from example program CH_05/example_03.py
is shown below:
class Rectangle: """This class defines a simple rectangle object """ def __init__( self, x: int, y: int, width: int, height: int, pen_color: str = "BLACK", fill_color: str = "BLUE", ): self._x = x self._y = y self.width = width self.height = height self.pen_color = pen_color self.fill_color = fill_color @property def x(self): return self._x @x.setter def x(self, value: int): """Limit the self._x to within the screen dimensions Arguments: value {int} -- the value to set x to """ if self._x + value < 0: self._x = 0 elif self._x + self._width + value > Screen.max_x: self._x = Screen.max_x - self._width else: self._x = value @property def y(self): return self._y @y.setter def y(self, value): """Limit the self._y to within the screen dimensions Arguments: value {int} -- the value to set y to """ if self._y + value < 0: self._y = 0 elif self._y + self._height + value > Screen.max_y: self._y = Screen.max_y - self._height else: self._y = value
The first thing to notice is the attributes x and y are prefixed with a single underbar ‘_’ character. Using the underbar this way is a convention to indicate the attribute should be considered private and not accessed directly, but it doesn’t enforce any notion of a private attribute.
The second thing to notice is the new decorated methods in the class. For example the two new methods for accessing the self._x
attribute are:
@property def x(self): return self._x @x.setter def x(self, value): """Limit the self._x to within the screen dimensions Arguments: value {int} -- the value to set x to """ if not (0 < value < SCREEN_WIDTH - self.width): self.dir_x = -self.dir_x self._x += abs(self._x - value) * self.dir_x
The @property
decorator over the first def x(self)
function defines the getter functionality, in this case, returning the value of self._x
.
The @x.setter
decorator over the second def x(self, value)
function defines the setter functionality. Inside the function self._x
is constrained to within the screen x-axis maximum dimension. If setting the value of self._x
places any part of the Rectangle outside the screen area, the direction of travel is negated to start it moving in the opposite direction.
Having the above-decorated methods in the Rectangle
class means code like this works again:
rectangle.x += 1
The program statement appears to be setting the rectangle instance x attribute directly, but the decorated methods above are called instead. The += operation calls the getter method to retrieve the current value of self._x
, adds one to that value, and uses the setter method to set self._x
to that new value. If the resulting change places the Rectangle outside of the screen dimensions, the direction of travel along the x-axis is reversed.
The beautiful part of this is you can define your classes using direct attribute access initially. If it becomes necessary to constrain access to an attribute, you can define getter and setter property methods. Existing code using your class doesn’t have to change at all. From the point of view of the caller, the API of the class is the same.
Take note of another feature of using setter and getter decorated methods. You don’t need to create both setter and getter decorated functions on attributes. You can create only a getter, which creates a read-only attribute. Likewise, you can create only a setter creating a write-only attribute.
The rarely used @deleter
decorator can be used to delete an attribute.
Composition
In the inheritance section, you saw the relationships between the Rectangle
, Square
, Circle,
and Shape
classes. These relationships allowed the child classes to inherit attributes and behavior from their parent class. This creates the idea that a Rectangle
IS-A Shape
, and a Square
IS-A Rectangle
, which also means it IS-A Shape
as well.
These relationships also imply a certain similarity between attributes and behaviors of the parent classes and the child classes which inherit from them, but this isn’t the only way to include attributes and behavior into classes.
Look at the Shape
class, it has two attributes for pen and fill color. These two attributes provide color to the shape and are distinguished from each other by their names, but they offer the same thing, a color, most likely from a palette of colors the system can create. This means the color is a common attribute within the Shape
itself and expressed twice.
It’s possible with inheritance to handle this and add to the hierarchy in the examples by creating a Color
class having pen and fill color attributes and having the Shape
class inherit from it.
Doing this works, but the inheritance feels awkward. You can make a Shape
have an IS-A relationship to a Color
class in code, but logically it doesn’t make sense. A shape is not a color, and it doesn’t fit the IS-A model of an inheritance structure.
Instead of trying to force inheritance to provide the desired behavior, you can use composition. You’ve already been using composition when giving class attributes which are integers and strings. You can take this further and create custom classes to be used as attributes, composing behavior into your classes.
Creating a new class Color
provides a consistent abstraction for color in the application. It has a class-level definition for the colors supported and has a mechanism only to allow defined colors to be set. The UML diagram showing the addition of a Color class to the hierarchy structure looks like this:
The Color class is connected to the Shape class as a composite, indicated in the diagram above by the connecting line with the filled black diamond symbol. Here’s what the Color
class looks like from the CH_05/example_06.py
application program:
@dataclass class Color: """This class defines a color and it's methods """ PALETTE = [ arcade.color.BLACK, arcade.color.LIGHT_GRAY, arcade.color.LIGHT_CRIMSON, arcade.color.LIGHT_BLUE, arcade.color.LIGHT_CORAL, arcade.color.LIGHT_CYAN, arcade.color.LIGHT_GREEN, arcade.color.LIGHT_YELLOW, arcade.color.LIGHT_PASTEL_PURPLE, arcade.color.LIGHT_SALMON, arcade.color.LIGHT_TAUPE, arcade.color.LIGHT_SLATE_GRAY, ] color: tuple = PALETTE[0] _color: tuple = field(init=False) @property def color(self) -> tuple: return self._color @color.setter def color(self, value: tuple) -> None: """Sets the color in the class Arguments: value {tuple} -- the color tuple from COLOR_PALETTE to set """ if value in Color.PALETTE: self._color = value
The Color
class moves the allowable color list within the scope of the class and out of the global namespace. It’s also a Python dataclass, which can make defining simple classes that are mostly data easier to implement. The class provides getter and setter property decorators to make using the color within the class more straightforward.
In order to use the Color
class the Shape
class is modified to use it for the pen and fill color attributes. The __init__()
constructor for the class is shown below:
class Shape: """This class defines generic shape object """ def __init__( self, x: int, y: int, width: int, height: int, pen: Color = Color(), fill: Color = Color(), dir_x: int = 1, dir_y: int = 1, speed_x: int = 1, speed_y: int = 1, ): self._x = x self._y = y self.width = width self.height = height self.pen = Color(Color.PALETTE[0]) self.fill = Color(Color.PALETTE[1]) self.dir_x = 1 if dir_x > 0 else -1 self.dir_y = 1 if dir_y > 0 else -1 self.speed_x = speed_x self.speed_y = speed_y
The attribute names for pen and fill color have been simplified to pen and color because they’re both Color
class instances. The initial default values have been set to black for the pen and light gray for the fill colors. Adding the Colo
r class to the Shape
class this way creates a HAS-A relationship; a Shape
has Color
attributes but it isn’t a Color.
The set_pen_color()
and set_fill_color()
methods have also been modified to use the new pen and fill attributes. Setting a color for the pen now looks like this:
def set_pen_color(self, color: tuple) -> Rectangle: """Set the pen color of the Rectangle Arguments: color {tuple} -- the color tuple to set the rectangle pen to Returns: Rectangle -- returns self for chaining """ self.pen.color = color return self
Running the CH_05/example_06.py
application produces a screen exactly like you’ve seen before, three shapes bouncing around the window and changing colors every second.
The use of composition gives you a way to add attributes and behavior to a class without having to create contrived hierarchies of inheritance.
That’s all for this article. If you want to learn more about the book, check it out on our browser-based liveBook platform here.