함수형 프로그래밍

함수형 프로그래밍은 자료 처리를 수학적 함수의 계산으로 취급하고, 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임의 하나이다. - 위키백과

람다를 이해하기 위해선 먼저, 함수형 프로그래밍에 대한 개념의 이해가 필요하다.

함수형 프로그래밍은 절차지향과 객체지향처럼 하나의 방법론 중 하나로, 다른 말로는 선언형 프로그래밍이라 한다. 이와 대조적으로 객체지향 프로그래밍은 명령형 프로그래밍이다.

명령형? 선언형?

  • 명령형: 클래스에서 메서드를 정의하고, 필요할 때 메서드를 호출
  • 선언형: 데이터가 입력으로 주어지고, 데이터가 처리되는 과정을 정의하는 것으로 동작.

함수형 프로그래밍의 조건

  • 순수 함수
    • 같은 입력 시 같은 출력을 보장한다. 부수 효과가 없다.
    • 멀티 쓰레드에도 안전하다.
  • 고차 함수
    • 일급 함수의 특징을 만족해야 한다.
  • 익명 함수
    • 이름이 없는 함수이다.
    • 람다식을 의미한다.
  • 합성 함수
    • 새로운 함수를 생성하거나 어떤 계산을 수행하기 위해 둘 이상의 함수를 결합할 수 있어야 한다.
    • 메서드 체이닝을 통해 구현된다.

함수형 프로그래밍의 특징

  • 불변성
    • 상태를 변경하지 않는다.
    • 데이터의 변경이 필요한 경우, 원본 데이터 구조를 변경하지 않고 복사본을 만들어 작업해야 한다.
  • 참조 투명성
    • 프로그램의 변경 없이도 어떤 표현식을 값으로 대체할 수 있다
  • 일급 함수
    • 함수를 함수의 매개변수로 넘길 수 있다.
    • 함수를 함수의 반환값으로 돌려줄 수 있다.
    • 함수를 변수나 자료구조에 담을 수 잇다.
  • 게으른 평가
    • 값이 필요한 시점에 평가한다.
    • 지연 연산과 관련있다.

함수형 인터페이스

함수형 인터페이스란, 추상 메서드가 오직 하나인터페이스를 말한다. default method 또는 static method 는 여러개 존재해도 상관없다.

인터페이스 앞에 @FunctionalInterface 애노테이션을 사용하는데, 이는 해당 인터페이스가 함수형 인터페이스 조건에 부합한지 컴파일 단계에서 검사하는 기능을 가지고 있다. 애노테이션을 굳이 붙이지 않아도 동작하고 사용하는데 문제없지만, 검증과 유지보수를 위해 작성해주는 것이 좋다.

Java에서 기본 제공하는 함수형 인터페이스

이름기호번역
PredicateT -> boolean단언하다
ConsumerT -> void소비자
Supplier() -> T제공자
FunctionT -> R함수
Comparator(T, T) -> int비교기
Runnable() -> void실행 가능한
Callable() -> T호출 가능한

대표적인 몇가지만 뽑아보았으며, 더 많은 목록은 Oracle Doc에서 확인할 수 있다.

표현법

public class LambdaTest {
    @FunctionalInterface
    interface MyLambdaFunction {
        String method();
    }

    @Test
    void functionalInterfaceTest() {
        System.out.println(new MyLambdaFunction() {
            @Override
            public String method() {
                return "Hello World";
            }
        }.method());
    }
}

람다 표현식

람다 표현식이란, 함수형 인터페이스를 좀 더 간편하고 간결하게 선언할 수 있도록 지원하는 문법이다.

기본적으로 () -> { return ; } 의 형태로 선언할 수 있으며, 인자의 갯수나 구문의 줄에 따라 조금씩 변경될 수 있다.

규칙은 다음과 같다.

public class LambdaTest {
    @Test
    void lambdaRole() {
        // 인자가 0개, 소괄호 생략 불가능
        Runnable run = () -> {};
        // 인자가 1개, 소괄호 생략 가능
        Consumer<Integer> cons = a -> {};
        // 인자가 2개이상, 소괄호 생략 불가능
        BiConsumer<Integer, Integer> biCons = (a, b) -> {};

        // 구문이 한 줄인 경우, 중괄호 + return 생략 가능.
        Comparator<Integer> comp = (a, b) -> a > b ? a : b;
        // 구문이 여러 줄인 경우, 중괄호 + return 생략 불가능.
        Comparator<Integer> comp2 = (a, b) -> {
            if( a > b ) {
                return a;
            } else {
                return b;
            }
        };
    }
}

