3장: 람다

익명클래스 대신 람다로 간결한 코드를 작성하자.

람다

  • 메서드로 전달할 수 있는 익명 함수를 단순화한 것

  • 특징

    • 익명: 일반적인 메서드와 달리 이름이 없어, 구현해야 할 코드에 대한 걱정거리가 줄어든다.

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

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

    • 간결성: 익명 클래스처럼 불필요한 선언 등의 코드를 작성할 필요가 없다.

  • 익명 클래스에서 구현했던 메서드의 바디만 직접 전달하는 것과 비슷하다.

람다의 구성

  • 파라미터 리스트

    • 람다 바디에서 사용되는 파라미터를 입력받는다.

  • 화살표(→)

    • 화살표는 람다의 파라미터 리스트와 바디를 구분한다.

  • 람다 바디

    • 람다의 반환값에 해당하는 표현식이다.

스타일

  • 일반 표현식 스타일

    • (parameters) -> expression

    • 람다 표현식에는 return이 함축되어 있으므로 return을 명시적으로 사용하지 않아도 된다.

    • (String s) -> "Iron Man" 형태는 파라미터를 입력받아 "Iron Man"을 return하는 람다 식이다.

  • 블록 스타일

    • (parameters) -> { statements; }

    • 블록에는 여러 행의 문장을 포함하는 구문이 들어가며, 리턴 타입이 void가 아니라면 return을 명시적으로 사용해야한다.

    • () -> {} 형태는 파라미터가 없는 void 람다 식이다.

    • (Integer i) -> {return "Alan" + i;} 형태는 파라미터를 입력받아 특정 문자열을 반환하는 람다 식이다.

람다 사용하기

실행 어라운드 패턴

  • DB 등의 자원을 처리할 때 자원을 초기화/준비 -> 작업 처리 -> 자원을 정리/마무리 하는 순서로 이뤄진다.

  • 실제 자원을 처리하는 코드를 설정과 정리 두 과정이 둘러싸는 형태를 실행 어라운드 패턴이라고 부른다.

  • 아래는 try-with-resources 구문을 사용해 파일에서 한 행을 읽는 코드이다.

public String processFile() throws IOException {
	try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
		return br.readLine(); // 실제 필요한 작업 수행
	}
}
  • 이제 함수형 인터페이스를 이용해 동작을 전달해보자. 정의한 BufferedReaderProcessor 인터페이스를 processFile 메서드의 인수로 전달할 수 있다.

@FuntionalInterface
public interface BufferedReaderProcessor {
	String process(BuffredReader b) throws IOException;
}
public String processFile(BufferedReaderProcessor p) throws IOException {
	try (BuffredReader br = new BuffredReader(new FileReader("data.txt"))) {
		return p.process(br);
	}
}
  • 다양한 동작들을 람다식 형태로 processFile 메서드에 전달할 수 있다.

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

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

함수형 인터페이스

개념

  • 정확히 하나의 추상 메서드를 지정하는 인터페이스

  • 여러 디폴트 메서드가 있어도 추상 메서드가 하나뿐이면 함수형 인터페이스다.

  • 다음은 함수형 인터페이스의 예시이다.

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

public interface Runnable {
    void run();
}

public interface Callable<V> {
    V call() throws Exception;
}
  • 람다 표현식으로 이러한 함수형 인터페이스의 추상 메서드를 구현해 직접 전달할 수 있다.

// 람다로 Runnable의 구현체 생성 가능
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);
// Runnable 구현체를 미리 생성해두지 않고 바로 만들어 사용 가능
process(() -> System.out.println("Hello Wordl 3"));

함수형 인터페이스 종류

Predicate<T>

  • test라는 추상 메서드를 정의한다.

  • test 메서드는 제네릭 형식 T의 객체를 인수로 받아 boolean 값을 반환한다.

  • 다음 코드는 Predicate 인터페이스를 구현해 String 객체가 Empty가 아닌 경우를 필터링한다.

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

// T 객체를 입력받아 boolean을 반환하도록 한다.
Predicate<String> nonEmptyStringPredicate = (String) -> !s.isEmpty();

// String 리스트 중 Empty가 아닌 String만 필터링한다.
List<String> nonEmpty= filter(listOfStrings, nonEmptyStringPredicate);

