代码分析:从溢出问题到多语言代码优化
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 三种语言代码的详细分析,我们深入了解了代码在复杂度、耦合度、目的和开发过程等方面的特点和问题。在代码开发过程中,我们应该注重以下几点:
- 采用测试驱动开发,确保代码的健壮性和可维护性。
- 合理评估代码复杂度,通过提取方法等方式降低复杂度。
- 降低代码耦合度,利用依赖注入和分离测试类等方法提高代码的灵活性。
- 明确代码的目的,及时解决抽象泄漏和方法缺失等问题,提高代码的内聚性。
- 优化开发过程,合理安排开发顺序,早期分离测试与生产代码,促进更好的封装。
通过遵循这些原则和方法,我们可以开发出高质量、易维护的代码。
超级会员免费看

被折叠的 条评论
为什么被折叠?



