부작용을 값으로 넘기기
메모리는 값이 어디에 사는가에 답했다. 이펙트는, 어떤 코드가 무엇을 해도 되는가에 답한다 —— DB에 닿고, 시계를 읽고, 메일을 보내는가. Mere의 답은 능력 전달(capability passing)이다: 이펙트를 일으킬 권리는 인자로 건네는 평범한 값이고, 그래서 함수의 파라미터 목록이 그것이 할 수 있는 일의 남김 없는 목록이 된다. 그리고 악명 높은 대가 —— 쓰지도 않는 함수를 통해 능력을 배관하는 것 —— 을 일부러 받아들인다.
Part II는 Mere에 값이 어디에 살고, 언제 죽는가에 대한 명시적 답을 주었다. Part III는, 언어가 똑같이 암묵에 맡기기를 꺼리는 물음을 떠맡는다: 어떤 코드가 무엇을 해도 되는가? 이 함수는 네트워크에 닿는가, 시계를 읽는가, 파일을 여는가, 메일을 보내는가? 대부분의 언어에서 정직한 답은 “호출 트리를 통째로 읽지 않으면 알 수 없다”이다. 어느 함수나, 어느 글로벌이든 import해서, 무엇이든 할 수 있기 때문이다. 그것이야말로 Mere의 제1원칙이 반대하는, 보이지 않는 힘이다. 이 Part는 그것을 보이게 하는 이야기다.
이펙트를 명시하는 네 갈래 길
여기에는 잘 답사된 설계 공간이 있고, 선택지를 봐 둘 가치가 있다. 선택은, 언어가 이미 중히 여기는 것에서 곧장 도출되기 때문이다.
- 모나드, Haskell처럼 —— 이펙트를 반환값의 타입에 꿴다. 명시적이고 순수하지만, 합성이 어색하고 실제 구문 비용을 동반한다.
- 대수적 이펙트, Koka처럼 —— 함수가 수행하는 이펙트를 선언하고, 다른 곳에서 handle 한다. 합성은 아름답고 테스트도 잘 되지만, 계산을 중단·재개하는 런타임 기구는 공짜가 아니다.
- 능력 전달 —— 이펙트를 일으킬 권리가 값이고, 인자로 넘겨진다. 최대한 명시적, 런타임 비용 제로, 테스트가 쉽다. 대가는 기술량이다.
- 표기만, checked exceptions처럼 —— 그저 “이 함수는 IO를 한다”고 선언한다. 쓰기는 값싸지만 약하다: 표기가 진실에서 벌어지고, 컴파일러가 진짜로 옭아맬 수 없다.
이것들을, Mere가 이미 약속한 것 —— 암묵보다 명시, 런타임 비용 제로, 그리고 코드는 기계의 도움으로 쓰이므로 기술량은 감당할 수 있다는 취급 —— 에 나란히 두면, 하나가 타협 없이 들어 맞는다.
능력 전달
Mere의 답은 능력 전달이다: 이펙트를 수행할 권한은 평범한 값이고, 그것을 평범한 방식 으로 손에 넣는다 —— 누군가 인자로 건네주는 것이다.
fn save_order(
order: owned Order,
db: &mut borrowed Database,
logger: &borrowed Logger,
) -> Result[unit, owned DbError] {
logger.info("saving order")
db.insert("orders", order)
}
파라미터 목록을 읽으면, 그 함수가 닿는 범위를 통째로 읽은 것이다. 그것은 데이터베이스와
말하고, 로거에 쓸 수 있다. Database와 Logger를 받았기 때문이다. 그리고 바깥 세계에
대해 다른 어떤 것도 할 수 없다. 그 권한을 얻는 다른 길이 없기 때문이다 —— import할 주변
글로벌도 없고, OS로의 숨은 채널도 없다. 함수가 무엇을 해도 되는가는, 코드 곁에 문서화되는
것이 아니라, 코드의 시그니처 그 자체다. 그것이 핵심의 전부다: 부작용은 공짜로 이동하지
않는다, 건네지는 것이 보이는 값으로서 이동한다.
이것은 또, 특별한 프레임워크 없이 이펙트를 공짜로 테스트 가능하게 한다. 테스트는 가짜
Database와, 버퍼에 쓰는 Logger를 넘기고, 테스트 대상 함수는 그 차이를 알 수도, 진짜로
달아날 수도 없다 —— 그것은 주어진 값밖에 가진 적이 없기 때문이다.
배관 문제와, Mere가 배관을 남기는 이유
능력 전달에 대한 정석적 반론은 즉각적이고 진짜다. 깊은 호출 연쇄에서, 중간의 함수가, 자기는 결코 건드리지 않는 능력을, 그저 하류로 넘기기 위해서만 받아야 한다:
fn handle_request(req, db, cache, logger, mailer) -> Response {
let user = authenticate(req, db, logger)?
let order = process_order(user, db, cache, logger, mailer)?
build_response(order)
}
// process_order 자신은 결코 메일을 보내지 않는다 —— 하류의 send_confirmation에
// 넘기기 위해서만 mailer를 취한다
대부분의 언어는 이 배관을, 기술로 없애야 할 문제로 다룬다. 보통은 깊은 함수가 글로벌에 직접 닿게 하여, 중간의 함수가 몰라도 되게 한다. Mere는 반대로 한다: 일부러 배관을 받아 들인다. 네 측면에서 본, 실은 하나의 이유로.
기술량 자체는 불리하게 세어지지 않는다 —— 이것은 기계의 도움으로 쓰이는 코드이고, 새 능력을
연쇄에 꿰는 것은, 바로 값싸게 만들고 값싸게 검증하는 기계적 편집이다. 더 중요한 것은, 배관이
정보라는 것이다: 파라미터 목록에 mailer를 가진 함수는, 설령 값을 그저 넘길 뿐이라도,
메일이 보내지는 경로 위에 있음이 보인다. 그리고 함수가 긴 능력 목록을 요구하게 되면, 그것은
억눌러야 할 잡음이 아니다 —— 설계의 신호다. 그 함수가 너무 많은 이펙트 경로 위에 앉아 있고,
코드는 재구성을 원할지도 모른다고 알린다. 배관이 진단이고, 그것을 숨기는 것은 진단을 숨기는
것이다.
이 밑에는, 설계 노트가 우기는 날카로운 구별이 있다: “운반을 쉽게 하지 않는다”는 “묶지 않는다”와 같지 않다. Mere는 전자를 거부한다 —— 이펙트를 보이지 않게 이동시키는 것을 목적으로 하는 기구는 더하지 않는다. 보이지 않는 이동이야말로, 이 Part 전체가 막기 위해 존재하는 것이기 때문이다. 모든 것을 마법처럼 꿰는 “능력 구조체”는, 능력 전달이 보이려 하는 바로 그것을, 다시 숨겨 버린다. 하지만 그것은, 관련된 능력을, 보이지 않게 하지 않으면서 편의를 위해 이름 아래 그룹화할 수 있는가, 하는 물음과는 별개다 —— 그리고 그 물음은, 여기서는 열린 채, Part의 뒤에서 답해진다.
이것이 일부러 미완으로 남기는 것
능력 전달로의 결정은 방향을 정하지만, 설계의 전부는 아니다. 이음매에 대해 정직할 가치가 있다. 다음 몇 회가, 바로 이것들에 관한 것이기 때문이다:
- 고차 함수.
map(list, f)가f를 부르고,f가 능력을 요하면, 그 요구는 어디에 나타나는가? 그것이 잠자코map의 시그니처로 새어서는 안 된다. 그러지 않으면 범용 함수를 하나도 쓸 수 없게 된다. 이것은 모델에 대한 첫 진짜 부하이고, 다음 회는 그것을 푸는 이야기다. - 이펙트의 종류. 파라미터는, 함수가 데이터베이스에 닿을 수 있음은 알리지만, 그저 읽는 지 쓰는지도, 이펙트가 배타적인지 안전하게 공유되는지도 알리지 않는다 —— 로거는 쓰이지만, 많은 부르는 이가 동시에 로그할 수 있다. 파라미터의 유무는 너무 거칠다. 이펙트에는 결이 필요하다.
- 숨기지 않고 묶기. 현실적인 함수가 능력 다섯과 region 파라미터를 요하면, 시그니처는 길어진다. 어떤 그룹화의 길이 아마 필요하다 —— 열린 물음은, “운반을 쉽게 하기”로 되돌아 가지 않으면서, 그것이 어떤 형태를 취하는가다.
방향은 정해졌다: 이펙트는 값이고, 배관째로 명시적으로 넘겨진다. Part III의 나머지는, 위의 세 부하에 관한 것이다. 다음 회: 고차 함수와 능력 —— 다른 함수를 취하는 함수가, 이펙트를 새지 않게 하면서 명시적으로 유지하는 방법.