Lowent 매뉴얼←↑→

4 수 — 폭이 정해진 정수와 부동소수점

먼저 알아야 할 것

2장 첫 프로그램 · VM 과 네이티브가 같은 답을 내야 한다
3장 겉모습 · 리터럴은 자리의 타입에 들어가지 않으면 거절된다

돌아보기

3장에서 let x u8 300 . 은 어떻게 되었는가? C 는 같은 일을 어떻게 처리하는가?

답. E-TYPE-WIDTH 로 거절되었다. 300 은 u8 의 범위 0 … 255 밖이기 때문이다. C 는 조용히 잘라 44 로 만든다. 그러면 소스에 적힌 값과 실제 값이 달라진다. 이 장은 리터럴이 아니라 계산 결과가 폭을 넘을 때 같은 원칙이 어떻게 적용되는지를 본다.

이 장의 필요성과 맥락

시스템 프로그램의 결함 가운데 오래되고 흔한 것이 정수의 결함이다. 넘쳐서 작은 수가 되고, 음수가 큰 양수로 바뀌고, 0 으로 나누어 미정의 동작이 된다. 대부분은 조용히 일어나서 한참 뒤의 엉뚱한 자리에서 드러난다. 제2부의 첫 장이 수인 것은 모든 계산이 수 위에서 일어나고, Lowent 가 “조용히” 를 없애는 방식 — 처분을 이름으로 적게 하는 방식 — 을 가장 단순한 모양으로 보여 주는 곳이 여기이기 때문이다.

이 장이 끝나면

정수 타입이 부호와 폭으로 이름 지어지고 어디서나 같은 크기라는 것을 알게 된다. 넓히기는 자동이고 좁히기는 적어야 한다는 규칙, 넘침과 0 나누기가 멈춘다는 규칙, 그리고 멈추지 않고 감거나 끝값에 서거나 값으로 알려 달라고 이름으로 고르는 법을 익힌다. 참거짓이 수가 아니라는 것과 비트 연산· 부동소수점 수의 성질도 보게 된다.

이 장에서 답할 질문

  1. narrow_wrap 이 C 와 같은 일을 한다면, 기본을 그렇게 두지 않은 이유가 무엇인가?
  2. 비트 연산은 왜 expr 섬에서 중위로 쓸 수 없는가?
  3. cast 를 매번 적는 것은 번거롭지 않은가?

4.1 정수 타입은 이름에 폭을 적는다#

정수 타입의 이름은 부호와 비트 폭이다. u 는 부호 없음, i 는 부호 있음이다.

타입폭범위
u8 · u16 · u32 · u648 · 16 · 32 · 640 … 2N−1
i8 · i16 · i32 · i648 · 16 · 32 · 64−2N−1 … 2N−1−1
usize · idiff16 이상 — 구현이 정한다폭에 따른다(아래 「크기 타입」)
u128 · i128128 — 64 비트 대상에서만위와 같은 꼴(아래 「128 비트 정수」)

표 4.1 — 정수 타입과 범위

C 의 int 는 기계마다 크기가 다를 수 있지만, Lowent 의 u32 는 어디서나 32 비트다. 음수는 2 의 보수로 표현한다. usize 는 u64 와 폭이 같은 기계에서도 다른 타입이고 서로 자동으로 바뀌지 않는다 — 길이를 세는 수와 그냥 수를 섞는 자리를 소스에 드러내기 위해서다. 건너는 낱말은 cast 하나다(아래 「크기 타입」).

4.2 128 비트 정수#

u128 · i128 은 64 비트 대상(x86_64 · arm64 · riscv64 · win64)에서만 선다. 32 비트 대상에서 이 낱말을 쓰면 번역이 거부된다(E-INT128-TARGET). 64 비트 두 수의 곱 전체처럼 64 비트를 넘는 셈을 한 값으로 담을 때 쓴다.

fn top_half input a u64 . input b u64 . output u64 . do
  let x u128 widen u128 a. . .
  return cast u64 shr mul x. widen u128 b. . . 64 . . .
end .

4.3 넓히기는 자동, 좁히기는 적는다#

값을 잃지 않는 변환을 넓히기라 한다. u8 을 u32 자리에 넘기는 일은 값이 그대로이므로 컴파일러가 알아서 한다. 부호가 섞여도 값을 지키는 넓히기가 있으면 계산할 수 있다 — u8 은 i16 안에 모두 들어간다.

examples/ch04/widths.low

module widths .
rem run: mix 200 -100
rem run: shrink 200
rem trap: shrink 300
rem run: shrink_sat 300
rem run: shrink_wrap 300

fn mix input a u8 . input b i16 . output i16 .
do
  return add a. b. . .
end .

fn shrink input x u64 . output u8 .
do
  return narrow u8 x. . .
end .

fn shrink_sat input x u64 . output u8 .
do
  return narrow_sat u8 x. . .
end .

fn shrink_wrap input x u64 . output u8 .
do
  return narrow_wrap u8 x. . .
end .

실행 결과

$ lowentc --run mix widths.low 200 -100
mix(200, -100) = 100
$ lowentc --run shrink widths.low 200
shrink(200) = 200
$ lowentc --run shrink_sat widths.low 300
shrink_sat(300) = 255
$ lowentc --run shrink_wrap widths.low 300
shrink_wrap(300) = 44
$ lowentc --run shrink widths.low 300
== ir diagnostics (1) ==
0:0 E-VM-CAST: value does not fit the target width (use narrow_wrap / narrow_sat / narrow_try)

mix 는 u8 과 i16 을 더했고 결과는 넓은 쪽인 i16 이다. 넓히기를 눈에 보이게 적고 싶으면 widen u64 x. . 로 적는다. 같은 폭의 i32 와 u32 는 어느 쪽도 다른 쪽에 다 들어가지 않으므로 섞을 수 없다.

