Technology/Java

Java의 람다식에 대해 알아보자!!

ikjo 2022. 11. 6. 00:45

람다식이란?

람다식이란 함수형 인터페이스를 구현한 익명 클래스의 객체로서 별도로 인터페이스를 구현한 내부 익명 클래스의 객체 생성할 필요 없이 화살표(->) 연산자를 이용해 해당 인터페이스의 메서드에 대해 한 줄로 간단히 표현한 식이다. 마치 함수처럼 작성을 하지만 실제로는 익명 클래스의 객체를 생성하는 방식으로 동작한다. 이때 당연히 익명 객체의 메서드와 람다식의 매개변수의 타입과 개수 그리고 반환 타입이 일치한다.

 

람다식이 등장하기 전에는 인터페이스를 사용하기 위해 다음과 같이 메서드 내에 내부 익명 클래스의 객체를 생성하여 이를 통해 인터페이스를 구현한 메서드를 사용하였다.

 

class Main {

    public static void main(String[] args) {
        Token token = new Token() {
            @Override
            public void create() {
                System.out.println("CREATE!!");
            }
        };

        token.create();
    }
}

interface Token {
    void create();
}

 

하지만 람다식을 이용하면 다음과 같이 한 줄로 간단하게 나타낼 수 있다.

 

class Main {

    public static void main(String[] args) {
        Token token = () -> System.out.println("CREATE!!");

        token.create();
    }
}

interface Token {
    void create();
}

 

더욱이 이러한 람다식은 메서드의 매개변수로 전달하거나 메서드의 결과로 반환하는 것도 가능하다. 마치 변수처럼 다루는 것이 가능한 것이다. 람다식은 겉으로 보기에는 일종의 함수처럼 보이지만 앞서 언급했듯이 람다식은 익명 클래스의 객체이므로 객체를 다루는 것과 동일하기 때문에 변수처럼 사용할 수 있다.

 

예를 들어, 리스트(List)를 정렬할 때 Collections 클래스의 sort 메서드를 사용함에 있어 다음과 같이 메서드의 매개변수로 람다식을 사용할 수 있다.

 

    Collections.sort(list, ((o1, o2) -> o2 - o1));

 

실제 Collections 클래스의 sort 메서드 선언부를 보면, 아래와 같이 함수형 인터페이스인 Comparator 타입의 파라미터가 선언된 것을 확인할 수 있다. 즉, 람다식이 함수형 인터페이스를 구현한 익명 클래스의 객체의 역할을 한 것이다.

 

Collections 클래스 中 sort 메서드

 

Comparator 인터페이스 中 compare 메서드

 

 

함수형 인터페이스란?

앞서 함수형 인터페이스를 종종 언급했는데, 이는 자바에서 람다식을 다루기 위한 인터페이스이다. 이때 함수형 인터페이스에는 단 하나의 추상 메서드만 정의되어 있어야 하는데, 그래야만 람다식과 인터페이스의 메서드가 1:1로 연결될 수 있기 때문이다. (단, default 메서드와 static 메서드의 개수에는 제약이 없다.) 앞서 잠깐 다루었던 Comparator 역시 함수형 인터페이스이다.

 

예를 들면, 다음과 같이 선언한 인터페이스도 함수형 인터페이스라고 할 수 있다.

 

@FunctionalInterface
interface MyFunctionInterface {

    void myMethod();

}

 

참고로 이때 애노테이션으로 @FunctionalInterface를 사용했는데, 이는 개발자가 함수형 인터페이스를 정의함에 있어 추상 메서드가 하나만 존재해야한다는 제약 조건을 실수로 지키지 못하는 경우를 대비하여 컴파일러가 함수형 인터페이스 제약 조건을 대신 검사해주도록 하는 기능이다. 추후에 다른 사람이 볼 때도 이 애노테이션이 있으면 함수형 인터페이스임을 인지할 수 있으니, 가급적 붙여주는 것이 좋다.

 

위와 같이 정의된 함수형 인터페이스를 다음과 같이 람다식으로(익명 클래스 객체로) 구현할 수 있다.

 

    MyFunctionInterface myFunctionInterface = () -> System.out.println("hello!!");
    myFunctionInterface.myMethod(); // hello!

 

MyFunctionInterface 타입의 참조 변수에 람다식을 할당해주었는데, 다소 어색해 보이기는 하지만 이는 앞서 언급했듯이 익명 클래스 객체와 동일하게 동작하므로 기능상 아무런 문제가 없다. 그리고 마치 객체를 다루듯이 인터페이스에 정의된 추상 메서드를 호출할 수도 있다.

 

참고로 이러한 함수형 인터페이스를 개발자가 일일이 구현하지 않아도 자바에서 기본적인 형식의 함수형 인터페이스들을 제공한다. 앞서 정의했던 함수형 인터페이스 MyFunctionInterface은 자바에서 제공하는 함수형 인터페이스 Runnable로 대체가 가능하다.

 

