[Java] 람다와 스트림

9 분 소요

시작하며…

3주차 과제가 시작하기 전에 좀 더 연습해보기 위해 우테코 지난 프리코스에 진행했던 블랙잭 프로그램을 구현해보기로 했다.
저번 기수는 지하철 노선도 구현이 나왔다고 해서 이번에도 지하철 노선 구현이 나올까봐 블랙잭 프로그램을 연습 과제로 정했다.

먼저 람다와 스트림을 저번 과제 때 적극적으로 사용하지 못해서 몇가지 람다와 스트림에 대한 문제를 해결해보고 연습과제를 시작해보려 한다.

람다

자바 8부터 적용된 기능으로, 람다식이 없던 자바 8 이전버젼에서는 인터페이스의 다형성을 사용하기위해서는
인터페이스를 만든 뒤, 인터페이스에 작성된 메서드를 익명 클래스로 선언해서 오버라이딩 해주어야했다.

하지만 람다식이 나온후로는 이를 간결하게 람다식으로 표현할 수 있게되었다.
그럼 아래 예제를 통해 중복된 코드를 제거하고 다른부분을 인터페이스 선언을 통해 먼저 오버라이딩하여 익명클래스로 사용해주고, 이를 람다식으로 바꿔보자.

익명 클래스를 람다로 전환

  • 다음 테스트 코드에서 MoveStrategy에 대한 익명 클래스로 구현하고 있는데 람다를 적용해 구현한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    
    // nextstep.fp.CarTest의 이동, 정지 method
    public class CarTest {
      @Test
      public void 이동() {
          Car car = new Car("pobi", 0);
          Car actual = car.move(new MoveStrategy() {
              @Override
              public boolean isMovable() {
                  return true;
              }
          });
          assertEquals(new Car("pobi", 1), actual);
      }
    
      @Test
      public void 정지() {
          Car car = new Car("pobi", 0);
          Car actual = car.move(new MoveStrategy() {
              @Override
              public boolean isMovable() {
                  return false;
              }
          });
          assertEquals(new Car("pobi", 0), actual);
      }
    }
    

    인터페이스인 MoveStrategy 에 FunctionalInterface 어노테이션을 선언해주고, 람다식을 사용해서 오버라이딩된
    isMovable() 메서드를 대체해준다.

1
2
3
4
@FunctionalInterface
public interface MoveStrategy {
    boolean isMovable();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public class CarTest {
    @Test
    public void 이동() {
        Car car = new Car("pobi", 0);
        assertThat(car.move(()-> true)).isEqualTo(new Car("pobi", 1));
    }

    @Test
    public void 정지() {
        Car car = new Car("pobi", 0);
        assertThat(car.move(()-> false)).isEqualTo(new Car("pobi", 0));
    }
}

람다식을 사용한 후가 코드의 가독성이나 복잡도가 훨씬 개선된 것을 볼 수 있다.

람다를 활용해 중복 제거

1
2
3
4
5
6
7
8
9
10
// nextstep.fp.Lambda의 sumAll method
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

public int sumAll(List<Integer> numbers) {
    int total = 0;
    for (int number : numbers) {
        total += number;
    }
    return total;
}
1
2
3
4
5
6
7
8
9
10
11
12
// nextstep.fp.Lambda의 sumAllEven method
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

public int sumAllEven(List<Integer> numbers) {
    int total = 0;
    for (int number : numbers) {
        if (number % 2 == 0) {
            total += number;
        }
    }
    return total;
}

List에 담긴 값 중 3보다 큰 수만을 더해야 한다.
이 기능을 구현하려고 보니 앞의 요구사항 1,2와 많은 중복이 발생한다. 람다를 활용해 중복을 제거한다.

// nextstep.fp.Lambda의 sumAll, sumAllEven, sumAllOverThree method 소스 코드를 확인하고 중복 제거한다.

힌트

  • 변경되는 부분과 변경되지 않는 부분의 코드를 분리한다.
  • 변경되는 부분을 인터페이스로 추출한다.
  • 인터페이스에 대한 구현체를 익명 클래스(anonymous class)로 구현해 메소드의 인자로 전달한다.
  • 구글에서 자바의 익명 클래스로 검색해 익명 클래스가 무엇인지 학습한다.
  • 인터페이스는 다음과 같은 형태로 추출할 수 있다.
    1
    2
    3
    
    public interface Conditional {
      boolean test(Integer number);
    }
    