Consumer<T>

  • 제네릭 형식 T 객체를 받아서 void를 반환하는 accept라는 추상 메서드를 정의한다.

  • T 형식의 객체를 인수로 받아서 어떤 동작을 수행하고 싶을 때 Consumer 인터페이스를 사용할 수 있다.

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

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<T, R>

  • 제네릭 형식 T 객체를 인수로 받아 제네릭 형식 R 객체를 반환하는 추상 메서드 apply를 정의한다.

  • 입력을 출력으로 매핑하는 람다를 정의할 때 Function 인터페이스를 활용할 수 있다.

    • ex) 사과를 입력받아 사과의 무게 정보만 추출, 문자열을 입력받아 문자열의 길이만 추출

  • 다음 코드는 String 리스트를 인수로 받아 각 String의 길이를 포함하는 Integer 리스트로 반환한다.

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

primitive type을 위한 함수형 인터페이스

  • 제네릭 파라미터에는 reference type(참조형)만 입력받을 수 있다.

  • 따라서 기본형을 입출력으로 사용할 때 자동으로 박싱 타입으로 변환(오토박싱)하는 과정을 피할 수 있도록 각 primitive type을 위한 함수형 인터페이스를 제공한다.

  • 특정 형식을 입력받는 함수형 인터페이스의 경우 IntPredicate, DoublePredicate, LongBinaryOperator , IntFunction 처럼 타입명이 함께 붙는다.

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

IntPredicate evenNumbers = (int i) -> i % 2 == 0;
evenNumbers.test(1000); // int -> int 그대로 사용하므로 박싱 과정이 발생 x

다양한 함수형 인터페이스 정리

함수 디스크립터

  • 함수 디스트립터(function descriptor): 람다 표현식의 시그니처를 서술하는 메서드

  • 함수형 인터페이스의 추상 메서드 시그니처(입력 인자, 반환값 등 메서드의 특성)는 람다 표현식의 시그니처와 동일해야 한다.

  • 아래와 같이 함수형 인터페이스의 추상 메서드 시그니처와 람다의 시그니처가 다르다면 유효하지 않은 람다식이 된다. (Predicate는 boolean을 반환해야 하는데, 람다 식에서는 int를 반환하고 있어 메서드 시그니처 불일치!)

Predicate<Apple> p = (Apple a) -> a.getWeight();

@FunctionalInterface

  • 함수형 인터페이스임을 가리키는 어노테이션

  • 함수형 인터페이스가 아니라면 컴파일 에러가 발생한다.

예외 처리

  • 아래와 같이 Checked Exception인 IOException을 명시적으로 선언하는 함수형 인터페이스를 만들었다면, 예외를 던질 수 있는 람다를 사용해도 된다.

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

BufferedReaderProcessor p = (BufferedReader br) -> br.readline();
  • 하지만 자바에서 제공하는 함수형 인터페이스의 경우 Checked Exception을 던지는 동작을 허용하지 않는다.

  • 따라서 아래와 같이 람다식 내부에서 try/catch 블록으로 Checked Exception을 처리해주어야 한다.

Function<BufferedReader, String> f = (BufferedReader b) -> { 
    try { 
        return b.readLine();
    } 
    catch (IOException e) {
        throw new RuntimeException(e);
    }
};

형식 검사, 추론, 제약

  • 람다 표현식 자체에 어떤 함수형 인터페이스를 구현하는지 정보가 포함되어 있지 않으므로 람다의 형식을 파악해야 한다. 람다의 형식을 파악하기 위해 다음 세 가지의 과정을 거친다.

형식 검사

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

  • 어떤 콘텍스트(ex. 람다가 전달될 메서드 파라미터나 람다가 할당되는 변수 등)에서 기대되는 람다 표현의 형식을 대상 형식(target type)이라고 부른다.

List<Apple> heavierThan150g = 
	filter(inventory, (Apple apple) -> apple.getWeight() > 150);
  • 형식 검사 과정은 다음과 같다.

  1. 람다가 사용된 콘텍스트를 파악하기 위해 람다를 입력 인자로 받는 메서드의 선언을 먼저 확인한다.

    메서드는 파라미터로 Predicate<Apple> 형식(대상 형식)을 기대하며, Predicate 함수형 인터페이스는 test라는 한 개의 추상 메서드를 정의한다.

  2. test 메서드의 시그니처를 통해 Apple 객체를 입력받아 boolean을 반환하는 함수 디스크립터를 확인할 수 있다.

  3. 함수 디스크립터와 람다의 시그니처(Apple 객체를 입력받아 boolean을 반환)를 비교하여 일치하면 형식 검사를 완료한다.

  • 하나의 람다 표현식은 다양한 함수형 인터페이스에 할당될 수 있다.

  • 대상 형식이 다르더라도 람다 표현식과 호환되는 추상 메서드를 가진다면 아래처럼 정상적으로 할당된다.

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

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

