Peony의 기록 창고 🌼
반응형

이 글은 모던 자바 인 액션 책을 읽고 정리한 내용입니다.

http://www.yes24.com/Product/Goods/77125987

 

모던 자바 인 액션 - YES24

자바 1.0이 나온 이후 18년을 통틀어 가장 큰 변화가 자바 8 이후 이어지고 있다. 자바 8 이후 모던 자바를 이용하면 기존의 자바 코드 모두 그대로 쓸 수 있으며, 새로운 기능과 문법, 디자인 패턴

www.yes24.com

 

 2장에서 동작 파라미터화를 이용해서 변화하는 요구사항에 효과적으로 대응하는 코드를 구현한 수 있음을 확인했다. 동작 파라미터화를 이용하면 더 유연하고 재사용할 수 있는 코드를 만들 수 있다.

 익명 클래스로 다양한 동작을 구현할 수 있지만, 만족할 만큼 깔끔하지는 않았다. 3장에서는 더 깔끔한 코드로 동작을 구현하고 전달하는 자바8의 새로운 기능인 람다 표현식을 설명한다. 이 장에서는 람다 표현식을 어떻게 만드는지, 사용하는지, 어떻게 코드를 간결하게 만들 수 있는지 설명한다.

 

3.1 람다란 무엇인가?

람다 표현식 : 메서드로 전달할 수 있는 익명 함수를 단순화한 것.

람다 표현식에는 이름은 없지만, 파라미터 리스트, 바디 반환 형식, 발생할 수 있는 예외 리스트는 가질 수 있다.

 

람다의 특징

  • 익명 : 보통의 메서드와 달리 이름이 없는 익명 으로 표현된다.
  • 함수 : 람다는 메서드처럼 특정 클래스에 종속되지 않으므로 함수 라고 부른다.
  • 전달 : 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
  • 간결성 : 익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다.

 

람다를 이용하면 간결한 방식으로 코드를 전달할 수 있다. 예를 들어 커스텀 Compartor객체를 기존보다 간단하게 구현할 수 있다.

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

 

람다는 세 부분으로 이루어진다.

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

 

다음은 람다의 기본 문법이다.

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

 

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

이전 예제에서는 Comparator<Apple> 형식의 변수에 람다를 할당했다.

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

 

그래서 정확히 어디에서 람다를 사용할 수 있다는 걸까?

함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다. 함수형 인터페이스가 무엇인지 자세히 살펴보자.

 

1. 함수형 인터페이스

 2장에서 필터 메서드를 파라미터화하기 위해 사용했던 Predicate<T>가 바로 함수형 인터페이스 이다. Predicate<T>느는 오직 하나의 추상 메서드만 지정하기 때문이다.

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

 

함수형 인터페이스는 정확히 하나의 추상 메서드를 지정하는 인터페이스로, Comparator, Runnable 등이 있다.

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

public interface Runnable {
    void run();
}

 

 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있므로 전체 표현식을 함수형 인터페이스의 인스턴스로 취급할 수 있다. 함수형 인터페이스보단 덜 깔끔하지만 익명 내부 클래스로도 같은 기능을 구현할 수 있다.

Runnable r1 = () -> System.out.println("HELLO WORLD");
Runnable r2 = new Runnable() {
  public void run() {
    System.out.println("HELLO WORLD2");
  }
};

public static void process(Runnable r) {
  r.run();
}

process(r1);
process(r2);
process(() -> System.out.println("HELLO WORLD 3"));

 

2. 함수 디스크립터

람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터 라고 한다.

이 장에서는 람다와 함수형 인터페이스를 가리키는 특별한 표기법을 사용한다. () -> void 표기는 파라미터 리스트가 없으며 void를 반환하는 함수를 의미한다.(앞에서 설명한 Runnable)

 

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

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

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

 

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

현재 코드는 파일에서 한 번에 한 줄만 읽을 수 있다. 한 번에 두 줄을 읽거나 가장 자우 사용되는 단어를 반환하려면 어떻게 해야 할까?

기존의 설정, 정리 과정은 재사용하고, processFile메서드만 다른 동작을 수행하도록 명령할 수 있다면 좋을 것이다. processFile의 동작을 파라미터화 해보자.

BufferedReader를 이용해서 다른 동작을 수행할 수 있도록 processFile 메서드로 동작을 전달해야 한다.

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

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

 

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

함수형 인터페이스 자리에 람다를 사용할 수 있다.

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

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

 

정의한 인터페이스를 processFile 메서드의 인수로 전달해보자

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

 

3. 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. 4단계 : 람다 전달

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

