Creating Function Pipelines In Java

java.util.Function is a functional interface in Java that takes one argument and produces a result. The Function is defined as follows 

@FunctionalInterface  
public interface Function<T, R> {  
    R apply(T t);  
}

Creating function Pipelines in Java

The Function interface has a single abstract method, apply, which takes an argument of type T and returns a result of type R. The type parameters T and R represent the input type and output type of the function, respectively.

The Function is used in 'Stream' API, 'java.util.Optional' class to pass functional arguments that perform transformations/operations on data, classical example is the 'map' function 

<R> Stream<R> map(Function<? super T, ? extends R> mapper);

The Function interface is highly useful and allows us to link functions together to create efficient function pipelines, let's focus on some simple basics before creating Pipeline. 

Function<Integer, String> conversion = (Integer i) -> String.valueOf(i);  
System.out.println(conversion.apply(20));  # 20

The function outlined above takes an Integer as its input and produces a String as its output. In accordance with the diagram, the Integer is represented as T and the String is represented as R. 

The 'apply' method applies the function to the input argument, when Integer 20 is applied to 'function' String is returned. 

Let's understand by another example,

Function<String, String> toUppercase = (str) -> str.toUpperCase(); System.out.println(toUppercase.apply("hello")); # HELLO

The toUppercase function accepts a String as its input and returns a String by converting it to uppercase.

The Function interface has a very useful method 'andThen' is used to compose two functions into a single function, it has the following signature. 

default <V> Function<T, V> andThen(Function<? super R, ? extends V> after)

The andThen method takes another Function as an argument and returns a new Function that represents the composition of the original Function and the argument Function

Creating the first Pipeline

Imagine a class Text that has three methods: “addText” for adding text to the context, “removeSpecialCharacter” for removing special characters and adding spaces, and capitalize for converting the string to uppercase.

public class Text {  
    public static String addText(String text){  
        return text;  
    }  
  
    public static String removeSpecialCharacter(String text){  
        return text.replaceAll("[^\\p{Alpha}]+"," ");  
    }  
  
    public static String capitalize(String text){  
        return text.toUpperCase();  
    }
}

By utilizing the java.util.Function interface, we can create a cohesive function pipeline instead of calling each function separately, since each function is dependent on the previous one.

Creating function Pipelines in Java

To trigger the Pipeline first we need to create a reference, then chain the functions. 

Function<String, String> pipeline = Text :: addText;

String result = pipeline  
                    .andThen(x -> removeSpecialCharacter(x))  
                    .andThen(x -> capitalize(x))  
                    .apply("Java@#$%%%Programming%$##Language\"");  

System.out.println(result); # JAVA PROGRAMMING LANGUAGE

Above code can be refactored further to use method reference, 

result = pipeline  
        .andThen(Text :: removeSpecialCharacter)  
        .andThen(Text ::  capitalize)  
        .apply("Java@#$%%%Programming%$##Language\"");

Creating the second Pipeline

The more meaningful example would be a traditional line by line file reading a file. Let's re-factor the below code in the pipeline. 

public static void main(String[] args) {
    BufferedReader br;

    try {
        br = new BufferedReader(new FileReader("example.txt"));
        String line = br.readLine();

        while (line != null) {
            System.out.println(line);
            line = br.readLine();
        }
        br.close();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

The first step would be to create an instance of BufferedReader by passing in a String filename as a parameter. The second step is to read the lines. The third step is to print the lines, and the fourth step is to close the reader. The class methods looks like, 

public static BufferedReader openReader(String filename) {  
    System.out.println("Step1:Creating BufferedReader");  
    BufferedReader br;  
     try {  
        br = new BufferedReader(new FileReader(new File(filename)));  
    } catch (FileNotFoundException e) {  
        throw new RuntimeException(e);  
    }  
    return br;  
}  

public static BufferedReader readLines(BufferedReader br) {  
    System.out.println("Step2:Reading file");  
    StringBuilder sb = new StringBuilder();  
    try {  
        while(br.readLine()!=null){  
            sb.append(br.readLine());  
        }  
    } catch (IOException e) {  
        throw new RuntimeException(e);  
    }  
    lines = sb.toString();  
    return br;  
}  

public static BufferedReader print(BufferedReader br) {  
    System.out.println("Step3:Printing Lines");  
    System.out.println(lines);  
    return br;  
}  

public static BufferedReader closeReader(BufferedReader br) {  
    System.out.println("Step4:Closing Connection");  
    try {  
        br.close();  
    } catch (IOException e) {  
        throw new RuntimeException(e);  
    }  
    return br;  
}

To trigger the pipeline first we need to create the Function reference and then chain the remaining functions 

Function<String, BufferedReader> pipeline = FileOperations :: openReader;
pipeline  
        .andThen(FileOperations :: readLines)  
        .andThen(x -> print(x))  
        .andThen(x -> closeReader(x))  
        .apply("example.txt");

Or we can use Method reference,

Function<String, BufferedReader> pipeline = FileOperations :: openReader;
pipeline  
        .andThen(FileOperations :: readLines)  
        .andThen(FileOperations :: print)  
        .andThen(FileOperations :: closeReader)
        .apply("example.txt");

The way a simple Function is applied to the chained functions, similarly a BiFunction can be applied in which the method takes 2 parameters. 

Function<String, BufferedReader> pipeline = FunctionPipeline :: openReader;  
BiFunction<BufferedReader, String, BufferedReader> printBiFunction = (a, b) -> print(a, b);  
pipeline.andThen(br -> readLines(br))  
        .andThen(x -> printBiFunction.apply(x, getLines()))  
        .andThen(x -> closeReader(x))  
        .apply("example.txt");