GYUD-TECH

[스터디: 모던 자바] 람다 본문

스터디

[스터디: 모던 자바] 람다

GYUD 2024. 3. 28. 17:52

 

동적 파라미터화를 이용하여 변화하는 요구사항에 효과적으로 대응할 수 있는 코드를 구현할 수 있다.

 

동적 파라미터화의 구현하기 위해서 익명 클래스람다식을 활용하는 예제를 이전장에서 공부했다.

익명 클래스로 다양한 동작을 파라미터화 할 수 있지만 코드가 깔끔하지 않다는 단점이 있어, 자바 8 에서는 람다식을 도입하여 익명 함수처럼 이름이 없는 함수를 메서드의 인자로 전달할 수 있게 되었다.

 

이번 장에서는 람다식의 동작 원리와 활용, 그리고 메서드 참조에 대해서 공부해보자.

 


람다

람다란?

람다 표현식은 익명 함수를 단순화한 것이라고 할 수 있다.

이런 람다는 메서드처럼 특정 클래스에 종속되지 않기 때문함수라고 부른다.

 

람다의 구성

(parameters) -> expression
(parameters) -> {statements;}

람다 표현식은 파라미터 리스트, 화살표, 람다 바디로 이루어져있다.

 

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

 

  • 파라미터 리스트: Apple a1, Apple a2 부분으로 추상 메서드의 파리미터 부분이다.
  • 화살표: 파라미터 리스트와 바디를 구분하는 부분이다.
  • 람다 바디: a.getWeight().compareTo(a2.getWeight()) 로 람다식의 구현에 해당하는 표현식이다.

 

이렇게 간단하게 표현된 람다식은 익명클래스의 역할을 대체할 수 있는데 어떻게 이게 가능한 것인지 알아보자.

 


람다의 동작방식

함수 디스크립터

함수형 인터페이스의 추상 메서드 시그니처를 뜻한다.

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

위메서드는 Apple 인스턴스 2개를 받아서 boolean을 리턴하기 때문에 (Apple, Apple) → int  와 같은 함수 디스크립터로 표현할 수 있다.

 

람다의 동작방식

람다는 함수형 인터페이스의 추상메서드 함수 디스크립터를 바탕으로 동작한다.

 

아래 예시를 보면서 람다의 동작 방식을 살펴보자.

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

 

  1. 먼저 filter 메서드의 선언을 보고 filter 메서드의 두번째 파라미터는 Predicate<Apple> 을 기대하는 것을 확인한다.
  2. Predicate<Apple> 이 test라는 한개의 추상메서드만을 가지는 것을 확인한다.
  3. test 메서드는 Apple 을 받아 boolean 값을 반환하는 함수 디스크립터를 가지는 것을 확인한다.
  4. filter 메서드로 전달된 람다식이 이를 만족하는지 확인한다.

 

어떤 콘텍스트에서 기대되는 람다 표현식의 형식대상 형식 이라고 한다.

위 예제의 경우에 대상 형식은 Predicate<Apple>로 T 가 Apple 로 대체되었다.

 

결국 대상 형식과 람다 표현식의 형식이 같다면 람다 표현식으로 대상 형식을 대체할 수 있는 것이다.

 

+) 특별한 void 호환 규칙

형식 검사에서 대상 형식이 void의 경우에는 람다식이 값을 리턴하더라도 void로 취급되어 대체할 수 있다.

Consumer<String> b = s -> s.length();

위의 Consumer 함수형 인터페이스는 String을 파라미터로 받아서 void를 리턴받는 코드이지만, s.length()와 같이 int형을 리턴받는 람다식을 작성해도 제대로 동작한다.

하지만 이때 Consumer의 추상 메서드 정의 자체는 void 이기 때문에 s.length()의 리턴값이 무시된다.

 

 

형식 추론

자바 컴파일러는 람다 표현식이 사용된 대상 형식을 이용하여 관련된 매개변수 타입과 반환 타입을 추론한다.

Comparator<Apple> c = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());

 

위 예시에서  Comparator의 추상클래스 compare() 는 (T, T) → int의 시그니처를 가진다.

이때 T가 Apple 인것을 알 수 있기 때문에 a1 과 a2의 타입을 생략하더라도 a1 과 a2의 타입이 Apple임을 추론할 수 있다.

 

이를 통해서 Apple이라는 타입을 다 작성하지 않고도 더욱 간단하게 람다식을 작성할 수 있다.

 

지역 변수 사용

람다 표현식에서는 파라미터로 넘긴 변수가 아닌 외부에서 정의된 변수도 사용할 수 있다.

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

Runnable 함수형 인터페이스는 () → void 형태의 시그니처를 가진다.