//한 행
String oneLine = processFile((BufferedReader br) -> br.readLine());
//두 행
String twoLines = processFile((BufferedReader br) -> br.readLine() + br.readLine());

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

 

3.4 함수형 인터페이스 사용

함수형 인터페이스의 추상 메서드는 람다 표현식의 시그니처를 묘사한다. 함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터라고 한다. 다양한 람다 표현식을 사용하려면 공통의 함수 디스크립터를 기술하는 함수형 인터페이스 집합이 필요하다. 우리가 3.2에서 본 것 처럼 이미 자바 API는 다양한 함수형 인터페이스를 포함하고 있다. 다양한 함수형 인터페이스들을 알아보자.

 

1. Predicate

java.util.function.Predicate<T> 인터페이스는 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(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 형식의 객체를 인수로 받아 어떤 동작을 수행하고 싶을 때 사용할 수 있다.

@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. Function

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

@FunctionalInterface
public interface Consumer<T> {
    void accept(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()
);

 

기본형 특화

자바의 모든 형식은 참조형 아니면 기본형에 해당한다. 하지만, 제네릭 파라미터에는 참조형만 사용할 수 있다. 자바에서는 기본형을 참조형으로 변환하는 기능을 제공하는데, 이 기능을 박싱이라고 한다. 반대 동작은 언박싱이라고 한다. 이 두가시 기능이 자동으로 이루어지는 오토박싱이라는 기능도 제공한다.

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

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

IntPredicate evenNumbers = (int i) -> i % 2 == 0;
evenNumbers.test(1000); //참(박싱 없음)

Predicate<Integer> oddNumbers = (Integer i) -> i % 2 != 0;
oddNumbers.test(1000); //거짓(박싱)

 

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

람다의 실제 형식을 파악해보자.

1. 형식 검사

람다가 사용되는 콘텍스트를 이용해서 람다의 형식을 추론할 수 있다. 어떤 콘텍스트에서 기대되는 람다 표현식의 형식을 대상 형식이라고 부른다.

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

 

위의 예제에서는 다음과 같은 순서로 형식 확인 과정이 진행된다.

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

이 예제에서 람다 표현식은 Apple을 인수로 받아 boolean을 반환하므로 유효한 코드이다. 람다 표현식이 예외를 던질 수 있으면 추상 메서드도 같은 예외를 던질 수 있도록 throws로 선언해야 한다.

 

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

대상 형식이라는 특징 때문에 같은 람다 표현식이라도 다른 함수형 인터페이스로 사용될 수 있다. 즉, 하나의 람다 표현식을 다양한 함수형 인터페이스에 사용 할 수 있다.

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

ToIntBiFunction<Apple, Apple> c2 =
    (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

BiFunction<Apple, Apple, Integer> c3 =
    (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

 

3. 형식 추론

자바 컴파일러는 대상형식을 이용해서 람다 표현식과 관련된 함수형 인터페이스와 시그니처를 추론할 수 있다. 때문에 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 이를 생략할 수 있다. 즉, 자바 컴파일러는 다음처럼 람다 파라미터 형식을 추론할 수 있다.

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

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

 

4. 지역 변수 사용

지금까지의 모든 람다 표현식은 인수를 자신의 바디 안에서만 사용했다. 하지만, 람다 표현식에서는 익명 함수가 하는 것처럼 자유 변수를 활용할 수 있다. 이와 같은 동작을 람다 캡처링이라고 부른다.

다음은 portNumber 변수를 캡처하는 람다 예제다.

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

람다는 인스턴스 변수와 정적 변수를 자유롭게 캡처할 수 있다. 하지만, 그러려면 지역 변수는 명시적으로 final로 선언되어 있어야 하거나 실질적으로 final로 선언된 변수와 똑같이 사용되어야 한다.

 

3.6 메서드 참조

메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다.

//기존 코드
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
//메서드 참조를 활용한 코드
inventory.sort(comparing(Apple::getWeight));

 

1. 요약

메서드 참조가 왜 중요할까? 메서드 참조는 특정 메서드만을 호출하는 람다의 축약형이라고 생각할 수 있다.

그럼 메서드 참조는 어떻게 활용할까? 메서드명 앞에 구분자(::)를 붙이는 방식으로 메서드 참조를 활용할 수 있다.

 

예제

//람다
(Apple apple) -> apple.getWeight);

//메서드 참조 단축 표현
Apple::getWeight

 

메서드 참조를 만드는 방법

  1. 정적 메서드 참조
    Integer::parseInt
  2. 다양한 형식의 인스턴스 메서드 참조
    String::length
  3. 기존 객체의 인스턴스 메서드 참조

 

2. 생성자 참조

ClassName::new처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 참조를 만들 수 있다. 이것은 정적 메서드의 참조를 만드는 방법과 비슷하다.

Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get();    //Supplier의 get 메서드를 호출해서 새로운 Apple 객체를 만들 수 있다.

위 코드는 다음 코드와 같다.

Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get();

 

Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(110);    //Function의 apply메서드에 무게를 인수로 호출해서 새로운 Apple 객체를 만들 수 있다.

이 코드도 다음과 같다.

Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(110);

 

다음 코드에서 Integer를 포함하는 리스트의 각 요소를 우리가 정의했던 map 같은 메서드를 이용해서 Apple 생성자로 전달한다. 결과적으로 다양한 무게를 포함하는 사과 리스트가 만들어진다.

List<Integer> weights = Arrays.asList(7, 3, 4, 10);
List<Apple> apples = map(weights, Apple::new);
public List<Apple> map(List<Integer> list, Function<Integer, Apple> f) {
  List<Apple> result = new ArraryList<>();
  for(Integer i : list) {
    result.add(f.apply(i));
  }
  return result;
}

 

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

처음에 다룬 사과 리스트를 다양한 정렬 기법으로 정렬하는 문제로 다시 돌아가서 이 문제를 더 세련되고 간결하게 해결하는 방법을 보여주면서 마무리해보자.

1. 1단계 : 코드 전달

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

 

2단계 : 익명 클래스 사용

한 번만 사용하는 Comparator는 위 코드보단 익명 클래스 를 이용하는 것이 좋다.

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

 

3단계 : 람다 표현식 사용

하지만, 여전히 코드가 장황한 편이다. 람다 표현식 을 이용해 더 간결하게 코드를 개선할 수 있다. 우리는 함수형 인터ㅔ이스를 기대하는 곳 어디에나 람다 표현식을 사용할 수 있음을 배웠다. 이제 다음처럼 코드를 개선해보자.

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

 

자바 컴파일러가 람다의 파라미터 형식을 추론할 수 있다고 학습했으므로 이 코드를 더 줄일 수 있다.

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

 

이 코드는 comparing 메서드를 이용하면 더 가독성을 높일 수 있다.

Comparator<Apple> c = Comparator.comparing((Apple a) -> a.getWeight());
//더 간소화한 코드
inventory.sort(comparing(apple -> apple.getWeight()));

 

4단계 : 메서드 참조 사용

마지막으로, 메서드 참조 를 이용해 코드를 조금 더 간소화할 수 있다.

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

 

이렇게 코드가 간결해지고 의미도 명확해졌다..!

 

3.8 람다 표현식을 조합할 수 있는 유용한 메서드

1. Comparator 조합

  • 역정렬 : inventory.sort(comparing(item::getWeight)); 를 역정렬 시킨다고 가정한다면 우리는 따로 재정의할 필요없이 reversed라는 디폴트 메서드를 이용하면 된다.inventory.sort(comparing(item::getWeight).reversed());
  • Comparator 연결 : 만약 무게가 같은 item이 있다고 가정해보자. 그럼 어떤방식으로 이 item을 정렬해야할까? 이러한 의문에서 출발하여 도와주는 두번째 comparator를 만들수 있다. inventory.sort(comparing(item::getWeight).reversed().thenComparing(item::getCountry)); 와 같은 방식으로 여기서 중요한 메소드는 thenComparing이다.

2. Predicate 조합

  • 복잡한 predicate를 만들수 있도록 nagate, and, or 세가지를 제공한다.

3. Function 조합

  • andThen, compose 두가지 디폴트 메서드를 제공한다.

 

3.9 비슷한 수학적 개념

 

3.10 마치며

  • 람다 표현식은 익명 함수의 일종이다. 이름은 없지만 파라미터 리스트, 바디, 반환 형식을 가지며 예외를 던질 수 있다.
  • 람다 표현식을 통해 간결한 코드를 구현할 수 있다.
  • 함수형 인터페이스란 단 하나의 추상 메서드만을 정의하는 인터페이스이다.
  • 람다 표현식 전체가 함수형 인터페이스의 인스턴스로 취급된다.
  • 자바 8은 오토박싱 동작을 피할 수 잇는 기본형 특화 인터페이스도 제공합니다.
  • 람다 표현식의 기대 형식을 대상 형식이라고 한다.
  • 메서드 참조를 이용하면 기존의 메서드 구현을 재사용하고 직접 전달할 수 있다.

 

반응형
profile

Peony의 기록 창고 🌼

@myeongju