9 줄 — 배열과 슬라이스
먼저 알아야 할 것
for 는 슬라이스를 훑고, guard 는 조건을 사실로 바꾼다proc 이어야 한다돌아보기
7장의 head_or_zero 에서 idx data. 0 . 이 안전했던 까닭은 무엇인가?
답. 바로 앞의 guard ge len data. . 1 . else return 0 . . 가 슬라이스가 비지 않았다는 사실을 그 아래 코드에 건넸기 때문이다. guard 를 지난 코드는 그 조건이 참인 세계에서만 산다. 이 장은 그 슬라이스가 무엇이고, 경계 검사가 언제 남고 언제 사라지는지를 다룬다.
이 장의 필요성과 맥락
이 장이 끝나면
array n t 과 slice t 의 차이, len·idx·for 로 읽는 법, 범위 밖 접근이 멈춘다는 것을 알게 된다. mut slice 로 받아야 원소에 쓸 수 있다는 규칙과 subslice 로 창을 좁히는 법을 익힌다. 계약이 본문의 경계 검사를 지우는 원리와, 문자열 리터럴이 그대로 색인되는 표라는 것도 보게 된다.이 장에서 답할 질문
- 문자열은 따로 된 타입이 없는가?
- 슬라이스 인자를
--run에 줄 때[3,4,5]는slice u64에도 쓸 수 있는가?
9.1 시작과 길이를 함께 갖는다#
slice t 는 타입 t 의 값이 연속으로 놓인 구간이다. 슬라이스 값은 시작 주소와 길이를 함께 갖는다. 그래서 “여기 몇 개가 있는가” 를 사람이 따로 기억할 필요가 없다.
examples/ch09/basics.low
module basics .
rem run: head [9,8,7]
rem run: last4 [1,2,3,4]
rem run: total [10,20,30]
rem trap: at [1,2,3] 5
fn head input data slice u8 .
output u8 .
requires ge len data. . 1 . .
do
return idx data. 0 . .
end .
fn last4 input xs array 4 u8 . output u8 .
do
return idx xs. 3 . .
end .
fn total input xs slice u8 . output u64 .
do
var acc u64 0 .
for x xs. do
set acc. add acc. widen u64 x. . . .
end .
return acc. .
end .
fn at input xs slice u8 . input i usize . output u8 .
do
return idx xs. i. . .
end .
실행 결과
$ lowentc --run head basics.low [9,8,7]
head([9,8,7]) = 9
arg0 (written) = [9,8,7]
$ lowentc --run last4 basics.low [1,2,3,4]
last4([1,2,3,4]) = 4
arg0 (written) = [1,2,3,4]
$ lowentc --run total basics.low [10,20,30]
total([10,20,30]) = 60
arg0 (written) = [10,20,30]
$ lowentc --run at basics.low [1,2,3] 5
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)
len data. .는 원소 개수,idx data. 0 .은 첫 원소다. 번호는 0 부터다.last4의array 4 u8는 길이가 정확히 4 인 줄이다. 입력 자리의array는 길이가 4 인 슬라이스를 받는다는 뜻이고, 그 길이는requires eq len xs. . 4 . .처럼 진입에서 검사된다.for x xs. do … end .는 원소를 차례로 훑는다.at에 길이 3 인 슬라이스와 5 를 주면E-VM-BOUNDS로 멈춘다. 남의 메모리를 읽지 않는다.
array 는 길이를 먼저, 원소 타입을 뒤에 적는다 — vec 4 u32 · matrix 2 3 4 u32 와 같은 차례다. 원소 타입이 맨 뒤라 배열 안의 배열도 괄호 없이 array 3 array 4 u8 로 겹친다. 차례를 뒤집으면 거절되고, lowentc --fmt 가 옛 차례를 새 차례로 옮겨 적는다.
examples/ch09/array_order.low
module array_order .
rem expect: E-TYPE-ORDER
fn last input xs array u8 4 . output u8 .
do
return idx xs. 3 . .
end .
실행 결과
$ lowentc --check array_order.low
4:24 E-TYPE-ORDER: the count comes first: `array <count> <element type>` — `array 4 u8`, and an array of arrays is `array 3 array 4 u8`. The old order `array u8 4` is no longer read; `lowentc --fmt` rewrites it — found `u8` (RFC-0142: a name opens, a stop closes; a vari
2026-09-27 부터 한 달 동안은 원소 타입을 먼저 적었다(array u8 4). 2026-10-29 에 길이를 먼저로 되돌렸고, vec 과 matrix 도 함께 옮겨 셋의 차례가 같다. 지금 array 는 op 의 입력 자리에서만 쓰고, 출력·지역·구조체 칸에서는 slice 와 계약으로 적는다.
문. 문자열은 따로 된 타입이 없는가?
답. 없다. 문자열 리터럴 "hello" 는 slice u8 이다. 글자가 몇 개인지, UTF-8 로 올바른지 같은 질문은 타입이 아니라 라이브러리의 op 이 답한다(33장). 바이트와 글자를 같은 것으로 여기는 순간 생기는 결함을 피하려는 선택이다.
9.2 쓰려면 mut slice#
슬라이스의 원소에 쓰려면 그 슬라이스가 mut 여야 한다. 그리고 호출자가 넘긴 바이트를 바꾸는 일은 호출자에게 보이므로 proc 이어야 한다(5장).
examples/ch09/fill.low
module fill .
rem run: fill_two [0,0,5]
proc fill_two input xs mut slice u8 . output u64 . effects state .
requires ge len xs. . 2 . .
do
set idx xs. 0 . 10 .
set idx xs. 1 . 20 .
var s u64 0 .
for x xs. do
set s. add s. widen u64 x. . . .
end .
return s. .
end .
실행 결과
$ lowentc --run fill_two fill.low [0,0,5]
fill_two([10,20,5]) = 35
arg0 (written) = [10,20,5]
VM 이 보여 주는 fill_two([10,20,5]) 는 op 이 끝난 뒤 인자의 모습이다. 호출자의 바이트가 바뀌었다. mut 없이 쓰면 거절된다.
examples/ch09/readonly.low
module readonly .
rem expect: E-TYPE-MUT
fn poke input xs slice u8 . output u64 .
do
set idx xs. 0 . 1 .
return 0 .
end .
실행 결과
$ lowentc --check readonly.low
readonly.low:6:0 E-TYPE-MUT: writing an element of a slice that is not declared `mut` (a shared slice is read-only)
mut 는 슬라이스의 원소에 대한 권한이다. 슬라이스를 담은 이름을 let 으로 지었는지와는 다른 질문이다 (6장). 원소에 쓸 수 있는 슬라이스는 한 번에 한 곳에서만 빌릴 수 있다는 규칙이 뒤따른다 (12장).
9.3 창을 좁힌다#
subslice s. from. to. . 는 s 의 from 번부터 to 번 앞까지를 가리키는 새 슬라이스를 만든다. 바이트를 베끼지 않는다. 같은 바이트 위에 좁은 창을 하나 더 얹을 뿐이다.
examples/ch09/windows.low
module windows .
rem run: middle_sum [1,2,3,4,5]
rem run: prefix_char 4
fn middle_sum input xs slice u8 . output u64 .
requires ge len xs. . 3 . .
do
let mid slice u8 subslice xs. 1 sub len xs. . 1 . . .
var s u64 0 .
for x mid. do
set s. add s. widen u64 x. . . .
end .
return s. .
end .
fn prefix_char input k usize . output u8 .
requires lt k. 11 . .
do
return idx "/api/users/" k. . .
end .
실행 결과
$ lowentc --run middle_sum windows.low [1,2,3,4,5]
middle_sum([1,2,3,4,5]) = 9
arg0 (written) = [1,2,3,4,5]
$ lowentc --run prefix_char windows.low 4
prefix_char(4) = 47
middle_sum 은 양 끝을 뺀 가운데 원소들의 합을 구한다. prefix_char 는 문자열 리터럴을 그대로 색인한다. 리터럴이 slice u8 이기 때문이다.
실제 사례. if 사슬을 표 하나로
if eq k. 0 . do return 47 . end . 같은 사슬로 쓰기 쉽다. 그러면 방출된 C 는 비교와 분기를 글자마다 하나씩 갖는다. 문자열 리터럴을 색인하면 정적 표 하나와 색인 한 번이 된다. HTTP 요청을 읽는 벤치마크에서 이 사슬을 표 조회로 바꾸자 분기 예측 실패가 손으로 쓴 C 와 같은 수준이 되었고 실행 시간이 약 25% 줄었다. 답은 한 비트도 바뀌지 않았다.examples/ch09/table.low
module table .
rem run: prefix_char 4
rem run: prefix_chain 4
rem 문자열 리터럴은 slice u8 이고 그대로 색인된다
fn prefix_char input k usize . output u8 .
requires lt k. 11 . .
do
return idx "/api/users/" k. . .
end .
rem 같은 일을 if 사슬로 쓰면 글자마다 비교와 분기가 하나씩 생긴다
fn prefix_chain input k u64 . output u8 . do
if eq k. 0 . do return 47 . end .
if eq k. 1 . do return 97 . end .
if eq k. 2 . do return 112 . end .
if eq k. 3 . do return 105 . end .
if eq k. 4 . do return 47 . end .
return 0 .
end .
실행 결과
$ lowentc --run prefix_char table.low 4
prefix_char(4) = 47
$ lowentc --run prefix_chain table.low 4
prefix_chain(4) = 47
두 op 의 답은 같다. --emit-c 로 보면 prefix_char 는 문자열 표 하나와 색인 한 번이고, prefix_chain 은 비교와 goto 의 사슬이다. 다만 이 판에서는 prefix_char 의 색인 검사가 남는다 — 분석이 리터럴의 길이 11 을 계약 lt k. 11 . 과 잇지 않기 때문이다.
9.4 줄을 바로 적는다 — 나열 리터럴#
값을 바로 적은 줄이 필요하면 lit 로 나열한다. lit array 5 u32 2 3 5 7 11 . 은 길이, 원소 타입, 원소들을 차례로 적은 array 5 u32 값이고, lit slice u32 4 5 6 . 은 길이를 적지 않는 슬라이스 값이다(원소 수가 길이다). 나열은 제 마침표로 닫힌다. 그래서 문장 끝에 오면 나열의 점과 문장의 점이 잇달아 오고(. .), 부름의 한가운데 오면 점 하나로 닫고 다음 인자가 이어진다.
examples/ch09/listlit.low
module listlit .
rem run: primes
rem run: padded
rem run: inside
fn total input xs slice u32 . output u64 .
do
var s u64 0 .
for x xs. do
set s. add s. widen u64 x. . . .
end .
return s. .
end .
rem 배열 나열 --- 원소 타입, 길이, 원소들, 그리고 나열의 점
export fn primes output u64 .
do
let p lit array 5 u32 2 3 5 7 11 . .
return total p. . .
end .
rem 끝의 _ 는 나머지 칸이 0 이라는 뜻
export fn padded output u64 .
do
let p lit array 6 u32 1 2 _ . .
return add cast u64 len p. . . total p. . . .
end .
rem 슬라이스 나열은 길이를 적지 않는다 --- 원소 수가 길이다. 한가운데서도 점 하나로 닫힌다
export fn inside output u64 .
do
return add total lit slice u32 4 5 6 . . 1 . .
end .
실행 결과
$ lowentc --run primes listlit.low
primes() = 28
$ lowentc --run padded listlit.low
padded() = 9
$ lowentc --run inside listlit.low
inside() = 16
원소가 길이보다 적으면 끝에 _ 를 적어 «나머지 칸은 0» 이라고 말해야 한다. 적지 않으면 E-LIT-COUNT 다 — 칸은 말없이 채워지지 않는다. 원소는 그 타입에 들어가야 한다(E-TYPE-WIDTH).
원소가 모두 상수인 let 나열은 프로그램에 박힌 읽기 전용 바이트다. 그 이름을 mut 자리에 넘기면 거부된다 (E-TYPE-ARGMUT). 고쳐 쓸 줄이 필요하면 var 에 묶는다. var 에 묶은 나열과 실행 중에 계산한 원소가 든 나열은 이 op 의 틀 안에 놓인다 — C 의 지역 배열과 같다. 칸은 set idx buf. i. . v. . 로 쓴다.
examples/ch09/framelist.low
module framelist .
rem run: squares 5
rem run: spread 3 4
rem run: again 3
rem var 에 묶은 나열은 이 op 의 틀 안 칸이다 --- 칸은 set (index …) 로 쓴다
export fn squares input n usize . output u64 .
do
var buf lit array 8 u64 _ . .
var i usize 0 .
while lt i. 8 . do
set idx buf. i. . cast u64 mul i. i. . . .
set i. add i. 1 . .
end .
guard lt n. 8 . else return 0 . .
return idx buf. n. . .
end .
rem 원소를 실행 중에 계산해도 된다 --- 그런 let 나열도 틀 안에 놓인다
export fn spread input a u64 . input b u64 . output u64 .
do
let t lit array 3 u64 a. b. mul a. b. . . .
return add idx t. 2 . cast u64 len t. . . . .
end .
rem 반복 안의 선언은 바퀴마다 새로 채워진다 --- 앞 바퀴의 값을 보지 않는다
export fn again input n u64 . output u64 .
do
var total u64 0 .
var k u64 0 .
while lt k. n. . do
var cell lit array 1 u64 _ . .
set idx cell. 0 . add idx cell. 0 . 10 . .
set total. add total. idx cell. 0 . . .
set k. add k. 1 . .
end .
return total. .
end .
실행 결과
$ lowentc --run squares framelist.low 5
squares(5) = 25
$ lowentc --run spread framelist.low 3 4
spread(3, 4) = 15
$ lowentc --run again framelist.low 3
again(3) = 30
틀 안의 칸은 그 선언이 든 블록이 끝날 때까지 산다. 선언이 다시 돌면(반복의 다음 바퀴) 칸은 새로 채워진다. 그래서 그 칸을 보는 슬라이스를 블록 밖으로 내보내면 — return 하거나, 더 바깥에 선언된 이름이나 매개변수의 칸에 담거나, actor 에게 보내면 — 거부된다(E-LIT-ESCAPE). 칸의 값을 읽어 스칼라로 꺼내는 것은 괜찮다.
examples/ch09/mistake_frameescape.low
module mistake_frameescape .
rem expect: E-LIT-ESCAPE
rem ✘ var 나열을 돌려준다 --- 그 칸은 이 op 이 끝나면 다른 값이 된다
fn hold output slice u8 . do
var buf lit array 4 u8 1 2 3 4 . .
return buf. .
end .
실행 결과
$ lowentc --check mistake_frameescape.low
mistake_frameescape.low:7:0 E-LIT-ESCAPE: this carries a view of a list literal's frame storage — or of bytes an allocator gets back at the end of the block (RFC-0135 D11) — OUT of the block that declared it (a `return`, a name declared further out, a parameter's field or element, or an actor). That storage belongs to the declaring block (RFC-0132 §13.2 ⓐ): after the block — or on the next pass of a loop — the bytes are something else. Copy what you need into storage that outlives the block, or declare the list further out
mistake_frameescape.low:6:0 W-VAR-NEVER-SET: this `var` is never changed — write `let` (a `let` cannot be reassigned, which is what this name already does, and the interval analysis keeps its facts) — RFC-0113 O4
var 배열의 이름 자체를 다른 줄로 바꾸는 set buf … 은 E-ARRAY-SET 이다 — 칸을 하나씩 쓴다. 한 op 의 틀 안 나열은 모두 합쳐 64 KiB(운영체제가 없는 작은 기계는 4 KiB)를 넘을 수 없다(E-FRAME-SIZE). 틀 안 나열을 가진 op 은 재귀할 수 없다(E-FRAME-RECURSIVE) — 깊이만큼 틀이 곱해지는데 그 깊이를 번역할 때 모르기 때문이다. 재귀 안에서 버퍼가 필요하면 할당기에서 받는다(20장). 그리고 부르는 길을 따라 스택에 한꺼번에 살아 있는 나열이 예산(4 MiB)을 넘으면 E-STACK-BUDGET 이다. 태스크로 도는 op(spawn)은 작은 스택에서 돌기 때문에 예산이 64 KiB 다. 스택이 모자라는 일을 번역이 미리 막는다. lowentc --stack-report <파일> 은 진입 op 과 태스크마다 가장 깊은 길의 나열 바이트와 예산을 찍는다.
길이가 긴 배열에서 몇 칸만 값이 다르면 칸을 골라 채운다. do … end 안에 <번호> <값> . 을 적고, 끝의 _ <값> . 이 나머지 칸 모두를 채운다. 모든 칸이 정해져야 하므로 남는 칸이 있는데 _ 가 없으면 E-LIT-COUNT 다. 번호는 상수이고 길이 안이며 한 번씩만 적는다(E-LIT-INDEX). 블록이 나열을 닫으므로 나열 자신의 점은 없다.
examples/ch09/cellfill.low
module cellfill .
rem run: table 3 5
rem run: table 3 1
rem 칸을 골라 채운다 --- 0 번과 5 번만 적고, 나머지 칸은 모두 1
export fn table input a u64 . input i usize . output u64 .
do
let t lit array 6 u64 do 0 100 . 5 mul a. 2 . . _ 1 . end . .
guard lt i. 6 . else return 0 . .
return idx t. i. . .
end .
실행 결과
$ lowentc --run table cellfill.low 3 5
table(3, 5) = 6
$ lowentc --run table cellfill.low 3 1
table(3, 1) = 1
값은 식이어도 되고 적은 차례로 계산된다.
원소가 구조체여도 된다. 다만 바이트 배치가 있는 구조체여야 한다 — 칸이 모두 크기 있는 수인 구조체다.
examples/ch09/structlist.low
module structlist .
rem run: total 7
struct pt do
x u32 .
y u64 .
end .
rem 원소가 구조체인 나열 --- 원소는 값의 사본이고, 칸은 `(field ps 1 y)` 로 쓴다
export fn total input a u64 . output u64 .
do
let p pt lit pt do x 1 . y a. . end . .
var ps lit array 3 pt p. lit pt do x 2 . y 20 . end . _ . .
set field ps. 2 y . 100 .
var s u64 0 .
for q ps. do
set s. add s. add widen u64 field q. x . . field q. y . . . .
end .
return s. .
end .
실행 결과
$ lowentc --run total structlist.low 7
total(7) = 130
- 원소는 준 값의 사본이다.
p를 나열에 넣은 뒤 나열의 칸을 바꿔도p는 그대로다. - 원소의 칸은
set field ps. 2 y . 100 .처럼 쓴다. 나열 이름 · 자리 번호 · 칸 이름 차례다. - 칸 골라 채우기(
do <번호> <값> . _ <값> . end)와lit slice pt …도 같다. 모두_인let나열은 읽기 전용 0 이다. - 슬라이스 ·
owned· 배열 칸이 있는 구조체는 아직 원소가 될 수 없다(E-LIT-UNBUILT). 원소 자리에 수를 적으면E-TYPE-FIELD, 다른 구조체를 적으면E-TYPE-STRUCT다.
한 조각의 내용을 다른 조각에 베끼려면 copy <받는 쪽> <주는 쪽> . 이라 적는다.
examples/ch09/copying.low
module copying .
rem run: shift 9
rem `copy` 는 한 조각의 내용을 다른 조각에 베낀다 --- 겹쳐도 옳다
export fn shift input a u8 . output u64 .
do
var buf lit array 6 u8 1 2 3 4 5 6 . .
var dst lit array 3 u8 _ . .
copy dst. subslice buf. 0 3 . .
copy subslice buf. 1 6 . subslice buf. 0 5 . .
set idx dst. 0 . a. .
var s u64 0 .
for x buf. do
set s. add mul s. 10 . widen u64 x. . . .
end .
return add mul s. 1000 . add mul widen u64 idx dst. 0 . . 100 . widen u64 idx dst. 2 . . . . .
end .
실행 결과
$ lowentc --run shift copying.low 9
shift(9) = 112345903
- 두 조각은 길이가 같아야 한다. 다르다고 번역할 때 알면
E-COPY-LEN, 실행 중에 다르면 멈춘다. - 같은 배열의 겹치는 두 조각도 옳게 베낀다 —
1 2 3 4 5 6이1 1 2 3 4 5가 된다. - 받는 쪽은 쓸 수 있어야 하고(
E-TYPE-MUT),var배열의 이름을 다른 저장소로 바꾸는set buf …대신 이것을 쓴다.
원소가 고정 길이 배열이면 줄의 나열이다. 타입은 slice array 4 u8 이다.
examples/ch09/rows.low
module rows .
rem run: grid 5
rem 줄의 나열 --- `index g 1` 은 그 줄을 보는 슬라이스다(복사 없음)
export fn grid input a u8 . output u64 .
do
var g lit slice array 4 u8 lit array 4 u8 1 2 3 4 . lit array 4 u8 _ . lit array 4 u8 _ . . .
let row mut slice u8 idx g. 1 . .
set idx row. 2 . a. .
set idx idx g. 2 . 3 . 9 .
var s u64 0 .
for r g. do
for x r. do
set s. add s. widen u64 x. . . .
end .
end .
return add mul s. 100 . add cast u64 len g. . . mul cast u64 len idx g. 0 . . . 10 . . . .
end .
실행 결과
$ lowentc --run grid rows.low 5
grid(5) = 2443
len g. .는 줄 수(3)이고idx g. 1 .은 둘째 줄을 보는slice u8이다. 복사가 아니므로row에 쓰면g가 바뀐다.- 칸은
set idx idx g. 2 . 3 . 9 .처럼 줄을 거쳐 쓴다. 한 줄을 통째로 바꾸는set idx g. 1 . …은E-TYPE-SET이다. - 줄 값은 모양이 같아야 한다(
lit array 4 u8 …). 틀 안 나열의 줄은 틀의 바이트를 보므로 돌려주면E-LIT-ESCAPE다.
이미 있는 바이트 위에도 줄 표를 얹을 수 있다. view_array array 4 u8 b. 는 바이트 b 를 4 바이트씩 끊어 보는 slice array 4 u8 다 — 베끼지 않고, b 를 쓸 수 있으면 줄의 칸도 쓸 수 있다. b 의 길이가 4 의 배수가 아니면 멈춘다. 그래서 같은 op 하나가 소스에 박은 표와 읽어 들인 바이트를 함께 받는다.
9.5 배열 안의 배열#
원소가 다시 배열이면 배열 안의 배열이다. array 2 array 3 u8 은 3 칸짜리 줄 2 개가 이어 놓인 표다 — C 의 u8 a[2][3] 과 같은 배치이고, 원소 타입이 맨 뒤라 괄호가 들지 않는다.
examples/ch09/nested.low
module nested .
rem run: table 7
rem 배열 안의 배열 --- 2 줄 3 칸. `idx g. r.` 은 그 줄을 보는 슬라이스다(복사 없음)
struct board do
id u8 .
cells array 2 array 3 u8 .
end .
fn rowsum input g array 2 array 3 u8 . input r usize . output u64 .
requires lt r. 2 . .
do
var s u64 0 .
for x idx g. r. . do
set s. add s. widen u64 x. . . .
end .
return s. .
end .
export fn table input a u8 . output u64 .
do
var b lit board do id 1 . cells lit array 2 array 3 u8 lit array 3 u8 1 2 3 . _ . . end . .
set idx idx field b. cells . 1 . 2 . a. .
return add mul rowsum field b. cells . 0 . 100 . rowsum field b. cells . 1 . . .
end .
실행 결과
$ lowentc --run table nested.low 7
table(7) = 607
- 값은
lit array 2 array 3 u8 <줄>… .이고, 끝의_는 남은 줄을 0 으로 채운다. idx g. r. .은 줄 r 을 보는 슬라이스다(복사 없음). 줄의 나열과 같은 규칙이라 줄의 나열도, 바이트 위의 줄 보기도 같은 입력에 넘길 수 있다. 줄 수는 진입에서 검사한다.- 구조체 칸이면 바이트가 레코드 안에 있다. 구조체를 베끼면 함께 베껴진다.
- 돌려줄 수도 있다 —
output r array 2 array 3 u8 .. 다른 배열 결과처럼 부르는 쪽이 자리를 준다(var g array 2 array 3 u8 f … ., 5 장). - 겹은 몇이든 된다.
array 2 array 3 array 4 u8은 길이 4 인 줄 3 개짜리 판 2 개이고,idx g. i. .는 판 i 를 보는slice array 4 u8, 한 번 더idx하면 줄을 보는slice u8이다. 겹의 깊이가 다른 값은 넘길 수 없다(E-TYPE-ARG). - 크기를 미리 아는 수 표를 셈하는 데에는 다음 절의
matrix도 있다.
9.6 여러 차원 — matrix#
차원이 둘 이상이고 크기를 미리 알면 matrix 로 적는다. matrix 2 3 4 u32 는 u32 가 3 줄 4 칸으로 놓인 표다 — 차원 수(2), 차원마다의 크기, 그리고 원소 타입이다(array 와 같은 차례). 차원 수를 먼저 적으므로 어디까지가 크기인지 갈린다.
examples/ch09/matrix.low
module matrix .
rem run: row_sum 2
rem 3 줄 4 칸의 표 --- 차원 수(2) 다음에 크기를 적는다
fn table output t matrix 2 3 4 u32 .
do
repeat r usize dim t. 0 . do
repeat c usize dim t. 1 . do
set idx t. r. c. . cast u32 add mul r. 10 . c. . . .
end .
end .
return t. .
end .
rem 색인은 차원마다 하나 --- 저마다 제 크기로 검사한다
export fn row_sum input r usize . output u64 .
requires lt r. 3 . .
do
let t matrix 2 3 4 u32 table . .
var s u64 0 .
repeat c usize dim t. 1 . do
set s. add s. widen u64 idx t. r. c. . . . .
end .
return s. .
end .
rem `flat` 은 같은 원소를 한 줄로 본다
export fn total output u64 .
do
let t matrix 2 3 4 u32 table . .
var s u64 0 .
for x flat t. . do
set s. add s. widen u64 x. . . .
end .
return s. .
end .
실행 결과
$ lowentc --run row_sum matrix.low 2
row_sum(2) = 86
idx t. r. c. .는 색인을 차원마다 하나씩 받는다. 색인은 저마다 제 크기로 검사한다 —c가 4 면 줄 안에 칸이 남았든 아니든 멈춘다. 한 줄로 편 배열에r × 4 + c로 적으면 그 잘못이 다음 줄의 칸을 조용히 읽는다.dim t. 0 .은 첫 차원의 크기(3),dim t. 1 .은 둘째(4)다. 번역 시점에 수로 바뀐다.len t.은 거절된다 — 첫 크기인지 원소 수인지 읽는 사람마다 갈리기 때문이다.- 원소는 한 덩어리로 줄 우선으로 놓인다(마지막 색인이 가장 빨리 돈다).
flat t. .은 그 덩어리를slice u32로 본다 — 복사가 아니다. 모든 원소를 도는 것은for x flat t. . do다. - 크기가 다르면 다른 타입이다.
matrix 2 3 4 u32를 받는 op 에matrix 2 4 3 u32이나array 12 u32를 넘기면E-MATRIX-SHAPE다. 통째로 넘기고 돌려받을 때 모양은 번역 시점에 견준다. - matrix 를 내는 op 의 결과가 갈 자리는 둘이다: 같은 모양으로 적은 이름(
let t matrix 2 3 4 u32 table . .), 그 모양을 내는 op 의return.array이름에 묶는 것도E-MATRIX-SHAPE다. - C 는 같은 배치를
uint32_t a[3][4]로 본다.slice u32를 받는externop 에flat t. .을 넘기면 C 는 포인터와 원소 수를 받는다. - 값을 바로 적으려면
lit matrix 2 2 3 u32 1 2 3 4 5 6 .처럼 줄 우선 차례로 나열한다. 모자라면 끝에_를 적어 0 으로 채운다. matrix는let·var, op 의input·output, 구조체의 칸에 적는다. 이 타입은 자리만 정한다 — 행렬의 곱 같은 셈은 직접 적는다.
색인은 차원 수만큼 준다. 색인을 하나 덜 준 idx 가 줄을 뜻하지는 않는다 — 빠뜨린 색인과 구별되지 않기 때문이다.
examples/ch09/mistake_matrixrow.low
module mistake_matrixrow .
rem expect: E-MATRIX-INDEX
fn f output u64 .
do
let m lit matrix 2 2 3 u32 1 2 3 4 5 6 . .
rem ✘ 차원은 둘인데 색인이 하나다 --- 줄 하나만 꺼내는 꼴은 없다
return widen u64 idx m. 1 . . .
end .
실행 결과
$ lowentc --check mistake_matrixrow.low
mistake_matrixrow.low:8:20 E-MATRIX-INDEX: `idx <matrix>. i1 … ik .` takes one index per dimension — as many indexes as the type has dimensions. One row is `row m. i. .`; all the elements as one row are `flat m. .`
줄 하나가 필요하면 row 라고 적는다. 구조체의 칸으로 쓰는 표, 크기를 정해 두지 않은 op 도 같은 예에 담았다.
examples/ch09/matrixmore.low
module matrixmore .
rem run: total
rem 구조체의 칸으로 쓰는 표
struct board do
id u64 .
g matrix 2 3 4 u32 .
end .
rem 어떤 크기든 받는다 --- 크기는 번역 시점의 입력이다
fn sum input comptime r usize . input comptime c usize . input m matrix 2 r c u32 . output u64 .
do
var t u64 0 .
repeat i usize dim m. 0 . do
rem `row` 는 줄 하나를 슬라이스로 본다
for x row m. i. . do
set t. add t. widen u64 x. . . .
end .
end .
return t. .
end .
export fn total output u64 .
do
var b lit board do id 1 . g lit matrix 2 3 4 u32 1 2 3 4 5 6 7 8 9 10 11 12 . . end . .
set idx field b. g . 0 0 . 100 .
let small lit matrix 2 2 2 u32 1 2 3 4 . .
return add widen u64 idx field b. g . 0 0 . . sum 2 2 small. . . .
end .
실행 결과
$ lowentc --run total matrixmore.low
total() = 110
row m. i. .는 그 줄을slice u32로 본다 — 복사가 아니다. 색인은 마지막을 뺀 차원마다 하나다(3 차원이면 둘).m이var이면let w mut slice u32 row m. i. . .로 묶어 그 줄에 쓸 수 있고,let이면 그렇게 묶지 못한다(E-TYPE-MUT).- 칸이
matrix인 구조체는idx field b. g . r. c. .로 읽고 쓴다. 리터럴에서 그 칸의 값은 같은 모양의lit matrix다. - 크기를 정해 두지 않으려면 그 크기를 번역 시점의 입력으로 받는다:
input comptime r u64 .을 적고 타입에matrix 2 r c u32라고 쓴다. 부르는 쪽이 크기를 적고(sum 2 2 small. .), 넘긴 표의 모양이 그 수와 다르면E-MATRIX-SHAPE다. 이름으로 적은 크기는 입력에만 설 수 있다 — 지역과 결과와 칸은 수로 적는다.
9.7 계약이 경계 검사를 지운다#
idx 의 경계 검사는 증명되지 않은 자리에만 남는다. 길이 조건을 requires 로 적으면 검사는 op 진입에서 한 번만 일어나고, 본문의 색인 검사는 사라진다.
examples/ch09/bounds.low
module bounds .
rem run: sum_first [3,4,5,6] 3
fn sum_first input a slice u8 . input n usize . output u64 .
requires le n. len a. . . .
do
var s u64 0 .
var i usize 0 .
while lt i. n. . do
set s. add s. widen u64 idx a. i. . . . .
set i. add i. 1 . .
end .
return s. .
end .
실행 결과
$ lowentc --run sum_first bounds.low [3,4,5,6] 3
sum_first([3,4,5,6], 3) = 12
arg0 (written) = [3,4,5,6]
requires le n. len a. . . . 가 있으면, while lt i n 안에서 i 는 언제나 len a. . 보다 작다. 컴파일러의 구간 분석이 그것을 알아내고 idx a. i. . 의 검사를 지운다. 이 논증의 규칙들은 Coq 로 증명되어 있고, 지운 자리마다 컴파일러가 남긴 산술 근거를 독립 검산기가 다시 따진다(41장).
주의할 것이 하나 있다. 개수 n 에는 le 를 쓰지만, 색인 자체에는 lt 를 쓴다. requires le i. len a. . . . 는 i = len a 를 허락하고, 그것은 한 칸 밖이다.
흔한 오해. len 을 반복 조건에 두면 매번 비싸게 센다
len s. . 는 호출도 메모리 읽기도 아니다. 슬라이스 값 {시작, 길이} 의 길이 칸을 꺼낼 뿐이다. 원소에 써도 길이는 바뀌지 않는다. 길이가 바뀌는 길은 그 이름에 다른 슬라이스를 다시 담는 것 하나다. 오히려 길이를 미리 let m u64 len xs. . . 로 담아 두었다가 xs 를 더 짧은 슬라이스로 바꾸면 m 은 낡은 길이가 되어 범위 밖으로 갈 수 있다. 미리 담는 것은 최적화가 아니라 의미의 선택이다.examples/ch09/stale_len.low
module stale_len .
rem run: fresh [1,2,3,4]
rem trap: stale [1,2,3,4]
rem len 을 조건에 그대로 둔다 --- 매 바퀴 지금의 길이를 읽는다
fn fresh input xs slice u8 . output u64 . do
var s slice u8 xs. .
var t u64 0 .
var i usize 0 .
while lt i. len s. . . do
set t. wrap_add t. widen u64 idx s. i. . . . .
set s. subslice s. 0 2 . .
set i. add i. 1 . .
end .
return t. .
end .
rem 길이를 미리 담았다 --- s 가 짧아져도 m 은 그대로다
fn stale input xs slice u8 . output u64 . do
var s slice u8 xs. .
let m usize len s. . .
var t u64 0 .
var i usize 0 .
while lt i. m. . do
set t. wrap_add t. widen u64 idx s. i. . . . .
set s. subslice s. 0 2 . .
set i. add i. 1 . .
end .
return t. .
end .
실행 결과
$ lowentc --run fresh stale_len.low [1,2,3,4]
fresh([1,2,3,4]) = 3
arg0 (written) = [1,2,3,4]
$ lowentc --run stale stale_len.low [1,2,3,4]
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)
fresh 는 매 바퀴 지금의 길이를 읽으므로 s 가 두 칸으로 줄면 반복도 거기서 멈춘다. stale 은 처음 길이 4 를 m 에 담아 두었으므로 셋째 바퀴에서 두 칸짜리 s 의 2 번 칸을 읽으려다 멈춘다. len 은 매번 지금 값을 읽고, 그것이 안전을 지킨다.
문. 슬라이스 인자를 --run 에 줄 때 [3,4,5] 는 slice u64 에도 쓸 수 있는가?
답. --run 의 대괄호 인자는 바이트의 나열이다. 그래서 이 책의 실행 예제는 슬라이스를 slice u8 로 받는다. slice u64 에 넘기면 바이트 수가 8 의 배수가 아니라서 VM 이 멈춘다. 넓은 원소의 슬라이스는 프로그램 안에서 만들어 넘기는 것이 보통이다.
9.8 흔한 실수#
줄을 다룰 때의 실수는 거의 모두 번호 하나 차이에서 온다. C 라면 남의 메모리를 읽고 조용히 지나갔을 자리에서 Lowent 는 멈춘다.
반례. 반복 조건에 le 를 써서 한 칸을 더 돈다
examples/ch09/mistake_offbyone.low
module mistake_offbyone .
rem trap: total [1,2,3]
fn total input xs slice u8 . output u64 .
do
var t u64 0 .
var i usize 0 .
rem ✘ `le` 라서 i 가 3 일 때도 돈다 --- 길이 3 인 줄의 번호는 0, 1, 2 뿐이다
while le i. len xs. . . do
set t. add t. widen u64 idx xs. i. . . . .
set i. add i. 1 . .
end .
return t. .
end .
실행 결과
$ lowentc --run total mistake_offbyone.low [1,2,3]
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)
길이가 3 인 줄의 번호는 0, 1, 2 다. le i. len xs. . . 는 i 가 3 일 때도 참이라 네 번째 칸을 읽으려다 E-VM-BOUNDS 로 멈춘다. 번호가 0 부터 시작하므로 “개수보다 작다” 가 맞는 조건이다 — while lt i. len xs. . .. 원소를 모두 훑는 일이면 번호를 쓰지 않는 for x xs. do 가 이 실수를 아예 없앤다.
반례. xs[0] 처럼 대괄호로 읽는다
examples/ch09/mistake_cindex.low
module mistake_cindex .
rem expect: E-CHAR
fn first input xs slice u8 . output u8 .
do
rem ✘ 대괄호 색인은 없다 --- `index xs 0` 으로 읽는다
return xs[0] .
end .
실행 결과
$ lowentc --check mistake_cindex.low
7:12 E-CHAR: unexpected character
7:14 E-CHAR: unexpected character
대괄호 색인은 없다. 원소를 읽는 것은 idx xs. 0 ., 쓰는 것은 set idx xs. 0 . v. . 다. 기호 대신 이름을 쓰면 읽기·쓰기·범위 밖 멈춤이 모두 같은 모양의 폼이 되고, 기호를 외울 것이 줄어든다.
반례. 빈 줄일 수 있는데 첫 원소를 읽는다
examples/ch09/mistake_empty.low
module mistake_empty .
rem trap: first []
fn first input xs slice u8 . output u8 .
do
rem ✘ 빈 줄에는 0 번 원소가 없다
return idx xs. 0 . .
end .
실행 결과
$ lowentc --run first mistake_empty.low []
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)
입력이 비었을 가능성을 잊기 쉽다. 번호 0 은 원소가 하나 이상일 때만 있다. 먼저 걸러 내고 읽는다.
examples/ch09/empty_fixed.low
module empty_fixed .
rem run: first_or [] 0
rem run: first_or [7,8] 0
fn first_or input xs slice u8 . input fallback u8 . output u8 .
do
rem 비었으면 여기서 떠난다 --- 아래에서는 원소가 하나 이상이라는 것이 사실이다
guard gt len xs. . 0 . else return fallback. . .
return idx xs. 0 . .
end .
실행 결과
$ lowentc --run first_or empty_fixed.low [] 0
first_or([], 0) = 0
arg0 (written) = []
$ lowentc --run first_or empty_fixed.low [7,8] 0
first_or([7,8], 0) = 7
arg0 (written) = [7,8]
guard 를 지난 아래에서는 “비지 않았다” 가 사실이므로 idx xs. 0 . 이 안전하고, 컴파일러도 그 사실로 경계 검사를 지운다 (7장).
반례. subslice 의 시작과 끝을 뒤집는다
examples/ch09/mistake_subrev.low
module mistake_subrev .
rem trap: tail_len [1,2,3,4]
fn tail_len input xs slice u8 . output usize .
do
rem ✘ 시작(3)이 끝(1)보다 뒤다 --- `subslice s from to` 는 from ≤ to ≤ len 이어야 한다
return len subslice xs. 3 1 . . .
end .
실행 결과
$ lowentc --run tail_len mistake_subrev.low [1,2,3,4]
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: subslice out of bounds (panic)
subslice s. from. to. . 는 from ≤ to ≤ len s 여야 한다. 뒤집으면 빈 창이 되는 것이 아니라 멈춘다 — 잘못 계산한 경계가 조용히 빈 결과로 바뀌면 그 결함은 한참 뒤에야 드러나기 때문이다.
흔한 오해. subslice s. 1 3 . 은 1 번부터 3 번까지 세 칸이다
examples/ch09/subslice_end.low
module subslice_end .
rem run: middle [10,20,30,40,50]
fn middle input xs slice u8 . output usize .
do
rem 1 번부터 3 번 앞까지 --- 1 번과 2 번, 둘이다
return len subslice xs. 1 3 . . .
end .
실행 결과
$ lowentc --run middle subslice_end.low [10,20,30,40,50]
middle([10,20,30,40,50]) = 2
arg0 (written) = [10,20,30,40,50]
끝 번호는 포함하지 않는다. 1 번과 2 번, 두 칸이다. 이렇게 정하면 길이가 to − from 으로 바로 나오고, subslice s. 0 k. . 와 subslice s. k. len s. . . 가 겹치거나 빠지는 칸 없이 줄을 나눈다. 대부분의 언어가 같은 규칙(반열린 구간)을 쓰는 까닭이다.
반례. 문자열 리터럴을 mut slice 로 받아 고친다
examples/ch09/mistake_litwrite.low
module mistake_litwrite .
rem expect: E-TYPE-ARGMUT
rem ✘ 문자열 리터럴을 mut slice 로 받아 고친다 — 리터럴은 프로그램에 박힌 읽기 전용 바이트다
fn poke output u8 . do
let buf mut slice u8 "abc" .
set idx buf. 0 . 65 .
return idx buf. 0 . .
end .
실행 결과
$ lowentc --check mistake_litwrite.low
mistake_litwrite.low:6:0 E-TYPE-ARGMUT: a string LITERAL was bound to a name declared `mut`. A literal is bytes baked into the program, not a place that can be written: the VM used to change them while the native build did not, so the same program gave two answers, and a library op writing there killed the native build. Take bytes you will change from `alloc_bytes` or from the caller's buffer, and copy the literal into them
문자열 리터럴은 프로그램에 박힌 바이트라 고칠 자리가 아니다. 그래서 mut 로 선언한 이름에 묶는 것도, mut 자리에 넘기는 것도 E-TYPE-ARGMUT 로 거절된다. 2026-09-16 까지는 둘 다 통과했고, 실행하면 VM 은 65 를 네이티브는 97 을 돌려줬다 — 같은 프로그램이 두 답을 냈고, 표준 라이브러리가 그 바이트에 쓰면 네이티브는 세그폴트로 죽었다. 고칠 바이트는 alloc_bytes 나 호출자의 버퍼로 받고, 리터럴은 거기에 베껴 넣는다.
9.9 이 장의 문법을 한눈에#
| 모양 | 뜻 | 왜 이렇게 |
|---|---|---|
slice u8 | u8 이 연속으로 놓인 구간(시작 + 길이) | 개수를 따로 들고 다니지 않게 |
mut slice u8 | 원소에 쓸 수 있는 구간 | 쓸 수 있는지가 타입에 보인다 |
input xs array 4 u8 . | 길이가 정확히 4 인 줄을 받는다(입력 자리만) | 길이 다음에 원소 타입 — 진입에서 검사 |
len xs. . | 원소 개수 | 길이 칸을 꺼낼 뿐, 비용이 없다 |
idx xs. i. . | i 번 원소(0 부터) | 범위 밖이면 멈춘다 — 남의 메모리를 읽지 않는다 |
set idx xs. i. . v. . | i 번에 쓰기 | mut slice 와 proc 이어야 한다 |
for x xs. do … end . | 원소를 차례로 | 번호 실수가 생길 자리가 없다 |
subslice xs. from. to. . | from 부터 to 앞까지의 창(베끼지 않음) | 반열린 구간 — 길이는 to − from |
"hello" | slice u8 인 리터럴 — 그대로 색인된다 | 문자열 타입이 따로 없다 |
lit array 3 u32 1 2 3 . | 값을 바로 적은 배열 — 제 점으로 닫힌다 | 길이가 약속이다 — 모자라면 끝에 _ |
lit slice u32 4 5 . | 값을 바로 적은 슬라이스 | 원소 수가 길이다 |
var buf lit array 8 u8 _ . . | 쓸 수 있는 지역 배열 — op 의 틀 안 | 선언 블록 밖으로 못 나간다 |
lit array 8 u8 do 2 5 . _ 0 . end . | 칸을 골라 채운 배열 | 모든 칸이 정해져야 한다 — 나머지는 _ |
matrix 2 3 4 u32 | 3 줄 4 칸의 u32 — 차원 수 · 크기 · 원소 타입 차례 | 모양이 타입에 있다 — 크기가 다르면 다른 타입 |
idx m. r. c. . · dim m. 0 . · flat m. . | 칸 하나 · 한 차원의 크기 · 한 줄로 보기 | 색인마다 제 크기로 검사한다 |
requires le n. len xs. . . . | 길이 조건을 계약으로 | 본문의 경계 검사가 지워진다 |
표 9.1 — 줄의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
len 은 길이 칸을 꺼내고, idx 는 범위 밖이면 멈추며, for 는 원소를 훑는다. array n t 은 길이 다음에 원소 타입을 적고 입력 자리에서 쓰며, 값을 바로 적은 줄은 lit array·lit slice 로 나열한다. var 에 묶은 나열은 틀 안의 지역 배열이라 그 블록 안에서 쓸 수 있고, 남의 줄에 쓰려면 mut slice 와 proc 이 필요하다. subslice 는 베끼지 않고 창을 좁힌다. 길이 조건을 계약으로 적으면 본문의 경계 검사가 지워지고, 문자열 리터럴은 그대로 색인되는 표다.