Lowent 매뉴얼←↑→

15 효과 — op 이 세상에 남기는 자국

먼저 알아야 할 것

5장 op · fn 은 순수하고 proc 의 effects 는 좁히는 절이다
14장 계약 · 강제되지 않는 약속은 사실로 쓰지 않는다

돌아보기

5장에서 순수한 helper 가 스스로 출력하지 않고 say 를 부르기만 했는데도 거절되었다. 왜인가?

답. 효과는 부르는 쪽으로 번지기 때문이다. say 가 io 를 내므로 그것을 부르는 helper 도 io 를 내는 것이 되고, 순수한 fn 은 효과를 낼 수 없다. 이 장은 그 효과가 무엇으로 이루어지고, 선언과 실제가 어느 쪽으로 어긋날 때 무엇이 일어나는지를 다룬다.

이 장의 필요성과 맥락

계약(14장)이 값의 약속이라면 효과는 행동의 약속이다. 파일을 읽는가, 메모리를 받는가, 멈출 수 있는가, 다른 흐름을 기다리는가. 이 물음의 답이 머리에 없으면 호출자는 본문을 끝까지 따라가야 한다. Lowent 는 효과를 닫힌 낱말의 집합으로 적게 하고 컴파일러가 양쪽으로 검사한다. 권한(16장)은 그 효과를 누가 허락했는지를 적는 짝이므로, 효과를 먼저 세운다.

이 장이 끝나면

효과가 닫힌 원자의 집합이라는 것과 원자마다 무엇을 뜻하는지 알게 된다. 효과가 호출을 따라 번지고, 적은 것보다 많이 하면 거절되며 적어 놓고 안 하면 경고된다는 것을 익힌다. 효과 줄이 목록이 아니라 집합이라 생기는 규칙, concurrent 가 wait 을 딸고 오는 닫힘, 타입 인자가 효과를 정하게 하는 via 를 보게 된다. 마지막으로 순수함이 컴파일러에게 무엇을 허락하는지 이해하게 된다.

이 장에서 답할 질문

  1. 로그 한 줄을 남기려고 순수한 op 전체를 proc 으로 바꿔야 하는가?

15.1 효과는 닫힌 낱말이다#

효과는 op 이 바깥에 주는 영향이다. 저자가 새 효과를 만들 수는 없다. 목록은 언어가 정해 두었다.

원자뜻
none아무 효과도 없다. 바닥이다
io바깥과 자료를 주고받는다(파일·연결·표준 입출력)
alloc고정 창에서 메모리를 얻거나 돌려준다
heap자라는 뿌리에서 메모리를 얻는다. 운영체제가 있는 기계에만 있다
state모듈이나 액터가 지닌 상태, 호출자의 저장소를 고친다
panic프로그램을 멈출 수 있다
atomic나눌 수 없는 읽기·쓰기를 한다
concurrent완결이 다른 실행 흐름의 진행에 달려 있다
wait기다린다. 누구의 도움 없이도 언젠가 깨어난다
lock자물쇠를 잡는다
device장치를 직접 건드린다
unsafe언어가 검사할 수 없는 일을 한다
page_fault · blocking · cancel · detach흐름이 멈추거나 끊기는 일들 — 아직 이를 내는 원시 연산이 없다

표 15.1 — 효과 원자

io·alloc·heap·state·panic·atomic·concurrent·wait·unsafe 아홉은 그것을 실제로 내는 원시 연산이 있고 컴파일러가 강제한다 — 몸이 그 일을 하면 적어야 하고, 적어 놓고 안 하면 알린다. 나머지 여섯(lock·device·page_fault· blocking·cancel·detach)은 그 일을 하는 원시 연산이 아직 없다. 그래서 선언으로 받아 부르는 쪽으로 번지게는 하지만, 몸이 정말 그 일을 하는지는 묻지 못한다. 목록에 없는 낱말은 거절된다.

examples/ch15/undef.low

module undef .
rem expect: E-EFFECT-UNDEF

proc checked input x u64 . output u64 . effects crash .
do
  return x .
end

실행 결과

