제네릭 filter와 map 직접 만들기
타입과 조건만 다른 반복문을 타입 매개변수와 Predicate·Function으로 일반화해 어떤 목록에도 쓸 수 있는 filter와 map을 만들고, 함수형 매개변수에 와일드카드가 필요한 이유를 컴파일 오류로 확인합니다.
목록에서 조건에 맞는 원소만 골라내는 반복문과, 원소마다 다른 값으로 바꿔 새 목록을 만드는 반복문은 거의 모든 프로그램에 나옵니다. 앞의 조건을 매개변수로 넘기는 행동 매개변수화에서는 고르는 조건을 함수로 넘겨 반복문을 재사용하는 방법을 보았습니다. 이 글에서는 한 걸음 더 나아가 원소의 타입까지 타입 매개변수로 빼내서, 어떤 목록에도 쓸 수 있는 두 메서드를 직접 만듭니다. 조건에 맞는 원소만 남기는 연산을 filter, 원소 하나하나를 다른 값으로 바꾸는 연산을 map이라고 부릅니다. map은 자료구조 Map과는 관계가 없고, 값을 다른 값에 대응시킨다는 뜻에서 온 이름입니다.
일반화는 타입 이름을 바꿔 끼우는 것으로 끝나지 않습니다. 원본 목록을 건드릴지, 원소 순서를 지킬지, 함수를 원소마다 몇 번 부를지, 결과를 언제 만들지도 정해야 하고, 쓰는 사람도 그 답을 알아야 안심하고 쓸 수 있습니다. 이 글의 filter와 map은 원본을 수정하지 않고, 입력 순서대로 원소마다 함수를 한 번씩 호출하며, 호출이 끝나면 수정할 수 없는 새 목록을 돌려줍니다. 다음 장에서 배울 Stream API에도 같은 이름의 filter와 map이 있으므로, 여기서 직접 만들어 보는 것이 그 준비가 됩니다. 실행하는 방식은 조금 다른데, 그 차이는 이 장의 뒤쪽 글에서 직접 확인합니다.
타입과 조건만 다른 두 반복문
다음 두 메서드 가운데 하나는 다섯 글자 이상인 문자열을 고르고, 다른 하나는 정수를 제곱합니다. 하는 일은 달라 보이지만 뼈대는 같습니다. 결과 목록을 만들고, 원본을 처음부터 끝까지 돌면서 원소마다 무언가를 판단하거나 계산해 결과에 넣고, 마지막에 결과를 돌려줍니다.
import java.util.ArrayList;
import java.util.List;
public final class ConcreteCollectionUtilities {
static List<String> longStrings(List<String> source) {
List<String> result = new ArrayList<>();
for (String value : source) {
if (value.length() >= 5) {
result.add(value);
}
}
return result;
}
static List<Integer> squares(List<Integer> source) {
List<Integer> result = new ArrayList<>();
for (Integer value : source) {
result.add(value * value);
}
return result;
}
public static void main(String[] args) {
System.out.println(longStrings(List.of("map", "filter", "lambda", "for")));
System.out.println(squares(List.of(1, 2, 3, 4)));
}
}[filter, lambda]
[1, 4, 9, 16]두 메서드에서 다른 곳은 세 군데뿐입니다. 원소 타입(String과 Integer), 원소를 고르는 조건(value.length() >= 5), 원소를 바꾸는 계산(value * value)입니다. 나머지는 글자 하나 다르지 않은데도, 이 구조로는 '가격이 50 이하인 상품'이나 '주문을 요약 문자열로' 같은 요구가 생길 때마다 같은 반복문을 다시 써야 합니다. 반복문을 복사할 때마다 결과 목록을 새로 만드는 것을 잊거나 조건을 반대로 쓰는 실수가 들어갈 자리도 함께 늘어납니다.
바뀌는 부분을 매개변수로 빼기
다른 세 군데를 하나씩 매개변수로 바꿔 보겠습니다. 원소 타입은 타입 매개변수 T가 됩니다. map은 결과 원소의 타입이 입력과 다를 수 있으므로 결과 타입 R을 하나 더 둡니다. 조건은 'T를 받아 boolean을 돌려주는 함수'이고, 계산은 'T를 받아 R을 돌려주는 함수'입니다.
자바 표준 라이브러리에는 이 두 모양의 함수형 인터페이스가 이미 있습니다. Predicate<T>는 boolean test(T t)를, Function<T, R>은 R apply(T t)를 추상 메서드로 가집니다(표준 함수형 인터페이스와 함수 조합 참고). 이 글에서는 Predicate로 받는 함수를 조건 함수, Function으로 받는 함수를 변환 함수(mapper)라고 부르겠습니다. 둘을 매개변수로 받는 첫 번째 버전은 다음과 같습니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
public final class StrictFilterMap {
static <T> List<T> filter(List<T> source, Predicate<T> predicate) {
List<T> result = new ArrayList<>();
for (T value : source) {
if (predicate.test(value)) {
result.add(value);
}
}
return result;
}
static <T, R> List<R> map(List<T> source, Function<T, R> mapper) {
List<R> result = new ArrayList<>();
for (T value : source) {
result.add(mapper.apply(value));
}
return result;
}
public static void main(String[] args) {
System.out.println(filter(List.of("map", "filter", "lambda", "for"), value -> value.length() >= 5));
System.out.println(map(List.of(1, 2, 3, 4), value -> value * value));
}
}[filter, lambda]
[1, 4, 9, 16]반환 타입 앞의 <T>와 <T, R>은 이 메서드에서 쓸 타입 매개변수를 선언하는 부분입니다. 클래스 전체가 아니라 메서드 하나에만 타입 매개변수를 두는 이런 메서드를 제네릭 메서드라고 하며, 타입 상한과 메서드 추론에서 다뤘습니다. 호출하는 쪽에서 타입을 적지 않아도 되는 것은 컴파일러가 인자를 보고 타입을 정해 주기 때문입니다. 첫 줄에서는 List<String>을 넘겼으니 T는 String이 되고, 람다 value -> value.length() >= 5는 Predicate<String>으로 해석됩니다.
출력은 앞의 두 메서드와 똑같습니다. 달라진 것은 조건과 계산을 호출하는 쪽에서 정하게 됐다는 점이고, 덕분에 반복문은 filter 하나와 map 하나만 남았습니다.
이미 만든 함수를 넘기면 컴파일되지 않는다
람다를 호출하는 자리에서 바로 쓰면 이 버전으로 충분해 보입니다. 하지만 함수를 값으로 다루는 이유는 한 번 만든 함수를 여러 곳에서 다시 쓰기 위해서입니다. main의 끝에 다음 코드를 더해 보겠습니다. 어떤 숫자든 양수인지 검사하는 Predicate<Number>로 점수 목록을 거르고, 문자열 길이를 구하는 Function<String, Integer>의 결과를 List<Number>로 받으려는 코드입니다.
List<Integer> scores = List.of(70, -5, 90);
Predicate<Number> positive = number -> number.doubleValue() > 0;
List<Integer> valid = filter(scores, positive);
List<String> words = List.of("map", "filter");
Function<String, Integer> length = String::length;
List<Number> lengths = map(words, length);두 호출에서 넘긴 함수는 의미로 보면 틀리지 않았습니다. 어떤 Number든 검사하는 함수는 Integer도 검사할 수 있고, Integer인 길이는 Number로 담아도 문제가 없습니다. 그런데도 컴파일하면 filter 호출과 map 호출 모두 오류가 납니다.
StrictFilterMap.java:31: error: method filter in class StrictFilterMap cannot be applied to given types;
List<Integer> valid = filter(scores, positive);
^
required: List<T>,Predicate<T>
found: List<Integer>,Predicate<Number>
reason: inference variable T has incompatible equality constraints Number,Integer
where T is a type-variable:
T extends Object declared in method <T>filter(List<T>,Predicate<T>)
StrictFilterMap.java:35: error: incompatible types: inference variable R has incompatible equality constraints Number,Integer
List<Number> lengths = map(words, length);
^
where R,T are type-variables:
R extends Object declared in method <T,R>map(List<T>,Function<T,R>)
T extends Object declared in method <T,R>map(List<T>,Function<T,R>)
2 errors첫 번째 오류는 reason 줄에 원인이 있습니다. scores가 List<Integer>이므로 T는 Integer여야 하고, positive가 Predicate<Number>이므로 T는 Number여야 합니다. 와일드카드를 쓰지 않은 제네릭 타입은 타입 인자가 정확히 같을 때만 서로 대입할 수 있어서(이 성질을 불공변이라고 합니다) Predicate<Number>는 Predicate<Integer>로 쓰일 수 없고, T 하나가 두 타입과 동시에 같을 수도 없습니다. 메시지의 equality constraints, 즉 '같아야 한다는 조건' 두 개가 서로 부딪친 것입니다. 두 번째 오류도 구조가 같습니다. 결과를 List<Number>에 담으려면 R이 Number여야 하는데, length가 Function<String, Integer>이므로 R은 Integer여야 합니다.
그런데 두 호출은 실행해 보면 아무 문제가 없을 코드입니다. 어떤 Number든 검사할 수 있는 조건이라면 Integer도 당연히 검사할 수 있고, Integer를 돌려주는 함수의 결과는 Number 목록에 넣어도 됩니다. 그러니 오류의 원인은 호출하는 코드가 아니라 필요 이상으로 좁은 시그니처에 있습니다.
와일드카드로 받는 범위 넓히기
필요한 것은 '정확히 이 타입'이 아니라 '이 타입을 다룰 수 있는 범위'를 적는 방법이고, 그것이 와일드카드와 PECS에서 배운 와일드카드입니다. 어느 쪽 와일드카드를 쓸지는 메서드가 그 매개변수를 어떻게 쓰는지만 보면 정해집니다.
- 조건 함수는 filter가 건네주는
T값을 받기만 합니다. 값을 받아들이는 쪽(소비자)이므로Predicate<? super T>라고 쓰면T나 그 상위 타입을 받는 조건을 모두 넘길 수 있습니다. - 변환 함수도
T값을 받기만 하므로 입력 쪽은? super T입니다. 반면 변환 결과는 map이 꺼내 쓰는 값(생산자)이므로? extends R라고 써서R의 하위 타입을 돌려주는 함수도 받습니다. - 원본 목록에서는 원소를 꺼내기만 하므로
List<? extends T>로 받을 수 있습니다.
인터페이스 하나 안에 소비자 자리와 생산자 자리가 함께 있는 Function<? super T, ? extends R>의 모양은 Function 매개변수의 와일드카드와 타입 추론에서도 보았습니다. 이렇게 고친 최종 버전은 다음과 같습니다. main의 앞부분은 상품 목록을 거르고 바꾸는 평범한 사용 예이고, 뒷부분은 방금 컴파일되지 않던 여섯 줄을 그대로 옮긴 것입니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
public final class GenericFilterMap {
static <T> List<T> filter(List<? extends T> source, Predicate<? super T> predicate) {
List<T> result = new ArrayList<>();
for (T value : source) {
if (predicate.test(value)) {
result.add(value);
}
}
return List.copyOf(result);
}
static <T, R> List<R> map(
List<? extends T> source,
Function<? super T, ? extends R> mapper) {
List<R> result = new ArrayList<>(source.size());
for (T value : source) {
result.add(mapper.apply(value));
}
return List.copyOf(result);
}
record Product(String name, int price) {
}
public static void main(String[] args) {
List<Product> products = List.of(
new Product("Keyboard", 120),
new Product("Cable", 15),
new Product("Book", 35));
List<Product> affordable = filter(products, product -> product.price() <= 50);
List<String> names = map(affordable, Product::name);
List<Integer> nameLengths = map(names, String::length);
System.out.println(affordable);
System.out.println(names);
System.out.println(nameLengths);
List<Integer> scores = List.of(70, -5, 90);
Predicate<Number> positive = number -> number.doubleValue() > 0;
List<Integer> valid = filter(scores, positive);
List<String> words = List.of("map", "filter");
Function<String, Integer> length = String::length;
List<Number> lengths = map(words, length);
System.out.println(valid);
System.out.println(lengths);
}
}[Product[name=Cable, price=15], Product[name=Book, price=35]]
[Cable, Book]
[5, 4]
[70, 90]
[3, 6]첫 줄은 가격이 120인 Keyboard가 빠지고 15인 Cable과 35인 Book이 원래 순서대로 남은 결과이며, 레코드의 toString()이 Product[name=Cable, price=15] 모양으로 출력한 것입니다. 둘째 줄의 이름 목록은 처음의 세 상품이 아니라 filter가 돌려준 두 상품에서 만든 것입니다. 각 단계는 바로 앞 단계의 결과만 입력으로 받으므로, 셋째 줄의 [5, 4]도 Cable과 Book의 길이입니다.
넷째 줄과 다섯째 줄은 와일드카드가 한 일을 보여 줍니다. filter(scores, positive)에서 T는 Integer로 정해지고, Predicate<Number>는 Predicate<? super Integer>에 들어맞습니다. map(words, length)에서는 결과를 받는 변수의 타입 때문에 R이 Number로 정해지고, Function<String, Integer>는 Function<? super String, ? extends Number>에 들어맞습니다. 원본 쪽의 ? extends T도 비슷한 일을 합니다. List<Number> widened = filter(scores, positive);처럼 쓰면 T가 Number로 정해지고 List<Integer>가 List<? extends Number>에 들어맞으므로, 정수 목록에서 고른 결과를 Number 목록으로 받을 수 있습니다. 어느 경우든 GenericFilterMap.<Integer>filter(...)처럼 타입 인자를 직접 적어야 하는 일은 거의 없습니다.
두 메서드는 결과를 ArrayList에 모은 뒤 List.copyOf로 한 번 더 복사해 돌려줍니다. List.copyOf가 만든 목록에는 원소를 추가하거나 지우거나 바꿀 수 없으므로, 호출한 쪽이 결과를 고쳐서 다른 곳에 영향을 주는 일이 생기지 않습니다. 다만 복사되는 것은 목록의 구조뿐이고 원소 객체는 원본과 같은 것을 가리킵니다. 그래서 원소가 가변 객체라면 그 객체의 상태 변화는 결과 목록에서도 그대로 보입니다(수정할 수 없는 목록과 정렬 결과 다루기 참고). map에서만 new ArrayList<>(source.size())로 용량을 미리 잡은 이유는 map의 결과 크기가 언제나 입력 크기와 같기 때문입니다. filter는 몇 개가 통과할지 미리 알 수 없어 기본 용량으로 시작합니다.
순서와 호출 횟수 확인하기
결과 값이 맞다고 해서 메서드가 약속대로 동작한다고 단정할 수는 없습니다. 조건 함수를 원소마다 두 번 부르는 구현도 결과는 같을 수 있기 때문입니다. 하지만 조건이 데이터베이스 조회처럼 비싼 작업이면 비용이 두 배가 되고, 난수나 현재 시각에 따라 답이 달라지는 함수라면 두 번의 답이 서로 다를 수도 있습니다. 결과의 순서도 중요합니다. 화면에 보여 줄 목록이나 처리 기록은 입력 순서를 따라야 예측할 수 있습니다.
다음 프로그램은 조건 함수와 변환 함수가 불릴 때마다 그 사실을 trace 목록에 적어서, 어떤 순서로 몇 번 불렸는지 보여 줍니다. 일부러 정렬되지 않은 입력 [3, 1, 4, 2]를 썼고, 마지막에는 원본에 원소를 더한 뒤 결과가 바뀌는지와 결과 목록에 원소를 추가할 수 있는지도 확인합니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
public final class FilterMapTrace {
static <T> List<T> filter(List<? extends T> source, Predicate<? super T> predicate) {
List<T> result = new ArrayList<>();
for (T value : source) {
if (predicate.test(value)) {
result.add(value);
}
}
return List.copyOf(result);
}
static <T, R> List<R> map(
List<? extends T> source,
Function<? super T, ? extends R> mapper) {
List<R> result = new ArrayList<>(source.size());
for (T value : source) {
result.add(mapper.apply(value));
}
return List.copyOf(result);
}
public static void main(String[] args) {
List<Integer> source = new ArrayList<>(List.of(3, 1, 4, 2));
List<String> trace = new ArrayList<>();
List<Integer> selected = filter(source, value -> {
trace.add("test:" + value);
return value % 2 == 0;
});
List<String> mapped = map(selected, value -> {
trace.add("map:" + value);
return "N" + value;
});
System.out.println("source=" + source);
System.out.println("selected=" + selected);
System.out.println("mapped=" + mapped);
System.out.println("trace=" + trace);
source.add(6);
System.out.println("after source.add(6): selected=" + selected);
try {
selected.add(8);
} catch (UnsupportedOperationException exception) {
System.out.println("selected.add(8): " + exception.getClass().getSimpleName());
}
}
}source=[3, 1, 4, 2]
selected=[4, 2]
mapped=[N4, N2]
trace=[test:3, test:1, test:4, test:2, map:4, map:2]
after source.add(6): selected=[4, 2]
selected.add(8): UnsupportedOperationException처음 세 줄을 보면 원본은 그대로이고, 고른 결과는 정렬되지 않은 채 입력 순서인 4, 2를 따릅니다. trace에서는 조건 함수가 네 원소에 대해 입력 순서대로 정확히 한 번씩 불렸고, 변환 함수는 통과한 두 원소에만 불렸다는 것을 확인할 수 있습니다. test 네 번이 모두 끝난 다음에야 map이 시작된다는 점도 눈여겨보세요. filter가 결과 목록을 완성해서 돌려줘야 map이 그 목록을 받을 수 있기 때문입니다.
이렇게 단계마다 전체 결과를 곧바로 만들어 내는 방식을 즉시 평가(eager evaluation)라고 합니다. 마지막 두 줄은 즉시 평가의 결과가 호출하던 순간의 스냅샷이라는 것을 보여 줍니다. filter가 끝난 뒤 원본에 6을 더해도 selected는 [4, 2] 그대로이고, List.copyOf로 만든 결과에 원소를 추가하려 하면 UnsupportedOperationException이 납니다. 즉시 평가에는 단계마다 중간 목록을 하나씩 만든다는 비용도 따릅니다. 두 단계를 한 반복문으로 합치는 방법은 다음 글에서, 결과가 필요해질 때까지 계산을 미루는 지연 평가(lazy evaluation)는 그다음 글에서 다룹니다.
이 예제의 람다는 호출 기록을 남기려고 바깥 목록 trace를 수정했습니다. 디버깅에는 쓸 만하지만, 실제 조건 함수나 변환 함수가 바깥 상태를 바꾸면 함수를 다시 실행하거나 순서를 바꾸거나 병렬로 나눠 실행할 때 결과가 흔들립니다. 조건과 변환은 되도록 같은 입력에 같은 결과를 내고 바깥 상태를 바꾸지 않게 만드는 것이 좋습니다. 상태를 가진 람다가 어떤 문제를 일으키는지는 상태를 가진 람다와 재사용 문제에서 자세히 다룹니다.
변환 함수가 null을 돌려주거나 예외를 던질 때
변환 함수가 정상적인 값을 돌려주지 못하는 경우는 두 가지입니다. 예외를 던지는 경우와, 예외 없이 null을 돌려주는 경우입니다. 둘은 비슷해 보이지만 map이 멈추는 위치가 다릅니다. 다음 프로그램은 "ok", "bad", "later" 세 입력 가운데 "bad"에서만 문제를 일으키는 변환 함수 두 개로 같은 map을 호출합니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
public final class MapFailurePoint {
static <T, R> List<R> map(
List<? extends T> source,
Function<? super T, ? extends R> mapper) {
List<R> result = new ArrayList<>(source.size());
for (T value : source) {
result.add(mapper.apply(value));
}
return List.copyOf(result);
}
public static void main(String[] args) {
List<String> inputs = List.of("ok", "bad", "later");
List<String> trace = new ArrayList<>();
try {
map(inputs, value -> {
trace.add("map:" + value);
return value.equals("bad") ? null : value.toUpperCase();
});
} catch (NullPointerException exception) {
System.out.println("returns null -> NullPointerException " + trace);
}
trace.clear();
try {
map(inputs, value -> {
trace.add("map:" + value);
if (value.equals("bad")) {
throw new IllegalArgumentException("bad input");
}
return value.toUpperCase();
});
} catch (IllegalArgumentException exception) {
System.out.println("throws -> IllegalArgumentException " + trace);
}
}
}returns null -> NullPointerException [map:ok, map:bad, map:later]
throws -> IllegalArgumentException [map:ok, map:bad]예외를 던진 둘째 경우는 단순합니다. mapper.apply에서 던진 예외가 반복문을 그대로 빠져나가므로 "bad"에서 멈추고, "later"는 호출되지 않습니다. null을 돌려준 첫째 경우는 다릅니다. ArrayList는 null 원소를 허용하므로 null이 지역 결과 목록에 그대로 들어가고, 반복문은 "later"까지 계속 돕니다. NullPointerException은 반복문이 끝난 뒤 마지막 줄의 List.copyOf에서 납니다. Javadoc에 적힌 대로 List.copyOf는 null 원소가 하나라도 있으면 NullPointerException을 던지기 때문입니다. 변환 함수는 "later"에서 정상적으로 값을 돌려주므로, 기록에 호출이 세 번 모두 남아 있다는 것은 예외가 반복문이 끝난 뒤에 났다는 뜻입니다.
입력 null과 결과 null은 서로 다른 경계
mapper 정상 호출 결과를 지역 목록에 추가한 뒤 마지막 copyOf에서 null을 거절합니다. 콜백 예외의 즉시 중단과 마지막 null 실패의 시점을 구분할 가치가 있습니다.
입력 null과 결과 null은 서로 다른 경계
입력과 결과의 null 경계
입력 null과 결과 null은 서로 다른 경계
반복문은 null 입력도 콜백에 전달합니다. 결과 목록의 null 거절은 마지막 List.copyOf에서 일어납니다.
null 입력을 조건식이 제외
- 함수 평가
- 그 입력에도 predicate 호출
- 현재 유틸리티의 결과
- 선택하지 않으면 copyOf에 전달되지 않음
null 입력을 정상 값으로 변환
- 함수 평가
- mapper가 null을 받아 대체값 반환
- 현재 유틸리티의 결과
- 결과가 모두 non-null이면 반환 가능
매퍼 결과 하나가 null
- 함수 평가
- 나머지 매퍼가 정상 반환하면 계속 평가
- 현재 유틸리티의 결과
- 마지막 List.copyOf에서 NullPointerException
조건식·매퍼가 예외를 던짐
- 함수 평가
- 해당 호출에서 즉시 중단
- 현재 유틸리티의 결과
- 부분 결과 목록은 반환하지 않음
GenericFilterMap의 반복문과 결과 확정 위치를 비교했습니다. 원본 List.of 예제에는 null 입력이 없으며 이 표의 null 분기는 별도 실행 관측이 아닙니다.
콜백이 예외를 던지면 그 호출에서 중단합니다. 매퍼가 null을 정상 반환한 경우와 구분합니다.
이 예의 조건
- null 허용 조건의 설명이며 원 List.of main의 실행 로그가 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 GenericFilterMap.map은 각 입력을 mapper에 전달하고 정상 반환값은 null이어도 지역 목록에 추가한다. 모든 호출이 정상 반환한 뒤 List.copyOf가 null 원소를 거절한다. mapper가 직접 예외를 던지면 그 호출에서 즉시 중단한다. 그림의 지역 목록 단계는 반복마다 일어나고 copyOf는 끝에서 한 번이다. null을 허용하는 별도 입력 또는 반환값 조건을 설명하며 원 List.of main의 실행 기록은 아니다. 모바일도 같은 7개 노드와 6개 관계다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 GenericFilterMap.map은 각 입력을 mapper에 전달하고 정상 반환값은 null이어도 지역 목록에 추가한다. 모든 호출이 정상 반환한 뒤 List.copyOf가 null 원소를 거절한다. mapper가 직접 예외를 던지면 그 호출에서 즉시 중단한다. 그림의 지역 목록 단계는 반복마다 일어나고 copyOf는 끝에서 한 번이다. null을 허용하는 별도 입력 또는 반환값 조건을 설명하며 원 List.of main의 실행 기록은 아니다. 모바일도 같은 7개 노드와 6개 관계다. |
| 전체 읽기 정보 | 입력 null과 결과 null은 서로 다른 경계 입력 null과 결과 null은 서로 다른 경계 입력 null과 결과 null은 서로 다른 경계 입력과 결과의 null 경계 입력 null과 결과 null은 서로 다른 경계 반복문은 null 입력도 콜백에 전달합니다. 결과 목록의 null 거절은 마지막 List.copyOf에서 일어납니다. null 입력을 조건식이 제외 함수 평가 그 입력에도 predicate 호출 현재 유틸리티의 결과 선택하지 않으면 copyOf에 전달되지 않음 null 입력을 정상 값으로 변환 함수 평가 mapper가 null을 받아 대체값 반환 현재 유틸리티의 결과 결과가 모두 non-null이면 반환 가능 매퍼 결과 하나가 null 함수 평가 나머지 매퍼가 정상 반환하면 계속 평가 현재 유틸리티의 결과 마지막 List.copyOf에서 NullPointerException 조건식·매퍼가 예외를 던짐 함수 평가 해당 호출에서 즉시 중단 현재 유틸리티의 결과 부분 결과 목록은 반환하지 않음 GenericFilterMap의 반복문과 결과 확정 위치를 비교했습니다. 원본 List.of 예제에는 null 입력이 없으며 이 표의 null 분기는 별도 실행 관측이 아닙니다. 콜백이 예외를 던지면 그 호출에서 중단합니다. 매퍼가 null을 정상 반환한 경우와 구분합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| mapper 전달 각 입력을 mapper에 넘깁니다. |
|
|
| 지역 누적 정상 반환이면 null도 지역 목록에 추가합니다. |
|
|
| 최종 복사 모든 호출이 정상 반환한 뒤 List.copyOf를 한 번 수행합니다. |
|
|
입력 null과 결과 null은 서로 다른 경계
mapper 정상 호출 결과를 지역 목록에 추가한 뒤 마지막 copyOf에서 null을 거절합니다. 콜백 예외의 즉시 중단과 마지막 null 실패의 시점을 구분할 가치가 있습니다.
입력 null과 결과 null은 서로 다른 경계
입력과 결과의 null 경계
입력 null과 결과 null은 서로 다른 경계
반복문은 null 입력도 콜백에 전달합니다. 결과 목록의 null 거절은 마지막 List.copyOf에서 일어납니다.
null 입력을 조건식이 제외
- 함수 평가
- 그 입력에도 predicate 호출
- 현재 유틸리티의 결과
- 선택하지 않으면 copyOf에 전달되지 않음
null 입력을 정상 값으로 변환
- 함수 평가
- mapper가 null을 받아 대체값 반환
- 현재 유틸리티의 결과
- 결과가 모두 non-null이면 반환 가능
매퍼 결과 하나가 null
- 함수 평가
- 나머지 매퍼가 정상 반환하면 계속 평가
- 현재 유틸리티의 결과
- 마지막 List.copyOf에서 NullPointerException
조건식·매퍼가 예외를 던짐
- 함수 평가
- 해당 호출에서 즉시 중단
- 현재 유틸리티의 결과
- 부분 결과 목록은 반환하지 않음
GenericFilterMap의 반복문과 결과 확정 위치를 비교했습니다. 원본 List.of 예제에는 null 입력이 없으며 이 표의 null 분기는 별도 실행 관측이 아닙니다.
콜백이 예외를 던지면 그 호출에서 중단합니다. 매퍼가 null을 정상 반환한 경우와 구분합니다.
이 예의 조건
- null 허용 조건의 설명이며 원 List.of main의 실행 로그가 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 GenericFilterMap.map은 각 입력을 mapper에 전달하고 정상 반환값은 null이어도 지역 목록에 추가한다. 모든 호출이 정상 반환한 뒤 List.copyOf가 null 원소를 거절한다. mapper가 직접 예외를 던지면 그 호출에서 즉시 중단한다. 그림의 지역 목록 단계는 반복마다 일어나고 copyOf는 끝에서 한 번이다. null을 허용하는 별도 입력 또는 반환값 조건을 설명하며 원 List.of main의 실행 기록은 아니다. 모바일도 같은 7개 노드와 6개 관계다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 GenericFilterMap.map은 각 입력을 mapper에 전달하고 정상 반환값은 null이어도 지역 목록에 추가한다. 모든 호출이 정상 반환한 뒤 List.copyOf가 null 원소를 거절한다. mapper가 직접 예외를 던지면 그 호출에서 즉시 중단한다. 그림의 지역 목록 단계는 반복마다 일어나고 copyOf는 끝에서 한 번이다. null을 허용하는 별도 입력 또는 반환값 조건을 설명하며 원 List.of main의 실행 기록은 아니다. 모바일도 같은 7개 노드와 6개 관계다. |
| 전체 읽기 정보 | 입력 null과 결과 null은 서로 다른 경계 입력 null과 결과 null은 서로 다른 경계 입력 null과 결과 null은 서로 다른 경계 입력과 결과의 null 경계 입력 null과 결과 null은 서로 다른 경계 반복문은 null 입력도 콜백에 전달합니다. 결과 목록의 null 거절은 마지막 List.copyOf에서 일어납니다. null 입력을 조건식이 제외 함수 평가 그 입력에도 predicate 호출 현재 유틸리티의 결과 선택하지 않으면 copyOf에 전달되지 않음 null 입력을 정상 값으로 변환 함수 평가 mapper가 null을 받아 대체값 반환 현재 유틸리티의 결과 결과가 모두 non-null이면 반환 가능 매퍼 결과 하나가 null 함수 평가 나머지 매퍼가 정상 반환하면 계속 평가 현재 유틸리티의 결과 마지막 List.copyOf에서 NullPointerException 조건식·매퍼가 예외를 던짐 함수 평가 해당 호출에서 즉시 중단 현재 유틸리티의 결과 부분 결과 목록은 반환하지 않음 GenericFilterMap의 반복문과 결과 확정 위치를 비교했습니다. 원본 List.of 예제에는 null 입력이 없으며 이 표의 null 분기는 별도 실행 관측이 아닙니다. 콜백이 예외를 던지면 그 호출에서 중단합니다. 매퍼가 null을 정상 반환한 경우와 구분합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| mapper 전달 각 입력을 mapper에 넘깁니다. |
|
|
| 지역 누적 정상 반환이면 null도 지역 목록에 추가합니다. |
|
|
| 최종 복사 모든 호출이 정상 반환한 뒤 List.copyOf를 한 번 수행합니다. |
|
|
두 경우 모두 일부만 처리된 결과 목록이 호출한 쪽에 돌아가지는 않습니다. 차이는 문제가 생긴 뒤에도 변환 함수가 더 호출되느냐입니다. 변환 함수가 비싸거나 바깥에 흔적을 남기는 함수라면 "later"까지 호출하는 것은 낭비이고, List.copyOf가 던진 예외만으로는 어느 원소가 문제였는지 알 수 없습니다. 문제가 생긴 원소에서 바로 멈추려면 호출할 때마다 결과를 바로 검사하면 되며, 연습 문제 1이 그 방법을 다룹니다.
입력 쪽의 null도 따로 생각해야 합니다. 두 메서드는 원소를 검사하지 않고 그대로 함수에 넘기므로, ArrayList처럼 null을 담을 수 있는 목록을 넘기면 조건 함수나 변환 함수가 null을 받습니다. 조건 함수가 null을 걸러 내면 문제가 없지만 통과시키면 filter도 마지막 List.copyOf에서 실패합니다. 입력의 null을 처음부터 일관되게 거절하고 싶다면 함수를 부르기 전에 원소를 검사하는 코드를 따로 넣어야 합니다.
더 깊이: JDK Stream과 같은 선언, 다른 null 처리
여기서 만든 시그니처는 JDK가 쓰는 것과 같습니다. java.util.stream.Stream의 두 메서드는 Stream<T> filter(Predicate<? super T> predicate)와 <R> Stream<R> map(Function<? super T, ? extends R> mapper)로 선언되어 있습니다. 조건 함수에 ? super T를 붙이고, 변환 함수의 입력과 출력에 각각 ? super T와 ? extends R을 붙인 것까지 똑같습니다. 다음 장에서 이 선언을 다시 만나면 왜 이렇게 생겼는지 바로 읽을 수 있을 것입니다.
결과 목록을 만드는 방법에서는 차이가 납니다. 이 글의 메서드는 List.copyOf를 썼기 때문에 null이 섞인 결과를 거절합니다. 반면 스트림의 원소를 목록으로 모으는 Stream.toList()도 수정할 수 없는 목록을 돌려주지만 null 원소는 허용해서, Stream.of("a", null).toList()는 [a, null]을 돌려줍니다. 결과 목록을 복사하지 않고 감싸기만 하는 Collections.unmodifiableList(result)를 쓸 수도 있습니다. 복사를 한 번 줄이고 null도 허용하며, result가 메서드 밖으로 새어 나가지 않으니 이 경우에는 감싸기만 해도 안전합니다. 어느 방법이 옳다기보다, 결과에 null을 허용할지를 먼저 정하고 그에 맞는 방법을 고르는 것입니다.
정리
- 타입과 조건만 다른 반복문은 원소 타입을 타입 매개변수로, 조건과 계산을
Predicate와Function매개변수로 빼서 하나로 합칠 수 있습니다. Predicate<T>나Function<T, R>처럼 정확한 타입만 받으면Predicate<Number>,Function<String, Integer>같은 기존 함수를 재사용할 때 컴파일 오류가 납니다. 함수가 값을 받는 자리에는? super, 값을 내주는 자리에는? extends를 붙입니다.- 이 글의 filter와 map은 원본을 바꾸지 않고, 입력 순서대로 원소마다 함수를 한 번씩 부르며, 호출 시점의 결과를 수정할 수 없는 목록으로 돌려줍니다. filter가 모두 끝난 뒤에 map이 시작되는 즉시 평가입니다.
- 변환 함수가 예외를 던지면 그 자리에서 멈추지만,
null을 돌려주면 끝까지 호출한 뒤List.copyOf에서 실패합니다.
연습 문제
- 원소와 0부터 시작하는 위치를 함께 받는 함수형 인터페이스
IndexedMapper<T, R>를 정의하고, 이를 받는mapIndexed를 작성하세요. 원본 순서를 지키고, 변환 결과가null이면 그 자리에서 위치가 담긴 메시지와 함께NullPointerException을 던져야 합니다.List.of("java", "lambda", "stream")을"위치:값"모양으로 바꿔 출력해 보세요. - 입력
["ok", "bad", "later"]에서"bad"에만null을 돌려주는 변환 함수를 이 글의map과 1번의mapIndexed에 각각 넘기면, 변환 함수는 몇 번 호출되고 어디서 실패할까요? - 이 글의 최종
filter와map이 있고,words는List<String>,numbers는List.of(1, 2.5, 4)로 만든List<Number>입니다. 아래의 (가), (나), (다) 가운데 컴파일되지 않는 것은 무엇이고, 그 이유는 무엇일까요?
Predicate<Object> notNull = Objects::nonNull;
List<String> kept = filter(words, notNull); // (가)
Function<CharSequence, Integer> size = CharSequence::length;
List<Integer> sizes = map(words, size); // (나)
Predicate<Integer> even = value -> value % 2 == 0;
List<Number> evens = filter(numbers, even); // (다)힌트
- 위치는 반복문 안에서 직접 세면 됩니다.
Objects.requireNonNull(값, 메시지)는 값이null이면 그 메시지로NullPointerException을 던집니다. - 두 구현은
null을 검사하는 시점이 다릅니다. - 호출마다
T가 만족해야 하는 조건을 적어 보세요.List<? extends T>에 넘긴 목록의 원소 타입은T의 하위 타입이어야 하고,Predicate<? super T>에 넘긴 조건 함수의 타입은T의 상위 타입이어야 합니다.
해설
- 위치를 세는 일은
mapIndexed가 맡고, 변환 함수는 받은 위치를 쓰기만 합니다.
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class IndexedMapSolution {
@FunctionalInterface
interface IndexedMapper<T, R> {
R map(int index, T value);
}
static <T, R> List<R> mapIndexed(
List<? extends T> source,
IndexedMapper<? super T, ? extends R> mapper) {
List<R> result = new ArrayList<>(source.size());
int index = 0;
for (T value : source) {
R mapped = mapper.map(index, value);
result.add(Objects.requireNonNull(mapped, "null result at index " + index));
index += 1;
}
return List.copyOf(result);
}
public static void main(String[] args) {
List<String> result = mapIndexed(
List.of("java", "lambda", "stream"),
(index, value) -> index + ":" + value);
System.out.println(result);
try {
mapIndexed(List.of("ok", "bad"), (index, value) -> index == 1 ? null : value);
} catch (NullPointerException exception) {
System.out.println(exception.getMessage());
}
}
}[0:java, 1:lambda, 2:stream]
null result at index 1결과를 목록에 넣기 직전에 Objects.requireNonNull로 검사하므로 문제가 생긴 원소에서 바로 멈추고, 메시지에 그 위치가 남습니다. for (int i = 0; i < source.size(); i++)로 돌면서 source.get(i)를 써도 결과는 같습니다. 하지만 LinkedList처럼 위치로 원소를 찾을 때마다 앞에서부터 따라가야 하는 목록에서는 get이 매번 느려져 전체 시간이 원소 수의 제곱에 비례하게 됩니다. 향상된 for문과 카운터는 어떤 목록이든 처음부터 끝까지 한 번만 훑습니다.
- 이 글의
map은 변환 함수를ok,bad,later에 모두 호출해 세 번 부릅니다.null은 지역 목록에 그대로 들어가고, 반복문이 끝난 뒤List.copyOf에서 메시지 없는NullPointerException이 납니다.mapIndexed는bad의 결과를 받자마자 검사하므로 두 번만 호출하고null result at index 1로 실패하며,later는 호출하지 않습니다. 실제로 기록을 남기며 실행하면mapIndexed쪽 기록은[map:ok, map:bad]입니다. 두 구현 모두 일부만 채운 결과를 돌려주지는 않습니다. 변환 함수가null대신 예외를 던진다면 두 구현 모두bad에서 바로 멈춥니다. - 컴파일되지 않는 것은 (다)입니다. (가)에서
T는String이상이면서Object이하여야 하므로String으로 정할 수 있습니다. (나)에서T는String이상이면서CharSequence이하이고,R은 결과 변수에 맞춰Integer가 됩니다. (다)에서는numbers때문에T가Number이상이어야 하고,even때문에Integer이하여야 합니다. 두 조건을 함께 만족하는 타입은 없으므로 컴파일러는 다음과 같이 거절합니다.
reason: inference variable T has incompatible bounds
upper bounds: Integer,Object
lower bounds: Number이 거절은 옳습니다. numbers에는 Double인 2.5가 들어 있는데, even은 Integer만 다룰 수 있는 조건이기 때문입니다. 와일드카드는 받는 범위를 넓혀 주지만, 타입이 실제로 맞지 않는 조합까지 통과시키지는 않습니다.
다음 글
filter와 map을 연결하고 한 반복문으로 합치기에서는 filter와 map을 이어 붙인 처리를 단계별로 실행할 때와 한 반복문으로 합쳐 실행할 때를 비교합니다. 결과는 같아도 함수가 호출되는 순서와 예외가 나는 지점은 달라질 수 있습니다.