Java

[Java] 함수형 인터페이스(Functional Interface)와 람다식(Lambda Expression)

이덩우 2023. 12. 14. 22:15

Java8이 등장하면서 자바에는 많은 변화가 생겼다.

단순 객체 지향 프로그래밍을 넘어서 함수형 프로그래밍 방식을 지원하는 다양한 기능들이 추가되었는데, 함수형 프로그래밍을 제대로 이해하려면 함수형 인터페이스 -> 람다 표현식 -> 스트림 의 순서로 이해가 되어야한다.

함수형 프로그래밍에 대한 자세한 내용은 후술할 포스팅에서 이야기하도록 하고,

먼저 함수형 프로그래밍에 근간이 되는 *함수형 인터페이스*와 *람다식*에 대해 이야기해보려고 한다.

 

함수형 인터페이스(Functional Interface)

- 함수형 인터페이스란?

Java8에 도입된 함수형 인터페이스는 인터페이스가 마치 하나의 함수처럼 동작하기에 함수형 인터페이스라고 불린다.

 

함수형 인터페이스를 생성하는 방법은, 오직 단 하나의 추상 메소드를 갖는 인터페이스를 생성하면 된다.

추상 메소드가 하나이면 되므로 디폴트 메소드, 스태틱 메소드는 여러개 가질 수 있다.

직접 코드를 살펴보자.

// 단 하나의 추상 메소드만 갖는 함수형 인터페이스
public interface TestFunctional {
    int max(int a, int b);
}

 

위 코드는 단 하나의 추상 메소드만 갖는 함수형 인터페이스이다. 

단순히 파라미터로 넘어온 두 개의 정수형 변수를 비교해 더 큰 값을 반환해주는 역할을 한다.

 

이대로 사용해도 괜찮지만, 함수형 인터페이스라는 제약을 더욱 강력하게 걸고 싶다면 @FunctionalInterface 애노테이션을 활용하면 된다. 해당 애노테이션이 있으면 컴파일 과정에서 제대로 함수형 인터페이스를 만들지 않았다면 에러를 반환해준다.

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

 

- 왜 사용할까?

왜 함수형 인터페이스를 배우고 있을까? 

가장 큰 이유는 바로 뒤에서 다룰 람다식을 사용하기 위해서는 함수형 인터페이스를 활용해야하기 때문이다.

-> 다른말로, 자바의 람다식은 함수형 인터페이스를 통해서만 접근이 가능하다.

 

또한 함수형 인터페이스 내 추상 메소드는 큰 틀만 정의해놓고 있기 때문에, 해당 함수형 인터페이스를 구현할 때 정확히 원하는 동작을 작성할 수 있다. 

-> 추상화로 인한 확장성이 좋다.

 

- 어떻게 사용해?

사실 함수형 인터페이스는 구현 클래스를 직접 생성해서 사용하진 않지만, 익명 함수나 람다식을 사용하지 않는다면 다음과 같이 사용할 수 있다. 위에서 예를 들었던 두 정수 타입의 입력이 있을 때, 최댓값을 구하는 방법을 살펴보자.

// 함수형 인터페이스
@FunctionalInterface
public interface TestFunctional {
    int max(int a, int b);
}

// 구현 클래스
public class TF implements TestFunctional{
    @Override
    public int max(int a, int b) {
        return Math.max(a, b);
    }
}

// main
public class Test {
    public static void main(String[] args) {
        TestFunctional tf = new TF();
        System.out.println(tf.max(1, 10));
    }
}

// 출력 : 10

 

일반적인 인터페이스를 다루는 방법과 동일하다. 

 

지금까지 다뤘던 예제는 두 정수 타입의 입력이 있을 때, 정수 타입을 리턴하는 함수형 인터페이스이다.

사실 입력이 없고 반환타입은 void이고, 입력이 없고 반환타입은 String 인 상황이 있는 것처럼 매번 상황에 맞는 함수형 인터페이스를 만드는 것은 귀찮은 일이다. 

 

자바는 이런 함수형 인터페이스를 더욱 간단하고, *표준화*되어 다룰수 있도록 어떻게 지원할까?

 

- 함수형 인터페이스 표준 API

정말 다양한 입력 타입이 있을 수 있고 다양한 반환 타입이 있을 수 있기 때문에, 자바는 함수형 인터페이스를 개발자가 직접 매번 만들지 않고 사용할 수 있도록 *표준 API*를 제공한다.

대부분의 경우에서 개발자가 직접 함수형 인터페이스를 만들지 않을 수 있도록 많은 함수형 인터페이스를 제공한다.

 

