[모던 자바인 액션] chp 3. 람다 표현식 (1)

sameul__choi·2022년 2월 25일
0

[모던 자바인 액션]

목록 보기
3/11
post-thumbnail

저번 장에서 동작 파라미터를 이용하여 변화하는 요구사항에 효과적으로 대응하는 코드를 구현해보았다. 또한 정의한 코드 블록을 다른 메서드로 전달 할 수 있다. 따라서 파라미터화를 이용하면 더 유연하고 재사용할 수 있는 코드를 만들 수 있다.

익명 클래스로 다양한 동작을 구현할 수 있지만, 코드가 깔끔하지는 않았다. 깔끔하지 않으면 실무에 적용하는 것이 꺼려지게 된다. 이번 장에서는 더 깔끔한 코드로 동작을 구현하고 전달하는 자바 8의 람다 표현식을 알아본다.

람다표현식은 익명클래스처럼 이름이 없는 함수이면서, 메서드를 인수로 전달할 수 있으므로 일단은 익명클래스와 비슷하다고 생각하자.

람다를 어떻게 만들고, 어떻게 사용하고, 람다를 이용하여 어떻게 코드를 간결하게 만들 수 있나 ?

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

00 람다란 무엇인가 ?

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

람다의 특징

  1. 익명 : 보통의 메서드와 달리 이름이 없으니 익명이라 표현한다. 구현해야 할 코드에 대한 걱정거리가 줄어든다.

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

  3. 전달 : 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.

  4. 간결성 : 익명 클래스처럼 자질구레한 코드를 구현할 필요가 없다.

람다는 미적분학 학계에서 개발한 시스템에서 유래했다. 람다 표현식이 중요한 이유는 2장에서 확인한 것처럼 자질구레한 코드가 많이 생기는 문제를 람다를 통해 해결할 수 있기 때문이다.

즉, 람다를 활용하여 간결한 방식으로 코드를 전달할 수 있다. 람다가 기술적으로 자바 8 이전의 자바로 할 수 없었던 일을 제공하는 것은 아니고 다만 동작 파라미터를 이용할 때 동작 파라미터 형식의 코드를 더 쉽게 구현할 수 있도록 한 것이다. 결과적으로 코드가 유연해지고 간결해진다.

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 메서드의 바디를 직접 전달하는 것처럼 코드를 전달할 수 있다.

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

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

다음은 자바에서 제공하는 다섯가지 람다 표현식 예제이다.

(String s) -> s.length() 
//String 형식의 파라미터를 가지며, int를 반환 

(Apple a) -> a.getLength() > 150 
//Apple 형식의 파라미터를 가지며, boolean을 반환 

(int x, int y) -> { 
System.out.println("Result :"); 
System.out.println(x + y); } 
//int 형식의 파라미터 2개를 가지며, 리턴값이 없음 

() -> 42 
//파라미터가 없으며, int 42를 반환 

(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())
//Apple 형식의 파라미터 2개를 가지며, int(두 사과의 무게 비교 결과)를 반환

01 어디에, 어떻게 람다를 사용하는가 ?

이쯤되니 람다 표현식을 어디에 사용할 수 있는지 궁금하다. 이전 예제에서는 Comparator<Apple>에 람다를 할당했다. 2장에서 구현했던 필터 메서드에도 람다를 활용할 수 있었다.

List<Apple> greenApples = 
	filter(inventory, (Apple a) -> GREEN.equals(a.getColor()));

람다는 정확히 말하자면 함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다. 위 예제에서는 함수형 인터페이스 Predicate<T>를 기대하는 filter 메서드의 두 번째 인수로 람다 표현식을 전달했다. 무슨 의미인지 잘 와닿지가 않는데, 함수형 인터페이스가 무엇인지 부터 알아보자.

(1) 함수형 인터페이스

