본문 바로가기
Languages/Java

[Java 8] 함수형 인터페이스와 람다

by yoon_seon 2023. 5. 18.

함수형 인터페이스와 람다

  1. 함수형 인터페이스와 람다표현식 소개
  2. 자바에서 제공하는 함수형 인터페이스
  3. 람다 표현식
  4. 메서드 레퍼런스

📌 함수형 인터페이스와 람다 표현식 소개

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

  • 추상 메서드를 딱 하나만 가지고 있는 인터페이스
  • SAM (Single Abstract Method) 인터페이스
  • @FuncationInterface 애노테이션을 가지고 있는 인터페이스
@FunctionalInterface
public interface RunSomething {
    void doIt();

    static void printName() {
        System.out.println("name");
    };

    default void printAge() {
        System.out.println(30);
    }
}
  • @FunctionalInterface : 해당 인터페이스가 함수형 인터페이스임을 명시하는 어노테이션이다. 함수형 인터페이스 규칙을 위반하면 컴파일 에러가 발생해서 더 견고하게 관리할 수 있다.
  • 위 코드를 보면 추상메서드인 doIt() 메서드와 printName()과 printAge() 메서드, 총 3개의 메서드가 있는데도 해당 인터페이스는 함수형 인터페이스(Functional Interface)이다.
    static 메서드와 default 메서드가 유무와 상관없이 하나의 추상메서드만 존재하기 때문이다.
참고 : interface에서 해당 메서드가 추상메서드라고 명시하는 abstract는 생략가능하다.
또한 static 메서드에서 public 접근제어자도 생략 가능하다.

 

자바 8 이전 → 익명 내부 클래스 사용

public class Foo {
    public static void main(String[] args) {
        // 익명 내부 클래스 annoymous inner class
        RunSomething runSomething = new RunSomething() {
            @Override
            public void doIt() {
                System.out.println("Hello");
            }
        };
    }
}

 

자바 8 이후

람다 표현식을 이용하여 간략하게 구현할 수 있다.

public class Foo {
    public static void main(String[] args) {
        RunSomething runSomething = () -> System.out.println("Hello");
    }
}

 

만약 함수 내에서 처리해야 하는 일이 하나가 아니라면 다음과 같이 사용한다.

public class Foo {
    public static void main(String[] args) {
        RunSomething runSomething = () -> {
            System.out.println("Hello");
            System.out.println("Lambda");
        };
    }
}

 

람다 표현식 (Lambda Expressions)

  • 함수형 인터페이스의 인스턴스를 만드는 방법으로 쓰일 수 있다.
  • 코드를 줄일 수 있다.
  • 메서드 매개변수, 리턴 타입, 변수로 만들어 사용할 수도 있다.

 

자바에서 함수형 프로그래밍

  • 함수를 First class object(일급 객체)로 사용할 수 있다.
    : RunSomething runSomething = () -> System.out.println("Hello");
    람다를 사용하면 다른 언어의 함수를 정의한 것처럼 보이지만 자바에서는 이런 형태를 특수한 형태의 오브젝트라고 볼 수 있다. 함수형 인터페이스를 인라인 형태로 구현한 오브젝트라 볼 수 있는데, 자바는 객체지향언어(OOP)이기 때문에 이 함수를 변수에 할당, 메서드의 파라미터로 전달, 리턴타입으로 사용할 수 있다.

  • 고차 함수 (Higher-Order Function)
    • 함수가 함수를 매개변수로 받을 수 있고 함수를 리턴할 수도 있다.
      → 자바에서는 함수가 특수한 형태의 오브젝트일 뿐이기에 당연히 가능하다.
  • 순수 함수 (Pure function)