examples/ch04/sign_bad.low

module sign_bad .
rem expect: E-TYPE-SIGN

fn total input a i32 . input b u32 . output i32 .
do
  return add a. b. . .
end .

실행 결과

$ lowentc --check sign_bad.low
sign_bad.low:6:0 E-TYPE-SIGN: sign mismatch: no value-preserving widening exists (widen both to a strictly wider signed type, or bitcast_sign)

값을 잃을 수 있는 변환은 좁히기이고 반드시 적는다. 같은 파일의 shrink 는 narrow u8 x. . 로 u64 를 u8 로 줄인다. 200 은 들어가서 그대로 나오지만, 300 은 들어가지 않으므로 실행이 멈춘다 (E-VM-CAST). 멈추지 않기를 원하면 원하는 처분을 이름으로 고른다. narrow_sat 는 끝값 255 에 서고, narrow_wrap 은 C 처럼 감아서 44 를 준다. narrow_try 는 들어가지 않으면 none 을 준다.

문. narrow_wrap 이 C 와 같은 일을 한다면, 기본을 그렇게 두지 않은 이유가 무엇인가?

답. 감기가 의도였는지 실수였는지를 소스에 남기려는 것이다. 기본이 감기면 narrow u8 x. . 가 44 를 준 자리를 읽는 사람이 그것이 원하던 값인지 알 수 없다. 기본이 멈춤이면, 감기를 원한 저자는 narrow_wrap 이라고 적었을 것이다. 적힌 이름이 곧 저자의 의도에 대한 기록이다.

4.4 넘치면 멈춘다#

산술은 선언된 폭에서 일어난다. 결과가 폭에 들어가지 않으면 멈춘다(트랩).

examples/ch04/overflow.low

module overflow .
rem run: bump 254
rem trap: bump 255
rem run: bump_wrap 255
rem run: bump_sat 255

fn bump input a u8 . output u8 .
do
  return add a. 1 . .
end .

fn bump_wrap input a u8 . output u8 .
do
  return wrap_add a. 1 . .
end .

fn bump_sat input a u8 . output u8 .
do
  return sat_add a. 1 . .
end .

실행 결과

$ lowentc --run bump overflow.low 254
bump(254) = 255
$ lowentc --run bump_wrap overflow.low 255
bump_wrap(255) = 0
$ lowentc --run bump_sat overflow.low 255
bump_sat(255) = 255
$ lowentc --run bump overflow.low 255
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)

bump 254 는 255 로 들어가지만 bump 255 는 256 이 되어야 하므로 VM 이 E-VM-OVERFLOW 로 멈춘다. 네이티브도 같은 자리에서 멈추고 비영 종료 코드를 낸다(검증 스크립트는 둘 다 멈추는지를 확인한다). 처분을 바꾸려면 넓히기와 마찬가지로 이름을 고른다.

처분op넘치면
멈춤(기본)add · sub · mul실행이 멈춘다
감김wrap_add · wrap_sub · wrap_mul폭 안에서 돌아 감는다
포화sat_add · sat_sub · sat_mul그 폭의 끝값에 선다
값으로chk_add · chk_sub · chk_muloption 으로 알린다

표 4.2 — 넘침의 처분을 이름으로 고른다

“값으로” 알리는 쪽은 넘침을 프로그램이 다룰 수 있는 사건으로 만든다.

examples/ch04/chk.low

module chk .
rem run: safe_bump 255
rem run: safe_bump 7

fn safe_bump input a u8 . output u64 .
do
  let r option u8 chk_add a. 1 . .
  guard is_some r. . else return 999 . .
  return widen u64 some_value r. . . .
end .

실행 결과

$ lowentc --run safe_bump chk.low 255
safe_bump(255) = 999
$ lowentc --run safe_bump chk.low 7
safe_bump(7) = 8

chk_add a. 1 . 은 option u8 을 준다. 넘치면 none 이고, guard is_some r else … 가 그 경우를 먼저 처리한다. 이 모양은 11장에서 다시 만난다.

흔한 오해. 넘침을 검사하면 느려진다

모든 덧셈에 검사가 남는다면 그렇다. 그러나 계약이나 범위 타입으로 값의 범위가 증명되는 자리에서는 검사가 번역할 때 지워진다(14장). 반복 변수처럼 범위가 뻔한 자리도 컴파일러의 구간 분석이 걷어낸다. 남는 검사는 정말로 넘칠 수 있는 자리의 검사이고, 그 자리의 검사는 비용이 아니라 결함을 막는 값이다.

4.5 나눗셈과 나머지#

정수를 0 으로 나누면 멈춘다. 부호 있는 나눗셈은 0 쪽으로 버리고, 나머지 mod 의 부호는 나누는 수를 따른다.

examples/ch04/divide.low

module divide .
rem run: quot -7 2
rem run: modulo -7 2
rem trap: quot 1 0
rem run: safe_div 7 0
rem run: safe_div 7 2

fn quot input a i32 . input b i32 . output i32 .
do
  return div a. b. . .
end .

fn modulo input a i32 . input b i32 . output i32 .
do
  return mod a. b. . .
end .

fn safe_div input a u32 . input b u32 . output u32 .
do
  let nz option nonzero u32 nonzero_of b. . .
  guard is_some nz. . else return 0 . .
  return div_nz a. some_value nz. . . .
end .

실행 결과

$ lowentc --run quot divide.low -7 2
quot(-7, 2) = -3
$ lowentc --run modulo divide.low -7 2
modulo(-7, 2) = 1
$ lowentc --run safe_div divide.low 7 0
safe_div(7, 0) = 0
$ lowentc --run safe_div divide.low 7 2
safe_div(7, 2) = 3
$ lowentc --run quot divide.low 1 0
== ir diagnostics (1) ==
0:0 E-VM-DIV0: divide by zero

