Lowent 매뉴얼←↑→

5 op — fn 과 proc

먼저 알아야 할 것

3장 겉모습 · op 머리의 절 차례
4장 수 · 넘침은 멈추고 처분은 이름으로 고른다

돌아보기

3장의 절 차례표에서 output 과 effects 는 각각 무엇의 뒤에 오는가? 왜 그 자리인가?

답. output 은 데이터 입력 뒤에 온다. 출력 타입이 입력의 타입 매개변수를 쓸 수 있기 때문이다. effects 는 output 뒤, 계약 앞에 온다. 앞에서 받은 권한으로 무엇을 하는지를 적는 자리이기 때문이다. 이 장은 그 머리를 가진 실행 단위 — op — 가 두 갈래로 나뉘는 까닭을 다룬다.

이 장의 필요성과 맥락

Lowent 프로그램의 기본 단위는 op 이고, op 은 언제나 fn 이거나 proc 이다. 이 갈래는 제4부의 효과 체계, 제6부의 트레이트, 제7부의 병렬이 모두 기대는 바닥이다. 순수한 op 은 결과를 기억해 두어도, 차례를 바꾸어도, 나누어 돌려도 되기 때문이다. 그래서 수를 다룬 바로 다음, 지역과 흐름보다 먼저 op 의 두 갈래를 세운다.

이 장이 끝나면

fn 과 proc 의 차이, 그리고 순수함이 관측적이라는 뜻(안에 갇힌 변이는 순수)을 알게 된다. 매개변수를 input 절로 받고 output 으로 돌려주는 법, 재귀, neg 가 유일한 단항 산술이라는 것을 익힌다. 순수한 op 이 비순수한 op 을 부를 때와 mut 매개변수로 호출자에게 쓸 때 무엇이 거절되는지도 보게 된다.

이 장에서 답할 질문

  1. proc 인데 실제로 아무 효과도 내지 않으면 어떻게 되는가?

5.1 두 갈래#

op(operation)은 이름과 계약을 가진 실행 단위다. 표면에서는 언제나 두 낱말 가운데 하나로 연다.

기본값은 없다. fn 인지 proc 인지는 이름 앞에 늘 적혀 있다. add 나 len 같은 내장 연산도 op 이다 — 언어가 미리 만들어 둔 op 이라서 내장 op 이라 부른다.

examples/ch05/kinds.low

module kinds .
rem run: add3 1 2 3
rem run: running_total [4,5,6]
rem run: fact 10

fn add3 input a i32 . input b i32 . input c i32 . output i32 .
do
  return add (add a b) c .
end

rem 지역 가변 상태는 op 안에 갇혀 있으므로 fn 도 쓸 수 있다
fn running_total input xs slice u8 . output u64 .
do
  var total u64 be 0 .
  for x xs do
    set total (add total (widen u64 x)) .
  end
  return total .
end

fn fact input n u64 . output u64 .
  requires le n 20 .
do
  if le n 1 . do return 1 . end
  return mul n (fact (sub n 1)) .
end

실행 결과

$ lowentc --run add3 kinds.low 1 2 3
add3(1, 2, 3) = 6
$ lowentc --run running_total kinds.low [4,5,6]
running_total([4,5,6]) = 15
  arg0 (written) = [4,5,6]
$ lowentc --run fact kinds.low 10
fact(10) = 3628800

세 op 이 모두 fn 이다. add3 은 인자 셋을 받아 더하고, fact 는 자기 자신을 부르는 재귀다. fact 의 requires le n 20 . 은 21! 이 u64 를 넘기 때문에 붙인 계약이다. 이 계약이 없으면 fact 21 은 곱셈에서 멈춘다(4장). 계약이 있으면 진입에서 멈추고, 멈춘 자리가 부르는 쪽의 잘못임을 알린다.

5.2 순수함은 관측으로 정한다#

running_total 을 다시 보자. 본문에 var total 이 있고 set 으로 값을 바꾼다. 그런데도 fn 이다. Lowent 의 순수함은 관측적이기 때문이다. op 안에 갇힌 지역 변수의 변이는 바깥에서 볼 수 없으므로 순수로 친다. 기계어 수준에서 메모리에 쓰는 일이 일어나도, 호출자가 관측할 수 없으면 효과가 아니다.

