The solution to the complexity brought by Lambda expression

Posted by dragongamer on Mon, 29 Nov 2021 05:28:56 +0100

1, Background

Lambda expressions in Java 8 are no longer a "new feature".

Many people once resisted Lambda expressions, but now they have almost become standard.

The most common thing in actual development is that many people use Stream to deal with collection classes.

However, due to the abuse of Lambda expressions, the readability of the code will become worse, so how to solve it? This paper will discuss this problem and give some solutions.

2, View

There are different views on Lambda expression or Stream.

2.1 support

Using Lambda expressions can reduce the common of classes or methods. Using Stream can enjoy the fun of chain programming.

Some people see that others are using it. It seems that some are high-end, or they worry that they will be eliminated and use it in large quantities.

2.2 objections

Some people object to lambda expressions.

They think that the code written with a lot of lambda expressions is not easy to understand.

In addition, there are many old people in the team, who are not easy to accept new things and do not advocate the use of Lambda expressions.

For example: The wide use of Stream has brought many template methods.

List<String> tom = dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(dog -> dog.getName().toLowerCase()).collect(Collectors.toList());

Even people often write a lot of conversion code in the map function of Stream.

import lombok.Data;

@Data
public class DogDO {
    private String name;

    private String nickname;

    private String address;

    private String owner;

}

DogVO and DogDO have the same structure.

   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());

What's more, the whole Stream expression result is directly passed into the method as a parameter:

   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()));

When a large number of the above phenomena occur in a method, the code cannot be read.

Others worry that Stream will bring some side effects.

3, Underlying principle

See my other article Deep understanding of Lambda expressions

4, Suggestion

Lambda can simplify the code, but we should grasp the degree. If lambda expressions are abused, the code readability will be very poor.

4.1 use method reference

 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()));

Can be optimized as:

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 complex code extraction

For some complex logic and some logic that needs to be reused, it is recommended to package it into independent classes. Such as Predicate, Function, Consumer class and Comparator under java.util.function package commonly used in Stream parameters.

   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()));

The transformation is as follows:

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;
    }
}

reform

 result.addAll(dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(new DogDO2VOConverter()).collect(Collectors.toList()));

Or define static methods

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;
    }
}

You can use method calls directly

        result.addAll(dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(DogDO2VOConverter::toVo).collect(Collectors.toList()));

4.3 do not put stream operations in method parameters

Just like the code above

        result.addAll(dogs.stream().filter(dog -> dog.getName().startsWith("tom")).map(DogDO2VOConverter::toVo).collect(Collectors.toList()));

When writing code, many people like to put the Stream operation into the method parameters to save a local variable.

I personally object to this behavior, which greatly reduces the readability of the code.

We should define the operation of Stream as a return value with clear meaning, and then use it. For example:

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

4.4 Lambda expression should not be too long

After tasting the sweetness of chain programming, many people always like to write long code. For example:

 Optional.ofNullable(dogs).orElse(new ArrayList<>()).stream().filter(dog -> dog.getName().startsWith("tom")).map(DogDO2VOConverter::toVo).collect(Collectors.toList()));

But it's hard to look at this code. When a large number of this code appears in a function, it's like vomiting blood.

For this long Lambda expression, it is recommended to split it as much as possible.

    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()))

Then, the logic of dogs.stream is further encapsulated into sub functions.

    List<Dog> dogs = Optional.ofNullable(dogs).orElse(new ArrayList<>());
        List<String> toms = getDogNamesStartWithTom(dogs)

This is more clear and easy to understand.

4.5 the template method uses generic encapsulation

If you find that Lambda is widely used in your project, and the logic of many codes is very similar, you can consider using generic encapsulation tool classes to simplify the code. Here are two simple examples.

4.5.1 Stream object conversion

In the actual development, there are many codes like filtering before conversion:

List<DogVO> vos = dogs.stream().map(DogDO2VOConverter::toVo).collect(Collectors.toList())