quot -7 2 는 −3 이고 modulo -7 2 는 1 이다. 나머지가 나누는 수의 부호를 따르므로, 양수 n 으로 나눈 mod h. n. . 은 언제나 0 이상 n 미만이다. 해시 테이블이 슬롯을 고를 때 idx slots. mod h. n. . . 의 경계 검사가 지워지는 근거가 이 성질이고, 이 성질은 Coq 로 증명되어 있다(40장).

부호 있는 나눗셈이 넘치는 경우는 MIN / −1 하나뿐이고, 이것도 멈춘다. C 에서는 미정의 동작이다.

0 이 아님을 한 번 확인하고 그 사실을 들고 다니는 길도 있다. nonzero_of b. . 는 option nonzero u32 를 주고, 그 안의 값은 div_nz 에 넘길 수 있다. div_nz 자리에는 0 검사가 남지 않는다. 검사를 없앤 것이 아니라 한 자리로 옮기고 그 결과를 타입에 실은 것이다.

4.6 참거짓은 수가 아니다#

bool 은 true 와 false 두 값이다. 정수를 조건 자리에 쓰면 거절된다.

examples/ch04/cond_bad.low

module cond_bad .
rem expect: E-TYPE-COND

fn nonzero_flag input n u32 . output u8 .
do
  if n. do return 1 . end .
  return 0 .
end .

실행 결과

$ lowentc --check cond_bad.low
cond_bad.low:6:0 E-TYPE-COND: a condition must be bool, not a number — this language has no truthiness (D12: no implicit int↔bool). Say what the test is: `ne x 0`

C 의 if (n) 은 “n 이 0 이 아니다” 로 읽히지만, 읽는 사람은 그것이 “n 이 있다” 인지 “n 이 참이다” 인지 문맥으로 짐작해야 한다. Lowent 는 묻는 것을 적게 한다 — if gt n 0 이다. 반대로 bool 을 수처럼 더할 수도 없다. and·or·not 은 참거짓만 받고, and 와 or 는 앞쪽만으로 답이 정해지면 뒤쪽을 계산하지 않는다.

4.7 비트 연산#

비트 연산은 값을 비트의 나열로 보고 다룬다. 기호 대신 낱말이다.

examples/ch04/bits.low

module bits .
rem run: mask 12 10
rem run: flip 12
rem run: spin 129 1
rem run: s_shr -8 1
rem run: u_shr 4294967288 1
rem run: ones 7

rem 12 = 0000_1100 · 10 = 0000_1010
fn mask input a u8 . input b u8 . output u8 . do return bit_and a. b. . . end .
fn flip input a u8 . output u8 . do return bit_not a. . . end .
fn spin input a u8 . input n u8 . output u8 . do return rotl a. n. . . end .
fn s_shr input a i32 . input n u32 . output i32 . do return shr a. n. . . end .
fn u_shr input a u32 . input n u32 . output u32 . do return shr a. n. . . end .
fn ones input a u8 . output u8 . do return count_ones a. . . end .

실행 결과

$ lowentc --run mask bits.low 12 10
mask(12, 10) = 8
$ lowentc --run flip bits.low 12
flip(12) = 243
$ lowentc --run spin bits.low 129 1
spin(129, 1) = 3
$ lowentc --run s_shr bits.low -8 1
s_shr(-8, 1) = -4
$ lowentc --run u_shr bits.low 4294967288 1
u_shr(4294967288, 1) = 2147483644
$ lowentc --run ones bits.low 7
ones(7) = 3

옮기는 칸 수가 타입의 폭 이상이면 멈춘다. C 에서는 미정의 동작인 자리다. 칸 수를 폭 안으로 감싸기를 원하면 wrap_shl·wrap_shr 을 쓴다.

칸 수는 부호 없는 정수 타입으로 적는다. s_shr 의 n 이 u32 인 까닭이다 — i32 로 적으면 번역이 거부된다 (E-SHIFT-SIGNED). 음수 칸 수에는 정해진 뜻이 하나로 없기 때문이다. 돌리기는 칸 수를 폭으로 나눈 나머지만큼 돌리므로 어느 정수든 받는다.

문. 비트 연산은 왜 expr 섬에서 중위로 쓸 수 없는가?

답. C 에서 a & b == c 는 a & (b == c) 로 묶인다. 오래 쓴 사람도 걸리는 함정이고, 언어마다 비트 연산의 우선순위가 다르다. 섬에 넣으면 읽기 쉬워지는 대신 찾아볼 것이 는다. 전위로 적으면 괄호가 답을 말하므로 틀릴 수 없다.

4.8 부동소수점#

f32 와 f64 는 IEEE 754 의 이진 32·64 비트 형식이다. 연산은 피연산자의 폭에서 일어나고, 정수와 부동소수점 수는 서로 자동으로 바뀌지 않는다. 셈 하나는 반올림 한 번이다 — 처리기는 곱하고 더하기를 한 명령(FMA)으로 합치지 않는다. 합치면 반올림이 한 번 줄어 0.1 × 10 − 1 이 0 이 아니게 된다. 바꾸기도 반올림 한 번이다 — cast f32 는 f64 나 정수를 가장 가까운 f32 로 반올림하고, f32 에 담기지 않을 만큼 큰 수는 무한대가 된다(cast f32 <f64 1e300> 은 inf).

examples/ch04/floats.low

module floats .
rem run: third 1.0
rem run: hyp 3.0 4.0

use math .

fn third input x f64 . output f64 .
do
  return div x. 3.0 . .
end .

fn hyp input a f64 . input b f64 . output f64 .
do
  return math.sqrt add mul a. a. . mul b. b. . . . .
end .

실행 결과

$ lowentc --run third floats.low 1.0
third(1.0) = 0.333333
$ lowentc --run hyp floats.low 3.0 4.0
hyp(3.0, 4.0) = 5.0

