Lowent 매뉴얼←↑→

9 줄 — 배열과 슬라이스

먼저 알아야 할 것

7장 흐름 · for 는 슬라이스를 훑고, guard 는 조건을 사실로 바꾼다
5장 op · 호출자에게 보이는 쓰기는 proc 이어야 한다

돌아보기

7장의 head_or_zero 에서 index data 0 이 안전했던 까닭은 무엇인가?

답. 바로 앞의 guard ge (len data) 1 . else return 0 . 가 슬라이스가 비지 않았다는 사실을 그 아래 코드에 건넸기 때문이다. guard 를 지난 코드는 그 조건이 참인 세계에서만 산다. 이 장은 그 슬라이스가 무엇이고, 경계 검사가 언제 남고 언제 사라지는지를 다룬다.

이 장의 필요성과 맥락

C 에서 가장 값비싼 결함은 포인터가 가리키는 곳에 몇 개가 있는지를 사람이 따로 기억하다가 틀리는 데서 나온다. 버퍼 넘침이 그것이다. Lowent 에는 포인터가 없고, 여러 값을 가리키는 일은 길이를 함께 가진 슬라이스가 맡는다. 제3부의 첫 장이 슬라이스인 것은 문자열·버퍼·파일 내용 같은 거의 모든 데이터가 슬라이스로 오가기 때문이다.

이 장이 끝나면

array n t 와 slice t 의 차이, len·index·for 로 읽는 법, 범위 밖 접근이 멈춘다는 것을 알게 된다. mut slice 로 받아야 원소에 쓸 수 있다는 규칙과 subslice 로 창을 좁히는 법을 익힌다. 계약이 본문의 경계 검사를 지우는 원리와, 문자열 리터럴이 그대로 색인되는 표라는 것도 보게 된다.

이 장에서 답할 질문

  1. 문자열은 따로 된 타입이 없는가?
  2. 슬라이스 인자를 --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)

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 사슬을 표 하나로

색인에 따라 상수를 돌려주는 op 을 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 u8u8 이 연속으로 놓인 구간(시작 + 길이)개수를 따로 들고 다니지 않게
mut slice u8원소에 쓸 수 있는 구간쓸 수 있는지가 타입에 보인다
input xs array 4 u8 .길이가 정확히 4 인 줄을 받는다(입력 자리만)길이를 먼저 적는다 — 진입에서 검사
len xs원소 개수길이 칸을 꺼낼 뿐, 비용이 없다
index xs ii 번 원소(0 부터)범위 밖이면 멈춘다 — 남의 메모리를 읽지 않는다
set (index xs i) v .i 번에 쓰기mut slice 와 proc 이어야 한다
for x xs do … end원소를 차례로번호 실수가 생길 자리가 없다
subslice xs from tofrom 부터 to 앞까지의 창(베끼지 않음)반열린 구간 — 길이는 to − from
"hello"slice u8 인 리터럴 — 그대로 색인된다문자열 타입이 따로 없다
requires le n (len xs) .길이 조건을 계약으로본문의 경계 검사가 지워진다

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

복습 정리

슬라이스는 시작과 길이를 함께 갖는다. len 은 길이 칸을 꺼내고, index 는 범위 밖이면 멈추며, for 는 원소를 훑는다. array n t 는 길이를 먼저 적고 입력 자리에서 쓴다. 원소에 쓰려면 mut slice 와 proc 이 필요하다. subslice 는 베끼지 않고 창을 좁힌다. 길이 조건을 계약으로 적으면 본문의 경계 검사가 지워지고, 문자열 리터럴은 그대로 색인되는 표다.