제네릭 filter·map
반복문의 선택·변환 구조를 함수와 와일드카드로 일반화해 입력 불변인 컬렉션 유틸리티를 만듭니다.
컬렉션 처리에서 필터는 원소를 유지할지 판단하고, map은 각 원소를 다른 값으로 바꿉니다.
String 목록용 메서드를 먼저 만들 수 있지만 반복문 구조는 원소 타입과 무관합니다.
구체 타입을 제네릭 타입 매개변수로 바꾸고 변화하는 판단과 변환을 함수 매개변수로 받으면 하나의 알고리즘으로 여러 도메인을 처리할 수 있습니다.
일반화할 때는 문법만 치환하지 않습니다.
원본 목록을 수정하는지, 입력 처리 순서를 보존하는지, null을 허용하는지, 결과를 즉시 만드는지 계약을 정합니다.
예제의 반복문은 원본을 직접 수정하지 않고 수정 불가 결과 목록을 반환합니다. 원본이 순회 중 바뀌지 않고 함수가 정상 반환하는 경우, 각 유틸리티는 자신의 입력 목록 순서대로 원소당 함수를 한 번 호출합니다.
타입별 알고리즘 중복
다음 코드는 String 길이 필터와 Integer 제곱 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("compile-only concrete collection counterexample");
}
}필터의 타입은 T -> boolean, map의 타입은 T -> R입니다.
JDK 표준 인터페이스로 각각 Predicate<? super T>와 Function<? super T, ? extends R>를 받을 수 있습니다.
입력 쪽 super는 T의 상위 타입을 처리하는 기존 함수를 재사용하게 하고 산출물 쪽 extends는 R의 하위 타입을 생산하는 매퍼를 허용합니다.
알고리즘의 제네릭 매개변수는 원본과 함수 사이 타입 관계를 컴파일러에 제공합니다.
원시 Predicate나 Function을 쓰면 구현은 짧아도 형변환 실패가 실행 시점으로 밀립니다.
호출 예에서 타입 추론이 충분하면 명시적 타입 인수를 쓰지 않아도 됩니다.
Predicate·Function 분리
아래 filter()와 map()은 원본 순서를 보존하고 결과를 복사해 반환합니다.
filter()는 원소 참조를 선택할 뿐 깊은 복사하지 않습니다.
원소 자체가 가변이면 결과 목록을 불변으로 만들어도 원소 상태 변화는 보입니다.
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> lengths = map(names, String::length);
System.out.println(affordable);
System.out.println(names);
System.out.println(lengths);
}
}입력 null과 결과 null은 서로 다른 경계
| 조건 | 함수 평가 | 현재 유틸리티의 결과 |
|---|---|---|
| null 입력을 조건식이 제외 | 그 입력에도 predicate 호출 | 선택하지 않으면 copyOf에 전달되지 않음 |
| null 입력을 정상 값으로 변환 | mapper가 null을 받아 대체값 반환 | 결과가 모두 non-null이면 반환 가능 |
| 매퍼 결과 하나가 null | 나머지 매퍼가 정상 반환하면 계속 평가 | 마지막 List.copyOf에서 NullPointerException |
| 조건식·매퍼가 예외를 던짐 | 해당 호출에서 즉시 중단 | 부분 결과 목록은 반환하지 않음 |
- null 입력을 조건식이 제외
- 함수 평가: 그 입력에도 predicate 호출현재 유틸리티의 결과: 선택하지 않으면 copyOf에 전달되지 않음
- null 입력을 정상 값으로 변환
- 함수 평가: mapper가 null을 받아 대체값 반환현재 유틸리티의 결과: 결과가 모두 non-null이면 반환 가능
- 매퍼 결과 하나가 null
- 함수 평가: 나머지 매퍼가 정상 반환하면 계속 평가현재 유틸리티의 결과: 마지막 List.copyOf에서 NullPointerException
- 조건식·매퍼가 예외를 던짐
- 함수 평가: 해당 호출에서 즉시 중단현재 유틸리티의 결과: 부분 결과 목록은 반환하지 않음
GenericFilterMap의 반복문과 결과 확정 위치를 비교했습니다. 원본 List.of 예제에는 null 입력이 없으며 이 표의 null 분기는 별도 실행 관측이 아닙니다.
입력 null까지 일관되게 거절하려면 함수 호출 전 검사가 추가로 필요합니다. 원문에는 이 검사가 없으므로 결과 목록의 null 금지와 입력 정책을 같게 설명하지 않습니다.
모든 원소 오류를 모아야 한다면 매퍼 결과를 Result로 바꾸거나 별도 수집 API를 만듭니다.
호출 계약 검증
함수형 유틸리티의 결과 값만 맞아도 내부 의미가 모두 맞는 것은 아닙니다.
조건식을 두 번 호출하면 부수 효과가 없는 경우 결과는 같지만 비용이 두 배이고 비결정 함수에서는 결과가 달라질 수 있습니다.
처리 순서를 보존해야 UI 목록이나 감사 결과가 예측 가능합니다.
다음 검증 프로그램은 호출 추적과 원본 목록을 함께 출력합니다.
필터와 map이 원본 순서대로 각 원소를 한 번씩 처리하고 원본을 변경하지 않는지 확인합니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
public final class FilterMapContractProbe {
static <T> List<T> filter(List<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<T> source, Function<? super T, ? extends R> mapper) {
List<R> result = new ArrayList<>();
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);
}
}추적을 이용한 예제는 함수를 상태 유지하게 만들었지만 관찰 목적입니다.
운영 필터가 외부 목록을 변경한다면 병렬화, 재실행, 순서 변경에 취약합니다.
판단과 변환은 가능하면 같은 입력에 같은 결과를 내고 외부 상태를 바꾸지 않게 합니다.
즉시 결과와 원본 스냅샷
이 유틸리티는 호출 중 순회하며, 중간에 예외가 발생하지 않으면 전체 입력을 처리합니다.
결과 목록은 그 시점의 선택과 변환 스냅샷입니다.
원본이 이후 변경돼도 결과 구성원은 바뀌지 않습니다.
지연 파이프라인은 최종 연산 전까지 함수를 실행하지 않으며 원본 변경을 언제 관찰할지 다른 규칙이 필요합니다.
큰 목록에서 필터 결과와 map 결과를 각각 만들면 중간 컬렉션 할당이 생깁니다.
먼저 명료한 두 알고리즘의 의미를 익힌 뒤 한 번의 반복문으로 융합하는 방법을 선택할 수 있습니다.
최적화 때문에 필터와 map의 역할 이름까지 지우면 오류 정책과 호출 순서를 설명하기 어려워집니다.
연습 문제
원소와 0부터 시작하는 인덱스를 받는 IndexedMapper<T, R>를 정의하고 mapIndexed()를 작성합니다.
원본 순서를 보존하고 매퍼 결과가 null이면 인덱스가 포함된 오류를 던집니다.
문자열 목록을 "index:value" 형태로 변환해 실행합니다.
해답 보기
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());
for (int index = 0; index < source.size(); index += 1) {
R mapped = mapper.map(index, source.get(index));
result.add(Objects.requireNonNull(mapped, "null result at index " + index));
}
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());
}
}
}해답은 인덱스 생성 책임을 알고리즘에 두고 매퍼가 원본 접근 방식을 알지 않게 합니다.
연결 목록처럼 인덱스 접근이 비싼 원본까지 지원하려면 Iterable 기반 반복문과 별도 카운터가 더 일반적입니다.