圈複雜度基礎

圈複雜度的基礎知識以及為什麼每個程式設計師都應該了解它

你寫的每個函數都有一個複雜度評分。如果函數不包含任何決策點,則沒有複雜度評分。 ifelse沒有循環,沒有 switch它的圈複雜度為 1:程式碼只有一條路徑。添加一個 if 語句變為 2。再增加一個,就變成 3。當函數在幾個嵌套層級中累積了十幾個條件分支時,它的圈複雜度可能達到 15 或 20,對其進行完整測試需要相應數量的測試案例,每個用例覆蓋一條不同的執行路徑。

圈複雜度 (CC) 由 Thomas J. McCabe 於 1976 年提出,用於量化程序控制流的邏輯複雜度。它至今仍是最實用的程式碼品質指標之一,因為它具有具體且可操作的意義:複雜度分數可以告訴你實現完整路徑覆蓋所需的最小測試用例數量,預測程式碼的理解和修改難度,並識別最有可能包含未發現缺陷的函數。本指南涵蓋了複雜度的計算公式、閾值、特定語言的範例、能夠有效降低複雜度的重構技巧,以及如何自動測量和追蹤複雜度。

SMART TS XL

幫助您掌握圈複雜度、優化效能並防止隱藏的錯誤

了解更多…

什麼是環路複雜度?

環路複雜度衡量的是程式原始碼中線性無關路徑的數量。托馬斯·J·麥凱布從圖論中推導出了這個概念:每個程式都可以表示為一個控制流程圖,其中節點代表語句,邊代表語句之間的可能流程。公式如下:

CC = E - N + 2P

當:

  • E = 控制流程圖中的邊數
  • N = 節點數
  • P = 連通分量的數量(通常單一函數為 1)

實際計算中,有一個更簡單的等效方法: CC = 決策點數 + 1。 一切 if, else if, while, for, case, catch, &&以及 || 增加一個決策點。任何函數的初始CC均為1。

Java的

// CC = 1: no decision points
public String greet(String name) {
    return "Hello, " + name;
}

// CC = 3: two decision points (two if statements)
public String classify(int score) {
    if (score >= 90) return "Excellent";
    if (score >= 70) return "Satisfactory";
    return "Needs improvement";
}

// CC = 5: four decision points (three conditions + one loop)
public double calculateTotal(List<Item> items, boolean isMember, boolean isHoliday) {
    double total = 0;
    for (Item item : items) {          // +1
        total += item.getPrice();
    }
    if (isMember) total *= 0.9;        // +1
    if (isHoliday) total *= 0.95;      // +1
    if (total > 100) total -= 5;       // +1
    return total;
}

閾值:什麼樣的分數才算合格?

麥凱布最初的指導意見(至今仍被引用最多)定義了四個風險等級:

CC評分風險等級同步口譯
1 – 10簡單、結構良好、易於測試
11 – 20中度更複雜;需要增加測試工作量。
21 – 50程式碼複雜且難以測試;建議重構。
> 50很高實際操作中無法測試;有嚴重的品質風險

在持續整合/持續交付 (CI/CD) 品質閘控中,最常用的閾值是 10。 SonarQube 的預設認知複雜度閾值為 15(這是一個相關但不同的指標)。美國國家標準與技術研究院 (NIST) 針對安全關鍵系統的指南建議每個模組的閾值最大為 10。

一個重要的細微差別是: CC 衡量的是結構複雜性,而非語意複雜性。一個 CC = 8 的函數如果實現了複雜的財務計算,可能比一個 CC = 15 的函數(只包含簡單的防禦性檢定)更難理解。 CC 應作為調查的訊號,而非最終結論。

不同語言中的圈複雜度

蟒蛇

Python 中對 CC 有貢獻的決策結構: if, elif, else (不計入,沒有條件限制) for, while, try/except (每個 except 計數), with (不算),以及布林運算符 and/or 在條件方面。

蟒蛇

# CC = 1
def format_name(first: str, last: str) -> str:
    return f"{first} {last}"

