python-mastery/Exercises/ex2_4.md
2023-07-16 20:21:00 -05:00

450 lines
10 KiB
Markdown

\[ [Index](index.md) | [Exercise 2.3](ex2_3.md) | [Exercise 2.5](ex2_5.md) \]
# Exercise 2.4
*Objectives:*
- Make a new primitive type
In most programs, you use the primitive types such as `int`, `float`,
and `str` to represent data. However, you're not limited to just those
types. The standard library has modules such as the `decimal` and
`fractions` module that implement new primitive types. You can also
make your own types as long as you understand the underlying protocols
which make Python objects work. In this exercise, we'll make a new
primitive type. There are a lot of little details to worry about, but
this will give you a general sense for what's required.
## (a) Mutable Integers
Python integers are normally immutable. However, suppose you wanted to
make a mutable integer object. Start off by making a class like this:
```python
# mutint.py
class MutInt:
__slots__ = ['value']
def __init__(self, value):
self.value = value
```
Try it out:
```python
>>> a = MutInt(3)
>>> a
<__main__.MutInt object at 0x10e79d408>
>>> a.value
3
>>> a.value = 42
>>> a.value
42
>>> a + 10
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'MutInt' and 'int'
>>>
```
That's all very exciting except that nothing really works with this
new `MutInt` object. Printing is horrible, none of the math
operators work, and it's basically rather useless. Well, except for
the fact that its value is mutable--it does have that.
## (b) Fixing output
You can fix output by giving the object methods such as `__str__()`,
`__repr__()`, and `__format__()`. For example:
```python
# mint.py
class MutInt:
__slots__ = ['value']
def __init__(self, value):
self.value = value
def __str__(self):
return str(self.value)
def __repr__(self):
return f'MutInt({self.value!r})'
def __format__(self, fmt):
return format(self.value, fmt)
```
Try it out:
```python
>>> a = MutInt(3)
>>> print(a)
3
>>> a
MutInt(3)
>>> f'The value is {a:*^10d}'
The value is ****3*****
>>> a.value = 42
>>> a
MutInt(42)
>>>
```
## (c) Math Operators
You can make an object work with various math operators if you implement the
appropriate methods for it. However, it's your responsibility to
recognize other types of data and implement the appropriate conversion
code. Modify the `MutInt` class by giving it an `__add__()` method
as follows:
```python
class MutInt:
__slots__ = ['value']
def __init__(self, value):
self.value = value
...
def __add__(self, other):
if isinstance(other, MutInt):
return MutInt(self.value + other.value)
elif isinstance(other, int):
return MutInt(self.value + other)
else:
return NotImplemented
```
With this change, you should find that you can add both integers and
mutable integers. The result is a `MutInt` instance. Adding
other kinds of numbers results in an error:
```python
>>> a = MutInt(3)
>>> b = a + 10
>>> b
MutInt(13)
>>> b.value = 23
>>> c = a + b
>>> c
MutInt(26)
>>> a + 3.5
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'MutInt' and 'float'
>>>
```
One problem with the code is that it doesn't work when the order of operands
is reversed. Consider:
```python
>>> a + 10
MutInt(13)
>>> 10 + a
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'MutInt'
>>>
```
This is occurring because the `int` type has no knowledge of `MutInt`
and it's confused. This can be fixed by adding an `__radd__()` method. This
method is called if the first attempt to call `__add__()` didn't work with the
provided object.
```python
class MutInt:
__slots__ = ['value']
def __init__(self, value):
self.value = value
...
def __add__(self, other):
if isinstance(other, MutInt):
return MutInt(self.value + other.value)
elif isinstance(other, int):
return MutInt(self.value + other)
else:
return NotImplemented
__radd__ = __add__ # Reversed operands
```
With this change, you'll find that addition works:
```python
>>> a = MutInt(3)
>>> a + 10
MutInt(13)
>>> 10 + a
MutInt(13)
>>>
```
Since our integer is mutable, you can also make it recognize the in-place
add-update operator `+=` by implementing the `__iadd__()` method:
```python
class MutInt:
__slots__ = ['value']
def __init__(self, value):
self.value = value
...
def __iadd__(self, other):
if isinstance(other, MutInt):
self.value += other.value
return self
elif isinstance(other, int):
self.value += other
return self
else:
return NotImplemented
```
This allows for interesting uses like this:
```python
>>> a = MutInt(3)
>>> b = a
>>> a += 10
>>> a
MutInt(13)
>>> b # Notice that b also changes
MutInt(13)
>>>
```
That might seem kind of strange that `b` also changes, but there are subtle features like
this with built-in Python objects. For example:
```python
>>> a = [1,2,3]
>>> b = a
>>> a += [4,5]
>>> a
[1, 2, 3, 4, 5]
>>> b
[1, 2, 3, 4, 5]
>>> c = (1,2,3)
>>> d = c
>>> c += (4,5)
>>> c
(1, 2, 3, 4, 5)
>>> d # Explain difference from lists
(1, 2, 3)
>>>
```
## (d) Comparisons
One problem is that comparisons still don't work. For example:
```python
>>> a = MutInt(3)
>>> b = MutInt(3)
>>> a == b
False
>>> a == 3
False
>>>
```
You can fix this by adding an `__eq__()` method. Further methods such
as `__lt__()`, `__le__()`, `__gt__()`, `__ge__()` can be used to
implement other comparisons. For example:
```python
class MutInt:
__slots__ = ['value']
def __init__(self, value):
self.value = value
...
def __eq__(self, other):
if isinstance(other, MutInt):
return self.value == other.value
elif isinstance(other, int):
return self.value == other
else:
return NotImplemented
def __lt__(self, other):
if isinstance(other, MutInt):
return self.value < other.value
elif isinstance(other, int):
return self.value < other
else:
return NotImplemented
```
Try it:
```python
>>> a = MutInt(3)
>>> b = MutInt(3)
>>> a == b
True
>>> c = MutInt(4)
>>> a < c
True
>>> a <= c
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: '<=' not supported between instances of 'MutInt' and 'MutInt'
>>>
```
The reason the `<=` operator is failing is that no `__le__()` method was provided.
You could code it separately, but an easier way to get it is to use the `@total_ordering`
decorator:
```python
from functools import total_ordering
@total_ordering
class MutInt:
__slots__ = ['value']
def __init__(self, value):
self.value = value
...
def __eq__(self, other):
if isinstance(other, MutInt):
return self.value == other.value
elif isinstance(other, int):
return self.value == other
else:
return NotImplemented
def __lt__(self, other):
if isinstance(other, MutInt):
return self.value < other.value
elif isinstance(other, int):
return self.value < other
else:
return NotImplemented
```
`@total_ordering` fills in the missing comparison methods for you as long as
you minimally provide an equality operator and one of the other relations.
## (e) Conversions
Your new primitive type is almost complete. You might want to give it
the ability to work with some common conversions. For example:
```python
>>> a = MutInt(3)
>>> int(a)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: int() argument must be a string, a bytes-like object or a number, not 'MutInt'
>>> float(a)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: float() argument must be a string, a bytes-like object or a number, not 'MutInt'
>>>
```
You can give your class an `__int__()` and `__float__()` method to fix this:
```python
from functools import total_ordering
@total_ordering
class MutInt:
__slots__ = ['value']
def __init__(self, value):
self.value = value
...
def __int__(self):
return self.value
def __float__(self):
return float(self.value)
```
Now, you can properly convert:
```python
>>> a = MutInt(3)
>>> int(a)
3
>>> float(a)
3.0
>>>
```
As a general rule, Python never automatically converts data though. Thus, even though you
gave the class an `__int__()` method, `MutInt` is still not going to work in all
situations when an integer might be expected. For example, indexing:
```python
>>> names = ['Dave', 'Guido', 'Paula', 'Thomas', 'Lewis']
>>> a = MutInt(1)
>>> names[a]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: list indices must be integers or slices, not MutInt
>>>
```
This can be fixed by giving `MutInt` an `__index__()` method that produces an integer.
Modify the class like this:
```python
from functools import total_ordering
@total_ordering
class MutInt:
__slots__ = ['value']
def __init__(self, value):
self.value = value
...
def __int__(self):
return self.value
__index__ = __int__ # Make indexing work
```
**Discussion**
Making a new primitive datatype is actually one of the most complicated
programming tasks in Python. There are a lot of edge cases and low-level
issues to worry about--especially with regard to how your type interacts
with other Python types. Probably the key thing to keep in mind is that
you can customize almost every aspect of how an object interacts with the
rest of Python if you know the underlying protocols. If you're going to
do this, it's advisable to look at the existing code for something similar
to what you're trying to make.
\[ [Solution](soln2_4.md) | [Index](index.md) | [Exercise 2.3](ex2_3.md) | [Exercise 2.5](ex2_5.md) \]
----
`>>>` Advanced Python Mastery
`...` A course by [dabeaz](https://www.dabeaz.com)
`...` Copyright 2007-2023
![](https://i.creativecommons.org/l/by-sa/4.0/88x31.png). This work is licensed under a [Creative Commons Attribution-ShareAlike 4.0 International License](http://creativecommons.org/licenses/by-sa/4.0/)