從零開始學Python:21課-函式的高階應用

千鋒Python唐小強發表於2020-08-13

在前面的一節Python課中,我們已經對函式進行了更為深入的研究,還探索了Python中的高階函式和Lambda函式。在這些知識的基礎上,這節課我們為大家分享兩個和函式相關的內容,一個是裝飾器,一個是函式的遞迴呼叫。

裝飾器

裝飾器是Python中 用一個函式裝飾另外一個函式或類併為其提供額外功能的語法現象。裝飾器本身是一個函式,它的引數是被裝飾的函式或類,它的返回值是一個帶有裝飾功能的函式。很顯然,裝飾器是一個高階函式,它的引數和返回值都是函式。下面我們先透過一個簡單的例子來說明裝飾器的寫法和作用,假設已經有名為downlaod和upload的兩個函式,分別用於檔案的上傳和下載,下面的程式碼用休眠一段隨機時間的方式模擬了下載和上傳需要花費的時間,並沒有聯網做上傳下載。

說明:用Python語言實現聯網的上傳下載也很簡單,繼續你的學習,這個環節很快就會來到。


import random

import time


def download(filename):
   print( f'開始下載 {filename}.')
   time.sleep(random.randint( 2, 6))
   print( f' {filename}下載完成.')


def upload (filename):
   print( f'開始上傳 {filename}.')
   time.sleep(random.randint( 4, 8))
   print( f' {filename}上傳完成.')


download( 'MySQL從刪庫到跑路.avi')
upload( 'Python從入門到住院.pdf')

現在我們希望知道呼叫download和upload函式做檔案上傳下載到底用了多少時間,這個應該如何實現呢?相信很多小夥伴已經想到了,我們可以在函式開始執行的時候記錄一個時間,在函式呼叫結束後記錄一個時間,兩個時間相減就可以計算出下載或上傳的時間,程式碼如下所示。

start = 
time.
time()

download( 'MySQL從刪庫到跑路.avi')
end = time. time()
print(f '花費時間: {end - start:.3f}秒')
start = time. time()
upload( 'Python從入門到住院.pdf')
end = time. time()
print(f '花費時間: {end - start:.3f}秒')

透過上面的程式碼,我們可以得到下載和上傳花費的時間,但不知道大家是否注意到,上面記錄時間、計算和顯示執行時間的程式碼都是重複程式碼。有程式設計經驗的人都知道, 重複的程式碼是萬惡之源,那麼有沒有辦法在不寫重複程式碼的前提下,用一種簡單優雅的方式記錄下函式的執行時間呢?在Python中,裝飾器就是解決這類問題的最佳選擇。我們可以把記錄函式執行時間的功能封裝到一個裝飾器中,在有需要的地方直接使用這個裝飾器就可以了,程式碼如下所示。

import 
time



# 定義裝飾器函式,它的引數是被裝飾的函式或類
def record_time(func):

   # 定義一個帶裝飾功能(記錄被裝飾函式的執行時間)的函式
   # 因為不知道被裝飾的函式有怎樣的引數所以使用*args和**kwargs接收所有引數
   # 在Python中函式可以巢狀的定義(函式中可以再定義函式)
   def wrapper(*args, **kwargs):
       # 在執行被裝飾的函式之前記錄開始時間
       start = time. time()
       # 執行被裝飾的函式並獲取返回值
       result = func(*args, **kwargs)
       # 在執行被裝飾的函式之後記錄結束時間
        end = time. time()
       # 計算和顯示被裝飾函式的執行時間
        print(f '{func.__name__}執行時間: {end - start:.3f}秒')
       # 返回被裝飾函式的返回值(裝飾器通常不會改變被裝飾函式的執行結果)
        return result

   # 返回帶裝飾功能的wrapper函式
    return wrapper

使用上面的裝飾器函式有兩種方式,第一種方式就是直接呼叫裝飾器函式,傳入被裝飾的函式並獲得返回值,我們可以用這個返回值直接覆蓋原來的函式,那麼在呼叫時就已經獲得了裝飾器提供的額外的功能(記錄執行時間),大家可以試試下面的程式碼就明白了。


download = record_time(download)

upload = record_time(upload)
download( 'MySQL從刪庫到跑路.avi')
upload( 'Python從入門到住院.pdf')

上面的程式碼中已經沒有重複程式碼了,雖然寫裝飾器會花費一些心思,但是這是一個一勞永逸的騷操作,如果還有其他的函式也需要記錄執行時間,按照上面的程式碼如法炮製即可。

