Lowent 매뉴얼←↑→

2 첫 프로그램 — 짓고, 돌리고, 거절당하기

먼저 알아야 할 것

1장 Lowent 는 무엇을 하려는 언어인가 · 머리만 읽고 op 이 할 일을 안다는 목표

돌아보기

1장은 proc main input out cap io . output u8 . effects io . 라는 머리에서 무엇을 읽어 냈는가? 그리고 적혀 있지 않은 것에서는 무엇을 읽어 냈는가?

답. 순수하지 않은 op 이고(proc), io 권한을 out 이라는 이름으로 받으며, u8 을 돌려주고, io 효과를 낸다는 것을 읽었다. 적혀 있지 않은 것에서는 이 op 이 할당하지 않고, 파일 시스템 권한이 없고, 스레드를 띄우지 않는다는 것을 읽었다. 이 장에서 그 머리를 가진 프로그램을 실제로 짓고 돌린다.

이 장의 필요성과 맥락

언어를 배우는 첫 걸음은 손에 쥔 도구가 무엇을 해 주는지를 아는 것이다. Lowent 의 도구 lowentc 는 기본 모드가 없고, 같은 프로그램을 두 가지 방식(VM 과 네이티브)으로 돌리며, 틀린 프로그램에는 코드가 붙은 진단을 낸다. 이 세 가지를 문법보다 먼저 몸에 익혀 두면, 뒤의 장에서 새 규칙을 만날 때마다 직접 돌려 보고 직접 거절당해 볼 수 있다. 그래서 문법 설명(3장)보다 이 장이 앞선다.

이 장이 끝나면

lowentc 를 짓고, 가장 작은 프로그램을 VM 으로 한 번·네이티브로 한 번 돌리게 된다. 돌려주는 값이 종료 코드가 되는 이유, 계약이 실행을 멈추는 모습, test 블록을 돌리는 법을 보게 된다. 컴파일러가 거절할 때 내는 진단을 읽는 법과, --fmt 가 모양을 고쳐 주는 범위도 알게 된다.

이 장에서 답할 질문

  1. 왜 기본 모드가 없는가? lowentc hello.low 만 쳐도 알아서 짓게 할 수 있지 않은가?
  2. 진단 코드는 외워야 하는가?

2.1 도구 짓기#

컴파일러의 이름은 lowentc 다. 저장소를 받은 뒤 impl/ 에서 짓는다. 필요한 것은 C23 을 아는 C 컴파일러(gcc 나 clang)와 make 뿐이다.

cd impl
make            # build/lowentc
make check      # 단위 시험과 표준 라이브러리 검사

make 가 끝나면 impl/build/lowentc 가 생긴다. 이 책의 명령은 그것이 경로에 있다고 치고 lowentc 로 적는다.

모드하는 일
--check검사만 한다(계약·효과·소유·권한). 아무것도 내보내지 않는다
--run OP 인자…VM 으로 op 하나를 돌린다. 슬라이스 인자는 [3,4,8] 로 준다
--emit-c네이티브 C 소스를 표준출력으로 낸다
--testtest 블록을 돌린다
--fmt정규형으로 다시 찍는다
--diag-json진단을 JSON 한 줄씩 낸다(도구와 AI 가 읽는다)

표 2.1 — 자주 쓰는 lowentc 모드

문. 왜 기본 모드가 없는가? lowentc hello.low 만 쳐도 알아서 짓게 할 수 있지 않은가?

답. 할 수 있지만 그러면 그 명령이 무엇을 했는지가 명령 줄에 안 보인다. 검사만 했는지, 실행했는지, 파일을 썼는지를 사람이 기억해야 한다. 1장의 “한 뜻에 한 표기” 가 도구에도 적용된 것이다. 짧게 쓰고 싶으면 lowentc check, lowentc run 같은 서브커맨드 이름이 같은 일을 한다.

2.2 hello, entropy#

가장 작은 출력 프로그램이다.

examples/ch01/hello.low

module hello .
rem run: main

proc main input out cap io . output u8 . effects io . do
  return narrow u8 (write_out out 1 "hello, entropy!\n") .
end

실행 결과

$ lowentc --run main hello.low
hello, entropy!
main() = 16

