본문 바로가기
Java/Java-basic

[Java Study 15주차] 람다식

by 어썸오184 2021. 2. 28.
728x90
반응형

목표


자바의 람다식에 대해 학습하세요.

학습할 것 (필수)


  • 람다식 사용법
  • 함수형 인터페이스
  • Variable Capture
  • 메소드, 생성자 레퍼런스

람다식 사용법


람다식이란?

람다식(Lambda Expression)은 간단히 말하면 메서드를 하나의 식(expression)으로 표현한 것이다. 식에 대한 개념은 아래 포스팅에 정리해뒀다.

wisdom-and-record.tistory.com/65

 

[Java] Statement와 Expression

람다식을 공부하다가 식(expression)과 문(statement)에 대한 이해가 부족한 것 같아서 포스팅으로 정리합니다. 아래의 글을 많이 참조했습니다. 오류 정정 및 피드백 댓글로 주시면 감사하겠습니다. w

wisdom-and-record.tistory.com

 

예를 들어 아래와 같이 두 개의 정수를 받아서 큰 값을 반환하는 메서드가 있다면

int max(int a, int b) {
    return a > b ? a : b;
}

이를 아래와 같이 식(expression)으로 표현하는 것이다.

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

 

메서드를 람다식으로 바꾸는 규칙은 다음과 같다.

1. 반환 타입과 메서드 이름을 지운다.

2. 매개변수 선언부와 몸통 사이를 화살표( ->)로 연결한다.

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

 

여기서 몇 가지 조건을 충족하면 식을 더욱 간단하게 만들 수 있다.

우선, return문 대신 식으로 대신할 수 있다. 문장이 아닌 식이므로 끝에 세미콜론이 붙지 않는다.

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

 

선언된 매개변수의 타입이 추론 가능한 경우 타입을 생략할 수 있는데, 대부분 생략이 가능하다.

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

이때 매개변수 하나만 타입을 생략하는건 안된다. 즉, (int a, b) -> a > b ? a : b 이런 식으로 쓰는건 안된다. 

 

만약 매개변수가 하나라면 괄호를 생략할 수 있다. 인자 하나를 받아서 그 제곱 값을 돌려주는 메서드라면 다음과 같은 식으로 바꿀 수 있는 것이다.

a -> a * a

이때 매개변수의 타입을 생략하지 않았다면 괄호도 생략할 수 없다.

 

괄호 안의 문장이 하나일 때는 괄호를 생략할 수 있다. 주의할 점은 이 때 문장의 끝에 세미콜론을 붙이지 않아야 한다.

(String name, int i) -> {
    System.out.println(name + "=" + i);
}

↓↓↓↓↓↓↓↓↓↓↓↓

(name, i) -> System.out.println(name + "=" + i)

 

함수형 인터페이스


한 가지 궁금증이 생긴다. 자바에서 모든 메서드는 클래스 내에 포함되어야 한다. 그럼 람다식은 어떻게 동작하는 것일까?

위에서는 마치 메서드를 람다식으로 치환하는 것처럼 설명했지만, 사실 람다식은 런타임에 익명 객체로서 동작한다.

예를 들어 아래와 같이 추상메서드 하나가 정의된 인터페이스가 있다고 생각해보자.

interface MyFunction {
    int myMethod(int a, int b);
}

 

그럼 이 인터페이스를 구현한 익명 객체는 다음과 같이 생성할 수 있다.

{
    MyFunction f = new MyFunction() {
        public int myMethod(int a, int b) {
            return a + b;
        }
    };

    int sum = f.myMethod(3, 5);
}

 

위 코드의 익명 객체는 다음과 같이 람다식으로 바꿀 수 있다.

{
    MyFunction f = (a, b) -> a + b;

    int sum = f.myMethod(3, 5);
}

 

이처럼 MyFunction 인터페이스를 구현한 익명 객체를 람다식으로 대체 가능한 이유는, 람다식도 실제로는 익명 객체고, MyFunction 인터페이스를 구현한 익명 객체의 메서드와 람다식의 매개변수의 타입과 개수 그리고 반환값이 일치하기 때문이다.

