본문 바로가기

언바운드 메서드 참조 활용

String::length처럼 특정 객체를 붙잡지 않는 메서드 참조에서 첫 번째 인수가 수신 객체가 되는 규칙을 익히고, 정렬과 축약, null 처리에 안전하게 적용합니다.

앞 글에서 본 네 가지 메서드 참조 가운데 String::length 같은 언바운드 참조는 특정 문자열을 붙잡지 않습니다. ToIntFunction<String>에 대입하면 applyAsInt가 받은 문자열이 수신 객체가 되어 receiver.length()가 호출됩니다. 어떤 문자열이 수신 객체가 될지는 참조를 만들 때가 아니라 함수를 호출하는 쪽이 정하기 때문에, 이런 참조를 임의 객체의 메서드 참조라고도 부릅니다.

메서드에 매개변수가 있으면 규칙이 한 단계 늘어납니다. 함수의 첫 번째 인수가 수신 객체가 되고, 나머지 인수가 순서대로 메서드의 인수가 됩니다. 예를 들어 String::substring을 BiFunction<String, Integer, String>으로 받으면 apply(text, 3)은 text.substring(3)이 됩니다. 이 글에서는 이 규칙을 매개변수 개수별로 확인하고, 정렬과 축약, null 처리에서 언바운드 참조를 쓸 때 주의할 점을 살펴봅니다.

수신 객체 뒤에 메서드 인수가 이어진다

다음 예제는 매개변수가 0개, 1개, 2개인 String의 인스턴스 메서드를 각각 알맞은 함수형 인터페이스에 대입합니다. JDK에는 인수 세 개를 받는 표준 함수형 인터페이스가 없으므로, substring(begin, end)처럼 수신 객체 뒤에 인수가 둘 붙는 경우를 위해 TriFunction을 직접 선언했습니다.

src/UnboundInstanceReferences.java
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.ToIntBiFunction;
import java.util.function.ToIntFunction;

public final class UnboundInstanceReferences {
    @FunctionalInterface
    interface TriFunction<A, B, C, R> {
        R apply(A first, B second, C third);
    }

    public static void main(String[] args) {
        ToIntFunction<String> length = String::length;
        Function<String, String> trim = String::trim;
        BiFunction<String, Integer, String> suffix = String::substring;
        TriFunction<String, Integer, Integer, String> slice = String::substring;
        ToIntBiFunction<String, String> compareIgnoringCase = String::compareToIgnoreCase;

        System.out.println(length.applyAsInt("lambda"));
        System.out.println(trim.apply(" java "));
        System.out.println(suffix.apply("stream", 3));
        System.out.println(slice.apply("functional", 1, 5));
        System.out.println(compareIgnoringCase.applyAsInt("JAVA", "java"));
    }
}

suffix와 slice는 같은 String::substring을 가리키지만, 대상 타입의 매개변수 개수가 달라서 서로 다른 오버로드가 선택됩니다. 같은 참조라도 어느 메서드가 될지는 대상 타입이 정한다는 점을 셋째, 넷째 줄에서 확인해 보세요.

6
java
eam
unct
0

각 호출이 실제로 어떤 메서드 호출로 바뀌는지 표로 정리하면 다음과 같습니다. 어느 줄이든 함수의 첫 번째 인수가 점 앞의 수신 객체로 가고, 나머지 인수가 괄호 안으로 들어갑니다.

함수 호출실제로 실행되는 코드결과
length.applyAsInt("lambda")"lambda".length()6
trim.apply(" java ")" java ".trim()java
suffix.apply("stream", 3)"stream".substring(3)eam
slice.apply("functional", 1, 5)"functional".substring(1, 5)unct
compareIgnoringCase.applyAsInt("JAVA", "java")"JAVA".compareToIgnoreCase("java")0

String::substring이라는 같은 참조가 두 번 서로 다른 메서드를 가리켰다는 점을 눈여겨보세요. String에는 substring(int)와 substring(int, int) 두 오버로드가 있고, 대상 타입의 인수 가운데 수신 객체 몫인 첫 번째를 뺀 나머지 개수가 어느 쪽을 쓸지 정합니다. BiFunction은 수신 객체 뒤에 인수가 하나 남으므로 substring(int)를, TriFunction은 둘 남으므로 substring(int, int)를 고릅니다. 마지막 줄의 compareToIgnoreCase는 대소문자를 무시하고 두 문자열을 비교하며, 같다고 판단하면 0을 돌려줍니다.

