2 값과 스택
먼저 알아야 할 것: 1장(코드의 차례, pnx, 주석).
이 장이 답하는 물음
- 스택이란 무엇이고, 값과 낱말은 그 위에서 어떻게 움직이는가?
- 보통의 수식을 Pinax 의 차례로 바꾸려면 어떻게 하는가?
- 처리기는 스택을 언제 검사하고, 무엇을 거절하는가?
- Pinax 에는 어떤 값이 있고, 정수가 넘치면 무슨 일이 일어나는가?
이 장은 Pinax 의 바탕인 스택을 다룬다. 다른 언어를 써 본 사람에게는 가장 낯선 부분이지만, 규칙은 몇 개뿐이다. 이 장의 예제를 표를 따라 한 줄씩 짚어 보면 나머지 장은 훨씬 쉽게 읽힌다.
2.1 스택이란
스택(stack)은 값을 쌓아 두는 자리다. 접시를 쌓는 것과 같다. 새 접시는 맨 위에 올리고, 꺼낼 때도 맨 위에서 꺼낸다. 가운데 있는 접시를 바로 빼지는 않는다.
Pinax 의 코드는 이 스택 위에서 돈다. 처리기는 코드를 왼쪽에서 오른쪽으로, 위에서 아래로 토큰 하나씩 읽고, 토큰마다 둘 중 하나를 한다.
- 값이면(
2,"abc",true) 스택의 맨 위에 올린다. - 낱말이면(
add,io.println) 그 낱말에게 일을 시킨다. 낱말은 스택의 맨 위에서 필요한 만큼 값을 내리고, 일을 하고, 결과가 있으면 맨 위에 올린다.
2 3 add 를 이 규칙대로 따라가 보자. 이 책은 이런 표를 자주 쓴다. 스택은 맨 위를 오른쪽에 적고, — 는 빈 스택이다.
| 토큰 | 그 뒤의 스택 | 일어난 일 |
|---|---|---|
2 | 2 | 값이다. 올린다. |
3 | 2 3 | 값이다. 2 위에 올린다. 이제 맨 위는 3 이다. |
add | 5 | 낱말이다. 맨 위의 두 값 2 와 3 을 내려 더하고, 결과 5 를 올린다. |
다른 언어의 2 + 3 이 Pinax 에서는 2 3 add 다. 값을 먼저 적고 일을 뒤에 적는 이것이 1장에서 본 뒤붙이기 표기다. 처음에는 거꾸로 보이지만, “필요한 것을 먼저 늘어놓고 일을 시킨다” 고 읽으면 자연스럽다.
2.2 스택에서 계산하기
02-stack.pnx
use io
use str
2 3 add 4 mul str.to_str io.println rem (2 + 3) * 4
2 3 4 mul add str.to_str io.println rem 2 + 3 * 4
10 3 sub str.to_str io.println rem the first value is on the left: 10 - 3
10 3 swap sub str.to_str io.println rem swap turns it round: 3 - 10
출력
$ pnx 02-stack.pnx
20
14
7
-7
첫 줄 2 3 add 4 mul 을 표로 따라가 보자.
| 토큰 | 그 뒤의 스택 | 일어난 일 |
|---|---|---|
2 | 2 | 올린다. |
3 | 2 3 | 올린다. |
add | 5 | 두 값을 더한다. |
4 | 5 4 | 올린다. |
mul | 20 | 두 값을 곱한다. |
str.to_str | "20" | 정수를 글로 바꾼다. 값 하나를 내리고 하나를 올린다. |
io.println | — | 글을 출력한다. 스택이 빈다. |
둘째 줄 2 3 4 mul add 는 값 셋을 먼저 쌓는다. mul 은 맨 위의 두 값 3 과 4 를 곱하고, add 는 남은 2 와 그 결과를 더한다. 그래서 다.
| 토큰 | 그 뒤의 스택 | 일어난 일 |
|---|---|---|
2 3 4 | 2 3 4 | 값 셋을 차례로 올린다. |
mul | 2 12 | 맨 위의 3 과 4 를 곱한다. 2 는 아래에 그대로 있다. |
add | 14 | 2 와 12 를 더한다. |
괄호가 없어도 계산의 차례가 글의 차례 그대로다. 무엇을 먼저 계산할지는 낱말을 적는 자리가 정한다.
2.3 수식을 뒤붙이기로 바꾸기
익숙한 수식은 다음 차례로 옮기면 된다.
- 괄호가 있으면 안쪽부터 옮긴다.
- 연산 하나는 “왼쪽 값, 오른쪽 값, 연산 낱말” 의 차례로 적는다: 는
a b sub다. - 곱하기와 나누기를 더하기와 빼기보다 먼저 옮긴다. 산수의 차례 그대로다.
- 옮긴 조각은 다시 하나의 값으로 보고, 바깥 연산의 왼쪽이나 오른쪽 자리에 넣는다.
라면, 왼쪽 괄호가 8 3 sub, 오른쪽 괄호가 2 4 add 이고, 두 조각 뒤에 mul 을 붙여 8 3 sub 2 4 add mul 이 된다.
02-infix.pnx
use io
use str
rem (8 - 3) * (2 + 4): each bracket first, then the operation after its two values
8 3 sub 2 4 add mul str.to_str io.println rem 5 * 6 = 30
rem 100 - 4 * 5 * 2: the multiplications first, as in arithmetic
100 4 5 mul 2 mul sub str.to_str io.println rem 100 - 40 = 60
rem (1 + 2 + 3 + 4) / 2: add as you go
1 2 add 3 add 4 add 2 div str.to_str io.println rem 10 / 2 = 5
rem 12 * 12 + 1: dup copies 12 instead of writing it twice
12 dup mul 1 add str.to_str io.println rem 145
출력
$ pnx 02-infix.pnx
30
60
5
145
| 토큰 | 그 뒤의 스택 | 일어난 일 |
|---|---|---|
8 3 | 8 3 | 왼쪽 괄호의 두 값. |
sub | 5 | . |
2 4 | 5 2 4 | 오른쪽 괄호의 두 값. 5 는 아래에서 기다린다. |
add | 5 6 | . |
mul | 30 | 두 괄호의 결과를 곱한다. |
문답
물음. 어떤 값이 어디에 쓰일지 헷갈린다. 쉬운 방법은?
답. 한 줄에 한 덩어리만 계산하고, 결과에 이름을 붙인다(4장의 지역 변수). 8 3 sub to left 처럼 쓰면 스택을 머릿속에 오래 들고 있을 필요가 없다. Pinax 는 스택을 짧게 쓰고, 길어지면 이름을 쓰라고 권한다.
2.4 차례가 중요한 낱말
더하기와 곱하기는 두 값의 차례가 바뀌어도 결과가 같다. 빼기, 나누기, 나머지, 비교는 다르다. 규칙은 하나다: 먼저 올린 값이 왼쪽이다. 10 3 sub 는 이고 3 10 lt 는 “ 인가?” 이다.
02-order.pnx
use io
use str
10 3 sub str.to_str io.println rem 10 - 3 = 7: the value pushed first is on the left
10 3 div str.to_str io.println rem 10 / 3 = 3 (whole numbers: the rest is cut off)
10 3 mod str.to_str io.println rem the rest of 10 / 3 is 1
3 10 lt if "3 is less than 10" io.println end rem lt asks: first < second?
출력
$ pnx 02-order.pnx
7
3
1
3 is less than 10
div 는 정수 나눗셈이라 나머지를 버린다. 버린 나머지는 mod 가 준다.
2.5 스택을 고치는 낱말 다섯
값을 계산하지 않고 스택의 모양만 바꾸는 낱말이 다섯 있다. 표의 a b 는 아무 값이고, 오른쪽이 맨 위다.
| 낱말 | 앞 | 뒤 | 하는 일 |
|---|---|---|---|
dup | a | a a | 맨 위 값을 하나 더 만든다. |
drop | a | — | 맨 위 값을 버린다. |
swap | a b | b a | 위의 두 값의 자리를 바꾼다. |
over | a b | a b a | 둘째 값을 하나 더 만들어 맨 위에 올린다. |
nip | a b | b | 둘째 값을 버린다. |
02-stack-words.pnx
use io
use str
rem dup: a becomes a a
7 dup mul str.to_str io.println rem 7 * 7 = 49
rem drop: a b becomes a
1 2 drop str.to_str io.println rem 2 is thrown away; 1 is printed
rem swap: a b becomes b a
1 2 swap sub str.to_str io.println rem 2 - 1 = 1
rem over: a b becomes a b a
5 8 over sub str.to_str io.println rem 8 - 5 = 3 is printed; 5 is still there
drop rem so it is thrown away here
rem nip: a b becomes b
5 8 nip str.to_str io.println rem 8
출력
$ pnx 02-stack-words.pnx
49
1
1
3
8
over 의 줄을 따라가 보자. over 는 아래 값을 복사하므로 5 는 그대로 남는다. 그래서 출력한 뒤에 drop 으로 버려야 한다 — 그러지 않으면 프로그램 끝에 값이 남아 거절된다.
| 토큰 | 그 뒤의 스택 | 일어난 일 |
|---|---|---|
5 8 | 5 8 | 두 값을 올린다. |
over | 5 8 5 | 둘째 값 5 를 복사해 맨 위에 올린다. |
sub | 5 3 | . 맨 위의 둘만 쓴다. |
str.to_str io.println | 5 | 3 을 출력한다. 5 가 남았다. |
drop | — | 남은 5 를 버린다. |
흔한 오해: 스택의 세 번째 값을 맨 위로 가져오는 rot 이 있다
rot 이나 pick 처럼 깊은 곳의 값을 꺼내는 낱말은 일부러 두지 않았다. 값이 셋을 넘으면 스택을 머릿속으로 따라가기 어렵기 때문이다. 그럴 때는 값에 이름을 붙인다 (3장의 이름 있는 입력, 4장의 지역 변수).02-rot.pnx
use io
use str
1 2 3 rot add add str.to_str io.println rem there is no rot: name the values instead
거절
$ pnx 02-rot.pnx
02-rot.pnx:3:7: this name is not declared before this place
2.6 값의 타입
02-values.pnx
use io
use str
use fmt
42 str.to_str io.println
-7 str.to_str io.println
0xff str.to_str io.println rem 255
1_000_000 str.to_str io.println
'A' str.to_str io.println rem a character is a u8, its byte
3 4 lt if "3 < 4 is true" else "false" end io.println
"a string" str.len str.to_str io.println
255 fmt.hex io.println
출력
$ pnx 02-values.pnx
42
-7
255
1000000
65
3 < 4 is true
8
ff
이 책에서 주로 쓰는 타입은 넷이다.
int— 64 비트 정수. 십진, 십육진(0xff), 이진(0b101)으로 적고 밑줄로 자리를 가를 수 있다. 문자'A'는 그 바이트의 값인u8이다.u8u16u32u64i8i16i32는 그 폭의 정수 타입으로,255 u8처럼int뒤에 이름을 써서 만들고raw로int를 되찾는다.int와 섞어 셈하지 않는다.bool—true와false. 비교 낱말(lteq…)이 내고if가 받는다.str— 문자열. 5장에서 자세히 본다.err— 실패의 까닭. 8장에서 본다.
문답
물음. int 와 bool 은 서로 바뀌는가?
답. 아니다. 1 은 참이 아니고, true 에 더할 수도 없다. 조건 자리에는 bool 만, 산술에는 int 만 온다.
2.7 처리기는 스택을 미리 안다
처리기는 프로그램을 돌리기 전에, 적재할 때 이미 모든 자리에서 스택에 값이 몇 개 있고 각각 어떤 타입인지 안다. 위의 표를 처리기가 미리 그려 보는 셈이다. 그래서 스택에 관한 실수는 그 줄이 실제로 도는지와 상관없이 언제나 돌기 전에 드러난다. 실수는 세 가지다.
첫째, 값이 모자란다. add 는 값이 둘 필요한데 스택에 하나뿐이다.
02-stack-short.pnx
use io
use str
5 add str.to_str io.println
거절
$ pnx 02-stack-short.pnx
02-stack-short.pnx:3:3: the stack does not hold enough values for this word
둘째, 타입이 맞지 않는다. 문자열과 정수는 더할 수 없다.
02-types-mixed.pnx
use io
"3" 4 add drop
거절
$ pnx 02-types-mixed.pnx
02-types-mixed.pnx:2:7: this word takes two int values, or two of the same declared type
셋째, 값이 남는다. 프로그램이 끝날 때(그리고 낱말의 몸이 끝날 때, 3장) 스택에 아무도 가져가지 않은 값이 있으면 거절된다. 남은 값은 대개 잊어버린 일의 흔적이기 때문이다.
02-leftover.pnx
use io
use str
1 2 add str.to_str io.println
1 2 rem two values that nothing takes
거절
$ pnx 02-leftover.pnx
02-leftover.pnx:5:1: values are left on the stack at the end of the source
메시지의 5:1 은 다섯째 줄의 첫 칸, 곧 소스가 끝난 자리다. 값이 남은 것은 끝에 가서야 알 수 있으므로 끝을 가리킨다.
흔한 오해: 스택의 깊이는 돌면서 바뀌어도 된다
Forth 같은 언어에서는 값의 수가 실행에 따라 달라지는 낱말을 쓸 수 있다. Pinax 는 그러지 않는다. 모든 자리에서 스택의 깊이와 타입이 하나로 정해져야 한다. 그래서if 의 두 갈래는 같은 스택을 남겨야 하고, 루프의 한 바퀴는 스택을 그대로 둬야 한다(4장). 이 규칙 덕분에 위와 같은 검사가 가능하다.2.8 넘침은 결함이다
02-overflow.pnx
use io
use str
word double (n int gives int) n 2 mul end
4611686018427387903 double str.to_str io.println
4611686018427387904 double str.to_str io.println
결함으로 멈춤
$ pnx 02-overflow.pnx
9223372036854775806
02-overflow.pnx:3: integer overflow
n int = 4611686018427387904
int 의 범위를 넘는 결과는 조용히 감기지 않는다. 실행이 그 자리에서 멈추고 처리기가 결함을 알린다. pnx 는 결함이 난 줄과 그 낱말의 지역 변수를 보여 준다. 감기는 산술이 필요하면 wrap_mul, 끝에서 멈추는 산술은 sat_mul, 넘침을 실패로 받으려면 chk_mul 을 쓴다.
결함은 적재 때의 거절과 다르다. 넘칠지는 값에 달려 있어 돌기 전에 알 수 없으므로, 돌다가 그 자리에서 멈춘다. 어느 쪽이든 메모리를 망가뜨리거나 틀린 값으로 조용히 계속 가는 일은 없다.
2.9 십진 소수
돈처럼 점 아래 자릿수가 정해진 수는 점을 찍어 쓴다: 4.20 은 점 아래 두 자리의 십진 소수 dec(2) 다. 정수 420 으로 담기므로 더해도 정확하고, 어느 기계에서나 같다. 같은 dec(N) 과 더하고 빼며, int 를 곱하고 int 로 나눈다. dec 둘을 곱하면 자리를 모두 지킨다. rescale 은 자릿수를 정하고, 0 에서 먼 쪽으로 반올림한다.
02-dec.pnx
use io
word demo ()
4.20 to coffee rem dec(2): two digits after the point
coffee 3 mul 1.15 add to bill rem times an int, plus another dec(2)
bill 0.10 mul to tip rem dec(2) times dec(2): dec(4), nothing lost
cat ("bill ", bill, ", tip ", tip, ", rounded ", tip 2 rescale) io.println
cat ("split in three: ", bill 3 div) io.println
end
demo
출력
$ pnx 02-dec.pnx
bill 13.75, tip 1.3750, rounded 1.38
split in three: 4.58
int 로 나누면 정수처럼 0 쪽으로 자른다: 9.75 3 div 는 3.25 지만 10.00 3 div 는 3.33 이고 1 센트가 남는다. 그것이 중요하면 raw 로 센트를 꺼내 직접 나눈다.
흔한 오해: 4.20 과 0.5 는 더할 수 있다
dec(2) 와 dec(1), 다른 타입이다. 0.50 으로 쓰거나 하나를 rescale 한다.02-dec-scales.pnx
word demo () 4.20 0.5 add drop end rem dec(2) and dec(1)
거절
$ pnx 02-dec-scales.pnx
02-dec-scales.pnx:1:23: two dec values of different scales: rescale one first (d 2 rescale)
정리
- 값은 스택에 올라가고, 낱말은 맨 위에서 값을 내려 일하고 결과를 올린다(표준 9.3).
- 먼저 올린 값이 왼쪽이다:
10 3 sub는 7 이다(표준 9.3). - 스택 낱말은
dupdropswapovernip뿐이다. 값이 많아지면 이름을 붙인다(9.3). - 처리기는 적재 때 스택의 깊이와 타입을 모두 검사한다: 모자란 값, 맞지 않는 타입, 남는 값은 거절된다(7.1, 9.2, 9.3).
- 점이 있는 수는
dec(N)이다: 점 아래 N 자리, 정확한 셈, 반올림은rescale(표준 7.5). - 정수 넘침과 0 으로 나눔은 결함이다. 다른 산술 낱말이 따로 있다(7.1, 15.1).