VM 은 부동소수점 결과를 짧게 보여 준다(0.333333). 부동소수점 수를 0 으로 나누면 멈추지 않고 IEEE 754 가 정한 대로 무한이 된다. sqrt·sin·exp 같은 초월 함수는 표준 라이브러리 math 에 있고(use math . 뒤 math.sqrt x. .), 부동소수점 전용이며 운영체제가 있는 기계에서만 쓸 수 있다. 이 함수들의 마지막 비트가 어떤 값인지는 기계와 셈 라이브러리가 정한다.

흔한 오해. 부동소수점 수도 eq 로 비교하면 된다

0.1 과 0.2 를 더한 값은 0.3 과 비트가 다르다. 부동소수점 수의 같음은 대개 “충분히 가깝다” 로 물어야 하고, 표준 라이브러리의 math 모듈이 그 물음(close)을 준다(32장). 부동소수점 수의 반올림· NaN·−0 같은 성질은 이 언어의 증명이 다루지 않은 자리이기도 하다(50장).

f32 와 크기 타입도 같은 규칙을 따른다.

examples/ch04/sizes.low

module sizes .
rem run: advance 5 -8
rem run: third32 1.0
rem run: same_third 1.0 1.0

rem usize · idiff — 길이 · 첨자와 그 차이. 폭은 구현이 정한다(16 비트 이상). 다른 정수와는 cast 로만 오간다
fn advance input n usize . input k idiff . output idiff .
  requires le n. 1000 . .
  requires ge k. -1000 . .
  requires le k. 1000 . .
do
  return add cast idiff n. . k. . .
end .

rem f32 — 32 비트 부동소수점. 메모리가 절반이고 정밀도는 약 일곱 자리
fn third32 input x f32 . output f32 . do
  return div x. 3.0 . .
end .

rem 같은 1/3 도 f32 와 f64 는 다른 수다 — 넓혀서 비교하면 거짓
fn same_third input x f32 . input y f64 . output bool . do
  let a f32 div x. 3.0 . .
  let b f64 div y. 3.0 . .
  return eq widen f64 a. . b. . .
end .

실행 결과

$ lowentc --run advance sizes.low 5 -8
advance(5, -8) = -3
$ lowentc --run third32 sizes.low 1.0
third32(1.0) = 0.333333
$ lowentc --run same_third sizes.low 1.0 1.0
same_third(1.0, 1.0) = 0

4.9 크기 타입 — usize 와 idiff#

길이 · 첨자 · 바이트 수는 usize 로 적는다. len · bitset_count · stack_count · capacity 가 내는 수가 usize 다. idiff 는 그 짝인 부호 있는 타입이고, 쓰임새는 두 위치의 차이 하나다. 이 둘은 다른 정수와 달리 폭이 이름에 없다.

4.9.1 폭은 구현이 정한다#

usize 와 idiff 의 폭은 16 비트 이상이고, 위로는 그 구현의 가장 넓은 정수 폭까지다. 이 처리기는 대상 기계의 주소 폭을 쓴다 — --target 이 정한다.

폭usize 의 가장 큰 값idiff 의 범위그런 기계
1665 535−32 768 … 32 767작은 마이크로컨트롤러 — word16(번역까지만)
324 294 967 295−2 147 483 648 … 2 147 483 647cortex_m 같은 32 비트 보드
64264−1−263 … 263−1x86_64 · arm64 · riscv64

표 4.3 — 폭에 따른 usize · idiff 의 범위

같은 소스가 이 셋 어디서나 번역되도록 쓰는 것이 목표다. 그래서 아래 규칙은 폭을 묻지 않고 선다.

4.9.2 다른 정수와 섞이지 않는다 — 건너는 자리는 cast#

usize 와 idiff 는 다른 어떤 정수 타입과도 자동으로 바뀌지 않는다. u8 에서 usize 로도, usize 에서 u64 로도 그렇다. 셈과 견줌에서도 섞이지 않는다 — usize 와 u64 를 더하거나 견주려면 한쪽을 먼저 옮긴다. 옮기는 낱말은 cast 하나다.

cast 는 값이 대상 타입에 들어가면 그 값을 그대로 내고, 들어가지 않으면 멈춘다(E-VM-CAST). 조용히 자르지 않는다.

widen 과 narrow 는 usize · idiff 에 쓸 수 없다. 이 두 낱말은 방향을 말하는데, usize 에서 u32 로 가는 것이 64 비트 기계에서는 좁히기이고 16 비트 기계에서는 넓히기다. 방향이 기계에 달린 변환을 방향의 낱말로 적으면 그 글은 한 기계에서만 참이다. cast 는 방향을 묻지 않는다.

수 리터럴은 그대로 쓴다 — add i. 1 . · idx xs. 0 . 의 1 과 0 은 자리의 타입을 따른다.

examples/ch04/usize_cast.low

module usize_cast .
rem run: last [3,4,5]
rem run: bucket [1,2,3,4] 1000003
rem run: from_outside [9,9,9] 2
rem run: from_outside [9,9,9] 7
rem run: gap 3 10
rem run: packed [5,6,7,8] 3

rem 길이와 첨자는 usize 로 끝까지 들고 다닌다 --- 변환이 하나도 없다
fn last input xs slice u8 . output u8 .
  requires gt len xs. . 0 . .
do
  let n usize len xs. . .
  return idx xs. sub n. 1 . . .
end .

rem 길이가 아닌 수(해시)와 만나는 자리 --- 넓은 쪽(u64)에서 셈하고, 첨자로 쓸 때 cast 한다
fn bucket input xs slice u8 . input h u64 . output u8 .
  requires gt len xs. . 0 . .
do
  let slot u64 mod h. cast u64 len xs. . . . .
  return idx xs. cast usize slot. . . .
end .