메서드 참조

메서드 참조란, 함수형 인터페이스를 람다식이 아닌 일반 메서드를 참조시켜 선언하는 방법이다. 일반 메서드를 참조하기 위해서는 다음의 3가지 조건을 만족해야 한다.

  • 함수형 인터페이스의 매개변수 타입 = 메서드의 매개변수 타입
  • 함수형 인터페이스의 매개변수 개수 = 메서드의 매개변수 개수
  • 함수형 인터페이스의 반환형 = 메서드의 반환형

참조 가능한 메서드는 일반 메서드, Static 메서드, 생성자가 있으며, 클래스이름::메서드이름 으로 참조할 수 있다. 이렇게 참조를 하면 함수형 인터페이스로 반환된다.

public class LambdaTest {
    @Test
    void MethodReferenceTest() {
        // 일반 메서드 참조
        Function<String, Integer> function = String::length;
        function.apply("Hello World");

        // 일반 메서드 참조 2
        Consumer<String> consumer = System.out::println;
        consumer.accept("Hello World");

        // Static 메서드 참조
        Predicate<Object> predicate = Objects::isNull;
        predicate.test(consumer);

        // 생성자 참조 - 여기에서는 기본 생성자를 참조
        Supplier<String> supplier = String::new;
        String a = supplier.get();
        predicate.test(a);
    }
}

함수형 인터페이스와 람다식의 차이 - This

public class LambdaTest {
    @Test
    void thisTest() {
        Runnable funcCons = new Runnable() {
            @Override
            public void run() {
                System.out.println("funcCons.run.this = " + this);
                System.out.println("LambdaTest.this = " + LambdaTest.this);
            }
        };

        Runnable lamCons = () -> {
            System.out.println("lamCons.this = " + this);
            System.out.println("LambdaTest.this = " + LambdaTest.this);
        };

        System.out.println("funcCons = " + funcCons);
        funcCons.run();

        System.out.println("--------------------");

        System.out.println("lamCons = " + lamCons);
        lamCons.run();
    }
}
funcCons = myTest.LambdaTest$3@376b4233
funcCons.run.this = myTest.LambdaTest$3@376b4233
LambdaTest.this = myTest.LambdaTest@2fd66ad3
--------------------
lamCons = myTest.LambdaTest$$Lambda$334/0x0000000800ca25f0@5d11346a
lamCons.this = myTest.LambdaTest@2fd66ad3
LambdaTest.this = myTest.LambdaTest@2fd66ad3

위의 코드에서 함수형 인터페이스와 람다식으로 생성된 2개의 익명 함수가 있다. this를 이용해 이 들의 주소값을 출력해보자 신기한 결과가 출력된다.

먼저, 함수형 인터페이스로 생성된 funcCos를 보자.

funcCons의 주소값과 내부의 run 메서드에서 this를 이용해 출력된 주소값이 서로 같은 것을 볼 수 있다.

어찌보면 당연한 일이다. new 를 이용해 새로운 객체를 할당해 Heap 영역에 할당했고, 이 객체는 LambdaTest 클래스와 다른 Heap 영역에 할당되어 있는 별개의 객체이기 때문이다.

그래서 여기에서 외부 변수를 사용하기 위해서는 this가 아닌, LambdaTest.this 를 이용해야 한다.

그런데 람다식은 조금 다르게 동작하는 것을 볼 수 있다.

출력된 주소값을 보면 알겠지만, 람다식을 넣은 변수 lamCons의 주소값이 이상하게 되어있고, 람다식 내부의 this와 LambdaTest.this 가 같은 것을 볼 수 있다. 이게 어찌된 일일까?

결론부터 말하자면, 람다식은 컴파일이 될 때 객체를 생성하는 것이 아닌, LambdaTest 클래스의 private static 메서드로 생성된다.

이 때문에 함수형 인터페이스와 달리 this가 LambdaTest 클래스를 가리키는 것이다.

이에 대한 더 자세한 내용은 이 블로그를 참조하자.

접근 제한

람다 내부에서는 인스턴스 변수, 지역 변수, 정적 변수를 자유롭게 캡처할 수 있지만, 이 변수는 final이거나 final처러 사용되어야 한다. ( 할당만 되고, 변경은 한 번도 하지 않은 변수에 한해 사용 가능하다. )

