[도서][모던 자바 인 액션] - 람다 표현식

Junseo Kim·2021년 2월 24일
0

람다란?

  • 깔끔한 코드로 동작을 구현하고 전달하는 기능.
  • 이름이 없는 함수면서 메서드를 인수로 전달할 수 있다.
  • 익명 함수의 일종. 메서드로 전달할 수 있는 익명 함수를 단순화한 것.
  • 이름은 없지만, 파라미터 리스트, 바디, 반환 형식, 발생할 수 있는 예외 리스트는 가질 수 있다.

특징

  • 익명: 일반 메서드와 다르게 이름이 없다.
  • 함수: 메서드와 다르게 특정 클래스에 속해있지 않기 때문에 함수라 명명한다.
  • 전달: 람다식을 메서드 인수로 전달하거나, 변수로 저장할 수 있다.
  • 간결성: 익명 클래스처럼 지저분하게 코드를 작성할 필요가 없다.

람다식을 사용하는 것의 큰 장점은 코드가 간결하고 유연해진다는 것이다.

구조

람다식은 파라미터, 화살표, 바디 3부분으로 구성되어 있다. 화살표를 기준으로 왼편이 파라미터, 오른편이 바디이다.

(parameters) -> body
(parameters) -> { statements; }
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
  • 파라미터 리스트: 메서드의 파라미터
  • 화살표: 파라미터 리스트와 바디를 구분
  • 바디: 람다의 반환값에 해당하는 표현식. return이 함축되어 있기 때문에 명시해주지 않는다(명시해도 상관은 없지만 이 경우 {}로 바디를 감싸준다.). 여러 행으로 나타낼 수도 있는데 이때는 {}로 바디를 감싸준다.

어디에 사용할까?

함수형 인터페이스라는 문맥에서만 람다 표현식을 사용할 수 있다. 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있기 때문에 전체 표현식을 함수형 인터페이스의 인스턴스로 취급(함수형 인터페이스를 구현한 클래스의 인스턴스) 할 수 있다.

함수 디스크립터

함수형 인터페이스의 추상 메서드는 람다 표현식의 시그니처를 묘사한다. 함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터라고 부른다.
즉, 함수 디스크립터 == 람다 표현식의 시그니처이다.

// 예제
() -> void
(Apple, Apple) -> int
T -> boolean

다양한 람다 표현식을 사용하기 위해서는 공통의 함수 디스크립터를 기술하는 함수형 인터페이스 집합이 필요하다. 자바 8부터 여러가지 새로운 함수형 인터페이스를 제공해준다.

람다의 형식 검사 과정

람다 표현식 자체에는 람다가 어떤 함수형 인터페이스를 구현하는지의 정보가 포함되어 있지 않다. 따라서 아래의 과정을 거쳐 형식을 검사한다.

  1. 호출된 메서드의 선언을 확인
  2. 메서드가 기대하는 함수형 인터페이스 타입 확인
  3. 함수형 인터페이스의 추상 메서드 확인
  4. 함수형 인터페이스의 추상 메서드의 함수 디스크립터 확인
  5. 호출 시 전달된 인수가 함수 디스크립터를 만족하는지 확인

예외 처리

람다 표현식이 예외를 던질 수 있다면 추상 메서드도 같은 예외를 던질 수 있도록 throws로 선언해야한다.

위 문장이 한 번에 이해가 가지 않아 직접 해보았다.

아래와 같은 함수형 인터페이스를 만들고 람다 표현식으로 테스트해보았다.

public interface StringPredicate {
    boolean test(String string);
}

일단 Unchecked Exception은 따로 throws를 붙여주지 않아도 상관없었다.

private void testMethod(StringPredicate stringPredicate) {
}
        
testMethod((String string) -> {
   throw new RuntimeException();
});

하지만 Checked Exception은 throws를 붙여주지 않으니 컴파일에러가 발생했다.

testMethod((String string) -> {
    throw new IOException(); // 컴파일 에러 발생
});

이경우는 함수형 인터페이스의 추상메서드에 throws를 붙여줘야했다.

public interface StringPredicate {
    boolean test(String string) throws IOException;
}

그럼 기존의 Predicate를 사용해보자. 기존 Predicate는 추상 메서드에 throws가 붙어 있지 않다.

