Hints and Nudges
Which of these identically implemented Python functions has the best signature?1
def add_new_item_to_shopping_list(
list_: tuple[str, ...],
item: str
) -> tuple[str, ...]:
return tuple((new_item, *shopping_list))
def anitsl(l: tuple[str, ...], i: str) -> tuple[str, ...]:
return tuple((i, *l))
def add_item(shopping_list: tuple[str, ...], new_item: str) -> tuple[str, ...]:
return tuple((shopping_list, *new_item))
Well, the second option is obviously terrible (though some style guides would claim that the only problem is that there are five too many letters in the function name).
The third one also appears at first to not be particularly great. The name “add_item
” is quite generic: what exactly is being added? At least the first one is exact, if overly verbose.
However, I argue add_item
is actually better than add_new_item_to_shopping_list
for the following reasons.
Firstly functions are almost never taken in isolation. If you see add_item
in a codebase it is likely
either bound to a class or go-to-definition will show you the import line which will clarify the intent2.
But the kicker for me isn’t actually the name, its the arguments. This is something I’ve noticed doing now that my NeoVim
has inlay hints enabled.
To illustrate, which of these two snippets of code contains a bug? Both have the exact same function add_item
.
Is it this one?
Or this one?
Its the first one: shopping_list=todo_list
is the dead giveaway!
The hint here is provided inline by my LSP attached to the editor (basedpyright
at the time of writing) and shows the argument name if it differs from the variable name.
The result of this is that I find myself tending to write descriptive and semantic argument names3 and then matching these at the call points to avoid seeing the inlay hint. This makes a huge difference when types are either not available (because Python4), or too verbose. For example, we could have done a similar thing with
ShoppingList = tuple[str, ...] # or newtypes?
ShoppingListItem = str
def add_item(shopping_list: ShoppingList, new_item: ShoppingListItem) -> ShoppingList:
return tuple((shopping_list, *new_item))
but then the burden is shifted to understanding what ShoppingList
means as a type in disguise.
I found this useful in a recent project where I had offsets in bytes and offsets in characters (not the same due to UTF-8) for some text I was working with. This was a rust project, so I could certainly have had
type OffsetInBytes(usize);
type OffsetInChars(usize);
and used the type system but found variables offset_in_bytes: usize
and offset_in_chars: usize
so much easier to work with5. And, with appropriately named arguments and inlay hints, I never mixed the two up!
Postscript
A possible alternative is
def add_item(*, shopping_list: tuple[str, ...], new_item: str) -> tuple[str, ...]:
return tuple((shopping_list, *new_item))
Here we use the *
marker to force all the arguments to be passed by keyword.
That is, you have to specify exactly what you are passing:
add_item(todo_list, new_item) # Raises a TypeError
add_item(shopping_list=todo_list, new_item=new_item) # Runs, but is obviously wrong
However, despite preventing errors by forcing you to type something silly like shopping_list=todo_list
, this is incredibly verbose when used “correctly” as I described above as you end up having a lot of argument_name=argument_name
in your code.
Its a trade-off, but I find this too much.
-
I’ve used typing in all of them, even the insane one, because… you always should try, even if Python makes this a futile battle. ↩︎
-
If your import line is
from utils import add_item
you only have yourself to blame. ↩︎ -
This helps with disambiguating the function: if the signature is
add_item(shopping_list, new_item)
then even without types or other signals, its pretty clear what the function does. ↩︎ -
Yes, Python has type hints, but they are not particularly expressive yet and often run contrary to a lot of established code bases that bought into duck typing or use trickery the type system can’t handle. ↩︎
-
For instance you don’t have my usual problem with rust newtypes: having to re-export all the functionality of the wrapped type. ↩︎