1. 程式人生 > >python學習要點(二)

python學習要點(二)

我的部落格:https://www.luozhiyun.com/archives/269

'==' VS 'is'

'=='操作符比較物件之間的值是否相等。
'is'操作符比較的是物件的身份標識是否相等,即它們是否是同一個物件,是否指向同一個記憶體地址。

如:

a = 10
b = 10
 
a == b
True
 
id(a)
4427562448
 
id(b)
4427562448
 
a is b
True

Python 會為 10 這個值開闢一塊記憶體,然後變數 a 和 b 同時指向這塊記憶體區域,即 a 和 b 都是指向 10 這個變數,因此 a 和 b 的值相等,id 也相等。

不過,對於整型數字來說,以上a is b為 True 的結論,只適用於 -5 到 256 範圍內的數字。這裡和java的Integer的快取有點像,java快取-127到128。

當我們比較一個變數與一個單例(singleton)時,通常會使用'is'。一個典型的例子,就是檢查一個變數是否為 None:

if a is None:
      ...
 
if a is not None:
      ...

比較操作符'is'的速度效率,通常要優於'=='。因為'is'操作符不能被過載,而執行a == b相當於是去執行a.eq(b),而 Python 大部分的資料型別都會去過載__eq__這個函式。

淺拷貝和深度拷貝

淺拷貝

淺拷貝,是指重新分配一塊記憶體,建立一個新的物件,裡面的元素是原物件中子物件的引用。因此,如果原物件中的元素不可變,那倒無所謂;但如果元素可變,淺拷貝通常會帶來一些副作用,如下:

l1 = [[1, 2], (30, 40)]
l2 = list(l1)
l1.append(100)
l1[0].append(3)
 
l1
[[1, 2, 3], (30, 40), 100]
 
l2
[[1, 2, 3], (30, 40)]
 
l1[1] += (50, 60)
l1
[[1, 2, 3], (30, 40, 50, 60), 100]
 
l2
[[1, 2, 3], (30, 40)]

在這個例子中,因為淺拷貝里的元素是對原物件元素的引用,因此 l2 中的元素和 l1 指向同一個列表和元組物件。

l1[0].append(3),這裡表示對 l1 中的第一個列表新增元素 3。因為 l2 是 l1 的淺拷貝,l2 中的第一個元素和 l1 中的第一個元素,共同指向同一個列表,因此 l2 中的第一個列表也會相對應的新增元素 3。

l1[1] += (50, 60),因為元組是不可變的,這裡表示對 l1 中的第二個元組拼接,然後重新建立了一個新元組作為 l1 中的第二個元素,而 l2 中沒有引用新元組,因此 l2 並不受影響。

深度拷貝

所謂深度拷貝,是指重新分配一塊記憶體,建立一個新的物件,並且將原物件中的元素,以遞迴的方式,通過建立新的子物件拷貝到新物件中。因此,新物件和原物件沒有任何關聯。

Python 中以 copy.deepcopy() 來實現物件的深度拷貝。

import copy
l1 = [[1, 2], (30, 40)]
l2 = copy.deepcopy(l1)
l1.append(100)
l1[0].append(3)
 
l1
[[1, 2, 3], (30, 40), 100]
 
l2 
[[1, 2], (30, 40)]

不過,深度拷貝也不是完美的,往往也會帶來一系列問題。如果被拷貝物件中存在指向自身的引用,那麼程式很容易陷入無限迴圈:

import copy
x = [1]
x.append(x)
 
x
[1, [...]]
 
y = copy.deepcopy(x)
y
[1, [...]]

這裡沒有出現 stack overflow 的現象,是因為深度拷貝函式 deepcopy 中會維護一個字典,記錄已經拷貝的物件與其 ID。拷貝過程中,如果字典裡已經儲存了將要拷貝的物件,則會從字典直接返回。

def deepcopy(x, memo=None, _nil=[]):
    """Deep copy operation on arbitrary Python objects.
    	
	See the module's __doc__ string for more info.
	"""
	
    if memo is None:
        memo = {}
    d = id(x) # 查詢被拷貝物件 x 的 id
	y = memo.get(d, _nil) # 查詢字典裡是否已經儲存了該物件
	if y is not _nil:
	    return y # 如果字典裡已經儲存了將要拷貝的物件,則直接返回
        ...    

Python引數傳遞

Python 中引數的傳遞是賦值傳遞,或者是叫物件的引用傳遞。這裡的賦值或物件的引用傳遞,不是指向一個具體的記憶體地址,而是指向一個具體的物件。

  • 如果物件是可變的,當其改變時,所有指向這個物件的變數都會改變。
  • 如果物件不可變,簡單的賦值只能改變其中一個變數的值,其餘變數則不受影響。

