고차 함수, 이펙트를 새지 않게

능력을 인자로 넘긴다면, `f`가 logger를 요할 때 `map(list, f)`는 어떻게 되는가? logger를 f의 타입에 넣으면 map의 시그니처로 샌다, map은 결코 로그하지 않는데. f를 순수하게 하면, 이펙트를 가진 함수를 애초에 넘길 수 없다. 여기가 이펙트 모델이 가장 깨지기 쉬운 곳이었다 —— 그리고 출구는, 앞선 결정에 그것을 정하게 하고, 확인하기 위해 최소의 trial을 세우는 것이었다.

mereeffectscapabilitieshigher-orderlanguage-design

지난 회는 방향을 정했다 —— 이펙트는 능력으로서, 값으로서 넘겨진다 —— 그리고, 모델이 가장 갈라지기 쉬운 곳을 곧장 가리켰다. 일차 함수는, 그 시그니처가 자기 능력을 정직하게 늘어놓을 수 있다. 하지만 다른 함수를 취하는 함수는 어떻게 되는가?

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 형의 슈가

—— 하지만 일부러 그것을 미구축으로 남긴다. 소박한 형태를 먼저 구현하고, 슈가가 그 자리에 값하는지를, 필요에 앞서 편의를 더하는 것이 아니라, 실제 사용에서 판단한다는 원칙에 서서. 반복되는 규율이다: 숨길 것을 정하기 전에, 정직하고 장황한 판을 돌린다.

이펙트 모델은 이제 고차 함수를 살아남았다 —— 그것이 구조상 가장 어려운 시험이었다. 아직 말할 수 없는 것은, 능력이 나르는 이펙트의 종류다 —— 파라미터는, 함수가 데이터베이스에 닿을 수 있음은 알리지만, 그저 읽는지 쓰기도 하는지도, 접근이 배타적인지 안전하게 공유되는 지도 알리지 않는다. 유무는 너무 거칠다. 다음 회: 이펙트의 결 —— 읽기 대 쓰기, 배타 대 공유, 그리고 그 선을 긋는 대여 표기.

← Back to Mere: 언어를 만들다