Other bidict Types

Now that we’ve covered Basic Usage with the bidict.bidict type, let’s look at some other bidirectional mapping types.

Bidict Types Diagram

bidict types diagram

All bidirectional mapping types that bidict provides are subclasses of bidict.BidirectionalMapping. This abstract base class extends collections.abc.Mapping by adding the “inverseabstractproperty.

As you may have noticed, bidict.bidict is also a collections.abc.MutableMapping. But bidict provides immutable bidirectional mapping types as well.

frozenbidict

frozenbidict is an immutable, hashable bidirectional mapping type.

As you would expect, attempting to mutate a frozenbidict causes an error:

>>> from bidict import frozenbidict
>>> f = frozenbidict({'H': 'hydrogen'})
>>> f['C'] = 'carbon'
Traceback (most recent call last):
    ...
TypeError: ...

frozenbidict also implements collections.abc.Hashable, so it’s suitable for insertion into sets or other mappings:

>>> my_set = {f}      # not an error
>>> my_dict = {f: 1}  # also not an error

See the frozenbidict API documentation for more information.

OrderedBidict

bidict.OrderedBidict is a mutable BidirectionalMapping that preserves the ordering of its items, and offers some additional ordering-related APIs that non-Ordered bidicts can’t offer. It’s like a bidirectional version of collections.OrderedDict.

>>> from bidict import OrderedBidict
>>> element_by_symbol = OrderedBidict([
...     ('H', 'hydrogen'), ('He', 'helium'), ('Li', 'lithium')])

>>> element_by_symbol.inverse
OrderedBidict([('hydrogen', 'H'), ('helium', 'He'), ('lithium', 'Li')])

>>> first, second, third = element_by_symbol.values()
>>> first, second, third
('hydrogen', 'helium', 'lithium')

>>> # Insert an additional item and verify it now comes last:
>>> element_by_symbol['Be'] = 'beryllium'
>>> last_item = list(element_by_symbol.items())[-1]
>>> last_item
('Be', 'beryllium')

Additional ordering-related APIs modeled after OrderedDict, e.g. popitem(last=False) and move_to_end(), are provided as well:

>>> element_by_symbol.popitem(last=True)   # Remove the last item
('Be', 'beryllium')
>>> element_by_symbol.popitem(last=False)  # Remove the first item
('H', 'hydrogen')

>>> # Re-adding hydrogen after it's been removed moves it to the end:
>>> element_by_symbol['H'] = 'hydrogen'
>>> element_by_symbol
OrderedBidict([('He', 'helium'), ('Li', 'lithium'), ('H', 'hydrogen')])

>>> # But there's also a `move_to_end` method just for this purpose:
>>> element_by_symbol.move_to_end('Li')
>>> element_by_symbol
OrderedBidict([('He', 'helium'), ('H', 'hydrogen'), ('Li', 'lithium')])

>>> element_by_symbol.move_to_end('H', last=False)  # move to front
>>> element_by_symbol
OrderedBidict([('H', 'hydrogen'), ('He', 'helium'), ('Li', 'lithium')])

As with OrderedDict, updating an existing item preserves its position in the order:

>>> element_by_symbol['He'] = 'updated in place!'
>>> element_by_symbol
OrderedBidict([('H', 'hydrogen'), ('He', 'updated in place!'), ('Li', 'lithium')])

Collapsing overwrites

When setting an item in an ordered bidict whose key duplicates that of an existing item, and whose value duplicates that of a different existing item, the existing item whose value is duplicated will be dropped, and the existing item whose key is duplicated will have its value overwritten in place:

>>> o = OrderedBidict([(1, 2), (3, 4), (5, 6), (7, 8)])
>>> o.forceput(3, 8)  # item with duplicated value (7, 8) is dropped...
>>> o  # and the item with duplicated key (3, 4) is updated in place:
OrderedBidict([(1, 2), (3, 8), (5, 6)])
>>> # (3, 8) took the place of (3, 4), not (7, 8)

>>> o = OrderedBidict([(1, 2), (3, 4), (5, 6), (7, 8)])  # as before
>>> o.forceput(5, 2)  # another example
>>> o
OrderedBidict([(3, 4), (5, 2), (7, 8)])
>>> # (5, 2) took the place of (5, 6), not (1, 2)

__eq__() is order-insensitive

To ensure that equality of bidicts is transitive (and to uphold the Liskov substitution principle), equality tests between an ordered bidict and other mappings are always order-insensitive:

>>> b = bidict([('one', 1), ('two', 2)])
>>> o1 = OrderedBidict([('one', 1), ('two', 2)])
>>> o2 = OrderedBidict([('two', 2), ('one', 1)])
>>> b == o1
True
>>> b == o2
True
>>> o1 == o2
True

For order-sensitive equality tests, use equals_order_sensitive():

>>> o1.equals_order_sensitive(o2)
False

Note that this differs from the behavior of collections.OrderedDict’s __eq__(), by recommendation of Raymond Hettinger (the author of OrderedDict) himself. He later said that making OrderedDict’s __eq__() intransitive was a mistake.

