티스토리 뷰

728x90
반응형

서론


  • 해당 챕터를 읽다 보니 예전에 면접에서 함수형 프로그래밍이란 어떤 것이고, 어떻게 생각하느냐라는 질문을 받은 적이 있었습니다. 
    그때 제대로 된 답변을 못했는데... 라는 생각이 스쳐 지나갔고 사실 지금도 다시 물어본다면 제대로 된 답변을 하기 어려울 거 같습니다. 그래서 이번 내용 정리를 토대로 개념을 가지고 갈 필요성이 있는 것 같습니다.

 

시스템 구현과 유지보수


  • 시스템 구현 및 유지보수 중 우리는 예상치 못한 변수값으로 인해 많은 문제를 겪고 있습니다. 왜 예상치 못한 변수값으로 인해 우리는 힘들어할까요? 여기서 함수형 프로그래밍이 제공하는 부작용 없음과 불변성이라는 개념이 이 문제를 해결하는데 도움을 준다고 합니다.

 

💡 공유된 가변 데이터

  • 변수값이 예상치 못한 값을 갖는 이유는 우리가 사용중인 메서드에서 공유된 가변 데이터를 읽고 갱신하기 때문입니다.
  • 아래는 어떠한 리스트를 참조하는 여러 클래스가 있다고 가정을 합니다. 과연 리스트 소유자는 어느 클래스 일까요? 또한 어떠한 클래스로 인해 리스트가 수정된 경우 참조하고 있는 다른 클래스에서는 어떠한 액션을 취해야 할까요? 또한 액션을 취하기전 그 클래스들은 해당 리스트가 변경되었다는 것을 알 수 있을까요? 이처럼 공유된 가변 데이터를 사용하게 되면 생각보다 많은 문제로 인해 고민이 많아지게 됩니다.

 

🤔 어떻게 해결할 수 있을까?

  • 위에서 공유된 가변 데이터로 인해 많은 고민을 해야했었습니다. 이러한 문제를 어떻게 해결할 수 있을까요?
  • 자신을 포함하는 클래스의 상태 그리고 다른 객체의 상태를 바꾸지 않으며 return 문을 통해서만 자신의 결과를 반환하는 메서드를
    순수 메서드 또는 부작용 없는 메서드라고 부릅니다.
  • 불변 객체를 통해서도 문제를 해결할 수 있습니다. 불변 객체는 인스턴스화한 다음에는 객체의 상태를 변경할 수 없으므로 함수 동작에 영향을 미치지 않습니다. 즉 인스턴스화한 불변 객체의 상태는 결코 예상하지 못한 상태로 바뀌지 않습니다. 따라서 불변 객체는 복사하지 않고 공유할 수 있으며, 객체의 상태를 바꿀 수 없으므로 스레드 안전성을 제공합니다.

 

💡 선언형 프로그래밍

  • 선언형 프로그래밍에는 어떻게 수행할 것인지에 집중하는 방식무엇을에 집중하는 방식이 있다고 합니다.

어떻게 수행할 것인지에 집중하는 방식

  • 아래는 숫자 리스트에서 가장 높은 값을 구할 때의 예제입니다. forEach를 순회하면서 숫자를 가져와 가장 큰 숫자와 비교합니다. 
    이처럼 어떻게에 집중하는 프로그래밍 형식은 고전의 객체지향 프로그램에서 이용하는 방식입니다.(할당, 조건문, 분기문, 루프)
public static void main(String[] args) {
    List<Integer> integerList = List.of(1, 2, 3, 4, 5, 6);
    int max = Integer.MIN_VALUE;
    for (Integer integer : integerList) {
        if (max < integer)
            max = integer;
    }
}

 

무엇을에 집중하는 방식

  • 아래는 Stream API를 사용하여 다음과 같은 질의를 만들 수 있습니다. 질의문 자체로 문제를 어떻게 푸는지 명확하게 보여준다는 것이 내부 반복 프로그래밍의 큰 장점입니다. 
public static void main(String[] args) {
    List<Integer> integerList = List.of(1, 2, 3, 4, 5, 6);
    int max = integerList.stream().max(Integer::compare).get();
}

 

