17、代码分析:从溢出问题到多语言代码优化

代码分析:从溢出问题到多语言代码优化

1. 数值异常情况

在代码处理数值时,会遇到一些异常情况,主要包括溢出、下溢和除零错误:
- 溢出(Overflow) :当存储的数字超出特定数据类型的存储范围时会发生溢出。例如,对货币实体进行加法运算或货币与其他数字相乘时可能导致溢出。
- 下溢(Underflow) :当存储的数字过小(非常接近零)时会发生下溢。此时,没有足够的有效数字来正确表示该数字。例如,将货币除以一个大数或存在非常小的汇率时可能导致下溢。
- 除零错误(Division by zero) :非零数字除以零的结果是无穷大,而零除以零的结果是未定义的。

目前这些情况都未经过测试,这表明代码存在不完整性。不过,我们可以通过测试驱动来构建处理这些情况的功能。

2. 代码开发过程的影响

代码的质量可以从多个维度进行评估,其中开发过程也是一个重要方面。开发过程会影响最终代码的形态,包括中间版本。如果以不同的顺序开发功能,可能会得到不同的实现。例如,货币实体(Money)目前有乘法和除法方法,但没有加法方法。如果在早期实现“5 USD + 10 USD = 15 USD”这个功能,货币实体可能就会有加法方法。

在安排功能开发顺序时,通常遵循先简单后复杂的逻辑。但如果一开始就开发不同货币的加法功能(如“5 USD + 10 EUR = 17 USD”),就需要提前引入汇率,并且可能更难识别和提取投资组合(Portfolio)和银行(Bank)等抽象概念。

3. Go 语言代码分析

3.1 代码概况

可以使用 gocyclo 工具来测量 Go 代码的圈复杂度。在 go 文件夹中运行 gocyclo . 后,得到以下最复杂的方法:
| 方法 | 圈复杂度 | 文件位置 |
| — | — | — |
| stocks (Portfolio).Evaluate | 5 | stocks/portfolio.go:12:1 |
| main assertNil | 3 | money_test.go:129:1 |
| stocks (Bank).Convert | 3 | stocks/bank.go:14:1 |
| main assertEqual | 2 | money_test.go:135:1 |

其中, Portfolio.Evaluate 方法的圈复杂度最高为 5,虽然低于启发式阈值 10,但可以通过提取方法重构来降低复杂度。例如,将失败消息的创建提取到一个新方法中:

func (p Portfolio) Evaluate(bank Bank, currency string) (*Money, error) {
  ... 
  failures := createFailureMessage(failedConversions) 
  return nil, errors.New("Missing exchange rate(s):" + failures)
}

func createFailureMessage(failedConversions []string) string { 
  failures := "["
  for _, f := range failedConversions {
    failures = failures + f + ","
  }
  failures = failures + "]"
  return failures
}

重构后, Evaluate 方法的圈复杂度降低到 4,但两个方法的总圈复杂度变为 6。

3.2 代码耦合与简洁性

代码的耦合度较低, Portfolio 依赖于 Money Bank Bank 依赖于 Money ,测试类不可避免地依赖于这三者。可以将测试分离到不同的类中,如 TestMoney TestPortfolio TestBank 来进一步降低耦合。

Go 提供了 vet 命令来检查可疑代码。运行 go vet ./... 后,我们的程序没有警告信息,这是我们应该努力保持的状态。该命令不仅会查找多余的代码(如无用的赋值和无法到达的代码),还会警告 Go 构造中的常见错误。

3.3 代码目的与改进

Go 代码具有良好的内聚性,三个命名类型都有明确的职责。但存在一个问题,由于 Money 类中没有 Add 方法,金额的加法操作在 Portfolio.Evaluate 方法中进行,而不是在 Money 类中。

如果 Money 类有 Add 方法, Portfolio.Evaluate 方法可以简化:

func (p Portfolio) Evaluate(bank Bank, currency string) (*Money, error) {
  totalMoney := NewMoney(0, currency) 
  failedConversions := make([]string, 0)
  for _, m := range p {
    if convertedMoney, err := bank.Convert(m, currency); err == nil {
      totalMoney = *totalMoney.Add(convertedMoney) 
    } else {
      failedConversions = append(failedConversions, err.Error())
    }
  }
  if len(failedConversions) == 0 {
    return &totalMoney, nil 
  }
  ... 
}

