자바 뚝딱거리기

[JAVA] 람다 표현식

bimppap 2021. 3. 1. 21:26

더 간결하고 유연한 코드를 구현하는 방법이 있을까?

자바 8이 새로 선보인 기능, 람다 표현식을 소개해본다.

 

람다 표현식 lambda expression
메소드로 전달할 수 있는 익명 함수를 단순화한 것

람다 표현식은 코드 단순화에 존재 의의를 두고 있다.

즉, 동작 파라미터 형식의 코드를 더 쉽게 구현 및 전달할 수 있고 코드를 간결하고 유연해지게 만든다.

 

1. 특징 - 람다 표현식은...

  • 익명
    보통의 메소드와 달리 이름이 없다.

  • 함수
    메소드처럼 클래스에 종속되지 않는다. 하지만 메소드처럼 파라미터 리스트, 바디, 반환 형식, 가능한 예외 리스트를 포함한다.

  • 전달
    인수로 전달하거나 변수로 저장할 수 있다.

  • 간결성
    자질구레한 코드를 구현할 필요가 없다.

2. 구조 및 문법 - 람다 사용하기

어떻게 사용하길래 람다 표현식이 간결하다고 하는 걸까?

예시로 사과의 무게를 비교하는 코드를 보자.

Comparator<Apple> byWeight = new Comparator<Apple> {
	public int compare(Apple a1, Apple a2) {
    	return a1.getWeight().compareTo(a2.getWeight());
    }
};

위 코드를 람다로 바꾸면 이런 식으로 써진다.