수신 객체 뒤에 이어지는 메서드 인수

수신 객체 뒤에 이어지는 메서드 인수

수신자 뒤의 명시 인수

수신 객체 뒤에 이어지는 메서드 인수

첫 함수 인수는 수신 문자열입니다. 그 뒤의 인수들이 length 또는 선택된 substring 오버로드에 전달됩니다.

함수 인수 셋을 수신자와 두 명시 인수로 배분한다TriFunction에 연결한 String::substring의 첫 함수 인수 functional은 수신 객체다. 둘째1은 beginIndex, 셋째5는 endIndex로 전달해 substring(1,5)가 unct를 만든다. 화살표는 인수와 호출 위치의 대응이며 세 함수 인수 모두가 메서드의 명시 인수라는 뜻이 아니다. 모바일은 같은5노드4간선으로 세 입력의 포트를 분리한다.첫 함수 인수"functional"둘째 함수 인수시작 1셋째 함수 인수끝 5수신자.substring(시작, 끝)1 포함 · 5 제외결과 unct수신자시작끝
함수 인수 셋을 수신자와 두 명시 인수로 배분한다TriFunction에 연결한 String::substring의 첫 함수 인수 functional은 수신 객체다. 둘째1은 beginIndex, 셋째5는 endIndex로 전달해 substring(1,5)가 unct를 만든다. 화살표는 인수와 호출 위치의 대응이며 세 함수 인수 모두가 메서드의 명시 인수라는 뜻이 아니다. 모바일은 같은5노드4간선으로 세 입력의 포트를 분리한다.첫 함수 인수"functional"둘째 함수 인수시작 1셋째 함수 인수끝 5수신자.substring(시작, 끝)1 포함 · 5 제외결과 unct수신자시작끝

0개

수신자 · 추가 인수
"lambda"
선택된 호출과 결과
length() → 6

1개

수신자 · 추가 인수
"stream" · 3
선택된 호출과 결과
substring(3) → eam

2개

수신자 · 추가 인수
"functional" · 1 · 5
선택된 호출과 결과
substring(1, 5) → unct

0·1·2개는 수신자를 제외한 메서드의 명시 인수 수입니다. substring의 끝 인덱스 5는 포함하지 않습니다.

도표는 slice.apply("functional", 1, 5)의 세 인수가 수신 객체 하나와 메서드 인수 둘로 나뉘는 모습을 보여 줍니다. "functional"의 인덱스는 0번 f, 1번 u, 2번 n, 3번 c, 4번 t, 5번 i 순서이고, substring(1, 5)는 시작 인덱스 1을 포함하고 끝 인덱스 5를 포함하지 않습니다. 그래서 1번부터 4번까지의 unct가 남습니다. 함수가 받은 인수는 셋이지만 그중 하나는 메서드의 인수가 아니라 수신 객체라는 점이 핵심입니다.

수신 객체가 될 첫 번째 인수의 타입은 메서드를 가진 타입과 맞아야 합니다. CharSequence::length는 String이나 StringBuilder처럼 CharSequence를 구현한 모든 객체에 쓸 수 있으므로 ToIntFunction<CharSequence>에 대입할 수 있습니다. 반대로 isBlank처럼 String에만 있는 메서드가 필요하다면 대상 타입도 String을 받도록 정해야 합니다.

메서드 이름을 문자열로 넘기면 잃는 것

언바운드 참조는 '어떤 객체가 오든 이 메서드를 호출한다'는 동작을 값으로 들고 다니게 해 줍니다. 비슷한 일을 메서드 이름 문자열과 리플렉션으로도 할 수 있습니다. 다음 코드는 메서드 이름을 문자열로 받아 실행 시점에 찾아서 호출합니다.

bad/StringNamedInstanceOperation.java
import java.lang.reflect.Method;

