Abstract
Functional language 중 하나인 Haskell은 코드의 안정성을 위해 제안되었던 몇가지 특징이 강하게 남아있는 언어이다. 그 중심에 바로 함수인데, Haskell은 현대에 주류로 자리잡은 언어의 구조와 상당히 다른 형태를 갖고있다. 이 문서에서는 언어에 대해 공부하면서 참고했던 자료 일부와 함께 함수 구조 자체를 기본으로 기록한다.
Basis
가장 많이 참고했던 수학 이론은 바로 집합이다. 현대 수학에서 집합을 파고 들어가면 한도 끝도 없이 파고들게 되고 얼마 안가서 대체 뭘 하려고했는지 잊어버리게 되는데, 프로그래밍 언어에서는 그 정도로 고도화된 이론을 바탕으로 하지 않는다. 하지만 그럼에도 수학에서 정의되는 집합과 대응이라는 개념을 이해할 필요가 있다. 집합에 대해 알아야 할 것들은 아래와 같다.
Useful knowledge in the Set theory
위키피디아에서는 집합(Set)을 이렇게 말하고 있다.
어떤 명확한 조건을 만족시키는 서로 다른 대상들의 모임이다.(집합 in Wikipedia)
수학이라는 분야 자체에 얽매이면 이 대상을 숫자 이외에 더 넓은 개념으로 확장시키지 못하기도 하는데, 집합에서 대상은 반드시 숫자일 필요가 없다. 집합에서 다루는 대상은 숫자나 수식, 문자, 사물, 개념 등등 모든 것이다. 물론 이 방향으로 사고를 확장하다보면 집합이 대상인 집합을 생각하게되면서 러셀의 역설에 맞닥들일 수 있기 때문에 더 엄밀한 정의를 필요로 한다면 현대 수학의 기반이 되고있는 ZFC 공리계을 살짝 찾아볼 수는 있다.
Haskell에서 집합이 어떻게 표현되는지는 다음에 한번 더 정리를 해볼 예정이다.
About function
현대에 주류 언어에서는 거의 함수라는 용어로 통용되어가는것 같다. 하지만 원래 비슷한 개념들이 프로그래밍 언어에는 아주 많았는데, Function, Procedure, Subroutine, 그리고 OOP에서 사용되는 Method이다. 모두 비슷하지만 다른 용어이다.
프로그램 코드 덩어리를 하나의 작업으로 보면 항상 같은 과정으로 작업을 수행하기 때문에 Routine 이라고 부른다. 이 Routine을 분할하면 각각을 Subroutine이라고 부른다. Procedure는 프로그램 코드를 특정한 목적을 달성하기위한 Process라고 할 때 그 Process 묶음을 Procedure라고 부른다. Method란 어떤 사람, 동물, 기계, 각종 사물, 개념적인 뭔가가 하는 행위 자체를 의미한다. 이 개념은 Procedure와 Routine을 포함하고 있다. 그래서 Method는 항상 Class 안에서만 정의되고 Class로 생성된 Object에서 호출한다.
이런 점에서 Function은 다른 개념과는 큰 차이가 있는데, Function은 본질적으로 두 집합간의 대응을 의미기 때문이다. 그래서 다른 개념은 실행 시점의 상태(state)에 따라서 같은 입력에도 다른 결과가 발생할 수 있다. 하지만 Function은
로 정의할 수 있다. 이러한 함수의 정의가 보여주는 핵심은 ‘입력값이 같으면 항상 같은 결과를 반환하는가’의 여부이며, 이 조건이 충족되는 경우에만 진정한 의미의 함수(pure function)라고 부른다.
이 특징이 Haskell과 같은 프로그래밍 언어에서 어떤 의미를 갖는지 이해하는 것이 중요하다.
Function in Haskell
위의 내용을 바탕으로 Haskell의 함수를 바라보아야 Haskell 코드의 작동 방식을 제대로 이해할 수 있다. 간단한 예로 $f(x) = x + 1$ 을 Haskell 문법으로 표현하면 이렇게 된다.
f :: Num -> Num
f x = x + 1
여기에서 f가 이름, Num이 데이터 타입, x가 Argument 이다. Haskell에서는 함수 자체에 다양한 문법 기능을 지원한다. 여기서부터 Haskell에서 함수에 제공하는 문법의 핵심이 되는 문법들을 압축해서 기록해둔다.
Pattern matching
이 기능은 수학에서 흔히 쓰이는 표현과 대응된다. 간단한 함수를 예로 들어서 보면 이런걸 들 수 있다.
이 함수를 Haskell 문법으로 표현하면 이렇게 표현한다.
y :: Num -> Num
y x
| x >= 0 = x + 1
| x < 0 = 1
여기서 |를 가드(Guard)라고 부른다. Haskell 에서는 Procedure language 패러다임에서 보이는 분기 구문이 없기 때문에 이러한 방법으로 조건에 따라 다른 연산을 할 수 있게 수식을 분리할 수 있다. 위에서 이야기했던 함수의 특징에는 순차실행의 개념이 없기 때문이다.
Recursive function
프로그래밍 코드를 해석하는 컴파일러나 인터프리터는 코드 안에 정의되어있는 함수 안에서 같은 함수를 또 쓸 수 있다. 이런 함수를 재귀적 함수 라고 부르는데, 수학에서 가장 쉽게 볼 수 있는 형태는 바로 집합을 표현하는 방법 중 하나인 점화식이 있다. 예를 들면 이런 것들이 있다.
이 함수는 그대로 Haskell로 표현하면 이렇게 된다.
a :: Int -> Int
a x
| x > 1 = a(x-1) + 2
| x == 1 = 1
이 코드를 약간 해석한다면 함수는 정수 값을 입력 받아서 정수 값을 반환하는 함수인데, 이 점화식으로 표현되는 집합의 x 번째 수를 구하는 함수가 된다. 이러한 재귀적 함수를 작성할 때는 반드시 재귀가 종료되는 지점을 알려주어야 한다.
여기에 약간 공학적인 관점에서 이유를 붙이자면, 컴파일러는 함수를 호출하는 시점을 메모리 스택에 기록해두는데, 호출 한 함수가 끝났을 때 다시 호출시점으로 돌아오게 하기위함이다. 그런데 메모리에는 항상 한계가 있어서 무한 재귀함수는 공학적으로 불가능하기 때문에 시스템은 늘 재귀 한계를 정의해둔다. 따라서 이 한계를 넘어서는 순간 Stack overflow, 혹은 Segmentation fault로 프로그램을 강제로 종료시켜버린다. 이런 조치가 없으면 프로그램 카운터가 이 프로그램이 할당받은 메모리 양을 무단으로 넘어가게되고 확률적으로 여러 다른 프로그램에 말도 안되는 영향을 줄 수 있기 때문에 OS가 메모리를 관리하려는 차원에서도 강제로 종료시키는 것이다.
Haskell 에는 Lazy evaluation 등의 다양한 최적화 기법들을 이용해서 메모리가 무작정 낭비되는 문제를 피하기 떄문에 쉽게 오류를 볼 수는 없겠지만 무한히 긴 실행시간을 보게된다. 무슨말이냐면, 재귀 함수가 무한히 반복되지 않게 해야 한다는 것이다.
Lambda expression
함수를 표현하는 또 하나의 문법으로 익명 함수를 의미하는 Lambda expression이 있다. 이 표현식은 “Lambda calculus”에서 사용되는 표기법을 옮긴 것으로, 간단한 구조의 함수들은 인라인에서 정의될 수 있음을 활용하는 것이라고 할 수 있다.
가령, 앞에서 예로 들었던 함수 $f(x) = x + 1$ 의 경우 이 표현식을 위해서 함수를 정의하기 보다는 \x -> x + 1 로 표현하는 식이다. 이런 굉장히 단순해보이는 표기법의 변경에서 자칫 Lambda expression이 문법을 단순화해주는 도구로 오해할 수 있지만, 이 표현식의 핵심은 모든 계산이 함수로 표현될 수 있다는 Lambda calculus의 핵심 개념과 닿아있다. 즉, 앞에서 보인 함수 표현식은 $\lambda x.x+1$과 직접적으로 대응되는 표현이다.
Application of function
다시 말하자면 함수는 Subroutine, Procedure, Method와 다른 방식으로, 보다 수학적인 의미의 함수로 활용이 가능하다. 아래에는 그런 활용 방식을 나열해본다.
일급 함수로서의 Lambda expression
Haskell과 같은 Functional programming language는 함수를 객체화한다. 가장 간단한 예로 이런 함수를 정의해보겠다.
f::(a->a) -> a -> a
f t x = (t x) + 1
이렇게 정의한 함수를 아래와같이 호출하는 것이다.
v = f (\n -> 2 * n) 10
이 호출 결과 v는 21이 되고, 여기서 함수 f의 파라미터인 t를 일급 함수라고 할 수 있다.
고차 함수로서의 Lambda expression
고차함수, Higher order function은 함수를 인자로 받을 수 있고, 반환결과로 함수를 반환하는 함수를 가리킨다. 뒤에서 좀 더 자세히 보겠지만, currying으로 아주 자연스럽게 구현되어있는데, 항목 1의 예제 함수와 같은
f:: Num a => (a->a) -> a -> a
f t x = (t 2*x) / 2
라고 할 때, 함수 f 자체가 고차 함수라고 할 수 있다. f가 고차함수로 활용되는 예를 이렇게 할 수 있다.
g = f \x->(sqrt x)
v = g 10
이 때는 함수 f의 반환형도 바로 함수 g이다.
Closure
클로저는 고차 함수에서 파생되는 개념으로, 고차함수를 정의할 때 반환되는 함수에서 하나 이상의 값으로 표현되는 실행 환경을 나중에 정의할 수 있도록 변수화 하는 기법이라고 표현할 수 있을 것 같다.
수식으로 표현해본다면
이 수식에서 a = 1, b = 2라고 정의하면
이렇게 만들어진다.
그런데 이 a, b도 함수의 파라미터로 정의할 수 있는데, 바로 Haskell 문법에서 이렇게 적을 수 있게된다.
g::Num a => a->a->(a->a)
g a b = \x->a*x+b
f = g 1 2
코드가 이런 구조일 때 함수 g는 고차함수가 되고, a, b가 클로저이다. 즉, 함수 g를 호출하는 것으로 상수인 계수 a, b를 고정하는 것으로 함수 f가 정의되는 시점의 상태를 고정한 함수의 부분 적용과 같다고 볼 수 있다. 아래는 좀 더 실용적으로 표현해본 예제이다.
g::Num a => a->(a->a)
g a = \x->a*x
makeDouble = g 2
4 == makeDouble 2
makeTriple = g 3
6 == makeTriple 2
Composition
집합에서 함수 $f:A \to B, g:B \to C$ 일 때, 합성 함수 $g \circ f:A \to C$ 라고 표현할 수 있는데, Haskell 언어에서도 이 표현식을 문법으로 지원하고 있다.
f x = x + 1
g x = 2 * x
5 == f.g 2
길게 볼 필요 없이 이렇게 된다. 이때 f.g와 $f \circ g$는 동일한 표현이다.
Currying
이 언어의 이름인 Haskell은 원래 지금 설명하는 Currying과 함께 수학자인 Haskell Curry (wikipedia)의 이름에서 따온 이름이다.
Currying는 원래의 수학적 의미가 다변수 함수를 단변수 함수의 중첩으로 변환하는 작업을 의미한다. Haskell에도 이 의미가 그대로 적용되어 아래와 같은 변화가 문법 수준에서 지원된다.
f::(a,a) -> a
f x y = x * y
이렇게 표현하면 (a, a)는 같은 타입으로 전달되는 두개의 파라미터를 가리키는데, 이 표현은 이렇게 바꿀 수 있다.
f::a->a->a
f x y = x*y
그리고 이렇게 표현된 함수는 이런 식으로 활용이 가능하다.
makeDouble = f 2
6 == makeDouble 3
Conclusion
가능하면 간단하게 함수의 활용에 대해 정리하고 이게 Haskell에서 어떻게 표현되는지 정리해봤다. 너무 짧게 정리하려고 했더니 논리적인 비약도 있고 건너뛴 점도 많고 왜곡도 있을 수 있지만 대충 아는대로 정리한 내용이라서 나중에 더 많이 알게되면 좀 더 내용을 보완해서 새로운 글로 만들어야 할 것 같다. 특히 함수에 대해서는 Haskell을 포함해서 Functional programming 자체를 이해하기 위해 집합론을 대충 훑었던 지식을 짜집기한 결과이기 때문에 실제로는 아닌 내용이 있을 수 있다.
물론 이것 만으로는 프로그램을 만들 수 없기 때문에 이 다음에는 타입 시스템에 대해서도 다뤄볼까 한다.