본문 바로가기
Java

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

by 태크민 2023. 8. 23.

람다식(Lambda Expression) 이란?

Stream 연산들은 매개변수로 함수형 인터페이스(Functional Interface)를 받도록 되어 있다. 그리고 람다식은 반환 값으로 함수형 인터페이스를 반환하고 있다. 그렇기 때문에 우리는 Stream API를 정확히 이해하기 위해 람다식과 함수형 인터페이스에 대해 알고 있어야 한다.

 

[람다식(Lambda Expression)]

람다식(Lambda Expression)이란 함수를 하나의 식(expression)으로 표현한 것이다. 함수를 람다식으로 표현하면 메소드의 이름이 필요없기 때문에, 람다식은 익명함수(Anonymous Function)의 한 종류라고 볼 수있다.

익명함수(Anonymouns Function)란 함수의 이름이 없는 함수로, 익명함수들은 모두 일급 객체이다. 일급 객체인 함수는 변수처럼 사용가능하며 매개 변수로 전달이 가능하다는 등의 특징을 가지고 있다.

💡 1급 객체(First-Class Object)란
다음과 같은 것들이 가능한 객체를 의미한다.
  > 변수나 데이터 구조 안에 담을 수 있다.
  > 파라미터로 전달할 수 있다.
  > 반환 값으로 사용할 수 있다.
  > 할당에 사용된이름과 무관하게 고유한 구별이 가능하다.

 

기존의 방식에서는 함수를 선언할 때 다음과 같이 선언하였다.

// 기존의 방식
반환티입 메소드명 (매개변수, ...) { 실행문 }
// 예시

public String hello()
{
return

"Hello World!"
; }

 

하지만 람다 방식으로는 위와 같이 메소드 명이 불필요하며, 다음과 같이 괄호()와 화살표 ->를 이용해 함수를 선언하게 된다.

// 람다 방식
(매개변수, ... ) -> { 실행문 ... }
// 예시
() ->
"Hello World!"
;

이렇게 람다식이 등장하게 된 이유는 불필요한 코드를 줄이고, 가독성을 높이기 위함이다. 그렇기 때문에 함수형 인터페이스의 인스턴스를 생성하여 함수를 변수처럼 선언하는 람다식에서는 메소드의 이름이 불필요하다고 여겨져서 이를 사용하지 않는다. 대신 컴파일러가 문맥을 살펴 타입을 추론한다. 또한 람다식으로 선언된 함수는 1급객체이기 때문에 Stream API의 매개 변수로 전달이 가능해진다.

 

 

[람다식(Lambda Expression)의 문법]

자바에서 람다식을 사용하는 문법을 자세히 들여다보자. 자바에서 람다식은 다음과 같이 사용할 수 있다.

(매개변수 목록) -> { 람다식 바디 }

람다식의 시작 부분에는 파라미터들을 명시할 수 있다. 비교적 엄격한 타입 제한을 두고 있는 자바이지만 람다식의 파라미터를 추론할 수 있는 경우에는 타입을 생략할 수 있다. (매개변수의 타입을 추론할 수 없는 경우에는 메소드의 매개변수처럼 타입을 명시해줘야 한다.)

 

매개변수가 하나인 경우 괄호를 생략할 수 있다. 예를 들어 제곱을 구하는 람다식을 다음처럼 정의할 수 있다.

a -> a * a

 

람다식의 바디부분에 하나의 표현식만 오는 경우에는 중괄호를 생략할 수 있다. 위에서 봤던 제곱을 구하는 람다는 식이 하나라서 중괄호를 생략할 수 있었다. 중괄호가 생략된 람다식에서는 세미콜론(;)을 붙이지 않는다.

 

하지만, 한가지 예외가 있는데 람다식의 바디에 'return' 문이 있는 경우 중괄호를 생략할 수 없다. 예를 들어 

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

위의 람다식을

 

(a, b) -> return a > b ? a : b