함수형 프로그래밍이란 무엇인가?


  • 말 그대로 함수를 이용하는 프로그래밍이다라고 할 수 있습니다. 그럼 함수는 무엇일까요? 라는 질문이 다시 꼬리를 잡을 수 있습니다.
  • 함수형 프로그래밍에서 함수는 수학적인 함수를 말합니다. 즉, 함수는 0개 이상의 인수를 가지며, 한 개 이상의 결과를 반환하지만 부작용이 없어야 한다고 합니다.
  • 함수형이라는 말은 "수학의 함수처럼 부작용이 없는"을 의미합니다.

부작용이 없는 함수

 

💡 2가지 종류의 함수형

 

순수 함수형

  • 함수 그리고 if-then-else 등의 수학적 표현만 사용하는 방식

부작용이 없는 함수형

  • 시스템의 다른 부분에는 영향을 미치지 않는다면 내부적으로는 함수형이 아닌 기능도 함수라 칭할 수 있습니다.
  • 즉 내부적으로는 부작용이 발생하지만 호출자가 이를 알아차리지 못한다면 실제로 부작용이 발생한 것이라고 말할 수 없고 이를 함수형 프로그래밍으로 볼 수 있습니다.

 

💡 함수형 자바

  • 실질적으로 자바에서는 완벽한 함수형 프로그래밍을 구현하기는 어렵다고 합니다. 어떠한 메서드에서 Scanner.nextLine을 두번 호출한다던가, Random.nextInt를 호출하면 다른 값이 반환될 수 있습니다.
  • 하지만 시스템의 컴포넌트가 순수한 함수형인 것처럼 동작하도록 코드를 구현할 수 있습니다. 자바에서는 순수 함수형이 아닌 함수형 프로그래밍을 구현할 것 입니다.
  • 실제 부작용은 있지만 아무도 이를 보지 못하게 함으로써 함수형을 달성할 수 있습니다.
  • 부작용을 일으키지 않는 어떤 함수나 메서드가 있는데, 다만 진입할 때 어떤 필드의 값을 증가시켰다가 return 할 때 값을 되돌려놓는다고 가정합니다. 단일 스레드의 경우에는 아무런 부작용이 일어나지 않으므로 함수형이라 간주할 수 있습니다.
    하지만 멀티 스레드 환경에서는 다른 스레드가 필드의 값을 확인하거나 동시에 메서드를 호출하는 상황이 발생할 수 있다면 이 메서드는 함수형이 아닙니다. 만약 메서드에 잠금을 걸어 해결할 수 있다면 함수형이라 간주할 수 있습니다.

 

💡 함수형의 조건

  • 함수나 메서드는 지역 변수만을 변경해야 함수형이라 할 수 있습니다. 그리고 함수나 메서드에서 참조하는 객체가 있다면 그 객체는 불변 객체여야 합니다. 예외적으로 메서드 내부에서 생성한 객체의 필드는 갱신할 수 있습니다. 단 생성된 객체의 필드가 외부에 노출되지 않아야 하며, 다음에 메서드를 다시 호출할 때 결과에 영향을 미치지 않아야 합니다.
  • 함수형이라면 함수나 메서드가 어떤 예외도 일으키지 않아야 합니다. 예외가 발생하면 이전에 설명한 것처럼 return으로 결과를 반환 받을 수 없기 때문입니다. 이러한 경우 Optional을 사용하여 예외가 발생했다면 빈 Optional을 반환하거나 특정 값을 반환함으로써 해결할 수 있습니다.
  • 함수형에서는 비함수형 동작을 감출 수 잇는 상황에서만 부작용을 포함하는 라이브러리 함수를 사용해야 합니다.
    먼저 자료구조를 복사한다든가 발생할 수 있는 예외를 적절하게 내부적으로 처리함으로써 자료구조의 변경을 호출자가 알 수 없도록 감춥니다.

