함수형 프로그래밍?
오늘은 함수형 프로그래밍에 대해 알아보겠습니다.
함수형 프로그래밍? 함수로 만들면 함수형 아닌가? 절차지향형 프로그래밍에서는 함수를 사용하니까 지금껏 해왔던게 함수형 프로그래밍이군? 이라고 생각할 수 있습니다.(정확히는 제가 그렇게 생각했습니다..)
먼저 정의부터 알아보겠습니다.
함수형 프로그래밍이란?
- 하나의 프로그래밍 패러다임으로 정의되는 일련의 코딩 접근 방식
- 자료처리를 수학적 함수의 계산으로 취급, 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임을 의미
자바는 객체지향 언어입니다. 객체를 정의할 때 상태와 메서드가 결합되어있다는 점이 특징입니다. 절차지향형 언어와 달리 객체 지향 언어에서는 클래스의 서브루틴을 function이라고 부르지 않고 method(행위라고도 합니다)라고 부르는 것은 상태와 긴밀하게 연관되어 상태를 변경시키는 방법을 정의하기 때문입니다. 그렇기에 객체지향 언어의 method는 절차지향 언어의 function과 조금 다릅니다. 객체에서 상태(property)와 행위(method)를 결합시켜 객체 간의 메시지를 송/수신하는 것을 통해 로직을 구현합니다. 그렇기에 객체의 상태에 따라 행위의 결과가 바뀔 수 밖에 없습니다. 즉, 메서드의 수행 결과는 상태에 의존하게 되는 것이죠.
절차지향 언어도 마찬가지 입니다. 절차 지향 언어에서는 객체지향 언어에서 메서드처럼 함수가 긴밀하게 상태와 결합되어있지는 않습니다. 그렇지만 절차 지향 언어에서도 마찬가지로 전역 변수 등을 통해 상태를 기억하고, 상태에 의존하는 함수의 수행이 발생할 수 있습니다. 이런 경우 마찬가지로 항상 동일한 함수의 수행을 보장받을 수 없겠죠. 공유하는 상태가 다른 함수의 실행에 의해 영향받을 때, 데이터가 '오염'되었다는 말을 많이 씁니다.
함수형 프로그래밍 패러다임의 핵심은 함수를 항상 순수한 상태로 유지할 수 있도록 외부와의 의존성을 차단하는 것입니다. 이러한 함수에 실행에 있어서 외부와 주고받는 영향이 없는 함수를 순수함수라고 합니다. 즉, 동일한 입력에는 항상 같은 값을 반환합니다. 순수함수의 특징이자 함수형 프로그래밍의 특징은 첫번째로 부수 효과(Side Effect)가 없다는 것입니다. 부수 효과는 함수 외부에 초점을 맞춘 서술입니다. 함수가 실행되었을 때 함수 외부에 주는 영향이 없다는 것을 강조한 표현이죠. 특이한 점은 예외를 발생시키는 것도 외부에 영향을 주는 것이므로 부수효과를 발생시키는 것으로 간주된다는 점입니다. 두번째로, 참조 투명성(Referential Transparency)가 있습니다. 참조 투명성은 함수 내부에 초점을 맞춘 서술입니다. 함수가 외부의 영향을 받지 않는다는 것이죠.
그래서 순수함수를 사용하면 얻는 장점이 무엇일까요? 아래와 같습니다.
함수형 프로그래밍의 장점
- 결정론적이므로 원인을 찾기 쉽다. 항상 동일한 결과를 반환하므로 많지않은 테스트로 원인을 찾을 수 있다.
- 테스트하기 쉽다. 부수 효과가 없으므로 mock을 만들 필요가 없다.
- 조립하기 쉽다. 부수 효과, 예외가 없고 값의 변경이 없다. 동시성 문제 또한 발생하지 않는다.
- 구성, 재구성이 쉽다. 기반 함수를 작성한 후 조합하기만 하면 된다. 참조 투명하기 때문에, 다른 프로그램을 작성 할 때도 코드 변경이 필요없다.
.
위에서는 대략적인 함수형 프로그래밍에 대해 말씀드렸습니다. 함수형 프로그래밍의 특징을 조금 자세히 살펴보겠습니다.
함수형 프로그래밍의 특징
1. 순수함수 (Pure function)
- 동일한 입력에는 항상 같은 값을 반환해야하는 함수입니다.
- 함수의 실행이 프로그램의 실행에 영향을 미치지 않아야 하는 함수입니다.
- 함수 내부에서 인자의 값을 변경하거나 프로그램 상태를 변경하는 Side Effect이 없습니다.
- 순수 함수는 프로그램의 변화 없이 입력 값에 대한 결과를 예상 가능합니다.
2. 비상태, 불변성 (Stateless, Immutability)
- 데이터는 변하지 않는 불변성을 유지합니다.
- 변경이 필요한 경우, 원본 데이터 구조를 변경하지 않고 사본을 만들어 변경 작업을 진행합니다.
3. 선언형 함수 (Expressions)
- 명령형 프로그래밍은 어떻게 할 것인가에 주목
- 선언형 프로그래밍은 무엇을 할 것인가에 주목
4. 1급 객체와 고차함수(First-class, Higher-order functions)
- 함수형 프로그래밍에서는 함수가 1급 객체가 되며, 고차 함수의 속성을 갖습니다.
- 변수나 데이터 구조 안에 담을 수 있습니다.
- 파라미터로 전달할 수 있습니다.
- 반환값으로 사용할 수 있습니다.
- 할당에 사용된 이름과 관계없이 고유한 구별이 가능합니다.
5. Strict Evaluation(선언형) vs Non-Strict Evaluation(함수형)
print(length([2+1,1/0]))
- 위 수식을 평가할 경우 exception(strict) vs 2(non strict)
- Strict는 함수 값을 반환하는데 모든 값을 계산 후 평가합니다.
- Non-Strict는 필요한 부분만 계산하여 평가합니다.
여기까지 함수형 프로그래밍 패러다임의 특징을 한번 살펴보았습니다. 다음으로 다룰 주제는 위의 4번째 특징과 연관이 있습니다. 함수형 프로그래밍은 함수를 1급 객체로 다룹니다. 1급 객체는 함수를 인자로 받거나 리턴 값으로 반환할 수 있다는 의미입니다.
x ↦ ( y ↦ x² + y² )
( (x ↦ ( y ↦ x² + y² ))(5) )(2)
= (( y ↦ 5² + y² )(2)
= 5² + 2²
= 29
위의 예시를 보면 x가 호출한 결과가 y에 대한 함수입니다. 이처럼 함수의 반환 값이 또 다른 함수가 될 수 있는 것이 일급 객체의 특징입니다. 인자를 두개 받지 않고 함수와 함수의 합성으로 표현하는 것을 Lambda Calculus의 특징입니다. 여러 인자를 가지는 하나의 복잡한 함수를 더 낮은 레벨의 일급 객체 함수의 합성들로 표현할 수 있습니다. 각각의 함수들은 다 일급객체의 성질을 만족하므로 외부와의 영향을 주고받지 않기에 재사용성이 굉장히 뛰어납니다. 레고를 조립하듯이 함수들을 조립해서 상위레벨의 함수를 만들어서 로직을 구성하는 것이 함수형 프로그래밍 패러다임의 핵심입니다. 그리고 이를 이해하기 위해서는 Lambda Calculus에 대한 이해가 필요합니다.
Lambda Calculus란?
람다 대수(Lambda Calculus)는 함수 추상화(function abstraction)와 함수 실행(function application)을 위한 형식 체계(formal system)에 대한 이론입니다. 형식 체계는 기호 체계와 추론 규칙을 가진다는 것을 의미합니다. 정확한 정의가 궁금하면 아래 링크를 참고하시면 됩니다.
(https://en.wikipedia.org/wiki/Formal_system)
Lambda Calculus Terms
람다 대수의 3가지 문법은 아래와 같습니다.
1. Variable 변수
x, y 등 논리 값, 숫자 값, 반복적으로 사용되는 매개변수 문자열을 의미합니다.
2. Abstraction 추상화
- λ parameter.return의 문법으로 작성합니다.
- 추상화 단계에서는 함수에 값을 대입해서 실행하는 것이 아닌, 단순히 함수를 set up(정의)하는 것을 의미합니다.
- 추상화의 정의를 통해서 변수의 연결 관계(binding)을 생성합니다.
- return도 lambda terms여야 유효한 표현이 됩니다.
- 수학의 Curring 개념을 적용할 수 있습니다.
f(x,y) = x -> λx.λy.x
Curring은 인자를 여러 개 받는 함수를 분리하여 인자를 하나씩만 받는 함수의 체인으로 만드는 방법입니다. 하나의 함수를 반복적으로 호출함으로써 재사용성을 높일 수 있습니다.
3. Application 적용
- 추상화된 function에 실제로 lambda를 대입하여 함수를 실행하는 것을 의미합니다.
Lambda Calculus Operation
1. alpha-conversion
(λx.M[x]) → (λy.M[y])
위처럼 Symbol을 교체하는 것이 함수 동작 방식에 영향이 없기에 이름 충돌을 피하기 위해서 변경이 가능합니다.
2. beta-reduction
((λx.M) E) → (M[x := E])
x에 E를 대입한 것으로 항을 reduce 시킬 수 있음을 의미합니다. 즉, lambda의 scope 내에 있지 않은(binding 되지 않은) 변수를 외부 변수라고 하는데, beta-reduction은 이러한 외부변수는 종속성을 갖지 않기 때문에 값을 대입해서 제거할 수 있는 성질을 의미합니다.
익명 함수(Anonymous Function)
익명 함수는 람다 대수로부터 영향을 받아 만들어진 함수형 프로그래밍의 함수 표현 방식 중 하나입니다. Lambda Calculus에서 변수는 추상화와 적용 단계를 거쳐 함수를 실행하게 되는 과정을 확인할 수 있었습니다. Lambda Calculus Operation의 Alpha-conversion처럼 Symbol은 추상화를 통해 변경 가능한 것임을 알수 있는데, 이처럼 함수를 추상화해나가면 함수의 이름, 변수명과 같은 것은 함수의 실행에는 영향을 주지 않습니다. 그러므로, 함수의 이름을 없앤 익명 함수 또는 람다 함수(Lambda Abstraction)가 등장하게 됩니다. 그리고, 익명 함수로부터 파생된 개념인 클로저까지 살펴봅니다.
클로저(Closure)란?
- 클래스 내에 정의한 변수를 람다가 직접 사용하는 것을 의미한다.
- 더 일반적으로 자신을 둘러싼 context 내 변수 등에 접근하는 것을 의미한다.
- 자신의 범위 밖의 변수를 사용하면 Closure라고 볼 수 있다.
- 람다는 클로저를 포함하는 큰 개념, 람다가 범위 밖의 변수를 참조하면 람다인 동시에 클로저가 된다.
JAVA - Lambda와 함수형 인터페이스
마지막으로, 자바에서 어떻게 함수형 프로그래밍을 적용하는지 알아봅니다. 자바는 8이후 람다 함수와 Stream API를 통한 고차함수의 Collections 연산을 지원합니다. 그리고 함수형 인터페이스 구현을 통해서 람다 함수를 구현할 수도 있습니다. 먼저 함수형 인터페이스를 통한 일급 객체 함수의 구현과 기존에 지원하던 익명 클래스와의 차이점을 살펴보겠습니다.
함수형 인터페이스 vs 익명 클래스
- 익명 클래스는 인스턴스를 생성해야하지만, 함수는 평가될 때마다 새로 생성되지 않습니다. 함수를 위한 메모리 할당은 자바 힙의 permenant에 한번 저장됩니다. (Static Method와 유사하게 작동합니다.)
- 객체는 데이터와 밀접하게 연관되어 동작하지만, 함수는 데이터와 분리됩니다. 위에서 언급한 내용이죠. 상태를 보존하지 않으므로 여러 번 연산해도 결과가 변하지 않습니다. 즉, 멱등성을 만족합니다.
타입 추론과 함수형 인터페이스
타입 추론이란? 타입이 정해지지 않은 변수의 타입을 컴파일러가 유추하는 기능입니다. Java는 Type을 명시하는 언어로 변수 선언 시 타입을 필요로 합니다. 즉, 타입이 정해져있는데 그럼에도 자바에서 타입 추론이라고 하면 Generic과 Lambda에 대한 타입 추론에 대한 부분입니다. 자바는 Type Erasure(컴파일 시 제네릭 타입 제거)를 사용하여, Generic에 대해서는 타입 추론이 되지 않습니다.
익명 함수인 람다 함수를 지원하기 위해서는 적절한 타입 정보가 필요한데, 이를 지원하기 위해 타입 추론을 강화하여, 함수형 인터페이스를 만들었습니다. 함수형 인터페이스는 하나의 추상 메소드로 이루어진 인터페이스를 의미합니다. 그러므로 어떤 함수가 구현되는지 컴파일러가 생략된 정보들을 추론하여 타입을 얻을 수 있게됩니다.
함수형 인터페이스에 대해 다음으로 알아보겠습니다.
Functional Interface
Annotation : @FunctionalInterface
- 단 하나의 추상 메소드만 가질 수 있는 함수형 인터페이스
- 추상 메소드가 없거나, 2개 이상이면 컴파일 에러 발생
1. Predicate<T>
boolean value function을 구현해야할 때 사용합니다. 즉, 하나의 인자를 받아서 boolean value로 반환하는 경우입니다. test 함수를 통해서 함수의 실행을 할 수 있습니다.
아래 예제에서는 완전수를 계산하는 함수에 대해 객체 지향적 방식과 비교하였습니다. 함수형 프로그래밍은 static으로 함수를 선언하여 메모리 permenant 영역에 생성되고, 매개변수로 필요한 인자를 받아 외부와 독립되어있으며, filter 함수에 다양한 Predicate를 적용하여 재사용성이 뛰어납니다. 반면 아래 객체 지향적 방식은 인스턴스를 생성하여 각 인스턴스의 상태와 행위를 바인딩시키는 방식입니다.
public static Predicate<Integer> isPerfect = (number -> sum(factors.apply(number)) - number
== number); // 외부와 독립적으로 작동하는 순수함수
public static <T> boolean filter(T number, Predicate<T> predicate) {
return predicate.test(number); //함수를 매개변수로 받아 함수가 일급객체가 된다.
}
// 객체 지향 방식의 함수 작성 - 순수함수가 아님
public int number;
public boolean isPerfect() {
return sum(factors()) - number == number;
}
2. BiPredicate<T, U>
Predicate와 동일하게 boolean을 반환하지만, 두 개의 인자를 받아서 boolean value로 반환하는 함수입니다. 비슷한 예시로 약수를 계산하는 함수를 두 가지 방법으로 작성하였습니다.
public static BiPredicate<Integer, Integer> isFactor = ((number, potentialFactor) ->
number % potentialFactor == 0);
public static <T, U> boolean filter(T number, U index, BiPredicate<T, U> biPredicate) {
return biPredicate.test(number, index);
}
//객체 지향 프로그래밍
public boolean isFactor(int potentialFactor) {
return number % potentialFactor == 0;
}
3. Consumer<T>
인자를 하나 받지만 return 값이 없는 경우입니다. 매개변수를 받아 로직을 실행하고 void를 return하는 함수를 생각하시면 이해가 빠릅니다.
4. Function<T,R>
인자를 하나 받고 하나의 리턴 값을 반환하는 경우입니다. 가장 일반적인 경우라고 할 수 있습니다. Apply를 통해서 함수를 실행할 수 있습니다. 예시는 임의의 정수에 대해 약수들을 set으로 반환하는 함수입니다.
public static Function<Integer, Set<Integer>> factors = (number ->
IntStream.rangeClosed(1, (int) Math.sqrt(number))
.filter(index -> filter(number, index, isFactor))
.flatMap(index -> IntStream.of(index, number/index))
.boxed()
.collect(Collectors.toUnmodifiableSet()));
// 객체지향 방식
public Set<Integer> factors() {
HashSet<Integer> factors = new HashSet<>();
for (int pod=1; pod <= Math.sqrt(number); pod++) {
if (isFactor(pod)) {
factors.add(pod);
factors.add(number / pod);
}
}
return factors;
}
5. Supplier<T>
Consumer와 반대로 인자를 받지 않고 Result를 반환하는 경우입니다. getter와 같은 함수가 대표적인 예시입니다.
6. 메소드 참조
메소드를 간결하게 지칭할 수 있는 방법입니다. 람다가 쓰이는 곳 어디서나 사용할 수 있습니다.
Syntax: <target reference> :: <method name>
아래는 간단한 예시입니다.
IntStream.rangeClosed(2, numberRange)
.mapToObj(index -> getType(index))
.forEach(System.out::println);
메소드 참조에는 여러가지 방법이 있습니다. 대표적인 3가지 참조 방식을 소개합니다.
6.1 Static 메소드 참조
문법 : ContainingClass::staticMethodName
Consumer<List<Integer>> c = Collections::sort; 대표적인 예입니다. 컴파일러는 자동적으로 (list) -> Collections.sort(list)로 변환합니다. target reference는 static 메소드를 포함하는 클래스 이름입니다.
6.2 인스턴스의 메소드 참조
문법 : containingObject::instanceMethodName
인스턴스의 메소드를 참조합니다. 예를 들어, list::add는 (list, ele) -> list.add(ele)와 같은 의미로 사용됩니다.
6.3 특정 유형의 임의 객체 메소드 참조
문법 : ContainingType::methodName
arbitary object (임의 객체)라는 용어가 사용된 이유는, 메소드 참조가 실행될 때마다 인자로 넘어온 객체가 다를 수 있기 때문입니다.
'Computer Science > 기타' 카테고리의 다른 글
함수형 프로그래밍 기초 - 재귀, 카테고리 이론, 모나드(Monad), 고차 함수(Java) (0) | 2023.02.04 |
---|---|
오토마타 이론과 Parser의 원리 - HTML Parser 만들기 (Java) (0) | 2023.02.01 |
객체지향 설계 연습(SOLID, GRASP) - 좌표평면 도형 그리기 (Java) (2) | 2023.01.28 |
프로세스의 메모리 영역 (Heap, Stack) 구현하기 (Java) (0) | 2023.01.15 |
미니 16비트 CPU + 메모리 구현해보기 (Java) (0) | 2023.01.14 |