不写重复代码
不要复制代码
编写可重用的、通用的代码,调用已有的代码
如果复制代码,就需要在多个地方修复BUG。
举例
一个管理银行账户的系统,通过Transfer类对象来表示钱在不同账户之间的流转过程。
CheckingAccount表示银行提供的支票账户 ,如下
public class CheckingAccount
{
private int transferLimit = 100;
public Transfer MakeTransfer(string counterAccount, Money amount)
{
//1 检查提款限额
if (amoiunt.GreaterThan(this.transferLimit))
{
throw new BusinessException("Limit exceeded!");
}
//2 假设结果是一个9位数的银行账户号码,使用11-test进行验证
int sum = 0;
for (int i = 0; i < counterAccount.Length; i++)
{
sum += (9 - i) * (int)char.GetNumericValue(counterAccount[i]);
}
if (sum % 11 == 0)
{
//3 查找对方账户并创建transfer对象
CheckingAccount acct = Accounts.FindAcctByNumber(counterAccount);
Transfer result = new Transfer(this, acct, amount);
return result;
}
else
{
throw new BusinessException("Invalid account number!");
}
}
}
根据对方的账户号码,MakeTransfer方法会创建一个Transfer对象,首先检查转账金额有没有超过指定限额,然后检查对方账户号码是否有效,然后就获取到表示该账户的对象,然后创建一个Transfer对象。
如果银行新增了一种账户类型,比如储蓄账户,它没有转账限额,但是有另一个限制:钱只能转给一个指定的支票账户。为此需要创建一个新类,这里是复制CheckingAccount并重命名和一些调整
public class SavingAccount
{
CheckingAccount registeredCounterAccount;
public Transfer MakeTransfer(string counterAccount, Money amount)
{
//1 假设结果是一个9位数的银行账户号码,使用11-test进行验证
int sum = 0;
for (int i = 0; i < counterAccount.Length; i++)
{
sum += (9 - i) * (int)char.GetNumericValue(counterAccount[i]);
}
if (sum % 11 == 0)
{
//2 查找对方账户并创建transfer对象
CheckingAccount acct = Accounts.FindAcctByNumber(counterAccount);
Transfer result = new Transfer(this, acct, amount);
//3 检查提款方是否是已注册的对方账户
if (result.getCounterAccount().Equals(this.registeredCounterAccount))
{
return result;
}
else
{
throw new BusinessException("Counter-account not registered!");
}
}
else
{
throw new BusinessException("Invalid account number!");
}
}
}
假设我们在验证银行账户号码的地方有BUG,那么需要对以上2个类都进行修改,这样不仅增加了额外的工作,也让维护变得低效。
重复代码定义是,一段至少6行都相同的代码(不包括空行和注释),也称为1类克隆,与出现的位置无关。
在语法上相同的两段代码称为2类克隆,只是在空格,注释,标识符名称和字面值方面不同。原则适用于1类克隆。
这个6行代码的限制是根据SIG的经验,正好达到一个平衡点,避免识别的重复代码过多或过少,举例,一个ToString()方法可能有3或4行代码,这些代码可能出现在许多的领域对象中,这样的重复代码可以忽略。
重复代码更加难以分析:遇到问题肯定是希望去解决它,解决的一个要点是对问题的定位,当你调用一个已有方法时,你可以很容易找到源代码,而当你复制代码时,问题的根源可能还存在与其他地方。
代码重复更加难以修改:如果重复的代码中含有一个BUG,那么相同的BUG就会出现多次,常规的修改也存在同样的问题,需要修改多处。
如何使用原则
将重复的代码提取到一个方法中,对于不同类之间的重复代码,可以将提取方法放到一个工具类中,使用静态方法。
public class Accounts
{
public static bool IsValid(string number)
{
int sum = 0;
for (int i = 0; i < number.Length; i++)
{
sum += (9 - i) * (int)char.GetNumericValue(number[i]);
}
return sum % 11 == 0;
}
}
那么在CheckingAccount类变为:
public class CheckingAccount
{
private int transferLimit = 100;
public Transfer MakeTransfer(string counterAccount, Money amount)
{
//1 检查提款限额
if (amoiunt.GreaterThan(this.transferLimit))
{
throw new BusinessException("Limit exceeded!");
}
//2 假设结果是一个9位数的银行账户号码,使用11-test进行验证
if (Accounts.IsValid(counterAccount))
{
//3 查找对方账户并创建transfer对象
CheckingAccount acct = Accounts.FindAcctByNumber(counterAccount);
Transfer result = new Transfer(this, acct, amount);
return result;
}
else
{
throw new BusinessException("Invalid account number!");
}
}
}
同样,在SavingAccount类变为:
public class SavingAccount
{
CheckingAccount registeredCounterAccount;
public Transfer MakeTransfer(string counterAccount, Money amount)
{
//1 假设结果是一个9位数的银行账户号码,使用11-test进行验证
if (Accounts.IsValid(counterAccount))
{
//2 查找对方账户并创建transfer对象
CheckingAccount acct = Accounts.FindAcctByNumber(counterAccount);
Transfer result = new Transfer(this, acct, amount);
//3 检查提款方是否是已注册的对方账户
if (result.getCounterAccount().Equals(this.registeredCounterAccount))
{
return result;
}
else
{
throw new BusinessException("Counter-account not registered!");
}
}
else
{
throw new BusinessException("Invalid account number!");
}
}
}
此时重复代码已消失,但存在以下问题:
1. 重复代码已经不存在,但是两个类中仍然存在相同的逻辑。
2. 由于C#的方法必须在类中,不得不将提取出来的代码放在另一个类中,这个类很快会变成一堆无关方法的大杂烩,从而导致体积巨大并且耦合过紧,类中有太多无关的提供各种功能的方法,这样导致其他方法为了使用这个巨大的类,必须知道其实现细节,造成互相之间的紧耦合。
解决以上问题,提取父类,这个重构技巧,不只是将代码行片段提取成方法,而且提取到原类的一个新的父类中。
public class Account
{
public virtual Transfer MakeTransfer(string counterAccount, Money amount)
{
//1 假设结果是一个9位数的银行账户号码,使用11-test进行验证
int sum = 0;
for (int i = 0; i < counterAccount.Length; i++)
{
sum += (9 - i) * (int)char.GetNumericValue(counterAccount[i]);
}
if (sum % 11 == 0)
{
//2 查找对方账户并创建transfer对象
CheckingAccount acct = Accounts.FindAcctByNumber(counterAccount);
Transfer result = new Transfer(this, acct, amount);
return result;
}
else
{
throw new BusinessException("Invalid account number!");
}
}
}
这个父类包括两种特殊账户所共享的逻辑,下面是2个子类
public class CheckingAccount : Account
{
private int transferLimit = 100;
public override Transfer MakeTransfer(string counterAccount, Money amount)
{
//1 检查提款限额
if (amoiunt.GreaterThan(this.transferLimit))
{
throw new BusinessException("Limit exceeded!");
}
return base.MakeTransfer(counterAccount, amount);
}
}
public class SavingAccount : Account
{
CheckingAccount RegisteredCounterAccount { get; set; }
public override Transfer MakeTransfer(string counterAccount, Money amount)
{
Transfer result = base.MakeTransfer(counterAccount, amount);
//3 检查提款方是否是已注册的对方账户
if (result.getCounterAccount().Equals(this.RegisteredCounterAccount))
{
return result;
}
else
{
throw new BusinessException("Counter-account not registered!");
}
}
}
对于上面的父类,还可以把校验账号的部分提取方法,如下:
public class Account
{
public virtual Transfer MakeTransfer(string counterAccount, Money amount)
{
//1 假设结果是一个9位数的银行账户号码,使用11-test进行验证
if (IsValid(counterAccount))
{
//2 查找对方账户并创建transfer对象
CheckingAccount acct = Accounts.FindAcctByNumber(counterAccount);
Transfer result = new Transfer(this, acct, amount);
return result;
}
else
{
throw new BusinessException("Invalid account number!");
}
}
public static bool IsValid(string number)
{
int sum = 0;
for (int i = 0; i < number.Length; i++)
{
sum += (9 - i) * (int)char.GetNumericValue(number[i]);
}
return sum % 11 == 0;
}
}
常见反对意见:
应该允许直接从其他代码库复制代码,从其他代码库里复制代码不是问题,因为没有给当前系统带来重复代码。
如果这段代码解决了相同环境下的相同问题,复制代码看上去是能带来好处的,但是如果存在一下任何一种情况,都会遇到麻烦:
1. 另一个代码库仍在维护中,只是将代码复制过来,将无法获得后续对原有代码的改进。
2. 如果另一个代码库不再维护,那么你就是在重写这个代码库。通常只有在遇到可维护性问题或者技术更新时,才会考虑重写已有代码。如果是可维护性差,那么重写计划可能会受到复制代码的影响,因为你引入了确定难以维护的代码。如果是技术更新,可能将旧技术的局限性带到新的代码库中来,例如无法使用所需的抽象概念而导致无法有效地重用方法。
由于细微修改而导致的代码重复是不可避免的
系统经常会包含一些略微不同的通用方法,例如,有些方法只是针对不同的操作系统,不同版本或者不同用户组略有不同,但是这并不意味着重复代码是不可避免,你需要找到所有这些代码中相同的部分,然后将它移到一个通用的父类,应该努力去区分代码的不同之处,保证他们是清晰的,独立的并且是可测试的。
这些代码永远不会发生变化
现实情况大部分系统会由于多种原因发生变化,系统的功能性需求可能因为用户群体的改变、使用行为的改变、组织业务的改变而发生改变。组织可能会在所有权、职责范围、开发方式、开发流程或者法律要求方面发生改变。系统所处环境中的技术也可能改变,如操作系统、第三方库、框架或者与其他应用程序之间的接口。代码自身也可能有BUG、重构甚至界面优化而发生改变。
拷贝整个文件相当于多了一个备份,建立保留备份,Git这样的版本控制系统提供了很好的而备份机制,不要把备份文件放在代码库里面,以后会分不清哪个文件才是真正使用的。
单元测试会帮助我们发现问题,这只有当重复代码处于相同的方法中,并且单元测试能够覆盖这两段代码时才成立。如果重复代码位于其他方法中,就只能依赖代码分析程序来检查是否发生了变化。否则,只是重复代码发生了变化,单元测试不一定会提示失败。因此,不能只依赖单元测试的结果而不去寻找问题的根源。不能认为最后所有的问题都会在后续开发过程中得以解决,于是现在就可以对它们视而不见。
字符串文本的重复不可避免并且是无害的,我们经常在C#代码中看到,以字符串形式出现的长SQL查询、XML或者HTML文档,有些时候他们是完全相同的,但更多时候是其中某些部分不断重复地出现,例如,长达几百行的SQL查询,只是在排序顺序上有所不同,虽然这里重复代码都不算C#本身的逻辑,但它们依然有害,事实上解决这类重复代码的方法很简单:将字符串提取成一个方法,使用字符串追加和参数方式来处理变量。使用模板引擎,通过体积更小的、没有重复代码的多个文件来生成HTML内容。
SIG评估重复代码
评估重复代码,除了完全是import语句的重复代码以外,将所有6行以上的1类代码计算在内,然后将这些重复代码分为两个风险分类:多余重复代码和非多余重复代码。例如,代码库中出现了3次10行代码的重复,其中2个是可以去掉的,就都被认为是多余的,因此i行代码是多余重复代码,另一段重复代码被认为是非多余的,要想被评为4星,多余代码占比最多是4.6%