예를들어 list.add(s) 메서드는 boolean을 반환하지만, 다음과 같이 작성하면 유효하게 할당되며 반환되는 값은 무시된다.

Consumer<String> cs = s -> list.add(s);

아래처럼 같은 함수형 디스크립터를 가진 두 함수형 인터페이스를 갖는 메서드를 오버로딩할 때 명시적으로 함수형 인터페이스로 캐스팅하여 분명하게 할 수 있다.

public void execute(Runnable runnable) {

runnable.run();

}

public void execute(Action<T> action) {

action.act();

}

execute((Action) () -> {});

형식 추론

  • 자바 컴파일러는 람다 표현식이 사용된 콘텍스트(대상 형식)를 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다.

  • 즉, 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론할 수 있다.

  • 결과적으로 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 이를 생략할 수 있다.

  • 자바 컴파일러는 다음처럼 람다 파라미터 형식을 추론할 수 있다.

// 형식을 지정해주어 형식 추론이 필요 없음
List<Apple> greenApples =
	filter(inventory, Apple a -> GREEN.equals(a.getColor()));

// 형식을 지정하지 않아 형식 추론이 필요함
List<Apple> greenApples =
	filter(inventory, a -> GREEN.equals(a.getColor()));

제약

  • 람다 캡처링(capturing lambda): 익명 함수처럼 파라미터로 넘겨진 변수가 아닌 외부에서 정의된 자유 변수(free variable)를 활용할 수 있다

  • 람다는 인스턴스 변수와 정적 변수 모두 자유롭게 사용할 수 있지만, 한번만 할당할 수 있도록 final로 선언되거나 final 변수와 동일한 상태여야만 한다.

  • 그렇지 않다면 Variable used in lambda expression should be final or effectively final 컴파일 오류가 발생한다.

int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
  • 이러한 제약이 있는 이유는 람다가 스레드에서 실행될 때, 변수를 할당한 스레드가 사라져 변수 할당이 해제되었는데도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있어 복사본의 값이 바뀌지 않도록 해야 하기 때문이다.

  • 람다에 값을 복사해서 사용하는 이유는 인스턴스 변수는 힙에 저장되고 지역 변수는 스택에 위치하는데, 스택은 스레드마다 고유한 영역이므로 다른 스레드에서 접근할 수 없다. 따라서 다른 스레드의 람다 표현식에서도 해당 값에 접근할 수 있도록 복사해서 사용한다.

클로저

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

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

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

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

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

메서드 참조

  • 특정 메서드만을 호출하는 람다를 축약해서 표현하는 방식

  • 메서드명 앞에 구분자 '::' 를 붙여 메서드 참조를 활용 가능하다.

  • 기존의 메서드 정의를 재활용해 람다처럼 전달 가능하다.

  • 메서드 참조와 주어진 함수형 인터페이스의 디스크립터와 호환되어야 한다.

  • 컴파일러는 람다 표현식의 형식을 검사하던 방식과 비슷하게 메서드 참조가 주어진 함수형 인터페이스와 호환되는지 확인한다.

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

// 메서드 참조와 java.util.Comparator.comparing을 활용한 코드
inventory.sort(comparing(Apple::getWeight));

유형

  1. 정적 메서드 참조

    • 클래스이름::정적메서드이름 형태로 참조할 수 있다.

    • Intege의 정적 메서드 parseInt()를 람다로 표현하면 Integer.parseInt()

    • 메서드 참조로 표현하면 Integer::parseInt

  2. 다양한 형식의 인스턴스 메서드 참조

    • 클래스이름::인스턴스메서드이름 형태로 참조할 수 있다.

    • String의 length()를 람다로 표현하면 (String s) -> s.length()

    • 메서드 참조로 표현하면 String::length

  3. 기존 객체의 인스턴스 메서드 참조

    • 객체이름::인스턴스메서드이름 형태로 참조할 수 있다.

    • expensiveTransaction.getValue()를 람다로 표현하면 () -> expensiveTransaction.getValue()

    • 메서드 참조로 표현하면 expensiveTransaction::getValue

    • 공통적으로 반복되는 작업을 수행하도록 만든 비공개 헬퍼 메서드를 유용하게 사용할 수 있다.

// 비공개 헬퍼 메서드 예시
private boolean isValidName(String string) {
	return Character.isUpperCase(string.charAt(0));
}

filter(list, this::isValidName); // filter(list, (String s) -> isValidName(s)); 와 같은 의미

생성자 참조

  • new 키워드를 사용해 생성자의 참조를 만들 수 있다.

  • 인수가 없는 생성자의 경우 Supplier 함수형 인터페이스의 시그니쳐와 같다.

Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get();
  • 인수가 하나인 생성자의 경우 Function 함수형 인터페이스의 시그니처와 같다.

Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(110);
  • 인수가 두개인 생성자의 경우 BiFunction 함수형 인터페이스의 시그니처와 같다.