public final class StringNamedInstanceOperation {
    static Object invoke(Object receiver, String methodName, Class<?>[] types, Object[] arguments)
            throws Exception {
        Method method = receiver.getClass().getMethod(methodName, types);
        return method.invoke(receiver, arguments);
    }

    public static void main(String[] args) throws Exception {
        Object result = invoke("lambda", "substring", new Class<?>[] { int.class }, new Object[] { 2 });
        System.out.println((String) result);
    }
}

getMethod는 이름과 매개변수 타입이 모두 맞는 public 메서드를 실행 중에 찾습니다. 여기서는 int 하나를 받는 substring을 찾아 "lambda"에 대해 호출합니다.

mbda

결과는 "lambda".substring(2)와 같지만, 이 방식은 컴파일러의 도움을 거의 받지 못합니다. 매개변수 타입을 Class 배열로, 인수를 Object 배열로 따로 맞춰 넘겨야 하고, 결과도 Object라서 형변환해야 합니다. 메서드 이름에 오타를 내 보면 차이가 더 분명해집니다. "substring"을 "substrng"으로 바꿔도 컴파일은 문제없이 되고, 실행해야 비로소 다음 예외가 나며 그 아래로 스택 트레이스가 이어집니다.

Exception in thread "main" java.lang.NoSuchMethodException: java.lang.String.substrng(int)

메서드 참조로 같은 오타를 내면 컴파일 단계에서 바로 걸립니다. 다음은 String::substrng이라고 쓴 한 줄을 컴파일한 결과입니다.

ReferenceTypo.java:5: error: invalid method reference
        BiFunction<String, Integer, String> suffix = String::substrng;
                                                     ^
  cannot find symbol
    symbol:   method substrng(String,Integer)
    location: class String
1 error

리플렉션은 플러그인을 불러오거나 객체를 직렬화할 때처럼, 실행하기 전에는 어떤 클래스와 메서드를 쓸지 알 수 없을 때 쓰는 도구입니다(28장). 컴파일할 때 이미 알고 있는 메서드를 이름 문자열로 바꾸면 유연해지는 것이 아니라, 컴파일러가 해 줄 수 있던 검사를 잃을 뿐입니다. 메서드 참조는 메서드가 있는지, 오버로드 가운데 무엇을 고를지, 접근할 수 있는지, 반환 타입이 맞는지를 모두 컴파일할 때 확인하고, IDE에서 메서드 이름을 바꾸면 참조도 함께 바뀝니다.

언바운드 참조와 바운드 참조 중 무엇을 쓸지는 수신 객체를 고정할지로 정합니다. 언바운드 참조는 수신 객체를 붙잡지 않으므로 함수 하나를 여러 객체에 적용할 수 있고, String::trim 하나로 목록의 모든 문자열을 다듬는 것이 그 예입니다. 반대로 특정 정책 객체나 형식 지정 객체를 항상 쓰고 싶다면 그 객체를 수신 객체로 고정하는 바운드 참조가 맞습니다. 이때 바운드 참조가 고정하는 것은 어떤 객체를 쓸지일 뿐, 그 객체의 상태까지 복사해 두지는 않습니다. 그래서 객체 내부 상태가 나중에 바뀌면 함수도 바뀐 상태로 동작합니다.

정렬 규칙을 메서드 참조로 만들기

언바운드 참조가 가장 자주 쓰이는 곳은 정렬입니다. 같은 타입의 값 하나를 받아 int를 돌려주는 인스턴스 메서드를 언바운드로 참조하면, 수신 객체까지 합쳐 '값 두 개를 받아 int를 돌려준다'는 Comparator의 모양과 딱 맞기 때문입니다. String::compareToIgnoreCase를 Comparator<String>이 필요한 자리에 넘기면, 비교할 두 문자열 가운데 첫 번째가 수신 객체가 되고 두 번째가 인수가 됩니다.

src/CompareIgnoringCaseSort.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public final class CompareIgnoringCaseSort {
    public static void main(String[] args) {
        List<String> words = new ArrayList<>(List.of("banana", "Cherry", "apple"));
        words.sort(Comparator.naturalOrder());
        System.out.println(words);
        words.sort(String::compareToIgnoreCase);
        System.out.println(words);
    }
}

