I recently challenged myself to write a function in Python with the type signature make_tuple_type(T: type, n: int) -> type
that returns the type of tuples consisting of n
elements of type T
. For example, make_tuple_type(str, 4)
should return typing.Tuple[str, str, str, str]
. My first thought was to use a starred expression:
>>> from typing import Tuple
>>> def make_tuple_type(T: type, n: int) -> type:
... return Tuple[*[int] * n]
File "<stdin>", line 2
return Tuple[*[int] * n]
^
SyntaxError: invalid syntax
>>> # Note that that wasn't a TypeError saying I can't multiply a type by an int,
>>> # because unary * has lower precedence than binary *.
Crumbs. I don't really understand where I can and can't use starred expressions. What about something with eval
?
from typing import Tuple
def make_tuple_type(T: type, n: int) -> type:
return eval(f"Tuple[{(T.__name__ + ', ') * n}]", globals(), locals())
This seems to work, but it's gross, and a clever person might do something like this:
>>> class T:
... pass
>>> T.__name__ = "int"
>>> make_tuple_type(T, 1)
typing.Tuple[int]
Of course, __name__
is just a class attribute, and can therefore be messed with. I sent this problem over to my friend Blake and he suggested the following (more or less):
def make_tuple_type(T: type, n: int) -> type:
return Tuple[tuple([T] * n)]
This is starting to look a lot better, but why is there a call to the tuple
constructor? Why does Tuple's __getitem__
automatically unpack tuples?
This really seems like a natural place for a starred expression, instead of whatever that tuple
junk is. Let's play around with this.
>>> Tuple[int, int, int]
typing.Tuple[int, int, int]
>>> # Phew
>>> Tuple[*[int] * 3]
File "<stdin>", line 1
Tuple[*[int] * 3]
^
SyntaxError: invalid syntax
>>> # Same as above.
>>> Tuple[(*[int] * 3)]
File "<stdin>", line 1
Tuple[(*[int] * 3)]
^^^^^^^^^^
SyntaxError: cannot use starred expression here
>>> # Okay, but I can definitely use a starred expression in a function call,
>>> # so I'll just call __getitem__ directly.
>>> Tuple.__getitem__(*[int] * 3)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib/python3.10/typing.py", line 312, in inner
return func(*args, **kwds)
TypeError: _TupleType.__getitem__() takes 2 positional arguments but 4 were given
>>> # Why didn't that work? Shouldn't the function's arguments all end up in args?
>>> # This isn't a problem with other variadic functions, like print:
>>> print(*[int] * 3)
<class 'int'> <class 'int'> <class 'int'>
>>> # I am now confused.
>>> Tuple.__getitem__((*[int] * 3,))
typing.Tuple[int, int, int]
>>> # It worked! But why?
>>> # I think this exaplins Blake's call to the `tuple` constructor
At this point, I think I have a mental model.
Places I can use starred expressions:
- inside of list brackets,
- inside of function call parentheses,
- inside of tuple parentheses if the starred expression is followed by a comma.
Places I can't use starred expressions:
- everywhere else
For some reason, print
and Tuple.__getitem__
don't treat starred expressions the same way, even though they both take a variable number of arguments. I don't know why. If you do, please let me know.
I now present what I imagine is thought was the smallest and least readable answer to my original challenge:
def make_tuple_type(T, n):
return Tuple[(*[T]*n,)]
Blake later explained that the tuple
in his implementation gets automatically unpacked because there is no distinction between x[a, b]
and x[(a, b)]
. Try it. When you pass multiple things to a __getitem__
, they get packed up into a tuple. When you pass one thing to a __getitem__
, it's left alone. This explains why there is a distinction between x[a]
and x[(a,)]
.
🤮
EDIT: I sent this to my friend Nate, and he pointed out that you can just return Tuple[(T,) * n]
. This is probably the best way to do it, and makes the problem a whole lot less interesting.