람다식으로는 파라미터로 값을 넘겨주지 않지만, 외부에서 선언한 portNumber를 람다식으로 작성하여 넘겨줄 수 있다.

 

외부에서 정의된 portNumber를 자유변수라고 하고 이 동작을 람다 캡처링 이라고 한다.

이때 자유변수는 final 로 선언되거나, 실질적으로 final 처럼 값이 재할당 되어서는 안된다.

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

위 코드는 portNumber의 값을 재할당하였기 때문에 에러가 발생한다.

자유 변수에 대한 final 제약 조건은 쓰레드 안정성과, 자바의 람다 구현 세부사항을 맞추기 위한 제약조건이다.

 

람다 표현식은 다른 쓰레드에 전달되고 실행될 수 있기 때문에 람다가 변경 가능한 지역변수에 엑세스하여 값을 수정 할 수 있다면, 동시성 문제가 발생할 수 있다.

 

또한 지역 변수는 스택 영역에 저장되기 때문에 쓰레드가 종료되면 사라지기 때문에 다른 쓰레드에서 값에 접근하기 위해서 원래 변수가 아닌 자유 지역 변수의 복사본을 제공한다.

이때 복사본의 값과 원래 값을 동일하게 맞추기 위해서 변수를 final로 설정하거나, final 처럼 동작하도록 해야한다.

 

람다와 익명 클래스는 모두 외부에서 정의된 변수에 값에 접근은 할 수 있지만 값을 바꿀 수는 없다는 사실을 기억하자.

 


함수형 인터페이스의 활용 사례

실행 어라운드 패턴

자원 처리에 사용하는 순환패턴은 자원을 열고, 처리한 다음 자원을 닫는 순서로 이루어진다.

이렇게 초기화/준비 코드 -> 작업 -> 정리/마무리 코드 형식의 코드를 실행 어라운드 패턴 이라고 한다.

 

이를 코드로 나타내면 아래와 같이 표현할 수 있다.

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

try-with-resources 구문을 사용하여 자원을 생성하고, 내부에서 작업하며 끝날 때 자원 사용을 자동으로 닫아주는 작업을 수행한다.

 

위 코드는 한번에 한 줄씩 자원을 읽을 수 있는 코드이다.

만약 한번에 두줄을 읽으려면 어떻게 해야할까?

 

자원을 열고 닫는 부분은 재사용하고 작업을 수행하여 줄을 읽는 부분만 동적 파라미터화를 통해 외부에서 넣어주면 중복 코드를 최소화 하여 로직을 구현할 수 있다.

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

 

이를 위해 (BufferedReader) → String 시그니처의 추상메서드를 가지는 함수형 인터페이스를 선언하자.

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

 

이후 아래와 같이 람다식을 활용하여 메서드를 외부에서 넣어줄 수 있다.

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

 

이렇게 코드를 작성하면 한줄, 두줄, 세줄을 읽던 간에 기존 코드는 그대로 두고, 요구사항에 따라 이를 외부에서 주입해주기만 하면 된다.

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

 

 

Predicate

T 형식의 객체를 인수로 받아서 boolean을 반환하는 Predicate는 함수형 인터페이스의 대표적인 예시이다.

@FuncionalInterface
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(listOfString, nonEmptyStringPredicate);

따라서 람다식으로 T 객체를 입력받아서 boolean을 리턴하는 함수를 주입하여 해당 객체를 생성할 수 있다.

 

Consumer

T 형식의 객체를 받아서 void로 리턴하는 accept라는 추상메서드를 정의한다.

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

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

 

 

Function

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

문자열의 길이 계산과 같이 입력을 출력으로 매핑해야 할 때 Function 인터페이스를 활용할 수 있다.

 

아래 예시는 String List를 인수로 받아서 각 String의 길이를 Integer List로 반환하는 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()
);

 

 

Supplier

void를 입력받아 특정 객체를 생성하기 위해 사용하는 함수형 인터페이스이다.

@FunctionalInterface
public interface Supplier<T> {
    public T get();
}

Supplier<Date> date = () -> new Date();
System.out.println(date.get());

 

 

기본형 특화 함수형 인터페이스

자바의 제네릭은 참조형만 사용할 수 있기 때문에 기본형인 int, double, byte, char 와 같은 자료형에는 사용할 수 없다.

따라서 기본 자료형으로 제네릭을 사용하기 위해서는 래퍼클래스로 변경해줘야 하는데 박싱 과정에서 메모리와 시간이 추가적으로 소모될 것이다.

 

자바는 기본 자료형도 람다식을 이용할 수 있도록 하기 위하여 함수형 인터페이스앞에 자료형의 이름을 붙인 기본형 특화 함수형 인터페이스를 제공한다.

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