위와 같이 바꾸면 에러가 발생한다. 따라서, 중괄호를 쓰고 싶지 않으면 'return' 문도 함께 생략시켜야한다.

 

[람다식(Lambda Expression)의 특징]

  • 람다식 내에서 사용되는 지역 변수는 final이 붙지 않아도 상수로 간주된다.
  • 람다식으로 선언된 변수명은 다른 변수 명과 중복될 수 없다.

 

[람다식(Lambda Expression)의 장단점]

장점

  1. 코드를 간결하게 만들 수 있다.
  2. 식에 개발자의 의도가 명확히 드러나 가독성이 높아진다.
  3. 함수를 만드는 과정없이 한번에 처리할 수 있어 생산성이 높아진다.
  4. 병렬프로그래밍이 용이하다.

단점

  1. 람다를 사용하면서 만든 무명함수는 재사용이 불가능하다.
  2. 디버깅이 어렵다.
  3. 람다를 남발하면 비슷한 함수가 중복 생성되어 지저분해질 수 있다.
  4. 재귀로 만들 경우에 부적합하다.

결국 무조건 람다가 좋다는 보장은 없다. 상황에 따라 필요에 맞는 방법을 사용하는 것이 중요하다.

 

 

2. 함수형 인터페이스(Functional Interface) 란?

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

이제 우리는 람다식으로 순수 함수를 선언할 수 있게 되었다. 하지만 Java는 기본적으로 객체지향 언어이기 때문에 순수 함수와 일반 함수를 다르게 취급하고 있으며, Java에서는 이를 구분하기 위해 함수형 인터페이스가 등장하게 되었다.

💡 순수 함수(pure function)란
순수 함수란 같은 입력에 대해 항상 같은 출력을 반환하는 함수로, 다음과 같은 특징을 갖는다.
  > 동일한 입력에 대해 항상 같은 값을 반환
  > 함수의 실행이 외부에 영향을 끼치지 않는 함수

함수형 인터페이스란 함수를 1급 객체처럼 다룰 수 있게 해주는 어노테이션으로, 인터페이스에 선언하여 단 하나의 추상 메소드만을 갖도록 제한하는 역할을 한다. 함수형 인터페이스를 사용하는 이유는 Java의 람다식이 함수형 인터페이스를 반환하기 때문이다.

 

예를 들어 우리가 두 값 중 큰 값을 구하는 익명 함수를 개발하였다고 하자.

그러면 기존 방식에서는 다음과 같이 개발을 하였을 것이다.

public class Lambda {

    public static void main(String[] args) {
    
        // 기존의 익명함수
        System.out.println(new MyLambdaFunction() {
            public int max(int a, int b) {
                return a > b ? a : b;
            }
        }.max(3, 5));
    }
}

 

하지만 함수형 인터페이스의 등장으로 우리는 함수를 변수처럼 선언할 수 있게 되었고, 코드 역시 간결하게 작성할 수 있게 되었다. 함수형 인터페이스를 구현하기 위해서는 인터페이스를 개발하여 그 내부에는 1개 뿐인 abstract함수를 선언하고, 위에는 @FunctionalInterface 어노테이션을 붙여주면 된다. 위의 코드를 람다식으로 변경하면 다음과 같다.

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

public class Lambda {

    public static void main(String[] args) {

        // 람다식을 이용한 익명함수
        MyLambdaFunction lambdaFunction = (int a, int b) -> a > b ? a : b;
        System.out.println(lambdaFunction.max(3, 5));
    }

}

 

이제 우리는 Java8 이전에 사용했던 익명함수들을 람다식으로 변경해 코드를 줄일 수 있게 되었고, 여기서 놓치지 말아야 하는 것은 람다식으로 생성된 순수함수는 인터페이스로만 선언이 가능하다는 점이다. 또한 @FunctionalInterface는 해당 인터페이스가 1개의 함수만을 갖도록 제한하기 때문에, 여러 개의 함수를 선언하면 컴파일 에러가 발생할 것이라는 점이다.

 