첫 줄 module hello . 는 모든 파일이 여는 모듈 선언이다. 둘째 줄의 rem 은 줄 주석이고, 이 책에서는 검증 스크립트에게 무엇을 할지 알려 주는 데 쓴다.

op 의 머리를 한 절씩 읽는다.

본문은 do 와 end 사이다. write_out out 1 "…" 는 권한 out 을 첫 인자로 받아 파일 기술자 1(표준출력)에 바이트를 쓰고, 쓴 바이트 수를 돌려준다. hello, entropy!\n 는 16 바이트이므로 16 이 나온다. 그것을 narrow u8 로 u8 에 맞게 좁혀 돌려준다. 좁히기는 값이 들어가지 않으면 실행 중에 멈추는 검사를 동반한다(4장).

실행 결과의 마지막 줄 main() = 16 은 VM 이 op 의 반환값을 보여 주는 줄이다.

2.3 두 가지로 돌린다#

--run 은 컴파일러 안의 가상 기계로 돌린 것이다. 같은 프로그램을 네이티브로 돌리려면 C 로 내보내고 C 컴파일러로 짓는다.

lowentc --emit-c hello.low > hello.c
cc -O2 -o hello hello.c -lm
./hello main

네이티브 실행 파일은 첫 인자로 돌릴 op 의 이름을 받는다. main 을 돌리면 화면에는 같은 한 줄이 찍히고, 반환값 16 은 프로세스의 종료 코드로 나온다(echo $? 로 본다). main 의 output 이 u8 인 까닭이다 — 종료 코드는 한 바이트다.

실제 사례. 두 결과가 다르면 누구의 잘못인가

VM 과 네이티브는 같은 중간 표현에서 출발한다. 둘이 다른 출력을 내면 프로그램이 아니라 컴파일러의 결함이다. 개발 저장소는 수백 개의 op 을 경계값 인자로 두 백엔드에서 돌려 바이트 단위로 맞대고, 이 책의 검증 스크립트도 rem run: 이 붙은 모든 예제를 두 번 돌려 비교한다. 이 책을 쓰는 동안에도 한 가지 어긋남이 드러났다 — 프로그램 인자를 읽는 arg 가 VM 과 네이티브에서 번호를 다르게 센다. 그래서 인자를 읽는 예제는 이 판에서 검사만 하고 실행 결과를 싣지 않는다.

슬라이스 인자는 대괄호로 준다. 다음 op 은 바이트 슬라이스의 평균을 구한다.

examples/ch01/stats.low

module stats .
rem run: mean [3,4,8]

fn mean input xs slice u8 . output u64 .
  requires gt (len xs) 0 .
do
  var total u64 be 0 .
  for x xs do
    set total (add total (widen u64 x)) .
  end
  return div total (len xs) .
end

실행 결과

$ lowentc --run mean stats.low [3,4,8]
mean([3,4,8]) = 5
  arg0 (written) = [3,4,8]

requires gt (len xs) 0 . 는 계약이다. 호출자는 빈 슬라이스를 주지 않는다고 약속한다. 그 약속이 있으니 본문의 div total (len xs) 가 0 으로 나눌 걱정을 하지 않는다. 결과 아래의 arg0 (written) = [3,4,8] 은 VM 이 슬라이스 인자의 마지막 모습을 보여 주는 줄이다.

2.4 계약이 멈추는 모습#

계약을 어기면 어떻게 되는가. 다음 op 은 100 이하만 받는다.

examples/ch02/twice.low

module doubling .
rem run: twice 21
rem test

export fn twice input n u32 . output u32 .
  requires le n 100 .
do
  return mul n 2 .
end

test twice_works
do
  expect eq (twice 5) 10 .
  expect eq (twice 0) 0 .
end

실행 결과

$ lowentc --run twice twice.low 21
twice(21) = 42
$ lowentc --test twice.low
  [PASS] twice_works
== tests: 1 run, 1 passed, 0 FAILED ==

lowentc --run twice twice.low 200 처럼 약속을 어긴 값을 주면, VM 은 본문에 들어가기 전에 멈추고 E-VM-CONTRACT: requires violated at entry 를 낸다. 인자가 상수로 적힌 호출(twice 250)이면 실행할 필요도 없이 --check 가 E-CONTRACT-IMPOSSIBLE 로 거절한다. 계약은 주석이 아니다.