하나의 메서드가 선언된 인터페이스를 정의해서 람다식을 다루는 것은 기존의 자바의 규칙을 어기지 않으면서도 자연스럽다. 그래서 인터페이스를 통해 람다식을 다루기로 결정되었으며, 람다식을 다루기 위한 인터페이스를 '함수형 인터페이스(functional interface)'라고 부르기로 했다.

함수형 인터체이스는 오직 하나의 추상 메서드만 정의되어 있어야 한다는 제약이 있다. 그래야 람다식과 인터페이스의 메서드가 1 대 1로 연결될 수 있기 때문이다. static 메서드와 default 메서드에는 제약이 없다.

함수형 인터페이스를 선언할 때는 @FunctionalInterface 애노테이션을 붙이는 것이 베스트 프렉티스이다. 이 애노테이션을 붙여주면 컴파일러가 함수형 인터페이스의 제약을 지켰는지 확인해준다.

 

함수형 인터페이스 타입의 매개변수와 반환타입

아래와 같이 함수형 인터페이스가 정의되어 있고, 해당 타입의 참조변수를 매개변수로 받는 메서드가 정의되어 있을 때,

public class LambdaDemo {

    interface MyFunction {
        void myMethod();
    }

    static void aMethod(MyFunction f) {
        f.myMethod();
    }
}

 

다음과 같이 람다식을 매개변수로 넘길 수 있다.

public static void main(String[] args) {
    aMethod(() -> System.out.println("Hi"));
}

 

또 반환타입이 함수형 인터페이스인 경우에도 람다식을 반환할 수 있다.

package lambda;

public class LambdaDemo {

    interface MyFunction {
        void myMethod();
    }

    static MyFunction aMethod() {
        return () -> {};
    }

    public static void main(String[] args) {
        MyFunction myFunction = aMethod();
        System.out.println(myFunction);   //lambda.LambdaDemo$$Lambda$14/0x0000000800b8d448@3a71f4dd

    }
}

 

이렇게 람다식을 이용하면 마치 메서드를 변수처럼 주고 받는 것이 가능하다. 정확히 말하면 메서드가 아니라 익명 객체를 전달하는 것이지만, 익명 객체를 사용하는 것보다 훨씬 간결한 형태로 사용 가능하다.

 

람다식의 타입과 형변환

람다식은 익명 객체다. 익명 객체는 타입이 없다(정확히 말하면 컴파일러가 임의로 타입을 지정한다). 함수형 인터페이스로 람다식을 참조할 수 있는 것일 뿐, 람다식의 타입이 함수형 인터페이스의 타입과 일치하는 것은 아니다. 그래서 대입 연산자의 양변의 타입을 일치시키려면 아래와 같이 형변환을 해줘야한다.

public class LambdaDemo {

    @FunctionalInterface
    interface MyFunction {
        void myMethod();
    }

    public static void main(String[] args) {
        MyFunction f = (MyFunction) () -> {};
    }
}

 

그리고 람다식은 Object 타입으로 형변환 할 수 없다. 람다식은 오로지 함수형 인터페이스 타입으로만 형변환이 가능하다. 굳이 Object 타입으로 형변환하려면 먼저 함수형 인터페이스 타입으로 형변환 해야한다.

public class LambdaDemo {

    @FunctionalInterface
    interface MyFunction {
        void myMethod();
    }

    public static void main(String[] args) {
        MyFunction f = () -> {};
        Object obj = (Object) (MyFunction) (() -> {});
        String str = ((Object)((MyFunction)(() -> {}))).toString();

        System.out.println(f);
        System.out.println(obj);
        System.out.println(str);
    }
}

실행 결과

LambdaDemo$$Lambda$1/0x0000000800b8d448@5674cd4d LambdaDemo$$Lambda$2/0x0000000800b8d840@63961c42 LambdaDemo$$Lambda$3/0x0000000800b8da60@85ede7b

실행 결과를 보면 컴파일러가 람다식의 타입을 어떤 형식으로 만들어내는지 알 수 있다.