In fact, this writing is used to it, but it is very unreadable when a method appears many times.

After being encapsulated into a tool class, it is relatively concise:

 List<DogVO> vos = MyCollectionUtils.convert(dogs,DogDO2VOConverter::toVo);

Tools:

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());
    }
}

By encapsulating common template methods into tool classes, you can greatly simplify the code when using them.

4.5.2 Spring policy pattern cases

as Smart use of Spring automatic injection to implement policy pattern upgrade The following cases are mentioned in:

Define interface

public interface Handler {

    String getType();

    void someThing();
}

VIP user implementation:

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 User, follow the logic here");
    }
}

Common user implementation:

@Component
public class CommonHandler implements Handler{

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

    @Override
    public void someThing() {
        System.out.println("Ordinary users, follow the logic here");
    }
}

Used in Simulated Service:

@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);
        });
    }
}

The code in setApplicationContext is very similar.

You can write tool classes

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 {

// Construct the mapping of type to multiple bean s
  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;
    }

// Construct the mapping of type to a single 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;
    }
}

After transformation

@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);
    }
}

Many people may say that it takes time to write tools. However, after writing the tool method, the code repetition rate decreases; The code is more concise and the readability is improved; Subsequent similar logic can realize code reuse, and the development efficiency is also improved; Kill many birds with one stone.

4.6 use of reinforcement package

As mentioned earlier, you can reduce the complexity of Lambda code by encapsulating tool classes. In addition, we can also consider using some enhancement packages to solve this problem.

4.6.1 StreamEx

as StreamEx

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

<dependency>
    <groupId>one.utilgroupId>
    <artifactId>streamexartifactId>
    <version>0.8.0version>
dependency>

Java 8 writing method

 Map<Role, List<User>> role2users = users.stream().collect(Collectors.groupingBy(User::getRole));

StreamEx:

Map<Role, List<User>> role2users = StreamEx.of(users).groupingBy(User::getRole);

Previous cases

List<DogVO> vos = dogs.stream().map(DogDO2VOConverter::toVo).collect(Collectors.toList())

You can change it to

 List<DogVO> vos = StreamEx.of(dogs).map(DogDO2VOConverter::toVo).toList();

4.6.2 vavr

vavr

User documentation: https://docs.vavr.io/

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

<dependency>
    <groupId>io.vavrgroupId>
    <artifactId>vavrartifactId>
    <version>1.0.0-alpha-4version>
dependency>

Writing in Java 8:

// = ["1", "2", "3"] in Java 8
Arrays.asList(1, 2, 3)
      .stream()
      .map(Object::toString)
      .collect(Collectors.toList())

In vavr:

// = Stream("1", "2", "3") in Vavr
Stream.of(1, 2, 3).map(Object::toString)

4.7 some scenarios do not use Lambda expressions

If you find that you use too much lambda in a function (in actual work, you will find that more than half of a function is lambda expressions, which is a headache), you can consider changing some difficult lambda writing methods to ordinary writing methods, which will greatly improve the readability.

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()));

Optimized as

 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);
            }
        }

Although the code is longer, it is easier to understand.

This part of logic can also be encapsulated as a sub function and given a meaningful name.

  /**
     * Get name and nickname
     */
    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;
    }

You can call directly when using:

List<String> names = getNamesAndNickNames(users);

In this way, the outer function can clearly know the intention of this part of logic. There is no need to look at so much code at all, and the readability is greatly improved.

5, Thinking

When we use Lambda expressions, we must not ignore readability.

There is nothing wrong with Lambda expressions. The mistake is that many people abuse Lambda expressions.

In the coding process, we should pay attention to making trade-offs and mastering the degree.

This paper briefly discusses the advantages and disadvantages of Lambda expression, and gives some solutions. I hope it will help you. Of course, there may be many solutions. Welcome to leave a message.

I hope you can strive to be a programmer with pursuit.

It's not easy to create. If this article is helpful to you, your support and encouragement are the biggest driving force of my creation.