Search code examples
pythonoperator-overloadingpython-collections

How a class having only the '__getitem__' method defined support the 'in' operator?


If I have the following defined:

Card = namedtuple('Card', ['rank', 'suit'])

class CardDeck():
  ranks = [str(x) for x in range(2, 11)] + list('JQKA')
  suits = 'spades diamonds clubs hearts'.split()

  def __init__(self):
    self._cards = [Card(rank, suit) for rank in self.ranks for suit in self.suits]

  def __getitem__(self, index):
    return self._cards[index]

How the in operator is supported without having the __contains__ dunder method defined. For example the following:

deck = CardDeck()
print(Card('2', 'hearts') in deck)

will output:

True

Any Ideas?


Solution

  • __getitem__ is used as a fallback when no __contains__ or __iter__ method is available. See the Membership test operations section of the expressions reference documentation:

    Lastly, the old-style iteration protocol is tried: if a class defines __getitem__(), x in y is True if and only if there is a non-negative integer index i such that x is y[i] or x == y[i], and no lower integer index raises the IndexError exception.

    So what actually happens is that Python simply uses an increasing index, which in Python would look something like this:

    from itertools import count
    
    def contains_via_getitem(container, value):
        for i in count():   # increments indefinitely
            try:
                item = container[i]
                if value is item or value == item:
                    return True
            except IndexError:
                return False
    

    This treatment extends to all iteration functionality. Containers that do not implement __iter__ but do implement __getitem__ can still have an iterator created for them (with iter() or the C-API equivalent):

    >>> class Container:
    ...     def __init__(self):
    ...         self._items = ["foo", "bar", "baz"]
    ...     def __getitem__(self, index):
    ...         return self._items[index]
    ...
    >>> c = Container()
    >>> iter(c)
    <iterator object at 0x1101596a0>
    >>> list(iter(c))
    ['foo', 'bar', 'baz']
    

    Containment tests via iteration are, of course, not really efficient. If there is a way to determine if something is an item in the container without a full scan, do implement a __contains__ method to provide that better implementation!

    For a card deck, I can imagine that simply returning True when the item is a Card instance should suffice (provided the Card class validates the rank and suit parameters):

    def __contains__(self, item):
        # a card deck contains all possible cards
        return isinstance(item, Card)