같은 목록을 처음에는 Comparator.naturalOrder()로, 다음에는 String::compareToIgnoreCase로 정렬합니다. 두 줄에서 Cherry의 위치가 어떻게 달라지는지 보세요.

[Cherry, apple, banana]
[apple, banana, Cherry]

자연 순서는 문자 코드 값을 비교하므로 대문자 C(67)가 소문자 a(97)보다 앞에 옵니다. compareToIgnoreCase로 바꾸면 대소문자를 무시하므로 알파벳 순서대로 정렬됩니다.

여러 기준으로 정렬할 때는 Comparator.comparing이 비교할 키를 꺼내는 함수를 받아 비교자를 만들어 줍니다. Comparator.comparing(User::name)의 User::name은 User를 받아 이름을 돌려주는 Function<User, String>이고, thenComparingInt(User::score)의 User::score는 int를 돌려주는 ToIntFunction<User>입니다. 다음 예제는 이렇게 만든 비교자 두 개로 정렬해 보고, 최댓값을 구하는 reduce와 이름별 최고점을 모으는 Collectors.toMap에도 메서드 참조를 넘깁니다.

src/MethodReferenceApplications.java
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.BinaryOperator;
import java.util.stream.Collectors;

public final class MethodReferenceApplications {
    record User(String name, int score) {
    }

    public static void main(String[] args) {
        List<User> users = List.of(
                new User("Mina", 80),
                new User("Jin", 95),
                new User("Mina", 90));

        Comparator<User> allReversed = Comparator
                .comparing(User::name)
                .thenComparingInt(User::score)
                .reversed();
        Comparator<User> nameUpScoreDown = Comparator
                .comparing(User::name)
                .thenComparing(Comparator.comparingInt(User::score).reversed());

        BinaryOperator<Integer> maximum = Integer::max;
        int best = users.stream().map(User::score).reduce(0, maximum);
        Map<String, Integer> bestByName = users.stream()
                .collect(Collectors.toMap(User::name, User::score, Integer::max));

        System.out.println(users.stream().sorted(allReversed).toList());
        System.out.println(users.stream().sorted(nameUpScoreDown).toList());
        System.out.println("best=" + best);
        System.out.println(bestByName);
    }
}

처음 두 줄은 같은 users를 두 비교자로 정렬한 결과입니다. sorted는 정렬된 새 스트림을 만들 뿐 원래 목록을 바꾸지 않으므로, 두 번째 정렬도 처음 순서의 목록에서 시작합니다.

[User[name=Mina, score=90], User[name=Mina, score=80], User[name=Jin, score=95]]
[User[name=Jin, score=95], User[name=Mina, score=90], User[name=Mina, score=80]]
best=95
{Mina=90, Jin=95}

첫 줄의 reversed()는 바로 앞까지 만든 비교자 전체, 즉 '이름 오름차순, 이름이 같으면 점수 오름차순'을 통째로 뒤집습니다. 그래서 이름도 내림차순이 되어 Mina가 Jin보다 앞에 오고, 같은 Mina 안에서는 점수가 높은 90이 먼저 옵니다. 이름은 오름차순으로 두고 점수만 내림차순으로 정렬하려면, 두 번째 줄처럼 점수 비교자만 따로 뒤집어 thenComparing에 넘겨야 합니다. 메서드 참조로 짧게 쓸 수 있다고 정렬 방향까지 저절로 맞는 것은 아니므로, 어느 부분이 뒤집히는지는 이렇게 출력으로 확인해 보는 것이 좋습니다.

세 번째 줄의 Integer::max는 정적 메서드 Integer.max(int, int)를 가리키며, 두 값을 받아 같은 타입의 값을 돌려주므로 BinaryOperator<Integer>로 쓸 수 있습니다. reduce(0, maximum)은 0에서 시작해 점수를 하나씩 max로 합쳐 95를 구합니다. 마지막 줄에서 toMap의 세 번째 인수 Integer::max는 같은 이름이 두 번 나올 때 두 점수 중 큰 값을 남기는 병합 함수라서 Mina는 90이 됩니다. toMap이 돌려주는 맵은 순서를 약속하지 않으므로 Jin과 Mina가 출력되는 순서는 환경에 따라 다를 수 있습니다.

