모던자바인액션 - 3

이건희·2023년 7월 5일
2

모던자바인액션

목록 보기
3/9

챕터 3은 Java 8에 추가된 기능인 Lambda(람다)에 관한 내용이다. 이전 공부할때 작성한 내용에도 조금 다뤘었지만 오늘은 조금 더 자세하고 깊게 정리해보자.

Lambda(이하 람다)란?

  • Java 8에 추가된 새로운 기능
  • 메서드로 전달할 수 있는 익명 함수를 단순화 한 것
  • 간결한 방식으로 코드를 전달할 수 있다

람다는 기술적으로 Java 8 이전의 자바로 할 수 없었던 일을 제공하는 것이 아닌 조금 더 간결한 방식으로 코드를 작성할 수 있게 도와주는 표현 방법이다.


람다의 구성 부분

우선 람다가 어떻게 구성되어 있는지 확인해보자. 람다는 아래와 같이 세 부분으로 이루어진다.

(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
  1. 람다 파라미터
  2. 화살표
  3. 람다 바디

람다의 기본 문법

  1. 표현식 스타일의 람다 (expression style)
(parameters) -> expression
  1. 블록 스타일의 람다 (block-style)
(parameters) -> { statement; }

표현식(expression)은 값을 반환하는 코드 조각이다.

  • (ex. 3 + 4, x * y, Math.sqrt(16))

구문(statement)은 프로그램의 실행 흐름을 제어하는 코드의 일부분이다. ;로 끝나는 경우가 많다.

  • (ex. int x = 5;, if (x > 0) { ... } 등)

구문은 대개 표현식을 포함한다.


람다는 어디에 어떻게 사용될까?

람다는 함수형 인터페이스라는 문맥에서 사용한다! 그러면 함수형 인터페이스는 무엇일까?

함수형 인터페이스

함수형 인터페이스는 오직 하나의 추상 메서드만 가지는 인터페이스이다 !
예를 들어 다음은 함수형 인터페이스이다.

public interface Comparator<T> {
	int compare(T o1, T o2);
}

인터페이스 Comparator는 오직 하나의 추상 메서드인 compare을 가지므로 함수형 인터페이스이다.
여기서 중요한 것은, 여러개의 디폴트 메서드를 가지더라도 오직 하나의 추상 메서드를 가지면 함수형 인터페이스이다 !

갑자기 람다를 설명하는데 함수형 인터페이스가 왜 나왔을까?

함수형 인터페이스의 추상 메서드를 구현하는 방식이 람다이다 !

즉, 람다의 전체 표현식을 함수형 인터페이스의 인스턴스로 취급(기술적으로 따지면 함수형 인터페이스를 구현한 클래스의 인스턴스)한다.

아직 말로만 봐서는 무슨 뜻인지 잘 와닿지 않는다. 아래 예제를 보며 이해해보자.

Runnable은 하나의 추상 메서드인 run을 가지고 있다.

Runnable r1 = () -> System.out.println("Hello World 1"); //람다 사용하여 run 구현

Runnable r2 = () -> new Runnable() { //익명 클래스 사용하여 run 구현
	public void run() {
    	System.out.println("Hello World 2");
    }
};

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

process(r1); //Hello World 1
process(r2); //Hello World 2
process(() -> System.out.println("Hello World 3")); //Hello World 3

람다의 전체 표현식이 함수형 인터페이스의 인스턴스로 취급되므로,
마지막 process(() -> System.out.println("Hello World 3"));은 Runnable의 run을 구현한 인스턴스로 취급된다.

++ @FunctionalInterface 어노테이션을 사용해 함수형 인터페이스임을 선언하고, 함수형 인터페이스가 아닐 시 에러를 발생시킨다. 즉, @FunctionalInterface는 해당 인터페이스가 함수형 인터페이스인지 확인해준다.


함수 디스크립터

함수형 인터페이스의 추상 메서드 시그니처는 람다 표현식의 시그니처를 가르킨다.

함수 시그니처란, 함수의 이름, 매개변수 목록, 반환 타입 등 함수의 주요 구성 요소를 의미한다. 따라서 람다 표현식의 시그니처는 추상 메서드의 시그니처와 동일해야한다. 그리고 람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터라고 한다.

즉, 함수 디스크립터는 함수형 인터페이스의 추상 메서드 시그니처를 의미한다.


미리 구현된 함수형 인터페이스

람다를 사용하고 싶을 때마다 함수형 인터페이스를 생성하는 것은 매우 번거로운 일이다.

자바 API는 이미 Comparable, Runnable, Callable 등 다양한 함수형 인터페이스를 포함하고 있기 때문에 본인의 상황에 맞는 함수 디스크립터의 인터페이스를 선택해 바로 사용할 수 있다.

1. Predicate

  • 추상 메서드 test 정의
  • 제네릭 형식 T의 객체를 인수로 받아 boolean 반환
  • <T> -> boolean

    예제

    @FunctionalInterface
    public interface Predicate<T> {
    	boolean test(T t);
    }
    
    public <T> List<T> filter(List<T> list, Predicate<T> p) {
    	List<T> results = new ArrayList<>();
    	for(T t: list) {
      		if(p.test(t)) {
              	results.add(t);
            }
        }
        return results;
    }
    
    Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
    List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);
    • nonEmptyStringPredicate는 람다를 이용해 test를 정의한 인스턴스
    • filter 메서드의 파라미터 Predicate<T>에 nonEmptyStringPredicate를 넘겨주어 구현된 test 사용

