Calciumというプログラミング言語を作り始めた

いつもお世話になっております。わいとん(@ytnobody)です。

新年も25日が経過し、月日の流れの速さを感じるこの頃です。

さて実は2~3日前くらいからCalciumという言語を作り始めておりまして、今回はその紹介をしておこうということで筆をとった次第です。

Calciumという言語の特徴

ではCalciumの特徴をばばっと列挙しましょう。

  • 100%AIドリブン開発
  • Goで作られている
  • バイトコードへのコンパイル
  • 動的型付け・関数型
  • イミュータブル
  • 制約という仕組み
  • 副作用の可視化
  • 制御構文は基本matchmapだけ
  • 関数の制限
  • イベントドリブンな待ち受け構文

100%AIドリブン開発

Calciumの開発は100%Claude Opus 4.5で行われております。というか、AI以外にコミットさせるケースはほぼないです。今後もほぼ100%AIによってコードが書かれることになります。

これには大変重要な理由があり、もちろんAIもミスをしますが、人間である私はさらにしょっちゅうミスをするから、というものです。人間の中でも私は極めておっちょこちょいですから(注意力3万しかない)、これを信用仕切るのはリスクが大きすぎる。それならまだAIのほうが信用できるというものですし、AIのミスを私が指摘するということもすでに行われていますので(当然逆のほうが多いけど)、昭和的な言い方をするなら、常にダブルチェック体制で開発されているということになります。

あと、AIのほうがコードを素早く作成します。私のように40代半ばの体力が衰えているおっさんなどよりもはるかに素早い。なので、自分の役割はAIに仕様を伝えて設計の伴走をするということに徹しております。

Goで作られている

コンパイラ、トークナイザー、パーサー、レクサー、などなど…いま疲れてるのでアルファベットを打ち込むのが面倒でついついカタカナで書いちゃいましたが、これらのものをすべてGo言語で実装してあります。単純にいろんなOSやCPUアーキテクチャに対応させるうえで、Goは手早く開発できるという経験則があります。

また、AIにとっても比較的書きやすく、学習量も相応にあります。成果物も単体バイナリとなりますから、配布する上で非常に好都合。実行速度も比較的軽快な傾向にありますし、プログラミング言語を作るうえで大変実用的な言語だなあと。私にとってはそれがGoだったのです。

バイトコードへのコンパイル

Calciumは専用VM上で動作する言語として開発をはじめました。そのため、ソースコードをバイトコードへコンパイルする機能があります。

一応REPLもありますし、その気になれば単一実行バイナリを生成することも可能です。

バイトコードへのコンパイル時に、実行速度やコンパイル後の重複コードの最適化も行われます。そのため、一見ムダが大きそうな以下のような処理もほどほど悪くない速度で動作するようになる、と思われます。

1
2
3
4
5
6
7
8
9
use core.io!;

// 2つの値を加算するだけの関数
func add(x, y) = x + y;

// ↓同じ引数で同じ関数を2度コールするがコンパイル時に最適化が行われる
func my_calc(x, y) = add(x, y) * add(x, y);

[2, 3]... |> my_calc !> io.say; // <--- 25

動的型付け・関数型

Calciumは関数型言語でして、いっぽう今さらながら動的型付け言語です。そう、Elixirなんかと同じですね。

でも動的型付けにしたのには私なりに考えがあるんですよ。私、普段はGoやTypeScriptをAIに書かせることがほとんどなんです。しかしですね…PerlやRubyなんかも気まぐれにAIに書かせてみると、これが予想以上にスルスルと意図した通りのコードを生成してくれる。おまけにトークン消費量がTypeScriptやGoなんかよりもだいぶ少ないんですよ。

これは私の体感によるものかなあと思っていたのですが、実際のところ大マジメに検証した人がいるらしく、その結果が以下のポストにチャートとして表わされていました。

ということで、AIフレンドリーにしたいなあ、という欲もありましてこのような選択をしたわけです。

イミュータブル

変数への再代入も厳に禁じました。関数型言語にした理由とも被るところがありますが、やはりAIフレンドリーにしたいという点もありますし、そもそも状態をプログラムの責務として偏在させたくないという思いがあります。

なお、どうしても状態管理(というかイベントドリブンな待ち受け)が必要なシーンではどうするべきか、というのは後で紹介する機能 async.stay でカバーさせることにしました。

制約という仕組み

Calcium言語は動的型付け言語ですから、無対策では関数にどんな値が入ってくるのかわからず、コンパイルする利点がだいぶ薄れてしまいます。

ところが変数への再代入を禁じた上でこの「制約」という仕組みを利用することで、型とは違う方式で安全性を担保しつつ、コンパイル時にも最適化が働くといううまみを得ることを狙いました。

1
2
3
4
5
6
7
8
9
10
use core.io!;

// 0より大きい値であることを制約する
constraint Positive(n) = n > 0;

// 100はPositive制約に準拠している
100 |> Positive? !? {
success(v) => "OK: ${v}" !> io.say
failure(e) => "NG: ${e}" !> io.say
}; // --> OK: 100

なんだよ、ただの名前付きバリデーションじゃないか、と思ったそこのあなた!実に察しがよろしい、その通りなんです!でも、これなら型パズルでAIトークンを消耗することもないですし、ビジネスロジックをそのまま制約として記述できますよね。

