모르는게 많은 개발자

[Java] 함수형 인터페이스, 람다 표현식 개념/예제 본문

자바

[Java] 함수형 인터페이스, 람다 표현식 개념/예제

Awdsd 2020. 11. 27. 03:06
반응형

최근 자바8을 제대로 모르는 것 같아 공부를 하면서 함수형 인터페이스와 람다 표현식을 정리하기 위해 이 글을 쓴다.


 1. 함수형 인터페이스

람다식을 알기전에 함수형 인터페이스가 무엇인지 알아야할 것 같다.

함수형 인터페이스란 한개의 추상 메소드가 선언된 인터페이스를 의미한다.

@FunctionalInterface
public interface RunSomething {
    void doIt();
}

위의 코드를 보면 @FunctionalInterface Annotation이 선언되어 있는데 이것은 해당 인터페이스가 함수형 인터페이스라는 것을 알려준다.

만약, @FunctionalInterface가 선언된 인터페이스에 추상 메소드가 1개가 아닐 경우 컴파일 에러가 발생한다.

@FunctionalInterface      //컴파일 에러 발생
public interface RunSomething {
    void doIt();
    void doIt2();
}

자바8에서는 인터페이스에 변경점이 있다. 바로 default method와  static method이다.

default method(기본 함수)

자바8 이전 인터페이스에는 구현체 메소드를 넣을 수 없었다. 이로 인해 인터페이스를 상속하는 클랙스는 필요하지 않은 메소드라도 모두 구현해야하는 문제가 있었다. 이것을 해결하기 위해 자바8부터 인터페이스에 메소드 구현체를 넣을 수 있게 되었다.

반환형 앞에 default를 넣어 사용할 수 있다.

아래 코드를 보면 기본 함수로 구현된 defaultMethod()를 재구현하지 않고 사용할 수 있다. 물론 Foo를 상속받은 클래스에서 override도 가능하다.

public interface Foo{
    void printName();
    default void defaultMethod() {
        System.out.println("hello world");
    }
}

public class DefaultFoo implements Foo {
    public DefaultFoo(){}
    @Override
    public void printName() {
        System.out.println("foo");
    }
}
//main
public static void main(String[] args) {
    Foo foo = new Foo();
    foo.defaultMethod(); //hello world
}
static method

인터페이스에 static method를 넣어 클래스의 static method처럼 인스턴스없이 사용 가능하다

public interface Foo{
    void printName();
    default void printNameUpperCase() {
        System.out.println("awd");
    }
    static void staticMethod() {
        System.out.println("static");
    }
}

public static void main(String[] args) {
    Foo.staticMethod();	//static
}

여기서 중요한 것은 함수형 인터페이스이다. 위에서 함수형 인터페이스는 한개의 추상 메소드가 정의된 인터페이스라 말했다.

즉, 함수형 인터페이스에 default, static method가 구현되어있어도 함수형 인터페이스라 할 수 있다.


2. 람다 표현식

자 그럼 함수형 인터페이스는 어디에 사용되는 것일까? 그것은 자바8에 추가된 람다 표현식과 관련이 있다. 람다 표현식이 무엇인지부터 살펴보자.

먼저 람다식을 알기전에 함수형 프로그래밍에 대해 먼저 알아야한다.

함수형 프로그래밍은 함수의 입력만을 의존하여 출력을 만드는 구조로 외부에 상태를 변경하는 것을 지양하는 패러다임으로 부작용(Side-effect) 발생을 최소화 하는 방법론이라 할 수 있다. 함수형 프로그래밍은 다음 조건을 만족해야한다.

  • 순수한 함수 : 외부의 상태를 변경시키지 않는 함수
  • 익명 함수 : 이름이 없는 함수를 정의(람다식을 통해 정의)
  • 고계 함수 : 함수를 하나의 값으로 취급하여 인자로 전달하거나 변수에 저장할 수 있는 함수(일급 객체)

람다식은 익명 함수(anonymous function)을 생성하기 위한 식이다.

위에 보면 일급 객체라는 단어가 있는데, 일급 객체는 다음 조건을 만족하는 것을 말한다.

  • 파라미터로 전달 가능
  • 리턴값으로 사용 가능
  • 변수나 데이터 구조 안에 담는 것이 가능

