Lowent 매뉴얼←↑→

12 빌리기 — ref 와 mut_ref

먼저 알아야 할 것

9장 줄 · mut slice 여야 원소에 쓸 수 있다
10장 묶음 · field 는 읽는 자리이자 쓰는 자리다

돌아보기

9장에서 mut 가 없는 슬라이스의 원소에 쓰면 무엇으로 거절되었는가? 그리고 let 으로 지은 이름의 불변과 슬라이스 원소의 불변은 어떻게 다른가?

답. E-TYPE-MUT 로 거절되었다. let 은 이름에 다른 값을 다시 넣는 일을 막고, 원소에 쓸 수 있는지는 슬라이스의 타입(slice 인가 mut slice 인가)이 정한다. 이 장은 슬라이스가 아닌 값 하나를 빌리는 참조와, 빌림이 겹칠 때의 규칙을 다룬다.

이 장의 필요성과 맥락

큰 값을 넘길 때마다 베끼면 느리고, 포인터로 넘기면 누가 언제 그 값을 바꾸는지 알 수 없다. Lowent 는 그 사이에 빌리기를 둔다. 읽기로 빌리는 쪽은 여럿일 수 있고, 쓰기로 빌리는 쪽은 혼자여야 한다. 이 규칙이 쓰레기 수거기 없이 메모리를 안전하게 하는 바탕이며, 동시성(제7부)에서 데이터 경합이 없다는 증명도 이 규칙 위에 선다. 메모리(제5부)로 가기 전에 빌리기의 모양부터 익힌다.

이 장이 끝나면

ref t 와 mut_ref t 로 값을 빌리고 deref 로 읽는 법을 익힌다. 읽기 빌림으로는 쓸 수 없고, 쓰기 권한은 한 방향으로만 좁아진다는 것을 알게 된다. 한 값에 대한 빌림이 “읽기 여럿 또는 쓰기 하나” 여야 한다는 배타 규칙과, 지역을 가리키는 참조가 op 밖으로 나갈 수 없다는 규칙을 보게 된다. mut ref slice 가 왜 거절되는지도 이해하게 된다.

이 장에서 답할 질문

  1. 널 참조는 어떻게 만드는가?

12.1 두 가지 빌림#

참조는 다른 곳에 있는 값을 가리킨다. 두 갈래뿐이고, 이름이 곧 권한이다.

도서관 책에 빗대면 쉽다. 여럿이 한꺼번에 열람(읽기)할 수는 있지만, 책에 고쳐 적으려면(쓰기) 혼자 빌려 가야 하고, 그동안은 아무도 열람하지 못한다. 고치는 동안 누가 읽으면 반쯤 고친 내용을 보게 되기 때문이다.

examples/ch12/borrow.low

module borrow .
rem run: use_sum 7
rem run: use_bump 7

struct big do
  a u64 .
  b u64 .
  c u64 .
  d u64 .
end

fn total input v ref big . output u64 .
  requires le (field v a) 1000 .
  requires le (field v b) 1000 .
  requires le (field v c) 1000 .
  requires le (field v d) 1000 .
do
  return add (add (field v a) (field v b)) (add (field v c) (field v d)) .
end

fn use_sum input x u64 . output u64 .
  requires le x 1000 .
do
  let v big be make big do a x . b 2 . c 3 . d 4 . end .
  return add (total (ref v)) (total (ref v)) .
end

proc bump input p mut_ref u64 . output u64 .
do
  set p (add (deref p) 1) .
  return deref p .
end

proc use_bump input start u64 . output u64 .
  requires lt start 1000 .
do
  var n u64 be start .
  let r u64 be bump (mut_ref n) .
  return add n r .
end

실행 결과

$ lowentc --run use_sum borrow.low 7
use_sum(7) = 32
$ lowentc --run use_bump borrow.low 7
use_bump(7) = 16

빌리는 쪽이 ref x 나 mut_ref n 을 부르는 자리에 적는다는 점이 중요하다. 호출을 읽는 사람은 그 자리만 보고도 이 호출이 n 을 바꿀 수 있다는 것을 안다.

문. 널 참조는 어떻게 만드는가?

답. 만들 수 없다. 참조는 언제나 살아 있는 값을 가리킨다. “가리키는 것이 없음” 을 나타내야 하면 option 을 쓴다(11장). 그러면 꺼내기 전에 확인하는 코드가 소스에 드러나고, 확인하지 않고 꺼내면 멈춘다.

