자바/모던 자바 인 액션

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

Rudtjs 2022. 9. 8. 16:59

2장에서는 클라이언트의 요구사항에 효과적으로 대응할 수 있는 코드를 동작 파라미터화를 이용하여 만들어 보았습니다. 이번에는 람다 표현식을 어떻게 만들고 어떻게 사용하는지 설명해보겠습니다.

 

3.1 람다란 무엇인가?

람다 표현식은 메서드로 전달할 수 있는 익명 함수를 단순화한 것이라고 할 수 있다.

람다의 특징

  • 익명: 보통의 메서드와 달리 이름이 없어 익명이라고 표현한다.
  • 함수: 람다는 메서드처럼 특정 클래스에 종속되지 않으므로 함수라고 부른다. 하지만 메서드처럼 파라미터 리스트, 바디, 반환 형식, 가능한 예외 리스트를 표현한다.
  • 전달: 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
  • 간결성: 익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다.

 

예를 들어 Comparator 객체를 이용한 사과 무게 비교에서

// 기존 코드
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());

기존 코드보다는 람다식을 활용한 코드가 훨씬 간결하다. 람다 표현식을 이용하면 compare 메서드의 바디를 직접 전달하는 것처럼 코드를 전달할 수 있다.

  • (Apple a1, Appple a2) 파라미터 리스트 : Comparator의 compare 메서드 파라미터
  • -> 화살표 : 화살표는 람다의 파라미터 리스트와 바디를 구분한다.
  • a1.getWeight()..... 람다 바디 : 두 사과의 무게를 비교한다. 람다의 반환 값에 해당하는 표현식이다.

 

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

정확히 말하면 함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다. 앞서 2장에서 설명한 예시에서 함수형 인터페이스  Predicate<T>를 기대하는 filter 메서드의 두 번째 인수로 람다 표현식을 전달했다.

아직은 이게 무슨 의미인지 알기 어려우니, 함수형 인터페이스부터 차근차근 알아가보겠습니다.

 

3.2.1 함수형 인터페이스

함수형 인터페이스는 하나의 추상 메서드를 지정하는 인터페이스다. 앞서 예시로 들었다 Predicate<T>, Comparator<T>가 이에 해당한다.

 

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

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

public interface Runnable {
  void run();
}

 

함수형 인터페이스는 뭘 할 수 있을까? 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스턴스로 취급할 수 있다.

//람다 사용
Runable r1 = () -> System.out.println("hello world");

//익명 클래스 사용
Runable r2 = new Runnable() {
  public void run() {
    System.out.println("hello world");
  }
};

 

3.2.2 함수 디스크립터

람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터라고 한다.

() -> void 표기는 파라미터 리스트가 없으며 void를 반환하는 함수를 의미한다.

 

 

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

람다와 동작 파라미터화를 이용하여 실용적인 예제를 만들어보자. (자원 처리) 자원을 열고, 처리한 다음, 자원을 닫는 순서를 만들어 보겠습니다.

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

 

3.3.1  1단계 : 동작 파라미터화

현재 코드는 파일에서 한 번에 한 줄만 읽을 수 있다. 여기서 기능을 수정하려면 어떻게 해야 할까? 

pcocessFile의 동작을 파라미터 화하는 것이다. BufferedReader를 이용해서 다르 동작을 수행할 수 있도록 processFile 메서드로 동작을 전달해야 한다.

 

두 행을 출력하는 코드가 필요하다면 BufferedReader를 인수를 받고 String으로 반환시켜주면 코드가 깔끔해질 것이다.

// 두행 출력
String result = processFile((BufferedReader br) -> br.readLine() + br.readLine());

 

 

3.3.2  2단계 : 함수형 인터페이스를  이용한 전달

함수형 인터페이스 자리에 람다를 사용할 수 있다. 따라서 BufferedReader -> String과 IOExcption을 던질 수 있는 시그니처와 일치하는 함수형 인터페이스를 만들어 보자.

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