测试驱动 Money.Add 方法时,可以遵循以下设计:
1. 该方法应接受一个 *Money 类型的参数。
2. 返回值应为 *Money 类型,表示两个货币的总和。
3. 只有当两个货币的币种相同时才进行加法操作。
4. 当两个货币的币种不同时,应通过返回 nil 或抛出异常来表示加法失败。

3.4 开发过程回顾

最初, Money Portfolio 代码放在一个源文件中,后来在 stocks 包中分离到两个文件,再后来创建了 Bank 。我们可以思考是否能更早地识别这种分离,或者是否可以将所有代码写在一个大文件中,最后再进行分离。开发过程会细微地影响 Go 代码的形态,在测试驱动开发(TDD)中,我们可以通过控制开发节奏来影响开发过程。

4. JavaScript 代码分析

4.1 代码复杂度测量

可以使用 JSHint 工具来收集 JavaScript 代码的复杂度指标。通过命令行运行 npm install -g jshint 可以全局安装该工具。在 js 文件夹中创建 .jshintrc 文件来指定配置参数:

{
    "esversion": 6, 
    "maxcomplexity": 1  
}

这里将 maxcomplexity 设置为 1,是为了让 jshint 将所有圈复杂度高于 1 的方法作为错误输出。在 js 文件夹中运行 jshint *.js 后,得到以下圈复杂度超过 1 的方法:
| 方法 | 圈复杂度 | 文件位置 |
| — | — | — |
| bank.js 中的某函数 | 3 | bank.js: line 13, col 12 |
| portfolio.js 中的某函数 | 2 | portfolio.js: line 14, col 41 |
| portfolio.js 中的某函数 | 2 | portfolio.js: line 12, col 13 |
| test_money.js 中的某函数 | 2 | test_money.js: line 80, col 21 |
| test_money.js 中的某函数 | 2 | test_money.js: line 91, col 44 |
| test_money.js 中的某函数 | 3 | test_money.js: line 99, col 30 |

这验证了测试驱动开发的一个关键主张,即 TDD 鼓励的增量式和渐进式编码风格会使代码的复杂度分布更均匀。

4.2 代码耦合与依赖注入

代码的耦合度较低, Portfolio Bank 都依赖于 Money Bank Portfolio 存在更微妙的依赖。 Portfolio.evaluate 方法需要一个实现了 convert 方法的“类银行对象”,这是一种接口依赖,而不是对具体实现的依赖。与 Portfolio Money 的依赖不同, evaluate 方法中明确调用了 new Money()

当类 A 创建类 B 的新实例时,很难使用依赖注入;但如果 A 只使用 B 定义的方法(即 A 对 B 有接口依赖),则更容易使用依赖注入。例如,可以创建一个 Bank 的测试替身来测试 Portfolio.evaluate 方法:

testAdditionWithTestDouble() {
  const moneyCount = 10; 
  let moneys = []
  for (let i = 0; i < moneyCount; i++) {
    moneys.push(
      new Money(Math.random(Number.MAX_SAFE_INTEGER), "Does Not Matter") 
    );
  }
  let bank = { 
    convert: function() { 
      return new Money(Math.PI, "Kalganid"); 
    }
  };
  let arbitraryResult = new Money(moneyCount * Math.PI, "Kalganid"); 
  let portfolio = new Portfolio();
  portfolio.add(...moneys);
  assert.deepStrictEqual(
    portfolio.evaluate(bank, "Kalganid"), arbitraryResult 
  );
}

是否使用测试替身需要权衡。如果引入测试替身的工作量大于使用真实代码,则使用真实代码;否则,使用测试替身。但使用测试替身也存在风险,可能会掩盖系统测试方法或函数调用时产生的非明显副作用,或者引入真实代码中不存在的新副作用。使用无状态且接口定义良好的代码是解决这个问题的一个起点。

4.3 代码目的与改进

