Reputation: 2922
I remember reading, or hearing somewhere that for a function, input types should be as generic as possible (Iterable
over list
), but return types should be as specific as possible.
Is this written down somewhere official that I can reference when this comes up in team discussions? Or am I crazy and this isn't actually a guideline?
Upvotes: 0
Views: 138
Reputation: 71454
A quick google hasn't found anything "official", but the benefits seem self-evident to me, so I'll take a crack at explaining them and you can decide whether the explanation sounds official enough.
The main benefit of this is in making the calling code simple. Suppose you have a silly function like this:
def add_ints(nums: list[int]) -> int:
return sum(nums)
This works fine, but what if your caller has a tuple[int, int, int]
?
nums = (1, 2, 3)
print(add_ints(nums)) # fails
print(list(add_ints(nums))) # works
This is silly; there's no good reason for them to have to convert their tuple to a list, other than the fact that you decided to annotate your function to require one. It's extra code to write (and read) and it'll also make it a little slower at runtime. You should instead define add_ints
to take an Iterable[int]
:
from typing import Iterable
def add_ints(nums: Iterable[int]) -> int:
return sum(nums)
A second benefit is that it is easier to infer from the type annotation what the function does. If the function takes a list
, there is a possibility that it might mutate it, since the list
interface allows mutation; an Iterable
isn't mutable, so we can now tell at a glance that even if we pass add_ints
a list
, it isn't going to try to modify it -- and mypy
will enforce that within the implementation of add_ints
as well!
This is just the corollary to the above. Suppose you have:
def nums_up_to(top: int) -> Iterable[int]:
return list(range(top))
This is technically valid -- but what if our caller needs a list? Again, we're forcing them to do needless checking/conversion:
nums = nums_up_to(5)
nums.append(add_ints(nums)) # fails, can't append to an iterable
nums = nums_up_to(5)
assert isinstance(nums, list)
nums.append(add_ints(nums)) # works because we narrowed the type with that assert
nums = list(nums_up_to(5))
nums.append(add_ints(nums)) # works because we explicitly constructed a list
Again, this is much more easily fixed by just improving the type annotation:
def nums_up_to(top: int) -> list[int]:
return list(range(top))
nums = nums_up_to(5)
nums.append(add_ints(nums)) # fine!
It's worth remembering that applying these guidelines is something that doesn't necessarily need to be done rigorously up front -- widening a parameter type and narrowing a return type are both backwards-compatible changes as far as the caller is concerned.
In practice I usually find myself applying these guidelines when I'm in the process of writing calling code, I find that I'm doing some unnecessary type conversion, and I resolve the issue by loosening/tightening the annotation in the dependency rather than working around it in my own code, trusting that mypy will let me know if my annotation doesn't match the actual implementation.
Upvotes: 4