关闭

高效重构 C++ 代码(中)

发表于:2016-9-27 09:40

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:魔术大师    来源:51Testing软件测试网采编

#
DoNet
  如何实施重构
  稍微复杂的重构,基本都是由一系列的重构手法组成. 《重构》一书中针对各种重构场景,给出了大量的重构手法.这些手法有的复杂,有的简单,如果不加以系统化的整理和提炼,很容易迷失在细节中.
  另外,在不同场景下重构手法的使用是非常讲究其顺序的.一旦顺序不当,很容易让重构失去安全性,或者干脆让某些重构变得很难完成.
  本节是个人对重构手法的整理和提炼,帮助大家跳出细节,快速掌握重要的重构手法并且能够尽快在自己的重构实践中进行使用.随后我们整理了重构手法应用顺序的背后思想,帮助大家避免死记硬背,可以根据自己的重构场景推导出合理的重构顺序.
  基本手法
  根据2-8原则,我们平时80%的工作场景中只使用到20%的基本重构手法. 而往往复杂的重构手法,也都是由一些小的基本手法组合而成. 熟练掌握基本手法,就能够完成绝大多数重构任务. 再按照一定顺序对其加以组合,就能够完成大多数的复杂重构.
  经过对《重构》一书中的所有重构手法进行分析,结合日常工作中的使用情况,我们认为以下几类重构手法为基本手法:
  · 重命名 (rename)
  · 提炼 (extract)
  · 内联 (inline)
  · 移动 (move)
  以上每一类的命名皆是动词,其宾语可以是变量,函数,类型,对于C/C++还应该再包含文件.
  例如重命名,包含重命名变量,重命名函数,重命名类,以及重命名文件,它们皆为基本重构手法,都属于重命名这一类.
  其它所有的重构手法大多数都是上述基本手法的简单变异,或者干脆由一系列基本手法组成.
  例如常用的Self Encapsulate Field(自封装字段),本质上就是简化版的Extract Method.
  再例如稍微复杂的Replace Condition with Polymorphism(以多态取代条件表达式),就是由Extract Method 和 Move Method组成的.
  所以我们学习重构手法,只要能够熟练掌握上面四类基本手法,就可以满足日常绝大多数重构场景.通过对基本重构手法的组合,我们就能完成复杂的重构场景.
  原子步骤
  在我们提炼出了上述四类基本手法后,我们还是想问,既然重构手法都是代码等价变化操作,它们背后是否存在哪些共性的东西? 因为即使是四类基本手法,展开后也包含了不少手法,而且要去死记硬背每种手法的具体操作过程,也是相当恼人的.
  事实上每种重构手法为了保证代码的等价变化,必须是安全且小步的,其背后的操作步骤都是相似的.我们对组成每种基本重构手法的步骤加以整理和提炼,形成一些原子步骤.一项基本重构手法是由原子步骤组成的.每一个原子步骤实施之后我们保证代码的功能等价性.
  我们可以认为,基本上重构手法都是由以下两个有序的原子步骤组成:
  1、setup
  · 根据需要创建一个新的代码元素. 例如:变量,函数,类,或者文件
  · 新创建的代码元素需要有好的名称,更合适的位置和访问性.更好体现出设计意图
  · 新的代码元素的实现,可以copy原有代码,在copy过来的代码基础之上进行修改.(注意是copy)
  · 这一原子步骤的操作过程中,不会对原有代码元素进行任何修改.
  · 这一过程的安全性只需要编译的保证
  2、substitute
  · 将原子步骤1中新创建的代码元素替换回原有代码
  · 这一过程需要搜索待替换元素在老代码中的所有引用点
  · 对引用点进行逐一替换; 一些场景下为了方便替换,需要先创建引用”锚点”
  · 这一过程是一个修改源代码的过程,所以每一次替换之后,都应该由测试来保证安全性
  原子步骤1,2的交替进行,可以完成一项基本重构或者复杂重构. 在这里1和2可以称之为原子步骤,除了因为大多数的重构手法可以拆解成这两个原子步骤.更是因为每个原子步骤也是一项代码的等价变换(只是层次更低),严苛条件下我们可以按照原子步骤的粒度进行代码的提交或者回滚.然而我们之所以不把原子步骤叫做手法,是因为原子步骤的单独完成往往不能独立达成一项重构目标. 灵活掌握了原子步骤的应用,我们除了不用死记硬背每种重构手法背后的繁琐步骤,更可以使自己的重构过程更安全和小步,做到更小粒度的提交和回滚,快速恢复代码到可用状态.
  以下是两个应用原子步骤完成基本重构手法的例子:
  重命名变量(Rename Variable)
  unsigned int start = Date::getTime();
  // load program ...
  unsigned int offset = Date::getTime() - start;
  cout << "Load time was: " << offset/1000 << "seconds" << endl;
  在上面的示例代码中,变量offset的含义太过宽泛,我们将其重命名为elapsed,第一步我们执行原子步骤setup,创建一个新的变量eclapsed,并且将其初始化为offset.在这里为了更好的体现设计意图,我们将其定义为const.
  unsigned int start = Date::getTime();
  // load program ...
  unsigned int offset = Date::getTime() - start;
  const unsigned int elapsed = offset;
  cout << "Load time was: " << offset/1000 << "seconds" << endl;
  经过这一步,我们完成了原子步骤1. 在这个过程中,我们只是增加了新的代码元素,并没有修改原有代码.新增加的代码元素体现了更好的设计意图. 最后我们编译现有代码,保证这一过程的安全性.
  接下来我们进行原子步骤substitute,首先找到待替换代码元素的所有引用点.对于我们的例子就是所有使用变量offset的地方.对于每个引用点逐一进行替换和测试.
  unsigned int start = Date::getTime();
  // load program ...
  unsigned int offset = Date::getTime() - start;
  const unsigned int elapsed = offset;
  cout << "Load time was: " << elapsed/1000 << "seconds" << endl;
  最后别忘了变量定义之处的替换:
  unsigned int start = Date::getTime();
  // load program ...
  const unsigned int elapsed = Date::getTime() - start;
  cout << "Load time was: " << elapsed/1000 << "seconds" << endl;
  每一次替换之后都需要运行测试,保证对源代码修改的安全性.
  在上述例子中,对于变量start和elapsed可以有更好的命名,这两个变量最好能够体现其代表时间的单位,例如可以叫做 startMs以及elapsedMs,大家可以自行练习替换. 另外程序中存在魔术数字1000,可以自行尝试用原子步骤进行extract variable重构手法,完成用变量对1000的替换.
  提炼函数 (Extract Method)
  void printAmount(const Items& items)
  {
  int sum = 0;
  for(auto item : items)
  {
  sum += item.getValue();
  }
  cout << "The amount of items is " << sum << endl;
  }
  上述函数完成了两件事,首先统计一个items的集合的所有元素value的总和,然后对总和进行打印.
  为了把统计和打印职责分开,我们提炼一个函数calcAmount用来专门对一个给定的Items集合求总和.为了完成Extract Method重构手法,我们首先使用原子步骤setup.
  首先建立calcAmount函数的原型,
  int calcAmount(const Items& items)
  {
  return 0;
  }
  void printAmount(const Items& items)
  {
  int sum = 0;
  for(auto item : items)
  {
  sum += item.getValue();
  }
  cout << "The amount of items is " << sum << endl;
  }
  接下来完成calcAmount函数的实现.这一步需要将源函数中相关部分copy到calcAmount中并稍加修改.切记由于原子步骤1中不能修改源代码,所以这里千万不要用剪切,否则一旦重构出错,是很难快速将代码回滚到正确的状态的,这点新手尤其需要注意!
  int calcAmount(const Items& items)
  {
  int sum = 0;
  for(auto item : items)
  {
  sum += item.getValue();
  }
  return sum;
  }
  void printAmount(const Items& items)
  {
  int sum = 0;
  for(auto item : items)
  {
  sum += item.getValue();
  }
  cout << "The amount of items is " << sum << endl;
  }
  到目前为止,原子步骤1就已經OK了,我们运行编译,保证新增加的代码元素是可用的.
  接下来我们进行原子步骤substitute.将新函数calcAmount替换到每一个对Items计算总量的地方.对于我们的例子,只有一个地方就是printAmount函数(相信对于真实代码,这类对Items求总量的计算会到处都是,写法各异).
  int calcAmount(const Items& items)
  {
  int sum = 0;
  for(auto item : items)
  {
  sum += item.getValue();
  }
  return sum;
  }
  void printAmount(const Items& items)
  {
  cout << "The amount of items is " << calcAmount(items) << endl;
  }
  替换之后运行测试.到目前为止我们的Extract Method已经完成了.
  如果更进一步,我们发现可以运用基本重构手法Move Method将calcAmount函数移入到Items类中,然后使用Rename Method手法将其重命名为getAmount会更好. 对于Move Method和Rename Method大家可以发现,它们都是由我们总结的原子步骤组成. 例如Move Mehod,我们首先应用原子步骤setup,在Items类中创建public成员方法calcAmount,然后将函数的具体实现copy过去修改好,保证编译OK. 接下来应用原子步骤substitute,用新创建的Items成员函数替换老的CalcAmount,测试OK后,我们就完成了Move Method重构.
  重构的最终效果如下,大家可自行练习.
  struct Items
  {
  int getAmount() const;
  ...
  };
  void printAmount(const Items& items)
  {
  cout << "The amount of items is " << items.getAmount() << endl;
  }
  在这里我们没有将printAmount也移入到Items中,是因为getAmount作为Items的接口是稳定的.但是如何打印往往是表示层关注的,各种场景下打印格式各异,所以没有将其移入Items中.
  通过上面的示例,我们演示了如何用原子步骤组合出基本的重构手法. 实际上,对于所有的rename和普通的extract重构,一般的C++ IDE都提供了直接的自动化重构快捷键供我们使用,平时开发直接使用重构快捷键即高效又安全.但是这并不影响我们掌握原子步骤的使用.由于C++语言的复杂性,大多数重构手法都是没有自动化重构快捷键支持的,即便有重构快捷键支持,一旦上下文稍微复杂一点(例如对有很多临时变量的函数执行Extract Method),自动化重构的结果也往往不能让人满意. 这里不仅对C++语言,对于一些动态类型语言(例如ruby),自动化重构更是匮乏.所以我们要掌握重构手法背后的思想,熟练掌握原子步骤,学会安全高效地手动重构.
  这里总结的原子步骤是非常适普的! 不仅我们列举出的基本重构手法都是由原子步骤组成.对于许多复杂的重构手法,除了会直接使用基本重构手法,甚至也会直接使用原子步骤.
  例如对于Martin描述的手法”Replace Type Code with Class(以类取代类型码)”,里面基本是在反复使用原子步骤,我们摘录原书中的操作描述:
  · 为类型码建立一个类
  · 修改源类的实现,让它使用新建的类
  · 编译,测试
  · 对于源类中每一个使用类型码的函数,相应建立一个函数,让新函数使用新建的类
  · 逐一修改源类用户,让它们使用新接口
  · 每修改一个用户,编译并测试
  · 删除使用类型码的旧接口,并删除保存旧类型码的静态变量
  · 编译,测试
  对于该重构,其中步骤1是原子步骤setup,步骤2是原子步骤substitute(第3步是对第2步的编译和测试,对第1步的编译过程作者省略了). 然后第4步又是setup,第5步开始执行substitute. 每一次按照原子步骤的要求进行执行,都可以保证重构是安全的,甚至严苛条件下,代码可以按照每一次原子步骤进行提交或者回滚. 另外对于每一种重构手法的操作描述,如果把它们统一成对原子步骤的组合的描述,也会极大的方便记忆. 掌握了原子步骤,即使忘记了某一项重构手法的具体操作,也可以方便的自行推导出来.
  锚点的使用
  在前面介绍原子步骤substitute的时候提到,为了方便替换,可以使用引用锚点.
  对于重构,最容易出错的地方就在替换.可以借助IDE帮助自动搜索到对旧码元素的所有的引用点.但是搜索的质量往往和IDE以及语言特性相关.例如对于C++宏内代码元素的搜索,IDE就很难搜索准确. 另外对于引用点很多的情况,逐一替换/测试也是相当累人的.
  所谓锚点,就是先增加一个中间层,把所有对旧代码元素的引用汇聚到一点,编译测试过后,然后在这一个点完成新旧代码元素的统一替换.完成替换后,可以保留锚点,或者用inline重构手法再去掉锚点.
  有两类手法经常被用来创建锚点,Encapsulate field(自封装字段)和Replace Constructor with Factory Method(以工厂函数取代构造函数)。
  Encapsulate field一般在修改类的某一成员字段的实现方式的重构场景下使用。例如:Move Field(在类间搬移字段)和Replace Type Code with Subclasses(以子类取代类型码)。Encapsulate field对于欲修改字段创建引用锚点,将类内部对该成员字段的使用汇聚到一起,方便对字段的实现方式进行替换。它的操作方式也是由我们前面介绍的两个原子步骤组成:
  Setup:在类内创建两个方法分别对于要修改字段的读取和设值(就是面向对象初学者最爱写的get和set成员函数)。编译。
  Substitute:将类内所有直接使用字段的地方替换为调用对应的函数. 读取的地方替换为调用get,设值的地方替换为调用set。执行测试!
  当执行完Encapsulate field后,类内再无对欲修改字段的直接使用了,这时就可以方便地在get和set方法内对于字段的实现方式进行修改了。
  例如对于”Move Field(在类间搬移字段)”重构操作,首先就需要在源类内使用Encapsulate field对需要搬移的字段创建引用锚点. 当执行完Encapsulate field后,源类内再无对欲搬移字段的直接使用了,这个时候再在目标类中创建对应字段,将源类内get和set方法内对于自身字段的使用修改为使用目标类中的字段. 测试通过后,再删除源类内的搬移字段. 最后对于源类内提取出来的get和set方法可以再inline回去.
  可以看到锚点将引用点汇聚到一处,每个客户都调用一个中间层,不再面对具体的待替换代码细节. 锚点的使用简化了替换过程,并且可以让替换更加安全.
  在某些场合下使用锚点还有更重要的意义,一些重构手法必须借助锚点才能完成,尤其是对一些需要子类化的重构! 例如对于 Replace Constructor with Factory Method(以工厂函数取代构造函数),在该重构手法里面,工厂函数就是锚点,它将类的创建汇聚到工厂函数里面,对客户代码隐藏了类的构造细节,后面如果进行某些子类化的重构就非常容易实施.
  下面我们以一个例子作为对原子步骤和锚点的总结。
// Shoes.h
enum Type
{
REGULAR,
NEW_STYLE,
LIMITED_EDITION
};
struct Shoes
{
Shoes(Type type, double price);
double getCharge(int quantity) const;
private:
Type type;
double price;
};
// Shoes.cpp
#include "Shoes.h"
Shoes::Shoes(Type type, double price)
: type(type), price(price)
{
}
double Shoes::getCharge(int quantity) const
{
double result = 0;
switch(type)
{
case REGULAR:
result += price * quantity;
break;
case NEW_STYLE:
if(quantity > 1)
{
result += (price + (quantity - 1) * price * 0.8);
}
else
{
result += price;
}
break;
case LIMITED_EDITION:
result += price * quantity * 1.1;
break;
}
return result;
}
  以上代码中有一个Shoes类,它的type字段指明一个Shoes对象的具体类型:REGULAR、NEW_STYLE或者LIMITED_EDITION。Shoes类的接口getCharge根据传入的数量quantity来计算总费用。getCharge根据不同类型按照不同方法进行计算。对于普通款(REGULAR),总价等于单价乘以数量;对于新款(NEW_STYLE),从第二双开始打八折;对于限量版(LIMITED_EDITION),每一双需要多收10%的费用。
31/3123>
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号