PHP代码质量度量:圈复杂度(Cyclomatic Complexity)与CRAP指标的分析与优化

PHP代码质量度量:圈复杂度(Cyclomatic Complexity)与CRAP指标的分析与优化

大家好,今天我们来深入探讨PHP代码质量度量中的两个重要指标:圈复杂度(Cyclomatic Complexity,CC)和CRAP(Change Risk Anti-Patterns)指标。理解并优化这两个指标,能显著提升代码的可读性、可维护性和可测试性,最终降低开发和维护成本。

1. 圈复杂度:代码复杂度的量化

1.1 什么是圈复杂度?

圈复杂度是一种衡量程序控制流复杂度的指标。它通过计算程序中线性无关的路径数量来评估代码的复杂度。简单来说,圈复杂度越高,代码的分支越多,理解和测试的难度就越大。

1.2 圈复杂度的计算方法

圈复杂度可以使用以下公式计算:

CC = E - N + 2P

其中:

  • CC:圈复杂度
  • E:图中边的数量(代表控制流的连接)
  • N:图中节点的数量(代表语句块)
  • P:连接组件的数量(通常为1,除非有多个独立的函数或模块)

实际上,在代码中,圈复杂度也可以通过统计以下关键词的数量来估算:

  • if
  • else if
  • else
  • while
  • for
  • foreach
  • case
  • catch
  • && (逻辑与)
  • || (逻辑或)
  • ? (三元运算符)

圈复杂度大致等于以上关键词的数量加1。

1.3 圈复杂度的解读

一般来说,圈复杂度的数值代表了代码需要测试的最小路径数量,以便覆盖所有可能的执行流程。

圈复杂度 代码复杂度 建议
1-10 简单易懂 保持现状
11-20 稍微复杂 考虑重构,分解为更小的函数或模块
21-50 复杂 必须重构,分解逻辑,提高可读性和可测试性
>50 非常复杂 立即重构,否则代码难以理解和维护,bug风险极高

1.4 PHP代码示例与圈复杂度计算

考虑以下PHP函数:

function processOrder(int $orderId, string $paymentMethod, bool $isRecurring): string
{
    $order = getOrderById($orderId);

    if (!$order) {
        return "Order not found.";
    }

    if ($order['status'] === 'pending') {
        if ($paymentMethod === 'credit_card') {
            if ($isRecurring) {
                $result = chargeCreditCardRecurring($order['total']);
            } else {
                $result = chargeCreditCard($order['total']);
            }
        } elseif ($paymentMethod === 'paypal') {
            $result = processPaypalPayment($order['total']);
        } else {
            return "Invalid payment method.";
        }

        if ($result === 'success') {
            updateOrderStatus($orderId, 'processed');
            return "Order processed successfully.";
        } else {
            updateOrderStatus($orderId, 'failed');
            return "Payment failed.";
        }
    } else {
        return "Order already processed.";
    }
}

这个函数的圈复杂度较高。我们来分析一下:

  • if 出现 4 次
  • else 出现 3 次
  • 因此,圈复杂度大约为 4 + 3 + 1 = 8。

虽然小于10,但是函数内部嵌套层级较深,可读性较差,需要进行重构。

1.5 圈复杂度的优化方法

  • 提取函数:将复杂的逻辑分解为更小的、功能单一的函数。每个函数只负责完成一个明确的任务。
  • 使用设计模式:策略模式、命令模式等可以有效地减少条件判断语句,降低复杂度。
  • 简化条件判断:使用卫语句(Guard Clauses)提前排除特殊情况,减少嵌套层级。
  • 使用多态:将不同的行为抽象成接口或抽象类,通过多态来实现不同的逻辑。
  • 使用查找表(Lookup Table):将条件判断转化为查找表,避免大量的 if-else 语句。

针对上面的例子,我们可以进行如下重构:

function processOrder(int $orderId, string $paymentMethod, bool $isRecurring): string
{
    $order = getOrderById($orderId);

    if (!$order) {
        return "Order not found.";
    }

    if ($order['status'] !== 'pending') {
        return "Order already processed.";
    }

    try {
        $paymentResult = processPayment($order, $paymentMethod, $isRecurring);
        updateOrderStatus($orderId, 'processed');
        return "Order processed successfully.";
    } catch (Exception $e) {
        updateOrderStatus($orderId, 'failed');
        return "Payment failed.";
    }
}

function processPayment(array $order, string $paymentMethod, bool $isRecurring): string
{
    switch ($paymentMethod) {
        case 'credit_card':
            return processCreditCardPayment($order, $isRecurring);
        case 'paypal':
            return processPaypalPayment($order['total']);
        default:
            throw new Exception("Invalid payment method.");
    }
}

function processCreditCardPayment(array $order, bool $isRecurring): string
{
    if ($isRecurring) {
        return chargeCreditCardRecurring($order['total']);
    } else {
        return chargeCreditCard($order['total']);
    }
}

重构后的代码,processOrder 函数的复杂度大大降低,逻辑更加清晰。processPayment 函数利用 switch 语句,将不同的支付方式进行分离。processCreditCardPayment 函数处理信用卡支付的逻辑。 通过这种方式,我们将原先复杂的函数分解为多个职责单一的函数,降低了圈复杂度,提高了代码的可读性和可维护性。

2. CRAP指标:综合评估代码质量

2.1 什么是CRAP指标?

CRAP指标,全称 Change Risk Anti-Patterns,是一个用于评估代码质量的综合指标。它结合了圈复杂度(Cyclomatic Complexity)和代码覆盖率(Code Coverage),旨在衡量代码修改的风险。

CRAP指标的核心思想是:复杂的、未经充分测试的代码,修改的风险最高。

2.2 CRAP指标的计算方法

CRAP指标的计算公式如下:

CRAP = CC^2 * (1 - coverage)^3 + CC

其中:

  • CRAP:CRAP指标
  • CC:圈复杂度
  • coverage:代码覆盖率(0到1之间的小数,例如 80% 的覆盖率表示为 0.8)

2.3 CRAP指标的解读

CRAP指标的值越高,表示代码质量越差,修改的风险越高。一般来说,CRAP指标的值可以这样解读:

CRAP指标 代码质量 建议
0-6 优秀 保持现状
7-12 良好 可以接受,但仍有改进空间
13-20 一般 需要关注,考虑重构和增加测试
21-30 必须重构和增加测试,否则代码难以维护,bug风险极高
>30 非常差 立即重构和增加测试,否则代码很可能导致严重问题

2.4 PHP代码示例与CRAP指标计算

假设我们有一个函数,其圈复杂度为 8,代码覆盖率为 70% (0.7),那么它的CRAP指标为:

CRAP = 8^2 * (1 - 0.7)^3 + 8
CRAP = 64 * (0.3)^3 + 8
CRAP = 64 * 0.027 + 8
CRAP = 1.728 + 8
CRAP = 9.728

根据上面的解读,CRAP值为9.728表示代码质量良好,但仍有改进空间。我们可以考虑进一步降低圈复杂度,或者提高代码覆盖率,以降低修改的风险。

现在,假设另一个函数,其圈复杂度为 20,代码覆盖率为 50% (0.5),那么它的CRAP指标为:

CRAP = 20^2 * (1 - 0.5)^3 + 20
CRAP = 400 * (0.5)^3 + 20
CRAP = 400 * 0.125 + 20
CRAP = 50 + 20
CRAP = 70

CRAP值为70,说明代码质量非常差,修改的风险极高,必须立即进行重构和增加测试。

2.5 CRAP指标的优化方法

优化CRAP指标,实际上就是降低圈复杂度,提高代码覆盖率。

  • 降低圈复杂度:参考 1.5 节的优化方法。
  • 提高代码覆盖率
    • 编写单元测试:针对每个函数、每个类编写单元测试,覆盖所有可能的输入和输出。
    • 使用测试驱动开发(TDD):先编写测试用例,再编写代码,确保代码从一开始就具有良好的可测试性。
    • 覆盖所有分支:确保测试用例覆盖代码中的所有 ifelseswitch 等分支。
    • 进行代码审查:通过代码审查发现潜在的缺陷和未覆盖的测试用例。