12.2 쓰기 권한은 한 방향으로만 좁아진다#

읽기 빌림으로 쓰려 하면 거절된다.

examples/ch12/ref_write.low

module ref_write .
rem expect: E-TYPE-REF

proc reset input p ref u64 . output u64 .
do
  set p 0 .
  return 0 .
end

실행 결과

$ lowentc --check ref_write.low
ref_write.low:6:0 E-TYPE-REF: write through a shared ref (declare mut_ref)

반대 방향도 막힌다. 읽기로만 받은 값을 mut·mut_ref·owned 를 받는 자리에 넘기면 E-TYPE-ARGMUT 으로 거절된다. 쓸 수 있는 것을 읽기로 건네는 것은 되지만, 읽기만 되는 것을 쓸 수 있는 자리로 건넬 수는 없다. 어떤 값을 읽기로 받은 쪽은 자기가 보는 동안 그 값이 바뀌지 않는다고 믿을 수 있어야 하고, 그 믿음은 아무도 몰래 쓰기 권한을 얻지 못할 때에만 선다.

12.3 읽기 여럿 또는 쓰기 하나#

같은 값에 대한 빌림이 겹칠 때가 문제다. 쓰기 빌림 둘이 겹치면 거절된다.

examples/ch12/excl.low

module excl .
rem expect: E-EXCL

proc set_both input a mut_ref u64 . input b mut_ref u64 . output void .
do
  set a 1 .
  set b 2 .
end

proc confused output u64 .
do
  var n u64 be 0 .
  set_both (mut_ref n) (mut_ref n) .
  return n .
end

실행 결과

$ lowentc --check excl.low
13:0 E-EXCL: exclusivity violation: overlapping borrow/owner access (readers-XOR-writer)

set_both 의 본문은 a 와 b 가 서로 다른 값이라고 믿는다. 같은 n 을 두 번 빌려주면 그 믿음이 깨지고, 남는 값이 1 인지 2 인지는 쓰는 차례에 달린다. 읽기와 쓰기가 겹쳐도 거절된다.

examples/ch12/stale.low

module stale .
rem expect: E-EXCL

proc look_then_write output u64 .
do
  var n u64 be 0 .
  let r ref u64 be ref n .
  set n 5 .
  return deref r .
end

실행 결과

$ lowentc --check stale.low
8:0 E-EXCL: exclusivity violation: overlapping borrow/owner access (readers-XOR-writer)

r 로 n 을 읽기로 빌린 채 n 에 5 를 쓰면, r 을 읽는 쪽은 자기가 본 값이 언제 바뀌었는지 알 수 없다.

같은 값 n 에 대한 빌림을 시간 순으로 늘어놓으면 규칙이 한눈에 보인다.

 시간 →          ①        ②        ③        ④        ⑤
 ref r1 n     ├─────────────────┤                          읽기 둘은 겹쳐도 된다
 ref r2 n              ├─────────────────┤
 mut_ref w n                                   ├────────┤  쓰기는 혼자일 때만
 ──────────────────────────────────────────────────────────────
 ✘ ref r n    ├──────────────────────────┤
   set n 5                   ●                             읽는 동안 값이 바뀐다  → 거절
 ✘ mut_ref a n ├──────────────┤
   mut_ref b n        ├──────────────┤                     쓰기 둘이 겹친다      → 거절

이 두 모양 — 다른 흐름이 동시에 고치는 것, 읽는 중에 값이 바뀌는 것 — 은 소스를 읽어서는 찾기 어려운 결함이라 규칙으로 없앤다.

흔한 오해. 배타 규칙은 멀티스레드 프로그램에만 필요하다

위의 두 예제에는 스레드가 하나도 없다. 한 흐름 안에서도 두 이름이 같은 값을 가리키면 한쪽의 쓰기가 다른 쪽의 믿음을 깬다. C 의 memcpy 가 겹치는 버퍼에서 미정의 동작인 것도 같은 이유다. 다만 이 규칙이 한 흐름 안에서 지켜지면 여러 흐름으로 나누었을 때 데이터 경합이 없다는 것까지 따라 나오고, 그것이 제7부의 증명이 기대는 자리다(45장).

