Lambda 表达式带来的复杂性的破解之道

2021-11-29 11:19:08 浏览数 (1)

一、背景

Java 8 的 Lambda 表达式已经不再是“新特性”。

曾经很多人抵触 Lambda 表达式,现在几乎也成了标配。

实际开发中最常见的是,很多人使用 Stream 来处理集合类。

但是由于 Lambda 表达式的滥用,代码可读性会变差,那么该如何解决?

本文会讨论一下这个问题,并给出自己的几个解决办法。

二、看法

对于 Lambda 表达式或者 Stream 的看法不尽一致。

2.1 支持

使用 Lambda 表达式可以减少类或者方法的常见。

使用 Stream 可以享受链式编程的乐趣。

有些人看别人都在用,似乎有些高端,或者担心自己被淘汰也跟着大量使用。

2.2 反对

有些人对 lambda 表达式持反对意见。

他们认为大量使用 lambda 表达式写出的代码不容易理解

还有的团队老人比较多,不太容易接受新的事物,不提倡使用 Lambda 表达式。

如:

Stream 的广泛使用带来了很多样板方法。

代码语言:javascript复制
List<String> tom = dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(dog -> dog.getName().toLowerCase()).collect(Collectors.toList());

甚至经常有人会在 Stream 的 map 函数中写大量转换代码。

代码语言:javascript复制
import lombok.Data;

@Data
public class DogDO {
    private String name;

    private String nickname;

    private String address;

    private String owner;

}

DogVO 和 DogDO 结构相同。

代码语言:javascript复制
   List<DogVO> result = dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(dog -> {
            DogVO dogVO = new DogVO();
            dogVO.setName(dog.getName());
            dogVO.setAddress(dog.getAddress());
            dogVO.setOwner(dog.getOwner());
            return dogVO;
        }).collect(Collectors.toList());

更有甚者直接将整个 Stream 表达结果当做参数传入方法中:

代码语言:javascript复制
   result.addAll(dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(dog -> {
            DogVO dogVO = new DogVO();
            dogVO.setName(dog.getName());
            dogVO.setAddress(dog.getAddress());
            dogVO.setOwner(dog.getOwner());
            return dogVO;
        }).collect(Collectors.toList()));

当一个方法中大量出现上述现象时,代码就没法读了。

还有人担心 Stream 会带来一些副作用。

三、底层原理

参见我的另外一篇文章

《深入理解 Lambda 表达式》

四、建议

Lambda 可以简化代码,但是要把握度,如果滥用 lambda 表达式,代码可读性会很差。

4.1 使用方法引用

代码语言:javascript复制
 List<String> names = new LinkedList<>();
names.addAll(users.stream().map(user -> user.getName()).filter(userName -> userName != null).collect(Collectors.toList()));
names.addAll(users.stream().map(user -> user.getNickname()).filter(nickname -> nickname != null).collect(Collectors.toList()));

可以优化为:

代码语言:javascript复制
List<String> names = new LinkedList<>();
        names.addAll(users.stream().map(User::getName).filter(Objects::nonNull).collect(Collectors.toList()));
        names.addAll(users.stream().map(User::getNickname).filter(Objects::nonNull).collect(Collectors.toList()));

4.2 复杂代码抽取出来

对于部分复杂逻辑、对于部分需要复用的逻辑,建议封装成独立的类。

Stream参数中常用的 java.util.function 包下的 PredicateFunctionConsumer 类和 Comparator 等。

代码语言:javascript复制
   result.addAll(dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(dog -> {
            DogVO dogVO = new DogVO();
            dogVO.setName(dog.getName());
            dogVO.setAddress(dog.getAddress());
            dogVO.setOwner(dog.getOwner());
            return dogVO;
        }).collect(Collectors.toList()));

改造如下:

代码语言:javascript复制
import java.util.function.Function;

