一、常见的代码坏味道及其重构方法
1.神秘命名(Mysterious Name)
整洁代码最重要的一环就是好的名字,好的名字要让人见名知意,所以我们要深思熟虑如何给函数、模块、变量和类命名,使它们能清晰地表明自己的功能和用法。
因此,改名可能是最常用的重构手法,包括改变函数声明(用于给函数改名)、变量改名、字段 改名等。
2. 重复的代码(Duplicated Code)
重复的代码的三种类型以及重构方法:
- 于同一个类:提炼成新方法进行调用
- 位于 不同的子类:提炼成方法放进父类
- 位于完全不相干的类:提炼出一个新的类,将重复代码放进新的类中
3.过长函数(Long Method)
与过大的类的原因类似,过长的函数违反了单一职责原则,每个方法应该精简地完成某一项任务。
因此,最常见的重构方法是找到函数中适合集中在一起的部分,将它们提炼出来形成一个新函数。比如现有方法中有用到 if() 或 for() 语句来验证用户输入或检查用户是否已登录的情况时,如果一定要做这些验证,应该创建自己的方法,由该方法去调用验证方法。
4.过长参数列(Long Parameter List)
最开始的时候我们通常会把函数所需的所有东西都以参数的形式传递进去。因为除此之外就只能选择全局数据,而全局数据很快就会变成邪恶的东西。但过长的参数列表本身也经常令人迷惑。如果可以向某个参数发起查询而获得另一个参数的值,那么就可以使用以查询取代参数(324)去掉这第二个参数。如果你发现自己正在从现有的数据结构中抽出很多数据项,就可以考虑使用保持对象完整(319)手法,直接传入原来的数据结构。如果有几项参数总是同时出现,可以用引入参数对象(140)将其合并成一个对象。如果某个参数被用作区分函数行为的标记(flag),可以使用移除标记参数(314)。
使用类可以有效地缩短参数列表。如果多个函数有同样的几个参数,引入一个类就尤为有意义。你可以使用函数组合成类(144),将这些共同的参数变成这个类的字段。如果戴上函数式编程的帽子,我们会说,这个重构过程创造了一组部分应用函数(partially applied function。
5.痴迷基本类型(Primitive Obsession)
当应用程序的所有地方都使用基本数据类型时,就会出现这种代码坏味道,重构方法是使用对象把基本类型封装起来。
6.依恋情结(Feature Envy)
当一个类的函数为了计算经常调用另一个类的一大堆的函数时就表示出现了依恋情节。将此部分出现依恋情节的代码提炼成函数放到另一个类里面
7.数据泥团(Data Clumps)
数据泥团指的是经常一起出现的数据,两个类中相同的字段、许多函数签名中相同的参数,这些总是绑在一起出现的数据应该拥有属于它们自己的类。比如示例二中每个方法的参数几乎相同,处理方式与过长参数列的处理方式相同,用Introduce Parameter Object(引入参数对象)将参数封装成对象。
8.发散式变化(Divergent Change)
如果某个类经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。
比如,如果新加入一个数据库必须修改这3个函数;新出现一种金融工具(举例说明),我必须修改这4个函数。这就是发散式变化的征兆。数据库交互和金融逻辑处理是两个不同的上下文,将它们分别搬移到各自独立的模块中,能让程序变得更好:每当要对某个上下文做修改时,我们只需要理解这个上下文,而不必操心另一个。
如果发生变化的两个方向自然地形成了先后次序(比如说,先从数据库取出数据,再对其进行金融逻辑处理),就可以用拆分阶段(154)将两者分开,两者之间通过一个清晰的数据结构进行沟通。如果两个方向之间有更多的来回调用,就应该先创建适当的模块,然后用搬移函数(198)把处理逻辑分开。如果函数内部混合了两类处理逻辑,应该先用提炼函数(106)将其分开,然后再做搬移。如果模块是以类的形式定义的,就可以用提炼类(182)来做拆分。
9.霰弹式修改(Shotgun Surgery)
霰弹式修改类似于发散式变化,但又恰恰相反。如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,你所面临的坏味道就是霰弹式修改。这种坏味道可以用Move Method(搬移函数)和Move Field(搬移值域)把相同业务的代码放进同一个类。
比如,想要创建一个新的用户规则(如“Supper-Admin”),然后你发现,为了增加这个规则还需要修改 Profile、Products 和 Employees 类中的某些方法。在这种情况下,可以考虑将这些方法放在一个单独的类中。
三、坏味道代码重构举例
1.神秘命名
我找到了大二上学期练习数据结构时做的一道题目:寻找数组中的主元(出现次数大于n/2),当时图省事就把项目名命名为findZY
,但这个命名很明显就是代码的坏味道。在leetcode上找到了这道题目的英文描述,更科学的命名为findMaj_Element
。
2.重复的代码
自己在学习Mybatis
框架时,dao
层实现类中每个方法都要先用几行代码拿到SqlSession
对象,大量重复代码如下
@Override
public Student getById(String id) {
String resource = "mybatis-config.xml";
InputStream inputStream = null;
try {
inputStream = Resources.getResourceAsStream(resource);
} catch (IOException e) {
e.printStackTrace();
}
sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
session = sqlSessionFactory.openSession();
//以上代码要在每个方法中重复出现
Student s = session.selectOne("test1.getById",id);
return s;
}
因此,要把重复的代码提取出来放到一个方法中,新建SqlSessionUtil
类,对外提供getSession
方法
static {
String resource = "mybatis-config.xml";
InputStream inputStream = null;
try {
inputStream = Resources.getResourceAsStream(resource);
} catch (IOException e) {
e.printStackTrace();
}
sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
}
private static ThreadLocal<SqlSession> t = new ThreadLocal<>();
public static SqlSession getSession(){
SqlSession session = t.get();
if(null == session){
session = sqlSessionFactory.openSession();
t.set(session);
}
return session;
}
这样,dao
层实现类中每个方法就变得简洁了
@Override
public Student getById(String id) {
SqlSession session = SqlSessionUtil.getSession();
Student s = session.selectOne("test1.getById",id);
return s;
}
3.过长函数
在大一做C语言课设——贪吃蛇时,在小蛇移动时需要判断是否撞墙,是否吃到炸弹等。当时把这些判断逻辑放在了小蛇移动函数里面。现在看来这就是代码的坏味道,一个函数里的功能太多,显得过于臃肿。
因此重构时要把判断小蛇的生死抽取出来放入一个新的函数里面,在小蛇移动函数里面调用这个函数判断。
void death() //判断小蛇生死
{
Snake *temp4;
temp4 = head->next;
//撞墙
if (head->s_pos.x < 10 || head->s_pos.x>560 || head->s_pos.y < 10 || head->s_pos.y>560)
snake.isAlive = 0;
if (map == 1)
{
if (head->s_pos.x < 150 && head->s_pos.y == 285)
snake.isAlive = 0;
if (head->s_pos.x > 420 && head->s_pos.y == 285)
snake.isAlive = 0;
if (head->s_pos.y < 150 && head->s_pos.x == 285)
snake.isAlive = 0;
if (head->s_pos.y > 420 && head->s_pos.x == 285)
snake.isAlive = 0;
}
if (map == 2)
{
if (head->s_pos.x < 250 && head->s_pos.y == 80)
snake.isAlive = 0;
if (head->s_pos.x > 350 && head->s_pos.y == 160)
snake.isAlive = 0;
if (head->s_pos.x < 250 && head->s_pos.y == 240)
snake.isAlive = 0;
if (head->s_pos.x > 350 && head->s_pos.y == 320)
snake.isAlive = 0;
if (head->s_pos.x < 250 && head->s_pos.y == 400)
snake.isAlive = 0;
}
//头碰到蛇身
if (snake.body >= 4)
{
while (temp4->next != NULL)
{
if (temp4->s_pos.x == head->s_pos.x&&temp4->s_pos.y == head->s_pos.y)
{
snake.isAlive = 0;
break;
}
else temp4 = temp4->next;
}
}
}
4.过长参数列
在网上找到的例子:打印基本信息的方法printBasicInfo
,我们每次要传四个参数,如果汽车增加一个属性,入参要增加参数,这样需要改动多出代码,这时可以用Introduce Parameter Object(引入参数对象)进行重构,创建一个汽车对象在方法之间进行传递.
原来的代码:
// 提炼打印基本信息方法
private void printBasicInfo(String brand, String model, Integer price, double power) {
System.out.println("品牌" + brand);
System.out.println("型号:" + model);
System.out.println("动力:" + power);
System.out.println("价格:" + price);
}
重构后的代码:
private void printBasicInfo(CarEntity carEntity) {
System.out.println("品牌" + carEntity.getBrand());
System.out.println("型号:" + carEntity.getModel());
System.out.println("动力:" + carEntity.getPower());
System.out.println("价格:" + carEntity.getPrice());
}
5.痴迷基本类型
使用对象把基本类型封装起来,下面是一个订单类,包含用户名、用户性别、订单价格、订单id等信息。利用Replace Data Value with Object(以对象取代数据值)把用户相关信息提炼成一个单独的Custom类,再在订单类中引用Custom对象。
原来的代码:
// 订单
public class Order {
private String customName;
private String customSex;
private Integer orderId;
private Integer price;
}
重构后的代码:
// 订单
public class Order {
private Custom custom;
private Integer orderId;
private Integer price;
}
// 把custom相关字段封装起来,在Order中引用Custom对象
public class Custom {
private String name;
private String address;
}
6.依恋情结
在类中找到一个大量使用另一个类的方法。在这种情况下,你可以考虑将这个方法移动到它使用的那个类中。例如,要将 getFullAddress() 从 User 类移动到 ContactInfo 类中,因为它调用了 ContactInfo 类的很多方法。
public class User{
private ContactInfo contactInfo;
public User(ContactInfo contactInfo){
this.contactInfo = contactInfo;
}
public void getFullAddress(ContactInfo contactInfo){
System.out.println("StreetNumber:" + contactInfo.StreetNumber);
System.out.println("StreetName:" + contactInfo.StreetName);
System.out.println("ZipCode:" + contactInfo.ZipCode);
System.out.println("Country:" + contactInfo.Country);
}
}
7.数据泥团
数据泥团指的是经常一起出现的数据,处理方式与过长参数列的处理方式相同,用Introduce Parameter Object(引入参数对象)将参数封装成对象。
例子与过长参数列相同,重构后的好处很明显,除了参数列变短,就算汽车有新的属性进行扩展,也不需要修改参数列,这样更便于我们对代码的维护。
8.发散式变化
发散式变化可以采用桥接模式重构
假设某个汽车厂商生产三种品牌的汽车:Big、Tiny和Boss,每种品牌又可以选择燃油、纯电和混合动力。如果用传统的继承来表示各个最终车型,一共有3个抽象类加9个最终子类,如果要新增一个品牌,或者加一个新的引擎(比如核动力),那么子类的数量增长更快。
首先把Car
按品牌进行子类化,但是,每个品牌选择什么发动机,不再使用子类扩充,而是通过一个抽象的“修正”类,以组合的形式引入。
//Car
public abstract class Car {
// 引用Engine:
protected Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
public abstract void drive();
}
//Engine
public interface Engine {
void start();
}
在一个“修正”的抽象类RefinedCar
中定义一些额外操作:
public abstract class RefinedCar extends Car {
public RefinedCar(Engine engine) {
super(engine);
}
public void drive() {
this.engine.start();
System.out.println("Drive " + getBrand() + " car...");
}
public abstract String getBrand();
}
这样一来,最终的不同品牌继承自RefinedCar
,例如BossCar
public class BossCar extends RefinedCar {
public BossCar(Engine engine) {
super(engine);
}
public String getBrand() {
return "Boss";
}
}
而针对每一种引擎,继承自Engine
,例如HybridEngine
:
public class HybridEngine implements Engine {
public void start() {
System.out.println("Start Hybrid Engine...");
}
}
客户端通过自己选择一个品牌,再配合一种引擎,得到最终的Car:
RefinedCar car = new BossCar(new HybridEngine());
car.drive();
重构后的代码如果要增加一种引擎,只需要针对Engine
派生一个新的子类,如果要增加一个品牌,只需要针对RefinedCar
派生一个子类,任何RefinedCar
的子类都可以和任何一种Engine
自由组合,即一辆汽车的两个维度:品牌和引擎都可以独立地变化,解决了发散式变化 的坏味道。