Lowent 매뉴얼←↑→

48 문법의 증명 — 어느 닫개로 닫아도 같은 나무

먼저 알아야 할 것

3장 겉모습 · 떨어진 마침표가 닫고, 개행은 공백이다
8장 식 · 전위 표기에는 우선순위가 없다
39장 수학 도구상자 · 집합과 관계 — “같다” 를 무엇으로 정하나

돌아보기

3장에서 poly 의 return 은 두 줄에 걸쳐 있었지만 한 폼이었다. 무엇이 그 폼을 닫았고, 개행은 무슨 일을 했는가?

답. 둘째 줄 끝의 떨어진 마침표가 닫았다. 개행은 스페이스와 똑같은 공백이라 아무것도 닫지 않았다. 그래서 줄을 어디서 나누어도 뜻이 같았다. 이 장은 그런 표면의 성질이 왜 증명의 대상이 되는지를 다룬다.

이 장의 필요성과 맥락

여기까지의 증명은 의미였다 — 프로그램이 무엇을 하는가. 이 장은 표면이다 — 프로그램이 어떻게 생겼는가. 문법에 증명이 필요한 까닭은 실제로 겪은 일 때문이다. 파서가 . 로 폼을 조각내고 뒤 단계가 말없이 다시 붙이고 있었다 — 두 층이 서로 다른 문법을 믿었고, 그 차이가 결함으로 나왔다. “한 뜻에 한 표기” 라는 원칙이 겉모습의 취향으로 남지 않으려면, 두 표기가 같은 나무를 만든다는 것을 기계가 지켜야 한다.

이 장이 끝나면

항등원과 결합법칙을 갖는 모노이드와, 두 표기가 같은지를 함수로 정하는 뜻함수(denotation)를 알게 된다. 문장 하나가 문장 하나짜리 블록과 같다는 정리, 닫개 셋이 같은 나무를 만든다는 정리, 블록의 결합법칙을 예제로 확인한다. 같은 뜻이 증명돼도 철자를 두지 않은 개행 닫기, 언어에서 뺀 규칙과 함께 뺀 정리, 그리고 이 정리가 실제 파서에 대해 말하지 않는 것도 보게 된다.

이 장에서 답할 질문

  1. 이 정리가 있으면 실제 파서가 옳다고 믿어도 되는가?
  2. C 에 우선순위 규칙이 수십 줄인데, 이 장의 증명은 왜 이렇게 짧은가?

48.1 대수와 뜻함수#

문장을 이어 붙이는 일에는 두 성질이 있다. 이어 붙이는 방식이 묶는 순서와 무관하고(결합법칙), 빈 블록은 아무것도 아니다(항등원 — 덧셈에서 0 이 하는 일). 이 둘을 갖는 구조를 모노이드라 한다. 목록을 이어 붙이는 것이 대표적인 모노이드이고, 프로그램의 문장 나열이 정확히 그 구조다.

두 표기가 “같은지” 를 말하려면 기준이 필요하다. 각 나무를 평평한 문장 나열로 보내는 함수 denote 를 두고, 결과가 같으면 같다고 한다. denote(do S end) = [S] = denote(S) 이므로 둘은 같다. 이것이 이 장의 유일한 기술이다 — “같다” 의 뜻을 함수로 정하고, 등식을 계산으로 확인한다.

48.2 한 문장은 한 문장짜리 블록이다#

examples/ch48/oneform.low

module oneform .
rem run: bare 3
rem run: blocked 3
rem fmt

fn bare input a u64 . output u64 .
  requires le a 1000 .
do
  var x u64 be a .
  if gt a 1 . set x (add x 10) .
  return x .
end

fn blocked input a u64 . output u64 .
  requires le a 1000 .
do
  var x u64 be a .
  if gt a 1 . do
    set x (add x 10) .
  end
  return x .
end

실행 결과

$ lowentc --run bare oneform.low 3
bare(3) = 13
$ lowentc --run blocked oneform.low 3
blocked(3) = 13
$ lowentc --fmt oneform.low
module oneform .
fn bare .
  input a u64 .
  output u64 .
  requires le a 1000 .
do
  var x u64 be a .
  if (gt a 1) do set x (add x 10) . end
  return x .
end
fn blocked .
  input a u64 .
  output u64 .
  requires le a 1000 .
do
  var x u64 be a .
  if (gt a 1) do set x (add x 10) . end
  return x .
end

bare 는 if 의 몸에 문장 하나를 그대로 적었고, blocked 는 do … end 로 감쌌다. 답이 같고, --fmt 가 찍는 정규형도 한 글자 다르지 않다.

수학. 정리 G1(LowentBlock.v 의 one_form_is_a_block)