JavaScript 代码中的三个类都有明确的单一目的。但 Portfolio.evaluate 方法的 try 块中存在一个抽象泄漏问题:

try {
    let convertedMoney = bank.convert(money, currency);
    return sum + convertedMoney.amount;
}

这里将转换后的货币对象 convertedMoney 拆开,取出其金额进行加法运算,最后又重新创建一个新的货币对象。如果 Money 类有 add 方法,可以减少这种抽象泄漏。可以通过以下测试来驱动 Money.add 方法的实现:

testAddTwoMoneysInSameCurrency() { 
  let fiveKalganid = new Money(5, "Kalganid");
  let tenKalganid = new Money(10, "Kalganid");
  let fifteenKalganid = new Money(15, "Kalganid");
  assert.deepStrictEqual(fiveKalganid.add(tenKalganid), fifteenKalganid);
  assert.deepStrictEqual(tenKalganid.add(fiveKalganid), fifteenKalganid);
}

testAddTwoMoneysInDifferentCurrencies() { 
  let euro = new Money(1, "EUR");
  let dollar = new Money(1, "USD");
  assert.throws(function() {euro.add(dollar);},
    new Error("Cannot add USD to EUR"));
  assert.throws(function() {dollar.add(euro);},
    new Error("Cannot add EUR to USD"));
}

实现 Money.add 方法后, Portfolio.evaluate 方法可以重构为:

evaluate(bank, currency) {
    let failures = [];
    let total = this.moneys.reduce( (sum, money) => {
        try {
            let convertedMoney = bank.convert(money, currency);
            return sum.add(convertedMoney); 
        }
        catch (error) {
            failures.push(error.message);
            return sum;
        }
      }, new Money(0, currency)); 
    if (!failures.length) {
        return total; 
    }
    throw new Error("Missing exchange rate(s):[" + failures.join() + "]");
}

是否移除抽象泄漏的成本是否值得增加 Money 类的 add 方法和相关测试,并没有明确的答案,这反映了“适合目的”概念中固有的主观性。

4.4 开发过程回顾

最初,所有源代码都在一个文件中,在第 4 章引入了关注点分离,在第 6 章将代码划分为模块,在第 11 章引入了 Bank 类。如果重新解决这个问题,我们可以更早地将测试与生产代码分离,这样可以使依赖关系更明确,促进更好的封装。

5. Python 代码分析

5.1 代码复杂度测量

Python 生态系统中可以使用 Flake8 工具来测量代码复杂度。通过 python3 -m pip install flake8 命令可以安装该工具。运行 flake8 可以扫描 Python 源文件中的所有违规和警告信息。使用 flake8 --select=C 可以只显示 mccabe 模块检测到的圈复杂度违规信息。默认复杂度阈值为 10,为了得到输出,需要设置更低的复杂度阈值。运行 flake8 --max-complexity=1 --select=C 后,得到以下圈复杂度超过 1 的方法:
| 方法 | 圈复杂度 | 文件位置 |
| — | — | — |
| Bank.convert | 3 | ./bank.py:12:5 |
| Portfolio.evaluate | 5 | ./portfolio.py:12:5 |
| test_money.py 中的某函数 | 2 | ./test_money.py:75:1 |

Portfolio.evaluate Bank.convert 方法的复杂度最高,但都在 McCabe 建议的启发式阈值 10 以内,这验证了测试驱动开发能产生复杂度较低的代码这一主张。

5.2 代码可读性改进

Portfolio.evaluate 方法中,通过检查 failures 列表的长度是否为 0 来判断是否有错误:

if len(failures) == 0: 
    return Money(total, currency)

在 Python 中,空字符串的布尔值为 False ,因此可以简化为:

if not failures: 
    return Money(total, currency)

使用语言习惯用法可以简化代码,即使不降低圈复杂度指标,也能使代码更符合语言规范,遵循最小惊讶原则。

5.3 代码目的与改进

Python 代码中的三个主要类都能很好地完成各自的任务,但 Portfolio.evaluate 方法存在抽象泄漏问题,该方法过于关注 Money 类的内部实现。根据迪米特法则(“不要和陌生人说话”),可以通过直接对 Money 对象进行加法操作来减少这种抽象泄漏。可以通过重写 __add__ 方法来实现:

