軟體開發中,確保程式碼的可靠性和穩定性至關重要,合約設計和防禦性程式設計是兩種重要的設計原則,可以有效提升程式碼品質。合約設計側重於在程式碼中明確定義執行條件和預期狀態,例如 Python 函式中的引數型別和傳回值約束,而防禦性程式設計則強調處理各種可能的錯誤和異常情況,例如檢查檔案是否存在或處理網路連線錯誤。這兩種設計原則並非互相排斥,而是相輔相成,共同提升程式碼的強健性。合約設計可以幫助開發者在設計階段就考慮到各種可能的錯誤情況,並制定相應的處理策略,而防禦性程式設計則可以在程式執行過程中有效地處理未預料到的錯誤,避免程式當機或產生錯誤結果。

合約設計與防禦性程式設計

在軟體開發中,合約設計(Design by Contract, DbC)與防禦性程式設計(Defensive Programming)是兩種重要的設計原則,用於提高程式碼的可靠性和穩定性。

合約設計

合約設計強調在程式碼中明確定義前置條件(preconditions)、後置條件(postconditions)以及不變條件(invariants)。這些條件構成了一個合約,描述了函式或方法在執行前後的預期狀態。

合約設計的核心價值

  1. 明確問題定位:當程式執行時出現錯誤,合約設計可以幫助開發者快速定位問題所在。
  2. 提高程式碼的強健性:每個元件都強制執行自己的約束條件,維護不變條件,從而提高整個程式的正確性。
  3. 改善程式結構:合約明確指定了每個函式或方法的工作預期和輸入輸出要求,使程式結構更加清晰。

Python 中的合約設計實踐

雖然 Python 沒有內建的合約設計支援,但可以透過以下方式實作:

  1. 使用控制機制:在方法、函式和類別中新增控制機制,當條件不滿足時丟擲 RuntimeErrorValueError
  2. 保持程式碼隔離:將前置條件、後置條件和核心邏輯分離,可以透過建立較小的函式或使用裝飾器實作。
def validate_input(value):
    if not isinstance(value, int):
        raise ValueError("Input must be an integer")

def my_function(value):
    validate_input(value)
    # 核心邏輯
    return value * 2

#### 內容解密:
1. `validate_input` 函式檢查輸入值是否為整數
2. 如果輸入值不是整數則丟擲 `ValueError`。
3. `my_function` 函式呼叫 `validate_input` 進行輸入驗證
4. 驗證透過後執行核心邏輯將輸入值乘以 2 後傳回

防禦性程式設計

防禦性程式設計關注於使程式碼能夠抵禦無效輸入,透過錯誤處理機制來應對預期和非預期的錯誤。

防禦性程式設計的核心思想

  1. 錯誤處理:對預期可能發生的錯誤進行處理,例如資料輸入錯誤。
  2. 保護機制:使程式碼的各個部分(物件、函式或方法)能夠保護自己免受無效輸入的影響。

錯誤處理的方法

  1. 值替換(Value Substitution):用預設值或替代值替換無效輸入。
  2. 錯誤日誌記錄(Error Logging):記錄錯誤資訊,以便於除錯和分析。
  3. 例外處理(Exception Handling):捕捉和處理異常,防止程式當機。
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        print("Cannot divide by zero!")
        return None

#### 內容解密:
1. `safe_divide` 函式嘗試執行除法運算
2. 如果除數為零則捕捉 `ZeroDivisionError` 異常
3. 列印錯誤訊息並傳回 `None` 以避免程式當機
4. 此舉防止了因除以零而導致的執行階段錯誤

軟體錯誤處理策略

在軟體開發過程中,錯誤處理是一個重要的議題。當軟體遇到錯誤時,如何處理這些錯誤直接影響到軟體的穩定性和可靠性。本篇文章將探討幾種常見的錯誤處理策略,包括值替換、預設值和例外處理。

值替換(Value Substitution)

在某些情況下,當軟體產生錯誤且有可能輸出錯誤的值或完全失敗時,我們可以將結果替換為另一個更安全的值。這種做法稱為值替換。值替換的目的是用一個不會影響結果的值來替換錯誤的值,例如使用預設值、已知常數或哨兵值。

def calculate_area(width, height):
    try:
        area = width * height
        return area
    except TypeError:
        # 值替換:當輸入型別錯誤時,傳回預設值 0
        return 0

內容解密:

  • try 區塊嘗試計算面積。
  • except TypeError 捕捉輸入型別錯誤的例外,並傳回預設值 0。
  • 這種做法避免了程式因為錯誤而當機,但可能會隱藏某些錯誤。

使用預設值

另一種處理錯誤的方法是使用預設值。當某些資料缺失時,可以使用預設值來替代。這種方法在處理組態檔案、環境變數或函式引數時尤其有用。

def connect_database(host="localhost", port=5432):
    logger.info("connecting to database server at %s:%i", host, port)
    # 連線資料函式庫的邏輯

