자바(13) - 내부 클래스, 람다식, 스트림

내부 클래스

내부 클래스(inner class)는 말 그대로 ‘클래스 내부에 선언한 클래스’이다. 내부에 선언하는 이유는 대개 이 클래스와 외부 클래스가 밀접한 관련이 있어서이다. 또한 그 밖의 다른 클래스와 협력할 일이 없는 경우에 내부 클래스로 선언해서 사용한다.

class Out {
    class In {
    }
}

내부 클래스의 선언은 위와 같이 클래스 안에 클래스를 선언하는 것이다. 내부 클래스는 선언하는 위치나 예약어에 따라 네 가지 유형이 있다.

  • 인스턴스 내부 클래스
  • 정적 내부 클래스
  • 지역 내부 클래스
  • 익명 클래스
class ABC {                   // 외부 클래스
    class In {                // 인스턴스 내부 클래스
        static class SIn      // 정적 내부 클래스
    }
    
    public void abc() {
        class Local {}        // 지역 내부 클래스
    }
}

인스턴스 내부 클래스

인스턴스 내부 클래스(instance inner class)는 인스턴스 변수를 선언할 때와 같은 위치에 선언하며, 외부 클래스 내부에서만 생성하여 사용하는 객체를 선언할 때 쓴다. 예를 들어 어떤 클래스 내에 여러 변수가 있고 이들 변수 중 일부를 모아 클래스로 표현하는 것이다. 이 클래스를 다른 외부 클래스에서 사용할 일이 없는 경우 내부 인스턴스 클래스로 정의하낟. 인스턴스 내부 클래스는 외부 클래스 생성 후 생성된다. 따라서 외부 클래스를 먼저 생성하지 않고 인스턴스 내부 클래스를 사용할 수 없다.

class OutClass {
    private int num = 10;
    private static int sNum = 20;
    
    private InClass inClass;
    
    public OutClass() {
        inClass = new InClass();
    }
    
    class InClass {
        int inNum = 100;
        // static int sInNum = 200;
        
        void inTest() {
            System.out.prinln(num);
            System.out.prinln(sNum);
        }
        
        // static void sTest() // 정적 메서드 정의 불가
        
        public void usingClass() {
            inClass.inTest();
        }
    }
}

public class InnerTest {
    public static void main(String[] args) {
        OutClass outClass = new OutClass();
        outClass.usingClass(); // 외부 클래스에서 내부 클래스 기능 호출
    }
}

외부 클래스에서 선언한 변수들은 private지만 내부 클래스도 다 접근 가능하다. 내부 클래스에서 정적 변수와 메서드는 선언할 수 없다. 즉 인스턴스 내부 클래스는 외부 클래스가 먼저 생성되어야 사용할 수 있고 인스턴스 내부 클래스의 메서드는 외부 클래스의 메서드가 호출될 때 사용할 수 있다. 내부 클래스를 생성하는 이유는 그 클래스를 감싸고 있는 외부 클래스에서만 사용하기 위해서이다. 그렇지만 다른 클래스에서 private이 아닌 내부 클래스를 생성하는 것이 가능은 하다.

OutClass outClass = new OutClass();
OutClass.InClass inClass = outClass.new InClass();

정적 내부 클래스

인스턴스 내부 클래스는 외부 클래스가 먼저 생성되어야 생성할 수 있기에 정적 변수와 메서드는 사용할 수 없다. 그런데 내부 클래스가 외부 클래스 생성과 무관하게 사용할 수 있어야 하고 정적 변수도 사용할 수 있어야 한다면 정적 내부 클래스(static inner class)를 사용하면 된다. 정적 내부 클래스는 인스턴스 내부 클래스처럼 외부 클래스의 멤버 변수와 같은 위치에 정의하며 static 예약어를 함께 사용한다.

class OutClass {
    private int num = 10;
    private static int sNum = 20;
    
    static class InStaticClass {
        int inNum = 100;
        static int sInNum = 200;
    }
    