# CC = 4: three decision points
def calculate_discount(price: float, is_member: bool, is_holiday: bool) -> float:
    discount = 0.0
    if is_member:        # +1
        discount += 0.10
    if is_holiday:       # +1
        discount += 0.05
    if price > 100:      # +1
        discount += 0.02
    return price * (1 - discount)

# CC = 6: five decision points (list comprehension counts as a loop)
def process_orders(orders: list[dict]) -> list[dict]:
    return [
        {**order, "total": order["qty"] * order["price"]}  # +1 (comprehension)
        for order in orders
        if order["qty"] > 0                                 # +1 (filter condition)
        if order["price"] > 0                              # +1 (second filter)
    ]

用於 Python 進行 CC 測量的工具: radon (radon cc src/ -s), flake8-cognitive-complexity, pylint 使用複雜度插件,SonarQube Python 分析。

Java的

Java的

// CC = 7: complex authentication with multiple conditions
public AuthResult authenticate(String userId, String password, boolean isMfa) {
    if (userId == null || password == null) return AuthResult.INVALID;  // +2 (||)
    User user = userRepository.findById(userId);
    if (user == null) return AuthResult.NOT_FOUND;                      // +1
    if (!user.checkPassword(password)) return AuthResult.WRONG_PASSWORD;// +1
    if (isMfa && !user.hasMfaEnabled()) return AuthResult.MFA_REQUIRED; // +2 (&&)
    return AuthResult.SUCCESS;
}
// CC = 1 + 2 + 1 + 1 + 2 = 7

減少它:

Java的

// After refactoring: CC = 3 (main method) + small helpers with CC = 2 each
public AuthResult authenticate(String userId, String password, boolean isMfa) {
    if (hasInvalidInputs(userId, password)) return AuthResult.INVALID;
    User user = findVerifiedUser(userId, password);
    if (user == null) return AuthResult.WRONG_PASSWORD;
    if (requiresMfa(user, isMfa)) return AuthResult.MFA_REQUIRED;
    return AuthResult.SUCCESS;
}

private boolean hasInvalidInputs(String userId, String password) {
    return userId == null || password == null;  // CC = 2
}

private boolean requiresMfa(User user, boolean isMfa) {
    return isMfa && !user.hasMfaEnabled();      // CC = 2
}

Java CC 工具: Checkstyle、PMD、SonarQube、IntelliJ IDEA 內建軟體 SMART TS XL.

C# 和 TypeScript

C# 和 TypeScript 遵循與 Java 相同的規則。關鍵差異在於:C# 中的 LINQ 表達式子句和 TypeScript 中的三元運算子鏈都增加了決策點。

尖銳的

// CC = 5: switch with four cases
public decimal GetShippingCost(string zone) => zone switch {
    "domestic"      => 5.99m,    // +1
    "eu"            => 15.99m,   // +1
    "international" => 29.99m,   // +1
    "express"       => 49.99m,   // +1
    _               => throw new ArgumentException($"Unknown zone: {zone}")
};

COBOL

COBOL中有助於CC的決策架構: IF/ELSE, EVALUATE WHEN (每個 WHEN 子句), PERFORM UNTIL, PERFORM VARYING ... WITH TEST BEFORE/AFTER, AT END, ON EXCEPTION, NOT ON EXCEPTION, ON SIZE ERROR.

科博爾

       CALCULATE-DISCOUNT.
           IF WS-CUSTOMER-TYPE = 'GOLD'               *> +1
               IF WS-PURCHASE-AMT > 1000              *> +1
                   COMPUTE WS-DISCOUNT = 0.20
               ELSE                                    *> (no increment)
                   COMPUTE WS-DISCOUNT = 0.15
           ELSE IF WS-CUSTOMER-TYPE = 'SILVER'        *> +1
               COMPUTE WS-DISCOUNT = 0.10
           ELSE                                        *> (no increment)
               COMPUTE WS-DISCOUNT = 0.05
           END-IF
           EVALUATE TRUE
               WHEN WS-REGION = 'NORTH' PERFORM APPLY-REGIONAL-RATE  *> +1
               WHEN WS-REGION = 'SOUTH' PERFORM APPLY-SOUTHERN-RATE  *> +1
           END-EVALUATE.
           *> Total CC = 1 + 5 = 6

