본문 바로가기

개발 서적/모던 자바 인 액션

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

 


[목차]
chapter 1. 자바 8, 9, 10, 11 : 무슨 일이 일어나고 있는가?
chapter 2. 동작 파라미터화 코드 전달하기
chapter 3. 람다 표현식(1)
chapter 3. 람다 표현식(2)


 

 

 

이 장에서는 람다 표현식을 어떻게 만드는지, 어떻게 사용하는지, 어떻게 코드를 간결하게 만들 수 있는지 설명한다. 또한 자바 8 API에 추가된 중요한 인터페이스와 형식 추론 등의 기능도 확인한다. 마지막으로 람다 표현식과 함께 위력을 발휘하는 새로운 기능인 메서드 참조를 설명한다.

 

3.1 람다란 무엇인가?

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

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

람다 표현식이 왜 중요할까?

→ 코드를 전달하는 과정에서 자질구레한 코드가 많이 생긴다. 람다를 이용하면 간결한 방식으로 코드를 전달할 수 있다. 람다가 기술적으로 자바 8 이전에서 할 수 없던 일을 제공한다기 보다는, 동작 파라미터 형식의 코드를 더 쉽게 구현할 수 있도록 하고, 결과적으로 코드가 간결해지고 유연해지도록 한다는데 의의가 있다.

람다는 아래와 같이 세 부분으로 이루어진다.

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

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

자바 설계자는 이미 C#이나 스칼라 같은 비슷한 기능을 가진 다른 언어와 비슷한 문법을 자바에 적용하기로 했다. 다음은 람다의 기본 문법이다.

  • 표현식 스타일
    • (parameters) -> expression
    • 람다 표현식에는 return이 함축되어 있으므로 return을 명시적으로 사용하지 않아도 됨
  • 블록 스타일
    • (parameters) -> { statements; }
    • 블록에는 여러 행의 문장을 포함하는 구문이 들어가며, 리턴 타입이 void가 아니라면 return을 명시적으로 사용해야한다.

 

 

 

 

람다 사용 사례 및 예제

사용 사례 람다 예제
불리언 표현식 (List<String> list) → list.isEmpty()
객체 생성 () → new Apple(10)
객체에서 소비 (Apple a) → { System.out.println(a.getWeight());} 
객체에서 선택/추출 (String s) → s.length()
두 값을 조합 (int a, int b) → a * b
두 객체 비교 (Apple a1, Apple a2) → a1.getWeight().compareTo(a2.getWeight())

 

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

정확히 어디에서 람다를 사용할 수 있을까? → 함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다.

3.2.1 함수형 인터페이스

간단히 말해 함수형 인터페이스는 정확히 하나의 추상 메서드를 지정하는 인터페이스다. 지금까지 살펴본 자바 API의 함수 인터페이스로 Comparator, Runable이 있다. (default method가 있더라도 추상 메서드가 오직 하나면 함수형 인터페이스다.)

함수형 인터페이스로 뭘 할 수 있을까? → 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스턴스로 취급(기술적으로 따지면 함수형 인터페이스를 구현한 클래스의 인스턴스)할 수 있다. (익명 내부 클래스로도 같은 기능 구현가능)

다음 예제는 Runnable이 오직 하나의 추상 메서드 run을 정의하는 함수형 인터페이스므로 올바른 코드다.

// 람다 사용 
Runnable r1 = () -> System.out.println("Hello world 1");

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

public static void process(Runnable r) {
	r.run();
}
process(r1);
process(r2);
process(() -> System.out.println("Hello Wordl 3")); // 람다 표현식으로 직접 전달도 가능

 

3.2.2 함수 디스크립터

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

 

+ 메서드 시그니처란? : 자바 프로그래밍 언어에서 메서드 시그니처는 메서드 명과 파라미터의 순서, 타입, 개수를 의미

예를 들어 Runnable 인터페이스의 유일한 추상 메서드 run은 인수와 반환값이 없으므로(void 반환) Runnable 인터페이스는 인수와 반환값이 없는 시그니처로 생각할 수 있다. 이는 () → void 와 같은 표기법으로 나타낼 수 있다.

두 개의 Apple을 인수로 받아 int를 반환하는 함수는 (Apple, Apple) -> int 로 표현한다.

→ 3.4절에서 더 다양한 종류의 함수 디스크립터를 다룸

 