일반적인 익명 객체라면 "외부클래스이름$번호"와 같은 형식으로 타입이 결정되었을텐데, 람다식 타입은 "외부클래스이름&&Lambda&&번호"와 같은 형식으로 되어있는 것을 알 수 있다.

 

Variable Capture


반복해서 언급하지만 람다식은 익명 클래스의 인스턴스이므로 람다식 외부에 선언된 변수에 접근하는 규칙은 익명 클래스의 그것과 같다.

람다식의 실행 블록 내에서 람다식을 감싸고 있는 클래스의 인스턴스 변수, 스태틱 변수, 지역 변수에 접근하는 것이 가능하다. 하지만 지역 변수에 접근할 때는 Variable Capture라는 특별한 작업이 수행되기 때문에 한 가지 제약이 생긴다. 우선 아래 예시를 보자.

public class CaseOne {

    static int staticVariable = 10;

    int instanceVariable;

    @FunctionalInterface
    interface MyFunction {

        void myMethod();
    }

    public static void main(String[] args) {

        CaseOne caseOne = new CaseOne();
        caseOne.instanceVariable = 20;

        int localVariable = 30;

        MyFunction myFunction = () -> {
            System.out.println("static variable = " + CaseOne.staticVariable);
            System.out.println("instance variable = " + caseOne.instanceVariable);
            System.out.println("local variable = " + localVariable);
        };

        myFunction.myMethod();
    }

}

실행 결과

static variable = 10
instance variable = 20
local variable = 30

한 클래스 내에서 각각 스태틱 변수와 인스턴스 변수, 지역 변수를 선언하고 이들을 출력하는 람다식을 정의했다. 문제 없이 잘 접근한다.

여기서 각각의 값을 변경한다면 어떻게 될까?

public class CaseOne {

    static int staticVariable = 10;

    int instanceVariable;

    @FunctionalInterface
    interface MyFunction {

        void myMethod();
    }

    public static void main(String[] args) {

        CaseOne caseOne = new CaseOne();
        caseOne.instanceVariable = 20;

        int localVariable = 30;

        // 값 변경
        CaseOne.staticVariable += 5;
        caseOne.instanceVariable += 5;
        localVariable += 5;

        MyFunction myFunction = () -> {
            System.out.println("static variable = " + CaseOne.staticVariable);
            System.out.println("instance variable = " + caseOne.instanceVariable);
            System.out.println("local variable = " + localVariable);    // 컴파일 오류 발생!
        };

        myFunction.myMethod();
    }
}
java: local variables referenced from a lambda expression must be final or effectively final

스태틱 변수나 인스턴스 변수를 변경하는 것은 아무 문제가 없다. 그런데 지역변수를 변경하는 순간 오류가 발생한다.

오류가 발생하는 이유는 클래스 내부에 선언된 로컬 클래스(여기선 람다식)가 지역 변수를 참조할 때는 그 값을 복사해서 사용하기 때문이다. 이를 Variable Capture라고 한다. 쉽게 말하면 Variable Capture란 객체 외부에서 선언된 변수를 객체 내부로 복사하는 행위이다. 지역 변수 뿐만 아니라 파라미터로 전달된 변수 또한 외부에서 선언된 변수이므로 같은 규칙이 적용된다.

void setParam(int i) {
    // 값 변경 
    i = 10;
        
    MyFunction myFunction = () -> {
        System.out.println("parameter = " + i); // 컴파일 오류 발생. 값을 변경하면 안됨
    };
}

 

외부에서 선언된 변수를 직접 사용하지 않고 내부로 복사해오는 이유는, 각 변수와 객체의 생명 주기와 관련이 있다. (변수의 생명 주기에 대해 모르시는 분들은 아래 포스팅 참조)

wisdom-and-record.tistory.com/35?category=907462

 

[Java] 클래스 멤버는 왜 인스턴스 멤버를 참조할 수 없을까?

클래스 멤버와 인스턴스 멤버 간 참조 관계 클래스 멤버(스태틱 변수, 스태틱 메서드)는 인스턴스 멤버를 사용할 수 없습니다. 아래 예시를 보시죠 static 예약어가 사용된 클래스 멤버에서 인스

