本章将涵盖以下主题:
- 代码异味介绍
- 整洁的代码的概念
- 敏捷和整洁的代码实践是如何关联的
- 固体设计原理
- 代码重构
- 将代码重构为整洁的代码
- 将代码重构为设计模式
整洁的代码是在功能上以准确的方式工作并且在结构上写得很好的源代码。通过彻底的测试,我们可以确保代码在功能上是正确的。我们可以通过代码自我审查、同行代码审查、代码分析来提高代码质量,最重要的是通过代码重构。
以下是整洁的代码的一些品质:
- 容易理解
- 易于增强
- 添加新功能不需要很多代码更改
- 易于重复使用
- 不言自明
- 必要时有注释
最后,编写整洁的代码的最大好处是参与项目或产品的开发团队和客户都会感到高兴。
重构有助于提高源代码的结构质量。它不会修改代码的功能;它只是提高了代码质量的结构方面。重构使代码更加清晰,但有时它可能会帮助您提高整体代码性能。但是,您需要理解性能调优不同于代码重构。
下图展示了开发过程概述:
代码重构是如何安全完成的?这个问题的答案如下:
- 拥抱 DevOps
- 适应测试驱动的开发
- 适应行为驱动的开发
- 使用验收测试驱动的开发
源代码有两个方面的质量,即功能性和结构性。一段源代码的功能质量可以通过对照客户规范测试代码来实现。大多数开发人员犯的最大错误是,他们倾向于将代码提交给版本控制软件,而不进行重构;也就是说,他们在认为代码功能完整的那一刻就提交了代码。
事实上,将代码提交给版本控制通常是一个好习惯,因为这使得持续集成和 DevOps 成为可能。在将代码提交给版本控制后,绝大多数开发人员忽略的是对其进行重构。重构代码以确保它是干净的是非常重要的,没有它敏捷是不可能的。
看起来像面条(意大利面)的代码需要更多的努力来增强或维护。因此,快速响应客户的请求实际上是不可能的。这就是为什么保持整洁的代码对于敏捷是至关重要的。无论您的组织遵循什么样的敏捷框架,这都是适用的。
敏捷就是快速失败。敏捷团队将能够快速响应客户的需求,而不需要开发团队的参与。团队使用哪种敏捷框架并不重要:Scrum、看板、XP 或其他。真正重要的是,你是否认真地跟随他们?
作为一名独立的软件顾问,我个人观察和了解了一般谁会抱怨,以及他们为什么会抱怨敏捷。
由于 Scrum 是最受欢迎的敏捷框架之一,让我们假设一家产品公司,比如中航科技私人有限公司,已经决定跟随 Scrum 开发他们计划开发的新产品。好消息是,ABC Tech 也像大多数组织一样,高效地主持了 Sprint 计划会议、每日站起来会议、Sprint 回顾、Sprint 回顾以及所有其他 Scrum 仪式。假设 ABC Tech 已经确保他们的 Scrum 大师是 Scrum 认证的,并且产品经理是 Scrum 认证的产品所有者。太好了。到目前为止一切听起来都很好。
假设 ABC Tech 产品团队不使用 TDD、BDD、ATDD 和 DevOps。您认为中航科技产品团队是否敏捷?当然不是。事实上,开发团队会因为紧张而不切实际的日程安排而高度紧张。说到底,自然减员率会非常高,因为球队不会高兴。因此,顾客会不高兴,因为产品质量会受到严重影响。
您认为中航科技产品团队出了什么问题?
Scrum 有两组过程,即项目管理过程,它被 Scrum 仪式所覆盖。然后是流程的工程方面,这是大多数组织不太关注的。这一点从 IT 行业对认证 SCRUM 开发人员 ( CSD )认证的兴趣或意识中可以明显看出。IT 行业对 CSM、CSPO 或 CSP 表现出的兴趣程度很难向 CSD 显示,而这是开发人员所必需的。然而,我不相信仅仅认证就能让一个人成为主题专家;它只显示了个人或组织在接受敏捷框架和向客户交付高质量产品时所表现出的严肃性。
除非代码保持干净,否则开发团队怎么可能快速响应客户的需求?换句话说,除非开发团队中的工程师在产品开发中接受 TDD、BDD、ATDD、持续集成和 DevOps,否则没有团队能够在 Scrum 中取得成功,或者说,在任何其他敏捷框架中取得成功。
底线是,除非你的组织将工程 Scrum 过程和项目管理 Scrum 过程同等重视,否则没有一个开发团队能够宣称在敏捷中取得成功。
SOLID 是一组重要设计原则的首字母缩略词,如果遵循这些原则,可以避免代码异味并提高代码质量,包括结构和功能。
如果您的软件体系结构符合 SOLID 设计原则,代码异味可以被防止或重构为整洁的代码。以下原则统称为固体设计原则:
- 单一责任原则
- 开放封闭原则
- 利斯科夫替代原理
- 界面分离
- 依赖倒置
最好的部分是大多数设计模式也遵循并符合 SOLID 设计原则。
让我们在接下来的部分中逐一介绍前面的每个设计原则。
单一责任原则简称 SRP 。SRP 说每个班级必须只有一个责任。换句话说,每个类必须恰好代表一个对象。当一个类代表多个对象时,它往往会违反 SRP,并为多个代码异味打开机会。
例如,我们来看一个简单的Employee
类,如下所示:
在上一个类图中,Employee
类似乎代表了三个不同的对象:Employee
、Address
和Contact
。因此,它违反了自律公约。按照这个原则,从前面的Employee
类中,可以提取另外两个类,即Address
和Contact
,如下:
为了简单起见,本节中使用的类图没有显示任何被各自的类支持的方法,因为我们的重点是用一个简单的例子来理解 SRP。
在前面重构的设计中,雇员有一个或多个地址(个人和官方)和一个或多个联系人(个人和官方)。最好的部分是重构设计后,每个类抽象出一个唯一的东西;也就是说,它只有一个责任。
当设计支持在不改变代码或不修改现有源代码的情况下添加新功能时,架构或设计符合开放封闭原则 ( OCP )。正如你所知,基于你的专业行业经验,你遇到的每个项目都可以以这样或那样的方式扩展。这就是你如何能够给你的产品增加新的功能。然而,当这样的功能扩展完成时,设计将符合 OCP,而无需您修改现有的代码。
我们来看一个简单的Item
类,如下代码所示。为简单起见,仅在Item
类中捕获基本细节:
#include <iostream>
#include <string>
using namespace std;
class Item {
private:
string name;
double quantity;
double pricePerUnit;
public:
Item ( string name, double pricePerUnit, double quantity ) {
this-name = name;
this->pricePerUnit = pricePerUnit;
this->quantity = quantity;
}
public double getPrice( ) {
return quantity * pricePerUnit;
}
public String getDescription( ) {
return name;
}
};
假设前面的Item
类是一个小商店的简单计费应用的一部分。由于Item
类将能够代表笔、计算器、巧克力、笔记本等,因此它足够通用,可以支持商店处理的任何可计费项目。然而,如果店主应该征收商品及服务税 ( 商品及服务税)或增值税 ( 增值税),现有的Item
类似乎不支持税收部分。一种常见的方法是修改Item
类以支持税收部分。然而,如果我们修改现有的代码,我们的设计将不符合 OCP。
因此,让我们使用 Visitor 设计模式重构我们的设计,使其符合 OCP 标准。让我们探索重构的可能性,如下面的代码所示:
#ifndef __VISITABLE_H
#define __VISITABLE_H
#include <string>
using namespace std;
class Visitor;
class Visitable {
public:
virtual void accept ( Visitor * ) = 0;
virtual double getPrice() = 0;
virtual string getDescription() = 0;
};
#endif
Visitable
类是一个抽象类,有三个纯虚函数。Item
类将继承Visitable
抽象类,如下所示:
#ifndef __ITEM_H
#define __ITEM_H
#include <iostream>
#include <string>
using namespace std;
#include "Visitable.h"
#include "Visitor.h"
class Item : public Visitable {
private:
string name;
double quantity;
double unitPrice;
public:
Item ( string name, double quantity, double unitPrice );
string getDescription();
double getQuantity();
double getPrice();
void accept ( Visitor *pVisitor );
};
#endif
接下来,我们来看看Visitor
类,如下代码所示。它表示未来可以实现任意数量的Visitor
子类来添加新功能,所有这些都不需要修改Item
类:
class Visitable;
#ifndef __VISITOR_H
#define __VISITOR_H
class Visitor {
protected:
double price;
public:
virtual void visit ( Visitable * ) = 0;
virtual double getPrice() = 0;
};
#endif
GSTVisitor
类是允许我们在不修改Item
类的情况下添加商品及服务税功能的类。GSTVisitor
的实现是这样的:
#include "GSTVisitor.h"
void GSTVisitor::visit ( Visitable *pItem ) {
price = pItem->getPrice() + (0.18 * pItem->getPrice());
}
double GSTVisitor::getPrice() {
return price;
}
Makefile
如下图所示:
all: GSTVisitor.o Item.o main.o
g++ -o gst.exe GSTVisitor.o Item.o main.o
GSTVisitor.o: GSTVisitor.cpp Visitable.h Visitor.h
g++ -c GSTVisitor.cpp
Item.o: Item.cpp
g++ -c Item.cpp
main.o: main.cpp
g++ -c main.cpp
重构后的设计符合 OCP 标准,因为我们可以在不修改Item
类的情况下添加新的功能。试想一下:如果商品及服务税的计算不时变化,而不修改Item
类,我们将能够添加Visitor
的新子类,并解决即将到来的变化。
利斯科夫替代原则 ( LSP )强调子类遵守基类建立的契约的重要性。在理想的继承层次结构中,当设计焦点在类层次结构中上移时,我们应该注意到泛化;随着设计焦点在类层次结构中下移,我们应该注意到专门化。
继承契约在两个类之间,因此基类有责任强加所有子类都可以遵循的规则,一旦达成一致,子类同样有责任遵守契约。折衷这些设计理念的设计将不符合 LSP。
LSP 说,如果一个方法以基类或接口作为参数,人们应该能够无条件地替换任何一个子类的实例。
事实上,继承违反了最基本的设计原则:继承弱内聚,强耦合。因此,继承的真正好处是多态性,与为继承付出的代价相比,代码重用只是一个微小的好处。当违反 LSP 时,我们不能用它的一个子类实例替换基类实例,最糟糕的是我们不能多形态地调用方法。尽管付出了使用继承的设计代价,如果我们不能获得多态性的好处,就没有使用它的真正动机。
识别 LSP 违规的技术如下:
- 子类将有一个或多个带有空实现的重写方法
- 基类将有一个专门化的行为,这将强制某些子类,不管那些专门化的行为是否是子类感兴趣的
- 不是所有的一般化方法都可以多形态调用
以下是重构 LSP 违规的方法:
- 将专门化的方法从基类转移到需要那些专门化行为的子类。
- 避免强迫模糊相关的类参与继承关系。除非子类是基类型,否则不要仅仅为了代码重用而使用继承。
- 不要寻找小的好处,比如代码重用,而是尽可能寻找使用多态性、聚合或组合的方法。
接口隔离设计原则建议为特定目的建模许多小接口,而不是为代表许多事物的一个更大的接口建模。在 C++ 的情况下,具有纯虚函数的抽象类可以被认为是一个接口。
让我们举一个简单的例子来理解接口隔离:
#include <iostream>
#include <string>
using namespace std;
class IEmployee {
public:
virtual string getDoor() = 0;
virtual string getStreet() = 0;
virtual string getCity() = 0;
virtual string getPinCode() = 0;
virtual string getState() = 0;
virtual string getCountry() = 0;
virtual string getName() = 0;
virtual string getTitle() = 0;
virtual string getCountryDialCode() = 0;
virtual string getContactNumber() = 0;
};
在前面的例子中,抽象类演示了一个混乱的设计。这个设计很混乱,因为它似乎代表了许多东西,比如员工、地址和联系人。可以重构前面抽象类的方法之一是将单个接口分成三个独立的接口:IEmployee
、IAddress
和IContact
。在 C++ 中,接口只不过是带有纯虚函数的抽象类:
#include <iostream>
#include <string>
#include <list>
using namespace std;
class IEmployee {
private:
string firstName, middleName, lastName,
string title;
string employeeCode;
list<IAddress> addresses;
list<IContact> contactNumbers;
public:
virtual string getAddress() = 0;
virtual string getContactNumber() = 0;
};
class IAddress {
private:
string doorNo, street, city, pinCode, state, country;
public:
IAddress ( string doorNo, string street, string city,
string pinCode, string state, string country );
virtual string getAddress() = 0;
};
class IContact {
private:
string countryCode, mobileNumber;
public:
IContact ( string countryCode, string mobileNumber );
virtual string getMobileNumber() = 0;
};
在重构的代码片段中,每个接口只代表一个对象,因此它符合接口隔离设计原则。
一个好的设计将是强内聚和松散耦合的。因此,我们的设计必须具有较少的依赖性。使代码依赖于许多其他对象或模块的设计被认为是糟糕的设计。如果违反依赖反转 ( DI ),依赖模块中发生的任何变化都会对我们的模块产生不好的影响,导致连锁反应。
让我们举一个简单的例子来理解 DI 的力量。一个Mobile
类“有一个”Camera
对象,注意,有一个形式就是构图。合成是一种专有所有权,其中Camera
对象的寿命由Mobile
对象直接控制:
在上图中可以看到,Mobile
类有一个Camera
的实例,有一个使用的形式是 composition,这是一个排他的所有权关系。
我们来看看Mobile
类的实现,如下:
#include <iostream>
using namespace std;
class Mobile {
private:
Camera camera;
public:
Mobile ( );
bool powerOn();
bool powerOff();
};
class Camera {
public:
bool ON();
bool OFF();
};
bool Mobile::powerOn() {
if ( camera.ON() ) {
cout << "\nPositive Logic - assume some complex Mobile power ON logic happens here." << endl;
return true;
}
cout << "\nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
<< endl;
return false;
}
bool Mobile::powerOff() {
if ( camera.OFF() ) {
cout << "\nPositive Logic - assume some complex Mobile power OFF logic happens here." << endl;
return true;
}
cout << "\nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
return false;
}
bool Camera::ON() {
cout << "\nAssume Camera class interacts with Camera hardware here\n" << endl;
cout << "\nAssume some Camera ON logic happens here" << endl;
return true;
}
bool Camera::OFF() {
cout << "\nAssume Camera class interacts with Camera hardware here\n" << endl;
cout << "\nAssume some Camera OFF logic happens here" << endl;
return true;
}
在前面的代码中,Mobile
有关于Camera
的实现级知识,这是一个糟糕的设计。理想情况下,Mobile
应该通过一个接口或者一个带有纯虚函数的抽象类与Camera
进行交互,因为这将Camera
的实现从它的契约中分离出来。这种方法有助于在不影响Mobile
的情况下替换Camera
,并且还提供了一个支持一堆Camera
子类来代替单个摄像机的机会。
想知道为什么叫依赖注入 ( DI )或者控制反转 ( IOC )?之所以称之为依赖注入,是因为目前Camera
的寿命由Mobile
对象控制;也就是说,Camera
被Mobile
对象实例化并销毁。在这种情况下,在没有Camera
的情况下几乎不可能对Mobile
进行单元测试,因为Mobile
对Camera
有很强的依赖性。除非实现Camera
,否则我们无法测试Mobile
的功能,这是一种糟糕的设计方法。当我们反转依赖关系时,它让Mobile
对象使用Camera
对象,同时放弃控制Camera
对象寿命的责任。这一过程被正确地称为国际奥委会。这样做的好处是,您将能够独立地对Mobile
和Camera
对象进行单元测试,并且由于 IOC,它们将具有很强的内聚性和松散耦合性。
让我们用 DI 设计原则重构前面的代码:
#include <iostream>
using namespace std;
class ICamera {
public:
virtual bool ON() = 0;
virtual bool OFF() = 0;
};
class Mobile {
private:
ICamera *pCamera;
public:
Mobile ( ICamera *pCamera );
void setCamera( ICamera *pCamera );
bool powerOn();
bool powerOff();
};
class Camera : public ICamera {
public:
bool ON();
bool OFF();
};
//Constructor Dependency Injection
Mobile::Mobile ( ICamera *pCamera ) {
this->pCamera = pCamera;
}
//Method Dependency Injection
Mobile::setCamera( ICamera *pCamera ) {
this->pCamera = pCamera;
}
bool Mobile::powerOn() {
if ( pCamera->ON() ) {
cout << "\nPositive Logic - assume some complex Mobile power ON logic happens here." << endl;
return true;
}
cout << "\nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
<< endl;
return false;
}
bool Mobile::powerOff() {
if ( pCamera->OFF() ) {
cout << "\nPositive Logic - assume some complex Mobile power OFF logic happens here." << endl;
return true;
}
cout << "\nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
return false;
}
bool Camera::ON() {
cout << "\nAssume Camera class interacts with Camera hardware here\n" << endl;
cout << "\nAssume some Camera ON logic happens here" << endl;
return true;
}
bool Camera::OFF() {
cout << "\nAssume Camera class interacts with Camera hardware here\n" << endl;
cout << "\nAssume some Camera OFF logic happens here" << endl;
return true;
}
在前面的代码片段中,更改以粗体突出显示。IOC 是一种如此强大的技术,它让我们可以像刚才演示的那样分离依赖关系;然而,它的实现相当简单。
代码异味是一个术语,用来指一段缺乏结构质量的代码;然而,代码在功能上可能是正确的。代码异味违反了 SOLID 设计原则,因此必须认真对待,因为从长远来看,写得不好的代码会导致沉重的维护成本。然而,代码异味可以重构为整洁的代码。
作为一名独立的软件顾问,我有很多机会与伟大的开发人员、架构师、质量保证人员、系统管理员、首席技术官和首席执行官、企业家等进行互动和学习。每当我们的讨论跨越十亿美元的问题,“什么是整洁的代码或好的代码?”,我或多或少得到了一个全球通用的回答,“好的代码会得到很好的评价。”虽然这是部分正确的,但这确实是问题的开始。理想情况下,整洁的代码应该是不言自明的,不需要任何注释。但是,在某些情况下,注释可以提高整体可读性和可维护性。不是所有的注释都是代码异味,因此有必要区分好的注释和坏的注释。看看下面的代码片段:
if ( condition1 ) {
// some block of code
}
else if ( condition2 ) {
// some block of code
}
else {
// OOPS - the control should not reach here ### Code Smell ###
}
我相信你已经见过这种评论了。不用解释,前面的场景是一种代码味道。理想情况下,开发人员应该重构代码来修复错误,而不是编写这样的注释。我曾经在半夜调试一个关键问题,我注意到控件到达了神秘的空代码块,其中只有一个注释。我相信你遇到过更有趣的代码,可以想象它带来的挫败感;有时,您也会编写这样的代码。
一个好的评论会表达为什么代码是以特定的方式编写的,而不是表达代码是如何做某事的。传达代码如何做某事的注释是代码异味,而传达代码的为什么部分是好的注释的注释,因为为什么部分不是由代码表达的;因此,一个好的评论可以增加价值。
当一个方法被确定具有多重责任时,它就是长的。自然,一个拥有超过 20-25 行代码的方法往往有不止一个责任。话虽如此,代码行越多的方法越长。这并不意味着少于 25 行代码的方法不会更长。看看下面的代码片段:
void Employee::validateAndSave( ) {
if ( ( street != "" ) && ( city != "" ) )
saveEmployeeDetails();
}
显然,前面的方法有多重责任;也就是说,它似乎验证并保存了细节。虽然保存前验证没有错,但同样的方法不应该两者兼而有之。因此,前面的方法可以重构为两个更小的方法,它们只有一个职责:
private:
void Employee::validateAddress( ) {
if ( ( street == "" ) || ( city == "" ) )
throw exception("Invalid Address");
}
public:
void Employee::save() {
validateAddress();
}
前面代码中显示的每个重构方法都只有一个职责。将validateAddress()
方法变成谓词方法是很有诱惑力的;也就是说,返回 bool 的方法。但是如果把validateAddress()
写成谓词方法,那么客户端代码就会被强制做if
检查,这是一个代码味。通过返回错误代码来处理错误不被认为是面向对象的代码,因此错误处理必须使用 C++ 异常来完成。
一个面向对象的方法需要更少的参数,因为一个设计良好的对象将是强内聚和松散耦合的。一个接受太多参数的方法是一种症状,它通知做出决策所需的知识是从外部接收的,这意味着当前对象没有自己做出决策所需的所有知识。
这意味着当前对象是弱内聚和强耦合的,因为它依赖太多的外部数据来做出决定。成员函数通常接收较少的参数,因为它们需要的数据成员通常是成员变量。因此,将成员变量传递给成员函数的需求听起来是人为的。
让我们看看为什么一个方法会接收太多参数的一些常见原因。这里列出了最常见的症状和原因:
- 对象弱内聚,强耦合;也就是说,它太依赖其他物体了
- 这是一种静态方法
- 这是一种错位的方法;也就是说,它不属于那个对象
- 它不是面向对象的代码
- 违反了 SRP
重构一个需要长参数表 ( LPL )的方法的方法如下:
- 避免零碎地提取和传递数据;考虑传递一个完整的对象,让方法提取它需要的细节
- 确定向接收 LPL 的方法提供参数的对象,并考虑将该方法移到那里
- 将参数列表分组,创建一个参数对象,并将接收 LPL 的方法移到新对象中
重复代码是一种常见的重复代码异味,不需要太多解释。复制和粘贴代码区域性本身不能成为代码重复的原因。重复的代码使代码维护变得更加麻烦,因为相同的问题可能必须在多个地方修复,集成新功能需要太多的代码更改,这往往会破坏意想不到的功能。重复的代码还会增加应用二进制文件的占用空间,因此必须重构它来清理代码。
条件复杂性代码异味是关于复杂的大型条件,随着时间的推移,这些条件会变得越来越大,越来越复杂。这种代码味道可以用策略设计模式重构。由于策略设计模式处理许多相关的对象,所以有使用Factory
方法的余地,空对象设计模式可以用来处理Factory
方法中不支持的子类:
//Before refactoring
void SomeClass::someMethod( ) {
if ( ! conition1 && condition2 )
//perform some logic
else if ( ! condition3 && condition4 && condition5 )
//perform some logic
else
//do something
}
//After refactoring
void SomeClass::someMethod() {
if ( privateMethod1() )
//perform some logic
else if ( privateMethod2() )
//perform some logic
else
//do something
}
大量的类代码异味使得代码难以理解和维护。一个大班可以为一个班做太多事情。可以通过将大型类分解成具有单一职责的小型类来重构它们。
死代码是注释代码或从未使用或集成的代码。它可以用代码覆盖工具检测到。通常,开发人员会因为缺乏信心而保留这些代码实例,这种情况在遗留代码中更常见。由于每个代码都在版本控制软件工具中被跟踪,死代码可以被删除,并且如果需要,总是可以从版本控制软件中检索回来。
原语痴迷 ( PO )是一个错误的设计选择:使用原语数据类型来表示复杂的域实体。例如,如果使用字符串数据类型来表示日期,尽管这在最初听起来是一个明智的想法,但从长远来看,它会带来很多维护麻烦。
假设您使用字符串数据类型来表示日期,以下问题将是一个挑战:
- 你需要根据日期来分类
- 随着字符串的引入,日期算法将变得非常复杂
- 根据地区设置支持各种日期格式将变得复杂
理想情况下,日期必须由类来表示,而不是原始数据类型。
数据类只提供 getter 和 setter 函数。虽然它们非常适合将数据从一个层传输到另一个层,但是它们往往会加重依赖于数据类的类的负担。由于数据类不会提供任何有用的功能,交互或依赖数据类的类最终会用数据类中的数据添加功能。以这种方式,数据类周围的类违反了 SRP,并且往往是一个大类。
如果某些类对其他类的其他内部细节有太多的了解,它们就被称为功能嫉妒。通常,当其他类是数据类时会发生这种情况。代码异味是相互关联的;打破一种代码异味往往会吸引其他代码异味。
在本章中,您了解了以下主题:
-
代码异味和重构代码的重要性
-
立体设计原则:
- 单一责任原则
- 开放封闭原则
- 利斯科夫替代
- 界面分离
- 依赖注入
-
各种代码异味:
- 评论有味道
- 长法
- 长参数列表
- 重复代码
- 条件复杂性
- 大班
- 死代码
-
面向对象的代码散发着原始的痴迷
- 数据类
- 特征嫉妒
您还学习了许多重构技术,这些技术将帮助您保持代码的整洁。快乐编码!