def testAddMoneysDirectly(self):
  self.assertEqual(Money(15, "USD"), Money(5, "USD") + Money(10, "USD"))
  self.assertEqual(Money(15, "USD"), Money(10, "USD") + Money(5, "USD"))
  self.assertEqual(None, Money(5, "USD") + Money(10, "EUR"))
  self.assertEqual(None, Money(5, "USD") + None)

def __add__(self, a):
  if a is not None and self.currency == a.currency:
      return Money(self.amount + a.amount, self.currency)
  else:
      return None

为了进一步简化代码,可以重新设计 Bank.convert 方法,使其返回两个值:一个 Money 对象和一个缺失汇率的键。当汇率存在时,返回有效的 Money 对象和 None ;当汇率未定义时,返回 None 和缺失的汇率键。可以使用以下重构后的测试来验证:

def testConversionWithDifferentRatesBetweenTwoCurrencies(self):
    tenEuros = Money(10, "EUR")
    result, missingKey = self.bank.convert(tenEuros, "USD")
    self.assertEqual(result, Money(12, "USD"))
    self.assertIsNone(missingKey)
    self.bank.addExchangeRate("EUR", "USD", 1.3)
    result, missingKey = self.bank.convert(tenEuros, "USD")
    self.assertEqual(result, Money(13, "USD")) 
    self.assertIsNone(missingKey) 

def testConversionWithMissingExchangeRate(self):
    bank = Bank()
    tenEuros = Money(10, "EUR")
    result, missingKey = self.bank.convert(tenEuros, "Kalganid")
    self.assertIsNone(result) 
    self.assertEqual(missingKey, "EUR->Kalganid") 

实现上述设计后,可以重构 Portfolio.evaluate 方法:

def evaluate(self, bank, currency):
    total = Money(0, currency)
    failures = ""
    for m in self.moneys:
        c, k = bank.convert(m, currency)
        if k is None:
            total += c
        else:
            failures += k if not failures else "," + k
    if not failures:
        return total
    raise Exception("Missing exchange rate(s):[" + failures + "]")

重构后的 Portfolio.evaluate 方法更短、更优雅,圈复杂度也更低。

5.4 开发过程回顾

最初,测试和生产代码都在一个文件中,在第 7 章将代码分离到模块中,此时有了 Money Portfolio 和测试类,在第 11 章引入了 Bank 类。开发功能的顺序会影响最终代码的形态,例如在第 3 章引入并在第 10 章移除了 Portfolio.evaluate 方法中的 lambda 表达式。如果重新设计代码,可以尝试重新引入 lambda 表达式带来的简洁性。

综上所述,通过对不同语言代码的分析,我们可以从多个维度评估代码质量,包括复杂度、耦合度、内聚性等。同时,开发过程也会对代码产生重要影响,合理的开发过程可以提高代码的可维护性和可扩展性。在处理数值异常情况时,需要通过测试驱动来完善代码,确保代码的健壮性。

6. 代码分析总结与对比

6.1 不同语言代码复杂度对比

语言 工具 高复杂度方法 最高复杂度
Go gocyclo stocks (Portfolio).Evaluate 5
JavaScript JSHint bank.js 中的某函数 3
Python Flake8 Portfolio.evaluate 5

从复杂度数据来看,不同语言的代码在复杂度分布上有一定差异,但都在可接受的范围内,这也验证了测试驱动开发有助于降低代码复杂度的观点。

6.2 不同语言代码耦合度对比

  • Go Portfolio 依赖 Money Bank Bank 依赖 Money ,测试类依赖三者,可通过分离测试类降低耦合。
  • JavaScript Portfolio Bank 依赖 Money Bank Portfolio 有接口依赖,可利用接口依赖进行依赖注入。
  • Python :未明确提及耦合度相关的特殊情况,但从代码结构来看,各模块之间的依赖关系相对清晰。

总体而言,三种语言的代码耦合度都较低,不过在处理依赖关系和降低耦合的方式上有所不同。

