掌握8条方法设计规则,设计优雅健壮的Java方法
一个良好的方法设计可以提高代码的可读性、可维护性和可扩展性,而糟糕的方法设计则可能导致代码难以理解和修改
本文基于 Effective Java 方法章节总结8条设计方法的规则,帮助开发者更好进行方法设计
检查参数的有效性
为了防止错误发生,方法中一般会对参数进行校验,比如ArrayList的构造和添加方法
传入容量为负数会抛出非法参数异常IllegalArgumentException
public ArrayList(int initialCapacity) { if (initialCapacity > 0) { this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { this.elementData = EMPTY_ELEMENTDATA; } else { throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); } }
还有其添加时会检查传入下标是否有效
private void rangeCheckForAdd(int index) { if (index > size || index < 0) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); }
为了增加方法的健壮性,必须在方法中对参数进行检查,同时也可以在文档中说明哪样的参数是有效的
如果不检查参数是否有效,可能在运行时抛出异常,也可能计算出错误结果导致排查时间久
必要时进行保护性拷贝
方法入参、响应的对象是可变对象时,如果方法中依赖这些对象,但在其他地方又对对象进行修改,那么可能会导致方法中计算错误
比如一个记录时间周期的类,使用可变对象Date记录起止时间
//可变周期 public final class Period { //起始时间使用可变对象Date private final Date start; private final Date end; //直接依赖可变对象 public Period(Date start, Date end) { if (start.compareTo(end) > 0) throw new IllegalArgumentException( start + " after " + end); this.start = start; this.end = end; } //获取起始时间(引用逃逸、不安全) public Date start() { return start; } public Date end() { return end; } public String toString() { return start + " - " + end; } }
构造方法则是直接依赖可变对象Date,并且start、end方法也是直接返回字段引用(引用逃逸)
当外界通过修改入参 或者 通过方法获取到逃逸对象进行改变时,就会导致错误的结果
Date start = new Date(); Date end = new Date(); Period p = new Period(start, end); //修改入参 start.setYear(76); //引用逃逸 被修改 p.end().setYear(78); //Thu Apr 01 11:10:31 CST 1976 - Sat Apr 01 11:10:31 CST 1978 System.out.println(p);
为了不发生这样错误的情况,可以将依赖的对象改变为不可变对象,也就是将Date替换为不可变对象如LocalDateTime
如果依赖的对象必须是不可变对象时,就要使用保护性拷贝
在入参依赖、方法返回时使用拷贝,防止外界对可变对象进行修改
public class CopyPeriod { private final Date start; private final Date end; //传入参数时进行拷贝 public CopyPeriod(Date start, Date end) { this.start = new Date(start.getTime()); this.end = new Date(end.getTime()); if (this.start.compareTo(this.end) > 0) throw new IllegalArgumentException( this.start + " after " + this.end); } //返回字段时进行拷贝 public Date start() { return new Date(start.getTime()); } public Date end() { return new Date(end.getTime()); } public String toString() { return start + " - " + end; } }
这样在其他地方修改可变对象时也不会对其进行影响
start = new Date(); end = new Date(); CopyPeriod cp = new CopyPeriod(start, end); //保护性拷贝 引用不会逃逸 修改无效 start.setYear(76); cp.end().setYear(78); //Mon Apr 01 11:10:31 CST 2024 - Mon Apr 01 11:10:31 CST 2024 System.out.println(cp);
方法返回通过拷贝的方式防止引用逃逸,但是这样可能存在频繁创建对象的性能问题
如果调用方是可信任的(不会修改逃逸的可变对象),那么也可以不进行保护性拷贝(在文档中说明)
谨慎设计方法
1.谨慎选择方法的名称
见名知意
2.不要过于追求提供便利的方法
设计API时方法太多导致不好维护,有必要提供便利的方法可以放在工具类中
3.避免过长的参数列表
太长导致使用不方便
可以通过拆分方法,每个方法使用子集的参数减少参数列表过长
也可以使用类包含所有参数
或者结合前两种情况使用建造者builder
4.定义参数类型为接口而不是类
接口范围更广、通用性更好,比如能定义Map就不要定义HashMap,如果参数为TreeMap是转换为HashMap需要开销
5.对于boolean参数,可以考虑使用两个元素的枚举类型
boolean类型参数往往不够明确,仅仅能表示两种状态,却不能提供清晰的语义含义
通过枚举类型,我们可以更具体地定义和区分这些状态,从而提高代码的可读性和可维护性
假设有一个处理订单的方法,它原本可能这样设计,接受一个布尔参数来决定是否需要立即执行发货操作
immediateShipping
参数的含义不是非常直观,使用者需要查阅文档或者根据上下文理解true和false的具体含义
public void processOrder(boolean immediateShipping) { //立即发货 if (immediateShipping) { shipNow(); } else { //后续再发货 scheduleForLater(); } }
调用者必须传递一个 ShippingPolicy
枚举实例,这使得意图更加明显,同时也减少了误解的可能性
public enum ShippingPolicy { IMMEDIATE_SHIPPING, SCHEDULE_FOR_LATER } public void processOrder(ShippingPolicy policy) { switch (policy) { case IMMEDIATE_SHIPPING: shipNow(); break; case SCHEDULE_FOR_LATER: scheduleForLater(); break; } }
如果未来业务需求扩展,比如增加了新的发货策略,只需在枚举中添加新值即可,无需更改方法签名,体现更好的扩展性
如果不确定/需要扩展可以考虑使用这种方式代替boolean类型
慎用重载
方法的重载是编译(静态)就确定的,而重写(覆写)才是动态运行时确定的
通过类型Set、List、Collection重载三个方法
public static String classify(Set<?> s) { return "Set"; } public static String classify(List<?> lst) { return "List"; } public static String classify(Collection<?> c) { return "Unknown Collection"; }
在编译时都申明为Collection,那么在执行时都调用的是classify(Collection<?> c)方法
Collection<?>[] collections = { new HashSet<String>(), new ArrayList<BigInteger>(), new HashMap<String, String>().values() }; for (Collection<?> c : collections) //Unknown Collection System.out.println(classify(c));
如果使用重载时发生类型转换从而调用成其他重载方法会导致结果错误并且难以排查
为了避免这种情况发生,最好不使用重载,并使用命名模式描述对应类型
比如ObjectOutputStream
中的writeInt(int val)
、writeChar(int val)
、writeLong(long val)
如果非要使用重载,那么重载方法中实现需要一样
比如String
中的contentEquals(StringBuffer sb)
、contentEquals(CharSequence cs)
public boolean contentEquals(StringBuffer sb) { return contentEquals((CharSequence)sb); } public boolean contentEquals(CharSequence cs) { //... }
慎用可变长参数
使用可变长参数时会将变量初始化为数组,如果能够确定参数数量在某个范围中(1-5),可以使用重载代替
如果无法预估参数数量才使用可变长参数,使用时携带必要的参数和注意性能
实现时要考虑不传可变长参数的情况或传必要参数
//如果不传参数 手动抛出异常报错 static int min(int... args) { if (args.length == 0) throw new IllegalArgumentException("Too few arguments"); int min = args[0]; for (int i = 1; i < args.length; i++) if (args[i] < min) min = args[i]; return min; } //优雅处理 有默认值 必要参数 firstArg static int min(int firstArg, int... remainingArgs) { int min = firstArg; for (int arg : remainingArgs) if (arg < min) min = arg; return min; }
返回空容器而不是null
当返回没数据的容器、数组时,应该返回空集合而不是null
使用Collections.emptyList()
或Collections.emptyMap()
高效返回空容器
使用空容器可以与调用方一致,大不了就是没数据不能继续操作,而返回null会导致调用方未判空从而出现空指针异常
当然调用方也可以规范使用空集合判空工具类如CollectionUtils.isNotEmpty()
谨慎使用Optional
Optional作为JDK8中提供处理非空判断的“容器”会存储一个对象
// 不使用Optional的情况 public User findUserByUsername(String username) { // 假设db.findUser是一个可能返回null的操作 return db.findUser(username); } // 调用方在不使用Optional时的处理 User user = findUserByUsername("nonexistent_user"); if (user != null) { // 处理用户信息 } else { // 用户不存在时的处理逻辑 }
使用Optional能够考虑处理更多种情况:非空情况、为空情况抛异常或默认值
// 使用Optional的情况 public Optional<User> findUserByUsername(String username) { return Optional.ofNullable(db.findUser(username)); } // 调用方在使用Optional时的处理 Optional<User> optionalUser = findUserByUsername("nonexistent_user"); optionalUser.ifPresent(user -> { // 处理用户信息 }); //为空抛出异常或用默认值 User user = optionalUser.orElseThrow(() -> new UserNotFoundException("User not found")); User defaultUser = optionalUser.orElse(new GuestUser()); // 使用Optional的情况 public Optional<User> findUserByUsername(String username) { return Optional.ofNullable(db.findUser(username)); } // 调用方在使用Optional时的处理 Optional<User> optionalUser = findUserByUsername("nonexistent_user"); optionalUser.ifPresent(user -> { // 处理用户信息 }); //为空抛出异常或用默认值 User user = optionalUser.orElseThrow(() -> new UserNotFoundException("User not found")); User defaultUser = optionalUser.orElse(new GuestUser());
如果注重性能、基本包装类型(冗余)、键值时(Map<String, Optional>)则不要使用Optional
为所有导出的API编写文档注释
为重要的API编写文档注释
包括方法作用、入参(@param)、返回(@return)、抛出异常(@throws)
总结
方法中不检查入参会导致运行时异常或错误结果,考虑在方法中检查入参,增加代码健壮性
依赖的可变对象逃逸被修改会导致错误结果,可使用不可变对象或保护性拷贝(入参、响应)解决
设计方法时需要见名知意、避免参数过长、定义参数类型为接口而不是类、boolean类型考虑泛型,并且API中不要追求大量便利的方法,这种方法应该在工具类中
重载编译时就能够确定,为了避免转换类型调用错重载方法,可以使用具体类型命名的方法代替重载,如果一定要使用重载可以让实现一致
无法预估参数长度才使用可变长参数,初始化数组有性能消耗,考虑方法不传可变长参数的情况
返回容器的方法不要返回null而是使用工具类返回空容器,调用时使用容器工具类判空
使用Optional判空可以考虑非空、为空默认值、为空异常等情况,但注重性能、使用基本包装类、键值对与集合的泛型中不要使用
为重要的API编写详细的文档注释
最后(不要白嫖,一键三连求求拉~)
本篇文章被收入专栏 Effective Java,感兴趣的同学可以持续关注喔
本篇文章笔记以及案例被收入 Gitee-CaiCaiJava、 Github-CaiCaiJava,除此之外还有更多Java进阶相关知识,感兴趣的同学可以starred持续关注喔~
有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~
关注菜菜,分享更多技术干货,公众号:菜菜的后端私房菜