public class Foo {
    public static void main(String[] args) {
        RunSomething runSomething = (number) -> {
            return number + 10;
        };
        System.out.println(runSomething.doIt(1)); // 11
        System.out.println(runSomething.doIt(1)); // 11
        System.out.println(runSomething.doIt(1)); // 11

        System.out.println(runSomething.doIt(2)); // 12
        System.out.println(runSomething.doIt(2)); // 12
        System.out.println(runSomething.doIt(2)); // 12
    }
}
  • 수학적인 함수에서 가장 중요한 것은 입력받은 값이 동일할 때 결과가 같아야 한다는 것이다. 매개변수로 1을 넣었으면 몇 번을 호출하던 11이 나와야 하고 2를 넣었으면 몇 번이던 12가 나와야 한다. 이런 결과를 보장하지 못하거나 못할 여지가 있다면 함수형 프로그래밍이라고 할 수 없다.
  • 사이드 이팩트가 없다. (함수 밖에 있는 값을 변경하지 않는다.)
  • 상태가 없다. (함수 밖에 있는 값을 사용하지 않는다.)

함수 내에서 외부의 값을 참조할 수는 있다.

함수 내에서는 외부의 값을 final 이라고 보기 때문에 외부의 값을 변경할 수 없다.

 

정리

자바에서 함수형 프로그래밍을 할 수 있도록 제공된 함수형 인터페이스(Functional Interface), 람다 표현식(Lambda Expressions)은 굳이 함수형 프로그래밍을 안 하더라도 문법적으로 허용되기에 사용할 수 있는 기능들이다.

하지만, 함수형 프로그래밍을 하겠다고하면 이 두 가지를 사용함에 있어서 순수 함수, 불변성에 대해 고려할 필요가 있다.

 


📌 자바에서 제공하는 함수형 인터페이스 

자바에서 기본으로 제공하는 함수형 인터페이스

자바에서 미리 정의해둔 자주 사용할만한 함수 인터페이스

  • Function
  • BiFunction
  • Consumer
  • Supplier
  • Predicate
  • UnaryOperator
  • BinaryOperator

함수 인터페이스 분석

1. Function<T, R>

:: T타입을 받아서 R타입을 반환하는 함수 인터페이스

  • R apply(T t)
    : T라는 타입을 받아서 R이라는 타입을 반환하는 추상 메서드

클래스를 이용한 방법

public class Plus10 implements Function<Integer, Integer> {
    @Override
    public Integer apply(Integer integer) {
        return integer + 10;
    }
}

public class Foo {
    public static void main(String[] args) {
        Plus10 plus10 = new Plus10();
        System.out.println(plus10.apply(1)); // 11
    }
}
  1. Function<T, R> 인터페이스의 구현체인 Plus10 클래스를 생성 후 apply() 메서드를 구현
  2. Plus10의 인스턴스 생성 후 apply() 메서드를 실행해 결괏값 반환

 

람다 표현식(Lambda Expressions)를 이용한 방법

public class Foo {
    public static void main(String[] args) {
        Function<Integer, Integer> plus10 = (i) -> i + 10;
        System.out.println(plus10.apply(1)); // 1;
    }
}
  • 람다 표현식을 이용하여 바로 구현

 

함수 조합용 메서드

public class Foo {
    public static void main(String[] args) {
        Function<Integer, Integer> plus10 = (i) -> i + 10;
        Function<Integer, Integer> multiply2 = (i) -> i * 2;

        Function<Integer, Integer> multiply2AndPlus10 = plus10.compose(multiply2);// 뒤에오는 함수를 적용 후 결과값을 앞에있는 함수에 입력값으로 사용
        Function<Integer, Integer> plus10AndMultiply2 = plus10.andThen(multiply2);// 앞에있는 함수 적용 후 결과값을 뒤에 있는 함수에 입력값으로 사용

        System.out.println(multiply2AndPlus10.apply(2)); // 12
        System.out.println(integerIntegerFunction.apply(2)); // 24
    }
}
  • compose : 뒤에오는 함수를 적용 후 결괏값을 앞에 있는 함수에 입력값으로 사용 → ( 2*2) + 10
  • andThan : 앞에있는 함수를 적용 후 결괏값을 뒤에 있는 함수에 입력값으로 사용 (2+10) * 2

 

2. BiFunction<T, U, R>

