모던 자바 인 액션 - 람다 표현식(1)

HYEON·2023년 7월 29일
0

Modern Java in Action

목록 보기
2/3
post-thumbnail
💡 모던 자바 인 액션을 읽고 스스로 정리한 내용입니다.

🤔 왜 쓸까?


동작 파라미터화를 이용해서 변화하는 요구사항에 효과적으로 대응하는 코드를 구현할 수 있음을 2장에서 확인했다.
…(중략)…
익명 클래스로 다양한 동작을 구현할 수 있지만 만족할 만큼 코드가 깔끔하지는 않았다. 깔끔하지 않은 코드는 동작 파라미터를 실전에 적용하는 것을 막는 요소다.
3장에서는 더 깔끔한 코드로 동작을 구현하고 전달하는 자바 8의 새로운 기능인 람다 표현식을 설명한다.

  • 『모던 자바 인 액션』, 87p

람다 자바를 사용해본 사람이면 한번 쯤은 본 적이 있는 코드일 것이다. 나 또한 람다를 많이 봐왔고 써봤다.

하지만 람다가 왜 깔끔한 코드를 동작으로 구현할 수 있을까? 람다가 도대체 어떤 방식으로 작동하는 것일까? 왜 이렇게 간단하게 표현이 되는 것일까? 어떻게 써야하는 것일까?

ƛ 람다 표현식


람다란 무엇인가?

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

  • 『모던 자바 인 액션』, 87p

람다를 간단하게 정리하자면 앞서 동작 파라미터화에서 봤던 자질구레한 코드를 간결한 방식으로 전달할 수 있는 자바 8의 핵심 기능이다.

람다의 특징은 아래와 같다.

  • 익명

    • 보통의 메서드와 달리 이름이 없으므로 익명이라도 표현한다. 구현해야 할 코드에 대한 걱정거리가 줄어든다.
  • 함수

    • 람다는 메서드처럼 특정 클래스에 종속되지 않으므로 함수라고 부른다. 하지만 메서드처럼 파라미터 리스트, 바디, 반환 형식, 가능한 예외 리스트를 포함한다.
  • 전달

    • 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
  • 간결성

    • 익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다.

    이러한 특징을 이해하기 위해 예시 코드를 보겠다.

예시 코드 - 커스텀 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());

위 람다식을 보면 기존 코드보다 훨씬 간결해졌음을 알 수 있다. 그렇지만 저게 무엇인지 각 부분이 어떤 의미를 지니고 있는지 아직은 모른다. 간단하게 설명하자면, 위의 예제 코드를 예시로 람다 표현식은 세 부분으로 이루어져있다.

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

람다의 기본 문법

  • (parameters) -> expression

    • 표현식 스타일 람다라고 불린다.
  • (parameters) -> { statements };

    • 블록 스타일 람다라고 불린다.

    그래서 람다는 정확히 어디에, 어떻게 람다를 사용할까? 아무 기능에 람다 표현식을 사용하면 되는걸까? 아니면 어떠한 기준이 있는걸까? 여기에 대해 알고 싶다면 함수형 인터페이스라는 개념에 대해 먼저 알아야한다.

함수형 인터페이스


함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다.

  • 『모던 자바 인 액션』, 92p

함수형 인터페이스란?

함수형 인터페이스정확히 하나의 추상 메서드를 지정하는 인터페이스다.
추상 메서드에 대해 간단하게 설명하자면, 메서드의 선언만 있고, 몸체(구현)이 없는 메서드를 얘기한다. 즉, 메서드의 시그니처만 정의되어 있는 것이다. 메서드 시그니처란 메서드의 이름, 매개변수, 리턴 타입을 말한다.

생각을 해보자, 그러면 인터페이스는 추상 메서드뿐만 아니라 디폴트 메서드를 가질 수 있는데 이 경우에는 어떻게 되는 것일까? 그럼 함수형 인터페이스는 추상 메서드만 존재해야하는 것일까? 일단 아직 그 내용은 책 9장에서 설명하지만 일단 많은 디폴트 메서드가 있더라도 오직 추상 메서드가 하나면 함수형 인터페이스이다.

이해를 위해 코드를 보자. 아래의 인터페이스 중 함수형 인터페이스는 어느 것일까?

public interface Adder {
	int add(int a, int b);
}

public interface SmartAdder extends Adder {
	int add(double a, double b);
}

public interface Nothing{
}