public class DogDO2VOConverter implements Function<DogDO, DogVO> {
    @Override
    public DogVO apply(DogDO dogDO) {
        DogVO dogVO = new DogVO();
        dogVO.setName(dogDO.getName());
        dogVO.setAddress(dogDO.getAddress());
        dogVO.setOwner(dogDO.getOwner());
        return dogVO;
    }
}

改造

代码语言:javascript复制
 result.addAll(dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(new DogDO2VOConverter()).collect(Collectors.toList()));

或者定义静态方法

代码语言:javascript复制
public class DogDO2VOConverter {
    
    public static DogVO toVo(DogDO dogDO) {
        DogVO dogVO = new DogVO();
        dogVO.setName(dogDO.getName());
        dogVO.setAddress(dogDO.getAddress());
        dogVO.setOwner(dogDO.getOwner());
        return dogVO;
    }
}

直接使用方法调用即可

代码语言:javascript复制
        result.addAll(dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(DogDO2VOConverter::toVo).collect(Collectors.toList()));

4.3 不要将stream 操作放在方法参数中

正如上面的代码一样

代码语言:javascript复制
        result.addAll(dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(DogDO2VOConverter::toVo).collect(Collectors.toList()));

很多人写代码时,喜欢将 Stream 操作放到方法参数中来节省一个局部变量。

我个人非常反对这种行为,这样做极大降低了代码的可读性。

我们应该将 Stream 的操作定义具有明确含义的返回值,然后再使用。

如:

代码语言:javascript复制
   List<DogVO> toms = dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(DogDO2VOConverter::toVo).collect(Collectors.toList());
  result.addAll(toms);

4.4 Lambda 表达式不宜过长

很多人尝到了链式编程的甜头以后,总喜欢把代码写的很长。

如:

代码语言:javascript复制
 Optional.ofNullable(dogs).orElse(new ArrayList<>()).stream().filter(dog -> dog.getName().startsWith("tom")).map(DogDO2VOConverter::toVo).collect(Collectors.toList()));

但看这一句代码都很费劲,当一个函数中出现大量这种代码时,简直要吐血。

对于这种长的 Lambda 表达式写法,建议尽可能拆分出来。

代码语言:javascript复制
    List<Dog> dogs = Optional.ofNullable(dogs).orElse(new ArrayList<>());
        List<String> toms = dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(DogDO2VOConverter::toVo).collect(Collectors.toList()))

然后进一步将 dogs.stream 这部分逻辑封装为子函数。

代码语言:javascript复制
    List<Dog> dogs = Optional.ofNullable(dogs).orElse(new ArrayList<>());
        List<String> toms = getDogNamesStartWithTom(dogs)

这样就比较清晰易懂。

4.5 样板方法使用泛型封装

如果你发现你的项目中大量使用 Lambda,而且有很多代码的逻辑非常相似,可以考虑使用泛型封装工具类来简化代码。

下面给出两个简单的示例。

4.5.1 Stream 对象转换

实际开发中类似先过滤后转换的代码非常多:

代码语言:javascript复制
List<DogVO> vos = dogs.stream().map(DogDO2VOConverter::toVo).collect(Collectors.toList())

实际上这种写法习以为常,但一个方法出现多次这种写法就非常不宜读。

封装成工具类之后就比较简洁:

代码语言:javascript复制
 List<DogVO> vos = MyCollectionUtils.convert(dogs,DogDO2VOConverter::toVo);

工具类:

代码语言:javascript复制
public class MyCollectionUtils {

    public static <S, T> List<T> convert(List<S> source, Function<S, T> function) {

        if (CollectionUtils.isEmpty(source)) {
            return new ArrayList<>();
        }

        return source.stream().map(function).collect(Collectors.toList());
    }

    public static <S, T> List<T> convert(List<S> source, Predicate<S> predicate, Function<S, T> function) {

        if (CollectionUtils.isEmpty(source)) {
            return new ArrayList<>();
        }

        return source.stream().filter(predicate).map(function).collect(Collectors.toList());
    }
}