2. Consumer

  • 추상 메서드 accept 정의
  • 제네릭 형식 T의 객체를 인수로 받아 void 반환
  • <T> -> void

3. Function

  • 추상 메서드 apply 정의
  • 제네릭 형식 T의 객체를 인수로 받아 제네릭 형식 R 반환
  • <T> -> <R>

위 3개 이외에도 Supplier<T>, UnaryOperator<T>, BinaryOperator<T> 등 여러가지가 존재한다. 해당 인터페이스를 사용할 일이 있을때 찾아서 사용하자.

기본형 특화 함수형 인터페이스

위의 세 개의 제네릭 함수 인터페이스 이외에도 기본형에 특화된 함수형 인터페이스가 존재한다.

  • 제네릭에서는 참조형(Integer, Byte, Object, List 등)만 사용
  • 오토박싱(기본형 -> 참조형 변환) 과정에서 비용이 발생

따라서 오토박싱 동작을 피할 수 있도록 여러가지 함수형 인터페이스를 제공한다.

일반적으로 특정 형식을 입력으로 받는 함수형 인터페이스의 이름 앞에는 DoublePredicate, IntConsumer, LongBinaryOperator, IntFunction처럼 형식명이 붙는다.

지금은 이러한 개념이 있다 정도로만 알고 나중에 사용할 일이 있을때 해당 인터페이스를 찾아서 사용하면 될 것 같다.


예외(Exception), 람다, 함수형 인터페이스의 관계

함수형 인터페이스는 확인된 예외를 던지는 동작을 허용하지 않는다.
즉, 예외를 던지는 람다 표현식을 만들려면 확인된 예외를 선언하는 함수형 인터페이스를 직접 정의하거나, 람다를 try/catch 블록으로 감싸야한다.

  1. 확인된 예외를 선언하는 함수형 인터페이스
@FunctionalInterface
public interface BufferedReaderProcessor {
	String process(BufferedReader b) throws IOException;
}

BufferedReaderProcessor p = (BufferedReader br) -> br.readLine();
  1. 람다를 try/catch 블록으로 감싸기
Function<BufferedReader, String> f = (BufferedReader b) -> {
	try {
    	return b.readLine();
    }
    catch(IOException e) {
    	throw new RuntimeException(e);
    }
};

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

람다 표현식을 처음 설명할 때 람다로 함수형 인터페이스의 인스턴스를 만들 수 있다고 언급했다.
람다 표현식 자체에는 람다가 어떤 함수형 인터페이스를 구현하는지의 정보가 포함되어 있지 않다.

형식 검사

람다가 사용되는 컨텍스트(Context)를 이용해서 람다의 형식(type)을 추론할 수 있다.
어떤 컨텍스트(람다가 전달될 메서드 파라미터나 람다가 할당되는 변수 등)에서 기대되는 람다 표현식의 형식을 대상 형식(Target type)라고 한다.

이 역시 예제를 통해 이해해보자.

List<Apple> heavierThan150g = 
	filter(inventory, (Apple apple) -> apple.getWeight() > 150);

위 코드는 다음 순서대로 형식 확인 과정이 진행된다.

  1. filter 메서드 선언 확인
  2. filter 메서드는 두번째 파라미터로 Predicate<Apple> 형식(대상 형식)을 기대한다.
  3. Predicate<Apple>은 test라는 한개의 추상 메서드를 정의하는 함수형 인터페이스이다.
  4. test 메서드는 Apple을 받아 boolean을 반환하는 함수 디스크립터이다.
  5. filter 메서드로 전달된 인수는 이와 같은 요구사항을 만족해야 한다.

형식 추론

위에서 말했듯이, 자바 컴파일러는 람다 표현식이 사용된 콘텍스트(대상 형식)을 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다. 즉, 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론할 수 있다.

결과적으로 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 이를 생략할 수 있다.

List<Apple> greenApples =
	filter(inventory, apple -> Green.equals(apple.getColor()));
    //Apple apple > apple로 형식 생략
Comparator<Apple> c =
	(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()); 
    //형식을 추론하지 않음
    
Comparator<Apple> c =
	(a1, a2) -> a1.getWeight().compareTo(a2.getWeight());
    //형식 추론

이를 통해 가독성을 증가시킬 수 있다.

제약 - 지역 변수 사용

지금까지 람다 표현식은 인수를 자신의 바디 안에서만 사용했다.

하지만 람다 표현식에서는 익명 함수가 하는 것처럼 자유 변수(파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수 - free variable)를 활용할 수 있다. 이와 같은 동작을 람다 캡처링(capturing Lambda)라고 한다.