IntPredicate evenNumbers = (int i) -> i % 2 == 0;
evenNumbers.test(1000);

위 함수형 인터페이스를 사용하면 별도의 박싱 과정없이 람다식으로 동적 파라미터화를 수행할 수 있어 시간과 메모리 효율이 더욱 좋아진다.

IntPredicate 외에도 DoublePredicate, IntConsumer, LongBinaryOperator, IntFunction 등 다양한 기본형 특화 함수형 인터페이스가 존재하기 때문에 필요할 때 이를 활용하자.

 


메서드 참조 & 생성자 참조

메서드 참조

메서드 참조란 말 그대로 메서드를 참조하는 것을 뜻한다.

Apple apple = new Apple();

위와 같은 코드가 있을 때 apple 을 Apple 객체의 참조 변수라고 한다.

 

객체와 비슷하게 메서드를 참조하고 싶을 때는 아래와 같이 코드를 작성하면 된다.

클래스명::메서드명

 

기존의 람다식을 활용한 코드를 메서드 참조를 활용하면 조금 더 간단하게 표현할 수 있다.

// 람다식
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));

// 메서드 참조
inventory.sort(comparing(Apple::getWeight));

이때 주의할 점은 실제 호출하는 것이 아니라 참조값을 전달하는 것이기 떄문에 ()를 붙이지 않는 것이다.

 

만약 List에 포함된 문자열을 대소문자를 구분하지 않고 정렬하는 프로그램을 구현한다고 가정하자.

List의 sort 메서드는 정렬 조건을 Comparator로 기대한다.

Comparator는 (T, T) → int 의 시그니처를 가지기 때문에 아래와 같이 람다식으로 나타낼 수 있다.

List<String> str = Arrays.asList("a", "b", "A", "B");
str.sort((s1, s2) -> s1.compareToIgnoreCase(s2));

 

이를 메서드 참조를 활용하면 아래와 같이 식을 간소화 시킬 수 있다.

List<String> str = Arrays.asList("a", "b", "A", "B");
str.sort(String::compareToIgnoreCase);

메서드 참조가 주어지면, 컴파일러는 메서드 참조가 주어진 함수형 인터페이스의 형식과 일치하는지 확인한다.

 

위 예시에서 Comparator의 compare() 추상 메서드는 (T, T) → int의 시그니처를 가지고, compareToIgnoreCase는 (String, String) → int 의 시그니처를 가지기 때문에 콘텍스트 형식이 일치한다.

따라서 compareToIgnoreCase() 메서드가 Comparator를 대체하며 대소문자 상관없이 정렬을 하는 기준을 sort 메서드에 제공한다.

 

 

생성자 참조

ClassName::new 의 형태로 메서드 참조와 비슷하게 생성자 참조도 만들 수 있다.

 

기존에 Supplier<T> 를 통해서 () → T 형식의 기능 인터페이스를 정의했다.

Supplier<Apple> s1 = () -> new Apple();

 

생성자 참조를 활용하여 메서드 참조의 메서드 위치에 new 를 넣어서 아래와 같이 간략하게 표현해보자.

Supplier<Apple> s1 = Apple::new;

 

만약 특정 파라미터로 int 인스턴스를 가지는 Apple을 생성한다면 아래와 같이 코드를 작성해줄 수 있다.

Function<Integer, Apple> f1 = (weight) -> new Apple(weight);
Function<Integer, Apple> f1 = Apple::new;

 

이렇게 생성자 참조를 통해서 복잡한 코드를 더 간단하게 표현하는 기능이 바로 생성자 참조이다.

 


람다, 메서드 참조 활용하기

사과 list가 있을 때 사과를 무게로 정렬해보자.

먼저 자바의 list 에서 sort 메서드를 지원하기 떄문에 정렬 조건만 신경 써주면 된다.

 

1. AppleComparator 만들기

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

inventory.sort(new AppleComaparator());

기존에 코딩테스트 문제를 풀 때 위와 같은 코드를 많이 작성했었는데 이를 더 간단하게 개선해보자.

 

2. 익명 클래스 사용

위 코드는 한번만 사용하는 Comparator 클래스를 선언해야 하기 때문에 익명클래스를 이용하여 불필요한 부분을 없엘 수 있다.