在Python中,使用裝飾器很有更為便捷的 語法糖(程式語言中新增的某種語法,這種語法對語言的功能沒有影響,但是使用更加方法,程式碼的可讀性也更強),可以用@裝飾器函式將裝飾器函式直接放在被裝飾的函式上,效果跟上面的程式碼相同,下面是完整的程式碼。


import random

import time


def record_time(func):

    def wrapper (*args, **kwargs):
       start = time.time()
       result = func(*args, **kwargs)
       end = time.time()
       print( f' {func.__name__}執行時間: {end - start: .3f}秒')
        return result

    return wrapper


@record_time
def download(filename):
   print( f'開始下載 {filename}.')
   time.sleep(random.randint( 2, 6))
   print( f' {filename}下載完成.')


@record_time
def upload(filename):
   print( f'開始上傳 {filename}.')
   time.sleep(random.randint( 4, 8))
   print( f' {filename}上傳完成.')


download( 'MySQL從刪庫到跑路.avi')
upload( 'Python從入門到住院.pdf')

上面的程式碼,我們透過裝飾器語法糖為download和upload函式新增了裝飾器,這樣呼叫download和upload函式時,會記錄下函式的執行時間。事實上,被裝飾後的download和upload函式是我們在裝飾器record_time中返回的wrapper函式,呼叫它們其實就是在呼叫wrapper函式,所以擁有了記錄函式執行時間的功能。

如果希望取消裝飾器的作用,那麼在定義裝飾器函式的時候,需要做一些額外的工作。Python標準庫functools模組的wraps函式也是一個裝飾器,我們將它放在wrapper函式上,這個裝飾器可以幫我們保留被裝飾之前的函式,這樣在需要取消裝飾器時,可以透過被裝飾函式的__wrapped__屬性獲得被裝飾之前的函式。


import random

import time

from functools import wraps


def record_time(func):

   @wraps(func)
    def wrapper (*args, **kwargs):
       start = time.time()
       result = func(*args, **kwargs)
       end = time.time()
       print( f' {func.__name__}執行時間: {end - start: .3f}秒')
        return result

    return wrapper


@record_time
def download(filename):
   print( f'開始下載 {filename}.')
   time.sleep(random.randint( 2, 6))
   print( f' {filename}下載完成.')


@record_time
def upload(filename):
   print( f'開始上傳 {filename}.')
   time.sleep(random.randint( 4, 8))
   print( f' {filename}上傳完成.')


download( 'MySQL從刪庫到跑路.avi')
upload( 'Python從入門到住院.pdf')
# 取消裝飾器
download.__wrapped__('MySQL必知必會.pdf')
upload = upload.__wrapped__
upload('Python從新手到大師.pdf')

裝飾器函式本身也可以引數化,簡單的說就是透過我們的裝飾器也是可以透過呼叫者傳入的引數來定製的,這個知識點我們在後面用上它的時候再為大家講解。除了可以用函式來定義裝飾器之外,透過定義類的方式也可以定義裝飾器。如果一個類中有名為__call__的魔術方法,那麼這個類的物件就可以像函式一樣呼叫,這就意味著這個物件可以像裝飾器一樣工作,程式碼如下所示。

class RecordTime:


   def __call__(self, func):

       @wraps(func)
       def wrapper(*args, **kwargs):
           start = time. time()
           result = func(*args, **kwargs)
            end = time. time()
            print(f '{func.__name__}執行時間: {end - start:.3f}秒')
            return result

        return wrapper


# 使用裝飾器語法糖新增裝飾器
@RecordTime()
def download(filename):
    print(f '開始下載{filename}.')
    time.sleep( random.randint( 2, 6))
    print(f '{filename}下載完成.')


def upload(filename):
    print(f '開始上傳{filename}.')
    time.sleep( random.randint( 4, 8))
    print(f '{filename}上傳完成.')


# 直接建立物件並呼叫物件傳入被裝飾的函式
upload = RecordTime()(upload)
download( 'MySQL從刪庫到跑路.avi')
upload( 'Python從入門到住院.pdf')

上面的程式碼演示了兩種新增裝飾器的方式,由於RecordTime是一個類,所以需要先建立物件,才能把物件當成裝飾器來使用,所以提醒大家注意RecordTime後面的圓括號,那是呼叫構造器建立物件的語法。如果為RecordTime類新增一個__init__方法,就可以實現對裝飾器的引數化,剛才我們說過了,這個知識點等用上的時候再為大家講解。使用裝飾器還可以裝飾一個類,為其提供額外的功能,這個知識點也等我們用到的時候再做講解。