$ lowentc --check undef.low
undef.low:4:0 E-EFFECT-UNDEF: unknown effect (the vocabulary is closed: none/alloc/heap/io/wait/concurrent/lock/atomic/unsafe/device/page_fault/blocking/cancel/detach/panic/state) — a typo here silently declares the op PURE

오타를 조용히 받아 주면 그 op 은 효과가 없는 것으로 읽힌다. 진단의 말대로 “a typo here silently declares the op PURE” 가 되는 자리를 막는다.

15.2 효과는 부르는 쪽으로 번진다#

op 을 부르면 그 op 의 효과가 부르는 쪽 효과에 더해진다. 적은 것보다 많은 효과를 내면 거절된다.

examples/ch15/spread.low

module spread .
rem expect: E-EFFECT

proc log_line input out cap io . input msg slice u8 . output u64 . effects io .
do
  return write_out out 1 msg .
end

proc compute input out cap io . input x u64 . output u64 . effects panic .
  requires le x 1000 .
do
  let n u64 be log_line out "computing\n" .
  if eq x 0 . do
    panic "zero" .
  end
  return mul x 2 .
end

실행 결과

$ lowentc --check spread.low
spread.low:11:1 E-EFFECT: this op performs `io`, which its `effects` clause does not declare — add it to `effects …`, or stop calling what needs it

compute 는 effects panic . 만 적었는데 log_line 을 부르므로 io 도 낸다. 진단은 “effects 에 더하거나, 그것이 필요한 것을 부르지 말라” 고 한다. 반대로 효과가 번지지 않는 층도 분명하다.

examples/ch15/layered.low

module layered .
rem run: main

fn celsius_to_f input c i64 . output i64 .
  requires ge c -1000 .
  requires le c 1000 .
do
  return add (div (mul c 9) 5) 32 .
end

proc print_digit input out cap io . input d u8 . output u64 . effects io .
  requires le d 9 .
do
  let buf slice u8 be "0123456789" .
  return write_out out 1 (subslice buf (widen u64 d) (add (widen u64 d) 1)) .
end

proc main input out cap io . output u8 . effects io .
do
  let f i64 be celsius_to_f 100 .
  let n u64 be print_digit out (narrow u8 (mod f 10)) .
  let m u64 be write_out out 1 "\n" .
  return 0 .
end

실행 결과

$ lowentc --run main layered.low
2
main() = 0

celsius_to_f 는 순수한 fn 이라 어디서든 부를 수 있다. print_digit 은 io 를 내므로 io 를 선언한 main 에서만 부를 수 있다. 계산하는 층과 바깥과 닿는 층이 머리에서 갈린다. 좋은 설계는 순수한 층을 넓게, 효과 있는 층을 얇게 둔다.

문. 로그 한 줄을 남기려고 순수한 op 전체를 proc 으로 바꿔야 하는가?

답. 그렇다. 로그를 남기는 op 은 바깥에 흔적을 남기므로 순수하지 않고, 그 사실이 머리에 보여야 한다. 대신 흔한 해법이 있다. 계산은 순수하게 두고 결과를 돌려주며, 로그는 그 결과를 받은 바깥 층에서 남긴다. 효과를 바깥으로 밀어내는 이 모양은 시험하기도 쉽다 — 순수한 층은 권한 없이 --run 과 test 로 바로 확인된다.

15.3 적어 놓고 안 하면 알린다#

선언만 하고 내지 않는 효과도 문제다. 선언한 효과는 부르는 쪽이 감당해야 하는 비용이기 때문이다.

examples/ch15/over.low

module over .
rem expect: W-EFFECT-OVER

proc double input x u64 . output u64 . effects panic .
  requires le x 1000 .
do
  return mul x 2 .
end

실행 결과

$ lowentc --check over.low
over.low:6:1 W-EFFECT-OVER: this op DECLARES `panic` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)

double 은 멈출 수 있다고 적었지만 멈추지 않는다. 그러면 이 op 을 부르는 모든 쪽이 panic 을 적어야 하고, 순수한 fn 은 부를 수 없게 된다. 하는 일을 부풀려 말한 것이다. 진단은 지우라고 하고, 다음 판에서 실제로 그 효과를 낼 예정이라면 그렇다고 적으라고 한다.

