Lowent 매뉴얼←↑→

8 식 — 전위 표기와 expr 섬

먼저 알아야 할 것

3장 겉모습 · 이름이 먼저, 인자가 뒤
4장 수 · 비트 연산은 낱말로 적는다
7장 흐름 · 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) 로 적으라고 했다. 이 장은 그 섬 안의 규칙 전체를 다룬다.

이 장의 필요성과 맥락

식은 가장 많이 쓰는 문법이다. Lowent 의 식은 기본이 전위 표기이고 중위 표기는 expr 섬 안에서만 허락된다. 이 두 겹 구조를 제대로 알아야 괄호가 겹겹이 쌓인 전위 식을 읽을 수 있고, 섬 안에서 무엇이 허락되지 않는지를 알아 헤매지 않는다. 제2부의 마지막 장으로, 앞의 장들에서 조금씩 본 식의 규칙을 한곳에 모은다.

이 장이 끝나면

전위 표기에 우선순위가 없는 이유와, expr 섬의 짧은 우선순위표를 알게 된다. 섬 안에서 op 을 부를 때 괄호가 필요하고 단항 연산자가 없다는 것, 비교와 and·or 를 섞을 때 괄호로 묶는다는 것을 익힌다. and·or 의 단락 평가로 안전한 조건을 적는 법과, 번역할 때 계산되는 size_of·comptime 도 보게 된다.

이 장에서 답할 질문

  1. 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
2eq ne lt le gt ge이어 쓸 수 없다같은 이름의 전위 연산
1band왼쪽and(단락 평가)
1aor왼쪽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 은 번역할 때 계산된다.