every time we use an operator, Python calls the corresponding special method for us. For example: 1 + 2 equals to (1).__add__(2) but way more complex and unreadable.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
classNumberOperator: def__init__(self, number: int): self.number = number
def__add__(self, other): v = self.number + other.number return NumberOperator(v)
defadd(): one = NumberOperator(1) two = NumberOperator(2) three = one + two print(f"one = {one.number}") print(f"two = {two.number}") print(f"three = {three.number}")
Traditional assignments built with the = operator don’t return a value, so they’re statements but not expressions. In contrast, assignment expressions are assignments that return a value. You can build them with the walrus operator (:=).
1 2 3 4
# this is an assignment expression, so it's syntactically correct. defassignment_expression(): while (line := input("Type some text: ")) != "stop": print(line)
1 2 3 4 5 6
# this is a normal assignment, it doesn't return a value, so it's syntactically incorrect defassignment(): # Unresolved reference 'line' # while (line = input("Type some text: ")) != "stop": # print(line) pass
Unpacking
Unpacking an iterable means assigning its values to a series of variables one by one.
Iterable Unpacking
1 2 3 4 5 6 7
# Once Python runs this assignment, the values are unpacked into the corresponding variable by position. one, two three, four = [1, 2, 3, 4]
a = 200 b = 100 # using the unpacking syntactic sugar, we can swap values between variables without assigning temporary variable a, b = b, a
*args and **kwargs
1 2 3 4 5 6 7 8 9 10 11 12
# 1 # (2, 3, 4) # {'hello': 'hello', 'world': 'world'} # args will be bound to positional arguments, and kwargs will be bound to keyword arguments defargs_func(number:int, *args, **kwargs): print(number) print(args) print(kwargs)
if __name__ == '__main__': args_func(1, 2, 3, 4, hello="hello", world="world")
# I suppose that there are two different types of arguments in Python's arguments, # The first type is named argument and the other type is anonymous argument. # In this situation, "name" is a named argument, and "hello" and "world" is anonymous arguments, # so "**kwargs" will be bound to anonymous arguments rather than being bound to all arguments defshow_args(number: int, *args, name: str, **kwargs): args_func(number, *args, **kwargs) print(name)
dict1 = {"k1": "v1", "k2": "v2"} show_args2(list1, list2, dict1=dict1) # both of list1 and list2 are bound to *args, it's worth noticing that each list is an entirety which being passed to *args # ([1, 2, 3, 4], [3, 4, 5, 6]) # {'dict1': {'k1': 'v1', 'k2': 'v2'}}
show_args2(*list1, dict1=dict1) # using *list1 will decompose list into a series of variables bound to *args # (1, 2, 3, 4) # {'dict1': {'k1': 'v1', 'k2': 'v2'}}
Loops and Comprehensions
Exploring for Loops
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
defsimple_loops(): numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] for number in numbers: print(number, end=', ')
Decorators are functions that take another function as an argument and extend their behavior dynamically without explicitly modifying it. In Python, you have a dedicated syntax that allows you to apply a decorator to a given function: In this piece of syntax, the @decorator part tells Python to call decorator() with the funcobject as an argument.
1 2 3
@decorator deffunc(): <body>
The following code produces a TypeError called ** ‘NoneType’ object is not callable** since decorator function prev returns nothing to be executed:
before f() executing, the decorator prev() was executed;
the execution of prev() returns nothing which means the () in f() has nothing to execute;
# args = (<function f at 0x10b944ca0>,) # kwargs = {} # running
Here is a bizarre statement that we never call f() but its decorator prev() was invoked automatically by compiler. It’s because decorator is loaded during loading modules
Introduced with PEP 255, generator functions are a special kind of function that return a lazy iterator. These are objects that you can loop over like a list. However, unlike lists, lazy iterators do not store their contents in memory.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
defgen_infinite_sequence(): num = 0 whileTrue: yield num num += 1
if __name__ == '__main__': gen = gen_infinite_sequence() print(gen) for i inrange(5): print(next(gen), end=' ')
Python yield statement and the code that follows it. yield indicates where a value is sent back to the caller, but unlike return, you don’t exit the function afterward.
Instead, the state of the function is remembered. That way, when next() is called on a generator object (either explicitly or implicitly within a for loop), the previously yielded variable num is incremented, and then yielded again.
Profiling Generator Performance
1 2 3 4 5 6
defprofiling_yield(): import cProfile cProfile.run('sum([i * 2 for i in range(10000)])')
if __name__ == '__main__': gen = multi_yield() print(next(gen)) print(next(gen)) # first string # second string
Using Advanced Generator Methods
method
description
generator.send()
Resumes the execution and “sends” a value into the generator function. The value argument becomes the result of the current yield expression. The send() method returns the next value yielded by the generator, or raises StopIteration if the generator exits without yielding another value.
generator.throw()
.throw() allows you to throw exceptions with the generator. I
generator.close()
As its name implies, .close() allows you to stop a generator.
The with Statement
Python’s with statement is a handy tool that allows you to manipulate context manager objects. These objects automatically handle the setup and teardown phases whenever you’re dealing with external resources such as files.
1 2 3 4 5 6 7 8 9 10 11
withopen("hello.txt", mode="w", encoding="utf-8") as file: file.write("Hello, World!")
# equals to file = open("hello.txt", mode="w", encoding="utf-8")
List comprehensions provide a concise way to create lists. Common applications are to make new lists where each element is the result of some operations applied to each member of another sequence or iterable, or to create a subsequence of those elements that satisfy a certain condition.
1 2 3 4
squares = []
for x inrange(10): squares.append(x**2)
we can obtain the same result with
1
squares = [x ** 2for x inrange(10)]
This is also simliar to the following code, except that the following code return <map object at 0x100d01760> which is a stream of data that is lazily initiated when the it’s called.
1 2 3 4 5 6
squares = map(lambda x: x**2, range(10)) print(squares) # <map object at 0x100d01760> for s in squares: print(s, end=' ') # 0 1 4 9 16 25 36 49 64 81
A list comprehension consists of brackets containing an expression followed by a for clause, then zero or more for or if clauses. The result will be a new list resulting from evaluating the expression in the context of the for and if clauses which follow it. For example, this listcomp combines the elements of two lists if they are not equal:
1 2 3 4 5 6 7 8
deflc2(): arr = [(x, y) for x in [1, 2, 3] for y in [3, 1, 4] if x != y] for item in arr: print(item, end=' ') # (1, 3) (1, 4) (2, 3) (2, 1) (2, 4) (3, 1) (3, 4)
The forementioned code is equivalent to the following code but way more readable:
1 2 3 4 5 6 7 8
for_arr = [] for x in [1,2,3]: for y in [3,1,4]: if x != y: for_arr.append((x, y)) for item in for_arr: print(item, end=' ') # (1, 3) (1, 4) (2, 3) (2, 1) (2, 4) (3, 1) (3, 4)
If the expression is a tuple (e.g. the (x, y) in the previous example), it must be parenthesized.
1 2 3
[x, x**2for x inrange(6)] # File "<stdin>", line 1, in <module> # [x, x**2 for x in range(6)]