본문 바로가기
java

더 자바(java 8) - 함수형 인터페이스와 람다

by 쭈꾸마뇽 2021. 5. 10.

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

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

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

    default void printAge() {
        System.out.println("30");
    }
}

이 인터페이스는 함수형 인터페이스가 아닌것 처럼 생겼지만 함수형 인터페이스가 맞다.  왜냐하면 추상 메소드를 단 하나만 가지고 있으며 @FuncationInterface 애노테이션을 가지고 있다.  static이나 default로 선언된 함수가 존재해도 추상 메소드는 단 하나만 있기 때문에 함수형 인터페이스로 볼 수 있다.

@FunctionalInterface
public interface RunSomething {
    void doIt();

    void doIt2(); // 추상 메소드가 2개 이상이므로 에러가 남

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

    default void printAge() {
        System.out.println("30");
    }
}

반대로 이 인터페이스는 추상 메소드를 두개 갖고 있기 때문에 함수형 인터페이스가 아니다.  @FuncationInterface 애노테이션을 가지고 있는 인터페이스에 추상메소드를 두개 선언하면 @FuncationInterface 애노테이션 부분에 에러가뜬다.

람다 표현식 (Lambda Expressions)

  • 함수형 인터페이스의 인스턴스를 만드는 방법으로 쓰일 수 있다.
  • 코드를 줄일 수 있다.
  • 메소드 매개변수, 리턴 타입, 변수로 만들어 사용할 수 있다.
public class Foo {
    public static void main(String[] args) {
        // 익명 내부 클래스
        RunSomething runSomething1 = new RunSomething() {
            @Override
            public void doIt() {
                System.out.println("Hello World");
            }
        };
        runSomething1.doIt();

        // 람다 표현식
        RunSomething runSomething2 = () -> System.out.println("Hello World");
        // 함수형 인터페이스를 인라인로 구현한 오브젝트로 볼 수 있음
        runSomething2.doIt();
    }
}

위 코드는 RunSomething이란 함수형 인터페이스를 선언해서 사용하는 두가지 케이스다.  첫번째는 익명 내부 클래스 형태로 선언한 케이스고 두번째는 람다 표현식으로 작성한 코드이다.  

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

  • 함수를 First Class Object로 사용할 수 있다.
  • 순수 함수 (Pure Function)
    • 사이드 이펙트가 없다 -> 함수 밖에 있는 값을 변경하지 않는다
    • 상태가 없다 -> 함수 밖에 있는 값을 사용하지 않는다.
  • 고차 함수 (Higher-Order Function)
    • 함수가 함수를 매개변수로 받을 수 있고 함수를 리턴할 수도 있다.
  • 불변성

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

Function<T, R>

  • T 타입을 받아서 R 타입을 리턴하는 함수 인터페이스
    • R apply(T t)
  • 함수 조합용 메소드
    • andThen
    • compose
Function<Integer, Integer> plus5 = (i) -> i + 5;
System.out.println(plus5.apply(1));

Function<Integer, Integer> multiply2 = (i) -> i * 2;
Function<Integer, Integer> multiply2AndAdd10 = plus5.compose(multiply2); // multiply2 실행 후 add10
System.out.println(multiply2AndAdd10.apply(2));

Function<Integer, Integer> add10AndThenMultiply = plus5.andThen(multiply2); // add10 실행 후 multiply2
System.out.println(add10AndThenMultiply.apply(2));

BiFunction<T, U, R>

  • 두개의 값(T, U)를 받아서 R타입을 리넡하는 함수 인터페이스
    • R apply(T t, U r)
BiFunction<Integer, Integer, Integer> multiply = (i, j) -> i * j;
System.out.println(multiply.apply(3, 4));

Consumer<T>

  • T 타입을 받아서 아무값도 리턴하지 않는 함수 인터페이스
    • void Accept(T t)
  • 함수 조합용 메소드 
    • andThen
Consumer<Integer> printT = i -> System.out.println(i);
printT.accept(10);

Consumer<Integer> printT = System.out::println;	// 줄여서 사용 가능

Supplier<T>

  • T 타입의 값을 제공하는 함수 인터페이스
    • T get()
Supplier<Integer> get10 = () -> 10;
System.out.println(get10);

