본문 바로가기
Java

람다식 (Lambda expression) 과 함수형 인터페이스

by 밀러 (miller) 2022. 1. 13.

자바로 알고리즘 문제를 풀다가 특정 코드에 함수형 프로그래밍을 적용하고 싶어, 스트림을 공부하고자 했다. 그런데 스트림에서는 자바스크립트의 ES6 의 화살표 함수와 비슷한 개념인 람다식을 주로 사용한다는 것을 알고, 람다식 또한 공부하게 되었다. 그리고 막상 공부해보니, 함수형 인터페이스 기반으로 구조가 이해하기 쉽게 되어있어 자바의 설계에 대해 감탄하는 계기가 된듯하다. 람다식을 공부하면서 정리한 내용을 공유하고자 한다.

 

해당 포스트는 자바의 정석 14장 - 람다식의 내용을 정리했습니다.

 

람다식 (Lambda expression)

람다식 (Lambda expression) 은 메서드를 하나의 식 (expression) 으로 표현한 것이다. 메서드를 람다식으로 표현하면 메서드의 이름과 반환값이 없어지므로, 람다식을 익명 함수 (anonymous expression) 이라고도 한다.

 

int[] arrr = new int[5];
Arrays.setAll(arr, (i) -> (int) (Math.random() * 5) + 1);

 

모든 메서드는 클래스에 포함되어야 하므로 클래스도 새로 만들어야 하고, 객체도 생성해야만 메서드를 호출할 수 있다. 하지만 람다식은 이러한 과정 없이 오직 람다식 자체가 하나의 독립적인 기능을 하는 함수이다. 그리고 람다식은 메서드의 매개변수로 전달될 수 있고, 메서드의 결과로 반환될 수 있다. 람다식으로 인해 함수를 변수로 다루는 것이 가능해진 것이다.

 

람다식 작성하기

람다식은 익명함수답게 메서드에서 이름과 반환타입을 제거하고 매개변수 선언부와 구현부 { } 사이에 ‘→’ 를 추가한다. 반환값이 있는 메서드의 경우 return 문 대신 식 (expression) 으로 대신할 수 있어, 식의 연산결과가 자동적으로 반환값이 된다. 식 (expression) 은 문장 (statement) 이 아니므로 끝에 ‘;’ 를 붙이지 않는다.

 

그리고 람다식에 선언된 매개변수 타입은 타입 추론이 가능한 경우는 생략할 수 있는데, 대부분의 경우 생략 가능하다. 람다식에 반환 타입이 없는 이유도 항상 추론이 가능하기 때문이다.

 

// 기존 메서드
반환타입 메서드이름 (매개변수 선언) {
    문장들
}

int max(int a, int b) {
    return a > b ? a : b;
}

// 람다식
(매개변수 선언) -> {
    문장들
}

(int a, int b) -> {
    return a > b ? a : b;
}

// 1. 위와 동일
(int a, int b) -> { return a > b ? a : b; }

// 2. return 문을 expression 이 대체
(int a, int b) -> a > b ? a : b;

// 3. 타입 추론을 통한 매개변수 생략
(a, b) -> a > b ? a : b;

// 4. 매개변수가 1개인 경우 괄호 생략 가능
a -> a * a

 

함수형 인터페이스

람다식의 클래스

자바에서 모든 메서드는 클래스에 포함되어야 하는데, 람다식은 어떤 클래스에 포함되는 것일까? 위에서 람다식이 메서드와 동등한 것처럼 말했지만, 사실 람다식은 익명 클래스의 객체와 동등하다.

 

// 람다식
(a, b) -> a > b ? a : b;

// 익명 클래스
new Object() {
    int max(int a, int b) {
        return a > b ? a : b;
    }
}

 

그럼 람다식으로 정의된 익명 객체의 메서드를 어떻게 호출해야할까? 일단 참조변수가 있어야 객체의 메서드를 호출할 수 있으니, 람다식으로 생성된 익명 객체의 주소를 f 라는 참조변수에 저장하자.

 

타입 f = (int a, int b) -> a > b ? a : b;

 

람다식 참조변수의 타입

