15 min read

모던 자바 인 액션 - Chapter 3 <람다 표현식>

앞선 챕터에서 동작 파라미터화를 통해 요구 사항을 효율적으로 반영할 수 있는 코드를 구현해보았습니다.특히 익명 클래스를 활용하여 다양한 동작을 구현했습니다.하지만 이는 람다 표현식에 비하면 코드가 깔끔하지는 않았습니다.이번 장에서는 람다를 활용해 좀 더 깔끔하게 동작을 구현하고 전달하는 법을 배울 수 있습니다.

람다란 무엇인가?

람다 표현식은 메서드로 전달할 수 있는 익명 함수를 단순화한 것이라고 할 수 있습니다.표현식에 이름은 존재하지 않지만 기존의 메서드처럼 파라미터 리스트,바디,리턴 형식,발생 가능한 예외를 모두 가질 수 있습니다.앞선 챕터에서 Comparator를 인자로 넘겨주는 예제가 존재했는데 이를 람다를 활용하면 아래와 같이 간결하게 구현할 수 있습니다.

Comparator<Apple> beWeight = (Apple a1, Apple a2) -> {
	a1.getWeight().compareTo(a2.getWeight());
}

본격적으로 람다가 어떻게 구성되는지 알아보자.

위의 그림을 보면 알 수 있듯이 크게 3가지 부분으로 나뉩니다.

  1. 파라미터 리스트 : compare() 메서드의 두가지 파라미터
  2. 화살표 : 화살표는 람다의 파라미터와 바디를 구분
  3. 람다 바디 : 실제 메서드가 구현된 로직,람다의 반환값에 해당하는 표현식

이러한 람다가 어디서 어떻게 사용되는지 알아봅시다.

어디에, 어떻게 람다를 사용할까?

결론적으로 얘기하면 함수형 인터페이스에 사용 가능합니다.우선은 함수형 인터페이스가 무엇인지부터 파악해야합니다.

함수형 인터페이스

2장에서 만든 Predicate<T> 인터페이스가 filter 메서드의 두번째 인자로 사용됩니다.여기서 나오는 Predicate<T> 인터페이스가 바로 함수 인터페이스입니다. Predicate<T>는 오직 하나의 추상 메서드만 지정하기 때문입니다.이해가 안가면 아래의 코드를 참고합시다.

public interface Predicate<T> {
	boolean test(T t);
}

위 코드처럼 해당 인터페이스는 단 하나의 추상 메서드를 지정하고 있고 이가 바로 함수형 인터페이스입니다.책에서 살펴본 함수형 인터페이스는 Comparator,Runnable 등의 인터페이스가 함수형 인터페이스입니다.

추가로 함수형 인터페이스가 아무리 많은 디폴트 메서드를 가진다해도 추상 메서드가 단 한개라면 이는 함수형 인터페이스이다.

이러한 함수형 인터페이스에 람다를 어떻게 활용할 수 있을까? 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달 할 수 있으므로 람다의 전체 표현식을 하나의 함수형 인터페이스의 구현체로 취급 가능하다.Runnable 함수형 인터페이스를 활용한 예제를 만들어보자.

Runnable r1 = () -> System.out.println("Hello World!");

위 코드는 람다 표현식을 통해 함수형 인터페이스를 구현했다.

Runnable r2 = new Runnable(){
	@Override
	public void run(){
		System.out.println("Hello World2!");
	}
}

위 코드는 익명 클래스를 통해 함수형 인터페이스를 구현하였다.익명 클래스의 경우 클래스 자체를 전부 구현하기에 구현할 인터페이스가 함수형 인터페이스가 아니더라도 구현이 가능하다.다만 함수형 인터페이스를 구현할 경우에는 람다를 통해 구현하는것이 훨씬 간결한것을 확인할 수 있다.

람다 활용 : 실행 어라운드 패턴