즉, 정리하면 함수가 일급 객체라는 것은 메소드 자체를 파라미터로 보내거나 리턴값으로 사용하거나 변수에 함수를 할당하는 것이 가능하다는 뜻이다.

자바8 이전까지는 메소드를 일급 객체로 사용할 수 없었다. 하지만 자바8이 나오면서 메소드를 일급 객체로 사용할 수 있게 되었고, 이로 인해 함수형 프로그래밍이 가능해졌고, 람다식을 통해 함수형을 표현할 수 있게 된 것이다. 

람다식 구조

람다식은 기본적으로 (매개 변수) → {구현}로 표현된다. 아래 코드의 thread1, thread2는 동일한 코드이다.

기존의 Override를 통해 익명클래스를 정의하는 대신 람다식을 이용해 간결하게 익명함수를 구현한 것이다.

그러면 익명클래스에서는 run()을 명시하고 구현했는데 어떻게 람다식을 통해 run() 메소드를 추론할 수 있었을까?

public static void main(String[] args) {
    Thread thread1 = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("test1");
        }
    });
    Thread thread2 = new Thread(() - > {
        System.out.println("test2");
    });
    thread1.start(); //test1
    thread2.start(); //test2
}

아래를 보면 Thread의 인자는 Runnable 객체를 받게 되어있고 Runnable은 함수형 인터페이스다.

다시 말해 람다식을 표현하기 위해서는 함수형 인터페이스가 필요하다.

람다식을 표현할 때 run() 메소드를 추론하는 것은 함수형 인터페이스에서 구현해야할 추상 메소드가 한개이기 때문에 추론이 가능하다.

다음 아래는 람다식의 형태를 모아보았다.

//void 형
@FunctionalInterface
public interface B {
    void test(String a, String b);
}

public static void main(String[] args) {
    B sample3 = (c, d) - > System.out.println(c + d);
    sample3.test("sam", "ple3");
}
//return 생략
@FunctionalInterface
public interface A {
    String test();
}

public static void main(String[] args) {
    //return 생략
    A sample1 = () - > "sample1";
    System.out.println(sample1.test());

		//return 생략 안함
    A sample2 = () - > {
        return "sample2";
    };
    System.out.println(sample2.test());

}

정리하면 아래와 같은 표현식들이 있다.

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

  2. () -> {함수몸체)

  3. (매개변수) -> 함수몸체

  4. (매개변수) -> {return 0;}


3. 자바에서 지원하는 함수형 인터페이스

자바8에서는 다양한 함수형 인터페이스를 제공한다.

그 중에서 자주 사용할 법한 인터페이스의 예제를 한번 살펴보자.

Function<T, R>

Function<T, R>은 T타입의 인자를 받아 R타입의 값을 반환하는 함수형 인터페이스이다.

아래처럼 Function Interface를 살펴보면 apply()라는 추상함수 하나만 가진 함수형 인터페이스인것을 확인 할 수 있다.

람다 함수를 이용해 apply 함수를 구현하는 것이다.

public static void main(String[] args) {
    Function < String, Integer > func = (s) - > {
        return s.equals("hello") ? 1 : 2;
    };
    System.out.println(func.apply("hello")); //1
    System.out.println(func.apply("not")); //2

    Function < String, Integer > func2 = (s) - > s.equals("hello") ? 1 : 2;
    System.out.println(func2.apply("hello")); //1
    System.out.println(func2.apply("not")); //2
}
@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;
    }
}

Function 인터페이스에는 andThen(), compose()라는 기본 함수가 존재하는데
compose() : apply를 인자가 compose()에 인자인 함수형 인터페이스의 반환형의 리턴값을 넣는 것이다.

andThen() : apply의 반환값을 andThen()에 인자인 함수형 인터페이스의 인자로 넣은 결과값을 출력한다.

public static void main(String[] args) {
    Function < String, Integer > func2 = (s) - > s.equals("hello") ? 1 : 2;
    
    Function < Integer, Integer > func3 = (i) - > i + 1;
    //compose()의 func2의 인자에 hello를 넣고 나온 결과값을 fun3의 인자로 넣는다.
    System.out.println(func3.compose(func2).apply("hello"));   //결과 : 2
	
    Function < String, String > func4 = (s) - > s;
    //func4의 인자로 hello를 넣고나온 결과값 "hello"를 func2의 인자로 넣는다.
    System.out.println(func4.andThen(func2).apply("hello"));   //결과 : 1
}

 

