在编程中,方法或函数的设计是至关重要的。一个好的方法设计不仅能提高代码的可读性和可维护性,还能提升系统的整体性能和可靠性。在这篇博客中,我们将探讨一个看似简单却蕴含深刻思想的原则:"方法要有返回值"。这个原则不仅要求开发者关注方法的签名和返回值,还要求在API设计时考虑方法能返回哪些内容,有时候,即使方法只是执行副作用,也可以有返回值。
前言
在软件开发中,尤其是面对复杂系统时,理解和改变系统的能力是衡量开发者水平的重要标准。最近,我在处理一些历史代码时,发现许多开发者忽略了方法可以有返回值这一事实。这种忽视不仅影响了代码的质量,也限制了系统的可扩展性和可维护性。
为什么方法要有返回值?
受到函数式编程思想的影响,我逐渐意识到纯函数的优势。纯函数只有输入和输出,没有副作用,引用透明。这使得纯函数在使用和复用时更加可靠,因为其正确性在很大程度上已经得到确认。然而,面向对象编程(OOP)和领域驱动设计(DDD)思想常常使开发者过分关注副作用,忽略了方法返回值的重要性。
副作用的存在使得代码复用变得困难,并容易引发意想不到的问题。例如,频繁修改对象的字段值会导致对象存在多种状态,开发者需要维护这些状态。这种情况下,开发者往往会深入到代码逻辑中去关注副作用,而忽略了方法的返回结果。
实际开发中的误用
以下是一些常见的误用例子:
-
Map#computeIfAbsent:在使用此方法时,返回结果常常被忽略。实际上,其返回值可以用于链式操作,从而提高代码的可读性和性能。
java// 普通实现 Map<Integer, List<User>> usersByState = new HashMap<>(); usersByState.computeIfAbsent(1, new ArrayList<>()); usersByState.get(1).add(user1); // 修改后 Map<UserState, List<User>> usersByState = new HashMap<>(); usersByState.computeIfAbsent(UserState.INACTIVE, new ArrayList<>()).add(user1);
-
Bean Setter:JavaBean的setter方法迫使我们关注对象状态,而忽略了返回值的使用。相比之下,builder模式通过返回this来提醒我们正在创建一个复杂对象。
javaUser user = new User(); user.setName("abc"); user.setAge(2); user.setAddress("xxx");
-
异步任务:异步任务的返回结果应该显式处理,即使不需要结果,也至少需要处理异常。
java// bad case EXECUTOR.submit(task); // good case: using guava public class TimeoutDemo { public static void main(String[] args) { ListeningExecutorService pool = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool()); ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(); ListenableFuture<Integer> future = pool.submit(() -> { try { Thread.sleep(5000); return 1; } catch (InterruptedException e) { return -1; } }); FluentFuture.from(future) .withTimeout(3, TimeUnit.SECONDS, timer) .catching(Exception.class, TimeoutDemo::logError, timer); System.out.println("result = " + Futures.getUnchecked(future)); pool.shutdown(); timer.shutdown(); } static <T> T logError(Throwable e) { System.out.println(e.getClass()); return null; } }
-
数据库访问:例如,MyBatis的insert方法返回插入的行数,这一返回值在实际开发中常常被忽略。
-
回调机制:不应忽略回调的返回值,例如Kafka发送消息成功后的回调钩子方法。
-
深入理解返回值:开发者需要理解返回值的特点,例如是否可变,输入和输出之间的关系等。
java// ok case Splitter.on(',').split(" foo,,, bar ,"); // good case // 返回值不可变,可复用,可以当成常量使用 private static final Splitter MY_SPLITTER = Splitter.on(',') .trimResults() .omitEmptyStrings(); // use MY_SPLITTER.split(xxx)
有趣的例子
- 在API设计中,返回值的设计同样重要。例如,Map#put方法返回之前放入的值,这一设计在多线程情况下可以防止检查-修改问题。
Java
// Caffeine 类库实现多线程阶乘计算
AsyncCache<Integer, Integer> cache = Caffeine.newBuilder().buildAsync();
int factorial(int x) {
var future = new CompletableFuture<Integer>();
var prior = cache.asMap().putIfAbsent(x, future);
if (prior != null) {
return prior.join();
}
int result = (x == 1) ? 1 : (x * factorial(x - 1));
future.complete(result);
return result;
}
- Collection#removeIf方法的返回值为boolean,表示是否有对象从列表中删除。然而,从设计角度来看,返回值最好是int,表示有多少个对象移除了。
java
users.removeIf(u -> {
if (blackList.contains(u)) {
log.info("user blocked userId={}", u.id());
return true;
}
return false;
});
有时候需要同时获得移除的对象和剩下的对象集合,可以使用stream改进实现如下:
java
Map<Boolean, List<User>> parts = users.stream().collect(partitioningBy(blackList::contains));
List<User> blockedUsers = parts.get(true);
List<User> passedUsers = parts.get(false);
-
Java8 引入了一些函数式编程特性,不过函数式接口感觉做的一般,比如 Runnable 实际上就是 Callable,Consumer 就是 Function<A, Void>,对于Java开发者来说,这些类型之间的转换比较麻烦。这些接口设计不利于开发者从函数的角度考虑问题,以至于很多API的设计需要支持多种实际上等价的方法,在此点名批评 CompleatableFuture 😂。
-
Java 官方不喜欢Tuple,也没有相关语法糖,导致用户需要手动定义一些临时Result类型。特别是在Stream Collector 中,定义了一些奇怪的结果,summarizingInt 返回统计信息类 IntSummaryStatistics,partitioningBy 返回类型为Map<Boolean, T>。
总结
方法的返回值不仅是一个简单的设计原则,更是提高代码质量和系统可靠性的重要手段。在设计和使用API时,开发者应充分利用返回值,以减少副作用带来的不良影响。通过关注方法的返回值,我们可以更好地理解和管理我们的项目,提高代码的可读性和可维护性。