Type hints without value assignment in Python

Question:

I was under the impression that typing module in Python is mostly for increasing code readability and for code documentation purposes.

After playing around with it and reading about the module, I’ve managed to confuse myself with it.

Code below works even though those two variables are not initialized (as you would normally initialize them e.g. a = "test").

I’ve only put a type hint on it and everything seems ok. That is, I did not get a NameError as I would get if I just had a in my code NameError: name 'a' is not defined

Is declaring variables in this manner (with type hints) an OK practice? Why does this work?

from typing import Any

test_var: int
a: Any

print('hi')

I expected test_var: int to return an error saying that test_var is not initiated and that I would have to do something like test_var: int = 0 (or any value at all). Does this get set to a default value because I added type hint to it?

Asked By: skcrypto

||

Answers:

Python will not initialize a variable automatically, so that variable doesn’t get set to anything. a: int doesn’t actually define or initialize the variable. That happens when you assign a value to it. The typings really only act as hints to the IDE, and have no practical effect without assigning a value during compilation or runtime.

Answered By: Devin Sag

It is fairly straightforward, when you consider the namespaces involved. This is hinted at by the fact that you get a NameError, when you actually try and do anything with test_var, such as passing it to a function (like print). It tells you that the name you used is not known to the interpreter.

What does variable assignment do?

What happens, when you assign a value to a variable in the global namespace of a module for the first time, is it gets added to that module’s globals dictionary with the key being the variable name as a string and the value being, well, its value. You can see this dictionary by calling the built-in globals function in that module:

(I will be using pprint in some of the following examples to make the output easier to read.)

from pprint import pprint

a = 1

pprint(globals())

The output looks something like this:

{'__annotations__': {},
 ...
 '__name__': '__main__',
 ...
 'a': 1,
 ...}

There are various other keys in the globals dictionary that we can ignore for this matter. But you can see that the key 'a' appears in it and its associated value is the 1 we assigned to the variable named a before. (In case this is not obvious, the order of statements matters; if you check the output of globals() before assigning a value to a, there will be no entry in that dictionary for it.)

What does annotation do?

When you look closer at that dictionary, you’ll find another interesting key there, namely __annotations__. Right now, its value is an empty dictionary. But I bet you can already guess, what will happen, if we annotate our variable with a type:

from pprint import pprint

a: int = 1

pprint(globals())

The output:

{'__annotations__': {'a': <class 'int'>},
 ...
 'a': 1,
 ...}

When we add a type hint to (i.e. annotate) a variable, the interpreter adds that name and type to the relevant __annotations__ dictionary (see annotation assignment docs); in this case that of our module. By the way, since the __annotations__ dictionary is in our global namespace we can access it directly:

a: int = 1

print("a" in globals())        # True
print("a" in __annotations__)  # True

As you can see, __annotations__ is a variable like any other, except that it is present by default, without you having to manually assign anything to it. It gets updated any time a variable is annotated in its scope.


Side note

There is nothing forcing us to annotate a correctly. We can assign a wrong type to it and Python will gladly add that to the __annotations__ dictionary and this incorrect annotation will have no effect whatsoever on the value assignment:

a: str = 1

print(a)                # 1
print(type(a) is int)   # True
print(__annotations__)  # {'a': <class 'str'>}

In fact, the interpreter’s laissez-faire policy regarding annotations goes so far that we can use literally anything for the annotation as long as it is a syntactically valid expression, even though it may make zero sense semantically. For example a complex number literal:

a: 2+1j = 1

print(a)                # 1
print(type(a) is int)   # True
print(__annotations__)  # {'a': (2+1j)}

You can even call arbitrary functions in the annotation itself:

a: bin(3) = 1

print(a)                # 1
print(type(a) is int)   # True
print(__annotations__)  # {'a': '0b11'}

But let us get back to the topic.


Can you annotate without assigning?

Finally, what happens, if we just annotate without assigning a value to a variable?

a: int

print("a" in globals())        # False
print("a" in __annotations__)  # True

And that is the explanation of why we get an error, if we try and e.g. print out a in this example, but otherwise don’t get any error. The code merely told the interpreter (and any static type checker) about the annotation, but it assigned no value, thus not creating an entry in the global namespace dictionary.

It makes sense, if you think about it: What should be set as the value for a in that namespace? It has no value (not even None or NotImplemented or anything like that). To the interpreter the a: int line merely meant the creation of an entry in the __annotations__ of our module, which is perfectly valid.

Runtime meaning of annotations

I would also like to stress the fact that the annotation is not meaningless for the interpreter and thus runtime, as some people often claim. It is admittedly rarely used, but as we just saw in the example, you can absolutely work with annotations at runtime. Whether or not this is useful is obviously up to you. Some packages like Pydantic or the standard library’s dataclasses actually rely heavily on annotations for their purposes.

The value set in the __annotations__ dictionary in our example is actually a reference to the int class. So we can absolutely work with it at runtime, if we want to:

a: int

a_type = __annotations__["a"]
print(a_type is int)  # True
print(a_type("2"))    # 2

You can play around with this concept in class namespaces as well (not just with the module namespace), but I’ll leave this as an exercise for the reader.

So to wrap up, for a name to be added to any namespace, it must have a value assigned to it. Not assigning a value and just providing an annotation is totally fine to create an entry in that namespace’s __annotations__.

Answered By: Daniil Fajnberg