Predicate<T>

  • T 타입을 받아서 boolean을 리턴하는 함수 인터페이스
    • boolean test(T t)
  • 함수 조합용 메소드
    • And
    • Or
    • Negate
Predicate<String> startWithKang = (s) -> s.startsWith("Kang");
Predicate<Integer> isEven = (i) -> i % 2 == 0;

UnaryOperator<T>

  • Function<T, R>의 특수한 형태로 입력값 하나를 받아서 동일한 타입을 리턴하는 함수 인터페이스
 UnaryOperator<Integer> plus4 = (i) -> i + 4;
 UnaryOperator<Integer> multiply4 = (i) -> i * 4;

BinaryOperator<T>

  • BiFunction<T, U, R>의 특수한 형태로, 동일한 타입의 입력값 두개를 받아 리턴하는 함수 인터페이스
BinaryOperator<Integer> plus = (i, j) -> i + j;
System.out.println(plus.apply(10, 20));

람다 표현식

람다

  • (인자 리스트) -> {바디}

인자 리스트

  • 인자가 없을 때 : ()
  • 인자가 한개일 때 : (one) 또는 one
  • 인자가 여러개 일 때 : (one, two)
  • 인자의 타입은 생략 가능, 컴파일러가 추론 하지만 명시할 수도 있다. (Integer one, Integer two)
UnaryOperator<Integer> plus4 = (i) -> i + 4;
UnaryOperator<Integer> plus4 = (Integer i) -> i + 4;

바디

  • 화살표 오른쪽에 함수 본문을 정의한다
  • 여러 줄인 경우에 {}를 사용해서 묶는다
  • 한줄인 경우 생략 가능, return도 생략 가능
UnaryOperator<Integer> plus4 = (i) -> i + 4;
UnaryOperator<Integer> plus4 = (i) -> {
	return i + 4;
};

변수 캡쳐 (Variable Capture)

  • 로컬 변수 캡처
    • final이거나 effective final인 경우에만 참조할 수 있다.
    • 그렇지 않을 경우 concurrency 문제가 생길 수 있어서 컴파일러가 방지한다.
  • effective final
    • 사실상 'final'인 변수
    • final 키워드를 사용하지 않은 변수를 익명 클래스 구현체 또는 람다에서 참조할 수 있다.
  • 익명 클래스 구현체와 달리 쉐도잉 하지 않는다
    • 익명 클래스는 새로 스콥을 만들지만, 람다는 람다를 감싸고 있는 스콥과 같다.
public class Foo3 {
    public static void main(String[] args) {
        Foo3 foo = new Foo3();
        foo.run();
    }

    private void run() {
        int baseNumber = 10;

        // 로컬 클래스
        class LocalClass {
            void printBaseNumber() {
                System.out.println(baseNumber);
            }
        }

        // 익명 클래스
        Consumer<Integer> integerConsumer = new Consumer<Integer>() {
            @Override
            public void accept(Integer integer) {
                System.out.println(baseNumber);
            }
        };
		
        // 람다
        IntConsumer printInt = (i) -> {
            System.out.println(i + baseNumber);
        };
        printInt.accept(10);
    }
}

공통점

위 코드에서 baseNumber는 effective final인 상태이다.  final 키워드가 없지만 코드에서 값 변화가 없기 때문이다.  그렇기 때문에 로컬 클래스, 익명클래스, 람다에서 모두 참조가 가능하다.

IntConsumer printInt = (i) -> {
	baseNumber++; // 에러
	System.out.println(i + baseNumber);	
};

하지만 위처럼 baseNumber를 조작하게되면 더이상 effective final이지 않게 되므로 에러가 발생한다

차이점

public class Foo3 {
    public static void main(String[] args) {
        Foo3 foo = new Foo3();
        foo.run();
    }

    private void run() {
        int baseNumber = 10;

        // 로컬 클래스
        class LocalClass {
            void printBaseNumber() {
                int baseNumber = 11;
                System.out.println(baseNumber); // 11 -> 섀도잉
            }
        }

        // 익명 클래스
        Consumer<Integer> integerConsumer = new Consumer<Integer>() {
            @Override
            public void accept(Integer integer) {
                int baseNumber = 11;
                System.out.println(baseNumber); // 11 -> 섀도잉
            }
        };

        IntConsumer printInt = (i) -> {
            int baseNumber = 11; // 에러
            System.out.println(i + baseNumber);
        };
       
        printInt.accept(10);
        baseNumber++; // effective final이 아니게 되어서 람다에서 참조가 불가능
    }
}

