Item 36: Raise From

今天要介紹 Python 3 引入的 raise ... from ... 述句。相信大家都知道 raise 述句是用來拋出一個例外。那 raise ... from ... 述句有何不同呢?

#!/usr/bin/env python3
import traceback

class TestError(ValueError):
    pass

def test(buf):
    try:
        return (buf[2], buf[3])
    except IndexError as e:
        raise TestError() from e

try:
    test(b'')
except Exception as e:
    print('cause:', repr(e.__cause__))
    print('context:', repr(e.__context__))
    print('suppress:', repr(e.__suppress_context__))
    traceback.print_exc()

這段程式碼會輸出:

cause: IndexError('index out of range',)
context: IndexError('index out of range',)
suppress: True
Traceback (most recent call last):
  File "test.py", line 9, in test
    return (buf[2], buf[3])
IndexError: index out of range

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "test.py", line 14, in <module>
    test(b'')
  File "test.py", line 11, in test
    raise TestError() from e
TestError

這段輸出有二個部分:上半部是最原始的 IndexError 的 traceback,下半部是 TestError 的 traceback。raise ... from ... 會把 from 指定的例外當成新的例外的「肇因(__cause__)」,讓開發者得以一路追回錯誤的源頭。

如果我們把 from e 拿掉呢?

#!/usr/bin/env python3
import traceback

class TestError(ValueError):
    pass

def test(buf):
    try:
        return (buf[2], buf[3])
    except IndexError as e:
        raise TestError()

try:
    test(b'')
except Exception as e:
    print('cause:', repr(e.__cause__))
    print('context:', repr(e.__context__))
    print('suppress:', repr(e.__suppress_context__))
    traceback.print_exc()

輸出會變成:

cause: None
context: IndexError('index out of range',)
suppress: False
Traceback (most recent call last):
  File "test.py", line 9, in test
    return (buf[0], buf[1], buf[2], buf[3])
IndexError: index out of range

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "test.py", line 14, in <module>
    test(b'')
  File "test.py", line 11, in test
    raise TestError()
TestError

和先前的輸出相比有兩個差異。第一個差異是:

cause: IndexError('index out of range',)
context: IndexError('index out of range',)
suppress: True

變成:

cause: None
context: IndexError('index out of range',)
suppress: False

第二個差異是:

The above exception was the direct cause of the following exception:

被換成:

During handling of the above exception, another exception occurred:

這是因為如果在 Python 3 的 except 子句或 finally 子句中,再度拋出一個例外,則「處理中的例外」會隱含的成為新例外的「上下文(__context__)」,但是不會是新例外的「肇因(__cause__)」。

raise ... from ... 相比,兩者的 traceback 輸出結果幾乎沒有差異。他們之間的差異主要還是在於開發者的意圖。如果開發者的意圖是重新打包例外再拋出,則應該使用 raise ... from ...。因為當 exceptfinally 子句拋出非預期的例外時,也會有類似的 traceback,使用者沒辦法知道 __context__ 記錄的例外是否是新例外的肇因,還是處理舊例外時再度產生新例外。例如:

#!/usr/bin/env python3
import traceback

class TestError(ValueError):
    pass

def test(buf):
    try:
        return (buf[2], buf[3])
    except IndexError as e:
        return (buf[0], b'\x00')

try:
    test(b'')
except Exception as e:
    print('cause:', repr(e.__cause__))
    print('context:', repr(e.__context__))
    print('suppress:', repr(e.__suppress_context__))
    traceback.print_exc()

這個例子中,開發者沒有考慮到 len(buf) 為零的情況,而導致處理舊例外的過程中又產生新例外。雖然他們先後發生,但是各自代表不同的錯誤。

那如果我們不想印出「處理中的例外」呢?如果不想要顯示「處理中的例外」,我們可以使用 raise ... from None

舉例來說,如果我們寫了一個指令對照表,我們希望在找不到指令時拋出 CommandError 而不是 KeyError,我們就可以寫成:

#!/usr/bin/env python3
import traceback

class CommandError(ValueError):
    pass

_commands = { 'hello': lambda: print('hello') }

def run_cmd(name):
    try:
        _commands[name]()
    except KeyError:
        raise CommandError(name) from None

try:
    run_cmd('hello')
    run_cmd('world')
except:
    traceback.print_exc()

輸出結果會變成:

hello
Traceback (most recent call last):
  File "test2.py", line 17, in <module>
    run_cmd('world')
  File "test2.py", line 13, in run_cmd
    raise CommandError(name) from None
CommandError: world

最後值得一提的是 raise ... from ... 述句的 from ... 不一定要丟出接到的例外。你也可以當場建構一個新的例外。

雖然我想不到什麼好例子,但可以參考以下程式碼:

#!/usr/bin/env python3

class ListError(Exception):
    pass

class ListNode(object):
    def __init__(self, value=None, next=None):
        self.value = value
        self.next = None

class List(object):
    def __init__(self):
        self.head = None
        self.size = 0

    def prepend(self, value):
        self.size += 1
        self.head = ListNode(value, self.head)

    def __getitem__(self, n):
        if n < 0 or n >= self.size:
            raise ListError() from IndexError(n)
        p = self.head
        for i in range(n):
            p = p.next
        return p.value

print(List()[1])

這段程式碼會在真的存取資料之前做錯誤檢查,如果發現使用者輸入的索引大於串列的節點個數,就會拋出一個 ListError 例外。與此同時,我們也建構一個 IndexError 作為例外的成因。