wisdom-and-record.tistory.com

 

스태틱 변수는 메서드 영역에, 인스턴스 변수는 힙 영역에, 지역 변수는 호출스택에 각각 생성된다. 람다식 또한 익명 객체의 인스턴스이기 때문에 힙 영역에 생성된다.

지역 변수는 해당 변수를 선언한 메서드가 종료되는 순간 메모리에서 사라진다. 하지만 로컬 클래스는 아얘 다른 위치에서 생성되기 때문에 생명주기가 메서드와 전혀 상관이 없다. 따라서 스태틱 변수, 인스턴스 변수와 달리 로컬 클래스가 지역 변수를 사용하려고 할때는 해당 지역 변수가 이미 호출스택에서 사라졌을 위험이 항상 존재한다.(매개변수도 마찬가지다) 

그럼 로컬 클래스는 절대로 외부에서 선언된 변수를 참조할 수 없도록 제약해야 할까? 그런 제약을 걸지 않으면서도 위에서 언급한 위험성을 제거하기 위해서 자바에서는 객체 외부에 선언된 변수의 값을 복사(Variable Capture)하면서 동시에 해당 변수는 반드시 final이어야 한다는 새로운 제약을 만들었다.

로컬 클래스가 외부에서 선언된 지역 변수 혹은 매개 변수를 사용할 때, 해당 변수는 반드시 final이거나 유사 final이어야 한다. 이게 핵심이다.

유사 final (effectively final)

유사 final이란 final로 선언되어 있지는 않지만 값을 한번만 할당한 변수를 말한다. 원래 Java 7에서는 로컬 클래스가 사용하는 지역 변수가 final로 선언되어 있지 않으면 컴파일 오류가 발생했다. 하지만 Java 8부터 유사 final이라는 개념을 도입해서, 프로그래머가 직접 final 키워드를 붙이지 않아도 컴파일러가 해당 변수가 변경되지 않았다고 판단하면 컴파일 오류가 발생하지 않는다. 위에서 언급한 첫 번째 예시에서 localVariable에 final을 붙이지 않아도 잘 작동했던 것이 이 이유 때문이다.

 

java.util.function 패키지


대부분의 메서드는 타입이 비슷하다. 매개변수가 없거나, 하나 또는 두개, 반환 값이 없거나 있거나. 게다가 제네릭 메서드로 정의하면 매개변수나 반환 타입이 달라도 문제가 되지 않는다. 그래서 java.util.function 패키지에는 일반적으로 자주 쓰이는 형식의 메서드를 함수형 인터페이스로 미리 정의해놨다. 가능하면 이것들을 사용하는게 유지 보수 측면에서 좋다. 자주 쓰이는 함수형 인터페이스는 다음과 같다.

함수형 인터페이스 메서드 설명
java.lang.Runnable void run() 매개변수 X, 반환값 X
Supplier<T> T get() 매개변수 X, 반환값 O
Consumer<T> void accept(T t) 매개변수 O, 반환값 X
Function<T, R> R apply(T t) 매개변수 O, 반환값 O
Predicate<T> boolean test(T t) 조건식을 표현하는데 사용됨. 매개변수는 하나, 반환 타입은 boolean

 

public class LambdaDemo {

    public static void main(String[] args) {
        String string = "";

        Predicate<String> isEmptyStr = (s) -> s.length() == 0;

        if (isEmptyStr.test(string)) {
            System.out.println("It is an empty string");
        }
    }
}

 

 

매개변수가 있는 함수형 인터페이스를 사용할 때 만약 매개변수를 두 개 사용하고 싶다면 앞에 'Bi'를 붙이면 된다.

public class LambdaDemo {

    public static void main(String[] args) {
        int a = 1;
        int b = 1;

        BiPredicate<Integer, Integer> isSame = (i, j) -> i.equals(j);

        if (isSame.test(a, b)) {
            System.out.println("Both are the same");
        }
    }
}

