3
\$\begingroup\$

I've created a subclass of Python's builtin dictionary dict. The subclass allows dictionaries to have multiple keys with the same value. Hence the name "homogeneous". Behind the scenes, I'm using a normally dictionary. If a key has more than one value, then it's values are implemented with a list. Otherwise, the key, value pair is normal.

Here are some example usages:

>>> d = HomogeneousDict([('a', 1), ('b', 2), ('c', 3), ('b', 4)])
>>> d
HomogeneousDict({'c': 3, 'b': 2, 'b': 4, 'a': 1})
>>> d['a']
1
>>> d['b']
[2, 4]
>>> d['a'] = 5
>>> d
HomogeneousDict({'c': 3, 'b': 2, 'b': 4, 'a': 1, 'a': 5})
>>> d['a']
[1, 5]
>>> len(d)
5
>>> d['subdict'] = HomogeneousDict([('d', 6), ('e', 7)])
>>> d
HomogeneousDict({'c': 3, 'b': 2, 'b': 4, 'a': 1, 'a': 5, 'subdict': HomogeneousDict({'e': 7, 'd': 6})})
>>> d['subdict'] = 8
>>> d
HomogeneousDict({'c': 3, 'b': 2, 'b': 4, 'a': 1, 'a': 5, 'subdict': HomogeneousDict({'e': 7, 'd': 6}), 'subdict': 8})
>>> d['subdict']
[HomogeneousDict({'e': 7, 'd': 6}), 8]
>>> del d['subdict']
>>> d
HomogeneousDict({'c': 3, 'b': 2, 'b': 4, 'a': 1, 'a': 5})
>>> d.pop('b', 1) # Pop the second value of the 'b' key
4
>>> d
HomogeneousDict({'c': 3, 'b': 2, 'a': 1, 'a': 5})
>>> for k, v in d.items():
    print('{} => {}'.format(k, v))


c => 3
b => 2
a => 1
a => 5
>>>

I haven't given much thought if there is a practically usage for this type of dictionary. I really made this as more of a hobby project, rather than out of necessity.

Here's the source code. I documented the important parts of the code, so their shouldn't be much to explain or figure out. It's actually only about 120 LOCs:

class HomogeneousDict(dict):
    """
    About
    -----

    A dictionary that allows multiple keys to have
    like values.

    The dictionary supports all of the same methods of a normally
    dictionary. The chief difference between a normally dictonary and
    a HomogeneousDict, is when you get items.

    If a key has be given multiple values, a list of all
    of the values are returned for that key. Otherwise, the
    key's value is returned normally as is.

    Examaples
    ---------
    >>> d = HomogeneousDict([('a', 1), ('b', 2), ('c', 3), ('b', 4)])
    >>> d
    HomogeneousDict({'c': 3, 'b': 2, 'b': 4, 'a': 1})
    >>> d['a']
    1
    >>> d['b']
    [2, 4]
    >>> d['a'] = 5
    >>> d
    HomogeneousDict({'c': 3, 'b': 2, 'b': 4, 'a': 1, 'a': 5})
    >>> d['a']
    [1, 5]
    >>> len(d)
    5
    >>> d['subdict'] = HomogeneousDict([('d', 6), ('e', 7)])
    >>> d
    HomogeneousDict({'c': 3, 'b': 2, 'b': 4, 'a': 1, 'a': 5, 'subdict': HomogeneousDict({'e': 7, 'd': 6})})
    >>> d['subdict'] = 8
    >>> d
    HomogeneousDict({'c': 3, 'b': 2, 'b': 4, 'a': 1, 'a': 5, 'subdict': HomogeneousDict({'e': 7, 'd': 6}), 'subdict': 8})
    >>> d['subdict']
    [HomogeneousDict({'e': 7, 'd': 6}), 8]
    >>> del d['subdict']
    >>> d
    HomogeneousDict({'c': 3, 'b': 2, 'b': 4, 'a': 1, 'a': 5})
    >>> d.pop('b', 1) # Pop the second value of the 'b' key
    4
    >>> d
    HomogeneousDict({'c': 3, 'b': 2, 'a': 1, 'a': 5})
    >>> for k, v in d.items():
            print('{} => {}'.format(k, v))


    c => 3
    b => 2
    a => 1
    a => 5
    >>>

    """
    def __init__(self, values):
        """
        Parameters
        ----------
        values : list
          List of tuples that represent key, value pair
        """
        for key, value in values:
            self.__setitem__(key, value)

    def __setitem__(self, key, value):
        if key in self:
            if isinstance(self[key], list):
                self[key].append(value)
            else:
                super().__setitem__(key, [self.pop(key), value])
        else:
            super().__setitem__(key, value)

    def __getitem__(self, key):
        return super().__getitem__(key)

    def __len__(self):
        count = 0
        for key, value in super().items():
            if isinstance(value, list):
                count += len(value)
            else:
                count += 1
        return count

    def __repr__(self):
        key_value_reprs = []
        for key, value in super().items():
            if isinstance(value, list):
                for element in value:
                    repr_ = '{}: {}'.format(key.__repr__(), element.__repr__())
                    key_value_reprs.append(repr_)
            else:
                repr_ = '{}: {}'.format(key.__repr__(), value.__repr__())
                key_value_reprs.append(repr_)
        return 'HomogeneousDict({%s})' % ', '.join(key_value_reprs)

    def pop(self, key, index=0):
        if isinstance(self[key], list):
            value = self[key].pop(index)
            if len(self[key]) == 1:
                super().__setitem__(key, self[key].pop())
            return value
        else:
            return super().pop(key)

    def items(self):
        for key, value in super().items():
            if isinstance(value, list):
                for element in value:
                    yield (key, element)
            else:
                yield key, value

