NRAO 채용 면접을 위해 작성한 간단한 프로그램을 Haskell, Common Lisp, Smalltalk로 구현하며 세 언어의 스타일과 장단점, 그리고 개인적인 프로그래밍 경험을 비교한 글입니다.
NRAO에서 면접을 지원하는 구직자들을 돕기 위해 최근에 간단한 "콘테스트" 프로그램을 작성했습니다. 자세한 내용은 밝힐 수 없지만, 핵심은 스캔 목록이 들어 있는 파일을 읽어서, 그릇을 움직이고 스캔하는 데 걸리는 시간을 계산해 보고하는 것입니다. 지원자들은 Java로 이 프로그램을 작성해야 하지만, 저에게는 그 제한이 적용되지 않아, 당연히 Java가 아닌 세 가지 언어, Haskell, Common Lisp, 그리고 Smalltalk로 이것을 작성해보았습니다.
이런 언어 게임을 하면 항상 뭔가 배우게 되거나, 최소한 기존에 알고 있던 사실을 확인하게 되는데, 이번에는 정말 예상치 못한 걸 발견했습니다. Haskell을 사용할 때의 즐거움이 Haskell의 적합성이나 사용성에 거의 영향을 받지 않는다는 점이었습니다. 오히려, 다른 어떤 것과도 비교할 수 없는, Haskell을 사용할 때 느끼는 감정에서 비롯된 것 같습니다. 코드의 한 부분을 보여드리죠:
haskell-- | Perform a scan and produce the scan log for it. performScan ∷ Scan → AntennaSimulator ScanLog performScan scan = wrapDuration $ do slewTime ← moveTo $ scanPosition scan -- move to the scan's position onSourceTime ← waitFor $ scanLength scan -- wait for the scan to finish return $ Log scan onSourceTime slewTime
이 문제를 구현하기 위해 사용했던 다른 언어로는 이렇게 아름다운 코드가 나오지 않더군요. Smalltalk와 비교해보면:
smalltalkScan ≫ runWith: anAntenna runWith: anAntenna "Performs this scan on the supplied antenna." | onSourceTime slewTime | slewTime := anAntenna moveTo: self position; lastActionDuration. onSourceTime := anAntenna wait: self duration; lastActionDuration. ^ ScanResult withScan: self slewTime: slewTime onSourceTime: onSourceTime
그리고 Lisp에는 이렇게 작성했습니다:
lisp(defun run-scan (antenna scan) "Perform a scan on this antenna. Return a scan log." (let* ((slew-time (move-to antenna (scan-position scan))) (on-source-time (delay antenna (scan-length scan))) (total-time (+ on-source-time slew-time))) (with-slots (last-duration) antenna (setf last-duration total-time) (make-scan-log scan slew-time on-source-time))))
이상하게도 Smalltalk가 Haskell과 꽤 비슷함에도 불구하고, Haskell 코드가 더 짧고 명확하다고 느꼈습니다. 보기엔 안 그렇지만 Haskell이 더 모듈화되어 있기도 하죠. wrapDuration
명령은 다음에 실행될 일련의 작업을 lastActionDuration
하나로 세도록 정리해주면서, 래핑된 동작 내부에서 lastActionDuration
을 사용하는 것에는 관여하지 않습니다. 이 개념은 다른 언어로는 거의 포팅이 불가능하더군요. Haskell 버전은 실제로 안테나를 구동하는 동작의 조립 가능한 모음처럼 느껴지지만, Smalltalk 버전은 수학적 조작처럼 느껴지고, Lisp 버전에서는 추상화가 많이 새어나옵니다.
Lisp 코드는 정말 끔찍합니다. 그전까지는 Lisp의 어떤 점이 싫은지 정확히 짚을 수 없었는데, 이번에 깨달았습니다. Lisp 코드는 종종 영리하고 흥미롭지만, 항상 내가 원하는 바를 단순히 표현하기보다는 Lisp을 속여서 원하는 걸 시키는 느낌이라는 겁니다.
Lisp도 Smalltalk도 제 스타일에 잘 맞지 않는 것 같습니다. 이제 보니, 제 스타일은 모든 것을 최대한 작은 단위로 나눠, 일이 아닌 것처럼 보이도록 만드는 것이네요. Haskell은 where
구문 덕분에 X는 정말로 Y+Z이고, Y는 이거, Z는 저거라고 자연스럽게 분해할 수 있도록 해줍니다. 예를 들어:
haskell-- | To calculate the time to move between two positions, we take -- whichever is larger of the time to turn and the time to change -- elevation. timeToMoveTo ∷ Position → Position → Time timeToMoveTo (sourceAz, sourceEl) (destAz, destEl) = max rotationTime ascensionTime where rotationTime, ascensionTime ∷ Time rotationTime = timeToRotate sourceAz destAz ascensionTime = timeToAscend sourceEl destEl
이건 거의 코드처럼 보이지도 않네요. Haskell을 모르는 프로그래머라도 대충 무슨 일이 벌어지는지 감을 잡을 수 있을 것 같습니다. 근데 아래의 Lisp 코드는 그렇게 말하기 힘드네요:
lisp(defun time-to-move (from-pos to-pos) "Calculates the total time to move between two positions, assuming rotation and ascension can occur simultaneously." (with-accessors ((from-az azimuth) (from-el elevation)) from-pos (with-accessors ((to-az azimuth) (to-el elevation)) to-pos (max (time-to-rotate from-az to-az) (time-to-ascend from-el to-el)))))
Lisp 사용자들은 문법의 프로그래머빌리티와 습득의 쉬움을 자주 이야기합니다. 하지만 문법은 여전히 존재하고, 단지 추가적인 단어나 기호 없이 위치적으로만 인코딩되어 있죠. 예컨대, Lisp 코드에서 azimuth
와 elevation
은 접근자 함수입니다. 바깥쪽 with-accessors
매크로는 이런 식으로 동작하죠:
smalltalktimeToMoveFrom: from_pos to: to_pos | from_az from_el | from_az := from_pos azimuth. from_el := from_pos elevation. ...
Lisp는 굉장히 간결하지만, 여전히 내가 무언가를 표현하기보다는 뭔가를 속여서 동작하게 만들고 있다는 느낌을 받습니다. 아마 더 명확한 Lisp 코드도 가능하리라 생각하지만, 저는 그 방법을 모릅니다. Haskell에 너무 오래 의존해서, 이제는 Haskell식의 명확함이 저 자신의 명확함이 되어버렸거든요.
아마 이것이 Haskell에 대한 저의 애정이 비합리적임을 방증하는 부분일 것 같습니다. Haskell이 내가 아는 모든 언어보다 더 우수하다고 자신 있게 말할 수도 없고, 이미 프로그래밍을 할 줄 아는 실용적 성향의 사람들에게 Haskell을 가르치는 것만큼 답답한 일도 상상할 수 없습니다. 그리고 쓸수록 제 스타일은 점점 더 별난 방향으로 갑니다. (저는 monad transformer의 대부분 사용예는 함수 분해 실패라고 봅니다.) 하지만 Haskell에서는 코드가 이상하게 깨질까 봐 잠도 못 이룰 일은 별로 없습니다. 물론 Haskell도 깨질 수는 있지만(그래도 아마 덜 자주겠지요).
이번 실험에서 또 한 가지 놀라웠던 점은, 효과적으로 프로그래밍하는 데 컴파일러나 인터프리터에 얼마나 의존하는지 발견한 것입니다. Dijkstra를 우상화하고 John Shipman, Al Stavely와 친분이 있음에도, 코드를 실행하기 전에 분석하는 것에 전혀 소질이 없습니다. 이 문제는 제가 사용하는 모든 언어에 영향을 미치지만, 아마도 Haskell을 제외한 다른 모든 언어를 존중하지 않기 때문에 더 심한 것 같습니다. Haskell은 정말 어이없는 일을 하려고 할 때, 내가 뭘 하고 있는지 이해하지 못하는 단계에서 훨씬 일찍 걸러냅니다. 반면 Lisp와 Smalltalk는 열 줄, 스무 줄이나 코드를 짜다가 문법적으로는 오류 없지만 완전히 잘못된 방향으로 설계돼 코너에 몰리는 경우가 많았습니다. 다른 언어들도 더 많은 연습을 한다면 더 깔끔한 코드를 적게 낭비하면서 쓸 수 있으리라 생각하지만, Haskell은 그런 걸 진짜 강제로 하게 만듭니다.
친구가 한 번 이런 말을 했죠. 외국어를 배우는 건 그 언어를 단순히 아는 데 목적이 있는 게 아니라, 그 나라의 시를 읽기 위해서라고. 우리는 최고의 시를 읽으려고 히브리어, 그리스어, 아랍어를 정교하게 공부하는 세상에 살고 있죠. Haskell이 항상 더 짧고, 항상 더 읽기 쉽다고 주장하는 건 오히려 우습습니다. Haskell로는 손도 대고 싶지 않은 분야(예: 웹 개발)도 있으니까요. 이것이 종교 시에는 히브리어를, 유머에는 영어를 선호하는 것과 비슷하지 않을까요?
많은 사람들이 Java나 C 같은 언어의 단순함 때문에 이들을 사용하고 가르치는 걸 좋아합니다. 제 아내도 C로 이런 훈련을 받은 적 있는데, 함수 호출, 순차 처리, 불리언 논리 같은 개념이 결코 직관적이지 않다는 걸 옆에서 보며 깨달았습니다. 저도 충분히 오래 전을 떠올려보면, 그 역시 저에게 직관적이지 않았고, 아주 오래 전에 그 다리를 건넜기에 거의 잊고 있을 뿐입니다. 그래서 Smalltalk이나 Haskell같은 생소한 언어들이 초보자 교육의 후보군에서도 제외되는 걸 보면 아쉽습니다. Haskell 배우기 전에는 범주론이 뭔지도 전혀 몰랐고, 지금도 별로 아는 게 없지만, 덕분에 간결하고 정확한 프로그램을 작성할 수 있었고, 그 성능을 추론할 수도 있습니다. 물론, Haskell 실행 모델을 이해하는 것은 주로 C 및 다른 언어들과 어떻게 다른지 관점에서이지만요. 그래도 예전엔 C의 실행 모델을 배우며 놀라고 감탄했던 기억도 있고, 그걸 다 이해하기 전에 충분히 생산적으로 C를 썼으니, Haskell도 마찬가지일 수 있지 않을까요?
여기에 간결하고 설득력 있는 결론을 삽입하고 싶지만, 프로그래밍은 도착지가 아니고 모든 결론은 임시적이라서 딱히 그런 결론이 있는 것도 아닙니다. 더 배울수록 불편함도 커지죠. 이를테면, Haskell이 그렇게 훌륭하다면 왜 좋은 의존성 관리가 없는 걸까요? Lisp나 Smalltalk는 왜 아직까지도 수동으로 실수 수정에 의존하고, Haskell처럼 강력한 추론 타입 시스템은 왜 못 갖는 걸까요? 더 중요하게는, 코드 진화와 실패를 포용하는 Smalltalk 같은 환경에는 왜 정말 애착을 갖지 못하는지, 그리고 컴파일 타임 정확성을 극도로 중시하는 시스템을 좋아하면서 코드 작성에는 빠른 반복을 더 의존하게 된 이유는 뭘까요?
앞으로 더 다뤄보고 싶은 유망한 옵션 중 하나는 Autotest입니다. 이 툴은 코드가 바뀔 때마다 어떤 테스트를 실행할지 자동으로 감지해서 실행해주며, TDD와 함께 사용할 수 있습니다. 이건 아마 강한 타입 시스템보다도 훨씬 더 강력할 수도 있겠지만, 만약 테스트하려는 것이 I/O라면? 그건 다음에 생각해볼 문제네요.
2011년 10월 30일