9 줄 — 배열과 슬라이스
먼저 알아야 할 것
for 는 슬라이스를 훑고, guard 는 조건을 사실로 바꾼다proc 이어야 한다돌아보기
7장의 head_or_zero 에서 index data 0 이 안전했던 까닭은 무엇인가?
답. 바로 앞의 guard ge (len data) 1 . else return 0 . 가 슬라이스가 비지 않았다는 사실을 그 아래 코드에 건넸기 때문이다. guard 를 지난 코드는 그 조건이 참인 세계에서만 산다. 이 장은 그 슬라이스가 무엇이고, 경계 검사가 언제 남고 언제 사라지는지를 다룬다.
이 장의 필요성과 맥락
이 장이 끝나면
array n t 와 slice t 의 차이, len·index·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 index data 0 .
end
fn last4 input xs array 4 u8 . output u8 .
do
return index xs 3 .
end
fn total input xs slice u8 . output u64 .
do
var acc u64 be 0 .
for x xs do
set acc (add acc (widen u64 x)) .
end
return acc .
end
fn at input xs slice u8 . input i u64 . output u8 .
do
return index 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는 원소 개수,index 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 는 길이를 먼저 적는다. 차례를 뒤집으면 거절된다.
examples/ch09/array_order.low
module array_order .
rem expect: E-TYPE-ARRAY
fn last input xs array u8 4 . output u8 .
do
return index xs 3 .
end
실행 결과
$ lowentc --check array_order.low
array_order.low:4:0 E-TYPE-ARRAY: `array` is written `array <count> <type>` — the length first, as a literal (`array 4 u64`). The other order used to be read as a plain slice and the length was dropped silently
진단에 따르면 옛 도구는 array u8 4 를 그냥 슬라이스로 읽고 길이를 조용히 버렸다. 적은 약속이 사라지는 자리였다. 지금 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 (index xs 0) 10 .
set (index xs 1) 20 .
var s u64 be 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 (index 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 be subslice xs 1 (sub (len xs) 1) .
var s u64 be 0 .
for x mid do
set s (add s (widen u64 x)) .
end
return s .
end
fn prefix_char input k u64 . output u8 .
requires lt k 11 .
do
return index "/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 u64 . output u8 .
requires lt k 11 .
do
return index "/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 계약이 경계 검사를 지운다#
index 의 경계 검사는 증명되지 않은 자리에만 남는다. 길이 조건을 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 u64 . output u64 .
requires le n (len a) .
do
var s u64 be 0 .
var i u64 be 0 .
while lt i n . do
set s (add s (widen u64 (index 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 보다 작다. 컴파일러의 구간 분석이 그것을 알아내고 index a i 의 검사를 지운다. 이 논증의 규칙들은 Coq 로 증명되어 있고, 지운 자리마다 컴파일러가 남긴 산술 근거를 독립 검산기가 다시 따진다(41장).
주의할 것이 하나 있다. 개수 n 에는 le 를 쓰지만, 색인 자체에는 lt 를 쓴다. requires le i (len a) . 는 i = len a 를 허락하고, 그것은 한 칸 밖이다.
흔한 오해. len 을 반복 조건에 두면 매번 비싸게 센다
len s 는 호출도 메모리 읽기도 아니다. 슬라이스 값 {시작, 길이} 의 길이 칸을 꺼낼 뿐이다. 원소에 써도 길이는 바뀌지 않는다. 길이가 바뀌는 길은 그 이름에 다른 슬라이스를 다시 담는 것 하나다. 오히려 길이를 미리 let m u64 be 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 be xs .
var t u64 be 0 .
var i u64 be 0 .
while lt i (len s) . do
set t (wrap_add t (widen u64 (index 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 be xs .
let m u64 be len s .
var t u64 be 0 .
var i u64 be 0 .
while lt i m . do
set t (wrap_add t (widen u64 (index 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.5 흔한 실수#
줄을 다룰 때의 실수는 거의 모두 번호 하나 차이에서 온다. 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 be 0 .
var i u64 be 0 .
rem ✘ `le` 라서 i 가 3 일 때도 돈다 --- 길이 3 인 줄의 번호는 0, 1, 2 뿐이다
while le i (len xs) . do
set t (add t (widen u64 (index 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
7:12 E-FORM-UNEXPECTED: unexpected token in form
대괄호 색인은 없다. 원소를 읽는 것은 index xs 0, 쓰는 것은 set (index 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 index 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 index 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 를 지난 아래에서는 “비지 않았다” 가 사실이므로 index 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 u64 .
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 u64 .
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 . be "abc" .
set (index buf 0) 65 .
return index 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.6 이 장의 문법 한눈에#
| 모양 | 뜻 | 왜 이렇게 |
|---|---|---|
slice u8 | u8 이 연속으로 놓인 구간(시작 + 길이) | 개수를 따로 들고 다니지 않게 |
mut slice u8 | 원소에 쓸 수 있는 구간 | 쓸 수 있는지가 타입에 보인다 |
input xs array 4 u8 . | 길이가 정확히 4 인 줄을 받는다(입력 자리만) | 길이를 먼저 적는다 — 진입에서 검사 |
len xs | 원소 개수 | 길이 칸을 꺼낼 뿐, 비용이 없다 |
index xs i | i 번 원소(0 부터) | 범위 밖이면 멈춘다 — 남의 메모리를 읽지 않는다 |
set (index xs i) v . | i 번에 쓰기 | mut slice 와 proc 이어야 한다 |
for x xs do … end | 원소를 차례로 | 번호 실수가 생길 자리가 없다 |
subslice xs from to | from 부터 to 앞까지의 창(베끼지 않음) | 반열린 구간 — 길이는 to − from |
"hello" | slice u8 인 리터럴 — 그대로 색인된다 | 문자열 타입이 따로 없다 |
requires le n (len xs) . | 길이 조건을 계약으로 | 본문의 경계 검사가 지워진다 |
표 9.1 — 줄의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
len 은 길이 칸을 꺼내고, index 는 범위 밖이면 멈추며, for 는 원소를 훑는다. array n t 는 길이를 먼저 적고 입력 자리에서 쓴다. 원소에 쓰려면 mut slice 와 proc 이 필요하다. subslice 는 베끼지 않고 창을 좁힌다. 길이 조건을 계약으로 적으면 본문의 경계 검사가 지워지고, 문자열 리터럴은 그대로 색인되는 표다.