함수형 프로그래밍의 특성 중 가장 중요한 것이 함수 안에서 일어난 일 때문에, 외부에서 변화가 일어나서는 안된다는 것인데, 이 때문에 위와 같은 제약을 걸어뒀다고 생각하면 된다.

그런데 이런 제약이 지역 변수는 문제가 없는데, 인스턴스 변수는 제약에 상관없이 수정이 가능하다. 이게 어떻게 된 일일까?

함수형 인터페이스 방식

public class LambdaTest {
    int outerField = 10;
    @Test
    void functionFieldTest() {
        int innerField = 20;

        Consumer<Integer> consumer = new Consumer<>() {
            @Override
            public void accept(Integer arg) {
                int lambdaField = 30;

                System.out.println("outerField = " + outerField); // 10
                outerField = 1000;                                // 변경 가능
                System.out.println("outerField = " + outerField); // 1000

                System.out.println("innerField = " + innerField); // 20
//              innerField = 2000;                                // 컴파일 에러
//              System.out.println("innerField = " + innerField); // 실행되지 않음

                System.out.println("lambdaField = " + lambdaField); // 30
                lambdaField = 3000;                                 // 변경 가능
                System.out.println("lambdaField = " + lambdaField); // 3000

                System.out.println("arg = " + arg); // 40
                arg = 4000;                         // 변경 가능
                System.out.println("arg = " + arg); // 4000
            }
        };

        int arg = 40;
        consumer.accept(arg);

        System.out.println("--------");
        System.out.println("outerField = " + outerField); // 1000
        System.out.println("innerField = " + innerField); // 20
        System.out.println("arg = " + arg); // 40
    }
}
outerField = 10
outerField = 1000
innerField = 20
lambdaField = 30
lambdaField = 3000
arg = 40
arg = 4000
--------
outerField = 1000
innerField = 20
arg = 40
  1. LambdaTest Class의 맴버 변수(outerField)의 값이 변경되었다.
  2. functionFieldTest 함수의 지역 변수(innerField)의 값을 변경하려 시도하면 컴파일 에러가 발생한다.

람다 표현식 방식

public class LambdaTest {
    int outerField = 10;
    @Test
    void lambdaFieldTest() {
        int innerField = 20;

        Consumer<Integer> consumer = (arg) -> {
            int lambdaField = 30;

            System.out.println("outerField = " + outerField); // 10
            outerField = 1000;                                // 변경 가능
            System.out.println("outerField = " + outerField); // 1000

            System.out.println("innerField = " + innerField); // 20
//          innerField = 2000;                                // 컴파일 에러
//          System.out.println("innerField = " + innerField); // 실행되지 않음

            System.out.println("lambdaField = " + lambdaField); // 30
            lambdaField = 3000;                                 // 변경 가능
            System.out.println("lambdaField = " + lambdaField); // 3000

            System.out.println("arg = " + arg); // 40
            arg = 4000;                         // 변경 가능
            System.out.println("arg = " + arg); // 4000
        };

        int arg = 40;
        consumer.accept(arg);

        System.out.println("--------");
        System.out.println("outerField = " + outerField); // 1000
        System.out.println("innerField = " + innerField); // 20
        System.out.println("arg = " + arg); // 40
    }
}
outerField = 10
outerField = 1000
innerField = 20
lambdaField = 30
lambdaField = 3000
arg = 40
arg = 4000
--------
outerField = 1000
innerField = 20
arg = 40
  1. LambdaTest Class의 맴버 변수(outerField)의 값이 변경되었다.
  2. functionFieldTest 함수의 지역 변수(innerField)의 값을 변경하려 시도하면 컴파일 에러가 발생한다.

이유가 뭘까?

결과적으로는 인스턴스 변수와 지역 변수의 저장 구역이 다르기 때문에 이 현상이 발생한다.

이를 자세히 설명한 글을 인터넷에서 찾아서 공유하고자 한다. 이 블로그에서 더 자세히 설명되어 있기에 들어가서 보기 바란다. 아래의 내용은 블로그의 글을 정리한 내용이다.


lambda 에서 사용되는 Local variable 은 왜 final or effectively final 이여야 할까?

해당 내용을 다루는 몇몇 글에서 “람다식에서 참조하는 외부 변수는 final 혹은 effectively final 이어야한다.” 라고 설명하고 있다. 하지만, 이는 엄밀히 말하면 틀린 내용이다.

