Programming/Java

[JAVA] 람다식(Lambda)에 대해 알아보자

아윤_ 2024. 6. 27. 12:49

 

개인적으로 람다 표현식에 대한 지식이 부족하다 생각했는데, 이번 기회를 통해 제대로 개념을 잡고 넘어가는 게 좋겠다는 생각이 들었다. 따라서, 이번 글에서는 자바 8부터 등장한 람다 표현식이 무엇인지, 람다 표현식을 어떻게 만들고 사용하는지 등에 대해 알아보도록 한다.

 

람다란 무엇인가

람다 표현식은 메서드로 전달할 수 있는 익명 함수를 단순화한 것이라고 할 수 있다. 람다 표현식에는 이름은 없지만, 파라미터 리스트, 바디, 반환 형식, 발생할 수 있는 예외 리스트를 가질 수 있다.

 

람다의 특징

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

 

람다 표현식이 중요한 이유

  • 람다를 이용하면 간결한 방식으로 코드를 전달할 수 있다.
  • 동작 파라미터를 이용할 때 익명 클래스 등 판에 박힌 코드를 구현할 필요가 없이 동작 파라미터 형식의 코드를 더 쉽게 구현할 수 있다.
  • 결과적으로, 코드가 간결하고 유연해진다.

 

람다 표현식

람다 표현식은 다음과 같이 파라미터, 화살표, 바디로 이루어진다.

  • 파라미터 리스트
    메서드 파라미터
  • 화살표
    화살표를 통해 람다의 파라미터 리스트와 바디를 구분
  • 바디
    람다의 반환값에 해당되는 표현식

 

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

결론만 말하면, 람다는 함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다.

 

함수형 인터페이스

함수형 인터페이스는 정확히 하나의 추상 메서드를 지정하는 인터페이스이다. 자바 API의 함수형 인터페이스에는 Comparator, Runnable 등이 있다.

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

public interface Runnable {
	void run();
}
참고
인터페이스는 디폴트 메서드(인터페이스의 메서드를 구현하지 않은 클래스를 구현해서 기본 구현을 제공하는 바디를 포함하는 메서드)를 포함할 수 있다. 많은 디폴트 메서드가 있더라도 추상 메서드가 오직 하나면 함수형 인터페이스다.

 

람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스턴스로 취급(기술적으로 따지면 함수형 인터페이스를 구현한 클래스의 인스턴스) 할 수 있다.

 

함수 디스크립터

함수형 인터페이스의 추상 메서드 '시그니처'는 람다 표현식의 시그니처를 가리킨다. 람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터라고 부른다.

 

() -> void 표기는 파라미터 리스트가 없으며 void를 반환하는 함수를 의미한다. (Apple, Apple) -> int는 두 개의 Apple을 인수로 받아 int를 반환하는 함수를 가리킨다.

 

람다 표현식은 변수에 할당하거나 함수형 인터페이스를 인수로 받는 메서드로 전달할 수 있으며, 함수형 인터페이스의 추상 메서드와 같은 시그니처를 갖는다는 사시을 기억하자.

public interface Runnable {
	void run();
}

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

process(() -> System.out.println("This is awesome!"));

 

위 코드를 실행하면 'This is awesome!'이 출력된다. () -> System.out.println("This is awesome!")은 인수가 없으며, void를 표현하는 람다 표현식이다. 이는 Runnable 인터페이스의 run 메서드 시그니처와 같다.

 

 

실행 어라운드 패턴

데이터베이스의 파일 처리에 사용하는 자원 처리와 같은 순환 패턴은 자원을 열고, 자원을 처리한 다음에, 자원을 닫는 순서로 이루어진다. 설정과 정리 과정은 대부분 비슷하다. 즉, 실제 자원을 처리하는 코드를 설정과 정리 두 과정이 둘러싸는 형태를 갖는다. 그림과 같은 형식의 코드를 실행 어라운드 패턴이라고 부른다. 

 

아래의 그림을 보면, 중복되는 준비 코드와 정리 코드가 작업 A와 작업 B를 감싸고 있다.

 

실행 어라운드 패턴

 

다음 예제는 파일에서 한 행을 읽는 코드이다. 해당 예제를 이용해 람다와 동작 파라미터화로 유연하고 간결한 코드를 구현해보자.

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

 

1단계 : 동작 파라미터화

현재 코드는 파일에서 한 번에 한 줄만 읽을 수 있다. 한 번에 두 줄을 읽거나 가장 자주 사용되는 단어를 반환하려면 어떻게 해야 할까? 기존의 설정, 정리 과정을 재사용하고 processFile 메서드만 다른 동작을 수행하도록 명령할 수 있다면 좋을 것이다. 즉, processFile의 동작을 파라미터화하는 것이다. processFile 메서드가 BufferedReader를 이용해서 다른 동작을 수행할 수 있도록 processFile 메서드로 동작을 전달해야 한다.

 