12.4 빌림은 빌려준 값보다 오래 살 수 없다#

op 안의 지역을 가리키는 참조를 op 밖으로 돌려주면 거절된다.

examples/ch12/escape.low

module escape .
rem expect: E-ESCAPE

fn leak output ref u32 .
do
  let here u32 be 42 .
  return ref here .
end

실행 결과

$ lowentc --check escape.low
7:0 E-ESCAPE: reference to a local escapes the op (dangling)

만약 이 코드가 번역된다면, 돌려받은 참조는 이미 사라진 값을 가리킨다. 그 참조를 읽으면 무슨 값이 나올지 아무도 모른다. Lowent 는 이것을 실행해 보고 아는 것이 아니라 번역할 때 안다. 빌림은 그것을 연 블록 밖으로 나갈 수 없고(E-BORROW-ESCAPE), 빌려준 값이 옮겨졌는데 빌림이 남아 있어도 거절된다(E-EXCL-MOVED).

값을 op 밖으로 내보내야 한다면 참조가 아니라 값을 돌려주거나, 호출자가 넘긴 저장소에 담는다. 호출자보다 오래 사는 저장소가 필요하면 영역을 쓴다(18장).

12.5 mut ref slice 는 없다#

슬라이스에는 mut ref 를 붙이지 않는다.

examples/ch12/mref_slice.low

module mref_slice .
rem expect: E-MREF-SLICE

proc shrink input p mut ref slice u8 . output u64 .
do
  return 0 .
end

실행 결과

$ lowentc --check mref_slice.low
mref_slice.low:4:0 E-MREF-SLICE: `mut ref slice` gives nothing that `mut slice` does not — the elements are already writable through a plain `mut slice` (the descriptor is copied, the bytes are shared). The ONLY thing it adds is replacing the caller's descriptor, which silently changes the length behind the caller's back. Say the new slice with a RETURN VALUE instead (SPEC-004 §4.4a: the windows through which someone else can change your local are listed, and this one is closed)

mut slice 를 넘기면 원소는 이미 쓸 수 있다. 슬라이스 값 {시작, 길이} 는 베껴 가지만 가리키는 바이트는 같기 때문이다. 그러니 mut ref slice 가 더 주는 능력은 하나뿐이다 — 호출자 쪽 슬라이스의 길이와 시작을 몰래 바꾸는 것. 그러면 호출자의 반복문은 자기 코드 어디에도 대입이 없는데 길이가 달라진다. 새 슬라이스는 돌려주면 된다.

examples/ch12/head.low

module head .
rem run: first_two [9,8,7,6]

fn take_front input p slice u8 . input n u64 . output slice u8 .
  requires le n (len p) .
do
  return subslice p 0 n .
end

fn first_two input xs slice u8 . output u64 .
  requires ge (len xs) 2 .
do
  let front slice u8 be take_front xs 2 .
  return len front .
end

실행 결과

$ lowentc --run first_two head.low [9,8,7,6]
first_two([9,8,7,6]) = 2
  arg0 (written) = [9,8,7,6]

take_front 는 앞쪽 n 개를 가리키는 새 슬라이스를 돌려준다. 길이가 달라졌다는 사실이 반환값과 let front 에 드러난다.

12.6 흔한 실수#

반례. 부르는 자리에 mut_ref 를 빠뜨린다

examples/ch12/mistake_nomutref.low

module mistake_nomutref .
rem expect: E-TYPE-ARG

proc bump input p mut_ref u64 . output u64 .
do
  set p (add (deref p) 1) .
  return deref p .
end

proc use_bump input start u64 . output u64 .
  requires lt start 1000 .
do
  var n u64 be start .
  rem ✘ 부르는 자리에 `mut_ref` 를 빠뜨렸다 --- 참조가 아니라 수 7 이 넘어간다
  let r u64 be bump n .
  return add n r .
end

실행 결과

$ lowentc --check mistake_nomutref.low
mistake_nomutref.low:15:0 E-TYPE-ARG: this op takes a BORROW here (`ref` / `mut_ref`), and a plain value was passed. Take the borrow at the call — `mut_ref <name>` — so the reader sees where the callee may write. It used to pass `--check` and stop at run time with `E-VM-TYPE: deref needs a reference`