:: T,U 타입을 입력받아서 R 타입을 반환하는 함수 인터페이스

public class Foo {
    public static void main(String[] args) {
        BiFunction<String, Integer, String> biFunction = (s, i) -> "Hello" + s + i;
        System.out.println(biFunction.apply("World",2023)); // HelloWorld2023
}
  • R apply(T t, U u)
  • Function<T, R> 함수와 매개변수 개수만 다를 뿐 동일하다.

 

3. Consumer<T>

:: T 타입을 받아서 수행 후 아무 값도 반환하지 않는 함수 인터페이스

public class Foo {
    public static void main(String[] args) {
        Consumer<Integer> printT = (i) -> System.out.println(i);
        printT.accept(1); // 1
}
  • accept(T t)
  • 함수조합용 메서드
    • andThan

 

4. Supplier<T>

:: T 타입의 값을 제공하는 함수 인터페이스

public class Foo {
    public static void main(String[] args) {
        Supplier<Integer> get10 = () -> 10;
        System.out.println(get10.get()); // 10
}
  • get()
  • 입력값이 없고 T타입의 값을 반환만 한다.

 

5. Predicate<T>

:: T 타입을 받아서 boolean을 리턴하는 함수 인터페이스

public class Foo {
    public static void main(String[] args) {
        Predicate<Integer> isEven = (i) -> i%2 == 0;
        Predicate<Integer> isOdd = (i) -> i%2 == 1;
        System.out.println(isEven.test(10)); // true
        System.out.println(isEven.test(13)); // false
        System.out.println(isOdd.test(13));  // true
        
        System.out.println(isEven.and(isOdd).test(1)); // && false
        System.out.println(isEven.or(isOdd).test(1));  // || true
        System.out.println(isEven.negate().test(1));   // !  true
}
  • boolean test(T t)
  • 함수 조합용 메서드
    • And
    • Or
    • Negate

 

6. UnaryOperator<T>

:: Function<T, R>의 특수한 형태로 입력값 타입과 반환값 타입이 동일한 함수 인터페이스

public class Foo {
    public static void main(String[] args) {
        UnaryOperator<Integer> plus20 = (i) -> i + 20;
        System.out.println(plus20.apply(1)); // 21
    }
}
  • T apply(T t)
  • 파라미터 타입과 리턴 타입이 동일할 때 사용

 

7. BinaryOprator<T>

:: BiFunction<T, U, R>의 특수한 형태로 입력값 타입과 반환값 타입이 동일한 함수 인터페이스

public class Foo {
    public static void main(String[] args) {
        BinaryOperator<String> binaryOperator = (s, i) -> "Hello" + s + i;
        System.out.println(binaryOperator.apply("World","2023")); // HelloWorld2023
    }
}
  • T apply(T t)
  • 두 개의 입력 매개변수 타입과 리턴 타입이 모두 동일할 때 사용

 


📌 람다 표현식

람다

람다는 기본적으로 인자와 바디로 이루어진다.

 

( 인자 리스트 ) → { 바디 } 

  • 인자 리스트
    • 인자가 없을 때 : ()
    • 인자가 1개 : (one) 또는 one → 괄호 생략 가능
    • 인자가 여러개 : (one, two) 괄호 생략 불가능
    • 인자의 타입은 생략가능, 컴파일러가 제네릭을 보고 추론(infer)하지만 명시할 수도 있다.
      ex. (Integer one, Integer two) -> { 바디 }
  • 바디
    • 화살표 오른쪽에 함수 본문을 정의한다.
    • 여러 줄인 경우 {} 사용해서 묶는다.
    • 한 줄인 경우 생략 가능하며 return도 생략 가능하다.
public class Foo {
    public static void main(String[] args) {
        
        /**** 인자리스트 예시 *****/
        // 인자가 없을 때 : 인자리스트 괄호() 필수
        Supplier<Integer> get10 = () -> 10;

        // 인자가 하나일 경우 : 인자리스트 괄호() 생략 가능
        UnaryOperator<Integer> plus10 = s -> s + 10;

        // 인자가 여러개일 경우 : 인자리스트()괄호 생략 불가능
        BinaryOperator<Integer> sum1 = (a, b) -> a + b;

        // 인자타입은 생략 가능하지만 파라미터 타입을 명시 가능.
        BinaryOperator<Integer> sum2 = (Integer a, Integer b) -> a + b;
        BinaryOperator<Integer> sum3 = (a,  b) -> a + b;


        /** 바디 예시 **/
        // 바디가 한 줄일 경우 바디 괄호{} 생략가능
        Consumer<String> print1 = (s) -> System.out.println("첫 번째 줄");
        Consumer<String> print2 = (s) -> {
            System.out.println(s + "첫 번째 줄");
            System.out.println(s + "두 번째 줄");
        };
    }
}

 

변수 캡처(Variable Capture)