3. 工具支持

幸运的是,有一些工具可以帮助我们自动计算圈复杂度和CRAP指标,并生成报告。

  • PHP_CodeSniffer: 这是一个PHP代码风格检测工具,可以配合自定义规则来检测圈复杂度。
  • PHP Mess Detector: 这是一个专门用于检测PHP代码中潜在问题的工具,包括过高的圈复杂度。
  • PHPCoverage: 这是一个用于收集PHP代码覆盖率信息的工具。
  • SonarQube: 这是一个代码质量管理平台,可以集成多种静态分析工具,提供全面的代码质量报告,包括圈复杂度、代码覆盖率和CRAP指标。
  • Psalm/Phan/Static Analysis Tools: 这些静态分析工具可以帮助你发现潜在的错误,并提供代码复杂度的信息。

4. 实践案例

接下来,我们通过一个实际的例子来演示如何使用工具来分析和优化代码。

假设我们有以下代码:

class DiscountCalculator
{
    public function calculateDiscount(string $customerType, float $orderTotal): float
    {
        $discount = 0;

        if ($customerType === 'new') {
            if ($orderTotal > 100) {
                $discount = 0.1; // 10% discount
            } else {
                $discount = 0.05; // 5% discount
            }
        } elseif ($customerType === 'loyal') {
            if ($orderTotal > 50) {
                $discount = 0.15; // 15% discount
            } else {
                $discount = 0.1; // 10% discount
            }

            if (date('w') == 5) { // Friday
                $discount += 0.05; // Additional 5% discount on Fridays
            }
        } elseif ($customerType === 'vip') {
            $discount = 0.2; // 20% discount
        } else {
            $discount = 0; // No discount
        }

        return $discount;
    }
}

这个 calculateDiscount 函数的圈复杂度较高,逻辑也比较复杂。我们可以使用 PHP Mess Detector 来检测它的圈复杂度。

首先,安装 PHP Mess Detector:

composer require phpmd/phpmd

然后,创建一个规则集文件 ruleset.xml

<?xml version="1.0"?>
<ruleset name="My Rules"
         xmlns="http://pmd.sf.net/ruleset/1.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://pmd.sf.net/ruleset/1.0.0 http://pmd.sf.net/ruleset_xml_schema.xsd"
         xsi:noNamespaceSchemaLocation="http://pmd.sf.net/ruleset_xml_schema.xsd">
    <description>My custom ruleset</description>

    <rule ref="rulesets/codesize.xml">
        <exclude name="TooManyMethods"/>
        <exclude name="TooManyFields"/>
        <exclude name="ExcessiveClassLength"/>
        <exclude name="ExcessiveMethodLength"/>
        <exclude name="ExcessiveParameterList"/>
        <exclude name="ExcessivePublicCount"/>
    </rule>

    <rule ref="rulesets/codesize.xml/CyclomaticComplexity">
        <properties>
            <property name="reportLevel" value="7"/>
        </properties>
    </rule>
</ruleset>

这个规则集指定了圈复杂度的阈值为 7。

最后,运行 PHP Mess Detector:

./vendor/bin/phpmd src/ DiscountCalculator.php xml ruleset.xml

运行结果可能会显示 DiscountCalculator::calculateDiscount 函数的圈复杂度超过了阈值。

接下来,我们可以重构代码,降低圈复杂度:

class DiscountCalculator
{
    private const NEW_CUSTOMER_THRESHOLD = 100;
    private const LOYAL_CUSTOMER_THRESHOLD = 50;
    private const FRIDAY = 5;

    public function calculateDiscount(string $customerType, float $orderTotal): float
    {
        $discountStrategy = $this->getDiscountStrategy($customerType);
        return $discountStrategy->calculateDiscount($orderTotal);
    }