안드로이드 개발에서 익명 객체로 자주 사용하는 Runnable 클래스도 람다 함수를 통해 자주 생성하는데, 클래스의 내부 코드를 확인해보면 @FunctionalInterface 어노테이션이 붙어있다.

@FunctionalInterface
public interface Runnable {
    /**
     * When an object implementing interface <code>Runnable</code> is used
     * to create a thread, starting the thread causes the object's
     * <code>run</code> method to be called in that separately executing
     * thread.
     * <p>
     * The general contract of the method <code>run</code> is that it may
     * take any action whatsoever.
     *
     * @see     java.lang.Thread#run()
     */
    public abstract void run();
}
Runnable runnable = () -> {
	System.out.println("Lambda");
};

 

[Java에서 제공하는 함수형 인터페이스]

Java에서 자주 사용될 것 같은 함수형 인터페이스가 이미 정의되어 있으며, 총 4가지 함수형 인터페이스를 지원하고 있다.

  • Supplier<T>
  • Consumer<T>
  • Function<T, R>
  • Predicate<T>

 

1. Supplier<T>

Supplier매개변수 없이 반환값 만을 갖는 함수형 인터페이스이다.

Supplier는 T get()을 추상 메소드로 갖고 있다.

// 정의
@FunctionalInterface
public interface Supplier<T> {
    T get();
}

// 사용 예시
Supplier<String> supplier = () -> "Hello World!";
System.out.println(supplier.get());

 

2. Consumer <T>

Consumer객체 T를 매개변수로 받아서 사용하며, 반환 값은 없는 함수형 인터페이스이다.

Consumer는 void accept(T t)를 추상메소드로 갖는다.

또한 Consumer는 andThen이라는 함수를 제공하고 있는데, 이를 통해 하나의 함수가 끝난 후 다음 Consumer를 연쇄적으로 이용할 수 있다. 아래의 예제에서는 먼저 accept로 받아들인 Consumer를 먼저 처리하고, andThen으로 받은 두 번째 Consumer를 처리하고 있다. 함수형에서 함수는 값의 대입 또는 변경 등이 없기 때문에 첫 번째 Consumer가 split으로 데이터를 변경하였다 하더라도 원본의 데이터는 유지된다.

// 정의
@FunctionalInterface
public interface Consumer<T> {

    void accept(T t);

    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}

// 예시
Consumer<String> consumer = (str) -> System.out.println(str.split(" ")[0]);
consumer.andThen(System.out::println).accept("Hello World");

// 출력
Hello
Hello World

 

3. Function <T, R>

Function객체 T를 매개변수로 받아서 처리한 후 R로 반환하는 함수형 인터페이스다.

Function은 R apply(T t)를 추상 메소드로 갖는다.

또한 Function은 Consumer와 마찬가지로 andThen을 제공하고 있으며, 추가적으로 compose를 제공하고 있다. 앞에서 andThen은 첫번째 함수가 실행된 이후에 다음 함수를 연쇄적으로 실행하도록 연결해준다고 하였다. 하지만 compose는 첫 번째 함수 실행 이전에 먼ㅁ저 함수를 실행하여 연쇄적으로 연결해준다는 점에서 차이가 있다.

또한 identity 함수가 존재하는데, 이는 자신을 반환하는 static 함수이다.

// 정의
@FunctionalInterface
public interface Function<T, R> {

    R apply(T t);

    default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
        Objects.requireNonNull(before);
        return (V v) -> apply(before.apply(v));
    }

    default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
        Objects.requireNonNull(after);
        return (T t) -> after.apply(apply(t));
    }

    static <T> Function<T, T> identity() {
        return t -> t;
    }
}

// 예시, 메소드 참조로 간소화 가능(String::length;)
Function<String, Integer> function = str -> str.length();
function.apply("Hello World");

 