    private void run() {
        int baseNumber = 10; // 외부지역변수, effective final variable
        // final int baseNumber = 10; // {1} 이 변수를 다른곳에서 변경하지 않는 경우 자바 8부터 final 생략 가능

        // 내부 클래스에서 외부지역변수 참조
        class LocalClass {
            void printBaseNumber() {
                int baseNumber = 20; // 쉐도윙(Shadowing)으로 외부 지역변수를 덮음
                System.out.println("내부 클래스 : "+baseNumber); // 20 {2}
            }
        }
        LocalClass localClass = new LocalClass();
        localClass.printBaseNumber();

        // 익명 클래스에서 외부지역변수 참조
        Consumer<Integer> integerConsumer = new Consumer<Integer>() {
            @Override
            public void accept(Integer baseNumber) { // 쉐도윙(Shadowing)으로 외부지역변수를 덮음
                System.out.println("익명 클래스 : "+baseNumber); // 30 {3}
            }
        };
        integerConsumer.accept(30);

        // 람다에서 지역변수 참조
        IntConsumer printInt = (i) -> {
            // int baseNumber = 50; // 변수 선언 불가(쉐도잉 안됨)
            System.out.println("람다 : "+ (i + baseNumber)); // 40 {4}
        };
        printInt.accept(30);

    }
  • {1} : 변수가 effective final variable일 경우 자바 8부터 final 키워드를 생략 가능하다.
  • {2} : 외부 지역변수와 내부 지역변수명이 같을 경우 쉐도윙(Shadowing)으로 인해 내부 지역변수가 출력된다.
  • {3} : 외부 지역변수와 내부 매개변수명이 같을 경우 쉐도윙(Shadowing)으로 인해 내부 매개변수가 출력된다.
  • {4} : 람다는 내부클래스, 익명클래스와 다르게 메서드와 같은 스코프를 가지고 있기 때문에 지역변수와 동일한 이름으로 변수 생성이 불가능하다. 즉, 쉐도윙(Shadowing)되지 않는다.

 

baseNumber가 메서드 내 위치와 상관없이 값이 재할당되어 effective final variable가 아니게 될 경우 외부 지역변수를 바라보고있는 내부or익명 클래스나 같은 스코프 내 지역변수는 컴파일 에러 발생한다.

 

Effective final variabledl이란?

자바에서 final 키워드가 선언되지 않은 변수지만, 값이 재할당되지 않아 final 과 유사하게 동작하는 변수

 

쉐도윙(Shadowing)이란?

쉐도잉은 동일한 변수나 메서드가 스코프 내에서 중복으로 선언될 때 변수나 메서드의 이름이 겹치는 경우에 발생하며, 스코프 내에서 가까운 범위에 있는 변수 또는 메서드가 우선적으로 접근되어 사용된다.

 

정리

  1. 람다는 외부 block에 있는 변수에 접근 가능
  2. 외부에 있는 변수가 지역 변수 일 경우 final 변수 또는 effective final variable 변수 인 경우만 접근 가능

 

 


📌 메서드 레퍼런스

람다 표현식을 구현할 때, 기존에 있던 메서드나 생성자를 참조하는 것

 

메서드 참조하는 방법

static 메서드 참조 타입 :: static 메서드
특정 객체의 인스턴스 메서드 참조 객체 레퍼런스 :: 인스턴스 메서드
임의 객체의 인스턴스 메서드 참조 타입 :: 인스턴스 메서드
생성자 참조 타입 :: new

 

예제를 위한 참조클래스

public class Greeting {

