编写软件是复杂性和简洁性之间的战争。在两者之间取得平衡很困难。权衡在于冗长的难以维护的方法和过度抽象之间。向任何一个方向倾斜太远都会损害代码可读性并增加缺陷的可能性。
缺陷是否可以避免?NASA 尝试过,但他们也进行了大量的测试。他们的软件从字面上讲是任务关键的——一次性的事情。对于大多数组织来说,情况并非如此,大量的测试成本高且不切实际。虽然没有什么能替代测试,但可以编写抗缺陷代码,而无需测试。
在20年的编码和应用架构工作中,我确定了四个减少缺陷的实践。前两个实践限制缺陷的引入,后两个实践暴露缺陷。每个实践本身都是一个广泛的主题,许多书籍都对其进行了阐述。我将每个实践浓缩为几段,并在可能的情况下提供了额外信息的链接。
1. 编写简单代码
简单应该很容易,但事实并非如此。编写简单代码很难。
有些人读到这里会认为这意味着使用简单的语言特性,但事实并非如此——简单代码不是愚蠢的代码。
为了保持客观性,我使用圈复杂度作为衡量标准。还有其他方法来衡量复杂性和其他类型的复杂性,我希望在后续文章中探讨这些主题。
Microsoft 将圈复杂度定义为:
圈复杂度衡量通过方法的线性独立路径数,这由条件分支的数量和复杂性决定。低圈复杂度通常表示一个易于理解、测试和维护的方法。
什么是低圈复杂度?Microsoft 建议保持圈复杂度低于25。
老实说,我发现 Microsoft 推荐的圈复杂度25太高了。对于可维护性和复杂性,我发现理想的方法大小在1到10行之间,圈复杂度在1到5之间。
Bill Wagner 在《Effective C#,第二版》中写到方法大小:
请记住,将 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的著作《代码大全》中,Steve 提供了许多关于代码审查好处的案例研究:
- AT&T 一个拥有200多人的组织的研究报告称,在该组织引入审查后,生产力提高了14%,缺陷减少了90%。
- 安泰保险公司通过使用检查发现了程序中82%的错误,并能够将其开发资源减少20%。
- 在由同一组人开发的11个程序中,前5个是在没有审查的情况下开发的。其余6个是通过审查开发的。所有程序发布到生产后,前5个平均每100行代码有4.5个错误。经过检查的6个平均每100行代码只有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
//Using FakeItEasy mocking framework
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
//An exception is thrown when this is not called.
A.CallTo(() => service.SendTemplate(A<Agency>.Ignore, A<User>.Ignore)).MustHaveHappened();
}
总结
编写抗缺陷代码出人意料地容易。别误会我,你永远不会编写无缺陷的代码(如果你想出办法,请告诉我!),但通过遵循本文中概述的4个实践,你会看到代码中发现的缺陷减少。
总结一下,编写简单代码是将圈复杂度保持在5左右并保持方法大小较小。编写可测试的代码在遵循控制反转和S.O.L.I.D原则时很容易实现。代码审查帮助你和团队理解领域和你编写的代码——仅仅是解释你的代码就会暴露问题。最后,单元测试可以大大提高代码质量并为未来的开发人员提供文档。
作者:Chuck Conway 是一位 AI 工程师,拥有近 30 年的软件工程经验。他构建实用的 AI 系统——内容管道、基础设施代理和解决实际问题的工具——并分享他沿途的学习成果。在社交媒体上与他联系:X (@chuckconway) 或访问他的 YouTube 和 SubStack。