November 11, 2024 11:11 am
Last time we looked at subclassing the Python built-in classes tuple, list, and dict with a focus on the built-in __new__ and __init__ methods (and never forget to include __str__ and/or __repr__ in your user-defined classes).
This time Simple Tricks explores many of the other built-in methods that help you create richly featured Python-aware objects. Specifically, we’ll focus on subclassing tuple to create (immutable) vector objects. A basic understanding of object-oriented programming is assumed.
Generally speaking, a vector is an array or list of numbers. They often represent a point, direction, or force in a multidimensional space. An [x, y] point on a graph is an example of a two-dimensional vector.
Here we design a vector class with the following features:
Items #3 and #5 suggest we use tuple as our base class. Tuples are immutable and indexable, so we inherit that functionality. Tuples can be any length — and the built-in len function already works with them — so item #1 is covered. We also inherit the container functionality, the ability index and iterate.
We need to implement items #2, #4, #6, and #7. Last time, the numbers class illustrated how to implement item #2, and we’ll use it again here. This post is mainly about requirements #4, #6, and #7.
Let’s start with the basics we learned creating the point class last time. The vector class we create here expands on that, and this first part implementing the __new__ and __init__ methods is very similar:
Recall that, because tuples are immutable, their initialization takes place during the __new__ method, not the __init__ method. Our vectors expect either a single iterable or multiple regular (unnamed) arguments for the vector. We also support some keyword arguments, so the generic new instance signature is:
vec = vector(*args, **kwargs)
Which is all the __new__ method (lines #10 to #23) needs. It ignores kwargs and looks only at args (line #14) for a single regular argument, which must be an iterable object, or multiple arguments. Either way, arglst holds the list elements.
In lines #17 to #20 we check to ensure each item is numeric (this implements requirement #2).
In line #23 we pass our vetted list of numbers to tuple.__new__ to actually create the tuple, which we return.
The __init__ method ignores the regular arguments. The tuple is already populated, and tuple.__init__ takes no arguments. We handle the keyword arguments here. The actual signature new instance signature is:
vec = vector(*args, prec=3, sep=', ', plus=True)
The keyword parameters allow fine-tuning the string returned by __str__. The prec parameter specifies the decimal digit precision (normally 3). The sep parameter specifies the string used between elements (normally a comma and space). The plus parameter specifies whether positive values have a plus sign (normally true). The __init__ method (lines #25 to #33) just stores their values (lines #31 to #33).
This fragment also implements the two built-in string methods. The __str__ method (lines #35 to #38) uses the keyword parameters stored in __init__. The __repr__ method (lines #40 to #43), as is my habit, returns a JSON string. You should implement them however works for you.
The above is similar to the point class from last time. Now we’ll start expanding our vector class to make it more useful:
The built-in __getattr__ method (lines #45 to #51) implements requirement #4 for x, y, z, w, & u properties that alias the first five elements. If a vector is too short for a given property, rather than raising an exception, we’ll return None. Arguably, raising an exception is preferred (and left as an exercise for the reader). These read-only attributes allow us to use vectors like this:
When run, this prints:
[+0.51, +1.67, +2.72, +3.14, +6.63] vec.x = 0.510000 vec.y = 1.670000 vec.z = 2.720000 vec.w = 3.140000 vec.u = 6.630000
The attributes are read-only because vectors are immutable.
The built-in __call__ method makes object instances callable as if they were functions. We use it in line #53 to return a tuple version. The aslist and asdict methods (lines #55 & #56) return list and dict versions, respectively. For the dictionary, keys are strings “e#”, with “#” starting at zero:
When run, this prints:
vec() = (0.51, 1.67, 2.72, 3.14, 6.63) type(vlist) = <class 'list'> 0: 0.51 1: 1.67 2: 2.72 3: 3.14 4: 6.63 type(vdict) = <class 'dict'> x0: 0.51 x1: 1.67 x2: 2.72 x3: 3.14 x4: 6.63
The built-in __bool__ method (lines #59 to #61) returns a Boolean value signifying whether an object is “true” or “false”. The usual treatment is that an object is “true” if it is “non-zero” and “false” if it is “zero” (with “zero” defined appropriately for the object). We’ll say a vector is “true” if any element is not zero:
When run, this prints:
[0.5, 1.7, 2.7, 3.1, 6.6] vector is True [] vector is False [0] vector is False [1, 0] vector is True [0, 0, 0] vector is False [0, 0, 0, 0, 1] vector is True
Lastly, the props method (lines #63 to #74) returns the keyword properties as a dictionary. It also allows changing keyword properties. As you’ll see below, its main purpose is ensuring the properties are passed on to new vector instances.
Next, we add three new methods. The magnitude method (lines #75 to #81) returns the mathematical vector length. It is implemented as a read-only property. Note that vector.magnitude and len(vector) return different values. The latter returns the number of elements, the length of the tuple, while the former returns the calculated vector length independent of the number of elements. I’ll use the term length to refer to vector magnitude and size to refer to the number of elements.
The distance method (lines #83 to #92) takes another vector as an argument and returns the Pythagorean distance between the two points indicated by the vectors. The dot method (lines #94 to #103) returns the dot product between two vectors.
I’ve included these mainly to illustrate a read-only property and, more importantly, the need to vet input arguments. For most operations we’re expecting another vector object, which is easy to check for (lines #86 and #97). Often, that other vector must have the same size (lines #88 and #99).
Importantly, note that we must design methods to work on vectors of any size. Here’s some demonstration code:
When run, this prints:
vec0 = [0, 0] vec1 = [1, 0] vec2 = [1, 1] vec3 = [0, 1] vec4 = [2, 5] vec0.magnitude = 0.0 vec1.magnitude = 1.0 vec2.magnitude = 1.4142135623730951 vec3.magnitude = 1.0 vec4.magnitude = 5.385164807134504 vec0.distance(vec0) = 0.0 vec0.distance(vec1) = 1.0 vec0.distance(vec2) = 1.4142135623730951 vec0.distance(vec3) = 1.0 vec0.distance(vec4) = 5.385164807134504 vec1.dot(vec1) = 1 vec1.dot(vec2) = 1 vec1.dot(vec3) = 0 vec1.dot(vec4) = 2
To satisfy requirement #6, we implement the built-in methods for comparing and sorting vector objects:
These implement behaviors for the comparison operators, == (equals), != (not equals), < (less than), <= (less than or equal), >= (greater than or equal), and > (greater than). Note how, in three cases, we only need to use the negation of one of the other three (lines #145 to #147).
We also implement the __hash__ method (line #149) because (a) vectors are immutable and (b) we implemented the equality operator. (See the links for details.)
Some demonstration code:
When run, this prints:
vec0: [+0, +0, +0] vec1: [+0.5, -1.6, -2.7] vec2: [+0.5, +1.6, -2.7] vec3: [+0.5, +0.8, -0.4] hash(vec0) = 2429128483648 hash(vec1) = 2429143898832 hash(vec2) = 2429144218608 hash(vec3) = 2429144215008 vec1 == vec1 = True vec1 == vec2 = False vec1 != vec1 = False vec1 != vec2 = True vec1 < vec1 = False vec1 < vec2 = True vec1 <= vec1 = True vec1 <= vec2 = True vec1 > vec1 = False vec1 > vec2 = False vec1 >= vec1 = True vec1 >= vec2 = False
And we can sort a list of random vectors:
When run, this prints:
[+0.210, +8.186, +2.376] [+0.523, +1.100, +5.871] [+1.895, +2.509, +8.288] [+2.354, +0.233, +8.390] [+2.576, +3.788, +2.408] [+2.936, +2.993, +3.999] [+3.070, +7.862, +9.898] [+4.963, +7.484, +6.885] [+5.648, +4.441, +7.105] [+6.658, +2.237, +7.883] [+7.119, +0.220, +1.852] [+7.661, +7.434, +0.449] [+8.151, +7.857, +0.038] [+8.555, +8.912, +4.952] [+8.967, +6.281, +2.075] [+9.885, +4.443, +4.133]
If you run that code fragment yourself, you’ll get different numbers, of course, but they will be (lexically) sorted.
Lastly, for requirement #7, we’ll implement some mathematical operators, starting with the unary operators:
These implement behaviors for the unary plus (+vec), the unary minus (-vec), the abs (absolute value) function, and the unary bitwise invert operator (~vec). These all return new vector objects (and you see now how the props method comes in handy ensuring the new objects inherit the property settings).
The unary plus (lines #151 to #153) returns an unaltered copy. The unary minus (lines #155 to #157) returns a copy with signs reversed on each element. The abs method (lines #159 to #161) returns a copy with each element forced to a positive value. The unary invert (lines #163 to #165) returns a copy with each element inverted (that is 1/value).
A demonstration:
Which prints:
vector : [-0.333333, -0.200000, +0.142857] id=2372540375872 unary plus : [-0.333333, -0.200000, +0.142857] id=2372555528912 unary minus : [+0.333333, +0.200000, -0.142857] id=2372555848768 abs() value : [+0.333333, +0.200000, +0.142857] id=2372555845088 unary invert: [-3.000000, -5.000000, +7.000000] id=2372555848848 vector : [-0.666667, +0.600000, -0.714286] id=2372540375872 unary plus : [-0.666667, +0.600000, -0.714286] id=2372555528912 unary minus : [+0.666667, -0.600000, +0.714286] id=2372555848848 abs() value : [+0.666667, +0.600000, +0.714286] id=2372555845088 unary invert: [-1.500000, +1.666667, -1.400000] id=2372555848768
Note how each vector is a separate object (their ids don’t match).
Next, we implement the basic four binary math operations:
These implement the basic binary operations plus (a+b), minus (a-b), multiplication (a*b), and division (a/b). Note the Python has two built-in division methods, __truediv__ and __floordiv__. The former is the traditional division operation; the latter is for integer division (a//b), which is equivalent to floor(a/b). There are many other binary operations we could implement [see Emulating numeric types for details].
For our vector objects, we want to consider four scenarios:
vector1 operator vector2 vector operator number number operator vector vector =operator number
The first case performs the operation element-by-element on two vectors. The second and third cases apply the single number to each element. The last case, the “in-place” version (a+=b), isn’t allowed because vectors are immutable. A final consideration is what we mean (and allow) by number.
The binary math methods all check the other parameter to see if it’s a vector. If so, they do an element-by-element operation. If not, they use the _is_numeric_type method (lines #167 to #172) to determine if the other parameter is a valid numeric type (int or float). If it is, they apply it to each element of the vector. Either way, they return a new vector object. The methods all raise a TypeError if both tests fail.
For the third use case, we implement the “right-hand” versions of the operators we’re defining. These are invoked when the left-hand side doesn’t handle the right-hand type (integers, for instance, don’t know how to handle vectors). Python checks to see if the right-hand object has an “r” version of the operation and, if so, calls it. We’ll implement all four basic math operations for right-hand side operations:
Note that these have the same names as their “left-hand” counterparts but with an “r” prepended to the name. Python calls these methods only when the left-hand object is not a vector, so we know other cannot be one. That means we only need to test for a numeric type. Because add and multiply commute, we can delegate to the regular methods (lines #222 to #224 and ##234 to #236).
Subtraction and division do not commute, so we need to implement the operations here (lines #226 to #232 and #238 to #244).
The final basic math methods are the “in-place” methods (the fourth use case), and since vectors are immutable, this operation is disallowed. Using any of these raises a NotImplemented exception.
Note that without implementing these, the following appears to work but probably with an unintended effect:
When run, this prints:
v1 = [+2.72, +3.14, +6.63], id=2693111110352 v2 = [+1, +1, +1] v1 = [+3.72, +4.14, +7.63], id=2693111430208
The add operation worked. It added v2 to v1, but then it assigned the resulting new vector to v1 (note how the ids are different). It’s the equivalent of:
Which prints the same thing. But the in-place operators imply modification of the existing object. What happens instead is a side effect of dealing with immutable objects.
Here’s some code to demonstrate the math methods:
When run, this prints:
vector-a (self) = [+2.718280, +3.141590, +6.626070] vector-b (other) = [+1.000000, +2.000000, +3.000000] a + b = [+3.718280, +5.141590, +9.626070] a - b = [+1.718280, +1.141590, +3.626070] a * b = [+2.718280, +6.283180, +19.878210] a / b = [+2.718280, +1.570795, +2.208690] a + 2 = [+4.718280, +5.141590, +8.626070] 2 + a = [+4.718280, +5.141590, +8.626070] a - 2 = [+0.718280, +1.141590, +4.626070] 2 - a = [-0.718280, -1.141590, -4.626070] a * 2 = [+5.436560, +6.283180, +13.252140] 2 * a = [+5.436560, +6.283180, +13.252140] a / 2 = [+1.359140, +1.570795, +3.313035] 2 / a = [+0.735759, +0.636620, +0.301838] vector-a (self) = [+2.718280, +3.141590, +6.626070] vector-b (other) = [+0.500000, +0.250000, +0.125000] a + b = [+3.218280, +3.391590, +6.751070] a - b = [+2.218280, +2.891590, +6.501070] a * b = [+1.359140, +0.785397, +0.828259] a / b = [+5.436560, +12.566360, +53.008560] a + 2 = [+4.718280, +5.141590, +8.626070] 2 + a = [+4.718280, +5.141590, +8.626070] a - 2 = [+0.718280, +1.141590, +4.626070] 2 - a = [-0.718280, -1.141590, -4.626070] a * 2 = [+5.436560, +6.283180, +13.252140] 2 * a = [+5.436560, +6.283180, +13.252140] a / 2 = [+1.359140, +1.570795, +3.313035] 2 / a = [+0.735759, +0.636620, +0.301838] vector-a (self) = [+1.000000, +2.000000, +3.000000] vector-b (other) = [+0.500000, +0.250000, +0.125000] a + b = [+1.500000, +2.250000, +3.125000] a - b = [+0.500000, +1.750000, +2.875000] a * b = [+0.500000, +0.500000, +0.375000] a / b = [+2.000000, +8.000000, +24.000000] a + 2 = [+3.000000, +4.000000, +5.000000] 2 + a = [+3.000000, +4.000000, +5.000000] a - 2 = [-1.000000, +0.000000, +1.000000] 2 - a = [+1.000000, +0.000000, -1.000000] a * 2 = [+2.000000, +4.000000, +6.000000] 2 * a = [+2.000000, +4.000000, +6.000000] a / 2 = [+0.500000, +1.000000, +1.500000] 2 / a = [+2.000000, +1.000000, +0.666667]
Because this is so long, I haven’t addressed the many changes and additions that would improve the vector class. This only illustrates the basics.
For instance, I mentioned above that the __getattr__ method should probably raise an exception rather than return None. More critically, there is no matching __setattr__ method. Without it, this works:
Because user-defined classes allow new attributes by default. When run, this prints:
vec = [+1.600, +2.700] vec.x = 1.6 vec.y = 2.7 vec.z = None vec = [+1.600, +2.700] vec.x = 42 vec.y = 2.7 vec.z = 21
We apparently modified vec.x and added vec.z but didn’t affect the vector elements. There are ways around this that I’ll next time (and see the vector class in the linked ZIP file below).
Link: Zip file containing all code fragments used in this post.
∅
Posted by Wyrd Smythe
Categories: Python
Tags: Python classes, Python code, Simple Tricks
You must be logged in to post a comment.
Mobile Site | Full Site
Get a free blog at WordPress.com Theme: WordPress Mobile Edition by Alex King.
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: Simple Python Tricks #11
By Wyrd Smythe on November 11, 2024 at 11:20 am
[…] The last two Simple Tricks posts looked at subclasses of built-in Python classes, in particular the tuple class, as well as the built-in class methods Python supports for any user-defined class. [See Simple Tricks #10 and Simple Tricks #11.] […]
By Simple Python Tricks #12 | The Hard-Core Coder on November 25, 2024 at 9:16 am