// ProcessFile 메서드로 전달
public String processFile(BufferedReaderProcessor p) throws IOException {
	...
}

 

3.3.3  3단계 : 동작 실행

람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으며 전달된 코드는 함수형 인터페이스의 인스턴스로 전달된 코드와 같은 방식으로 처리한다. 

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

 

3.3.4  4단계 : 람다 전달

이제 람다를 이용하여 processFile 메서드로 전달할 수 있다.

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

 

3.4.0 함수형 인터페이스

Predicate

Predicate 인터페이스는 test라는 추상 메서드를 정의하면 test는 제네릭 형식 T의 객체를 인수로 받아 boolean으로 반환한다. T 형식의 객체를 사용하는 boolean 표현식이 필요할 때 이 인터페이스를 사용하면 된다.

 

Consumer

Consumer 인터페이스는 제너릭 형식 T 객체를 받아서 void를 반환하는 accept라는 추상 메서드를 정의한다. T 형식의 객체를 인수로 받아 어떤 동작을 수행하고 싶을 때 사용할 수 있다.

 

Function

function 인터페이스는 제네릭 형식 T를 인수로 받아서 제네릭 형식 R 객체를 반환하는 추상 메서드 apply를 정의한다. 입력을 출력으로 매핑하는 람다를 정의할 때 활용할 수 있다.

 

 

기본형 특화

참조형(Byte, Integer, Object, List), 기본형(int, double, byte, char)이 있다. 

제네릭 파라미터에는 참조 형만 사용할 수 있다. 하지만 자바에서는 기본형을 참조형으로 바꿔주는 박싱, 참조형을 기본형으로 바꿔주는 언박싱, 자동으로 코드를 구현해주는 오토 박싱이라는 기능을 제공해준다. 

List<Integer> list = new ArrayList<>();
for (int i = 300; i < 400; i++) {
	list.add(i);
}

원래는 int가 들어 있었는데 Integer로 박싱 된 코드이다. 이런 변화 과정은 비용이 소모된다. 

이런 비용 소모를 막기 위해 자바 8에서는 특별한 버전의 함수형 인터페이스를 제공한다.

 

함수형 인터페이스의 종류는 아래 링크 참고

https://docs.oracle.com/en/java/javase/15/docs/api/java.base/java/lang/FunctionalInterface.html

 

FunctionalInterface (Java SE 15 & JDK 15)

@Documented @Retention(RUNTIME) @Target(TYPE) public @interface FunctionalInterface An informative annotation type used to indicate that an interface type declaration is intended to be a functional interface as defined by the Java Language Specification. C

docs.oracle.com

 

 

 

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

람다가 어떤 함수형 인터페이스를 구현하는지의 정보가 포함되어 있지 않다. 따라서 람다 표현식을 제대로 이해하려면 람다의 실제 형식을 파악해야 한다.

 

3.5.1 형식 검사

람다식에서 사용되는 콘텍스트를 이용해서 람다의 형식을 추론할 수 있다.

 

List<Apple> heavierThan150g = 
filter(inventory, (Apple apple) -> apple.getWeight() > 150);
  1. 람다가 사용된 콘텍스트는 무엇인가? filter의 정의를 확인해보자.
  2. filter 메서드는 두 번째 파마리터로 Predicate<Apple> 형식을 기대한다.
  3. Predicate<Apple> 인터페이스의 추상 메서드를 확인한다.
  4. Apple을 인수로 받아 boolean을 반환하는 test 메서드라는 것을 확인할 수 있다.

 

3.5.2 지역 변수 사용

지금까지의 람다식에서는 인수를 자신의 바디 안에서만 사용했다. 하지만 람다식은 자유 변수를 활용할 수 있다. 이 동작을 담다 캡처링이라고 부른다.

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