@FunctionalInterface
public interface Predicate<T> {

    /**
     * Evaluates this predicate on the given argument.
     *
     * @param t the input argument
     * @return {@code true} if the input argument matches the predicate,
     * otherwise {@code false}
     */
    boolean test(T t);

따라서 이 경우 Unchecked Exception은 상관 없지만, 람다식에서 Checked Exception이 발생할 경우 람다 내부에서 try-catch로 처리해주지 않으면 컴파일 에러가 발생한다.

private static void testMethod(Predicate<String> predicate) {
}

testMethod((String string) -> {
    try {
        throw new IOException();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
});

같은 람다식이라도 다른 함수형 인터페이스로 사용 가능

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

Callable<Integer> c = () -> 42;
PrivilegedAction<Integer> p = () -> 42;
Supplier<Integer> s = () -> 42;

다이아몬드 연산자

다이아몬드 연산자는 말 그대로 다이아몬드 처럼 생겼다. <>

아래 코드는 둘다 = new ArrayList<>();로 표현되어 있지만 앞의 콘텍스트를 보고 타입을 추론한다.

List<String> stringList = new ArrayList<>();
List<Integer> intergerList = new ArrayList<>();

특별한 void 호환 규칙

람다 바디에 일반 표현식이 들어가는 경우 void를 반환하는 함수 디스크립터와 호환된다. 자바의 List의 add메서드는 boolean을 리턴하지만 T -> void인 Consumer< T >를 호환한다.

Predicate<String> p = s -> list.add(s); // T -> boolean
Consumer<String> b = s -> list.add(s); // T -> void

함수 디스크립터가 같은 경우

두 함수형 인터페이스의 함수 디스크립터가 같은 경우. 람다 표현식이 같기 때문에 누구를 가리키는지 명확하지 않다. 이때 캐스트를 해주면 명확하게 알 수 있다.

execute((Runnable) () -> {});
// Runnable로 캐스트 했기 때문에 호출됨
public void execute(Runnable runnable) { 
    System.out.println("Runnable");
}

public void execute(Action action) {
    System.out.println("Action");
}

@FunctionalInterface
interface Action {
    void act();
}

형식추론

자바 컴파일러는 람다 표현식이 사용된 콘텍스트를 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론할 수 있다.

즉, 대상 형식(콘텍스트)를 이용해서 함수 디스크립터를 알 수 있으므로 컴파일러가 람다의 시그니처도 추론할 수 있다. 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 파라미터 타입을 생략할 수 있다.

// 형식 추론 x
Comparator<Apple> c = 
	(Apple a1, Apple a2) -> a1.getWeight().comareTo(a2.getWeight());

// 형식 추론 o
Comparator<Apple> c = 
	(a1, a2) -> a1.getWeight().comareTo(a2.getWeight()); // Comparator<Apple> 이므로 a1과 a2가 Apple 타입임을 추론

람다 캡처링

람다식에서 외부 변수를 사용할 수 있다.

int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);

인스턴스 변수와 정적 변수는 제약없이 캡처해서 사용할 수 있다. 하지만 지역 변수는 명시적으로 final로 선언되거나 실질적으로 final로 선언된 변수와 같이 사용되어야 한다. 즉, 지역변수는 한 번만 할당할 수 있어야 캡처할 수 있다는 제약이 있다.

아래와 같이, 인스턴스 변수와, 정적 변수는 값이 바뀌어도 캡처를 할 수 있다.

public class TestClass {
    private int instanceValue = 1;

    private static int staticValue = 2;

    public void test() {
        instanceValue = 2;
        staticValue = 2;

        Runnable r1 = () -> System.out.println(instanceValue);
        Runnable r2 = () -> System.out.println(staticValue);
        
        instanceValue = 3;
        staticValue = 3;
    }
}

하지만 지역 변수는 값이 바뀌면 캡처가 불가능하다.

int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber); // portNumber가 final 같이 사용되지 않으므로 컴파일 에러 발생
portNumber = 1; 

왜 지역 변수만 이런 제약이 있는 걸까?

내부적으로 인스턴스 변수에 저장되고, 지역 변수스택에 위치한다.