축약에 메서드 참조를 쓸 때는 reduce의 첫 인수인 항등값(identity)도 함께 봐야 합니다. reduce(0, Integer::max)의 0은 아직 아무 점수도 보지 않았을 때의 최댓값으로 쓰이는데, 점수가 모두 음수라면 실제 최댓값 대신 0이 나옵니다. 항등값 없는 reduce(Integer::max)는 Optional을 돌려주므로, 원소가 하나도 없는 경우와 최댓값이 0인 경우를 구분할 수 있습니다. String::concat도 BinaryOperator<String>으로 쓸 수 있어 reduce("", String::concat)처럼 문자열을 이어 붙일 수 있지만, 원소마다 새 문자열을 만들고 구분자도 넣을 수 없습니다. 문자열을 이어 붙일 때는 32장에서 다루는 Collectors.joining이 더 알맞습니다.

람다로 바꾸면 컴파일되지 않는 경우

메서드 참조를 같은 뜻의 람다로 바꿨는데 컴파일이 안 되는 경우가 있습니다. Comparator.comparing(User::name).reversed()를 Comparator.comparing(user -> user.name()).reversed()로 바꾸면 다음 오류가 납니다.

LambdaComparingInference.java:8: error: cannot find symbol
        Comparator<User> byNameDesc = Comparator.comparing(user -> user.name()).reversed();
                                                                       ^
  symbol:   method name()
  location: variable user of type Object
LambdaComparingInference.java:8: error: incompatible types: Comparator<Object> cannot be converted to Comparator<User>
        Comparator<User> byNameDesc = Comparator.comparing(user -> user.name()).reversed();
                                                                                        ^
2 errors

대입문 왼쪽의 Comparator<User>는 맨 끝의 reversed()가 돌려주는 값의 타입일 뿐, 그 앞의 comparing(...) 호출에는 전해지지 않습니다. 그래서 컴파일러는 람다의 매개변수 user가 어떤 타입인지 알 단서가 없어 Object로 보고, Object에는 name()이 없다고 알립니다. 반면 User::name은 그 자체로 'User의 name() 메서드'라는 정보를 담고 있어서, 컴파일러가 이 참조만 보고도 키를 꺼낼 대상이 User라는 것을 알아냅니다. 람다를 쓰고 싶다면 (User user) -> user.name()처럼 매개변수 타입을 직접 적으면 됩니다. JLS는 User::name처럼 오버로드 없이 메서드 하나로 정해지는 참조를 정확한(exact) 메서드 참조라고 부르며, 이런 참조는 타입 추론에 자기 시그니처를 제공할 수 있습니다.

null이 수신 객체로 들어오면

언바운드 참조에서 가장 흔한 실패는 수신 객체 자리에 null이 들어오는 경우입니다. 람다 name -> name.length()와 마찬가지로 String::length도 null에 메서드를 호출하려다 NullPointerException을 던집니다. 메서드 참조에는 null이면 건너뛰는 연산자가 따로 없으므로, null이 올 수 있는 원본이라면 참조를 적용하기 전에 처리해야 합니다.

src/NullReceiver.java
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;

public final class NullReceiver {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("kim", null, "lee");
        try {
            System.out.println(names.stream().map(String::length).toList());
        } catch (NullPointerException e) {
            System.out.println(e);
        }
        System.out.println(names.stream().sorted(Comparator.nullsFirst(Comparator.naturalOrder())).toList());
        System.out.println(names.stream().sorted(Comparator.nullsLast(Comparator.naturalOrder())).toList());
    }
}

첫 번째 스트림은 try 안에서 실행하므로, NullPointerException이 나면 예외 객체를 찍고 다음 줄로 넘어갑니다. 나머지 두 줄은 null을 맨 앞이나 맨 뒤로 보내는 비교자로 정렬한 결과입니다.

java.lang.NullPointerException
[null, kim, lee]
[kim, lee, null]

