킴의 레포지토리

JAVA: 익명 객체와 람다식 그리고 헷갈리는 스트림 문법 정리 본문

study/java

JAVA: 익명 객체와 람다식 그리고 헷갈리는 스트림 문법 정리

킴벌리- 2024. 4. 23. 21:02
        List<Student> students = List.of(
                new Student("hong", 100, Student.Sex.MALE, Student.City.Seoul),
                new Student("kim", 80, Student.Sex.FEMALE, Student.City.Seoul),
                new Student("park", 50, Student.Sex.MALE, Student.City.Pusan)
        );
        Map<Student.Sex, Double> avgScoreBySex = students.stream()
                .collect(
                        Collectors.groupingBy(
                                Student::getSex,
                                TreeMap::new,
                                Collectors.averagingDouble(Student::getScore)));
        avgScoreBySex.entrySet()
                .forEach(entry -> System.out.println(entry.getKey() + " : " + entry.getValue()));​
Comparator<Integer> maxComparator = Math::max;​
Thread thread = new Thread(() -> {
   System.out.println("thread running");
});

JAVA8부터 제공되는 스트림은 배열과 컬렉션을 쉽게 반복하며 처리할 수 있게 한다. 스트림의 처리는 람다식을 사용해서 더욱 간단하게 표현할 수 있는데, 람다 표현식을 제대로 이해려하려면 먼저 익명 객체에 대해서 이해할 필요가 있다. 이 글에서는 익명 객체가 무엇인지 살펴보고, 함수형 인터페이스의 익명 구현 객체를 간략하게 표현하는 람다식에 대해서 알아보고 마지막으로 헷갈리는 스트림 문법을 살펴보며 스트림에서 람다식을 활용하는 코드 예제를 살펴본다.

1. 익명 객체(Anonymous Class)

1-1. 익명 객체란? 언제 사용되는가?

 – 필드, 함수 호출 매개 변수, 로컬 변수에 대입되는 클래스의 이름이 정해지지 않은 객체를 말한다.

 – 익명 객체는 언제 사용하는가? 익명 객체는 클래스를 상속하거나 인터페이스를 구현하는 클래스가 한번만 생성되고 다른 곳에서 생성될 필요가 없을때 간략하게 클래스를 정의하기 위해서 사용된다.

 – 익명 자식 객체 혹은 익명 구현 객체정의함과 동시에 생성하는 문법이다.

 – 참고로 익명 클래스도 일반 클래스와 같이 Java Runtime Area의 메소드 영역에 로드되고 객체는 힙 메모리에 생성된다.

1-2. 코드 예시

아래는 Thread 클래스의 익명 자식 객체를 정의함과 동시에 생성해 로컬 변수에 대입하는 코드이다. Thread 클래스를 상속받는 익명의 클래스를 정의하면서 run() 함수를 오버라이드하고 동시에 익명 객체가 생성된다.

    public static void main(String[] args) {
        Thread thread = new Thread() {
            @Override
            public void run() {
                System.out.println("thread running");
            }
        };
        thread.start();
    }

아래는 ActionListener 인터페이스의 익명 구현 객체를 구현함과 동시에 생성해 매개변수에 대입하는 코드이다. javax.swing에서 제공하는 JButton 클래스를 생성하고 버튼의 특정 액션이 발생했을때 어떤 코드를 실행하고 싶다면 ActionListener를추가해주어야한다. 버튼에 ActionListener를 추가하기 위해서는 addActionListener()라는 함수에 ActionListener의 구현 객체를 넘겨야한다. ActionListener를 implement하는 클래스를 정의하고 생성한 다음에 넘기기보다 아래 코드처럼 간단하게 익명 구현 객체를 생성하고 바로 매개 변수에 넣어준다.

import javax.swing.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

public class Sample {
    public static void main(String[] args) {
        JButton button = new JButton();
        button.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                System.out.println("action performed");
            }
        });
    }
}

아래는 익명 구현 객체의 또다른 예시로 Runnable 인터페이스의 익명 구현 객체를 생성하는 코드이다. 일반 클래스인 Thread 클래스의 익명 자식 객체를 생성하는 것이 아니라 인터페이스인 Runnable의 익명 구현 객체를 생성하고 Thread 생성자에 매개변수로 넘겨준다.

    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("thread running");
            }
        });
        thread.start();
    }

1-2. 주의할 점 - 익명 객체의 로컬 변수 사용

익명 객체 내부에서 메소드의 매개변수나 로컬변수를 참조할 때는 참조하는 변수가 final 특성을 가져야한다(final or effectively final). 익명 객체가 참조하는 메소드 매개변수나 로컬변수의 값이 변경된다면 컴파일 에러가 발생한다.

