[JAVA] 람다와 스트림(Lambda & Stream)

Sia Hwang·2022년 12월 2일
0

JAVA

목록 보기
5/6

What is the Lambda expression?

  • 람다식은 메서드를 하나의 식(expression)으로 표현한 것이다. 람다식은 함수를 간략하면서도 명확한 식으로 표현할 수 있게 해준다.
  • 메서드를 람다식으로 표현하면 메서드의 이름과 반환값이 없어지므로 람다식을 익명 함수(anonymous function)이라고도 한다.

How to make the Lambda expression?

int sum(int a, int b) {
  return a + b;
}
  • 이렇게 작성된 메서드에서 메서드의 반환타입과 이름 int sum을 제거하고 매개변수 선언부 (int a, int b)와 몸통 {} 사이에 ->을 넣어준다.
(int a, int b) -> { return a + b; }
  • 반환값이 있는 메서드의 경우 리턴문 대신 식(expression)으로 대신 할 수 있다. 식의 연산결과가 자동적으로 반환값이 된다. 이 때는 문장(statement)이 아닌 식(expression)이므로 마지막에 ;을 붙이지 않는다.
(int a, int b) -> a + b
  • 여기서 사용된 매개변수가 추론이 가능한 경우엔 타입을 생략할 수 있는데 사실상 대부분의 경우에 생략이 가능하다.
  • 람다식에 반환타입이 없는 이유도 항상 추론 가능하기 때문이다.
(a, b) -> a + b
  • 만약에 매개변수가 하나라면 괄호 ()를 생략할 수 있지만, 매개변수의 타입이 명시되어 있으면 생략할 수 없다.
a -> a * a // OK
int a -> a * a // Error!
  • 중괄호 {} 안의 문장이 하나일 때엔 중괄호도 생략할 수 있다. 이 때엔 마지막에 ;을 붙이지 않는다.
  • 하지만 중괄호 안의 문장이 return문이라면 생략할 수 없다.

Functional Interface

  • 자바에서 람다식은 하나의 메서드가 선언된 인터페이스를 구현한 익명 객체를 사용하는 형태로 구현되어 있다. 이 때 사용되는 인터페이스를 함수형 인터페이스라고 한다.
@FunctionalInterface // 이걸 붙이면 컴파일러가 함수형 인터페이스를 올바르게 정의했는지 확인해준다.
interface MyFunction {
  public abstract int max(int a, int b);
}
  • 그래서 람다식을 참조변수에 담아 사용할 수도 있고 매개변수로 지정하는 것도 가능하다. 그리고 이 참조변수와 람다식을 반환할 수도 있다.
  • 즉, 변수처럼 메서드를 주고받는 것이 가능하다. (사실 객체이긴 함)
  • 하지만 함수형 인터페이스로 람다식을 참조할 수 있는 것 뿐이지 람다식의 타입 자체는 함수형 인터페이스가 아니기 때문에 참조변수로 사용할 경우 형변환을 해 주어야 한다. 하지만 형변환 코드가 생략이 가능하기 때문에 그냥 사용하면 된다.

Package: java.util.function

  • 그렇지만 람다식을 써 보면 굳이 함수형 인터페이스를 일일이 선언하지 않아도 쓸 수 있다. 그 이유는 java.util.function 패키지에 자주 쓰이는 메서드 형태를 함수형 인터페이스로 정의해 놓았기 때문이다.
  • 물론 내가 새롭게 정의해서 쓸 수도 있지만 함수형 인터페이스에 정의된 메서드 이름이 통일되고, 재사용성이나 유지보수 측면에서도 패키지의 인터페이스를 활용하는 것이 좋다. 무엇보다 다 만들어져 있는데 굳이 만들어 쓰는 것도 번거롭다.
  • 자주 쓰이는 기본적인 함수형 인터페이스는 다음과 같다.
함수형 인터페이스메서드설명
java.lang.Runnablevoid run()매개변수도 없고 반환값도 없음
SupplierT get()매개변수는 없고 반환값만 있음
Consumervoid accept(T t)매개변수만 있고 반환값이 없음
Function<T, R>R apply(T t)일반적인 함수. 하나의 매개변수를 받아서 결과 반환
Predicateboolean test(T t)조건식을 표현하는데 사용됨. 매개변수는 하나이고 반환 타입은 boolean
  • 그동안 자바 메서드에 가능한 매개변수 목록 볼 때마다 뭔가 싶었는데 이제서야 이해가...

매개변수가 2개인 함수형 인터페이스