💡 참조 투명성

  • 부작용을 감춰야 한다라는 제약 조건은 참조 투명성 개념으로 귀결됩니다. 즉 같은 인수로 함수를 호출 했을 경우 항상 같은 결과를 반환한다면 참조적으로 투명한 함수라고 할 수 있습니다.
  • 즉 어떠한 입력이 주어졌을 경우 언제, 어디서 호출하든 항상 같은 결과를 반환해야 합니다.
  • 또한 참조 투명성은 비싸거나 오랜 시간이 걸리는 연산을 캐싱을 통해 다시 계산하지 않고 저장하는 최적화 기능도 제공합니다.
// 항상 같은 결과가 나오므로 String.replace는 참조적으로 투명성 적합(원본을 변경하지 않고 새로운 객체를 생성)
"raoul".replace('r', 'R'); 

// 호출시 매번 다른 값이 나오므로 참조 투명성 위배
Random random = new Random();
Random.nextInt() 

// 호출시 매번 다른 값이 나오므로 참조 투명성 위배
Scanner scanner = new Scanner(System.in);
scanner.nextLine()

// a나 b의 값이 바뀔 수 있으므로 참조 투명성 위배
int a = 1;
int b = 2;
public int sum(int a, int b) {
    return a + b;
}

// a나 b의 값이 바뀔 수 없으므로 참조 투명성 적합
final int a = 1;
final int b = 2;
public int sum(int a, int b) {
    return a + b;
}

 

💡 참조 투명성의 문제

  • 아래 처럼 List를 반환하는 메서드를 두번 호출했다고 가정합니다. 이때 호출 결과로 같은 요소를 포함하는 리스트를 반환받지만 서로 다른 메모리 공간에 생성된 리스트를 참조하게 됩니다. 반환 리스트가 가변 객체라면 참조적으로 투명하지 않다고 볼 수 있지만, 순수 값으로 사용할 것이라면 두 리스트가 같은 객체라고 볼 수 있으므로 참조적으로 투명하다고 볼 수 있습니다.
public class Main {

    public static void main(String[] args) {
        List<Integer> integerList = List.of(1, 2, 3, 4, 5, 6);

        List<Integer> A = toList(integerList); // 같은 요소, 다른 주소
        List<Integer> B = toList(integerList); // 같은 요소, 다른 주소
    }
    
    private static List<Integer> toList(List<Integer> integerList) {
        return new ArrayList<>(integerList);
    }
}

 

재귀와 반복


  • 순수한 함수형 프로그래밍 언어에서는 while, for 같은 반복문을 포함하지 않는다고 합니다. 그 이유는 반복문을 사용하게 되면 변화가 자연스럽게 코드에 스며들기 때문이라고 합니다.

반복 방식의 팩토리얼

private static int factorial(int n) {
    int result = 0;
    for (int i = 0; i <= n; i++) {
        result += i;
    }
    return result;
}

 

재귀 방식의 팩토리얼

private static int factorialRecursive(int n) {
    return n == 0 ? 0 : n + factorialRecursive(n - 1);
}

 

스트림 팩토리얼

private static int factorialStream(int n) {
    return IntStream.rangeClosed(0, n)
            .reduce(0, Integer::sum);
}

 

꼬리 재귀 팩토리얼

  • 위의 3가지 재귀 코드는 비쌉니다. 그 이유는 재귀 함수를 호출할 때마다 호출 스택에 각 호출시 생성되는 정보를 저장할 새로운 스택 프레임이 만들어집니다. 즉 재귀 팩토리얼의 입력값에 비례해서 메모리 사용량이 증가하게 됩니다. 따라서 큰 입력값을 사용하게 된다면 스택오버 플로우가 발생하게 됩니다.
  • 아래 꼬리 호출 최적화 기법으로 해결할 수 있다고 합니다.
  • factorialHelper에서 재귀 호출이 가장 마지막에서 이루어지므로 꼬리 재귀라고 합니다.
  • 중간 결과를 각각의 스택 프레임으로 저장해야 하는 일반 재귀와 달리 꼬리 재귀에서는 컴파일러가 하나의 스택 프레임을 재활용할 가능성이 생긴다고 합니다.
private static int factorialTaiRecursive(int n) {
    return factorialHelper(0,n );
}
private static int factorialHelper(int start, int n) {
    return n == 0 ? start : factorialHelper(start + n, n  - 1);
}

 

 

부족한 부분은 내일 보충,,,

 

 

 

 

728x90
반응형