6 지역 — let 과 var
먼저 알아야 할 것
돌아보기
5장의 running_total 은 var 로 합을 쌓는데도 fn 이었다. 왜 허락되었는가?
답. 순수함이 관측적이기 때문이다. total 은 op 안에서만 사는 지역이므로 그 변이를 호출자가 볼 수 없다. 호출자에게 보이는 쓰기(mut 매개변수)만 효과로 친다. 이 장은 그 지역을 짓는 두 낱말과, 지역이 사는 범위를 다룬다.
이 장의 필요성과 맥락
이 장이 끝나면
let 은 불변, var 는 가변이고 set 으로만 다시 넣는다는 것을 알게 된다. 타입을 적는 경우와 생략하는 경우, 값 없는 선언이 거절되는 이유(특히 .5 의 함정)를 익힌다. 지역이 블록 안에서만 살고, 살아 있는 바깥 이름을 안쪽에서 다시 쓸 수 없다는 것, 그리고 if 가 값을 내지 않는 문장이라는 것도 보게 된다.이 장에서 답할 질문
let으로 지은 슬라이스의 원소는 바꿀 수 있는가?if를 식으로 쓸 수 있으면var를 줄일 수 있지 않은가?
6.1 바뀌는지는 낱말이 말한다#
op 본문에서 값을 담아 두는 이름이 지역이다. 두 가지뿐이다.
let <이름> <타입> be <식> .— 불변. 한 번 정하면 바뀌지 않는다.var <이름> <타입> be <식> .— 가변.set <이름> <식> .으로 다시 넣는다.
examples/ch06/sumto.low
module sumto .
rem run: sum_to 10
rem run: sum_to 100
fn sum_to input n u64 . output u64 .
do
var total u64 be 0 .
var i u64 be 1 .
while le i n . do
set total (add total i) .
set i (add i 1) .
end
return total .
end
실행 결과
$ lowentc --run sum_to sumto.low 10
sum_to(10) = 55
$ lowentc --run sum_to sumto.low 100
sum_to(100) = 5050
total 과 i 는 반복하며 바뀌므로 var 다. 바뀔 값만 var 로 짓고, 나머지는 let 으로 짓는 것이 기본이다. let 에 set 을 하면 거절된다.
examples/ch06/immutable.low
module immutable .
rem expect: E-IMMUTABLE
fn f output u64 .
do
let a u64 be 1 .
set a 2 .
return a .
end
실행 결과
$ lowentc --check immutable.low
immutable.low:7:0 E-IMMUTABLE: this binding was made with `let`, which is IMMUTABLE — `set` cannot reassign it. Until now `let` and `var` meant exactly the same thing (two spellings, one meaning — and SPEC-002 §2.5 forbids synonyms). Use `var` if you mean to reassign it
진단의 긴 설명에 따르면 한때는 let 과 var 가 같은 뜻이었다. 두 철자에 한 뜻이면 둘 중 하나는 아무것도 말하지 않는다. 지금 let 은 불변을 약속하고 컴파일러가 그 약속을 검사한다.
문. let 으로 지은 슬라이스의 원소는 바꿀 수 있는가?
답. let 이 막는 것은 이름에 다른 값을 다시 넣는 일이다. 슬라이스의 원소에 쓸 수 있는지는 슬라이스의 타입이 정한다 — mut slice 면 원소에 쓸 수 있고 slice 면 읽기만 한다(9장). 이름의 불변과 가리키는 바이트의 불변은 서로 다른 질문이다.
6.2 타입은 적거나, 값에게 맡긴다#
지역의 타입은 적을 수도 있고 생략할 수도 있다. 생략하면 값이 타입을 정한다.
examples/ch06/infer.low
module infer .
rem run: next_byte 41
rem trap: next_byte 255
fn next_byte input a u8 . output u8 .
do
let b be add a 1 .
return b .
end
실행 결과
$ lowentc --run next_byte infer.low 41
next_byte(41) = 42
$ lowentc --run next_byte infer.low 255
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)
let b be add a 1 . 에서 a 는 u8 이므로 b 도 u8 이다. 그래서 255 에 1 을 더하면 u8 에서 넘쳐 멈춘다. 값에서 타입을 정할 수 없으면 거절되고, 그때는 적어야 한다.
이 책은 대부분 타입을 적는다. 폭이 곧 넘침의 경계이므로(4장), 타입이 소스에 보이면 멈출 수 있는 자리가 함께 보이기 때문이다. 짧은 중간값이나 타입이 뻔한 자리에서만 생략한다.
6.3 값 없는 이름은 없다#
be 뒤에는 값이 있어야 한다. 이름만 먼저 짓고 값을 나중에 주는 길은 이 언어에 없다.
examples/ch06/novalue.low
module novalue .
rem expect: E-LET-NOVALUE
fn half output f64 .
do
let x f64 be .5 .
return x .
end
실행 결과
$ lowentc --check novalue.low
novalue.low:6:0 E-LET-NOVALUE: this binding has NO VALUE — `be` is followed by nothing, and the tool used to quietly bind 0 there: a value that appears NOWHERE in your source. If you wrote a float like `.5`, that is the cause: a LEADING DOT is a form CLOSER here, not part of a number, so the value vanished before it was ever read. Write `0.5`. Otherwise give the binding a value — this language has no way to declare a name and fill it in later
이 예는 빈칸을 적은 실수가 아니다. 저자는 .5 라는 값을 적었다고 믿는다. 그러나 .5 는 부동소수 리터럴이 아니다 — 떨어진 마침표가 폼을 닫고, be 뒤에는 아무것도 남지 않는다. 옛 도구는 그 자리에 조용히 0 을 넣었고, 소스 어디에도 없는 값이 프로그램에 들어갔다. 지금은 거절되고, 진단이 이 함정을 이름으로 짚는다. 부동소수는 0.5 로 적는다.
흔한 오해. 초기화하지 않은 변수는 0 이다
6.4 지역이 사는 범위#
지역은 자기가 지어진 블록이 끝날 때까지 산다. 블록이 끝나면 그 이름은 다시 쓸 수 있다.
examples/ch06/scope.low
module scope .
rem run: pick 5
rem run: pick 0
fn pick input a u64 . output u64 .
do
var result u64 be 0 .
if gt a 1 . do
let doubled u64 be mul a 2 .
set result doubled .
end
if le a 1 . do
let doubled u64 be 1 .
set result doubled .
end
return result .
end
실행 결과
$ lowentc --run pick scope.low 5
pick(5) = 10
$ lowentc --run pick scope.low 0
pick(0) = 1
두 if 블록은 각자 doubled 를 짓는다. 첫 블록이 끝나면 첫 doubled 는 사라지므로 둘째 블록에서 같은 이름을 다시 지어도 된다. 두 이름이 동시에 살아 있는 순간이 없기 때문이다.
반대로, 바깥에 살아 있는 이름을 안쪽 블록이 다시 지으면 거절된다.
examples/ch06/outer.low
module outer .
rem expect: E-NAME-SHADOW
fn f input a u64 . output u64 .
do
let t u64 be 1 .
if gt a 1 . do
let t u64 be 2 .
return t .
end
return t .
end
실행 결과
$ lowentc --check outer.low
outer.low:8:0 E-NAME-SHADOW: an inner block binds a name that is already bound OUTSIDE it. The namespace is FLAT (SPEC-002 2.10: no shadowing) — the outer value is still alive and the same letters now point at a different one. Rename the inner binding
outer.low:11:0 E-NAME-SCOPE: this name was declared INSIDE a block and is read outside it. A block is where a name lives (§6.5.1): on the path that did not enter the block the name never existed, and the tool used to answer 0 there — a value that appears nowhere in the source. Declare it before the block (`var … be 0 .`) and set it inside
안쪽 블록에서 t 는 2 이고 블록을 나오면 다시 1 이다. 같은 글자가 줄에 따라 다른 값을 가리키면 읽는 사람이 틀린 값을 떠올린다. 3장에서 본 매개변수 가림과 같은 규칙이다 — 이름 공간은 평평하다.
6.5 if 는 값을 내지 않는다#
if 는 문장이다. 식의 자리에 둘 수 없다.
examples/ch06/ifvalue.low
module ifvalue .
rem expect: E-IF-VALUE
fn pick input a u64 . output u64 .
do
let x u64 be if gt a 1 . 5 else 6 .
return x .
end
실행 결과
$ lowentc --check ifvalue.low
ifvalue.low:6:0 E-IF-VALUE: `if` is a STATEMENT — it gives no value (§6.5.2 (4)). Choose the value in each branch instead: `var x u64 be 6 . if c do set x 5 . end`, or `return` from each branch
갈래마다 값을 정하려면 진단이 제안하는 대로 var 를 기본값으로 짓고 갈래 안에서 set 하거나, 갈래마다 return 한다. 값이 여러 경우 중 하나로 정해지는 모양이 되풀이되면 match(7장)가 더 맞는 도구다.
문. if 를 식으로 쓸 수 있으면 var 를 줄일 수 있지 않은가?
답. 줄어든다. 대신 식 안에 블록이 들어오고, 블록 안에서 return 이나 break 를 만나면 그 식이 어디로 빠져나가는지 따져야 한다. Lowent 는 식과 문장을 가르는 쪽을 골랐다. 값은 식이 만들고, 흐름은 문장이 바꾼다.
6.6 흔한 실수#
반례. set 에서 이름을 잘못 적는다
examples/ch06/mistake_typo.low
module mistake_typo .
rem expect: E-IR-UNDEF
fn bump output u64 .
do
var total u64 be 0 .
rem ✘ 이름을 잘못 적었다 --- `totl` 은 지은 적이 없는 이름이다
set totl (add total 1) .
return total .
end
실행 결과
$ lowentc --check mistake_typo.low
mistake_typo.low:8:0 E-IR-UNDEF: set of an undeclared name
set 은 이미 지은 var 에만 값을 다시 넣는다. 처음 보는 이름에 set 하면 새 변수를 만들어 주는 언어도 있지만, 여기서는 E-IR-UNDEF 로 거절한다. 오타가 새 변수가 되면 원래 변수는 영영 바뀌지 않은 채 조용히 틀린 답을 내기 때문이다. 짓는 것(var)과 바꾸는 것(set)을 다른 낱말로 가른 까닭이 이것이다.
반례. = 로 값을 넣는다
examples/ch06/mistake_equals.low
module mistake_equals .
rem expect: E-CHAR
fn bump output u64 .
do
var total u64 be 0 .
rem ✘ `=` 는 이 언어의 기호가 아니다 --- 값을 다시 넣는 것은 `set` 이다
total = add total 1 .
return total .
end
실행 결과
$ lowentc --check mistake_equals.low
8:9 E-CHAR: unexpected character
8:9 E-FORM-UNEXPECTED: unexpected token in form
대부분의 언어에서 = 는 대입이지만 수학에서는 같음이다. 한 기호가 두 뜻을 오가며 if (x = 0) 같은 결함을 낳아 왔다. Lowent 는 기호를 쓰지 않는다 — 짓는 것은 let/var … be, 바꾸는 것은 set, 같은지 묻는 것은 eq 다. = 는 아예 모르는 글자라 E-CHAR 가 나온다.
반례. 흔한 영어 낱말로 지역 이름을 짓는다
examples/ch06/mistake_localname.low
module mistake_localname .
rem expect: E-NAME-BUILTIN
fn size input xs slice u8 . output u64 .
do
rem ✘ `count` 는 내장 op 의 이름이다 --- 지역 이름으로 쓸 수 없다
let count u64 be len xs .
return count .
end
실행 결과
$ lowentc --check mistake_localname.low
mistake_localname.low:7:0 E-NAME-BUILTIN: a local takes the name of a BUILTIN op. The namespace is FLAT (no shadowing), so this name now means two things — and which one it means decides how the SENTENCE IS BRACKETED: `f len data` reads as `f(len(data), …)` if `len` is the builtin (arity 1) and as `f(len, data)` if it is your local (arity 0). Same letters, different tree, no error. That is RFC-0046 P1. Rename the local
count·len·min·max·ok·avg 같은 이름은 이미 내장 op 이다. 이름 공간이 평평하므로 지역이 그 이름을 가져가면 f count data 가 “count 를 부른 값” 인지 “지역 count” 인지에 따라 문장이 다르게 묶인다. 같은 글자가 다른 나무가 되는 자리라서 E-NAME-BUILTIN 으로 막는다. n·total·size 처럼 짓는다.
반례. 블록 안에서 지은 지역을 블록 밖에서 읽는다
examples/ch06/mistake_outside.low
module mistake_outside .
rem expect: E-NAME-SCOPE
fn big_or_zero input a u64 . output u64 .
do
if gt a 3 . do
let big u64 be mul a 10 .
end
rem ✘ `big` 은 위 블록 안에서만 산다 --- 블록 밖에서 읽었다
return big .
end
실행 결과
$ lowentc --check mistake_outside.low
mistake_outside.low:10:0 E-NAME-SCOPE: this name was declared INSIDE a block and is read outside it. A block is where a name lives (§6.5.1): on the path that did not enter the block the name never existed, and the tool used to answer 0 there — a value that appears nowhere in the source. Declare it before the block (`var … be 0 .`) and set it inside
big 은 if 블록 안에서 지었으므로 블록이 끝나면 사라진다. 그 이름을 블록 밖에서 읽으면 E-NAME-SCOPE 로 거절한다 — 전에는 블록에 들어가지 않은 길에서 조용히 0 을 주었다. 소스 어디에도 없는 값이다. 블록 밖에서도 쓸 값은 블록 앞에서 짓는다.
examples/ch06/outside_fixed.low
module outside_fixed .
rem run: big_or_zero 5
rem run: big_or_zero 1
fn big_or_zero input a u64 . output u64 .
do
rem 블록 밖에서도 쓸 값은 블록 앞에서 짓고, 블록 안에서는 set 만 한다
var big u64 be 0 .
if gt a 3 . do
set big (mul a 10) .
end
return big .
end
실행 결과
$ lowentc --run big_or_zero outside_fixed.low 5
big_or_zero(5) = 50
$ lowentc --run big_or_zero outside_fixed.low 1
big_or_zero(1) = 0
이제 0 은 소스에 적힌 기본값이다. 같은 답이지만 어디서 온 0 인지가 보인다.
6.7 이 장의 문법 한눈에#
| 모양 | 뜻 | 왜 이렇게 |
|---|---|---|
let x T be e . | 바뀌지 않는 이름 | 기본은 불변 — 바뀌는 값만 따로 보이게 |
var x T be e . | 바뀔 수 있는 이름 | 바뀔 것을 짓는 순간에 밝힌다 |
set x e . | var 에 새 값을 넣는다 | 짓기와 바꾸기를 다른 낱말로 — 오타가 새 변수가 되지 않게 |
let x be e . | 타입을 값에게 맡긴다 | 짧은 중간값에만 — 폭이 곧 넘침의 경계라서 |
be 뒤의 값 | 반드시 있어야 한다 | 초기화하지 않은 변수라는 것이 없다 |
| 블록의 끝 | 그 안에서 지은 지역이 사라진다 | 이름은 쓰이는 곳 가까이 산다 |
| 안쪽에서 바깥 이름 다시 짓기 | 거절(E-NAME-SHADOW) | 같은 글자는 한 값만 가리킨다 |
eq a b | 같은가 묻기 | = 가 없다 — 대입과 같음이 섞이지 않는다 |
표 6.1 — 지역의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
let(불변)과 var(가변) 둘이고 set 은 var 에만 한다. 타입은 적거나 값에게 맡길 수 있지만, be 뒤의 값은 반드시 있어야 한다. 지역은 자기 블록이 끝날 때까지 살고, 살아 있는 이름은 안쪽에서도 다시 지을 수 없다. if 는 값을 내지 않는 문장이다.