함수형 인터페이스 Runnable

 

자바에서 제공하는 함수형 인터페이스는 이외에도 Supplier, Consumer, Function 등 매개변수의 타입과 개수 그리고 반환 타입 등에 따라 여러가지 존재한다.

 

 

Variable Capture

람다식으로 함수형 인터페이스를 구현할 때 일반적으로 해당 추상 메서드의 파라미터만을 참조하는 경우가 많지만, (예를 들면, 앞서 다루었던 Comparator) 이외에도 지역 변수나 멤버 변수도 참조할 수 있다.

 

    static int b = 1; // 멤버 변수

    public static void main(String[] args) {

        int a = 2; // 지역 변수

        MyFunctionInterface myFunctionInterface1 = () -> System.out.println(b);
        MyFunctionInterface myFunctionInterface2 = () -> System.out.println(b);
        
        myFunctionInterface1.myMethod(); // 1
        myFunctionInterface2.myMethod(); // 2

 

이처럼 람다식이 람다식 외부에 있는 지역 변수나 멤버 변수를 참조할 수 있는 것은 해당 변수들을 람다식이 사용할 수 있도록 복사되기 때문인데, 이를 Variable Capture라고 한다. 이처럼 변수를 복사해서 사용하는 이유는 외부에 있는 변수의 생명 주기 보다 람다식의 생명 주기가 더 길수도 있기 때문이다. 복사해놓지 않는다면 람다식이 참조하는 변수가 추후에 존재하지 않을 수도 있기 때문이다.

 

다만, 이 경우 제약이 있는데, 람다식에서 참조하고 있는 외부 변수가 final 처럼 동작해야한다는 것이다. (java 8 이전에는 익명 내부 클래스 사용 시 외부 변수를 참조할 때 반드시 final로 선언되어 있어야 했다.) 즉, 람다식에서 해당 변수의 값을 수정해서도 안되고, 람다식 밖에서도 수정해선 안된다. (단, 멤버 변수의 경우에는 상수로 간주되지 않으므로 변경해도 된다.)

 

아래와 같이 람다식에서 참조하고 있는 변수의 값을 수정하는 순간 컴파일 에러가 발생한다.

 

        int a = 10;
        a = 100;

        MyFunctionInterface myFunctionInterface1 = () -> System.out.println(a);

 

 

쉐도잉이 되지 않는 람다식

로컬 클래스와 익명 클래스의 경우 외부에 선언된 변수명과 동일한 변수명을 해당 클래스 내부에 선언할 경우 '쉐도잉'이 일어나기 때문에 외부에 선언된 변수를 내부에 선언된 변수로 덮어버린다. 이는 로컬 클래스와 익명 클래스가 메서드와 독립적인 스코프를 가지기 때문에 일어나는 것이다.

 

반면, 람다식은 이들과 다르게 메서드와 동일한 스코프를 가지고 있기 때문에 쉐도잉이 일어나지 않으므로, 람다식 내부에서 외부에 선언된 변수명과 동일한 명수명을 선언하면 컴파일 에러가 발생한다. 이는 하나의 스코프 내에 동일한 이름의 변수를 선언하지 못하는 것과 같은 원리이다.

 

 

메서드 및 생성자 레퍼런스

람다식이 단순히 하나의 메서드만 호출하는 경우에는 메서드 레퍼런스를 통해 더욱 간략하게 표현할 수 있게 된다. 이는 -> 연산자를 이용해 람다식을 나타내었던 것을 단순히 '클래스이름::메서드이름' (static 메서드, 임의 객체 인스턴스 메서드, 생성자 참조 시) 또는 '참조변수::메서드이름' (특정 객체 인스턴스 메서드 참조) 으로 바꿈으로써 더욱 간결해진다.

 

예를 들어, 자바에서 기본으로 제공하는 함수형 인터페이스 Consumer 타입의 참조 변수 f 에 다음과 같이 람다식을 할당했다고 가정해보자.

 

    Consumer<String> f = (str) -> System.out.println(str);
    f.accept("hello world");

 

현재의 람다식에선 단순히 println 메서드 하나만을 호출하는 것을 볼 수 있는데, 이를 메서드 레퍼런스를 이용하면 다음과 같이 간략하게 나타낼 수 있다.

 

        Consumer<String> f = System.out::println;
        f.accept("hello world");

 

생성자를 호출하는 람다식 역시 다음과 같이 메서드 참조로 변환할 수 있다.

 

    Supplier<Date> lambda = () -> new Date(); // 람다식
    Supplier<Date> reference = Date::new; // 생성자 레퍼런스

 

 

참고자료

  • 도우출판 "자바의 정석"
  • 인프런 "더 자바, Java 8"