람다 표현식의 형식을 어떻게 검사할까? → 3.5절(컴파일러가 람다 표현식의 유효성을 확인하는 방법)에서 다룸

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

 

예를 들어 이전 예제에서는 다음처럼 process 메서드에 직접 람다 표현식을 전달했다.

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 메서드 시그니처와 같다.

 

왜 함수형 인터페이스를 인수로 받는 메서드에만 람다 표현식을 사용할 수 있을까?

  • 언어 설계자들은 언어를 더 복잡하게 만들지 않는 방법을 선택했다.
  • 대부분의 자바 프로그래머가 하나의 추상 메서드를 갖는 인터페이스에 이미 익숙하다.

 

@FunctionalInterface?

  • 함수형 인터페이스임을 가리키는 어노테이션
  • @FunctionalInterface로 인터페이스를 선언했지만, 실제로 함수형 인터페이스가 아니면 컴파일러 에러가 발생

 

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

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

실제 자원을 처리하는 코드를 설정(setup)과 정리(cleanup) 두 과정이 둘러싸는 형태를 **실행 어라운드 패턴(execute around pattern)**이라고 부른다.

다음 예제에서 굵은 글씨는 파일에서 한 행을 읽는 코드다.(자바 7에 새로 추가된 try-with-resources 구문을 사용했음. 이를 사용하면 자원을 명시적으로 닫을 필요가 없으므로 간결한 코드를 구현하는 데 도움을 준다.)

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

try-with-resource란? → try()에서 선언된 객체들에 대해서 try가 종료될 때 자동으로 자원을 해제해주는 기능. (AutoCloseable을 구현한 객체에 한에서만 close()를 호출)

 

Java - Try-with-resources로 자원 쉽게 해제하기

try-with-resources는 try(...)에서 선언된 객체들에 대해서 try가 종료될 때 자동으로 자원을 해제해주는 기능입니다. 객체가 AutoCloseable을 구현하였다면 Java는 try구문이 종료될 때 close()를 호출해 줍니

codechacha.com

 

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

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

→ 이미 익숙한 시나리오다. processFile의 동작을 파라미터화하는 것이다. processFile 메서드가 BufferedReader를 이용해서 다른 동작을 수행할 수 있도록 processFile 메서드로 동작을 전달해야 한다.

람다를 이용해서 동작을 전달할 수 있다. processFile 메서드가 한 번에 두 행을 읽게 하려면 코드를 어떻게 고쳐야 할까? 우선 BufferedReader를 인수로 받아서 String을 반환하는 람다가 필요하다.

다음은 BufferedReader에서 두 행을 출력하는 코드다.

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

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

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

이 인터페이스를 BuffredReaderProcessor라고 정의하자.

@FuntionalInterface
public interface BufferedReaderProcessor {
	String process(BuffredReader b) throws IOException;
}

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

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

3.3.3 3단계 : 동작 실행

BuffredReaderProcessor에 정의된 process 메서드의 시그니처(BufferedReader → String)와 일치하는 람다를 전달할 수 있다.

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

따라서 processFile 바디 내에서 BufferedReaderProcessor 객체의 process를 호출할 수 있다.

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

3.3.4 4단계 : 람다 전달

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

// 한 행을 처리하는 코드
String oneLine = processFile((BufferedReader br) -> br.readLine());

// 두 행을 처리하는 코드
Strine twoLines = processFile((BuffredReader br) -> br.readLine() + br.readLine());

지금까지 함수형 인터페이스를 이용해서 람다를 전달하는 방법을 확인했다. 이때 인터페이스도 정의했다. 다음 절에서는 다양한 람다를 전달하는 데 재활용할 수 있도록 자바 8에 추가된 새로운 인터페이스를 살펴본다.

 

3.4 함수형 인터페이스 사용

  • 함수형 인터페이스는 오직 하나의 추상 메서드를 지정한다.
  • 함수형 인터페이스의 추상 메서드는 람다 표현식의 시그니처를 묘사한다.
  • 함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터라고 한다.

다양한 람다 표현식을 사용하려면 공통의 함수 디스크립터를 기술하는 함수형 인터페이스 집합이 필요하다. 이미 자바 API는 Comparable, Runnable, Callable 등의 다양한 함수형 인터페이스를 포함하고 있다.

자바 8 라이브러리 설계자들은 java.util.function 패키지로 여러 가지 새로운 함수형 인터페이스를 제공한다.

3.4.1 Predicate

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