두 개 이상의 매개변수를 갖는 함수형 인터페이스가 필요하다면 직접 만들어 써야한다.

 

UnaryOperator, BinaryOperator

Function 인터페이스의 변형으로 UnaryOperator와 BinaryOperator가 있는데, 매개 변수의 타입과 반환 타입의 타입이 모두 일치한다는 점만 빼고는 Function, BiFunction과 같다.

UnaryOperator<T> T apply(T t)
BinaryOperator<T> T apply(T t1, T t2)

 

컬렉션 프레임웍과 함수형 인터페이스

컬렉션 프레임웍의 인터페이스에 추가된 디폴트 메서드 중 함수형 인터페이스를 사용하는 메서드는 다음과 같다.

인터페이스 메서드 설명
Collection boolean removeIf(Predicate<E> filter) 조건에 맞는 요소 삭제
List void replaceAll(UnaryOperator<E> operator) 모든 요소를 변환하여 대체
Iterable void forEach(Consumer<T> action) 모든 요소에 작업 action을 수행
Map V compute(K key, BiFunction<K, V, V> f) 지정된 키의 값에 작업 f를 수행
V computeIfAbsent(K key, Function<K, V> f) 키가 없으면, 작업 f 수행 후 추가
V computeIfPresent(K key, BiFunction<K, V, V> f) 지정된 키가 있을 때, 작업 f tngod
V merge(K key, V value, BiFunction<V, V, V> f) 모든 요소에 병합작업 f를 수행
void forEach(BiConsumer<K,V> action) 모든 요소에 작업 action을 수행
void replaceAll (BiFunction<K, V, V> f) 모든 요소에 치환작업 f를 수행

 

예시

public class LambdaDemo {

    public static void main(String[] args) {
        ArrayList<Integer> numArr = new ArrayList<>();

        for (int i = 0; i < 10; i++) {
            numArr.add(i + 1);
        }

        ArrayList<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));

        // 모든 요소 출력
        numArr.forEach(i -> System.out.print(i + ", "));
        System.out.println();

        // 2 또는 3의 배수 제거
        numArr.removeIf(i -> i % 2 == 0 || i % 3 == 0);

        // 각 요소에 10을 곱한다.
        numArr.replaceAll(i -> i * 10);

        System.out.println(numArr);

        Map<String, Integer> map = new HashMap<>();
        map.put("a", 0);
        map.put("b", 0);
        map.put("c", 0);
        map.put("d", 0);
        map.put("e", 0);

        // 값을 키의 유니코드로 변환
        map.replaceAll((k, v) -> (int) k.charAt(0));

        System.out.println(map);
    }

}

출력 결과

1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
[10, 50, 70]
{a=97, b=98, c=99, d=100, e=101}

 

Function의 합성과 Predicate의 결합

java.util.function 패키지에는 추상 메서드 말고도 여러 디폴트 메서드와 스태틱 메서드가 정의되어 있다. Function과 Predicate만 알아볼 것인데, 이것들만 잘 알면 다른 함수형 인터페이스에도 적용할 수 있다.

Function

default <V> Function<T,V> andThen (Function<? super R, ? extends V> after)

andThen() 메서드는 다른 function 인터페이스를 인자로 받는다. 두 개의 함수를 연속으로 실행한다. 예를 들어 함수 f, g 가 있을 때, f.andThen(g)를 실행하면 f를 먼저 실행한 후 g를 실행한다.

예를 들어 문자를 받아서 16진수로 변환 후 이를 다시 이진수 문자열로 바꾸려면 다음과 같이 작성한다.

public class LambdaDemo {

    public static void main(String[] args) {
        // 16진수로 변환
        Function<String, Integer> toHex = s -> Integer.parseInt(s, 16);

        // 2진 문자열로 변환
        Function<Integer, String> toBinary = h -> Integer.toBinaryString(h);

        // 합성
        Function<String, String> HexStringToBinary = toHex.andThen(toBinary);

        String FF = HexStringToBinary.apply("FF");
        System.out.println(FF); // "FF" -> 255 -> "11111111"
    }

}

 