흔한 오해. 효과를 넉넉하게 적어 두면 나중에 고칠 일이 줄어든다

넉넉한 효과는 부르는 쪽 전체로 번진다. panic 하나를 미리 적으면 그 op 을 부르는 모든 op 이 panic 을 적어야 하고, 순수해야 할 계산이 순수함을 잃는다. 순수함을 잃은 op 에는 차례 바꾸기·기억해 두기 같은 최적화도 허락되지 않는다. 효과는 실제로 하는 만큼만 적는다.

15.4 효과 줄은 집합이다#

효과 줄은 목록이 아니라 집합이다. 같은 낱말을 두 번 적으면 거절된다.

examples/ch15/dup.low

module dup .
rem expect: E-EFFECT-DUP

proc checked input x u64 . output u64 . effects panic panic .
do
  if eq x 0 . do panic "zero" . end
  return x .
end

실행 결과

$ lowentc --check dup.low
dup.low:4:0 E-EFFECT-DUP: an effect atom appears more than once in the `effects` clause — the row is a SET, not a list. A repeat says nothing the first one didn't, and the reader has to decide it's noise (RFC-0057 E3)

두 번째 panic 은 첫 번째가 하지 않은 말을 하나도 하지 않는다. none 은 다른 원자와 함께 올 수 없다.

examples/ch15/nonemix.low

module nonemix .
rem expect: E-EFFECT-NONE-MIX

proc checked input x u64 . output u64 . effects none panic .
do
  if eq x 0 . do panic "zero" . end
  return x .
end

실행 결과

$ lowentc --check nonemix.low
nonemix.low:4:0 E-EFFECT-NONE-MIX: `none` sits in the `effects` clause ALONGSIDE a real effect — `none` means this op performs no effects, so it cannot co-occur with one. Say the effects, or say none; not both (RFC-0057 E2)

효과가 있으면서 없을 수는 없다. 효과를 적든지, 없다고 적든지 하나다. 그리고 fn 에는 effects none 도 적지 않는다(5장).

어떤 효과는 다른 효과를 딸고 온다. 지금 규칙은 하나다 — concurrent 를 적으면 wait 을 함께 적은 것이 된다. 다른 흐름의 진행을 기다려야 한다면 중단될 수도 있기 때문이다. 그래서 두 집합을 견줄 때는 이 닫힘을 적용한 뒤에 견준다. concurrent 를 적으면 wait 을 따로 적지 않아도 되지만, wait 만 적고 concurrent 한 일을 하면 거절된다.

15.5 via — 타입 인자가 효과를 정한다#

제네릭 컨테이너는 곤란한 처지에 놓인다. 어떤 할당기를 받느냐에 따라 효과가 달라지기 때문이다. 빌린 바이트 위에서 깎는 할당기로 열면 state 뿐이고, 자라는 힙을 딛는 할당기로 열면 heap 이 선다. 모든 할당기가 낼 수 있는 가장 큰 효과를 늘 적으면 거짓이 되고, 가장 작은 것만 적으면 숨은 할당이 된다.

export proc append
  input comptime t type .
  input comptime a type .
  input g mut vec t a .
  input x t .
  output bool .
  effects state via a .
  requires allocs.byte_allocator a .

표준 라이브러리 vecgen 의 append 머리다. effects state via a . 는 “타입 a 의 op 들이 적은 할당 계열 효과(alloc·heap·atomic)도 이 op 의 선언이다” 라는 뜻이다. 단형화된 인스턴스마다 그 타입이 효과를 정한다. 제네릭 컨테이너는 34장에서 다시 만난다.

15.6 순수함이 허락하는 것#

fn, 곧 효과가 none 인 op 은 세 가지를 만족한다.