6.3 不同语言代码目的与改进对比

  • Go Money 类缺少 Add 方法,可通过添加该方法简化 Portfolio.Evaluate 方法。
  • JavaScript Portfolio.evaluate 方法存在抽象泄漏问题,可通过添加 Money.add 方法解决。
  • Python Portfolio.evaluate 方法存在抽象泄漏,可通过重写 __add__ 方法和重新设计 Bank.convert 方法改进。

三种语言都存在类似的抽象泄漏或方法缺失问题,并且都可以通过添加相关方法来解决,这反映了代码设计中一些共性的问题和改进思路。

6.4 不同语言开发过程对比

  • Go :代码文件分离过程逐步进行,可思考更早分离或后期分离的可能性。
  • JavaScript :从单文件到模块划分,可更早分离测试与生产代码。
  • Python :测试和生产代码从单文件到模块分离,开发顺序影响了代码形态,可尝试重新引入 lambda 表达式。

不同语言的开发过程都经历了从简单到复杂、从单文件到模块化的过程,但在具体的改进方向上有所不同。

7. 代码优化建议与最佳实践

7.1 复杂度优化

  • 提取方法 :对于圈复杂度较高的方法,如 Portfolio.Evaluate (Go、Python),可以将一些功能提取到新的方法中,降低原方法的复杂度。
  • 使用语言特性 :在 Python 中,可以利用语言的特性简化代码,如使用 if not failures 替代 if len(failures) == 0 ,提高代码的可读性。

7.2 耦合度优化

  • 依赖注入 :在 JavaScript 中,利用接口依赖进行依赖注入,使用测试替身进行测试,提高代码的可测试性和可维护性。
  • 分离测试类 :在 Go 中,将测试分离到不同的类中,降低测试类与生产代码的耦合度。

7.3 目的与功能优化

  • 添加必要方法 :在 Go、JavaScript 和 Python 中,为 Money 类添加 Add 方法或重写相关方法,解决抽象泄漏问题,提高代码的内聚性。
  • 重新设计方法返回值 :在 Python 中,重新设计 Bank.convert 方法的返回值,使代码更简洁、优雅。

7.4 开发过程优化

  • 早期分离测试与生产代码 :在 JavaScript 和 Python 开发中,尽早将测试与生产代码分离,明确依赖关系,促进更好的封装。
  • 合理安排开发顺序 :在开发过程中,合理安排功能开发的顺序,避免后期频繁修改代码。

8. 代码优化流程

graph LR
    A[代码复杂度分析] --> B{复杂度是否过高}
    B -- 是 --> C[提取方法重构]
    B -- 否 --> D[代码耦合度分析]
    C --> D
    D --> E{耦合度是否过高}
    E -- 是 --> F[使用依赖注入或分离测试类]
    E -- 否 --> G[代码目的与功能分析]
    F --> G
    G --> H{是否存在抽象泄漏或方法缺失}
    H -- 是 --> I[添加必要方法或重新设计方法]
    H -- 否 --> J[开发过程回顾]
    I --> J
    J --> K{开发过程是否合理}
    K -- 是 --> L[代码完成]
    K -- 否 --> M[调整开发顺序或分离代码]
    M --> A

这个流程图展示了一个完整的代码优化流程,从复杂度分析开始,逐步检查耦合度、代码目的和开发过程,根据不同的情况进行相应的优化,直到代码达到满意的状态。

9. 总结

通过对 Go、JavaScript 和 Python 三种语言代码的详细分析,我们深入了解了代码在复杂度、耦合度、目的和开发过程等方面的特点和问题。在代码开发过程中,我们应该注重以下几点:
- 采用测试驱动开发,确保代码的健壮性和可维护性。
- 合理评估代码复杂度,通过提取方法等方式降低复杂度。
- 降低代码耦合度,利用依赖注入和分离测试类等方法提高代码的灵活性。
- 明确代码的目的,及时解决抽象泄漏和方法缺失等问题,提高代码的内聚性。
- 优化开发过程,合理安排开发顺序,早期分离测试与生产代码,促进更好的封装。

通过遵循这些原则和方法,我们可以开发出高质量、易维护的代码。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值