遞迴呼叫

從零開始學Python:21課-函式的高階應用



def 
fac
(num):

    if num in ( 0, 1):
        return 1
    return num * fac(num - 1)

上面的程式碼中,fac函式中又呼叫了fac函式,這就是所謂的遞迴呼叫。程式碼第2行的if條件叫做遞迴的收斂條件,簡單的說就是什麼時候要結束函式的遞迴呼叫,在計算階乘時,如果計算到0或1的階乘,就停止遞迴呼叫,直接返回1;程式碼第4行的num * fac(num - 1)是遞迴公式,也就是階乘的遞迴定義。下面,我們簡單的分析下,如果用fac(5)計算5的階乘,整個過程會是怎樣的。

# 遞迴呼叫函式入棧

# 5 * fac( 4)
# 5 * ( 4 * fac( 3))
# 5 * ( 4 * ( 3 * fac( 2)))
# 5 * ( 4 * ( 3 * ( 2 * fac( 1))))
# 停止遞迴函式出棧
# 5 * ( 4 * ( 3 * ( 2 * 1)))
# 5 * ( 4 * ( 3 * 2))
# 5 * ( 4 * 6)
# 5 * 24
# 120
print(fac( 5))    # 120

注意,函式呼叫會透過記憶體中稱為“棧”(stack)的資料結構來儲存當前程式碼的執行現場,函式呼叫結束後會透過這個棧結構恢復之前的執行現場。棧是一種先進後出的資料結構,這也就意味著最早入棧的函式最後才會返回,而最後入棧的函式會最先返回。例如呼叫一個名為a的函式,函式a的執行體中又呼叫了函式b,函式b的執行體中又呼叫了函式c,那麼最先入棧的函式是a,最先出棧的函式是c。每進入一個函式呼叫,棧就會增加一層棧幀(stack frame),棧幀就是我們剛才提到的儲存當前程式碼執行現場的結構;每當函式呼叫結束後,棧就會減少一層棧幀。通常,記憶體中的棧空間很小,因此遞迴呼叫的次數如果太多,會導致棧溢位(stack overflow),所以 遞迴呼叫一定要確保能夠快速收斂。我們可以嘗試執行fac(5000),看看是不是會提示RecursionError錯誤,錯誤訊息為:maximum recursion depth exceeded in comparison(超出最大遞迴深度),其實就是發生了棧溢位。

我們使用的Python官方直譯器,預設將函式呼叫的棧結構最大深度設定為1000層。如果超出這個深度,就會發生上面說的RecursionError。當然,我們可以使用sys模組的setrecursionlimit函式來改變遞迴呼叫的最大深度,例如:sys.setrecursionlimit(10000),這樣就可以讓上面的fac(5000)順利執行出結果,但是我們不建議這樣做,因為讓遞迴快速收斂才是我們應該做的事情,否則就應該考慮使用迴圈遞推而不是遞迴。

再舉一個之前講過的生成斐波那契數列的例子,因為斐波那契數列前兩個數都是1,從第3個數開始,每個數是前兩個數相加的和,可以記為

從零開始學Python:21課-函式的高階應用

很顯然這又是一個遞迴的定義,所以我們可以用下面的遞迴呼叫函式來計算第

n個斐波那契數。


def 
fib(n):

    if n in ( 1, 2):
        return 1
    return fib(n - 1) + fib(n - 2)


# 列印前 20個斐波那契數
for i in range( 1, 21):
    print(fib(i))

需要提醒大家,上面計算斐波那契數的程式碼雖然看起來非常簡單明瞭,但執行效能是比較糟糕的,原因大家可以自己思考一下,更好的做法還是之前講過的使用迴圈遞推的方式,程式碼如下所示。



def 
fib
(n):

   a, b = 0, 1
    for _ in range(n):
       a, b = b, a + b
    return a

簡單的總結

裝飾器是Python中的特色語法,可以透過裝飾器來增強現有的類或函式,這是一種非常有用的程式設計技巧。一些複雜的問題用函式遞迴呼叫的方式寫起來真的很簡單,但是函式的遞迴呼叫一定要注意收斂條件和遞迴公式,找到遞迴公式才有機會使用遞迴呼叫,而收斂條件確定了遞迴什麼時候停下來。函式呼叫透過記憶體中的棧空間來儲存現場和恢復現場,棧空間通常都很小,所以遞迴如果不能迅速收斂,很可能會引發棧溢位錯誤,從而導致程式的崩潰。



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69923331/viewspace-2711547/,如需轉載,請註明出處,否則將追究法律責任。

相關文章