試驗(yàn)還是很棒的,但是調(diào)試?就沒那么有趣了。事實(shí)是,在 Python 測(cè)試代碼之前沒有編譯器來分析你的代碼,因此使的測(cè)試成為開發(fā)的一個(gè)重要部分。本章的目標(biāo)是討論一些關(guān)于測(cè)試、調(diào)試和異常處理的常見問題。但是并不是為測(cè)試驅(qū)動(dòng)開發(fā)或者單元測(cè)試模塊做一個(gè)簡(jiǎn)要的介紹。因此,筆者假定讀者熟悉測(cè)試概念。
你的程序中有個(gè)方法會(huì)輸出到標(biāo)準(zhǔn)輸出中(sys.stdout)。也就是說它會(huì)將文本打印到屏幕上面。 你想寫個(gè)測(cè)試來證明它,給定一個(gè)輸入,相應(yīng)的輸出能正常顯示出來。
使用 unittest.mock
模塊中的 patch()
函數(shù), 使用起來非常簡(jiǎn)單,可以為單個(gè)測(cè)試模擬 sys.stdout
然后回滾, 并且不產(chǎn)生大量的臨時(shí)變量或在測(cè)試用例直接暴露狀態(tài)變量。
作為一個(gè)例子,我們?cè)?mymodule
模塊中定義如下一個(gè)函數(shù):
# mymodule.py
def urlprint(protocol, host, domain):
url = '{}://{}.{}'.format(protocol, host, domain)
print(url)
默認(rèn)情況下內(nèi)置的 print
函數(shù)會(huì)將輸出發(fā)送到 sys.stdout
。 為了測(cè)試輸出真的在那里,你可以使用一個(gè)替身對(duì)象來模擬它,然后使用斷言來確認(rèn)結(jié)果。 使用 unittest.mock
模塊的patch()
方法可以很方便的在測(cè)試運(yùn)行的上下文中替換對(duì)象, 并且當(dāng)測(cè)試完成時(shí)候自動(dòng)返回它們的原有狀態(tài)。下面是對(duì) mymodule
模塊的測(cè)試代碼:
from io import StringIO
from unittest import TestCase
from unittest.mock import patch
import mymodule
class TestURLPrint(TestCase):
def test_url_gets_to_stdout(self):
protocol = 'http'
host = 'www'
domain = 'example.com'
expected_url = '{}://{}.{}\n'.format(protocol, host, domain)
with patch('sys.stdout', new=StringIO()) as fake_out:
mymodule.urlprint(protocol, host, domain)
self.assertEqual(fake_out.getvalue(), expected_url)
urlprint()
函數(shù)接受三個(gè)參數(shù),測(cè)試方法開始會(huì)先設(shè)置每一個(gè)參數(shù)的值。 expected_url
變量被設(shè)置成包含期望的輸出的字符串。
unittest.mock.patch()
函數(shù)被用作一個(gè)上下文管理器,使用StringIO
對(duì)象來代替 sys.stdout
. fake_out
變量是在該進(jìn)程中被創(chuàng)建的模擬對(duì)象。 在 with 語句中使用它可以執(zhí)行各種檢查。當(dāng) with 語句結(jié)束時(shí),patch
會(huì)將所有東西恢復(fù)到測(cè)試開始前的狀態(tài)。 有一點(diǎn)需要注意的是某些對(duì) Python 的 C 擴(kuò)展可能會(huì)忽略掉 sys.stdout
的配置二直接寫入到標(biāo)準(zhǔn)輸出中。 限于篇幅,本節(jié)不會(huì)涉及到這方面的講解,它適用于純 Python 代碼。 如果你真的需要在 C 擴(kuò)展中捕獲 I/O,你可以先打開一個(gè)臨時(shí)文件,然后將標(biāo)準(zhǔn)輸出重定向到該文件中。 更多關(guān)于捕獲以字符串形式捕獲 I/O 和 StringIO
對(duì)象請(qǐng)參閱5.6小節(jié)。
你寫的單元測(cè)試中需要給指定的對(duì)象打補(bǔ)丁, 用來斷言它們?cè)跍y(cè)試中的期望行為(比如,斷言被調(diào)用時(shí)的參數(shù)個(gè)數(shù),訪問指定的屬性等)。
unittest.mock.patch()
函數(shù)可被用來解決這個(gè)問題。patch()
還可被用作一個(gè)裝飾器、上下文管理器或單獨(dú)使用,盡管并不常見。 例如,下面是一個(gè)將它當(dāng)做裝飾器使用的例子:
from unittest.mock import patch
import example
@patch('example.func')
def test1(x, mock_func):
example.func(x) # Uses patched example.func
mock_func.assert_called_with(x)
它還可以被當(dāng)做一個(gè)上下文管理器:
with patch('example.func') as mock_func:
example.func(x) # Uses patched example.func
mock_func.assert_called_with(x)
最后,你還可以手動(dòng)的使用它打補(bǔ)?。?/p>
p = patch('example.func')
mock_func = p.start()
example.func(x)
mock_func.assert_called_with(x)
p.stop()
如果可能的話,你能夠疊加裝飾器和上下文管理器來給多個(gè)對(duì)象打補(bǔ)丁。例如:
@patch('example.func1')
@patch('example.func2')
@patch('example.func3')
def test1(mock1, mock2, mock3):
...
def test2():
with patch('example.patch1') as mock1, \
patch('example.patch2') as mock2, \
patch('example.patch3') as mock3:
...
patch()
接受一個(gè)已存在對(duì)象的全路徑名,將其替換為一個(gè)新的值。 原來的值會(huì)在裝飾器函數(shù)或上下文管理器完成后自動(dòng)恢復(fù)回來。 默認(rèn)情況下,所有值會(huì)被 MagicMock
實(shí)例替代。例如:
>>> x = 42
>>> with patch('__main__.x'):
... print(x)
...
<MagicMock name='x' id='4314230032'>
>>> x
42
>>>
不過,你可以通過給 patch()
提供第二個(gè)參數(shù)來將值替換成任何你想要的:
>>> x
42
>>> with patch('__main__.x', 'patched_value'):
... print(x)
...
patched_value
>>> x
42
>>>
被用來作為替換值的 MagicMock
實(shí)例能夠模擬可調(diào)用對(duì)象和實(shí)例。 他們記錄對(duì)象的使用信息并允許你執(zhí)行斷言檢查,例如:
>>> from unittest.mock import MagicMock
>>> m = MagicMock(return_value = 10)
>>> m(1, 2, debug=True)
10
>>> m.assert_called_with(1, 2, debug=True)
>>> m.assert_called_with(1, 2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File ".../unittest/mock.py", line 726, in assert_called_with
raise AssertionError(msg)
AssertionError: Expected call: mock(1, 2)
Actual call: mock(1, 2, debug=True)
>>>
>>> m.upper.return_value = 'HELLO'
>>> m.upper('hello')
'HELLO'
>>> assert m.upper.called
>>> m.split.return_value = ['hello', 'world']
>>> m.split('hello world')
['hello', 'world']
>>> m.split.assert_called_with('hello world')
>>>
>>> m['blah']
<MagicMock name='mock.__getitem__()' id='4314412048'>
>>> m.__getitem__.called
True
>>> m.__getitem__.assert_called_with('blah')
>>>
一般來講,這些操作會(huì)在一個(gè)單元測(cè)試中完成。例如,假設(shè)你已經(jīng)有了像下面這樣的函數(shù):
# example.py
from urllib.request import urlopen
import csv
def dowprices():
u = urlopen('http://finance.yahoo.com/d/quotes.csv?s=@^DJI&f=sl1')
lines = (line.decode('utf-8') for line in u)
rows = (row for row in csv.reader(lines) if len(row) == 2)
prices = { name:float(price) for name, price in rows }
return prices
正常來講,這個(gè)函數(shù)會(huì)使用 urlopen()
從 Web 上面獲取數(shù)據(jù)并解析它。 在單元測(cè)試中,你可以給它一個(gè)預(yù)先定義好的數(shù)據(jù)集。下面是使用補(bǔ)丁操作的例子:
import unittest
from unittest.mock import patch
import io
import example
sample_data = io.BytesIO(b'''\
"IBM",91.1\r
"AA",13.25\r
"MSFT",27.72\r
\r
''')
class Tests(unittest.TestCase):
@patch('example.urlopen', return_value=sample_data)
def test_dowprices(self, mock_urlopen):
p = example.dowprices()
self.assertTrue(mock_urlopen.called)
self.assertEqual(p,
{'IBM': 91.1,
'AA': 13.25,
'MSFT' : 27.72})
if __name__ == '__main__':
unittest.main()
本例中,位于 example
模塊中的 urlopen()
函數(shù)被一個(gè)模擬對(duì)象替代, 該對(duì)象會(huì)返回一個(gè)包含測(cè)試數(shù)據(jù)的 ByteIO()
.
還有一點(diǎn),在打補(bǔ)丁時(shí)我們使用了 example.urlopen
來代替 urllib.request.urlopen
。 當(dāng)你創(chuàng)建補(bǔ)丁的時(shí)候,你必須使用它們?cè)跍y(cè)試代碼中的名稱。 由于測(cè)試代碼使用了 from urllib.request import urlopen
,那么 dowprices()
函數(shù) 中使用的 urlopen()
函數(shù)實(shí)際上就位于 example
模塊了。
本節(jié)實(shí)際上只是對(duì) unittest.mock
模塊的一次淺嘗輒止。 更多更高級(jí)的特性,請(qǐng)參考官方文檔
你想寫個(gè)測(cè)試用例來準(zhǔn)確的判斷某個(gè)異常是否被拋出。
對(duì)于異常的測(cè)試可使用 assertRaises()
方法。 例如,如果你想測(cè)試某個(gè)函數(shù)拋出了 ValueError
異常,像下面這樣寫:
import unittest
# A simple function to illustrate
def parse_int(s):
return int(s)
class TestConversion(unittest.TestCase):
def test_bad_int(self):
self.assertRaises(ValueError, parse_int, 'N/A')
如果你想測(cè)試異常的具體值,需要用到另外一種方法:
import errno
class TestIO(unittest.TestCase):
def test_file_not_found(self):
try:
f = open('/file/not/found')
except IOError as e:
self.assertEqual(e.errno, errno.ENOENT)
else:
self.fail('IOError not raised')
assertRaises()
方法為測(cè)試異常存在性提供了一個(gè)簡(jiǎn)便方法。 一個(gè)常見的陷阱是手動(dòng)去進(jìn)行異常檢測(cè)。比如:
class TestConversion(unittest.TestCase):
def test_bad_int(self):
try:
r = parse_int('N/A')
except ValueError as e:
self.assertEqual(type(e), ValueError)
這種方法的問題在于它很容易遺漏其他情況,比如沒有任何異常拋出的時(shí)候。 那么你還得需要增加另外的檢測(cè)過程,如下面這樣:
class TestConversion(unittest.TestCase):
def test_bad_int(self):
try:
r = parse_int('N/A')
except ValueError as e:
self.assertEqual(type(e), ValueError)
else:
self.fail('ValueError not raised')
assertRaises()
方法會(huì)處理所有細(xì)節(jié),因此你應(yīng)該使用它。
assertRaises()
的一個(gè)缺點(diǎn)是它測(cè)不了異常具體的值是多少。 為了測(cè)試異常值,可以使用 assertRaisesRegex()
方法, 它可同時(shí)測(cè)試異常的存在以及通過正則式匹配異常的字符串表示。例如:
class TestConversion(unittest.TestCase):
def test_bad_int(self):
self.assertRaisesRegex(ValueError, 'invalid literal .*',
parse_int, 'N/A')
assertRaises()
和 assertRaisesRegex()
還有一個(gè)容易忽略的地方就是它們還能被當(dāng)做上下文管理器使用:
class TestConversion(unittest.TestCase):
def test_bad_int(self):
with self.assertRaisesRegex(ValueError, 'invalid literal .*'):
r = parse_int('N/A')
但你的測(cè)試涉及到多個(gè)執(zhí)行步驟的時(shí)候這種方法就很有用了。
你希望將單元測(cè)試的輸出寫到到某個(gè)文件中去,而不是打印到標(biāo)準(zhǔn)輸出。
運(yùn)行單元測(cè)試一個(gè)常見技術(shù)就是在測(cè)試文件底部加入下面這段代碼片段:
import unittest
class MyTest(unittest.TestCase):
pass
if __name__ == '__main__':
unittest.main()
這樣的話測(cè)試文件就是可執(zhí)行的,并且會(huì)將運(yùn)行測(cè)試的結(jié)果打印到標(biāo)準(zhǔn)輸出上。 如果你想重定向輸出,就需要像下面這樣修改 main()
函數(shù):
import sys
def main(out=sys.stderr, verbosity=2):
loader = unittest.TestLoader()
suite = loader.loadTestsFromModule(sys.modules[__name__])
unittest.TextTestRunner(out,verbosity=verbosity).run(suite)
if __name__ == '__main__':
with open('testing.out', 'w') as f:
main(f)
本節(jié)感興趣的部分并不是將測(cè)試結(jié)果重定向到一個(gè)文件中, 而是通過這樣做向你展示了 unittest
模塊中一些值得關(guān)注的內(nèi)部工作原理。
unittest
模塊首先會(huì)組裝一個(gè)測(cè)試套件。 這個(gè)測(cè)試套件包含了你定義的各種方法。一旦套件組裝完成,它所包含的測(cè)試就可以被執(zhí)行了。
這兩步是分開的,unittest.TestLoader
實(shí)例被用來組裝測(cè)試套件。 loadTestsFromModule()
是它定義的方法之一,用來收集測(cè)試用例。 它會(huì)為 TestCase
類掃描某個(gè)模塊并將其中的測(cè)試方法提取出來。 如果你想進(jìn)行細(xì)粒度的控制, 可以使用 loadTestsFromTestCase()
方法來從某個(gè)繼承 TestCase 的類中提取測(cè)試方法。 TextTestRunner
類是一個(gè)測(cè)試運(yùn)行類的例子, 這個(gè)類的主要用途是執(zhí)行某個(gè)測(cè)試套件中包含的測(cè)試方法。 這個(gè)類跟執(zhí)行 unittest.main()
函數(shù)所使用的測(cè)試運(yùn)行器是一樣的。 不過,我們?cè)谶@里對(duì)它進(jìn)行了一些列底層配置,包括輸出文件和提升級(jí)別。 盡管本節(jié)例子代碼很少,但是能指導(dǎo)你如何對(duì) unittest
框架進(jìn)行更進(jìn)一步的自定義。 要想自定義測(cè)試套件的裝配方式,你可以對(duì) TestLoader
類執(zhí)行更多的操作。 為了自定義測(cè)試運(yùn)行,你可以構(gòu)造一個(gè)自己的測(cè)試運(yùn)行類來模擬 TextTestRunner
的功能。 而這些已經(jīng)超出了本節(jié)的范圍。unittest
模塊的文檔對(duì)底層實(shí)現(xiàn)原理有更深入的講解,可以去看看。
你想在單元測(cè)試中忽略或標(biāo)記某些測(cè)試會(huì)按照預(yù)期運(yùn)行失敗。
unittest
模塊有裝飾器可用來控制對(duì)指定測(cè)試方法的處理,例如:
import unittest
import os
import platform
class Tests(unittest.TestCase):
def test_0(self):
self.assertTrue(True)
@unittest.skip('skipped test')
def test_1(self):
self.fail('should have failed!')
@unittest.skipIf(os.name=='posix', 'Not supported on Unix')
def test_2(self):
import winreg
@unittest.skipUnless(platform.system() == 'Darwin', 'Mac specific test')
def test_3(self):
self.assertTrue(True)
@unittest.expectedFailure
def test_4(self):
self.assertEqual(2+2, 5)
if __name__ == '__main__':
unittest.main()
如果你在 Mac 上運(yùn)行這段代碼,你會(huì)得到如下輸出:
bash % python3 testsample.py -v
test_0 (__main__.Tests) ... ok
test_1 (__main__.Tests) ... skipped 'skipped test'
test_2 (__main__.Tests) ... skipped 'Not supported on Unix'
test_3 (__main__.Tests) ... ok
test_4 (__main__.Tests) ... expected failure
----------------------------------------------------------------------
Ran 5 tests in 0.002s
OK (skipped=2, expected failures=1)
討論
skip()
裝飾器能被用來忽略某個(gè)你不想運(yùn)行的測(cè)試。 skipIf()
和skipUnless()
對(duì)于你只想在某個(gè)特定平臺(tái)或 Python 版本或其他依賴成立時(shí)才運(yùn)行測(cè)試的時(shí)候非常有用。 使用 @expected
的失敗裝飾器來標(biāo)記那些確定會(huì)失敗的測(cè)試,并且對(duì)這些測(cè)試你不想讓測(cè)試框架打印更多信息。
忽略方法的裝飾器還可以被用來裝飾整個(gè)測(cè)試類,比如:
@unittest.skipUnless(platform.system() == 'Darwin', 'Mac specific tests')
class DarwinTests(unittest.TestCase):
pass
你有一個(gè)代碼片段可能會(huì)拋出多個(gè)不同的異常,怎樣才能不創(chuàng)建大量重復(fù)代碼就能處理所有的可能異常呢?
如果你可以用單個(gè)代碼塊處理不同的異常,可以將它們放入一個(gè)元組中,如下所示:
try:
client_obj.get_url(url)
except (URLError, ValueError, SocketTimeout):
client_obj.remove_url(url)
在這個(gè)例子中,元祖中任何一個(gè)異常發(fā)生時(shí)都會(huì)執(zhí)行 remove_url()
方法。 如果你想對(duì)其中某個(gè)異常進(jìn)行不同的處理,可以將其放入另外一個(gè) except
語句中:
try:
client_obj.get_url(url)
except (URLError, ValueError):
client_obj.remove_url(url)
except SocketTimeout:
client_obj.handle_url_timeout(url)
很多的異常會(huì)有層級(jí)關(guān)系,對(duì)于這種情況,你可能使用它們的一個(gè)基類來捕獲所有的異常。例如,下面的代碼:
try:
f = open(filename)
except (FileNotFoundError, PermissionError):
pass
可以被重寫為:
try:
f = open(filename)
except OSError:
pass
OSError
是 FileNotFoundError
和 PermissionError
異常的基類。
盡管處理多個(gè)異常本身并沒什么特殊的,不過你可以使用 as
關(guān)鍵字來獲得被拋出異常的引用:
try:
f = open(filename)
except OSError as e:
if e.errno == errno.ENOENT:
logger.error('File not found')
elif e.errno == errno.EACCES:
logger.error('Permission denied')
else:
logger.error('Unexpected error: %d', e.errno)
這個(gè)例子中, e
變量指向一個(gè)被拋出的 OSError
異常實(shí)例。 這個(gè)在你想更進(jìn)一步分析這個(gè)異常的時(shí)候會(huì)很有用,比如基于某個(gè)狀態(tài)碼來處理它。
同時(shí)還要注意的時(shí)候 except
語句是順序檢查的,第一個(gè)匹配的會(huì)執(zhí)行。 你可以很容易的構(gòu)造多個(gè) except
同時(shí)匹配的情形,比如:
>>> f = open('missing')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'missing'
>>> try:
... f = open('missing')
... except OSError:
... print('It failed')
... except FileNotFoundError:
... print('File not found')
...
It failed
>>>
這里的 FileNotFoundError
語句并沒有執(zhí)行的原因是 OSError
更一般,它可匹配 FileNotFoundError
異常, 于是就是第一個(gè)匹配的。 在調(diào)試的時(shí)候,如果你對(duì)某個(gè)特定異常的類成層級(jí)關(guān)系不是很確定, 你可以通過查看該異常的 __mro__
屬性來快速瀏覽。比如:
>>> FileNotFoundError.__mro__
(<class 'FileNotFoundError'>, <class 'OSError'>, <class 'Exception'>,
<class 'BaseException'>, <class 'object'>)
>>>
上面列表中任何一個(gè)直到 BaseException
的類都能被用于 except
語句。
怎樣捕獲代碼中的所有異常?
想要捕獲所有的異常,可以直接捕獲 Exception
即可:
try:
...
except Exception as e:
...
log('Reason:', e) # Important!
這個(gè)將會(huì)捕獲除了 SystemExit
、KeyboardInterrupt
和 GeneratorExit
之外的所有異常。 如果你還想捕獲這三個(gè)異常,將 Exception
改成 BaseException
即可。
捕獲所有異常通常是由于程序員在某些復(fù)雜操作中并不能記住所有可能的異常。 如果你不是很細(xì)心的人,這也是編寫不易調(diào)試代碼的一個(gè)簡(jiǎn)單方法。
正因如此,如果你選擇捕獲所有異常,那么在某個(gè)地方(比如日志文件、打印異常到屏幕)打印確切原因就比較重要了。 如果你沒有這樣做,有時(shí)候你看到異常打印時(shí)可能摸不著頭腦,就像下面這樣:
def parse_int(s):
try:
n = int(v)
except Exception:
print("Couldn't parse")
試著運(yùn)行這個(gè)函數(shù),結(jié)果如下:
>>> parse_int('n/a')
Couldn't parse
>>> parse_int('42')
Couldn't parse
>>>
這時(shí)候你就會(huì)撓頭想:“這咋回事啊?” 假如你像下面這樣重寫這個(gè)函數(shù):
def parse_int(s):
try:
n = int(v)
except Exception as e:
print("Couldn't parse")
print('Reason:', e)
這時(shí)候你能獲取如下輸出,指明了有個(gè)編程錯(cuò)誤:
>>> parse_int('42')
Couldn't parse
Reason: global name 'v' is not defined
>>>
很明顯,你應(yīng)該盡可能將異常處理器定義的精準(zhǔn)一些。 不過,要是你必須捕獲所有異常,確保打印正確的診斷信息或?qū)惓鞑コ鋈?,這樣不會(huì)丟失掉異常。
在你構(gòu)建的應(yīng)用程序中,你想將底層異常包裝成自定義的異常。
創(chuàng)建新的異常很簡(jiǎn)單——定義新的類,讓它繼承自 Exception
(或者是任何一個(gè)已存在的異常類型)。 例如,如果你編寫網(wǎng)絡(luò)相關(guān)的程序,你可能會(huì)定義一些類似如下的異常:
class NetworkError(Exception):
pass
class HostnameError(NetworkError):
pass
class TimeoutError(NetworkError):
pass
class ProtocolError(NetworkError):
pass
然后用戶就可以像通常那樣使用這些異常了,例如:
try:
msg = s.recv()
except TimeoutError as e:
...
except ProtocolError as e:
...
自定義異常類應(yīng)該總是繼承自內(nèi)置的 Exception
類, 或者是繼承自那些本身就是從 Exception
繼承而來的類。 盡管所有類同時(shí)也繼承自 BaseException
,但你不應(yīng)該使用這個(gè)基類來定義新的異常。 BaseException
是為系統(tǒng)退出異常而保留的,比如 KeyboardInterrupt
或 SystemExit
以及其他那些會(huì)給應(yīng)用發(fā)送信號(hào)而退出的異常。 因此,捕獲這些異常本身沒什么意義。 這樣的話,假如你繼承BaseException
可能會(huì)導(dǎo)致你的自定義異常不會(huì)被捕獲而直接發(fā)送信號(hào)退出程序運(yùn)行。
在程序中引入自定義異常可以使得你的代碼更具可讀性,能清晰顯示誰應(yīng)該閱讀這個(gè)代碼。 還有一種設(shè)計(jì)是將自定義異常通過繼承組合起來。在復(fù)雜應(yīng)用程序中, 使用基類來分組各種異常類也是很有用的。它可以讓用戶捕獲一個(gè)范圍很窄的特定異常,比如下面這樣的:
try:
s.send(msg)
except ProtocolError:
...
你還能捕獲更大范圍的異常,就像下面這樣:
try:
s.send(msg)
except NetworkError:
...
如果你想定義的新異常重寫了 __init__()
方法, 確保你使用所有參數(shù)調(diào)用 Exception.__init__()
,例如:
class CustomError(Exception):
def __init__(self, message, status):
super().__init__(message, status)
self.message = message
self.status = status
看上去有點(diǎn)奇怪,不過 Exception 的默認(rèn)行為是接受所有傳遞的參數(shù)并將它們以元組形式存儲(chǔ)在 .args
屬性中. 很多其他函數(shù)庫(kù)和部分 Python 庫(kù)默認(rèn)所有異常都必須有 .args
屬性, 因此如果你忽略了這一步,你會(huì)發(fā)現(xiàn)有些時(shí)候你定義的新異常不會(huì)按照期望運(yùn)行。 為了演示 .args
的使用,考慮下下面這個(gè)使用內(nèi)置的 RuntimeError
異常的交互會(huì)話, 注意看 raise 語句中使用的參數(shù)個(gè)數(shù)是怎樣的:
>>> try:
... raise RuntimeError('It failed')
... except RuntimeError as e:
... print(e.args)
...
('It failed',)
>>> try:
... raise RuntimeError('It failed', 42, 'spam')
... except RuntimeError as e:
... print(e.args)
...
('It failed', 42, 'spam')
>>>
關(guān)于創(chuàng)建自定義異常的更多信息,請(qǐng)參考 Python 官方文檔
你想捕獲一個(gè)異常后拋出另外一個(gè)不同的異常,同時(shí)還得在異常回溯中保留兩個(gè)異常的信息。
為了鏈接異常,使用 raise from
語句來代替簡(jiǎn)單的 raise
語句。 它會(huì)讓你同時(shí)保留兩個(gè)異常的信息。例如:
>>> def example():
... try:
... int('N/A')
... except ValueError as e:
... raise RuntimeError('A parsing error occurred') from e
>>>
example()
Traceback (most recent call last):
File "<stdin>", line 3, in example
ValueError: invalid literal for int() with base 10: 'N/A'
上面的異常是下面的異常產(chǎn)生的直接原因:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 5, in example
RuntimeError: A parsing error occurred
>>>
在回溯中科院看到,兩個(gè)異常都被捕獲。 要想捕獲這樣的異常,你可以使用一個(gè)簡(jiǎn)單的 except
語句。 不過,你還可以通過查看異常對(duì)象的 __cause__
屬性來跟蹤異常鏈。例如:
try:
example()
except RuntimeError as e:
print("It didn't work:", e)
if e.__cause__:
print('Cause:', e.__cause__)
當(dāng)在 except
塊中又有另外的異常被拋出時(shí)會(huì)導(dǎo)致一個(gè)隱藏的異常鏈的出現(xiàn)。例如:
>>> def example2():
... try:
... int('N/A')
... except ValueError as e:
... print("Couldn't parse:", err)
...
>>>
>>> example2()
Traceback (most recent call last):
File "<stdin>", line 3, in example2
ValueError: invalid literal for int() with base 10: 'N/A'
在處理上述異常的時(shí)候,另外一個(gè)異常發(fā)生了:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 5, in example2
NameError: global name 'err' is not defined
>>>
這個(gè)例子中,你同時(shí)獲得了兩個(gè)異常的信息,但是對(duì)異常的解釋不同。 這時(shí)候,NameError
異常被作為程序最終異常被拋出,而不是位于解析異常的直接回應(yīng)中。
如果,你想忽略掉異常鏈,可使用 raise from None
:
>>> def example3():
... try:
... int('N/A')
... except ValueError:
... raise RuntimeError('A parsing error occurred') from None...
>>>
example3()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 5, in example3
RuntimeError: A parsing error occurred
>>>
在設(shè)計(jì)代碼時(shí),在另外一個(gè) except
代碼塊中使用 raise
語句的時(shí)候你要特別小心了。 大多數(shù)情況下,這種 raise
語句都應(yīng)該被改成raise from
語句。也就是說你應(yīng)該使用下面這種形式:
try:
...
except SomeException as e:
raise DifferentException() from e
這樣做的原因是你應(yīng)該顯示的將原因鏈接起來。 也就是說,DifferentException
是直接從 SomeException
衍生而來。 這種關(guān)系可以從回溯結(jié)果中看出來。
如果你像下面這樣寫代碼,你仍然會(huì)得到一個(gè)鏈接異常, 不過這個(gè)并沒有很清晰的說明這個(gè)異常鏈到底是內(nèi)部異常還是某個(gè)未知的編程錯(cuò)誤。
try:
...
except SomeException:
raise DifferentException()
當(dāng)你使用 raise from
語句的話,就很清楚的表明拋出的是第二個(gè)異常。
最后一個(gè)例子中隱藏異常鏈信息。 盡管隱藏異常鏈信息不利于回溯,同時(shí)它也丟失了很多有用的調(diào)試信息。 不過萬事皆平等,有時(shí)候只保留適當(dāng)?shù)男畔⒁彩呛苡杏玫摹?/p>
你在一個(gè) except
塊中捕獲了一個(gè)異常,現(xiàn)在想重新拋出它。
簡(jiǎn)單的使用一個(gè)單獨(dú)的 rasie
語句即可,例如:
>>> def example():
... try:
... int('N/A')
... except ValueError:
... print("Didn't work")
... raise
...
>>> example()
Didn't work
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in example
ValueError: invalid literal for int() with base 10: 'N/A'
>>>
這個(gè)問題通常是當(dāng)你需要在捕獲異常后執(zhí)行某個(gè)操作(比如記錄日志、清理等),但是之后想將異常傳播下去。 一個(gè)很常見的用法是在捕獲所有異常的處理器中:
try:
...
except Exception as e:
# Process exception information in some way
...
# Propagate the exception
raise
你希望自己的程序能生成警告信息(比如廢棄特性或使用問題)。
要輸出一個(gè)警告消息,可使用 warning.warn()
函數(shù)。例如:
import warnings
def func(x, y, logfile=None, debug=False):
if logfile is not None:
warnings.warn('logfile argument deprecated', DeprecationWarning)
...
warn()
的參數(shù)是一個(gè)警告消息和一個(gè)警告類,警告類有如下幾種:UserWarning, DeprecationWarning, SyntaxWarning, RuntimeWarning, ResourceWarning, 或 FutureWarning.
對(duì)警告的處理取決于你如何運(yùn)行解釋器以及一些其他配置。 例如,如果你使用 -W all
選項(xiàng)去運(yùn)行 Python,你會(huì)得到如下的輸出:
bash % python3 -W all example.py
example.py:5: DeprecationWarning: logfile argument is deprecated
warnings.warn('logfile argument is deprecated', DeprecationWarning)
通常來講,警告會(huì)輸出到標(biāo)準(zhǔn)錯(cuò)誤上。如果你想講警告轉(zhuǎn)換為異常,可以使用 -W error
選項(xiàng):
bash % python3 -W error example.py
Traceback (most recent call last):
File "example.py", line 10, in <module>
func(2, 3, logfile='log.txt')
File "example.py", line 5, in func
warnings.warn('logfile argument is deprecated', DeprecationWarning)
DeprecationWarning: logfile argument is deprecated
bash %
在你維護(hù)軟件,提示用戶某些信息,但是又不需要將其上升為異常級(jí)別,那么輸出警告信息就會(huì)很有用了。 例如,假設(shè)你準(zhǔn)備修改某個(gè)函數(shù)庫(kù)或框架的功能,你可以先為你要更改的部分輸出警告信息,同時(shí)向后兼容一段時(shí)間。 你還可以警告用戶一些對(duì)代碼有問題的使用方式。
作為另外一個(gè)內(nèi)置函數(shù)庫(kù)的警告使用例子,下面演示了一個(gè)沒有關(guān)閉文件就銷毀它時(shí)產(chǎn)生的警告消息:
>>> import warnings
>>> warnings.simplefilter('always')
>>> f = open('/etc/passwd')
>>> del f
__main__:1: ResourceWarning: unclosed file <_io.TextIOWrapper name='/etc/passwd'
mode='r' encoding='UTF-8'>
>>>
默認(rèn)情況下,并不是所有警告消息都會(huì)出現(xiàn)。-W
選項(xiàng)能控制警告消息的輸出。 -W all
會(huì)輸出所有警告消息,-W ignore
忽略掉所有警告,-W error
將警告轉(zhuǎn)換成異常。 另外一種選擇,你還可以使用 warnings.simplefilter()
函數(shù)控制輸出。 always
參數(shù)會(huì)讓所有警告消息出現(xiàn),`ignore
忽略調(diào)所有的警告,error
將警告轉(zhuǎn)換成異常。
對(duì)于簡(jiǎn)單的生成警告消息的情況這些已經(jīng)足夠了。 warnings
模塊對(duì)過濾和警告消息處理提供了大量的更高級(jí)的配置選項(xiàng)。 更多信息請(qǐng)參考 Python 文檔
你的程序奔潰后該怎樣去調(diào)試它?
如果你的程序因?yàn)槟硞€(gè)異常而奔潰,運(yùn)行 python3 -i someprogram.py
可執(zhí)行簡(jiǎn)單的調(diào)試。 -i
選項(xiàng)可讓程序結(jié)束后打開一個(gè)交互式 shell。 然后你就能查看環(huán)境,例如,假設(shè)你有下面的代碼:
# sample.py
def func(n):
return n + 10
func('Hello')
運(yùn)行 python3 -i sample.py
會(huì)有類似如下的輸出:
bash % python3 -i sample.py
Traceback (most recent call last):
File "sample.py", line 6, in <module>
func('Hello')
File "sample.py", line 4, in func
return n + 10
TypeError: Can't convert 'int' object to str implicitly
>>> func(10)
20
>>>
如果你看不到上面這樣的,可以在程序奔潰后打開 Python 的調(diào)試器。例如:
>>> import pdb
>>> pdb.pm()
> sample.py(4)func()
-> return n + 10
(Pdb) w
sample.py(6)<module>()
-> func('Hello')
> sample.py(4)func()
-> return n + 10
(Pdb) print n
'Hello'
(Pdb) q
>>>
如果你的代碼所在的環(huán)境很難獲取交互 shell(比如在某個(gè)服務(wù)器上面), 通??梢圆东@異常后自己打印跟蹤信息。例如:
import traceback
import sys
try:
func(arg)
except:
print('**** AN ERROR OCCURRED ****')
traceback.print_exc(file=sys.stderr)
要是你的程序沒有奔潰,而只是產(chǎn)生了一些你看不懂的結(jié)果, 你在感興趣的地方插入一下 print()
語句也是個(gè)不錯(cuò)的選擇。 不過,要是你打算這樣做,有一些小技巧可以幫助你。 首先,traceback.print_stack()
函數(shù)會(huì)你程序運(yùn)行到那個(gè)點(diǎn)的時(shí)候創(chuàng)建一個(gè)跟蹤棧。例如:
>>> def sample(n):
... if n > 0:
... sample(n-1)
... else:
... traceback.print_stack(file=sys.stderr)
...
>>> sample(5)
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in sample
File "<stdin>", line 3, in sample
File "<stdin>", line 3, in sample
File "<stdin>", line 3, in sample
File "<stdin>", line 3, in sample
File "<stdin>", line 5, in sample
>>>
另外,你還可以像下面這樣使用 pdb.set_trace()
在任何地方手動(dòng)的啟動(dòng)調(diào)試器:
import pdb
def func(arg):
...
pdb.set_trace()
...
當(dāng)程序比較大二你想調(diào)試控制流程以及函數(shù)參數(shù)的時(shí)候這個(gè)就比較有用了。 例如,一旦調(diào)試器開始運(yùn)行,你就能夠使用 print
來觀測(cè)變量值或敲擊某個(gè)命令比如 w
來獲取追蹤信息。
不要將調(diào)試弄的過于復(fù)雜化。一些簡(jiǎn)單的錯(cuò)誤只需要觀察程序堆棧信息就能知道了, 實(shí)際的錯(cuò)誤一般是堆棧的最后一行。 你在開發(fā)的時(shí)候,也可以在你需要調(diào)試的地方插入一下 print()
函數(shù)來診斷信息(只需要最后發(fā)布的時(shí)候刪除這些打印語句即可)。
調(diào)試器的一個(gè)常見用法是觀測(cè)某個(gè)已經(jīng)奔潰的函數(shù)中的變量。 知道怎樣在函數(shù)奔潰后進(jìn)入調(diào)試器是一個(gè)很有用的技能。
當(dāng)你想解剖一個(gè)非常復(fù)雜的程序,底層的控制邏輯你不是很清楚的時(shí)候, 插入 pdb.set_trace()
這樣的語句就很有用了。
實(shí)際上,程序會(huì)一直運(yùn)行到碰到 set_trace()
語句位置,然后立馬進(jìn)入調(diào)試器。 然后你就可以做更多的事了。
如果你使用 IDE 來做 Python 開發(fā),通常 IDE 都會(huì)提供自己的調(diào)試器來替代 pdb。 更多這方面的信息可以參考你使用的 IDE 手冊(cè)。
你想測(cè)試你的程序運(yùn)行所花費(fèi)的時(shí)間并做性能測(cè)試。
如果你只是簡(jiǎn)單的想測(cè)試下你的程序整體花費(fèi)的時(shí)間, 通常使用 Unix 時(shí)間函數(shù)就行了,比如:
bash % time python3 someprogram.py
real 0m13.937s
user 0m12.162s
sys 0m0.098s
bash %
如果你還需要一個(gè)程序各個(gè)細(xì)節(jié)的詳細(xì)報(bào)告,可以使用 cProfile
模塊:
bash % python3 -m cProfile someprogram.py
859647 function calls in 16.016 CPU seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
263169 0.080 0.000 0.080 0.000 someprogram.py:16(frange)
513 0.001 0.000 0.002 0.000 someprogram.py:30(generate_mandel)
262656 0.194 0.000 15.295 0.000 someprogram.py:32(<genexpr>)
1 0.036 0.036 16.077 16.077 someprogram.py:4(<module>)
262144 15.021 0.000 15.021 0.000 someprogram.py:4(in_mandelbrot)
1 0.000 0.000 0.000 0.000 os.py:746(urandom)
1 0.000 0.000 0.000 0.000 png.py:1056(_readable)
1 0.000 0.000 0.000 0.000 png.py:1073(Reader)
1 0.227 0.227 0.438 0.438 png.py:163(<module>)
512 0.010 0.000 0.010 0.000 png.py:200(group)
...
bash %
不過通常情況是介于這兩個(gè)極端之間。比如你已經(jīng)知道代碼運(yùn)行時(shí)在少數(shù)幾個(gè)函數(shù)中花費(fèi)了絕大部分時(shí)間。 對(duì)于這些函數(shù)的性能測(cè)試,可以使用一個(gè)簡(jiǎn)單的裝飾器:
# timethis.py
import time
from functools import wraps
def timethis(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
r = func(*args, **kwargs)
end = time.perf_counter()
print('{}.{} : {}'.format(func.__module__, func.__name__, end - start))
return r
return wrapper
要使用這個(gè)裝飾器,只需要將其放置在你要進(jìn)行性能測(cè)試的函數(shù)定義前即可,比如:
>>> @timethis
... def countdown(n):
... while n > 0:
... n -= 1
...
>>> countdown(10000000)
__main__.countdown : 0.803001880645752
>>>
要測(cè)試某個(gè)代碼塊運(yùn)行時(shí)間,你可以定義一個(gè)上下文管理器,例如:
from contextlib import contextmanager
@contextmanager
def timeblock(label):
start = time.perf_counter()
try:
yield
finally:
end = time.perf_counter()
print('{} : {}'.format(label, end - start))
下面是使用這個(gè)上下文管理器的例子:
>>> with timeblock('counting'):
... n = 10000000
... while n > 0:
... n -= 1
...
counting : 1.5551159381866455
>>>
對(duì)于測(cè)試很小的代碼片段運(yùn)行性能,使用 timeit
模塊會(huì)很方便,例如:
>>> from timeit import timeit
>>> timeit('math.sqrt(2)', 'import math')
0.1432319980012835
>>> timeit('sqrt(2)', 'from math import sqrt')
0.10836604500218527
>>>
timeit
會(huì)執(zhí)行第一個(gè)參數(shù)中語句100萬次并計(jì)算運(yùn)行時(shí)間。 第二個(gè)參數(shù)是運(yùn)行測(cè)試之前配置環(huán)境。如果你想改變循環(huán)執(zhí)行次數(shù), 可以像下面這樣設(shè)置 number
參數(shù)的值:
>>> timeit('math.sqrt(2)', 'import math', number=10000000)
1.434852126003534
>>> timeit('sqrt(2)', 'from math import sqrt', number=10000000)
1.0270336690009572
>>>
當(dāng)執(zhí)行性能測(cè)試的時(shí)候,需要注意的是你獲取的結(jié)果都是近似值。 time.perf_counter()
函數(shù)會(huì)在給定平臺(tái)上獲取最高精度的計(jì)時(shí)值。 不過,它仍然還是基于時(shí)鐘時(shí)間,很多因素會(huì)影響到它的精確度,比如機(jī)器負(fù)載。 如果你對(duì)于執(zhí)行時(shí)間更感興趣,使用 time.process_time()
來代替它。例如:
from functools import wraps
def timethis(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.process_time()
r = func(*args, **kwargs)
end = time.process_time()
print('{}.{} : {}'.format(func.__module__, func.__name__, end - start))
return r
return wrapper
最后,如果你想進(jìn)行更深入的性能分析,那么你需要詳細(xì)閱讀 time
、timeit
和其他相關(guān)模塊的文檔。 這樣你可以理解和平臺(tái)相關(guān)的差異以及一些其他陷阱。 還可以參考13.13小節(jié)中相關(guān)的一個(gè)創(chuàng)建計(jì)時(shí)器類的例子。
你的程序運(yùn)行太慢,你想在不使用復(fù)雜技術(shù)比如 C 擴(kuò)展或 JIT 編譯器的情況下加快程序運(yùn)行速度。
關(guān)于程序優(yōu)化的第一個(gè)準(zhǔn)則是“不要優(yōu)化”,第二個(gè)準(zhǔn)則是“不要優(yōu)化那些無關(guān)緊要的部分”。 如果你的程序運(yùn)行緩慢,首先你得使用14.13小節(jié)的技術(shù)先對(duì)它進(jìn)行性能測(cè)試找到問題所在。
通常來講你會(huì)發(fā)現(xiàn)你得程序在少數(shù)幾個(gè)熱點(diǎn)地方花費(fèi)了大量時(shí)間, 不然內(nèi)存的數(shù)據(jù)處理循環(huán)。一旦你定位到這些點(diǎn),你就可以使用下面這些實(shí)用技術(shù)來加速程序運(yùn)行。
很多程序員剛開始會(huì)使用 Python 語言寫一些簡(jiǎn)單腳本。 當(dāng)編寫腳本的時(shí)候,通常習(xí)慣了寫毫無結(jié)構(gòu)的代碼,比如:
# somescript.py
import sys
import csv
with open(sys.argv[1]) as f:
for row in csv.reader(f):
# Some kind of processing
pass
很少有人知道,像這樣定義在全局范圍的代碼運(yùn)行起來要比定義在函數(shù)中運(yùn)行慢的多。 這種速度差異是由于局部變量和全局變量的實(shí)現(xiàn)方式(使用局部變量要更快些)。 因此,如果你想讓程序運(yùn)行更快些,只需要將腳本語句放入函數(shù)中即可:
# somescript.py
import sys
import csv
def main(filename):
with open(filename) as f:
for row in csv.reader(f):
# Some kind of processing
pass
main(sys.argv[1])
速度的差異取決于實(shí)際運(yùn)行的程序,不過根據(jù)經(jīng)驗(yàn),使用函數(shù)帶來15-30%的性能提升是很常見的。
每一次使用點(diǎn)(.)操作符來訪問屬性的時(shí)候會(huì)帶來額外的開銷。 它會(huì)觸發(fā)特定的方法,比如 __getattribute__()
和 __getattr__()
,這些方法會(huì)進(jìn)行字典操作操作。
通常你可以使用from module import name
這樣的導(dǎo)入形式,以及使用綁定的方法。 假設(shè)你有如下的代碼片段:
import math
def compute_roots(nums):
result = []
for n in nums:
result.append(math.sqrt(n))
return result
# Test
nums = range(1000000)
for n in range(100):
r = compute_roots(nums)
在我們機(jī)器上面測(cè)試的時(shí)候,這個(gè)程序花費(fèi)了大概40秒。現(xiàn)在我們修改compute_roots()
函數(shù)如下:
from math import sqrt
def compute_roots(nums):
result = []
result_append = result.append
for n in nums:
result_append(sqrt(n))
return result
修改后的版本運(yùn)行時(shí)間大概是29秒。唯一不同之處就是消除了屬性訪問。 用sqrt()
代替了 math.sqrt()
。 The result.append()
方法被賦給一個(gè)局部變量 result_append
,然后在內(nèi)部循環(huán)中使用它。
不過,這些改變只有在大量重復(fù)代碼中才有意義,比如循環(huán)。 因此,這些優(yōu)化也只是在某些特定地方才應(yīng)該被使用。
之前提過,局部變量會(huì)比全局變量運(yùn)行速度快。 對(duì)于頻繁訪問的名稱,通過將這些名稱變成局部變量可以加速程序運(yùn)行。 例如,看下之前對(duì)于 compute_roots()
函數(shù)進(jìn)行修改后的版本:
import math
def compute_roots(nums):
sqrt = math.sqrt
result = []
result_append = result.append
for n in nums:
result_append(sqrt(n))
return result
在這個(gè)版本中,sqrt
從 match
模塊被拿出并放入了一個(gè)局部變量中。 如果你運(yùn)行這個(gè)代碼,大概花費(fèi)25秒(對(duì)于之前29秒又是一個(gè)改進(jìn))。 這個(gè)額外的加速原因是因?yàn)閷?duì)于局部變量 sqrt
的查找要快于全局變量sqrt