이제 본격적으로 실용적인 예제로 실습해봅시다.자원 처리(ex - 파일 처리)에 사용되는 순환 패턴은 자원을 열고,처리한 다음 해당 자원을 닫는 순서로 진행되는 패턴입니다.즉,자원을 열고 닫는 과정은 비슷하게 반복된다고 볼 수 있습니다.실제 자원을 처리하는 코드는 자원을 열고 닫는 두 가지 과정에 의해 둘러 쌓입니다.이와 같은 형식을 실행 어라운드 패턴이라고 합니다.다음 코드는 이러한 실행 어라운드 패턴을 가지며 파일로부터 한 줄을 읽는 코드입니다.

public String processFile() throws IOException{
	try(BufferedReader br = new BufferedReader(new FileReader("data.txt"))){
		return br.readLine();
	}
}

해당 메서드를 단계적으로 최적화 해보겠습니다.

현재 코드는 파일로부터 한줄씩만 데이터를 읽어올 수 있습니다.이를 한번에 두줄씩 데이터를 읽도록 바꿀수는 없을까요?람다 표현식을 통해 아래와 같이 변경해볼 수 있습니다.

String result = processFile((BufferedReader) -> br.readLine() + br.readLine());

위와 같이 proceessFile의 메서드의 인자로 람다 표현식을 전달하려면 해당 메서드를 추상 메서드로 가지는 함수형 인터페이스를 생성해야합니다.

@FunctionalInterface
public interface BufferedReaderProcessor {
    String process(BufferedReader b) throws IOException;
}

@FunctionalInterface는 해당 인터페이스가 함수형 인터페이스가 아닐 경우 컴파일 에러를 발생시켜 주는 어노테이션입니다.

이제 해당 함수형 인터페이스를 인자로 받도록 processFile() 메서드를 변경해봅시다.

public String processFile(BufferedReaderProcessor p) throws IOException{
		try(BufferedReader br = new BufferedReader(new FileReader("data.txt"))){
		return p.process(br);
	}}

메서드의 파라미터로 함수형 인터페이스를 받도록 설정하여 동작 파라미터화를 구현혔습니다.또한 해당 인자를 통해 process() 메서드를 실행시켜 인자로 들어올 람다 표현식이 실행될 수 있도록 해줍니다.

위와 같은 과정을 통해 최종적으로 람다 표현식을 활용할 수 있습니다.

함수형 인터페이스 사용

우리는 이제 앞선 내용을 통해 함수형 인터페이스가 오직 하나의 추상 메서드를 지정하는것을 알고 있습니다.이러한 함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터라고 합니다.디스크립터라는 표현은 종종 식별자라는 의미로 사용됩니다.대표적인 예로 파일 디스크립터는 수많은 파일들을 파일 시스템에서 구분하기 위한 식별자 값으로 사용됩니다.함수 디스크립터 또한 함수형 인터페이스의 추상 메서드만을 식별하는 것으로 이해하면 될 것 같습니다.

다양한 람다 표현식을 사용하려면 여러개의 함수 디스크립터가 필요합니다.즉,함수형 인터페이스의 집합이 필요하다는 뜻입니다.자바8은 java.util.function 패키지를 통해 여러가지 새로운 함수형 인터페이스를 제공합니다.결론적으로 다양한 람다 표현식을 사용해볼 수 있습니다.대표적인 예시 몇가지만 살펴 보겠습니다.

Predicate

이미 앞선 예제를 통해 익숙해진 함수형 인터페이스입니다.실제 자바가 제공해주는 함수형 인터페이스입니다.Predicate를 통해 아래와 같은 테스트 코드를 작성해볼 수 있습니다.

@Test
    void predicateTest() {
        Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
        List<String> result
                = filter(Arrays.asList("test", "", "hi"), nonEmptyStringPredicate);
        Assertions.assertThat(result).containsExactly("test", "hi");
    }

    private <T> List<T> filter(List<T> list, Predicate<T> p) {
        List<T> results = new ArrayList<>();

        for (T s : list) {
            if (p.test(s)) {
                results.add(s);
            }
        }

        return results;
    }

