C++에서 배우는 자바-람다와 스트림

서문

람다와 스트림 파트를 읽어보며 느낀 점은 일단 너무 방대하다는 것이다.
자료형이 기본형인지 wrapper 클래스인지에 따라, 매개변수와 반환형의 존재여부에 따라, 매개변수의 개수에 따라 너무 다양하게 파생이 되기 때문이었다.
이러한 세세한 부분들은 나중에 필요할 때 충분히 찾아서 사용할 수 있다고 판단했기에 본 포스팅에서는 지나치게 세세하다고 느껴지는 부분을 제외하고 개념 위주로 정리해 볼 예정이다.

람다식

C++에서 간단한 함수 하나를 새로 작성하기 위해서는 바로 함수 하나를 작성하여 추가해주면 된다.
하지만 모든 메서드가 클래스 내에 있어야하는 JAVA의 특성 상 간단한 함수 하나를 추가로 작성해주려면 클래스를 만들고, 내부에 메서드를 작성한 후 클래스의 인스턴스까지 만들어주어야 한다.
우리는 이러한 복잡한 과정 대신 람다식을 사용할 수 있다. 람다식은 메서드를 수식처럼 나타낸 것이다. 람다식은 익명 함수라고도 불리는데 사용 방법은 다음과 같다.

  
반환타입 메서드이름(매개변수) {
    문장들
}  
 ||
(매개변수) -> {
    문장들
}

위와 같이 반환타입과 이름을 제거하고 몸통과 매개변수 사이에 화살표를 추가한 것이 람다식이다. 매개변수의 자료형은 대부분의 경우 추론이 가능하므로 생략이 가능하다. 실 사용례는 다음과 같다.

  
void printVar(String name, int i) {
    System.out.println(name+"="+i);
}
||
(name,i) -> {
    System.out.println(name+"="+i);
}

함수형 인터페이스

람다식은 기본적으로 익명 클래스의 객체와 동등하다.

  
(name,i) -> {
    System.out.println(name+"="+i);
}
||
new Object() {
    void print(String name, int i) {
        System.out.println(name+"="+i);
    }
}

따라서 참조변수가 있어야 객체의 메서드를 사용이 가능하다. 지금까지 배워온 자바의 규칙에 따르면 해당 참조변수는 동일한 메서드가 정의되어 있는 클래스 또는 인터페이스여야 한다. 따라서 위와 같은 람다식을 사용하기 위한 인터페이스를 하나 정의해보면

  
interface MyFunc {
    public abstract void print(String name, int i);
}
MyFunc f = (name,i) -> {
    System.out.println(name+"="+i);
}
f.print("me",1);

이러한 MyFunc 인터페이스를 구현한 익명 객체 대신 람다식을 사용할 수 있는 것은 람다식도 실제로는 익명 객체이고, 인터페이스의 메서드 print와 람다식의 매개변수 타입과 개수, 반환값이 일치하기 때문이다.
이렇게 람다식을 사용하기 위해 정의된 인터페이스를 함수형 인터페이스라고 부르기로 하였다. 함수형 인터페이스에는 람다식과 1:1로 매칭이 가능하도록 하나의 추상 메서드만이 정의되어있어야 한다는 제약이 있다.
이러한 함수형 인터페이스의 사용례를 하나 더 보고가자.

  
interface MyFunc {
    public abstract void print(String name, int i);
}
void amethod(MyFunc f) {
    f.print("me",1);
}
amethod((name,i)->System.out.println(name+"="+i));

위와 같이 참조변수 없이 람다식을 그대로 매개변수로 사용하는 것 또한 가능하다. 익명 객체를 생성하는 방법과 비교하면 코드가 많이 간결해진 것을 볼 수 있다.
람다식은 분명 익명 객체이지만, Object 타입으로의 형변환이 불가능하다. 오직 함수형 인터페이스로만 형 변환이 가능하다는 점에 주의해야 한다.

람다식과 외부 변수