通过将常见的样板方法封装成工具类,可以极大简化使用时的代码。

4.5.2 Spring 策略模式案例

如《巧用 Spring 自动注入实现策略模式升级版》 中提到,如下案例:

定义接口

代码语言:javascript复制
public interface Handler {

    String getType();

    void someThing();
}

VIP 用户实现:

代码语言:javascript复制
import org.springframework.stereotype.Component;

@Component
public class VipHandler implements Handler{
    @Override
    public String getType() {
        return "Vip";
    }

    @Override
    public void someThing() {
        System.out.println("Vip用户,走这里的逻辑");
    }
}

普通用户实现:

代码语言:javascript复制
@Component
public class CommonHandler implements Handler{

    @Override
    public String getType() {
        return "Common";
    }

    @Override
    public void someThing() {
        System.out.println("普通用户,走这里的逻辑");
    }
}

模拟 Service 中使用:

代码语言:javascript复制
@Service
public class DemoService implements ApplicationContextAware {


    private Map<String, List<Handler>> type2HandlersMap;

    public void test(){
      String type ="Vip";
      for(Handler handler : type2HandlersMap.get(type)){
          handler.someThing();;
      }
    }


    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {

        Map<String, Handler> beansOfType = applicationContext.getBeansOfType(Handler.class);
        beansOfType.forEach((k,v)->{
            type2HandlersMap = new HashMap<>();
            String type =v.getType();
            type2HandlersMap.putIfAbsent(type,new ArrayList<>());
            type2HandlersMap.get(type).add(v);
        });
    }
}

其中 setApplicationContext 里面的代码非常相似。

可以编写工具类

代码语言:javascript复制
import org.apache.commons.collections4.MapUtils;
import org.springframework.context.ApplicationContext;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

public class BeanStrategyUtils {

// 构造type 到多个 bean 的映射
  public static <K,B> Map<K, List<B>> buildTypeBeansMap(ApplicationContext applicationContext, Class<B> beanClass, Function<B,K> keyFunc) {
        Map<K, List<B>> result = new HashMap<>();

        Map<String, B> beansOfType = applicationContext.getBeansOfType(beanClass);
       if(MapUtils.isEmpty(beansOfType)){
           return result;
       }

        for(B bean : beansOfType.values()){
            K type = keyFunc.apply(bean);
            result.putIfAbsent(type,new ArrayList<>());
            result.get(type).add(bean);
        }
        return result;
    }

// 构造type 到单个 bean 的映射
    public static <K,B> Map<K, B> buildType2BeanMap(ApplicationContext applicationContext, Class<B> beanClass, Function<B,K> keyFunc) {
        Map<K, B> result = new HashMap<>();

        Map<String, B> beansOfType = applicationContext.getBeansOfType(beanClass);
        if(MapUtils.isEmpty(beansOfType)){
            return result;
        }

        for(B bean : beansOfType.values()){
            K type = keyFunc.apply(bean);
            result.put(type,bean);
        }
        return result;
    }
}

改造后

代码语言:javascript复制
@Service
public class DemoService  implements ApplicationContextAware {

    private Map<String, List<Handler>> type2HandlersMap;

    public void test(){
        String type ="Vip";
        for(Handler handler : type2HandlersMap.get(type)){
            handler.someThing();;
        }
    }


    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        type2HandlersMap = BeanStrategyUtils.buildTypeBeansMap(applicationContext,Handler.class, Handler::getType);
    }
}

很多人可能会说,写工具方法也很花时间呢。

但是,写工具方法之后,代码重复率降低;代码更加简洁,可读性提高;后续类似逻辑都可以实现代码复用,开发效率也提高了;一举多得。

4.6 使用加强包

前面讲到了,可以通过封装工具类来减少 Lambda 代码的复杂性。