반대로 호출자가 볼 수 있는 쓰기는 효과다. mut 매개변수를 통해 쓰는 fn 은 거절된다.

examples/ch05/mutparam.low

module mutparam .
rem expect: E-EFFECT-PURITY

fn zero_first input xs mut slice u64 . output u64 .
do
  set (index xs 0) 0 .
  return 0 .
end

실행 결과

$ lowentc --check mutparam.low
mutparam.low:4: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)

진단이 설명하는 대로, fn 은 강제되는 순수 계약이다. 호출자는 fn 의 결과를 기억해 두거나(memoise), 부르는 차례를 바꾸거나, 결과를 쓰지 않으면 호출을 지워도 된다. 호출자의 저장소를 바꾸는 op 에는 그 어느 것도 할 수 없다. 그래서 proc 이어야 한다.

examples/ch05/mutproc.low

module mutproc .
rem run: zero_first [7,8,9]

proc zero_first input xs mut slice u8 . output u64 . effects state .
  requires gt (len xs) 0 .
do
  set (index xs 0) 0 .
  return len xs .
end

실행 결과

$ lowentc --run zero_first mutproc.low [7,8,9]
zero_first([0,8,9]) = 3
  arg0 (written) = [0,8,9]

effects state . 는 이 op 이 자기 바깥의 상태를 고친다는 선언이다. VM 이 보여 주는 arg0 (written) = [0,8,9] 가 그 흔적이다 — 호출자가 넘긴 슬라이스의 첫 원소가 바뀌었다.

흔한 오해. proc 은 fn 보다 느리다

갈래는 속도와 상관이 없다. 둘 다 같은 방식으로 C 로 내려간다. 차이는 컴파일러와 호출자가 할 수 있는 일에 있다. fn 에는 차례 바꾸기·공통 부분식 제거·기억해 두기 같은 최적화가 허락되고, proc 에는 허락되지 않는다. 순수한 일을 proc 으로 적으면 그 기회를 스스로 버리는 것이다.

5.3 fn 에는 effects 를 적지 않는다#

fn 의 효과는 이미 none 이다. 그래서 effects none . 을 덧붙이면 거절된다.

examples/ch05/redundant.low

module redundant .
rem expect: E-EFFECT-REDUNDANT

fn square input a u64 . output u64 . effects none .
do
  return mul a a .
end

실행 결과

$ lowentc --check redundant.low
redundant.low:4:0 E-EFFECT-REDUNDANT: a `fn` is pure by contract, so `effects none .` says nothing that the `fn` did not already say — DROP the clause. One meaning must have one spelling (SPEC-002 §2.5). (Keep writing `effects` on `proc`: there it NARROWS, and omitting it means unrestricted.)

뜻이 틀려서가 아니라 한 뜻에 두 표기가 되기 때문이다. proc 에서는 사정이 다르다. proc 의 effects 절은 좁히는 일을 한다. 절이 없는 proc 은 좁히지 않은 것으로 읽힌다 — 입출력·할당·상태를 모두 할 수 있다고 본다. 그러니 proc 에는 절을 적는 편이 낫고, 적은 것보다 많은 일을 하면 거절되며, 적어 놓고 하지 않는 효과도 알려진다.

문. proc 인데 실제로 아무 효과도 내지 않으면 어떻게 되는가?

답. proc 에 effects none . 을 적는 것은 허락된다. 지역 가변 상태나 반복을 강조하고 싶어서 proc 으로 두는 경우가 옛 코드에 있다. 그러나 관측되는 효과가 없다면 fn 으로 적는 것이 이 언어의 권하는 모양이다. 순수함이 머리에 드러나야 호출자와 컴파일러가 그것을 쓸 수 있다.

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

op 을 부르면 그 op 의 효과가 부르는 쪽의 효과에 더해진다. 순수한 fn 이 io 를 내는 proc 을 부르면, 그 fn 은 더 이상 순수하지 않다.