COBOL 語言冗長的語法意味著其段落通常比現代語言中同等功能的段落更長。在遺留程式碼庫中,每段程式碼量超過 50 個字元的 COBOL 程式很常見,它們是重構和現代化規劃中優先順序最高的目標。

如何計算圈複雜度:三種方法

方法一:計算決策點數 + 1 最快的手動方法。逐一計數 if, else if, while, for, case, catch, &&, || 在函數內部。函數本身的值加 1。

方法二:控制流程圖 將函數繪製成圖:每個語句或程式碼區塊一個節點,每條控制流以一邊表示。應用 CC = E - N + 2.

方法三:自動化工具這是唯一適用於複雜功能以外的實用方法。大多數靜態分析工具會自動計算 CC 並將其整合到 CI/CD 管線中。

真正降低複雜性的重構技巧

保障條款(提前返還)

當前提條件不成立時,守衛子句會提前退出函數,從而消除 else 分支並減少巢狀深度。

蟒蛇

# Before: deeply nested, CC = 5
def process_order(order):
    if order is not None:
        if order.is_valid():
            if order.has_stock():
                if order.payment_cleared():
                    return fulfill_order(order)
                else:
                    return "Payment failed"
            else:
                return "Out of stock"
        else:
            return "Invalid order"
    else:
        return "No order"

# After: flat, CC = 5 (same complexity, dramatically better readability)
def process_order(order):
    if order is None: return "No order"
    if not order.is_valid(): return "Invalid order"
    if not order.has_stock(): return "Out of stock"
    if not order.payment_cleared(): return "Payment failed"
    return fulfill_order(order)

程式碼量並沒有減少,決策點依然存在,但程式碼的可讀性和測試性大大提高。真正減少程式碼量需要消除決策點,而不僅僅是重新排列它們。

提取方法

將邏輯決策組移到命名方法中,可以減少呼叫函數的複雜度,同時將複雜性分散到更小、可測試的單元。

Java的

// Before: one method doing everything, CC = 9
public double calculateInvoiceTotal(Invoice invoice, Customer customer) {
    double subtotal = 0;
    for (LineItem item : invoice.getItems()) {
        subtotal += item.getQuantity() * item.getUnitPrice();
        if (item.isTaxable()) subtotal += item.getPrice() * 0.1;
    }
    if (customer.isMember()) subtotal *= 0.9;
    if (customer.hasVoucher()) subtotal -= customer.getVoucherValue();
    if (subtotal < 0) subtotal = 0;
    return subtotal;
}

// After: main method CC = 4, helpers have CC = 2-3 each
public double calculateInvoiceTotal(Invoice invoice, Customer customer) {
    double subtotal = computeLineItemTotal(invoice.getItems());
    subtotal = applyCustomerDiscounts(subtotal, customer);
    return Math.max(0, subtotal);
}

用多態性取代條件語句

當函數根據類型或狀態進行分支時,多態性完全消除了這種分支。

Java的

// Before: switch on payment type, CC grows with each new type
public void processPayment(String type, double amount) {
    switch (type) {
        case "CREDIT":  processCreditCard(amount); break;
        case "PAYPAL":  processPayPal(amount);     break;
        case "CRYPTO":  processCrypto(amount);     break;
        default: throw new IllegalArgumentException("Unknown type: " + type);
    }
}

// After: new payment types require no changes to this method, CC = 1
public interface PaymentProcessor {
    void process(double amount);
}

public void processPayment(PaymentProcessor processor, double amount) {
    processor.process(amount);  // no branching
}

分解複雜條件語句

將複雜的布林表達式提取到命名方法中,從而揭示其意圖。

蟒蛇

# Before: dense boolean logic, hard to understand, easy to mis-test
if user.age >= 18 and user.country in ALLOWED_COUNTRIES and not user.is_banned and user.verified:
    grant_access()

# After: named predicate, self-documenting, unit-testable independently
def is_eligible_for_access(user: User) -> bool:
    return (
        user.age >= 18
        and user.country in ALLOWED_COUNTRIES
        and not user.is_banned
        and user.verified
    )