Comparator<Apple> byWeight = 
	(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

new 선언도 없고, 메소드 명도 없고, return을 명시하지도 않았지만 기존의 코드와 똑같은 기능을 수행한다.

람다의 구조를 뜯어보자.

 

  • 파라미터 리스트
    기존 메소드의 파라미터 역할을 한다. 여기선 Apple 2개를 파라미터로 받는다.

  • 화살표
    람다의 파라미터 리스트와 바디를 구분한다.

  • 람다 바디
    반환값에 해당하는 표현식이다. 여기선 두 사과의 무게를 비교한다.

람다의 문법, 즉 표현식은 크게 두 가지 방법으로 나뉜다.

// 표현식 스타일
(parameters) -> expressions

// 블록 스타일
(parameters) -> { statements; }

/*
* 사용예시
*/

// 파라미터가 없으며 문자열을 반환
() -> "I am Apple"

// 파라미터가 없으며 '명시적으로 return 문을 이용해' 문자열을 반환
() -> { return "I am Peach"; }

 

3. 어디서 사용하지? - 함수형 인터페이스

함수형 인터페이스 Functional Interface
단 하나의 추상 메소드를 지정하는 인터페이스

람다 표현식으로 함수형 인터페이스의 추상 메소드 구현을 직접 전달할 수 있다.

즉, 람다의 전체 표현식을 함수형 인터페이스의 인스턴스로 취급할 수 있다. 이게 무슨 소린가 싶을 것이다. 아래 예시를 보자.

// 유일한 추상 메소드 run()을 가지는 함수형 인터페이스 Runnable
Runnable r1 = () -> System.out.println("Hello, World!");

public static void process(Runnable r) {
	r.run();
}

process(r1);                                         // ㄱ
process(() -> System.out.println("Hello, World!"));  // ㄴ

ㄱ와 ㄴ은 동일하게 Hello, World! 를 출력한다.

하지만 ㄱ은 Runnable r1을 상단에서 미리 초기화를 한 것이고,

ㄴ은 람다를 직접 써서 인스턴스로서 전달한다. 함수형 인터페이스의 추상 메서드와 같은 시그니처를 가지고 있다면 이런 사용이 가능하다.

 

여기서 잠깐!  시그니처 Signature 란? 
메소드의 정의에서 메소드의 이름과 파라미터의 조합
(+) 람다 표현식의 시그니처를 서술하는 메소드를 함수 디스크립터 Function Descriptor 라고 한다.

 

4. 람다를 사용해보자! - 실행 어라운드 패턴 을 기반으로 한 로또 티켓 구매 기능 구현을 예시로

로또 티켓을 구매한다고 해보자.

티켓을 구매하는 방법은 수동과 자동, 총 두 가지로 직접 번호를 입력하거나 자동으로 입력 번호값이 적힌 티켓을 사는 것이다.

아래 코드를 보자. (코드 출처는 여기)

// 수동 티켓을 입력받는 메소드
private List<LottoTicket> inputManualTickets(int count) {
    List<LottoTicket> tickets = new ArrayList<>();
    IntStream.rangeClosed(1, count)
        .forEach(index -> tickets.add(new LottoTicket(InputView.inputNumbers())));
    return tickets;
}

// 자동 티켓을 입력받는 메소드
private List<LottoTicket> inputRandomTickets(int count) {
    List<LottoTicket> tickets = new ArrayList<>();
    IntStream.rangeClosed(1, count)
        .forEach(index -> tickets.add(new LottoTicket(RandomUtils.generateNumbers())));
    return tickets;
}

두 메소드는 List<Integer>를 반환하는 InputView.inputNumbers()RandomUtils.generateNumbers 를 제외한 부분은 정확하게 일치한다. 이를 실행 어라운드 패턴으로 설명해보겠다.

실행 어라운드 패턴 Execute Around Pattern
실제 자원을 처리하는 코드를 설정과 정리 두 과정이 둘러싸는 형태
  1. 설정 setup : 티켓을 담을 ArrayList를 생성한다. (중복)

  2. 처리 execute : 파라미터로 들어온 값만큼 수동/자동 티켓을 만든다.

  3. 정리 cleanup : 티켓이 담긴 ArrayList(tickets)를 반환한다. (중복)

그림으로 표현한 실행 어라운드 패턴. 동일한 설정, 정리 코드가 다른 작업을 감싸고 있다.

이런 식으로 중복되는 코드를 보면 중복을 없애고 싶어 안달이 날 것이다. 여기서 떠올려야 할 것은 동작 파라미터화다.

처리 과정을 파라미터화하면 되지 않는가? 하지만 어떻게? 이번에는 함수형 인터페이스를 떠올릴 차례다.

 

함수형 인터페이스 중에는 파라미터가 없으며 반환값이 있는 Supplier 가 있다. 추상 메소드는 get을 가지고 있다.

(더 다양한 함수형 인터페이스는 여기를 참고)

여기에 기반해 파라미터를 받지않고 List<Integer>를 반환하는 함수형 인터페이스를 정의해보자.

@FunctionalInterface
public interface NumbersSupplier {
	List<Integer> get();
}

이후 위의 두 메소드를 이렇게 하나로 합칠 수 있다.

private List<LottoTicket> inputTickets(int count, NumbersSupplier n) {
    List<LottoTicket> tickets = new ArrayList<>();
    IntStream.rangeClosed(1, count)
        .forEach(index -> tickets.add(new LottoTicket(n.get())));
    return tickets;
}

// 사용 예시
inputTickets(3, () -> InputView.inputValue());          // 수동 티켓 3장 생성
inputTickets(7, () -> RandomNumbers.generateNumbers()); // 자동 티켓 7장 생성

사용법은 여기까지. 이제 좀 더 어려운 얘기를 해보자.

 

5. 형식 검사, 추론, 그리고 제약

3. 어디서 사용하지? 에서 설명했다시피 람다로 함수형 인터페이스의 인스턴스를 만들 수 있다.

그러나 람다 표현식 자체만 보면 어떤 함수형 인터페이스를 구현하는지 알 수 없다.

대신 람다가 사용되는 콘텍스트context를 이용해 람다의 형식을 추론할 수 있다.

이 때, 추론되는 람다의 형식을 대상 형식이라 한다.

대상 형식 target type
어떤 콘텍스트에서 기대되는 람다 표현식의 형식

하지만 추론만 하고 끝나서는 안된다. 추론된 것이 맞는지 검사를 해야한다. 다행히 이건 우리의 몫이 아닌 컴파일러의 몫이다.

컴파일러가 어떤 과정을 통해 대상 형식을 검사하는지 살펴보자. 위에서 쓰인 로또 티켓 구매 메소드를 예시로 들겠다.

List<LottoTicket> tickets = 
inputTickets(3, () -> InputView.inputValue());
  1. inputTickets 메소드의 선언을 확인한다.
  2. inputTicekts 메소드는 두 번째 파라미터로 Supplier<List<Integer>> 형식(대상 형식)을 기대한다.
  3. Supplier<List<Integer>>는 get이라는 단 하나의 추상 메소드를 정의하는 함수형 인터페이스이다.
  4. get 메소드는 파라미터 없이 생성된 객체를 반환하는 함수 디스크립터를 묘사한다.
  5. inputTicket 메소드로 전달된 인수를 이와 같은 요구사항을 만족해야 한다.

대상 형식이라는 특징 때문에 같은 람다 표현식이라도 호환되는 추상 메소드를 가진 다른 함수형 인터페이스로 사용될 수 있다.

예시로 Callable과 PrivilegedAction 인터페이스는 둘 다 인수를 받지 않고 제네릭 형식 T를 반환하는 함수를 정의한다.

따라서 아래와 같이 써도 오류 없이 사용이 가능하다.

Callable<Integer> c = () -> "Hello, World!";
PrivilegedAction<Integer> p = () -> "Hello, World!";

 

6. 메소드 참조

메소드 참조
특정 메소드만을 호출하는 람다의 축약형

람다가 '이 메소드를 직접 호출해' 라고 명령한다면 메소드를 어떻게 호출해야 하는지 설명을 참조하기 보다는 메소드명을 직접 참조하는 게 편리하다. 명시적으로 메소드명을 참조함으로써 가독성을 높일 수 있다. 또한 메소드 구현을 재사용하고 직접적인 전달이 가능하다.

 

예시로 위의 티켓 구매 사용 예시는 메소드 참조를 통해 이렇게 쓸 수 있다.

inputTickets(3, InputView::inputValue);          // 수동 티켓 3장 생성
inputTickets(7, RandomNumbers::generateNumbers); // 자동 티켓 7장 생성

주목할 점은 실제로 메소드 호출을 하는 것이 아니기에 괄호를 쓰지 않아도 된다.

 

메소드 참조의 유형은 총 4가지 이다.

첫 번째 줄은 기존 코드, 두 번째 줄은 메소드 참조 코드로 보여주겠다.

1. 정적 메소드 참조

(int i) -> Integer.parseInt(i)
Integer::parseInt

2. 다양한 형식의 인스턴스 메소드 참조

(String s) -> String.length(s)
String::length

3. 기존 객체의 인스턴스 메소드 참조

() -> expensiveTransaction.getValue
expensiveTransaction::getValue

4. 생성자 참조

() -> new Apple();
Apple::new

 

참고

[ 모던 자바 인 액션 ] Ch3. 람다 표현식