forall s, denote (wrap s) = denote s. 문장 하나를 블록으로 감싸도 뜻이 같다. 여기서 “뜻이 같다” 는 각 나무를 평평한 문장 나열로 보내는 함수 denote 의 결과가 같다는 뜻이다. “같다” 의 뜻을 함수로 정하고 등식을 계산으로 확인하는 것이 이 장의 유일한 기술이다. 따름정리 body_is_one_thing 은 몸 자리에 문장을 쓰든 블록을 쓰든 같다고 말한다.

이 정리는 설계 결정 하나를 정당화한다. C 는 문장과 복합문을 다른 종류로 갈라 놓았다. if (c) x = 1; 과 if (c) { x = 1; } 가 문법적으로 다른 것이고, 유명한 goto fail; 함정이 그 틈에서 나왔다 — 들여쓰기는 두 줄이 if 에 속한 것처럼 보였지만 문법은 한 줄만 속하게 했다. Lowent 는 가르지 않는다.

 C       if (c)
             x = x + 10;
             y = 0;          ← 들여쓰기는 if 안처럼 보이지만 문법은 if 밖이다

 Lowent  if gt a 1 . set x (add x 10) .        문장 하나 = 문장 하나짜리 블록 (G1)
         if gt a 1 . do
           set x (add x 10) .
           set y 0 .
         end                                   몸이 길면 end 가 끝을 적는다

48.3 닫개는 같은 일을 한다#

수학. 정리 G2(closers_agree)

forall c1 c2 h ops, close c1 h ops = close c2 h ops. . 과 ) 가운데 어느 것으로 닫아도 같은 나무가 나온다. 둘 다 “열려 있는 가장 안쪽 폼을 닫는다” 를 한다. end 는 이 목록에 없다 — do … end 는 짝인 괄호라서 end 는 자기 do 만 닫고, 안에 열린 폼이 남아 있으면 대신 닫지 않고 거절한다(E-DOT-MISSING). 블록은 결합적이어서 블록 안의 블록은 자기 자리에서 펼쳐진다(block_assoc·nested_block_flattens).

예제로 보면 이렇다.

examples/ch48/closers.low

module closers .
rem run: one_line 4
rem run: spread 4

fn one_line input x u64 . output u64 .
  requires le x 1000 .
do
  return add (mul x x) (add x 1) .
end

rem 인자를 여러 줄에 늘어놓아도 표시가 필요 없다 --- 개행은 공백이다
fn spread input x u64 . output u64 .
  requires le x 1000 .
do
  return add
    (mul x x)
    (add x
         1) .
end

실행 결과

$ lowentc --run one_line closers.low 4
one_line(4) = 21
$ lowentc --run spread closers.low 4
spread(4) = 21

one_line 은 ) 두 개가 안쪽 폼을, . 이 바깥 폼을 닫는다. spread 는 같은 폼을 네 줄에 늘어놓았는데 아무 표시가 필요 없다. 개행은 공백이기 때문이다. 두 op 의 답이 같다.

 return add (mul x x) (add x 1) .      return add
                                           (mul x x)
                                           (add x
                                                1) .
 둘 다 같은 나무가 된다:
                 add
            ┌─────┴─────┐
           mul         add
          ┌─┴─┐       ┌─┴─┐
          x   x       x   1

개행도 닫개로 만들 수 있고 그것도 증명된다. 그래도 두지 않는다. 개행이 닫으면 줄바꿈이 의미가 되어 긴 줄을 나누는 것만으로 프로그램이 바뀐다. 같은 뜻임이 증명된다고 그 철자를 두어야 하는 것은 아니다. 한때 쉼표·줄잇기·개행 닫기에 붙어 있던 정리들도 모두 Qed 였지만, 그 규칙들이 언어에서 빠지면서 정리도 함께 뺐다. 지금 , 와 ; 는 E-VOCAB-REMOVED 로 거절된다 — 파싱되는데 아무 효과가 없는 낱말은 조용한 함정이기 때문이다. 죽은 규칙을 모형화한 정리를 남겨 두면 증명된 문서가 없는 규칙을 말하게 된다.

이 파일의 증명은 전부 계산으로 끝난다. 값은 증명의 어려움이 아니라 명제를 골라 적었다는 데 있다. “닫개들이 같은 일을 한다” 를 적어 두면, 누가 하나를 특별 취급했을 때 증명이 깨진다. 문법은 “여기만 예외를 두면 편한데” 로 조용히 표류하는 층이고, 정리가 그 표류를 잡는다. 막는 것은 사용자의 결함이 아니라 컴파일러의 결함이다.

문. 이 정리가 있으면 실제 파서가 옳다고 믿어도 되는가?