BiFunction<Color, Integer, Apple> c3 = Apple::new;
Apple a3 = c3.apply(GREEN, 110);
  • 인수를 세 개 이상 받고 싶은 경우, 직접 시그니처를 갖는 함수형 인터페이스를 만들어 사용해야 한다.

public interface TriFunction<T, U, V, R> {
    R apply(T t, U u, V v);
}

TriFunction<Integer, Integer, Integer, Color> colorFactory = Color::new;
  • 다음과 같이 giveMeFruit 메서드를 호출할 때 Fruit의 이름과 무게를 넣어 실제 객체가 생성되도록 만들 수도 있다.

static Map<String, Function<Integer, Fruit>> map = new HashMap<>();
static {
	map.put("apple", Apple::new);
	map.put("orange", Orange::new);
}

public static Fruit giveMeFruit(String fruit, Integer weight) {
	return map.get(fruit.toLowerCase())
            .apply(weight);
}

마무리

  • 함수형 인터페이스 구현체로 시작해 람다, 메서드 참조를 활용하는 것까지의 진화과정을 살펴보자.

  • 함수형 인터페이스를 직접 구현한 클래스를 넘긴다.

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

inventory.sort(new AppleComparator());
  • 익명 클래스를 넘긴다.

inventory.sort(new Comparator<Apple>() {
	public int compare(Apple a1, Apple a2) {
		return a1.getWeight().compareTo(a2.getWeight());
	}
});
  • 람다 표현식을 넘긴다.

inventory.sort((a1, a2) -> a1.getWeight().compareTo(a2.getWeight());
  • 정적 메서드인 comparing()을 사용해 간결화한다.

import static java.util.Comparator.comparing;
inventory.sort(comparing(apple -> apple.getWeight()));
  • 정적 메서드를 메서드 참조하여 간결화한다.

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

람다 표현식 조합을 위한 유용한 메서드

  • 몇몇 함수형 인터페이스는 람다 표현식 조합을 위해 유용한 메서드들을 제공한다.

  • default method를 사용해 제공하여 함수형 인터페이스의 정의를 벗어나지 않는다.

  • 단순 람다 표현식을 조합해 복잡한 람다 표현식을 만들 수 있으며, 가독성도 해치지 않는다.

Comparator

reversed()

  • 주어진 비교자의 순서를 뒤바꾸는 default 메서드를 제공하여 역정렬할 수 있다.

default Comparator<T> reversed() {
	return Collections.reverseOrder(this);
}

inventory.sort(comparing(Apple::getWegiht).reversed());

thenComparing()

  • Comparator를 여러 개 나열해서 처음 비교한 값이 같다면 다음 비교자 기준으로 비교하도록 할 수 있다.

default Comparator<T> thenComparing(Comparator<? super T> other) {
    Objects.requireNonNull(other);
    return (Comparator<T> & Serializable) (c1, c2) -> {
        int res = compare(c1, c2);
        return (res != 0) ? res : other.compare(c1, c2);
    };
}

inventory.sort(comparing(Apple::getWeight)
                    .reversed()
    		    .thenComparing(Apple::getCountry));

Predicate

negate()

  • 특정 Predicate를 반전시키는 메서드이다.

  • 아래처럼 사과 색이 빨강이면 true를 반환하는 람다를 negate 메서드를 통해 반전시킬 수 있다.

Predicate<Apple> notRedApple = redApple.negate();

and(), or()

  • and 메서드와 or 메서드를 이용해 "빨갛고 150g 이상인 사과 또는 녹색 사과일 때 true를 반환"하도록 할 수 있다.

Predicate<Apple> redAndHeavyApple = 
		redApple.and(apple -> apple.getWeight() > 150)
			.or(apple -> GREEN.equals(a.getColor()));

Function

andThen()

  • 주어진 함수 적용 결과를 다른 함수의 입력으로 전달한다.

  • 다음 예제의 경우 f 함수 먼저 수행한 후 g 함수를 수행하게 된다.

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.andThen(g); // h(x) = g(f(x))
int result = h.apply(1) // result = 4

compose()

  • 입력 인자로 전달된 함수를 먼저 실행한 후 결과를 외부의 함수에 전달한다.

  • 아래 예제의 경우 g 함수를 먼저 수행한 후 f 함수를 수행한다.

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.compose(g); // h(x) = f(g(x))
int result = h.apply(1) // result = 3

Last updated