람다를 이용해 동작을 전달할 수 있다. processFile 메서드가 한 번에 두 행을 읽게 하려면 코드를 어떻게 고쳐야 할까?

우선 BufferedReader를  인수로 받아서 String을 반환하는 람다가 필요하다.

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

 

2단계 : 함수형 인터페이스를 이용해서 동작 전달

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

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

 

정의한 인터페이스를 processFile 메서드의 인수로 전달할 수 있다.

public String processFile(BufferedReaderProcessor p) throws IOException {
	...
}

 

3단계 : 동작 실행

이제 BufferedReaderProcessor에 정의된 process 시그니처(BufferedReader -> String)와 일치하는 람다를 전달할 수 있다. 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으며 전달된 코드는 함수형 인터페이스의 인스턴스로 전달된 코드와 같은 방식으로 처리한다. 따라서 processFile 바디 내에서 BufferedReaderProcessor 객체의 process를 호출할 수 있다.

pulblic String processFile(BufferedReaderProcessor p) throws IOException {
	try (BufferedReader br =
				new BufferedReader(new FileReader("data.txt"))) {
			return p.process(br); //BufferedReader 객체 처리			
	}
}

 

4단계 : 람다 전달

이제 람다를 이용해서 다양한 동작을 processFile 메서드로 전달할 수 있다.

 

다음은 한 행을 처리하는 코드다.

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

 

다음은 두 행을 처리하는 코드다.

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

 

그림은 processFile 메서드를 더 유연하게 만드는 과정을 보여준다.

실행 어라운드 패턴을 적용하는 네 단계의 과정

 

 

함수형 인터페이스 사용

함수형 인터페이스는 오직 하나의 추상 메서드를 지정하며, 함수형 인터페이스의 추상 메서드는 람다 표현식의 시그니처를 묘사한다. 함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터라고 한다. 다양한 람다 표현식을 사용하려면 공통의 함수 디스크립터를 기술하는 함수형 인터페이스 집합이 필요하다.

 

이미 자바 API는 다양한 함수형 인터페이스를 포함하고 있지만, 자바 8 라이브러리 설계자들은 java.util.function 패키지로 여러 가지 함수형 인터페이스를 제공한다.

 

Predicate

java.util.function.Predicate<T> 인터페이스는 test라는 추상 메서드를 정의하며 test는 제네릭 형식 T의 객체를 인수로 받아 불리언을 반환한다. 우리가 만들었던 인터페이스와 같은 형식인데 따로 정의할 필요 없이 바로 사용할 수 있다는 점이 특징이다. T 형식의 객체를 사용하는 불리언 표현식이 필요한 상황에서 Predicate 인터페이스를 사용할 수 있다. 다음 예제처럼 String 객체를 인수로 받는 람다를 정의할 수 있다.

@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);

 

Consumer

java.util.function.Consumer<T> 인터페이스는 제네릭 형식 T 객체를 받아서 void를 반환하는 accept라는 추상 메서드를 정의한다. T 형식의 객체를 인수로 받아서 어떤 동작을 수행하고 싶을 때 Consumer 인터페이스를 사용할 수 있다. 예를 들어 Integer 리스트를 인수로 받아서 각 항목에 어떤 동작을 수행하는 forEach 메서드를 정의할 때 Consuemr를 활용할 수 있다.

@FunctionalInterface
public interface Consumer<T> {
	void accept(T t);
}

public <T> void forEach(List<T> list, Consumer<T> c) {
	for(T t: list) {
		c.accept(t);
	}
}
forEach(
	Arrays.asList(1,2,3,4,5),
	(Integer i) -> System.out.println(i) //Consumer의 accept 메서드를 구현하는 람다
);

 

Function

java.util.function.Function<T, R> 인터페이스는 제네릭 형식 T를 인수로 받아서 제네릭 형식 R 객체를 반환하는 추상 메서드 apply를 정의한다. 입력을 출력으로 매핑하는 람다를 정의할 때 Function 인터페이스를 활용할 수 있다. 다음은 String 리스트를 인수로 받아 각 String의 길이를 포함하는 Integer 리스트로 변환하는 map 메서드를 정의하는 예제다.

@FunctionalInterface
public interface Function<T, R> {
	R apply(T t);
}

