Rich가 여러 해에 걸쳐 메일링 리스트, 글, 대화에서 남긴 답변을 주제별로 정리한 컬렉션.
자주 묻는 질문들, 설계 결정, 클로저가 그런 모습이 된 이유 등을 Rich가 직접 남긴 답변(여러 해 전의 것이라도 오늘날에도 거의 유효합니다!)을 모았습니다. 친구나 동료가 같은 질문을 다시 할 때 이곳을 가리켜 주세요. 답변은 메일링 리스트, 글, 채팅에서 인용 그대로 붙였고(가독성을 위해 소소한 조정은 했지만 문장을 바꾸지는 않았습니다).
사용법:
namespace에 함수를 묶어 넣어야 할까?insert, lookup, append가 없나?(nth nil 1)은 범위를 벗어난 예외를 던져야 하지 않나?데이터의 규정적 측면은, 어떤 시점의 우주에 대한 사실/관찰의 기록을 반영한다는 것입니다(이게 ‘데이터’의 의미이며, 프로그래머가 생겨 디스크에 올린 임의로 갱신 가능한 비트들에 이 말을 갖다 붙이기 훨씬 이전부터 그런 뜻이었습니다). 데이터의 두 번째로 중요한 측면은, 데이터는 아무것도 하지 않으며(부수효과가 없음), 할 수도 없다는 점입니다. 세 번째 측면은 변하지 않는다는 것입니다. 그 정적인 본성이 핵심이며, 데이터가 “좋은 아이디어”가 되는 이유입니다. 여기서 “좋은 아이디어”란 현실과 상관관계를 갖는 추상화입니다. 사람들은 관찰을 기록하고 그 기록(과거의 것들)이 데이터입니다. 이 대화에서만은 아닌데, 누군가 당신이 데이터를 가지고 있다고 말하면, 나는 당신이 무슨 뜻인지 압니다(어떤 기록된 관찰들). 그 관찰의 해석은 완전히 직교적입니다.
‘데이터’라는 아이디어에는 사실/관찰을 전달하기 위한 포맷/레이블/공통 언어의 사용이 결여되어야 한다는 암시는 없습니다. 오히려 그것을 요구합니다. 데이터는 단순한 신호가 아니며, 그래서 두 개념/단어가 다른 것입니다. ‘42’ 자체는 사실(datum)이 아닙니다. ‘데이터’의 최소 충분성은 무엇인가 하는 질문은 유용하고 흥미롭습니다. 예를 들면, 데이터는 항상 시간을 포함해야 하는가, 레이블을 대역 내/외 어디에 둘 것인가, datom 단위로 할 것인가 데이터셋 단위로 할 것인가, 출처(provenance)를 어떻게 다룰 것인가 등입니다. 이는 데이터라는 아이디어 자체와는 관계가 없고, 데이터를 잘 표현하는 방법과 관련이 있습니다.
하지만 그러한 레이블링을 더 일반적인 해석과 동일시하는 것은 실수입니다. 예컨대 사실을 동적 인터프리터 뒤에 두는 것(동일 질문에 시간에 따라 다르게 답할 수도 있고, 사실과 의견/추론을 섞거나 부수효과를 가질 수도 있는 것)은 분명 데이터라는 아이디어를 넘어서고(그리고 깨뜨립니다). 바로 그렇기 때문에 우리는 데이터라는 아이디어가 필요합니다. 그것이 일어나고 있는지 아닌지 구분하고 이야기할 수 있어야 하니까요 — 나는 사실, 과거에 대한 불변의 관찰(“왕은 죽었다”)을 다루는가, 아니면 단지 일시적(파생된) 의견(“반란이 일어날지도 모른다”)을 다루는가. 사실(생년월일)을 (여러 번) 포함하는 계산과 실시간으로 갱신되는 파생치(나이)를 비교해 보십시오. 후자는 결과가 앞뒤가 맞지 않을 수 있습니다. ‘생년월일’은 데이터이고, ‘나이’는(시간 한정 ‘as-of’를 붙이지 않는 한) 데이터가 아닙니다.
대사(ambassador)와 상호작용할 때 사실을 얻을 수도 얻지 못할 수도 있고, 시간에 따라 다른 답을 받을 수도 있습니다. 그리고 어떤 질문이 전쟁을 촉발할지 항상 두려워해야 합니다. 소비하고 추론하는 데이터에 그런 재현 불가능성과 위험이 있었다면 과학은 일어날 수 없었을 것입니다.
‘데이터’는 보편적 아이디어, 즉 모든 것을 포괄하는 원시적 단일 개념이 아닙니다. 그러나 동적 객체/대사가(그 밖의 유용함이 무엇이든) 사실(데이터)을 대체할 수 있다는 생각은 나쁜 아이디어입니다(현실과 부합하지 않습니다). 사실은 일어난 일들이며, 일어난 일들은 일어난 일들입니다(의견이 아님). 바뀔 수 없으며 새로운 부수효과를 도입할 수 없습니다. 데이터/사실은 어떤 의미에서도 동적이지 않습니다(축적될 뿐). 때로는 우리는 사실을 원하고, 다른 때는 그것들에 대해 토론할 누군가를 원합니다. 그래서 좋은 아이디어가 하나 이상 있는 것입니다.
데이터는 숫자, 사실, 기록 보관만큼이나 안 좋은 아이디어입니다. 이 모든 것은 더 잘하거나 못하거나 할 수 있는 훌륭한 아이디어들입니다. 나는 프로그래밍에서 지금까지 데이터(사실의 유지)가 아주 형편없이 다뤄졌다고 확신하며, 그 책임의 적지 않은 부분을 객체 지향 및 장소 지향 프로그래밍에 돌립니다.
namespace에 함수를 묶어 넣어야 할까?그 질문에는 먼저 짚어야 할 전제가 깔려 있습니다. 즉, 시스템의 정보 기반 엔티티에 상응하는 특화된, 이름 붙은 데이터 구조/클래스가 있어야 하며, 함수들을 그 엔티티와의 연관성에 따라 클래스나 모듈에 분할 배치해야 한다는 생각입니다. C++/Java/C#/CLOS/OODBMS를 20년 해 본 후 내린 결론은, 그건 나쁜 아이디어라는 것입니다.
실제 엔티티 — 즉 스트림, 소켓, 윈도우 등 — 에 해당하는 경우나 실제 엔티티의 시뮬레이션에서는 OO가 나쁘지 않습니다. OO가 태어난 곳이며 빛나는 곳이죠. 그러나 시스템의 정보/데이터에 적용되면, 코드는 10배 길어지고 유용성은 1/10이 됩니다.
예를 들어, 고객은 제품을 구매하지 않나요? 둘 모두를 포함하는 함수는 어디에 소속시켜야 하나요? 데이터베이스는 테이블마다 다른 함수를 갖나요? 그러지 않길 바라겠죠. 그리고 DB 쿼리가 둘 이상의 테이블의 필드를 가진 레코드셋을 반환하면, 그것에 맵핑되는 커스텀 엔티티를 정의해야 할까요? 아니요 — ORM은 더 나쁜 아이디어입니다.
데이터는 데이터이고, 소수의 아주 일반적인 데이터 구조에 데이터를 담고, 아주 많은 일반적인 함수로 그것을 조작하는 것이 핵심입니다. 이것이 클로저의 철학입니다. (물론 이게 클로저만의 고유한 것은 아닙니다)
clojure.xml이 XML을 처리하는 방식을 보세요 — 외부 엔티티, 즉 어떤 XML 소스 앞의 SAX 파서를 다루고, 그로부터 데이터를 끄집어내 맵에 담고 끝입니다. XML에 특화된 함수가 많지도 않을 것이고 많아지지도 않을 겁니다. 이제 맵을 조작하는 함수들의 세계가 적용됩니다. 그리고 XML 애플리케이션을 다루려면 더 XQuery 같은 역량이 필요하게 될 경우, 그것들은 일반적으로 맵을 위해 만들어질 것이고, 모든 맵에서 사용되고 재사용될 수 있을 것입니다. 전형적인 XML DOM — 다른 어디에서도 쓸 수 없는 방대한 함수 모음 — 과 대비해 보세요.
마찬가지로, resultset-seq는 DB 쿼리의 데이터를 곧바로 꺼내어 맵의 시퀀스로 바꿉니다. 그럼 가장 일반적으로 조작할 수 있죠. 그렇다고 데이터에 출처, 용도, 문맥별 범주화 등을 식별하는 정보/메타데이터를 태깅해서는 안 된다는 뜻은 아닙니다. 하지만 로직의 게토화를 피해야 합니다. 끝없이 반복해야 할지도 모르니까요.
요약하면, 클로저에서는 원하는 결과 — 더 적고 더 강력한 코드를 쓰는 것 — 를 얻으려면 문제를 약간 다르게 생각해야 할 수도 있다는 말입니다.
스칼라는 흥미로운 작업이라 생각합니다만, 나는 스칼라에 많은 시간을 들이지 않았으니 클로저에 대해서만 이야기하겠습니다. 내가 본 바로는, 클로저가 스칼라보다 상당히 덜 복잡합니다.
클로저는, 많은 해 동안 해 본 뒤 내린 개인적 의견 — 명령형 프로그래밍은 대부분의 상황에서 별로 좋은 아이디어가 아니다 — 에 기초합니다. 그래서 클로저가 ‘옳은 일’(FP)을 쉽게, 그리고 기본(default) 방식으로 만들었다는 것을 제약이 아니라 속성이라 봅니다. 동시성 이야기가 없더라도 더 나은 프로그램을 낳는다고 생각합니다. 그리고 동시성을 마주하면, 내 생각에 그것만이 갈 길입니다. 클로저에도 명령형 프로그래밍을 할 옵션은 있습니다 — 자바를 호출하면 됩니다. 하지만 나는 명령형 프로그래밍을 장려하고 싶지 않고, 자바는 그 일을 충분히 잘하므로 클로저는 자체적인 명령형 구문을 추가하지 않습니다.
하지만 클로저는 실제 프로그램들(단순 계산이 아닌)에서 변화의 환상이 필요함을 인정합니다. 즉, 프로그램의 여러 부분이 어떤 아이덴티티를 상태와 결부시키고 싶어 합니다. 그 상태는 프로그램의 서로 다른 시점에 따라 달라질 수 있습니다. 언어 차원에서 강제된 동시성 의미론을 가진 구성요소 안에서만 변화가 일어나게 하는 필요한 조치를 취합니다(얼랭처럼). 이는 제약이 아니라 가능케 함입니다. 제어되지 않은 변경에서는, 동시성 프로그램의 정확성이 프로그래머에게 떠넘겨지니까요.
클로저를 선택할 사람은, 그 단순함, 확장성, 동적성, 그리고 전일적(holistic) 함수형 프로그래밍/영속적 데이터 구조/동시성 모델을 가치 있게 여길 것이며, Lisp가 제공하는 힘을 이해하거나 얻고자 할 것입니다.
당신이 그렇지 않다면 괜찮습니다. 클로저나 스칼라 어느 쪽도 더 낫지 않습니다 — 다를 뿐입니다.
구조를 기반으로 한 조건 분기라는 측면의 패턴 매칭을 말한다면, 나는 큰 팬이 아닙니다. 스위치문에 대해 느끼는 감정과 같습니다 — 부서지기 쉽고 확장 불가하죠. 일부 함수형 언어에서 패턴 매칭을 많이 쓰는 것은 그 언어들에 다형성이 부족함을 보여준다고 봅니다. 단순한 사용은 괜찮습니다 — 즉 빈 리스트냐 아니냐 — 하지만 클로저의 if가 그걸 직접 처리합니다. 더 복잡한 패턴 매칭은 결국 타입에 대한 스위치가 되며, 다음과 같은 단점이 있습니다:
다형적 함수와 맵이, 내 생각엔, 더 바람직합니다. 이유는 다음과 같습니다:
나는 (defrecord Color [r g b a])를 쓰고, 이름 붙은 부분을 가진 맵을 얻는 쪽이, real*real*real*real형 색에 대해 패턴 매칭마다 구성 요소에 이름을 다시 붙이는 것(‘rgba’였나 ‘argb’였나)보다 훨씬 낫습니다.
패턴 매칭이 틀렸거나 쓸모가 없다는 말은 아닙니다. 다만, 관용적 클로저 코드에서는 그 필요성이 덜하길 바랄 뿐입니다. 확실히 나는 패턴 매칭의 바인딩 부분을 조건 부분과 분리하고 싶으며, 클로저의 어떤 구조 해체 바인딩도 리스트뿐 아니라 모든 데이터 구조 — 즉 벡터와 맵과 메타데이터 — 에서 작동하길 바랍니다.
사람들은 표준 리더(reader)로 읽혀 평가되길 기대하는 텍스트 파일에 클로저 프로그램을 넣습니다. 그리고 서로 다른 사람이 작성한 프로그램들을 결합할 수 있게 하는 것이 클로저의 설계 목표입니다. 사용자 정의 리더 매크로는 합성되지 않고 충돌합니다.
리더를 수정한 버전의 클로저를 만들 수는 있습니다 — 리더 소스가 있으니까요. 하지만 당신 버전의 리더로 작성한 프로그램은, 똑같이 한 다른 사람이 쓴 것과 호환되지 않을 겁니다. 그래서 이는 기술적/라이선스 문제라기보다, 프로그래머들이 섬을 만들도록 부추기고 싶지 않다면 나쁜 아이디어라는 점이 더 큽니다. CL에는 이 기능이 있지만, 바로 그 이유로 거의 쓰이지 않습니다 — 커스텀 리더 매크로가 필요한 프로그램은 골칫거리니까요.
사람들은 리더를 사용해 다른 언어 또는 데이터 포맷의 프로그램을 읽고 싶다고 말합니다. 컴파일러로 보내지지 않을 데이터 리더라는 아이디어 자체에 대해 일반적으로 반대하는 것은 아니지만, 그런 것의 일반성에 대해서는 회의적입니다. 리스프 스타일 리더는 한두 페이지 분량의 클로저 코드입니다. 파서 컴비네이터 라이브러리 같은 것도 만들어지고 있죠. 표준 리더를 가변적으로 만들지 않고도 이 문제를 해결할 방법이 있습니다. 리더 매크로에 대한 API와 의미론을 제공해야 하는 추가 복잡성, 그리고 그에 따라 사용자 정의를 위해 영구적으로 남겨 둬야 하는 문자들의 문제도 생깁니다.
스스로를 위한 문법을 만들려고 리더를 수정하고 싶다면, 그 문법이 널리 유용하다고 나와 커뮤니티를 설득해 보세요. 그러면 모두가 이식성 있게 사용할 수 있도록 표준 리더에 넣는 것을 고려하겠습니다.
일반적인 TCO를 말할 때, 자기 자신에 대한 재귀 호출뿐 아니라 다른 함수에 대한 꼬리 호출도 포함됩니다. 후자의 경우, 자바 호출 규약을 보존하면서(즉 인터프리팅하거나 트램펄린을 삽입하지 않고) JVM에서 현재 전체 TCO는 불가능합니다.
자기 꼬리 호출을 점프로 바꾸는 것은 쉽습니다(결국 recur가 하는 일이 그거니까요). 하지만 이를 암묵적으로 하면, 예컨대 스킴처럼 전체 TCO가 있는 언어에서 온 사람들에게 잘못된 기대를 만들 겁니다. 그래서 명시적인 recur 구문이 있습니다.
본질적으로, 단순 최적화와 의미적 약속의 차이로 귀결됩니다. 약속으로 만들 수 있을 때까지, 부분적인 TCO를 두고 싶지 않습니다.
일부 사람들은 함수 이름을 반복해 쓰는 것보다 ‘recur’를 선호하기도 합니다. 또한 recur는 꼬리 호출 위치를 강제할 수 있습니다.
완전한 TCO는 꼬리 위치의 모든 호출(같은 함수이든 다른 함수이든)을 최적화합니다. JVM 스택과 호출 규약을 사용하는 언어(예: 클로저, 스칼라)는 TCO를 할 수 없습니다. 이를 위해서는 바이트코드 명세가 제공하지 않는 스택 조작 능력이 필요하거나 JVM 차원의 직접 지원이 필요하기 때문입니다. 후자가 JVM上的 함수형 언어에 대한 최선의 희망이지만, tail call이 JRuby/Jython/Groovy에서 관용적이지 않기 때문에 Sun(현 Oracle)의 우선순위인지 확신하지 못하겠습니다.
내 생각에, 언어가 전체 TCO를 갖거나, 아니면 단순히 제어 흐름에 함수 호출을 지원하지 않는 것입니다. 그리고 그런 용어를 사용하지 말아야 합니다. 자기 꼬리 호출 최적화를 구현하지 않은 이유는, TCO에 의존하는 사람들에게 일부 호출은 최적화되고 일부는 안 되는 것이, 기능이라기보다 오류의 처방이 될 수 있다고 보기 때문입니다.
그래서 클로저에는 recur가 있습니다. 스택을 늘리지 않는 유일한 상황을 분명히 표시하고, 진짜 TCO가 없음을 인정하며, 제어 흐름에 함수 호출을 지원한다고 가장하지 않고, JVM의 TCO를 애타게 기다리며, 그 시점에는 이를 완전히 지원할 것입니다.
recur 외에도 lazy 시퀀스와 lazy-cons가 있습니다. 처리 과정을 lazy 시퀀스 모델을 사용해 구성할 수 있다면, lazy-cons를 직접 호출하는 것을 포함하여, 전통적으로 보이는 함수형/재귀적 해법을 쓸 수 있습니다. 그 해법은 스택을 늘리지 않을 뿐 아니라, 힙에 완전히 실현된 결과 리스트를 만들지도 않아 엄청난 효율 향상을 줍니다. 이것이 TCO만큼 일반적이라고 주장하는 것은 아니지만, 꼬리 호출이 아닌 결과(e.g. 리스트 빌더)를 공간 효율적으로 만들 수 있는 등 다른 좋은 속성이 있습니다.
나는 패치를 선호합니다. 어떤 사람들은 그렇지 않다는 것을 이해합니다. 서로 동의하지 않기로 합의할 수 없을까요? 왜 이걸 반복해서 들춰야 하죠?
내가 보기엔 이렇습니다. 패치와 풀 리퀘스트 사이의 차이에 당신이 들일 시간 대비 나는 클로저에 최소 10만 배는 더 많은 시간을 들였습니다. 명령은 다음과 같습니다:
git format-patch master --stdout > your-patch-file.diff
변경 관리에는 두 면이 있습니다 — 생산/제출, 그리고 관리/평가/적용/기타 관리. 제출의 용이성에 맞춰 과정을 최적화해야 한다고 주장하는 사람들은 단순히 틀렸습니다. 만약 내가 패치가 풀 리퀘스트보다 평가하고 관리하는 데 두 배 효율적이라고 말하면 어떨까요? (실제로는 그 이상입니다) 수지를 계산해서 노력이 가장 잘 배분되는 방식을 알아보세요.
패치를 요청하는 것이 과한 요구라고 생각하지 않습니다. 추가 수고를 들이는 분들에게 진심으로 감사드립니다. 또한, 사람들이 동의하지 않을 수 있고, 그 결과로 참여하지 않기로 결정할 수 있음을 존중합니다. 하지만 내 결정을 계속 정당화해야 할 필요는 없습니다. 저와 다른 리스트 참여자들에 대한 약간의 배려는 어떨까요? 이런 ‘한 번 털고 가자’류 메시지는 리스트의 가치와 읽는 이(나를 포함)의 효용을 희석시킵니다.
때론 그냥 바텐더에게 털어놓는 게 낫습니다 :)
물론 cons 셀이 있습니다. 이름은 Cons이고, cons가 그것을 만듭니다. CL의 cons 셀과 다른 점은 임의의 쌍이 아니라는 것입니다(즉 rest는 반드시 시퀀스나 nil이어야 합니다).
클로저는 seq 추상화를 지원하므로, 구현이 이질적입니다. 실제로 cons 셀과 비슷한 데이터 구조가 두 개 있습니다:
user=> (class (cons 4 '(1 2 3))) clojure.lang.Cons user=> (counted? (cons 4 '(1 2 3))) false
user=> (class (conj '(1 2 3) 4)) clojure.lang.PersistentList user=> (counted? (conj '(1 2 3) 4)) true
그래서 list?는 더 구체적입니다. seq가 더 일반적이니까요. 원하는 것은 아마 seq?일 것입니다. IPersistentList에 cons하면 또 다른 IPersistentList를 반환하도록 만들 수는 있지만, 현재는 그렇지 않습니다.
클로저가 CLOS 같은 정교한 시스템이나, 심지어 파이썬 등과 같은 것을 갖지 않는 이유는, 그런 시스템들이 모두 한계를 갖고 있고 기벽이 있으며, 그것을 언어에 하드와이어드하는 것이 나쁜 아이디어라고 생각하기 때문입니다. 클로저의 계층과 멀티메서드는 단지 라이브러리입니다 — 클로저를 위한 CLOS 유사 객체 시스템을 정의하지 못할 이유가 없습니다(다만 어떤 그런 시스템도 자바의 타입 계층과 통합하는 데 문제를 겪을 겁니다. 결정적 슈퍼클래스 순서를 정할 수 없으니까요). 나는 OO의 유용한 성분을 골라내어 일품요리(a la carte)로 제공하려는 것입니다.
CLOS의 때로 편리한 기본 해상도 정책에 기여하는 두 가지가 있습니다:
하나는 좌→우 인자 순서 우선순위로, 이는 대략 클로저에서 벡터 우선순위로 번역됩니다(비록 벡터로의 매핑이 인자 순서를 따를 필요는 없습니다). 다른 하나는 defclass에서 좌→우 선언에 기반한 클래스의 기본 우선순위와, 그에 따른 위상 정렬(topological sort)입니다.
현재 매칭과 우선순위는 모두 isa?를 따르지만, 후자는 반드시 그럴 필요는 없고 보완될 수도 있습니다. 내가 중요하다고 생각하는 특성은 다음과 같습니다:
기존 자식(child)을 수정하지 않고 그 위에 부모를 중첩(superimpose)할 수 있어야 합니다. 이는 클로저의 일품요리식 계층의 핵심 기능이며, 내가 지금까지 작업한 모든 OO 언어(포함 CLOS)에서 갈망하던 것입니다. (독립적으로 개발된) A와 B의 클라이언트가 B와 함께 작동하도록 A를 수정해야 한다면 그건 작동 불능입니다.
자바 계층과 투명하게 동작해야 합니다. 이는 위의 사항을 내포합니다.
계층에 대한 독립적이고 순서 독립적인 주장(assertion)을 지원해야 합니다. 표준 마스터 정의에 기반한 것은 깨지기 쉽고 확장 불가능합니다.
내가 선호하는 것은 선호(preference) 선언입니다. 이는 순수 파생 계층의 명백한 모호성을 인정하는 것이지, 방향성을 주입해 계층의 개념을 오염시키는 것이 아닙니다.
그렇긴 해도, 메서드 수준의 선호 지정이, 다른 수준이나 다른 곳에서 선언하는 것만큼 재사용 가능하거나 편리하지 않을 수도 있습니다.
이걸 더 쓰기 쉽게 만드는 데 관심은 있지만, 친숙함으로부터의 논증은 충분치 않습니다 — 수반되는 트레이드오프를 인정해야 합니다. 나는 CLOS의 이 동작을 편의를 위해 반복할 생각이 없습니다:
(defclass a () ()) (defclass b () ()) (defclass c (a b) ()) (defclass d (b a) ()) (defclass e (c d) ())
(make-instance 'e)
Error: Error during finalization of class #<STANDARD-CLASS E 216B409B>: Cannot compute class precedence list for class: #<STANDARD- CLASS E 216B409B>
윽.
일반적으로도 말할 수 있습니다. 수동 락킹 시스템은 대개 망가져 있고, 그래서 동작 자체(성능은 말할 것도 없고)가 예측 불가능하다고요. 그게 내 경험입니다. 보통 사람 개발자 손에 든 수동 락킹은 그렇습니다. 어떤 수동 동시성 프로그램 하나의 동작을 이해하는 일이, STM의 성능 특성을 이해하는 일만큼 어려울 수 있습니다. 후자의 경우, 적어도 STM이 정확성을 다뤄 줍니다. 같은 STM의 모든 사용자가 지식을 공유할 수 있습니다(버그 수정과 성능 개선 포함). 각 애플리케이션이 스스로의 고르디아스의 매듭이 되는 것과 대비됩니다. 그리고 STM이 성능 면에서 당신을 배신하는 어떤 경우에는, 트랜잭션 밖에서 수동 락을 사용할 수 있습니다. 쓸 수 있는 만큼 STM을 사용하면, 시스템의 복잡성을 극적으로 낮출 수 있습니다.
동시성 논의를 성능 논의와 합치는 것에 조심스럽습니다. 동시성의 문제는 정확성에서 시작합니다. 그 점에서 수동 락킹 이야기는 복잡성 때문에 꽤 나쁩니다. 스케일러빌리티는 보편적인 문제가 아닙니다 — 어떤 시스템은 수천 개의 동시 연결을 처리해야 하지만 대부분은 그렇지 않습니다. 어떤 시스템은 단일 애플리케이션에서 수백 개의 CPU를 활용해야 하지만 대부분은 그렇지 않습니다. 그러나 멀티코어를 활용하고자 하는 모든 애플리케이션은 정확해야 합니다.
클로저 STM 참조들에 fault 카운터를 넣어 각각의 참조에 대한 정확한 경합 수준을 알 수 있게 하는 것은 아무 문제도 아닙니다. 일단 알게 되면, 두 경우 모두에서 답은 비슷합니다 — 가변 상태 공유를 줄이고, 긴 트랜잭션과 짧은 트랜잭션이 같은 데이터에 경합하지 않게 하며, 성공 조건을 가능한 한 빨리 검사하는 등입니다.
STM 설계는 서로 꽤 다릅니다. 일반론을 평가하기 어렵습니다. STM의 세분성(granularity) 수준이 작용한다고 봅니다. 내가 읽은 많은 STM 논문은 많은 트랜잭셔널 셀을 만들어, java.util.concurrent 같은 동시성 자료구조를 구성하는 것을 상상합니다. 나는 그게 전혀 좋은 생각이 아니라고 봅니다.
클로저는 동시 상황에서 올바르게 수행하는 데 락이나 조정이 전혀 필요 없는 불변 영속 데이터 구조 사용을 장려하고, 아이덴티티 보존적 변화를 가장(appearance)하도록 STM 참조를 지원합니다. 또한 캐시와 워크 큐에 대해 java.util.concurrent 같은 것을 사용할 것을 권합니다 — 일에 가장 적합한 도구죠.
아는 한, 클로저는 스냅샷 MVCC를 사용하는 최초의 STM입니다. 이는 읽기 로깅과 읽기 무효화로 인한 트랜잭션 재시작을 모두 피합니다. 클로저의 STM은 나중에 읽거나 쓸 참조를 ‘ensure’할 수 있게 하여, 접근의 부수효과에만 의존하지 않고 자원 획득 순서에 어느 정도 수동 통제를 제공합니다. 또한 명시적 commute를 지원해, 교환법칙이 성립하는 쓰기에서 재시도를 더 줄여줍니다. 성능 특성이 증명되었다고 주장하지는 않겠습니다만, 수동 락킹을 사용하는 프로그램과, 쓰기 트랜잭션이 읽기 트랜잭션의 재시도를 유발할 수 있는 STM들보다, 읽기로 인한 경합도, 읽기에 대한 경합도 낮습니다.
레코드 수준 STM 세분성(클로저처럼)에 이르면, 수십 년간 유사한 일을 해 온 데이터베이스 시스템의 성능 특성과의 유사점을 그리기도 좀 더 쉬워집니다.
STM 성능을, 수동 락킹 프로그램의 정확성 판정보다 연구 문제가 많다거나 적다고 보지 않습니다 — 아직 할 일이 남아 있습니다.
물론 STM도, 어떤 다른 메커니즘도, 은탄환은 아닙니다.
이 기능들 뒤의 설계 사고의 큰 부분은 다음과 같았습니다.
클로저는 일련의 추상화 위에 구축되어 있고, 호스트 플랫폼이 이를 가능하게 하기 위해 어떤 고성능 다형성 구성요소를 제공/요구합니다. 그러나 클로저는 호스트 언어 위에서 부트스트랩되었고, 유사한 구성을 자체적으로 제공하지는 않았습니다(멀티메서드는 더 강력하지만 느립니다). 클로저와 그 데이터 구조를 구현하려고 내가 했던 것과 유사한 일을 하고자 하는 사람들에게, 자바를 쓰거나, 인터롭을 사용해 사실상 자바를 클로저 옷을 입혀 쓰도록 남겨 두었습니다.
그래서 한 걸음 물러나, 클로저와 그 데이터 구조를 구현하기 위해 자바에서 내가 필요로 했던 것이 무엇인지, 무엇이 없어도 되는지, 어떤 의미론을 지원할 의향이 있는지 — 클로저 차원에서 — 생각했습니다. 결론은 — 인터페이스를 정의하고 구현하는 고성능 방법. 명시적으로 제외한 것은 — 구체적 파생(concrete derivation)과 구현 상속입니다.
reify는 클로저 의미론이고, proxy는 자바/호스트 의미론입니다. 왜 proxy를 대체하지 않을까요? proxy는 인자를 받는 생성자를 가진 구체 클래스에서 파생할 수 있기 때문입니다. 이를 지원하면 자바에서 많은 의미론이 들어옵니다. 내가 클로저 의미론에 원하지 않는 것들이죠. reify는 어떤 클로저 포트에서도 가능하고 이식 가능해야 하지만, proxy는 그럴 수 없습니다. reify의 성능 개선이 proxy에도 들어갈까요? 아마 언젠가, 지금은 우선순위가 아닙니다.
어떤 인터롭 API가 proxy 사용을 강제하지 않는 한 reify를 proxy보다 선호하세요. 당신이 클로저에서 proxy를 필요로 하는 걸 만들어서는 안 됩니다.
defstruct는 deftype으로 완전히 대체될 가능성이 크며, 언젠가는 사용 중단/삭제될 수도 있습니다.
조건 없이 defstruct보다 deftype을 선호하세요.
AOT deftype 대 gen-class는 같은 클로저 의미론 vs 자바/호스트 의미론에 닿습니다. 목표는 — 인터페이스 구현은 지원하지만 구체 파생은 지원하지 않는 것. 그래서 구체 베이스 클래스, super 호출, 자기 생성자 호출, static, 인터페이스 메서드를 구현하지 않는 메서드 등은 없습니다. deftype의 성능 개선이 gen-class에도 들어갈까요? 아마 언젠가, 지금은 우선순위가 아닙니다. proxy처럼 gen-class도 인터롭 기능으로 남을 것입니다.
어떤 인터롭 API가 gen-class 사용을 강제하지 않는 한 gen-class보다 deftype을 선호하세요.
definterface가 gen-interface와 유사하게, 아마 대체하며, deftype과 맞는 API로 제공될 것입니다.
그래서 definterface, deftype, reify로, 자바/C# 다형성 모델의 부분집합을 지정/구현하는 매우 깔끔한 방법을 갖게 됩니다. 내가 깔끔하고 합리적이라 보는 그 부분집합을, 이식성을 기대하며, 호스트의 동일 기능과 정확히 동등한 성능으로요. 여기서 멈출 수도 있었고, 거의 그랬습니다. 그러나 그 다형성 모델에는 클로저에 충분하지 않은 세 측면이 있습니다:
(.method x) 형태와 타입 힌트를 써야 합니다.우리는 모두 표현 문제를 경험했습니다 — 때로는 당신의 디자인과 잘 어울리도록 어떤 타입이 YourInterface를 구현해 달라고 요청/요구할 수조차 없습니다. 클로저 구현에도 이것이 보입니다 — RT.count/seq/get 등은 먼저 클로저의 추상화 인터페이스를 사용하려고 시도하지만, 인터페이스를 끼워 넣을 수 없었던 타입들(e.g. String)에 대해 수작업 분기절을 갖습니다.
반면 multimethod는 이 문제를 겪지 않습니다. 그러나 클로저만큼 일반적인 멀티메서드가 자바의 인터페이스 디스패치와 경쟁하기는 어렵습니다. 또한 멀티메서드는 일종의 원자적이며, 추상화를 완전히 지정하려면 종종 그것들의 집합이 필요합니다. 마지막으로, 멀티메서드는 클로저 측면의 추상화에는 좋은 이야기지만, 당신이 클로저에서 가치 있는 추상화와 유용한 코드를 정의하고 자바나 다른 JVM 언어에서 확장이나 상호운용을 가능하게 하려 할 때, 처방전은 무엇일까요?
defprotocol은 멀티메서드의 힘의 부분집합 — 오픈 확장 — 을 취하고, 매우 흔한 디스패치 메커니즘(첫 번째 인자의 ‘타입’ 단일 디스패치)과 결합하며, 추상화를 구성하는 함수 집합을 이름 붙이고 지정/구현하도록 하고, 호스트의 일반적 기능(:on interface)을 사용해 프로토콜을 확장하는 명확한 방법을 제공합니다.
추상화를 지정할 때, 인터페이스보다 프로토콜 사용을 선호하세요.
이렇게 하면 오픈 확장과 동적 시스템을 얻게 됩니다. 언제든 당신의 프로토콜을 어떤 타입에도 도달하게 만들 수 있고, :on interface를 사용해 프로토콜을 인터페이스로 확장 가능하게 만들 수 있습니다. 특히, :on interface의 인스턴스에 대한 프로토콜 함수 호출은 곧바로 통과하며, (.method #^AnInterface x)를 사용하는 호출만큼 빠릅니다. 따라서 프로토콜을 선택하는 데 성능의 선제적 타협이 없습니다.
자바에서 추상 클래스로부터의 확장 가능성은 deftype/protocol 설계에서 중요한 고려사항이었습니다. 구체 구현에 대한 한 가지 합리적 논거는 추상 슈퍼클래스입니다(올바르게 사용될 때 특히). 그리고 클로저 구현은 그것들을 사용합니다. 추상 클래스의 문제는 다음과 같습니다:
프로토콜은 계층 없는, 열린, 다중, 기계적 믹스인을 지원하도록 설계되었습니다. 이는 extend가 일반 함수이며, 이름에서 구현 함수로의 매핑이 일반 맵이라는 사실이 가능케 합니다. 이름에서 함수로의 매핑 맵을 만들기만 하면 믹스인을 만들 수 있습니다. 그리고 일반적인 맵 조작을 사용해, 함수들을 병합/교체하는 임의 방식으로 믹스인을 사용할 수 있습니다:
(extend ::MyType AProtocol (assoc a-mixin-map :a-fn-to-replace a-replacement-fn))
이것이 꽤 강력하고 프로그래밍 가능하다는 것을 사람들은 알게 될 것입니다.
핵심 질문은, [전략]들 중 하나를 선택하는 근거가 무엇이어야 하는가입니다. 모호성은 내재적입니다. 자바는 인터페이스의 다중 상속을 허용합니다. 그리고 구현을 인터페이스에 매달면 구현의 다중 상속을 얻게 됩니다. 이를 다음과 같이 해결할 수 있습니다:
a) 오류 — MI는 나쁘다, 다중 상속 금지
b) 기계적 — 우리가 사용할 수 있는 정보로 볼 때, 클래스 이름의 알파벳 순서보다 의미 있게 나은 기계적 해법을 보지 못했습니다. 그리고 누구도 만족시키기 어려울 겁니다.
c) 외부 정보를 사용
멀티메서드는 같은 문제에 직면했고 (c)를 선택했습니다 — 선호 시스템을 사용합니다. 보통은 그렇게 해서 진행할 수 있지만, 무엇이 ‘좋은’ 진행인지가 문제입니다. MI는 본질적으로 추론하기 어렵습니다. 선호를 임의로, 동적으로 추가하면 무엇을 선호할지 결정하는 경주가 벌어지고, 상충하는 선호를 중재할 좋은 방법이 없습니다. 또한 전혀 선언적이지 않아서, 관계를 보려면(그 순간의) 동적 질의가 필요합니다. 마지막으로, 앞으로는 인터페이스가 줄고 프로토콜이 늘어나므로, 어떤 것을 매달 인터페이스가 없을 수도 있습니다. 구체적 정적 타입 계층에서 멀어질수록, 어떤 것에 범주화/모호성 해소 시스템을 붙여야 할까요(그리고 그것을 빠르게 만들 수 있을까요)?
추상화(프로토콜)를 구체 클래스에 연결해야 한다고 불평하는 것은 묘합니다. 바로 그것이 구체 클래스 작성자가 인터페이스를 선택할 때(한 번만) 했던 일입니다. 어떤 의미에서, 프로토콜이 하는 일은 그 기능을 열어 주는 것뿐입니다. 추상화 자체(그 구체 인스턴스가 아니라)에 대해 구체적 동작을 정의하는 것은 논리적 관점에서 다소 깨져 있습니다. 호소력은 있습니다(열린 경우 집합을 포괄하도록 한번에 정의). 하지만 인터페이스 MI와 결합되면, 논리 문제는 남습니다. 선호는 순차적으로 고려되는 조건문을 삽입해 논리를 땜질할 뿐입니다.
A는 X들에 대해 참이다
B는 Y들에 대해 참이다
Fred는 X이면서 Y이지만, Fred에 대해서는 A만 참이다.
요컨대, 선호는 괜찮은 아이디어지만, 인터페이스에 매우 묶여 있으며, 나는 프로토콜에 대해서는 더 나은 것을 보고 싶습니다. 그때까지는, 나는 미루고 사람들이 정말 무엇을 필요로 하는지 볼 생각입니다.
클로저의 주요 목표 중 하나는, 다중 스레드 문맥에서 잘 동작하는 프로그램을 간단하게 만들 수 있게 하는 것입니다. 이는 어떤 변경 기능도 스레드를 인지해야 함을 의미하며, 클로저의 Var, Ref, Agent는 그렇습니다. 이 구성요소들(그리고 클로저의 영속 데이터 구조)을 사용하고 자바의 부수효과를 피하면, 견고한 멀티스레드 프로그램을 쉽게 작성할 수 있습니다.
로컬이 변수 — 즉 가변 — 이라면, 클로저가 가변 상태를 닫을(closes over) 수 있고, 클로저는 탈출할 수 있으므로(이를 금지하는 추가 제약이 없다면), 결과는 스레드 안전하지 않게 됩니다. 사람들은 분명 그렇게 할 겁니다. 예컨대 클로저 기반의 유사 객체. 이는 클로저 접근 방식에 거대한 구멍을 낼 것입니다.
가변 로컬이 없으면, 사람들은 recur — 함수형 루프 구성요소 — 을 사용하도록 강제됩니다. 처음에는 이상하게 보일 수 있지만, 이는 가변 루프만큼 간결하고, 결과 패턴은 다른 곳에서도 재사용할 수 있습니다. 즉 recur, reduce, alter, commute 등은 모두(논리적으로) 매우 유사합니다. 가변 클로저의 탈출을 감지하고 방지할 수 있더라도, 일관성을 위해 이렇게 두기로 했습니다. 가장 작은 문맥에서도, 비가변 루프는 가변 루프보다 이해하고 디버그하기 쉽습니다. 어떤 경우든, 적절할 때 사용할 수 있는 Var가 있습니다.
함수형 프로그래밍 경험이 없는 프로그래머들도 클로저의 함수형 스타일에 익숙해지길 바랍니다. 멀티스레드 프로그래밍을 전혀 하지 않더라도, 그렇게 하면 더 견고하고 이해하기 쉬운 프로그램의 혜택을 보게 될 것입니다.
일반적인 질문 — 언제 atom/ref/agent를 사용할 것인가 — 을 다루려면, 다음과 같이 생각하는 것이 도움이 됩니다.
먼저, atom/ref/agent는 여러 스레드에서 변경을 볼 수 있게 하는 클로저의 참조 타입입니다. 즉, 이 세 참조 타입은 모두 공유 참조 타입입니다.
그들을 사용할지에 대한 선택에는 두 가지 차원이 있습니다. 첫째 — 변경이 동기적일 것인가 비동기적일 것인가. 둘째 — 이 참조의 변경이 다른 참조(들)의 변경과 조정되어야 하는가.
조정된 변경에는 ref + 트랜잭션만이 유일한 방법이며, (commute 집합을 넘어서는) 비동기는 별 의미가 없습니다. 독립적 변경에는 선택지가 둘 있습니다 — agent와 atom.
Atom은 동기적입니다. 변경이 호출 스레드에서 일어납니다. 클로저에서 평범한 변수에 가장 가깝지만, 중요한 이점이 있습니다 — 스레드 안전하다는 것, 특히 읽기-수정-쓰기 경쟁 조건에 취약하지 않다는 것입니다. 읽은 것의 함수가 아닌 한, 쓰기가 일어나지 않습니다. 하지만 atom에 대한 수정은 부수효과이므로, 트랜잭션에서는 피해야 합니다.
Agent는 비동기적이며, 이는 중요한 이점을 가질 수 있습니다. 특히, 액션이 큐에 쌓이고, 보낸(sender)은 즉시 진행할 수 있다는 뜻입니다. 쓰레딩 시스템과 스레드 풀에 대한 투명한 인터페이스를 제공합니다. 또한 agent는 atom이 할 수 없는 방식으로 트랜잭션과 협력합니다 — 예컨대 트랜잭션에서 agent send가 허용되며 커밋까지 보류됩니다.
좋은 점은 참조 타입들 아래의 통합된 모델입니다. 모두 deref/@로 읽을 수 있고, 모두가 불변 데이터 값에 대한 참조를 위해 설계되었으며, 변화를 그 값의 함수로 모델링합니다. 모두 validator를 지원합니다.
이는, 상태 변환 함수를 순수 함수로 만들면, 서로 다른 참조 타입 간에 자유롭게 선택/전환할 수 있다는 뜻이며, 심지어 같은 로직을 두 다른 참조 타입에 대해서도 사용할 수 있습니다.
하지만 그들은 다릅니다. 그리고 그들이 다른 영역에서는 통합되지 않았습니다. 특히 각자는 고유의 수정 어휘를 갖습니다 — ref-set/alter/commute/send/send-off/swap!/compare-and-set!.
결국 클로저는 도구입니다. 당신을 위해 아키텍처 결정을 해 줄 수는 없습니다. 위의 내용이 정보를 바탕으로 선택하는 데 도움이 되길 바랍니다.
메모이제이션 예시는 atom의 주요 동기 사례입니다 — 로컬 캐시. 또한, 사람들이 단순 가변 변수를 사용해 구현하려 할 때 다중 스레드에서 흔히 틀리는 예시이기도 합니다.
클로저의 nil 처리와 같은 주제를 둘러싼 경쟁하는 트레이드오프 전부를 나열(혹은 심지어 기억)하기는 매우 어렵습니다 :)
nil 패닝(punning)이 복합(complecting)의 한 형태라는 점에는 의심의 여지가 없습니다. 하지만 빈 컬렉션과 empty?만 써서 모든 문제를 제거하지는 못합니다. Maybe 같은 것이 필요하고, 그러면 (간결한 동적 언어에서는) 정말 역해집니다(IMO).
나는 nil 패닝이 마음에 듭니다. 전반적으로 일반화와 경계 사례 감소의 훌륭한 원천이라고 봅니다. 물론 특정한 경우들에서는 경계를 도입함을 인정합니다. 나는 Tim과 함께 스킴보다 CL의 접근을 선호하며, 개인적 편향과 (비록 작지만) 그 복잡성에 대한 편안함이 있음을 인정합니다.
그러나 어디서나 유지될 수는 없었습니다. 특히 두 가지가 그것을 방해합니다. 하나는 게으름(laziness)입니다. 앞을 강제(force)하지 않고 rest에서 실제로 nil을 반환할 수는 없습니다. 클로저의 올드 타이머는 이게 달랐던 시절과 그로 인해 생긴 문제를 기억할 겁니다. 나는 이것이 여전히 의미 있게 복합되었다는 Mark의 말에 동의하지 않습니다. nil은 빈 컬렉션이 아닙니다. nil은 아무것도 아님입니다.
둘째, CL에서는 빈 컬렉션의 유일한 ‘타입’이 nil이고 cons가 다형적이지 않은 반면, 클로저에서는 conj가 다형적이고 (conj nil ...)에 대해 만들어질 수 있는 데이터 타입은 하나뿐입니다. 그래서 [], {}, 그리고 empty?가 있습니다. 데이터 구조가 비워질 때 nil로 붕괴한다면, 타입을 유지한 채로 다시 채울 수 없습니다.
이 시점에서, 이 논의는 학술적입니다. 이 영역에서는 어떤 것도 바뀔 수 없으니까요.
쉽게 생각하는 방법은, nil은 아무것도 아님이고, 빈 컬렉션은 아무것도 아님이 아니라는 것입니다. 시퀀스 함수는 컬렉션에서 (가능하면 lazy한) 컬렉션으로 가는 함수이며, seq/next는 게으름을 강제합니다. 아무도 당신이 rest와 empty?를 사용하는 것을 막지 않습니다. 당신 친구가 next와 조건문을 쓰는 것도요. 평화!
컬렉션 문서를 보면, 모두가 세 함수를 지원해야 한다는 것을 볼 수 있습니다:
countconjseq그리고 seq는 컬렉션 ‘위에(on)’ ISeq 인스턴스(자바 인터페이스)를 반환합니다. 이 점이 핵심입니다 — 시퀀스는 컬렉션의 논리적 순차 ‘뷰(view)’이지 (반드시) 컬렉션 자체가 아닙니다.
클로저에는 ISeq 인터페이스의 구체 구현이 약 20개가 있으며, 모든 데이터 구조에 대한 순차 접근을 지원합니다. 컬렉션에 대한 seq를 생각하는 한 가지 방법은 컬렉션 위의 커서나 이터레이터로 보는 것입니다. ISeq 자바 인터페이스는 두 개의 ‘메서드’ — first, rest — 를 가지며, 모든 구현이 제공합니다.
클로저 라이브러리에는 두 개의 ‘함수’ — first, rest — 가 있는데, 이들은 ‘컬렉션’에 정의되어 있으며 인자에 대해 먼저 seq를 호출하도록 지정되어 있습니다. 이미 시퀀스인 것에 seq를 호출하면 그대로 반환합니다.
그래서 [1 2 3]은 시퀀스가 아니고, {:a 1 :b 2}도 아닙니다. 하지만 first와 rest와 대부분의 시퀀스 라이브러리는 그것들에서 작동합니다. 먼저 seq 함수를 사용해 컬렉션 위의 시퀀스를 얻기 때문입니다. 그렇지 않다면 어디서나 seq를 호출해야 해서 꽤 성가셨을 겁니다. 따라서, 비록 그들이 컬렉션의 함수(즉 컬렉션을 받는 함수)일지라도, 컬렉션의 순차 뷰에서 작동합니다. 그래서 시퀀스 함수라는 이름이 붙었습니다.
일부 컬렉션은 그들 자신의 순차 뷰입니다. 즉 링크드 구조를 본질적으로 가지고 있으므로(예: 리스트) ISeq를 직접 구현합니다:
(instance? clojure.lang.ISeq [1 2 3]) -> false (instance? clojure.lang.ISeq '(1 2 3)) -> true
(.getClass [1 2 3]) -> clojure.lang.PersistentVector
(.getClass (seq [1 2 3])) -> clojure.lang.PersistentVector$ChunkedSeq
(.getClass '(1 2 3)) -> clojure.lang.PersistentList
(.getClass (seq '(1 2 3))) -> clojure.lang.PersistentList
물론 ISeq가 컬렉션처럼 동작하게 하는 것은 아주 간단합니다(즉 count, conj, seq를 구현). 그래서 그렇게 합니다. 등등 — 클로저 라이브러리는 최대한 다형적으로 설계되어 있습니다. 결국, 자바에서 시퀀스 라이브러리를 확장하는 데 관심이 없다면, 타입 계층을 아는 것은 중요하지 않습니다. 일반적으로 모든 시퀀스 함수는 모든 컬렉션에서 작동합니다. cons는 예외입니다. 링크드 구조를 만들기 때문입니다. 연관 구조 위에 우연히 링크드 구조를 만들고 싶지 않습니다. 반면 다른 모든 시퀀스 함수는 뷰 전용이거나 독립 구조를 만듭니다. 사용자가 정말 의도한다면 (cons 1 (seq {:a 1 :b 2 :c 3}))이 잘못은 아닙니다.
서로 다른 시퀀스 개념을 혼동하지 않는 것이 매우 중요합니다. 클로저의 모델은 매우 특정한 추상화 — Lisp 리스트 — 이며, 원래는 cons 셀의 단일 링크드 리스트로 구현되었습니다. 이는 first/second/third/rest 등의 영속적 추상화이지, 스트림이나 이터레이터가 아닙니다. cons 셀에서 그 추상화를 들어 올리는 것 자체가 그 영속적 본성을 바꾸지 않으며, lazy하게 실현하는 것도 마찬가지입니다. 캐시하지 않는 버전으로 실험해 본 결과, 이 추상화와 양립할 수 없다는 확신이 들었습니다.
시퀀스가 원래 명령형 소스에서 생성된 것이라면, 반복 가능한 읽기 영속성을 얻으려면 캐시가 필요합니다. 계산 비용이 들었다면, 영속 리스트에서 기대할 퍼포먼스를 얻기 위해 캐시가 필요합니다. 추상화는 단지 인터페이스 이상입니다.
부수효과가 있는 모든 것이 깨집니다. 많은 성능 문제, 명백한 다중 순회, 하지만 종종 시퀀스의 (rest x)나 다른 부분들에 대한 여러 지역 참조가 생기고, 이것이 다중 평가와 그에 따른 런타임 배수를 야기합니다. 한마디로, 더 조심해야 할 전혀 다른 항목들의 집합이 생기며, 훨씬 더 자주 그렇습니다.
종료되지 않거나 폭주하는 계산을 피하는 것이 게으름의 유일한 취지라면, 그럴지도 모릅니다. 하지만 그렇지 않습니다. 기억하세요, 클로저는 주로 엄격/즉시 평가 언어입니다. 클로저에서 게으른 것(명시적 delay 외에는)은 시퀀스 함수뿐입니다. 이는 여러 함의를 가집니다.
첫째, 클로저 코드에는 종료되지 않는 계산이 그다지 떠다니지 않습니다. 둘째, 클로저에서 게으른 시퀀스의 또 다른 혜택 — 그리고 주요한 중요성 — 이 있습니다. 중간 결과의 완전 실현을 피하는 것입니다. 이는 단순한 eager/lazy 이분의 문제가 아니라, 게으름의 세분성과 순차 처리의 문제입니다. 기본이 한 항목이어야 할까요? 나는 클로저 같은 언어들에는 답이 ‘아니다’ 쪽이라고 점점 확신하고 있습니다. 예컨대 다음 작업을 보세요. 클로저와 관련이 있다고 봅니다: MonetDB/X100: Hyper-Pipelining Query Execution
항목 단위 처리 대안의 중요성에는 동의합니다. 공식 API가 없었더라면, 이것이 진행되도록 주저했을 겁니다. 그러나 클로저 사용자 대다수가 몇 달 동안 문제나 불만 없이 청크 시퀀스를 사용해 왔습니다. 나는 아직 공식 API 작업을 끝내지 못했지만, 요청한 분들을 위해 비공식 API를 제공했습니다.
어떤 필요와 현재 관점으로 클로저의 일부에 다가가면 만족스럽지 않을 수 있습니다. 물론 완벽하지 않습니다. 하지만 좋은 가정은, 처음에는 눈치채지 못했을 더 많은 맥락, 제약, 문제들이 있을 수 있다는 것입니다.
Mark가 가장 정확했습니다. 알고리즘 복잡성이 이 결정에서 지배적이라는 점을 지적하면서요. 함수들이 추상화로 정의된다는 점은 사실이지만, 추상화는 여전히 리팩터링될 수 있습니다. 그것은 수단이지, 목적이 아닙니다.
역사: ‘last’는 Lisp 함수입니다. 커먼 리스프에서는 리스트에만 동작합니다. 그 함수는 그 존재 자체로 느림을 광고합니다. 그것을 사용하는 코드는 짧은 리스트에만 사용될 것이라는 전제를 가지고 있습니다. 사실 일반적으로 그렇게 사용됩니다. 매크로나 코드 처리 같은 데서요. 그 코드를 읽는 사람들도 같은 것을 전제할 수 있습니다.
일반적으로, 대상 인자에 따라 서로 다른 알고리즘 복잡도 범주로 전이하는 단일 다형 함수가 있는 것은 나쁘다고 생각합니다(주: 이것은 구현 특성을 활용해 성능을 올리는 다형성과 동치가 아닙니다. 그 특정 부분집합입니다). seq에 대한 nth는 반례이며, 커뮤니티가 요청했고, 지금도 다소 실수라고 생각합니다. 최소한, (아래에 주는 이유 때문에) 비선형 복잡도를 가진 elt도 있었으면 좋겠습니다.
보통은 ‘빠른’ 함수가 있고, 누군가 그것이 더 느린 범주에 들어갈 것에서도 작동하길 원합니다. 이 경우는 반대입니다. ‘last’는 느리고, 사람들은 가능한 곳에서 그것이 더 빠르길 원합니다. 결과는 같습니다.
왜 가능한 한 다형적이지 않게 하지 않을까요? 커먼 리스프에는 리스트를 연관 맵처럼 사용할 수 있게 하는 함수 집합이 있습니다. 크기가 커지면 성능이 좋지 않습니다(즉 하늘이 무너집니다). 그건 한때는 의미가 있었을지 몰라도(리스트밖에 없을 때), 이제도 좋은 생각일까요? 맵을 기대하는 함수들이 리스트도 받게 해야 할까요?
내 경험으로는, 모든 것이 리스트에서/와 함께 동작하게 하는 것은 작은 데이터셋에서 잘 작동하는 시스템을 쉽게 만들게 해줍니다. 하지만 커지면 폭망합니다. 그리고 성능을 좋게 만드는 것은 매우 도전적이었습니다. 일부는, 예컨대 해시맵 같은 인터페이스 준수 구현이 부족해 쉽게 교체할 수 없었기 때문입니다. 하지만 또 다른 범주의 문제는, 어떤 호출이 성능에 중요하고, 언제 리스트가 여전히 괜찮은지를 볼 수 없다는 것이었습니다. 그래서 클로저는 이런 결정들을 더 이르게 하고, 명시적으로 하도록 ‘강제’합니다. 그래도 여전히 매우 다형적입니다.
현재로서는, 문서에서는 선형보다 나을 것을 약속하지 않으면서도, 벡터 등에서 ‘last’가 더 잘 동작하게 하는 것이 나쁘지 않을지도 모르겠습니다. 하지만 나는 이것을 압니다 — 이건 정말 중요하지 않습니다. 우리 모두는 이런 것에 대해 다툴 시간보다 훨씬 더 좋은 일을 할 게 있기를 바랍니다.
벡터의 마지막 원소를 빠르게 얻는 완전히 적절하고 잘 문서화된 방법이 있습니다. 그게 중요한 코드라면(그리고 — 설령 last가 더 빨라지더라도 — 그래야만 합니다) 그것을 사용하세요. 중요하지 않은 코드라면 벡터에서 ‘last’를 사용해도 됩니다. — 왜냐하면, 바로 그 사실로 보아 중요하지 않기 때문입니다! 둘 중 어떤 코드를 읽든, 무엇이 중요하고 무엇이 아닌지 알게 될 것입니다. 그게 가장 중요한 것 같습니다.
MIT와 BSD는 상호주의(reciprocal) 라이선스가 아닙니다. 나는 상호주의 라이선스를 원합니다. 하지만 내 작업과 결합된 비파생(non-derivative) 작업에 그 라이선스가 적용되거나 그에 대해 어떤 것도 지시하길 원치 않습니다. GPL이 하는 것처럼요. 나는 그렇게 하는 것이 근본적으로 잘못되었다고 생각합니다.
GPL이 그 접근과 호환되지 않는 사실은 GPL의 문제이며, GPL 소프트웨어 사용자들의 문제입니다.
나는 어떤 ‘맞춤(customized)’ 라이선스도 사용하지 않을 것입니다. 널리 알려진 라이선스를 있는 그대로 사용하는 것이 사용자들이 라이선스를 검토하기 쉽게 만드는 유일한 방법입니다. 다른 것은 모두, 나와 그들의 변호사를 필요로 합니다.
나는 GPL이나 LGPL로 이중 라이선스를 하지 않을 것입니다. 두 라이선스 모두 GPL 아래에서 파생물을 만드는 것을 허용하는데, 나는 그 라이선스를 내 작업에 사용할 수 없습니다. 내가 사용할 수 없는 파생물을 허용하는 것은 상호주의가 아니며, 내게는 말이 되지 않습니다.
물론 사람마다 경험은 다를 겁니다. 내 2센트를 말하자면:
SICP는 프로그래밍 언어에 대한 책이라고 생각하지 않습니다. 프로그래밍에 대한 책입니다. 스킴을 사용하는 이유는, 스킴이 여러 면에서 원자적 프로그래밍 언어이기 때문입니다. 람다 미적분 + 반복을 위한 TCO + 제어 추상을 위한 컨티뉴에이션 + 구문 추상(매크로) + 필요할 때를 위한 가변 상태. 매우 작습니다. 충분합니다.
이 책은 프로그래밍의 이슈를 다룹니다. 모듈성, 추상화, 상태, 자료구조, 동시성 등. 일반 디스패치, 객체, 동시성, lazy 리스트, (가변) 자료구조, ‘태깅’ 등의 설명과 장난감 구현을 제공하며, 이슈를 조명합니다.
클로저는 원자적 프로그래밍 언어가 아닙니다. 나는 너무 피곤/늙었/게으릅니다. 원자로 프로그래밍하기엔요. 클로저는 일반 디스패치, 연관 맵, 메타데이터, 동시성 인프라, 영속 데이터 구조, lazy 시퀀스, 다형 라이브러리 등을 프로덕션 구현으로 제공합니다. SICP를 따라가며 만들게 될 것들 중 일부에 대해 훨씬 더 나은 구현이 이미 클로저에 있습니다.
그래서 SICP의 가치는 프로그래밍 개념을 이해하는 데 있을 겁니다. 개념을 이미 이해한다면, 클로저는 흥미롭고 견고한 프로그램을 훨씬 빨리 쓰게 해줍니다(내 생각에). 그리고 클로저의 코어가 스킴보다 크게 더 크다고 생각하지 않습니다. 스킴 사용자들은 어떻게 생각할까요?
클로저 이전의 Lisp는 함수형 프로그래밍과 리스트로 좋은 길로 당신을 이끕니다. 하지만 실제 프로그램에 필요한 자료구조 모음에 이르면 당신을 높은 곳에 두고 떠납니다. 그런 자료구조는 제공되더라도 가변이고 명령형입니다. 이전 Lisp는 또한 광범위한 프로세스 내 동시성 이전에 설계되었고, 라이브러리 인프라로서의 고성능 다형 디스패치(예: 가상 함수)의 가치가 잘 이해되기 이전에 설계되었습니다. 그들의 라이브러리는 다형성이 확연히 제한적입니다.
아쉽게도, 아직 클로저에 대한 책이 없습니다. 하지만, 완전한 기능을 제공하기 위해 표준을 넘어서는 스킴들(대부분이 그렇습니다)에 대해서도, 책은 없습니다. 둘 다 문서뿐입니다.
클로저로 가는 길에서 스킴이나 커먼 리스프를 배우는 것은 괜찮습니다. 번역되지 않는 구체가 있을 것입니다(스킴에서 — TCO 없음, false/nil/() 차이, 컨티뉴에이션 없음; CL에서 — Lisp-1, 심볼/var 이분). 하지만 개인적으로는 SICP가 클로저에 크게 도움이 되리라 생각하지 않습니다. YMMV.
ML, 더 나아가 하스켈은 대단히 흥미로운 타입 시스템과 언어라고 생각합니다. 하지만 개인적으로, 그들의 타입 시스템이 내 필요에 충분히 표현력이 있거나 충분히 동적이라고 느끼지 못했습니다. 나는 동적 다형성과 이질적 컬렉션을 좋아합니다. GHC 확장을 통해 유사한 것을 허용하려는 사람들이 있을 것이라 확신하지만, 그럴 시간은 없습니다. 내게는 그 타입 시스템의 이점이 비용을 상쇄하지 않습니다. 그리고 하스켈은 이미 그 종류의 해법을 훌륭히 제공하고 있습니다.
반면, 나는 불변성이 큰 이점이라고 생각합니다. 그래서 클로저는 그걸 동적 언어로 가져오려는 노력입니다(얼랭도 그렇죠). ML이 변경을 원자적 참조(배열 제외)에 제한한 것은 좋은 아이디어이며, 클로저에 영감을 주었습니다. 클로저는 거기에 트랜잭션 의미론을 추가합니다.
자바가 SML이나 하스켈보다 장황하다는 점을 부정하지 않으며, 원래 정식(formulation)의 아름다움을 기꺼이 인정합니다(다만 책에 없는 remove 코드는 어느 언어에서나 예쁘지 않습니다). 그러나 패턴 매칭 언어에서 비패턴 매칭 언어로 코드를 옮기는 것은 흥미로운 연습이었습니다. 내 경험은 이랬습니다:
먼저 원본을 거의 음역(transliteration)했습니다. 데이터타입 태그를 enum으로, 매치를 조건문으로 바꿨습니다. 결과는 그다지 빠르지 않았습니다. 또한, 많은 노드가 비분기 리프가 될 것이라는 점, 맵이 종종 집합처럼(즉 값 없이) 사용될 것이라는 점을 알았기에 메모리 사용을 최적화하고 싶었습니다. (또한 당시에는 영속 해시맵이 없었고, 레드-블랙 트리가 지금 해시맵이 하는 모든 목적을 수행해야겠다고 생각했습니다). Tree ADT(SML 버전)를 E|T에서 E|T|Leaf|EmptyT|EmptyLeaf 같은 것으로 확장하는 것을 상상할 수는 있지만, 다형성이 없으면 패턴 매칭에 영향을 주는 것은 조합의 곱셈적 성장입니다.
그래서 로마(자바)에 있는 만큼, 관용적 일을 하기로 하고, 혼합 전략을 택했습니다. 다형성의 사용을 극대화했습니다(자바에서 최적화된다는 것을 압니다). 그리고 조건문의 사용을 최소화했습니다. (remove 코드는 여전히 거의 조건문입니다. 논리가 너무 불투명해서 안전하게 다시 캐스팅하기 어렵거든요 :)
결과는 Node 계층이며, 메모리 사용 전략을 구현하고, 적어도 add/lookup에서는 대부분 다형 메서드 디스패치를 사용합니다. 결과는 더 효율적이고 훨씬 빠릅니다. 논리는 분산되었습니다 — 각 노드 타입이 어느 정도 자신을 처리합니다. 조건적 접근을 유지/관리하는 것보다, 이 방식으로 값/분기 유무 변형을 추가하기가 더 쉽다고 봤습니다. 결과는 다소 장황해 보일 수 있지만, Okasaki 책의 예와 비교하는 것은 사과와 오렌지입니다. 다음을 만족하는 SML/하스켈 버전이 필요할 겁니다:
모두 우아하게 가능하겠지만, 책의 예쁜 작은 것이 더 이상 아닐 겁니다. 내가 얻은 교훈은 다음과 같습니다:
후자는 특히 중요합니다. SML/하스켈/얼랭은 패턴 매칭을 최적화하는 것이 분명합니다. 매크로로 겉보기 동작을 쉽게 만들 수 있을지 몰라도, 최적화기는 아마도 사소하지 않을 겁니다.
(nth nil 1)은 범위를 벗어난 예외를 던져야 하지 않나?
다행히도 그렇지 않습니다(first/rest 등도). 그렇지 않았다면 추측적 구조 해체(및 많은 클로저 관용구)가 불가능했을 겁니다.
많은 함수가 nil에 적용될 때 에러를 생성하지 않고 의미를 갖는 것은 CL의 영향입니다. 그리고 규칙을 한 번 알면, 큰 효과로 사용할 수 있어 우아한 코드를 만들 수 있습니다. 내가 전에 언급한 링크입니다: http://people.cs.uchicago.edu/~wiseman/humor/large-programs.html
insert, lookup, append가 없나?
또한 답변함: 왜 일부 함수는 더 다형적(into, conj, count)이고, 다른 것들은 더 타입 특화(contains?, assoc)인지?
클로저의 중요한 측면은, 일반적으로 성능 보장이 함수의 의미론의 일부라는 것입니다. 특히, 데이터 구조에서 성능이 좋지 않은 함수는 지원되지 않습니다.
프로그램에서 시퀀스나 리스트를 맵이나 집합으로 그냥 바꿔치기할 수 없습니다. 그래서 작업에 맞는 올바른 데이터 구조를 사용해야 하고, 그러면 올바른 함수가 제공됩니다. 의미가 있을 때는 일부 함수가 최대한 다형적입니다(예: seq, into). 하지만 어떤 이름으로든 조회(lookup)는, 내 생각에, 다형적이어선 안 됩니다. 그래서 클로저에서는 아닙니다. 마찬가지로, 벡터의 앞에 삽입, 리스트 끝에 append, 맵의 값으로 조회, 시퀀스 중간 삽입 등은 없습니다. 클로저로 N^2 성능의 프로그램을 쓰도록 돕는 도구를 제공하고 싶지 않습니다 — 아무에게도 이롭지 않습니다. 그러나 사람들이 리스트의 O(n) 함수 위에 쌓으면서 성능이 그렇게 망가지는 Lisp 프로그램을 많이 보아 왔습니다.
프로그램에 조회 테이블이 있다면 집합이나 맵을 사용해 주세요. 또는 인덱스로라면 벡터를요. 그러면 get, contains?, nth를 사용할 수 있고, 성능이 좋고, 코드에서도 명확합니다.
seq-contains?는 때로, 예컨대 매크로에서, 알려진 짧은 시퀀스가 있고 무엇의 존재를 테스트해야 할 때가 있기 때문에 존재합니다. 집합으로 복사할 필요가 없습니다. 그리고 사람들은 이 일을 위해 자신의 includes? 등을 만들곤 했습니다. 이제 모두가 그 일을 위해 seq-contains?를 사용할 수 있습니다. 그 성능이 좋지 않다는 것은 이름과 문서로 표시됩니다. 누군가 seq-contains?에 기반한 코드 조각을 스케일하려고 하면, 그 함수가 명백한 병목으로 보일 것입니다.
타입 체크 주장은 허수아비에 불과하며, 클로저 실천에서는 성립하지 않습니다. 사람들은 작업에 맞는 올바른 데이터 구조를 사용하는 경향이 있으며, 타입 체크로 알고리즘을 선택하지 않습니다.
클로저는 특히 ‘덕 타이핑’하지 않습니다. 일반적으로 동일 이름의 함수의 존재로 통일되는 것이 아니라, 기반의 공유 추상화에 의해 통일됩니다. 프로토콜이 많은 타입에 도달하게 하는 데 사용될 수 있다고 해서, 균일하지 않은 것들을 통일하는 데 사용해야 한다는 뜻은 아닙니다.
문제는 단일 패스 vs 다중 패스가 아닙니다. 그보다는 컴파일 단위가 무엇인가, 즉 무엇에 대한 한 패스인가가 문제입니다. 클로저는, 그 이전의 많은 Lisp처럼, 강한 컴파일 단위 개념이 없습니다. Lisp는 REPL을 통해 일련의 상호작용/폼을 받도록 설계되었습니다. 파일/모듈/프로그램 등을 컴파일하는 것이 아닙니다. 이는 매우 작은 조각으로 인터랙티브하게 Lisp 프로그램을 구축할 수 있음을 의미하며, 가면서 네임스페이스를 전환하는 등도 가능합니다. Lisp 프로그래밍 경험의 매우 가치 있는 부분입니다. 이는 Lisp 프로그램의 조각을 단일 폼만큼 작게 소켓으로 스트리밍할 수 있고, 도착하자마자 컴파일 및 평가할 수 있음을 의미합니다. 이는 매크로를 정의하고 즉시 다음 폼의 컴파일에 컴파일러가 그것을 포함하게 할 수 있거나, 깨진 파일의 작은 섹션만 평가할 수 있음을 의미합니다. 등등. 그 ‘1980년대의 농담’은 여전히 유효하며, 큰 단위/다중 단위 컴파일러가 할 수 없는 것들을 가능케 합니다. 참고로, 클로저의 컴파일러는 2패스이지만, 단위는 매우 작습니다(톱레벨 폼).
Yegge가 정말로 요구하는 것은 상호 참조를 위한 다중 단위(그리고 더 큰 단위) 컴파일입니다. 한 단위가 다른 단위를 참조하고, 그 반대도 가능하며, 두 단위의 컴파일이 서로의 고려 이후에만 해소될 수 있는 참조를 매달아 두고, 그 다음 ‘패스’에서 연결짓는 방식입니다. 클로저에서 그런 단위는 무엇일까요? 클로저가 파일을 요구하고 그에 대한 의미론을 정의하기 시작해야 할까요? (지금은 그렇지 않습니다) 전방 참조는 다중 패스나 컴파일 단위를 필요로 하지 않습니다. 커먼 리스프는 선언되지 않았고 정의되지 않은 것들에 대한 참조를 허용하며, 런타임 시점에 정의되지 않았다면 런타임 오류를 생성합니다. 클로저도 같은 접근을 취할 수 있었습니다. 그 트레이드오프는 다음과 같습니다:
#1은 논쟁의 여지가 있지만, 이 검사는 편리하고 유용합니다. 클로저는 declare를 지원하므로, 함수를 특정 순서로 정의하도록 강제되지 않습니다.
#2는 디테일의 악마입니다. 커먼 리스프처럼, 클로저는 컴파일되도록 설계되었고 일반적으로 런타임에 이름으로 것을 조회하지 않습니다. (물론 딕셔너리를 지닌 객체를 다루는 데 초점을 맞춘 좋은 Smalltalk 구현들처럼, 조회하는 빠른 언어를 설계할 수는 있습니다. Lisp는 그렇지 않습니다). 그래서 클로저와 CL 모두 이름을, 컴파일된 코드에서 주소를 바인딩할 수 있는 것으로 구체화(reify)합니다(CL은 심볼, 클로저는 var). 이 구체화된 것들은 ‘intern’됩니다. 동일한 이름에 대한 어떤 참조도 동일한 객체를 참조하도록 하여, 값이 아직 정의되지 않은 것들을 참조하더라도 컴파일이 진행될 수 있습니다.
하지만 컴파일러가 이전에 bar를 본 적이 없을 때, 여기서는 무엇이 일어나야 할까요?
(defn foo [] (bar))
혹은 CL에서는:
(defun foo () (bar))
CL은 기꺼이 이를 컴파일하며, bar가 결코 정의되지 않으면 런타임 오류가 발생합니다. 좋습니다. 그런데, 컴파일 중에 bar에 대해 어떤 구체화된 것(심볼)을 사용했습니까? 폼이 읽힐 때 intern된 심볼입니다. 그러면 런타임 오류가 발생하고 bar가 다른 패키지에 정의되어 있고 import하는 것을 잊었다는 것을 깨닫게 되면, 다른 패키지를 import하려 합니다. 그리고 쾅! 또 다른 오류입니다 — 충돌, other-package:bar가 read-in-package:bar와 충돌합니다. 그런 다음 unintern에 대해 배우게 되죠.
클로저에서는 폼이 컴파일되지 않고 메시지가 표시되며, bar에 대해 어떤 var도 intern되지 않습니다. 다른 네임스페이스를 require하고 계속합니다. 나는 이 경험이 훨씬 좋다고 생각했고, 그래서 이런 트레이드오프를 택했습니다. intern하지 않는 리더를 사용하고, 정의/선언에서만 intern함으로써 얻은 다른 많은 이점이 있었습니다. 상호 참조를 지원하기 위해 그것들을 포기할 생각은 없습니다. 앞서 언급한 이점들도요.
시퀀스를 생각하는 한 가지 방법은, 왼쪽에서 읽고 오른쪽에서 먹인다는 것입니다:
<- [1 2 3 4]
대부분의 시퀀스 함수는 시퀀스를 소비하고 생성합니다. 그것을 체인으로 시각화할 수 있습니다:
map<- filter<-[1 2 3 4]
그리고 많은 시퀀스 함수를, 어떤 방식으로 파라메터화된 것으로 생각할 수 있습니다:
(map f)<-(filter pred)<-[1 2 3 4]
그래서 시퀀스 함수는 소스(들)를 마지막에 받고, 다른 파라미터는 그 앞에 받습니다. 그리고 partial이 위와 같이 직접 파라메터화를 가능케 합니다. 함수형 언어와 Lisp에 그런 전통이 있습니다.
이는 기본 피연산자를 마지막에 받는 것과 같지 않습니다. 일부 시퀀스 함수는 하나 이상의 소스를 가집니다(concat, interleave). 시퀀스 함수가 가변 인자라면, 보통 그 소스들에서 가변입니다.
가변 인자 목록이 기본 피연산자의 위치에 대한 기준이 되어야 한다고 생각하지 않습니다. 네, 그것들은 마지막에 와야 합니다. 그러나 assoc/dissoc의 진화가 보여 주듯, 가변 인자는 나중에 추가되기도 합니다.
partial도 마찬가지입니다. 모든 라이브러리는 결국 보다 순서 독립적인 부분 바인딩 방법을 갖게 됩니다. 클로저에서는 #()입니다.
그렇다면 일반 규칙은 무엇인가요?
기본 컬렉션 피연산자는 먼저 옵니다. 그래야 -> 같은 것을 쓸 수 있고, 그것의 위치는 가변 인자 유무와 독립적입니다. OO 언어와 CL에도 그런 전통이 있습니다(CL의 slot-value, aref, elt — 사실 CL에서 가장 자주 나를 걸리게 하는 것은 gethash입니다. 앞의 것들과 일관되지 않기 때문이죠).
결국 규칙은 두 개입니다. 하지만 난장판은 아닙니다. 시퀀스 함수는 소스를 마지막에 받고, 컬렉션 함수는 기본 피연산자(컬렉션)를 먼저 받습니다. 약간의 주름이 없는 것은 아닙니다(set/select 등).
순차적 조회는 중요한 연산이 아닙니다. 클로저에는 집합과 맵이 포함되어 있으며, 무언가를 조회하려 한다면 그것들을 사용해야 합니다. contains?는 java.util.Set.contains에 대응됩니다. java.util.Collection에도 contains가 있다는 사실은, 내 생각에, 실수입니다. 성능 특성이 극적으로 다양한 인터페이스에 맞춰 코드를 작성할 수는 없습니다. 그래서 집합과 맵에서의 조회가 우선하고 최고의 이름 — contains? — 을 얻습니다. 다른 언어에서 사람들이 contains()로 순진한 선형 조회 프로그램을 작성하고, 그에 상응하는 나쁜 N제곱 성능을 얻습니다 — 클로저에서 그걸 장려해야 할 논거가 아닙니다. 왜 그게 나쁜 생각인지, 집합과 맵을 어떻게 사용하는지에 대한 설명을 받는다면, 그건 괜찮습니다.
동기는 맵 리터럴을 갖는 데서 왔습니다. 문법을 피하고 싶었습니다. 예컨대 키와 값 사이의 ‘:’나 필수 쉼표 같은 것, 그리고 구조 — 키-값 쌍을 괄호로 묶는 것 등 — 도요. 하지만 키와 값이 균일한 큰 맵을 괄호 없이 출력하면 자리를 잃기 매우 쉽습니다: {1 2 3 2 5 3 7 2...}
쉼표를 공백으로 두면 양쪽의 최고를 얻습니다. 짧은 맵이나 키와 값이 뚜렷한 맵(e.g. {:fred 41 :ethel 42})은 쉼표 없이 입력할 수 있고, 도움이 될 때 쉼표를 사용할 수 있습니다. 기본적으로, 맵은 키/값 쌍 사이에 쉼표를 두고 출력됩니다: {1 2, 3 2, 5 3, 7 2...}. 맵 전용 문법이 아니므로, 어디든 사용할 수 있어, {}를 []로 바꾸는 것만으로 맵을 벡터로 바꾸는 식의 일이 쉬워집니다.
사람들은 다른 곳에서도 쓰기 시작했습니다. 특히 let 바인딩에서요. 클로저에는 각 바인딩을 전통적으로 괄호로 감싸는 것이 없고, 그곳에서도 쉼표는 선택적이며 비구조적이고, 개인 선호와 필요의 문제입니다. (let [a 1 b 2]...)는 괜찮을 수 있지만 (let [x a, b y, c z] ...)는 쉼표 없이보다 더 명확할 수 있습니다.
트랜지언트는, 당신이 그 밖에는 영속 데이터 구조를 사용하고 있다면, 트랜지언트로의 변환과 다시 돌아오는 것이 O(1)이라는 점에서 함수형 프로그래밍과 관련이 있습니다. 가변 버전을 만들기 위해 전체 복사도 없고(그 반대도), 가변 버전의 사용이 그 영속 소스에 대해 어떤 방식으로든 위험하지도 않습니다. 이를 위해서는 공생적 설계가 필요합니다. 단순히 “이 다른, 별도의, 가변 데이터 구조로 전환할 수 있게 해 줄게”라는 문제가 아닙니다.
물론 트랜지언트를 사용하는 것은 함수형 프로그래밍이 아니며, 그것들은 영속적이지도 않습니다. 하지만, 성능 병목을 만나면 데이터 구조와 코드의 형태를 바꿔야 할까 봐 함수형 프로그래밍과 불변 데이터를 꺼려할지도 모르는 사람들을 위한 이야기의 중요한 부분입니다.
클로저의 영속 벡터와 해시맵은 높은 분기 계수를 가진 트리입니다. 트랜지언트 버전을 만드는 것은 루트 노드를 트랜지언트 인터페이스를 구현하는 새 객체로 복사하고, 나머지 구조는 공유하는 것을 의미합니다. 수정이 이루어지면, 트랜지언트는 자신이 쓸 수 있는(writable) 복사본을 소유하지 않은 노드를 클론합니다. 한 번 어떤 노드의 자체 복사본을 갖게 되면, 그 노드에 영향을 미치는 이후의 수정은 제자리에서 할 수 있습니다. 편집을 마치면, 루트가 다시 클론됩니다. 이번에는 영속 인터페이스를 구현하는 객체로요. 그리고 트랜지언트의 사용을 비활성화하는 플래그가 전환됩니다. 본질적으로 트랜지언트는 제자리에서 영속 구조를 구축합니다. 변경 추적이나 압축이 없습니다. 스레딩과 패싱 요구사항은, 코드가 그것이 대체하는 함수형 코드와 동일한 모양을 유지하게 하도록 설계되었습니다.
이 문맥에서 다형성은 (foo x) 혹은 x.foo()라고 말했을 때, x의 어떤 특성에 따라 일어나는 일이 달라질 수 있음을 의미합니다. 전통적 OO 언어(단일 디스패치, 클래스의 메서드)에서는 활용할 수 있는 x의 유일한 특성은 그 타입/클래스, 이를테면 X입니다. 더 미묘한 두 번째 측면은, 어떤 foo를 말하는가입니다. 전통적 OO 언어에서 호출은 보통 두 번째 형태를 취합니다. x의 클래스의 스코프에서 foo를 조회하여 질문에 답하기 때문입니다. 그 스코프는 슈퍼클래스 등을 포함할 수 있지만 중요한 것은 네임스페이스를 구성한다는 점입니다. 즉 전통적 OO 언어는 네임스페이스와 다형성을 통합합니다.
정적 OO 언어(C++/Java 등)에서 스코프는 클래스 정의 시점에 닫힙니다. 원 작성자가 최종 발언권을 갖습니다. 더 이상의 메서드 추가도, 더 이상의 이름 도입도 없습니다. 동적 OO 언어(Smalltalk, Python, Ruby 등)에서는 보통 클래스 정의를 바꾸지 않고 클래스 스코프의 메서드 집합을 변경하거나 확장할 수단(원숭이 패치, monkey-patching)이 어느 정도 있습니다. 첫 번째 경우 — 기본 기능 변경 — 은 본질적으로 위험이 도사리지만, 두 번째 — 확장 — 은 바람직하고 합리적으로 보이며, 나머지 논의는 확장에 초점을 맞출 것입니다.
그래서 Fred는 X에 bar()를 추가하고 싶어 하고, 언어의 원숭이 패치 기능을 사용합니다. 그는 x.bar()를 호출했고, 잘 작동합니다. Ethel은 독립적으로 작업하면서, X에 자신만의 의미론을 가진 bar를 추가하고 싶어 합니다. 그녀도 그럴 수 있고, 그렇게 했고, 잘 작동합니다. Ricky는 Fred와 Ethel의 라이브러리를 모두 사용하고, X를 만들어 x.bar()를 호출합니다 — 무슨 일이 일어날까요? 좋은 일은 없습니다. 피할 수 있었을까요? Fred와 Ethel이 X에 주입하지 않고 독립 함수(e.g. fredlib.bar(X), ethellib.bar(X))를 쓸 수 있었겠죠? 아마 그들은 bar를 다형적으로 만들고 싶어서 그렇게 하지 않았을 겁니다. 즉 X, Y, Z 클래스에 bar를 추가하여, xyorz.bar()를 호출했을 때 xyorz의 타입에 따라 알맞은 일이 일어나게 하고 싶었겠죠. 그래서 원숭이 패치의 문제는, 그것이 모든 확장을 단일(클래스) 네임스페이스에 살도록 강제하므로 합성 불가능하다는 것입니다.
다른 방법이 있을까요? 있습니다. CLOS 설계자들은 위대한 지혜로, 그리고 다중 디스패치를 지원하려는 욕구로, 다형적 함수가 클래스 안에 있거나 클래스의 것이 되는 것의 한계를 깨달았습니다. 그들은 제네릭 함수 — 함수 독립체 — 를 발명했습니다. 함수의 하나 이상의 제네릭 메서드 정의를 통해 확장 가능한 다형 디스패치를 허용하는 함수입니다. 그러한 메서드는 함수의 하나 이상의 인자의 타입이나 값에 따라 선택됩니다. 그리고 그들은 패키지가 네임스페이스를 분리하는 언어 — 커먼 리스프 — 에서 그렇게 했습니다. 매우 강력한 다중 디스패치 능력을 잠시 제쳐 두더라도, 이 체계는 네임스페이스, 클래스 정의, 다형성의 분리라는 이점을 가집니다. 결과는 더 강력하고 합성 가능합니다. 클로저는 패키지(네임스페이스)와 멀티메서드(제네릭 함수)를 갖는다는 점에서 CLOS를 따릅니다.
그래서 클로저를 사용하면서, 인자의 타입에 다형적인 함수 bar를 원하고 자신의 네임스페이스에서 작업하는 Fred는 멀티메서드를 정의합니다:
(in-ns 'fred) (clojure/refer 'clojure) (defmulti bar class) (defmethod bar String [s] :fred-bar-string) (defmethod bar Integer [i] :fred-bar-int) (bar "foo") -> :fred-bar-string (bar 2) -> :fred-bar-int
그리고 Ethel도 유사하게 합니다:
(in-ns 'ethel) (clojure/refer 'clojure) (defmulti bar class) (defmethod bar String [s] :ethel-bar-string) (defmethod bar Symbol [s] :ethel-bar-sym) (bar "foo") -> :ethel-bar-string (bar 'foo) -> :ethel-bar-sym
Fred와 Ethel의 라이브러리를 모두 사용하려는 Ricky는 bar에 관해 많은 선택지가 있습니다. 그는 Fred의 bar에만 관심이 있을 수 있습니다. 그 경우 ethel을 refer할 때 :exclude bar를 할 것입니다. 혹은 둘 다 똑같이 사용할 수도 있습니다. 그 경우 둘 다 refer하지 않고, 각각을 fred/bar, ethel/bar로 완전 수식하여 호출하는 것을 선호할 것입니다. 또는 그 이름이 번거롭다면 :rename으로 barf, bare로 바꿀 수도 있습니다. 중요한 점은, Ricky는 fred/bar와 ethel/bar 사이의 의미 차이를 인지할 수 있고, 언제 어떤 것을 사용할지 선택할 수 있으며, 하나의 존재 때문에 다른 것에 접근이 금지되는 일이 결코 없다는 것입니다. 그리고 이 모든 결정은 bar의 다형성(여부)과 완전히 독립적입니다.
즉, 제네릭 함수/멀티메서드가 있으면, 다형적 확장을 제공하기 위해 누군가의 스코프를 수정할 필요가 없습니다. 따라서 CL이나 클로저에서는, 일부 다른 동적 OO 언어에서 원숭이 패칭 외에는 대안이 없는 종류의 확장을 제공하기 위해 원숭이 패칭이 필요하지 않습니다(언어 밖에서 CLOS 유사 디스패칭을 구축하지 않는 한). 패키지/네임스페이스의 제네릭 함수/멀티메서드는 합성 가능합니다. 독립적이고 충돌 없는 확장을 허용하기 때문입니다.
음악가들은 연습과 점점 더 어려운 곡에 도전함으로써 성장합니다. 악기나 장르를 바꾸거나, 쉽고 다양한 곡을 더 많이 배우는 것으로는 아닙니다. 전문가나 거장이 사는 거의 모든 다른 전문 분야도 마찬가지입니다. 다양한 경험은 다방면으로 균형 잡힌 것을 낳을 수는 있지만, 위대함이나 심지어 좋음조차 낳지 못합니다. 한 가지에서 다른 것으로 계속 바꾸면, 당신은 항상 자신의 편안함 영역 위로 손을 뻗고 있지만, 그럴 때마다 자신의 기술과 지식 수준을 0으로 리셋합니다.
어떤 범용 언어, 어떤 도메인, 어떤 플랫폼에서도 위대한 개발자가 될 수 있습니다. 그리고, 이 논의의 목적상 특히 주목할 만한 점은, 그런 개발자는 그 어떤 변화에도 그 위대함을 그대로 가지고 갈 수 있다는 것입니다. 그렇다면 소프트웨어 개발에서 그렇게 보편적으로 유용하고 이식 가능한 기술은 무엇일까요? 두 가지가 있습니다: 지식을 습득하는 능력, 그리고 문제를 해결하는 능력.
지식 습득과 문제 해결 능력을 어떻게 향상시킬까요? 피상적 지식을 많이 획득하거나 사소한 문제를 많이 해결하는 것(당신의 ‘업적’처럼)으로는 아닙니다. 더 깊은 지식을 획득하고 더 어려운 문제를 해결함으로써입니다.
당신의 표현 ‘레벨 업’을 새겨들어야 합니다. 게임을 계속 바꾸는 것으로는 레벨 업하지 못합니다. 하나를 오래 붙들고 고급 기술을 얻음으로써 레벨 업합니다. 그리고 실제 게임을 인지하는 데 주의해야 합니다. 프로그래밍 숙련은 언어, 패러다임, 플랫폼, 빌딩 블록, 오픈소스, 컨퍼런스 등과 거의 관련이 없습니다. 이런 것들은 항상 변하며 근본적이지 않습니다. 지식 습득 능력은 필요할 때 그것들을 이해시켜 줍니다. 얕은 경험을 부페식으로 늘어놓은 프로그래머보다, 깊은 지식 습득과 문제 해결 능력을 가진 개발자(혹은 심지어 비개발자)를 나는 언제든 선택하겠습니다.
이것이 불린 업적에 기반한 쉽게 실현 가능한 개선 전략으로 이어질까요? 아마 아닐 겁니다.