우리가 chpt 2에서 만든 Predicate<T> 인터페이스로 필터 메서드를 파라미터화 할 수 있었다. 바로 Predicate<T>가 함수형 인터페이스였기 때문이다. Predicate<T>는 오직 하나의 추상 메서드만 지정하고 있기 때문이다.

함수 인터페이스는 아래와 같이 한개의 추상 메서드를 인터페이스를 말한다. Single Abstract Method라 하여 SAM이라고 불리기도 한다.

public interface FunctionalInterface {
    public abstract void doSomething(String text);
}

함수형 인터페이스에는 Comparator, Runnable, Callable 등이 있다. 그렇다면 이 함수형 인터페이스로 뭘 할 수 있나 ?

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"); 
     }
};

(2) 함수 디스크립터

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

예를 들어 Runnable 인터페이스의 유일한 추상 메서드 run은 인수와 반환값이 없으므로(void) Runnable 인터페이스는 인수와 반환 값이 없는 시그니처로 생각할 수 있다.
() -> void 표기는 파라미터 리스트가 없으며 void를 반환하는 함수를 의미한다.

02 실행 어라운드 패턴

람다와 동작 파라미터화로 유연하고 간결한 코드를 구현하는 데 도움을 주는 실용적인 예제를 살펴본다. (데이터베이스의 파일 처리)에 사용하는 순환 패턴은 자원을 열고, 처리한 후, 자원을 닫는 순서로 이루어 진다. 여기서 설정과 정리 과정은 대부분 비슷하다.

즉, 실제 자원을 처리하는 코드를 설정과 정리 두 과정이 둘러싸는 형태를 갖는다.

이러한 형식의 코드를 실행 어라운드 패턴이라고 부른다. 다음 예제는 파일에서 한 행을 읽는 코드다. try-with-resources 구문을 사용했다. 이를 사용하면 명시적으로 닫아줄 필요가 없으므로 간결한 코드를 구현하는데 도움을 준다.

public String processFile() throws IOException { 
	try (BufferedReader br 
    		= new BufferReader(newFileReader("data.txt"))) { 
	    return br.readline(); //실제 작업 
    } 
}

1단계 : 동작 파라미터화를 기억하라

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

그러기 위해선 processFile 메서드가 BufferedReader를 이용하여 다른 동작을 수행할 수 있도록 processFile 메서드로 동작을 전달 해야한다.

우리는 람다를 이용하여 동작을 전달할 수 있다. processFile 메서드가 한번에 두 행을 읽게 하려면 BufferedReader를 인수로 받아 String을 반환하는 람다가 필요하다. 다음은 BufferedReader에서 두 행을 출력하는 코드이다.

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

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

BufferedReader -> String 과 IOException을 던질 수 있는 시그니처와 일치하는 함수형 인터페이스를 만들어보자.

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

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

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

3단계 : 동작 실행

이제 BufferdReaderProcessor에 정의된 process 메서드의 시그니처(BufferedReader -> String)와 일치하는 람다를 전달할 수 있다. 어떻게 람다의 코드가 processFile 내부에서 어떻게 실행될 수 있는지 다시 복습해보자. 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으며 전달된 코드는 함수형 인터페이스의 인스턴스로 전달된 코드와 같은 방식으로 처리한다.

따라서 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());

03 함수형 인터페이스 사용

앞서 살펴본 것을 정리하자면 함수형 인터페이스는 오직 하나의 추상메서드를 지정한다. 함수형 인터페이스의 추상메서드는 람다 표현식의 시그니처를 묘사하고, 함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터라고 한다. 다양한 람다표현식을 사용하려면 공통의 함수 디스크립터를 기술하는 함수형 인터페이스 집합이 필요한데, 이미 자바 API는 Comparable Runnable Callable 등의 다양한 함수형 인터페이스를 포함하고 있다.

(1) Predicate

인터페잉스는 test라는 추상 메서드를 정의하며 test는 제네릭 형식 T의 객체를 인수로 받아 불리언을 반환한다. 우리가 만들었던 인터페이스와 같은 형태인데 따로 정의할 필요 없이 바로 사용할 수 있다는 점이 특징이다.