다음 String 객체가 Empty가 아닌 경우를 필터링하는 예제다.

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

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

3.4.2 Consumer

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

다음은 forEach와 람다를 이용해서 리스트의 모든 항목을 출력하는 예제다.

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

3.4.3 Function

제네릭 형식 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<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()
);

기본형 특화

자바의 모든 형식은 참조형(reference type) 아니면 기본형(primitive type)에 해당한다. 하지만 제네릭 내부 구현상 제네릭 파라미터에는 참조형만 사용할 수 있다.

자바에서는 기본형을 참조형으로 변환하는 기능을 박싱(boxing)이라고 하고, 반대로 참조형을 기본형으로 변환하는 동작을 언박싱(unboxing)이라고 한다. 또한, 박싱과 언박싱이 자동으로 이루어지는 기능을 오토 박싱(autoboxing)이라고 한다.

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

위 코드는 int가 Integer로 오토박싱되는 코드다. 이런 변환 과정은 비용이 소모된다. 박싱한 값은 기본형을 감싸는 래퍼며 힙에 저장된다. 따라서 박싱한 값은 메모리를 더 소비하여 기본형을 가져올 때도 메모리를 탐색하는 과정이 필요하다.

자바 8에서는 기본형을 입출력으로 사용하는 상황에서 오토박싱 동작을 피할 수 있도록 특별한 버전의 함수형 인터페이스를 제공한다.

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

Predicate<Integer> oddNumbers = (Integer i) -> i % 2 != 0;
addNumbers.test(1000); -> 박싱 과정이 발생

@FunctionalInterface
public interface IntPredicate {
    boolean test(**int** value);
}

IntPredicate<Integer> evenNumbers = (Integer i) -> i % 2 == 0;
evenNumbers.test(1000); -> 박싱 과정이 발생 x

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

 

함수형 인터페이스  함수 디스크립터  기본형 특화
Predicate<T> T → boolean IntPredicate, LongPredicate, DoublePredicate
Consumer<T> T → void IntConsumer, LongConsumer, DoubleConsumer
Funtion<T, R> T → R IntFunction<R>, IntToDoubleFunction, IntToLongFunction,
LongFunction, LongToDoubleFunction, LongToIntFunction,
DoubleFunction, DoubleToIntFunction, DoubleToLongFunction,
ToIntFunction, ToDoubleFunction, ToLongFunction
... ... ...

함수형 인터페이스와 람다를 요약하면 다음과 같다.

사용 사례 람다 예제 대응하는 함수형 인터페이스
불리언 표현 (List<String> list) → list.isEmpty() Predicate<List<String>>
객체 생성 () → new Apple(10) Supplier<Apple>
객체에서 소비 (Apple a) → System.out.println(a.getWeight) Consumer<Apple>
객체에서 선택/추출 (String s) → s.length() Function<String, Integer> 또는 ToIntFunction<String>
두 값 조합 (int a, int b) → a*b IntBinaryOperator
두 객체 비교 (Apple a1, Apple a2) → a1.getWeight().compareTo(a2.getWeight()) Comparator<Apple> 또는 BiFunction<Apple, Apple, Integer> 또는 ToIntBiFunction<Apple, Apple>

 

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

함수형 인터페이스는 확인된 예외(checked Exception)를 던지는 동작을 허용하지 않는다.

checked Exception vs unchecked Exception

  • Error : 시스템에 비정상적인 상황이 발생했을 경우에 발생.  메모리 부족(OutofMemoryError) 이나 스택오버플로우(StackOverflowError) 등
  • Exception : 프로그램 실행 중에 개발자의 실수로 예기치 않은 상황이 발생했을 경우에 발생. 배열의 범위를 벗어남(ArrayIndexOutOfBoundsException), 값이 null인 참조변수를 참조(NullPointerException) 등
    • checked Exception : RuntimeException의 하위 클래스가 아니면서 Exception 클래스의 하위 클래스들. 반드시 에러 처리를 해야하는 특징(try/catch or throw)을 가짐
    • unchecekd Exception : RuntimeException의 하위 클래스들. 체크 예외와는 달리 에러 처리를 강제하지 않고, 실행 중(runtime)에 발생할 수 있는 예외를 의미
 

[Java] Checked Exception vs Unchecked Exception 정리

체크 예외와 언체크 예외(Checked, Unchecked Exception) 자바의 예외는 크게 3가지로 나눌 수 있습니다. 체크 예외(Checked Exception) 에러(Error) 언체크 예외(Unchecked Exception) 자바에서 에러 , 예외 관련..