4. Predicate<T>

Predicate는 객체 T를 매개 변수로 받아 처리한 후  Boolean을 반환한다.

Predicate는 Boolean test (T t)을 추상 메소드로 갖고 있다.

// 정의
@FunctionalInterface
public interface Predicate<T> {

    boolean test(T t);

    default Predicate<T> and(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) && other.test(t);
    }

    default Predicate<T> negate() {
        return (t) -> !test(t);
    }

    default Predicate<T> or(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) || other.test(t);
    }

    static <T> Predicate<T> isEqual(Object targetRef) {
        return (null == targetRef)
                ? Objects::isNull
                : object -> targetRef.equals(object);
    }
    
}

// 예시
Predicate<String> predicate = (str) -> str.equals("Hello World");
predicate.test("Hello World");

 

[메소드 참조(Method Reference)]

메소드 참조란 함수형 인터페이스를 람다식이 아닌 일반 메소드를 참조시켜 선언하는 방법이다.

참조가능한 메소드는 일반 메소드, Static 메소드, 생성자가 있으며 클래스이름::메소드이름으로 참조할 수 있다. 이렇게 참조를 하면 함수형 인터페이스로 반환이 된다. 3가지의 메소드에 대해 메소드 참조 예시를 자세히 살펴보도록 하자.

 

1. Static 메소드 참조

문법

정적 메소드가 포함된 클래스::정적메소드명

예제

// 람다식으로 list의 원소 절대값 만들기
List<Integer> list = Arrays.asList(1, -5, 2, -7, -3);
list.stream().map(num -> Math.abs(num))
        .forEach(num -> System.out.println(num));

// 정적 메소드 참조로 변경
list.stream().map(Math::abs)
        .forEach(num -> System.out.println(num));

 

2. 특정 객체의 인스턴스 메소드 참조 (일반 메소드)

문법

메소드가 포함된 객체::메소드명

예제

class Node {
    public int x;
    public int y;

    public Node(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public String toString(){
        return "x: " + x +", y: " + y;
    }
}

class NodeComparator implements Comparator<Node> {

    @Override
    public int compare(Node o1, Node o2) {
        return o1.x - o2.x;
    }
}
List<Node> nodes = new ArrayList<Node>();
nodes.add(new Node(3, 6));
nodes.add(new Node(1, 3));
nodes.add(new Node(6, 5));
nodes.add(new Node(12, 2));

NodeComparator nodeComparator = new NodeComparator();

// 람다식으로 정렬
nodes.stream().sorted((a, b) -> nodeComparator.compare(a, b))
	.forEach(node -> System.out.println(node.toString()));

// 인스턴스 메소드 참조로 정렬
nodes.stream().sorted(nodeComparator::compare)
	.forEach(node -> System.out.println(node.toString()));

 

3. 특정 유형의 임의 객체 메소드에 대한 참조 (일반 메소드)

문법

타입::메소드명

예제

List<String> words = Arrays.asList("ABC", "CD", "EFG");

// 인자로 넘어온 객체의 메소드를 호출
// 람다식으로 표현
words.stream().map(word -> word.toLowerCase())
	.forEach(word -> System.out.println(word));

// 메소드 참조로 표현
words.stream().map(String::toLowerCase)
	.forEach(word -> System.out.println(word));

 

4. 생성자 참조

문법

클래스명::new

예제

public class Node {
    int x;
    int y;
    public Node(int x, int y){
        this.x = x;
        this.y = y;
    }

    public String toString(){
        return "x:" + x+ ", y:"+y;
    }

}
// 람다식
BiFunction<Integer, Integer, Node> functionFunc1 = (a, b) -> new Node(a, b);
System.out.println(functionFunc1.apply(1, 3).toString());

// 생성자 참조
BiFunction<Integer, Integer, Node> functionFunc2 = Node::new;
System.out.println(functionFunc1.apply(5, 6).toString());