bump 는 “수를 가리키는 참조” 를 받기로 했는데 bump n 은 수 7 을 그대로 넘긴다. C++ 의 참조 매개변수는 부르는 자리에 아무 표시가 없어도 되지만, Lowent 는 호출만 보고도 무엇이 바뀔지 알게 하려고 mut_ref n 을 적게 한다. 이 판의 도구는 이 빠뜨림을 번역에서 거절하지 않고, 실행하면 deref 가 참조가 아닌 것을 만나 멈춘다(개발 저장소에 결함으로 적어 두었다). 멈춤의 메시지가 “deref needs a reference” 이면 부르는 자리에서 mut_ref 나 ref 를 찾는다.

반례. 참조를 deref 없이 수처럼 쓴다

examples/ch12/mistake_noderef.low

module mistake_noderef .
rem expect: E-TYPE-REFVAL

fn twice input p ref u64 . output u64 .
do
  rem ✘ `p` 는 수가 아니라 수를 가리키는 참조다 --- `deref p` 로 읽어야 한다
  return add p p .
end

fn double input x u64 . output u64 .
  requires le x 1000 .
do
  let n u64 be x .
  return twice (ref n) .
end

실행 결과

$ lowentc --check mistake_noderef.low
mistake_noderef.low:7:0 E-TYPE-REFVAL: a BORROW is being used where a number is expected. `ref t` / `mut_ref t` names a place, not the value in it — read it with `deref <name>` (and write through it with `set <name> …`). It used to pass `--check` and stop at run time with `E-VM-TYPE`, which blamed the arithmetic instead of the missing read

참조는 값이 있는 곳이지 값이 아니다. 읽을 때마다 deref p 라고 적어서 “여기서 빌린 값을 읽는다” 를 드러낸다. C 에서 *p 를 빠뜨리면 주소끼리 더하는 엉뚱한 계산이 되지만, Lowent 는 주소 계산을 허용하지 않으므로 잘못된 값이 조용히 나오지는 않는다. 다만 이 판의 도구는 번역에서 알리지 않고 실행 중에 멈춘다(결함으로 적어 두었다). 고친 줄은 add (deref p) (deref p) 다.

반례. let 으로 지은 이름을 쓰기로 빌려준다

examples/ch12/mistake_letmutref.low

module mistake_letmutref .
rem expect: E-TYPE-ARGMUT

proc bump input p mut_ref u64 . output void .
do
  set p (add (deref p) 1) .
end

proc use_bump input start u64 . output u64 .
  requires lt start 1000 .
do
  rem ✘ `let` 으로 지은 이름인데 쓰기로 빌려준다
  let n u64 be start .
  bump (mut_ref n) .
  return n .
end

실행 결과

$ lowentc --check mistake_letmutref.low
mistake_letmutref.low:14:0 E-TYPE-ARGMUT: a WRITE borrow (`mut_ref`) was taken of a name that cannot be written: a `let` binding (or a shared input). `let` says the value does not change (§6.5.1) — if a borrow could change it, the reader who checked that name once would be wrong, and the borrow makes the change invisible at the call site. Bind it with `var`, or take a read borrow (`ref`)

let 은 “이 이름의 값은 바뀌지 않는다” 는 약속이다. 그런 이름을 mut_ref 로 빌려주면 약속이 깨지므로 E-TYPE-ARGMUT 로 거절한다. 빌림은 부르는 자리에서 보이지 않게 값을 바꾸기 때문에, 한 번 확인하면 된다는 let 의 값이 사라진다. 바뀌어야 하는 값은 처음부터 var 로 짓는다. 그러면 읽는 사람이 “이 값은 어딘가에서 바뀐다” 를 선언에서 안다.

흔한 오해. input 으로 넘긴 변수는 불린 op 이 바꿀 수 있다

examples/ch12/copyparam.low

module copyparam .
rem run: use_inc 7

proc inc input x u64 . output u64 .
  requires lt x 1000 .
do
  rem `x` 는 호출자가 넘긴 값의 사본이다 --- 여기서 무엇을 해도 호출자의 변수에 닿지 않는다
  var y u64 be x .
  set y (add y 1) .
  return y .
end

proc use_inc input start u64 . output u64 .
  requires lt start 1000 .
do
  var n u64 be start .
  let r u64 be inc n .
  rem `n` 은 그대로 7 이다 --- 바꾸게 하려면 `mut_ref n` 으로 빌려준다
  return n .