로컬 클래스와 익명 클래스는 그 자체가 하나의 scope이다.  하지만 람다는 별도의 scope가 아니다.  따라서 동일한 이름을 갖는 변수를 선언 할 수 없다.  또한 람다는 외부 변수가 effective final이 아닌 경우 참조가 불가능 하다.


메소드 레퍼런스

람다가 하는일이 기존 메소드 또는 생성자를 호출하는 거라면, 메소드 레퍼런스를 사용해서 매우 간결하게 표현할 수 있다.

스태틱 메소드 참조 타입::스태틱 메소드
특정 객체의 인스턴스 메소드 참조 객체 레퍼런스::인스턴스 메소드
임의 객체의 인스턴스 메소드 참조 타입::인스턴스 메소드
생성자 참조 타입::new
  • 메소드 또는 생성자의 매개변수로 람다의 입력값을 받는다.
  • 리턴값 또는 생성한 객체는 람다의 리턴값이다.
public class Greeting {
    private String name;

    public Greeting() {
    }

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

    public String getName() {
        return name;
    }

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

    public static String hi(String name) {
        return "hi " + name;
    }
}
public class Foo4 {
    public static void main(String[] args) {
        Greeting greeting = new Greeting();

        UnaryOperator<String> hi = (s) -> "hi " + s;
        UnaryOperator<String> hi2 = Greeting::hi; // Greeting.hi 메소드 생성
        UnaryOperator<String> hello = greeting::hello; // Greeting.hello 메소드 생성

        Supplier<Greeting> newGreeting = Greeting::new; // 매개변수가 없는 생성자를 사용
        Greeting kang1 = newGreeting.get();
        System.out.println(kang1.getName()); // null

        Function<String, Greeting> kangGreeting = Greeting::new; // 매개변수가 있는 생성자를 사용
        Greeting kang2 = kangGreeting.apply("Kang");
        System.out.println(kang2.getName()); // Kang

        String[] names = {"Kang", "Min", "Hyeong"};
        Arrays.sort(names, new Comparator<String>() {
            @Override
            public int compare(String o1, String o2) {
                return o1.compareToIgnoreCase(o2);
            }
        });
        Arrays.sort(names, (o1, o2) -> o1.compareToIgnoreCase(o2));
        Arrays.sort(names, String::compareToIgnoreCase); // 임의의 인스턴스 메소드 참조
        System.out.println(Arrays.toString(names));
    }
}
  • UnaryOperator<String> hi2 = Greeting::hi; 의 경우 별다른 객체 생성 없이 static으로 메소드를 생성하고 있다.  따라서 hi 함수는 static으로 선언되어 있어야 한다.
  • UnaryOperator<String> hello = greeting::hello; 는 이미 선언된 객체의 hello 메소드를 생성한다.
  • Supplier<Greeting> newGreeting = Greeting::new; 는 매개변수가 없는 생성자를 사용한다.  Supplier가 입력값을 받지 않는 함수형 인터페이스이기 때문이다.
  • Function<String, Greeting> kangGreeting = Greeting::new; 는 매개변수가 있는 생성자를 사용한다.  Function이 입력값을 받아서 리턴하는 함수형 인터페이스이기 때문이다.
  • 위 코드의 sort는 모두 같은 로직을 수행한다.  첫번째는 Comparator라는 함수형 인터페이스를 사용해서 comapre 함수를 로버라이딩하여 작성한 케이스이고, 두번째는 이를 람다로 표현한 형태이다.  마지막은 String에서 제공하는 메소드 레퍼런스를 사용한 형태이다.

참고자료

 

더 자바, Java 8 - 인프런 | 강의

자바 8에 추가된 기능들은 자바가 제공하는 API는 물론이고 스프링 같은 제 3의 라이브러리 및 프레임워크에서도 널리 사용되고 있습니다. 이 시대의 자바 개발자라면 반드시 알아야 합니다. 이

www.inflearn.com

 

댓글