Haskell은 작은 함수를 조합하는 방식, Lisp는 옵션이 많은 만능 함수를 선호하는 방식이라는 철학과 실천의 차이를 간단한 리스트 처리 예제로 대비하고, 스트림 퓨전과 순수성의 역할을 언급한다.
UPDATE 2020-08-03: 더는 이 글의 내용을 지지하지 않습니다. 전반적인 정서는 어느 정도 맞다고 생각하지만, 글 속의 세부 내용은 (수년 간 많은 분들이 지적했듯) 틀렸습니다.
지적되었듯, remove-if-not의 start/count 매개변수는 동작이 다르고 함수에서 쉽게 분리될 수 없으며, 이는 제가 높이 평가하는 설계상의 절충입니다.
또한 지적되었듯 Clojure는 조합적인 스타일과 게으름(laziness)을 허용합니다. 덧붙이자면, 게으름은 스트림 퓨전에 꼭 필요하지는 않으며(순수성만으로도 충분합니다), 다만 사용성 측면에서는 도움이 됩니다.
이 글을 완전히 내리고 싶지는 않습니다. 수년 동안 HN/reddit에서 돌기도 했고요. 다만 별로 좋지 않은 글이었다고 인정하는 데에 그치고 싶습니다. 계속 읽어도 좋지만, 적당히 걸러서 읽어 주세요.
Lisp(예: Common Lisp, Emacs Lisp)과 Haskell의 철학 차이 중 하나는, 후자가 하나의 일을 하는 아주 작은 함수들을 폭넓게 사용하는 반면, Lisp에서는 함수가 동작을 구성하는 다양한 옵션을 많이 받는 경향이 있다는 점입니다. 전자를 조합성(composability), 또는 유닉스 철학이라고 부릅니다. 후자를 모놀리식 접근(monolithism), 즉 키친싱크나 스위스 아미 나이프 같은 만능 함수를 만드는 방식이라고 부릅니다.
어느 쪽이 더 나은지는 다른 글에서 논할 수 있겠습니다. 여기서는 이런 철학과 실천에 실제로 차이가 있음을 간단히 보여주고 싶습니다. 저는 사소하지 않은 수준의 Emacs Lisp(그리고 조금의 Common Lisp; Common Lisp 시스템을 유지보수해 봤습니다)과 사소하지 않은 수준의 Haskell을 꽤 작성해 봤으니 어느 정도 판단할 위치에 있다고 봅니다.
미리 밝히자면: 누구나 이해할 수 있는 사소한 예시들만 보겠습니다. 그리고 이 예시들이 해당 언어들에서 소프트웨어가 일반적으로 작성되는 방식을 대표한다는(검증되진 않았지만) 전제를 깔겠습니다.
프로그래머라면 누구에게나 익숙할, 리스트를 다루는 예시를 보죠. 예를 들어, CL에는 remove-if-not라는 함수가 있습니다. 문서에 나오는 시그니처는 다음과 같습니다.
(REMOVE-IF-NOT predicate seq :key :count :start :end :from-end)
하나의 함수에 여러 아이디어를 욱여넣은 것입니다.
이에 비해, Haskell에는 filter 함수가 있습니다:
filter :: (a -> Bool) -> [a] -> [a]
문제: “리스트에서 처음 세 개를 제외하고, 그중에서 술어 p를 만족하는 원소들만 취하되, 그 가운데 처음 다섯 개만 취하라.” Common Lisp에서는 다음처럼 아주 간결하게 표현할 수 있습니다:
(remove-if-not #'p xs :count 5 :start 3)
Haskell에서는 이렇게 표현합니다:
take 5 . filter p . drop 3
Haskell을 알든 Lisp을 알든, 차이는 분명합니다. Lisp 코드에서는 하나의 함수가 여러 동작을 수행하고, 그 동작들을 인자로 구성합니다. Haskell 코드에서는 하나의 일만 하는 서로 다른 세 함수를 씁니다:
take ∷ Int -> [a] -> [a]
filter ∷ (a -> Bool) -> [a] -> [a]
drop ∷ Int -> [a] -> [a]
. 연산자는 유닉스의 파이프처럼 함수를 합성합니다. 유닉스에서는 대략 이렇게 표현할 수 있겠습니다:
bash-3.2$ cat | tail -n '+4' | grep -v '^p' | head -n 5
1
2
3
4
5
6
7
8
9
10
여기서 Ctrl-d를 누르면 다음과 같이 됩니다:
4
5
6
7
8
유닉스의 파이프처럼, 이 함수들도 함께 합성되었을 때 성능을 잘 내도록 영리하게 동작합니다. 즉, 매번 리스트 전체를 순회하며 새 리스트를 만들지 않고, 각 항목은 필요할 때 생성됩니다. 실제로는 스트림 퓨전 덕분에 코드가 하나의 빠른 루프로 컴파일됩니다.
만약 술어를 만족하지 않는 것들을 원한다면, not과 다시 합성하면 됩니다:
take 5 . filter (not . p) . drop 3
Common Lisp에서는 합성(composition)을 거의 사용하지 않기 때문에 표기가 조금 장황하며, 대신 이를 위한 또 다른 함수가 있습니다:
(remove-if #'p xs :count 5 :start 3)
(아마 더 Lisp다운 접근은 remove-if 함수에 :not 키워드 인자를 두는 것이었을지도 모릅니다.)
이런 키친싱크식 접근의 가장 극단적인 예는 잘 알려진 LOOP 매크로입니다.
문제: 5보다 작은 모든 원소를 고른 뒤, 그 집합에서 짝수만 취하라.
LOOP 매크로를 쓰면 다음처럼 쉽게 표현됩니다:
> (loop for i in '(1 2 3 4)
when (evenp i)
collect i
when (> i 5) do (return))
(2 4)
Haskell에서는 두 개의 별도 함수를 사용해 이렇게 표현합니다:
λ> (filter even . takeWhile (< 5)) [1..4]
[2,4]
Haskell에서는 벡터 라이브러리, 텍스트 라이브러리, 바이트 라이브러리에도 같은 원리가 적용되어 서로 퓨전될 수 있습니다. 퓨전은 주로 순수성의 이점입니다. 부수효과가 없음을 알 수 있다면 n개의 루프를 하나의 루프로 합칠 수 있습니다. 이런 이점은 Idris, PureScript, Elm 같은 다른 순수 언어에도 적용될 수 있습니다.