Add key function to handle unhashable element input in list_update and list_difference_update - #4975
nikolajmunk wants to merge 12 commits into
Conversation
- Restrict input types of list_update and list_difference_update to iterables of hashables. - Add instance check to avoid unnecessary conversion to list/set
|
Optimizing hashable types while still allowing unhashable types would be nice. def list_difference_update(l1: Iterable[T], l2: Iterable[T]) -> list[T]:
if isinstance(l2, Iterator):
l2 = tuple(l2)
with contextlib.suppress(TypeError):
l2 = set(l2)
return [e for e in l1 if e not in l2] |
|
Yep, that works for cases when |
Not really liking this solution: def list_difference_update(l1, l2):
if isinstance(l2, Iterator):
l2 = tuple(l2)
with contextlib.suppress(TypeError):
l2_set = set(l2)
def is_in_l2(e) -> bool:
try:
return e in l2_set
except TypeError:
return e in l2
return [e for e in l1 if not is_in_l2(e)]Here a small testfrom typing import Iterator
import contextlib
def list_difference_update(l1, l2):
if isinstance(l2, Iterator):
l2 = tuple(l2)
with contextlib.suppress(TypeError):
l2_set = set(l2)
def is_in_l2(e) -> bool:
try:
return e in l2_set
except TypeError:
return e in l2
return [e for e in l1 if not is_in_l2(e)]
class MyClass:
def __init__(self, name):
self.name = name
def __repr__(self):
return self.name
__hash__ = None
a = [MyClass("a"), MyClass("b"), MyClass("c")]
b = [1, 2, 3]
c = list_difference_update(a, b)
print(c) |
Haha yes, I have a few solutions that look similar. It's possible to do this in a nice-looking way, but all my attempts have resulted in an algorithm that is markedly slower when all input is hashable, which it will be the vast majority of the time. Catching the edge case is simply very expensive. |
|
Okay, two things now that I've had a change to think about it.
def list_difference_update(
l1: Iterable[T], l2: Iterable[U], key: Callable[[T | U], Hashable] | None = None
) -> list[T]:
key = hash if key is None else key
l2_keys = {key(e) for e in l2}
return [e for e in l1 if key(e) not in l2_keys]Using this implementation, all the following assertions pass: from manim.utils.iterables import list_difference_update, hash_obj
assert list_difference_update([1, 2, 3, 4], [2, 3]) == [1, 4]
assert list_difference_update([[1], [2], [3], [4]], [[2], [3]], key=hash_obj) == [[1], [4]]
assert list_difference_update([1, 2, [3], 4], [2, 3], key=hash_obj) == [1, [3], 4]
assert list_difference_update([1, 2, [3], 4], [2, [3]], key=hash_obj) == [1, 4]
assert list_difference_update([[1], [2], [3]], [2, 3], key=hash_obj) == [[1], [2], [3]]This of course also broadens the possible uses of the function, which we may or may not want. For example, it would be possible to use it to filter out any items in >>> list_difference_update(["a", "b", "ab", "abc", [1, 2, 3], [1, 2]], [[0], "000"], key=len)
['ab', [1, 2]]I haven't profiled this at all yet so maybe the performance cost is simply too high (for "well-behaved" input) to justify doing this, but at least it's possible and doesn't require any manual "is this hashable" checking on the function's part. Thoughts? |
|
I went ahead and pushed the above implementation. It needs a bunch of overloads to capture the intended behavior when This probably needs some tests, I'll see what I can do. |
| l2: Iterable[U], | ||
| *, | ||
| key: Callable[[T | U], Hashable], | ||
| ) -> list[T]: ... |
| l2: Iterable[U], | ||
| *, | ||
| key: Callable[[T | U], Hashable], | ||
| ) -> list[T | U]: ... |
list_update and list_difference_updatelist_update and list_difference_update
- Only special-case key=None since hash() is not exactly equivalent to set semantics - Copyedit docstrings for list_difference_update and list_update
- Reuse common test cases across tests - Test case when key function does nothing - Test case when l1 is unordered - Test that the key function creates correct equivalence classes out of differently-typed objects
Particularly one-shot iterables since those risk getting consumed!
|
Added some new tests for the key function and for handling of various different iterable types, particularly consumable iterables and non-Sequences. This is a lot of testing for two functions, do people think it's overkill? |
Co-authored-by: GniLudio <50866361+GniLudio@users.noreply.github.com>
| @overload | ||
| def list_difference_update( | ||
| l1: Iterable[H1], l2: Iterable[H2], *, key: None = None | ||
| ) -> list[H1]: ... |
| @overload | ||
| def list_update( | ||
| l1: Iterable[H1], l2: Iterable[H2], *, key: None = None | ||
| ) -> list[H1 | H2]: ... |


Overview: What does this pull request change?
This addresses a small regression introduced by #4939, where an unhashable element in
l1and/orl2would throw aTypeError. This PR adds akeyfunction to the signatures oflist_difference_updateandlist_update. When provided, this key function is used to determine uniqueness/equality for all elements inl1andl2. This behavior is similar to that ofsortedandmin/max, which all provide a similar key function parameter.The
list_differencefunction uses its current implementation ifkeyhas its default value ofNone, since it is slightly faster to hash directly than to pass everything through the key function. Otherwise, performance depends entirely on the complexity of the key function.While the Manim library does not currently use either
list_difference_updatenorlist_updatewith unhashable elements anywhere, intended usage is as follows: a user has, say, a list of deeply-nesteddictobjects and they want to remove certain dicts (or dicts with the same contents) from that list.iterables.pycontains the functionhash_objwhich produces a hash from mutable collections, so the user writesto achieve their goal.
However, the flexibility afforded by
keyalso permits more complex behavior. Here are some examples of what is possible by passing various key functions:I've added tests for the new behavior as well as typing overloads expressing the intention that if
list_difference_updateandlist_updateare used without a key function, thenl1andl2should be iterables of hashable elements.Happy to hear any thoughts :)
Reviewer Checklist