    void inTest() {
        // num += 10; // 외부 클래스의 인스턴스 변수는 사용할 수 없음.
        System.out.println(inNum);
        System.out.println(sInNum);
        System.out.println(sNum);
    }
    
    static void sTest() {
        // num += 10;
        // inNum += 10; // 외부 클래스와 내부 클래스의 인스턴스 변수는 사용할 수 없음
        System.out.println(sNum);
        System.out.println(sInNum);
    }
}

public class InnerTest {
    public static void main(String[] argS) {
        OutClass.InStaticClass sInClass = new OutClass.InStaticClass();
        sInClass.inTest();
        OutClass.InStaticClass.sTest();
    }
}

지역 내부 클래스

지역 내부 클래스는 지역 변수처럼 메서드 내부에 클래스를 정의하여 사용하는 것을 말한다. 그렇기에 이 클래스는 메서드 안에서만 사용할 수 있다.

class Outer {
    int outNum = 100;
    static int sNum = 200;
    
    Runnable getRunnable(int i) {
        int num = 100;
        
        class MyRunnable implements Runnable {
            int localNum = 10;
            
            @Override
            public void run() {
                // num = 200; // 지역 변수는 상수로 바뀌므로 값을 변경할 수 없어 오류 발생
                // i = 100; // 매개변수도 지역 변수처럼 상수로 바뀌어 값을 변경할 수 없어 오류 발생
                System.out.println(i);
                System.out.println(num);
                System.out.println(localNum);
                System.out.println(outNum);
                System.out.println(Outter.sNu);
            }
        }
        return new MyRunnable();
    }
}

public class LocalInnerTest {
    public static void main(String[] args) {
        Outter out = new Outter();
        Runnable runner = out.getRunnable(10);
        runner.run();
    }
}

지역 내부 클래스에서 지역 변수의 유효성을 봐보자. 지역 변수는 메서드가 호출될 때 스택 메모리에 생성되고 메서드의 수행이 끝나면 메모리에서 사라진다. 그런데 지역 내부 클래스에 포함된 getRunnable() 메서드의 매개변수 i와 메서드 내부에 선언한 변수 num은 지역 변수이다. 그런데도 run()메서드가 정상적으로 호출된다. 이는 getRunnable() 메서드 호출이 끝나고 스택 메모리에서 지워진 변수를 이후에 또 참조할 수 있다는 것이다. 즉 지역 내부 클래스에서 사용하는 지역 변수는 상수로 처리된다. 이는 컴파일에서 final이 추가되어서 처리된다.

익명 내부 클래스

익명 내부 클래스는 이름을 사용하지 않는 클래스이다.

class Outter2 {
    Runnable getRunnable(int i) {
        int num = 100;
        
        return new Runnable() {
            @Override
            public void run() {
                System.out.println(i)
                System.out.println(num)
            }
        }; // 클래스 끝에 ; 사용
    }
    
    Runnable runner = new Runnable() {
        @Override
        public void run() {
            System.out.println("Runnable이 구현된 익명 클래스 변수")
        }
    }; // 마찬가지로 ; 사용
}

public class AnonymousInnerTest {
    public static void main(String[] args) {
        Outter2 out = new Outter2();
        Runnable runnable = out.getRunnable(10);
        runnerable.run();
        out.runner.run();
    }
}
종류 구현위치 사용할 수 있는 외부 클래스 변수 생성 방법
인스턴스 내부 클래스 외부 클래스 멤버 변수와 동일 외부 인스턴스 변수 외부 전역 변수 외부 클래스를 먼저 만든 후 내부 클래스 생성
정적 내부 클래스 외부 클래스 멤버 변수와 동일 외부 전역 변수 외부 클래스와 무관하게 생성
지역 내부 클래스 메서드 내부에 구현 외부 인스턴스 변수 외부 전역 변수 메서드를 호출할 때 생성
익명 내부 클래스 메서드 내부에 구현 변수에 대입하여 직접 구현 외부 인스턴스 변수 외부 전역 변수 메서드를 호출할 때 생성되거나, 인터페이스 타입 변수에 대입할 때 new 예약어를 사용하여 생성