    private function getDiscountStrategy(string $customerType): DiscountStrategyInterface
    {
        switch ($customerType) {
            case 'new':
                return new NewCustomerDiscountStrategy();
            case 'loyal':
                return new LoyalCustomerDiscountStrategy();
            case 'vip':
                return new VipCustomerDiscountStrategy();
            default:
                return new DefaultDiscountStrategy();
        }
    }
}

interface DiscountStrategyInterface
{
    public function calculateDiscount(float $orderTotal): float;
}

class NewCustomerDiscountStrategy implements DiscountStrategyInterface
{
    public function calculateDiscount(float $orderTotal): float
    {
        if ($orderTotal > DiscountCalculator::NEW_CUSTOMER_THRESHOLD) {
            return 0.1;
        } else {
            return 0.05;
        }
    }
}

class LoyalCustomerDiscountStrategy implements DiscountStrategyInterface
{
    public function calculateDiscount(float $orderTotal): float
    {
        $discount = ($orderTotal > DiscountCalculator::LOYAL_CUSTOMER_THRESHOLD) ? 0.15 : 0.1;

        if (date('w') == DiscountCalculator::FRIDAY) {
            $discount += 0.05;
        }

        return $discount;
    }
}

class VipCustomerDiscountStrategy implements DiscountStrategyInterface
{
    public function calculateDiscount(float $orderTotal): float
    {
        return 0.2;
    }
}

class DefaultDiscountStrategy implements DiscountStrategyInterface
{
    public function calculateDiscount(float $orderTotal): float
    {
        return 0;
    }
}

在这个重构后的代码中,我们使用了策略模式,将不同的客户类型对应的折扣计算逻辑分离到不同的类中。DiscountCalculator 类只负责选择合适的折扣策略,而具体的折扣计算由各个策略类来完成。

通过这种方式,我们大大降低了 DiscountCalculator::calculateDiscount 函数的圈复杂度,提高了代码的可读性和可维护性。

接下来,我们需要编写单元测试,提高代码覆盖率。可以使用 PHPUnit 来编写单元测试。

首先,安装 PHPUnit:

composer require --dev phpunit/phpunit

然后,创建一个测试类 DiscountCalculatorTest.php

use PHPUnitFrameworkTestCase;

class DiscountCalculatorTest extends TestCase
{
    public function testCalculateDiscountForNewCustomerWithOrderTotalGreaterThan100()
    {
        $calculator = new DiscountCalculator();
        $discount = $calculator->calculateDiscount('new', 150);
        $this->assertEquals(0.1, $discount);
    }

    public function testCalculateDiscountForNewCustomerWithOrderTotalLessThanOrEqualTo100()
    {
        $calculator = new DiscountCalculator();
        $discount = $calculator->calculateDiscount('new', 50);
        $this->assertEquals(0.05, $discount);
    }

    // Add more test cases for other customer types and scenarios
}

编写更多的测试用例,确保覆盖所有可能的输入和输出。

最后,运行 PHPUnit:

./vendor/bin/phpunit

通过编写单元测试,我们可以提高代码覆盖率,降低 CRAP 指标,从而降低代码修改的风险。

5. 注意事项

  • 不要盲目追求低圈复杂度:过度简化代码可能会导致可读性下降。需要在复杂度和可读性之间找到平衡。
  • CRAP 指标只是一个参考:不要过分依赖 CRAP 指标。需要结合实际情况,综合评估代码质量。
  • 持续改进:代码质量是一个持续改进的过程。定期进行代码审查,重构代码,编写测试用例,不断提升代码质量。
  • 关注代码可读性:可读性好的代码更容易理解和维护,降低了出错的风险。

总结

通过今天的内容,我们了解了圈复杂度与CRAP指标的计算方式与优化方法。这两个指标可以帮助我们量化代码的复杂度,并指导我们进行代码重构和测试。最终,我们应该结合实际情况,综合评估代码质量,并持续改进,编写出高质量、可维护的PHP代码。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注