Fixing Common Syntactic Snafus and Pitfalls in Python
By Alyce Osbourne
While Python is well known for being easy to learn and read, it isn’t without its quirks. There are a few syntactic snafus that can really ruin your day if they find their way into your project!
Funky Floats
Comparing floating-point numbers in Python can be tricky due to the inherent imprecision of floating-point arithmetic. This imprecision can lead to unexpected results in comparisons.
Example
print(.1 + .2 == .3) # False
The result is False
because the sum of .1
and .2
is not exactly .3
due to floating-point rounding errors. These small inaccuracies can cause subtle bugs in your code.
Fix
import math
print(math.isclose(.1 + .2, .3)) # True
print(math.isclose(.1 + .2, .3, rel_tol=1e-9)) # True
print(math.isclose(.1 + .2, .3, abs_tol=1e-9)) # True
import decimal
print(decimal.Decimal('.1') + decimal.Decimal('.2') == decimal.Decimal('.3')) # True
To address this issue, you can use the math.isclose
function, which allows you to specify a relative or absolute tolerance for the comparison. This helps account for small floating-point inaccuracies:
math.isclose(a, b)
: This checks if the valuesa
andb
are close to each other, considering a default relative tolerance.math.isclose(a, b, rel_tol=1e-9)
: Here, a relative tolerance of1e-9
is specified, making the comparison stricter.math.isclose(a, b, abs_tol=1e-9)
: This uses an absolute tolerance, ensuring the comparison considers the absolute difference between the values.
Alternatively, you can use the decimal
module for arbitrary precision arithmetic, which provides exact decimal representation and comparison:
decimal.Decimal('value')
: Creates aDecimal
object with the specified value. This ensures precise arithmetic operations and comparisons. Note, the value passed needs to be a string representation of the float value in question.
Shifty Scopes
Understanding the scope of variables can occasionally be rather unintuitive. Sometimes we expect a variable or argument to behave one way, and instead it behaves in another.
Example
squares = [lambda x: x * i for i in range(5)]
print([f(2) for f in squares]) # [8, 8, 8, 8, 8], not [0, 2, 4, 6, 8]
We expect the i
variable to be unique for each entry in the list, but instead we find that it was the last variable of the loop that was captured.
Fix
To fix this, you can use a default argument in the lambda function to capture the current value of i
:
squares = [lambda x, i=i: x * i for i in range(5)]
print([f(2) for f in squares]) # [0, 2, 4, 6, 8]
This ensures that each lambda function retains the value of i
at the time of its creation. By specifying i=i
in the lambda function, we create a default parameter that holds the value of i
at the time of the lambda’s definition, thus avoiding the reference issue. This binds the current iterations variable to the scope of the function. This gotcha can manifest itself in many places, but the most common is within loops.
Mutable Madness
Likely the most common gotcha on this list, assigning mutable variables to arguments.
Using mutable default arguments in functions can lead to unexpected behaviour, as the default argument is only evaluated once when the function is defined, not each time the function is called. This means that the mutable value is shared amongst instances.
Example
def add_to_list(value, my_list=[]):
my_list.append(value)
return my_list
print(add_to_list(1)) # [1]
print(add_to_list(2)) # [1, 2], not [2]
The list my_list
is shared across all calls to add_to_list
, leading to the accumulation of values.
Fix
To avoid this issue, use None
as the default value and create a new list inside the function if needed:
def add_to_list(value, my_list=None):
if my_list is None:
my_list = []
my_list.append(value)
return my_list
print(add_to_list(1)) # [1]
print(add_to_list(2)) # [2]
This way, each call to add_to_list
gets a new list if one is not provided. By using my_list=None
and checking if my_list
is None
, you ensure that a new list is created for each function call, avoiding unintended accumulation.
Murky Matching
Structural pattern matching is an incredibly useful tool, but there are a few little things that behave in ways you might not initially expect.
A common one is matching values against types.
Example
match 10:
case int:
print("It's an int")
case _:
print("It's something else!")
# It's something else!
It might not seem immediately obvious what the error is here. The problem is, we are comparing an instance with a type, which is much like 10 == int
.
Fix
To have the pattern matching work the way we intend, we adjust our syntax:
match 10:
case int():
print("It's an int")
case _:
print("It's something else!")
# It's an int
When we use int()
in the pattern matching, it helps us correctly recognize that the value 10
is an integer, much like calling isinstance(10, int)
. This makes sure that the pattern matcher understands the type and compares the value accordingly, instead of treating the type itself as a value to compare directly. The same principle can be applied to other types, such as floats, lists, dicts, etc., by using float()
, list()
, dict()
respectively.
Final thoughts
While most of the time Python is simple and straightforward, there are language quirks that can trip up beginner and expert alike with their seemingly unintuitive behaviour. Thankfully, the Python development team is improving the language all of the time, if I had written this list a few years ago, this list would have been much longer.