The biggest question I have is: How well did I emulate a Python container type. Is there anything unintuitive or non-normally for a dictionary-like container type? Also, is there a better name for a dictionary that behaves like this?

\$\endgroup\$
3
\$\begingroup\$
  1. __repr__ should:

    look like a valid Python expression that could be used to recreate an object with the same value (given an appropriate environment).

    Yours cannot possibly do that, as creating the vanilla dictionary loses all but one of the values for each key. Also note you can write:

    repr_ = '{!r}: {!r}'.format(key, element)
    
  2. In terms of emulating container types, you can look at the ABCs to see what needs to be implemented. I would probably have approached this with composition rather than inheritance, leveraging the ABCs for automatic implementation of some methods:

    class HomogeneousDict(MutableMapping):
    

    This helps avoid missing important methods; for example, you don't provide a matching implementation of .values.

  3. In terms of naming, this is generally called a MultiDict.

  4. Given the examples, I'd add the following to the end of the file:

    if __name__ == '__main__':
        import doctest 
        doctest.testmod()
    

    This won't impact anything importing the class, but means that the behaviour is validated against your expectations if you run the file directly, using doctest. This may mean tweaking things slightly to avoid issues with order, etc.; for example:

    File "so.py", line 20, in __main__.HomogeneousDict
    Failed example:
        d
    Expected:
        HomogeneousDict({'c': 3, 'b': 2, 'b': 4, 'a': 1})
    Got:
        HomogeneousDict({'a': 1, 'c': 3, 'b': 2, 'b': 4})
    

    You will also need to update multi-line examples with a leading ellipsis:

    >>> for k, v in d.items():
    ...     print('{} => {}'.format(k, v))
    ...
    c => 3
    
\$\endgroup\$
  • \$\begingroup\$ Thanks for the answer. Believe it or not, "Multidict" was one of the names I considered. But I thought it sounded generic and didn't really convey the dictionary's behavior. But I figured someone had made something like this before, so I guess "Multidict" would work. \$\endgroup\$ – Christian Dean Jul 14 '17 at 12:13
3
\$\begingroup\$
  1. The docstring says:

    A dictionary that allows multiple keys to have
    like values.
    

    but this isn't right. It's a dictionary that allows each key to have multiple values.

  2. Normally we don't need a specialized data structure for this use case because collections.defaultdict is good enough:

    >>> from collections import defaultdict
    >>> d = defaultdict(list) # mapping from key to list of values
    >>> d[1].append(2)
    >>> d[1].append(3)
    >>> d[1]
    [2, 3]
    >>> d[2].append(4)
    >>> d
    defaultdict(<class 'list'>, {1: [2, 3], 2: [4]})
    >>> d[1].pop()
    3
    >>> d[1].pop()
    2
    >>> d[1].pop()
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    IndexError: pop from empty list
    

    Note that if we want different semantics for the collections of values, we can easily switch to defaultdict(set) or defaultdict(deque) or whatever.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.