03.스트림과 람다를 이용한 효과적 프로그래밍

컬렉션 API 개선

컬렉션 팩토리

반환 객체는 불변

List Factory

//Before
List<String> friends = Arrays.asList("Park", "Kim", "Jeong");

//After
List<String> friends = List.of("Park", "Kim", "Jeong")

Set Factory

Set<String> friends = Set.of("Park", "Kim", "Jeong");

Map Factory

// 열 개 이하의 키/값 쌍을 가진 작은 맵을 만들 경우
Map<String, Integer> ageOfFriends = Map.of("Park", 20, "Kim", 21, "Jeong", 25);

// 그 이상의 맵 생성 (Map.enty: Map.Entry 객체를 만드는 새로운 팩토리 메서드)
import static java.util.Map.entry;
Map<String, String> ageOfFriends = Map.ofEntries(
        entry("Park", 20),
        entry("Kim", 21),
        entry("Jeong", 25));

리스트와 집합 처리

기존 컬렉션 객체와 Iterator 객체를 혼용한 삭제, 수정은 쉽게 문제를 일으켰었다.

그래서, java8에서는 List, Set Interface 에 새로운 메서드가 추가되었다. (기존 컬렉션 자체를 수정)

removeIf

  • Predicate를 만족하는 요소 제거 (List, Set)

transactions.removeIf(transaction ->
                     Character.isDigit(transaction.getReferenceCode().charAt(0)));

replaceAll

  • UnaryOperator 함수를 이용하여 요소 교체 (List)

referenceCodes.replaceAll(code -> Character.toUpperCase(code.charAt(0)) + code.substring(1));

sort

  • 리스트 정렬 (List)

list.sort(Comparator.naturalOrder()); // 오름차순
list.sort(Comparator.reverseOrder());
list.sort(String.CASE_INSENSITIVE_ORDER); // 대소문자 구분없이 오름차순
list.sort(Collections.reverseOrder(String.CASE_INSENSITIVE_ORDER));

맵 처리

1. forEach

  • 기존 맵에서 키/값을 반복하며 확인했다면, forEach 메서드를 사용해보자.

ageOfFriens.forEach((friend, age) -> System.out.println(friens + " is" + age + " years old"));

2. Order

  • Entry.comparingByValue / Entry.comparingByKey

favouriteMovies.entrySet().stream()
    .sorted(Entry.comparingByKey()) // 오름차순
//    .sorted(Entry.comparingByKey(Comparator.reverseOrder())) // 내림차순
    .forEachOrdered(System.out::println);

3. getOrDefault

  • 찾으려는 키가 존재하지 않으면 기본값을 반환 getOrDefault(key, default)

Map<String, String> favouriteMovies = Map.ofEntries(
                                        entry("Raphael", "Star Wars"),
                                        entry("Olivia", "James Bond"));

System.out.println(favouriteMovies.getOrDefault("Olivia", "Matrix")); // James Bond
System.out.println(favouriteMovies.getOrDefault("Thibaut", "Matrix")); // Matrix

4. Compute Pattern

computeIfAbsent

  • 제공된 키에 해당하는 값이 없으면(값이 없거나 널), 키를 이용해 새 값을 계산하고 맵에 추가

    • 현재 키와 관련된 값이 맵에 존재하며 널이 아닐 때만 새 값을 계산

Map<String, byte[]> dataToHash = new HashMap<>();
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");

lines.forEach(line ->
        dataToHash.computeIfAbsent(line, // 맵에서 찾을 키
                                   this::calculateDigest)); // 키가 존재하지 않을 경우 동작

private byte[] calculateDigest(String key) {
    return messageDigest.digest(key.getBytes(StandardCharsets.UTF_8));
}

// Map이 여러 값을 저장하는 형태일 경우
friendsToMovies.computeIfAbsent("Raphael", name -> new ArrayList<>())
    						.add("Star Wars");

computeIfPresent

  • 제공된 키가 존재하면 새 값을 계산하고 맵에 추가

compute

  • 제공된 키로 새 값을 계산하고 맵에 저장

5. Remove Pattern

//제공된 키에 해당하는 맵 항목 제거
favouriteMovies.remove(key)
    
//키가 특정한 값과 연관되어 있을 경우에만 항목을 제거하는 오버로드 버전 메서드
favouriteMovies.remove(key, value);