default <V> Function<V,R> compose(Function<? super V, ? extends T> before)

compose()는 반대로 f.compose(g) 라면 g를 먼저 실행한 후 f를 실행한다.

 

static <T> Function<T,T> identity()

identity() 메서드는 항등 함수가 필요할 때 사용한다. 아래 두 문장은 동등하다.

Function<String, String> f = x -> x;
Function<String, String> f = Function.identity();

스트림의 map()으로 변환작업할 때, 변환없이 그대로 처리하고자 할 때 사용한다.

 

Predicate

여러 조건식을 논리 연산자인 &&, ||, ! 로 연결해서 하나의 식을 구성할 수 있는 것처럼, 여러 Predicate을 and(), or(), negate()로 연결해서 하나의 새로운 Predicate으로 결합할 수 있다.

public class LambdaDemo {

    public static void main(String[] args) {

        Predicate<Integer> p = i -> i < 100;
        Predicate<Integer> q = i -> i < 200;
        Predicate<Integer> notP = p.negate();    // i >= 100

        // 100 <= i < 200
        Predicate<Integer> range100To199 = notP.and(q);

        System.out.println(range100To199.test(99));    //false
        System.out.println(range100To199.test(100));   //true
        System.out.println(range100To199.test(199));   //true
        System.out.println(range100To199.test(200));   //false
    }
}

 

 

메서드, 생성자 레퍼런스


지금까지 람다식으로 메서드를 간결하게 표현하는 방법을 배웠는데, 람다식을 한번 더 간결하게 표현하는 방법이 있다. 이를 메서드 레퍼런스라 부르는데 사용법은 다음과 같다.

종류 람다 메서드 레퍼런스
Static method reference (x) -> ClassName.method(x) ClassName::method
Instance method reference  (obj, x) -> obj.method(x) ClassName::method
특정 객체의 인스턴스 method reference (x) -> obj.method(x) obj::method
생성자 () -> new MyClass() MyClass::new 

항상 쓸 수 있는 것은 아니고, 람다식이 하나의 메서드만 호출하는 경우 사용 가능하다.

예를 들어, 아래의 람다식은

Function<String, Integer> f = (String s) -> Integer.parseInt(s);

아래처럼 메서드 레퍼런스로 사용 가능하다.

Function<String, Integer> f = Integer::parseInt;

 

인스턴스 메서드는 다음과 같이 사용한다.

BiFunction<String, String, Boolean> f = (s1, s2) -> s1.equals(s2);  // 람다식

BiFunction<String, String, Boolean> f = String::equals;  // 메서드 레퍼런스

참조 변수 f의 타입만 봐도 매개변수를 추론할 수 있기 때문에 생략할 수 있는 것이다.

 

이미 생성된 객체의 메서드를 람다식에서 사용하는 경우는 클래스 이름 대신 객체의 참조변수를 적어준다.

MyClass obj = new MyClass();

Function<String, Boolean> f = (x) -> obj.equals(x);  // 람다식

Function<String, Boolean> f = obj::equals;  // 메서드 레퍼런스

 

생성자의 경우, 매개변수가 있으면 매개변수의 개수에 맞는 함수형 인터페이스를 사용한다.

// 매개변수가 없는 생성자
Supplier<MyClass> s = () -> new MyClass;  // 람다식
Supplier<MyClass> s = MyClass::new;  // 메서드 레퍼런스


// 매개변수가 하나인 생성자
Function<Integer, MyClass> f = (i) -> new MyClass(i);  // 람다식
Function<Integer, MyClass> f = MyClass::new;   // 메서드 레퍼런스


// 매개변수가 둘인 생성자
BiFunction<Integer, String, MyClass> bf = (i, s) -> new MyClass(i, s);  // 람다식
BiFunction<Integer, String, MyClass> bf = MyClass::new   // 메서드 레퍼런스


//배열
Function<Integer, int[]> f = x -> new int[x];  // 람다식
Function<Integer, int[]> f = int[]::new. // 메서드 레퍼런스

 

 

참고자료

자바의 정석(남궁성 저)

 

728x90
반응형

댓글