此外,我们还可以考虑使用一些加强包来解决这个问题。

4.6.1 StreamEx

如 StreamEx

Maven 依赖

https://mvnrepository.com/artifact/one.util/streamex

代码语言:javascript复制
<dependency>
    <groupId>one.utilgroupId>
    <artifactId>streamexartifactId>
    <version>0.8.0version>
dependency>

Java 8 写法

代码语言:javascript复制
 Map<Role, List<User>> role2users = users.stream().collect(Collectors.groupingBy(User::getRole));

StreamEx 写法:

代码语言:javascript复制
Map<Role, List<User>> role2users = StreamEx.of(users).groupingBy(User::getRole);

前面的案例

代码语言:javascript复制
List<DogVO> vos = dogs.stream().map(DogDO2VOConverter::toVo).collect(Collectors.toList())

就可以改为

代码语言:javascript复制
 List<DogVO> vos = StreamEx.of(dogs).map(DogDO2VOConverter::toVo).toList();

4.6.2 vavr

vavr

用户文档:https://docs.vavr.io/

Maven 依赖

https://mvnrepository.com/artifact/io.vavr/vavr

代码语言:javascript复制
<dependency>
    <groupId>io.vavrgroupId>
    <artifactId>vavrartifactId>
    <version>1.0.0-alpha-4version>
dependency>

Java 8 中的写法:

代码语言:javascript复制
// = ["1", "2", "3"] in Java 8
Arrays.asList(1, 2, 3)
      .stream()
      .map(Object::toString)
      .collect(Collectors.toList())

vavr 中的写法:

代码语言:javascript复制
// = Stream("1", "2", "3") in Vavr
Stream.of(1, 2, 3).map(Object::toString)

4.7 有些场景不用 Lambda 表达式

如果你发现某个函数里使用 Lambda 过多时(实际工作中,发现会有人一个函数里一半以上都是 lambda 表达式,非常头疼),可以考虑将部分不容易懂的 Lambda 写法改为普通写法,通常可读性会大大提高。

代码语言:javascript复制
List<String> names = new LinkedList<>();
        names.addAll(users.stream().map(User::getName).filter(Objects::nonNull).collect(Collectors.toList()));
        names.addAll(users.stream().map(User::getNickname).filter(Objects::nonNull).collect(Collectors.toList()));

优化为

代码语言:javascript复制
 List<String> names = new LinkedList<>();
        for(User user : users) {
            String name = user.getName();
            if(name!= null ){
                names.add(name);
            }
            
            String nickname = user.getNickname();
            if(nickname != null){
                names.add(nickname);
            }
        }

虽然代码更长,但是更容易看懂。

还可将该部分逻辑封装为一个子函数并给一个有意义的命名。

代码语言:javascript复制
  /**
     * 获取名称和昵称
     */
    private  List<String> getNamesAndNickNames(List<User> users) {
        List<String> names = new LinkedList<>();
        for (User user : users) {
            String name = user.getName();
            if (name != null) {
                names.add(name);
            }

            String nickname = user.getNickname();
            if (nickname != null) {
                names.add(nickname);
            }
        }
        return names;
    }

使用时直接调用即可:

代码语言:javascript复制
List<String> names = getNamesAndNickNames(users);

这样外层函数可以明确知道这部分逻辑的意图,压根不需要看这么多代码,可读性大大提高。

五、思考

过犹不及,我们使用 Lambda 表达式时,一定不要忽略可读性。

Lambda 表达式没有错,错在很多人滥用 Lambda 表达式。

我们在编码过程中要注意做好权衡,掌握好度。

本文简单谈了 Lambda 表达式的利弊,给出自己的几点破解之法。

希望对大家有帮助。

当然,可能还有很多解决办法,欢迎留言补充。

希望大家能够努力做个有追求的程序员。

创作不易,如果本文对你有帮助,你的支持和鼓励,是我创作的最大动力。

0 人点赞