  • Conditional을 활용해 공통 메소드의 구조는 다음과 같다.
    1
    2
    3
    4
    
    public int sumAll(List<Integer> numbers,
      Conditional c) {
      // c.test(number)를 활용해 구현할 수 있다.
    }
    
  • 익명 클래스를 자바 8의 람다를 활용해 구현한다.

우선 sumAll만 남기고 sumAllEven, sumAllOverThree 메서드를 지워주었다.

sumAll의 코드에서 if 문의 조건문을 제외한 코드가 중복되므로, if문의 조건문을 지워준다.

1
2
3
4
5
6
7
8
9
 public static int sumAll(List<Integer> numbers, Conditional conditional) {
        int total = 0;
        for (int number : numbers) {
            if(){
                total += number;
            }
        }
        return total;
    }

이제 Conditional 이라는 인터페이스를 선언해주고, 인터페이스에서 test라는 boolean을 반환하는 메서드를 하나 선언한다.

1
2
3
4
@FunctionalInterface
public interface Conditional {
    boolean test(Integer number);
}

그리고 if문의 조건문을 Conditional 클래스의 test메서드로 채워준다.

1
2
3
4
5
6
7
8
9
 public static int sumAll(List<Integer> numbers, Conditional conditional) {
        int total = 0;
        for (int number : numbers) {
            if(Conditonal.test(number){
                total += number;
            }
        }
        return total;
    }

이제 lambdaTest 코드로 이동해서 sumAll메서드 하나로 기존의 sumEvenAll과 sumAllOverThree 메서드가 있던
테스트코드를 모두 통과하도록 익명 클래스를 작성해주자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Test
public void sumAll() throws Exception {
    int sum = Lambda.sumAll(numbers, new Conditional() {
        @Override
        public boolean test(Integer number) {
            return true;
        }
    });
    assertThat(sum).isEqualTo(21);
}

@Test
public void sumAllEven() throws Exception {
    int sum = Lambda.sumAll(numbers, new Conditional() {
        @Override
        public boolean test(Integer number) {
            return number % 2 == 0;
        }
    });
    assertThat(sum).isEqualTo(12);
}

@Test
public void sumAllOverThree() throws Exception {
    int sum = Lambda.sumAll(numbers, new Conditional() {
        @Override
        public boolean test(Integer number) {
            return number > 3;
        }
    });
    assertThat(sum).isEqualTo(15);
}

이를 간소화 하여 람다식으로 표현해주면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void sumAll() throws Exception {
    int sum = Lambda.sumAll(numbers, (Integer number) -> true);
    assertThat(sum).isEqualTo(21);
}

@Test
public void sumAllEven() throws Exception {
    int sum = Lambda.sumAll(numbers, (Integer number) -> number % 2 == 0);
    assertThat(sum).isEqualTo(12);
}

@Test
public void sumAllOverThree() throws Exception {
    int sum = Lambda.sumAll(numbers, (Integer number) -> number > 3);2
    assertThat(sum).isEqualTo(15);
}

익명 클래스를 사용했을때에 비해서 코드량도 훨씬 줄었을 뿐더러 가독성도 월등하게 좋아진 것을 확인할 수 있다.

스트림

스트림도 람다와 함께 마찬가지로 자바8에서부터 추가된 기능으로, 스트림은 배열 또는 컬렉션 인스턴스를 더 간결하고, 유연하게 표현할 수록 도와주는 기능이다.

이름 그대로 데이터의 흐름에 관한 기능이며, 기존에는 배열이나 컬렉션을 다룰 때 for문 또는 foreach문으로 작성한 데이터 하나하나에 일일히
접근하면서 처리를 해주어야 했다면, 스트림을 사용하면 훨씬 더 간결하고 가독성있게 배열 또는 컬렉션 인스턴스를 조합하여 결과를 필터링하고 가공할 수 있다.
즉, 배열과 컬렉션을 함수형으로 처리할 수 있게 된다.

또, 병렬처리가 가능하여 하나의 작업을 여러개로 잘게 나눠서 처리가 가능하다는 장점이있다.

스트림을 사용하는 방법은 크게 세 단계로 나눌 수 있는데,

생성하기 : 스트림 인스턴스 생성.
가공하기 : 필터링(filtering) 및 맵핑(mapping) 등 원하는 결과를 만들어가는 중간 작업(intermediate operations).
결과 만들기 : 최종적으로 결과를 만들어내는 작업(terminal operations).

이 순서대로 원하는 처리를 원하는 메서드를 나열해주면 된다.

스트림을 사용하기 위해선 배열이나 컬렉션을 스트림 자료형으로 변환해주어야 한다.

만약 String 배열 형태인 {“a”,”b”,”c”}로 이루어진 arr배열이 있다면 다음과 같이 스트림으로 변환해 줄 수 있다.

1
2
3
4
String[] arr = new String[]{"a", "b", "c"};
Stream<String> stream = Arrays.stream(arr);
Stream<String> streamOfArrayPart = 
  Arrays.stream(arr, 1, 3); // 1~2 요소 [b, c]

컬렉션 타입 (List,Set,Collection)의 경우는 뒤에 .stream() 메서드를 사용함으로써 스트림으로 변환해 줄 수 있다.

1
2
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();

이렇게 만들어준 스트림을 가공해주는데, 가장 많이 사용하는 메서드의 예제를 한번 보도록 하자.

map, filter, reduce

filter

1
2
3
4
5
6
7
8
9
10
// nextstep.fp.StreamStudy countWords method

String contents = new String(Files.readAllBytes(
  Paths.get("../ war-and-peace.txt")), StandardCharsets.UTF_8);
List<String> words = Arrays.asList(contents.split("[\\P{L}]+"));

long count = 0;
for (String w : words) {
  if (w.length() > 12) count++;  
}

이 코드는 resource.txt 파일에 있는 문자 중 길이가 12자리가 넘는 문자의 수를 구한다.

words를 for문으로 하나씩 접근해 count를 1씩 증가시키고 있는데, 이를 스트림으로 변환하여 filter메서드를 사용하면 더 간결하게 나타낼 수 있다.

1
2
3
4
5
6
String contents = new String(Files.readAllBytes(
  Paths.get("../alice.txt")), StandardCharsets.UTF_8);
List<String> words = Arrays.asList(contents.split("[\\P{L}]+"));

long count = 
  words.stream().filter(w -> w.length() > 12).count();

이처럼 filter는 람다식을 매개변수로 받아 조건을 충족하는 요소만을 반환해준다.
이렇게 반환한 요소들에 count메서드를 사용해주면 해당 조건을 충족하는 요소의 개수를 알 수 있다.

map

1
2
3
4
5
6
// nextstep.fp.StreamStudy 클래스의 doubleNumbers method 참고
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> doubleNumbers = ArrayList<Integer>();
for (int number : numbers) {
    doubleNumbers.add(number * 2);
}

위 코드는 (1,2,3,4,5,6)으로 이루어진 numbers 리스트의 모든 요소를 두배한 새로운 리스트를 만드는 코드이다.
이 코드또한 스트림을 사용하여 간결하게 만들어줄 수 있다.

요소의 형태를 변환하는 스트림 메서드로는 map을 사용해준다.

1
2
3
4
// nextstep.fp.StreamStudy 클래스의 doubleNumbers method 참고
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> dobuleNumbers =
  numbers.stream().map(x -> 2 * x).collect(Collectors.toList());

map()은 메서드로 람다식을 받아 요소들의 형태를 변환시켜준다. 이렇게 변환한 요소들을 리스트로 받고 싶다면,
collect() 메서드를 선언하고 매개변수로 원하는 형태로 변환하는 함수를 넣어주면 된다.

reduce

1
2
3
4
5
6
7
8
9
10
11
// nextstep.fp.StreamStudy 클래스의 sumAll method 참고

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

  public static int sumAll(List<Integer> numbers) {
        int total = 0;
        for (int number : numbers) {
            total += number;
        }
        return total;
    }

위 메서드는 리스트의 모든값을 더해서 total로 반환한다. 이렇게 리스트의 모든값에 대한 연산을 해서 하나의 값으로 출력할때는 스트림의 reduce() 메서드를 사용하면
간편하게 값을 구할 수 있다.

1
2
3
4
5
6
7
// nextstep.fp.StreamStudy 클래스의 sumAll method 참고

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

public int sumAll(List<Integer> numbers) {
    return numbers.stream().reduce(0, (x, y) -> x + y);
}

reduce() 메서드는 param으로 (total, n) -> total + n가 전달되고, reduce는 a1, a2, a3, a4…의 stream을 a1 + a2 + a3 + a4…로 연산을 수행한다.
첫번째로 total=a1, n=a2가 되고, 두번째로 total=(a1+a2), n=a3가 되어 초깃값인 첫번째 파라미터를 total로 받아 모든 배열의 수를 초깃값에 차례 차례 더해서 숫자 하나로 반환한다.

초깃값이 0이라면, 다음과 같이 초깃값을 생략하여 표현할 수 도 있다.

1
Optional<Integer> numbers = numbers.reduce(Integer::sum);

map, reduce, filter 실습 1

List에 담긴 모든 숫자 중 3보다 큰 숫자를 2배 한 후 모든 값의 합을 구한다. 지금까지 학습한 map, reduce, filter를 활용해 구현해야 한다.

  • nextstep.fp.StreamStudyTest 클래스의 sumOverThreeAndDouble() 테스트를 pass해야 한다.
1
2
3
4
5
6
@Test
public void sumOverThreeAndDouble() throws Exception {
    numbers = Arrays.asList(3, 1, 6, 2, 4, 8);
    long sum = StreamStudy.sumOverThreeAndDouble(numbers);
    assertThat(sum).isEqualTo(36);
}

filter를 사용해서 먼저 3보다 큰 숫자를 추출하고, 추출한 숫자들을 map을 사용해서 2배로 만들어준다. 그 다음 reduce를 사용해서 모든 숫자를 더해 반환하면 된다.

1
2
3
4
5
6
@Test
public void sumOverThreeAndDouble() throws Exception {
    numbers = Arrays.asList(3, 1, 6, 2, 4, 8);
    Optional<Integer> sum = numbers.stream().filter(x -> x > 3).map(x -> 2 * x).reduce(Integer :: sum);
    assertThat(sum.orElse(0)).isEqualTo(36);
}

reduce는 Optional로 반환되는데, 여기서 Optional의 개념이 등장한다.
아이폰 앱개발을 할 때 사용하는 언어인 스위프트에서는 거의 모든 자료형을 Optional을 염두에 두고 선언했어야 됐어서 앱개발을 했었던 나로써는 익숙한 개념인데,
이 값은 NullpointerException문제를 해결하기 위한 자료형으로 값을 넣으면 래핑된 상태로 보존된다.

Optional값은 래핑 되어있기 때문에 사용하기 위해서는 ofElse혹은 ofElseGet을 사용해서 Null값이 들어왔을때 넣어줄 기본값을 넣어주어야 래핑이 해제된다.
자바에서는 orElse 혹은 orElseGet을 사용하여 기본값을 지정해준다.

따라서 Optional을 사용하게 되면 if를 통한 Null값에 대한 유효성 검사에 대한 코드를 더 간결하게 표현 할 수 있다.

map, reduce, filter 실습 2

nextstep.fp.StreamStudy 클래스의 printLongestWordTop100() 메서드를 구현한다. 요구사항은 다음과 같다.

1
2
3
4
5
6
7
public static void printLongestWordTop100() throws IOException {
        String contents = new String(Files.readAllBytes(Paths
                .get("src/main/resources/fp/war-and-peace.txt")), StandardCharsets.UTF_8);
        List<String> words = Arrays.asList(contents.split("[\\P{L}]+"));

        // TODO 이 부분에 구현한다.
    }
  • 단어의 길이가 12자를 초과하는 단어를 추출한다.
  • 12자가 넘는 단어 중 길이가 긴 순서로 100개의 단어를 추출한다.
  • 단어 중복을 허용하지 않는다. 즉, 서로 다른 단어 100개를 추출해야 한다.
  • 추출한 100개의 단어를 출력한다. 모든 단어는 소문자로 출력해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public static void printLongestWordTop100() throws IOException {
    String contents = new String(Files.readAllBytes(Paths
            .get("src/main/resources/fp/war-and-peace.txt")), StandardCharsets.UTF_8);
    List<String> words = Arrays.asList(contents.split("[\\P{L}]+"));

    // TODO 이 부분에 구현한다.
    words.stream()
            .filter(x -> x.length() > 12)
            .sorted()
            .distinct()
            .map(x -> x.toLowerCase())
            .forEach(System.out :: println);
}

아주 간편하게 for문으로 로직을 짤 필요없이 요구사항 순서대로 알맞는 메서드만 입력해주면
원하는 값을 얻을 수 있으면 매우 가독성이 높아 읽기 편한 걸 알 수 있다.

카테고리:

업데이트:

댓글남기기