examples/ch05/calls.low

module calls .
rem expect: E-EFFECT-CALC

proc say input out cap io . output u64 . effects io .
do
  return write_out out 1 "hi\n" .
end

fn helper input out cap io . output u64 .
do
  return say out .
end

실행 결과

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

helper 는 스스로 출력하지 않는다. 출력하는 것은 say 다. 그래도 거절된다. 이 규칙 덕분에 op 의 머리만 보고도 그 op 이 부르는 모든 op 의 효과를 알 수 있다. 효과를 숨긴 채 전달하는 길이 없다. 어떤 효과가 어떤 권한과 짝지어지는지는 15·16장에서 다룬다.

5.5 매개변수와 반환#

op 하나를 조각조각 뜯어 보면 이렇다. 처음 보는 사람이 가장 헷갈리는 것은 절마다 마침표로 끝난다는 점이다. 마침표는 문장의 끝처럼 “이 절은 여기까지” 를 알린다.

fn   add3   input a i32 .   input b i32 .   input c i32 .   output i32 .
│    │      └─ 매개변수 a 의 타입은 i32 ─┘                  └ 돌려주는 값의 타입
│    └─ 이름 (부를 때 쓴다)
└─ 갈래: fn(순수) 또는 proc(효과를 낼 수 있음)
do                          ← 몸이 여기서 시작한다
  return add (add a b) c .  ← 계산해서 돌려준다. 문장도 마침표로 끝난다
end                         ← 몸이 끝나고, 선언 전체도 끝난다(뒤에 점을 찍지 않는다)

부를 때는 이름 뒤에 인자를 공백으로 늘어놓는다. add3 1 2 3 이 한 번의 호출이고, 괄호는 호출 하나를 다른 호출의 인자로 넣을 때만 쓴다 — add3 (add3 1 2 3) 4 5. 괄호가 곧 “이 안이 값 하나” 라는 표시다. C 처럼 add3(1, 2, 3) 으로 쓰는 법은 없다. 쉼표는 이 언어에서 뜻이 없는 기호이고, 그래서 거절된다(아래 «흔한 실수»).

매개변수는 input <이름> <타입> . 한 절에 하나다. 여럿이면 절을 되풀이한다. 부르는 쪽은 절의 차례대로 인자를 준다. 돌려주는 값은 output <타입> . 하나이고, 본문에서 return <식> . 으로 돌려준다.

fn compare input a i32 . input b i32 . output i32 .
do
  return sub a b .
end

타입 앞에 붙는 낱말이 소유와 가변과 유무를 말한다. 자주 만나는 것은 다음과 같다.

적는 것뜻
input xs slice u8 .남의 바이트를 읽기로 빌린다
input xs mut slice u8 .남의 바이트에 쓸 수 있게 빌린다(proc 이어야 한다)
input p ref point . · mut_ref point값 하나를 읽기로·쓰기로 빌린다(12장)
input o option u64 .있을 수도 없을 수도 있는 값(11장)
input out cap io .권한(16장)
input comptime t type .번역할 때 정해지는 타입(22장)

표 5.1 — 매개변수 타입에 붙는 낱말

반환값은 하나다. 여럿을 돌려주려면 struct 로 묶거나(10장), 호출자가 넘긴 mut 슬라이스에 쓴다.

5.6 neg 만 단항이다#

사칙 연산 add·sub·mul·div·mod 는 인자를 둘 받는다. 산술에서 인자를 하나 받는 것은 부호를 뒤집는 neg 뿐이다.

examples/ch05/neg.low

module negs .
rem run: flip 5
rem run: dist -3 4

fn flip input a i64 . output i64 .
do
  return neg a .
end

fn dist input a i64 . input b i64 . output i64 .
do
  return abs (sub a b) .
end

실행 결과

$ lowentc --run flip neg.low 5
flip(5) = -5
$ lowentc --run dist neg.low -3 4
dist(-3, 4) = 7

