鍍金池/ 教程/ Python/ 測試、調(diào)試和異常
類與對象
模塊與包
數(shù)據(jù)編碼和處理
元編程
網(wǎng)絡(luò)與 Web 編程
數(shù)字日期和時(shí)間
測試、調(diào)試和異常
字符串和文本
文件與 IO
腳本編程與系統(tǒng)管理
迭代器與生成器
函數(shù)
C 語言擴(kuò)展
并發(fā)編程
數(shù)據(jù)結(jié)構(gòu)和算法

測試、調(diào)試和異常

試驗(yàn)還是很棒的,但是調(diào)試?就沒那么有趣了。事實(shí)是,在 Python 測試代碼之前沒有編譯器來分析你的代碼,因此使的測試成為開發(fā)的一個(gè)重要部分。本章的目標(biāo)是討論一些關(guān)于測試、調(diào)試和異常處理的常見問題。但是并不是為測試驅(qū)動(dòng)開發(fā)或者單元測試模塊做一個(gè)簡要的介紹。因此,筆者假定讀者熟悉測試概念。

測試 stdout 輸出

問題

你的程序中有個(gè)方法會(huì)輸出到標(biāo)準(zhǔn)輸出中(sys.stdout)。也就是說它會(huì)將文本打印到屏幕上面。 你想寫個(gè)測試來證明它,給定一個(gè)輸入,相應(yīng)的輸出能正常顯示出來。

解決方案

使用 unittest.mock 模塊中的 patch() 函數(shù), 使用起來非常簡單,可以為單個(gè)測試模擬 sys.stdout 然后回滾, 并且不產(chǎn)生大量的臨時(shí)變量或在測試用例直接暴露狀態(tài)變量。

作為一個(gè)例子,我們在 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 。 為了測試輸出真的在那里,你可以使用一個(gè)替身對象來模擬它,然后使用斷言來確認(rèn)結(jié)果。 使用 unittest.mock 模塊的patch() 方法可以很方便的在測試運(yùn)行的上下文中替換對象, 并且當(dāng)測試完成時(shí)候自動(dòng)返回它們的原有狀態(tài)。下面是對 mymodule模塊的測試代碼:

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ù),測試方法開始會(huì)先設(shè)置每一個(gè)參數(shù)的值。 expected_url 變量被設(shè)置成包含期望的輸出的字符串。

unittest.mock.patch()函數(shù)被用作一個(gè)上下文管理器,使用StringIO 對象來代替 sys.stdout . fake_out 變量是在該進(jìn)程中被創(chuàng)建的模擬對象。 在 with 語句中使用它可以執(zhí)行各種檢查。當(dāng) with 語句結(jié)束時(shí),patch 會(huì)將所有東西恢復(fù)到測試開始前的狀態(tài)。 有一點(diǎn)需要注意的是某些對 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 對象請參閱5.6小節(jié)。

在單元測試中給對象打補(bǔ)丁

問題

你寫的單元測試中需要給指定的對象打補(bǔ)丁, 用來斷言它們在測試中的期望行為(比如,斷言被調(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è)對象打補(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è)已存在對象的全路徑名,將其替換為一個(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)用對象和實(shí)例。 他們記錄對象的使用信息并允許你執(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è)單元測試中完成。例如,假設(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ù)并解析它。 在單元測試中,你可以給它一個(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è)模擬對象替代, 該對象會(huì)返回一個(gè)包含測試數(shù)據(jù)的 ByteIO().

還有一點(diǎn),在打補(bǔ)丁時(shí)我們使用了 example.urlopen 來代替 urllib.request.urlopen 。 當(dāng)你創(chuàng)建補(bǔ)丁的時(shí)候,你必須使用它們在測試代碼中的名稱。 由于測試代碼使用了 from urllib.request import urlopen ,那么 dowprices() 函數(shù) 中使用的 urlopen()函數(shù)實(shí)際上就位于 example模塊了。

本節(jié)實(shí)際上只是對 unittest.mock模塊的一次淺嘗輒止。 更多更高級的特性,請參考官方文檔

在單元測試中測試異常情況

問題

你想寫個(gè)測試用例來準(zhǔn)確的判斷某個(gè)異常是否被拋出。

解決方案

對于異常的測試可使用 assertRaises() 方法。 例如,如果你想測試某個(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')

如果你想測試異常的具體值,需要用到另外一種方法:

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() 方法為測試異常存在性提供了一個(gè)簡便方法。 一個(gè)常見的陷阱是手動(dòng)去進(jìn)行異常檢測。比如:

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í)候。 那么你還得需要增加另外的檢測過程,如下面這樣:

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)是它測不了異常具體的值是多少。 為了測試異常值,可以使用 assertRaisesRegex()方法, 它可同時(shí)測試異常的存在以及通過正則式匹配異常的字符串表示。例如:

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')

但你的測試涉及到多個(gè)執(zhí)行步驟的時(shí)候這種方法就很有用了。