Consumer

Consumer<T> 인터페이스는 제네릭 형식 T 객체를 받아서 void를 반환하는 accept라는 추상 메서드를 정의합니다.T 형식의 객체를 인수로 받아서 해당 객체에 어떠한 동작을 수행하고 싶을 때 Consumer라는 함수형 인터페이스를 사용할 수 있습니다.아래 예제는 리스트의 요소를 Integer 타입으로 정의해 요소가 짝수일 경우 출력하는 동작을 수행하도록 합니다.

@Test
    void consumerTest() {
        forEach(Arrays.asList(1, 2, 3, 4, 5), (Integer i) -> {
            if (i % 2 == 0) {
                System.out.println(i);
            }
        });
    }

    private <T> void forEach(List<T> list, Consumer<T> c) {
        for(T i: list) {
            c.accept(i);
        }
    }

지금까지는 람다를 만드는 방법과 람다를 사용하는 법을 알아보았습니다.지금부터는 컴파일러가 람다의 형식을 어떻게 확인하는지,피해야할 사항은 무엇인지 등 깊이있는 내용을 알아보겠습니다.

형식 검사,형식 추론,제약

람다를 처음 설명할 때,람다로 함수형 인터페이스의 인스턴스를 만들 수 있다고 했습니다.하지만 람다 표현식 자체에는 람다가 어떤 함수형 인터페이스를 구현하는지의 정보가 포함되어있지 않습니다.람다의 실제 형식을 파악해보며 어떻게 가능한지 알아봅시다.

형식 검사

람다가 사용되는 컨텍스트를 활용하여 람다의 형식을 추론할 수 있습니다.컨텍스트는 람다가 전달될 메서드 파라미터나 람다가 할당되는 변수 등이라고 이해하시면 됩니다.이러한 컨텍스트에서 기대되는 람다 표현식의 형식을 대상 형식이라고 합니다.다음의 예제를 통해 람다 표현식이 사용되는 실체를 봅시다.아래는 익숙한 사과 필터 예제입니다.

List<Apple> heavierThan150gram = filter(inventory, (Apple apple) -> apple.getWegiht() > 150);

위 코드는 아래와 같은 과정을 통해 대상 형식을 확인합니다.

  1. filter 메서드의 선언 확인합니다.
  2. filter 메서드는 자신이 호출 될때,두번째 인자로 Predicate<Apple>이라는 대상 형식을 기대합니다.
  3. Predicate<Apple>은 test라는 추상 메서드를 가진 함수형 인터페이스임을 확인합니다.
  4. test 메서드는 apple이라는 객체를 받아 boolean을 반환하는 메서드로 구현되어야함을 체크합니다.
  5. filter 메서드의 두번째 인자로 들어오는 구현이 위의 요구사항을 만족하는지 확인합니다.

즉,대상 형식을 통해 인자로 들어온 람다 표현식을 특정 컨텍스트에 사용할 수 있는지 판단하는 과정을 거칩니다.다음으로 대상 형식을 통해 람다의 파라미터 형식을 추론하는 예제를 보겠습니다.

형식 추론

우리는 형식 검사에서 사용한 람다 표현식보다 좀 더 간결하게 람다를 사용할 수 있습니다.아래와 같이 람다 파라미터 리스트의 형식을 생략할 수 있습니다.

List<Apple> heavierThan150gram = filter(inventory, apple -> apple.getWegiht() > 150);

이렇게 파라미터의 형식이 생략될 경우 다음과 같이 해당 형식을 추론합니다.자바 컴파일러는 람다 표현식이 사용된 컨텍스트를 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론합니다.즉,앞서 나온 형식 검사를 통해 대상 형식을 통해 함수 디스크립터를 알 수 있으니 컴파일러는 람다 표현식이 필요로하는 정보를 추론할 수 있습니다.그 결과 위 코드와 같이 파라미터의 형식을 생략할 수 있습니다.

람다에서의 지역변수