같은 파일의 test twice_works 블록은 시험이다. expect 가 단언이고, --test 가 모든 test 블록을 돌린다. 결과의 마지막 두 줄이 그 출력이다. --check 는 시험을 돌리지 않는다 — 검사가 통과했다고 시험이 통과한 것은 아니므로, 시험이 있는 파일을 --check 하면 컴파일러가 W-TEST-NOT-RUN 경고로 그 사실을 일러 준다.

흔한 오해. --check 가 조용하면 프로그램이 옳다

--check 가 보증하는 것은 머리에 적은 약속과 본문이 어긋나지 않는다는 것까지다. 적지 않은 약속은 검사할 수 없고, 시험은 돌리지 않았으며, 실행 중에만 알 수 있는 계약 위반은 실행해야 드러난다. “검사 통과” 와 “시험 통과” 와 “옳다” 는 세 가지 다른 말이다.

2.5 거절당하기#

Lowent 를 배우는 시간의 상당 부분은 컴파일러에게 거절당하는 시간이다. 거절은 벌이 아니라 대화다. 진단마다 안정된 코드가 붙어 있고, 그 코드는 판이 바뀌어도 같은 뜻을 지킨다.

examples/ch01/twice_bad.low

module twice_bad .
rem expect: E-EFFECT-CALC

fn shout input out cap io . output u64 .
do
  return write_out out 1 "hi\n" .
end

실행 결과

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

진단 한 줄은 파일:줄:칸 코드: 설명 의 꼴이다. 이 예에서 fn shout 은 순수하다고 선언했는데 본문이 write_out 으로 입출력을 한다. 컴파일러는 E-EFFECT-CALC 로 거절하고, 고치는 길 둘을 말한다 — proc 으로 바꾸고 effects 를 적거나, 효과를 없애거나.

--diag-json 을 붙이면 같은 진단이 JSON 한 줄로 나온다. 규칙 코드(rule), 위치(span), 고치는 방법의 이름(repair)이 칸으로 나뉘어 있어서, 편집기나 AI 가 설명 문장을 뜯지 않고도 무엇이 틀렸는지 안다.

{"rule":"E-EFFECT-CALC","sev":"error","phase":"effect", … ,"repair":"R-CALC-TO-PROC"}

문. 진단 코드는 외워야 하는가?

답. 외울 필요는 없다. 코드는 검색하고 참조하라고 붙인 이름이다. 이 책의 부록에 자주 만나는 진단을 모았고, 각 장은 그 장의 규칙을 어길 때 나오는 코드를 본문에서 보여 준다.

2.6 모양을 고쳐 주는 --fmt#

op 머리의 절은 정해진 한 차례로 적는다. 입력이 출력보다 먼저다. 차례가 틀리면 거절된다.

examples/ch02/messy.low

module messy .
rem expect: E-CLAUSE-ORDER

fn area output u64 . input w u64 . input h u64 .
do
  return mul w h .
end

실행 결과

$ lowentc --check messy.low
messy.low:4:22 E-CLAUSE-ORDER: a data input comes after `output`. An op header has ONE order: `satisfies`/`lowdoc` · `vector`/`priority` · `comptime` inputs · capability/region inputs · `using` · data inputs · `output` · `effects` · `link`/`variadic` · `asm` · `access`/`inplace`/`invalidates`/`parallel`/`reduce` · `requires` · `ensures` · `errors` · `tests` (`--fmt` moves the non-input clauses for you; inputs are call positions, so reorder those and their call sites yourself)

진단의 긴 설명이 곧 차례표다. 전부는 3장에서 다루고, 지금은 한 가지만 기억한다: --fmt 는 입력이 아닌 절(출력·효과·계약 따위)을 제자리로 옮겨 준다. 입력끼리의 차례는 옮기지 않는다. 입력의 차례는 부르는 쪽 인자의 차례이기도 해서, 옮기면 모든 호출 자리가 함께 바뀌어야 하기 때문이다.

--fmt 가 찍는 정규형은 절마다 한 줄이고 괄호를 모두 드러낸다. 사람이 그 모양으로 쓸 필요는 없다. 정규형은 두 코드가 같은 뜻인지를 기계가 비교하는 기준이다.

2.7 인자와 권한#