rem 바깥에서 온 수는 멈추지 않는 narrow_try 로 받는다 --- 안 들어가면 none 이고 프로그램이 정한다
fn from_outside input xs slice u8 . input want u64 . output u8 . do
  let p option usize narrow_try usize want. . .
  guard is_some p. . else return 255 . .
  let i usize some_value p. . .
  guard lt i. len xs. . . else return 254 . .
  return idx xs. i. . .
end .

rem 두 위치의 차이는 idiff --- 범위가 usize 의 절반이라 cast 가 지킨다
fn gap input a usize . input b usize . output idiff . do
  return sub cast idiff a. . cast idiff b. . . .
end .

rem 첨자를 좁은 칸(u8)에 줄여 담는다 --- 넣을 때와 꺼낼 때 cast 를 적는다
fn packed input xs slice u8 . input i usize . output u8 .
  requires lt i. len xs. . . .
do
  let small u8 cast u8 i. . .
  return idx xs. cast usize small. . . .
end .

실행 결과

$ lowentc --run last usize_cast.low [3,4,5]
last([3,4,5]) = 5
  arg0 (written) = [3,4,5]
$ lowentc --run bucket usize_cast.low [1,2,3,4] 1000003
bucket([1,2,3,4], 1000003) = 4
  arg0 (written) = [1,2,3,4]
$ lowentc --run from_outside usize_cast.low [9,9,9] 2
from_outside([9,9,9], 2) = 9
  arg0 (written) = [9,9,9]
$ lowentc --run from_outside usize_cast.low [9,9,9] 7
from_outside([9,9,9], 7) = 254
  arg0 (written) = [9,9,9]
$ lowentc --run gap usize_cast.low 3 10
gap(3, 10) = -7
$ lowentc --run packed usize_cast.low [5,6,7,8] 3
packed([5,6,7,8], 3) = 8
  arg0 (written) = [5,6,7,8]

4.9.3 멈추지 않고 옮기기#

cast 가 멈추면 프로그램이 끝난다. 값이 프로그램 바깥에서 왔다면 — 파일의 머리 칸, 네트워크로 받은 길이, 사용자가 친 수 — 그것이 들어가지 않는다는 것은 결함이 아니라 거절할 입력이다. 그런 자리에는 무엇을 할지를 이름이 고르는 셋을 쓴다.

낱말하는 일
cast usize x. .멈춘다 — 들어가지 않는 것이 결함일 때
narrow_try usize x. .option usize 를 낸다 — 들어가지 않으면 none
narrow_sat usize x. .가장 큰 값에 붙인다
narrow_wrap usize x. .아랫비트만 남긴다 — 해시처럼 감기는 것이 뜻일 때만

표 4.4 — usize 로 옮길 때 들어가지 않으면

4.9.4 폭에 따라 조심할 것#

같은 글이 16 · 32 · 64 비트 어디서나 서게 하려면 다음을 지킨다.

반례. usize 와 다른 정수를 그대로 섞는다

examples/ch04/mistake_usizemix.low

module mistake_usizemix .
rem expect: E-TYPE-NOMINAL

rem ✘ usize 와 u64 를 그대로 더한다 --- 어느 폭에서 셈할지를 적지 않았다
fn end_of input start usize . input more u64 . output u64 . do
  return add start. cast usize more. . . .
end .

실행 결과

$ lowentc --check mistake_usizemix.low
mistake_usizemix.low:6:0 E-TYPE-NOMINAL: these are NOMINALLY DISTINCT types with the same representation — a `newtype` (or usize/idiff) is not interchangeable with its base or with another newtype. Convert explicitly with `cast <type> <value>` (RFC-0002 §6.3.1)

start 는 메모리 안의 위치이고 more 는 u64 다. 어느 폭에서 더할지를 글이 말하지 않았다. add cast u64 start. . more. . 로 넓은 쪽에서 더한다고 적는다. 결과를 다시 위치로 쓸 때는 cast usize 로 내린다.

반례. usize 를 widen 으로 옮긴다

examples/ch04/mistake_usizewiden.low

module mistake_usizewiden .
rem expect: E-TYPE-NOMINAL

rem ✘ usize 를 widen 으로 옮긴다 --- 넓어지는지는 기계에 달렸다. cast 로 적는다
fn as_u64 input n usize . output usize . do
  return widen u64 n. . .
end .

실행 결과

$ lowentc --check mistake_usizewiden.low
mistake_usizewiden.low:6:0 E-TYPE-NOMINAL: `usize` / `idiff` convert with `cast` only — their width is the implementation's (16 bits up to the widest integer), so whether this WIDENS or NARROWS depends on the target, and `widen` promises a direction. Write `cast <type> <value>`: it asks no direction and stops when the value does not fit
mistake_usizewiden.low:6:0 E-TYPE-NOMINAL: these are NOMINALLY DISTINCT types with the same representation — a `newtype` (or usize/idiff) is not interchangeable with its base or with another newtype. Convert explicitly with `cast <type> <value>` (RFC-0002 §6.3.1)

64 비트 기계만 보면 usize 에서 u64 로는 늘 넓어지는 것 같지만, 폭은 구현이 정한다. cast u64 n. . 로 적는다.

4.10 명시적 변환은 cast 가 맡는다#

값을 다른 타입으로 읽는 일은 소스에 적힌 곳에서만 일어난다. 컴파일러가 스스로 하는 변환은 넓히기 하나이고, 그 밖의 변환은 저자가 낱말로 적는다. 그 중심에 cast <타입> <값> 이 있다. 이 언어에서 명시적 변환은 cast 가 맡는다.

낱말맡는 자리값이 안 맞으면
widen값이 그대로 들어가는 넓히기 — 컴파일러도 스스로 한다일어나지 않는다
narrow같은 갈래 안에서 폭을 줄인다멈춘다
cast그 밖의 모든 변환 — 부호 바꾸기 · 정수와 부동소수점 사이 · usize·idiff 와 다른 정수 사이 · 참거짓에서 수로 · 열거와 수 사이멈춘다