익명 객체에 의해 참조되는 메소드의 매개변수가 로컬변수가 final이어야한다는 제약사항이 있는 이유는 메소드의 로컬변수나 매개변수와 익명 객체의 생명주기가 다르기 때문이다. 함수의 실행시 스택 메모리에 프레임이 생성되고 프레임에 매개변수, 로컬 변수들이 생성된다. 메소드 실행이 완료되면 프레임이 스택 메모리에서 제거되기 때문에 로컬 변수와 매개변수는 더 이상 사용이 불가능하다. 하지만 생성된 익명 객체는 함수 실행 완료 후에도 여전히 힙 메모리에 존재할 수 있고 계속 참조할 수 있게 된다. 자바는 이 문제를 해결하기 위해 익명 객체가 메소드의 매개변수나 로컬 변수를 참조한다면 그 값을 객체 내부에 복사해둔다. 이때, 매개변수나 로컬 변수가 final이 아니라서 수정되게 된다면 복사해둔 값과 원본 값이 달라지는 문제를 해결하기 위해서 참조되는 변수를 final로 선언한다.

2. 람다식

2-1. 람다식이란?

– 람다식은 함수적 인터페이스의 익명 구현 객체를 간략하게 생성할 수 있도록 하는 문법이다.

– 함수적 인터페이스란 구현해야하는 추상 함수가 하나 뿐인 인터페이스를 말한다.

 

람다식을 다음과 같은 경우에 적용하는 것은 불가능하다.

  1. 익명 구현 객체가 아닌 익명 자식 객체를 생성: 람다식을 사용해서 인터페이스인 Runnable을 구현하는 것은 가능하지만 일반 클래스인 Thread를 생성하는 것은 불가능하다.
  2. 추상 함수가 없거나, 두개 이상인 인터페이스의 익명 구현 객체 : 람다식을 사용해서 Appendable 인터페이스는 구현이 불가능합니다. Appendable 인터페이스는 append()라는 함수의 세가지 오버로드를 구현해야하기 때문이다.
    ⇒ 두 경우 모두 람다식이 어떤 함수를 오버라이드하는지 알 수 없기 때문에 적용이 불가능하다.

2-2. 람다식의 동작 원리

람다식의 형태는 매개변수를 가진 코드 블록으로 함수를 선언하는 것처럼 보이지만 런타임시에는 익명 구현 객체를 생성한다. 람다식은 인터페이스 변수에 대입되거나 매개변수로 전달될 수 있는데 이때 선언된 인터페이스 타입이나, 메소드 매개변수 타입에 따라서 람다식이 어떤 인터페이스를 구현하는지 컴파일러가 추론할 수 있다. 또한, 람다식은 함수적 인터페이스를 구현하기 때문에 어떤 메소드를 오버라이드하는지 명확하다.

2-3. Runnable 인터페이스의 익명 구현 객체를 람다식으로 표현

다음은 위에서 Runnable 익명 구현 객체를 생성하는 코드를 람다식으로 표현한 것이다. 람다식을 사용하면 코드가 간략해지고 가독성이 좋아진다.

Thread thread = new Thread(() -> {
     System.out.println("thread running");
});

2-4. 람다식과 메소드 참조

메소드 참조는 람다식을 더욱 간략하게 표현하기 위해 사용되는 문법이다. 람다식의 매개변수가 단순히 메서드의 매개값으로 전달된다면 매개변수를 생략하는 메소드 참조를 사용해서 간략히 표현할 수 있다.

 

아래는 Comparator의 익명 구현 객체의 람다식을 메소드 참조로 변환한 예시이다. 이때 매개변수 a,b는 단순히 다른 함수호출 시 매개변수로 전달된다. 람다식의 매개변수 타입과 호출되는 매개변수의 타입이 같다. 이때는 메소드 참조를 사용해서 더 간략하게 표현할 수 있다.

Comparator<Integer> maxComparator = (a, b) -> {
     return Math.max(a, b);
};
Comparator<Integer> maxComparator = Math::max;

 

아래는 메소드 참조를 사용해서 매개변수를 생략한 또 다른 예시로, PrintStream 클래스의 println() 이라는 정적 함수를 참조하는 코드이다. 람다식은 Consumer 인터페이스의 익명 구현 객체의 accept(Integer num) 메소드를 오버라이드 한다. 이때 람다식의 매개변수는 println 함수 호출시 전달되기만 하고, 람다식의 매개변수와 println의 매개변수 타입이 같이 때문에 메소드 참조를 사용해 간략히 표현할 수 있다.

Consumer<Integer> print = (num) -> {
    System.out.println(num)
};
Consumer<Integer> print = System.out::println;

 

