고코딩

[JAVA] 람다는 무엇일까? 본문

JAVA

[JAVA] 람다는 무엇일까?

고코딩 2021. 5. 13. 17:01

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

람다 표현식은 무엇일까?

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

  • 익명

    보통의 메서드와 달리 이름이 없으므로 익명이라 표현한다.

  • 함수

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

  • 전달

    람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다. (함수형 프로그래밍이랑 비슷한 특징이다.)

  • 간결성

    익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다.

묻지도 따지지도 말고 예제 코드를 하나 들어보자 예를 들어 Comparator 객체를 기존보다 간단하게 구현할 수 있다. 참고로 여기서 사용하는 Apple클래스는 맨 밑에 코드로 적언 놓았으니 참고.

Comparator<Apple> byWeight = new Comparator<Apple>() {
            @Override
            public int compare(Apple a1, Apple a2) {
                return a1.getWeight()-a2.getWeight();
            }
        };

다음은 람다를 이용한 코드이다.

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

코드가 확실히 간단해졌다.

  • (Apple a1, Apple a2) : 람다 파라미터 리스트
  • -> : 화살표
  • a1.getWeight()-a2.getWeight(); : 람다 바디

5줄 짜리 코드가 1줄로 변화했다. 그리고 가독성도 높아졌다. Apple객체를 2개 받아 서로의 값을 빼서 리턴하는 의미이다.

람다에서 중요한 개념은 동작을 파라미터화 시킬 수 있다는 것이다.

함수형 인터페이스

그럼 모든 메소드에 대해서 다 람다형식으로 바꿀수 있는 것일까? 아니다. 람다는 함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다.

함수형 인터페이스는 정확히 하나의 추상 메서드를 지정하는 인터페이스다. 이제부터 나올 대부분의 람다형식은 함수형 인터페이스를 표현한 것이다.

public interface Comparator<T>{
    int compare(T o1, T o2);
}
public interface Runnable{
    void run();
}
public interface Callable<V>{
    V call() throws Exception;
}

인터페이스를 implements해서 구현하려면 추상메서드를 반드시 구현해야 한다. 람다는 추상메서드를 익명함수로 구현한 것이라고 생각하면 된다. 정확히 말하면 람다표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스터스로 취급 할 수 있다.(기술적으로 보면 함수형 인터페이스를 구현한 클래스의 인스턴스)

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);
process(()->System.out.println("Hello World 3"));

위 코드를 실행하면

Hello World 1
Hello World 2
Hello World 3

으로 출력되게 된다. r2객체는 기존에 우리가 사용하던 익명 객체로 run()메소드를 정의하였다.

r1은 람다를 사용해서 run() 메소드에 표현식을 전달한 것이고,

r3process()메소드에 파라미터로 람다 표현식을 직접 전달한 것이다. 이 처럼 함수형 인터페이스의 메소드를 전달하는 방식과 그 규칙이 람다식이다.

전체적으로 람다식을 기존에 작성하던 코드와 비교해 보면 많이 생략되었지만 보고 이해하기는 쉬워졌을 것이다. 그것이 람다의 가장 큰 장점이다.

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

Predicate

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

@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();    //test()메소드를 구현
List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);    //nonEmptyStringPredicate를 파라미터로 전달

Consumer

java.util.funcction.Consumer<T>인터페이스는 제네릭 형식 T 객체를 받아서 void를 반환하는 accept라는 추상 메서드를 정의한다. T 형식의 객체를 인수로 받아서 어떤 동작을 수행하고 싶을 때 Consumer 인터페이스를 사용할 수 있다. 예를 들어 Integer리스트를 인수로 받아서 각 항목에 어떤 동작을 수행하고 싶을때 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);    //accept(t)는 System.out.println(t)로 실행됨
    }
}

forEach(Arrays.asList(1,2,3,4,5), (Integer i) -> System.out.println(i));    //Consumer인터페이스의 accept()를 람다식으로 표현해서 파라미터로 전달.

Function

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

@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> list = map(Arrays.asList("Lamdas", "in", "action"), 
                         (String s) -> s.length());    //Function인터페이스의 apply()메서드를 람다표현식으로 파라미터화 해서 넘김

제네릭 파라미터에는 형식이 참조형(Byte, Integer, Object, List, Double)만 사용할 수 있다. 다행히 자바에서는 기본형 -> 참조형으로 변환하는 기능을 제공한다. 이 기능을 박싱, 언박싱 이라고 한다. 그리고 프로그래머가 편리하게 코드를 구현할 수 있도록 오토박싱 기능까지 있다.

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

만약 Predicate<Integer>를 사용하게 된다면 오토박싱 동작이 발생할 수 밖에 없다. 자바 8 에서는 이러한 오토박싱을 피할 수 있도록 특별한 버전의 함수형 인터페이스를 제공한다.

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

IntPredicate evenNumbers = (int i) -> i% 2 == 0;
evenNumbers.test(1000);    //박싱이 이뤄지지 않는다.