devlog-wjdrbs96.tistory.com

함수형 인터페이스가 Checked Exception 을 던지도록 코드를 작성하면 어떻게 될까?

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

// IOException은 Exception을 상속. 즉, checkedException
public class IOException extends Exception {
	...
}

아래의 이미지처럼 에러 메시지와 함께 컴파일이 되지 않는다.

예외를 던지는 람다 표현식을 만드려면

  • 확인된 예외(checked Exception)를 선언하는 함수 인터페이스를 직접 정의하거나
  • @FunctionalInterface public interface BufferedReaderProcessor { String process(BufferedReader b) **throws IOException;** }
  • 람다를 try/catch 블록으로 감싸야 한다.
  • Function<BufferedReader, String> f = (BufferedReader b) -> { try { return b.readLine(); } catch (IOException e) { **throw new RuntimeException(e);** } };

 

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

컴파일러가 람다의 형식을 어떻게 확인하는지, 피해야 할 사항은 무엇인지 등 더 깊이있는 내용을 살펴본다.

람다로 함수형 인터페이스의 인스턴스를 만들 수 있다고 했다. 람다 표현식 자체에는 람다가 어떤 함수형 인터페이스를 구현하는지의 정보가 포함되어 있지 않는데 어떻게 알고 적절한 인스턴스를 만들어낼까? 람다의 실제 형식을 살펴볼 필요가 있다.

3.5.1 형식 검사

람다가 사용되는 콘텍스트(context)를 이용해서 람다의 형식을 추론할 수 있다. 어떤 콘텍스트(람다가 전달될 메서드 파라미터나 람다가 할당되는 변수 등)에서 기대되는 람다 표현의 형식을 **대상 형식(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을 반환하는 함수 디스크립터(Apple → boolean)를 묘사한다.
  5. 함수 디스크립터와 람다의 시그니처 비교 : Apple → boolean으로 람다의 시그니처와 일치함. 람다도 Apple을 인수로 받아 boolean을 반환하므로 코드 형식 검사가 성공적으로 완료된다.

3.5.2 같은 람다, 다른 함수형 인터페이스

대상 형식이라는 특징 때문에 같은 람다 표현식이라도 호환되는 추상 메서드를 가진 다른 함수형 인터페이스로 사용될 수 있다.

예를 들어 Callable과 PriviligedAction 인터페이스는 인수를 받지 않고 제네릭 형식 T를 반환하는 함수를 정의한다.

// () -> T
Callable<Integer> c = () -> 42;
PriviligedActio<Integer> p = () -> 42;

다이아몬드 연산자

자바 7에서도 다이아몬드 연산자로 콘텍스트에 따른 제네릭 형식을 추론할 수 있다. 주어진 클래스 인스턴스 표현식을 두 개 이상의 다양한 콘텍스트에 사용할 수 있다. 인스턴스 표현식의 형식 인수는 콘텍스트에 의해 추론된다.

List<String> listOfStrings = new ArrayList<>();
List<Integer> listOfIntegers = new ArrayList<>();

→ 다이아몬드 연산자를 사용해야 하는 이유?

 

Java 제네릭 - Raw Type을 쓰면 안되는 이유

작년 여름에 자바 제네릭과 가변성에 대해 정리하고, 나름 깨달은 바가 있다 생각했지만 최근에 아직도 모르는 게 많다는 것을 알게 되었다. 나의 무지를 일깨워 준 것은 프로젝트 진행 중에 맞

happinessoncode.com

  • 자바는 정적 언어다. 정적 언어라는 것은 자료형을 컴파일 시에 결정하는 언어를 의미한다.
  • 정적 언어는 컴파일 시에 타입에 대한 정보를 결정하기 때문에 속도 측면에서도 유리하고, 타입 에러로 인한 문제를 초기에 발견할 수 있어서 타입의 안정성을 보장한다.
  • 다이아몬드 연산자는 이러한 타입 안정성을 보장하기 위해 사용해야 한다.

→ 다이아몬드 연산자를 사용하지 않아도 컴파일에 문제가 없어보이는데?

 

자바 제네릭스(4) Java Generics: 원천(Raw) 타입들 - durtchrt

자바 제네릭스(java generics) 번역

durtchrt.github.io

  • generic 클래스 혹인 인터페이스에 타입 인자가 없는 것을 Raw 타입이라고 한다.
  • 제네릭을 지원하지 않는 JDK5.0 이전 자바 API와의 하위 호환을 위해 Raw 타입을 허용하고 있다.
  • Raw 타입을 사용하면 컴파일 시 unchecked라는 경고를 띄운다. “unchecked(체크되지 않음)“는 타입 안정성을 보장하기 위해 필요한 타입체크 정보가 충분치 않다는 것을 의미한다.

특별한 void 호환 규칙

람다의 바디에 일반 표현식이 있으면 void를 반환하는 함수 디스크립터와 호환된다.

예를 들어 List의 add 메서드는 Consumer 콘텍스트(T → void)가 기대하는 void 대신 boolean을 반환하지만 유효한 코드다.

// Predicate는 불리언 반환값을 갖는다.
Predicate<String> p = s -> list.add(s);
// Consumer는 void 반환값을 갖는다.
Consumer<String> c = s -> list.add(s);

3.5.3 형식 추론

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

결과적으로 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 이를 생략할 수 있다. 즉, 자바 컴파일러는 다음처럼 람다 파라미터 형식을 추론할 수 있다.

List<Apple> greenApples =
	filter(inventory, **apple** -> GREEN.equals(apple.getColoer()));

상황에 따라 명시적으로 형식을 포함하는 것이 좋을 때도 있고 형식을 배제하는 것이 가독성을 향상시킬 때도 있으므로, 개발자가 어떤 코드가 가독성을 향상시킬 수 있는지 결정해서 사용해야 한다.

3.5.4 지역 변수 사용

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

// portNumber 변수를 캡처하는 람다 예제
int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);