//특정 조건에 해당하는 항목 삭제
favouriteMovies.entrySet().removeIf(entry -> entry.getValue < 10);

6. Replace Pattern

replaceAll

  • BiFunction 을 적용한 결과로 각 항목의 값을 교체

Map<String, String> favouriteMovies = new HashMap<>();
favouriteMovies.put("Raphael", "Star Wars");
favouriteMovies.put("Olivia", "james bond");
favouriteMovies.replaceAll((friend, movie) -> movie.toUpperCase());

Replace

  • 키가 존재하면 맵의 값 교체

7. Merge

두 개의 맵에서 값을 합치거나 교체할 경우 사용

  • 중복된 키가 없을 경우

Map<String, String> family = Map.ofEntries(
        entry("Teo", "Star Wars"), entry("Cristina", "James Bond"));
Map<String, String> friends = Map.ofEntries(
    	entry("Raphael", "Star Wars"));

Map<String, String> everyone = new HashMap<>(family);
everyone.putAll(friends);
  • 중복된 키가 있을 경우

Map<String, String> family = Map.ofEntries(
        entry("Teo", "Star Wars"), entry("Cristina", "James Bond"));
Map<String, String> friends = Map.ofEntries(
    	entry("Raphael", "Star Wars"), entry("Cristina", "Matrix"));

Map<String, String> everyone = new HashMap<>(family);
friends.forEach((k, v) -> // forEach + merge 로 key 충돌 해결
                everyone.merge(k, v, (movie1, movie2) -> movie1 + " & " + movie2));
  • 초기 검사 구현이 필요할 경우

    • 지정된 키와 연관된 값이 없거나 널이면, 두 번째 인수를 키와 연결

    • 그렇지 않을 경우, 세 번째 인자의 BiFunction을 적용

moviesToCount.merge(movieName, 1L, (key, count) -> count + 1L);

ConcurrentHashMap

ConcurrentHashMap 클래스는 동시 친화적이며 내부 자료구조의 특정 부분만 잠궈 동시 추가, 갱신 작업을 허용

forEach

  • 각 (키/값) 쌍에 주어진 액션 실행

reduce

  • 모든 (키/값) 쌍을 제공된 리듀스 함수를 이용해 결과로 합침

search

  • 널이 아닌 값을 반환할 떄까지 각 (키/값) 쌍에 함수 적용

(키/값) 인수를 이용한 네 가지 연산 형태 지원

ConcurrentHashMap 상태를 잠그지 않고 연산을 수행하므로, 연산에 제공한 함수는 계산이 진행되는 동안 바뀔 수 있는 객체, 값, 순서 등에 의존하면 안 됨!

  • 키/값으로 연산 : forEach, reduce, search

  • 키로 연산 : forEachKey, reduceKeys, searchKeys

  • 값으로 연산 : forEachValue, reduceValues, searchValues

  • Map.entry 객체로 연산 : forEachEntry, reduceEntries, searchEntriess

Count

mappingCount

  • 맵의 매핑 개수를 반환 (매핑 개수가 int의 범위를 넘어서는 이후의 상황 대처)

리팩터링, 테스팅, 디버깅

리팩터링

코드 가독성 개선

코드 가독성이 좋다?란 '어떤 코드를 다른 사람도 쉽게 이해할 수 있음'을 의미한다.

1. 익명 클래스를 람다 표현식으로 리팩터링하기

  • 익명 클래스에서 사용한 this와 super는 람다 표현식에서 다른 의미를 갖는다.

    • 익명 클래스에서 this는 익명 클래스 자신

    • 람다에서 this는 람다를 감싸는 클래스

  • 익명 클래스는 감싸고 있는 클래스의 변수를 가릴 수 있다.

    • 람다 표현식으로는 불가

/*
 * 익명 클래스 사용
 */
Runnable r1 = new Runnable() {
    public void run() {
        System.out.println("Hello");
    }
};

/*
 * 람다 표현식 사용
 */
Runnable r2 = () -> System.out.println("Hello");

interface Task {
    public void execute();
}
public static void doSomething(Runnable r) { r.run(); }
public static void doSomething(Task a) { r.execute(); }
// 람다 표현식의 대상 형식이 모호할 경우 명시적 형변환을 이용
doSomething((Task)() -> System.out.println("Hello"));
doSomething((Runnable)() -> System.out.println("Hello"));