T형식의 객체를 사용하는 불리언 표현식이 필요한 상황에서는 Predicate 인터페이스를 사용할 수 있다. String 객체를 인수로 받는 람다를 정의할 수 있다.

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

//Main
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);

(2) Consumer

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

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

//Main
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)
);

(3) Function

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

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

//Main
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()
);

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

앞서 람다로 함수형 인터페이스의 인스턴스를 만들 수 있다고 언급했다. 람다 표현식 자체에는 람다가 어떤 함수형 인터페이스를 구현하는징의 정보가 포함되어 있지 않다. 그럼 어떻게 이런 것이 가능한 것일까 ? 람다의 실제 형식을 더 제대로 파악해보자

(1) 형식 검사

우선 람다가 사용되는 콘텍스트를 이용하여 형식을 추론할 수 있다. 어떤 콘텍스트(람다가 전달될 메서드 파라미터나 람다가 할당되는 변수)에서 기대되는 람다 표현식의 형식을 대상 형식이라고 부른다.

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

위의 코드를 예제로 형식 확인 과정을 살펴보자.

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

(2) 같은 람다, 따른 함수형 인터페이스

대상 형식이라는 특징 때문에 같은 람다 표현식이더라도 호환되는 추상 메서드를 가진 다른 함수형 인터페이스로 사용될 수 있다. 예를 들어 이전에 살펴본 CallablePrivilegedAction 인터페이스는 인수를 받지 않고 제네릭 형식 T를 반환하는 함수를 정의한다. 따라서 다음 두 할당문은 모두 유효한 코드이다.

Callable<Integer> c = () -> 42
PriviledgedAction<Integer> p = () -> 42;

(3) 형식추론

우리는 코드를 조금 더 단순화 시킬 수 있다. 자바 컴파일러는 람다 표현식이 사용된 콘텍스트(대상 형식)를 이용하여 람다 표현식과 관련된 함수형 인터페이스를 추론하다. 즉, 대상 형식을 이용하여 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론 가능하다. 결과적으로 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있기 때문에 람다 문법에서 이를 생략할 수 있다.

//형식을 추론하지 않음
Comparator<Apple> c = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
//형식을 추론함
Comparator<Apple> c = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());

그런데, 붙이는게 더 가독성을 위해 좋은 것이 아닌가 생각이 들었다. 아니나 다를까 책에서 여러 파라미터를 포함하는 람다 표현식에서는 코드 가독성 향상에 좋다고 말하고 있다. 역시 상황에 따라 명시적으로 형식을 포함하는 것이 좋을 때도 있고, 형식을 배제하는 것이 가독성을 향상시킬 때도 있다고 한다. 이것은 개발자 판단의 몫이다.

(4) 지역 변수 사용

지금까지 살펴본 모든 람다 표현식은 인수를 자신의 바디 안에서만 사용했다. 하지만 람다 표현식에서 익명 함수가 사용하는 것처럼 자유 변수(파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수)를 활용할 수 있다.

이와 같은 동작을 람다 캡처링이라고 한다. 예시는 다음과 같다.

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

하지만 자유 변수에도 약간의 제약이 있다. 람다는 정적, 인스턴스 변수를 자유롭게 캡처 할 수 있는데(자신의 바디에서 사용), 그러려면 지역 변수는 명시적으로 final로 선언되어 있어야 하거나 실질적으로 final로 선언된 변수와 똑같이 사용해야한다.

why ? ?

그 이유는 인스턴스 변수는 힙에 저장되는 반면 지역변수는 스택에 위치하기 때문이다. 람다에서 지역변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행된다면 변수를 할당한 스레드가 사라져 변수 할당이 해제 되었는데도, 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다. 때문에 자바 구현에서는 원래 변수에 접근을 허용하는 것이 아니라 자유 지역변수의 복사본을 제공한다. 복사본 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생긴 것이다.

0개의 댓글