首頁 > 軟體

手把手帶你用Python實現一個計時器

2022-06-28 18:05:23

雖然許多資料工作者認為 Python 是一種有效的程式語言,但純 Python 程式比C、Rust 和 Java 等編譯語言中的對應程式執行得更慢,為了更好地監控和優化Python程式,雲朵君將和大家一起學習如何使用 Python 計時器來監控程式執行的速度,以便正對性改善程式碼效能。

為了更好地掌握 Python 計時器的應用,我們後面還補充了有關Python類、上下文管理器和裝飾器的背景知識。因篇幅限制,其中利用上下文管理器和裝飾器優化 Python 計時器,將在後續文章學習,不在本篇文章範圍內。

Python 計時器

首先,我們向某段程式碼中新增一個Python 計時器以監控其效能。

Python 定時器函數

Python 中的內建time[1]模組中有幾個可以測量時間的函數:

  • monotonic()
  • perf_counter()
  • process_time()
  • time()

Python 3.7 引入了幾個新函數,如thread_time()[2],以及上述所有函數的納秒版本,以_ns字尾命名。例如,perf_counter_ns()perf_counter()的納秒版本的。

perf_counter()返回效能計數器的值(以秒為單位),即具有最高可用解析度的時鐘以測量短持續時間。

首先,使用perf_counter()建立一個 Python 計時器。將把它與其他 Python 計時器函數進行比較,看看 perf_counter() 的優勢。

範例

建立一個指令碼,定義一個簡短的函數:從清華雲上下載一組資料。

import requests
def main():
    source_url = 'https://cloud.tsinghua.edu.cn/d/e1ccfff39ad541908bae/files/?p=%2Fall_six_datasets.zip&dl=1'
    headers = {'User-Agent': 'Mozilla/5.0'}
    res = requests.get(source_url, headers=headers)
    with open('dataset/datasets.zip', 'wb') as f:
        f.write(res.content)
       
if __name__=="__main__":
    main()

我們可以使用 Python 計時器來監控該指令碼的效能。

第一個 Python 計時器

現在使用函數time.perf_counter()函數建立一個計時器,這是一個非常適合針對部分程式碼的效能計時的計數器。

perf_counter()從某個未指定的時刻開始測量時間(以秒為單位),這意味著對該函數的單個呼叫的返回值沒有用。但當檢視對perf_counter()兩次呼叫之間的差異時,可以計算出兩次呼叫之間經過了多少秒。

>>> import time
>>> time.perf_counter()
394.540232282

>>> time.perf_counter()  # 幾秒鐘後
413.31714087

在此範例中,兩次呼叫 perf_counter() 相隔近 19 秒。可以通過計算兩個輸出之間的差異來確認這一點:413.31714087 - 394.540232282 = 18.78

現在可以將 Python 計時器新增到範例程式碼中:

# download_data.py
import requests
import time
def main():
    tic = time.perf_counter()
    source_url = 'https://cloud.tsinghua.edu.cn/d/e1ccfff39ad541908bae/files/?p=%2Fall_six_datasets.zip&dl=1'
    headers = {'User-Agent': 'Mozilla/5.0'}
    res = requests.get(source_url, headers=headers)
    with open('dataset/datasets.zip', 'wb') as f:
        f.write(res.content)
    toc = time.perf_counter()
    print(f"該程式耗時: {toc - tic:0.4f} seconds")
if __name__=="__main__":
    main()

注意perf_counter()通過計算兩次呼叫之間的差異來列印整個程式執行所花費的時間。

print()函數中 f 字串前面的表示這是一個 f-string ,這是格式化文字字串的較為便捷的方式。:0.4f是一個格式說明符,表示數位,toc - tic應列印為帶有四位小數的十進位制數。

執行程式可以看到程式經過的時間:

該程式耗時: 0.026 seconds

就是這麼簡單。接下來我們一起學習如何將 Python 計時器包裝到一個類、一個上下文管理器和一個裝飾器中,這樣可以更加一致和方便使用計時器。

一個 Python 定時器類

這裡我們至少需要一個變數來儲存 Python 計時器的狀態。接下來我們建立一個與手動呼叫 perf_counter() 相同的類,但更具可讀性和一致性。

建立和更新Timer類,使用該類以多種不同方式對程式碼進行計時。

$ python -m pip install codetiming

理解 Python 中的類