이 세 성질이 컴파일러에게 최적화를 허락한다. 같은 인자로 두 번 부른 순수 호출은 한 번으로 줄일 수 있고 (공통 부분식 제거), 결과를 기억해 둘 수 있으며(memoise), 쓰이지 않는 호출은 지울 수 있고, 서로 기대지 않는 호출은 차례를 바꾸거나 나누어 돌릴 수 있다. 이 최적화들이 프로그램의 뜻을 바꾸지 않는다는 것은 Coq 로 증명되어 있다(44장). 다만 호출 경계와 멈추는 시점은 그 증명 밖이다.

실제 사례. 효과 선언이 증명의 전제가 되는 자리

효과 체계의 건전성 정리는 “선언한 효과가 실제로 일어나는 효과를 덮는다” 는 것이다. 이 정리가 있어야 효과에 기댄 최적화의 정리가 선다. 그래서 컴파일러는 선언보다 많이 하는 코드를 거절하고(정리의 전제), 선언보다 적게 하는 코드는 경고한다(정리에는 해가 없지만 부르는 쪽에 거짓 비용을 떠넘긴다). 두 방향의 무게가 다른 이유가 여기에 있다.

15.7 흔한 실수#

반례. 순수한 fn 에서 panic 을 쓴다

examples/ch15/mistake_fnpanic.low

module mistake_fnpanic .
rem expect: E-EFFECT-CALC

rem ✘ 순수한 `fn` 이 `panic` 을 쓴다 --- 멈추게 하는 것도 효과다
fn nonzero input x u64 . output u64 .
do
  if eq x 0 . do panic "zero" . end
  return x .
end

실행 결과

$ lowentc --check mistake_fnpanic.low
mistake_fnpanic.low:6:1 E-EFFECT-CALC: this fn is declared pure but performs `panic` — make it a `proc` with `effects …`, or remove the effect

프로그램을 멈추게 하는 것도 바깥에 남는 자국이다. 부르는 쪽이 이 op 을 기억해 두거나 차례를 바꾸면 멈춤이 일어나는 때가 달라지므로, fn 은 panic 을 쓸 수 없고 E-EFFECT-CALC 로 거절된다. fn 에 effects panic . 을 붙이는 것도 같은 코드로 거절된다 — 순수한가 아닌가는 fn·proc 이라는 낱말이 이미 말하기 때문이다. 고치는 길은 둘이다. 멈춤을 효과로 적은 proc 으로 바꾸거나, 그 조건을 부르는 쪽의 책임인 계약으로 옮긴다.

examples/ch15/fnpanic_fixed.low

module fnpanic_fixed .
rem run: nonzero_checked 5
rem trap: nonzero_strict 0

rem 고친 길 1 --- 멈춤을 효과로 적은 `proc`
proc nonzero_checked input x u64 . output u64 . effects panic .
do
  if eq x 0 . do panic "zero" . end
  return x .
end

rem 고친 길 2 --- 조건을 계약으로 옮기면 `fn` 으로 남는다
fn nonzero_strict input x u64 . output u64 .
  requires ne x 0 .
do
  return x .
end

실행 결과

$ lowentc --run nonzero_checked fnpanic_fixed.low 5
nonzero_checked(5) = 5
$ lowentc --run nonzero_strict fnpanic_fixed.low 0
== ir diagnostics (1) ==
0:0 E-VM-CONTRACT: `requires` violated at entry — the caller broke the contract

두 번째 길이 대개 낫다. nonzero_strict 는 순수함을 지키고, 0 을 넘긴 잘못이 부르는 쪽에 있다고 진단이 말한다.

반례. fn 이 mut 로 받은 줄에 쓴다

examples/ch15/mistake_fnmutwrite.low

module mistake_fnmutwrite .
rem expect: E-EFFECT-PURITY

rem ✘ `fn` 이 `mut` 로 받은 호출자의 줄에 쓴다
fn clear input xs mut slice u8 . output void .
do
  var i u64 be 0 .
  while lt i (len xs) do
    set (index xs i) 0 .
    set i (add i 1) .
  end
end

실행 결과

