수학적 계산을 위한 함수형 프로그래밍
원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기
프로그래밍 언어는 문제에 대한 일반적인 해법을 기술하도록 돕는다. 그 결과는 마침 기계가 실행할 수 있는 형태가 될 뿐이다. 모든 프로그래밍 언어는 저마다 다른 장점과 단점을 가지고 있는데, 그 이유 중 하나는 구문과 의미론이 해당 언어로 쉽게 다룰 수 있는 문제의 범위에 큰 영향을 미치기 때문이다.
tl;dr: 함수형 프로그래밍이 더 흔한 명령형 접근 방식보다 수학적 계산에 더 적합하다고 생각한다.
수학을 위한 내장 추상화 활용
언어 뒤에 있는 아이디어(기저의 프로그래밍 패러다임)는 그 언어를 중심으로 형성되는 커뮤니티를 특징짓는다. 개발자들은 언어 코어를 중심으로 바로 사용할 수 있는 라이브러리와 프레임워크로 이루어진 독특한 생태계를 만들어 낸다. 그 결과, 어떤 언어는 비즈니스 애플리케이션 같은 분야에서 강점을 보이고(코볼을 떠올릴 수 있다), 어떤 언어는 C나 Rust처럼 시스템 프로그래밍에 탁월하다.
컴퓨터로 수학적·수치적 문제를 해결하는 이야기를 할 때면 포트란이 떠오를 수 있다. 포트란은 범용 언어이지만 주로 과학 계산 분야에서 알려져 있다. 물론 이 언어는 애초에 그런 목적을 염두에 두고 만들어졌다. 그래서 이름도 Formula Translation이다.
이 분야에서 포트란이 인기를 끈 이유 중 하나는 성능을 놓치지 않으면서도 수학적 개념을 표현할 수 있는 도메인 특화 키워드를 내장하고 있기 때문이다. 예를 들어 복소수를 위한 전용 자료형인 COMPLEX나, 수학적 용어와 매우 유사하며 배열과 벡터를 만드는 데 사용할 수 있는 DIMENSION이라는 키워드를 제공한다.
명령형 vs 함수형 스타일
내장 키워드는 언어가 특정 문제 영역에서 표현력을 확장하는 데 도움이 될 수 있지만, 이 접근 방식은 심각하게 제한적이다. 언어 코어를 ad infinitum 무한정 확장하는 것은 현실적이지 않다. 유지보수는 더 어려워지고 배우는 데도 더 오래 걸릴 뿐이다. 따라서 대부분의 언어는 루틴을 더 작고 관리하기 쉬운 부분으로 나누기 위해 functions, subroutines, classes와 objects 같은 다른 추상화 수단을 제공한다. 이러한 메커니즘은 프로그램의 복잡도를 제어하는 데 도움이 될 수 있지만, 특히 수학적 문제를 다룰 때는 상용구 코드로 해법을 흐리지 않도록 주의해야 한다.
사례 I - 팩토리얼
예를 들어, 양수 n의 팩토리얼을 계산하는 다음 공식을 프로그램 코드로 옮기는 것이 과제라고 해보자.
위 공식을 명령형 스타일의 Java로 구현하면 다음과 같이 보일 수 있다.
public static long fact(final int n) {
if (n < 0) {
// Negative numbers not allowed
return 0;
}
long prod = 1;
for (int i = 1; i <= n; ++i) {
prod *= i;
}
return prod;
}이것은 이렇게 짧은 문제 정의에 비해 상당히 긴 해법이다. (1부터 n까지 명시적인 루프를 사용한 버전을 의도적으로 작성했음을 밝혀둔다. 재귀 함수가 더 짧겠지만, 수학적 공식에는 등장하지 않는 개념을 사용한다.)
게다가 이 프로그램은 public, static, System.err.println() 같은 언어 고유의 키워드를 많이 포함한다. 그뿐만 아니라 프로그래머는 사용하는 모든 변수에 대해 자료형을 명시적으로 지정해야 하는데, 이는 번거로운 의무다.
이 모든 것이 수학적 정의를 흐리게 만든다.
이를 Haskell 같은 함수형 언어로 작성한 다음 버전과 비교해 보자.
fact n = product [1..n]이 코드는 문제 정의를 거의 직접 옮긴 것이다. 명시적인 타입도, 임시 변수도, (public 같은) 접근 제어자도 필요하지 않다.
사례 II - 내적
위 Haskell 프로그램이 간결한 이유는 해당 작업에 꼭 맞는 추상화(즉, product 키워드와 [1..n] 범위 문법)를 언어가 제공하기 때문이라고 주장할 수도 있다. 따라서 Haskell에도 Java에도 없는 간단한 함수, 즉 두 벡터의 내적을 살펴보자. 수학적 정의는 다음과 같다.
3차원 벡터의 경우 다음과 같이 쓸 수 있다.
먼저 Haskell 구현이다.
type Scalar a = a
data Vector a = Vector a a a deriving (Show)
dot :: (Num a) => Vector a -> Vector a -> Scalar a
(Vector a1 a2 a3) `dot` (Vector b1 b2 b3) = a1*b1 + a2*b2 + a3*b3수학적 타입을 각각 한 줄로 정의할 수 있다는 점에 주목하자. 또 dot 함수를 중위 표기법으로 정의한다는 점에도 주목하자. 즉, dot의 첫 번째 인수를 함수 이름 앞에, 두 번째 인수를 뒤에 둔다. 이렇게 하면 코드가 수학적 표현과 더 비슷해진다. 위 함수를 호출하는 예는 다음과 같다.
(Vector 1 2 3) ’dot’ (Vector 3 2 1)짧고 정확하며 읽기 쉽다.
이제 Java로 유사하게 구현한 버전이다.
public static class Vector<T extends Number> {
private T x, y, z;
public Vector(T x, T y, T z) {
this.x = x;
this.y = y;
this.z = z;
}
public double dot(Vector<?> v) {
return (x.doubleValue() * v.x.doubleValue() +
y.doubleValue() * v.y.doubleValue() +
z.doubleValue() * v.z.doubleValue());
}
}
public static void main(String[] args) {
Vector<Integer> a = new Vector<Integer>(3, 2, 1);
Vector<Integer> b = new Vector<Integer>(1, 2, 3);
System.out.println(a.dot(b));
}
}벡터를 제대로 된 텍스트 형태로 표현하려면 toString() 메서드도 오버라이드해야 한다. Haskell에서는 코드에서 본 것처럼 Show 타입클래스에서 단순히 derive하면 된다.
새로운 추상화 만들기
함수와 타입만으로는 명료한 프로그램을 작성하기에 충분하지 않다면, Haskell은 언어 코어 자체를 확장하는 새로운 연산자와 키워드를 만들 수 있는 간단한 구문을 제공한다. 덕분에 도메인 특화 언어를 만드는 것이 가능해지고, 개발자는 (메모리 관리나 배열 순회 같은) 프로그래밍 언어 자체의 특이성을 우회하는 대신 실제 문제에 더 직접적으로 집중할 수 있다. Haskell은 이러한 개념을 적극 수용한다. Java에는 이런 기능이 없다.
결론
여기서 Java를 깎아내리거나 Haskell을 떠받들려는 의도는 아니다. 두 언어 모두 각자의 자리가 있다. 단지 많은 프로그래머가 읽을 수 있기 때문에 Java를 예로 든 것뿐이다.
이 비교는 수치 및 기호 프로그래밍에 대한 함수형 접근과 명령형 접근 사이의 비교에 가깝다. 그리고 그 분야에서는 나는 언제나 함수형 접근을 선호한다. 불필요한 요소를 걷어내고 우아한 해법을 만들어내기 때문이다. 높은 추상화 수준에서 작업하고 수학적 용어로 이야기할 수 있는 편리한 방법을 제공함에도, 많은 프로그래머가 이러한 강점을 간과한다.
1966년 저서 The Psychology of Science에서 에이브러햄 H. 매슬로가 한 관찰이 적절해 보인다.
“유일하게 가진 도구가 망치뿐이라면, 모든 것을 못인 양 다루고 싶은 유혹에 빠지기 쉽다.”
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기