List.of는 null 원소를 허용하지 않으므로 여기서는 Arrays.asList로 목록을 만들었습니다. 첫 줄은 map(String::length)가 null 원소를 만나 실패한 결과입니다. 예외 메시지가 비어 있다는 점도 눈여겨보세요. JDK 21에서 같은 코드를 람다 name -> name.length()로 쓰면 Cannot invoke "String.length()" because "<parameter1>" is null 같은 설명이 붙지만, 메서드 참조에서 난 예외에는 이런 메시지가 붙지 않았습니다. 원인을 찾으려면 스택 트레이스를 따라가야 하므로, null이 섞일 수 있는 곳에서는 미리 걸러 두는 편이 낫습니다.

정렬에서는 Comparator.nullsFirst와 Comparator.nullsLast가 null의 자리를 정해 줍니다. 둘째와 셋째 줄처럼, 감싼 비교자는 null이 아닌 값끼리만 비교하고 null은 맨 앞이나 맨 뒤로 보냅니다.

참조한 메서드가 null을 돌려주는 경우도 조심해야 합니다. 예를 들어 null을 돌려줄 수 있는 map(User::nickname) 다음에 map(String::trim) 같은 언바운드 참조가 이어지면, 앞 단계가 만든 null이 다음 단계의 수신 객체가 되어 실패합니다. 이런 값은 filter(Objects::nonNull)로 걸러 내거나, 33장에서 다루는 Optional처럼 '값이 없을 수 있음'을 타입으로 드러내는 방법으로 다룹니다.

정리

  • 언바운드 참조는 함수의 첫 번째 인수를 수신 객체로 쓰고, 나머지 인수를 순서대로 메서드에 넘깁니다. 같은 참조라도 대상 타입의 인수 개수에 따라 다른 오버로드가 선택됩니다.
  • 이미 알고 있는 메서드는 이름 문자열과 리플렉션 대신 메서드 참조로 넘깁니다. 오타와 타입 불일치를 컴파일할 때 잡을 수 있습니다.
  • compareToIgnoreCase처럼 같은 타입의 값 하나를 받아 int를 돌려주는 인스턴스 메서드의 언바운드 참조는 Comparator로 쓸 수 있습니다. reversed()는 그때까지 만든 비교자 전체를 뒤집으므로 일부만 뒤집으려면 그 비교자만 따로 뒤집습니다.
  • reduce에 메서드 참조를 넘길 때는 항등값이 결과를 바꾸지 않는지 확인합니다. 항등값 없는 reduce는 Optional을 돌려줍니다.
  • 메서드 참조에는 null을 건너뛰는 기능이 없습니다. null은 참조를 적용하기 전에 거르거나 nullsFirst, nullsLast로 자리를 정합니다.

연습 문제

1. substring의 범위 밖 인덱스

예제의 suffix에 ("stream", 6), ("stream", 7), (null, 0)을 각각 넘기면 어떻게 되나요? slice에 ("functional", 5, 1)을 넘기면 시작과 끝을 알아서 바꿔 주나요? 빈 결과와 예외를 구분해서 답해 보세요.

풀이 확인

"stream"의 길이는 6이므로 시작 인덱스 6은 문자열의 끝 바로 뒤를 가리키고, substring(6)은 빈 문자열 ""을 돌려줍니다. 시작 인덱스 7은 길이를 넘으므로 StringIndexOutOfBoundsException이 납니다. (null, 0)은 수신 객체가 null이라 NullPointerException입니다. ("functional", 5, 1)은 시작이 끝보다 커서 역시 StringIndexOutOfBoundsException이 나며, 인덱스를 바꿔 주거나 뒤집힌 문자열을 만들어 주지 않습니다. String.substring 문서는 시작 인덱스가 음수이거나, 끝 인덱스가 길이보다 크거나, 시작이 끝보다 크면 IndexOutOfBoundsException을 던진다고 정해 두었고, 실제로 던져지는 StringIndexOutOfBoundsException은 그 하위 클래스입니다.

2. 음수 점수와 빈 목록의 최댓값

점수 목록 [-5, -2]와 빈 목록에 대해 reduce(0, Integer::max)와 reduce(Integer::max)의 결과를 각각 예상해 보세요. 항등값을 두고 싶다면 0 대신 무엇을 써야 음수 점수에서도 맞는 답이 나올까요?