람다식은 익명 클래스와 마찬가지로 외부 변수에 접근할 수 있다.
하지만 주의해야할 점이 2가지 있는데 첫 번째는 람다식이 참조한 외부 변수는 상수가 된다는 것이다. 따라서 다른 곳에서 람다식에서 접근한 변수를 수정하는 것은 에러를 발생시킨다.
두 번째는 외부 지역변수와 같은 이름의 람다식 매개변수는 허용하지 않는다는 것이다. 이 또한 에러를 발생시킨다.

java.util.function 패키지

대개의 메서드는 형식이 비슷하다. 반환값이 있거나 없고, 매개변수가 없거나 한두개 정도이다. 따라서 java.util.function 패키지에서 자주 쓰이는 형식의 메서드를 함수형 인터페이스로 미리 정의해놓았다. 매번 함수형 인터페이스를 새로 정의하는 대신 이 패키지의 인터페이스를 활용하는 것이 좋을 것이다.
자주 쓰이는 함수형 인터페이스는 다음과 같다.

  • java.lang.Runnable (void run()) : 매개변수와 반환값이 없음
  • Supplier (T get()) : 매개변수는 없고, 반환값만 있음
  • Consumer (void accept(T t)) : 매개변수만 있고, 반환값이 없음
  • Function<T,R> (R apply(T t)) : 매개변수와 반환값 둘 다 있음
  • Predicate (boolean test(T t)) : 조건식의 표현에 사용, 매개변수 하나, 반환은 boolean

매개변수가 2개일 때에는 각 인터페이스 앞에 Bi를 붙여 BiConsumer<T,U>와 같이 사용한다.
컬렉션 프레임워크의 디폴트 메서드 중 이러한 함수형 인터페이스를 사용하는 것이 여럿 추가되었으니 알아두면 유용하게 사용할 수 있을 것이다. 예를 들어 모든 요소에 action을 수행하는 Iterable의 void foreach(Consumer action) 등이 있다.

기본형을 사용하는 인터페이스

위에서 배운 함수형 인터페이스의 매개변수와 반환값은 모두 지네릭 타입임을 볼 수 있다. 따라서 기본형을 사용하더라도 wrapper 클래스로 처리되는데 알다시피 wrapper 클래스를 기본형 대신 사용하는 것은 박싱 과정이 있기에 비효율적이다. 따라서 기본형을 사용하도록 정의된 인터페이스도 있는데, 형식을 알아두고 자료형을 대입만 하면 된다. 형식은 다음과 같다.

  • AtoBFunction : 입력이 A타입 출력이 B타입
  • ToAFunction : 입력은 지네릭타입, 출력은 A타입
  • AFunction : 입력이 A타입, 출력은 지네릭 타입
  • objAFunction : 입력이 T, A타입 출력은 없다.

Function의 합성과 Predicate의 결합

두 함수를 결합하여 새로운 함수를 만들 수 있는 것처럼 Function 또한 그러한 합성이 가능하다. 함수의 적용 순서에 따라 결합하는 방식이 다르다.

  • andThen() : 매개변수를 나중에 적용
  • compose() : 매개변수를 먼저 적용

예시를 하나 들어보자.

  
Function<String, Integer> f=(s)->Integer.parseInt(s,16);
Function<Integer,String> g=(i)->Integer.toBinaryString(i);
Function<String,String> h=f.andThen(g);

문자열을 숫자로 변환하는 함수 f와 숫자를 2진 문자열로 변환하는 함수 g를 합성하여 새로운 함수 h를 만들어 냈다.

Predicate 또한 일반 조건식처럼 not, or 등을 사용한 합성이 가능하다. 이것도 예시를 한번 들어보자.

  
Predicate<Integer> p=i->i<100;
Predicate<Integer> q=i->i>200;
Predicate<Integer> h=p.negate();
Predicate<Integer> c=p.or(q);

위와 같이 합성하여 여러 새로운 Predicate 조건식을 생성해낼 수 있다.

메서드 참조

