Sometime ago I talked about Python Assignment Expressions aka walrus operator, and overtime I've really come to appreciate it. Some weeks ago I came across and odd limitation of this operator, it can not be used for assigning to an attribute of an object (so you can use it only with variables), as you'll get an "SyntaxError: cannot use assignment expressions with attribute" error. I don't remember what I was trying when I hit this problem, but now I can think of an example like the verify method below:
class Country:
def __init__(self, name):
self.name = name
self.cities = None
self.last_verification = None
def _lookup_cities(self):
print("looking up cities")
return ["Paris", "Toulouse", "Lyon"]
def verify(self):
# [code to perform verification here]
print(f"last verification done at: {(self.last_verification := datetime.now())}")
# SyntaxError: cannot use assignment expressions with attribute
So the above throws a SyntaxError: cannot use assignment expressions with attribute. I can think of one technique to circunvent this limitation, leveraging an "assign()" custom function that I use sometimes to conveniently set several properties in one go).
def assign(val: Any, **kwargs) -> Any:
for key, value in kwargs.items():
setattr(val, key, value)
return val
def verify(self):
# [code to perform verification here]
print(f"last verification done at: {assign(self, last_verification=datetime.now()).last_verification}")
That syntax is cool, but having the print() call as the most visible part of the statement is probably confusing, as it makes us think that the important action in that line is print, while setting the last_verification attribute is the real deal in that line. So probably using the "traditional syntax" would make sense:
def verify(self):
# [code to perform verification here]
self.last_verification = datetime.now()
print(f"last verification done at: {self.last_verification}")
Another example for using this technique:
def _lookup_cities(self):
print("looking up cities")
return ["Paris", "Toulouse", "Lyon"]
def get_cities(self) -> list[str]:
# return self.cities or (self.cities := self._lookup_cities())
# SyntaxError: cannot use assignment expressions with attribute
return self.cities or assign(self, cities=self._lookup_cities()).cities
Notice that this case could be rewritten using a lazy property via functools @cached_property
@cached_property
def cities(self):
print(f"initializing lazy property")
return self._lookup_cities()
That looks really neat, but notice that I think we should be careful with the use of cached/lazy properties. On one hand, cities represents data belonging to the object, it's part of its state, so using a property rather than a method feels natural. But on the other hand, to obtain those cities maybe we do a http or db request, an external request. This kind of external interaction can be considered as a side-effect, so in that sense we should use a method. In general, I think lazy properties should only be used for data that is calculated based on other data belonging to the object (and if that data is read-only or we observe it and update the property accordingly, and if that calculation is not too lenghty, as accessing a property should always be fast). This is an interesting topic and it has prompted me to revisit this stackoverflow question that I remember to have read several times over the last 15 years.
I have to add that these examples would look much nicer if we had a pipe operator (like in elixir) and we could write something like this:
return self.cities or (self | assign(cities=self._lookup_cities()) | .cities)