ただ、今のところ関数の引数に対してこの制約を適用する機能は未実装です(実装予定はあります)。

副作用の可視化

この言語の結構大きな特徴のひとつがこれ。副作用を可視化することで、ビジネスロジックを純粋に保つことが可能となります。

そのための手段として、関数定義自体がfuncfunc!の2種類ありまして、!が付いている方は副作用があります!という風になっているというわけです。

パイプライン演算子についても|>!>があり、こちらも!が付いている方は右辺に副作用があることを示します。

さらに、モジュール名にも副作用が含まれる場合には!が付く。さっきから例示しているコードにあるuse core.io!;という行ですが、これにも末尾に!が付いている通り、副作用が含まれるモジュールとなります。だいたい標準出力も副作用ですからね。このあたりはとても厳格です。

1
2
3
4
5
6
7
8
use core.io!;

func add(x, y) = x + y;
func my_calc(x, y) = add(x, y) * add(x, y);

[2, 3]...
|> my_calc // <-- これは純粋関数なので普通のパイプライン演算子でコールする
!> io.say; // <-- これはio.sayが副作用をもつ関数なので、副作用パイプライン演算子でコールする

上記の例ではio.sayのところを普通のパイプライン演算子でコールすると以下のように怒られます。

1
2
3
4
5
6
$ ./calcium run ./foo.ca 
./foo.ca: error
line 8, column 5:
|> io.say;
^
cannot use '|>' with effect function; use '!>' instead

制御構文は基本matchmapだけ

この言語の非常にとがった特徴がこのあたりでしょう。if, else, for, while, switch。これらは全部ありません

if, else, switchのかわりにmatchがありますから、そちらを使っていただくということで頑張っていくという言語です。

またwhileasync.stayで代用が可能(なんとコアモジュールに追い出されています!)でして、forのかわりにmapを使ってください、ということです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use core.io!;

func fizz(n) = match n % 3
0 => "Fizz"
_ => "";

func buzz(n) = match n % 5
0 => "Buzz"
_ => "";

func fizzbuzz_or(result, n) = match result
"" => to_string(n)
_ => result;

func fizzbuzz(n) = fizz(n) + buzz(n)
|> fizzbuzz_or(n);

"FizzBuzz (1-15):" !> io.say;
range(1, 16)
|> map(fizzbuzz)
!> io.say;

上記の例ではFizzBuzz問題を解いていますが、場合分け処理が必要なfizz,buzz,fizzbuzz_orの3つの関数でifなどではなくmatchがつかわれています。

また、数値配列に対して順番に処理を行うためループ処理を行いますが、forではなくmapをつかっています。

関数の制限

なんと、Calciumでは関数はシングルステートメントに限定されます。つまり!2つ以上の処理をしたければパイプラインを使うか、関数を分けるしかないということになります!

なぜそんな制限を設けているのかというと、マルチステートメントをサポートすることで関数自体の複雑度が爆上げしてしまうので、それをなくしたいという思いからこのようになっています。

パイプラインであれば、どこからが副作用でどこまでが純粋なのか一目で把握できます。ビジネスロジックに副作用が入り込んでいることがわかれば、ビジネスロジックのテスタビリティを向上させるために副作用を分離したくなるというものでして、そのようなリファクタを行いやすくするためなのです。

イベントドリブンな待ち受け構文

言語の基本機能ではなくコアモジュールの機能ではあるのですが、いわゆるwhileループの代わりになるのがasync.stayです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use core.io!;
use core.schedule!;
use core.async!;

// async.stayでwhileループのように待ち受ける。waitは状態変数、5000が初期値。
result = async.stay(wait: 5000) {
src = schedule.timeout(wait); // <-- 5秒経過したらsrcがsuccessとなるか、どこかのタイミングでfailureとなる。
handler = async.expects(ev => async.leave("completed"), src); // <-- srcを監視し、trueになった瞬間にasync.leaveする。
handler.ready() // <-- srcを監視し始める。
};

// resultに結果が入っているので、出力する。
result !? {
success(v) => io.println("Result: " + v)
failure(e) => io.println("Error: " + e)
};

上記の例ではasync.staywhileループのような役割を行っています。async.stayブロックには引数としてkey: valueの形式で複数の値を状態変数として渡すことが可能です。

なお、ひとつのasync.stayに対して複数のasync.expectsを設定することが可能です。async.leaveがコールされた時点で、特段の指定がなければasync.stayは処理を終了します。

複数のイベントをこの構文で待ち受け可能ということです。

Calciumの現状

はっきり言ってまだ作り始めてから数日ですから、粗もバグもあると思います。しかし、いったん動作させることには成功していると言える状態には持ってきたと思います。

また、エコシステムも現状開発中でして、近いうちにモジュールレジストリboneyardとモジュールマネージャーboneが連携して動作するようになる予定です。

バイトコードへのコンパイルとバイトコードの実行は現時点で概ね動作していますが、外部モジュールのバンドル等についてはまだ現時点では実装ができていません。

まとめ

ひとまず、AIに色々指示を出してCalciumを作っている、という状況です。また進展があったらエントリを書くと思います。気長にお待ちいただけますと幸いです。