Consumer<T>

Consumer<T>는 매개값 타입만 입력받아 소모하고 리턴 타입이 없다. 즉, 리턴을 하지 않는 함수형 인터페이스다.

추상 메소드는 accept()이다.

Function 인터페이스에도 있던 andThen같은 경우 같은 consumer를 설정하고 똑같은 인자를 전달한다.

public static void main(String[] args) {
    Consumer < String > consumer1 = s - > System.out.println(s);
    Consumer < String > consumer2 = s - > System.out.println(s);
    consumer1.accept("consumer1");
    consumer1.andThen(consumer2).accept("consumer2");
}

결과
consumer1
consumer2
consumer2

 

Supplier<T>

Consumer는 반환타입이 없었다면 Supplier는 반환타입만 있는 경우다.

public static void main(String[] args) {
    Supplier < String > supplier = () - > "supplier";
    System.out.println(supplier.get());
}

결과
supplier

 

Predicate<T>

Predicate는 인자타입을 받고 boolean타입을 반환하는 함수형 인터페이스이다.

추상 메소드는 test() 기본 메소드는 negate(), and(), or()이 있다.

and()는 다른 Predicate를 인자로 받아 and 연산자로 boolean을 판별한다. or()도 똑같이 or 연산자로 boolean판단.

negate()는 반환 boolean을 반대로 만든다.

public static void main(String[] args) {
    Predicate < String > predicate1 = s - > s.equals("test");
    Predicate < String > predicate2 = s - > s.equals("not");
    //predicate1와 predicate2에 "test"를 인자를 넣어 나온 결과를 or로 처리
    System.out.println(predicate1.or(predicate2).test("test"));
    //and로 처리
    System.out.println(predicate1.and(predicate2).test("test"));
    //출력 boolean의 반대
    System.out.println(predicate1.negate().test("test"));
}

결과
true
false
false

4. 메소드 래퍼런스(함수 참조)

람다 표현식을 더 간단하게 표현하는 방법

이미 우리가 표현하고자 하는 람다식이 구현된 메소드를 참조하는 방법이다.

메소드 래퍼런스는 3가지 형태로 사용될 수 있다.

  • 클래스::정적 메소드
  • 인스턴스::메소드
  • 클래스::new
public class Test {
    private String testStr;
    public Test() {
        this.testStr = "new no has param test";
    }
    public Test(String str) {
        this.testStr = str;
    }
    public static String test(String str) {
        return str;
    }
    public void voidTest(String str) {
        System.out.println(str);
    }
    public String stringTest(String str) {
        return str;
    }
    public String getTestStr() {
        return testStr;
    }
}

public static void main(String[] args) {
    Test test = new Test();

    //static 메소드를 Function에 등록
    //test()는 String인자를 받고 String을 반환한다. -> Function<String, String>같음 ->유추가능
    Function < String, String > function1 = Test::test;  
    System.out.println(function1.apply("static test"));

    //인자가 없는 test객체의 voidTest()를 등록
    Consumer < String > consumer = test::voidTest;
    consumer.accept("void test");

    //인자가 있는 test객체의 stringTest()를 등록
    Function < String, String > function2 = test::stringTest;
    System.out.println(function2.apply("return test"));

    //인자가 한개 있는 생성자 등록
    Function < String, Test > function3 = Test::new;
    System.out.println(function3.apply("new has param test").getTestStr());

    //인자가 없는 기본 생성자 등록
    Supplier < Test > supplier = Test::new;
    System.out.println(supplier.get().getTestStr());
}

위와 같은 형태들이 있는데 이것이 가능한 것은 인자와 리턴타입을 알고 있기 때문에 생략이 가능한 것이다.

 

 

 

 

참조

https://skyoo2003.github.io/post/2016/11/09/java8-lambda-expression

https://isooo.github.io/etc/2019/11/13/일급객체.html

http://hong.adfeel.info/backend/java-람다식익명메소드/

반응형
Comments