함수형 프로그래밍은 명령형 프로그래밍의 패러다임에서 벗어나 선언형 프로그래밍의 패러다임에 발을 딛는것을 의미한다. 구체적으로 어떤 방식으로 할 것인지 how 가 아닌 무엇을 할 것인지 what 에 집중하는 프로그래밍 설계 패러다임을 의미하는 것이다.
프로그래밍은 항상 유지보수의 가능성을 열어두고 설계를 진행해야 한다.
노련한 개발자들 사이에서는 synchronized 라는 키워드의 사용 여부에 따라서 해당 시스템이 함수형 프로그래밍의 패러다임을 적절히 사용했는지를 판단한다는 말이 있을 정도로 함수형 프로그래밍에 대한 필요성이 중요시 된다.
함수형 프로그래밍을 사용 한다는 것은 시스템의 각 부분이 상호 의존성을 가르키는 결합성과 시스템의 다양한 부분이 서로 어떤 관계를 갖는지 가르키는 응집성이라는 소프트웨어 엔지니어링 도구로 프로그램 구조를 평가 할 수 있다는 것을 의미한다.
함수형 프로그래밍을 통한 유지보수
함수형 프로그래밍 설계를 적극 사용했다면 아래와 같은 개념의 문제를 해결하는데 큰 도움을 받을 수 있다.
- 메서드 호출의 부작용 : no side effect
- 불변성 : immutability
프로그래밍을 설계하다 보면 공유된 가변 데이터로부터 오는 문제점을 확인 할 수 있다.
변수가 예상하지 못한 값을 갖는 이유는 결국 우리가 유지보수하는 시스템의 여러 메서드에서 공유된 가변 데이터 구조를 읽고 갱신하기 때문이다.
메서드 호출의 부작용을 피하기 위해 값의 변화를 피하기 위해서 자신이 포함하는 클래스의 상태 그리고 다른 객체의 상태를 바꾸지 않으며 return 문을 통해서만 자신의 결과를 반환하는 메서드가 필요하고 이것을 순수함수 라고 한다.
순수 함수를 통한 연산은 메서드 호출의 부작용을 감소시켜 주면서 불변 객체는 복사하지 않고 공유 할 수 있으며, 객체의 상태를 바꿀 수 없으므로 스레드 안전성을 제공한다.
선언형 프로그래밍
Transaction mostExpensive = transactions.get(0);
if(mostExpensive == null){
throw new IllegalArgumentException("Empty list of transactions");
}
for(Transaction t: transactions.subList(1, transactions.size())){
if(t.getValue() > mostExpensive.getValue()){
mostExpensive = t;
}
}
위 코드는 mostExpensive 를 가장 비싼 값으로 갱신 시키는 로직이다.
어떠한 방식을 통해 갱신 시키는지 확인할 수 있는 위와같은 프로그래밍 방식을 명령형 프로그래밍이라고 하며 정확하게 어떻게 how 에 집중하고 있다.
하지만 무엇을 what 할 것인지에 집중한다면 아래와 같은 함수형 프로그래밍으로써 코드를 변경시킬 수 있다.
Optional<Transaction> mostExpensive = transactions.stream().max(comparing(Transaction::getValue));
코드의 길이가 짧아진 것도 큰 장점이지만 그것보다는 무엇을 할 것인지가 한눈에 보이는 코드라는 것이 핵심이다.
Stream 을 이용하여 각 transaction 이 가지고 있는 값 중 max 값을 뽑아 내고 그 반환값은 Optional 을 통해서 NullPointerException 의 위험성 까지 한번에 처리를 했다.
이와 같은 구현 방식을 내부 반복 internal iteration 이라고 한다. 문제 자체가 코드로 명확하게 드러난다는 것은 선언형 프로그래밍의 강점이다.
함수형 프로그래밍의 예외
함수형이라면 함수나 메서드가 어떤 예외도 일으키지 않아야 한다. 예외가 발생하면 return 으로 결과를 반환할 수 없다는 것을 의미한다. 따라서 이러한 제약은 함수형을 수학적으로 활용하는데 큰 걸림돌이 될 수 있다. 수학적 함수는 주어진 인수값에 대응하는 하나의 결과 값을 반환하는 것을 의미한다. 예외 발생을 없애기 위한 이러한 과정이 귀찮은 작업이라고 생각 할 수 있지만 함수형 프로그래밍과 순수 함수형 프로그래밍의 장단점을 실용적으로 고려해서 다른 컴포넌트에 영향을 미치지 않도록 지역적으로만 예외를 사용하는 방법도 고려할 수 있다.
참조 투명성
함수형 프로그래밍의 부작용을 감춰야 한다 라는 것은 참조 투명성의 개념으로 귀결된다. 같은 인수로 함수를 호출 했을 때 항상 같은 결과를 반환한다면 참조적으로 투명한 함수라고 표현하고 참조 투명성을 지켰다고 한다.
참조 투명성은 프로그램을 이해하는데 큰 도움을 준다.
재귀와 반복
순수 함수형 프로그래밍 언어에서는 while, for 와 같은 반복분을 사용하지 않는데 이러한 반복문 때문에 변화가 자연스럽게 코드에 스며들 수 있기 때문이다. 예를 들어 Iterator 를 통한 반복문을 사용 할 경우 호출자는 변화를 확인 할 수 없기 때문에 변화의 위험성에서 상대적으로 자유롭지만 전역변수를 사용한 while 문 혹은 for 문 같은 경우 변화의 위험성에서 자유롭지 못하기 때문에 함수형 프로그래밍과는 맞지 않는 설계 방식이다.
이러한 문제 때문에 순수 함수형 프로그래밍 언어에서는 부작용 연산을 원천적으로 제거 했는데 이론적으로 반복을 이용하는 모든 프로그램은 재귀로 구현할 수 있기 때문에 재귀를 이용하여 루프 단계마다 갱신되는 반복 변수를 제거 할 수 있다.
예를 들어 반복 방식의 팩토리얼은 다음과 같이 설계 할 수 있다.
static int factorialIterator(int n){
int r = 1;
for(int i=1; i<=n; i++){
r *= i;
}
return r;
}
재귀 방식의 팩토리얼은 다음과 같이 설계 할 수 있다.
static long factorialRecursive(long n){
return n==1 ? 1 : n*factorialRecursive(n-1);
}
일반적인 루프를 상용한 코드는 매 반복마다 변수 r 과 i 가 갱신이 된다. 하지만 재귀 방식의 팩토리얼은 자신을 호출하면서 좀 더 수학적인 접근 방법을 통해 해결한다. 좀더 나아가 stream을 활용하여 팩토리얼을 계산한다면 아래와 같이 설계 할 수 있다.
static long factorialStream(long n){
return LongStream.range(1, n)
.reduce(1, (long a, long b) -> a*b );
}
일반 반복문에서 재귀 방식의 반복을 통해 문제를 해결했고 좀더 나아가 스트림을 이용해서 문제를 해결했다. 변화의 양상에 따라서 일반 반복문 보다 재귀 방식의 반복이 좀 더 좋은 코드인것 처럼 보이지만 프로그램 비용의 측면에서 본다면 일반 반복코드보다 재귀 코드가 더 비싼 비용이 든다. 함수를 호출 할 때 마다 호출 스택에 각 호출시 생성되는 정보를 저장할 새로운 스택 프레임이 만들어지기 때문이다. 따라서 큰 입력값을 통해 계산을 진행할 경우 StackOverFlow 에러가 발생할 가능성이 존재한다.
위와 같은 문제를 해결하기 위해서 꼬리 호출 최적화 라는 알고리즘을 통해 구조를 변경할 필요성이 있다.