ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java函数式编程入门:Function、Consumer、Supplier、Predicate四大接口详解

Java函数式编程入门:Function、Consumer、Supplier、Predicate四大接口详解 最近不少同事问我Java函数式编程到底怎么入门尤其是java.util.function包里那一堆接口看着就头大。其实把这些接口拆开看核心就四个——Function、Consumer、Supplier、Predicate把它们的职责边界和使用场景搞清楚函数式编程的大门基本就推开了一半。这篇文章我结合自己实际项目里的经验和踩过的坑把这几兄弟彻底讲透。1. 为什么非要搞懂这四个函数式接口1.1 从一段传统匿名内部类代码说起很多人在没接触函数式编程之前写代码都是这种画风ExecutorService executor Executors.newFixedThreadPool(10); executor.submit(new Runnable() { Override public void run() { System.out.println(Task executed); } });匿名内部类这个东西语法上是没问题但读起来总归多了一层包装。后来Java 8引入了Lambda表达式同样的逻辑变成一行executor.submit(() - System.out.println(Task executed));但Lambda本身只是语法糖真正支撑它的是背后那些函数式接口。如果你不理解Runnable其实就是一个无参无返回值的特殊函数式接口不理解每个Lambda到底匹配哪个接口那遇到复杂场景还是会懵。1.2 函数式编程在Java里的落地方式Java不是纯函数式语言它是通过函数式接口把行为当参数传递、当返回值返回、当数据来操作。这就是函数式编程在Java里的落地方式——不是说非得用Stream、Optional才叫函数式而是你随时可以把一段逻辑封装成对象传来传去。举个例子项目里经常要做数据脱敏String maskPhone(String phone) { return phone.replaceAll((\\d{3})\\d{4}(\\d{4}), $1****$2); }这方法写死了逻辑。但如果有的字段要脱敏手机号、有的要脱敏身份证、有的要脱敏邮箱每个字段都写一个方法就太蠢了。这时候FunctionT, R就派上了用场——把怎么脱敏这个行为当作参数传递进去一套通用工具方法搞定所有字段。这四个接口的组合关系我用一句话概括Supplier负责提供数据、Consumer负责消费数据、Function负责转换数据、Predicate负责判断数据。搞懂这四种职责你在任何代码里看到它们都能立刻反应出它是干嘛的。2. Function接口有进有出的数据转换器2.1 接口源码与核心方法解析先看源码FunctionalInterface public interface FunctionT, R { R apply(T t); default V FunctionV, R compose(Function? super V, ? extends T before) { Objects.requireNonNull(before); return (V v) - apply(before.apply(v)); } default V FunctionT, V andThen(Function? super R, ? extends V after) { Objects.requireNonNull(after); return (T t) - after.apply(apply(t)); } static T FunctionT, T identity() { return t - t; } }apply是核心抽象方法接收一个T类型参数返回一个R类型结果。compose和andThen是组合操作的默认方法identity是静态工厂方法。泛型的通配符看着吓人实际用的时候大部分场景你都碰不到那么复杂的类型边界。记住一点compose是先执行参数里的函数再执行自己andThen是先执行自己再执行参数里的函数。2.2 实际场景演练一套通用脱敏方案回到刚才说的脱敏场景。先定义一个脱敏工具类public class DesensitizationUtil { public static String maskPhone(String phone) { return phone.replaceAll((\\d{3})\\d{4}(\\d{4}), $1****$2); } public static String maskEmail(String email) { return email.replaceAll((\\w{2})\\w(\\w\\.[a-z]), $1***$2); } public static String maskIdCard(String idCard) { return idCard.replaceAll((\\d{4})\\d{10}(\\w{4}), $1**********$2); } }然后定义一个通用方法接收Function作为脱敏策略public static String desensitize(String value, FunctionString, String maskStrategy) { if (value null || value.isEmpty()) { return value; } return maskStrategy.apply(value); }调用的时候String phone DesensitizationUtil.desensitize(13812345678, DesensitizationUtil::maskPhone); String email DesensitizationUtil.desensitize(zhangsanexample.com, DesensitizationUtil::maskEmail); String idCard DesensitizationUtil.desensitize(110101199001011234, DesensitizationUtil::maskIdCard);看到DesensitizationUtil::maskPhone这种写法吗这是方法引用等价于(String p) - DesensitizationUtil.maskPhone(p)。方法引用的本质就是Function接口的匿名实现。2.3 compose和andThen的链式调用技巧有时候一个数据要经过多步转换比如用户输入的字符串要去空格、转小写、再截断10个字符。传统写法一层层嵌套代码可读性很差String result truncate(toLowerCase(trim(input)));用Function的链式组合就清爽了FunctionString, String trim String::trim; FunctionString, String lowerCase String::toLowerCase; FunctionString, String truncate s - s.length() 10 ? s.substring(0, 10) : s; FunctionString, String pipeline trim.andThen(lowerCase).andThen(truncate); String result pipeline.apply( Hello World, Java Function );注意andThen的执行顺序是从左到右先trim再toLowerCase最后truncate。而compose正好相反FunctionString, String pipeline2 truncate.compose(lowerCase).compose(trim); String result2 pipeline2.apply( Hello World, Java Function );这段代码的执行顺序是先执行最右边的trim再执行中间的lowerCase最后执行最左边的truncate。效果和上面andThen的版本完全一致。提示compose和andThen比较反直觉容易搞混。我的记忆方法是andThen是顺着来compose是倒着去代码review时看到compose要特别留意一下执行顺序。3. Consumer接口只进不出的数据消费者3.1 接口源码与使用场景看源码FunctionalInterface public interface ConsumerT { void accept(T t); default ConsumerT andThen(Consumer? super T after) { Objects.requireNonNull(after); return (T t) - { accept(t); after.accept(t); }; } }Consumer的核心特征是一个词副作用。它接收参数但不返回结果意味着它的存在就是为了做一些有副作用的操作——打印日志、写入数据库、发送消息、更新状态等等。这在Stream的forEach里用得最多ListString names Arrays.asList(Alice, Bob, Charlie); names.forEach(System.out::println);3.2 一个容易被忽视的用法批量回调通知之前做一个批处理系统任务执行完成后需要同时做三件事更新数据库状态、发送通知给管理员、记录审计日志。用Consumer链式处理特别合适ConsumerTaskResult updateDb result - taskDao.updateStatus(result.getTaskId(), result.getStatus()); ConsumerTaskResult sendNotify result - notifyService.send(result.getOwner(), 任务 result.getTaskId() 已完成); ConsumerTaskResult writeAudit result - auditLogDao.insert(result.toAuditLog()); ConsumerTaskResult combined updateDb.andThen(sendNotify).andThen(writeAudit); // 批次处理完成后统一回调 taskResults.forEach(combined);andThen在这里的作用是确保三个操作按顺序执行任何一个抛异常都会中断后续操作。这跟三个单独调用的区别在于使用Consumer链可以把处理流程和具体逻辑解耦上层只需要传入一个Consumer对象完全不需要关心里面到底调了几个动作。3.3 Consumer结合Optional优雅判空Optional.ifPresent接收的就是Consumer参数OptionalString optional Optional.ofNullable(getNullableValue()); optional.ifPresent(value - { // 只有值存在时才执行 System.out.println(Value is: value); });以前写判空都是if (value ! null) { doSomething(value); }用Optional加Consumer代码更紧凑。不过这里要提醒一句Optional不是为了取代所有判空而生的它更适合作为方法返回值来提示调用方这个结果可能不存在不要滥用。4. Supplier接口只出不进的数据提供者4.1 接口源码与基本概念FunctionalInterface public interface SupplierT { T get(); }这就是一个无参工厂。它不接收任何参数每次调用get()返回一个新对象或者一个计算值。很多框架里的懒加载、延迟计算都是基于这个接口实现的。Supplier用得最多的一个地方是配合Optional.orElseGet做懒加载。我见过太多人这么写了String result optional.orElse(expensiveOperation());这个写法有个隐蔽的性能坑expensiveOperation()无论optional里有没有值都会被执行。如果这个操作很重查数据库、调外部接口就是白白浪费资源。正确的写法是String result optional.orElseGet(() - expensiveOperation());orElseGet接收Supplier参数只有当optional为空时才会调用expensiveOperation()实现了真正的懒加载。这就是Supplier的核心价值——把什么时候取值的决策权交给调用方。4.2 真实场景用Supplier实现缓存穿透保护之前写了一个配置中心客户端需要频繁读取配置。配置存储在远程服务上但本地有缓存。缓存失效的瞬间如果大量请求同时涌入每个请求都去远程拉配置就是典型的缓存击穿。用Supplier可以优雅地实现单次加载、全局复用public class ConfigLoader { private volatile String cachedConfig; public String getConfig() { return getOrLoad(() - loadFromRemote()); } private String getOrLoad(SupplierString loader) { String local cachedConfig; if (local ! null) { return local; } synchronized (this) { if (cachedConfig null) { cachedConfig loader.get(); } return cachedConfig; } } }这段代码的精髓在于getOrLoad方法不关心loader怎么实现它只负责缓存有就直接返回缓存没有就加载一次且保证只有一次。后面如果想让缓存定时刷新只需要换一个Supplier实现或者加个定时任务调一下getConfig。4.3 Supplier与Stream的generate结合Stream.generate接收Supplier参数生成无限流Stream.generate(Math::random) .limit(5) .forEach(System.out::println);Math::random就是一个SupplierDouble。这种方式在做测试数据生成、模拟数据填充时特别方便。比如生成10个UUIDStream.generate(UUID::randomUUID) .limit(10) .collect(Collectors.toList());5. Predicate接口返回布尔值的条件判断器5.1 接口源码与核心方法FunctionalInterface public interface PredicateT { boolean test(T t); default PredicateT and(Predicate? super T other) { Objects.requireNonNull(other); return (t) - test(t) other.test(t); } default PredicateT negate() { return (t) - !test(t); } default PredicateT or(Predicate? super T other) { Objects.requireNonNull(other); return (t) - test(t) || other.test(t); } static T PredicateT isEqual(Object targetRef) { return (null targetRef) ? Objects::isNull : object - targetRef.equals(object); } }test是核心方法and、or、negate分别对应逻辑与、逻辑或、逻辑非。这三个默认方法让Predicate具备了组合能力可以像搭积木一样拼出复杂的判断条件。5.2 业务中的多条件组合过滤以前写筛选条件都是把逻辑堆在循环里面ListOrder filtered new ArrayList(); for (Order order : orders) { if (order.getStatus() OrderStatus.PAID order.getAmount() 1000 order.getUserId() ! null) { filtered.add(order); } }条件少还好一旦条件多起来一个if里三层嵌套可读性就很差了。用Predicate可以把每个条件单独抽出来再自由组合PredicateOrder isPaid order - order.getStatus() OrderStatus.PAID; PredicateOrder isExpensive order - order.getAmount() 1000; PredicateOrder hasUser order - order.getUserId() ! null; PredicateOrder filter isPaid.and(isExpensive).and(hasUser); ListOrder filtered orders.stream() .filter(filter) .collect(Collectors.toList());哪天产品说金额大于1000改成金额在500到5000之间或者排除某个特殊用户你只需要新增或修改一个Predicate不影响其他条件。这种代码的风格就是函数式编程的组合优于继承思想比在if里改逻辑安全得多。5.3 Stream filter和removeIf的配合removeIf是Collection接口的默认方法接收Predicate参数作用是移除满足条件的元素ListString words new ArrayList(Arrays.asList(apple, banana, cherry, date)); words.removeIf(word - word.length() 5); // 执行结果[apple, date]这个是原地修改不会产生新集合。在ArrayList上使用removeIf时迭代器的remove机制是安全的不用担心ConcurrentModificationException。如果既要过滤又要保留原始数据那就用Stream.filter再collect返回一个新集合。两种方式适用场景不同原集合无所谓、只想筛选就不需要保留原样本原数据不能动就用Stream。6. 四大接口联合实战搭建一个数据清洗管线6.1 需求场景抽象这四个接口单独看都很简单但是放在一起用才能真正体会它们的威力。我之前做一个用户数据清洗项目需求是这样的从第三方渠道拿到的原始用户数据脏得不行。有null字段、有空字符串、有格式错误的手机号还可能有重复记录。我需要做一套清洗流程过滤掉无效数据Predicate对有效的字段做格式修复Function把修好的数据逐条插入数据库Consumer数据来源是外部接口需要按需拉取Supplier这个场景天然就是四个接口的组合应用。6.2 管线代码的完整实现先定义实体类public class UserData { private String name; private String phone; private String email; private Integer age; // 构造方法、getter/setter 略 }定义清洗器public class UserDataCleaner { public static final PredicateUserData VALID_PHONE u - u.getPhone() ! null u.getPhone().matches(^1\\d{10}$); public static final PredicateUserData VALID_EMAIL u - u.getEmail() null || u.getEmail().matches(^[\\w.-][\\w-]\\.[\\w.]$); public static final PredicateUserData VALID_AGE u - u.getAge() null || (u.getAge() 0 u.getAge() 120); public static final PredicateUserData VALID VALID_PHONE.and(VALID_EMAIL).and(VALID_AGE); public static final FunctionUserData, UserData FIX_PHONE u - new UserData(u.getName(), DesensitizationUtil.maskPhone(u.getPhone()), u.getEmail(), u.getAge()); public static final FunctionUserData, UserData TRIM_FIELDS u - new UserData( u.getName() null ? null : u.getName().trim(), u.getPhone(), u.getEmail() null ? null : u.getEmail().trim().toLowerCase(), u.getAge() ); public static final ConsumerUserData SAVE_TO_DB u - System.out.println(Saving user: u.getName() , phone: u.getPhone()); }组装管线并执行public class DataCleaningPipeline { public static void main(String[] args) { SupplierListUserData dataSupplier DataCleaningPipeline::fetchRawData; ListUserData rawData dataSupplier.get(); ListUserData cleanedData rawData.stream() .filter(UserDataCleaner.VALID) .map(UserDataCleaner.FIX_PHONE) .map(UserDataCleaner.TRIM_FIELDS) .collect(Collectors.toList()); cleanedData.forEach(UserDataCleaner.SAVE_TO_DB); } private static ListUserData fetchRawData() { // 模拟从第三方接口拉取数据 return Arrays.asList( new UserData( Alice , 13812345678, AliceExample.com, 25), new UserData(Bob, 12345, bobexample.com, 30), new UserData(Charlie, 13987654321, null, -5), new UserData(David, 13711112222, davidexample.com, 40) ); } }跑一下这段代码Bob因为手机号不合法被过滤Charlie因为年龄无效被过滤Alice的邮箱被修正为小写最终只有Alice和David被保存。整个过程一目了然。6.3 管线的扩展性与可维护性分析这套管线最棒的地方在于每一步都是独立的。产品说手机号校验规则从1开头11位改成支持座机只需要改VALID_PHONE这个Predicate说保存前要加个数据落库日志只需要在SAVE_TO_DB后面加一个andThen说数据源从第三方接口换成读本地文件只需要换那个Supplier。如果用传统命令式写法每个需求变更都可能牵动大范围代码修改。而函数式组合的方式让变更的冲击面被限制在最局部的位置。这就是函数式编程在工程维护上的真正优势不是代码少几行的问题是变更成本的问题。7. 常见误区与性能考量7.1 字段捕获与effectively final使用Lambda表达式时有一个硬性规则lambda体里访问的外部局部变量必须是effectively final的即赋值后不再修改。比如这样写编译不通过int base 100; FunctionInteger, Integer addBase x - x base; base 200; // 编译错误base should be effectively final原因是Lambda在底层会捕获这个变量的值如果允许变量变化捕获的值和外部值就不一致了容易引发难以定位的并发问题。如果想用变化的变量可以改用数组或者AtomicInteger但并发场景要注意线程安全问题int[] base {100}; FunctionInteger, Integer addBase x - x base[0]; base[0] 200; // 编译通过但这种方法不推荐7.2 装箱拆箱的性能损耗PredicateInteger底层用的是Integer对象每次test调用都涉及装箱拆箱。如果在一个百万级数据量的Stream里做过滤这个性能损耗是实打实的。Java专门搞了一套原始类型特化接口来规避这个问题接口泛型版专门版用途PredicateInteger有装箱IntPredicate避免int装箱拆箱ConsumerInteger有装箱IntConsumer同上FunctionInteger, Integer有装箱IntUnaryOperator同上SupplierInteger有装箱IntSupplier同上写代码的时候如果能确定操作的是基本类型优先用专门接口IntStream.range(1, 1_000_000) .filter(n - n % 2 0) // IntPredicate接收的是原始int .map(n - n * n) // IntUnaryOperator .sum();7.3 链式调用别忽略调试成本函数式链式调用有个实际痛点不好调试。断点打在lambda体里要一步步往下看比较麻烦尤其是多个map连着的时候你不知道某个值在传递过程中哪一步变成了奇怪的样子。一个常用技巧是在链式调用中间临时加一个偷看用的peekListUserData result rawData.stream() .filter(UserDataCleaner.VALID) .peek(u - System.out.println(After filter: u.getPhone())) .map(UserDataCleaner.FIX_PHONE) .peek(u - System.out.println(After fix phone: u.getPhone())) .map(UserDataCleaner.TRIM_FIELDS) .collect(Collectors.toList());调试完了再把peek删掉。另外不要在生产环境留peek做日志peek不是设计用来做日志记录的它是个调试偷看操作留着反而影响性能且代码语义不清。7.4 别为了函数式而函数式最后说句掏心窝的话函数式编程不是银弹。我自己见过有些人在一个只有3个元素的小集合上疯狂链式操作代码写了一长串可读性反而比for循环差。还有人把业务逻辑全塞在lambda里方法体几百行比命令式更难看。函数式编程的适用场景是逻辑相对通用、存在组合需求、行为需要参数化传递。如果只是遍历一个list做简单打印for循环完全没问题。工程上没有标准答案选最简单直接的方式就好。在我自己带的团队里我通常会强调几个原则单个Lambda表达式体的逻辑不要超过3行超过就一定抽方法链式调用建议不超过4个中间操作再多就把流程拆成有名字的方法任何在Lambda里写if-else嵌套多于2层的先停下来想想有没有更好的设计这些原则本质上不是函数式编程的问题是代码可读性的问题。函数式只是让代码更优雅不是让代码更难懂。我自己的经验是想真正掌握这四个接口光看书没用把项目里的for循环改成stream、把if判断改成Predicate、把重复的转换逻辑改成Function改着改着就有感觉了。下次看到别人代码里的Function、Consumer你一眼就能知道这段逻辑在干什么——这就是这篇入门文章的目标所在了。
返回列表