
编写软件是复杂性和简单性之间的较量。在两者之间取得平衡是困难的。权衡在于冗长的不可维护方法和过度抽象之间。过度倾向于任何一个方向都会损害代码可读性并增加缺陷的可能性。
缺陷是可以避免的吗?NASA在尝试,但他们也进行了大量的测试。他们的软件确实是任务关键型的——一次性交易。对于大多数组织来说,情况并非如此,大量测试既昂贵又不实用。虽然测试无可替代,但在不进行测试的情况下,编写抗缺陷代码是可能的。
在20年的编码和架构应用程序经验中,我总结了减少缺陷的四个实践。前两个实践限制缺陷的引入,后两个实践暴露缺陷。每个实践本身都是一个广泛的主题,已经有很多书籍专门讨论。我将每个实践浓缩为几段文字,并在可能的情况下提供了额外信息的链接。
1. 编写简单代码
简单应该是容易的,但事实并非如此。编写简单代码是困难的。
有些人读到这里会认为这意味着使用简单的语言特性,但事实并非如此——简单代码不是愚蠢代码。
为了保持客观,我使用圈复杂度作为衡量标准。还有其他方法来衡量复杂性和其他类型的复杂性,我希望在以后的文章中探讨这些主题。
Microsoft将圈复杂度定义为:
圈复杂度衡量通过方法的线性无关路径的数量,这由条件分支的数量和复杂性决定。低圈复杂度通常表示方法易于理解、测试和维护。
什么是低圈复杂度?Microsoft建议将圈复杂度保持在25以下。
说实话,我发现Microsoft推荐的圈复杂度25太高了。对于可维护性和复杂性,我发现理想的方法大小在1到10行之间,圈复杂度在1到5之间。
Bill Wagner在Effective C#, Second Edition中写道关于方法大小:
记住,将C#代码转换为机器可执行代码是一个两步过程。C#编译器生成在程序集中交付的IL。JIT编译器根据需要为每个方法(或涉及内联时的方法组)生成机器代码。小函数使JIT编译器更容易分摊这个成本。小函数也更可能成为内联的候选者。这不仅仅是小巧:更简单的控制流同样重要。函数内部较少的控制分支使JIT编译器更容易将变量注册化。这不仅仅是编写更清晰代码的良好实践;这是在运行时创建更高效代码的方法。
为了让圈复杂度有个概念,以下方法的圈复杂度为12。
public string ComplexityOf12(int status)
{
var isTrue = true;
var myString = "Chuck";
if (isTrue)
{
if (isTrue)
{
myString = string.Empty;
isTrue = false;
for (var index = 0; index < 10; index++)
{
isTrue |= Convert.ToBoolean(new Random().Next());
}
if (status == 1 || status == 3)
{
switch (status)
{
case 3:
return "Bye";
case 1:
if (status % 1 == 0)
{
myString = "Super";
}
break;
}
return myString;
}
}
}
if (!isTrue)
{
myString = "New";
}
switch (status)
{
case 300:
myString = "3001";
break;
case 400:
myString = "4003";
break;
}
return myString;
}
一个普遍接受的复杂性假设假定复杂性和缺陷之间存在正相关关系。
前面的句子有点复杂。用最简单的话说——保持代码简单可以降低缺陷率。
2. 编写可测试代码
研究表明,编写可测试代码,即使不编写实际测试,也会降低缺陷发生率。这是如此重要和深刻,需要重复:编写可测试代码,即使不编写实际测试,也会降低缺陷发生率。
这引出了一个问题,什么是可测试代码?
我将可测试代码定义为可以独立测试的代码。这意味着所有依赖项都可以从测试中模拟。依赖项的一个例子是数据库查询。在测试中,数据被模拟(伪造),并对预期行为进行断言。如果断言为真,测试通过,否则失败。
编写可测试代码可能听起来很难,但实际上,当遵循控制反转(依赖注入)和S.O.L.I.D原则时,这是容易的。你会惊讶于其简易性,并会想知道为什么花了这么长时间才开始以这种方式编写。
3. 代码审查
开发团队可以采用的最有影响力的实践之一是代码审查。
代码审查促进了开发人员之间的知识共享。根据经验,与其他开发人员公开讨论代码对我的代码编写技能产生了最大的影响。
在Steve McConnell的书Code Complete中,Steve提供了关于代码审查好处的众多案例研究:
- 对AT&T一个拥有200多人的组织的研究报告显示,在该组织引入审查后,生产力提高了14%,缺陷减少了90%。
- Aetna保险公司通过使用检查发现了程序中82%的错误,并能够将其开发资源减少20%。
- 在同一组人开发的11个程序组中,前5个是在没有审查的情况下开发的。其余6个是在审查的情况下开发的。在所有程序发布到生产环境后,前5个平均每100行代码有4.5个错误。经过检查的6个平均只有0.82个错误。审查将错误减少了80%以上。
如果这些数字不能说服你采用代码审查,那么你注定要漂向黑洞,同时唱着Johnny Paycheck的Take This Job and Shove It。
4. 单元测试
我承认,当我面临截止日期时,测试是第一个被放弃的。但测试的好处不容否认,如以下研究所示。
Microsoft对单元测试的有效性进行了研究。他们发现,使用自动化测试编码版本2(版本1没有测试)立即将缺陷减少了20%,但代价是额外增加30%的成本。
另一项研究关注测试驱动开发(TDD)。他们观察到代码质量的提高,与不使用TDD的类似项目相比,提高了两倍多。TDD项目平均开发时间长15%。TDD的副作用是测试为库和API提供了文档。
最后,在一项关于测试覆盖率和验证后缺陷的研究中:
…我们发现在两个项目中,当调整预发布更改数量时,测试覆盖率的增加与现场报告问题的减少相关…
一个例子
以下代码的圈复杂度为4。
public void SendUserHadJoinedEmailToAdministrator(DataAccess.Database.Schema.dbo.Agency agency, User savedUser)
{
AgencySettingsRepository agencySettingsRepository = new AgencySettingsRepository();
var agencySettings = agencySettingsRepository.GetById(agency.Id);
if (agencySettings != null)
{
var newAuthAdmin = agencySettings.NewUserAuthorizationContact;
if (newAuthAdmin.IsNotNull())
{
EmailService emailService = new EmailService();
emailService.SendTemplate(new[] { newAuthAdmin.Email }, GroverConstants.EmailTemplate.NewUserAdminNotification, s =>
{
s.Add(new EmailToken { Token = "Domain", Value = _settings.Domain });
s.Add(new EmailToken
{
Token = "Subject",
Value =
string.Format("New User {0} has joined {1} on myGrover.", savedUser.FullName(), agency.Name)
});
s.Add(new EmailToken { Token = "Name", Value = savedUser.FullName() });
return s;
});
}
}
}
让我们检查上述代码的可测试性。
这是简单代码吗?
是的,圈复杂度低于5。
有任何依赖项吗?
是的。有2个服务AgencySettingsRepository
和EmailService
。
这些服务可以模拟吗?
不,它们的创建隐藏在方法内部。
代码可测试吗?
不,这段代码不可测试,因为我们无法模拟AgencySettingsRepository
和EmailService
。
重构代码示例
我们如何使这段代码可测试?
我们注入(使用构造函数注入)AgencySettingsRepository
和EmailService
作为依赖项。这允许我们从测试中模拟它们并独立测试。
下面是重构后的版本。
注意服务是如何注入到构造函数中的。这允许我们控制传递给SendMail
构造函数的实现。然后很容易传递虚拟数据并拦截服务方法调用。
public class SendEmail
{
private IAgencySettingsRepository _agencySettingsRepository;
private IEmailService _emailService;
public SendEmail(IAgencySettingsRepository agencySettingsRepository, IEmailService emailService)
{
_agencySettingsRepository = agencySettingsRepository;
_emailService = emailService;
}
public void SendUserHadJoinedEmailToAdministrator(DataAccess.Database.Schema.dbo.Agency agency, User savedUser)
{
var agencySettings = _agencySettingsRepository.GetById(agency.Id);
if (agencySettings != null)
{
var newAuthAdmin = agencySettings.NewUserAuthorizationContact;
if (newAuthAdmin.IsNotNull())
{
_emailService.SendTemplate(new[] { newAuthAdmin.Email },
GroverConstants.EmailTemplate.NewUserAdminNotification, s =>
{
s.Add(new EmailToken { Token = "Domain", Value = _settings.Domain });
s.Add(new EmailToken
{
Token = "Subject",
Value = string.Format("New User {0} has joined {1} on myGrover.", savedUser.FullName(), agency.Name)
});
s.Add(new EmailToken { Token = "Name", Value = savedUser.FullName() });
return s;
});
}
}
}
}
测试示例
下面是独立测试的示例。我们使用模拟框架FakeItEasy。
[Test]
public void TestEmailService()
{
//Given
//使用FakeItEasy模拟框架
var repository = A<IAgencySettingsRepository>.Fake();
var service = A<IEmailService>.Fake();
var agency = new Agency { Name = "Acme Inc." };
var user = new User { FirstName = "Chuck", LastName = "Conway", Email = "chuck.conway@fakedomain.com" }
//When
var sendEmail = new SendEmail(repository, service);
sendEmail.SendUserHadJoinedEmailToAdministrator(agency, user);
//Then
//当这个没有被调用时会抛出异常。
A.CallTo(() => service.SendTemplate(A<Agency>.Ignore, A<User>.Ignore)).MustHaveHappened();
}
结语
编写抗缺陷代码出奇地容易。不要误解我的意思,你永远不会编写无缺陷的代码(如果你找到了方法,请告诉我!),但通过遵循本文概述的4个实践,你会看到代码中发现的缺陷减少。
总结一下,编写简单代码是将圈复杂度保持在5左右,方法大小要小。编写可测试代码在遵循控制反转和S.O.L.I.D原则时很容易实现。代码审查帮助你和团队理解领域和你编写的代码——仅仅需要解释你的代码就会暴露问题。最后,单元测试可以大幅提高代码质量,并为未来的开发人员提供文档。
作者:Chuck Conway 专注于软件工程和生成式人工智能。在社交媒体上与他联系:X (@chuckconway) 或访问他的 YouTube。