그럼 참조변수 f 의 타입은 어떤 것이어야 할까? 객체의 참조형이니까 클래스 또는 인터페이스가 가능하다. 그리고 람다식과 동등한 메서드가 정의되어 있는 것이어야 한다. 그래야 참조변수로 람다식 익명 객체의 메서드를 호출할 수 있기 때문이다.

 

예를 들면 아래와 같이 max() 라는 메서드가 정의된 MyFunction 인터페이스가 정의되어 있다고 가정하자.

 

interface MyFunction {
    public abstract int max(int a, int b);
}

 

그럼 MyFunction 인터페이스를 구현한 익명 클래스의 객체를 람다식과 별도로! 하나 생성하자. 익명 클래스는 클래스의 선언과 객체의 생성을 동시에 하기 때문에 오직 하나의 객체만을 생성하며, 이름이 없기 때문에 상위클래스의 이름이나 구현하고자 하는 인터페이스의 이름을 사용해서 정의한다.

 

익명 클래스 객체 생성

new 상위클래스이름() {
    // 멤버 선언
}

new 구현인터페이스이름() {
    // 멤버 선언
}

 

MyFunction 인터페이스를 구현한 익명 클래스 객체 생성

// MyFunction 인터페이스를 구현한 익명 클래스의 객체 생성
MyFunction f = new MyFunction() {
    public int max(int a, int b) {
        return a > b ? a : b;
    }
}

 

MyFunction() 으로 익명 클래스의 객체를 생성하면서 MyFunction 인터페이스의 max 메서드를 구현하였다. 그리고 MyFunction 인터페이스를 구현한 익명 클래스의 객체에 정의된 메서드 max() 와 익명 클래스의 객체인 람다식 (int a, int b) -> a > b ? a : b 의 메서드의 선언부가 일치한다. 즉, MyFunction 인터페이스를 구현한 익명 클래스와 람다식의 익명 클래스의 메서드 선언부가 일치하고, 어차피 둘 다 익명 클래스의 객체이므로 위 코드의 익명 객체를 람다식으로 대체할 수 있다!

 

람다식의 인터페이스는 함수형 인터페이스

위에서 MyFunction 인터페이스와 같이, 하나의 메서드가 선언된 인터페이스를 통해 람다식을 다루며, 람다식을 다루기 위한 인터페이스를 특별히 ‘함수형 인터페이스 (functional interface)’ 라고 부른다.

 

단, MyFunction 과 마찬가지로, 함수형 인터페이스에는 오직 하나의 추상 메서드만 정의되어 있어야한다. 그래야 인터페이스 MyFunction 과 람다식 (int a, int b) -> a > b ? a : b 이 연결되어 메서드가 연결된 것처럼, 1:1 로 연결될 수 있기 때문이다.

 

@FunctionalInterface
interface MyFunction {
    public abstract int max(int a, int b);
}

 

그럼 기존 인터페이스를 람다식으로 대체해 어떻게 간단히 처리하는지 보자. 기존에는 아래와 같이 인터페이스의 메서드 하나를 구현할 때 복잡했지만, 람다식으로 간단히 코드를 작성할 수 있다.

 

List<String> list = Arrays.asList("abc", "aaa", "bbb", "ddd", "aaa");

// 기존의 인터페이스의 익명 객체 생성
Collections.sort(list, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return s2.compare(s1);
    }
});

// 람다식으로 간단하게 처리
Collections.sort(list, (s1, s2) -> s2.compare(s1));

 

함수형 인터페이스 타입의 매개변수와 반환타입

메서드의 매개변수가 MyFunction 타입이면, 이 메서드를 호출할 때 람다식을 참조하는 참조변수를 매개변수로 지정해야한다는 뜻이다. 혹은 참조변수 없이 직접 람다식을 매개변수로 지정할 수도 있는데, 주로 스트림에서 람다식을 매개변수에 직접 입력할 때 사용하는 방식이다!

 

함수형 인터페이스 타입의 매개변수

@FunctionalInterface
interface MyFunction {
    void myMethod();
}

// 메서드의 매개변수가 함수형 인터페이스 타입
void aMethod(MyFunction f) {
    f.myMethod();
}