풀이 확인

[-5, -2]에서 reduce(0, Integer::max)는 0과 -5, -2를 차례로 비교하므로 0을 돌려줍니다. 실제 최고점 -2를 잃은 것입니다. reduce(Integer::max)는 원소끼리만 비교해 Optional[-2]를 돌려줍니다. 빈 목록에서는 앞의 것이 항등값 0을 그대로 돌려주고, 뒤의 것은 Optional.empty를 돌려줍니다. 그래서 항등값 없는 쪽만 '원소가 없음'과 '최댓값이 0'을 구분할 수 있습니다. 항등값이 꼭 필요하다면 어떤 값과 max를 해도 그 값이 그대로 나오는 Integer.MIN_VALUE를 쓰면 [-5, -2]에서도 -2가 나옵니다.

3. null을 걸러 내고 정리하는 파이프라인

null이 섞일 수 있는 문자열 목록에서 null과 공백뿐인 값을 제거하고, 앞뒤 공백을 지우고 소문자로 바꾼 뒤, 길이 순서로 정렬하세요. 길이가 같으면 사전순으로 정렬합니다. 변환은 메서드 참조로 쓰되 null은 첫 언바운드 참조보다 먼저 걸러야 하고, 원본 목록은 바꾸지 않아야 합니다.

해답 보기
src/NullSafeReferenceSolution.java
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;

public final class NullSafeReferenceSolution {
    static List<String> normalize(List<String> source) {
        Predicate<String> present = Objects::nonNull;
        Predicate<String> nonBlank = value -> !value.isBlank();
        Function<String, String> trim = String::trim;
        Function<String, String> lower = String::toLowerCase;
        Comparator<String> byLengthThenText = Comparator
                .comparingInt(String::length)
                .thenComparing(Comparator.naturalOrder());

        return source.stream()
                .filter(present)
                .map(trim)
                .filter(nonBlank)
                .map(lower)
                .sorted(byLengthThenText)
                .toList();
    }

    public static void main(String[] args) {
        List<String> source = Arrays.asList(" Java ", null, " ", "LAMBDA", "go", " java");
        System.out.println(normalize(source));
        System.out.println(source);
    }
}

실행 결과:

[go, java, java, lambda]
[ Java , null,  , LAMBDA, go,  java]

Objects::nonNull이 null을 먼저 걸러 내므로 그 뒤의 String::trim에는 null이 수신 객체로 들어오지 않습니다. " "는 trim 뒤에 빈 문자열이 되므로 공백 검사는 trim 다음에 둡니다. 남은 값을 소문자로 바꾸면 java, lambda, go, java이고, 길이 순서로 정렬하면 go, java, java, lambda가 됩니다. 중복을 없애는 단계가 없으므로 java 두 개가 모두 남습니다. 둘째 줄을 보면 원본 목록은 null과 공백까지 그대로 남아 있고, 결과는 새 목록으로 만들어졌다는 것을 알 수 있습니다.

null 검사를 map(trim) 뒤로 옮기면, 필터에 도달하기 전에 trim이 null을 수신 객체로 받아 NullPointerException이 납니다. 또 하나 주의할 점은 String::toLowerCase가 인수 없는 toLowerCase()를 가리키고, 이 메서드는 실행 환경의 기본 로케일을 따른다는 것입니다. 기본 로케일이 터키어이면 "TITLE".toLowerCase()는 점 없는 ı가 들어간 tıtle이 됩니다. 로케일과 상관없이 같은 결과가 필요하다면 value -> value.toLowerCase(Locale.ROOT)처럼 람다로 인수를 직접 넘겨야 합니다. 메서드 참조는 함수가 받은 인수만 넘길 수 있기 때문입니다.

다음 글

이 장에서는 filter와 map을 직접 만들어 보며 함수를 넘기는 방법과, 넘기는 함수가 지켜야 할 규칙을 익혔습니다. 32장의 Stream 파이프라인부터는 JDK가 제공하는 Stream을 본격적으로 다룹니다. 이 장에서 쓴 메서드 참조와 '상태를 갖지 않는 함수'라는 원칙이 거기서도 그대로 쓰입니다.