將測試輸出用日志記錄到文件中

問題

你希望將單元測試的輸出寫到到某個(gè)文件中去,而不是打印到標(biāo)準(zhǔn)輸出。

解決方案

運(yùn)行單元測試一個(gè)常見技術(shù)就是在測試文件底部加入下面這段代碼片段:

import unittest

class MyTest(unittest.TestCase):
    pass

if __name__ == '__main__':
    unittest.main()

這樣的話測試文件就是可執(zhí)行的,并且會(huì)將運(yùn)行測試的結(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é)感興趣的部分并不是將測試結(jié)果重定向到一個(gè)文件中, 而是通過這樣做向你展示了 unittest 模塊中一些值得關(guān)注的內(nèi)部工作原理。

unittest 模塊首先會(huì)組裝一個(gè)測試套件。 這個(gè)測試套件包含了你定義的各種方法。一旦套件組裝完成,它所包含的測試就可以被執(zhí)行了。

這兩步是分開的,unittest.TestLoader 實(shí)例被用來組裝測試套件。 loadTestsFromModule() 是它定義的方法之一,用來收集測試用例。 它會(huì)為 TestCase 類掃描某個(gè)模塊并將其中的測試方法提取出來。 如果你想進(jìn)行細(xì)粒度的控制, 可以使用 loadTestsFromTestCase() 方法來從某個(gè)繼承 TestCase 的類中提取測試方法。 TextTestRunner類是一個(gè)測試運(yùn)行類的例子, 這個(gè)類的主要用途是執(zhí)行某個(gè)測試套件中包含的測試方法。 這個(gè)類跟執(zhí)行 unittest.main() 函數(shù)所使用的測試運(yùn)行器是一樣的。 不過,我們在這里對它進(jìn)行了一些列底層配置,包括輸出文件和提升級別。 盡管本節(jié)例子代碼很少,但是能指導(dǎo)你如何對 unittest 框架進(jìn)行更進(jìn)一步的自定義。 要想自定義測試套件的裝配方式,你可以對 TestLoader 類執(zhí)行更多的操作。 為了自定義測試運(yùn)行,你可以構(gòu)造一個(gè)自己的測試運(yùn)行類來模擬 TextTestRunner 的功能。 而這些已經(jīng)超出了本節(jié)的范圍。unittest 模塊的文檔對底層實(shí)現(xiàn)原理有更深入的講解,可以去看看。

忽略或期望測試失敗

問題

你想在單元測試中忽略或標(biāo)記某些測試會(huì)按照預(yù)期運(yùn)行失敗。

解決方案

unittest 模塊有裝飾器可用來控制對指定測試方法的處理,例如:

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)行的測試。 skipIf()skipUnless() 對于你只想在某個(gè)特定平臺或 Python 版本或其他依賴成立時(shí)才運(yùn)行測試的時(shí)候非常有用。 使用 @expected 的失敗裝飾器來標(biāo)記那些確定會(huì)失敗的測試,并且對這些測試你不想讓測試框架打印更多信息。

忽略方法的裝飾器還可以被用來裝飾整個(gè)測試類,比如:

@unittest.skipUnless(platform.system() == 'Darwin', 'Mac specific tests')
class DarwinTests(unittest.TestCase):
    pass

處理多個(gè)異常

問題

你有一個(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() 方法。 如果你想對其中某個(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ì)有層級關(guān)系,對于這種情況,你可能使用它們的一個(gè)基類來捕獲所有的異常。例如,下面的代碼:

try:
    f = open(filename)
except (FileNotFoundError, PermissionError):
    pass

可以被重寫為:

try:
    f = open(filename)
except OSError:
    pass

OSErrorFileNotFoundErrorPermissionError異常的基類。

討論

盡管處理多個(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í)候,如果你對某個(gè)特定異常的類成層級關(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 、KeyboardInterruptGeneratorExit 之外的所有異常。 如果你還想捕獲這三個(gè)異常,將 Exception 改成 BaseException 即可。

討論

捕獲所有異常通常是由于程序員在某些復(fù)雜操作中并不能記住所有可能的異常。 如果你不是很細(xì)心的人,這也是編寫不易調(diào)試代碼的一個(gè)簡單方法。

正因如此,如果你選擇捕獲所有異常,那么在某個(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ì)丟失掉異常。

創(chuàng)建自定義異常

問題

在你構(gòu)建的應(yīng)用程序中,你想將底層異常包裝成自定義的異常。

解決方案

創(chuàng)建新的異常很簡單——定義新的類,讓它繼承自 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)退出異常而保留的,比如 KeyboardInterruptSystemExit 以及其他那些會(huì)給應(yīng)用發(fā)送信號而退出的異常。 因此,捕獲這些異常本身沒什么意義。 這樣的話,假如你繼承BaseException 可能會(huì)導(dǎo)致你的自定義異常不會(huì)被捕獲而直接發(fā)送信號退出程序運(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ù)庫和部分 Python 庫默認(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)建自定義異常的更多信息,請參考 Python 官方文檔

捕獲異常后拋出另外的異常

問題

你想捕獲一個(gè)異常后拋出另外一個(gè)不同的異常,同時(shí)還得在異?;厮葜斜A魞蓚€(gè)異常的信息。

解決方案

為了鏈接異常,使用 raise from語句來代替簡單的 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è)簡單的 except 語句。 不過,你還可以通過查看異常對象的 __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è)異常的信息,但是對異常的解釋不同。 這時(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)在想重新拋出它。