또한 개발자가 직접 함수형 인터페이스를 만들지 않기 때문에, 함수형 인터페이스 사용에 있어서 표준화가 가능해진다.

다른 개발자들의 코드를 봐도 이해가 더욱 쉽다는 장점이 있다.

또한, 자바 스트림 API과 호환이 잘 되어있기 때문에 함수형 프로그래밍을 할 때 편리하다는 장점이 있다.

 

하나의 예시로 Runnable 인터페이스를 살펴보자.

 

Runnable 인터페이스는 입력과 반환이 모두 없는 형태이다.

Runnabe 인터페이스를 사용해 간단한 코드를 구현해보자. 

public class RunnableImpl implements Runnable{
    @Override
    public void run() {
        System.out.println("Hello, Runnable");
    }
}

public class RunnableTest {
    public static void main(String[] args) {
        Runnable runnable = new RunnableImpl();
        runnable.run();
    }
}

// 출력 : Hello, Runnable

 

 

Runnable 인터페이스처럼 자주 사용하는 함수형 인터페이스는 뭐가있을까?

  • Function<T, R> : T 타입의 인자를 받아 R타입을 반환하는 apply() 메소드를 구현
  • Consumer<T> : T 타입의 인자를 받지만 리턴 타입은 void, accept() 메소드를 구현 -> 말그대로 소비의 느낌
  • Prediate<T> : T 타입의 인자를 받아 boolean 타입을 반환하는 test() 메소드를 구현
  • Supplier<T> : 인자는 없고 T타입을 반환하는 get() 메소드를 구현
  • Runnable : 인자도 없고 반환 타입도 void, run() 메소드를 구현

 

 

이외에도 정말 많은 함수형 인터페이스가 존재하지만, 이것을 다 하나하나 외우고 사용하지 않아도 된다. 

마치 컬렉션의 메소드를 필요할 때 찾아보듯, 자주 사용하지 않는 함수형 인터페이스는 그때마다 찾아보며 사용하면 된다. 
아래 그림은 인자의 종류 별, 반환 타입에 따라 사용되는 함수형 인터페이스를 정리해놓은 표이다.

 

* 좌측 열에 작성되어 있는게 인자의 종류, 상단 행에 작성되어 있는게 반환 타입이다.

 


람다식(Lambda Expression)

이쯤에서 *다시 한번* 기억하자. 지금 이야기하고 있는 함수형 인터페이스, 람다식, 뒤에서 배울 스트림을 모두 단순히 객체 지향 프로그래밍 방식을 넘어서 함수형 프로그래밍을 적용하기 위한 기술이다. 

실제로 스트림 API 연산에서는 매개변수로 함수형 인터페이스를 받도록 설계되어있다. -> *일급 객체로 취급된다.*

그렇다면 스트림 API 연산을 위해 매개변수를 전달할 때, 매 번 구현 클래스를 만들고 객체를 생성해 전달해야할까..? 너무 비효율적이다.

 

위에서 함수형 인터페이스에 대해 알아볼 때는 익명함수나 람다식을 전혀 사용하지 않고, 단순히 함수형 인터페이스의 구현체를 가지고 추상 메소드를 사용하는 방법에 대해 알아봤다.

하지만 이렇게 되면 일반적인 인터페이스와 다를게 없다!

 

구현 클래스를 직접 생성해 사용하는 방식을 넘어, 함수형 인터페이스를 구현체 없이 사용하는 방법에 대해 알아보자.

 

- 익명 함수(Anonymous Function)

'구현 클래스를 생성하지 않고 객체를 생성한다' 에서 시작하는 것이 바로 익명 함수(Anonymous Function)이다.

이게 무슨소리일까? 코드를 먼저 살펴보자.

// 기존 방식
public class RunnableImpl implements Runnable {
    @Override
    public void run() {
        System.out.println("Hello, Runnable");
    }
}

public class RunnableTest {
    public static void main(String[] args) {
        Runnable runnable = new RunnableImpl();
        runnable.run();
    }
}
// 출력 : Hello, Runnable



// 익명 함수 사용
public class RunnableTest {
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("Hello, Runnable");
            }
        };
        runnable.run();
    }
}
// 출력 : Hello, Runnable

 

기존 방식과 비교했을때, 익명 함수를 사용하는 방식은 구현 클래스가 필요없다.

객체를 생성하는 시점에 바로 함수형 인터페이스의 추상 메소드를 오버라이드하면서 메소드를 정의해주면 된다.

얼마나 간편해졌는가?

 

실제로 단 한번의 연산을 위해 함수형 인터페이스를 사용한다고 했을때, 그 한 번을 위해서 구현 클래스를 생성하는 것은 낭비이다.