만약 람다가 지역 변수에 바로 접근할 수 있다면, 변수를 할당한 스레드가 사라져서 변수 할당이 해제되어도 람다를 실행한 스레드에서는 해당 변수에 접근하려 할 수 있다.

따라서 자바에서는 원래 변수에 접근을 허용하지 않고 자유 지역 변수의 복사본을 제공한다. 복사본은 값이 바뀌지 않아야 하기 때문에 지역 변수에 이런 제약이 생긴 것이다.

람다는 변수가 아닌 값에 국한되어 동작을 수행한다.

메서드 참조

특정 메서드만을 호출하는 람다의 축약형. 기존의 메서드 정의를 재활용해서 람다 처럼 전달할 수 있게 해주는 것.

어떻게라기보다 무엇을! 명시적으로 메서드 명을 참조함으로써 가독성을 높일 수 있다.

사용방법

메서드 명 앞에 구분자 ::를 붙이는 방식으로 메서드 참조를 사용. 실제로 메서드를 호출하는 것은 아니므로 괄호는 필요없다.

(Apple a) -> a.getWeight() // 람다식

Apple::getWeight // 메서드 참조. Apple 클래스에 정의 된 getWeight메서드 참조

3가지 유형

1) 정적 메서드 참조

(args) -> ClassName.staticMethod(args) // 람다식
ClassName::staticMethod // 메서드 참조

(String s) -> Integer.parseInt(s) // 람다식
Integer::parseInt // 메서드 참조

2) 다양한 형식의 인스턴스 메서드 참조

(arg0, rest) -> arg0.instanceMethod(rest) // 람다식. arg0은 ClassName 형식
ClassName::instanceMethod // 메서드 참조

(String s) -> s.length() // 람다식
String::length // 메서드 참조

(String s1, String s2) -> s1.split(s2) // 람다식
String::split // 메서드 참조

(String s1, String s2) -> s2.split(s1) // s1과 s2의 위치를 바꾼 경우 메서드 참조 불가능 

3) 기존 객체의 인스턴스 메서드 참조

(args) -> expr.instanceMethod(args) // 람다식
expr::instanceMethod // 메서드 참조

Transaction expensiveTransaction = new Transaction(1);

() -> expensiveTransaction.getValue() // 람다식
expensiveTransaction::getValue // 메서드 참조

(int value) -> expensiveTransaction.setValue(value) // 람다식
expensiveTransaction::setValue // 메서드 참조

컴파일러는 람다 표현식의 형식을 검사하던 방식과 비슷한 과정으로 메서드 참조가 주어진 함수형 인터페이스와 호환하는지 확인하다. 즉, 메서드 참조는 콘텍스트의 형식과 일치해야 한다.

생성자 참조

아래와 같이 기존 생성자의 참조를 만들 수도 있다.

ClassName::new

BiFunction<Color, Integer, Apple> c3 = Apple::new;
Apple a3 = c3.apply(GREEN, 110);

람다 표현식 조합

Comparator

inventory.sort(comparing(Apple::getWeight) // 무게 기준으로 정렬
	.reversed() // 무게를 내림차순으로
    	.thenComparing(Apple::getCountry)); // 무게가 같은 경우 국가 별로 정렬

Predicate

Predicate<Apple> notRedApple = redApple.negate(); // 기존 Predicate 객체인 redApple의 결과를 반대로 만든다.

Predicate<Apple> redAndHeavyAppleOrGreen = 
	redApple.and(apple -> apple.getWeight() > 150) // 빨간색이면서 무거운 사과
    		.or(apple -> GREEN.equals(a.getColor())); // 빨간색이면서 무거운 사과 또는 그냥 녹색 사과

Function

andThen: 주어진 함수를 먼저 적용한 후 그 결과를 다른 함수의 입력으로 전달

compose: 인수로 주어진 함수를 먼저 실행한 다음에 그 결과를 외부 함수의 인수로 제공. andThen과 반대로 생각.

Function<String, String> addHeader = Letter::addHeader;
Function<String, String> transformationPipeline = 
	addHeader.andThen(Letter::checkSpelling) // 헤더를 더하고, 스펠링체크를 하고
    		 .andThen(Letter::addFooter); // 푸터를 더해라

0개의 댓글