內容解密:

  • 函式 connect_database 使用預設引數 host="localhost"port=5432
  • 當呼叫此函式時,如果沒有提供 hostport,將使用預設值。
  • 這種做法提高了函式的靈活性,並減少了因缺少引數而導致的錯誤。

例外處理(Exception Handling)

當函式遇到錯誤時,使用案例外處理機制可以通知呼叫者錯誤的發生。這種機制使得程式可以在錯誤發生時停止執行,並提供錯誤資訊。

def read_file(filename):
    try:
        with open(filename, 'r') as file:
            content = file.read()
            return content
    except FileNotFoundError:
        raise FileNotFoundError(f"檔案 {filename} 不存在")

內容解密:

  • try 區塊嘗試開啟並讀取檔案。
  • except FileNotFoundError 捕捉檔案不存在的例外,並重新引發例外,提供更詳細的錯誤資訊。
  • 這種做法使得呼叫者可以根據具體的錯誤資訊進行相應的處理。

正確處理例外狀況的重要性

在軟體開發中,正確處理例外狀況(exceptions)是確保程式碼品質和可靠性的關鍵因素之一。例外狀況是指在程式執行過程中發生的非預期事件,它們可能會破壞程式的正常流程。正確地處理這些例外狀況,可以幫助開發者避免程式當機、資料丟失等問題,並提供更好的使用者經驗。

抽象層級與例外處理

在處理例外狀況時,保持抽象層級的一致性是非常重要的。這意味著在特定的抽象層級中,應該處理與該層級相關的例外狀況。例如,在資料傳輸物件(DataTransport)中,連線錯誤(ConnectionError)和資料解碼錯誤(ValueError)是兩種不同型別的例外狀況,它們應該在不同的抽象層級中被處理。

程式碼範例

def connect_with_retry(connector: Connector, retry_n_times: int, retry_backoff: int) -> Connection:
    """
    Tries to establish the connection of <connector> retrying <retry_n_times>, 
    and waiting <retry_backoff> seconds between attempts.
    
    :param connector: An object with a ``.connect()`` method.
    :param retry_n_times: The number of times to try to call ``connector.connect()``.
    :param retry_backoff: The time lapse between retry calls.
    :return: The connection object if it can connect.
    :raises ConnectionError: If it's not possible to connect after the retries have been exhausted.
    """
    for _ in range(retry_n_times):
        try:
            return connector.connect()
        except ConnectionError as e:
            logger.info("%s: attempting new connection in %is", e, retry_backoff)
            time.sleep(retry_backoff)
    exc = ConnectionError(f"Couldn't connect after {retry_n_times} times")
    logger.exception(exc)
    raise exc

class DataTransport:
    """An example of an object that separates the exception handling at different abstraction levels."""
    
    _RETRY_BACKOFF: int = 5
    _RETRY_TIMES: int = 3
    
    def __init__(self, connector: Connector) -> None:
        self._connector = connector
        self.connection = None
    
    def deliver_event(self, event: Event):
        self.connection = connect_with_retry(self._connector, self._RETRY_TIMES, self._RETRY_BACKOFF)
        self.send(event)
    
    def send(self, event: Event):
        try:
            return self.connection.send(event.decode())
        except ValueError as e:
            logger.error("%r contains incorrect data: %s", event, e)
            raise

內容解密:

  1. connect_with_retry函式:這個函式負責嘗試建立連線,並在失敗時重試。它接受一個connector物件和重試次數、重試間隔作為引數。如果連線成功,則傳回連線物件;如果連線失敗,則丟擲ConnectionError
  2. DataTransport類別:這個類別封裝了資料傳輸的邏輯。它使用connect_with_retry函式來建立連線,並在send方法中處理資料解碼錯誤。
  3. 例外處理的分離:透過將連線邏輯和資料解碼邏輯分離到不同的函式或方法中,可以使程式碼更加清晰和易於維護。

避免向使用者暴露Traceback資訊

當程式發生錯誤時,雖然需要記錄詳細的錯誤資訊以便於除錯,但不應該將這些資訊暴露給使用者。這是因為Traceback資訊可能包含敏感的程式碼細節,從而對安全性造成威脅。

最佳實踐

  1. 記錄詳細錯誤資訊:在發生錯誤時,記錄完整的Traceback資訊和其他相關細節,以便於開發者進行除錯。
  2. 向使用者顯示友好的錯誤訊息:不要直接向使用者顯示Traceback資訊,而是顯示友好的錯誤訊息,以避免洩露敏感資訊。

正確處理例外與錯誤:提升程式碼的強健性

在開發過程中,正確地處理錯誤與例外是至關重要的。這不僅能提升程式的穩定性,也能幫助開發者快速定位和修復問題。本篇文章將探討如何有效地處理 Python 中的例外,避免常見的陷阱,並使用斷言來確保程式的正確性。

避免使用空的 except 區塊

使用空的 except 區塊是一種常見的錯誤處理方式,但這其實是一種不佳的實踐。因為它會捕捉所有例外,包括那些你可能不想捕捉的例外,從而掩蓋真正的問題。