이름이 없는 함수, 즉 구현 클래스의 필요 없이 함수를 작성할 수 있는 것이 바로 익명 함수이다.

 

다른 예시를 들어보자. 실제로 컬렉션 정렬에 자주 사용되는 함수형 인터페이스, Comparator<T> 를 익명 함수를 통해 구현하는 방법을 알아보자.

public class Main {
    public static void main(String[] args) {
        List<String> strings = Arrays.asList("apple", "orange", "banana", "grape");
        Collections.sort(strings, new Comparator<String>() {
            @Override
            public int compare(String s1, String s2) {
                return Integer.compare(s1.length(), s2.length());
            }
        });
        System.out.println(strings); 
    }
}
// 출력: [grape, apple, orange, banana]

 

Collection.sort()의 두 번째 인자로는 함수형 인터페이스인 Comparator<T>를 필요로 한다.

익명 함수를 이용해 Comparator<T>의 추상 메소드인 compare()을 오버라이드해 구현하는 모습이다.

 

확실히 구현 클래스를 통해 객체를 생성하는 것보단 많이 편하지만, 여전히 메소드를 오버라이드해서 작성하는 건 불편하다.

같은 함수형 인터페이스를 반복해서 구현해야 한다면, 매 번 오버라이드하면서 구현해야되기 때문이다.

그래서 이런 익명 함수의 단점을 보완하는 람다 표현식이라는 것이 등장했다.

 

- 람다식 표현 방법

람다식은 익명 함수의 일종이다. 

익명 함수는 함수형 인터페이스의 추상 메소드를 명시적으로 오버라이드하며 사용하지만, 

람다식은 단순히 해당 메소드의 매개변수와 리턴값을 화살표를 이용해 표현한다.

(매개변수) -> {함수몸체}

 

아래 예시를 보자.

// 익명 함수 사용
public class RunnableTest {
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("Hello, Runnable");
            }
        };
        runnable.run();
    }
}
// 출력 : Hello, Runnable

// 람다 표현식 사용
public class RunnableTest {
    public static void main(String[] args) {
        Runnable runnable = () -> {
            System.out.println("Hello, Runnable");
        };
        runnable.run();
    }
}
// 출력 : Hello, Runnable

 

람다 표현식은 다음과 같은 규칙을 통해 더욱 간결하게 작성할 수 있다.

1. 매개변수의 타입을 추론할 수 있는 경우에는 타입을 생략할 수 있다.

2. 매개변수가 하나인 경우에는 괄호를 생략할 수 있다.

3. 함수의 몸체가 하나의 명령문만으로 이루어진 경우, 중괄호를 생략할 수 있다. (이 때, 세미콜론은 붙이지 않는다.)

4. 함수의 몸체가 하나의 return 문으로만 이루어진 경우는 중괄호를 생략할 수 없다.

5. return 문 대신 표현식을 사용할 수 있고, 표현식의 결과값을 반환한다. (이 때, 세미콜론은 붙이지 않는다.)

 

몇 가지 예제를 살펴보자.

public int sum(int a, int b) {
	return a + b;
}

(a, b) -> {return a + b}; // ok
(a, b) -> a + b; // ok
public static void main() {
	execute(() -> System.out.println("Hello, Runnable"));
}

static void execute(Runnable runnable) {
	runnable.run();
}
Runnable runnable = () -> System.out.println("Hello, Runnable");
runnable.run();

 

 

- 람다식 메소드 참조

람다식을 통해 코드를 굉장히 줄였지만, 이보다 더 간단하게 줄이는 방법이 존재한다.

 

메소드 참조 방식은, 특정 조건을 만족한다면 람다식에 전달하는 매개변수와 화살표를 지워버릴 수 있는 방식이다.

이해를 위해 간단한 예제를 살펴보자.

// 람다 표현식
(x, y) -> Math.max(x, y);

// 메소드 참조
Math::max;

 

위 상황을 보면, 매개변수로 주어진 (x, y)가 max() 메소드의 새로운 매개변수로 처리된다.

하지만 이는 불필요한 코드 중복이기 때문에 Math::max; 처럼 더 간단하게 표현할 수 있는 것이다.

 

물론 아무때나 사용할 수 있는건 아니다. 다음과 같은 조건을 만족해야한다.

  1. 함수형 인터페이스의 매개변수 타입 == 실행할 메소드의 매개변수 타입
  2. 함수형 인터페이스의 매개변수 개수 == 실행할 메소드의 매개변수 개수
  3. 함수형 인터페이스의 반환 타입 == 실행할 메소드의 반환 타입