    private String name;

    public Greeting() {
    }

    public Greeting(String name) {
        this.name = name;
    }

    public String hello(String name) {
        return "hello " + name;
    }

    public static String hi(String name) {
        return "hi " + name;
    }
}

 

기존의 방식대로 기능 구현

public static void main(String[] args) {
    UnaryOperator<String> hi = (s) -> "hi " + s;
    System.out.println(hi.apply("java")); // hi java
}

 

메서드 레퍼런스를 사용한 기능 구현 (static 메서드 참조)

UnaryOperator<String> hi = Greeting::hi;
System.out.println(hi.apply("java")); // hi java
  • Greeting 클래스에 구현되어 있는 static 메서드 hi를 메서드 레퍼런스를 사용하여 다음과 같이 호출할 수 있다.

메서드 레퍼런스를 사용한 기능 구현 (특정 객체의 인스턴스 메서드 참조)

Greeting greeting = new Greeting();
UnaryOperator<String> hello = greeting::hello;
System.out.println(hi.apply("java")); // hello java
  • Greeting클래스 인스턴스 생성 후 인스턴스의 메서드 hello를 메서드 레퍼런스를 사용하여 다음과 같이 호출할 수 있다.

메서드 레퍼런스를 사용한 기능 구현 (생성자 메서드 참조)

// 입력값이 없는 생성자 메서드 참조
Supplier<Greeting> newGreeting = Greeting::new;
Greeting newMethodGreeting = newGreeting.get();

// 입력값을 있는 생성자 메서드 참조
Function<String, Greeting> newGreetingWithName = Greeting::new;
Greeting newMethodParamGreeting = newGreetingWithName.apply("java");
  • 입력값은 존재하지 않지만 반환값은 존재하는 경우 Supplier<T>를 통해 구현할 수 있다.
    기본 생성자 또한 입력값은 없고 반환값만 존재하지 않기 때문에 Supplier<T>로 구현할 수 있다.
  • 입력값이 존재하는 생성자는 Function<T,R>을 통해 구현 할 수 있다.
    생성자 입력값으로 String 인자 값을 전달받아 생성된 Greeting 타입 인스턴스를 반환한다.
참고 : Supplier<T>, Function<T,R>를  통해 구현한 생성자 메서드 참조 함수는 생성자 함수를 만든 것 뿐이지 실제 인스턴스가 생성된 것은 아니기 때문에 함수 구현 후  get(), apply()를 호출해서 인스턴스를 만들어줘야한다.

 

메서드 레퍼런스를 사용한 기능 구현 (임의 객체의 인스턴스 메서드 참조 → 불특정 타입의 불특정 인스턴스의 메서드를 참조)

String[] names = {"member3", "member2", "member1"};

// 기존의 익명클래스를 사용한 방법
Arrays.sort(names, new Comparator<String>() {
    @Override
    public int compare(String o1, String o2) {
        return 0;
    }
});

// 람다 사용
Arrays.sort(names,((o1, o2) -> 0));

// 메서드 레퍼런스 사용
Arrays.sort(names, String::compareToIgnoreCase);

System.out.println(Arrays.toString(names));
  • 기존에는 익명 클래스를 이용해 정렬을 구현해야했다.
  • 자바 8이후, Comparator클래스는 @FunctionalInterface가 되었고 함수형 인터페이스이기 때문에 람다 표현식으로 사용할 수 있게 되었다.
  • 람다 표현식으로 사용할 수 있다는 말은 메서드 레퍼런스를 사용할 수 있다는 의미이기 때문에 String 클래스의 compareToIgnoreCase 메서드를 사용하며 names의 파라미터를 넘겨서 정렬할 수 있다.

댓글