2. 람다 표현식을 메서드 참조로 리팩터링하기

/*
 * 람다 표현식 사용
 */
Map<CaloricLevel, List<Dish> dishesByCaloricLevel = 
    menu.stream().collect(
            groupingBy(dish -> {
              if (dish.getCalories() <= 400) { return CaloricLevel.DIET; }
              else if (dish.getCalories() <= 700) { return CaloricLevel.NORMAL; }
              else { return CaloricLevel.FAT; }
    }));

/*
 * 메서드 참조 사용
 */
public class Dish {
    //...
    
    public CaloricLevel getCaloricLevel() {
        if (dish.getCalories() <= 400) { return CaloricLevel.DIET; }
        else if (dish.getCalories() <= 700) { return CaloricLevel.NORMAL; }
        else { return CaloricLevel.FAT; }
    }
}

Map<CaloricLevel, List<Dish>< dishesByCaloricLevel = 
    menu.stream().collect(groupingBy(Dish::getCaloricLevel));
// 람다 표현식 사용
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
// 정적 헬퍼 메서드(comparing, maxBy) 참조 사용
inventory.sort(comparing(Apple::getWeight));

// 람다 표현식 사용
int totalCalories = menu.stream().map(Dish::getCalories).reduce(0, (c1, c2) -> c1 + c2);
// 내장 헬퍼 메서드(sum, maximum) 참조 사용
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));

3. 명령형 데이터 처리를 스트림으로 리팩터링하기

스트림 API로 데이터 처리 파이프라인의 의도를 더 명확하게 보여주자.

/*
 * 명령형 코드
 */
List<String> dishNames = new ArrayList<>();
for(Dish dish: menu) {
    if(dish.getCalories() > 300) {
        dishNames.add(dish.getName());
    }
}

/*
 * 스트림 API
 */
menu.parallelStream()
    .filter(d -> d.getCalories() > 300)
    .map(Dish::getName)
    .collect(toList());

4. 코드 유연성 개선

람다 표현식을 이용해서 동작 파라미터화를 쉽게 구현해보자.

  • 함수형 인터페이스 적용

    • 람다 표현식을 이용하기 위해 함수형 인터페이스를 추가

  • 조건부 연기 실행

    // Before : 복잡한 제어 흐름 코드 (상태 노출 및 매번 상태 체크)
    if (logger.isLoggable(Log.FINER)) {
        logger.finer("Problem: " + generateDiagnostic());
    }
    // After : 가독성과 캡슐화 강화
    public void log(Level level, Supplier<String> msgSupplier) {
        if(logger.isLoggable(level)) {
            log(level, msgSupplier.get());
        }
    }
    
    logger.log(Level.FINER, () -> "Problem: " + generateDiagnostic());
  • 실행 어라운드

    • 준비, 종료 과정을 처리하는 로직을 재사용하여 코드 중복 줄이기

    String oneLine = processFile((BufferedReader b) -> b.readLine()); // 람다 전달
    String twoLines = processFile((BufferedReader b) -> b.readLine() + b.readLine()); // 다른 람다 전달
    
    public static String processFile(BufferedReaderProcessor p) throws IOException {
        try (BufferedReader br = new BufferedReader(new FileReader("Aaron/MJIA/test.txt"))) {
            return p.process(br); // 인수로 전달된 BufferedReaderProcessor 실행
        }
    } // IOException을 던질 수 있는 람다의 함수형 인터페이스
    
    public interface BufferedReaderProcessor {
        String process(BufferedReader b) throws IOException;
    }

람다 테스팅

Example

public class Point {
    private final int x;
    private final int y;
    public final static Comparator<Point> compareByXAndThenY = 
        comparing(Point::getX).thenComparing(Point::getY);
    
    private Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    public int getX() { return x; }
    public int getY() { return y; }
    public Point moveRightBy(int x) {
        return new Point(this.x + x, this.y);
    }
}
@Test
public void testMoveRightBy() throws Exception {
    Point p1 = new Point(5, 5);
	Point p2 = p1.moveRightBy(10);
    assertEquals(15, p2.getX());
    assertEquals(5, p2.getY());
}

