[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개의 댓글