Self-hosted alpha/sign · 1,411 lines · 6 samples

空白は演算子である。
6 本のサンプルで読む Sign 言語。

!s⊙s ' 0⊙null   — 中黒(⊙)が余積 primitive、カンマは直積

johnny-shaman/Sign のセルフホスト前段 alpha/sign/ は、Sign 自身で書かれた字句解析器・パーサ・前処理器・アセンブリ出力の骨格である ── 全 1,411 行、6 ファイル。

著者が README に記した「セルフホストで見付かった 6 つの作法」を、教材として単独で読める最小サンプル 6 本に抽出した。各 40〜60 行、単一仕様に対応、末尾の駆動式まで含めて動く。

抽出もと github.com/johnny-shaman/Sign クローン 2026-09-22 · depth 1 総行数 315 lines / 6 files 対応仕様 documents/ja-jp/impl/*
§ 抽出方針

なぜセルフホスト前段から抽出するのか

alpha/sign/ は「Sign を動かす」JavaScript 処理系と対をなす「Sign で書かれたもの」である。字句解析・構文解析・前処理・アセンブリ出力を、Sign 自身の仕様のもとで書き切っている ── ここに現れるコードは、言語設計者本人が「まとまった量を書いて初めて効いてくる」と繰り返した作法の生きた実例である。

教材はそこから 6 項目に 1 対 1 対応 させて抽出した。順番は元の README とは変え、言語の骨格 → 完全性公理 → 短絡評価 → 表引き → 局所束縛 という学習の傾斜になるよう並べ替えている。各サンプルは末尾に alpha/javascript 処理系で走らせて値が確認できる駆動式を含む。

コード本文は元ソースからの 構造抽出:識別子名を短く保ち、文脈依存の周辺コードを削り、コメントで「なぜこの書き方なのか」を仕様書のパスとともに残した。特に、著者が実測ログとして残した 2629 → 2205 命令 のような数字は改変せずそのまま引用している。

Suggested Curriculum · 90分 × 3 コマ

01

Sign の骨格

Samples 01 + 02
一様則と、直積 vs 余積。演習:is_digit と first_digit を 01 のパターンで書く。

02

完全性公理と再帰

Samples 03 + 04
器を縮める vs 添字で歩く、短絡評価。演習:join を両形で書き、なぜ後者が要るかを実測で比較する。

03

仕様のコード化

Samples 05 + 06
表引きと仮引数の let。演習:operator_table.sn の一段を自分で組み、cont1_naive を表引き版に書き直す。

01

UNIFORM LAW · 一様則 「終わり」を型で書く、if を使わない

Sign 最短の「これだけは覚える」パターン。!s(Unit の否定)と s ' 0(先頭アクセス)だけでリストの再帰が書ける ── を体感する。if / switch / return / null は一度も出ない。

Source lexer.sn · drop_space Spec 0_design_principles.md 原理5 Lines 44

述語は「値そのもの」で真を返す

Sign には真偽値型が存在しない。述語 is_space は真のとき その値(引数の文字)を返し、偽のとき __ を返す。この設計により、| は「または」ではなく「左辺が __ なら右辺」の短絡演算子として自然に働く。

本体は 3 節で書ける

  • !s : __ ── リストが尽きたら Unit で終わり
  • is_space (s ' 0) : drop_space (s ' 1~) ── 空白なら残りへ
  • s ── ここまで来たら停止(分岐マーカー ? が節を切る)
Pitfall — 制御文字リテラル

\n と書くと 改行ではなく「n」という文字になる。\ は次の 1 バイトをそのまま取るだけで、エスケープシーケンスは存在しない。改行は「行末に何も無い \」で書く。

Take
predicates that return the value on truth and __ on falsehood are the trick — everything else follows. Sign's rest of the type system is a formalisation of this one move.
01_uniform_law.sn 44 lines · lexer.sn
02

PRODUCT VS COPRODUCT · 直積と余積 空白は連結、カンマは並列

「トークン列は必ずカンマで積む」の理由を String の吸収則から自分で導けるようにする。処理系のバグではなく、`123` 123 = `123123` が成り立つ以上、避けられない仕様の帰結である。

Source lexer.sn · tokens Spec type_system.md §3.2 Lines 43

余積は空白、直積はカンマ

Sign は 2 つの積を持つ ── 余積(⊙、字句上は空白) と 直積(,)。文字列同士を空白で並べれば連結、カンマで並べれば構造保持のリストになる。

カンマも Unit は落とす

直積の単位元は __(零対象は終対象でもあるので A × __ ≅ A)。cons セルで積んでいく書き方では、残りが尽きた終端に __ が現れない:

[`a` , [`b` , [`c` , __]]]
` == [`a` [`b` [`c`]]]  ── 末尾に __ は残らない

名前付きスロットは対照的

値が __ でも キーが残る。ラベルは型の一部だから ── 位置スロットとはここが違う。

Pitfall — 文字列に生の改行不可

文字列は 同一行内で閉じる必要がある(sign.pegjs の string 規則が [^`\r\n]*)。改行を含む文字列は newline 定数を連接して組み立てる。

02_product_vs_coproduct.sn 43 lines · lexer.sn
03

RECURSION · 完全性公理と再帰 器を縮めるか、添字で歩くか

Sign 最大の落とし穴を、同じ問題(総和)の 2 通りの書き方で対比する。「終わり」を __ で表そうとすると、完全性公理により次の呼び出しが本体に一歩も入らず崩壊する ── これを分かって書くための 2 形。

Source preprocess · emit · parser Spec 0_design_principles.md 原理5 Lines 49

A:器を縮める

末端で自然に止まる ── 尽きた瞬間に完全性公理で呼び出しが消える。リスト書き換え・生成の再帰はこちらでよい。

B:添字で歩く

再帰を続けたい/終わりを値として扱いたい場面では、位置と長さの一致で終わりを決める。emit.sn の put_str や parser.sn の end_of はこの形。

Pitfall — 「終わり」を __ で返さない

parser.sn の end_of は初期形でこれをやり、無限ループしていた。再帰を続けたい場面では __ を返してはならない ── 直後の関数呼び出しがまるごと消える。

Rule
emit.sn の put_str が「B」を選んだのは、途中で列を縮めると末端の out # (s ' i) が __ に飲まれて 出力ゼロで止まるから。添字を進めて i >= ||s|| で終わる。
03_recursion_by_index.sn 49 lines · preprocess · emit
04

SHORT CIRCUIT · 短絡評価 | が「見付からなければ次」になる

「または」以上のもの ── ルックアップで 「見付かればそれ、無ければ既定」を 1 行で書ける。| は左辺が __ のときだけ右辺を評価する短絡演算子。__ が式全体を崩壊させる終対象なので、「値が在ること」そのものが真理値の役割を果たす。

Source emit.sn · put_infix Spec unit.md §3.3 Lines 60

3 つの慣用形

  • 「無ければ既定」 ── (look_up k) | `default`
  • 「無ければエラーとして進める」 ── (mnemonic ' k~) | `.err`(emit.sn の put_infix そのまま。例外を投げないコードの原型)
  • A → B → C の連鎖 ── a | b | c(target_info.sn の char_size_of がこの形)
Insight
短絡が「或るか無いか」で答えるので、Boolean を経由しない。値の在/不在がそのまま条件の真/偽に写り、if 相当の分岐が要らなくなる。
04_short_circuit.sn 60 lines · emit.sn · target_info.sn
05

LOOKUP · 表引きで場合分けを消す 29 本の比較を、1 回の引き算に

if/switch を関数と比較で書くのではなく、「表を 1 度引く」形に畳む練習。表を持つことは「その綴りは中置演算子である」の言明そのものであり、印は要らない ── 引ければ真、__ が返れば偽。

Source preprocess.sn · cont1 Spec 0_design_principles.md 原理1 Lines 60

Before → After の実測

preprocess.sn の初期形 cont1_naive は 29 本の等号比較の連なりだった。表引き版 cont1 に書き換えた結果:

Measured
2629 命令 → 2205 命令(-424 命令、-16.1%)。読める・書ける・遅い・重複するの 4 拍子が、表 1 個に畳まった。

表の設計 3 原則

  • 同じ事実を 2 箇所で持たない ── 廃止された綴りは表から抜くだけ。「印」を別に持たない
  • 幅と endian を同じ表に混ぜない ── Int と String が混じると型が決まらず機械語生成が断る(target_info.sn 冒頭コメント)
  • 表は必ず関数越しに引く ── 静的な鍵で外すと機械は「まだ出せない識別子です」で断り、__ が返らない
05_lookup_over_conditional.sn 60 lines · preprocess.sn
06

NAMED LOCALS · 仮引数ブロックで let を書く 節の中の : は分岐にしか読まれない

局所束縛は 仮引数リストの側で宣言する。関数本体のインデント行は全て match_case であり、そこに 識別子 : 値 と書いても分岐にしかならない。

Source preprocess.sn · continues Spec function_guide.md · unit.md §1.5 Lines 59

なぜこの形が要るか

数式 1 つの中に「同じ問い」を何度も書くと、段の深さに対して計算量が 指数的に効く。parser.sn の作者本人が実測ログを残している:

Measured
数式ひとつに 2 分以上かかっていたのが、値を仮引数で 1 度だけ計算して回す形にして 数ミリ秒に短縮。(parser.sn end_of の実装コメント)

仮引数で let を書けば

  • 同じ問いを 1 度しか書かない(実行時の共有)
  • 型情報が伝わる(本体の別枝でも同じ b から取っている)
  • 字下げのブロックがそのまま「局所名の宣言」の場所になる
Pitfall — 型が伝わらないケース

target_info.sn §lit_family のコメント:既定引数のブロックで let にした digit_run を本体から呼ぶと、String の型が呼ばれた先へ伝わらず、s ' i が「まだ出せない式です」になる(実測)。このときは同じ式を 2 回書くのが唯一の書き方(unit.md §1.5「式を 2 回書く方が安い」)。

06_named_locals.sn 59 lines · preprocess · parser
§ 実行のしかた

alpha/javascript 処理系に読ませる

各サンプル末尾の駆動式が最後の値として返る。compile() で AST に落として、interpreter.js で 1 ノードずつ評価する形。

Sign 本体は node があれば動く。パーサはピュア JavaScript で、依存は無い。

# Sign 本体をクローン(浅く 1 回で十分) $ git clone --depth 1 https://github.com/johnny-shaman/Sign.git $ cd Sign/alpha/javascript # 教材サンプルを 1 本読ませる(PATH は自分の環境に合わせる) $ node -e " import('./compile.js').then(async ({compile}) => { const I = await import('./interpreter.js'); const src = require('fs').readFileSync( '../../samples/01_uniform_law.sn', 'utf8'); const { nodes } = compile(src); const env = I.newRuntimeEnv(null); let last = I.UNIT; for (const n of nodes) last = I.evaluate(n, env); console.log(JSON.stringify(last)); });" # 01 の期待値:drop_space ` hello` == `hello` # 05 の期待値:[cont1(+), cont1(?), cont1(a), cont1_naive(+)] # == [`+`, `?`, __, `+`]
§ 教材として使うときの注意

.sn ファイル特有の 4 つの制約

alpha/sign/README.md の「書いてみて分かった作法」から、教材コードを自分で書くときに引っかかる項目を 4 つ抜粋した。

CAVEAT 01

コメント内に ` を書けない

コメントは行頭の ` で始まり、同じ行に閉じる ` が無い場合にコメントとなる。中に ` を入れると、そこで文字列として閉じてしまい後続がすべて壊れる。

CAVEAT 02

文字列に生の改行を書けない

文字列は同一行内で閉じる必要がある。改行を含む文字列は newline 定数を連接して組み立てる。

CAVEAT 03

TAB 1 個 = 1 段

インデントは TAB でのみ書く(利用者の決定 2026-09-15)。エディタで空白展開すると壊れる。

CAVEAT 04

行末に空白・タブを置かない

制御文字リテラルは行末で不可視になるため、\ 直後に置くと整形処理で落ちる危険がある。改行文字(末尾に何も無い \)はこの問題を受けない。