end

실행 결과

$ lowentc --run use_inc copyparam.low 7
use_inc(7) = 7

맨 값 매개변수(input x u64)는 호출자의 값을 베껴서 받는다. inc 가 안에서 무엇을 하든 use_inc 의 n 은 7 그대로다. 어떤 언어는 큰 값을 몰래 참조로 넘겨 이 경계가 흐리지만, Lowent 에서 호출자의 값을 바꾸는 길은 mut_ref 하나이고 그 표시는 부르는 자리에 남는다.

흔한 오해. 한 op 안에서는 같은 값을 mut_ref 로 한 번만 빌릴 수 있다

examples/ch12/seqborrow.low

module seqborrow .
rem run: use_twice 7

proc bump input p mut_ref u64 . output void .
  requires lt (deref p) 1000 .
do
  set p (add (deref p) 1) .
end

proc use_twice input start u64 . output u64 .
  requires lt start 900 .
do
  var n u64 be start .
  rem 첫 빌림은 이 호출이 끝날 때 돌아온다 --- 그래서 다음 줄에서 또 빌릴 수 있다
  bump (mut_ref n) .
  bump (mut_ref n) .
  return n .
end

실행 결과

$ lowentc --run use_twice seqborrow.low 7
use_twice(7) = 9

배타 규칙이 막는 것은 동시에 겹친 빌림이다. 호출 하나에 넘긴 mut_ref n 은 그 호출이 끝나면 돌아오므로 다음 줄에서 또 빌릴 수 있다. 거절되는 것은 set_both (mut_ref n) (mut_ref n) 처럼 한 호출 안에 두 빌림이 함께 살아 있는 모양이다.

12.7 이 장의 문법 한눈에#

모양뜻왜 이렇게
input v ref big .읽기로 빌려 받는다큰 값을 베끼지 않고, 바꾸지 않는다고 약속한다
input p mut_ref u64 .쓰기로 빌려 받는다호출자의 값을 바꿀 수 있는 유일한 길
total (ref v) · bump (mut_ref n)부르는 자리에서 빌려준다호출만 보고도 무엇이 바뀔지 안다
deref p참조가 가리키는 값을 읽는다값과 값이 있는 곳을 섞지 않는다
set p <값> .참조가 가리키는 곳에 쓴다(mut_ref)읽기 빌림으로는 E-TYPE-REF
field v a참조를 거쳐 칸을 읽는다구조체에 대한 참조도 같은 철자
같은 값에 mut_ref 둘 · ref 와 쓰기겹친 빌림 — 거절(E-EXCL)읽기 여럿 또는 쓰기 하나
같은 저장소를 쓰기 자리와 읽기 자리에피호출자가 inplace <쓰기> <읽기> . 로 허락했고 같은 구간일 때만 — 아니면 E-EXCL-INPLACE원소마다 읽고 쓰는 몸은 같은 구간에서만 옳다
inplace o a 를 적은 op 의 몸a 를 다 읽은 뒤에 o 에 쓰거나, 한 첨자로 원소마다 읽고 쓴다 — 아니면 E-INPLACE-UNPROVEN선언이 참인지 처리기가 몸에서 본다
invalidates <입력> 을 밝힌 op 을 부른 뒤 그 저장소의 옛 뷰를 쓴다거절(E-VIEW-INVALIDATED) — 뷰를 다시 받는다반환·성장·되감기 뒤의 뷰는 남의 자리를 본다
return ref here .지역을 가리키는 참조를 돌려준다 — 거절사라진 값을 가리키지 않게
mut ref slice없다 — 거절(E-MREF-SLICE)길이를 몰래 바꾸지 않게 — 새 슬라이스는 돌려준다

표 12.1 — 빌리기의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나

복습 정리

ref t 는 읽기, mut_ref t 는 쓰기 빌림이고, 빌리는 쪽이 부르는 자리에 ref x·mut_ref n 을 적는다. 참조가 가리키는 값은 deref 로 읽는다. 쓰기 권한은 한 방향으로만 좁아진다. 한 값에 대한 빌림은 읽기 여럿 또는 쓰기 하나이며, 빌림은 빌려준 값보다 오래 살 수 없다. mut ref slice 는 거절되고, 새 슬라이스는 돌려준다.