例如:

def my_func1(b):
	b = 2
 
a = 1
my_func1(a)
a
1

這裡的引數傳遞,使變數 a 和 b 同時指向了 1 這個物件。但當我們執行到 b = 2 時,系統會重新建立一個值為 2 的新物件,並讓 b 指向它;而 a 仍然指向 1 這個物件。所以,a 的值不變,仍然為 1。

def my_func3(l2):
	l2.append(4)
 
l1 = [1, 2, 3]
my_func3(l1)
l1
[1, 2, 3, 4]

這裡 l1 和 l2 先是同時指向值為 [1, 2, 3] 的列表。不過,由於列表可變,執行 append() 函式,對其末尾加入新元素 4 時,變數 l1 和 l2 的值也都隨之改變了。

def my_func4(l2):
	l2 = l2 + [4]
 
l1 = [1, 2, 3]
my_func4(l1)
l1
[1, 2, 3]

這裡 l2 = l2 + [4],表示建立了一個“末尾加入元素 4“的新列表,並讓 l2 指向這個新的物件。這個過程與 l1 無關,因此 l1 的值不變。

裝飾器

首先我們看一個裝飾器的簡單例子:

def my_decorator(func):
    def wrapper():
        print('wrapper of decorator')
        func()
    return wrapper
 
def greet():
    print('hello world')
 
greet = my_decorator(greet)
greet()
 
# 輸出
wrapper of decorator
hello world

這段程式碼中,變數 greet 指向了內部函式 wrapper(),而內部函式 wrapper() 中又會呼叫原函式 greet(),因此,最後呼叫 greet() 時,就會先列印'wrapper of decorator',然後輸出'hello world'。

my_decorator() 就是一個裝飾器,它把真正需要執行的函式 greet() 包裹在其中,並且改變了它的行為。

在python中,可以使用更優雅的方式:

def my_decorator(func):
    def wrapper():
        print('wrapper of decorator')
        func()
    return wrapper
 
@my_decorator
def greet():
    print('hello world')
 
greet()

@my_decorator就相當於前面的greet=my_decorator(greet)語句

通常情況下,我們會把args和**kwargs,作為裝飾器內部函式 wrapper() 的引數。args和**kwargs,表示接受任意數量和型別的引數,因此裝飾器就可以寫成下面的形式:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print('wrapper of decorator')
        func(*args, **kwargs)
    return wrapper

這樣可以讓裝飾器接受任意的引數。

自定義引數的裝飾器

比如我想要定義一個引數,來表示裝飾器內部函式被執行的次數

def repeat(num):
    def my_decorator(func):
        def wrapper(*args, **kwargs):
            for i in range(num):
                print('wrapper of decorator')
                func(*args, **kwargs)
        return wrapper
    return my_decorator
 
 
@repeat(4)
def greet(message):
    print(message)
 
greet('hello world')
 
# 輸出:
wrapper of decorator
hello world
wrapper of decorator
hello world
wrapper of decorator
hello world
wrapper of decorator
hello world

保留原函式的元資訊

如下:

greet.__name__
## 輸出
'wrapper'
 
help(greet)
# 輸出
Help on function wrapper in module __main__:
 
wrapper(*args, **kwargs)

greet() 函式被裝飾以後,它的元資訊變了。元資訊告訴我們“它不再是以前的那個 greet() 函式,而是被 wrapper() 函式取代了”。

因此,可以加上內建的裝飾器@functools.wrap,它會幫助保留原函式的元資訊。
如下:

import functools
 
def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print('wrapper of decorator')
        func(*args, **kwargs)
    return wrapper
    
@my_decorator
def greet(message):
    print(message)
 
greet.__name__
 
# 輸出
'greet'

類裝飾器

類裝飾器主要依賴於函式__call_(),每當你呼叫一個類的示例時,函式__call__()就會被執行一次。

class Count:
    def __init__(self, func):
        self.func = func
        self.num_calls = 0
 
    def __call__(self, *args, **kwargs):
        self.num_calls += 1
        print('num of calls is: {}'.format(self.num_calls))
        return self.func(*args, **kwargs)
 
@Count
def example():
    print("hello world")
 
example()
 
# 輸出
num of calls is: 1
hello world
 
example()
 
# 輸出
num of calls is: 2
hello world
  

裝飾器的巢狀

如:

@decorator1
@decorator2
@decorator3
def func():
    ...

等效於:

decorator1(decorator2(decorator3(func)))

例子:

import functools
 