함수형 인터페이스메서드설명
BiConsumer<T, U>void accept(T t, U u)두 개의 매개변수만 있고 반환값이 없음
BiPredicate<T, U>boolean test(T t, U u)조건식을 표현하는데 사용됨. 매개변수는 둘이고 반환 타입은 boolean
BiFunction<T, U, R>R apply(T t, U u)두 개의 매개변수를 받아서 하나의 결과를 반환
  • 매개변수가 2개를 초과하는 함수형 인터페이스가 필요하다면 직접 만들어서 쓰면 된다.

UnaryOperator와 BinaryOperator

  • 매개변수의 타입과 반환타입이 모두 일치하는 형태이다.
함수형 인터페이스메서드설명
UnaryOperator와T apply(T t)Function의 자손으로 매개변수와 결과의 타입이 같다.
BinaryOperatorT apply(T t, T t)BiFunction의 자손으로 매개변수와 결과의 타입이 같다.
  • 그 외 더 많은 함수형 인터페이스가 있는데 자세한 건 자바의 정석을 참고하자.

Method reference

  • 람다식이 하나의 메서드만 호출하는 경우에는 메서드 참조라는 방법으로 람다식을 더 간결하게 할 수 있다.
Function<String, Integer> f = (String s) -> Integer.parseInt(s);
  • 문자열을 정수로 변환하는 람다식을 메서드 참조형으로 바꾼다면 아래와 같이 쓸 수 있다.
Function<String, Integer> f = Integer::parseInt;
  • 람다식의 일부가 생략되었지만, 컴파일러는 생략된 부분을 우변의 parseInt 메서드의 선언부로부터, 또는 좌변의 Function 인터페이스에 지정된 제네릭 타입으로부터 알아낼 수 있다.

하나의 메서드만 호출하는 람다식은 클래스이름::메서드이름 또는 참조변수::메서드이름으로 바꿀 수 있다.

  • 생성자도 메서드 참조 방식으로 호출할 수 있다.
Supplier<MyClass> s = () -> new MyClass(); // 람다식
Supplier<MyClass> s = MyClass::new; // 메서드 참조
  • 이걸 설명하는 이유는 이제부터 알아볼 스트림에서 많이 사용하게 되기 때문이다.

What is the Stream?

  • 코딩테스트를 준비할 때 for문으로 반복문을 사용하는 경우가 많다. 편하게 쓸 수 있지만 이게 사용할 일이 많아지면 전체적으로 코드의 가독성이 떨어지고 재사용성도 떨어진다. 어떨 땐 굉장히 기계적으로 작성하고 있는 나를 발견하곤 한다.

  • 또 다른 문제는 데이터 소스마다 다른 방식으로 다뤄야 한다. 각 컬렉션 클래스에는 같은 기능의 메서드들이 중복해서 정의되어 있다. 예를 들어 List를 정렬해야 할 때는 Collections.sort()를 사용하고, 배열을 정렬할 때는 Arrays.sort()를 사용해야 한다. 헷갈린다.

  • 이러한 문제점들을 해결하기 위해서 만든 것이 스트림(Stream)이다. 스트림은 데이터 소스를 추상화하고, 데이터를 다루는데 자주 사용되는 메서드들을 정의해 놓았다.

    데이터 소스를 추상화하였다는 것은 데이터 소스가 무엇이던 간에 같은 방식으로 다룰 수 있게 되었다는 것과 코드의 재사용성이 높아졌다는 것을 의미한다.

  • 때문에 스트림을 이용하면 배열이나 컬렉션 뿐만 아니라 파일에 저장된 데이터도 모두 같은 방식으로 다룰 수 있다.

Features of the Stream

  • 데이터 소스를 변경하지 않는다.

    • 데이터 소스로부터 데이터를 읽기만 할 뿐, 데이터 소스를 변경하지 않는다는 차이가 있다. 필요하다면 정렬된 결과를 컬렉션이나 배열에 담아서 반환할 수도 있다.
  • 일회용이다.

    • 한 번 사용하면 닫혀서 다시 사용할 수 없다. 필요하다면 스트림을 다시 생성해야 한다.
  • 작업을 내부 반복으로 처리한다.

    • 내부 반복이라는 것은 반복문을 메서드의 내부에 숨길 수 있다는 것을 의미한다.
    • forEach()를 호출하면 그 메서드 내부 안에 반복문이 작성되어 있어 반복하고자 하는 작업을 인자값으로 넣어주기만 하면 반복 작업이 내부적으로 수행되는 것이다. 그래서 간결한 코드를 작성할 수 있다.
  • 스트림의 연산

    • 스트림이 제공하는 다양한 연산을 사용해서 복잡한 작업들을 간단하게 처리할 수 있다.
  • 병렬 스트림

    • 스트림으로 데이터를 다루면 병렬 처리가 쉽다. parallel() 메서드만 호출하면 된다.