표 4.5 — 변환을 적는 세 낱말

examples/ch04/cast_kinds.low

module cast_kinds .
rem run: sign 200
rem run: to_float 7
rem run: to_int 7.9
rem run: bool_num 9
rem run: level_num 2
rem trap: sign 40000

rem 부호를 바꾼다 --- u32 의 값을 i16 으로 읽는다. 들어가지 않으면 멈춘다
fn sign input n u32 . output i16 . do
  return cast i16 n. . .
end .

rem 갈래를 넘는다 --- 정수를 부동소수점 수로
fn to_float input n u32 . output f64 . do
  return cast f64 n. . .
end .

rem 부동소수점 수를 정수로 --- 소수부는 0 쪽으로 버린다
fn to_int input x f64 . output i64 . do
  return cast i64 x. . .
end .

rem 참거짓을 수로 --- 참은 1, 거짓은 0
fn bool_num input n u64 . output u8 . do
  return cast u8 gt n. 5 . . .
end .

enum level do
  low .
  mid .
  high .
end .

fn pick input k u8 . output level . do
  if eq k. 0 . do return low. . end .
  if eq k. 1 . do return mid. . end .
  return high. .
end .

rem 열거를 수로 --- 갈래의 번호(첫 갈래 1)
fn level_num input k u8 . output u8 . do
  return cast u8 pick k. . . .
end .

실행 결과

$ lowentc --run sign cast_kinds.low 200
sign(200) = 200
$ lowentc --run to_float cast_kinds.low 7
to_float(7) = 7.0
$ lowentc --run to_int cast_kinds.low 7.9
to_int(7.9) = 7
$ lowentc --run bool_num cast_kinds.low 9
bool_num(9) = 1
$ lowentc --run level_num cast_kinds.low 2
level_num(2) = 3
$ lowentc --run sign cast_kinds.low 40000
== ir diagnostics (1) ==
0:0 E-VM-CAST: value does not fit the target width (use narrow_wrap / narrow_sat / narrow_try)

어느 쪽으로 읽을지 갈릴 수 있는 변환은 저절로 일어나지 않는다. 같은 비트를 두 가지 수로 읽을 수 있거나, 기계에 따라 넓어지기도 하고 좁아지기도 하거나, 값의 갈래가 바뀌는 변환이 그렇다. 앞으로 언어에 들어오는 타입과 변환도 같은 규칙을 따른다 — 애매하면 cast 로 적는다.

문. cast 를 매번 적는 것은 번거롭지 않은가?

답. 적는 품은 든다. 그 대신 읽는 사람이 값이 바뀔 수 있는 자리를 모두 눈으로 찾을 수 있다. 프로그램에서 cast 를 찾아 읽으면 타입이 바뀌는 곳을 다 본 것이다. C 의 정수 승격이나 부호 섞임처럼 컴파일러가 몰래 바꾸는 자리가 하나라도 있으면 그 약속은 깨진다.

4.11 흔한 실수#

수의 실수는 번역을 통과하고 실행 중에 드러나는 것이 많다. 다른 언어라면 조용히 틀린 값이 나올 자리에서 Lowent 는 멈춘다. 멈춘 자리가 곧 고칠 자리다.

반례. 둘을 더한 뒤 반으로 나눠 가운데 값을 구한다

examples/ch04/mistake_midpoint.low

module mistake_midpoint .
rem trap: mid 200 100

fn mid input a u8 . input b u8 . output u8 .
do
  rem ✘ 둘을 먼저 더하면 300 이 되어 u8 에 들어가지 않는다
  return div add a. b. . 2 . .
end .

실행 결과

$ lowentc --run mid mistake_midpoint.low 200 100
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)

200 + 100 은 300 이고 u8 은 255 까지다. 나누기 전에 이미 넘친다. 이진 탐색의 (lo + hi) / 2 가 수십 년 동안 여러 라이브러리에서 터진 바로 그 결함이다. C 라면 감긴 값 44 를 반으로 나눈 22 가 조용히 나왔을 것이다. 고치는 법은 계산을 넓은 폭에서 하는 것이다.

examples/ch04/midpoint_fixed.low

module midpoint_fixed .
rem run: mid 200 100
rem run: mid 255 255

fn mid input a u8 . input b u8 . output u8 .
do
  rem 한 폭 넓혀서 더한다 --- u16 은 510 까지 담는다
  let both u16 add widen u16 a. . widen u16 b. . . .
  rem 둘로 나눈 값은 다시 u8 에 들어간다는 것을 우리는 알고, narrow 가 그것을 확인한다
  return narrow u8 div both. 2 . . .
end .

실행 결과

$ lowentc --run mid midpoint_fixed.low 200 100
mid(200, 100) = 150
$ lowentc --run mid midpoint_fixed.low 255 255
mid(255, 255) = 255

widen u16 으로 올려서 더하고, 나눈 뒤 narrow u8 로 내린다. 가운데 값은 언제나 u8 에 들어가므로 좁히기가 멈출 일은 없고, 혹시 계산을 잘못 바꾸면 그 자리에서 멈춰 알려 준다. (lo <= hi 가 보장되면 add lo. div sub hi. lo. . 2 . . 도 넘치지 않는다.)

반례. 부호 없는 수에서 큰 수를 뺀다

examples/ch04/mistake_usub.low

module mistake_usub .
rem trap: gap 3 5

fn gap input a u64 . input b u64 . output u64 .
do
  rem ✘ 부호 없는 수는 0 아래로 내려가지 않는다 --- 3 − 5 는 음수가 아니라 멈춤이다
  return sub a. b. . .
end .

실행 결과