람다식이 하나의 메서드만을 호출하는 경우에는 메서드 참조를 활용하여 코드를 더욱 간결하게 할 수 있다. 예시는 다음과 같다.

  
Function<String,Integer> f=(String s)->Integer.parseInt(s);
||
Function<String,Integer> f=Integer::parseInt;

위와 같이 화살표와 매개변수를 다 떼고 클래스 이름과 메서드만 남겨 사용할 수 있다.

스트림

java에서는 Collection 프레임워크를 기준으로 상속을 하며 최대한 클래스들을 다루는 방식을 표준화하긴 했지만, 그럼에도 Collection 프레임워크 내부의 list와 배열은 비슷하지만 사용방식이 다르다.
이러한 문제를 해결하기 위해 나온 것이 스트림이다. 스트림은 데이터 소스를 다루는 방식을 추상화하여 데이터 소스가 무엇이던 같은 방식으로 다룰 수 있도록 하였고, 데이터를 다루는데 자주 사용되는 메서드를 정의해 놓았다.
스트림의 특징은 다음과 같다.

  • 스트림은 데이터소스를 변경하지 않는다.
  • 스트림은 일회용이다.
  • 스트림은 작업을 내부 반복으로 처리한다.

스트림은 데이터 소스로부터 데이터를 읽어올 뿐, 스트림 내에서 정렬 등을 수행해도 데이터 소스는 수정되지 않는다. 원한다면 스트림을 다시 컬렉션으로 변환하여 반환할 수도 있다.

스트림은 iterator와 같이 일회용이다. 최종연산을 마치고 나면 해당 스트림은 닫혀 재사용이 불가능하다. 다시 사용하고 싶다면 스트림을 새로 만들어야 한다.

스트림의 코드가 간결한 비결은 내부 반복이다. foreach 메서드를 예로 들면 내부에서는 for문으로 반복을 수행하지만 그것이 메서드 내부에 은닉되어 있어 우리는 간결한 foreach만 사용하여 같은 작업을 수행할 수 있다.

스트림의 연산

스트림의 연산엔 두 종류가 있다. 중간 연산최종 연산이 있는데 중간 연산은 값으로 스트림을 반환한다. 따라서 연속해서 중간 연산을 반복할 수 있고, 반면에 최종 연산은 결과값이 스트림이 아니다. 즉 최종 연산을 수행하면 해당 스트림은 더이상 연산이 불가능하고 닫히게 된다.
중간 연산으로는

  • distinct() : 중복을 제거
  • filter() : 조건에 맞지않는 항목 제거
  • skip() : 스트림의 일부를 건너뜀
    등이 있고 최종 연산으로는
  • void foreach(Consumer action) : 모든 항목에 지정된 작업 수행
  • long count() : 스트림 내부 항목의 개수 반환
  • boolean allMatch(Predicate p) : 항목들이 모두 해당 조건을 만족하는지 반환

등이 있다.
스트림의 연산은 최종연산이 수행되기 전까지는 수행되지 않는다. 중간 연산들은 최종연산 전에 이러한 연산이 수행되어야 한다고 알려주는 역할을 하고, 실제 연산은 최종 연산 때 수행된다.
이외에도 많은 중간연산과 최종연산 메서드가 있지만 개념 위주로 보기 위해, 그리고 해당 메서드들은 필요할 때 충분히 찾아서 사용이 가능하다 생각하므로 넘어가겠다.

스트림의 생성

컬렉션의 경우 최고 조상인 Collection 클래스에 stream()이 정의되어 있으므로 List,Set을 구현한 클래스들은 모두 stream()을 통해 스트림의 생성이 가능하다.

  
Stream<T> Collection.stream();

배열에서 스트림을 생성하기 위해선 Stream과 Arrays에 static 메서드로 정의된 메서드들을 사용하면 된다.

  
Stream<T> Stream.of(T[]);
Stream<T> Arrays.stream(T[]);