$ lowentc --check mistake_fnmutwrite.low
mistake_fnmutwrite.low:5:0 E-EFFECT-PURITY: a `fn` WRITES through a `mut` parameter — that write is visible to the CALLER. A fn is an enforced purity contract (SPEC-003 §27): callers may memoise it, reorder it, or elide it. An op that changes caller-owned storage can do none of those. Declare it a `proc`. (Local mutation stays pure: storage confined to the op is not observable — a machine write is not an observable effect. RFC-0057)

clear 가 쓴 0 은 호출자의 줄에 남는다. 호출자가 보기에 이것은 바깥이 바뀐 것이고, 그런 op 은 기억해 두거나 지우거나 차례를 바꿀 수 없다. 그래서 E-EFFECT-PURITY 다. 머리를 proc clear … effects state . 로 바꾼다. 진단이 덧붙인 말대로 op 안의 지역을 고치는 것은 순수함을 깨지 않는다(아래 오개념).

반례. 효과 사이에 쉼표를 넣는다

examples/ch15/mistake_effcomma.low

module mistake_effcomma .
rem expect: E-VOCAB-REMOVED

rem ✘ 효과 사이에 쉼표를 넣었다 --- 낱말을 띄어 적기만 한다
proc greet input out cap io . input x u64 . output u64 . effects io, panic .
do
  if eq x 0 . do panic "zero" . end
  return write_out out 1 "hi\n" .
end

실행 결과

$ lowentc --check mistake_effcomma.low
5:68 E-VOCAB-REMOVED: `,` (R3) was removed — RFC-0103, 2026-08-27. It opened the NEXT operand of the same form, but nothing used it that way: every `,` in the corpus was a LINE CONTINUATION, and newlines no longer close a form, so continuing a line needs nothing at all. A form is `head operand*` and ends at its closer `.` — just write the operands, on as many lines as you like.

목록을 쉼표로 가르는 습관이 여기서는 E-VOCAB-REMOVED 가 된다. 쉼표는 한때 문법에 있었으나 한 줄을 이어 적는 데에만 쓰였고, 지금은 줄바꿈이 형식을 닫지 않으므로 필요 없어 빠졌다. effects io panic . 처럼 띄어 적는다. 형식은 마침표에서 끝난다.

흔한 오해. var 와 set 을 쓰면 순수하지 않다

examples/ch15/pure_local.low

module pure_local .
rem run: sum_to 10

rem `var` 와 `set` 을 써도 순수하다 --- 바뀌는 것이 이 op 안에서만 보이기 때문이다
fn sum_to input n u64 . output u64 .
  requires le n 1000 .
do
  var s u64 be 0 .
  var i u64 be 0 .
  while le i n do
    set s (add s i) .
    set i (add i 1) .
  end
  return s .
end

실행 결과

$ lowentc --run sum_to pure_local.low 10
sum_to(10) = 55

sum_to 는 지역 변수 둘을 계속 고치지만 fn 이다. 바뀌는 저장소가 이 op 안에만 있고 호출자는 결과 55 만 본다. 같은 입력에 언제나 같은 답을 내고, 부르지 않아도 바깥에 남는 것이 없다. 순수함은 “안에서 아무것도 바꾸지 않는다” 가 아니라 “바깥에서 보이는 자국이 없다” 이다.

흔한 오해. fn 은 절대 멈추지 않는다

examples/ch15/fn_can_stop.low

module fn_can_stop .
rem run: first [7,8]
rem trap: first []

rem 순수한 `fn` 이지만 빈 줄을 받으면 멈춘다 --- 경계 검사의 멈춤은 op 이 낸 효과가 아니다
fn first input xs slice u8 . output u8 .
do
  return index xs 0 .
end

실행 결과

$ lowentc --run first fn_can_stop.low [7,8]
first([7,8]) = 7
  arg0 (written) = [7,8]
$ lowentc --run first fn_can_stop.low []
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)

fn 이 쓸 수 없는 것은 panic 효과다. 빈 줄의 첫 칸을 읽는 것처럼 계약이나 경계가 깨져 처리기가 멈추는 것은 op 이 한 일이 아니라 잘못 불린 결과로 본다(정본 6.5.9). 그래서 순수한 first 도 [] 를 받으면 멈춘다. 멈추지 않게 하려면 requires ge (len xs) 1 . 로 책임을 부르는 쪽에 드러내거나, option 을 돌려준다.