프로그램 인자를 읽는 것도 권한이다. 다음 프로그램은 첫 인자를 받아 인사한다.

examples/ch02/greet.low

module greet .
rem (인자는 네이티브와 VM 의 번호가 달라 여기서는 검사만 한다)

proc main input out cap io . input a cap args . output u8 . effects io .
do
  let who option slice u8 be arg a 0 .
  guard is_some who . else return narrow u8 (write_out out 1 "no name\n") .
  let n u64 be write_out out 1 "hello, " .
  let m u64 be write_out out 1 (some_value who) .
  let k u64 be write_out out 1 "\n" .
  return 0 .
end

실행 결과

$ lowentc --check greet.low
== check: ok ==

input a cap args . 로 인자 권한을 받고, arg a 0 이 첫 인자를 읽는다. 인자가 없을 수도 있으니 결과는 option slice u8 이다. guard is_some who . else … 는 값이 없으면 그 자리에서 떠나고, 그 아래에서는 값이 있다고 믿어도 된다(11장). 권한 입력이 데이터 입력보다 앞에 오고, 권한 둘은 적은 차례대로 인자 자리를 차지한다.

effects io . 한 줄이 두 권한을 모두 덮는 것에 주목한다. args 는 효과 없이 읽을 수 있는 권한이다. 권한과 효과가 어떻게 짝지어지는지는 16장이 표로 정리한다.

2.8 흔한 실수#

첫 프로그램에서 가장 자주 만나는 거절과 멈춤이다. 진단 한 줄의 코드를 보고 여기로 돌아오면 된다.

반례. do 를 열고 end 로 닫지 않는다

examples/ch02/mistake_noend.low

module mistake_noend .
rem expect: E-BLOCK-UNCLOSED

proc main input out cap io . output u8 . effects io .
do
  let n u64 be write_out out 1 "hi\n" .
  return narrow u8 n .
rem ✘ 몸을 연 `do` 에 짝이 되는 `end` 가 없다

실행 결과

$ lowentc --check mistake_noend.low
5:1 E-BLOCK-UNCLOSED: missing 'end' for do-block

몸은 do 로 열고 end 로 닫는다. 파일이 끝날 때까지 닫는 말이 없으면 컴파일러는 어디까지가 몸인지 알 수 없어 E-BLOCK-UNCLOSED 를 낸다. 진단의 5:1 은 짝을 잃은 do 의 자리다 — 몸이 여럿 겹쳐 있으면 그 줄부터 짝을 맞춰 본다. 들여쓰기를 맞춰 두면 짝이 눈에 보인다.

반례. 출력하면서 권한을 받지 않는다

examples/ch02/mistake_nocap.low

module mistake_nocap .
rem expect: E-EFFECT-NO-CAP

rem ✘ `io` 를 한다고 적었지만 권한(`cap io`)을 받지 않았다
proc main output u8 . effects io .
do
  let n u64 be write_out 1 "hi\n" .
  return narrow u8 n .
end

실행 결과

$ lowentc --check mistake_nocap.low
mistake_nocap.low:5:0 E-EFFECT-NO-CAP: this op declares the `io` effect but receives NO capability that authorizes it. `io` is a cap-effect (RFC-0007 §6.7): I/O is a RIGHT you are HANDED, not an ambient power — `input fs cap file_system .` (or another `cap …` input). An effect you declare but hold no capability for is a claim the signature cannot back

effects io . 는 “입출력을 한다” 는 선언이고, 실제로 할 수 있게 해 주는 것은 입력으로 받는 권한(input out cap io .)이다. 권한 없이 효과만 적으면 E-EFFECT-NO-CAP 이다. 입출력은 아무 데서나 꺼내 쓰는 힘이 아니라 main 이 시작할 때 건네받아 필요한 op 에게 넘겨주는 권리다(16장). 고치는 법: 머리에 input out cap io . 를 더하고, write_out 의 첫 인자로 out 을 준다.

반례. 종료 코드에 255 보다 큰 수를 돌려준다

examples/ch02/mistake_exitcode.low

module mistake_exitcode .
rem trap: main

proc main input out cap io . output u8 . effects io .
do
  var n u64 be 0 .
  var i u64 be 0 .
  while lt i 8 . do
    set n (add n (write_out out 1 "32 bytes of text on every line.\n")) .
    set i (add i 1) .
  end
  rem ✘ 256 바이트를 썼다 --- 종료 코드는 한 바이트(0 … 255)라 들어가지 않는다
  return narrow u8 n .