위 예시 상황을 다시 살펴보자.

람다식으로 표현한 부분은 함수형 인터페이스 IntBinaryOperator를 통해 변수로 담을 수 있다.

IntBinaryOperator a = (x, y) -> Math.min(x, y);
IntBinaryOperator b = Math::max;

a.applyAsInt(1, 10); // 1
b.applyAsInt(1, 10); // 10

 

해당 인터페이스와 Math 클래스의 min(), max() 메소드는 모두 서로 매개변수 타입, 개수, 반환 타입이 동일하다.

그래서 메소드 참조 방식을 사용할 수 있는 것이다.

 

이런식으로 메소드 참조가 가능한 이유는 컴파일러가 람다식의 타입을 추론할 수 있기 때문이다.

어떻게 추론할 수 있을까? 

위 3가지 조건을 모두 갖추면 인터페이스의 추상 메소드의 형태와 반환 메소드의 형태가 같기 때문이다!

@FuntionalInterface
public interface IntBinaryOperator {
	int applyAsInt(int left, int right);
}

class Math {
	
    public static int max(int a, int b) {
    	return (a >= b) ? a : b;
    }
}

// 둘다 2개의 매개변수 -> 1번 ok
// 둘다 매개변수 타입 동일 -> 2번 ok
// 둘다 반환타입 int -> 3번 ok

 

 

자, 이제 메소드 참조에 대한 기본적인 설명은 끝났다.

실제로 메소드 참조는 어떤 메소드를 참조하냐에 따라 종류가 나뉘게 된다.

* 참고로 위 예제에서는 static 메소드를 참조했다.

  • 정적(static) 메소드 참조
    • 먼저 정적 메소드 참조는 위의 예제와 같은 상황이다. 메소드 참조 방식은 아래와 같다.
    • 람다 표현식 : (x) -> ClassName.method(x)
    • 메소드 참조 : ClassName::method
  • 인스턴스 메소드 참조
    • 인스턴스 메소드 참조는 메소드 참조 기호 전에 클래스명이 아니라 해당 인스턴스명을 넣어준다.
    • 람다 표현식 : (x) -> instanceName.method(x)
    • 메소드 참조 : instanceName::method
  • 매개변수의 메소드 참조
    • 매개변수 메소드 참조는 매개변수로 넘어온 타입의 클래스 메소드를 실행문에서 사용할 때 사용한다.
      메소드 참조 방식은 정적 메소드 참조 방식와 동일하다.
    • 람다 표현식 : (x) -> ClassName.method(x)
    • 메소드 참조 : ClassName::method
Function<String, Integer> length;
// 람다 표현식
size = (s -> s.length());
// 매개변수 메소드 참조
size = String::length;

 

 

- 그래서 람다식을 어디에 사용하는걸까?

*다시 한번* 강조해도 충분하지 않다. 우리는 함수형 프로그래밍의 패러다임을 이해하기 위해 함수형 인터페이스, 람다 표현식 까지 알아봤다. 남은건 무엇일까? 

 

Java8 부터 지원하는 Stream API는 매개변수로 함수형 인터페이스를 받는다.

우리는 함수형 인터페이스에 대해 알아봤고, 이러한 함수형 인터페이스를 강력하게 만들어주는 익명 함수부터 람다 표현식까지 공부했다.

 

이제 Steam API가 무엇인지, Stream API를 어떻게 사용하는지 공부할 때 함수형 인터페이스를 깊이 있게 이해한 상태로 들어갈 수 있다. 

Stream API의 매개변수는 함수형 인터페이스를 사용하기 때문에

  1. Stream API를 다루기 위해서는 람다 표현식을 다룰 줄 알아야 한다.
  2. 람다 표현식을 다루기 위해서는 함수형 인터페이스가 무엇이고 어떤건지 이해할 수 있어야한다.

결론은 함수형 프로그래밍을 다루기 위한 Stream API를 사용하기위해 람다 표현식을 배웠다고 말할 수 있다.

단순히 "람다 써봐야지~", "스트림 써봐야지~" 의 생각으로 Java8 문법에 대해 접근하는 것이 아니라, 함수형 프로그래밍의 패러다임을 이해하기위해 배워나가고 있음을 명심했으면 좋겠다.

'Java' 카테고리의 다른 글

[Java] 컬렉션 프레임워크  (0) 2024.01.02
[Java] Stream API  (1) 2023.12.21
[Java] 추상 클래스와 인터페이스  (0) 2023.05.30
[Java] Call by value & reference  (0) 2023.05.26
[Java] for each문 & switch/case문  (0) 2023.05.25