이렇게 사용할 수 도 있지만 변수를 final로 선언해야 하거나 값을 두 번 할당해서는 안된다.

 

 

3.6 메서드 참조

메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다. 

코드로 살펴보자

inventory.sort((Apple a1, Apple a2 ->
				a1.getWeight().compareTo(a2.getWeight)));
                
                
// 메서드 참조
inventory.sort(comparing(Apple::getWeight));

 

3.6.1 요약

메서드 참조는 특정 람다 표현식을 축약한 것이라고 생각하면 된다. 메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다.

 

람다 표현식 대신 명시적으로 메서드명을 참조함으로써 기독 성을 높일 수 있고, 실제로 메서드를 호출하는 것이 아니므로 괄호는 필요가 없다.

 

메서드 참조를 만드는 3가지 방법

  1. 정적 메서드 참조 : 예를 들어 Integer의 parseInt 메서드는 Integer::parseInt로 표현할 수 있다.
  2. 다양한 형식의 인스턴스 메서드 참조 : 예를 들어 String의 length 메서드는 String::length로 표현할 수 있다.
  3. 기존 객체의 인스턴스 메서드 참조 : 예를 들어 Transaction 객체를 할당받은 expensiveTransaction 지역 변수가 있고, Transaction 객체에는 getValue 메서드가 있다면, 이를 expensiveTransaction::getValue라고 표현할 수 있다.
// 다양한 형식의 인스턴스 메서드 참조 예시
(String s) -> s.toUpperCase() 
String::toUpperCase


// 기존 객체의 인스턴스 메서드 참조 예시
() -> expensiveTransaction.getValue()
expensiveTransaction::getValue()

 

3.6.2 생성자 참조

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

Supplier<Apple> c1 = () -> new Apple();
Supplier<Apple> c2 = Apple::new;

Apple a1 = c1.get();
Apple a2 = c2.get();

 

Apple(Integer weight)라는 시그니처를 갖는 생성자는 Function 인터페이스와 시그니처가 같다. 따라서 다음과 같은 코드를 구현할 수 있다.

Function<Integer, Apple> c3 = ( weight) -> new Apple(weight);
Function<Integer, Apple> c4 = Apple::new;

Apple a3 = c3.apply(110);
Apple a4 = c4.apply(110);
인수가 3개 이상인 생성자 참조를 만들려면 함수형 인터페이스를 직접 만들어서 사용하자

 

3.7 람다, 메서드 참조 활용 

이때까지 배운 것들을 써보면서 코드가 줄여 드는 과정을 다시 한번 확인해보자.

 

3.7.1 코드 전달

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

inventory.sort(new AppleComparator());

 

3.7.2  익명 클래스 사용

한 번만 사용할 Comparator를 익명 클래스를 사용해서 바꾸자

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

 

3.7.3 람다 표현식 사용

Comparator의 함수 디스크립터를 이용해 다음처럼 작성할 수 있다.

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

여기서 형식을 추론하면

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

Comparator는 Comparable 키를 추출해서 Comparator 객체로 만드는 Function 함수를 인수로 받는 정적 메서드 comparing을 포함한다.

 

Comparator<Apple> c = Comparator.comparing((apple a) -> a.getWeight());

그러면 코드가 다음처럼 간소화되는 걸 확인할 수 있다.

inventory.sort(comparing(apple -> apple.getWeight()));

메서드 참조까지 이용한다면 코드를 더 간소화하고 깔끔하게 전달이 가능해진다.

inventory.sort(comparing(Apple::getWeight));

 

이렇게 코드를 줄여봤는데 코드만 짧아진 것이 아니라 코드의 의미도 명확해졌다. 즉 코드 자체로 의미를 전달할 수 있게 되었다.

 

마치며

메서드 참조까지 사용하며 코드가 간결해지는 순서를 확인할 수 있었고, 사람들이 자바 8을 선호하는 이유를 알 수 있었던 장이였다.