15 효과 — op 이 세상에 남기는 자국
먼저 알아야 할 것
fn 은 순수하고 proc 의 effects 는 좁히는 절이다돌아보기
5장에서 순수한 helper 가 스스로 출력하지 않고 say 를 부르기만 했는데도 거절되었다. 왜인가?
답. 효과는 부르는 쪽으로 번지기 때문이다. say 가 io 를 내므로 그것을 부르는 helper 도 io 를 내는 것이 되고, 순수한 fn 은 효과를 낼 수 없다. 이 장은 그 효과가 무엇으로 이루어지고, 선언과 실제가 어느 쪽으로 어긋날 때 무엇이 일어나는지를 다룬다.
이 장의 필요성과 맥락
이 장이 끝나면
concurrent 가 wait 을 딸고 오는 닫힘, 타입 인자가 효과를 정하게 하는 via 를 보게 된다. 마지막으로 순수함이 컴파일러에게 무엇을 허락하는지 이해하게 된다.이 장에서 답할 질문
- 로그 한 줄을 남기려고 순수한 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) | 증명의 전제 · 부르는 쪽의 거짓 비용 |
concurrent | wait 을 딸고 온다 | 다른 흐름을 기다리면 멈출 수 있다 |
effects state via a . | 타입 인자 a 의 할당 계열 효과를 물려받는다 | 할당기마다 효과가 정확해진다 |
표 15.2 — 효과의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
none 섞기가 거절되고, concurrent 는 wait 을 딸고 온다. via 는 타입 인자가 할당 계열 효과를 정하게 한다. 순수함은 차례 바꾸기·기억하기·지우기 같은 최적화를 허락한다.