Summary
f"{0xBE_AF:.2f}"
is what happens if you use "_" in hex, and interpolate it with a 2 decimal precision.[*(_ for [[][:], *_] in [((1, 2), "woops")])]
combines a generator, unpacking in a list, unpacking in a loop, and unpacking in a slice, for this delicious cryptic effect.(lambda *数字: __import__('math').prod(数字))(*([2]*6)) == 2**6
leverages the splat operator*
for variadic parameters (as they work in a lambda) and unpacking arguments. Becauseprod()
is not a built-in, we use the__import__
function to access the module that holds it. 数字 is variable name.
f"{0xBE_AF:.2f}"
Python has come with a variety of notations for numbers for a long time:
>>> 250 # decimal
250
>>> 0b11111010 # binary
250
>>> 0xFA # hexadecimal
250
>>> 2.5e2 # scientific notation
250.0
>>> 0xFA == 250 == 0xFA == 2.5e2
True
In 2016, PEP 515 started to allow underscores in numeric literals to Python 3.6. E.G, as a thousands separator:
>>> 250_000_000 == 250000000
True
But the new feature is not limited to base 10:
>>> 0b11_11_10_10
250
>>> 0xBEAF == 0xBE_AF == 48_815
True
Now, it turns out 3.6 also added another fantastic feature, f-strings. And f-strings have the capacity to allow arbitrary code in them:
>>> f"{1 + 1}"
'2'
They also have an entire formatting language, inherited from format()
, with pretty sweet capabilities:
>>> f"{1:03}" # pad with zero
'001'
>>> f"{3.14957:.3f}" # choosing decimal precision
'3.150'
And because those features can be combined, if you use "_" in hex, and interpolate it with a 2 decimal precision, you get:
>>> f"{0xBE_AF:.2f}"
'48815.00'
Of course, while all those nice toys are useful independently, I would not advise you to mix all of them too often. That doesn't scream readability, does it?
[*(_ for [[][:], *_] in [((1, 2), "woops")])]
While not as powerful as destructuring in other languages (I still wish we could unpack dicts as JS does with objects), unpacking is still much more interesting than most people thinks.
When people say "unpacking", they usually mean one of those in their head...
Putting iterable content in variables:
>>> a, b, c = range(3)
>>> a
0
>>> b
1
>>> c
2
Inverting variables:
>>> a, b = b, a
>>> a
1
>>> b
0
(don't know why this is so famous, I never, ever used that in prod)
And because unpacking works in for loops, iterating on a dictionary:
>>> score = {"red": 1, "blue": 2}
>>> for team, points in score.items():
... print(team, ":", points)
...
red : 1
blue : 2
But unpacking packs more than that. It can assign nested structures:
>>> [(team1, score1), (team2, score2)] = score.items()
>>> team1
'red'
>>> score1
1
If you don't care about other values, you can use *
to capture them:
>>> first, *others = ["Matrix 1", "Matrix 2", "Matrix 3", "Matrix 4"]
>>> first
'Matrix 1'
>>> others
['Matrix 2', 'Matrix 3', 'Matrix 4']
Also *
has a completely different meaning, it can populate a list by unpacking other iterables in it:
>>> [*"abc", *range(3)]
['a', 'b', 'c', 0, 1, 2]
This also works for tuples and sets, and **
will populate mapping, but let's not make this article too big.
Now, for a new trick, Python allows you to assign to slices:
>>> numbers = list(range(10))
>>> numbers[3:6] = ["III", "IV", "V"]
>>> numbers
[0, 1, 2, 'III', 'IV', 'V', 6, 7, 8, 9]
It seems weird, but it's extremely performant in Python, where loops are super slow.
Well, it gets weirder when you realize you can actually use unpacking AND assign to slices together.
Imagine you have a list of RGB pixels:
img = ["r", "g", "b"] * 10_000_000
And you want to turn it into a BGR pixels.
You can use a loop:
>>> %%timeit
... img = ["r", "g", "b"] * 10_000_000
... iterator= iter(img)
... groups= zip(iterator, iterator, iterator)
... img = [pixel for group in groups for pixel in group[::-1]]
...
...
1.15 s ± 18.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
But using slicing and unpacking is about 5 times faster:
>>> %%timeit
... img = ["r", "g", "b"] * 10_000_000
... img[:-1:3], img[2::3] = img[2::3], img[:-1:3]
...
...
279 ms ± 2.99 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
To understand the title on this section, you also need to realize _
is a valid variable name in Python. In fact, we use it to say we want to ignore a value.
Now we can decrypt the hieroglyphs.
First, we assigned to a slice. But look at that, we can omit the variable to hold the list, and it still works. It's useless, but it works:
>>> a = []
>>> a[:] = (1, 2)
>>> a
[1, 2]
>>> [][:] = (1, 2)
So this beast:
>>> [][:], *_ = ((1, 2), "woops")
>>> _
['woops']
Is unpacking (1, 2)
to a slice on the left, and "woops"
to *_
on the right.
We can put it in a for loop because unpacking is legal in there:
>>> for [[][:], *_] in [((1, 2), "woops")]:
... print(_)
...
['woops']
And if it can go in a for loop, it can go into a comprehension list:
>>> [_ for [[][:], *_] in [((1, 2), "woops")]]
[['woops']]
A comprehension list can be turned into a generator, we just have to swap brackets for parentheses. And we can unpack it in a list:
>>> gen = (_ for [[][:], *_] in [((1, 2), "woops")])
>>> [*gen]
[['woops']]
Finally:
>>> [*(_ for [[][:], *_] in [((1, 2), "woops")])]
[['woops']]
Do you want this monstrosity to be written anywhere? Certainly not, but by decoding it, we learned a ton about Python.
(lambda *数字: __import__('math').prod(数字))(*([2]*6)) == 2**6
Last one for the road?
There are no way to manipulate pointers from Python syntax, but splat operator (*
) is still used for a lot of different cases.
First, putting a number to a power of something in Python is not ^
, but **
:
>>> 3**4
81
Then, you have variadic parameters, which allows you to declare an infinite number of params for a function:
>>> def a_lot(*of_stuff): # accept 0, 1 or more params
... print(of_stuff)
...
>>> a_lot(1, 2, 3) # they are stored in a tuple in of_stuff
(1, 2, 3)
No need to call it *args
by the way. This is just the naming convention used for "I have no idea what's going in there", like for decorators.
Turns out, this works with lambdas:
>>> lambda *of_stuff: print(of_stuff)
<function <lambda> at 0x7f5093e39510>
One tricky thing that beginners get confused about, is that you can also use *
to unpack arguments in a function call:
>>> def square_area(x1, y1, x2, y2):
... side_length = abs(x2 - x1)
... area = side_length ** 2
... return area
... coords = [0, 0, 3, 3]
>>> square_area(coords[0], coords[1], coords[2], coords[3])
9
>>> square_area(*coords)
9
It's easy to get mixed up between def func(*values)
(declare func with infinite params) and func(*values)
(call func while unpacking arguments).
Let's add one last use case for *
, initializing a list:
>>> [0] * 10
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
And we are almost there.
Now for the final touch, we must understand import
is a statement in Python, meaning it must exist on its own line. But there is a function __import__
that can also import modules, and calling it is an expression, so we can use it inside another expression:
>>> import sys
>>> print("dev mode:", sys.flags.dev_mode) # if you don't know dev_mode, check it out!
dev mode: False
>>> print("dev mode:", import sys)
Cell In[126], line 1
print("dev mode:", import sys)
^
SyntaxError: invalid syntax
>>> print("dev mode:", __import__('sys').flags.dev_mode)
dev mode: False
Now all together!
We create a list of 2:
>>> [2]*6
[2, 2, 2, 2, 2, 2]
This can be passed to the math.prod
function:
>>> __import__('math').prod([2, 2, 2, 2, 2, 2])
64
Giving us the exact same value as:
>>> 2**6
64
Let's make it needlessly complicated with a function that accepts infinite parameters, then unpack the list in it:
>>> def useless_prod_wrapper(*numbers):
... return __import__('math').prod(numbers)
...
>>> useless_prod_wrapper(*[2, 2, 2, 2, 2, 2])
64
Hey, they cancel each other :)
This can be a lambda:
>>> useless_prod_wrapper = lambda *numbers: __import__('math').prod(numbers)
But why give a name to a lambda? We can call it directly:
>>> ( lambda *numbers: __import__('math').prod(numbers) )(*[2, 2, 2, 2, 2, 2])
64
Well, if you put them all together, and translate “numbers” into simplified chinese, you get our magic formulas:
>>> (lambda *数字: __import__('math').prod(数字))(*([2]*6)) == 2**6
True
Yep, some unicode characters are valid Python variable names.
Once again, don't do this to your colleagues. Or to yourself.
Yet, while we can't fight against masters like C++ or Perl, Python is perfectly capable of being unreadable if you beat it with a hammer.
What else?
Python has this wonderful quality of allowing people to be rapidly productive with the core language and stdlib.
But you can dig deeper and deeper into the languages for years and still learn things.
For example, did you know that the else
keyword can be used with for
, while
and try
?
Have fun with the learning curve, it's a long one, but it's smooth and rewarding.
This was an amazing article that forced me to dive deep into the rabbit hole of Python documentation and read all the documentation. I think avoiding for loops explicitly can be an amazing mantra to discover a lot of "pythonic" ways to do things that someone is used to doing in other languages.
Just wanted to suggest a correction in the article.
> img[:-1:3], img[1::3] = img[1::3], img[:-1:3]
This creates sequences of ['g', 'r', 'b', 'g', 'r', 'b', ...]
I think the correct code would be
img[:-1:3], img[2::3] = img[2::3], img[:-1:3]
['b', 'g', 'r', 'b', 'g', 'r', ....]