neg 는 부호 있는 타입의 일이다. 부호 없는 값에 쓰면 넘침이다. abs 는 절댓값이고, i64 의 가장 작은 값처럼 양수로 표현할 수 없는 값을 주면 멈춘다. expr 섬 안에도 단항 연산자는 없어서, 부호를 뒤집으려면 expr 0 - a 로 적거나 (neg a) 를 괄호로 부른다.

5.7 수식어#

op 머리 앞에 수식어를 붙일 수 있다.

unsafe extern proc c_area do
  input k cap c .
  input w i64 .
  input h i64 .
  output i64 .
  effects unsafe .
  link lw_c_area .
end

5.8 흔한 실수#

처음 op 을 쓸 때 누구나 한 번쯤 밟는 자리들이다. 모두 번역할 때 걸리므로, 진단 코드를 보고 이 절로 돌아오면 된다.

반례. C 처럼 괄호와 쉼표로 부른다

다른 언어에 익숙하면 손이 먼저 add3(1, 2, 3) 을 친다.

examples/ch05/mistake_ccall.low

module mistake_ccall .
rem expect: E-VOCAB-REMOVED

fn add3 input a i32 . input b i32 . input c i32 . output i32 .
do
  return add (add a b) c .
end

fn use3 output i32 .
do
  rem ✘ C 처럼 괄호와 쉼표로 불렀다 --- Lowent 의 호출은 `이름 인자 인자 …` 다
  return add3(1, 2, 3) .
end

실행 결과

$ lowentc --check mistake_ccall.low
12:16 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.
12:19 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.

Lowent 의 호출은 이름 다음에 인자를 공백으로 늘어놓는 전위 표기다. 쉼표는 한때 쓰였다가 없앤 기호라서 E-VOCAB-REMOVED 가 나온다. 괄호·쉼표 없이 부르는 까닭은 기호를 줄여 누구나(휴대전화 자판으로도) 같은 모양으로 쓰게 하려는 것이다. 고치는 법: return add3 1 2 3 .

반례. 인자 수가 input 절 수와 다르다

examples/ch05/mistake_arity.low

module mistake_arity .
rem expect: E-IR-ARITY

fn add3 input a i32 . input b i32 . input c i32 . output i32 .
do
  return add (add a b) c .
end

fn use3 output i32 .
do
  rem ✘ input 절이 셋인데 인자를 둘만 줬다
  return add3 1 2 .
end

실행 결과

$ lowentc --check mistake_arity.low
mistake_arity.low:12:0 E-IR-ARITY: `add3` takes 3 arguments but 2 are given here — an op call is `<name> <arg>…` with exactly as many arguments as its `input` clauses

input 절 하나가 인자 하나다. 기본값 인자나 인자 수가 달라지는 op 은 없다 — 머리를 보면 부르는 모양이 하나로 정해져야 읽는 사람이 헷갈리지 않기 때문이다. 진단이 몇 개를 받고 몇 개를 주었는지 말해 준다.

반례. 어느 갈래에서 return 을 빠뜨린다

examples/ch05/mistake_partial.low

module mistake_partial .
rem expect: E-RETURN-PARTIAL

fn sign input a i32 . output i32 .
do
  if gt a 0 . do
    return 1 .
  end
  rem ✘ a 가 0 이하일 때 돌려줄 값이 없다 --- 끝까지 내려오는 길이 있다
end

실행 결과

$ lowentc --check mistake_partial.low
mistake_partial.low:4:0 E-RETURN-PARTIAL: this op says it OUTPUTS a value, but some path through its body reaches the end without a `return`. Until now the tool quietly returned 0 there — a value that appears NOWHERE in your source (RFC-0019 G-TOTAL). Give every path a `return`, or say `output void` if it really produces nothing. An exhaustive `match` whose every arm returns counts as returning

output 을 적은 op 은 모든 길에서 값을 돌려주어야 한다. 옛 도구는 빠진 자리에서 조용히 0 을 돌려주었는데, 그 0 은 소스 어디에도 적혀 있지 않은 값이라 버그를 숨겼다. 그래서 지금은 E-RETURN-PARTIAL 로 거절한다. 고치는 법은 if 뒤에 나머지 경우의 return 을 적는 것이다 — 예를 들면 return 0 . 을 end 앞에 둔다.

