https://pypi.org/project/attrs/
>>> import attr
>>> @attr.s
... class SomeClass(object):
... a_number = attr.ib(default=42)
... list_of_numbers = attr.ib(factory=list)
...
... def hard_math(self, another_number):
... return self.a_number + sum(self.list_of_numbers) * another_number
>>> sc = SomeClass(1, [1, 2, 3])
>>> sc
SomeClass(a_number=1, list_of_numbers=[1, 2, 3])
>>> sc.hard_math(3)
19
>>> sc == SomeClass(1, [1, 2, 3])
True
>>> sc != SomeClass(2, [3, 2, 1])
True
>>> attr.asdict(sc)
{'a_number': 1, 'list_of_numbers': [1, 2, 3]}
>>> SomeClass()
SomeClass(a_number=42, list_of_numbers=[])
>>> C = attr.make_class("C", ["a", "b"])
>>> C("foo", "bar")
C(a='foo', b='bar')
copy from https://opensource.com/article/18/10/functional-programming-python-immutable-data-structures
Why functional programming? Because mutation is hard to reason about. If you are already convinced that mutation is problematic, great. If you’re not convinced, you will be by the end of this post.Let’s begin by considering squares and rectangles. If we think in terms of interfaces, neglecting implementation details, are squares a subtype of rectangles?
The definition of a subtype rests on theLiskov substitution principle. In order to be a subtype, it must be able to do everything the supertype does.
How would we define an interface for a rectangle?
from zope.interface import Interface
class IRectangle(Interface):
def get_length(self):
"""Squares can do that"""
def get_width(self):
"""Squares can do that"""
def set_dimensions(self, length, width):
"""Uh oh"""
If this is the definition, then squares cannot be a subtype of rectangles; they cannot respond to aset_dimensionsmethod if the length and width are different.
A different approach is to choose to make rectanglesimmutable.
class IRectangle(Interface):
def get_length(self):
"""Squares can do that"""
def get_width(self):
"""Squares can do that"""
def with_dimensions(self, length, width):
"""Returns a new rectangle"""
Now, a square can be a rectangle. It can return a_new_rectangle (which would not usually be a square) whenwith_dimensionsis called, but it would not stop being a square.
This might seem like an academic problem—until we consider that squares and rectangles are, in a sense, a container for their sides. After we understand this example, the more realistic case this comes into play with is more traditional containers. For example, consider random-access arrays.
We haveISquareandIRectangle, andISquareis a subtype ofIRectangle.
We want to put rectangles in a random-access array:
class IArrayOfRectangles(Interface):
def get_element(self, i):
"""Returns Rectangle"""
def set_element(self, i, rectangle):
"""'rectangle' can be any IRectangle"""
We want to put squares in a random-access array too:
class IArrayOfSquare(Interface):
def get_element(self, i):
"""Returns Square"""
def set_element(self, i, square):
"""'square' can be any ISquare"""
Even thoughISquareis a subtype ofIRectangle, no array can implement bothIArrayOfSquareandIArrayOfRectangle.
Why not? Assumebucketimplements both.
>>> rectangle = make_rectangle(3, 4)
>>> bucket.set_element(0, rectangle) # This is allowed by IArrayOfRectangle
>>> thing = bucket.get_element(0) # That has to be a square by IArrayOfSquare
>>> assert thing.height == thing.width
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AssertionError
Being unable to implement both means that neither is a subtype of the other, even thoughISquareis a subtype ofIRectangle. The problem is theset_elementmethod: If we had a read-only array,IArrayOfSquarewould be a subtype ofIArrayOfRectangle.
Mutability, in both the mutableIRectangleinterface and the mutableIArrayOf*interfaces, has made thinking about types and subtypes much more difficult—and giving up on the ability to mutate meant that the intuitive relationships we expected to have between the types actually hold.
Mutation can also have_non-local_effects. This happens when a shared object between two places is mutated by one. The classic example is one thread mutating a shared object with another thread, but even in a single-threaded program, sharing between places that are far apart is easy. Consider that in Python, most objects are reachable from many places: as a module global, or in a stack trace, or as a class attribute.
If we cannot constrain the sharing, we might think about constraining the mutability.
Here is an immutable rectangle, taking advantage of theattrslibrary:
@attr.s(frozen=True)
class Rectange(object):
length = attr.ib()
width = attr.ib()
@classmethod
def with_dimensions(cls, length, width):
return cls(length, width)
Here is a square:
@attr.s(frozen=True)
class Square(object):
side = attr.ib()
@classmethod
def with_dimensions(cls, length, width):
return Rectangle(length, width)
Using thefrozenargument, we can easily haveattrs-created classes be immutable. All the hard work of writingsetitemcorrectly has been done by others and is completely invisible to us.
It is still easy to_modify_objects; it’s just nigh impossible to_mutate_them.
too_long = Rectangle(100, 4)
reasonable = attr.evolve(too_long, length=10)
ThePyrsistentpackage allows us to have immutable containers.
# Vector of integers
a = pyrsistent.v(1, 2, 3)
# Not a vector of integers
b = a.set(1, "hello")
Whilebis not a vector of integers, nothing will ever stopafrom being one.
What ifawas a million elements long? Isbgoing to copy 999,999 of them? Pyrsistent comes with “big O” performance guarantees: All operations takeO(log n)time. It also comes with an optional C extension to improve performance beyond the big O.
For modifying nested objects, it comes with a concept of “transformers:”
blog = pyrsistent.m(
title="My blog",
links=pyrsistent.v("github", "twitter"),
posts=pyrsistent.v(
pyrsistent.m(title="no updates",
content="I'm busy"),
pyrsistent.m(title="still no updates",
content="still busy")))
new_blog = blog.transform(["posts", 1, "content"],
"pretty busy")
new_blogwill now be the immutable equivalent of
{'links': ['github', 'twitter'],
'posts': [{'content': "I'm busy",
'title': 'no updates'},
{'content': 'pretty busy',
'title': 'still no updates'}],
'title': 'My blog'}
Butblogis still the same. This means anyone who had a reference to the old object has not been affected: The transformation had only_local_effects.
This is useful when sharing is rampant. For example, consider default arguments:
def silly_sum(a, b, extra=v(1, 2)):
extra = extra.extend([a, b])
return sum(extra)
In this post, we have learned why immutability can be useful for thinking about our code, and how to achieve it without an extravagant performance price. Next time, we will learn how immutable objects allow us to use powerful programming constructs.