또는 람다식을 매개변수로 받아 해당 람다식에 의해 계산되는 값들을 가진 무한 스트림을 생성할 수도 있다.

  
static <T> Stream<T> iterate(T seed, UnaryOperator<T> f);
static <T> Stream<T> generate(Supplier<T> s);

iterate는 seed부터 시작하여 람다식 f에 의해 계산된 결과를 다시 seed값으로 하여 계산을 반복한다. 아래의 식은 짝수들을 저장하는 스트림을 만든다.

  
Stream<Integer> s=Stream.iterate(0,n->n+2);

generate는 iterate와 달리 이전 결과를 이용하여 계산하지 않는다. 또한 매개변수가 Supplier이므로 매개변수가 없는 람다식만 허용된다.
또한 Stream의 static 메서드인 concat()을 사용하면 같은 타입의 스트림 두 개를 하나의 스트림으로 합칠 수 있다.

  
Stream<String> str3=Stream.concat(str1,str2);

Optional

최종 연산의 결과 타입이 Optional인 경우가 종종 있다. Optional는 T타입의 객체를 감싸는 wrapper 클래스이다. 따라서 Optional 객체에는 모든 타입의 참조변수를 담을 수 있다. 최종 연산의 결과를 Optional 객체에 담아서 반환하는 이유는 간단하다. **null 체크**가 편하기 때문이다. if문을 사용하여 null 체크를 하지 않아도 Exception의 걱정 없이 코드를 사용할 수 있는 것이다. Optional 객체를 생성할 때는 of(), 또는 ofNullable()을 사용하는데, null값의 가능성이 있다면 ofNullable()을 사용해야 한다. of()는 null값이 들어가면 Exception이 발생하기 때문이다. Optional 객체의 값을 가져올 때에는 get()을 사용하고, isPresent()를 사용하여 값이 null이면 false, 아니라면 true를 반환받을 수 있다.

스트림의 최종 연산

가장 중요한 reduce만 설명하고 넘어가겠다. 대부분의 기능은 이 reduce를 이용하여 구현한 것이기 때문이다.
reduce()는 스트림의 요소를 줄여나가며 최종 결과를 반환한다. 처음 두 요소로 계산한 결과를 바탕으로 다음 요소와 연산을 진행한다. 이 과정에서 스트림의 요소가 줄어들게 된다. 이외에 매개변수로 초기값을 받아 초기값과 스트림의 첫 요소로 계산을 시작할 수도 있다.
reduce가 중요한 이유는 count, sum, max 등의 여러 메서드들이 reduce 기반이기 때문이다.

  
int count=intStream.reduce(0,(a,b)->a+1);
int sum=intStream.reduce(0,(a,b)->a+b);
int max=intStream.reduce(Integer.MIN_VALUE,(a,b)->a>b?a:b);

이런 식으로 하나씩 요소를 줄여나가며 원하는 값을 반환했던 것이다.

collect()

스트림의 최종 연산들 중 가장 유용하면서도 복잡한 메서드이다. 매개변수로 collector를 필요로 하는데 collector는 Collector 인터페이스를 구현한 것이다. 직접 구현해도 되고 Collectors 클래스에 이미 정의된 것을 사용해도 된다.
collect()는 스트림의 요소를 조건에 맞게 수집하는 메서드이다. Collectors 클래스의 toList,toSet 등의 메서드를 사용하여 스트림을 컬렉션으로 변환시켜줄 수도 있다.

  
List<String> name=stuStream.map(Student::getName).collect(Collectors.toList());

map을 사용하여 Student 클래스의 name만 따온 것을 List로 반환하는 코드이다.
이와 같은 방법으로 counting, summingint, reducing 등의 메서드를 사용하여 기존에 수행했던 작업들을 collect를 사용해서도 수행할 수 있다. 기존에도 가능했는데 collect를 통해 수행하는 이유는 이후 설명할 groupingBy() 메서드에서 알 수 있다.

groupingBy() & partitioningBy()