지금까지 람다 표현식에서의 변수는 모두 파라미터로 넘어온 인수를 자신의 바디 내에서 사용했습니다.람다 또한 표현식 외부에서 정의된 변수를 사용할 수 있습니다.이러한 변수를 자유변수라고 합니다.또한 이러한 동작은 람다 캡쳐링이라고도 합니다.

간단한 코드를 봅시다.

int port = 1234;
Runnable r = () -> System.out.println(port);

위 코드는 port라는 변수를 캡쳐링하여 사용하는 예제입니다.다만 이러한 자유 변수를 사용할 때는 다음과 같은 제약이 존재합니다.자유 변수로 사용가능한 지역 변수는 명시적으로 final이 선언되어 있거나 final이 선언된 것처럼 활용되어야합니다.즉,사용하려는 지역 변수가 한번만 할당이 되어야한다는 것입니다.만약 한번이 넘게 할당되는 지역변수를 람다 표현식에서 사용하려고 하면 아래와 같이 컴파일 에러가 발생합니다.

메서드 참조

지금부터는 java8의 새로운 기능 중 하나인 메서드 참조에 대해 알아보겠습니다.메서드 참조를 이용하면 기존의 메서드 정의를 재활용하여 람다처럼 이용할 수 있습니다.즉,기존에 구현된 메서드로 람다 표현식을 만들 수 있습니다.이때 명시적으로 메서드명을 참조하여 가독성을 높일 수 있습니다.메서드 명에 ::를 붙이는 방식으로 메서드 참조를 활용할 수 있습니다.예를 들어 Apple::getWegith는 Apple 클래스에 정의된 getWeight 메서드 참조입니다.실제로 메서드를 호출하는것은 아니니 괄호는 필요 없습니다.

아래 예제를 봅시다.

@Test
    void methodReferenceTest() {
        List<Apple> inventory = Arrays.asList(new  Apple(Color.GREEN,140),
                new Apple(Color.GREEN,120));
        inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
        inventory.sort(comparing(Apple::getWeight));
    }

comparing() 메서드는 내부에서 compareTo() 메서드를 호출하여 비교를 진행해주는 함수입니다.해당 함수의 인자로 람다 표현식 Apple a -> a.getWeight()를 축약해준것과 동일합니다.결론적으로 메서드 참조는 기존에 정의된 함수를 통해 람다 표현식을 사용할 수 있도록 해줍니다.

다음의 예제를 통해 더 이해해봅시다.

@Test
    void methodRefExampleTest() {
        // 1.
        ToIntFunction<String> stringToInt1 = (String s) -> Integer.parseInt(s);
        ToIntFunction<String> stringToInt2 = Integer::parseInt;

        // 2.
        BiPredicate<List<String>, String> contains1 = (list, element) -> list.contains(element);
        BiPredicate<List<String>, String> contains2 = List::contains;
    }

생성자 참조

Class::new처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 참조를 만들 수 있습니다.아래의 예제들처럼 필요한 인자들을 받는 생성자가 존재하면 생성자 참조를 활용할 수 있습니다.

	@Test
    void constructorRefTest() {
        /*
        * get()을 호출할 경우 새로운 Apple 객체 생성
        * */
        Supplier<Apple> c1 = Apple::new;
        Apple a1 = c1.get();


        /*
        * 110을 인수로 받는 생성자를 선택하고
        * 해당 인수의 값을 가지는 새로운 Apple 객체 생성
        * */
        Function<Integer, Apple> c2 = Apple::new;
        Apple a2 = c2.apply(110);


        /*
        * 두 가지 인수를 받는 생성자를 선택하고
        * 해당 인수의 값을 가지는 새로운 Apple 객체 생성
        * */
        BiFunction<Color, Integer, Apple> c3 = Apple::new;
        Apple a3 = c3.apply(Color.GREEN, 110);
    }

이렇게 Chapter3를 통해 람다 표현식부터 생성자 참조까지 사용법을 알아보았습니다.