Lowent 매뉴얼←↑→

9 줄 — 배열과 슬라이스

먼저 알아야 할 것

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

돌아보기

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

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

이 장의 필요성과 맥락

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

이 장이 끝나면

array n t 과 slice t 의 차이, len·idx·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 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)

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

색인에 따라 상수를 돌려주는 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 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

한 조각의 내용을 다른 조각에 베끼려면 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

원소가 고정 길이 배열이면 줄의 나열이다. 타입은 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

이미 있는 바이트 위에도 줄 표를 얹을 수 있다. 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

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 가 줄을 뜻하지는 않는다 — 빠뜨린 색인과 구별되지 않기 때문이다.

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

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 u8u8 이 연속으로 놓인 구간(시작 + 길이)개수를 따로 들고 다니지 않게
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 u323 줄 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 는 베끼지 않고 창을 좁힌다. 길이 조건을 계약으로 적으면 본문의 경계 검사가 지워지고, 문자열 리터럴은 그대로 색인되는 표다.