groupingBy를 사용하면 우리는 스트림을 마치 sql의 query를 쓰듯이, 여러 조건에 맞게 원하는대로 분할할 수 있다.
groupingBy는 스트림을 특정 기준으로 그룹화하고, partitioningBy는 스트림을 조건에 일치하는 그룹, 조건에 일치하지 않는 그룹 두 가지로 분할한다. 따라서 groupingBy는 매개변수로 Function을, partitioningBy는 매개변수로 Predicate를 받는다.
그렇기에 스트림을 2개의 그룹으로 나눠야 한다면 partitioningBy를, 아니라면 groupingBy를 쓰는 것이 좋을 것이다. 두 메서드 모두 반환타입이 Collector이기에 collect()메서드와 함께 사용하고, Map에 결과를 담아 반환한다.
일단 partitioningBy부터 예시를 들어보자.

  
Map<Boolean,List<Student>> stuBysex=stuStream.collect(partitioningBy(Student::isMale));
List<Student> maleStu=stuBysex.get(true);  
List<Student> femaleStu=stuBysex.get(false);

메서드와 변수의 이름에서 지금까지의 경험으로 대충 무엇인지 알 수 있을 것이다.
성별로 학생들을 분할하여 조건에 맞는 학생은 true의 key를 가진 List로, 맞지 않는다면 false의 key를 가진 List로 들어가는 것을 알 수 있다.
매개변수로 Collector를 추가하여 분할에 더불어 통계까지 한번에 할 수 있다.

  
Map<Boolean,Long> stunumBysex=stuStream.collect(partitioningBy(Student::isMale,counting()));
Long maleStunum=stunumBysex.get(true);  
Long femaleStunum=stunumBysex.get(false);

counting() 메서드를 매개변수로 추가하여 분할과 함께 통계까지 완료한 것을 볼 수 있다. sql의 query와 같이 이중으로 조건을 작성하여 사용할 수도 있다.

  
Map<Boolean,Map<Boolean,List<Student>>> failedstuBysex=stuStream.collect(partitioningBy(Student::isMale,
    partitioningBy(s->s.getScore()<150)));
List<Student> failmaleStu=failedstuBysex.get(true).get(true);  
List<Student> failfemaleStu=failedstuBysex.get(false).get(true);

이중으로 partitioningBy를 사용하여 Map 또한 value로 Map을 갖게 되었다.

이번엔 groupingBy를 써보자.

  
Map<Long,List<Student>> stuByBan=stuStream.collect(groupingBy(Student::getBan));
List<Student> 1Ban=stuBysex.get(1);  
List<Student> 2Ban=stuBysex.get(2);

학생들을 반 별로 그룹화하여 Map으로 반환된 모습이다. 이번엔 조금 더 복잡하게 학생들을 시험 점수에 따라 레벨로 나눠 그룹화 한 후 수를 집계해보자.

  
Map<Student.Level,Long> stuByLevel=stuStream.collect(groupingBy(s-> {
    if(s.getScore()>=200) return Student.Level.High;
    else if(s.getScore()>=100) return Student.Level.Mid;
    else return Student.Level.Low;
},counting()));
Long HighStu=stuByLevel.get(Student.Level.High);  
Long LowStu=stuByLevel.get(Student.Level.Low);

리턴 값을 key로써 사용하는 것을 확인할 수 있다. 위와 같이 사용하면 레벨 별로 학생들을 집계할 수 있다.

지금까지 jave의 람다식과 스트림에 대해 알아보았다. 너무나도 복잡하고 방대한 내용이기에 중요한 부분만 정리했음에도 상당히 힘든 파트였다.
세세한 부분은 넘어갔지만 이 정도 정리라면 추후 스트림이나 람다식을 사용해야할 때가 올 때 이 포스팅을 보고 필요한 부분을 찾아서 사용할 수 있을 것 같다.
다음 단원은 I/O 입출력인데, 저번 면접에서 내가 네트워크에 대해 너무 기초조차 모른다는 것이 판명되었기에 빨리 그 다음 단원인 네트워크 파트를 공부하고 싶은 마음이다.