일반적으로 특정 형식을 입력으로 받는 함수형 인터페이스의 이름 앞에는 DoublePredicate,IntConsumer, LongBinaryOperator 처럼 형식명이 붙는다. 인터페이스의 이름을 보고 적절한 함수형 인터페이스를 람다식으로 구현할 수 있을 것이다.

물론 우리가 직접 함수형 인터페이스를 만들어 낼 수도 있다. 함수형 인터페이스 조건(구현해야 할 메소드가 1개)만 만족하면 함수형인터페이스이다. @FunctionalInterface를 붙여서 작성하면 함수형 인터페이스로 작성했는지 검사해준다.

메서드 참조

메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다. 때로는 람다 표현식보다 메서드 참조를 사용하는 것이 더 가독성이 좋으며 자연스러울 수 있다.

그럼 메서드 참조가 왜 중요할까? 메서드 참조는 특정 메서드만을 호출하는 람다의 축약형이라고 생각할 수 있다. 예를 들어 람다가 '이 메서드를 직접 호출해' 라고 명령한다면 메서드를 어떻게 호출해야 하는지 설명을 참조하기보다는 메서드명을 직접 참조하는 것이 편리하다. 메서드는 어떻게 참조할 수 있을까? 메서드명 앞에 구분자(::)를 붙이는 방식으로 메서드 참조를 활용할 수 있다. 예제를 보자

람다 메서드 참조 단축 표현
(Apple apple) -> apple.getWeight() Apple::getWeight
() -> Thread.currentThread().dumpStack Thread.currentThread()::dumpStack
(str, i)-> str.substring(i) String::substring
(String s) -> System.out.println(s) System.out::println
(String s) -> this.isValidName(s) this::isValidName

처음 볼 때는 많이 헷갈릴 것이다. 하지만 기존의 코딩 방법에서 최대한 생략해서 표현했다고 생각하면 된다. 예를 들어 System.out.println(s)는 어떤 파라미터가 들어와도 결국에는 화면에 출력하라는 한가지 행동으로 추론할 수 있다. 파라미터는 컴파일할때 추론될 것이므로 메서드명만 써줌으로써 가독성을 높여준다.

메서드 참조를 만드는 방법은 3가지 유형으로 구분할 수 있다.

  1. 정적 메소드 참조

    예를 들어 Integer의 parseInt 메서드는 Integer::parseInt로 표현할 수 있다.

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

    예를 들어 String의 length 메서드는 String::length로 표현할 수 있다.

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

    예를 들어 Transaction 객체를 할당받은 expensiveTransaction 지역 변수가 있고, Transaction 객체에는 getValue 메서드가 있다면, 이를 expensiveTransaction::getValue라고 표현할 수 있다.

생성자 참조

ClassName::new처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 참조를 만들 수 있다. 예를 들어

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

get()메서드는 Apple객체를 리턴해주는 역할을 한다. 위 코드를 람다식으로 표현해보면 아래와 같다.

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

만약 Apple(Integer weight)을 갖는 생성자를 참조하고 싶다면 Function 함수 인터페이스로 구현할 수 있다.

Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(10);

이 코드를 람다로 표현하면 아래와 같다.

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

마치며

람다 형식의 표현은 처음보는 사람에게는 정말 생소하고 어렵다. 나도 이해하는데 오래걸렸고 수많은 글을 보면서 이해하려고 노력했다. 하지만 결국 실제 코드로 쳐 보면서 사용을 해보는 것이 람다를 익힐 수 있는 가장 좋은 방법이라고 생각한다. 나도 이 글을 쓰면서 람다에 대해서 완벽하게 설명하지 못 했다. 내가 이해하고 있는 람다의 지식을 글로 쓰려니 여간 어려운게 아니였다. 하지만 코드를 쓰다 보면 나도 모르게 자연스럽게 람다를 사용하는 모습을 발견할 수 있을 것이다. 왜냐면 람다는 개발자들의 가독성을 위해서 나왔기 때문이다. 최대한 람다식을 많이 쓰려고 해보자.

public class Apple{

    private int weight = 0;
    private Color color;

    public Apple(int weight, Color color) {
        this.weight = weight;
        this.color = color;
    }

    public int getWeight() {
        return weight;
    }

    public void setWeight(int weight) {
        this.weight = weight;
    }

    public Color getColor() {
        return color;
    }

    public void setColor(Color color) {
        this.color = color;
    }

    @SuppressWarnings("boxing")
    @Override
    public String toString() {
        return String.format("Apple{color=%s, weight=%d}", color, weight);
    }

}

'JAVA' 카테고리의 다른 글

[JAVA] JNDI란?  (0) 2021.05.26
스트림 실습 문제 8개(매우 기초)  (0) 2021.05.14
[JAVA] Collection 관계  (0) 2021.04.07
[JAVA] JDK JRE JVM 관계  (0) 2021.04.05
[JAVA] UUID의 개념과 간단 사용법  (0) 2021.02.22