We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Type System as a Design Tool step 16 of 24
Accept broad, return narrow: group_by and merge_counts
“Take Iterable here, not list“ is the most common concrete piece of feedback
in a review of an otherwise-fine function. It is worth internalising the rule
rather than collecting the comment.
Accept broad:
| you do this to the argument | annotate it |
|---|---|
| iterate it once |
Iterable[T] |
index it, slice it, or need len |
Sequence[T] |
| look keys up, read values |
Mapping[K, V] |
| mutate it |
list[T] / dict[K, V] — and invariance is correct |
Return narrow: hand back list[T] and dict[K, V]. The caller can widen
what you give them to whatever they like; they cannot narrow what you demanded.
A function returning Iterable[str] forces every caller who needs an index to
call list() on it, and forces every reviewer to ask whether it is single-use.
The payoff is concrete: a list, a tuple, a set, a generator expression, a
dict.items() view and a MappingProxyType are all valid arguments to a
function annotated broadly, and none of them are valid to one annotated
list[...].
The task
def group_by[T, K](items: Iterable[T], key: Callable[[T], K]) -> dict[K, list[T]]: ...
def merge_counts(sources: Iterable[Mapping[str, int]]) -> dict[str, int]: ...
def solve(
words: list[str],
counts: list[dict[str, int]],
trap: str,
) -> tuple[list[str], list[list[str]], dict[str, int], list[str]]: ...
group_by returns a dict from key to the list of items with that key.
Insertion order is preserved for both the groups (first-seen key order) and the
members within a group.
merge_counts sums values across sources, per key. It takes
Iterable[Mapping[str, int]] — note both levels are broad. A module constant
HOUSE_COUNTS: Final[Mapping[str, int]] = MappingProxyType({"z": 1})
is merged in on every call, and MappingProxyType is not a dict. If you
annotate the parameter Iterable[dict[str, int]], that call fails; if you
annotate it list[...], the [*counts, HOUSE_COUNTS] splat fails too.
solve returns four things:
-
the group keys, in first-seen order — grouping
wordsby first character (word[:1], so the empty string groups under""); - the corresponding group members;
-
merge_counts([*counts, HOUSE_COUNTS]); -
the distinct characters of
trap, in first-seen order.
The trap, taught explicitly
Element 4 exists because of this:
group_by(trap, lambda char: char)
trap is a str. A str is a Sequence[str], so this type-checks
perfectly, T solves to str, and the function groups characters. There is
no error, no warning, and no way for the checker to know you meant to pass a
list of words.
This is why process("abc") where process(items: Iterable[str]) silently
iterates three characters instead of failing. It is one of the very few places
where accepting the broad type costs you something, and the mitigation is
knowing it exists — plus, if the distinction matters, taking
Sequence[str] | None and rejecting str explicitly, or naming the parameter
words so the call site reads wrong.
Types
group_by needs two type parameters, [T, K], and both must survive: grouping
a list[str] by a Callable[[str], str] yields dict[str, list[str]], not
dict[Any, list[Any]].
--disallow-any-generics rejects a bare Callable, Mapping or dict
anywhere in your signatures.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.