Class是物件導向程式設計的主要構建塊。類本質上是一個模板,可以使用它來建立物件

在 Python 中,當需要對需要跟蹤特定狀態的事物進行建模時,類非常有用。一般來說,類是屬性的集合,稱為屬性,以及行為,稱為方法

建立 Python 計時器類

類有利於跟蹤狀態。在Timer類中,想要跟蹤計時器何時開始以及已經多少時間。對於Timer類的第一個實現,將新增一個._start_time屬性以及.start().stop()方法。將以下程式碼新增到名為 timer.py 的檔案中:

# timer.py
import time
class TimerError(Exception):
    """一個自定義異常,用於報告使用Timer類時的錯誤"""

class Timer:
    def __init__(self):
        self._start_time = None

    def start(self):
        """Start a new timer"""
        if self._start_time is not None:
            raise TimerError(f"Timer is running. Use .stop() to stop it")
        self._start_time = time.perf_counter()

    def stop(self):
        """Stop the timer, and report the elapsed time"""
        if self._start_time is None:
            raise TimerError(f"Timer is not running. Use .start() to start it")

        elapsed_time = time.perf_counter() - self._start_time
        self._start_time = None
        print(f"Elapsed time: {elapsed_time:0.4f} seconds")

這裡我們需要花點時間仔細地瀏覽程式碼,會發現一些不同的事情。

首先定義了一個TimerError Python 類。該(Exception)符號表示TimerError 繼承自另一個名為Exception的父類別。使用這個內建類進行錯誤處理。不需要向TimerError新增任何屬性或方法,但自定義錯誤可以更靈活地處理Timer內部問題。

接下來自定義Timer類。當從一個類建立或範例化一個物件時,程式碼會呼叫特殊方法.__init__()初始化範例。在這裡定義的第一個Timer版本中,只需初始化._start_time屬性,將用它來跟蹤 Python 計時器的狀態,計時器未執行時它的值為None。計時器執行後,用它來跟蹤計時器的啟動時間。

注意: ._start_time的第一個下劃線(_)字首是Python約定。它表示._start_time是Timer類的使用者不應該操作的內部屬性。

當呼叫.start()啟動新的 Python 計時器時,首先檢查計時器是否執行。然後將perf_counter()的當前值儲存在._start_time中。

另一方面,當呼叫.stop()時,首先檢查Python計時器是否正在執行。如果是,則將執行時間計算為perf_counter()的當前值與儲存在._start_time中的值的差值。最後,重置._start_time,以便重新啟動計時器,並列印執行時間。

以下是使用Timer方法:

from timer import Timer
t = Timer()
t.start()
# 幾秒鐘後
t.stop()

Elapsed time: 3.8191 seconds

將此範例與前面直接使用perf_counter()的範例進行比較。程式碼的結構相似,但現在程式碼更清晰了,這也是使用類的好處之一。通過仔細選擇類、方法和屬性名稱,可以使你的程式碼非常具有描述性!

使用 Python 計時器類

現在Timer類中寫入download_data.py。只需要對以前的程式碼進行一些更改:

# download_data.py
import requests
from timer import Timer
def main():
    t = Timer()
    t.start()
    source_url = 'https://cloud.tsinghua.edu.cn/d/e1ccfff39ad541908bae/files/?p=%2Fall_six_datasets.zip&dl=1'
    headers = {'User-Agent': 'Mozilla/5.0'}
    res = requests.get(source_url, headers=headers)
    with open('dataset/datasets.zip', 'wb') as f:
        f.write(res.content)
    t.stop()
if __name__=="__main__":
    main()

注意,該程式碼與之前使用的程式碼非常相似。除了使程式碼更具可讀性之外,Timer還負責將經過的時間列印到控制檯,使得所用時間的記錄更加一致。執行程式碼時,得到的輸出幾乎相同:

Elapsed time: 0.502 seconds
...

列印經過的時間Timer可能是一致的,但這種方法好像不是很靈活。下面我們新增一些更加靈活的東西到程式碼中。

增加更多的便利性和靈活性

到目前為止,我們已經瞭解到類適用於我們想要封裝狀態並確保程式碼一致性的情況。在本節中,我們將一起給 Python 計時器加入更多便利性和靈活性,那怎麼做呢?

  • 在報告消耗的時間時,使用可調整的文字和格式
  • 紀錄檔記錄列印到控制檯、寫入到紀錄檔檔案或程式的其他部分
  • 建立一個可以在多次呼叫中可積累的Python計時器
  • 構建 Python 計時器的資訊表示

