每天进步一点点 - 坏味道方法论总结
10、 变量声明与赋值分离：普通的变量声明，怎么也有坏味道？
变量初始化最好一次性完成。 用声明式的标准来看代码，是一个发现代码坏味道的重要参考一、初始化和业务掺杂在一起EpubStatus status = null; CreateEpubResponse response = createEpub(request); if (response.getCode() == 201) { status = EpubStatus.CREATED; } else { status = EpubStatus.TO_CREATE; }这段代码在做的事情是向另外一个服务发请求创建 EPUB（一种电子书格式），如果创建成功，返回值是 HTTP 的 201，也就表示创建成功，然后就把状态置为 CREATED；而如果没有成功，则把状态置为 TO\_CREATE。后面对于 TO\_CREATE 状态的作品，还需要再次尝试创建。这种代码真正的问题就是不清晰，变量初始化与业务处理混在在一起。解决方法：我们提取出了一个函数，将 response 转成对应的内部的 EPUB 状态。const CreateEpubResponse response = createEpub(request); const EpubStatus status = toEpubStatus(response); private: EpubStatus toEpubStatus(const CreateEpubResponse response) { if (response.getCode() == 201) { return EpubStatus.CREATED; } return EpubStatus.TO_CREATE; }在能够使用 const 的地方尽量使用 const ，限制变量的赋值。二、集合初始化List<Permission> permissions; permissions.push_back(Permission.BOOK_READ); permissions.push_back(Permission.BOOK_WRITE); check.grantTo(Role.AUTHOR, permissions);这里先给 permission 初始化成了一个 List，这个时候，permissions 虽然存在了，但我们并不会把它传给 grantTo 方法，它还不能直接使用，因为它还缺少必要的信息。然后，我们将 BOOK\_READ 和 BOOK\_WRITE 两个枚举对象添加了进去，这样，这个 permissions 对象才是我们真正需要的那个对象。这种代码是非常常见的，声明一个集合，然后，调用一堆添加的方法，将所需的对象添加进去。解决办法：使用构造函数，初始化的时候就将形参传给它。// c++11 initializer list syntax: List<Permission> permissions = {Permission.BOOK_READ, Permission.BOOK_WRITE}; check.grantTo(Role.AUTHOR, permissions); 这个 List 一旦创建好了，就是不能修改了，对应的实现就是各种添加、删除之类的方法全部都禁用了。Note:变量的声明和赋值是分离的。二者分离带来的问题就是，把赋值的过程与业务处理混杂在一起。发现变量声明与赋值分离一个做法就是在声明前面加上 const，用“不变性”约束代码。
这一类的坏味道：可变的数据。一、满天飞的 Settersetter 几乎是排名第一的坏味道。 相比于读数据，修改是一个更危险的操作 比可变的数据更可怕的是，不可控的变化，而暴露 setter 就是这种不可控的变化。1、常见错误一：使用set来改变成员变量public: void approve(const long bookId) { ... book.setReviewStatus(ReviewStatus.APPROVED); ... }就是因为这里用了 setter。setter 往往是缺乏封装的一种做法。对于缺乏封装的坏味道。实际情况往往是，生成 getter 的同时，setter 也生成了出来。setter 同 getter 一样，反映的都是对细节的暴露。解决办法：移除设值函数（Remove Setting Method），将变化限制在一定的范围之内。用一个函数替代了 setter，也就是把它用行为封装了起来：public： void approve(const long bookId) { ... book.approve(); ... }通过在 Book 类里引入了一个 approve 函数，我们将审核状态封装了起来。class Book { public： void approve() { reviewStatus_ = ReviewStatus.APPROVED; } }虽然审核状态这个字段还是会修改，但你所有的修改都要通过几个函数作为入口。有任何业务上的调整，都会发生在类的内部，只要保证接口行为不变，就不会影响到其它的代码。2、常见错误2：使用连续的set来初始化对象Book book = new Book(); book.setBookId(bookId); book.setTitle(title); book.setIntroduction(introduction);我这个 setter 只是用在初始化过程中，而并不需要在使用的过程去调用，就像这样，实际上，对于这种只在初始化中使用的代码，压根没有必要以 setter 的形式存在，真正需要的是一个有参数的构造函数。解决办法：消除 setter ，有一种专门的重构手法，叫做移除设值函数（Remove Setting Method）。@Getter @Setter class Book { public: Book(BookId bookId, String title, String introduction) : bookId_(bookId), title_(title), introduction_(introduction) { } private: BookId bookId_; String title_; String introduction_; } BookId getBookId(){return bookId_;} BookId setBookId(BookId bookId){bookId_ = bookId;} string getTitle(){return title_;} 不写 setter 的代码并不代表没有 setter。为了清晰的表达这个设计意图，其实就没必要再加上setTitle。可以直接看出来，初始化之后就不会再有set的操作。二、可变的数据使用 setter，一个重要的原因就是它暴露了数据。 把 setter 封装成一个个的函数，实际上是把不可控的修改限制在一个有限的范围内。在这种思路下，可变数据（Mutable Data）就成了一种坏味道。解决可变数据，还有一个解决方案是编写不变类我们怎么设计不变类呢？要做到以下三点：所有的字段只在构造函数中初始化；所有的方法都是纯函数；如果需要有改变，返回一个新的对象，而不是修改已有字段。解决办法：class Book { public: Book approve() { return new Book(..., ReviewStatus.APPROVED, ...); } }回过头来看我们之前改动的“用构造函数消除 setter”的代码，其实就是朝着这个方向在迈进。如果按照这个思路改造我们前面提到的 approve 函数。我们创建出了一个“其它参数和原有 book 对象一模一样，只是审核状态变成了 APPROVED ”的对象。比如，用来表示时间的类。原来的 Date 类里面还有各种 setter，而新增的 LocalDateTime 则一旦初始化就不会再修改了。如果要操作这个对象，则会产生一个新的对象：LocalDateTime twoDaysLater = now.plusDays(2);想要完全消除可变数据是很难做到的，但我们可以尽可能地编写一些不变类。区分类的性质:对象分成两种，实体和值对象。实体对象要限制数据变化，而值对象就要设计成不变类。三、可变数据的解决办法case1: 可以使用封装变量来确保所有数据更新操作都通过很少几个函数来进行，使其更容易监控和演进。case2: 使用移动语义和提炼函数 尽量把逻辑从处理更新操作的代码中搬移出来，将没有副作用的代码与执行数据更新操作的代码分开。在设计API时，可以使用将查询函数和修改函数分离确保调用者不会调用到有副作用的代码。string alterForMiscreant(vector<Person> peoples) { for(auto people : peoples) { if (people == "Don") { setOffAlarms(); return "Don"; } if (people == "John") { setOffAlarms(); return "John"; } return ""; } } 解决办法：将修改和查询相分离string findForMiscreant(vector<Person> peoples) { for(auto people : peoples) { if (people == "Don") { return "Don"; } if (people == "John") { setOffAlarms(); return "John"; } return ""; } } string alterForMiscreant(vector<Person> peoples) { if(findForMiscreant(peoples) == "") { setOffAlarms(); } }三、全局数据（Global Data）如果你能够理解可变数据是一种坏味道，全局数据也就很容易理解了，它们处理手法基本上是类似的。 全局数据的问题在于，从代码库的任何地方都可以修改它，而且没有任何机制可以探测出到底哪段代码做出了修改。如果想搬移一处被广泛使用的额数据，最好的办法往往是先以函数形式封装所有对该数据的访问。封装能提供一个清晰的观点，可以由此监控数据的变化和使用情况；对于所有可变的数据，只要它的作用域超过单个函数，就要将其封装其他，只允许通过函数访问。封装手段可以使用getter和setter来封装。Note:虽然对数据结构做了封装，但是不能控制对数据内部数据项的修改，有两种方式可以做到对数据不能做修改：case1: 在取值函数中返回数据的一份副本，调用端可以随便修改它，但不会影响到共享的这份数据。例子参考 P135 重构这本书case2: 阻止对数据的修改，比如通过封装记录就能很好的实现这一效果。解决办法：把全局函数使用一个函数包装起来，至少能看见修改它的地方，并开始控制它的访问。随后，最好将这个函数搬移到一个类或者模块中，只允许模块内的代码使用它。取值和设值时都可以返回副本，这取决于数据从哪儿来，以及是否需要保留对源数据的连接。
8、 缺乏封装：如何应对火车代码和基本类型偏执问题？
一个重要的观念就是封装，将零散的代码封装成一个又一个可复用的模块。 造成的结果就是：写代码的人认为自己提供了封装，但实际上，我们还是看到许多的代码散落在那里。 这种坏味道违反了迪米特法则一、火车残骸火车残骸的代码就是连续的函数调用，它反映的问题就是把实现细节暴露了出去，缺乏应有的封装。重构的手法是隐藏委托关系，实际就是做封装。 软件行业有一个编程指导原则，叫迪米特法则，可以作为日常工作的指导，规避这种坏味道的出现。String name = book.getAuthor().getName();这段代码表达的是“获得一部作品作者的名字”。作品里有作者信息，想要获得作者的名字，通过“作者”找到“作者姓名”，这就是很多人凭借直觉写出的代码，不过它是有问题的。是不是必须得先了解 Book 和 Author 这两个类的实现细节？也就是说，我们必须得知道，作者的姓名是存储在作品的作者字段里的。这段代码只是用来说明这种类型坏味道是什么样的，在实际工作中，这种在一行代码中有连续多个方法调用的情况屡见不鲜，数量上总会不断突破你的认知。这种坏味道起的名字叫过长的消息链（Message Chains），而有人则给它起了一个更为夸张的名字：火车残骸（Train Wreck），形容这样的代码像火车残骸一般，断得一节一节的。解决办法：解决这种代码的重构手法叫隐藏委托关系（Hide Delegate），说得更直白一些就是，把这种调用封装起来：class Book { ... public： String getAuthorName() { return author_.getName(); } ... } String name = book.getAuthorName();火车残骸这种坏味道的产生是缺乏对于封装的理解，因为封装这件事并不是很多程序员编码习惯的一部分。现在写出一个 getter 往往是 IDE 中一个快捷键的操作，甚至不需要自己手工敲代码。**要想摆脱初级程序员的水平，就要先从少暴露细节开始。**声明完一个类的字段之后，请停下生成 getter 的手，转而让大脑开始工作，思考这个类应该提供的行为。Note:链式调用不一定都是火车残骸。比如builder模式，每次调用返回的都是自身，不牵涉到其他对象，不违反迪米特法则。在软件行业中，有一个编程的指导原则几乎就是针对这个坏味道的，叫做迪米特法则（Law of Demeter），这个原则是这样说的：每个单元对其它单元只拥有有限的知识，而且这些单元是与当前单元有紧密联系的；每个单元只能与其朋友交谈，不与陌生人交谈；只与自己最直接的朋友交谈。二、基本类型偏执基本类型偏执就是用各种基本类型作为模型到处传递，这种情况下通常是缺少了一个模型。解决它，常用的重构手法是以对象取代基本类型，也就是提供一个模型代替原来的基本类型。基本类型偏执不局限于程序设计语言提供的基本类型。 应该构建模型，封装散落的代码。public: double getEpubPrice(const boolean highQuality, const int chapterSequence) { ... }问题就出在返回值的类型上，也就是价格的类型上。虽然价格本身是用浮点数在存储，但价格和浮点数本身并不是同一个概念，有着不同的行为需求。比如，一般情况下，我们要求商品价格是大于 0 的，但 double 类型本身是没有这种限制的。解决办法：这就是很多人使用基本类型（Primitive）作为变量类型思考的角度。但实际上，这种采用基本类型的设计缺少了一个模型。如果补齐这里缺失的模型，我们可以引入一个 Price 类型，这样的校验就可以放在初始化时进行：class Price { public: Price(const double price) { if (price <= 0) { throw new IllegalArgumentException("Price should be positive"); } price_ = price; } private: long price_; }这种引入一个模型封装基本类型的重构手法，叫做以对象取代基本类型（Replace Primitive with Object）。一旦有了这个模型，我们还可以再进一步，比如，如果我们想要让价格在对外呈现时只有两位，在没有 Price 类的时候，这样的逻辑就会散落代码的各处，事实上，代码里很多重复的逻辑就是这样产生的。public: double getDisplayPrice() { BigDecimal decimal = new BigDecimal(price_)； return decimal.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue(); }大部分程序员都学过这样一个设计原则：组合优于继承，class Books : public List<Book> { ... }Note:ＳＴＬ中的容器类是可以继承的，但由于ＳＴＬ中的容器类都是没有virtual析构的，所以其衍生类与基类并不符合IS-A关系，这种继承其实是为了重用代码，而从重用代码的角度来看，公有继承不如私有继承，继承不如组合。而应该写成组合的样子，也就是：class Books { private: List<Book> books; ... }Books 可能不需要提供 List 的所有方法。封装之所以有难度，主要在于它是一个构建模型的过程，而很多程序员写程序，只是用着极其粗粒度的理解写着完成功能的代码，根本没有构建模型的意识；还有一些人以为划分了模块就叫封装，所以，我们才会看到这些坏味道的滋生。
7、 滥用控制语句：出现控制结构，多半是错误的提示
循环和选择语句，可能都是坏味道。 这个坏味道就是滥用控制语句，也就是你熟悉的 if、for 等等一、嵌套的代码public: void distributeEpubs(const int bookId) { List<Epub> epubs = this->getEpubsByBookId(bookId); for (Epub epub : epubs) { if (epub.isValid()) { boolean registered = this->registerIsbn(epub); if (registered) { this->sendEpub(epub); } } } }我们看到了多层的缩进，for 循环一层，里面有两个 if ，又多加了两层。即便不是特别复杂的代码，也有这么多的缩进。解决办法：具体到这段代码，一个着手点是 for 循环，因为通常来说，for 循环处理的是一个集合，而循环里面处理的是这个集合中的一个元素。所以，我们可以把循环中的内容提取成一个函数，让这个函数只处理一个元素。情况1:好的写法 public: void distributeEpubs(const int bookId) { List<Epub> epubs = this->getEpubsByBookId(bookId); for (Epub epub : epubs) { this->distributeEpub(epub)； } } private: void distributeEpub(const Epub epub) { if (epub.isValid()) { boolean registered = this->registerIsbn(epub); if (registered) { this->sendEpub(epub); } } }Note:分解出来 distributeEpub 函数每次只处理一个元素。拆分出来的两个函数在缩进的问题上，就改善了一点。不要写成下面这种形式。情况2: 不好的写法 public: void distributeEpubs(const int bookId) { List<Epub> epubs = this->getEpubsByBookId(bookId); this.distributeEpub(epubs)； } private: void distributeEpub(List<Epub> epubs) { for (Epub epub : epubs) { if (epub.isValid()) { boolean registered = this->registerIsbn(epub); if (registered) { this->sendEpub(epub); } } } }条件表达式和循环常常也是提炼的信号。你可以使用分解表达式来处理条件表达式。如果发现提炼出的循环很难命名，可能是因为其中做了几件事情了。1、拆分循环常常能见到一些身兼数职的循环，它们一次做了两三件事情，不为别的，就因为这样可以只循环一次。一次循环中做了两件不同的事情，那么每次修改循环都要理解这两件事情。情况1: 如果一个循环只计算一个值，那么它直接返回该值即可；情况2: 但如果循环做了太多件事情，那就只得返回结构型数据或者通过局部变量传值了；Tips:如果将情况2重构为情况1这种情况时，需要考虑是否会成为性能瓶颈。举一个粒子，比如一个循环里面有两件事，一个是计算收入总值（数据累加），一个是找出最小收入int total = 0; vector<int> datas = [15, 25, 3, 65, 15]; int min = data[0]; for(auto data : datas) { total += data[i]; if (data[i] < min) { min = data[i]; } }解决办法：使用情况2这种方式进行拆分，但是需要删除涉及到重复计算的逻辑，否则会累加出错。第一步：拆分int total = 0; vector<int> datas = [15, 25, 3, 65, 15]; for(auto data : datas) { total += data[i]; } vector<int> datas = [15, 25, 3, 65, 15]; int min = data[0]; for(auto data : datas) { if (data[i] < min) { min = data[i]; } }第二步：封装int calTotalData(vector<int> datas) { int total = 0; for(auto data : datas) { total += data[i]; } return total; } int findMinDate(vector<int> datas) { int min = data[0]; for(auto data : datas) { if (data[i] < min) { min = data[i]; } } return min; } 在做提炼之前，最好先用移动语句微调一下代码顺序，将与循环相关的变量先搬移到一起。针对这种for语句的循环其实也可以利用STL中自带的算法库来优化。比如for\_each 、find\_if、copy[参见effective STL 第五条]等2、if 和 elseelse 也是一种坏味道，这是挑战很多程序员认知的 通常来说，if 语句造成的缩进，很多时候都是在检查某个先决条件，只有条件通过时，才继续执行后续的代码。这样的代码可以使用卫语句（guard clause）来解决，也就是设置单独的检查条件，不满足这个检查条件时，立刻从函数中返回。一种典型的重构手法：以卫语句取代嵌套的条件表达式（Replace Nested Conditional with Guard Clauses）。针对上面的情况1进行修改： private： void distributeEpub(const Epub epub) { if (!epub.isValid()) { return; } boolean registered = this->registerIsbn(epub); if (!registered) { return; } this->sendEpub(epub); }有一个衡量代码复杂度常用的标准，叫做圈复杂度（Cyclomatic complexity，简称 CC），圈复杂度越高，代码越复杂，理解和维护的成本就越高。我们也可以利用 Checkstyle 就可以做圈复杂度的检查，你可以限制最大的圈复杂度。Note: 可以在else if中直接使用lamber表达式void HwaBbsObsaiLinkSync::onHandlePortPipeResp(portPipeRespType& respMsg, const std::string& message) { if (respMsg.portPipeStatuses.empty()) { log_ << error << __FUNCTION__ << "Received " << message << " with empty portPipeStatuses. "; handleTaskResultCallback(ETaskResult::HwapiError); } else if (std::all_of(respMsg.portPipeStatuses.begin(), respMsg.portPipeStatuses.end(), [](const SBbbPortPipeStatus& portPipeStatus) { return EStatus_NoError == portPipeStatus.status; }))3、重复的 Switch这也是一种典型的坏味道：重复的 switch（Repeated Switch）。 如果有多个switch语句基于同一个条件进行分支选择，就应该使用以多态取代条件表达式public： double getBookPrice(const User user, const Book book) { double price = book.getPrice(); switch (user.getLevel()) { case UserLevel.SILVER: return price * 0.9; case UserLevel.GOLD: return price * 0.8; case UserLevel.PLATINUM: return price * 0.75; default: return price; } } public: double getEpubPrice(final User user, final Epub epub) { double price = epub.getPrice(); switch (user.getLevel()) { case UserLevel.SILVER: return price * 0.95; case UserLevel.GOLD: return price * 0.85; case UserLevel.PLATINUM: return price * 0.8; default: return price; } }这两段代码，分别计算了用户在网站上购买作品在线阅读所支付的价格，以及购买 EPUB 格式电子书所支付的价格。其中，用户实际支付的价格会根据用户在系统中的用户级别有所差异，级别越高，折扣就越高。这两个函数里出现了类似的代码，其中最类似的部分就是 switch，都是根据用户级别进行判断。解决办法：之所以会出现重复的 switch，通常都是缺少了一个模型。所以，应对这种坏味道，重构的手法是：以多态取代条件表达式（Relace Conditional with Polymorphism）class IUserLevel { double getBookPrice(Book book); double getEpubPrice(Epub epub); } class RegularUserLevel : IUserLevel { public: double getBookPrice(const Book book) { return book.getPrice(); } double getEpubPrice(const Epub epub) { return epub.getPrice(); } } class GoldUserLevel : IUserLevel { public: double getBookPrice(const Book book) { return book.getPrice() * 0.8; } double getEpubPrice(const Epub epub) { return epub.getPrice() * 0.85; } } class SilverUserLevel : IUserLevel { public: double getBookPrice(const Book book) { return book.getPrice() * 0.9; } double getEpubPrice(const Epub epub) { return epub.getPrice() * 0.85; } } class PlatinumUserLevel : IUserLevel { public: double getBookPrice(const Book book) { return book.getPrice() * 0.75; } double getEpubPrice(const Epub epub) { return epub.getPrice() * 0.8; } } 错误的重构：getBookPrice的形参不一定会变 public: double getBookPrice(const IUserLevel user, const Book book) { return user.getBookPrice(book); } 正确的重构：需要再封装一个函数将user 转化为 UserLevel public: double getBookPrice(const User user, const Book book) { IUserLevel level = user.getUserLevel(); return level.getBookPrice(book); } 调用的地方：public: double getBookPrice(const User user, const Book book) { IUserLevel level = user.getUserLevel(); return level.getBookPrice(book); } public: double getEpubPrice(cosnt User user, const Epub epub) { IUserLevel level = user.getUserLevel() return level.getEpubPrice(epub); } IUserLevel User::getUserLevel()// 个人感觉这个地方叫createUserLevel更合适 { switch (user.getLevel()) { case UserLevel.SILVER: return new SilverUserLevel(); case UserLevel.GOLD: return new GoldUserLevel(); case UserLevel.PLATINUM: return new PlatinumUserLevel(); default: return new RegularUserLevel(); } }我们看到的是一连串的“ if..else”。我们都知道，switch 其实就是一堆“ if..else” 的简化写法，二者是等价的，所以，这个重构手法，以多态取代的是条件表达式，而不仅仅是取代 switch。这个例子其实使用的就是策略模式，如果不方便叫UserLevel 这个名字的话，可以使用名字UserLevel Strategy来命名。Tips: 并不是所有的if/else都需要使用多态来替代，但是当满足如下两种情况中的任意一种时，建议使用多态来重构。情况1：最明显的征兆就是有好几个函数都有基于类型代码的switch语句，若真如此，我就可以针对switch语句中的每种分支逻辑创建一个类，用多态来承载各个类型特有的行为，从而去除重复的分支逻辑。情况2：基础逻辑可能是最常用的，也可能是最简单的。我可以把基础逻辑放到父类，把变体逻辑放到子类中。
6、长参数列表：如何处理不同类型的长参数？
函数之间还是要传递信息的，既然不能用全局变量，参数就成了最好的选择，于是乎，只要你想到有什么信息要传给一个函数，就自然而然地把它加到参数列表中，参数列表也就越来越长了。 形参中有bool类型的标志位违背了单一职责原则和接口隔离原则一、聚沙成塔public: void createBook(const String title, const String introduction, const URL coverUrl, const BookType type, const BookChannel channel, const String protagonists, const String tags, const boolean completed) { ... Book book = Book.builder .title(title) .introduction(introduction) .coverUrl(coverUrl) .type(type) .channel(channel) .protagonists(protagonists) .tags(tags) .completed(completed) .build(); repository_.save(book); }这是一个创建作品的函数，我们可以看到，这个函数的参数列表里，包含了一部作品所要拥有的各种信息，比如：作品标题、作品简介、封面 URL、作品类型、作品归属的频道、主角姓名、作品标签、作品是否已经完结等等。但是如果继续拓展呢？我们很自然地就会想到给这个函数增加一个参数。但正如我在讲“长函数”那节课里说到的，很多问题都是这样，每次只增加一点点，累积起来，便不忍直视了。怎么解决这个问题呢？这里所有的参数其实都是和作品相关的，也就是说，所有的参数都是创建作品所必需的。所以，我们可以做的就是将这些参数封装成一个类，一个创建作品的参数类：class NewBookParamters { private: String title; String introduction; URL coverUrl; BookType type; BookChannel channel; String protagonists; String tags; boolean completed; ... }这里你看到了一个典型的消除长参数列表的重构手法：将参数列表封装成对象。这个函数参数列表就只剩下一个参数了，一个长参数列表就消除了。public: void createBook(const NewBookParamters parameters) { ... }还有个疑问，只是把一个参数列表封装成一个类，然后，用到这些参数的时候，还需要把它们一个个取出来，这会不会是多此一举呢？就像这样：public: void createBook(constNewBookParamters parameters) { ... Book book = Book.builder .title(parameters.getTitle()) .introduction(parameters.getIntroduction()) .coverUrl(parameters.getCoverUrl()) .type(parameters.getType()) .channel(parameters.getChannel()) .protagonists(parameters.getProtagonists()) .tags(parameters.getTags()) .completed(parameters.isCompleted()) .build(); repository_.save(book); }解决办法：一个模型的封装应该是以行为为基础的。之前没有这个模型，所以，我们想不到它应该有什么行为，现在模型产生了，它就应该有自己配套的行为，那这个模型的行为是什么呢？从上面的代码我们不难看出，它的行为应该是构建一个作品对象出来class NewBookParamters { private: String title; String introduction; URL coverUrl; BookType type; BookChannel channel; String protagonists; String tags; boolean completed; public: Book newBook() { return Book.builder .title(title) .introduction(introduction) .coverUrl(coverUrl) .type(type) .channel(channel) .protagonists(protagonists) .tags(tags) .completed(completed) .build(); } }创建作品的函数就得到了极大的简化：public: void createBook(const NewBookParamters parameters) { ... Book book = parameters.newBook(); repository_.save(book); }1、将数据组织成新的数据结构将函数的参数封装成对象，一旦识别出新的数据结构。就可以重组程序的行为来使用这些结构，例如添加这个类的成员函数。demo1: 下面的函数负责找到超出指定范围的温度度数 int findOutRangeTemperature(vector<int>temperatures, int min, int max) { for(auto temperature : temperatures) { if(temperature < min || temperature > max) return temperature; } } 调用方： findOutRangeTemperature(temperatures, operatingPlan.temperatureFloor, operatingPlan.temperatureCeiling)operatingPlan对象用了另外的名字temperatureFloor和temperatureCeiling来表示温度的上下限，与findOutRangeTemperature所使用的名字不同。像这种两项不相干的数据表示一个范围，最好是将其组合成一个对象。class NumberRange { public: NumberRange(int min, int max) : min_(min), max_(max) { } int min_; int max_; } int findOutRangeTemperature(vector<int>temperatures, NumberRange numberRange) { for(auto temperature : temperatures) { if(temperature < numberRange.min || temperature > numberRange.max) return temperature; } } 调用方： findOutRangeTemperature(temperatures, numberRange)demo2:public: void postBlog(String title, String summary, String keywords, String content, String category, long authorId); // 将参数封装成对象 class Blog { public: String title; String summary; String keywords; Strint content; String category; long authorId; } public: void postBlog(Blog blog);如果函数是对外暴露的远程接口，将参数封装成对象，还可以提高接口的兼容性。在往接口中添加新的参数的时候，老的远程接口调用者有可能就不需要修改代码来兼容新的接口了。二、动静分离把长参数列表封装成一个类，这能解决大部分的长参数列表，但并不等于所有的长参数列表都应该用这种方式解决，因为不是所有情况下，参数都属于一个类。 这种情况可以把静态不变的数据变为类的成员变量public: void getChapters(const int bookId, const HttpClient httpClient, const ChapterProcessor processor) { HttpUriRequest request = createChapterRequest(bookId); HttpResponse response = httpClient.execute(request); List<Chapter> chapters = toChapters(response); processor.process(chapters); }这个函数的作用是根据作品 ID 获取其对应的章节信息。如果，单纯以参数个数论，这个函数的参数数量并不算多。不过，绝对的数量并不是关键点，参数列表也应该是越少越好。解决办法：每次传进来的 bookId 都是不一样的，是随着请求的不同而改变的。但 httpClient 和 processor 两个参数都是一样的，因为它们都有相同的逻辑，没有什么变化。换言之，bookId 的变化频率同 httpClient 和 processor 这两个参数的变化频率是不同的。一边是每次都变，另一边是不变的这里表现出来的就是典型的动数据（bookId）和 静数据（httpClient 和 processor），它们是不同的关注点，应该分离开来。解决办法：静态不变的数据完全可以成为这个函数所在类的一个字段[成为类的成员变量]，而只将每次变动的东西作为参数传递就可以了。public: void getChapters(const int bookId) { HttpUriRequest request = createChapterRequest(bookId); HttpResponse response = httpClient_.execute(request); List<Chapter> chapters = toChapters(response); processor_.process(chapters); }这个坏味道其实是一个软件设计问题，代码缺乏应有的结构，所以，原本应该属于静态结构的部分却以动态参数的方式传来传去，无形之中拉长了参数列表。长参数列表固然可以用一个类进行封装，但能够封装出这个类的前提条件是：这些参数属于一个类，有相同的变化原因。应对长参数列表主要的方式就是减少参数的数量，一种最直接的方式就是将参数列表封装成一个类。但并不是说所有的情况都能封装成类来解决，我们还要分析是否所有的参数都有相同的变动频率。变化频率相同，则封装成一个类。变化频率不同的话：静态不变的，可以成为软件结构的一部分；多个变化频率的，可以封装成几个类。三、告别标记不要在函数中使用布尔类型的标识参数来控制内部逻辑，true 的时候走这块逻辑，false 的时候走另一块逻辑。这明显违背了单一职责原则和接口隔离原则。public: void editChapter(const int chapterId, const String title, const String content, const boolean apporved) { ... }使用标记参数，是程序员初学编程时常用的一种手法，不过，正是因为这种手法实在是太好用了，造成的结果就是代码里面彩旗（flag）飘飘，各种标记满天飞。不仅变量里有标记，参数里也有。解决标记参数，一种简单的方式就是，将标记参数代表的不同路径拆分出来。// 普通的编辑，需要审核 public: void editChapter(const long chapterId, const String title, cosnt String content) { ... } // 直接审核通过的编辑 public: void editChapterWithApproval(const long chapterId, const String title, const String content) { ... }有的是布尔值的形式，有的是以枚举值的形式，还有的就是直接的字符串或者整数。无论哪种形式，我们都可以通过拆分函数的方式将它们拆开。在重构中，这种手法叫做移除标记参数（Remove Flag Argument）。Note:以下这种情况可以考虑不要移除标志位，不过，如果函数是 private 私有函数，影响范围有限，或者拆分之后的两个函数经常同时被调用，我们可以酌情考虑保留标识参数。// 拆分成两个函数的调用方式 boolean isVip = false; //...省略其他逻辑... if (isVip) { buyCourseForVip(userId, courseId); } else { buyCourse(userId, courseId); } // 保留标识参数的调用方式更加简洁 boolean isVip = false; //...省略其他逻辑... buyCourse(userId, courseId, isVip);四、考虑函数是否职责单一虑函数是否职责单一，是否能通过拆分成多个函数的方式来减少参数public: User getUser(String username, String telephone, String email); // 拆分成多个函数 User getUserByUsername(String username); User getUserByTelephone(String telephone); User getUserByEmail(String email);
5.大类：如何避免写出难以理解的大类？
大类一种表现形式就是我们上节课讲到的长函数，一个类只要有几个长函数，那它就肯定是一眼望不到边了; 还有一种表现形式，类里面有特别多的字段和函数。 类之所以巨大，大部分原因都是违反了单一职责原则。 记住：把类写小，越小越好。一、分模块的程序人类面对复杂事物给出的解决方案是分而治之。所以，我们看到几乎各种程序设计语言都有自己的模块划分方案，从最初的按照文件划分，到后来，使用面向对象方案按照类进行划分，本质上，它们都是一种模块划分的方式。这样，人们面对的就不再是细节，而是模块，模块的数量显然会比细节数量少，人们的理解成本就降低了。实现某个功能时不知道该用哪个函数了，想用哪个函数翻半天都找不到了，只用到一个小功能要引入整个类（类中包含很多无关此功能实现的函数）的时候，这就说明类的行数过多了。如果一个类里面的内容太多，它就会超过一个人的理解范畴，顾此失彼就在所难免了。二、大类的产生想要理解怎么拆分一个大类，我们需要知道，这些类是怎么变成这么大的。1、职责不单一class User { private: long userId; String name; String nickname; String email; String phoneNumber; AuthorType authorType; ReviewStatus authorReviewStatus; EditorType editorType; ... }面对这样一个类时，我们要问的第一个问题就是，这个类里的字段都是必需的吗？问题所在：首先，普通的用户既不是作者，也不是编辑。作者和编辑这些相关的字段，对普通用户来说，都是没有意义的。其次，对于那些成为了作者的用户，编辑的信息意义也不大，因为作者是不能成为编辑的，反之亦然，编辑也不会成为作者，作者信息对成为编辑的用户也是没有意义的。在这个类的设计里面，总有一些信息对一部分人是没有意义，但这些信息对于另一部分人来说又是必需的。之所以会出现这样的状况，关键点就在于，这里只有“一个”用户类。普通用户、作者、编辑，这是三种不同角色，来自不同诉求的业务方关心的是不同的内容。只是因为它们都是这个系统的用户，就把它们都放到用户类里，造成的结果就是，任何业务方的需求变动，都会让这个类反复修改。这种做法实际上是违反了单一职责原则。单一职责原则，它让我们把模块的变化纳入考量。单一职责原则是衡量软件设计好坏的一把简单而有效的尺子，通常来说，很多类之所以巨大，大部分原因都是违反了单一职责原则。而想要破解“大类”的谜题，关键就是能够把不同的职责拆分开来。解决办法：虽然这是一个类，但其实，它把不同角色关心的东西都放在了一起，所以，它变得如此庞大。我们只要把不同的信息拆分开来，问题也就迎刃而解了。下面就是把不同角色拆分出来的结果：class User { private: long userId; String name; String nickname; String email; String phoneNumber; ... }class Author { private: long userId; AuthorType authorType; ReviewStatus authorReviewStatus; ... }class Editor { private: long userId; EditorType editorType; ... }我们拆分出了 Author 和 Editor 两个类，把与作者和编辑相关的字段分别移到了这两个类里面。在这两个类里面分别有一个 userId 字段，用以识别这个角色是和哪个用户相关。这个大 User 类就这样被分解了。Note:评价一个类的职责是否足够单一，我们并没有一个非常明确的、可以量化的标准，因为随着需求的改变，就有可能发生变化。我们可以先写一个粗粒度的类，满足业务需求。随着业务的发展，如果粗粒度的类越来越庞大，代码越来越多，这个时候，我们就可以将这个粗粒度的类，拆分成几个更细粒度的类。这就是所谓的持续重构2、字段未分组大类的产生往往还有一个常见的原因，就是字段未分组。比如，我们看一下上面拆分的结果，那个新的 User 类：class User { private: long userId; String name; String nickname; String email; String phoneNumber; ... }即便相比于原来的 User 类小了许多，这个类依然也不算是一个小类，原因就是，这个类里面的字段并不属于同一种类型的信息。比如，userId、name、nickname 几项，算是用户的基本信息，而 email、phoneNumber 这些则属于用户的联系方式。从需求上看，基本信息是那种一旦确定就不怎么会改变的内容，而联系方式则会根据实际情况调整，比如，绑定各种社交媒体的账号。所以，如果我们把这些信息都放到一个类里面，这个类的稳定程度就要差一些。所以，我们可以根据这个理解，把 User 类的字段分个组，把不同的信息放到不同的类里面。class User { private: long userId; String name; String nickname; Contact contact; ... }class Contact { private: String email; String phoneNumber; ... }这里我们引入了一个 Contact 类（也就是联系方式），把 email 和 phoneNumber 放了进去，后面再有任何关于联系方式的调整就都可以放在这个类里面。经过这次调整，我们把不同的信息重新组合了一下，但每个类都比原来要小。解决方法：前面是根据职责，拆分出了不同的实体，后面是将字段做了分组，用类把不同的信息分别做了封装。或许你已经发现了，所谓的将大类拆解成小类，本质上在做的工作是一个设计工作。我们分解的依据其实是单一职责这个重要的设计原则。3、类中成员的排列顺序成员变量和函数之间：在类中，成员变量排在函数的前面。成员变量之间或函数之间，都是按照“先静态（静态函数或静态成员变量）、后普通（非静态函数或非静态成员变量）”的方式来排列的。除此之外，成员变量之间或函数之间，还会按照作用域范围从大到小的顺序来排列，先写 public 成员变量或函数，然后是 protected 的，最后是 private 的。类内部成员的排列顺序：函数之间的排列顺序，会按照刚刚我们提到的作用域的大小来排列。实际上，还有另外一种排列习惯，那就是把有调用关系的函数放到一块。比如，一个 public 函数调用了另外一个 private 函数，那就把这两者放到一块。三、错误的思想如果我们把大类都拆成小类，类的数量就会增多？ —>并不会程序设计语言早就已经有了很好的解决方案，所以，我们会看到在各种程序设计语言中，有诸如包、命名空间之类的机制，将各种类组合在一起
3.重复代码：简单需求到处修改，怎么办？
复制粘贴是最容易产生重复代码的地方，所以，一个最直白的建议就是，不要使用复制粘贴。真正应该做的是，先提取出函数，然后，在需要的地方调用这个函数，把公共的部分先统一掉。 短函数常常能让编译器的优化功能运转更良好，因为短函数可以更容易地被缓存。一、重复的结构 public: void sendBook() { try { service_.sendBook(); } catch (Throwable t) { this.notification_.send(new SendFailure(t))); throw t; } } public: void sendChapter() { try { service_.sendChapter(); } catch (Throwable t) { notification_.send(new SendFailure(t))); throw t; } } public: void startTranslation() { try { service_.startTranslation(); } catch (Throwable t) { notification_.send(new SendFailure(t))); throw t; } }这三段函数业务的背景是：一个系统要把作品的相关信息发送给翻译引擎。所以，结合着代码，我们就不难理解它们的含义，sendBook 是把作品信息发出去，sendChapter 就是把章节发送出去，而 startTranslation 则是启动翻译。我们可以看到，虽然这三个函数调用的业务代码不同，但它们的结构是一致的，其基本流程可以理解为：调用业务函数；如果出错，发通知。上面三段代码如何进行重构，将业务抽象出来，封装一个函数将公共的内容放到里面private: void executeTask(const Runnable& runnable) { try { runnable.run(); } catch (Throwable t) { notification_.send(new SendFailure(t))); throw t; } } public: void sendBook() { executeTask(service_::sendBook); } public: void sendChapter() { executeTask(service_::sendChapter); } public: void startTranslation() { executeTask(service_::startTranslation); }理解到这一点，我们就容易发现结构上的相似之处。比如在上面的例子中，发送作品信息、发送章节、启动翻译之所以看上去是三件不同的事，只是因为它们的动词不同，但是除了这几个动词之外的其它部分是相同的，所以，它们在结构上是重复的。Note:当有catch这种异常场景时应该如何重构的技巧二、if else表达式造成的代码重复只要你看到 if 语句出现，而且 if 和 else 的代码块长得又比较像，多半就是出现了这个坏味道。如果你不想所有人都来玩“找茬”游戏，赶紧消灭它。if (user.isEditor()) { service.editChapter(chapterId, title, content, true); } else { service.editChapter(chapterId, title, content, false); }if 选择的一定是两段不同的业务处理。但只要你稍微看一下，就会发现，if 和 else 两段代码几乎是一模一样的。在经过仔细地“找茬”之后，才能发现，原来是最后一个参数不一样。只有参数不同，是不是和前面说的重复代码是如出一辙的？没错，这其实也是一种重复代码。解决办法：写代码要有表达性。把意图准确地表达出来，是写代码过程中非常重要的一环。显然，这里的 if 判断区分的是参数，而非动作。所以，我们可以把这段代码稍微调整一下，会让代码看上去更容易理解：boolean approved = user.isEditor(); service.editChapter(chapterId, title, content, approved);Note:改进1：这里我把 user.isEditor() 判断的结果赋值给了一个 approved 的变量，而不是直接作为一个参数传给 editChapter，这么做也是为了提高这段代码的可读性。因为 editChapter 最后一个参数表示的是这个章节是否审核通过。通过引入 approved 变量，我们可以清楚地看到，一个章节审核是否通过的判断条件是“用户是否是一个编辑”，这种写法会让代码更清晰。改进2：如果将来审核通过的条件改变了，变化的点全都在 approved 的这个变量的赋值上面。如果你追求更有表达性的做法，甚至可以提取一个函数出来，这样，就把变化都放到这个函数里了，就像下面这样：boolean approved = isApproved(user); service.editChapter(chapterId, title, content, approved); private: boolean isApproved(final User user) { return user.isEditor(); }改进3：其实也可以将两个业务分别使用两个不同的函数来命名，这样就可以避免使用bool类型变量作为函数形参 boolean approved = isApproved(user); if (approved) { EditChapter(chapterId, title, content); } else { NotEditChapter(chapterId, title, content); } private boolean isApproved(final User user) { return user.isEditor(); } 三、何时应该把代码放进一个独立的函数中case1: 代码的长度，当一个函数的代码的长度应该能在一横屏中显示case2:从复用的角度考虑，认为只要用过不止一次的代码，就应该单独放进一个函数case3:个人认为最合理的应该是，将意图与实现分开，如果你需要花时间浏览一段代码才能弄清它到底在干什么，那么就应该将其提炼到一个函数中，并根据它所做的事为其命名。Tips:可以反向来判断是否真的需要去提炼函数：当提炼完函数时，发现并不能找出一个更有意义的名字来命名，可以再把它内联回去。四、长函数的解决方法1、提炼函数1.1、创造一个新的函数要想一个好的名字，提炼的过程中也会有好的名字出现将待提炼的代码从源函数复制到新建的目标函数中，检查提炼出的函数是否有访问不到的变量。若是，则以参数的形式传递给新的函数。case1：如果这个变量不只在新的函数中使用，则作为参数传递给新的函数就行。case2：如果这个变量只在新的函数中使用，则将提炼部分声明之外的声明也加到新的函数中。1.2、仔细检查提炼出的函数2、移动语句如果重复代码只是类似而不是完全相同，请首先尝试试用移动语句重组代码顺序，把相似的部分放在一起以便提炼。3、函数上移当类中有两个函数出现代码重复时，就会面临修改其中一个而导致另一个未被修改的风险总结：开发过程中导致代码重复的原因：1 代码结构不合理导致同一个实现散落各处由于初期代码结构设计不合理导致后续功能实现无法快速找到已有实现，或者找到了但是不好引用已有实现。改进：初期设计代码逻辑合理，对于不合理的地方要及时重构 防止演变成原因2 2 为了稳定性，不动老逻辑 拷贝一份。由于对于业务的不熟悉和对自己代码能力的不信任不敢重构导致。改进通过微重构进行多次迭代小改进慢慢优化 3 写的时候为了快 由于时间紧张或者能力问题无法识别出的坏代码。改进提升能力在软件开发里，有一个重要的原则叫做 Don't Repeat Yourself（不要重复自己，简称 DRY）在一个系统中，每一处知识都必须有单一、明确、权威地表述。 Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
常见的命名规则是：类名是一个名词，表示一个对象，而方法名则是一个动词，或者是动宾短语，表示一个动作。eg. 动词+名词，不要是名词+动词一、命名多长最合适在足够表达其含义的情况下，命名当然是越短越好。这样一方面能让命名短一些，另一方面又不影响阅读理解，比如，sec 表示 second、str 表示 string、num 表示 number、doc 表示 document。对于作用域比较小的变量，我们可以使用相对短的命名，比如一些函数内的临时变量。对于类名这种作用域比较大的，我更推荐用长的命名方式。二、违反语法规则的命名public: void completedTranslate(const List<ChapterId> chapterIds) { List<Chapter> chapters = repository.findByChapterIdIn(chapterIds); chapters.forEach(Chapter::completedTranslate); repository.saveAll(chapters); }初看之下，这段代码写得还不错，它要做的是将一些章节的信息标记为翻译完成。似乎函数名也能反映这个意思，但仔细一看你就会发现问题。作者想表达的是“完成翻译”，因为是已经翻译完了，所以，他用了完成时的 completed，而翻译是 translate。这个函数名就成了 completedTranslate。但是上面这个并不是动宾短语：completedTranslate 并不是一个有效的动宾结构。如果把这个名字改成动宾结构，只要把“完成”译为 complete，“翻译”用成它的名词形式 translation 就可以了。所以，这个函数名可以改成 completeTranslation：public: void completeTranslation(const List<ChapterId> chapterIds) { List<Chapter> chapters = repository.findByChapterIdIn(chapterIds); chapters.forEach(Chapter::completeTranslation); repository.saveAll(chapters); }再比如：一个函数名是 retranslation，其表达的意图是重新翻译，但作为函数名，它应该是一个动词，所以，正确的命名应该是 retranslate。三、不准确的英语词汇public: enum ChapterAuditStatus { PENDING, APPROVED, REJECTED; }如果你用审核作为关键字去字典网站上搜索，确实会得到 audit 这个词。public: enum ChapterReviewStatus { PENDING, APPROVED, REJECTED; }我把 audit 和 review 同时放到了搜索引擎里查了一下。原来，audit 会有更官方的味道，更合适的翻译应该是审计，而 review 则有更多核查的意思，二者相比，review 更适合这里的场景。于是，章节的审核状态也统一使用了 review：这种情况的解决办法：；最好的解决方案还是建立起一个业务词汇表，千万不要臆想。一般情况下，我们都可以去和业务方谈，共同确定一个词汇表，包含业务术语的中英文表达。这样在写代码的时候，你就可以参考这个词汇表给变量和函数命名。这个词汇表: 一方面，词汇表给出的都是业务术语，同时也给出了在特定业务场景下的含义；另一方面，它也给出了相应的英文，省得你费劲心思去思考。当你遇到了一个词汇表中没有的术语怎么办呢？那就需要找出这个术语相应的解释，然后，补充到术语表里。四、英语单词拼写错误对今天的程序员来说，工具已经很进步了，像 IntelliJ IDEA 这样的 IDE 甚至可以给你提示代码里有拼写错误（typo），不少支持插件的工具也都有自己的拼写检查插件，比如Visual Studio Code的**Code Spell Checker插件**五、其他简单的坏味道使用拼音进行命名；使用不恰当的单词简写（比如，多个单词的首字母，或者写单词其中的一部分）。六、命名要可读、可搜索我这里所说的“可读”，指的是不要用一些特别生僻、难发音的英文单词来命名。在命名的时候，最好能符合整个项目的命名习惯。大家都用“selectXXX”表示查询，你就不要用“queryXXX”；大家都用“insertXXX”表示插入一条数据，你就要不用“addXXX”，统一规约是很重要的，能减少很多不必要的麻烦。
4 长函数：为什么你总是不可避免地写出长函数？
从不同的抽象层次理解单一职责一、长函数的产生不过，限制函数长度，是一种简单粗暴的解决方案。最重要的是你要知道，长函数本身是一个结果，如果不理解长函数产生的原因，还是很难写出整洁的代码。接下来，我们就来看看长函数是怎么产生的。 如何确定该提炼哪一段代码：寻找注释1、多长的函数才算“长”？那就是不要超过一个显示屏的垂直高度。比如，在我的电脑上，如果要让一个函数的代码完整地显示在 IDE 中，那最大代码行数不能超过 50。这个说法我觉得挺有道理的。因为超过一屏之后，在阅读代码的时候，为了串联前后的代码逻辑，就可能需要频繁地上下滚动屏幕，阅读体验不好不说，还容易出错2、以性能为由不过，这个观点在今天是站不住的。性能优化不应该是写代码的第一考量。一方面，一门有活力的程序设计语言本身是不断优化的，无论是编译器，还是运行时，性能都会越来越好；另一方面，可维护性比性能优化要优先考虑，当性能不足以满足需要时，我们再来做相应的测量，找到焦点，进行特定的优化。这比在写代码时就考虑所谓性能要更能锁定焦点，优化才是有意义的。现代编程语言几乎已经完全免除了进程内的函数调用开销。3、平铺直叙一种最常见的原因也会把代码写长，那就是写代码平铺直叙，把自己想到的一点点罗列出来。//这段代码来自java public void executeTask() { ObjectMapper mapper = new ObjectMapper(); CloseableHttpClient client = HttpClients.createDefault(); List<Chapter> chapters = this.chapterService.getUntranslatedChapters(); for (Chapter chapter : chapters) { // Send Chapter SendChapterRequest sendChapterRequest = new SendChapterRequest(); sendChapterRequest.setTitle(chapter.getTitle()); sendChapterRequest.setContent(chapter.getContent()); HttpPost sendChapterPost = new HttpPost(sendChapterUrl); CloseableHttpResponse sendChapterHttpResponse = null; String chapterId = null; try { String sendChapterRequestText = mapper.writeValueAsString(sendChapterRequest); sendChapterPost.setEntity(new StringEntity(sendChapterRequestText)); sendChapterHttpResponse = client.execute(sendChapterPost); HttpEntity sendChapterEntity = sendChapterPost.getEntity(); SendChapterResponse sendChapterResponse = mapper.readValue(sendChapterEntity.getContent(), SendChapterResponse.class); chapterId = sendChapterResponse.getChapterId(); } catch (IOException e) { throw new RuntimeException(e); } finally { try { if (sendChapterHttpResponse != null) { sendChapterHttpResponse.close(); } } catch (IOException e) { // ignore } } // Translate Chapter HttpPost translateChapterPost = new HttpPost(translateChapterUrl); CloseableHttpResponse translateChapterHttpResponse = null; try { TranslateChapterRequest translateChapterRequest = new TranslateChapterRequest(); translateChapterRequest.setChapterId(chapterId); String translateChapterRequestText = mapper.writeValueAsString(translateChapterRequest); translateChapterPost.setEntity(new StringEntity(translateChapterRequestText)); translateChapterHttpResponse = client.execute(translateChapterPost); HttpEntity translateChapterEntity = translateChapterHttpResponse.getEntity(); TranslateChapterResponse translateChapterResponse = mapper.readValue(translateChapterEntity.getContent(), TranslateChapterResponse.class); if (!translateChapterResponse.isSuccess()) { logger.warn("Fail to start translate: {}", chapterId); } } catch (IOException e) { throw new RuntimeException(e); } finally { if (translateChapterHttpResponse != null) { try { translateChapterHttpResponse.close(); } catch (IOException e) { // ignore } } } }主要原因就是把前面所说的逻辑全部平铺直叙地摆在那里了，这里既有业务处理的逻辑，比如，把章节发送给翻译引擎，然后，启动翻译过程；又有处理的细节，比如，把对象转成 JSON，然后，通过 HTTP 客户端发送出去。从这段代码中，我们可以看到平铺直叙的代码存在的两个典型问题：把多个业务处理流程放在一个函数里实现；把不同层面的细节放到一个函数里实现。重构后：public void executeTask() { ObjectMapper mapper = new ObjectMapper(); CloseableHttpClient client = HttpClients.createDefault(); List<Chapter> chapters = this.chapterService.getUntranslatedChapters(); for (Chapter chapter : chapters) { String chapterId = sendChapter(mapper, client, chapter); translateChapter(mapper, client, chapterId); } }将一个长函数拆分为不同层面的短函数, 方法可以参考后面的”函数语句放在同一抽象层级“函数中间实现的细节private String sendChapter(final ObjectMapper mapper, final CloseableHttpClient client, final Chapter chapter) { SendChapterRequest request = asSendChapterRequest(chapter); CloseableHttpResponse response = null; String chapterId = null; try { HttpPost post = sendChapterRequest(mapper, request); response = client.execute(post); chapterId = asChapterId(mapper, post); } catch (IOException e) { throw new RuntimeException(e); } finally { try { if (response != null) { response.close(); } } catch (IOException e) { // ignore } } return chapterId; } private HttpPost sendChapterRequest(final ObjectMapper mapper, final SendChapterRequest sendChapterRequest) throws JsonProcessingException, UnsupportedEncodingException { HttpPost post = new HttpPost(sendChapterUrl); String requestText = mapper.writeValueAsString(sendChapterRequest); post.setEntity(new StringEntity(requestText)); return post; } private String asChapterId(final ObjectMapper mapper, final HttpPost sendChapterPost) throws IOException { String chapterId; HttpEntity entity = sendChapterPost.getEntity(); SendChapterResponse response = mapper.readValue(entity.getContent(), SendChapterResponse.class); chapterId = response.getChapterId(); return chapterId; } private SendChapterRequest asSendChapterRequest(final Chapter chapter) { SendChapterRequest request = new SendChapterRequest(); request.setTitle(chapter.getTitle()); request.setContent(chapter.getContent()); return request }我们只用了最简单的提取函数这个重构手法，就把一个大函数拆分成了若干的小函数。长函数往往还隐含着一个命名问题。如果你看修改后的 sendChapter，其中的变量命名明显比之前要短，理解的成本也相应地会降低。因为变量都是在这个短小的上下文里，也就不会产生那么多的命名冲突，变量名当然就可以写短一些。Note:for (Chapter chapter : chapters) 一般for循环内部分函数都可以重新提取为一个新的函数再举一个类似的例子：// 重构前的代码 public List<String> appendSalts(List<String> passwords) { if (passwords == null || passwords.isEmpty()) { return Collections.emptyList(); } List<String> passwordsWithSalt = new ArrayList<>(); for (String password : passwords) { if (password == null) continue; if (password.length() < 8) { // ... } else { // ... } } return passwordsWithSalt; }重构后：// 重构后的代码：将部分逻辑抽成函数 public List<String> appendSalts(List<String> passwords) { if (passwords == null || passwords.isEmpty()) { return Collections.emptyList(); } List<String> passwordsWithSalt = new ArrayList<>(); for (String password : passwords) { if (password == null) continue; passwordsWithSalt.add(appendSalt(password)); } return passwordsWithSalt; } private String appendSalt(String password) { String passwordWithSalt = password; if (password.length() < 8) { // ... } else { // ... } return passwordWithSalt; }4、一次加一点有时，一段代码一开始的时候并不长，就像下面这段代码，它根据返回的错误进行相应地错误处理：if (code == 400 || code == 401) { // 做一些错误处理 }然后，新的需求来了，增加了新的错误码，它就变成了这个样子：if (code == 400 || code == 401 || code == 402) { // 做一些错误处理 }任何代码都经不起这种无意识的累积，每个人都没做错，但最终的结果很糟糕。代码变长根本是一个无意识的问题，写代码的人没有觉得自己把代码破坏了。但只要你认识到长函数是一个坏味道，后面的许多问题就自然而然地会被发掘出来，至于解决方案，你已经看到了，大部分情况下，就是拆分成各种小函数。二、分解条件表达式代码嵌套层次过深往往是因为 if-else、switch-case、for 循环过度嵌套导致的。 对于条件逻辑，将每个分支条件分解成新函数还可以带来更多好处，可以突出条件逻辑，更清楚的表明每个分支的作用，并且突出每个分支的原因。1、这种方式并不能减少循环的次数，但是可以提高代码的可读性对条件判断和每个条件分支运用提炼函数的手法 void ApdTimerController::sfpInstalledCallback(const std::string& sfp) { std::string connector = connectorFacade_.getByChild(sfp); if (**sfpFacade_.isPresent(sfpFacade_.getByPort(connector)) && connectorFacade_.isAdministrativeStateUnlocked(connector)**) { startTimer(connector); } } bool ApdTimerController::isSfpPresent(const std::string& connector) { return sfpFacade_.isPresent(sfpFacade_.getByPort(connector)); } bool ApdTimerController::isUnlocked(const std::string& connector) { return connectorFacade_.isAdministrativeStateUnlocked(connector); }也可以使用解释性变量来解释复杂表达式：void ApdTimerController::sfpInstalledCallback(const std::string& sfp) { std::string connector = connectorFacade_.getByChild(sfp); bool isDetectedStatus = sfpFacade_.isPresent(sfpFacade_.getByPort(connector)) && connectorFacade_.isAdministrativeStateUnlocked(connector); if (isDetectedStatus) { startTimer(connector); } } 可以参考如何减少圈复杂度的介绍！三、代码分割成更小的单元块善于将大块的复杂逻辑提炼成类或者函数，屏蔽掉细节，让阅读代码的人不至于迷失在细节中，这样能极大地提高代码的可读性。重构前，在 invest() 函数中，最开始的那段关于时间处理的代码，是不是很难看懂？// 重构前的代码 public: void invest(int userId, int financialProductId) { Calendar calendar = Calendar.getInstance(); calendar.setTime(date); calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1)); if (calendar.get(Calendar.DAY_OF_MONTH) == 1) { return; } //... } 解决办法：提炼函数，将逻辑抽象成一个函数// 重构后的代码：提炼函数之后逻辑更加清晰 public: void invest(int userId, int financialProductId) { if (isLastDayOfMonth(new Date())) { return; } //... } public: boolean isLastDayOfMonth(Date date) { Calendar calendar = Calendar.getInstance(); calendar.setTime(date); calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1)); if (calendar.get(Calendar.DAY_OF_MONTH) == 1) { return true; } return false; }重构之后，我们将这部分逻辑抽象成一个函数，并且命名为 isLastDayOfMonth，从名字就能清晰地了解它的功能，判断今天是不是当月的最后一天。这里，我们就是通过将复杂的逻辑代码提炼成函数，大大提高了代码的可读性。Note:只有代码逻辑比较复杂的时候，我们其实才建议提炼类或者函数。毕竟如果提炼出的函数只包含两三行代码，在阅读代码的时候，还得跳过去看一下，这样反倒增加了阅读成本。1、函数设计要职责单一单一职责原则的时候，针对的是类、模块这样的应用对象。实际上，对于函数的设计来说，更要满足单一职责原则。相对于类和模块，函数的粒度比较小，代码行数少。public: boolean checkUserIfExisting(String telephone, String username, String email) { if (!StringUtils.isBlank(telephone)) { User user = userRepo.selectUserByTelephone(telephone); return user != null; } if (!StringUtils.isBlank(username)) { User user = userRepo.selectUserByUsername(username); return user != null; } if (!StringUtils.isBlank(email)) { User user = userRepo.selectUserByEmail(email); return user != null; } return false; } // 拆分成三个函数 public: boolean checkUserIfExistingByTelephone(String telephone); boolean checkUserIfExistingByUsername(String username); boolean checkUserIfExistingByEmail(String email);拆成三个函数。2、移除过深的嵌套层次长函数的脚本检测方法：比如，在 Java 中，我们就可以把代码行的约束加到 CheckStyle 的配置文件中，就像下面这样<module name="MethodLength"> <property name="tokens" value="METHOD_DEF"/> <property name="max" value="20"/> <property name="countEmpty" value="false"/> </module>
1. 缺乏业务含义的命名：如何精准命名
大到项目名、模块名、包名、对外暴露的接口，小到类名、函数名、变量名、参数名，只要是做开发，我们就逃不过“起名字”这一关一、坏味道一：不精准的命名命名过于宽泛，不能精准描述，这是很多代码在命名上存在的严重问题，也是代码难以理解的根源所在。class Demo { public: void processChapter(long chapterId) { Chapter chapter = repository_.findByChapterId(chapterId); if (chapter == nullptr) { throw new IllegalArgumentException("Unknown chapter [" + chapterId + "]"); } chapter.setTranslationState(TranslationState.TRANSLATING); repository_.save(chapter); } }问题就出在函数名上。这个函数的名字叫 processChapter（处理章节），这个函数确实是在处理章节，但是，这个名字太过宽泛。将章节的翻译状态改成翻译中或者将章节的翻译状态改成翻译完都可以称为处理章节，那么处理章节就是一个过于宽泛的名字，没有错，但不精准。1、常见的错误我们看看下面这些词是不是经常出现在你的代码里：data、info、flag、process、handle、build、maintain、manage、modify 等等。这些名字都属于典型的过于宽泛的名字，只要稍微仔细想想，类似的名字你一定还能想出不少来。回到前面那段代码上，首先，命名要能够描述出这段代码在做的事情。这段代码在做的事情就是“将章节修改为翻译中”。那是不是它就应该叫 changeChapterToTranslating 呢？我们为什么要把翻译状态修改成翻译中，这一定是有原因的，也就是意图。函数封装的意义在于我们不想知道那么多的细节。一个好的名字应该描述意图，而非细节。我们把翻译状态修改成翻译中，是因为我们在这里开启了一个翻译的过程。所以，这段函数应该命名 startTranslation。二、坏味道二：用技术术语命名这是一种不费脑子的命名方式，但是，这种命名却会带来很多问题，因为它是一种基于实现细节的命名方式。List<Book> bookList = service.getBooks();这个 bookList 变量之所以叫 bookList，原因就是它声明的类型是 List。这种命名在代码中几乎是随处可见的，比如 xxxMap、xxxSet。Bad: 我需要把这个变量的类型从 List 改成 Set。变量类型你一定会改，但变量名你会改吗？这还真不一定，一旦出现遗忘，就会出现一个奇特的现象，一个叫 bookList 的变量，它的类型是一个 Set。这样，一个新的混淆就此产生了。List<Book> books = service.getBooks();也许你发现了，这个名字其实更简单，但从表意的程度上来说，它却是一个更有效的名字。事实上，在实际的代码中，技术名词的出现，往往就代表着它缺少了一个应有的模型。1、改进点：应该用业务语言写代码无论是不精准的命名也好，技术名词也罢，归根结底，体现的是同一个问题：对业务理解不到位。 编写可维护的代码要使用业务语言。怎么才知道自己的命名是否用的是业务语言呢？一种简单的做法就是，把这个词讲给产品经理，看他知不知道是怎么回事。 改进函数名字的好办法：先写一句注释描述这个函数的用途，再把这句注释变成函数的名字让每个人根据自己的理解来命名，确实就有可能出现千奇百怪的名字，所以，一个良好的团队实践是，建立团队的词汇表，让团队成员有信息可以参考。public: void approveChapter(long chapterId, long **userId**) { ... }这个函数的意图是，确认章节内容审核通过。这里有一个问题，chapterId 是审核章节的 ID，这个没问题，但 userId 是什么呢？了解了一下背景，我们才知道，之所以这里要有一个 userId，是因为这里需要记录一下审核人的信息，这个 userId 就是审核人的 userId。通过业务的分析，我们会发现，这个 userId 并不是一个好的命名，因为它还需要更多的解释，更好的命名是 reviewerUserId，之所以起这个名字，因为这个用户在这个场景下扮演的角色是审核人（Reviewer）。public: void approveChapter(long chapterId, long **reviewerUserId**) { ... }这个坏味道也是一种不精准的命名，但它不是那种一眼可见的坏味道，而是需要在业务层面上再进行讨论，所以，它是一种更高级的坏味道。三、重构中对函数进行改名如果只是用于改名，我会将这个重构称做函数改名double circum(radius) { return 2 * Math.PI * radius; }1、简单做法找出所有使用旧的函数声明的地方，将它们改为使用新的函数声明解决方案1：double circumference(radius) { return 2 * Math.PI * radius; }2、迁移式做法通过提炼一个新函数的方式添加一层间接，并把旧函数的调用转发给新函数。比如要重构一个新的对外发布的API，在提炼出新函数之后，你可以暂停重构，将原来的函数声明为“不推荐使用”(desprecated)，然后登客户端也使用新函数的时候，再将后端旧的接口给删除解决方案2： double circum(radius) { return circumference(radius); } double circumference(radius) { return 2 * Math.PI * radius; }这种重构手法同样适用于我无权修改的代码。四、重构中对变量进行改名对于作用域超出一次函数调用的字段，则需要更用心命名。1、变量作用域只在一个函数对其改名最为简单，找到所有变量的引用，然后修改过来就可以了2、变量的出现不只一个函数在代码库的很多地方都会出现，有些地方读取变量值，有些地方更新它的值。对于这种情况，一般使用getter或者setter对其进行封装。string getTitle() { return title; } void setTitle(string newTitle) { title = newTitle; }如果这个变量title被广泛使用，以至于我觉得应该先做封装才敢做改名，那就有必要保持这个状态，将变量的疯转在函数后面。先封装完getTitle和setTitle 再去改title的名字。3、利用上下文简化命名class User { private: String userName; String userPassword; String userAvatarUrl; //... }我们没有在成员变量的命名中重复添加“user”这样一个前缀单词，而是直接命名为 name、password、avatarUrl。在使用这些属性时候，我们能借助对象这样一个上下文，表意也足够明确。User user = new User(); user.getName(); // 借助user对象这个上下文同理，函数参数也可以借助函数这个上下文来简化命名public: void uploadUserAvatarImageToAliyun(String userAvatarImageUri); //利用上下文简化为： void uploadUserAvatarImageToAliyun(String imageUri);4、给常量进行改名如果想修改的是一个常量，我可以复制这个常量，这样既不需要进行封装，又可以逐步完成改名。const string title = "C++"; const string bookTitle = title;常量取代魔法数字public: double CalculateCircularArea(double radius) { return (3.1415) * radius * radius; } // 常量替代魔法数字 public: static const Double PI = 3.1415; double CalculateCircularArea(double radius) { return PI * radius * radius; }五、重构中给字段改名class Student { public: Student(Person person) : name_(person.name), country_(person.country) { } string getName(){ return name_; } string getCountry{ return country_; } string name_; string country_; }; const Student st = {"chen xuyuan", "shanghai"};现在需要将name 字段改为firstName.第一步：修改访问函数class Student { public: Student(Person person) : firstName_(person.name), country_(person.country) { } string getName(){ return **firstName_**; } string getCountry{ return country_; } string **firstName_**; string country_; }; const Student st = {{"chen xuyuan", "shanghai"}};//persoon = {name : "chen xuyuan", country: "shanghai"}第二步：在构造函数中使用firstName\_字段class Student { public: Student(Person person) : country_(person.country) { **firstName_ = person.firstName_.empty()? person.name : person.firstName_);** } string getName(){ return firstName_; } string getCountry{ return country_; } string firstName_; string country_; }; const Student st = {{"chen xuyuan", "shanghai"}};第三步：当调用的地方改为firstName字段之后就可以修改构造函数 class Student { public: Student(Person person) : firstName_(person.firstName_), country_(person.country) { } string getName(){ return firstName_; } string getCountry{ return country_; } string firstName_; string country_; }; const Student st = {(**firstname** : "chen xuyuan", country: "shanghai"}};第四步：给访问函数改名getFirstNameclass Student { public: Student(Person person) : firstName_(person.firstName_), country_(person.country) { } string **getFirstName**(){ return firstName_; } string getCountry{ return country_; } string firstName_; string country_; }; const Student st = {(**firstname** : "chen xuyuan", country: "shanghai"}};