This series of posts is for those who have used a programming language before but are not familiar with Python. The first six posts introduced the language; the seventh discussed how to download, install, and start using Python.
The last post began an exploration of user-defined data type (aka classes). This post picks up where we left off.
When we last saw our 3D Point class, we’d given its instance objects the following:
- The data attributes
x,y, andz. - The ability to initialize the data attributes when creating new instances.
- An “informal” print string (accessible via the built-in
strfunction). - A representation or debug string (accessible via the built-in
reprfunction). - A
True/Falsevalue (accessible via the built-inboolfunction). - Iteration over the three data attributes (accessible via the built-in
iterfunction). - Implementing the addition operators (
+and+=) for point objects.
Line item #6 lets us treat Point objects like list objects:
002|
003| pt = Point(3, 1, 4)
004|
005| print(f’Point: {pt}‘)
006| print()
007|
008| print(‘for-loop:’)
009| for ix,elem in enumerate(pt):
010| print(f’point[{ix}] = {elem}‘)
011| print()
012|
013| print(f’list: {list(pt)}‘)
014| print(f’tuple: {tuple(pt)}‘)
015| print()
016|
017| print(f’iter: {list(iter(pt))}‘)
018| print(f’enumerate: {list(enumerate(pt))}‘)
019| print(f’sorted: {list(sorted(pt))}‘)
020| print()
021|
022| print(f’any: {any(pt)}‘)
023| print(f’all: {all(pt)}‘)
024| print(f’min: {min(pt)}‘)
025| print(f’max: {max(pt)}‘)
026| print(f’sum: {sum(pt)}‘)
027| print()
028|
When run, this prints:
Point: [3.000, 1.000, 4.000] for-loop: point[0] = 3.0 point[1] = 1.0 point[2] = 4.0 list: [3.0, 1.0, 4.0] tuple: (3.0, 1.0, 4.0) iter: [3.0, 1.0, 4.0] enumerate: [(0, 3.0), (1, 1.0), (2, 4.0)] sorted: [1.0, 3.0, 4.0] any: True all: True min: 1.0 max: 4.0 sum: 8.0
We can use Point objects in the above contexts because we defined the dunder iter method in our Point class (see last post).
Line #1 imports the Point definition from the examples module (which is a Python file named examples.py in the local directory). This makes the Point class available to the code below.
Line #3 defines a new Point object with values; line #5 prints its “informal” value.
Lines #9 and #10 implement a for loop — which invokes a list context: a list-like object is expected. In this case one that iterates over the elements (x, y, and z). The enumerate function (see below) gives us index numbers (0, 1, and 2).
Lines #13 and #14 use the list and tuple constructors to create a list and a tuple respectively. Both constructors expect a list-like object. [See part 2 and part 3 to review list-like objects.]
Lines #17 to #20 demonstrate the built-in functions iter, enumerate, and sorted. Of note is that these return special list-like objects that are not exactly lists [see part 4 for more on these functions]. They are usually used in for loops. Here we pass them to the list constructor to make them actual list objects.
Lines #22 to #26 demonstrate the built-in functions any, all, min, max, and sum (see the Built-in Functions documentation for the full list). The first two are logical functions that return True if, respectively, any items or all items of the input list evaluate as true. The last three are basic math functions that return, respectively, the minimum, maximum, and sum of a list. These require the list contain numeric items, of course. Our iterator returns the x, y, and z elements, which are numeric, so we can use these functions without error.
So, giving the Point class a dunder iter method allowed our Point objects to be list-like in many contexts. Generally, we can pass these objects to any function expecting something list-like.
But not all:
002|
003| pt = Point(3, 1, 4)
004|
005| print(f’Point: {pt}‘)
006| print()
007|
008| print(‘Try indexing:’)
009| try:
010| print(f’[0] {pt[0]}‘)
011| print(f’[1] {pt[1]}‘)
012| print(f’[2] {pt[2]}‘)
013|
014| except Exception as e:
015| print(f’OOPS: {type(e).__name__}‘)
016| print(e)
017| print()
018|
019| print(‘Try getting the length:’)
020| try:
021| print(f’length: {len(pt)}‘)
022|
023| except Exception as e:
024| print(f’OOPS: {type(e).__name__}‘)
025| print(e)
026| print()
027|
028| print(‘Try reversing:’)
029| try:
030| print(f’reversed: {list(reversed(pt))}‘)
031|
032| except Exception as e:
033| print(f’OOPS: {type(e).__name__}‘)
034| print(e)
035| print()
036|
When run, this prints:
Point: [3.000, 1.000, 4.000] Try indexing: OOPS: TypeError 'Point' object is not subscriptable Try getting the length: OOPS: TypeError object of type 'Point' has no len() Try reversing: OOPS: TypeError 'Point' object is not reversible
So, our Point objects can’t be indexed (lines #10 to #12), and we can’t even get their length (line #21).
Lastly, the built-in reversed function (line #30) doesn’t work even though the seemingly similar enumerate and sorted functions did.
Let’s fix this by adding the appropriate dunder methods for the class:
002|
003| class Point:
004| ”’A 3D XYZ Point class.”’
005|
006| …
007|
008| def __iter__ (self):
009| ”’Return an iterator over the elements.”’
010| return iter([self.x, self.y, self.z])
011|
012| def __len__ (self):
013| ”’Return length (always 3).”’
014| return 3
015|
016| def __getitem__ (self, element_index):
017| ”’Return an element by index (x=0, y=1, z=2).”’
018| if element_index == 0: return self.x
019| if element_index == 1: return self.y
020| if element_index == 2: return self.z
021|
022| # Invalid type…
023| raise IndexError(f”Invalid index: {element_index}“)
024|
025| def __setitem__ (self, element_index, new_value):
026| ”’Set an element by index (x=0, y=1, z=2).”’
027| if element_index == 0: self.x = float(new_value); return
028| if element_index == 1: self.y = float(new_value); return
029| if element_index == 2: self.z = float(new_value); return
030|
031| # Invalid type…
032| raise IndexError(f”Invalid index: {element_index}“)
033|
034| def __delitem__ (self, element_index):
035| ”’Delete an element by index (not allowed).”’
036| raise NotImplementedError(f”Point elements may not be deleted!“)
037|
038| …
039|
The above is a code fragment with only the new class methods shown. We defined dunder iter in the last post but include it here because it’s so closely related to the new methods.
Lines #12 to #14 define the dunder len method (“length”). The built-in len function calls this method to obtain the object’s length. Python calls it internally when it needs to know a list-like object’s length.
Lines #16 to #23 define the dunder getitem method. Python calls this when we use the index notation — object[index] — to access a list item.
Lines #25 to #32 define the dunder setitem method. Python calls this when we use the index notation to change a list item (e.g. object[index] = new_value).
Lines #34 to #36 define the dunder delitem method. Python calls this when we use the del statement and index notation to delete a list item (e.g. del object[index]).
Now we have a more fully featured list-like object:
002|
003| pt = Point(3, 1, 4)
004|
005| print(f’Point: {pt}‘)
006| print()
007|
008| print(f’length: {len(pt)}‘)
009| print()
010|
011| print(‘Getting values by index:’)
012| try:
013| print(f’[0] {pt[0]}‘)
014| print(f’[1] {pt[1]}‘)
015| print(f’[2] {pt[2]}‘)
016| print(f’[3] {pt[3]}‘)
017|
018| except Exception as e:
019| print(f’OOPS: {type(e).__name__}‘)
020| print(e)
021| print()
022|
023| print(‘Changing values by index:’)
024| try:
025| pt[0] = 2
026| print(f’[0] {pt[0]}‘)
027| pt[1] = 7
028| print(f’[1] {pt[1]}‘)
029| pt[2] = 1
030| print(f’[2] {pt[2]}‘)
031| pt[3] = 8
032| print(f’[3] {pt[3]}‘)
033|
034| except Exception as e:
035| print(f’OOPS: {type(e).__name__}‘)
036| print(e)
037| print()
038|
039| print(f’reversed: {list(reversed(pt))}‘)
040| print()
041|
042| print(‘Try deleting:’)
043| try:
044| # Try to make it a 2D point…
045| del pt[2]
046|
047| except Exception as e:
048| print(f’OOPS: {type(e).__name__}‘)
049| print(e)
050| print()
051|
When run, this prints:
Point: [3.000, 1.000, 4.000] length: 3 Getting values by index: [0] 3.0 [1] 1.0 [2] 4.0 OOPS: IndexError Invalid index: 3 Changing values by index: [0] 2.0 [1] 7.0 [2] 1.0 OOPS: IndexError Invalid index: 3 reversed: [1.0, 7.0, 2.0] Try deleting: OOPS: NotImplementedError Point elements may not be deleted!
Note that lines #25, #27, #29, and #31 invoke the dunder setitem method to change the value while lines #26, #28, #30, and #32 (as well as lines #13 to #16) invoke the dunder getitem method to access it.
Note also how we control the Exception types and content. This allows including relevant information in the content as we did with the index errors.
As an aside, let’s dig a little deeper into the reversed function:
002|
003| pt = Point(2.11, 4.22, 6.33)
004|
005| print(f’Point: {pt}‘)
006| print()
007|
008| rv = reversed(pt)
009| print(f’reversed(pt) returned a {type(rv)}‘)
010| print(rv)
011| print()
012|
013| try:
014| print(f’length: {len(rv)}‘)
015|
016| except Exception as e:
017| print(f’OOPS: {type(e).__name__}‘)
018| print(e)
019| print()
020|
021| try:
022| print(f’index: {rv[1]}‘)
023|
024| except Exception as e:
025| print(f’OOPS: {type(e).__name__}‘)
026| print(e)
027| print()
028|
029| print(f’make it a list: {list(rv)=}‘)
030| print(f’try that again: {list(rv)=} ??‘)
031| print()
032|
033| print(‘for-loop:’)
034| for elem in reversed(pt):
035| print(elem)
036| print()
037|
Line #3 creates a new Point object as usual. Line #8 passes it to the reversed function and stores the returned object in the variable rv. Line #9 uses the type function to give us that returned object’s data type. Line #10 prints the object.
Line #14 tries to get its length, and line #22 attempts to index a list item. Then, lines #29 and #30 both convert the object to a list.
Lines #34 and #35 exercise the reversed function in a for loop. Note this results in a new reversed object for the loop.
When run, this prints:
Point: [2.110, 4.220, 6.330] reversed(pt) returned a <class 'reversed'> <reversed object at 0x000002CDD8D43A00> OOPS: TypeError object of type 'reversed' has no len() OOPS: TypeError 'reversed' object is not subscriptable make it a list: list(rv)=[6.33, 4.22, 2.11] try that again: list(rv)=[] ?? for-loop: 6.33 4.22 2.11
The reversed function, as with many built-in functions that return lists, actually returns an iterator object that isn’t a list, but which acts like one in most list contexts. It’s a kind of proxy object that hands us list items when asked. Importantly, it never contains the list items — it has no length. It can only return the next list item or signal the end of the list.
This is why the type function (from line #9) tells us the returned object is a <class ‘reversed’> — an instance of some class named “reversed” — and printing the object gives us the default Python object representation string. Clearly this is not a list. We can’t use index notation (the rv[1] on line #22 raises an Exception), and it has no length (the len(rv) on line # 14 also raises an Exception).
We can make it a list, as we do on line #29, but when we try that a second time, we get an empty list. Iterator objects provide only one trip through their list. Asked beyond that, we get nothing (or a StopIteration Exception depending on context).
In this regard, the reversed function is indeed similar to other functions such as sorted, enumerate, and many others. They all return some type of iterator rather than an actual list. The reason is simple: there’s no reason to duplicate a list in the iterator. Better to have it merely traverse the list.
The difference can be substantial:
002| from sys import getsizeof
003|
004| # Create a big list…
005| big_list = list(range(1_000_000))
006|
007| print(f’list length: {len(big_list):,} items‘)
008| print(f’list size: {getsizeof(big_list):,} bytes‘)
009| print()
010|
011| print(f’first 3 list items: {big_list[:3]}‘)
012| print(f’last 3 list items: {big_list[–3:]}‘)
013| print()
014|
015| # Get a big list iterator…
016| list_iter = iter(big_list)
017|
018| print(f’iterator size: {getsizeof(list_iter):,} bytes‘)
019| print()
020|
021| print(f’iter item: {next(list_iter)}‘)
022| print(f’iter item: {next(list_iter)}‘)
023| print(f’iter item: {next(list_iter)}‘)
024| print(‘…’)
025| print()
026|
Note that we import the getsizeof function from the sys module (line #2) so we can determine how much memory our objects require.
Line #5 creates a list of one million numbers, starting at zero. The built-in range function is one of those functions that returns an iterator, so we pass its returned value to list to be converted to an actual list.
Line #16 gets an iterator for big_list using the built-in iter function.
When run, this prints:
list length: 1,000,000 items list size: 8,000,056 bytes first 3 list items: [0, 1, 2] last 3 list items: [999997, 999998, 999999] iterator size: 48 bytes iter item: 0 iter item: 1 iter item: 2 ...
The list requires over eight million bytes. Its iterator only 48. (The range object is also 48 bytes.)
The reversed function differs from these other functions in three ways:
Firstly, for reversed to work an object must provide dunder len and dunder getitem. As we saw, this differs from enumerate and sorted which require only dunder iter. Any function that iterates forwards only needs an iterator from the list-like object. But reversed iterates backwards, so it needs to know the length and how to access the list items in reverse sequence — which it can only do by indexing them.
Secondly, there is an alternate way to get a reversed version of a list. Recall that the index notation allows three parameters: start, stop, and step. Since list[:] defaults to the entire list, list[::-1] gives us a reversed copy of the list:
002|
003| # Strings are lists, so…
004| message = “Hello, World!”
005|
006| print(f’forward: {message}‘)
007| print(f’reverse: {message[::–1]}‘)
008| print()
009|
010| items = [1, 2, 3, ‘a’, ‘b’, ‘c’]
011|
012| print(f’forward: {items}‘)
013| print(f’reverse: {items[::–1]}‘)
014| print()
015|
When run, this prints:
forward: Hello, World! reverse: !dlroW ,olleH forward: [1, 2, 3, 'a', 'b', 'c'] reverse: ['c', 'b', 'a', 3, 2, 1]
Our Point class makes no allowances for this notation. We assume indexing only in the form pt[n] where n must be 0, 1 or 2. We could modify our dunder getitem and dunder setitem methods to handle this if we wanted, but it doesn’t make sense for our simple example class. The key point is that we can make our class as sophisticated (or not) as a given project requires.
Thirdly, there is a dunder reversed special method that lets us control exactly what reversed returns:
002|
003| class Point:
004| ”’A 3D XYZ Point class.”’
005|
006| …
007|
008| def __reversed__ (self):
009| ”’Return a reversed generator.”’
010| def generator ():
011| ”’Generator function with reversed elements.”’
012| yield self.z
013| yield self.y
014| yield self.x
015|
016| # Return a new generator…
017| return generator()
018|
019| …
020|
If implemented, reversed invokes this method, otherwise it defaults to using dunder len and dunder getitem. If none of these are provided (or even just one of the latter two), the reversed doesn’t work.
Lines #8 to #17 define our dunder reversed. It’s a method (function) containing a function named generator (lines #10 to #14). Line #17 calls generator and returns what it returns.
The generator function is special because it contains the yield statement. It is this statement that makes the function a generator. The yield statement is similar to the return statement, but it doesn’t exit the function the way return does. Instead, yield provides a value and then sleeps until called again to return another value.
As implemented here, the generator yields three values, the point elements z, y, and x, in that order. As with any iterator object, the built-in next function manually causes a generator to yield items. In list contexts, generators act like any iterator, providing list items one by one.
Generators are an advanced topic for later. This is just an introduction. [See Python Generators, part 1, part 2, and part 3, if you want to dig into the details now.]
Note that we can return an actual list:
002|
003| class Point:
004| ”’A 3D XYZ Point class.”’
005|
006| …
007|
008| def __reversed__ (self):
009| ”’Return a reversed list.”’
010| return [self.z, self.y, self.x]
011|
012| …
013|
If we test it with:
002|
003| pt = Point(2.11, 4.22, 6.33)
004|
005| print(f’Point: {pt}‘)
006| print()
007|
008| rv = reversed(pt)
009| print(f’reversed(pt) returned a {type(rv)}‘)
010| print(rv)
011| print()
012|
013| print(f’length: {len(rv)=}‘)
014| print()
015|
016| print(f’index: {rv[0]=}‘)
017| print(f’index: {rv[1]=}‘)
018| print(f’index: {rv[2]=}‘)
019| print()
020|
021| print(f’make it a list: {list(rv)=}‘)
022| print(f’try that again: {list(rv)=} !!‘)
023| print()
024|
025| print(‘for-loop:’)
026| for elem in reversed(pt):
027| print(elem)
028| print()
029|
When run, this prints:
Point: [2.110, 4.220, 6.330] reversed(pt) returned a <class 'list'> [6.33, 4.22, 2.11] length: len(rv)=3 index: rv[0]=6.33 index: rv[1]=4.22 index: rv[2]=2.11 make it a list: list(rv)=[6.33, 4.22, 2.11] try that again: list(rv)=[6.33, 4.22, 2.11] !! for-loop: 6.33 4.22 2.11
This works, but we’re returning an actual list, so reversed doesn’t work quite as usual and doesn’t follow expectations (which, in coding, is bad, m’kay).
It would be better to do it like this:
002|
003| class Point:
004| ”’A 3D XYZ Point class.”’
005|
006| …
007|
008| def __reversed__ (self):
009| ”’Return a reversed list.”’
010| return iter([self.z, self.y, self.x])
011|
012| …
013|
Which returns an iterator as expected (and if very like how we implemented the dunder iter method last week). However, the first method above (with yield) is cleaner and doesn’t require the intermediate list or the call to the iter function.
The bottom line of this digression is that reversed is a bit special compared to other seemingly similar built-in functions. If an object has a dunder reversed method, then the reversed function calls it and returns whatever it returns. Failing that, if the object has both the dunder len and dunder getitem methods, then the reversed function uses those internally something like this:
002| ”’Simple example implementation of reversed.”’
003|
004| # Get number of items in list…
005| index = list_like_object.__len__()
006|
007| # While items are in list…
008| while 0 < index:
009|
010| # Decrement the index…
011| index -= 1
012|
013| # Get the indexed item…
014| item = list_like_object.__getitem__(index)
015|
016| # Yield the item
017| yield item
018|
019|
020| items = list(range(12))
021|
022| rv = the_reversed_function(items)
023| print(f’{rv}‘)
024| print(f’{type(rv)=}‘)
025| print(f’{list(rv)=}‘)
026| print(f’{list(rv)=}‘)
027| print()
028|
Note the use of the yield statement, which makes the function a generator.
When run, this prints:
<generator object the_reversed_function at 0x0000018C5B88A4D0> type(rv)=<class 'generator'> list(rv)=[11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0] list(rv)=[]
Which is essentially the behavior we saw above for a proper reversed object.
That was a deeper dive than I intended. Hopefully it provided some insight to how Python works with objects.
We ended last time by giving our Point class the ability to handle the + and += operators. This allowed us to add and add-to Point objects in three basic cases:
Point₃ = Point₁ + Object₂ Point₁ += Object₂ Point₃ = Object₂ + Point₁
In the first two cases, Object₂ may or may not be a Point object. Dispatching the addition to the Point dunder methods depends only on Point₁. In the third case, Object₂ is definitely not a Point and doesn’t now know how to add one. In this case, Python dispatches the addition to the Point. (If that also fails, Python raises an Exception.)
Note that these three forms extend to other operators, such as minus, multiply, divide, and many more (see Operators).
These points apply to any numeric data type we implement. Particular to our Point design, however, is the issue of member-wise addition versus single-value (aka scalar) addition. There are two possibilities. Firstly, adding two points:
Secondly, adding a Point to a scalar value:
We’d like our Point objects to handle both, so here’s a more sophisticated version of the three dunder add methods:
002|
003| class Point:
004| ”’A 3D XYZ Point class.”’
005|
006| …
007|
008| def __add__ (self, other):
009| ”’Add two points or scalar, return sum in new Point.”’
010|
011| # A scalar adds to each member…
012| if isinstance(other,int) or isinstance(other,float):
013| x = self.x + other
014| y = self.y + other
015| z = self.z + other
016| return Point(x, y, z)
017|
018| # A vector adds memberwise…
019| if isinstance(other, type(self)):
020| x = self.x + other.x
021| y = self.y + other.y
022| z = self.z + other.z
023| return Point(x, y, z)
024|
025| # Invalid type…
026| raise ValueError(f”Can’t add a {typename(other)}!“)
027|
028| def __iadd__ (self, other):
029| ”’Add another point or scalar to self; return self.”’
030|
031| # A scalar adds to each member…
032| if isinstance(other,int) or isinstance(other,float):
033| self.x += other
034| self.y += other
035| self.z += other
036| return self
037|
038| # A vector adds memberwise…
039| if isinstance(other, type(self)):
040| self.x += other.x
041| self.y += other.y
042| self.z += other.z
043| return self
044|
045| # Invalid type…
046| raise ValueError(f”Can’t add a {typename(other)}!“)
047|
048| def __radd__ (self, other):
049| ”’Add scalar number to self; return new Point.”’
050|
051| # Only called when X + Point, so X is never a Point…
052| if isinstance(other,int) or isinstance(other,float):
053| return self.__add__(other)
054|
055| # Invalid type…
056| raise ValueError(f”Can’t add a {typename(other)}!“)
057|
058| …
059|
This implementation of dunder add (lines #8 to #26) checks to see if other is an integer or float object (line #12) and, if so, adds that single value to x, y, and z (lines #13 to #15) and uses the new values to create and return a new Point object (line #16). Failing the first check, it checks to see if other is a Point object (line #19). If so, it does a member-wise addition (lines #20 to #22) and, as above, uses the sums to create and return a new Point object.
Failing both checks, other isn’t an expected data type, so we fall to line #26 and raise ValueError.
The dunder iadd (lines #28 to #46) is identical in structure but we apply the sums to self and return it.
The dunder radd only gets called when the left-hand object is not a Point and doesn’t know how to add one. In fact, it raises an Exception (probably ValueError), which Python catches and tries again with the right-hand object. If that also raises an Exception, Python considers the add a failure and re-raises the Exception.
Note that we could just call dunder add:
002|
003| class Point:
004| ”’A 3D XYZ Point class.”’
005|
006| …
007|
008| def __radd__ (self, other):
009| ”’Add scalar number to self; return new Point.”’
010|
011| return self.__add__(other)
012|
013| …
014|
Rather than do the check for integer or float objects here. The dunder add method does the same check, so this works fine.
Because addition commutes, we can even do this:
002|
003| class Point:
004| ”’A 3D XYZ Point class.”’
005|
006| …
007|
008| def __add__ (self, other):
009| …
010|
011| __radd__ = __add__
012|
013| …
014|
Which makes dunder radd an alias for dunder add.
Important: note that subtraction and division do not commute, so dunder rmul and dunder rtruediv need thicker implementations. (Likewise, some of the other operators with right-hand dunder methods. See the Emulating numeric types documentation for details.)
That’s enough for now. The ZIP file below includes a full example of the Point class. Next week we’ll finish our design with some (non-special) methods of our own.
Link: Zip file containing all code fragments used in this post.
∅
ATTENTION: The WordPress Reader strips the style information from posts, which can destroy certain important formatting elements. If you’re reading this in the Reader, I highly recommend (and urge) you to [A] stop using the Reader and [B] always read blog posts on their website.
This post is: This is Python! (part 9)