Make the Stream

Collection

  • 컬렉션의 최고 조상인 Collectionstream()이 정의되어 있다. 그래서 Collection의 자손인 ListSet을 구현한 컬렉션 클래스들은 모두 이 메서드로 스트림을 생성할 수 있다.
Stream<T> Collection.stream()
  • List로부터 스트림을 생성하려면 다음과 같이 작성한다.
List<Integer> list = Arrays.asList(1,2,3,4,5);
Stream<Integer> intStream = list.stream(); // 스트림 생성 완료 

Array

  • 배열을 소스로 하는 스트림을 생성하는 메서드는 StreamArraysstatic 메서드로 정의되어 있다.
Stream<T> Stream.of(T... values) // 가변인자
Stream<T> Stream.of(T[])
Stream<T> Arrays.stream(T[])
Stream<T> Arrays.stream(T[] array, int startInclusive, int endExclusive)
  • 문자열 스트림을 생성하려면 다음과 같이 작성한다.
Stream<String> strStream = Stream.of("a","b","c");
Stream<String> strStream = Stream.of(new String[]{"a","b","c"});
Stream<String> strStream = Arrays.stream(new String[]{"a","b","c"});
Stream<String> strStream = Arrays.stream(new String[]{"a","b","c"}, 0, 3);

스트림의 연산

  • 스트림이 제공하는 연산은 중간연산과 최종연산으로 분류할 수 있다.

    중간연산 : 연산 결과가 스트림인 연산. 중간 연산을 연속해서 연결할 수 있다.
    최종연산 : 연산 결과가 스트림이 아닌 연산. 스트림의 요소를 소모하므로 단 한번만 가능

  • 연산 메서드는 여러 종류가 있지만 자주 사용되는 것들만 언급하자면 map(), forEach(), reduce(), collect(), sorted(), filter() 등이 있다.

중간 연산

map()

  • 스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 할 때 사용한다.
Stream<R> map(Function<? super T, ? extends R> mapper)

sorted()

  • 스트림을 정렬할 때 사용한다.
Stream<T> sorted()
Stream<T> sorted(Comparator<? super T> comparator)

filter()

  • 주어진 조건(Predicate)에 맞지 않는 요소를 걸러낸다.
  • 매개변수로는 연산 결과가 boolean인 람다식을 사용하면 된다.
Stream<T> filter(Predicate<? super T> predicate)

최종 연산

forEach()

  • for문처럼 스트림의 요소를 하나씩 순회하며 원하는 작업을 할 수 있다. 주로 스트림의 요소를 출력하는 용도로 많이 사용된다.
void forEach(Consumer<? super T> action)

reduce()

  • 스트림의 요소를 줄여나가면서 연산을 수행하고 최종 결과를 반환한다. 처음 두 요소를 가지고 연산한 결과를 가지고 그 다음 요소와 연산한다.
Optional<T> reduce(BinaryOperator<T> accumulator)
T reduce(T identity, BinaryOperator<T> accumulator)
U reduce(U identity, BiFunction<U, T, U> accumulator, BinaryOperator<U> combiner)

collect()

  • 스트림의 요소를 수집하는 최종 연산으로 주로 컬렉션과 배열로 변환하는데 사용된다.
Object collect(Collector collector) // Collector를 구현한 클래스의 객체를 매개변수로 가짐
Object collect(Supplier supplier, BiConsumer accumulator, BiConsumer combiner)

Optional< T >

  • 제네릭 클래스로 T 타입의 객체를 감싸는 래퍼 클래스이다. 그래서 Optional 타입의 객체에는 모든 타입의 참조변수를 담을 수 있다.
public final class Optional<T> {
  private final T value; // T 타입의 참조변수
  ...
}
  • 스트림의 최종 연산의 결과를 그냥 반환하는 것이 아니라 Optional 객체에 담아서 반환한다.
  • Optional 객체에 담아서 반환하면, 반환된 객체가 null인지 매번 if문으로 체크하는 대신 Optional에 정의된 메서드를 통해서 간단히 처리할 수 있다.
  • 때문에 Optional을 사용하면 null 체크를 위한 if문 없이, isPresent(), ifPresent()와 같은 메서드를 사용해서 NullPointerException이 발생하지 않는 보다 간결하고 안전한 코드를 작성하는 것이 가능하다.

References

profile
당면한 문제는 끝까지 해결하기 위해 노력하는 주니어 개발자입니다.

0개의 댓글