$ lowentc --run gap mistake_usub.low 3 5
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)

u64 는 0 아래가 없다. 3 − 5 는 −2 가 아니라 넘침이고, 그래서 멈춘다(C 라면 18446744073709551614 가 나온다). 차이의 크기가 알고 싶으면 큰 쪽에서 작은 쪽을 뺀다 — if ge a. b. . do return sub a. b. . . end . return sub b. a. . . — 음수가 뜻이 있는 값이면 처음부터 i64 로 계산한다.

반례. 반복 변수를 너무 좁은 타입으로 둔다

examples/ch04/mistake_narrowloop.low

module mistake_narrowloop .
rem expect: E-TYPE-WIDTH

fn tally output u64 .
do
  rem ✘ u8 반복 변수는 255 다음이 없다 --- 256 번째 `add i 1` 에서 멈춘다
  var i u8 0 .
  var n u64 0 .
  while lt i. 256 . do
    set n. add n. 1 . .
    set i. add i. 1 . .
  end .
  return n. .
end .

실행 결과

$ lowentc --check mistake_narrowloop.low
mistake_narrowloop.low:9:0 E-TYPE-WIDTH: this comparison holds a value that the other side's type cannot hold, so the answer is the same for every input — the comparison decides nothing. A name declared `u8` is at most 255, `u16` at most 65535, `i8` at most 127; a literal beyond that is out of range exactly as it would be in an assignment, where the tool has always refused it. Widen the name's type, or compare against a value the type can reach. (This is how a loop written `while lt i 256 .` over a `u8` counter never ends)
mistake_narrowloop.low:9:0 E-TYPE-WIDTH: value does not fit the declared type (a literal out of range, or a narrowing — use narrow / narrow_wrap / narrow_sat / round_to)

u8 은 256 에 닿을 수 없으니 lt i. 256 . 은 영원히 참이다. C 라면 i 가 255 에서 0 으로 감겨 끝나지 않는 반복이 되었을 것이다. Lowent 는 그 비교를 번역 때 E-TYPE-WIDTH 로 거절한다 — 자리에 안 들어가는 값과의 비교는 입력과 무관하게 답이 같으므로 아무것도 가르지 않는다. 반복 변수는 셀 수 있는 가장 큰 값보다 넓은 타입(u64)으로 둔다.

반례. 부동소수점 수를 정수 리터럴로 계산한다

examples/ch04/mistake_floatint.low

module mistake_floatint .
rem expect: E-TYPE-MIX

fn half input x f64 . output f64 .
do
  rem ✘ f64 를 정수 리터럴 2 로 나눴다 --- 부동소수점 자리에는 2.0 을 쓴다
  return div x. 2 . .
end .

실행 결과

$ lowentc --check mistake_floatint.low
mistake_floatint.low:7:0 E-TYPE-MIX: a floating-point value and an INTEGER literal are mixed in one operation. Floating point and integers do not convert implicitly (§6.2.5), so write the literal as a float (`2.0`) — or convert the other side. It used to pass `--check` and stop at run time (`E-VM-TYPE: arithmetic needs ints`)

정수와 부동소수점 수는 서로 자동으로 바뀌지 않는다. f64 를 나누려면 리터럴도 부동소수점 수로 2.0 이라고 적는다. 섞어 적으면 E-TYPE-MIX 로 번역 때 거절한다 — 전에는 실행 중에 E-VM-TYPE 으로 멈췄다.

--run 인자로 u64 의 가장 큰 값을 넘겨도 그대로 들어간다.

examples/ch04/runmax.low

module runmax .
rem run: echo 18446744073709551615
rem test

rem u64 의 위쪽 절반도 `--run` 인자로 그대로 들어간다(1.20.0 부터)
fn echo input n u64 . output u64 .
do
  return n. .
end .

rem 경계값은 시험 안에서 리터럴로도 적을 수 있다
test largest_u64
do
  let m u64 18446744073709551615 .
  expect gt m. 9223372036854775807 . .
  expect eq div m. 2 . 9223372036854775807 . .
end .

실행 결과

$ lowentc --run echo runmax.low 18446744073709551615
echo(18446744073709551615) = 18446744073709551615
$ lowentc --test runmax.low
  [PASS] largest_u64
== tests: 1 run, 1 passed, 0 FAILED ==

2026-09-16 까지는 이 수가 조용히 9223372036854775807 로 잘려 들어갔고 — 다른 수를 준 것이고, 두 뒤끝이 똑같이 그랬으므로 대조로도 드러나지 않았다 — 그 뒤 1.19.0 까지는 경계에서 거절됐다(E-VM-CONTRACT). 1.20.0 부터는 0 부터 2^64 − 1 까지를 부호 없이 받는다. -1 과 2^64 는 여전히 거절된다. 경계값 시험은 largest_u64 처럼 프로그램 안의 리터럴로도 적을 수 있다.

흔한 오해. f32 와 f64 는 같은 수를 담는다

examples/ch04/mistake_f32literal.low

module mistake_f32literal .
rem run: stored
rem run: computed

rem ✘ 리터럴을 그대로 담은 f32 — 이 판에서는 32 비트로 반올림되지 않아 f64 의 0.1 과 같다고 나온다
fn stored output bool . do
  let a f32 0.1 .
  let b f64 0.1 .
  return eq widen f64 a. . b. . .
end .

rem 셈을 한 번 거친 f32(0.1 + 0)는 32 비트로 반올림되어 다르다고 나온다
fn computed output bool . do
  let a f32 0.1 .
  let c f32 add a. 0.0 . .
  let b f64 0.1 .
  return eq widen f64 c. . b. . .
end .

실행 결과

$ lowentc --run stored mistake_f32literal.low
stored() = 0
$ lowentc --run computed mistake_f32literal.low
computed() = 0

