In line with @NeilGirdhar’s suggestion, I assert that the right operand of subtractive operations in dictionaries should be confined exclusively to sets. This approach would promote a more intuitive and consistent structure for these operations.
When performing subtractive operations with dictionaries, several complexities arise, such as value priority and order. Using sets instead solves this problem; the order and values from the dictionary should be used because sets don’t have any order or value that corresponds to a key.
Allowing containers (a more general type than iterable) as the right operand can also lead to ambiguity, similar to what is encountered in loosely typed languages.
To illustrate these complexities, consider the following examples:
# The following operations would be valid if containers were allowed as the right operands:
{} - [] # I think I've seen this on wtfjs...
asdict(mydataclass) & os.listdir('my_dir') - range(100)
# This dict-list operation is much slower (about 4 times) than the dict-set one.
dict.fromkeys(range(99950, 100050)) & list(range(100000, 100100))
# This operation is particularly confusing since it appears to intersect keys but doesn't do so.
{i: i * 2 for i in range(100)} & (i ** 2 for i in range(100))
# expected: {0: 0, 1: 2, 4: 8, 9: 18, 16: 32, 25: 50, 36: 72, 49: 98, 64: 128, 81: 162}
# result: {0: 1, 1: 2}
These examples highlight the potential confusion and performance issues that can arise when allowing containers as the right operands.
Sets are designed with limitations on set (binary) operations to be only between sets; that could be another reason not to permit this functionality on subtractive operations of dictionary.
{1, 3} - range(2, 100) # TypeError: unsupported operand type(s) for -: 'set' and 'range'
{1: 2, 2: 4}.keys() - [2, 3] # This should be forbidden.
If we categorize the types of operands used in the operations, it looks like this:
- Additive operations:
- Subtractive operations:
It’s neat and consistent.
A Python implementation of this proposal (be conscious that this implementation returns a dictionary, not NewDict):
from collections.abc import Set
class NewDict(dict):
def __and__[K, V](self: dict[K, V], other: Set) -> dict[K, V]:
assert isinstance(other, Set), 'Real implementation should raise a TypeError.'
return {k: v for k, v in self.items() if k in other}
def __sub__[K, V](self: dict[K, V], other: Set) -> dict[K, V]:
assert isinstance(other, Set), 'Real implementation should raise a TypeError.'
return {k: v for k, v in self.items() if k not in other}
def __xor__[K1, V1, K2, V2](self: dict[K1, V1], other: dict[K2, V2]) -> dict[K1 | K2, V1 | V2]:
return {k: v for k, v in self.items() if k not in other
| {k: v for k, v in other.items() if k not in self}}
# or alternatively, (self - other.keys()) | (other - self.keys())
# __rand__ = __and__ # Maybe implementing __rand__ makes sense?
Here are some use cases:
# Using set for the right operand
NewDict({i: i * 2 for i in range(100)}) & {i ** 2 for i in range(100)}
Instead of allowing KeysView to be used as a right operand, pass KeysView instead.
NewDict({i: i * 2 for i in range(1, 10)}) & {i: i * 2 for i in range(5, 15)}.keys()
# Examples @Erotemic proposed previously work well too.
def func(**kwargs):
print(f'Called with: {kwargs = }')
kwargs = NewDict(kwargs)
expected = {'foo', 'bar', 'baz'}
config = kwargs & expected
unexpected = kwargs - expected
if unexpected:
raise ValueError(f'Unexpected {unexpected = }!')
print('-- Call 1 --')
func(foo=1, baz=3)
print('-- Call 2 --')
func(bar=1, buz=3)