public <T, R> List<R> map(List<T> list, Function<T, R> f {
	List<T> result = new ArrayList<>();
	for(T t: list){
		list.add(f.apply(t));
	}
	return result;
}

// [7,2,6]
List<Integer> l = map(
        Arrays.asList("lambdas", "in", "function"),
        (String s) -> s.length() //Function의 apply 메서드를 구현하는 람다
)

 

기본형 특화

지금까지 세 게의 제네릭 함수형 인터페이스를 살펴봤다. 하지만 제네릭 파라미터에는 참조형만 사용할 수 있다. 자바 8에서는 기본형을 입출력으로 사용하는 상황에서 특별한 버전의 함수형 인터페이스를 제공한다. 예를 들어 아래 예제에서 IntPredicate는 1000이라는 값을 박싱하지 않지만, Predicate<Interger>는 1000이라는 값을 Integer 객체로 박싱한다.

public interface IntPredicate {
	boolean test(int t);
}

IntPredicate evenNumbers = (int i) -> i% 2 == 0;
evenNumbers.test(1000); //참(박싱 없음)

Predicate<Integer> oddNumbers = (Integer i) -> i % 2 != 0;
oddNumbers.test(1000); //거짓(박싱)

 

아래의 표는 자바 API에서 제공하는 대표적인 함수형 인터페이스의 디스크립터를 보여준다. 자바에서 제공하는 함수형 인터페이스 중 일부에 불과하다는 사실을 기억하자. 필요하다면 우리가 직접 함수형 인터페이스를 만들 수도 있다.

자바 8에 추가된 함수형 인터페이스

 

 

메서드 참조

메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다. 때로는 메서드 참조를 사용하는 것이 람다 표현식보다 더 가독성이 좋으며 자연스러울 수 있다.

 

메서드 참조는 메서드명 앞에 구분자(::)를 붙이는 방식으로 활용할 수 있다. 예를 들어 Apple::getWeight는 Apple 클래스에 정의된 getWeight의 메서드 참조다. 결과적으로 메서드 참조는 람다 표현식 (Apple a) → a.getWeight()를 축약한 것이다.

 

메서드 참조를 만드는 방법

메서드 참조는 세 가지 유형으로 구분할 수 있다.

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

세 가지 종류의 람다 표현식을 메서드 참조로 바꾸는 방법

 

생성자 참조

ClassName::new 처럼 클래스명과 new를 사용해 기존 생성자의 참조를 만들 수 있다. 정적 메서드의 참조를 만드는 방법과 비슷하다. 예를 들어 인수가 없는 생성자, 즉 Supplier의 () → Apple과 같은 시그니처를 갖는 생성자가 있다고 가정하자.

Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get(); //Supperlier의 get 메서드를 호출해서 새로운 Apple 객체를 만들 수 있다.

 

위 예제는 다음 코드와 같다.

Supplier<Apple> c1 = new Apple(); // 람다 표현식은 디폴트 생성자를 가진 Apple을 만든다.
Apple a1 = c1.get(); // Supplier의 get 메서드를 호출해서 새로운 Apple 객체를 만들 수 있다.

 

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

Function<Integer, Apple> c2 = Apple::new //Apple(Integer weight) 생성자 참조
Apple a2 = c2.apply(110); //Function의 apply 메서드에 무게를 인수로 호출해서 새로운 Apple 객체를 만들 수 있다.

 

이 코드는 다음과 같다.

Function<Integer, Apple> c2 = (weight) -> new Apple(weight); //특정 무게의 사과를 만드는 람다 표현식
Apple a2 = c2.apply(110); //Function의 apple 메서드에 무게를 인수로 호출해서 새로운 Apple 객체를 만들 수 있다.

 

 

정리

  • 람다 표현식은 익명 함수의 일종이다. 이름은 없지만, 파라미터 리스트, 바디, 반환 형식을 가지며 예외를 던질 수 있다.
  • 람다 표현식으로 간결한 코드를 구현할 수 있다.
  • 함수형 인터페이스는 하나의 추상 메서드만을 정의하는 인터페이스다.
  • 함수형 인터페이스를 기대하는 곳에서만 람다 표현식을 사용할 수 있다.
  • 람다 표현식을 이용해서 함수형 인터페이스의 추상 메서드를 즉석으로 제공할 수 있으며 람다 표현식 전체가 함수형 인터페이스의 인스턴스로 취급된다.
  • java.util.function 패키지는 Predicate<T>, Function<T, R> … 등을 포함해서 자주 사용하는 다양한 함수형 인터페이스를 제공한다.
  • 자바 8은 제네릭 함수형 인터페이스와 관련한 박싱 동작을 피할 수 있는 기본형 특화 인터페이스도 제공한다.
  • 실행 어라운드 패턴을 람다와 활용화면 유연성과 재사용성을 추가로 얻을 수 있다.
  • 람다 표현식의 기대 형식을 대상 형식이라고 한다.
  • 메서드 참조를 이용하면 기존의 메서드 구현을 재사용하고, 직접 전달할 수 있다.
  • Comparator, Predicate, Function과 같은 함수형 인터페이스는 람다 표현식을 조립할 수 있는 다양한 디폴트 메서드를 제공한다.

'Programming > Java' 카테고리의 다른 글

[JAVA] 스트림(Stream) 개념  (0) 2024.06.27