메서드 참조는 람다식 매개변수의 메소드의 참조도 포함한다. 아래의 코드는 String의 compareToIgnoreCase()라는 정정 메소드를 참조하는 것이 아니라 람다식의 매개변수로 전달되는 String 객체의 compareToIgnoreCase()를 호출하면서 나머지 매개변수를 매개변수 값으로 전달하는 코드이다. 이때, 메소드 참조시의 클래스 타입이 매개변수 타입과 같고, 나머지 매개변수가 참조되는 메소드의 매개변수 타입과 같기 때문에 컴파일러가 어떤 객체의 메소드를 참조하는지 추론할 수 있다.

ToIntBiFunction<String, String> function = String::compareToIgnoreCase;

 

메서드 참조는 생성자 참조도 포함한다. 아래는 새로운 ArrayList<String> 객체를 생성해주는 Supplier의 익명 구현 객체를 람다식으로 표현한 것이다. 람다식은 Supplier의 get() 함수를 오버라이드 한다. get()은 매개변수가 없는 함수로 객체 생성시 매개변수가 없다면 매개변수를 생략한 생성자 참조를 사용할 수 있다.

Supplier<ArrayList<String>> listSupplier = () -> new ArrayList<>();
Supplier<ArrayList<String>> listSupplier = ArrayList::new;

3. 스트림

3-1. 스트림이란?

스트림은 java 8부터 추가된 컬렉션, 배열 등의 저장 요소를 하나씩 참조해서 람다식(functional-style)로 처리할 수 있도록 해주는 반복자이다.

스트림의 특징은 다음과 같다.

1. 람다식으로 요소 처리 코드를 제공한다.

  • java에서 제공되는 함수형 인터페이스 Function, Predicate, Supplier, Consumer를사용하여 요소를 처리하기 때문에 람다식을 사용해서 간략하게 요소 처리가 가능하다.
  • for문이나 while문 등 외부 반복자를 사용하지 않고 처리 내용만 포함하는 객체만 람다식으로 제공하면 되어 코드가 간결하다.

2. 내부 반복자를 사용하므로 병렬 처리가 쉽다.

  • parrallelStream이 내부적으로 ForkJoinPool의 작업 스레드를 사용해 병렬적으로 요소를 처리하도록 할 수 있어 효율적으로 반복할 수 있다.

3. 스트림은 중간 처리와 최종 처리를 할 수 있다.

  • 중간 처리에서는 매핑, 필터링, 정렬을 수행하고 최종 처리에서는 반복, 카운팅, 평균, 총합 등 집계 처리를 수행할 수 있다.
  • 여러개의 중간 처리와 하나의 최종 처리를 파이프라인으로 간단하게 연결할 수 있다.
  • 최종 처리가 시작되기 전까지 중간 처리는 지연되고 최종 처리가 시작되면 컬렉션의 요소가 하나씩 중간 스트림에서 처리되고 최종 스트림까지 오게 된다.

3-2. 헷갈리는 스트림 문법

3-3-1. IntStream vs Stream<Int>

IntStream, LongStream, DoubleStream과 같은 숫자 Stream은 기본 데이터 타입인 int, long, double을 사용한다. Stream<T>는 Integer, Long, Double과 같은 객체들을 요소로 한다.

3-2-1. asDoubleStream() vs boxed()vs mapToInt()

asXXStream()은 숫자 스트림에서 대해서 각 요소를 upcasting한 숫자 Stream을 생성할때 사용한다. 예를 들어, IntStream을 LongStream으로 변환하거나 LongStream을 DoubleStream으로 변환할때 사용된다. 다음 코드는 Arrays.stream()을 사용하여 얻은 IntStream을 DoubleStream으로 변환하는 예시이다.

int[] arr = {1, 2, 3, 4, 5};
Arrays.stream(arr) //IntStream
      .asDoubleStream() //DoubleStream
      .forEach(System.out::println);

 

boxed()는 숫자 Stream을 Stream<T> 객체로 변환할때 사용한다. IntStream은 int를 다루기 때문에 컬렉션에 담을 수 없다. IntStream의 결과를 List<Integer>등의 컬렉션으로 수집하고자 할때 boxed()를 사용할 수 있다.

int[] arr = {1, 2, 3, 4, 5};
IntStream intStream = Arrays.stream(arr);
List<Integer> collect = intStream.boxed().collect(Collectors.toList());

mapToXX() 는 각 요소에서 primitive 타입의 값을 추출해서 숫자 Stream 을 만들기 위해서 사용한다. 참고로 map() Stream<T>를 만든다. 아래는 Stream<List<Student>>에서 각 요소들의 점수만 추출해 IntStream을 만들고 출력하는 코드이다.

        List<Student> students = List.of(
                new Student("hong", 100, Student.Sex.MALE, Student.City.Seoul),
                new Student("kim", 80, Student.Sex.FEMALE, Student.City.Seoul),
                new Student("park", 50, Student.Sex.MALE, Student.City.Pusan)
        ); 
        
        students.stream()
                .mapToInt(Student::getScore) // 매개변수의 getScore() 참조
                .forEach(score -> System.out.println(score));
