BA Barış Akın~/blog

·16 分で読めます·48 閲覧

Cobra:3 つのエンジンを持ち、GIL がなく、ネイティブコンパイルできるスクリプト言語

Go で書いた Cobra 言語の全体紹介。end で閉じるブロック、struct と record、デコレーター、GIL なしの並列性、チャネル、標準ライブラリ、LSP とデバッガー。インタプリタ、バイトコード VM、PyPy を上回るネイティブコンパイラの仕組みも解説します。

Cobra:3 つのエンジンを持ち、GIL がなく、ネイティブコンパイルできるスクリプト言語

Cobra は、私が Go で書いた汎用スクリプト言語です。ブロックはインデントではなく end で閉じ、単一の静的バイナリとして配布され、同じプログラムを 3 つの異なる経路で実行できます。ツリーウォーキングインタプリタ、バイトコード VM、そしてマシンコードを直接生成するネイティブコンパイラです。GIL はありません。async タスクは本当にすべてのコアで同時に動きます。その周りには、LSP サーバー、フォーマッター、VS Code 拡張、テストフレームワーク、Django 風の Web フレームワーク、NumPy 風の数値計算ライブラリがあります。

この記事では言語全体を見ていきます。構文、データ型、関数、オブジェクト、エラー、並行性、標準ライブラリ、ツール、そして最後に内部構造と性能です。ここにある例はすべて、Cobra 0.13.0 の両方のエンジンで実行しました。示している出力は本物の出力です。言語のサイトは cobralang.baltavista.com です。

なぜまた新しい言語なのか

Cobra は層を重ねるように作りました。まず字句解析器、次に Pratt パーサー、次にツリーウォーキング評価器、次にバイトコードコンパイラとスタック VM、そして最後にネイティブコンパイラです。最初から 1 つのルールがありました。すべての機能は両方のエンジンに入り、両者が同じように振る舞うことをテストする、というものです。テストとベンチマークでは、2 つのエンジンがバイト単位で同一の出力を出すことを要求しています。食い違えば、数値は報告すらされません。

目指していたのは次のようなことです。

  • インストール不要のスクリプト:ファイルを 1 つコピーして実行するだけ。venv もパッケージマネージャーも不要。
  • 本物の並列性:CPU バウンドな処理は、コアに分散したときに実際に速くなるべき。
  • 組み込みやすく、読みやすい:言語全体が、最初から最後まで読んで理解できるくらい小さくあり続けること。
  • 必要なときには速さを:同じソースをネイティブにコンパイルし、数値計算で JIT ランタイムを上回ること。

60 秒でわかる Cobra

let name = "Cobra"
let langs = ["go", "rill", "cobra"]
let ages = {"ada": 36, "linus": 28}

def greet(who, times)
    let out = []
    for i in range(times)
        out.push("hello ${who} #${i + 1}")
    end
    return out
end

for line in greet(name, 2)
    print(line)
end

let squares = map(def(x) -> x * x, [1, 2, 3, 4])
print(squares, squares[-1], squares[1:3])
print(filter(def(n) -> n % 2 == 0, range(10)))
let a, b = 1, 2
a, b = b, a
print(a, b, 7 / 2, float(7) / 2)
for k in ages
    print(k, ages[k])
end
print("  a,b  ".strip().split(","))

出力:

hello Cobra #1
hello Cobra #2
[1, 4, 9, 16] 16 [4, 9]
[0, 2, 4, 6, 8]
2 1 3 3.5
ada 36
linus 28
["a", "b"]