답. 아니다. 모델은 나무를 만드는 작은 함수들이고, 실제 파서는 그보다 훨씬 크다. 둘의 대응은 시험이 받친다. 그리고 두 백엔드 대조는 VM 과 네이티브를 비교하지만 둘은 같은 앞단을 지나므로, 파서가 틀리면 둘 다 똑같이 틀린다. 파서를 이 모델과 직접 대조하려던 시도는 모델이 실제 문법보다 작아서 결론을 낼 만큼의 짝을 얻지 못했다. 앞단은 이 언어의 검증에서 가장 약한 이음매다(50장).

문. C 에 우선순위 규칙이 수십 줄인데, 이 장의 증명은 왜 이렇게 짧은가?

답. 이 언어의 식은 전위 표기라 연산자 우선순위가 없다(expr 섬은 따로 다룬다, 8장). 우선순위가 없으니 “이 표기가 어떤 나무가 되는가” 에 답할 경우가 적다. 설계가 증명을 쉽게 만든 사례다. 그 대신 증명이 짧다고 값이 작은 것은 아니다 — 명제를 적어 두었다는 것 자체가 표류를 막는다.

흔한 오해. 줄을 바꾸면 문장이 끝난다

개행은 공백이다. closers.low 의 spread 는 한 폼을 네 줄에 늘어놓았는데 아무 표시도 없이 one_line 과 같은 나무가 된다. 폼을 끝내는 것은 닫개(.·)) 뿐이다. 그래서 마침표를 빠뜨리면 다음 줄이 앞 폼에 이어 붙어 엉뚱한 진단이 나오고, 긴 식을 여러 줄로 나누어도 뜻이 바뀌지 않는다. 개행을 닫개로 만드는 규칙도 증명은 되지만 두지 않은 까닭이 바로 이 둘째 성질이다.

48.4 흔한 실수#

반례. 줄 끝의 마침표를 빠뜨린다

examples/ch48/mistake_noperiod.low

module mistake_noperiod .
rem expect: E-IR-ARITY

fn total input a u64 . input b u64 . output u64 .
  requires le a 1000 .
  requires le b 1000 .
do
  rem ✘ 이 줄 끝의 마침표를 빠뜨렸다
  var s u64 be add a b
  set s (mul s 2) .
  return s .
end

실행 결과

$ lowentc --check mistake_noperiod.low
mistake_noperiod.low:10:0 E-IR-ARITY: extra operands in expression (strict arity)

진단은 마침표를 빠뜨린 줄이 아니라 다음 줄을 가리킨다. 개행이 아무것도 닫지 않으니 add a b 뒤에 set s (mul s 2) 가 이어 붙어 add 가 인자를 넷 받은 모양이 되었고, 도구는 “인자가 남는다” 고 말한다. 줄 번호보다 한 줄 위를 먼저 본다. 이 진단이 헷갈려도 규칙은 바뀌지 않는다. 줄이 문장을 끝낸다면 긴 식을 나누는 것만으로 뜻이 바뀐다.

반례. C 처럼 ; 로 닫는다

examples/ch48/mistake_semicolon.low

module mistake_semicolon .
rem expect: E-VOCAB-REMOVED
fn twice input a u64 . output u64 .
  requires le a 1000 .
do
  rem ✘ C 처럼 쌍반점으로 닫는다
  return mul a 2 . ;
end

실행 결과

$ lowentc --check mistake_semicolon.low
7:20 E-VOCAB-REMOVED: `;` was a THIRD spelling of the closer `.` (the lexer literally emitted a DOT for it) — one meaning, three spellings, and SPEC-002 §2.5 forbids synonyms. It only survived inside the old interpreter's second language. Write `.`

; 는 예전에 마침표의 셋째 철자였다. 한 뜻에 철자가 셋이면 읽는 사람이 차이를 찾게 되므로 뺐고, 이제 E-VOCAB-REMOVED 로 거절한다. 조용히 받아들이지 않는 것은, 파싱은 되는데 아무 뜻이 없는 낱말이 가장 찾기 어려운 함정이기 때문이다.

48.5 증명하지 않은 것#

복습 정리

문장 나열은 결합법칙과 항등원을 갖는 모노이드이고, 두 표기가 같은지는 평평한 나열로 보내는 뜻함수로 정해 계산으로 확인한다. 문장 하나는 문장 하나짜리 블록과 같고(G1), .·) 는 같은 나무를 만들며(G2), 블록 안의 블록은 제자리에서 펼쳐진다. 개행 닫기는 증명돼도 두지 않았고, 언어에서 뺀 규칙의 정리는 함께 뺐다. 이 정리들은 컴파일러의 표류를 막지만, 실제 파서가 이 모델이라는 보장은 시험이 받친다.