람다는 인스턴스 변수와 정적 변수를 자유롭게 캡처할 수 있지만, 지역 변수는 명시적으로 final로 선언되어 있거나 실질적으로 final로 선언된 변수와 똑같이 사용되어야 한다. 즉, 람다 표현식은 한 번만 할당할 수 있는 지역 변수를 캡처할 수 있다.

// portNumber에 값을 두 번 할당하므로 컴파일할 수 없는 코드다.
int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
portNumber = 31337;

지역 변수 제약

왜 지역 변수에 이런 제약이 필요할까?

  • 우선 내부적으로 인스턴스 변수와 지역 변수는 태생부터 다르다.
    • 인스턴스 변수는 힙에 저장, 지역 변수는 스택에 위치한다.
    • 스택은 스레드마다 고유한 영역이므로 다른 스레드에서 접근할 수 없다. 그렇기 때문에 다른 스레드의 람다 표현식에서도 해당 값에 접근하기 위해 복사본을 제공하도록 한다. → 그래서 람다 캡쳐링이라는 용어를 사용한다.
    • 람다가 스레드에서 실행된다면 변수를 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다. → 동시성 이슈
    • 따라서 복사본의 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생긴다.
  • 또한 지역 변수의 제약 때문에 외부 변수를 변화시키는 일반적인 명령형 프로그래밍 패턴(병렬화를 방해하는 요소)에 제동을 걸 수 있다.
    • 명령형 프로그래밍 - 어떻게 할지를 표현. 알고리즘을 명시하고, 목표를 명시하지 않는다.
      • for (int i = 0; i < 10; i++) {...}
    • 함수형 프로그래밍 - 무엇을 할지에 주목. 알고리즘을 명시하지 않고, 목표만 명시한다.
      • list.stream().forEach(...)

클로저(Clojure)

클로저란 함수의 비지역 변수를 자유롭게 참조할 수 있는 함수의 인스턴스를 가리킨다.

예를 들어 클로저를 다른 함수의 인수로 전달할 수 있다. 클로저는 클로저 외부에 정의된 변수의 값에 접근하고, 값을 바꿀 수 있다.

자바8의 람다와 익명 클래스는 클로저와 비슷한 동작을 수행하지만, 람다와 익명 클래스는 람다가 정의된 메서드의 지역 변수의 값은 바꿀 수 없다. 람다가 정의된 메서드의 지역 변수 값은 final 변수여야 한다.

지역 변수 값은 스택에 존재하므로 자신을 정의한 스레드와 생존을 같이 해야하며, 따라서 지역 변수는 final이어야 한다.

가변 지역 변수를 새로운 스레드에서 캡처할 수 있다면 안전하지 않은 동작을 수행할 가능성이 생긴다.

반응형