try:
    process_data()
except:
    pass

上述程式碼的問題在於,它不會在出現錯誤時失敗,這使得除錯變得更加困難。正確的做法是捕捉特定的例外,或者在 except 區塊中進行適當的錯誤處理。

正確的做法

  1. 捕捉特定的例外:不要捕捉太廣泛的例外,如 Exception。應該捕捉特定的例外,如 AttributeErrorKeyError

    try:
        process_data()
    except KeyError as e:
        logger.error("Key error occurred: %s", e)
    
  2. 進行錯誤處理:在 except 區塊中,可以記錄錯誤、傳回預設值,或者引發不同的例外。

    try:
        return data_dictionary[record_id]
    except KeyError as e:
        raise InternalDataError("Record not present") from e
    

使用 contextlib.suppress 可以明確地忽略特定的例外。

import contextlib

with contextlib.suppress(KeyError):
    process_data()

包含原始例外

當你決定引發不同的例外時,應該包含原始例外的資訊。這可以使用 raise <e> from <original_exception> 語法來實作。

範例

class InternalDataError(Exception):
    """An exception with the data of our domain problem."""

def process(data_dictionary, record_id):
    try:
        return data_dictionary[record_id]
    except KeyError as e:
        raise InternalDataError("Record not present") from e

這樣做可以保留原始例外的 traceback,使得除錯更加容易。

使用斷言

斷言用於檢查程式中的不變條件。如果斷言失敗,表示程式中存在缺陷。斷言不應該用於控制流程,也不應該被捕捉。

assert condition.holds(), "Condition is not satisfied"

正確使用斷言

  • 斷言應該用於檢查程式中的不變條件。
  • 不應該捕捉 AssertionError,因為這可能會掩蓋程式中的缺陷。
  • 可以在應用程式層級捕捉 AssertionError 並顯示通用錯誤訊息,同時記錄詳細的錯誤資訊。

軟體開發中的錯誤處理與設計原則

在軟體開發過程中,錯誤處理是一個至關重要的環節。適當的錯誤處理機制不僅能夠提升軟體的品質,還能幫助開發者快速定位和修復問題。本文將探討 Python 中的斷言(assertions)及其與例外處理(exception handling)的關係,並介紹軟體設計中的重要原則,包括關注點分離(separation of concerns)、內聚性(cohesion)和耦合性(coupling)。

斷言的重要性

斷言是一種用於驗證程式碼正確性的機制。當斷言失敗時,程式應該終止,以幫助開發者識別和修復錯誤。在 Python 中,使用 -O 旗標執行程式會抑制斷言陳述式,但這並不被推薦,因為斷言正是用來檢測程式中需要修復的部分。

result = condition.holds()
assert result > 0, f"Error with {result}"

在上述程式碼中,我們首先計算 condition.holds() 的結果並將其指定給 result,然後使用斷言檢查 result 是否大於 0。這種做法比直接在斷言中使用函式呼叫更好,因為函式呼叫可能具有副作用,且其結果不一定可重複。

內容解密:

  1. result = condition.holds():首先執行 condition.holds() 並將結果儲存於 result,確保函式只被呼叫一次。
  2. assert result > 0, f"Error with {result}":檢查 result 是否大於 0。如果斷言失敗,則輸出錯誤訊息,包含導致錯誤的 result 值。

斷言與例外處理的區別

斷言和例外處理都用於處理錯誤情況,但它們的目的不同。例外處理主要用於處理與業務邏輯相關的意外情況,而斷言則是用於驗證程式碼的正確性。例如,當演算法需要保持某種不變性(invariant)時,可以使用斷言來檢查這一條件是否成立。

關注點分離

關注點分離是一種軟體設計原則,旨在將不同的職責分配到不同的元件、層或模組中。這種設計可以提高軟體的可維護性,減少因變更或錯誤所導致的連鎖反應。

@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultTextAlignment center
skinparam rectangleBackgroundColor #F5F5F5
skinparam rectangleBorderColor #333333
skinparam arrowColor #333333

title 關注點分離

rectangle "介面呼叫" as node1
rectangle "資料共用" as node2
rectangle "結果回傳" as node3
rectangle "錯誤處理" as node4

node1 --> node2
node2 --> node3
node3 --> node4

@enduml

此圖示展示了模組之間的互動關係,每個模組負責不同的功能,從而實作關注點分離。

內容解密:

  1. 模組間的介面呼叫:模組1透過介面呼叫模組2,實作功能的解耦。
  2. 資料共用:模組3與模組2共用資料,確保資料的一致性。
  3. 結果回傳與錯誤處理:模組2將結果回傳給模組1,並在必要時進行錯誤處理,與模組3互動。

內聚性與耦合性

內聚性指的是物件應該具有明確且有限的目的,而耦合性則描述了物件之間的依賴關係。好的軟體設計應該追求高內聚性和低耦合性,以提高程式碼的可重用性和可維護性。