// 람다식을 참조하는 참조변수를 메서드의 인자로 사용
MyFunction f = () -> System.out.println("myMethod()");
aMethod(f);

**// 람다식을 직접 메서드의 인자로 사용 -> 스트림에서 사용하는 방식!**
aMethod(() -> System.out.println("myMethod()");

 

메서드의 반환타입이 함수형 인터페이스 타입이라면, 함수형 인터페이스의 추상메서드와 동등한 람다식을 가리키는 참조변수를 반환하거나 람다식을 직접 반환할 수 있다. 즉, 메서드를 통해 람다식을 주고받을 수 있다.

 

함수형 인터페이스 타입의 반환타입

// 람다식을 가리키는 참조변수를 반환
MyFunction myMethod() {
    MyFunction f = () -> {};
    return f;
}

// 람다식을 직접 반환
MyFunction myMethod() {
    return () -> {};
}

 

람다식의 타입

함수형 인터페이스로 람다식을 참조할 수 있는 것일 뿐, 람다식의 타입이 함수형 인터페이스의 타입과 일치하는 것은 아니다. 람다식은 익명 객체이고, 익명 객체는 타입은 있지만 컴파일러가 임의로 이름을 정하기 때문에 알 수 없다. 따라서 참조변수에 람다식을 대입할 때, 다음과 같이 형변환이 필요하다.

 

MyFunction f = (MyFunction) (() -> {});

 

앞서 함수형 인터페이스를 구현한 익명 클래스의 객체를 람다식이 대체한다는 것에 대해 좀 더 생각해보자. 람다식은 위의 MyFunction 인터페이스를 직접 구현하지는 않았지만 인터페이스를 구현한 클래스의 객체와 완전히 동일하므로 위와 같은 형변환을 허용한다. 물론, 지금까지 참조변수에 람다식을 대입한 것과 마찬가지로, 위의 형변환은 생략 가능하다.

 

제공되는 함수형 인터페이스

람다식을 함수형 인터페이스 타입의 참조변수에 대입할 수 있다는 것까지는 알았다. 그럼 람다식을 참조변수에 대입할 때마다, 참조변수의 인터페이스와 메서드의 선언부를 매번 작성해야할까?

 

다행히도 대부분의 메서드는 매개변수가 0개/1개/2개, 반환 값이 0개/1개로 타입이 비슷하다. java.util.function 패키지에 일반적으로 자주 쓰이는 형식의 메서드를 함수형 인터페이스로 미리 정의해 두었다. 다시 말하면, 람다식을 참조할 참조변수의 함수형 인터페이스들을 미리 만들어 두었다. 그리고, 인터페이스 내 메서드를 지네릭 메서드를 정의해 대입한 타입에 따라 적용된다.

 

기본적인 함수형 인터페이스

 

 

매개변수와 반환값의 유무에 따라 4개의 함수형 인터페이스가 정의되어있다. 함수형 인터페이스이기 때문에, 내부에 정의된 추상 메서드는 1개뿐이고, 해당 메서드는 위 표의 가운데에 해당한다.

특히 PredicateFunction 의 변형으로, 반환값이 boolean 이라는 것만 제외하면 Function 과 동일하다. Predicate 는 특히 조건식인 람다식을 참조하는 변수의 타입으로 사용된다. 람다식을 참조하는 함수형 인터페이스 타입의 참조 변수는 람다식과 같이 수행 시, 위의 메서드를 호출한다.

 

// Function
Function<String, Integer> f = s -> Integer.parseInt(s, 16);
System.out.println(f.apply("FF"));

// Predicate
Predicate<String> p = s -> s.length == 0;
if (p.test(s))
    System.out.println("This is an empty String.");

 

매개변수가 2개인 함수형 인터페이스

매개변수가 2개인 함수형 인터페이스는 이름 앞에 접두사 ‘Bi’ 가 붙는다.

 

 

매개변수와 반환타입이 같은 함수형 인터페이스

Function 의 또 다른 변형으로 Unary OperatorBinary Operator 가 있는데, 매개변수의 타입과 반환타입의 타입이 모두 일치한다는 점만 제외하고는 Function 과 같다.

 

 

참조변수 안쓰면 함수형 인터페이스를 어디에 쓰지?

위의 함수형 인터페이스는 컬렉션 프레임워크의 여러 인터페이스의 메서드나, 스트림에서 메서드에 매개변수로 정의되어있다. 예를 들면, 컬렉션 프레임워크 내의 Iterable 인터페이스의 forEach 메서드의 선언부는 다음과 같다.

 

void forEach(Consumer<T> action);

 

다른 인터페이스의 메서드도 위와 같이 특정 함수형 인터페이스를 매개변수로 받는다. 즉, 함수형 인터페이스로 받을 수 있는 람다식을 위와 같은 메서드에 입력하면, 람다식이 메서드 내부에서 처리되는 것이다! 스트림에서 람다식을 쓸 수 있는 이유는 스트림의 메서드가 위와 같이 정의되어있기 때문이다!

 

메서드 참조

람다식보다 메서드를 더 간결하게 표현할 수 있는 방법이 있다. 람다식이 하나의 메서드만 호출하는 경우에, 메서드 참조 (method reference) 로 람다식을 간략히 할 수 있다.

 

예를 들면 문자열을 정수로 반환하는 람다식은 아래와 같은데, 람다식을 메서드로 표현하면 그 아래와 같다.

 

// 람다식
Function<String, Integer> f = (String s) -> Integer.parseInt(s);

// 메서드
Integer method(String s) {
    return Integer.parseInt(s);
}

 

위에서 임의로 이름붙인 method 메서드는 값을 받아 Integer.parseInt() 에 값을 넘겨주는 일만 할 뿐, 별로 하는 일이 없다. 이 거추장스러운 메서드를 벗겨내고 Integer.parseInt() 를 직접 호출하는 메서드 참조를 사용하자!

 

static 메서드 참조

 

// 람다식
Function<String, Integer> f = (String s) -> Integer.parseInt(s);

// 메서드 참조
Function<String, Integer> f = Integer::parseInt;

 

혹은 다음과 같이 인자로 입력된 인스턴스에서 메서드를 호출할 때, 다른 인자를 입력하는 경우에도 메서드 참조를 사용할 수 있다.

 

인스턴스 메서드 참조

 

// 람다식
BiFunction<String, String, Boolean> f = (s1, s2) -> s1.equals(s2);

// 메서드 참조
BiFunction<String, String, Boolean> f = String::equals;

 

혹은 이미 생성된 객체의 메서드를 람다에서 사용한 경우에은 위의 두 사례처럼 클래스 이름 대신, 그 객체의 참조변수를 적어줘야 한다.

 

특정 객체 인스턴스 메서드 참조

 

// 람다식
Function<String, Boolean> f = (x) -> obj.equals(x);

// 메서드 참조
Function<String, Boolean> f = obj::equals;

 

위에서 3가지 경우의 메서드 참조에 대해 알아봤는데, 정리하면 다음과 같다.

 

 

즉, 하나의 메서드만 호출하는 람다식은 클래스이름::메서드이름 또는 참조변수::메서드이름 으로 바꿀 수 있다!

 

생성자의 메서드 참조

생성자를 호출하는 람다식도 클래스이름::new 와 같이 메서드 참조로 변환할 수 있다. 매개변수가 있는 생성자라면, 매개변수의 개수에 따라 알맞은 함수형 인터페이스를 사용하면 된다.

 

// 매개변수가 없는 생성자
Supplier<MyClass> s = () -> new MyClass(); // 람다식
Supplier<MyClass> s = MyClass::new; // 메서드 참조

// 매개변수가 있는 생성자
Function<Integer, MyClass> f = (i) -> new MyClass(i);
Function<Integer, MyClass> f = MyClass::new;

BiFunction<Integer, String, MyClass> bf = (i, s) -> new MyClass(i, s);
BiFunction<Integer, String, MyClass> bf = MyClass::new;

// 배열 생성
Function<Integer, int[]> f = x -> new int[x];
Function<Integer, int[]> f = int[]::new;

 

댓글