inventory.sort(new Comparator<Apple>() {
    public int compare(Apple a1, Apple a2) {
        return a1.getWeight() - a2.getWeight();
    }
}

 

3. 람다 표현식 사용

익명 클래스를 람다 표현식을 활용하여 더 간소화해보자.

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

 

사실 자바의 Comparator키값을 추출해서 Comparator 객체를 반환해주는 comparing 메서드를 제공한다.

public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
        Function<? super T, ? extends U> keyExtractor) {
    Objects.requireNonNull(keyExtractor);
    return (Comparator<T> & Serializable)
        (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}

comparing 메서드에서는 Function<T, U> 형태의 시그니처를 가진 메서드를 파라미터로 받기 때문에 Apple.getWeight() 메서드를 파라미터로 전달할 수 있다.

inventory.sort(comparing((a1) -> a1.getWeight()));

 

4. 메서드 참조 사용

a1.getWeight()는 Apple 내에 정의된 메서드이기 때문에 메서드 참조를 사용하여 더 간단하게 표현할 수 있다.

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

 

위 코드는 매우 간단할 뿐만 아니라 Apple 객체의 무게를 가져와서 비교하여 정렬한다는 의미도 명확하게 드러난다.

 

comparing 메서드는 Comparator 인터페이스에서 제공하는 편의메서드로 코드를 간단하게 표현하는 것을 도와줬다.

이 외에도 어떤 편의 메서드들이 있는지 알아보자.

 


다양한 편의 메서드

함수형 인터페이스에서는 다양한 편의 메서드를 디폴트 메서드의 형태로 제공해준다.

이런 편의 메서드들을 연결해서 사용한다면, 코드를 보다 간단하게 작성할 수 있다.

 

Comparator 조합

사과 리스트가 주어질 때 사과를 무게의 내림차순으로 정렬해야한다면 어떻게 해야할까?

comparing을 통해서 정렬 기준을 추출하고 난 후에 이를 역순으로 바꾸기 위해서 Comparator의 reversed() 메서드를 활용하면 된다.

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

 

여기서 만약 무게가 같을때 국가별로 정렬하는 기준을 추가하려면 어떻게 해야할까?

이때는 thenComparing() 메서드를 활용하여 두번째 비교자를만들 수 있다.

    default <U extends Comparable<? super U>> Comparator<T> thenComparing(
            Function<? super T, ? extends U> keyExtractor)
    {
        return thenComparing(comparing(keyExtractor));
    }
    
    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);
        };
    }

thenComparing 메서드는 두번째 정렬 기준을 받아서 만약 이전의 기준의 정렬 기준 값이 같을 때 2번째 정렬 기준을 적용한다.

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

위와 같이 편의 메서드를 연쇄적으로 연결하여 람다식과 메서드 참조 만으로 복잡한 식을 간소화 할 수 있다.

 

코딩테스트에서도 여러가지 정렬기준을 적용하여 list를 정렬하는 경우가 많은데 앞으로는 위 방법을 활용하여 더욱 간단하게 코드를 표현할 수 있을 것 같다.

 

 

Predicate 조합

predicate 인터페이스는 복잡한 predicate를 만들 수 있도록 negate, and, or 메서드를 제공한다.

 

negate

기존 predicate의 부정을 negate()를 활용하여 만들 수 있다.

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

 

and, or

두 Predicate를 합쳐서 새로운 Predicate을 만들 수도 있다.

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

이렇게 기존 람다식에 여러 람다식을 조합하여 사용할 수 있으며, 가독성 또한 좋아지기 때문에 적극적으로 활용하자.

 

 

Function 조합

Function 인터페이스도 andThen, compose 두가지 디폴트 편의 메서드를 제공한다.

 

andThen, compose

주어진 함수를 먼저 적용한 결과를 다른 함수의 입력으로 전달하는 방식이다.

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 1;

Function<Integer, Integer> h1 = f.andThen(g);
Function<Integer, Integer> h2 = f.compose(g);

System.out.println(h1.apply(1)) // 4
System.out.println(h2.apply(1)) // 3

두 메서드 모두 기존의 Function 인터페이스에 다른 Function 인터페이스를 연결하는 역할을 한다.

 

이때 둘의 가장 큰 차이점은 연결하는 순서이다.

 

  • f.andThen(g): andThen은 말 그대로 f를 먼저 하고, 그 다음에 g를 수행하라는 의미이다.
  • f.compoase(g): 반대로 g를 먼저 수행하고 f를 수행하는 것이다.

 

이처럼 andThen과 compose는 하나의 파이프라인을 작게 분리하여 Function을 재사용 할 수 있도록 도와주는 편의 메서드이다.

 


이번 장에서는 람다식에 대해서 깊이 있게 공부할 수 있었다.

 

이때까지 자바를 잘 안다고 생각했지만, 람다를 공부하며 정말 모르는게 많다는 사실을 깨달았다.

람다를 공부하면서 평소에 자주 사용했던 Comparator를 어떻게하면 잘 활용할 수 있을지를 알 수 있었다.

 

이전의 오브젝트 책에 비해서는 내용이 많고, 어렵긴 하지만, 하나씩 공부하면서 자바를 잘 다루는 진정한 자바 개발자로 성장하고 싶다.