엄밀히 말하면 외부 변수가 아닌, 외부 지역 변수만 위의 제약이 걸려있다. 좀 더 자세히 말하자면, 지역 변수를 제외한 인스턴스 변수, 정적 변수는 이에 해당되지 않는다는 이야기이다.

람다식에는 3가지 타입이 존재한다.

  • Capturing Lambda: 외부 변수를 사용하는 람다식
    • Local Capturing Lambda: 지역 변수를 사용하는 람다식
    • Non-Local Capturing Lambda: 지역 변수만 사용하지 않는 람다식
  • Non-Capturing Lambda: 외부 변수를 사용하지 않는 람다식

Local Capturing Lambda

람다식에서 사용되는 외부 지역 변수는 복사본이다.

람다식에서는 외부 지역 변수를 그대로 사용하지 못하고 복사본을 사용해야 하는데 그 이유는 다음과 같다.

  • 지역 변수는 Stack에 생성된다. 따라서 지역 변수가 생성된 블럭이 끝나면 스택에서 제거된다.
    • 메소드 내 지역 변수를 참조하는 람다식을 리턴하는 메소드가 있을 경우, 메소드 블럭이 끝나면 지역 변수가 스택에서 제거되므로 추후에 람다식이 수행될 때 참조할 수 없다.
  • 지역 변수를 관리하는 쓰레드와 람다식이 실행되는 쓰레드가 다를 수 있다.
    • 스택은 각 쓰레드의 고유의 공간이고, 쓰레드끼리 공유되지 않기 때문에 마찬가지로 람다식이 수행될 때 값을 참조할 수 없다.

final 혹은 effectively final인 지역 변수만 람다식에서 사용할 수 있다.

만약 참조하고자 하는 지역 변수가 final 혹은 effectively final이 아닐 경우, 즉 변경이 가능할 경우 다음과 같은 문제가 발생할 수 있다.

public void executelocalVariableInMultiThread() {
    boolean shouldRun = true;

    executor.execute(() -> {
        while (shouldRun) {
            // do operation
        }
    });
    
    shouldRun = false;
}

람다식이 어떤 쓰레드에서 수행될지 미리 알 수 없다. 이 얘기는 곧, 외부 지역 변수를 다루는 쓰레드와 람다식이 수행되는 쓰레드가 다를 수 있다는 의미이다.

지역 변수 값을 제어하는 쓰레드 A, 람다식을 수행하는 쓰레드 B가 있다고 가정하자. 문제는 다음과 같다.

쓰레드 B의 shouldRun값이 가장 최신 값으로 복사되어 전달됐는지 확신할 수 없다! 왜냐하면 지역 변수를 쓰레드간에 sync해주는 것은 불가능하기 때문이다! 값이 보장되지 않는다면 매번 람다식의 결과가 달라질 수 있게 된다. 예측할 수 없는 코드의 의미가 과연 있을까?

이러한 이유로 인해 외부 지역 변수는 전달되는 복사본이 변경되지 않은 최신 값임을 보장하기 위해 final 혹은 effectively final 이어야하는 제약이 필요하다.

복사된 지역 변수 값은 람다식 내부에서도 변경할 수 없다. 즉, final 변수로 다뤄야 한다.

그럼, 이미 복사가 된 값을 익명 함수 내부에서 변경하면 문제가 없는 것 아닌가?

결론은 아니다. 컴파일 된 람다식은 static 메소드 형태로 변경이 되는데, 이때 복사된 값이 파라미터로 전달되므로 마찬가지로 스택 영역에 존재하기 때문에 sync를 해주는 것도 불가능하다.

Non - Local Capturing Lambda

private int instanceNumber = 1;
private static int staticNumber = 1; 

public void testPlusByInstanceVariable() {
    instanceNumber = 2;
    Addable addableImple = () -> instanceNumber + 1;
}

public void testPlusByStaticVariable() {
    staticNumber = 2;
    Addable addableImple = () -> staticNumber + 1;
}

인스턴스 변수나 클래스 변수를 저장하고 있는 메모리 영역은 공통 영역(Heap)이고 값이 메모리에서 바로 회수되지 않는다. 따라서 복사 과정이 불필요하고 참조 시 최신값임을 보장할 수 있다.

다만 멀티 쓰레드 환경일 경우 volatile, synchronized 등을 이용하여 sync를 맞춰주는 작업을 잊어서는 안된다.

출저

profile
백엔드 개발자 지망생

0개의 댓글