반례. 드문 효과를 적은 op 을 순수한 fn 에서 부른다

examples/ch15/mistake_blocking.low

module mistake_blocking .
rem expect: E-EFFECT-CALC

rem blocking — 실행 흐름을 붙잡아 둘 수 있다고 머리에 적는다
proc wait_for_device output u64 . effects blocking .
do
  return 0 .
end

rem ✘ 순수한 fn 이 흐름을 붙잡을 수 있는 op 을 부른다 — 이 판에서는 통과해 버린다
fn ready output u64 . do
  return wait_for_device .
end

실행 결과

$ lowentc --check mistake_blocking.low
mistake_blocking.low:11:23 E-EFFECT-CALC: this fn is declared pure but performs (이름 없는 효과 비트) — make it a `proc` with `effects …`, or remove the effect

blocking 은 “흐름을 붙잡아 둘 수 있다” 이므로, 그런 op 을 부르는 fn 은 순수하지 않다. 그래서 E-EFFECT-CALC 로 거절된다. 2026-09-16 까지는 lock·device·page_fault·blocking·cancel·detach 여섯이 부르는 쪽으로 번지지 않아 통과했다 — 특히 장치를 직접 건드리는 device 가 순수 함수 뒤에 숨었다. 같은 모양에 wait 를 적으면 예전에도 거절됐다.

examples/ch15/blocking_wait.low

module blocking_wait .
rem expect: E-EFFECT-CALC

rem wait 는 번진다 — 같은 모양이 E-EFFECT-CALC 로 거절된다
proc wait_for_device output u64 . effects wait .
do
  return 0 .
end

fn ready output u64 . do
  return wait_for_device .
end

실행 결과

$ lowentc --check blocking_wait.low
blocking_wait.low:6:1 W-EFFECT-OVER: this op DECLARES `wait` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
blocking_wait.low:10:23 E-EFFECT-CALC: this fn is declared pure but performs `wait` — make it a `proc` with `effects …`, or remove the effect

이 여섯은 아직 그 일을 하는 원시 연산이 없어서 “선언했는데 안 한다”(W-EFFECT-OVER)로는 묻지 않는다 — 물을 몸이 없기 때문이다. 전파만 한다.

15.8 이 장의 문법 한눈에#

모양뜻왜 이렇게
fn …효과 없음 — effects 절을 적지 않는다순수함이 낱말 하나로 보인다
proc … effects io panic .이 op 이 낼 수 있는 효과의 집합머리만 읽고 무엇을 하는지 안다
proc … (절 없음)좁히지 않았다 — 무엇이든 낼 수 있다좁히는 것은 저자의 선택
효과 원자 io·alloc·heap·state·panic·…언어가 정한 닫힌 목록오타가 조용히 “순수” 가 되지 않게
effects panic panic . · none panic거절(E-EFFECT-DUP · E-EFFECT-NONE-MIX)집합이라 겹침과 모순이 없다
적은 것보다 많이 함 · 적고 안 함오류(E-EFFECT) · 경고(W-EFFECT-OVER)증명의 전제 · 부르는 쪽의 거짓 비용
concurrentwait 을 딸고 온다다른 흐름을 기다리면 멈출 수 있다
effects state via a .타입 인자 a 의 할당 계열 효과를 물려받는다할당기마다 효과가 정확해진다

표 15.2 — 효과의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나

복습 정리

효과는 닫힌 원자의 집합이고, 목록 밖의 낱말은 거절된다. 효과는 호출을 따라 번지며, 적은 것보다 많이 하면 거절되고 적어 놓고 안 하면 경고된다. 효과 줄은 집합이라 중복과 none 섞기가 거절되고, concurrent 는 wait 을 딸고 온다. via 는 타입 인자가 할당 계열 효과를 정하게 한다. 순수함은 차례 바꾸기·기억하기·지우기 같은 최적화를 허락한다.