What about order-preserving dicts?

In PyPy as well as CPython 3.6+, dict preserves insertion order. Given that, can you get away with using a non-Ordered bidict.bidict in places where you need an order-preserving bidirectional mapping?

Consider this example:

>>> ob = OrderedBidict([(1, -1), (2, -2), (3, -3)])
>>> b = bidict(ob)
>>> ob[2] = b[2] = 'UPDATED'
>>> ob
OrderedBidict([(1, -1), (2, 'UPDATED'), (3, -3)])
>>> b
bidict({1: -1, 2: 'UPDATED', 3: -3})
>>> b.inverse  # look what happens here
bidict({-1: 1, -3: 3, 'UPDATED': 2})
>>> ob.inverse  # need an OrderedBidict for full order preservation
OrderedBidict([(-1, 1), ('UPDATED', 2), (-3, 3)])

When the value associated with the key 2 in the non-Ordered bidict b was changed, the corresponding item stays in place in the forward mapping, but moves to the end of the inverse mapping. Since non-Ordered bidicts provide weaker ordering guarantees (which allows for a more efficient implementation), it’s possible to see behavior like in the example above after certain sequences of mutations.

That said, if you depend on preserving insertion order, a non-Ordered bidict may be sufficient if:

  • you’re never mutating it, or
  • you’re only mutating by removing and/or adding whole new items, never changing just the key or value of an existing item, or
  • you’re only changing existing items in the forward direction (i.e. changing values by key, rather than changing keys by value), and only depend on the order in the forward bidict, not the order of the items in its inverse.

On the other hand, if your code is actually depending on the order, using an OrderedBidict() makes for clearer code.

This will also give you additional order-specific APIs, such as move_to_end() and popitem(last=False). (And also __reversed__() on Python < 3.8. On Python 3.8+, all bidicts are reversible.)

FrozenOrderedBidict

FrozenOrderedBidict is an immutable ordered bidict type. It’s like a hashable OrderedBidict without the mutating APIs, or like a reversible frozenbidict even on Python < 3.8. (All bidicts are order-preserving when never mutated, so frozenbidict is already order-preserving, but only on Python 3.8+, where dicts are reversible, are all bidicts (including frozenbidict) also reversible.)

If you are using Python 3.8+, frozenbidict gives you everything that FrozenOrderedBidict gives you, but with less space overhead.

namedbidict()

bidict.namedbidict(), inspired by collections.namedtuple(), allows you to easily generate a new bidirectional mapping type with custom attribute-based access to forward and inverse mappings:

>>> from bidict import namedbidict
>>> ElementMap = namedbidict('ElementMap', 'symbol', 'name')
>>> noble_gases = ElementMap(He='helium')
>>> noble_gases.name_for['He']
'helium'
>>> noble_gases.symbol_for['helium']
'He'
>>> noble_gases.name_for['Ne'] = 'neon'
>>> del noble_gases.symbol_for['helium']
>>> noble_gases
ElementMap({'Ne': 'neon'})

Using the base_type keyword arg – whose default value is bidict.bidict – you can override the bidict type used as the base class, allowing the creation of e.g. a named frozenbidict type:

>>> ElMap = namedbidict('ElMap', 'symbol', 'name', base_type=frozenbidict)
>>> noble = ElMap(He='helium')
>>> noble.symbol_for['helium']
'He'
>>> hash(noble) is not TypeError  # does not raise TypeError: unhashable type
True
>>> noble['C'] = 'carbon'  # mutation fails
Traceback (most recent call last):
...
TypeError: ...

Polymorphism

(Or: ABCs ftw!)

You may be tempted to write something like isinstance(obj, dict) to check whether obj is a Mapping. However, this check is too specific, and will fail for many types that implement the Mapping interface:

>>> from collections import ChainMap
>>> issubclass(ChainMap, dict)
False

The same is true for all the bidict types:

>>> issubclass(bidict, dict)
False

The proper way to check whether an object is a Mapping is to use the abstract base classes (ABCs) from the collections module that are provided for this purpose:

>>> issubclass(ChainMap, Mapping)
True
>>> isinstance(bidict(), Mapping)
True

Also note that the proper way to check whether an object is an (im)mutable mapping is to use the MutableMapping ABC:

>>> from bidict import BidirectionalMapping

>>> def is_immutable_bimap(obj):
...     return (isinstance(obj, BidirectionalMapping)
...             and not isinstance(obj, MutableMapping))

>>> is_immutable_bimap(bidict())
False

>>> is_immutable_bimap(frozenbidict())
True

Checking for isinstance(obj, frozenbidict) is too specific and could fail in some cases. For example, FrozenOrderedBidict is an immutable mapping but it does not subclass frozenbidict:

>>> from bidict import FrozenOrderedBidict
>>> obj = FrozenOrderedBidict()
>>> is_immutable_bimap(obj)
True
>>> isinstance(obj, frozenbidict)
False

Besides the above, there are several other collections ABCs whose interfaces are implemented by various bidict types. Have a look through the collections.abc documentation if you’re interested.

For more you can do with bidict, check out Extending bidict next.