람다는 인스턴스 변수와 정적 변수를 자유롭게 자신의 바디에서 참조할 수 있는데, 두가지의 제약이 있다.

  1. 지역 변수는 명시적으로 final로 선언되어 있어야 한다.
  2. 실질적으로 final로 선언된 변수와 똑같이 사용되어야 한다.

즉 람다 표현식은 한번만 할당할 수 있는 지역 변수를 캡처할 수 있다. (한번 할당된 값을 변경할 수 없는 상태여야 한다.)


메서드 참조

  • 특정 람다 표현식을 축약한 것
  • 이미 정의된 메서드를 참조하여 함수형 인터페이스의 추상 메서드 구현
  • 기존의 메서드 정의를 재활용하여 람다처럼 전달

람다가 '이 메서드를 직접 호출해'라고 명령한다면 메서드를 어떻게 호출하는지 설명을 참조하기보다 메서드명을 직접 참조하는 것이 편리하다. 이때 명시적으로 메서드명 앞에 구분자(::)를 붙이는 방식으로 메서드를 참조하므로써 가독성을 높일 수 있다.

예제를 보며 이해해보자

예제

ToIntFunction<String> stringToInt = (String s) -> Integer.parseInt(s);
>>ToIntFunction<String> stringToInt = Integer::parseInt;

BiPredicate<List<String>, String> contains = (list, element) -> list.contains(element);
>>BiPredicate<List<String>, String> contains = List::contains;

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

따라서 메서드 참조는 컨텍스트의 형식과 일치해야 한다.

생성자 참조

ClassName::new 처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 참조를 만들 수 있다.

예제

예를 들어 인수가 없는 생성자가 다음과 같이 있다고 가정하자.

Supplier<Apple> c1 = Apple::new; //Supplier의 추상 메서드 get 구현
Apple a1 = c1.get(); //Supplier의 get메서드를 호출해서 새로운 Apple 객체 생성

아래 코드는 위와 동일하다

Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get();

Apple(Integer weight)라는 시그니처를 갖는 생성자는 Function 인터페이스를 이용할 수 있다.

Function<Integer, Apple> c2 = Apple::new //생성자 참조하여 추상메서드 apply 구현
Apple a2 = c2.apply(110); //무게를 인수로하여 Apple 객체 생성

이외에도 여러가지 인수를 받는 생성자는 따로 함수형 인터페이스를 구현하여 적용할 수 있다.


람다, 메서드 참조 활용하기

sort가 다음과 같은 시그니처를 갖는다 했을 때, 사과 리스트 정렬 문제를 해결해보자.

  void sort(Comparator<? super E> c)

1. 코드 전달

public class AppleComparator implements Comparator<Apple> {
	public int compare(Apple a1, Apple a2) {
			return a1.getWeight().compareTo(a2.getWeight());
	}
}

inventory.sort(new AppleComparator());
  • 따로 Comparator를 구현한 클래스를 만들고 sort의 인자에 구현 객체를 넣어 주었다. 하지만 이는 가독성도 떨어지고 코드의 복잡성도 높다.

2. 익명 클래스 사용

inventory.sort(new Comparator<Apple>() {
	public int compare(Apple a1, Apple a2) {
		return a1.getWeight().compareTo(a2.getWeight());
	}
});
  • 익명 클래스로 따로 클래스를 만들지 않고 바로 객체를 생성해 인자로 넣었다. 하지만 이도 가독성이 좋지 않고 장황하다.

3. 람다 표현식 사용

inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));

>>형식 추론에 의해 아래도 같은 코드

inventory.sort((a1, a2) -> a1.getWeight().compareTo(a2.getWeight()));
  • 람다를 통해 Comparator( (T, T) -> int )의 추상 메서드(compare)를 구현한 인스턴스를 바로 넣어주었다.
  • 함수형 인터페이스를 기대하는 곳 어디에서 람다 표현식을 사용할 수 있으므로, 가독성이 매우 향상 되었다.

4. 메서드 참조 이용

inventory.sort(comparing(Apple::getWeight));
  • comparing 메소드를 사용하여 getWeight를 기준으로 정렬하는 Comparator 인스턴스를 생성하고, 그것을 사용해 inventory 리스트를 정렬

  • 참고 : Comparator는 추상 메소드 compare을 가지고 있다. comparing은 Comparator 인터페이스의 정적 메소드이고, 이 메소드는 객체를 비교하는 기준(keyExtractor)을 받아 해당 기준에 따라 동작하는 Comparator 인스턴스를 생성하여 반환한다. 따라서 위 람다처럼 compare 메소드를 구현하는 것과는 관계가 없다. 하지만 sort의 시그니처가 Comparator의 인스턴스를 인수로 기대하기 때문에 compare을 람다로 구현해서 넘겨주는 방법과 comparing 메소드를 사용하는 방법 둘 다 사용 가능하다.
    위 경우에서는 comparing에서 Apple의 getWeight 메소드를 인수로 넘겨주었기 때문에 Weight으로만 정렬 기준을 판단하겠다는 의미이다.

profile
백엔드 개발자가 되겠어요

0개의 댓글