首先,自定義用於報告所用時間的文字。在前面的程式碼中,文字 f"Elapsed time: {elapsed_time:0.4f} seconds" 被生寫死到 .stop() 中。如若想使得類程式碼更加靈活, 可以使用範例變數,其值通常作為引數傳遞給.__init__()並儲存到 self 屬性。為方便起見,我們還可以提供合理的預設值。

要新增.textTimer範例變數,可執行以下操作timer.py

# timer.py
def __init__(self, text="Elapsed time: {:0.4f} seconds"):
    self._start_time = None
    self.text = text

注意,預設文字"Elapsed time: {:0.4f} seconds"是作為一個常規字串給出的,而不是f-string。這裡不能使用f-string,因為f-string會立即計算,當你範例化Timer時,你的程式碼還沒有計算出消耗的時間。

注意: 如果要使用f-string來指定.text,則需要使用雙花括號來跳脫實際經過時間將替換的花括號。

如:f"Finished {task} in {{:0.4f}} seconds"。如果task的值是"reading",那麼這個f-string將被計算為"Finished reading in {:0.4f} seconds"

.stop()中,.text用作模板並使用.format()方法填充模板:

# timer.py
def stop(self):
    """Stop the timer, and report the elapsed time"""
    if self._start_time is None:
        raise TimerError(f"Timer is not running. Use .start() to start it")

    elapsed_time = time.perf_counter() - self._start_time
    self._start_time = None
    print(self.text.format(elapsed_time))

在此更新為timer.py之後,可以將文字更改如下:

from timer import Timer
t = Timer(text="You waited {:.1f} seconds")
t.start()
# 幾秒鐘後
t.stop()

You waited 4.1 seconds

接下來,我們不只是想將訊息列印到控制檯,還想儲存時間測量結果,這樣可以便於將它們儲存在資料庫中。可以通過從.stop()返回elapsed_time的值來實現這一點。然後,呼叫程式碼可以選擇忽略該返回值或儲存它以供以後處理。

如果想要將Timer整合到紀錄檔logging中。要支援計時器的紀錄檔記錄或其他輸出,需要更改對print()的呼叫,以便使用者可以提供自己的紀錄檔記錄函數。這可以用類似於你之前客製化的文字來完成:

# timer.py
# ...
class Timer:
    def __init__(
        self,
        text="Elapsed time: {:0.4f} seconds",
        logger=print
    ):
        self._start_time = None
        self.text = text
        self.logger = logger
    # 其他方法保持不變
    def stop(self):
        """Stop the timer, and report the elapsed time"""
        if self._start_time is None:
            raise TimerError(f"Timer is not running. Use .start() to start it")
        elapsed_time = time.perf_counter() - self._start_time
        self._start_time = None
        if self.logger:
            self.logger(self.text.format(elapsed_time))

        return elapsed_time

不是直接使用print(),而是建立另一個範例變數 self.logger,參照一個接受字串作為引數的函數。除此之外,還可以對檔案物件使用logging.info().write()等函數。還要注意if中,它允許通過傳遞logger=None來完全關閉列印。

以下是兩個範例,展示了新功能的實際應用:

from timer import Timer
import logging
t = Timer(logger=logging.warning)
t.start()
# 幾秒鐘後
t.stop()  # A few seconds later

WARNING:root:Elapsed time: 3.1610 seconds  
3.1609658249999484

t = Timer(logger=None)
t.start()
# 幾秒鐘後
value = t.stop()
value

4.710851433001153

接下來第三個改進是積累時間度量的能力。例如,在迴圈中呼叫一個慢速函數時,希望以命名計時器的形式新增更多的功能,並使用一個字典來跟蹤程式碼中的每個Python計時器。

我們擴充套件download_data.py指令碼。

# download_data.py
import requests
from timer import Timer
def main():
    t = Timer()
    t.start()
    source_url = 'https://cloud.tsinghua.edu.cn/d/e1ccfff39ad541908bae/files/?p=%2Fall_six_datasets.zip&dl=1'
    headers = {'User-Agent': 'Mozilla/5.0'}
    for i in range(10):
        res = requests.get(source_url, headers=headers)
        with open('dataset/datasets.zip', 'wb') as f:
            f.write(res.content)
    t.stop()
