手把手教你實現(xiàn)一個 Python 計時器
為了更好地掌握 Python 計時器的應(yīng)用,我們后面還補充了有關(guān)Python類、上下文管理器和裝飾器的背景知識。因篇幅限制,其中利用上下文管理器和裝飾器優(yōu)化 Python 計時器,將在后續(xù)文章學(xué)習(xí),不在本篇文章范圍內(nèi)。
Python 計時器
首先,?我們向某段代碼中添加一個Python 計時器以監(jiān)控其性能。
Python 定時器函數(shù)
Python 中的內(nèi)置time[1]模塊中有幾個可以測量時間的函數(shù):
- monotonic()
- perf_counter()
- process_time()
- time()
Python 3.7 引入了幾個新函數(shù),如thread_time()[2],以及上述所有函數(shù)的納秒版本,以_ns?后綴命名。例如,perf_counter_ns()?是perf_counter()的納秒版本的。
perf_counter()
返回性能計數(shù)器的值(以秒為單位),即具有最高可用分辨率的時鐘以測量短持續(xù)時間。
首先,使用perf_counter()?創(chuàng)建一個 Python 計時器。將把它與其他 Python 計時器函數(shù)進行比較,看看 perf_counter() 的優(yōu)勢。
示例
創(chuàng)建一個腳本,?定義一個簡短的函數(shù):從清華云?上下載一組數(shù)據(jù)。
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 計時器來監(jiān)控該?腳本的性能。
第一個 Python 計時器
現(xiàn)在使用函數(shù)time.perf_counter()?函數(shù)創(chuàng)建一個計時器,這是一個非常適合針對部分代碼的性能計時的計數(shù)器。
perf_counter()?從某個未指定的時刻開始測量時間(以秒為單位),這意味著對該函數(shù)的單個調(diào)用的返回值沒有用。但當(dāng)查看對perf_counter()兩次調(diào)用之間的差異時,可以計算出兩次調(diào)用之間經(jīng)過了多少秒。
>>> import time
>>> time.perf_counter()
394.540232282
>>> time.perf_counter() # 幾秒鐘后
413.31714087
在此示例中,兩次調(diào)用 perf_counter() 相隔近 19 秒。可以通過計算兩個輸出之間的差異來確認(rèn)這一點:413.31714087 - 394.540232282 = 18.78。
現(xiàn)在可以將 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()?通過計算兩次調(diào)用之間的差異來打印整個程序運行所花費的時間。
print()?函數(shù)中 f? 字符串前面的表示這是一個 f-string? ,這是格式化文本字符串的較為便捷的?方式。:0.4f?是一個格式說明符,表示數(shù)字,toc - tic應(yīng)打印為帶有四位小數(shù)的十進制數(shù)。
運行程序可以看到程序經(jīng)過的時間:
該程序耗時: 0.026 seconds
就是這么簡單。接下來我們一起學(xué)習(xí)如何將 Python 計時器包裝到一個類、一個上下文管理器和一個裝飾器中(該系列后續(xù)兩篇文章,待更新)?,這樣可以更加一致和方便使用計時器。
一個 Python 定時器類
這里我們至少需要一個變量?來存儲 Python 計時器的狀態(tài)。接下來?我們創(chuàng)建一個與手動調(diào)用 perf_counter() 相同的類,但更具可讀性和一致性?。
創(chuàng)建和更新Timer類,?使用該類以多種不同方式對代碼進行計時。
$ python -m pip install codetiming
理解 Python 中的類?
Class?類是面向?qū)ο缶幊痰闹饕獦?gòu)建塊。類本質(zhì)上是一個模板,可以使用它來創(chuàng)建對象。
在 Python 中,當(dāng)需要對需要跟蹤特定狀態(tài)的事物進行建模時,類非常有用。一般來說,類是屬性的集合,稱為屬性,以及行為,稱為方法。
創(chuàng)建 Python 計時器類
類有利于跟蹤狀態(tài)。在Timer?類中,想要跟蹤計時器何時開始以及已經(jīng)多少時間。對于Timer?類的第一個實現(xiàn),將添加一個._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")
這里我們需要花點時間仔細地瀏覽代碼,會發(fā)現(xiàn)一些不同的事情。
首先定義了一個TimerError? Python 類。該(Exception)?符號表示TimerError? 繼承自另一個名為Exception?的父類。使用這個內(nèi)置類進行錯誤處理。不需要向TimerError?添加任何屬性或方法,但自定義錯誤可以更靈活地處理Timer內(nèi)部問題。
接下來自定義Timer?類。當(dāng)從一個類創(chuàng)建或?qū)嵗粋€對象時,代碼會調(diào)用特殊方法.__init__()?初始化實例。在這里定義的第一個Timer?版本中,只需初始化._start_time?屬性,將用它來跟蹤 Python 計時器的狀態(tài),計時器未運行時它的值為None。計時器運行后,用它來跟蹤計時器的啟動時間。
注意: ._start_time?的第一個下劃線(_)?前綴是Python約定。它表示._start_time是Timer類的用戶不應(yīng)該操作的內(nèi)部屬性。
當(dāng)調(diào)用.start()?啟動新的 Python 計時器時,首先檢查計時器是否運行。然后將perf_counter()?的當(dāng)前值存儲在._start_time中。
另一方面,當(dāng)調(diào)用.stop()?時,首先檢查Python計時器是否正在運行。如果是,則將運行時間計算為perf_counter()?的當(dāng)前值與存儲在._start_time?中的值的差值。最后,重置._start_time,以便重新啟動計時器,并打印運行時間。
以下是使用Timer方法:
from timer import Timer
t = Timer()
t.start()
# 幾秒鐘后
t.stop()
Elapsed time: 3.8191 seconds
將此示例與前面直接使用perf_counter()的示例進行比較。代碼的結(jié)構(gòu)相似,但現(xiàn)在代碼更清晰了,這也是使用類的好處之一。通過仔細選擇類、方法和屬性名稱,可以使你的代碼非常具有描述性!
使用 Python 計時器類
現(xiàn)在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還負責(zé)將經(jīng)過的時間打印到控制臺,使得所用時間的記錄更加一致。運行代碼時,得到的輸出幾乎相同:
Elapsed time: 0.502 seconds
...
打印經(jīng)過的時間Timer可能是一致的,但這種方法好像不是很靈活。下面我們添加一些更加靈活的東西到代碼中。?
增加更多的便利性和靈活性
到目前為止,我們已經(jīng)了解到類適用于我們想要封裝狀態(tài)并確保代碼一致性的情況。在本節(jié)中,我們將一起給 Python 計時器加入更多便利性和靈活性,那?怎么做呢?
- 在報告消耗的時間時,使用可調(diào)整的文本和格式
- 將日志記錄?打印到控制臺、寫入到?日志文件或程序的其他部分
- 創(chuàng)建一個可以在多次調(diào)用中可積累的Python計時器
- 構(gòu)建 Python 計時器的信息表示
首先,自定義用于報告所用時間的文本。在前面的代碼中,文本 f"Elapsed time: {elapsed_time:0.4f} seconds"? 被生?硬編碼到 .stop() ?中。如若想使得類代碼更加靈活, 可以使用實例變量,其值通常作為參數(shù)傳遞給.__init__()?并存儲到 self 屬性。為方便起見,我們還可以提供合理的默認(rèn)值。
要添加.text?為Timer?實例變量,可執(zhí)行以下操作timer.py:
# timer.py
def __init__(self, text="Elapsed time: {:0.4f} seconds"):
self._start_time = None
self.text = text
注意,默認(rèn)文本"Elapsed time: {:0.4f} seconds"?是作為一個常規(guī)字符串給出的,而不是f-string?。這里不能使用f-string?,因為f-string會立即計算,當(dāng)你實例化Timer時,你的代碼還沒有計算出消耗的時間。
注意: 如果要使用f-string?來指定.text,則需要使用雙花括號來轉(zhuǎn)義實際經(jīng)過時間將替換的花括號。
如: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
接下來,我們不只是想將消息打印到控制臺,還想保存時間測量結(jié)果,這樣可以便于將它們存儲在數(shù)據(jù)庫中。可以通過從.stop()?返回elapsed_time的值來實現(xiàn)這一點。然后,調(diào)用代碼可以選擇忽略該返回值或保存它以供以后處理。
如果想要將Timer集成到日志logging中。要支持計時器的日志記錄或其他輸出,需要更改對print()的調(diào)用,以便用戶可以提供自己的日志記錄函數(shù)。這可以用類似于你之前定制的文本來完成:
# 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()?,而是創(chuàng)建另一個實例變量 self.logger?,引用一個接受字符串作為參數(shù)的函數(shù)。除此之外,還可以對文件對象使用logging.info()?或.write()?等函數(shù)。還要注意if中,它允許通過傳遞logger=None來完全關(guān)閉打印。
以下是兩個示例,展示了新功能的實際應(yīng)用:
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
接下來第三個改進是積累時間度量的能力。例如,在循環(huán)中調(diào)用一個慢速函數(shù)時,希望以命名計時器的形式添加更多的功能,并使用一個字典來跟蹤代碼中的每個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()
這段代碼的一個微妙問題是,不僅要測量下載數(shù)據(jù)所需的時間,還要測量 Python 存儲數(shù)據(jù)到磁盤所花費的時間。這可能并重要,有時候這兩者所花費的時間可以忽略不計。但還是希望有一種方法可以精確地計時沒一個步驟,將會更好。
有幾種方法可以在不改變Timer當(dāng)前實現(xiàn)的情況下解決這個問題,且只需要幾行代碼即可實現(xiàn)。
首先,將引入一個名為.timers的字典作為Timer的類變量,此時Timer的所有實例將共享它。通過在任何方法之外定義它來實現(xiàn)它:
class Timer:
timers = {}
類變量可以直接在類上訪問,也可以通過類的實例訪問:
>>> from timer import Timer
>>> Timer.timers
{}
>>> t = Timer()
>>> t.timers
{}
>>> Timer.timers is t.timers
True
在這兩種情況下,代碼都返回相同的空類字典。
接下來向 Python 計時器添加可選名稱。可以將該名稱用于兩種不同的目的:
- 在代碼中查找經(jīng)過的時間
- 累加同名定時器
要向Python計時器添加名稱,需要對 timer.py? 進行更改。首先,Timer 接受 name 參數(shù)。第二,當(dāng)計時器停止時,運行時間應(yīng)該添加到 .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的情況下設(shè)置值,如果name已經(jīng)在.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}
現(xiàn)在可以重新訪問download_data.py并確保僅測量下載數(shù)據(jù)所花費的時間:
# 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()
現(xiàn)在你有了一個非常簡潔的版本,Timer它一致、靈活、方便且信息豐富!也可以將本節(jié)中所做的許多改進應(yīng)用于項目中的其他類型的類。
Timer改進
最后一個改進Timer,以交互方式使用它時使其更具信息性。下面操作是實例化一個計時器類,并查看其信息:
>>> from timer import Timer
>>> t = Timer()
>>> t
<timer.Timer object at 0x7f0578804320>
最后一行是 Python 表示對象的默認(rèn)方式。我們從這個結(jié)果中看到的信息,并不是很明確,我們接下來對其進行改進。
這里介紹一個 dataclasses 類,該類僅包含在 Python 3.7 及更高版本中。
pip install dataclasses
可以使用@dataclass裝飾器將 Python 計時器轉(zhuǎn)換為數(shù)據(jù)類
# 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__() ?方法。請注意數(shù)據(jù)類如何使用類似于之前看到的用于定義所有變量的類變量語法的語法。事實上,.__init__()是根據(jù)類定義中的注釋變量自動為數(shù)據(jù)類創(chuàng)建的。
如果需要注釋變量以使用數(shù)據(jù)類。可以使用此注解向代碼添加類型提示。如果不想使用類型提示,那么可以使用 Any 來注釋所有變量。接下來我們很快就會學(xué)習(xí)如何將實際類型提示添加到我們的數(shù)據(jù)類中。
以下是有關(guān) Timer 數(shù)據(jù)類的一些注意事項:
- 第 6 行:@dataclass 裝飾器將Timer 定義為數(shù)據(jù)類。
- 第 8 行:數(shù)據(jù)類需要特殊的 ClassVar 注釋來指定.timers 是一個類變量。
- 第 9 到 11 行:.name?、.text? 和.logger 將被定義為 Timer 上的屬性,可以在創(chuàng)建 Timer 實例時指定其值。它們都有給定的默認(rèn)值。
- 第 12 行:回想一下._start_time? 是一個特殊屬性,用于跟蹤 Python 計時器的狀態(tài),但它應(yīng)該對用戶隱藏。使用dataclasses.field()?, ._start_time? 應(yīng)該從.__init__() 和 Timer 的表示中刪除。
- 除了設(shè)置實例屬性之外,可以使用特殊的 .__post_init__()? 方法進行初始化。這里使用它將命名的計時器添加到 .timers。
新 Timer 數(shù)據(jù)類與之前的常規(guī)類使用功能一樣,但它現(xiàn)在有一個很好的信息表示:
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
總結(jié)
現(xiàn)在我們有了一個非常簡潔的 Timer 版本,它一致、靈活、方便且信息豐富!我們還可以將本文中所做的許多改進應(yīng)用于項目中的其他類型的類。
現(xiàn)在我們訪問當(dāng)前的完整源代碼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
總結(jié)下: 使用類創(chuàng)建 Python 計時器有幾個好處:
- 可讀性:仔細選擇類和方法名稱,你的代碼將更自然地閱讀。
- 一致性:將屬性和行為封裝到屬性和方法中,你的代碼將更易于使用。
- 靈活性:使用具有默認(rèn)值而不是硬編碼值的屬性,你的代碼將是可重用的。
這個類非常靈活,幾乎可以在任何需要監(jiān)控代碼運行時間的情況下使用它。但是,在接下來的部分中,云朵君將和大家一起了解如何使用上下文管理器和裝飾器,這將更方便地對代碼塊和函數(shù)進行計時。