8 식 — 전위 표기와 expr 섬
먼저 알아야 할 것
match 는 모든 경우를 덮어야 한다돌아보기
3장은 expr a lt b lt c 를 왜 거절했는가? 진단은 무엇으로 고치라고 했는가?
답. 비교를 이어 쓰면 수학에서는 “a < b 이고 b < c” 로 읽히지만 많은 언어에서는 (a < b) < c 로 읽힌다. 같은 글자가 사람과 기계에게 다른 뜻이 되므로 E-EXPR-CHAIN 으로 거절했다. 진단은 expr (a lt b) and (b lt c) 로 적으라고 했다. 이 장은 그 섬 안의 규칙 전체를 다룬다.
이 장의 필요성과 맥락
expr 섬 안에서만 허락된다. 이 두 겹 구조를 제대로 알아야 괄호가 겹겹이 쌓인 전위 식을 읽을 수 있고, 섬 안에서 무엇이 허락되지 않는지를 알아 헤매지 않는다. 제2부의 마지막 장으로, 앞의 장들에서 조금씩 본 식의 규칙을 한곳에 모은다.이 장이 끝나면
expr 섬의 짧은 우선순위표를 알게 된다. 섬 안에서 op 을 부를 때 괄호가 필요하고 단항 연산자가 없다는 것, 비교와 and·or 를 섞을 때 괄호로 묶는다는 것을 익힌다. and·or 의 단락 평가로 안전한 조건을 적는 법과, 번역할 때 계산되는 size_of·comptime 도 보게 된다.이 장에서 답할 질문
- VM 은
in_range(5, 1, 9) = 1이라고 보여 준다.bool이 수가 된 것인가?
8.1 전위 표기에는 외울 것이 없다#
전위 표기에서 연산은 이름이 먼저 오고 인자가 뒤에 온다. 인자가 폼이면 괄호로 감싼다. 이 모양에는 우선순위가 없다. 무엇이 먼저 계산되는지는 괄호가 전부 말한다.
examples/ch08/island.low
module island .
rem run: score 3 4
rem run: score_prefix 3 4
rem run: grouped 3 4
rem run: in_range 5 1 9
rem run: spread 3 9
fn score input a u32 . input b u32 . output u32 .
requires le a 1000 .
requires le b 1000 .
do
return expr a + b * 2 .
end
fn score_prefix input a u32 . input b u32 . output u32 .
requires le a 1000 .
requires le b 1000 .
do
return add a (mul b 2) .
end
fn grouped input a u32 . input b u32 . output u32 .
requires le a 1000 .
requires le b 1000 .
do
return expr (a + b) * 2 .
end
fn in_range input x u32 . input lo u32 . input hi u32 . output bool .
do
return expr (lo le x) and (x le hi) .
end
fn spread input a u32 . input b u32 . output u32 .
do
return expr (max a b) - (min a b) .
end
실행 결과
$ lowentc --run score island.low 3 4
score(3, 4) = 11
$ lowentc --run score_prefix island.low 3 4
score_prefix(3, 4) = 11
$ lowentc --run grouped island.low 3 4
grouped(3, 4) = 14
$ lowentc --run in_range island.low 5 1 9
in_range(5, 1, 9) = 1
$ lowentc --run spread island.low 3 9
spread(3, 9) = 6
score_prefix 의 add a (mul b 2) 와 score 의 expr a + b * 2 는 같은 식이다. 두 op 의 답이 같고, 섬은 전위로 번역되므로 실행 비용도 같다. 섬은 표기의 투영일 뿐 다른 연산이 아니다.
기호 대신 낱말을 쓰는 이유도 같은 곳에 있다. ^ 는 어떤 언어에서 거듭제곱이고 어떤 언어에서 배타적 논리합이다. bit_xor 는 어디서 읽어도 한 뜻이다. 그리고 낱말은 소리 내어 읽을 수 있다.
8.2 expr 섬의 우선순위표#
섬 안에서만 우선순위가 있고, 표는 이것이 전부다.
| 단계 | 연산 | 결합 | 같은 뜻의 전위 |
|---|---|---|---|
| 5 | ( ) 묶음 | — | 묶음 표시일 뿐 값이 아니다 |
| 4 | * / | 왼쪽 | mul · div |
| 3 | + - | 왼쪽 | add · sub |
| 2 | eq ne lt le gt ge | 이어 쓸 수 없다 | 같은 이름의 전위 연산 |
| 1b | and | 왼쪽 | and(단락 평가) |
| 1a | or | 왼쪽 | or(단락 평가) |
표 8.1 — expr 섬 안의 우선순위(강한 것이 위)
grouped 의 expr (a + b) * 2 처럼 괄호로 차례를 바꿀 수 있다. 표에 없는 연산 — 나머지 mod, 비트 연산, min·max — 은 섬 안에서도 전위로 적고 괄호로 부른다. spread 의 expr (max a b) - (min a b) 가 그 모양이다.
in_range 는 비교 두 개를 and 로 묶었다. 표로는 비교가 and 보다 강하므로 괄호가 없어도 될 것 같지만, 이 판의 컴파일러는 expr lo le x and x le hi 를 E-TYPE-LOGICAL 로 거절한다. 비교를 and·or 와 섞을 때는 비교마다 괄호로 묶는다. 읽는 사람도 표를 떠올릴 필요가 없어진다.
문. VM 은 in_range(5, 1, 9) = 1 이라고 보여 준다. bool 이 수가 된 것인가?
답. 아니다. VM 의 결과 줄이 bool 을 0 과 1 로 보여 줄 뿐이다. 프로그램 안에서 bool 은 수와 섞이지 않는다(4장). 네이티브로 내보낸 C 에서도 bool 은 한 바이트의 0 또는 1 로 표현된다.
8.3 섬 안에서 하지 못하는 것#
섬 안은 중위 연산자의 세계다. 그래서 두 가지가 없다.
첫째, 괄호 없는 op 부름. twice a + 1 은 twice (a + 1) 인지 (twice a) + 1 인지 알 수 없다.
examples/ch08/app_bad.low
module app_bad .
rem expect: E-EXPR-APP
fn twice input a u32 . output u32 .
requires le a 1000 .
do
return mul a 2 .
end
fn use input a u32 . output u32 .
requires le a 100 .
do
return expr twice a + 1 .
end
실행 결과
$ lowentc --check app_bad.low
app_bad.low:13:0 E-EXPR-APP: an op call inside an `expr` island must be parenthesised — write `(len d) ge 4`, not `len d ge 4`. The island has infix operators only: without the parentheses nothing says where the argument list ends, and you and the compiler would read it differently
진단이 예를 들어 주듯, 섬 안의 부름은 (twice a) + 1 처럼 괄호로 묶는다.
둘째, 단항 연산자. 섬에는 왼쪽 피연산자가 없는 연산자가 없다.
examples/ch08/unary_bad.low
module unary_bad .
rem expect: E-EXPR-UNARY
fn flip input a i32 . output i32 .
do
return expr - a .
end
실행 결과
$ lowentc --check unary_bad.low
unary_bad.low:6:0 E-EXPR-UNARY: an `expr` island has INFIX operators only — there is no unary form. `expr - 5` has nothing on the left of `-`. Write the prefix op instead (`neg 5`, `not b`, `bit_not x`), or give the left operand. The island exists to read like arithmetic; a unary sign would make `a - 5` and `a (- 5)` two readings of the same letters (SPEC-002 §2.5 · RFC-0091)
부호를 뒤집으려면 neg a 를 전위로 적거나, 섬 안이라면 (neg a) 로 부르거나 expr 0 - a 로 적는다. not b · bit_not x 도 같다.
흔한 오해. 섬이 작은 것은 아직 덜 만들어서다
8.4 단락 평가로 조건을 지킨다#
and 와 or 는 앞쪽만으로 답이 정해지면 뒤쪽을 계산하지 않는다. 이것을 이용하면 뒤쪽 식이 안전하게 계산될 조건을 앞쪽에 둘 수 있다.
examples/ch08/shortcircuit.low
module shortcircuit .
rem run: safe_first_is_zero []
rem run: safe_first_is_zero [0,4]
rem run: ratio_ok 0 0
rem run: ratio_ok 10 5
fn safe_first_is_zero input xs slice u8 . output bool .
do
return and (gt (len xs) 0) (eq (index xs 0) 0) .
end
fn ratio_ok input num u32 . input den u32 . output bool .
do
return and (ne den 0) (ge (div num den) 2) .
end
실행 결과
$ lowentc --run safe_first_is_zero shortcircuit.low []
safe_first_is_zero([]) = 0
arg0 (written) = []
$ lowentc --run safe_first_is_zero shortcircuit.low [0,4]
safe_first_is_zero([0,4]) = 1
arg0 (written) = [0,4]
$ lowentc --run ratio_ok shortcircuit.low 0 0
ratio_ok(0, 0) = 0
$ lowentc --run ratio_ok shortcircuit.low 10 5
ratio_ok(10, 5) = 1
safe_first_is_zero 는 빈 슬라이스에서 index xs 0 을 계산하지 않는다. 앞쪽 gt (len xs) 0 이 거짓이면 거기서 답이 거짓으로 정해지기 때문이다. ratio_ok 도 분모가 0 이면 나눗셈을 하지 않는다. 이 모양은 guard 를 쓰기엔 작은 조건에 알맞다. 조건이 op 전체에 대한 사실이라면 guard 가 낫다 (7장).
8.5 번역할 때 계산되는 식#
어떤 식은 실행하기 전에 값이 정해진다.
examples/ch08/compt.low
module compt .
rem run: table_size
rem run: pick
fn table_size output u64 .
do
return mul (size_of u32) 16 .
end
fn pick output u32 .
do
match comptime (add 2 3) do
case 0 . do return 100 . end
case 5 . do return 500 . end
case _ . do return 999 . end
end
end
실행 결과
$ lowentc --run table_size compt.low
table_size() = 64
$ lowentc --run pick compt.low
pick() = 500
size_of u32 는 그 타입 값 하나의 바이트 수를 번역할 때 준다. 타입을 매개변수로 받는 코드가 가장 넓은 폭을 가정하지 않고 비용을 정직하게 계산할 수 있게 한다(22장).
comptime (add 2 3) 은 식을 번역할 때 계산하라는 표시다. match 의 가를 값이 번역 시점 상수이면 match 는 맞는 갈래 하나로 접히고 실행 중 비교는 사라진다. 그래도 접힌 다른 갈래들은 타입 검사를 받는다. C 의 #ifdef 로 지운 코드가 검사도 받지 않는 것과 다르다. 빌드 설정을 읽는 config <옵션> 도 번역 시점 상수로 쓰인다(31장).
8.6 흔한 실수#
비교(단계 2)가 and(단계 1b)보다 강하므로, 괄호 없이 섞어도 뜻은 하나다.
examples/ch08/mixcmp.low
module mixcmp .
rem run: in_range 3 1 5
rem run: in_range 9 1 5
fn in_range input x u64 . input lo u64 . input hi u64 . output bool .
do
rem 비교가 `and` 보다 강하므로 괄호 없이 섞어도 뜻이 하나다
return expr lo le x and x le hi .
end
실행 결과
$ lowentc --run in_range mixcmp.low 3 1 5
in_range(3, 1, 5) = 1
$ lowentc --run in_range mixcmp.low 9 1 5
in_range(9, 1, 5) = 0
그래도 괄호를 적으면 읽는 사람이 표를 떠올리지 않아도 된다. 아래가 같은 일을 괄호로 적은 것이다.
examples/ch08/mixcmp_fixed.low
module mixcmp_fixed .
rem run: in_range 5 1 9
rem run: in_range 12 1 9
fn in_range input x u64 . input lo u64 . input hi u64 . output bool .
do
return expr (lo le x) and (x le hi) .
end
실행 결과
$ lowentc --run in_range mixcmp_fixed.low 5 1 9
in_range(5, 1, 9) = 1
$ lowentc --run in_range mixcmp_fixed.low 12 1 9
in_range(12, 1, 9) = 0
반례. 나머지를 % 로 적는다
examples/ch08/mistake_percent.low
module mistake_percent .
rem expect: E-CHAR
fn last_digit input a u64 . output u64 .
do
rem ✘ `%` 는 섬의 연산자가 아니다 --- 나머지는 `mod` 를 괄호로 부른다
return expr a % 10 .
end
실행 결과
$ lowentc --check mistake_percent.low
7:17 E-CHAR: unexpected character
7:17 E-FORM-UNEXPECTED: unexpected token in form
섬의 연산자는 표에 있는 것이 전부다. % 는 C 에서는 나머지지만 언어마다 부호 규칙이 달라(음수의 나머지) 같은 기호가 다른 답을 낸다. Lowent 의 나머지는 이름 mod 하나이고 부호는 나누는 수를 따른다(4장). 섬 안에서는 괄호로 부른다.
examples/ch08/percent_fixed.low
module percent_fixed .
rem run: last_digit_plus 1234 1
fn last_digit_plus input a u64 . input k u64 . output u64 .
do
rem 표에 없는 연산은 섬 안에서도 전위로 적고 괄호로 감싼다
return expr (mod a 10) + k .
end
실행 결과
$ lowentc --run last_digit_plus percent_fixed.low 1234 1
last_digit_plus(1234, 1) = 5
반례. 거듭제곱을 ^ 로 적거나 정수에 pow 를 쓴다
^ 는 섬의 연산자가 아니다(E-CHAR) — 어떤 언어는 거듭제곱, 어떤 언어는 배타적 논리합이라서 넣지 않았다. 정수의 제곱은 mul a a 로 적는다. 그리고 pow 는 부동소수의 거듭제곱이다. 이 판의 도구는 정수에 쓴 pow 를 거절하지 않고 틀린 값을 준다.
examples/ch08/mistake_pow.low
module mistake_pow .
rem expect: E-TYPE-KIND
fn square input a u64 . output u64 .
do
rem ✘ 정수에 `pow` 를 썼다 — 초월 함수는 부동소수 전용이다
return pow a 2 .
end
실행 결과
$ lowentc --check mistake_pow.low
mistake_pow.low:7:0 E-TYPE-KIND: this is a floating-point-only operation (canon §6.3.9) and it was given an INTEGER. `pow` used to answer anyway — and the answer was wrong (`pow 5 2` gave 5 on both backends), which is the worst kind of wrong: quiet. Convert first (`cast f64 n`), or use repeated multiplication for an integer power
정본은 pow·sqrt·sin·cos·exp·log·fmod 를 부동소수 전용으로 정한다(§6.3.9). 정수를 주면 E-TYPE-KIND 로 거절된다. 2026-09-16 까지는 거절하지 않고 square 5 가 25 가 아니라 5 를 냈다 — 번역도 실행도 알리지 않는 조용한 오답이었다. 정수의 거듭제곱은 곱셈으로 적고, 부동소수로 셈하려면 cast f64 n 으로 먼저 옮긴다.
examples/ch08/pow_fixed.low
module pow_fixed .
rem run: square_f 1.5
rem run: square_i 5
fn square_f input a f64 . output f64 .
do
rem `pow` 는 부동소수에서 맞게 돈다
return pow a 2.0 .
end
fn square_i input a u64 . output u64 .
requires le a 1000 .
do
rem 정수의 거듭제곱은 곱셈으로 적는다 — 이름이 하는 일을 말한다
return mul a a .
end
실행 결과
$ lowentc --run square_f pow_fixed.low 1.5
square_f(1.5) = 2.25
$ lowentc --run square_i pow_fixed.low 5
square_i(5) = 25
흔한 오해. 중첩된 부름에는 괄호가 반드시 있어야 한다
examples/ch08/noparen.low
module noparen .
rem run: with_parens 3 4
rem run: without_parens 3 4
fn with_parens input a u64 . input b u64 . output u64 .
do
return add 1 (mul a b) .
end
fn without_parens input a u64 . input b u64 . output u64 .
do
rem 괄호가 없어도 `mul` 이 인자 둘을 받는다는 것으로 묶인다 --- 같은 13 이다
return add 1 mul a b .
end
실행 결과
$ lowentc --run with_parens noparen.low 3 4
with_parens(3, 4) = 13
$ lowentc --run without_parens noparen.low 3 4
without_parens(3, 4) = 13
괄호가 없어도 컴파일러는 각 op 이 받는 인자 수로 문장을 묶는다. mul 이 인자 둘을 받으므로 add 1 mul a b 는 add 1 (mul a b) 와 같다. 그래도 이 책은 괄호를 적는다. 인자 수를 외우지 않은 사람에게는 괄호가 묶음을 보여 주고, 지역 이름이 내장 op 과 같은 철자일 때(6장의 count) 묶음이 달라지는 사고도 막는다.
8.7 이 장의 문법 한눈에#
| 모양 | 뜻 | 왜 이렇게 |
|---|---|---|
add a (mul b c) | 전위 표기 — 우선순위가 없다 | 괄호가 계산 차례를 다 말한다 |
expr a + b * c | 중위 섬 — * / 가 + - 보다 강하다 | 학교에서 배운 셈과 같은 것만 중위로 |
expr (a + b) * c | 섬 안의 묶음 | 괄호는 값이 아니라 묶음 표시 |
expr (lo le x) and (x le hi) | 비교를 논리와 섞기 | 비교마다 괄호 — 표를 떠올리지 않아도 된다 |
expr (twice a) + 1 | 섬 안에서 op 부르기 | 괄호가 없으면 어디까지가 부름인지 모른다(E-EXPR-APP) |
expr (mod a 10) + k | 표에 없는 연산(mod·비트·min) | 섬은 커지지 않는다 — 전위로 부른다 |
expr 0 - a · (neg a) | 부호 뒤집기 | 섬에 단항 연산자가 없다(E-EXPR-UNARY) |
and · or | 단락 평가 — 앞쪽으로 답이 정해지면 뒤를 계산하지 않는다 | 뒤쪽 식이 안전할 조건을 앞에 둔다 |
size_of u32 | 타입 하나의 바이트 수(번역할 때) | 제네릭 코드가 비용을 정직하게 센다 |
comptime (add 2 3) | 번역할 때 계산 | 접힌 갈래도 타입 검사는 받는다 |
표 8.2 — 식의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
expr 섬은 사칙·비교·and·or 만 중위로 허락하고, 섬 안의 op 부름은 괄호로 묶으며 단항 연산자는 없다. 비교를 and·or 와 섞을 때는 비교마다 괄호로 묶는다. and·or 의 단락 평가로 뒤쪽 식을 지킬 수 있고, size_of 와 comptime 은 번역할 때 계산된다.