end

실행 결과

$ lowentc --run main mistake_exitcode.low
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
== ir diagnostics (1) ==
0:0 E-VM-CAST: value does not fit the target width (use narrow_wrap / narrow_sat / narrow_try)

번역은 통과하지만 실행 중에 멈춘다. main 의 반환값은 운영체제에 넘기는 종료 코드라서 한 바이트(u8)이고, 256 은 narrow u8 에 들어가지 않는다. Lowent 는 값을 조용히 잘라 0 을 돌려주지 않고 그 자리에서 멈춘다. 잘린 종료 코드는 “성공” 으로 읽혀 버그를 숨기기 때문이다. 쓴 바이트 수가 알고 싶은 것이라면 출력하고, 종료 코드로는 성공(0)·실패(0 아닌 수)만 돌려준다.

반례. 돌릴 op 의 이름을 잘못 적는다

examples/ch02/mistake_opname.low

module mistake_opname .
rem trap: mian

proc main input out cap io . output u8 . effects io .
do
  let n u64 be write_out out 1 "hi\n" .
  return narrow u8 n .
end

실행 결과

$ lowentc --run mian mistake_opname.low
== ir diagnostics (1) ==
0:0 E-VM-UNDEF: no such op

--run 뒤의 이름은 파일 안의 op 이름과 글자 하나까지 같아야 한다. mian 은 없으므로 VM 이 E-VM-UNDEF: no such op 로 멈춘다. 네이티브 실행 파일도 첫 인자로 받은 이름의 op 이 없으면 비영으로 끝난다. 무엇을 돌릴지 명령 줄에 보이게 한 대가다 — 대신 같은 파일 안의 어느 op 이든 따로 돌려 볼 수 있다.

2.9 이 장의 문법 한눈에#

모양뜻왜 이렇게
module hello .이 파일의 모듈 이름다른 파일이 use hello . 로 부를 이름
rem …줄 주석(줄 끝까지)기호 없이 낱말로 — 이 책에서는 검증 지시도 싣는다
proc main input out cap io . output u8 . effects io .프로그램의 시작점받는 권한·돌려주는 종료 코드·하는 일이 머리에 다 보인다
do … end몸여는 말과 닫는 말이 짝을 이룬다
write_out out 1 "…"표준출력(1)에 쓰고 쓴 바이트 수를 돌려준다권한 out 이 첫 인자 — 권한 없이는 못 쓴다
narrow u8 nu8 로 좁힌다(안 들어가면 멈춘다)값이 조용히 바뀌지 않게
requires c . · test t do … end · expect c .계약 · 시험 블록 · 시험 속 단언약속은 검사되고, 시험은 따로 돈다
lowentc --check f.low검사만 한다무엇을 했는지 명령 줄에 보이게 — 기본 모드가 없다
lowentc --run op f.low 인자…VM 으로 op 하나를 돌린다파일 안의 어느 op 이든 따로 돌려 본다
lowentc --emit-c f.low > f.c네이티브용 C 를 낸다VM 과 같은 답인지 맞대 볼 수 있게
lowentc --test · --fmt시험을 돌린다 · 정규형으로 다시 찍는다검사와 시험과 모양은 서로 다른 일
lowentc --hw auto f.low기계의 암호 명령과 폭을 담는다(AES-NI · 캐리 없는 곱셈 · SSE2/AVX2 레인)빌드가 담을 것을 정하고, 담은 것이 둘 이상이면 시작할 때 한 번 고른다 — 답은 어느 쪽이든 같다

표 2.2 — 첫 프로그램의 모양과 도구 — 모양 · 뜻 · 왜 이렇게 생겼나

복습 정리

lowentc 는 모드를 반드시 고른다. --run 은 VM 으로, --emit-c 와 C 컴파일러는 네이티브로 같은 프로그램을 돌리고, 둘은 같은 답을 내야 한다. main 의 반환값은 종료 코드다. 계약은 실행 전이나 진입에서 멈추고, test 는 --test 로만 돈다. 거절에는 안정된 코드가 붙고, 절의 차례는 --fmt 가 입력 밖의 절에 한해 고친다.