고차 함수, 이펙트를 새지 않게
능력을 인자로 넘긴다면, `f`가 logger를 요할 때 `map(list, f)`는 어떻게 되는가? logger를 f의 타입에 넣으면 map의 시그니처로 샌다, map은 결코 로그하지 않는데. f를 순수하게 하면, 이펙트를 가진 함수를 애초에 넘길 수 없다. 여기가 이펙트 모델이 가장 깨지기 쉬운 곳이었다 —— 그리고 출구는, 앞선 결정에 그것을 정하게 하고, 확인하기 위해 최소의 trial을 세우는 것이었다.
지난 회는 방향을 정했다 —— 이펙트는 능력으로서, 값으로서 넘겨진다 —— 그리고, 모델이 가장 갈라지기 쉬운 곳을 곧장 가리켰다. 일차 함수는, 그 시그니처가 자기 능력을 정직하게 늘어놓을 수 있다. 하지만 다른 함수를 취하는 함수는 어떻게 되는가?
fn map(f: ???, xs: List[T]) -> List[U]
// ^^^ f가 Logger를 요하면, map의 시그니처는 무엇이 되는가?
명백한 두 답은, 어느 쪽도 틀렸다. logger를 f의 타입에 넣으면, 그것은 map의 시그니처
로도 올라온다 —— 이제 map은 결코 쓰지 않는 로그 능력을 광고하고, 모든 고차 함수가, 무엇을
넘겨받든 그 이펙트에 오염된다. 대신 f를 순수한 T -> U로 하면, 로그하는 함수를 그저
넘길 수 없다. 범용 함수가 범용이 아니게 되거나, 쓸모없어지거나 둘 중 하나다. 이것이 이펙트
시스템의 진짜 시험이다 —— 잎의 함수가 아니라, 그 위에 앉는 콤비네이터가 시험된다.
다섯 후보, 둘로 좁히기
설계 노트는 그것을 다룰 다섯 갈래 길을 늘어놓았고, 대부분이 스스로 탈락했다.
고차 함수를 순수 함수만으로 제한하는 것은, 맨 먼저 떨어졌다 —— map·filter·fold
에서 용도의 대부분을 도려낸다. f가 이펙트를 수행하고, 스택 위의 무언가가 그것을 handle
하는 대수적 이펙트 핸들러도 옆으로 치웠다: 그것은 암묵의 비국소 제어 흐름에 의존하고,
그것이야말로 이펙트 시스템 전체가 없애기 위해 존재하는 보이지 않음이며, 이미 내린 능력
전달의 결정과도 모순된다. 클로저 캡처 타입 —— 클로저에, 그것이 포획한 이펙트를
표기하는 것 —— 은, 첫 번째로 살아남은 후보의 바꿔 말하기일 뿐, 별개가 아님이 드러났다.
그리하여 진짜 결승 후보 둘이 남았다:
- 이펙트 행 다형성, Koka·Eff·OCaml 5의 이펙트처럼:
map에, 이펙트 변수에 대해 다형인 시그니처를 주어,f가 가진 어떤 이펙트도 통과시킨다.map(fn x -> x+1)은 빈 이펙트 행으로 돌고,map(fn x -> { log(x); x })는 logger 이펙트를 자동 추론한다. 추론은 완비 이고map은 완전히 범용으로 유지된다 —— 타입 시스템에 행 타입을 더하는 대가와, 모든 함수 타입이 이펙트 표기로 부푸는 대가를 동반한다. - 능력 인자의 명시: 이펙트를 값의 레벨에 머무르게 한다. 로그하는 함수는, 실은 logger
인자를 취하는 함수이고, 부르는 이는 그 함수를
map에 넘기기 전에 logger를 공급한다. 그래서map이 그것을 볼 무렵에는, 그것은 평범한T -> U다.
앞선 결정에 정하게 하기
두 결승 후보는 어느 쪽도 실용에 족하고, 어느 쪽도 어딘가에서 실증되었다. 특히 행 다형성은, 세련되고 잘 벤치마크된 답으로, 실제 언어에서의 실적을 가진다. 표현력만으로는, 이쪽이 더 강한 선택지다.
하지만 선택은 표현력으로 이뤄지지 않았다. 일관성으로 이뤄졌다. 두 선택지가, Mere가 이미
입장을 취한 한 축에서 갈리기 때문이다: 행 다형성은 이펙트를 함수 타입에 싣고, 능력 인자는
이펙트를 값의 레벨에 머무르게 하여 함수 타입을 평범한 T -> U로 남긴다. 지난 회의 결정
—— 능력 전달 —— 은 이미, Mere에서는 이펙트가 값이고, 타입에 부호화되는 무언가가 아니다
라고 선언했다. 행 다형성은 그 반대의 선언을, 고차 함수용으로 차려입은 것이다. 여기서 그것을
택하면, 한 회 전에 취한 입장을 잠자코 뒤집고, 언어가 평소 피하려 애쓰는 행 타입의 기구를
끌어들이게 된다.
그래서 Mere는 능력 인자의 명시를 택했다. 장치는 거의 김빠질 만큼 소박하다:
let inc = fn x -> x + 1 // 순수
let f = fn x -> log_x(x, logger) // 부르는 이가 logger를 채운다; f : Int -> Int
map(inc, xs) // map은 평범한 Int -> Int를 본다
map(f, xs) // 이것도 마찬가지 —— cap은 이미 안에 있다
f는 logger를 쓰지만, 그것이 map에 닿는 시점에 logger는 공급되어 있다 —— 그것은 이미
클로저 안으로 소비된 값이다. map이 받는 것은 평범한 Int -> Int이고, map의 시그니처는
결코 변하지 않는다: (T -> U) -> List[T] -> List[U] 그대로, 평범한 Hindley–Milner 추론으로
다뤄진다, 확장은 일절 없이. 같은 설명이, 둘레에서 logger를 포획하는 클로저(노트가 이것과
나란히 좇는 자매 물음)도 덮는다: 그 타입은 그저 Int -> Int이고, 능력을 포획했다는 사실은
타입에 나타나지 않는다. 그것은, 능력을 인자로 취하고, 그것을 채워 넣은 함수와 꼭 등가이기
때문이다.
확인하기 위해 trial을 세우다
일관성으로부터의 논은 설득적이지만, 증명은 아니다. 이 연재가 몇 번이고 되돌아오는 방법론
—— 종이 위에서 정하고, 확정 전에 dogfood한다 —— 은 여기에도 들어맞는다: 살아남은 후보는,
언어에 들어가기 전에 작은 독립 trial로 구현되었다. 약 330줄의 인터프리터, 16개의 테스트,
설계가 살아남아야 할 모든 형태를 통과한다: map을 통과하는 순수 함수, 로그하는 함수,
포획하는 클로저, 능력을 채우는 부분 적용, 한쪽에 이펙트를 가진 함수 합성, 중첩된 고차 호출.
그 모두가 행 타입 없는 평범한 HM 추론으로 돌았고 —— 그리고 거부되어야 할 프로그램에 대해
발화해야 할 타입 에러가, 기대대로 발화했다. “능력 인자는 타입 시스템의 확장을 요하지
않는다”는 주장은, 바람이기를 그치고, 관측된 사실이 되었다.
그것이 trial의 요점이다: 우아하게 들리는 선택지와 소박한 선택지는, 어느 쪽도 종이 위에서는 잘 논해진다. 소박한 쪽을 값싸게 세우는 것이, 그 소박함이 진짜이고 숨은 부채가 아님을 아는 길이다.
그 대가와, 미룬 것
능력 인자의 명시는 마찰 없이가 아니다. 남는 대가는 부분 적용의 장황함이다 —— 부르는 이가,
행 다형성이라면 추론으로 지워 버릴 방식으로, 능력을 몇 번이고 손으로 채워야 한다. 설계
노트는 그것을 누그러뜨릴 using 슈가의 안을 스케치한다:
let f = fn x using [logger] -> { logger.info(x); x } // curry 형의 슈가
—— 하지만 일부러 그것을 미구축으로 남긴다. 소박한 형태를 먼저 구현하고, 슈가가 그 자리에 값하는지를, 필요에 앞서 편의를 더하는 것이 아니라, 실제 사용에서 판단한다는 원칙에 서서. 반복되는 규율이다: 숨길 것을 정하기 전에, 정직하고 장황한 판을 돌린다.
이펙트 모델은 이제 고차 함수를 살아남았다 —— 그것이 구조상 가장 어려운 시험이었다. 아직 말할 수 없는 것은, 능력이 나르는 이펙트의 종류다 —— 파라미터는, 함수가 데이터베이스에 닿을 수 있음은 알리지만, 그저 읽는지 쓰기도 하는지도, 접근이 배타적인지 안전하게 공유되는 지도 알리지 않는다. 유무는 너무 거칠다. 다음 회: 이펙트의 결 —— 읽기 대 쓰기, 배타 대 공유, 그리고 그 선을 긋는 대여 표기.