if __name__=="__main__":
    main()

這段程式碼的一個微妙問題是,不僅要測量下載資料所需的時間,還要測量 Python 儲存資料到磁碟所花費的時間。這可能並重要,有時候這兩者所花費的時間可以忽略不計。但還是希望有一種方法可以精確地計時沒一個步驟,將會更好。

有幾種方法可以在不改變Timer當前實現的情況下解決這個問題,且只需要幾行程式碼即可實現。

首先,將引入一個名為.timers的字典作為Timer的類變數,此時Timer的所有範例將共用它。通過在任何方法之外定義它來實現它:

class Timer:
    timers = {}

類變數可以直接在類上存取,也可以通過類的範例存取:

>>> from timer import Timer
>>> Timer.timers
{}

>>> t = Timer()
>>> t.timers
{}

>>> Timer.timers is t.timers
True

在這兩種情況下,程式碼都返回相同的空類字典。

接下來向 Python 計時器新增可選名稱。可以將該名稱用於兩種不同的目的:

  • 在程式碼中查詢經過的時間
  • 累加同名定時器

要向Python計時器新增名稱,需要對 timer.py 進行更改。首先,Timer 接受 name 引數。第二,當計時器停止時,執行時間應該新增到 .timers 中:

# timer.py
# ...
class Timer:
    timers = {}
    def __init__(
        self,
        name=None,
        text="Elapsed time: {:0.4f} seconds",
        logger=print,
    ):
        self._start_time = None
        self.name = name
        self.text = text
        self.logger = logger

        # 向計時器字典中新增新的命名計時器
        if name:
            self.timers.setdefault(name, 0)

    # 其他方法保持不變
    
    def stop(self):
        """Stop the timer, and report the elapsed time"""
        if self._start_time is None:
            raise TimerError(f"Timer is not running. Use .start() to start it")
        elapsed_time = time.perf_counter() - self._start_time
        self._start_time = None
        if self.logger:
            self.logger(self.text.format(elapsed_time))
        if self.name:
            self.timers[self.name] += elapsed_time
        return elapsed_time

注意,在向.timers中新增新的Python計時器時,使用了.setdefault()方法。它只在沒有在字典中定義name的情況下設定值,如果name已經在.timers中使用,那麼該值將保持不變,此時可以積累幾個計時器:

>>> from timer import Timer
>>> t = Timer("accumulate")
>>> t.start()

>>> t.stop()  # A few seconds later
Elapsed time: 3.7036 seconds
3.703554293999332

>>> t.start()

>>> t.stop()  # A few seconds later
Elapsed time: 2.3449 seconds
2.3448921170001995

>>> Timer.timers
{'accumulate': 6.0484464109995315}

現在可以重新存取download_data.py並確保僅測量下載資料所花費的時間:

# download_data.py
import requests
from timer import Timer
def main():
    t = Timer("download", logger=None)
    source_url = 'https://cloud.tsinghua.edu.cn/d/e1ccfff39ad541908bae/files/?p=%2Fall_six_datasets.zip&dl=1'
    headers = {'User-Agent': 'Mozilla/5.0'}
    for i in range(10):
        t.start()
        res = requests.get(source_url, headers=headers)
        t.stop()
        with open('dataset/datasets.zip', 'wb') as f:
            f.write(res.content)
    download_time = Timer.timers["download"]
    print(f"Downloaded 10 dataset in {download_time:0.2f} seconds")
    
if __name__=="__main__":
    main()

現在你有了一個非常簡潔的版本,Timer它一致、靈活、方便且資訊豐富!也可以將本節中所做的許多改進應用於專案中的其他型別的類。

Timer改進

最後一個改進Timer,以互動方式使用它時使其更具資訊性。下面操作是範例化一個計時器類,並檢視其資訊:

>>> from timer import Timer
>>> t = Timer()
>>> t
<timer.Timer object at 0x7f0578804320>

最後一行是 Python 表示物件的預設方式。我們從這個結果中看到的資訊,並不是很明確,我們接下來對其進行改進。

這裡介紹一個 dataclasses 類,該類僅包含在 Python 3.7 及更高版本中。

pip install dataclasses

可以使用@dataclass裝飾器將 Python 計時器轉換為資料類

