선형 타입 시스템의 스레딩 문제를 렌즈와 흐름 타이핑으로 해결하는 방식을 살펴보고, 이를 새로운 프로그래밍 언어에 구현해 보는 이야기입니다.
새 프로그래밍 언어를 개발하려는 여러 시도에서, 저는 선형 타입을 곁눈질해 본 적이 있습니다. 엄청나게 번거롭지만 않다면 정말 유용할 것처럼 보이는 대상이었기 때문입니다.
그런데 이제 몇몇 사람이 훨씬 쉽게 사용할 수 있게 만드는 방법[1]을 생각해 냈습니다! 적어도 희망은 그렇습니다. 지난 한 주 동안 저는 실제로 사용할 때 어떤 느낌인지 알아보기 위해, 그들의 실행 모델을 새 프로그래밍 언어에 구현하려고 해 왔습니다. 이미 논문에 익숙하다면 제 현장 기록을 확인해 보세요.
논문을 훌륭하게 소개하는 초록이므로 전문을 인용하겠습니다.
선형 타입 시스템은 흔히 스레딩 문제를 겪는다. 값이 두 번 이상 사용된다면, 각 사용을 통해 명시적으로 전달되어야 한다. 기존 해결책은 이를 완화하기 위해 추가 언어 기능을 도입하지만, 그 대가로 핵심 언어와 그 의미론이 복잡해진다. 이 글은 핵심 언어를 확장할 필요를 피하는 스레딩 문제의 새로운 해결책인 흐름 타이핑을 소개한다. 흐름 타이핑은 가변 참조를 함수적으로 표현하는 렌즈 이론에 기반하며, 여기서 렌즈 항은 상태를 갖는 타입 컨텍스트의 일부를 통제된 방식으로 변경한다. 이 접근법은 순수하게 함수적이므로 표준 선형 람다 대수로 구현할 수 있으며, 흐름 타이핑에 단순하고 직접적인 의미론을 제공한다.
좋습니다. 여기에는 일반적인 프로그래머에게 완전히 익숙하지는 않을 법한 용어가 여럿 있다는 점을 인정하겠습니다. 또 저 자신도 그 의미를 알고 있음을 확인하고 싶으니, 조금 나누어 살펴봅시다.
타입 시스템은 모든 값을 정확히 한 번 사용해야 한다면 _선형_입니다. 아마 (가) 엄청나게 제한적으로 들리는데 나는 값을 여러 번 쓰는 것을 꽤 좋아하고, (나) 대체 왜 그런 것을 원하겠냐고 생각할 수 있습니다. 우선 두 번째에 답하겠습니다.
선형 타입 시스템이 매력적일 수 있는 몇 가지 이유는 다음과 같습니다.
파일을 닫는 것을 잊어버려서 남아 있는 파일 디스크립터가 생긴 적이 있나요? 포인터를 해제하는 것을 잊어버린 적이 있나요? 웹 개발 용어로 말하면, 데이터베이스 연결을 닫지 못했거나 리스너 구독을 해제하지 못한 경우는 어떤가요?
어떤 값(가령 DBConnection)을 정확히 한 번 사용해야 하고, 라이브러리가 (가) 연결을 인수로 받아 반환값으로 다시 돌려주는 함수들과 (나) 이를 “없애는” 유일한 방법인 close() 함수를 제공한다면, 이제 데이터베이스 연결을 닫는 일을 절대 잊지 않도록 보장하는 정적으로 강제 가능한 메커니즘을 갖게 됩니다.
이를 구체적으로 살펴보겠습니다.
let run = () => {
let db = sqlite.open('./my.db');
let [db2, records] = sqlite.run_query(db, RECORD_QUERY);
save(records);
sqlite.close(db2);
};
마지막 sqlite.close(db2) 줄이 없다면(선형 타입 시스템을 갖춘 가상의 자바스크립트에서), db2 must be used exactly once, but was used 0 times라는 타입 오류가 발생할 것입니다. (save 함수는 “데이터베이스에서 얻은 레코드로 무언가를 하는 것”을 대신합니다.)
이런 시스템에서는 반환값을 무시하거나 잊어버리는 일도 정적으로 금지됩니다.
순수 함수형 언어는 대개 불변성을 통해 순수성을 달성하며, 순수 언어를 갖기 위한 필수 구성 요소라고 생각해도 용서받을 정도입니다. 여기서 “순수”란 “함수에 같은 입력을 주면 언제나 같은 출력을 반환한다는 것이 보장된다”는 뜻입니다[2]. 자바스크립트에서 불순한 함수의 간단한 예는 다음과 같습니다.
let lie = false;
function addTwo(input) {
if (lie) return input + 3;
return input + 2;
}
console.log(addTwo(2)); // 4
lie = true;
console.log(addTwo(2)); // 5
이것은 순수 함수형 프로그래밍이 우리를 구해 주는 해악인 원격 작용의 꽤 얌전한 예입니다.
Haskell이나 Elm에서는 변수를 초기화한 뒤에는 “lie가 나타내는 값을 변경할” 수 없으므로 위 코드를 표현할 수 없습니다. 모든 변수는 불변이므로, 구성 자체로 순수성을 제공합니다.
반대로 선형 타입 시스템에서 순수성은 어떤 변수도 여러 관찰자를 갖지 못하게 막음으로써 달성됩니다. 본질적으로 “변경이 일어났지만 아무도 그것을 관찰할 수 없다면, 소리가 나는가?”라는 질문입니다[3]. 위 코드는 7번째 줄이 “변수 lie에 대한 두 번째 참조”이므로 타입 오류가 되어 금지됩니다.
Rust는 가변 변수에 대한 선형성에서 영감을 얻은 구성으로 유명합니다. 변수가 가변으로 표시되면, 한 번에 하나의 “접근자”(읽는 쪽 또는 쓰는 쪽)만 있을 수 있습니다. 이 덕분에, 특히 동시성 맥락에서 안전성에 관해 매우 강력한 보장을 제공할 수 있습니다. 그러나 가변 변수에 참조를 통한 접근도 제공하고 싶어 합니다(가변인 모든 것을 값으로 전달해야 한다면 몹시 불편할 것이기 때문입니다). 이는 악명 높은 “빌림 검사기”를 통해 이루어집니다. 선형 스타일의 요구 사항은 “빌림” 시스템으로 표현되며, 컴파일러는 코드의 특정 시점에 어떤 변수가 “활성” 상태인지 추적하고 주어진 값에 대해 한 번에 활성 참조가 하나뿐임을 보장해야 합니다. 이는 사람들에게 배우기 어렵고 혼란스럽기로 유명합니다. 그럼에도 Rust는 엄청나게 성공한 언어입니다.
잠시 선형 타입 시스템이 바람직하다고(혹은 적어도 흥미롭다고) 받아들이고, “가변인 모든 것을 값으로 전달해야 한다면 몹시 불편하다”는 문제로 돌아가 봅시다. 이것이 Wilshaw & Hutton이 “스레딩 문제”라고 부르는 것입니다. 간단한 타입스크립트 코드로 보여드리겠습니다.
let person = new Person();
setName(person, 'Lucretia');
setAge(person, 51);
setSummary(person, getSummary(person));
display(person);
객체를 생성하고, 몇 가지 함수로 변경한 뒤, 이를 표시합니다.
타입스크립트에서는 참조로 전달하며, 이것이 setName 등이 객체에 영향을 줄 수 있는 방식입니다. 선형 타입 시스템에서는 참조 전달이 허용되지 않습니다(변수마다 하나의 접근자만 허용됩니다). 따라서 값 전달을 사용한 코드는 다음과 같이 보일 것입니다[4].
let person = new Person();
let person2 = setName(person, 'Lucretia');
let person3 = setAge(person2, 51);
let person4 = setSummary(person3, getSummary(person3));
display(person4);
그리고 setName은 예를 들어 (person, name) => {person.name = name}에서 (person, name) => ({...person, name})로 바뀌어야 합니다.
이것은 거의 선형 규칙에 맞지만, 4번째 줄에서 person3를 두 번 사용하므로 불법입니다. 다음처럼 바꿔야 합니다.
let [person4, summary] = getSummary(person3);
let person5 = setSummary(person4, summary);
display(person5);
극도로 번거롭습니다. person 객체를 건드리는 모든 함수에 넣고 다시 꺼내기 위해 이를 “스레딩”해야 합니다.
렌즈에 대한 제 이해는, 주어진 데이터 조각에서 잠재적으로 깊게 중첩된 일부에 대해 (흔히 함수적·불변 방식으로) 연산하는 방법이라는 것입니다. Lodash의 set과 update가 이와 관련되어 있고, ramda는 렌즈를 명시적으로 지원합니다.
이 논문의 혁신은 영리한 문법 설탕을 사용하여, 전통적인 선형 타입 시스템의 단순성과 순수성은 유지하면서 가변 참조의 편리함을 제공한다는 점입니다. 객체 내부의 여러 깊은 계층까지 가능하며, 그것이 렌즈 부분입니다. 이는 본질적으로 빌림 검사기와 비슷한 것을 제공하지만, 의미론과 구현은 극적으로 단순해, 이것이 작동한다는 형식적 증명 같은 것도 가능하게 합니다 ;).
하지만 제게 가장 큰 질문은 _실제로 사용할 때 어떤 느낌인가?_입니다. 언어에 실제로 구현하기는 얼마나 어렵고, 그 결과 코드는 작성하고 읽고 이해하기 쉬운가요?
그래서 저는 그들의 “흐름 타이핑” 시스템을 구현하려는 언어를 작성하기 시작했습니다. 여기에 제 현장 기록이 있습니다.
저는 올해 사람들이 가장 좋아하는 프로그래밍 언어 논문을 물었던 Alexis King의 이 게시물을 통해 이 논문을 알게 되었습니다. ↩︎
위키백과에서 바꾸어 쓴 것입니다. 물론 이는 기반 시스템의 신뢰성에 관한 특정 가정 안에서만 참입니다. 같은 컴퓨터의 다른 행위자가 힙을 손상시킬 수도 있고, 우주선이 비트를 뒤집을 수도 있습니다. ↩︎
Clojure는 트랜지언트에 이 접근법을 사용하지만, 물론 그것들이 올바르게 사용된다는 정적 보장은 전혀 없습니다. ↩︎
사실 타입스크립트는 여전히 참조로 전달하고 있지만, 불변 업데이트를 수행함으로써 값을 값 전달인 것처럼 취급하고 있습니다. ↩︎