정답은 Adder만 함수형 인터페이스이다. 
SmartAdderAdder을 상속하면서 두 추상 add메서드를 포함한다. 보면 자료형이 다르다. Adder를 오버로딩하였기 때문에, Adder의 add와 SmartAdder의 add는 다른 추상 메서드이다.
Nothing은 추상 메서드가 하나도 없기 때문에 함수형 인터페이스가 될 수 없다.

함수형 인터페이스로 무엇을 할 수 있을까?

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

아래의 예시를 보자.

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

Runnable runnable = () -> {
    System.out.println("Hello, world!");
};

runnable.run();

느낌이 올 것이다. Runnable는 추상 메서드가 단 하나(run() 메서드)만 있어서 함수형 인터페이스이다. 따라서, 이름을 생략해도 어떤 메서드를 구현한지 알 수 있기 때문에, 익명 클래스를 쓰지 않고 람다 표현식만으로도 인터페이스의 추상 메서드를 구현할 수 있다.

이러한 이유로 람다 표현식으로 익명 클래스나 별도의 클래스를 생성하지 않고도 인터페이스의 메서드를 구현할 수 있게 된다.

다시 코드를 보면, 함수형 인터페이스의 추상 메서드 시그니처(메서드의 이름, 매개변수, 리턴 타입) 는 람다 표현식의 시그니처를 가리킨다. 쉬운 말로, 람다 표현식의 메서드 이름, 매개변수, 리턴 타입은 함수형 인터페이스의 메서드의 이름, 매개변수, 리턴 타입 즉, 시그니처가 동일해야 된다는 말이다.

그럼 람다 표현식의 시그니처를 서술하는 메서드를 뭐라고 할까?

람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터라고 부른다. 람다 표현식의 시그니처를 서술하는 메서드가 뭘까?
바로 함수형 인터페이스의 추상 메서드 시그니처이다! 따라서 함수형 인터페이스의 추상 메서드 시그니처함수 디스크립터라고도 한다.

더 궁금한 내용은 뒤에서 설명하고, 이정도만 알아두고 이제 람다의 활용으로 넘어가보자.

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


드디어 이제 람다와 동작 파라미터화로 유연하고 간결한 코드를 구현하는 데 도움을 주는 실용적인 예제를 살펴보자!

살펴볼 예제는 실행 어라운드 패턴이다. 아래의 그림은 실행 어라운드 패턴을 설명하는 그림이다.

이 패턴을 쓰는 예제 코드를 보자.

public String processFile() throws IOException {
	try (BufferedReader br = 
									new BufferedReader(new FileReader("data.txt"))) {
		return br.readLine();   <- 실제 필요한 작업을 하는 행이다.
	}
}

보듯이 실제 필요한 작업을 하기 위해 try-with-resources 구문을 사용해 작업 코드를 감싸고 있다. 이 코드는 파일에서 한 번에 한 줄만 읽을 수 있다.

그런데 갑자기 파일에서 전부를 읽고 싶다면? 거기서 가장 많이 쓰는 단어를 반환하고 싶다면? 익숙한 시나리오이다. 앞서 동작 파라미터화에서 봤던 변화하는 요구사항에서의 농부의 모습이 떠오른다 !

1단계 : 동작 파라미터화

processFile의 동작을 파라미터화 해보자. BufferedReader를 이용해서 다른 동작을 수행할 수 있도록 processFile 메서드로 동작을 전달해야 한다.

람다를 이용해서 동작을 전달하여 processFile 메서드가 한 번에 두 행을 읽게 해보자.

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

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

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

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

//정의한 인터페이스를 processFile 메서드의 인수로 전달
public String processFile(BufferedReaderProcessor p) throws IOExceptiom {
 ...
}

3단계 : 동작 실행

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

public 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());

함수형 인터페이스 사용


여기선 한번씩은 써봤을 법한 함수형 인터페이스를 보여준다. fliter, forEach와 같은 반가운 코드들이 예제에 있을 것이다.

Predicate

java.util.function.Predicate 인터페이스는 test라는 추상 메서드를 정의하며 test는 제네릭 형식 T의 객체를 인수로 받아 불리언을 반환한다. 예제처럼 불리언 표현식이 필요한 상황에서 사용할 수 있다.

@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(p)){
			results.add(p);
		}
	}
	return results;
}

Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);

Consumer

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

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

Function

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

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

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

List<Integer> l = map(
		Arrays.asList("lambdas", "in", "action"),
		(String s) -> s.length() <- Function의 apply 메서드를 구현하는 람다
);

💡 2편에서 계속 됩니다 ! 뒷 내용은 2편을 참고해주세요!

profile
레벨업하는 개발자

1개의 댓글

comment-user-thumbnail
2023년 7월 29일

유익한 글이었습니다.

답글 달기