public class Student {
    enum Sex {MALE, FEMALE}
    enum City {Seoul, Pusan}

    private String name;
    private int score;
    private Sex sex;
    private City city;

    public Student(String name, int score, Sex sex, City city) {
        this.name = name;
        this.score = score;
        this.sex = sex;
        this.city = city;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

    public Sex getSex() {
        return sex;
    }

    public City getCity() {
        return city;
    }
}

3-2-2. 그룹핑

아래는 학생들을 성별로 그룹핑하는 코드이다. Collectors.groupingBy()를 사용해 키가 성별이고 값이 학생리스트인 맵으로 수집할 수 잇다. 참고로, Student 클래스는 위에서 확인할 수 있다.

        List<Student> students = List.of(
                new Student("hong", 100, Student.Sex.MALE, Student.City.Seoul),
                new Student("kim", 80, Student.Sex.FEMALE, Student.City.Seoul),
                new Student("park", 50, Student.Sex.MALE, Student.City.Pusan)
        );
        Collector<Student, ?, Map<Student.Sex, List<Student>>> collector = Collectors.groupingBy(Student::getSex);
        Map<Student.Sex, List<Student>> mapBySex = students.stream().collect(collector);

아래는 도시별로 학생을 그룹핑하고 학생들의 이름만 가져오는 코드이다. Collector.groupBy()에서 첫번째 매개변수는 각 요소에서 분류 기준이 되는 값을 추출하는 Function이고, 두번째 매개변수는 분류되는 값을 가공하여 수집하는 Collector이다.

         List<Student> students = List.of(
                new Student("hong", 100, Student.Sex.MALE, Student.City.Seoul),
                new Student("kim", 80, Student.Sex.FEMALE, Student.City.Seoul),
                new Student("park", 50, Student.Sex.MALE, Student.City.Pusan)
        );
        Map<Student.City, List<String>> studentNamesGroupByCity = students.stream()
                .collect(
                        Collectors.groupingBy(
                                Student::getCity,  // 분류 기준 Function
                                Collectors.mapping(Student::getName, Collectors.toList()))); // 이름만 리스트로 묶는다 Collector
        studentNamesGroupByCity.entrySet()
                .forEach(entry -> System.out.println(entry.getKey() + " : " + entry.getValue()));

아래는 성별 평균 점수를 TreeMap으로 가져오는 코드이다.

       List<Student> students = List.of(
                new Student("hong", 100, Student.Sex.MALE, Student.City.Seoul),
                new Student("kim", 80, Student.Sex.FEMALE, Student.City.Seoul),
                new Student("park", 50, Student.Sex.MALE, Student.City.Pusan)
        );
        Map<Student.Sex, Double> avgScoreBySex = students.stream()
                .collect(
                        Collectors.groupingBy(
                                Student::getSex,
                                TreeMap::new,
                                Collectors.averagingDouble(Student::getScore)));
        avgScoreBySex.entrySet()
                .forEach(entry -> System.out.println(entry.getKey() + " : " + entry.getValue()));

정리

✅ 익명 객체클래스를 상속하거나 인터페이스를 구현하는 객체의 생성을 간소화 해준다. 한번 생성되고 나면 다른 곳에서 생성될 필요가 없는 클래스의 경우 익명 객체로 생성할 수 있으며 필드, 매개변수, 로컬변수에 대입할 수 있다.

 

 익명 객체 사용시 주의할 점은 로컬 변수나 매개변수를 참조 시 그 변수는 final 특성을 가져야 한다는 것이다. 이는 익명 객체와 메소드의 생명주기의 차이 때문에 생긴 제약사항이며, 만약 참조하는 변수가 수정되는 경우에는 컴파일 에러가 발생한다.

 

 람다식함수형 인터페이스의 익명 구현 객체의 생성을 간단하게 해주는 문법이다. 람다식의 매개변수가 람다식 실행블록 안에서 함수를 호출할때 그대로 매개변수로 전달되는 경우라면 메소드 참조를 통해 더욱 간단하게 표현할 수 있다.

 

 스트림배열이나 컬렉션을 효율적으로 반복하면서 요소의 처리를 람다식으로 간단하게 표현할 수 있게 한다. 스트림은 중간 연산자와 최종 연산자를 파이프라인으로 연결해 각 요소에 여러 가지의 처리를 간단하게 적용할 수 있다. 특히, 스트림은 최종 연산자 collect()에서 다양한 Colletor를 적용해 여러 수집 방식을 적용할 수 있다.