1. 보이는 람다 표현식의 동작 테스팅

  • 람다는 익명이므로 테스트 코드 이름을 호출할 수 없다.

  • 필요 시 람다를 필드에 저장해서 재사용하거나, 람다 로직 테스트가 가능하다.

  • 람다 표현식은 함수형 인터페이스의 인스턴스를 생성하므로 인스턴스의 동작으로 람다 표현식을 테스트할 수 있다

public class Point {
	//...
    public final static Comparator<Point> compareByXAndThenY = 
        comparing(Point::getX).thenComparing(Point::getY);
	//...
}
@Test
public void testComparingTwoPoints() throws Exception {
    Point p1 = new Point(10, 15);
    Point p2 = new Point(10, 25);
    int result = Point.compareByXAndThenY.compare(p1, p2);
    assertTrue(result < 0);
}

2. 람다를 사용하는 메서드의 동작에 집중하자

  • 람다의 목표는 정해진 동작을 다른 메서드에서 사용할 수 있도록, 하나의 조각으로 캡슐화 하는 것

  • 세부 구현을 포함하는 람다 표현식을 공개하지 말아야 한다.

public static List<Point> moveAllPointsRightBy(List<Point> points, int x) {
    return points.stream()
        		.map(p -> new Point(p.getX() + x, p.getY()))
        		.collect(toList());
}
@Test
public void testMoveAllPointsRightBy() throws Exception {
    List<Point> points = Arrays.asList(new Point(5, 5), new Point(10, 5));
	List<Point> expectedPoints = Arrays.asList(new Point(15, 5), new Point(20, 5));
    List<Point> newPoints = Point.moveAllPointsRightBy(points, 10);
    assertEquals(expectedPoints, newPoints);
}

3. 복잡한 람다를 개별 메서드로 분할하기

  • 많은 로직을 포함하는 복잡한 람다 표현식의 경우, 람다 표현식을 메서드 참조로 바꾸자.

// 람다 표현식을 메서드 참조로 바꾼 예시
public class Dish {
    //...
    public CaloricLevel getCaloricLevel() {
        if (dish.getCalories() <= 400) { return CaloricLevel.DIET; }
        else if (dish.getCalories() <= 700) { return CaloricLevel.NORMAL; }
        else { return CaloricLevel.FAT; }
    }
}

Map<CaloricLevel, List<Dish> dishesByCaloricLevel = 
    menu.stream().collect(groupingBy(Dish::getCaloricLevel));

4. 고차원 함수 테스팅

  • 메서드가 함수를 인수로 받을 경우, 다른 람다로 메서드 동작을 테스트

    @Test
    public void testFilter() throws Exception {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
        List<Integer> even = filter(numbers, i -> i % 2 == 0);
        List<Integer> smallerThanThree = filter(numbers, i -> i < 3);
        assertEquals(Arrays.asList(2, 4), even);
        assertEquals(Arrays.asList(1, 2), smallerThanThree);
    }
  • 다른 함수를 반환하는 메서드의 경우, 함수형 인터페이스의 인스턴스로 간주하고 함수의 동작을 테스트

디버깅

1. 스택 트레이스 확인하기

  • 스택 트레이스를 통해 프로그램이 어디서, 어떻게 멈추게 되었는지 살펴보자.

  • 다만, 람다 표현식은 이름이 없어서 복잡한 스택 트레이스가 생성된다.

    • 메서드 참조를 사용해도 스택 트레이스에서는 메서드명이 나타나지 않는다.

  • 따라서, 람다 표현식과 관련한 스택 트레이스는 이해하기 어려울 수 있다.

2. 정보 로깅

  • peek 스트림 연산을 활용하여 스트림 파이프라인에 적용된 각 연산이 어떤 결과를 도출하는지 확인할 수 있다.

List<Integer> result = 
    Stream.of(2, 3, 4, 5) //or numbers.stream()
        .peek(x -> System.out.println("from stream: " + x)) // 소스에서 처음 소비한 요소
        .map(x -> x + 17)
        .peek(x -> System.out.println("after map: " + x)) // map 동작 실행 결과
        .filter(x -> x % 2 == 0)
        .peek(x -> System.out.println("after filter: " + x)) // filter 동작 후 선택된 숫자
        .limit(3)
        .peek(x -> System.out.println("after limit: " + x)) // limit 동작 후 선택된 숫자
        .collect(toList());

Last updated