解決方案

簡單的使用一個(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

14.11 輸出警告信息

問題

你希望自己的程序能生成警告信息(比如廢棄特性或使用問題)。

解決方案

要輸出一個(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.

對警告的處理取決于你如何運(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ù)軟件,提示用戶某些信息,但是又不需要將其上升為異常級別,那么輸出警告信息就會(huì)很有用了。 例如,假設(shè)你準(zhǔn)備修改某個(gè)函數(shù)庫或框架的功能,你可以先為你要更改的部分輸出警告信息,同時(shí)向后兼容一段時(shí)間。 你還可以警告用戶一些對代碼有問題的使用方式。

作為另外一個(gè)內(nèi)置函數(shù)庫的警告使用例子,下面演示了一個(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)換成異常。

對于簡單的生成警告消息的情況這些已經(jīng)足夠了。 warnings 模塊對過濾和警告消息處理提供了大量的更高級的配置選項(xiàng)。 更多信息請參考 Python 文檔

調(diào)試基本的程序崩潰錯(cuò)誤

問題

你的程序奔潰后該怎樣去調(diào)試它?

解決方案

如果你的程序因?yàn)槟硞€(gè)異常而奔潰,運(yùn)行 python3 -i someprogram.py 可執(zhí)行簡單的調(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 來觀測變量值或敲擊某個(gè)命令比如 w 來獲取追蹤信息。

討論

不要將調(diào)試弄的過于復(fù)雜化。一些簡單的錯(cuò)誤只需要觀察程序堆棧信息就能知道了, 實(shí)際的錯(cuò)誤一般是堆棧的最后一行。 你在開發(fā)的時(shí)候,也可以在你需要調(diào)試的地方插入一下 print() 函數(shù)來診斷信息(只需要最后發(fā)布的時(shí)候刪除這些打印語句即可)。

調(diào)試器的一個(gè)常見用法是觀測某個(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 手冊。

給你的程序做性能測試

問題

你想測試你的程序運(yùn)行所花費(fèi)的時(shí)間并做性能測試。

解決方案

如果你只是簡單的想測試下你的程序整體花費(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í)間。 對于這些函數(shù)的性能測試,可以使用一個(gè)簡單的裝飾器:

# 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)行性能測試的函數(shù)定義前即可,比如:

>>> @timethis
... def countdown(n):
...     while n > 0:
...             n -= 1
...
>>> countdown(10000000)
__main__.countdown : 0.803001880645752
>>>

要測試某個(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
>>>

對于測試很小的代碼片段運(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)行測試之前配置環(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í)行性能測試的時(shí)候,需要注意的是你獲取的結(jié)果都是近似值。 time.perf_counter()函數(shù)會(huì)在給定平臺上獲取最高精度的計(jì)時(shí)值。 不過,它仍然還是基于時(shí)鐘時(shí)間,很多因素會(huì)影響到它的精確度,比如機(jī)器負(fù)載。 如果你對于執(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)模塊的文檔。 這樣你可以理解和平臺相關(guān)的差異以及一些其他陷阱。 還可以參考13.13小節(jié)中相關(guān)的一個(gè)創(chuàng)建計(jì)時(shí)器類的例子。

加速程序運(yùn)行

問題

你的程序運(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ù)先對它進(jìn)行性能測試找到問題所在。

通常來講你會(huì)發(fā)現(xiàn)你得程序在少數(shù)幾個(gè)熱點(diǎn)地方花費(fèi)了大量時(shí)間, 不然內(nèi)存的數(shù)據(jù)處理循環(huán)。一旦你定位到這些點(diǎn),你就可以使用下面這些實(shí)用技術(shù)來加速程序運(yùn)行。

使用函數(shù)

很多程序員剛開始會(huì)使用 Python 語言寫一些簡單腳本。 當(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ī)器上面測試的時(shí)候,這個(gè)程序花費(fèi)了大概40秒?,F(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)行速度快。 對于頻繁訪問的名稱,通過將這些名稱變成局部變量可以加速程序運(yùn)行。 例如,看下之前對于 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è)版本中,sqrtmatch模塊被拿出并放入了一個(gè)局部變量中。 如果你運(yùn)行這個(gè)代碼,大概花費(fèi)25秒(對于之前29秒又是一個(gè)改進(jìn))。 這個(gè)額外的加速原因是因?yàn)閷τ诰植孔兞?sqrt 的查找要快于全局變量sqrt