Lowent 매뉴얼←↑→

6 지역 — let 과 var

먼저 알아야 할 것

3장 겉모습 · 가림(shadowing)이 금지된다
5장 op · op 안에 갇힌 변이는 순수로 친다

돌아보기

5장의 running_total 은 var 로 합을 쌓는데도 fn 이었다. 왜 허락되었는가?

답. 순수함이 관측적이기 때문이다. total 은 op 안에서만 사는 지역이므로 그 변이를 호출자가 볼 수 없다. 호출자에게 보이는 쓰기(mut 매개변수)만 효과로 친다. 이 장은 그 지역을 짓는 두 낱말과, 지역이 사는 범위를 다룬다.

이 장의 필요성과 맥락

바뀌는 값은 읽는 사람에게 비싸다. 한 이름이 쓰이는 자리마다 “여기서는 무슨 값이지” 를 다시 물어야 하기 때문이다. Lowent 는 바뀌지 않는 이름과 바뀌는 이름을 낱말로 가르고, 값 없이 이름만 짓는 길을 막는다. 흐름(7장)에서 반복과 갈래를 쓰기 전에 그 안에서 값을 담아 둘 그릇을 먼저 정한다.

이 장이 끝나면

let 은 불변, var 는 가변이고 set 으로만 다시 넣는다는 것을 알게 된다. 타입을 적는 경우와 생략하는 경우, 값 없는 선언이 거절되는 이유(특히 .5 의 함정)를 익힌다. 지역이 블록 안에서만 살고, 살아 있는 바깥 이름을 안쪽에서 다시 쓸 수 없다는 것, 그리고 if 가 값을 내지 않는 문장이라는 것도 보게 된다.

이 장에서 답할 질문

  1. let 으로 지은 슬라이스의 원소는 바꿀 수 있는가?
  2. if 를 식으로 쓸 수 있으면 var 를 줄일 수 있지 않은가?

6.1 바뀌는지는 낱말이 말한다#

op 본문에서 값을 담아 두는 이름이 지역이다. 두 가지뿐이다.

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 이다

C 에서 지역 변수를 초기화하지 않으면 값은 정해지지 않는다. 어떤 언어는 0 을 넣어 준다. Lowent 에는 둘 다 없다 — 초기화하지 않은 변수라는 것 자체가 없다. 값이 정해지지 않은 순간을 만들 수 없으므로, 그 순간에 읽는 결함도 없다.

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 는 값을 내지 않는 문장이다.