微信公众号:牛奶 Yoka 的小屋
有任何问题。欢迎来撩~
最近更新:2024/08/03
[大家好,我是牛奶。]
我在上一篇文章打开IDEA,程序员思考的永远只有两件事!中,通过代码命名、重复代码、合格方法三个章节,着重讲解了24种代码坏味道最常见的五种:注释、命名、重复代码、过长函数、过长的参数列表。在这篇文章中,我将通过对象数据、对象关系、代码表达力及重复的坏味道四个章节,再详细讲一讲剩下的19种坏味道。
《重构》的大佬Martin Fowler给出24种代码坏味道,但我看未必。我认为有些坏味道犯了“重复坏味道”的问题,所以我把它们汇总到最后一章重复的坏味道中,大家看完其他坏味道再看这些,便可很快理解;有些坏味道只是代码啰嗦了点,并不会对业务逻辑产生什么实质影响,我将他们放在了代码表达力的章节;还有些坏味道根因类似,比如基本类型偏执和重复的switch,所以我把它们放到一起,方便理解。本篇文章的阅读导图如下:
代码坏味道
总之,当你看完所有的坏味道,会发现大佬翻来覆去,讲的都是那几类问题。愿此篇文章,能使诸君对坏味道代码的敏感度有所提升!
对象数据定义时有哪些坑?
普通数据可变问题
可变数据是Martin Fowler大佬在《重构》第二版新增的坏味道。这个坏味道很是反直觉,我琢磨了半天,才咂巴出点味道。突然想起句名人名言:
我横竖睡不着,仔细看了半夜,才从字缝里看出字来,整段都写着几个字是“别让数据可变”!——坡迅
什么是可变数据?
任何在赋值之后仍可以修改的变量称为可变数据。例如拥有Getter方法和Setter方法的类变量(Getter方法的返参可变导致变量可变);再或者拥有类似Setter设置变更值方法的变量。(咱就说这两类变量常不常用!)
为啥可变数据很危险?
一句话概括,你不知道数据会在哪里被何人以什么方式修改。下面给一个银行存钱的简单案例,上代码:
import java.math.BigDecimal;public class BankAccount {
private BigDecimal balance;public BankAccount(BigDecimal initialBalance) {
this.balance = initialBalance;}// 存款方法
public void deposit(BigDecimal amount) {
balance = balance.add(amount);}// 取款方法
public void withdraw(BigDecimal amount) {
if (amount.compareTo(balance) < 1) {
balance = balance.subtract(amount);} else {System.out.println("Insufficient funds");}}// 获取余额方法
public BigDecimal getBalance() {
return balance;}
}
BankService类:
public class BankService {private BankAccount account;public BankService(BankAccount account) {this.account = account;}// 服务方法,意外地修改了账户余额public void performService() {// 假设这里执行了一些操作,需要修改账户余额account.deposit(BigDecimal.valueOf(100)); // 意外地给账户增加了100元}
}
主函数:
public class Main {public static void main(String[] args) {BankAccount account = new BankAccount(BigDecimal.valueOf(1000)); // 初始余额1000元BankService service = new BankService(account);// 显示初始余额System.out.println("Initial balance: " + account.getBalance());// 执行服务,意外修改了账户余额service.performService();// 显示修改后的余额,意外地增加了100元System.out.println("Balance after service: " + account.getBalance());}
}
代码中的银行余额balance为可变变量,该变量会被传递到银行服务类BankService中时,该类可能是另一位同事负责开发,他在执行服务的方法中意外修改了银行余额,最终导致BankAccount类中的可变变量发生了非预期的变化。
数据可变带来的不可控和难定位等风险远比我们想象严重,甚至出现了完全建立在“数据永不改变”概念基础上的软件开发流派——函数式编程。在该编程范式中,如果要更新一个数据结构,就返回一份新的数据副本,旧的数据仍保持不变。
我仔细想了想,发现写过的大部分类中的可变变量,基本都是一次赋值,需要再次赋值都会new出一个新类。所以,针对可变数据,Martin Fowler给出的优化建议是:
-
将可变数据设置为私有且不可变状态(private final)
-
将参数赋值优化为使用有参构造函数
-
必须二次赋值时使用新的数据副本
作者倡导类的创建和类变量的赋值要同时进行,创建类的时候就要初始化变量,自此不可更改,非要更改就重新创建一个新类。
上述银行余额问题,满足优化建议第二点,但不满足第一三点。而银行余额是必须要存取,因此,BankAccount类代码优化后为:
public class BankAccount {private final BigDecimal balance;public BankAccount(BigDecimal initialBalance) {this.balance = initialBalance;}public BankAccount deposit(BigDecimal amount) {if (amount.compareTo(BigDecimal.ZERO) <= 0) {throw new IllegalArgumentException("Deposit amount must be positive.");}return new BankAccount(this.balance.add(amount));}public BankAccount withdraw(BigDecimal amount) {if (amount.compareTo(BigDecimal.ZERO) <= 0 || amount.compareTo(this.balance) > 0) {throw new IllegalArgumentException("Invalid withdrawal amount.");}return new BankAccount(this.balance.subtract(amount));}public BigDecimal getBalance() {return balance;}
}
优化后,余额balance成为不可变变量,依然作为构造函数参数不变。同时存取方法返回的是新的实例对象,这样即使在BankService类的performService方法中误操作,最后在主函数中打印account.getBalance()银行余额,依然是1000元,不会平白无故新增100元。大家可以在编辑器中进行简单测试。
新的问题随之而来,如果有个类中有1000个变量,难道往构造函数中传1000个参数么?答案是,不用,用构建器替代构造函数(构建器可直接使用Build注解)。
上代码:
@Data
public class BankAccount {public BigDecimal balance;public String amount;
}
应优化为
@Builder
public class BankAccount {private final BigDecimal balance;private final String amount;
}
构建器使用方式:
BankAccount bankAccount = BankAccount.builder().balance(0.00).amount("1000").build();
使用构建器后,就可以选择性的赋值参数,添加参数也不影响旧代码。提高了代码的扩展性和可维护性。
如果可变数据赋值固定,赋值总是那几个值,比如订单状态等。开源项目Moco作者郑晔大佬还提出一个优化建议:将可变数据的赋值操作封装为一个不带参的类方法,取代Setter方法。比如这种:
原代码:
public void approve(final long bookId) {
...
book.setReviewStatus(ReviewStatus.APPROVED);
...
}
优化后可为:
public void approve(final long bookId) {
...
book.approve();
...
}
class Book {
public void approve() {this.reviewStatus = ReviewStatus.APPROVED;
}
}
这样既能表明该操作逻辑,又能避免变量不可控,同时维持了可变数据的可变特性。
全局数据可变问题
明白了可变数据,全局数据的可变问题就很好理解了。全局数据的优点:全局可修改。但其带来优点的同时也带来风险,你很难知道在全局哪处什么时候做了修改,这种BUG定位又极其困难。举个例子,某个类中定义了两个public静态变量:
import java.util.HashMap;
import java.util.Map;public class ShoppingInfo {public static Map<String, Integer> productAndNum = new HashMap<>();public static Integer productUpLimit = 10;}
但是在其他地方又出现令人迷惑的修改:
public class Market {private Map<String, String>productAndNum = new HashMap<>();public static Double calculateChenGuangMarketPrice(String type, Integer number) {Double prices;// 迷惑的修改productUpLimit = 3;productAndNum = new HashMap<>();
}
有些IDEA不会提示带ShoppingInfo类名来使用静态变量(如ShoppingInfo.productUpLimit),如果无意中命名了一个和静态变量同名的变量,极其容易误改静态变量值。因此建议:
-
全局变量使用全部大写命名
-
将全局变量和使用全局变量的方法聚合到一个类中
-
将该类中的全局变量设置关键字static final,保证该变量类中私有且一旦赋值不可被其他地方更改
全局数据的重构优化
一句话,将使用全局变量的代码提取为方法并移动至全局变量对应的类中,之后实例化方法,设置全局变量为私有且不可变。对应的快捷键如下:
快捷键 | 目的 |
---|---|
Crtl+Alt+M | 选中使用全局变量的代码提取方法 |
F6 | 将提取的方法转移到全局变量对应的类中 |
Refactor->Convert To Instance Method | 将移动后的静态方法转变为该类的实例化方法 |
数据泥团
你常常可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。——Robert C. Martin
这个坏味道相对容易,如大佬所言,多个类中数据有重复或者多个方法中的参数有重复,比如联系人姓名,性别,电话这几个参数,完全可以聚合到联系人类中,省、市、区、县等参数完全可以聚合到地址类中。优化方法:引入参数对象,把关联性强的参数聚合到一起。
排查方法:
删掉众多数据中的一项。如果这么做,其他数据有没有因而失去意义?如果它们不再有意义,这就是一个明确信号:你应该为它们产生一个新对象。——Robert C. Martin
趁机复习几个重构优化的快捷键:
refactor的快捷键为:Ctrl+Alt+Shift+T
快捷键 | 功能 |
---|---|
Refactor+Extract Delegate | 提取一个新类 |
Refactor+Introduce Parameter Object | 引入一个新的参数对象 |
Ctrl+Alt+M | 提取方法 |
Ctrl+Alt+N | 内联 |
F6 | 移动方法 |
Crtl+Alt+C | 提炼常量 |
Alt+Delete | 安全刪除 |
临时字段
有时你会看到这样的类:其内部某个字段仅为某种特定情况而设。这样的代码让人不易理解,因为你通常认为对象在所有时候都需要它的所有字段。——Robert C. Martin
Martin大佬认为,特殊情况才使用的类变量属于临时变量,这类变量不应该出现在该类中。简而言之,如果一个字段的使用频率很低,那这个字段及其关联的方法应搞出去,要么字段用方法替换掉,要么单独放到一个类中。比如有个账户类,类中增加余额和减少余额方法使用频率很高,但是账户评估使用频率很低,那就把账户评估相关的方法和字段抽取到一个单独的类中。这个坏味道暗含的原则是,一个类中所有字段的使用频率应该是相同的,不同频的字段不应放到一起。否则会让人难以理解。
举个取代临时字段的例子:
public class GradeBook {private List<Double> grades;private Double maxGrade; // 临时字段,用于存储最高分public GradeBook(List<Double> grades) {this.grades = grades;}public void processGrades() {maxGrade = Double.MIN_VALUE; // 初始化最高分为可能的最低值for (Double grade : grades) {if (grade > maxGrade) {maxGrade = grade;}}// 使用 maxGrade 做其他处理...}public Double getMaxGrade() {return maxGrade;}
}
maxGrade是临时字段,使用方法优化后:
public class GradeBook {private List<Double> grades;public GradeBook(List<Double> grades) {this.grades = grades;}public void processGrades() {Double maxGrade = findMaxGrade();// 使用 maxGrade 做其他处理...}private Double findMaxGrade() {return grades.stream().max(Double::compare); // 使用 Stream API 找出最高分}public Double getMaxGrade() {return findMaxGrade();}
}
上述案例中存储最高分的maxGrade字段使用频率很低,则使用查找最大分数方法对该字段进行代替。
基本类型偏执
这名字起的,好像大家都偏爱基本类型。但其实并非偏爱或者偏执。我觉得它应该称基本类型陷阱更为合适。为何出此言,且听我道来:
当我们面对业务需求中存在价格,坐标等名词时,我们起手就是double price和int[][]Coordinates;这如果是我们在刷算法题集,那这样写完全ok。但在实际业务需求中,价格 ≠ "double price",坐标 ≠ "int[][]Coordinates"。为啥?double类型的范围是-1.79E+308 ~ +1.79E+308,请问价格可以是负数么?int的类型是 -2147483648~2147483647,请问世界坐标系的范围会达到200多万么?
显然不等价,所以我们一定会对这样的类型进行处理,比如获取到对应的价格就会校验是否为负数,如果为负数就抛出异常。
if (price < 0) {throw new IllegalArgumentException("Price should be positive");}
问题是,难道我们要在所有用到价格的地方进行异常校验么?显然太重复了。最好的办法就是,把价格封装为一个类对象,用对象取代基本类型:
class Price {private long price;public Price(final double price) {if (price < 0) {throw new IllegalArgumentException("Price should be positive");}this.price = price;}}
一旦用对象替代,你就可以在对象内为所欲为,比如限定价格范围,比如要求价格必须保留两位小数,比如限定价格的币种等。类对象相对基本类型更接近于实际事物,不断完善类对象的过程,其实就是对现实事物建模的过程。
还有一类使用的基本类型的参数叫类型码,即用来控制走哪一个代码逻辑分支的参数。还记得我在上一篇文章讲的标记参数么?(打开IDEA,程序员思考的永远只有两件事!),标记参数更多指布尔类型参数,类型码则参数类型不定,但两者都用来区分代码逻辑分支。根据SRP原则,本身一个方法就建议只履行一个相关职责。一旦有了类型码或者标记参数,他便存在多个分支履行多个职责的可能。在上一篇文章我建议将标记参数区分的逻辑代码拆分成多个不同的方法。而面对类型码,同样如果每种类型下的处理逻辑相对复杂,那就把各分支的逻辑拆分成各个子类,使用子类取代类型码是常见的一种重构手段。在实际编码时,多分支情况具体运行哪一个子类逻辑,可以在工厂模式中进行选择。
这里需要强调的是,子类取代类型码并不能消除条件分支,它只是将分支的逻辑从方法中搬移到子类中,然后在工厂类中选择具体的类,目的是为了更方便扩展和日后维护。举个产品的例子:
优化前的代码:
public class Product {public enum ProductType {ELECTRONIC, BOOK}private ProductType type;private final double price;public Product(ProductType type, double price) {this.type = type;if (price < 0) {throw new IllegalArgumentException("Price cannot be negative");}this.price = price;}public double getPrice() {return price;}public void display() {// 类型码switch (type) { case ELECTRONIC:System.out.println("Electronic product with price: " + price);break;case BOOK:System.out.println("Book product with price: " + price);break;default:throw new IllegalStateException("Invalid product type: " + type);}}
}
优化后的代码:
Price类:
public class Price {private final double value;public Price(double value) {if (value < 0) {throw new IllegalArgumentException("Price cannot be negative");}this.value = value;}public double getValue() {return value;}
}
product类:
public interface Product {void display();
}public class ElectronicProduct implements Product {private final Price price;public ElectronicProduct(Price price) {this.price = price;}@Overridepublic void display() {System.out.println("Electronic product, price: " + price.getValue());}
}public class BookProduct implements Product {private final Price price;public BookProduct(Price price) {this.price = price;}@Overridepublic void display() {System.out.println("Book product, price: " + price.getValue());}
}
产品工厂类:
public class ProductFactory {// 根据类型参数决定创建哪种产品public Product createProduct(String type, Price price) {if ("Electronic".equalsIgnoreCase(type)) {return new ElectronicProduct(price);} else if ("Book".equalsIgnoreCase(type)) {return new BookProduct(price);} else {throw new IllegalArgumentException("Unknown product type: " + type);}}
}
上述案例解决了基本类型坏味道涉及到的两个问题。
重复的switch
说到类型码,顺路可以引入另一个坏味道--重复的switch。
重复的switch 的问题在于:每当你想增加一个选择分支时,必须找到所有的 switch,并逐一更新。多态给了我们对抗这种黑暗力量的武器,使我们得到更优雅的代码库。我们甚至还听过这样的观点:所有条件逻辑都应该用多态取代,绝大多数 if 语句都应该被扫进历史的垃圾桶。****——Robert C. Martin
接着用上面产品的原案例解释下,假如在不用子类的情况,需要计算不同产品价格,需要获取产品折扣,需要设定不同产品购买数量限制,那原案例代码列出的每种上述对应方法中,都要用switch语句来区分不同产品,编写不同的逻辑,这样多个方法中就存在多个近乎重复的switch代码。这个坏味道其实本质大同小异,都是在说当代码存在多个条件下的多个场景处理逻辑,尽可能把各个场景使用子类表示,这样,选取哪个子类只需要在工厂中写一次,避免了switch语句在每个动作方法中的重复改动。
当然,并非所有的switch都必须转换成子类,如果有些场景很简单,不用建模也可以解决,那就没必要增加代码的复杂性。
总结
这一章节从对象数据的角度,讲解了可变数据、全局可变数据、数据泥团、临时字段、基本类型偏执、重复的switch六种代码坏味道。为了防止数据可变,可以设置数据状态,添加有参构造函数;面对数据泥团,可以抽取封装成新类;面对低频字段,可以用方法或封装新类消除低频字段;面对不够明确的基本类型,同样可以封装新的类替代;面对多个条件选择的switch,工厂选择类加封装子类替代。
在对象数据的处理中,我们能看到除了可变数据坏味道,其余数据坏味道基本都是使用相同的重构手法--封装!在上一篇的重复代码与合格方法的坏味道处理手法也是封装(封装成方法或者类),甚至我们后面讲到的发散式变化、霰弹式修改等对象关系问题的处理更是封装!
还记得Java的三大特性:封装,多态,继承。面对不同的业务场景,能够进行不同的合理的封装,才是迈向高手的关键一步!
对象关系间会有什么幺蛾子?
发散式变化
如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。——Robert C. Martin
发散式变化说的是,不要总是在改同一模块,翻译过来即不要让某一方法或类承担的职责过多。不然任何问题都来同一方法或类里看,改来改去就容易改出问题。出现发散式变化有两个原因,要么类内代码间耦合度太高,要么类内职责太多。优化方法就很简单了,将耦合度较高的代码进行聚合,并根据职责封装为各个新类,每个类各司其职,保证每次改动只在该类中进行。为了减少外部使用该类功能的地方需要同步改动,所以尽可能将该类对外的方法名称和出入参抽象化和固定化,比如之前入参为一个,后面突然改成两个,那你直接就把出参定义一个类对象,把这两个出参放进去嘛,省的别人跟着你动来动去。每次改动都要遵循对外不变,对内优化的原则。
霰(xian)弹式修改
如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,你所面临的坏味道就是霰弹式修改。——Robert C. Martin
和发散式变化相反,发散式变化是逮着一个模块薅,霰弹式变化是到处逮模块薅。导致霰弹式变化的原因是,没有很好的将关联性较强的数据或方法汇总到一个类中。优化方法就是尽可能聚合呗(这也正体现DDD设计中高内聚低耦合的思想,后期分享)。在聚合过程中注意一点,有时候某一数据计算出来的派生数据可能分散在各个其他类中,导致派生数据的修改比较分散。这个时候可以在源数据所在类中搞一个转化方法,这个方法计算所有的派生数据汇总到一起作为出参,之后每个用到派生数据的地方直接调用该方法,可以有效避免霰弹式修改,同样也可以减少重复代码。
依恋情结
但有时你会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁, 远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。——Robert C. Martin
从问题症状来看,它其实和霰弹式变化很像。都是本模块和其他模块交互出了问题。前者是对外交互模块太多,后者是对外交互模块太频繁。但不论怎样,优化措施都万变不离其宗,即尽可能把强关联性的类和数据、方法聚合到一起。交互太频繁,那就把这一步频繁操作移动到依赖的类中。举个例子:
public class Order {private List<Item> items; // 订单包含多个商品项public double calculateTotal() {double total = 0;for (Item item : items) {total += item.getPrice(); // 这里Order类的方法依赖于Item类的状态}return total;}
}public class Item {private double price;public double getPrice() {return price;}
}
上述代码Order类在for循环中频繁访问Item中的getPrice方法。解决措施就是把这个循环计算方法移动到Item类中,如下:
public class Item {private double price;public double getPrice() {return price;}// 将计算总价的逻辑移动到Item类中public double calculateTotal(List<Item> items) {double total = 0;for (Item item : items) {total += item.getPrice();}return total;}
}public class Order {private List<Item> items;public double getTotalPrice() {return new Item().calculateTotal(items); // 委托Item类来计算总价}
}
这样,对数据的频繁访问就变成对方法的一次访问。访问频率大大降低。
过长的消息链
有人称这个坏味道为火车残骸。说的更形象了点。 举个例子:
String name = book.getChinaBook().getAuthor().getName();
像火车一样,点出一节又一节。这种最大的问题是,用它的地方还要了解它的内部结构。你想获取个国内书籍作者的名字,你还需要了解ChinaBook对象和Author对象,这就很离谱,我想看个电视,难道还要先了解下怎样组装电视?另外,如果调用链其中一节发生变化,使用端还要跟着更改,增大了对象使用的风险。最好的解决办法是,将获取对象的行为封装成一个方法:
class Book {...public String getAuthorName() {return this.chinaBook.author.getName();}...}String name = book.getAuthorName();
当然并不是所有的消息链都是坏味道,比如stream管道流也是消息链但它不属于坏味道,实际开发中需要注意甄别。
内幕交易
内幕,是指只能在两者之间进行,交易,是指两者之间相互交换信息;所以内幕交易这个坏味道,重点指的是两个类之间频繁的数据调用和信息交换,这些数据信息多指私有数据(还记得依恋情结坏味道么?它和内幕交易很类似,不过它是一个类数据频繁的依赖另一个类数据)。但 Martin大佬又在这个坏味道里面多加了一句『继承常会造成密谋,因为子类对超类的了解总是超过后者的主观愿望。』。我也是服了这个老六,继承本身父子类间就是信息继承,何来交易?还密谋,人家谈自己的家事,何来密谋?我是不认为继承是一种内幕交易,但我承认,一些不合理的继承确实也是一种坏味道,我们一一来说吧。
先说相互依赖,举个很简单的例子: BankAccount类中:
public class BankAccount {private static Double balance = 0.00;private final String amount;public Double getActualBalance() {return balance > 100 ? 50.00 : 100;}public Double getAccountBalance(BankService bankService) {return bankService.PerformService() + 100;}
}
BankService类中:
public class BankService {private Double getMoney;private Double exchangeRate;public Double PerformService() {return getMoney * exchangeRate;}public Double addMoney(BankAccount bankAccount) {return bankAccount.getActualBalance() + 100.00;}
}
从代码可以明显看出,BankAccount类和BankService类都爱多管闲事,前者的getAccountBalance方法没事去管BankService的数据,后者的addMoney方法没事去管BankAccount的数据,最好办法用F6搬移函数,职能归位,各管各的:
BankAccount类
public class BankAccount {private static Double balance = 0.00;private final String amount;public Double getActualBalance() {return balance > 100 ? 50.00 : 100;}public Double addMoney() {return getActualBalance() + 100.00;}
}
BankService类:
public class BankService {private Double getMoney;private Double exchangeRate;public Double PerformService() {return getMoney * exchangeRate;}public Double getAccountBalance() {return PerformService() + 100;}
}
如果一个方法中同时用到两个类,也可以考虑把这一类的方法单独放到一个新类中。
第二种不合理的继承,是指有些子类虽然继承了父类,但可能只用到了父类很小的一部分功能,或者根本不使用父类功能。当父子关系“不够亲密”时,继承的耦合有时候就会成为负担。因为每次父类的调整都会影响子类。子类还需要承担很多不必要的“父爱”。所以,这个时候作者建议使用委托替代子类。委托说白了就是两个类取消之前的继承关系,一个类需要另一个时,直接new出另一个类对象并使用其方法。举个例子:
interface DocumentFormat {String formatDocument(String content);
}class PDFDocumentFormat implements DocumentFormat {public String formatDocument(String content) {// 格式化为 PDFreturn "PDF format of content: " + content;}
}class WordDocumentFormat implements DocumentFormat {public String formatDocument(String content) {// 格式化为 Wordreturn "Word format of content: " + content;}
}class Document {private DocumentFormat format;public Document(DocumentFormat format) {this.format = format;}public String createFormattedDocument(String content) {return format.formatDocument(content);}
}public class Main {public static void main(String[] args) {Document pdfDocument = new Document(new PDFDocumentFormat());System.out.println(pdfDocument.createFormattedDocument("Sample content"));Document wordDocument = new Document(new WordDocumentFormat());System.out.println(wordDocument.createFormattedDocument("Sample content"));}
}
在这个例子中,Document 类不直接继承任何特定的格式类,而是委托给 DocumentFormat 类来进行具体的文档格式实现。
在IDEA中委托取代继承的快捷键是Crtl+Alt+Shift+T->Replace Inheritance with Delegation。
被拒绝的遗赠
这个坏味道讲的是子类继承父类,又不全部使用父类功能,未使用的部分就是被拒绝的遗赠。听起来是不是和内幕交易中不合理的继承很像?我觉得它们讲的就是同一个问题!
子类应该继承超类的函数和数据。但如果它们不想或不需要继承,又该怎么办呢?它们得到所有礼物,却只从中挑选几样来玩!——Robert C. Martin
不过,内幕交易重点放在被拒绝的遗赠过多的情况,直接建议委托替代继承。而在该坏味道中,它根据父子间亲密程度,提供了两个解决办法,父子间较为亲密时,把父类相对子类多余的少部分方法单独下沉到另一个子类中;父子间不是很亲密时,直接用委托取代继承。
总结
这一章介绍了发散式变化、霰弹式修改、依恋情结、过长的消息链、内幕交易、被拒绝的遗赠六个对象关系间的坏味道,其实发散式变化更多是对象内部职责不单一的问题,但是为了和霰弹式修改作比较,我统一放到这一章节。整体来说,你看这对象之间不能太亲密--霰弹式修改、依恋情结;不能太不亲密--被拒绝的遗赠;也不能偷偷亲密--内幕交易;多个对象间更不能一节一节的成套娃般亲密,这个尺度可不好把握哦~
你代码的表达力行不行?
循环语句
从最早的编程语言开始,循环就一直是程序设计的核心要素。但我们感觉如今循环已经有点儿过时,就像喇叭裤和植绒壁纸那样。——Robert C. Martin
循环语句本身没啥问题,就是有些集合、数组用for循环操作提高了代码的复杂度,管道的链式操作更简洁连贯且不容易出错,另外数据量大时使用管道的并行流还能提高性能,因此代码中针对集合或数组过滤、映射、求和、查找第一个匹配项、分组、去重、统计常用管道替代。举个管道并行流的例子:
传统计算数组中所有数平方和:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int sumOfSquares = 0;
for (int number : numbers) {sumOfSquares += number * number;
}
System.out.println("Sum of squares (sequential): " + sumOfSquares);
使用管道的方法:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int sumOfSquares = numbers.parallelStream() // 转换为并行流.mapToInt(n -> n * n).sum();
System.out.println("Sum of squares (parallel): " + sumOfSquares);
案例中,四行代码被精简为了一行,代码量减少4倍!parallelStream() 方法将顺序流转换为并行流。并行流会自动将数据分成多个块,并在多个线程中并行处理这些块,大大提高计算性能。
最后复习下常用替换管道快捷键:
快捷键 | 含义 |
---|---|
Alt+Enter->Replace with collect | 用collect替代for循环 |
Alt+Enter->Replace with sum | 替代for循环求和 |
Alt+Enter->Replace with Map.forEach | forEach替代复杂for循环 |
冗赘(zhuì)的元素
这个代码坏味道的英文名是Lazy Element,直译过来是懒惰元素,我理解作者想表达的是某些元素长期不被用到,结果翻译过来起名为冗赘元素,忍不住就想吐槽下,非得搞个生僻字,叫冗余元素不也挺好嘛~
言归正传,这个坏味道很简单。它强调的是有些已经注释掉的代码,删掉它;有些暂时没用以后可能有用的代码,删掉它;有些经过很久依旧职责不多的子类,就把它和父类合并。为的就是代码能够简洁明了。来看看大佬原话怎么说:
可能有这样一个函数,它的名字就跟实现代码看起来一模一样;也可能有这样一个类,根本就是一个简单的函数。这可能是因为,起初在编写这个函数时,程序员也许期望它将来有一天会变大、变复杂,但那一天从未到来;也可能是因为,这个类原本是有用的,但随着重构的进行越变越小,最后只剩了一个函数。不论上述哪一种原因,请让这样的程序元素庄严赴义吧。——Robert C. Martin
排查这类坏味道的方式:可以通过IDEA中Analyze——Inspect Code——Declaration redundancy,再结合人工判断,批量发现冗赘代码,常用重构快捷键如下:
快捷键 | 含义 |
---|---|
Crtl+Y | 整行删除 |
Alt+Delete | 安全删除 |
Crtl+Alt+P | 抽取为方法参数 |
Crtl+Alt+F | 提取为属性字段 |
夸夸其谈通用性
这里说的通用性核心指的是那些为了通用而创造的接口或抽象类,但有些接口或抽象类长期下来可能就只有一个实现类,这个时候就得重新审视下是否有必要存在这个接口或抽象类了。还有些类或方法只有测试代码中用到,这些类和方法同样可以干掉它。
在编码中专家们强调要抽象,现在又不能太过抽象。这中间的尺度,还真不好把握。不同业务不同场景给出的抽象也不同,谁也不能保证这个抽象未来就不会使用。只能开发依据业务需求自行把握。我觉得这个坏味道基本也是属于冗余设计,完全可以和上一个坏味道归类到一起。
中间人
这个代码坏味道也很简单。如果一个类或对应的方法只负责包装另一个类中方法的返回,不做任何实际的逻辑处理,那就干掉它,直接使用原方法。举个例子:
public class Order {private String id;private double amount;
}public class OrderProcessor {public void processOrder(Order order) {// 实际的订单处理逻辑}
}public class OrderManager { // 中间人private OrderProcessor processor;public OrderManager(OrderProcessor processor) {this.processor = processor;}public void submitOrder(Order order) {// 这里仅仅是转发订单,没有添加任何逻辑processor.processOrder(order);}
}
上面的订单管理类就是个中间人,不做任何具体逻辑,只管转发。这种就可以直接干掉。使用原方法:
public class OrderProcessor {public void handleOrder(Order order) {// 订单处理逻辑}public void submitOrder(Order order) {...// 直接处理订单,不再需要中间人handleOrder(order);}
}
// 现在客户端代码可以直接使用 OrderProcessor
public class Main {public static void main(String[] args) {OrderProcessor processor = new OrderProcessor();Order order = new Order(/* 初始化订单数据 */);processor.submitOrder(order);}
}
还有一种中间人类似如下这种:
public class BankAccount {private final Double balance;private final String amount;public Double getActualBalance() {return getBalance() > 100 ? 50.00 : 100;}public Double getBalance() {return balance;}
}
在同一个类中,多此一举搞了一个getBalance,直接使用balance多好:
public class BankAccount {private final Double balance;private final String amount;public Double getActualBalance() {return balance > 100 ? 50.00 : 100;}
}
解决中间人问题提供两个快捷键:
快捷键 | 含义 |
---|---|
Ctrl+Alt+Shift+T -> Remove Middleman | 移除中间人(第一类中间人) |
Ctrl+Shift+N | 内联(第二类中间人) |
总结
这一节介绍了循环语句、冗赘(zhuì)的元素、夸夸其谈通用性、中间人这几种坏味道。这几种坏味道本身对业务逻辑或性能不会产生什么影响。单纯为了使代码更简洁清晰,更易理解,为了提高代码表达力所提出。所以整体优先级相对较低,有些坏味道出现重复,倒也可以理解。
重复的坏味道
根据标题可以看出,这一节的坏味道其实在之前已经提出,但经过一定包装,我们在这一节再重新认识一下。
过大的类
在我看来,这个坏味道更多是其他一些坏味道未解决而造成的表象。先看定义,什么是过大的类?由于属性未分组和职责不单一而包含过多属性、方法和代码行的类。 Martin提出的解决办法是拆分过大的类保证职责单一。可你仔细想想,在上一篇解决重复代码坏味道时,抽取公共方法,方法上移超类,难道不是精简类?解决数据泥团坏味道时将同类属性提炼新类中,难道不是精简类?解决类型码参数和重复的switch坏味道时,把条件分支逻辑用子类替代,难道不是精简类?解决循环语句用管道方法难道不是精简类?解决冗赘元素、临时字段、中间人坏味道,难道不是精简类?所以,何必单独过大的类列出来增加读者的学习负担呢? Martin大佬,不好意思,这里我想批评下你~
异曲同工的类
类异曲同工,是指两个类功能完全相同或者大部分功能完全相同,只是定义不同。这种坏味道没什么可说的,完全相同,就舍弃掉一个,部分相同,就想办法把两个类合并到一起,相同的部分提取父类中。忍不住又想吐槽,请问这个和重复代码的坏味道有什么区别嘞?
纯数据类
我们正常的类,应该包括该类对象需要的属性和常见的对这些属性的业务操作行为。这样才能通过类对象,对现实生活中的某一对象进行完整的建模。但是纯数据类只包括数据字段和访问这些字段的函数,除此之外一无所长。那就存在问题了,我举一个在基本类型偏执坏味道讲过的案例,比如一个金额类,里面某个属性字段是BigDecimal Amount,这个字段就包含金额不为负数,金额保留两位小数,金额有个最大限制等业务操作行为,如果只提供一个纯数据类,那在所有使用到金额类的地方都需要添加这几种操作行为,如果需要对金额类某一字段添加一种新的业务行为,就要所有涉及的地方都进行修改。这个时候,霰弹式修改坏味道就来了。另外,纯数据类中的字段一般为可变字段,这又会导致文章开头讲的可变数据的坏味道。
但并非所有的纯数据类都是坏味道。我们在DDD设计模式下进行开发时,会有很多model类、VO类、DTO类,这些纯数据类的字段一般和数据库的字段一一对应。对这些字段的操作一般意味着最终对数据库实体字段的操作,而不是某一类对象的特有行为,且每个类的行为并不固定,搞成充血模型(数据行为不分离)反而可能会导致过大的类坏味道。所以这一种类没有数据行为也罢。另外,如果一个纯数据类用作函数的返回结果,不用来进行业务处理,这种类中只有纯数据也无可厚非。
总结一下,纯数据类可能是坏味道,但并非一定是坏味道,它是坏味道时一定会引发可变数据、霰弹式修改等其他坏味道。所以,我觉得,这个坏味道也是重复的坏味道。
总结
这一节介绍了纯数据类、过大的类、异曲同工的类三个坏味道。之所说他们是重复的坏味道,是因为它们都是因为没有好好解决之前的坏味道导致的连锁味道。如果有理解之前的坏味道,那理解这几个坏味道也不是问题。
最后
至此,所有的坏味道介绍完毕。命名、注释虽不起眼,却至关重要;重复代码虽有提示,但还得细细甄别;对象数据、对象方法、对象关系的设计,每一个看似平平,实则大有乾坤;最后没事还得多提升提升代码格调,该删的删,该简的简(管道替代循环等等),多挖掘挖掘编辑器的快捷键,熟练掌握这些,你离开发大神就不远喽!
----------------end----------------
我是牛奶,目前是一名互联网开发菜鸟,主要聚焦于互联网技术开发和个人成长的高营养价值内容分享,感兴趣的小伙伴可以在下方加个关注,大家一起共同学习和进步。