if is_eligible_for_access(user):
    grant_access()

CI/CD 流水線中的圈複雜度

CI/CD 管線中的自動化 CC 強制執行可防止複雜性在程式碼審查之間悄悄累積。

雅姆

# GitHub Actions: fail PR if any function exceeds CC threshold
name: Code Quality

on: [pull_request]

jobs:
  complexity-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install radon (Python CC tool)
        run: pip install radon

      - name: Check cyclomatic complexity
        run: |
          radon cc src/ --min C --show-complexity
          # Fails if any function has CC grade C (11-15) or worse
          radon cc src/ --min C --total-average | grep -q "Average complexity" \
            && echo "Complexity check passed" \
            || (echo "Functions with high complexity found" && exit 1)

適用於使用 SonarQube 的 Java:

雅姆

# SonarQube quality gate blocks merge if CC exceeds threshold
- name: SonarCloud Scan
  uses: SonarSource/sonarcloud-github-action@master
  env:
    SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
  with:
    args: |
      -Dsonar.qualitygate.wait=true
      -Dsonar.java.complexity.Function.threshold=10

品質門應該會阻止新的高複雜度程式碼,而不是阻止整個遺留程式碼庫,因為遺留程式碼庫可能已經存在高複雜度程式碼,這些程式碼正在單獨處理。

COBOL 和遺留程式碼庫中的圈複雜度

對於企業級系統而言,COBOL 程式中某些段落的圈複雜度 (CC) 可能超過 50 甚至 100,因此圈複雜度分析的主要用途與現代程式碼庫有所不同。問題不再是“我們是否應該重構這個函數?”,而是“這個程序的遷移風險是什麼?我們應該按什麼順序進行現代化改造?”

一個 COBOL 程序,如果其主段落的 CC 值為 80,則該程式有 80 條獨立的執行路徑,每條路徑都需要一個測試案例進行驗證。如果程式缺少測試案例(大多數遺留 COBOL 程式都存在這種情況),則 CC 值是預測在任何轉換工作被認為安全之前需要建立多少驗證場景的主要指標。

SMART TS XL“ 靜態程式碼分析 同時計算 COBOL、JCL、RPG、PL/I 和所有現代語言的圈複雜度,產生專案組合層級的圈複雜度分佈,進而為現代化排序決策提供依據。圈複雜度得分最高且呼叫者最多(高扇入)的程式是風險最高的遷移目標,具體內容請參考上下文。 為 COBOL 應用程式建立可維護性指標CC 是更廣泛的品質圖景的一個組成部分,該圖景還包括 Halstead Volume 和程式碼行數。

影響分析功能使用基於 CC 的複雜性分類來確定任何高複雜性程式變更時需要驗證的內容:更高的 CC 意味著更多的執行路徑,這意味著必須驗證更多的測試場景,以確認在任何修改前後行為的等效性。

對於計劃進行傳統現代化改造的團隊來說,整個產品組合中的 CC 分佈是遷移波次排序的輸入:CC 低且調用者少的項目會先遷移;CC 高且調用者多的項目會在團隊積累了對簡單組件的專業知識並且測試基礎設施到位以驗證複雜組件之後最後遷移。

複雜性本身並非敵人,隱形複雜性才是。

圈複雜度是少數幾個與可測試性直接相關的程式碼品質指標之一,完全路徑覆蓋所需的測試案例數量,根據定義,至少等於圈複雜度得分。這種關聯性使得圈複雜度具有實際操作性,而許多其他品質指標則不具備這種特性。

管理複雜度的學科要求我們理解複雜性是逐步累積的。 if 添加到不斷增長的函數中的語句本身是合理的。然而,三年來一系列看似合理的決策最終可能導致一個複雜度高達 40 的函數,由於其過於複雜而難以安全地進行推理,因此無人願意觸碰。本指南中的工具和技術,例如守衛子句、方法提取、多態性、條件分解和 CI/CD 品質門控,旨在防止這種複雜性悄然累積,並在其發生時系統地加以解決。