람다식

자바는 객체를 기반으로 프로그램을 만든다. 만약 어떤 기능이 필요하다면 클래스를 먼저 만들고, 클래스 안에 기능을 구현한 메서드를 만든 후 그 메서드를 호출해야 한다. 다시 말해 클래스가 없다면 메서드를 사용할 수 없다. 그런데 프로그래밍 언어 중에 함수의 구현과 호출만으로 프로그램을 만들 수 있는 프로그램 방식이 있다. 이를 함수형 프로그래밍(Functional Programming; FP)라고 한다. 자바는 8버전부터 이를 지원하고 있다. 자바에서 제공하는 함수형 프로그래밍 방식을 람다식(Lambda expression)이라고 한다. 람다식은 함수 이름이 없는 익명 함수를 만드는 것이다. 람다식의 문법은 다음과 같다.

(매개변수) -> {실행문;} ```java // 기존 int add(int x, int y) { return x + y; }

// 람다식 (int x, int y) -> {return x + y};


이 외에도 여러 가지로 표현할 수 있다.

```java
// 매개변수 자료형 생략. 매개변수가 하나인 경우네는 괄호도 생략가능하다
str -> {System.out.println(str);}

// 이런식으로는 안된다.
x, y -> {System.out.println(x+y);

// 중괄호 안의 구현 부분이 한 문장인 경우 중괄호 생략
str -> System.out.println(str);

// 중괄호 안의 구현 부분이 한 문장이더라도 return문은 중괄호를 생략할 수 없다.
str -> return str.length();

// return 생략하기, 중괄호 안의 구현 부분이 return문 하나라면 중괄호와 return을 모두 생략하고 식만 쓴다.
(x, y) -> x + y
str -> str.length()

람다식 사용

두 수 중 큰 수를 찾는 함수를 람다식으로 구현해보겠다. 람다식을 구현하기 위해서는 먼저 인터페이스를 만들고, 인터페이스에 람다식으로 구현할 메서드를 선언한다. 이를 함수형 인터페이스라고 한다.

public interface MyNumber {
    int getMax(int num1, int num2); // 추상 메서드 선언
}

public class TestMyNumber {
    public static void main(String[] args) {
        MyNumber max = (x, y) -> (x >= y) ? x : y; // 람다식으로 표현
        System.out.println(max.getMax(10, 20));
    }
}

함수형 프로그래밍은 순수 함수(pure function)을 구현하고 호출함으로써 외부 자료에 부수적인 영향(side effect)를 주지 않도록 구현하는 방식이다. 순수 함수란 매개변수만을 사용하여 만드는 함수이다. 즉 함수 내부에서 함수 외부에 있는 변수를 사용하지 않아 함수가 수행되더라도 외부에 영향을 주지 않는다. 객체 지향 언어가 객체를 기반으로 구현하는 방식이라면 함수형 프로그램은 함수를 기반으로 하고, 자료를 입력받아 구현하는 방식이다. 함수가 입력받은 자료 이외에 외부 자료에 영향을 미치지 않기 때문에 여러 자료 동시에 처리하는 병렬 처리에 적합하며, 안정되고 확장성 있는 프로그램을 개발할 수 있는 장점이 있다. 또 순수 함수로 구현된 함수형 프로그램은 함수 기능이 자료에 독립적일 수 있도록 보장해준다. 즉 동일한 입력에 대해서는 동일한 출력을 보장하고, 다양한 자료에 같은 기능을 수행할 수 있다.

함수형 인터페이스

람다식은 메서드 이름이 없고 메서드를 실행하는 데 필요한 매개변수와 매개변수를 활용한 실행 코드를 구현하는 것이다. 그렇다면 메서드는 어디에 선언하고 구현해야 할까. 함수형 언어에서는 함수만 따로 호출할 수 있지만, 자바에서는 참조 변수 없이 메서드를 호출할 수 없다. 그렇기에 람다식을 구현하기 위해 함수형 인터페이스를 만들고, 인터페이스에 람다식으로 구현할 메서드를 선언하는 것이다. 람다식은 하나의 메서드를 구현하여 인터페이스형 변수에 대입하므로 인터페이스가 두 개 이상의 메서드를 가지면 안된다.

public interface MyNumber {
    int getMax(int num1, int num2);
    int add(int num1, int num2);
}

이런식으로 인터페이스에 여러 메서드가 있다면 어떤 메서드를 구현할 것인지 모호해진다. 그렇기에 람다식은 오직 하나의 메서드만 선언한 인터페이스를 구현할 수 있다. 위처럼 실수로 다른 메서드를 추가함을 방지하기 위해 @FunctionalInterface라는 어노테이션을 제공한다. 클래스 위에 위 어노테이션을 달면 메서드가 두 개 이상 발생시 오류가 발생한다. 필수는 아니지만 명시적으로 표시해주면 좋다.

@FunctionalInterface
public interface MyNumber {
    int getMax(int num1, int num2);
    int add(int num1, int num2); // 오류 발생
}

객체 지향 프로그래밍 방식과 람다식 비교

문자열 두 개를 연결해서 출력하는 예제를 기존 객체 지향 방식과 람다식으로 구현해 보겠다. 람다식을 사용하면 기존 방식보다 간결한 코드를 구현할 수 있다. 메서드의 구현부를 클래스에 만들고, 이를 다시 인스턴스로 생성하고 호출하는 코드가 줄어들기 때문이다.

public interface StringConcat {
    public void makeString(String s1, String s2);
}

이 인터페이스는 문자열 두 개를 매개변수로 입력받아 두 문자열을 연결하여 출력하는 메서드를 가지고 있다. Hello, World 이런식으로 합쳐질 것이다.

// 기존 객체지향
public class StringConCatImpl implements StringConcat {
    @Override
    public void makeString(String s1, String s2) {
        System.out.println(s1 + ", " + s2);
    }
}

// 람다식
public class TestStringConcat {
    public static void main(String[] args) {
        String s1 = "Hello";
        String s2 = "World";
        StringConCatImpl concat1 = new StringConCatImpl();
        concat1.makeString(s1, s2);
        StringConcat concat2 = (s, v) -> System.out.println(s + ", " + v);
        concat2.makeString(s1, s2);
    }
}

익명 객체를 생성하는 람다식

자바는 객체 지향 언어이다. 그런데 람다식은 객체 없이 인터페이스의 구현만으로 메서드를 호출할 수 있었다. 어떻게 생성없이 메서드 호출이 가능했을까. 우리는 앞에서 익명 내부 클래스에 대해 배웠다. 익명 내부 클래스는 클래스 이름 없이 인터페이스 자료형 변수에 바로 메서드 구현부를 생성하여 대입할 수 있다. 즉 람다식으로 메서드를 구현해서 호출하면 컴퓨터 내부에서는 다음처럼 익명 클래스가 생성되고 이를 통해 익명 객체가 생성되는 것이다.

StringConcat concat3 = new StringConcat() {
    @Override
    public void makeString(String s1, String s2) {
        System.out.println(s1 + ", " + s2);
    }
};

람다식에서 사용하는 지역 변수 두 문자열을 연결하는 람다식 코드에서 외부 메서드의 지역 변수인 i를 수정하면 어떻게 될까

public class TestStringConcat {
    public static void main(String[] args) {
        int i = 100;
        
        StringConcat concat2 = (s, v) -> {
            // i = 200; // 람다식 내부에서 변경하면 오류 발생         
        };
    }
}

위 주석 부분이 에러가 나타날 것인데, 이는 지역 내부 클래스에서 다루었던 내용과 같다. 지역 변수는 메서드 호출이 끝나면 메모리에서 사라지기 때문에 익명 내부 클래스에서 사용하는 경우에는 지역 변수가 상수로 변한다. 람다식 역시 익명 내부 클래스가 생성되므로 외부 메서드의 지역 변수를 사용하면 변수는 final 변수, 즉 상수가 되기에 이 변수를 변경하면 오류가 발생한다.

함수를 변수처럼 사용하는 람다식

람다식을 이용하면 구현된 함수를 변수처럼 사용할 수 있다. 우리가 프로그램에서 변수를 사용하는 경우는 크게 세 가지이다.

  1. 특정 자료형으로 변수 선언 후 값 대입하여 사용하기. int a = 10;
  2. 매개변수로 전달하기. int add(int x, int y);
  3. 메서드의 반환 값으로 반환하기. return num;

람다식으로 구현된 메서드도 변수에 대입하여 사용할 수 있고, 매개변수로 전달하고 반환할 수 있다.

  • 인터페이스형 변수에 람다식 대입하기

인터페이스형 변수에 람다식을 대입하는 방법은 위에서 해보았다. 다음과 같이 함수형 인터페이스 PrintString이 있고, 여기에 메서드를 하나 선언한다. 이 메서드를 구현하면 다음과 같다.

interface PrintString {
    void showString(String str);
}

PrintString lambdaStr = s -> System.out.println(s)
lambdaStr.showString("hello lambda");
  • 매개변수로 전달하는 람다식

람다식을 변수에 대입하면 이를 매개변수로 전달할 수 있다. 이때 전달되는 매개변수의 자료형은 인터페이스형이다.

interface PrintString {
    void showString(String str);
}

public class TestLambda {
    public static void main(String[] args) {
        PrintString lambdaStr = s -> System.out.println(s);
        lambdaStr.showString("hello lambda");
        showMyString(lambdaStr);
    }
    
    public static void showMyString(PrintString p) {
        p.showString("hello lambda2");
    }
}
  • 반환 값으로 쓰이는 람다식

다음과 같이 메서드의 반환형을 람다식의 인터페이스형으로 선언하면 구현한 람다식을 반환할 수 있다.

public static PrintString returnString() {
    PrintString str = s -> System.out.println(s + "world");
    return str;
}

// 또는

public static PrintString returnString() {
    return s -> System.out.println(s + "world");;
}

// 테스트
interface PrintString {
    void showString(String str);
}

public class TestLambda {
    public static void main(String[] args) {
        PrintString reStr = returnString();
        reStr.showString("hello ");
    }
    
    public static void showMyString(PrintString p) {
        p.showString("hello lambda2");
    }
    
    public static PrintString returnString() {
        return s -> System.out.println(s + "world");
    }
}

스트림

자료가 모여 있는 배열이나 컬렉션 또는 특정 범위 안에 있는 일련의 숫자를 처리하는 기능이 미리 구현되어 있다면 프로그램의 코드가 훨씬 간결해지고 일관성 있게 다룰 수 있을 것이다. 예를 들어 배열 요소를 특정 기준에 따라 정렬하거나, 요소 중 특정 값을 제외하고 출력하는 기능같은 것이다. 이렇게 여러 자료의 처리에 대한 기능을 구현해 놓은 클래스가 스트림이다. 스트림을 활용하면 배열, 컬렉션 등의 자료를 일관성 있게 처리할 수 있다. 자료에 따라 기능을 각각 새로 구현하는 것이 아니라 처리해야 하는 자료가 무엇인지와 상관없이 같은 방식으로 메서드를 호출할 수 있기 때문이다. 다른 말로는 자료를 추상화했다고 표현한다.

배열을 예로 들어보자.

// 기존 방식
int[] arr = {1, 2, 3, 4, 5};
for(int i=0; i<arr.length; i++) {
    System.out.println(arr[i]);
}

// 스트림 사용
int[] arr = {1, 2, 3, 4, 5};
Arrays.stream(arr).forEach(n -> System.out.println(n));

Array.strean()이 스트림 생성 부분이고, .forEach() 메서드(최종 연산)를 사용하여 배열의 요소를 하나씩 꺼내 출력했다.

스트림 연산

스트림 연산의 종류에는 크게 중간 연산과 최종 연산 두 가지가 있다. 중간 연산은 자료를 거르거나 변경하여 또 다른 자료를 내부적으로 생성한다. 최종 연산은 생성된 내부 자료를 소모해 가면서 연산을 수행한다. 따라서 최종 연산은 마지막에 한 번만 호출된다. 그리고 최종 연산이 호출되어야 중간 연산의 결과가 만들어 진다.

중간 연산 filter(), map()

filter()는 조건을 넣고 그 조건에 맞는 참인 경우만 추출하는 경우에 사용한다. 문자열 배열이 있을 때 문자열의 길이가 5 이상인 경우만 추출해보겠다.

sList.stream().filter(s -> s.length() >= 5).forEach(s -> System.out.println(s));

map()은 클래스가 가진 자료 중 이름만 출력하는 경우에 사용한다. 예를 들어 고객 클래스가 있다면 고객 이름만 갖고와서 출력할 수 있다. map()은 요소들을 순회하여 다른 형식으로 변환하기도 한다.

customerList.stream().map(c -> c.getName()).forEach(s -> System.out.println(s));

최종 연산 forEach(), count(), sum(), reduce()

최종 연산은 스트림의 자료를 소모하면서 연산을 수행하기 때문에 최종 연산이 수행되고 나면 해당 스트림은 더 이상 사용할 수 없다. 최종 연산은 결과를 만드는데 주로 사용한다. forEach()는 요소를 하나씩 꺼낼 때 사용되며, 통계용으로 사용되는 sum(), count()는 배열 요소의 합계를 구한다든가 개수를 출력하는 등의 연산을 수행한다.

public class IntArrayTest {
    public static void main(String[] args) {
        int[] arr = {1, 2, 3, 4, 5};
        
        int sumVal = Arrays.stream(arr).sum();
        int count = (int)Arrays.stream(arr).count(); // long 으로 반환해주어 형 변환
    }
}

다음은 Collection 인터페이스를 구현한 클래스 중 가장 많이 사용하는 ArrayList에 스트림을 생성하고 활용해 보겠다.

List<String> sList = new ArrayList<String>();
sList.add("Tomas");
sList.add("Edward");
sList.add("Jack");

Stream<String> stream = sList.stream();
stream.forEach(s -> System.out.println(s));

Stream<String> stream2 = sList.stream();
stream2.sorted().forEach(s -> System.out.println(s));

stream2에서 중간 연산자로 sorted() 메서드를 호출했는데, 이는 정렬 방식에 대한 정의가 필요하다. 따라서 사용하는 자료 클래스가 Comparable 인터페이스를 구현해야 한다. 만약 구현되어있지 않았다면, sorted()메서드의 매개 변수로 Comparator 인터페이스를 구현한 클래스를 지정할 수 있다.

스트림의 특징

  • 자료의 대상과 관계없이 동일한 연산을 수행한다.

배열이나 컬렉션에 저장된 자료를 가지고 수행할 수 있는 연산은 여러 가지가 있다. 배열에 저장된 요소 값을 출력한다든지, 조건에 따라 자료를 추출한거나, 자료가 숫자일 때 합계, 평균 등을 구할 수도 있다. 스트림은 컬렉션의 여러 자료 구조에 대해 이러한 작업을 일관성 있게 처리할 수 있는 메서드를 제공한다.

  • 한 번 생성하고 사용한 스트림은 재사용할 수 없다.

어떤 자료에 대한 스트림을 생성하고 이 스트림에 메서드를 호출하여 연산을 수행했다면 해당 스트림을 다시 다른 연산에 사용할 수 없다. 예를 들어 스트림을 생성하여 배열에 있는 요소를 출력하기 위해 각 요소들을 하나씩 순회하면서 출력에 사용하는데, 이때 요소들이 ‘소모된다’고 한다. 소모된 요소는 재사용할 수 없다. 만약 다른 기능을 호출하려면 스트림을 다시 생성해야 한다.

  • 스트림의 연산은 기존 자료를 변경하지 않는다.

스트림을 생성하여 정렬한다거나 합을 구하는 등의 여러 연산을 수행한다고 해서 기존 배열이나 컬렉션이 변경되지는 않는다. 스트림 연산을 위해 사용하는 메모리 공간이 별도로 존재하므로, 스트림의 여러 메서드를 호출하더라도 기존 자료에는 영향을 미치지 않는다.

  • 스트림의 연산은 중간 연산과 최종 연산이 있다.

스트림에서 사용하는 메서드는 크게 중간 연산과 최종 연산 두 가지로 나뉜다. 스트림에 중간 연산은 여러 개가 적용될 수 있고, 최종 연산은 맨 마지막에 한 번 적용된다. 만약 중간 연산이 여러 개 호출되었더라도 최종 연산이 호출되어야 스트림의 중간 연산이 모두 적용된다. 예를 들어 자료를 정렬하거나 검색하는 중간 연산이 호출되어도 최종연산 호출되지 않으면 정렬이나 검색한 결과를 가져올 수 없다. 이를 지연 연산(lazy evaluation)이라고 한다.

프로그래머가 기능을 지정하는 reduce() 연산

위의 연산들은 기능이 이미 정해져 있다. reduce() 연산은 내부적으로 스트림의 요소를 하나씩 소모하면서 프로그래머가 직접 기능을 지정한 기능을 수행한다. reduce()는 다음의 형태이다. T reduce(T identify, BinaryOperator<T> accumlator) 첫 번째 매겨변수 T identify는 초깃값을 의미하고 두 번째 매개변수 BinaryOperator<t> accumlator는 수행해야 할 기능이다. BinaryOperator 인터페이스는 두 매개변수로 람다식을 구현하며 이 람다식이 각 요소가 수행해야 할 기능이 된다. 이때 BinaryOperator인터페이스를 구현한 람다식을 직접 써도 되고, 람다식이 길면 인터페이스를 구현한 클래스를 생성하여 대입해도 된다. 또한 BinaryOperator는 함수형 인터페이스로 apply()메서드를 반드시 구현해야 한다. apply()메서드는 두 개의 매개변수와 한 개의 반환 값을 가지는데, 세 개 모두가 같은 자료형이다. reduce() 메서드가 호출될 때 BinaryOperator의 apply() 메서드가 호출된다. 아래는 모든 요소를 합치는 reduce() 메서드이다.

Arrays.stream(arr).reduce(0, (a, b) -> a + b);    

내부적으로 반복문이 호출되면서 람다식에 해당하는 부분이 리스트 요소만큼 호출된다. 따라서 reduce() 메서드에 어떤 람다식이 전달되느냐에 따라 다양한 연산을 수행할 수 있다. reduce()는 처음부터 마지막까지 모든 요소를 소모하면서 람다식을 반복해서 수행하므로 최종 연산이다.

다음은 배열에 여러 문자열이 있을 때 그 중 길이가 가장 긴 문자열을 찾는 예제를 따라 하면서 reduce() 메서드 사용법을 봐보자. 두 번째 매개변수에 람다식을 직접 쓰는 경우와 BinaryOperator 인터페이스를 구현한 클래스를 사요하는 두 가지 경우이다.

import java.util.function.BinaryOperator;
    
class CompareString implements BinaryOperator<String> {
    @Override
    public String apply(String s1, String s2) {
        if(s1.getBytes().length >= s2.getBytes().length) return s1;
        else return s2;
    }
}

public class ReduceTest {
    public static void main(String[] args) {
        String[] greetings = {"asdfdsaf", "bb", "aaaaa"};
        System.out.println(Arrays.stream(greetings).reduce("", (s1, s2) -> {
                            if(s1.getBytes().length >= s2.getBytes().length)
                                return s1;
                            else return s2;}));
        
        String str = Arrays.stream(greetings).reduce(new CompareString()).get();
        System.out.println(str);
    }
}