ルールは少なく、明快です。

  • 改行で文が終わります。セミコロンはありません。
  • ブロックは def、if、elif、else、while、for、struct、try などのキーワードで始まり、end で閉じます。インデントは見た目だけのもので、インデントがおかしくてもプログラムが壊れることはありません。
  • # は行末までのコメントを始めます。
  • 論理演算子は単語です:and、or、not。空の値は null です。
  • ( [ { の中の式や呼び出し引数は、複数行にまたがってもかまいません。

変数と型

let は現在のスコープに新しい名前を定義し、単なる x = ... は既存の名前を更新します。未定義の名前への代入はエラーなので、タイプミスで新しい変数がこっそり作られることはありません。const は不変の束縛を定義しますが、不変なのは束縛であって値ではありません。const xs = [1] の後では、xs = ... は禁止されますが、xs.push(2) は問題ありません。

型は、整数、浮動小数点数、文字列、真偽値、null、リスト、辞書、そしてファーストクラスの関数です。細かい点をいくつか挙げます。

  • 整数と浮動小数点数を混ぜると浮動小数点数に昇格します。1 + 2.5 は 3.5、1 == 1.0 は真です。
  • 2 つの整数の間の / は整数除算です。7 / 2 は 3 です。小数の結果が欲しければ float(7) / 2 とします。
  • 偽とみなされる値:false、null、0、""、[]、{}。
  • and と or は短絡評価で、結果を決めたオペランドを返します。0 or "x" は "x" です。
  • 多値代入で、スワップが 1 行で書けます:a, b = b, a。右辺は何かが束縛される前にすべて評価されます。
  • += -= *= /= %= は、変数、インデックスの対象(xs[i] += 1)、フィールド(self.n += 1)に使えます。

コレクションと文字列

リストは参照型です。let b = a はコピーせず、同じリストを指します。負のインデックスは末尾から数え(l[-1])、+ は連結、== は深い比較を行います。メソッドは push、pop、contains、find、count、reverse、sort です。

スライスは Python の seq[low:high:step] と同じように動作します:xs[:2]、xs[1:]、xs[::-1]。範囲外の境界は丸められます。リストでは新しいリストを、文字列ではルーン単位の(UTF-8 安全な)文字列を、range では別の遅延 range を返します。

辞書は挿入順を保ちます。キーには文字列、整数、真偽値、null が使えます。存在しないキーを d[k] で読むとエラーになります。has で確認するか、get(k, default) を使います。メソッドは keys、values、items、has、get、del です。

文字列は不変で、インデックスでアクセスできます。upper、lower、strip、split、join、replace、startswith、find、count などのメソッドはチェーンできます。${...} は文字列に式を埋め込みます。中にはどんな式でも、入れ子の補間でさえ入れられます。三重引用符の文字列は複数行のテキストに使えます。

ソートには比較関数を渡せます。また fmt モジュールを使うと、数値を画面にきれいに表示できます。

import "fmt"
import "json"
let rows = json.parse("[{\"name\": \"keyboard\", \"price\": 1299}, {\"name\": \"mouse\", \"price\": 349.5}]")
rows.sort(def(a, b) -> a["price"] < b["price"])
for r in rows
    print(fmt.pad(r["name"], 8, " "), fmt.group(r["price"], 2))
end
   mouse 349.50
keyboard 1,299.00

sort(less) は安定ソートなので、等しい要素は入力の順序を保ち、複数キーのソートは順に実行するだけで組み合わせられます。fmt.group と math.round は、半分を偶数に丸め(銀行家の丸め)、2 進の浮動小数点ではなく 10 進のテキストを通して丸めます。そのため、0.145 * 100 のようなたちの悪い誤差はここでは起きません。

関数、クロージャ、デコレーター

関数はファーストクラスの値で、定義されたスコープをキャプチャします。1 つの式からなる無名関数はアロー形式を使います:def(x) -> x * 2。複数行の本体にはブロック形式 def(x) ... end があります。map、filter、reduce、zip、enumerate、any、all は組み込みです。

デコレーターは def の上に置き、@d は name = d(name) を意味します。Web のルートを登録するには自然な方法です。

let routes = {}

def route(path)
    return def(fn)
        routes[path] = fn
        return fn
    end
end

@route("/")
def home(req)
    return "home page"
end

@route("/hello")
def hello(req)
    return "hello ${req["name"]}"
end

for path in routes
    print(path, "->", routes[path]({"name": "ada"}))
end
/ -> home page
/hello -> hello ada

デコレーターは重ねられます。def にいちばん近いものが最初に適用されます。async def にも使えます。

オブジェクト:struct、継承、record、contract

Cobra のクラスは struct で定義します(class も別名として受け付けます)。init がコンストラクタで、メソッドの本体では self は暗黙です。単一継承には < を使い、super で親のメソッドに到達できます。struct の本体には、クラス定数(const)、フィールドのように読めるプロパティ(get)、代入フック(set)、静的メソッド(static def)も置けます。

record は、不変で値によって比較される struct です。フィールドが等しい 2 つの record は == であり、with は一部のフィールドを置き換えた新しいコピーを返します。

struct Shape
    def init(name)
        self.name = name
    end
    def describe()
        return "${self.name}: area ${self.area()}"
    end
end

struct Circle < Shape
    const PI = 3.14159
    def init(r)
        super.init("circle")
        self.r = r
    end
    def area()
        return Circle.PI * self.r * self.r
    end
end

struct Rect < Shape
    def init(w, h)
        super.init("rectangle")
        self.w = w
        self.h = h
    end
    def area()
        return self.w * self.h
    end
    get square()
        return self.w == self.h
    end
end

record Point
    def init(x, y)
        self.x = x
        self.y = y
    end
end

for s in [Circle(2), Rect(3, 3)]
    print(s.describe())
end
print(Rect(2, 5).square)
let p = Point(1, 2)
print(p == Point(1, 2), p.with({"x": 9}))
try
    p.x = 5
catch err
    print("error:", err)
end
circle: area 12.56636
rectangle: area 9
false
true Point{x: 9, y: 2}
error: line 49: cannot modify immutable record Point

Shape.describe の中で呼ばれる self.area() はサブクラスで定義されています。メソッドはインスタンスの実際の型に基づいて動的にディスパッチされます。contract は軽量なインターフェースです。必要なメソッド名を列挙し、implements(x, Drawable) で値がそれらをすべて定義しているかどうかがわかります。

エラー:try、catch、finally

throw はどんな値でも投げることができ、それはそのまま捕捉されます。実行時エラーは、行番号付きのメッセージとして捕捉されます。finally は必ず実行され、return、break、continue は try ブロックをそのまま通り抜けます。

def divide(a, b)
    if b == 0
        throw "division by zero"
    end
    return a / b
end
try
    print(divide(10, 2))
    print(divide(1, 0))
catch e
    print("caught:", e)
finally
    print("finally always runs")
end
@memo
def x()
end

最後の @memo は意図的に未定義のままにしています。まさにここで 2 つのエンジンの違いが出ます。ツリーウォーキングインタプリタはその行までプログラムを実行してそこで止まりますが、VM はコンパイル中に未定義の名前を検出し、1 行も実行しません。エラーメッセージと行番号はどちらも同じです。

ターミナルの Cobra:2 つのエンジン、並列性、ネイティブコンパイル

コンパイル時に検出されるのは未定義の変数だけではありません。組み込みへの代入、ループ外の break、const への再代入も、何かが実行される前にすべて拒否されます。

並行性:async、await、GIL なしの並列性

async def で定義した関数を呼ぶとすぐにタスクが返り、その本体は専用の OS スレッドで実行されます。await task は結果を待ち、await [t1, t2, ...] はすべてを集めて、結果を順番通りに返します。タスク内で投げられたエラーは await で再送出されます。

GIL がないので、CPU バウンドな処理は本当に速くなります。同じ計算を、まず逐次で、次に 4 つのタスクで実行してみましょう。

import "time"
import "fmt"

def work(n)
    let s = 0
    let i = 0
    while i < n
        s = s + i % 7
        i = i + 1
    end
    return s
end

async def job(n)
    return work(n)
end

let t0 = time.clock()
let seq = [work(5000000), work(5000000), work(5000000), work(5000000)]
let t1 = time.clock()
let par = await [job(5000000), job(5000000), job(5000000), job(5000000)]
let t2 = time.clock()
print(seq == par)
print("sequential ${fmt.decimal(t1 - t0, 3)}s, parallel ${fmt.decimal(t2 - t1, 3)}s")

10 コアの Apple M シリーズのマシンで、VM は sequential 0.584s, parallel 0.186s と表示しました。約 3 倍で、コードにはロックもプールも追加の構文もありません。pmap(fn, list) は同じ考え方の最短形です。map と同じ呼び出しですが、要素はコアに分散され、結果は入力の順序を保ちます。

並列性は、共有状態の同期をプログラマーに委ねます。そのための組み込みツールがあります。

  • Mutex() と RWMutex():lock、unlock、try_lock、そして読み取り側の rlock、runlock。
  • Channel() と Channel(n):Go と同じく、バッファなし、または n スロットのバッファ付きチャネル。send、recv、close、try_send、try_recv。
  • select(ops):複数のチャネルを同時に待ち、準備ができたものから処理を続けます。default を与えると待ちません。
let ch = Channel(4)
let done = Channel()

async def producer()
    for i in range(5)
        ch.send(i * i)
    end
    ch.close()
end

async def consumer()
    let total = 0
    let v = ch.recv()
    while v != null
        total += v
        v = ch.recv()
    end
    done.send(total)
end

producer()
consumer()
print("total:", done.recv())
print(pmap(def(x) -> x * 10, [1, 2, 3]))
total: 30
[10, 20, 30]

グローバルスコープとモジュールキャッシュは内部で同期されているので、異なるグローバル変数に触れるタスクは常に安全です。競合しているタスクでの回復可能なパニック(たとえば共有リストへの範囲外インデックス)は、プログラムをクラッシュさせる代わりに、捕捉可能な "data race in task" エラーになります。これはセーフティネットであって、ロックの代わりではありません。Go と同じく、共有された辞書をロックなしで変更するのは致命的なエラーです。

モジュールと標準ライブラリ

import "lib/utils.cobra" はファイルを一度だけ実行してキャッシュし、utils というモジュールオブジェクトを束縛します。as で別の名前を付けられます。インポートの循環はエラーで、モジュールは外部からは読み取り専用です。

組み込みのネイティブモジュールは Go で書かれていて、両方のエンジンで共有されています。そこで起きる失敗はすべて捕捉できます。

モジュール内容
fs読み込み、書き込み、追記、行単位の読み込み、ディレクトリ操作
math、random数学関数、シード指定可能な乱数
fmt数値を固定小数点、桁区切り、パディング付きのテキストに
jsonキーの順序を保つ parse、インデント付きの stringify
time時計、単調タイマー、strftime 形式の format と parse
reRE2 構文の線形時間正規表現
httpクライアント(get、post、request)とサーバー(serve)、SSE ストリーミング
proc、netサブプロセス、TCP ソケット、TLS
crypto、encodingsha256、hmac、PBKDF2 パスワードハッシュ、base64、hex
os、runtime引数、環境変数、GC とメモリの統計
test組み込みのユニットテストフレームワーク

HTTP サーバーは、フレームワークなしでも数行で書けます。

import "http"
import "json"

def handler(req)
    if req["path"] == "/hello"
        let name = req["query"].get("name", "world")
        return {"headers": {"Content-Type": "application/json"}, "body": json.stringify({"hello": name})}
    end
    return {"status": 404, "body": "not found"}
end

http.serve(":8099", handler)

curl "localhost:8099/hello?name=ada" は {"hello":"ada"} を返します。リクエストの辞書には、メソッド、パス、クエリ、ヘッダー、ボディ、クライアントのアドレス、そして multipart/form-data で送られたファイルが入っています。ハンドラーはリクエストごとに 1 つの goroutine で並列に実行されます。

テストも言語の中にあります。

import "test"

@test.it
def addition()
    test.assert_eq(2 + 2, 4)
end

@test.it
def deep_equality()
    test.assert_eq([1, {"a": [2]}], [1, {"a": [2]}])
end

@test.it
def raises()
    test.assert_raises(def() -> 1 / 0)
end

let summary = test.run()
ok    addition
ok    deep_equality
ok    raises

3 passed, 0 failed, 3 total

ツール

1 つのバイナリで、すべてのモードを扱います。

cobra app.cobra                  # tree-walking interpreter
cobra --vm app.cobra             # bytecode VM
cobra --native app.cobra         # compile to machine code and run (cached)
cobra build app.cobra            # app.cobrac bytecode file
cobra build --exe app.cobra      # self-contained file with the VM embedded
cobra build --native app.cobra   # standalone native executable
cobra                            # REPL (multi-line, keeps state)
cobra fmt -w app.cobra           # source formatter
cobra watch app.cobra            # re-run when the file changes
cobra lsp                        # language server
cobra dap                        # debugger (Debug Adapter Protocol)
  • REPL は両方のエンジンにあります。開いた def や if のブロックを入力している間は、.. の継続プロンプトで続きを待ちます。
  • LSP サーバーは、本物のパーサーとコンパイラによるリアルタイムの診断を提供します。入力中の構文エラーと未定義変数、補完、ホバーでのドキュメント、ドキュメントシンボル、定義へのジャンプです。
  • VS Code 拡張は、シンタックスハイライト、ブロックを理解したインデント、スニペット、モジュールメンバーの補完、アウトラインビューをもたらします。cobra が PATH にあれば、LSP を自分で起動します。
  • デバッガー:cobra dap は Debug Adapter Protocol を通じて、ブレークポイント、ステップ実行、コールスタック、変数の確認をエディターに提供します。--ast と --tokens はパーサーと字句解析器が見ているものを表示し、実行時エラーにはスタックトレースが付きます。
  • Go プラグイン:import "name.so" は Go で書かれたプラグインを実行時にロードし、モジュールのように使います。

リリースバイナリは依存関係のない静的バイナリで、macOS(arm64、amd64)、Linux(amd64、arm64)、Windows 向けにビルドしています。

内部構造:1 つのフロントエンド、3 つのエンジン

Cobra のアーキテクチャ:字句解析器、パーサー、AST、3 つのエンジン

字句解析器がソースをトークンに、Pratt パーサーがトークンを AST に変えます。そこから道は 3 つに分かれますが、値の振る舞い(object パッケージ)と標準ライブラリ(eval パッケージ)はすべてで共有されています。だから、リストのメソッドや json.parse の呼び出しが 2 つのエンジンで違う振る舞いをすることはありえません。

ツリーウォーキングインタプリタは AST ノードを直接評価します。セットアップのコストがないので、REPL や短いスクリプトに最適です。

バイトコード VM は、ベンチマークスイートを約 4 倍速く実行します。速さの理由を、効果の大きいおおよその順に挙げます。

  • 浮動小数点数と struct インスタンス用の、VM ごとのアロケーションアリーナ。GIL がなく、アリーナが 1 つの VM に属しているので安全です。
  • レジスタ化したディスパッチループ:命令ポインタ、スタックポインタ、値スタックをローカル変数に置き、呼び出しとエラー経路の前後でだけ書き戻します。バイトコードは OpHalt で終わるので、ループに境界チェックは不要です。
  • 最もホットなパターンを 1 回のディスパッチにまとめる、11 個の融合スーパー命令:while i < n のような比較してジャンプ、i = i + 1 のような計算して格納、local.field の読み取り、list[local] のインデックスアクセス。
  • 整数と浮動小数点数の算術のインライン高速パスと、小さな整数(-1024 から 131071)のインターン。
  • 事前に確保した配列に置かれたコールフレーム:呼び出しごとのアロケーションはゼロ。
  • 文字列キーの辞書のための独自のオープンアドレス法ハッシュテーブル。ハッシュは文字列オブジェクトにキャッシュされます。
  • 行テーブルの検索をホットパスから完全に外したこと。エラーメッセージは、必要なときにだけ行番号を求めます。

ネイティブコンパイラは、プログラム全体の型推論を行います。Cobra は動的型付けですが、実際のプログラムはほとんどが型安定です。ある変数は常に整数を持ち、あるリストは常に浮動小数点数を持ちます。コンパイラはそれを証明できる箇所では、ボックス化しない整数と浮動小数点数、フラットな struct、ネイティブのスライスとマップを出力し、Go のツールチェーンをコード生成器として使います。出力は、浮動小数点数のフォーマットや丸めに至るまで、VM とバイト単位で同一であることを検証しています。

ネイティブコンパイルの対象は、言語の型安定なコア部分です。関数、init とメソッドを持つ struct、リスト、辞書、文字列、ループ、math です。クロージャ、async、try/catch、継承、record、ほかのモジュールを使うプログラムは「ネイティブコンパイル不可」と報告され、障害となる構文がそれぞれ名指しされます。そのプログラムは VM でそのまま動きます。ネイティブのファイルを作るにはビルドマシンに Go が必要ですが、実行には何も必要ありません(私のマシンでは約 2 MB の静的な Mach-O ファイルになります)。

性能

ネイティブコンパイルと 2 つのエンジンの比較

上のグラフは、言語のサイトで公開している測定結果です。n-body、binary-trees、spectral-norm、mandelbrot、word-frequency の各ベンチマークを Cobra と Python で同じように書き、すべてのランタイムが同じ出力を出します。ネイティブの Cobra は合計 0.28 秒で終わり、PyPy のトレーシング JIT(0.44 秒)を上回ります。CPython 3.14 は同じスイートに 5.99 秒かかります。

下のグラフは、6 つのマイクロベンチマークで 2 つのエンジンを比較したものです。再帰呼び出し(fib)では VM が 10 倍以上速く、合計では 4.1 倍です。

正直に言うと、VM がすべてにおいて CPython に勝つわけではありません。この記事のために、600×600 ピクセル、200 反復の mandelbrot ループを自分で計測しました。ツリーウォーキングインタプリタは 16.5 秒、VM は 2.5 秒、CPython 3.14 は 1.7 秒、ネイティブコンパイルした Cobra は 0.07 秒(ビルドが済んでキャッシュされた後)でした。浮動小数点の計算が多く、ループ条件が複雑な場合は、VM のボックス化のコストが表に出ます。いずれにせよ、そうした処理に適した道具は --native です。Cobra の本当の持ち場は、高速でインストール不要なスクリプト、組み込みスクリプティング、そして最初から最後まで理解できるくらい小さなランタイムです。

エコシステム:cobranum と Venom

cobranum は Gonum の上に作った、あるいは macOS で cgo を使ってコンパイルした場合は Apple Accelerate(BLAS/LAPACK)の上に作った、NumPy 風の数値配列ライブラリです。密な 1 次元と 2 次元の配列、ベクトル化された演算、ブロードキャスト、集約、線形代数を提供します。本では、最小二乗法、PCA、PageRank、数値積分、微分方程式といった科学的な例とともに解説しています。

cobranum によるロトカ・ヴォルテラの捕食者・被食者モデル

Venom は、すべて Cobra で書かれた Django にインスパイアされた Web フレームワークです。デコレーターによるルーティング、OxiDB 上の ORM、継承のできるテンプレートエンジン、ミドルウェア、セッション、静的ファイル、CSRF 保護を備えています。Cobra はクラス継承よりファクトリ関数を好むので、モデルはファクトリから作られます。

import "venom/router.cobra"
import "venom/response.cobra"
import "venom/orm.cobra"

let route = router.route
let db    = orm.connect("127.0.0.1", 4444)
let User  = orm.model(db, "users").ensure()

@route("GET", "/users/<name>")
def show_user(req)
    let user = User.get({"name": req["params"]["name"]})
    if user == null
        return response.not_found("no such user")
    end
    return response.ok(user)
end

router.serve(":8000")

OxiDB への接続は、Go で書かれた oxidb.so プラグインを通じて行います。同じファミリーには、OxiDB の SQL エンジン用の Entity Framework 風 ORM(oxisqlorm)もあります。

本

言語全体を、トルコ語と英語の 2 冊の本で解説しています。基本から制御フローまで、コレクションからオブジェクト指向まで、並行性から内部構造と cobranum までです。英語版には Venom の章も追加しています。どちらも PDF で無料です。

試してみる

バイナリ、VS Code 拡張、cobranum、本は cobralang.baltavista.com にあります。macOS(Apple Silicon)でのいちばん短い手順は次の通りです。

curl -LO https://cobralang.baltavista.com/releases/cobra-0.13.0-darwin-arm64
chmod +x cobra-0.13.0-darwin-arm64 && mv cobra-0.13.0-darwin-arm64 /usr/local/bin/cobra
cobra --version

あとはファイルを開いて print("hello") と書くだけです。

BA
Barış Akın

25年以上の経験を持つソフトウェアソリューションアーキテクト。FinTechと分散システム。OxiDB、Rill、Cobraの開発者。