This came up at work and left me thinking about the best way to model this:
In Python, we have the built-in
list container, which is a mutable sequence. Equality between two lists is defined as equality of all items in the list, in their respective positions.
Now a colleague felt the need to define a type that's a
list for all practical purposes, except that two lists should be considered equal if they contain the same elements, in the same quantities, but in arbitrary order. Basically,
unordered_list_1 = UnorderedList([1,2,3]) unordered_list_2 = UnorderedList([3,2,1]) unordered_list_1 == unordered_list_2 # True!
The colleague solved this by inheriting from
list and overriding the
__eq__ special method:
class UnorderedList(list): def __eq__(self, other): if isinstance(other, UnorderedList): return ordered(self) == ordered(other) else: return NotImplemented
In this form it runs into a gotcha, because the builtin python types such as
list take some shortcuts with their special methods; the not-equal
__ne__ special method does not just fall back onto the
__eq__ special method, so you get the funny scenario where two of these unordered lists can both be equal and not equal.
I suggested inheriting from
UserList instead, which is meant to be subclassed, or maybe from one of the
collections.abc abstract base classes. Another colleague chimed in with the familiar "Favor composition over inheritance" advice.
I feel that composition in this case would lead to a lot of boilerplate delegation code:
class UnorderedListUsingDelegation: def __init__(self): self._list = list() def __eq__(self, other): if isinstance(other, UnorderedListUsingDelegation): return ordered(self._list) == ordered(self.other._list) else: return NotImplemented def append(self, item): self._list.append(item) # Basically def every method implemented by class list and write delegation code for it: # pop, push, extend, __getitem__, __setitem__ and so on
So from that consideration, I feel like inheritance is exactly right here: A teeny tiny specialization of behavior.
But on the other hand: Is the
UnorderedList actually substitutable for the
list class? Not so sure here. If you do "normal" list operations, then you shouldn't notice whether you are using an instance of the
list class or of the
UnorderedList class. Inserting and retrieving of elements works just fine. On the other hand, you might get unexpected behavior when comparing lists:
list1 = UnorderedList() list2 = UnorderedList() list1.append(1) list2.append(3) list1 == list2 # False list1.append(2) list2.append(2) list1 == list2 # False list1.append(3) list2.append(1) list1 == list2 # True!
I guess what I'm after is some clarity on how broadly or narrowly the Liskov substitution principle should be applied. Or maybe the solution is something altogether different. Maybe we shouldn't put such a "hack" into the
__eq__ special method and rather be explicit about what we're doing, by writing a function like
def sorted_equal(a, b): return sorted(a) == sorted(b)
I assume the colleague is working with some framework that expects to be working with
list objects but wants to inject this special way of comparing lists.