stored 와 computed 가 둘 다 거짓이다. f32 자리에 담긴 0.1 은 32 비트로 반올림되므로, 넓혀도 f64 의 0.1 과 다른 수다. 2026-09-16 까지는 리터럴(과 --run 으로 건넨 인자)이 반올림되지 않아 stored 가 참이었다 — 같은 타입의 값이 어디서 왔느냐에 따라 다른 정밀도를 갖던 자리다. 정밀도가 중요한 코드는 폭을 적어서 고르고, 두 폭을 견줄 때는 어느 쪽으로 옮길지 widen·narrow 로 적는다.

흔한 오해. div 7 2 . 는 3.5 다

정수 나눗셈은 몫만 남기고 소수점 아래를 버린다.

examples/ch04/intdiv.low

module intdiv .
rem run: avg2 3 4
rem run: avg_f 3.0 4.0

fn avg2 input a u64 . input b u64 . output u64 .
do
  rem 정수 나눗셈은 소수점 아래를 버린다 --- 7 / 2 는 3
  return div add a. b. . 2 . .
end .

fn avg_f input a f64 . input b f64 . output f64 .
do
  rem 소수점 아래가 필요하면 처음부터 부동소수점 수로 계산한다
  return div add a. b. . 2.0 . .
end .

실행 결과

$ lowentc --run avg2 intdiv.low 3 4
avg2(3, 4) = 3
$ lowentc --run avg_f intdiv.low 3.0 4.0
avg_f(3.0, 4.0) = 3.5

avg2 3 4 는 3 이다. 소수점 아래가 필요하면 입력부터 부동소수점 수로 받아 2.0 으로 나눈다. 반올림이 필요하면 add n. div d. 2 . . 를 더한 뒤 나누는 식으로 적어서 고른다 — 언어가 대신 반올림하지 않는다.

부동소수점 수의 나머지는 정수의 mod 와 이름을 갈라 math.fmod 로 적는다.

examples/ch04/floatmod.low

module floatmod .
rem run: rest
rem run: whole_rest 7 2

use math .

rem 부동소수점 수의 나머지는 `fmod` --- 정수의 `mod` 와 이름을 가른다
fn rest output f64 .
do
  return math.fmod 7.5 2.0 . .
end .

fn whole_rest input a u64 . input b u64 . output u64 .
  requires gt b. 0 . .
do
  return mod a. b. . .
end .

실행 결과

$ lowentc --run rest floatmod.low
rest() = 1.5
$ lowentc --run whole_rest floatmod.low 7 2
whole_rest(7, 2) = 1

math.fmod 7.5 2.0 . 은 1.5, mod 7 2 . 는 1 이다. 한 이름에 두 갈래를 싣지 않는 것은 div 와 같은 까닭이다 — 무엇을 셈하는지가 이름에서 보인다.

4.12 이 장의 문법을 한눈에#

모양뜻왜 이렇게
u8 … u64 · i8 … i64폭이 이름에 적힌 정수기계마다 크기가 달라지지 않게
f32 · f64IEEE 754 부동소수점정수와 섞이지 않는다 — 리터럴도 2.0
widen u64 x. .넓히기(값을 잃지 않음)자동으로도 일어나지만 보이게 적을 수 있다
narrow u8 x. .좁히기 — 안 들어가면 멈춘다값이 조용히 바뀌지 않게
narrow_sat · narrow_wrap · narrow_try끝값에 서기 · 감기 · option 으로처분을 이름으로 고른다
cast i16 n. .그 밖의 모든 변환 — 안 들어가면 멈춘다값이 바뀌는 자리가 소스에 다 보이게
add · sub · mul넘치면 멈추는 산술넘침은 기본이 결함
wrap_add · sat_add · chk_add감김 · 포화 · option의도한 처분이 소스에 남는다
div · mod몫(0 쪽으로 버림) · 나머지(나누는 수의 부호)0 나누기와 MIN / −1 은 멈춘다
nonzero_of b. . · div_nz0 이 아님을 한 번 확인해 타입에 싣기검사를 한 자리로 옮긴다
bit_and · bit_or · bit_xor · bit_not비트별 논리기호 우선순위의 함정이 없다
shl · shr · rotl · rotr옮기기 · 돌리기폭 이상 옮기면 멈춘다
call_builtin clmul_lo · call_builtin clmul_hi캐리 없는 곱셈(빌트인 연산) — 답은 낱말 둘기계 명령이 있으면 그것으로, 없으면 셈으로
call_builtin mul_hi(bigint64.mul_hi)u64 곱의 위쪽 64 비트(빌트인 연산) — 아래쪽은 wrap_mulC 컴파일러에 128 비트 정수가 있으면 곱 하나, 없으면 32 비트 곱 넷으로
count_ones · leading_zeros · trailing_zeros · byte_swap비트 세기 · 바이트 뒤집기흔한 일은 이름 하나로
true · false · and · or · not참거짓과 그 논리수를 조건에 쓰지 않는다
usize · idiff · f32길이 · 첨자의 정수(폭은 구현이 정한다) · 32 비트 부동소수점길이와 위치를 적을 때 · 정밀도보다 양이 중요할 때
cast usize x · cast u64 nusize · idiff 와 다른 정수 사이를 옮긴다폭이 기계마다 달라도 같은 뜻이게

표 4.6 — 수의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나

복습 정리

정수 타입은 부호와 폭을 이름에 적고 어디서나 같은 크기다. 넓히기는 자동이고 좁히기는 narrow 로 적는다. 넘침·0 나누기·범위 밖 좁히기·폭 이상의 옮기기는 멈추며, 다른 처분(감김·포화·값으로)은 wrap_·sat_·chk_·narrow_try 같은 이름으로 고른다. mod 의 부호는 나누는 수를 따른다. 참거짓은 수가 아니고, 비트 연산은 낱말로 적으며 폭과 부호가 뜻을 정한다.