# timer.py
import time
from dataclasses import dataclass, field
from typing import Any, ClassVar
# ...
@dataclass
class Timer:
    timers: ClassVar = {}
    name: Any = None
    text: Any = "Elapsed time: {:0.4f} seconds"
    logger: Any = print
    _start_time: Any = field(default=None, init=False, repr=False)

    def __post_init__(self):
        """Initialization: add timer to dict of timers"""
        if self.name:
            self.timers.setdefault(self.name, 0)

    # 其餘程式碼不變

此程式碼替換了之前的 .__init__() 方法。請注意資料類如何使用類似於之前看到的用於定義所有變數的類變數語法的語法。事實上,.__init__()是根據類定義中的註釋變數自動為資料類建立的。

如果需要註釋變數以使用資料類。可以使用此註解向程式碼新增型別提示。如果不想使用型別提示,那麼可以使用 Any 來註釋所有變數。接下來我們很快就會學習如何將實際型別提示新增到我們的資料類中。

以下是有關 Timer 資料類的一些注意事項:

  • 第 6 行:@dataclass 裝飾器將 Timer 定義為資料類。
  • 第 8 行:資料類需要特殊的 ClassVar 註釋來指定 .timers 是一個類變數。
  • 第 9 到 11 行:.name.text 和 .logger 將被定義為 Timer 上的屬性,可以在建立 Timer 範例時指定其值。它們都有給定的預設值。
  • 第 12 行:回想一下 ._start_time 是一個特殊屬性,用於跟蹤 Python 計時器的狀態,但它應該對使用者隱藏。使用 dataclasses.field() ._start_time 應該從 .__init__() 和 Timer 的表示中刪除。
  • 除了設定範例屬性之外,可以使用特殊的 .__post_init__() 方法進行初始化。這裡使用它將命名的計時器新增到 .timers

新 Timer 資料類與之前的常規類使用功能一樣,但它現在有一個很好的資訊表示

from timer import Timer
t = Timer()
t
Timer(name=None, 
  text='Elapsed time: {:0.4f} seconds',
  logger=<built-in function print>)
t.start()
# 幾秒鐘後
t.stop()

Elapsed time: 6.7197 seconds
6.719705373998295

總結

現在我們有了一個非常簡潔的 Timer 版本,它一致、靈活、方便且資訊豐富!我們還可以將本文中所做的許多改進應用於專案中的其他型別的類。

現在我們存取當前的完整原始碼Timer。會注意到在程式碼中新增了型別提示以獲取額外的檔案:

# timer.py

from dataclasses import dataclass, field
import time
from typing import Callable, ClassVar, Dict, Optional

class TimerError(Exception):
    """A custom exception used to report errors in use of Timer class"""

@dataclass
class Timer:
    timers: ClassVar[Dict[str, float]] = {}
    name: Optional[str] = None
    text: str = "Elapsed time: {:0.4f} seconds"
    logger: Optional[Callable[[str], None]] = print
    _start_time: Optional[float] = field(default=None, init=False, repr=False)

    def __post_init__(self) -> None:
        """Add timer to dict of timers after initialization"""
        if self.name is not None:
            self.timers.setdefault(self.name, 0)

    def start(self) -> None:
        """Start a new timer"""
        if self._start_time is not None:
            raise TimerError(f"Timer is running. Use .stop() to stop it")

        self._start_time = time.perf_counter()

    def stop(self) -> float:
        """Stop the timer, and report the elapsed time"""
        if self._start_time is None:
            raise TimerError(f"Timer is not running. Use .start() to start it")

        # Calculate elapsed time
        elapsed_time = time.perf_counter() - self._start_time
        self._start_time = None

        # Report elapsed time
        if self.logger:
            self.logger(self.text.format(elapsed_time))
        if self.name:
            self.timers[self.name] += elapsed_time

        return elapsed_time

總結下: 使用類建立 Python 計時器有幾個好處:

  • 可讀性:仔細選擇類和方法名稱,你的程式碼將更自然地閱讀。
  • 一致性:將屬性和行為封裝到屬性和方法中,你的程式碼將更易於使用。
  • 靈活性:使用具有預設值而不是寫死值的屬性,你的程式碼將是可重用的。

這個類非常靈活,幾乎可以在任何需要監控程式碼執行時間的情況下使用它。但是,在接下來的部分中,雲朵君將和大家一起了解如何使用上下文管理器和裝飾器,這將更方便地對程式碼塊和函數進行計時。

以上就是手把手帶你用Python實現一個計時器的詳細內容,更多關於Python計時器的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com