Python is considered a dynamically-typed language, meaning that the type of a variable can change during runtime if its value is changed to something else. For example:
x = 7 print(type(x)) # prints: <class 'int'> x = "Hello World" print(type(x)) # prints: <class 'str'>
In the code above, the variable x
contains an integer type value when it is originally set. Afterward, the value is changed to "Hello World"
, which is a String type value. Ordinarily when we write Python code, we do not need to declare the specific types of the variables; instead the Python interpreter will keep track of the current type internally, based on the values that we assign to the variable.
The opposite of this would be a statically-typed language such as C or Java. In a statically-typed language, the developer must explicitly declare the type when a variable is created, and the type is not able to change simply by changing the value. If the user wants the type to change, they must use a specific conversion or casting function to create a new variable of the desired type.
What is Typing Information?
Type hints are an optional extra bits of code that declare the intended types for function arguments and returns. Adding type hints to Python code is basically sharing the developers intentions in the code. It is stating something like "the person who wrote this code says that this variable is a String and should always remain a String. If you are writing code that interacts with this it is safe for you to assume this variable will be a String." Since Python is a dynamically-typed language, it is not required for us to declare types like this, but there are some benefits of doing so even though it isn't required.
Benefits of Typing
The Python and CircuitPython interpreters ignore typing information. So it will make no difference when your code is running whether or not you've included types. The real benefits of typing are felt during the development phase, rather than at runtime. Types allow you, the developer, to be more certain about what kinds of values are expected to be contained by variables. This can make it easier to spot bugs in the code before it's executed. For instance:
x = "Hello World" print(x.lower()) # prints: hello world x = 7 print(x.lower()) # raises AttributeError
In the code above, the x
variable is initially assigned the value "Hello World"
, which is a String type. In Python, Strings have a function called lower()
that returns the lower-cased version of the String. The first print()
statement executes successfully. Afterward the value is changed to 7
, an integer type which does not contain the lower()
function. So when the second print()
statement tries to execute, it causes an AttributeError
to be raised, which will crash the program if it's not caught. Type hints try to help us spot this type of error earlier, ideally before we've even attempted to run the program at all. IDEs and other programs can parse the code with type hints and attempt to warn us ahead of time when situations like this arise.
In the image above, PyCharm IDE has highlighted part of the code and shows a warning to the programmer that they are attempting to access the lower()
function, which does not exist for the integer type; it's likely to cause an error if it's executed before resolving this problem. PyCharm and other IDEs use the typing information provided to double check our work and warn us when we accidentally try to use a variable in an invalid way.
So now you know what type hints are and why they are beneficial, but how do you actually add type hints to your code? For CircuitPython libraries, we are primarily concerned with types for function arguments and returns. CPython (desktop computer Python) supports other types beyond them. But these are the ones we are focused on right now.
Function Argument Types
To add types to function arguments, we code a colon :
after the argument name and then put the type after that. For example:
def unhexlify(hexstr: str): # code that would be here isn't relevant to the example pass
In the example above, the argument hexstr
has its type declared as str
, which is short for String. So the type hint is indicating that when you call unhexlify()
, you should always pass it a String type variable as the hexstr
argument. If there is more than one argument, then each argument should get its own colon and type declaration; if you are providing default values for the arguments, they are coded with a equals sign after the type. Here is another example showing a more complex function with its arguments typed:
def __init__( self, i2c_bus: busio.I2C, address: int = 0x0C, gain: int = GAIN_1X, resolution: int = RESOLUTION_16, filt: int = FILTER_7, oversampling: int = OSR_3, debug: bool = False, ):
In this example, there are 5 integer type arguments, 1 boolean type argument, and 1 busio.I2C object argument. Many of the arguments are given default values of constant variables declared in the class that this comes from. Note that the first argument self
does not receive a type.
Function Return Types
To code function return types, we use a hyphen and an angle bracket to make an arrow that points to the type that will be returned by the function. This is coded after the parentheses containing the arguments, and before the colon that indicates the beginning of the functions code definition.
def last_status(self) -> int: # code that would be here isn't relevant to the example pass
The above function has been declared to return an integer type value. So any code using this function should be expecting to receive an integer value and should not try to treat it as a String or anything else.
One thing to keep in mind while coding Python classes is that the __init__()
function always returns the None
type. Here is an example of an __init__()
function with its return type coded:
def __init__(self, spi: busio.SPI, cs: digitalio.DigitalInOut) -> None: # code that would be here isn't relevant to the example pass
CircuitPython-Specific Considerations
In CPython ("normal" desktop Python), there is a built-in module called typing
which contains some helper classes sometimes used when declaring types. typing
was introduced with Python version 3.5. CircuitPython does not currently have this module, so any code that attempts to import it will raise an ImportError
if it runs on a CircuitPython microcontroller. This may sound problematic, but it actually ends up working out in our favor. We can catch this exception and ignore it, and since types make no difference at runtime, our code will still work properly. The benefit is that we can use this exception as an indicator that the code is running on a microcontroller and choose to ignore the error without importing any of the other classes used only for typing, which will save some precious RAM on the microcontroller.
try: # imports used only for typing from typing import Tuple # <- this line causes the error # any imports below this won't happen if the error gets raised from circuitpython_typing import ReadableBuffer from busio import I2C except ImportError: pass # ignore the error
In the CircuitPython libraries, we code the imports as shown. The first import should always be from the typing
module. The remaining imports after the first one are other classes that may actually exist in CircuitPython or in CircuitPython-compatible libraries such as circuitpython_typing
.
By putting the typing
import first, it will cause the ImportError
to be raised before any of the classes that might actually exist get imported, preventing them from being imported and consuming RAM that won't be used when the program executes.
Finding the Correct Types
Now you know why type hints are helpful, and you know the syntax used to code the type hints. But you may be wondering how do you figure out which type an argument or return is supposed to be so that you can code the hint? If you are authoring a brand new library from scratch, you probably will know what types are expected since you are the one writing the code and choosing which things get passed in as arguments and which things get returned from functions.
If you're adding type hints to an existing library, you'll have to put on your detective's hat and look for context clues and other evidence that suggests which type any given argument or return type is supposed to be.
Common Places to Look
No two libraries or functions are the exact same, so there is no "one size fits all" approach to determining types. However there are a few common places to look around in the code that are likely to provide good clues about the types.
Docstrings
If the code contains docstring comments, this might already have the type listed in them and you can use the type specified in the docstring for the type hint.
def _get_pixel(self, xpos, ypos): """ Get value of a matrix pixel :param int xpos: x position :param int ypos: y position :return: value of pixel in matrix :rtype: int """ # code that would be here isn't relevant to the example pass
In this function, the docstring comment does document all of the arguments and their types as well as the return type. In this case, the detective work is mostly done for us, we just need to put the types specified by the docstring into our type hints using the syntax noted above.
Here's how it would look after adding the type hints:
def _get_pixel(self, xpos: int, ypos: int) -> int: """ Get value of a matrix pixel :param int xpos: x position :param int ypos: y position :return: value of pixel in matrix :rtype: int """ # code that would be here isn't relevant to the example pass
Function Definition
If there is no docstring comment, or if it does not contain the argument and return types, then you'll have to dive a bit deeper. The next place to look is inside of the function definition. You're looking for the lines of code that use the argument variables, and then from those lines of code you may be able to determine what the type is meant to be.
def scroll(self, delta_x, delta_y): if delta_x < 0: shift_x = 0 xend = self.width + delta_x dt_x = 1 else: shift_x = self.width - 1 xend = delta_x - 1 dt_x = -1 if delta_y < 0: y = 0 yend = self.height + delta_y dt_y = 1 else: y = self.height - 1 yend = delta_y - 1 dt_y = -1
In this function, we see that there are two arguments that we want to find the type for, delta_x
and delta_y
. When we look in to the definition code for the function, we see a couple of if
statements that are comparing delta_x
and delta_y
to 0
using the less than operator <
. We also find some lines of code performing mathematical operations with delta_x
and delta_y
such as:
xend = delta_x - 1
In this code, it appears that delta_x
and delta_y
are being treated as numbers. Numbers can be compared to other numbers with greater than or less than, and they can also be added and subtracted to other numbers. None of the numbers shown contain decimals, and there is no division operator being used, so it's a good guess that these numbers are specifically integers which use the abbreviation int
for the type. If we found decimals, or if the variable we are trying to type came from the division operator such as:
some_var = 100/3
then it may be more likely that our numbers could be of the float
type rather than int
. You'll have to look for context clues in the code like this to try to make the most educated guess possible based on the information you find.
Function Usage
If you don't find concrete evidence in the function definition, another place to look is in the code that is calling the function. This might be inside of an example file, or perhaps in another source code file within the library if it is a multi-file package; sometimes it could even be a usage in the same file where it's defined, but just in a different area of code within that file. Try using the find utility with the function in your code editor to search for usages. Some of the more advanced IDEs even provide a specific "Function Usages" menu item when you right click a function name that will parse all of the project files and show you a list of every found usage. Here is an excerpt from an example script:
text_to_show = "Hello world" text_area = bitmap_label.Label(terminalio.FONT, text=text_to_show)
In this piece of code, we see a variable named text_to_show
which has been given the value of "Hello World"
. We know that "Hello World"
is a String because it has quotes around it which is exactly how strings are coded.
Next we see that text_to_show
has been passed to the Label()
initializer function as an argument named text
. So from this, we can conclude that the argument named text
has the type of string
.
In this case, the variable names also contain a clue: since they both mention the word "text", we know that Strings in Python store text values, so this is further evidence that suggests string
is the correct type.
Page last edited March 08, 2024
Text editor powered by tinymce.