반례. 내장 op 의 이름으로 op 을 짓는다

examples/ch05/mistake_builtin.low

module mistake_builtin .
rem expect: E-NAME-BUILTIN

rem ✘ `min` 은 이미 내장 op 의 이름이다 --- 이 선언은 영영 불리지 않는다
fn min input a u64 . input b u64 . output u64 .
do
  return add a b .
end

실행 결과

$ lowentc --check mistake_builtin.low
mistake_builtin.low:5:0 E-NAME-BUILTIN: this name is a BUILTIN — the resolver always picks the builtin, so your declaration can never be called: it exists and does not exist. The namespace is FLAT (no shadowing). Rename it

이름 공간은 평평해서 같은 이름이 둘일 수 없다. 내장 op(add·min·len·ok …)의 이름을 쓰면 그 선언은 영영 불리지 않으므로 E-NAME-BUILTIN 으로 알려 준다. 지역 이름에도 같은 규칙이 붙는다. 내장 이름의 목록은 부록 A 에 있다.

흔한 오해. 결과를 받지 않아도 fn 을 부르면 무언가 일어난다

fn 은 순수하므로 결과를 버리면 아무 일도 일어나지 않은 것과 같다. 컴파일러는 그런 호출을 지워도 된다.

examples/ch05/discard.low

module discard .
rem run: caller

fn twice input a u64 . output u64 .
do
  return mul a 2 .
end

fn caller output u64 .
do
  rem 결과를 받지 않은 fn 호출 --- 순수하므로 아무 흔적도 남기지 않는다
  twice 5 .
  rem 결과를 쓰려면 이름에 묶는다
  let t u64 be twice 5 .
  return t .
end

실행 결과

$ lowentc --run caller discard.low
caller() = 10

첫 twice 5 . 는 계산만 하고 흔적을 남기지 않는다. 답이 10 인 것은 둘째 줄에서 결과를 t 에 묶었기 때문이다. 무언가를 하려는 호출(출력·쓰기)이라면 그것은 proc 이어야 하고, 그러면 머리에 효과가 적힌다.

5.9 이 장의 문법 한눈에#

모양뜻왜 이렇게
fn f input a T . output R . do … end순수한 op결과를 기억·재배치·생략해도 되는지 머리만 보고 알게
proc f … effects E . do … end효과를 낼 수 있는 op무엇을 하는지(E)가 머리에 드러나게
input x T .매개변수 하나절마다 하나라 이름과 타입이 줄마다 보인다
output T . · output void .돌려주는 값의 타입 · 돌려주는 값 없음반환은 하나 — 여럿이면 struct 로 묶는다
return e .값을 돌려주고 끝낸다모든 길에서 적어야 한다(E-RETURN-PARTIAL)
f a b c부르기 — 이름 뒤에 인자를 공백으로괄호·쉼표 없는 전위 표기
f (g a) b호출을 인자로 넣기괄호는 “이 안이 값 하나” 라는 표시
requires c .부르는 쪽이 지켜야 할 조건어긴 쪽의 잘못임을 진입에서 알린다(14장)
effects state .호출자의 저장소를 고친다mut 매개변수로 쓰는 op 의 머리
neg a부호 뒤집기(유일한 단항 산술)빼기와 뒤섞이지 않게 이름으로
export · extern · unsafe내보내기 · 몸이 C 에 · 검사 못 하는 일할 수 있는 일이 머리 앞에 보이게

표 5.2 — op 의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나

복습 정리

op 은 fn 이거나 proc 이고 갈래는 언제나 적는다. 순수함은 관측적이어서 op 안에 갇힌 변이는 순수로 치지만, mut 매개변수로 호출자에게 보이는 쓰기는 proc 이어야 한다. fn 에는 effects 를 적지 않고, proc 의 effects 는 좁히는 절이다. 효과는 부르는 쪽으로 번진다. 매개변수는 절마다 하나, 반환은 하나이며, 산술의 단항은 neg 뿐이다.