Treat objects as objects
Standard approach in Object-Oriented Analysis and Programming: identify objects in the problem, and then model their data and behaviours. However, it isn’t always as easy.
You can proceed like that:
- store data in a few variables;
- if you will pass the same set of related variables to a set of function, you can think about grouping both variables and functions into a class.
Example: start to think of a polygon as a set of points. To compute the perimeter, you need to compute the distance between each point:
from __future__ import annotations
from math import hypot
from typing import Tuple, List
Point = Tuple[float, float]
def distance(p_1: Point, p_2: Point) -> float:
return hypot(p_1[0]-p_2[0], p_1[1]-p_2[1])
square = [(1,1), (1,2), (2,2), (2,1)]
Polygon = List[Point]
def perimeter(polygon: Polygon) -> float:
pairs = zip(polygon, polygon[1:]+polygon[:1])
return sum(distance(p1,p2) for p1, p2 in pairs)
perimeter(square)Output:
4.0
(I honestly didn’t understand the effect of from __future__ import annotations in the above example)
Looking at the code above, we notice that
- a new
Polygonclass could encapsulate the list of points (data) and theperimeterfunction (behaviour); - a new
Pointclass could encapsulate the x and y coordinates (data) and thedistancefunction (behaviour):
from math import hypot
from __future__ import annotations
class Point:
def __init__(self, x, y) -> None:
self.x = x
self.y = y
def distance(self, other: Point) -> float:
return hypot(self.x - other.x, self.y - other.y)
class Polygon:
def __init__(self) -> None:
self.vertices: List[Point] = []
def add_point(self, point: Point) -> None:
self.vertices.append(point)
def perimeter(self) -> float:
pairs = zip(self.vertices, self.vertices[1:] + self.vertices[:1])
return sum(p1.distance(p2) for p1, p2 in pairs)
square = Polygon()
square.add_point(Point(1,1))
square.add_point(Point(1,2))
square.add_point(Point(2,2))
square.add_point(Point(2,1))
square.perimeter()Output:
4.0(Now, it makes sense to use from __future__ import annotations because in the distance method I’m using Point class that is not already defined).
Although the latter OOP code appears longer and less compact than the former, it is much more readable and easy to understand because the relationship among the objects are more clearly defined by hints. Code length is not a goof indicator of code complexity.
Adding behaviours to class with properties
(I feel that topics like setters, getters, an properties are not clearly explained in the book, so I preferred to write two separate note articles about these topics: Setters and Getters and Properties).
Deciding when to use properties
The built-in property blurs the division between behaviour and data, so it can be confusing to understand when to choose an attribute, or a method, or a property. Some suggestions:
- use methods to represent actions. A method should do something. Method names are generally verbs.
- use attributes or properties to represent the state of the object:
- use ordinary attribute, initialized in the
__init__()method, to immediately provide that attribute to an instance; - use properties when there is a computation involved with setting or getting. Examples include data validation, logging, cache management.
- use ordinary attribute, initialized in the
Let’s look at a realistic example: caching a value that is difficult or expensive to calculate. Indeed, the goal is to store locally this value to avoid repeated expensive calls. We’ll do this with a custom getter on the property.
from urllib.request import urlopen
from typing import Optional, cast
class WebPage:
def __init__(self, url: str) -> None:
self.url = url
self._content = Optional[bytes] = None
@property
def content(self) -> bytes:
if self._content is not None:
print("Retrieving New Page...")
with urlopen(self.url) as response:
self._content = response.read()
return self._contentWith this property we’ll only read the website content once because self._content is initially empty. Now self._content is no more empty so it’s not re-computed, so it will not be recomputed after the first call. Let’s verify this:
import time
webpage = WebPage("http://ccphillips.net/")
now = time.perf_counter()
content1 = webpage.content
first_fetch = time.perf_counter() - now
now = time.perf_counter()
content2 = webpage.content
second_fetch = time.perf_counter() - now
assert content2 == content1
print(f"First fetch: {first_fetch:.5}")
print(f"Second fetch: {second_fetch:.5}")Output:
Retrieving New Page...
First fetch: 0.27059
Second fetch: 3.325e-05
Another example of another custom getter: calculate some attributes based on other object attributes:
from typing import List
class AverageList(List[int]):
@property
def average(self) -> float:
return sum(self) / len(self)
aList = AverageList([10, 5, 2])
aList.average # Output: 5.666666666666667