def my_decorator1(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print('execute decorator1')
        func(*args, **kwargs)
    return wrapper
 
 
def my_decorator2(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print('execute decorator2')
        func(*args, **kwargs)
    return wrapper
 
 
@my_decorator1
@my_decorator2
def greet(message):
    print(message)
 
 
greet('hello world')
 
# 輸出
execute decorator1
execute decorator2
hello world

協程

協程和多執行緒的區別,主要在於兩點,一是協程為單執行緒;二是協程由使用者決定,在哪些地方交出控制權,切換到下一個任務。

我們先來看一個例子:

import asyncio
 
async def crawl_page(url):
    print('crawling {}'.format(url))
    sleep_time = int(url.split('_')[-1])
    await asyncio.sleep(sleep_time)
    print('OK {}'.format(url))
 
async def main(urls):
    tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
    for task in tasks:
        await task
 
%time asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
 
########## 輸出 ##########
 
crawling url_1
crawling url_2
crawling url_3
crawling url_4
OK url_1
OK url_2
OK url_3
OK url_4
Wall time: 3.99 s

執行協程有多種方法,這裡我介紹一下常用的三種:

首先,我們可以通過 await 來呼叫。await 執行的效果,和 Python 正常執行是一樣的,也就是說程式會阻塞在這裡,進入被呼叫的協程函式,執行完畢返回後再繼續,而這也是 await 的字面意思。

其次,我們可以通過 asyncio.create_task() 來建立任務。要等所有任務都結束才行,用for task in tasks: await task 即可。

最後,我們需要 asyncio.run 來觸發執行。asyncio.run 這個函式是 Python 3.7 之後才有的特性。一個非常好的程式設計規範是,asyncio.run(main()) 作為主程式的入口函式,在程式執行週期內,只調用一次 asyncio.run。

在上面的例子中,也可以使用await asyncio.gather(*tasks),表示等待所有任務。

import asyncio
 
async def crawl_page(url):
    print('crawling {}'.format(url))
    sleep_time = int(url.split('_')[-1])
    await asyncio.sleep(sleep_time)
    print('OK {}'.format(url))
 
async def main(urls):
    tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
    await asyncio.gather(*tasks)
 
%time asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
 
########## 輸出 ##########
 
crawling url_1
crawling url_2
crawling url_3
crawling url_4
OK url_1
OK url_2
OK url_3
OK url_4
Wall time: 4.01 s

協程中斷和異常處理

import asyncio
 
async def worker_1():
    await asyncio.sleep(1)
    return 1
 
async def worker_2():
    await asyncio.sleep(2)
    return 2 / 0
 
async def worker_3():
    await asyncio.sleep(3)
    return 3
 
async def main():
    task_1 = asyncio.create_task(worker_1())
    task_2 = asyncio.create_task(worker_2())
    task_3 = asyncio.create_task(worker_3())
 
    await asyncio.sleep(2)
    task_3.cancel()
 
    res = await asyncio.gather(task_1, task_2, task_3, return_exceptions=True)
    print(res)
 
%time asyncio.run(main())
 
########## 輸出 ##########
 
[1, ZeroDivisionError('division by zero'), CancelledError()]
Wall time: 2 s

這個例子中,使用了task_3.cancel()來中斷程式碼,使用了return_exceptions=True來控制輸出異常,如果不設定的話,錯誤就會完整地 throw 到我們這個執行層,從而需要 try except 來捕捉,這也就意味著其他還沒被執行的任務會被全部取消掉。

Python 中的垃圾回收機制

python採用的是引用計數機制為主,標記-清除和分代收集(隔代回收)兩種機制為輔的策略。

引用計數法

引用計數法機制的原理是:每個物件維護一個ob_ref欄位,用來記錄該物件當前被引用的次數,每當新的引用指向該物件時,它的引用計數ob_ref加1,每當該物件的引用失效時計數ob_ref減1,一旦物件的引用計數為0,該物件立即被回收,物件佔用的記憶體空間將被釋放。

它的缺點是它不能解決物件的“迴圈引用”。

標記清除演算法

對於一個有向圖,如果從一個節點出發進行遍歷,並標記其經過的所有節點;那麼,在遍歷結束後,所有沒有被標記的節點,我們就稱之為不可達節點。顯而易見,這些節點的存在是沒有任何意義的,自然的,我們就需要對它們進行垃圾回收。

在 Python 的垃圾回收實現中,mark-sweep 使用雙向連結串列維護了一個數據結構,並且只考慮容器類的物件(只有容器類物件才有可能產生迴圈引用)。

分代收集演算法

Python 將所有物件分為三代。剛剛創立的物件是第 0 代;經過一次垃圾回收後,依然存在的物件,便會依次從上一代挪到下一代。而每一代啟動自動垃圾回收的閾值,則是可以單獨指定的。當垃圾回收器中新增物件減去刪除物件達到相應的閾值時,就會對這一代物件啟動垃圾回收