I disagree strongly, based on 20 years of using Python without annotations and ~5 years of seeing people ask questions about how to do advanced things with types. And based on reading Python code, and comparing that to how I feel when reading code in any manifest-typed language.
>Reading Python functions in isolation, you might not even know what data/structure you’re getting as input
I'm concerned with what capabilities the input offers, not the name given to one particular implementation of that set of capabilities. If I have to think about it in any more detail than "`ducks` is an iterable of Ducklike" (n.b.: a code definition for an ABC need not actually exist; it would be dead code that just complicates method resolution) I'm trying to do too much in that function. If I have to care about whether the iterable is a list or a string (given that length-1 strings satisfy the ABC), I'm either trying to do the wrong thing or using the wrong language.
> if there’s something that muddles up immediate clarity it’s ambiguity about what data code is operating on.
There is no ambiguity. There is just disregard for things that don't actually matter, and designing to make sure that they indeed don't matter.
IMO this is the source of much of the demand for type hints in Python. People don't want to write idiomatic Python, they want to write Java - but they're stuck using Python because of library availability or an existing Python codebase.
So, they write Java-style code in Python. Most of the time this means heavy use of type hints and an overuse of class hierarchies (e.g. introducing abstract classes just to satisfy the type checker) - which in my experience leads to code that's twice as long as it should be. But recently I heard more extreme advice - someone recommended "write every function as a member of a class" and "put every class in its own file".
> But recently I heard more extreme advice - someone recommended "write every function as a member of a class" and "put every class in its own file".
Good heavens.
You can specify exactly that and no more, using the type system:
def foo(ducks: Iterable[Ducklike]) -> None:
...
If you are typing it as list[Duck] you're doing it wrong.I keep seeing people trying to wrap their heads around various tricky covariance-vs-contravariance things (I personally can never remember which is which), or trying to make the types check for things that just seem blatantly unreasonable to me. And it takes up a lot of discussion space in my circles, because two or more people will try to figure it out together.
No, you do gain information from it: that the function takes an Iterable[Ducklike].
Moreover, now you can tell this just from the signature, rather than needing to discover it yourself by reading the function body (and maybe the bodies of the functions it calls, and so on ...). Being able to reason about a function without reading its implementation is a straightforward win.
I already had that information. I understand my own coding style.
>Being able to reason about a function without reading its implementation is a straightforward win.
My function bodies are generally only a few lines, but my reasoning here is based on the choice of identifier name.
Yes, it takes discipline, but it's the same kind of discipline as adding type annotations. And I find it much less obnoxious to input and read.
Good for you, but you're not the only person working on the codebase, surely.
>My function bodies are generally only a few lines, but my reasoning here is based on the choice of identifier name.
Your short functions still call other functions which call other functions which call other functions. The type will not always be obvious from looking at the current function body; often all a function does with an argument is forward it along untouched to another function. You often still need to jump through many layers of the call graph to figure out how something actually gets used.
An identifier name can't be as expressive as a type without sacrificing concision, and can't be checked mechanically. Why not be precise, why not offload some mental work onto the computer?
>Yes, it takes discipline, but it's the same kind of discipline as adding type annotations.
No, see, this is an absolutely crucial point of disagreement:
Adding type annotations is not "discipline"!
Or at least, not the same kind of discipline as remembering the types myself and running the type checker in my head. The type checker is good because it relieves me of the necessity of discipline, at least wrt to types.
Discipline consumes scarce mental effort. It doesn't scale as project complexity grows, as organizations grow, and as time passes. I would rather spend my limited mental effort on higher level things; making sure types match is rote clerical work, entirely suitable to a machine.
The language of "discipline" paints any mistake as a personal/moral failure of an individual. It's the language of a blame-culture.
I actually am. But I've also read plenty of non-annotated Python code from strangers without issue. Including the standard library, random GitHub projects I gave a PR to fix some unidiomatic expression (defense in depth by avoiding `eval` for example), etc. When the code of others is type-annotated, I often find it just as distracting as all the "# noqa: whatever" noise not designed to be read by humans.
And long functions are vastly more mentally taxing.
> often all a function does with an argument is forward it along untouched to another function. You often still need to jump through many layers of the call graph to figure out how something actually gets used.
Yes, and I find from many years of personal experience that this doesn't cause a problem. I don't need to "figure out how something actually gets used" in order to understand the code. That's the point of organizing it this way. This is also one of the core lessons of SICP as I understood it. The dynamic typing of LISP is not an accident.
> An identifier name can't be as expressive as a type without sacrificing concision
On the contrary: it is not restricted to referring to abstractions that were explicitly defined elsewhere.
> Why not be precise, why not offload some mental work onto the computer?
When I have tried to do it, I have found that the mental work increased.
> No, see, this is an absolutely crucial point of disagreement
It is.
Type checking on the other hand makes duck typing awesome. All the flexibility, none of the surprises.
If you passed a string expecting it to be treated as an atomic value rather than as a sequence (i.e. you made a mistake and want a type checker to catch it for you), there are many other things you can do to avoid creating that expectation in the first place.
JSON = float | bool | int | str | None | list[“JSON”] | dict[str, “JSON”]Unfortunately Python’s type system is unsound. It’s possible to pass all the checks and yet still have a function annotated `int` that returns a `list`.
i : int | list[int] = 0
def foo() -> None:
global i
i = []
def bar() -> int:
if isinstance(i, int):
foo()
return i
return 0
print(type(bar()))Don't do what?
- Don't write unsound code? There's no way to know until you run the program and find out your `int` is actually a `list`.
- Don't assume type annotations are correct? Then what's the point of all the extra code to appease the type checker if it doesn't provide any guarantees?
You may as well argue that unit tests are pointless because you could cheat by making the implementations return just the hardcoded values from the test cases.
class C:
def __init__(self) -> None:
self.i : int | list[int] = 0
def foo(self) -> None:
self.i = []
def bar(self) -> int:
if isinstance(self.i, int):
self.foo()
return self.i
return 0
print(type(C().bar())) class C {
i: number | number[];
constructor() {
this.i = 0;
}
foo(): void {
this.i = [];
}
bar(): number {
if (typeof this.i === 'number') {
this.foo();
return this.i;
}
return 0;
}
}
console.log(typeof new C().bar());
It seems to be a problem with "naked" type unions in general. It's unfortunate.If you want to be able to change the type of something at runtime, static analysis isn't always going to be able to have your back 100% of the time. Turns out that's a tradeoff that many are willing to make.
[1] Unfortunately, many important 3rd party libraries aren't typed. I try to wrap them in type-safe modules or localise their use, but if your codebase is deeply dependent on them, this isn't always feasible.