We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Type System as a Design Tool step 12 of 24
Bounds versus constraints: clamp and dedupe
One syntax, two entirely different mechanisms, and the wrong choice silently throws away subclass information from every return type in the module.
def f[T: int](x: T) -> T: ... # BOUND — T is int or any subtype
def g[T: (int, str)](x: T) -> T: ... # CONSTRAINTS — T is exactly int, or exactly str
The colon-with-one-type is a bound: an upper limit. T solves to the most
precise type the call site actually supplied, as long as it is a subtype of the
bound. Pass a bool (an int subclass) and T is bool.
The colon-with-a-tuple is a constraint list. T must solve to exactly one
of the listed types — the checker tries each in turn and collapses the result.
Pass a bool and T becomes int, discarding the subclass. Pass a MyStr and
T becomes str. Every downstream annotation loses the precision.
That is the trap: [T: (int, float)] looks like it means “int or float”, the
way a union does, and it does not. A union is a type. Constraints are a
substitution rule.
Constraints have one thing bounds cannot do: they reject mixed calls.
clamp(1, 0.0, 2) cannot solve — T would have to be int and float
simultaneously, and there is no single listed type that fits. A bound of
int | float would happily accept it and produce nonsense.
The error message when you hit that, though, is famously unhelpful:
Value of type variable "T" of "clamp" cannot be "object"
object is the join the checker fell back to. It does not say “you mixed int
and float”. Recognising that message is the lesson.
One deprecation worth knowing: AnyStr — the stdlib’s own constrained TypeVar
— was deprecated in 3.13 with removal scheduled for 3.18, and the documented
replacement is a PEP 695 constrained parameter, [A: (str, bytes)].
The task
def clamp[T: (int, float)](value: T, low: T, high: T) -> T: ...
def dedupe_sorted[T: Hashable](items: Iterable[T]) -> list[T]: ...
def solve(
numbers: list[int],
reals: list[float],
low: int,
high: int,
words: list[str],
) -> tuple[list[int], list[float], list[str]]: ...
clamp — constraints. Returns value limited to [low, high], and raises
ValueError("low must not exceed high") if the range is inverted. Because T
is constrained, clamp(1, 0, 10) returns an int and clamp(1.0, 0.0, 10.0)
returns a float, but clamp(1, 0.0, 10) does not type-check at all.
dedupe_sorted — a bound. Removes duplicates while preserving first-seen
order, so an already-sorted input stays sorted. The Hashable bound is what
lets you use a set for the seen-check; it is a bound rather than a constraint
because you want the concrete element type to survive: dedupe_sorted of a
list[MyStr] must reveal list[MyStr], not list[str]. Had you written
[T: (str, bytes)] instead, it would reveal list[str] — and every caller
would lose the subclass.
solve clamps numbers into [low, high], clamps reals into the same
range converted to floats, and dedupes words.
Types
The float conversion in solve is not busywork: clamp(r, low, high) with
r: float and low: int is precisely the mixed call the constraints exist to
reject. You have to write float(low) and float(high), and being made to
write it is the type system telling you a unit conversion is happening. That is
the behaviour you want in code that mixes money, timestamps and ratios.
--disallow-any-generics also applies here: Iterable without a parameter is
an error.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.