cumin v0.9.9a

2021-02-12 (Fri.)

cumin

INDEX

初めに

まだ一度も日本語で cumin のドキュメントをまともに書いてないこと気づいたので, 一度区切りを付ける意味で書いてみる. 執筆現在の最新バージョンは v0.9.9a である.

cumin という言語を開発した. これは設定ファイル用言語である. プログラマならば JSON や YAML を触ったことがあるはずだろう. 非プログラマであっても ini ファイルくらいなら触ったことがあるかもしれない. そういった言語をここでは設定ファイル用言語と呼んでいる. 設定ファイル用言語とはなにか. プログラミング言語がデータとその加工方法を記述する言語であるのに対して, 設定ファイルは静的なデータを記述することに特化した言語である. 例えばプログラミング言語は任意のデータに対する加工方法を記述する必要があるから, 複雑な計算が出来る必要がある一方で, 設定ファイルではそのままの値がそのまま記述されるだけのことが多い. そこで JSON や YAML といった言語はどんなデータでもありのままを記述できることだけに注力している一方で, データの妥当性の保証を何もしてくれない.

例として, 複数あるサーバの接続先を記述するYAMLを書いてみよう.

---
servers:
  - host: 192.168.0.1
    port: 8080
  - host: 192.168.0.2
    port: 8080

よくある例だ. 一方で別の設定ファイルを開くと次のように書いてあった.

---
servers:
  - host: 192.168.0.1
  - host: 192.168.0.2
    port: 8080

host: 192.168.0.1 に対する port が抜けている. これが単純に記述側のミスなのか, これも妥当なデータであるのかどうかは, このYAMLだけからは分からない. YAML として文法が誤っているわけではないので間違いなく妥当であって, 後はこのデータを使う側の責任だからだ. もしかしたら port フィールドが無いと実行時エラーを起こすかもしれないし, ない場合はデフォルト値が使われるように設計されているかもしれない.

ここで言える YAML(JSON であっても ini であっても全く同様だが)の問題点はこうだ. 結局の所, データはデータを使って加工するプログラムと密な関係であって, プログラムの中を読まない限りデータに意味を与えることが出来ない. したがってそれが正しいのかどうかをデータを見ただけでは何も分からない.

一方でここに緩やかな意味付けを与える技術があり, 型システムと呼ばれている. ここでは, 「servers はリストであって, その各養素は hostport を持つ. しかも port は整数である」という程度の意味をもたせる事ができる. もちろん port が整数だからといってそれが何を表す整数なのかまでは何も言う事ができない. (だから, ポート番号としては意味のないような数を与えることは出来てしまう.) その意味で緩やかな意味づけだと述べた.

cumin

そこで cumin を開発した. cumin は次のような特徴がある.

  1. 漸進的型システムを備えている
  2. 構造体や列挙体による構造的記述をサポートする
  3. 関数定義による記述量の削減
  4. データを JSON/YAML に出力するコンパイラの存在
  5. 少しだけプログラム記述が可能である

特に最後の「少しだけ」であるが, 逆に言えば, 普通のプログラミング言語のようなプログラム記述は出来ない. 例えば if 文がないしループ文がない. 関数はあるけれど再帰呼び出しすらできない. cumin はあくまで設定ファイル用言語であって, 静的なデータを記述することだけを目的としているからである.

Jsonnet, Dhall といったよく似た目的の言語がある. これらは cumin から見れば, かなりプログラマブルで表現力豊かな高級言語である. cumin を開発するにあたってこれらの言語の存在は知らなかったわけではなく, 表現力が豊かであることをむしろ良しとしていなかったことこそが開発動機になっている なぜなら, 静的なデータを人間が読むときに, 誰もフィボナッチ数の定義など見たくないからだ. リストへの map 操作を見て正しい挙動であることのチェックをしたいわけではないからだ. そういったことは寧ろ, データを使う側の責任であり, 設定ファイルの上で行うことではないと, 私は考えている.

本当は関数を入れるかも悩んだくらいだ. しかし今の所, 関数のボディに記述できることは限られているし, マクロ程度にしか使えないはずなので, 記述量の削減という目的が果たせると思い, 導入してある.

では先程の YAML による servers を cumin で記述してみよう.

struct Server {
    host: String,
    port: Nat,
}

{{
    servers = [
        Server("192.168.0.1", 8080),
        Server("192.168.0.2", 8080),
    ]
}}

大きく2つの部位からなっている. 一つは Server という構造体の定義である. これは文字列 host と自然数 port を有する辞書だと言っている. そして次に {{ }} で辞書を表現している. この辞書は servers というフィールドを持っていて, その値は配列である. 配列の中には Server データが2つある.

YAML と比較して, 全体として記述量は増えたし冗長にはなった. これはもちろんわざわざ構造に名前を与え, 中身のスキーマに型という制約を与えたからだ. しかしこのおかげで serversServer の配列だというチェックが走るようになる. もし違う値が混ざっていたらエラーを起こすので設定ファイルを読む時点で気づく事ができる.

例えば,

{{
    servers = [
        Server("192.168.0.1", 8080),
        0,
        Server("192.168.0.2", "8080"),
    ]
}}

これはダメだ. なぜならまず他は Server データなのに 0 というただの自然数のデータが混ざっているのから. それから最後の Server データは port に文字列 "8080" を渡そうとしている. Server の定義を見ると port は自然数でないといけないからこれもエラーになる.

このように構造に名前を与え, いちいち型をアノテーションするという冗長な記述が人間に優しい, という思想で cumin は設計されている.

言語仕様

ここからは言語仕様をやや非形式的に説明していく.

文と式

文として次がある

  1. 構造体宣言
  2. 列挙体の宣言
  3. 合併型宣言
  4. 変数の let 束縛
  5. 関数宣言
  6. 外部モジュールのインポート

これらはデータそのものの表現ではなくて, データを表現するための準備に使う. 一方で, 式はデータそのものを表す

  1. 自然数, 整数, 文字列, 配列, 辞書データ, Option データ
  2. 宣言済みの構造体にデータを入れたもの
  3. 宣言済みの列挙体の列挙子
  4. 宣言済みの型に型変換したデータ
  5. 宣言済みの変数
  6. 宣言済みの関数に関数適用したデータ
  7. 以上のデータを組合せて計算させたもの

この「宣言済みの」というのが初めに文で宣言したものという意味である. データは複雑であればあるほど, そこに構造を見出す事ができる. その構造に名前を与えて整理するというのが文の役割であって, データはそこに具体的な値を入れて並べただけのものになっている.

したがって cumin の記述は全体として次のようになっているはずである.

(文1)
(文2)
:
(文N)
(式)

データが単純であれば文は必ずしも必要ではなくて 0 個かもしれない.

cumin に登場するデータには何かしらの型がついている. 型は次のように記述される.

Nat
    自然数, これは 0 以上の整数を表す

Int
    整数

Float
    浮動小数点

String
    文字列

Bool
    真偽値

Any
    任意のデータに割り当てることが出来る型
    トップ型などとも呼ばれる

Array<_>
    配列型
    _ の部分には要素の型を入れる
    例えば自然数の配列なら Array<Nat>
    整数の配列の配列なら Array<Array<Int>>

Option<_>
    Null (Nil, None) でありえるデータ
    _ には Null でない場合に入るデータの型を入れる
    例えば Null を許容する文字列型は Option<String>

構造体

いくつかのデータをグルーピングしたものを構造的という. 集合意味論で言えば直積にあたる.

宣言

struct <NAME> {
    <VAR> : <TYPE> ,
}

NAME は構造体の名前, VAR はフィールド名, TYPE は型名.

struct S {
    x: Int,
    y: Nat,
    s: Array<String>,
}

これは「整数 x, 自然数 y, 文字列の配列 s」というデータ3つ組に S という名前を与えている.

これを宣言した後では自由にこの S を使ってデータを記述することが出来る.

デフォルト値付きの構造体

構造体の各フィールドはデフォルト値を持つ事ができる.

struct <NAME> {
    <VAR> : <TYPE> = <EXPR> ,
}

例えば上の Ss だけにデフォルト値を持たせて

struct S {
    x: Int,
    y: Nat,
    s: Array<String> = [],
}

とすることが出来る. 構造体を適用する際に, デフォルト値を持ったフィールドは省略することが出来る.

適用

構造体に値を適用することで, データを作ることが出来る. 上の例の S では x, y, s という3つを適用することで, S という型を持ったデータを実体化できる.

適用は関数適用の用に丸括弧の中に渡す値をカンマ区切りで並べる.

<NAME> ( <EXPR> , ... )
S(x, y, s)

丸括弧の中に並べた引数は, 構造体で定義された順にフィールドに代入され, その際に型のチェックが走る. また定義のフィールドの数に足りない場合は定義時のデフォルト値を使おうとする. 引数の数が足りなく, デフォルト値も宣言されてない場合はエラーとなる.

struct S {
    x: Int,
    y: Nat,
    s: Array<String> = [],
}

S(1, 2)  // => S { x = 1, y = 2, s = [] }
struct S {
    x: Int,
    y: Nat = 0,
    s: Array<String>,
}

S(1, 2)  // ERROR!! s はデフォルト値を持たない

もう一つの適用方法があり, こちらは丸括弧の代わりに波括弧を使い, そして各引数はどのフィールドに与えるかをキーワードで指定する.

<NAME> { <IDENTIFIER> = <EXPR> , ... }
S { x = x, y = y, s = s }

こちらは引数の順序は任意で良く, また与えられなかった引数はデフォルト値があれば使おうとする.

struct S {
    x: Int,
    y: Nat = 0,
    s: Array<String>,
}

S { x = 1, s = [] }  // => S { x = 1, y = 0, s = [] }

列挙体

有限通りの内のいずれかちょうど一つを取るようなデータを列挙体という. 集合意味論でちょうど有限集合に相当する.

宣言

列挙体の名前及び列挙子(集合の要素)に名前を与えて宣言する.

enum <NAME> { <VARIANT> , ... }

例えば次は4点からなる列挙体である.

enum BloodType {
    A,
    B,
    AB,
    O,
}

列挙子

列挙体の名前と列挙子の名前を :: でつなげて列挙子を表すデータになる.

<NAME>::<VARIANT>

BloodType::A

合併型

構造体及び列挙体として宣言した複数の型を関連付けるのに合併型を使う. これは和集合に相当する.

宣言

組み込みまたは宣言済みの型を複数並べて,

type <NAME> = <TYPE> | <TYPE> ... ;

と記述する. あまり意味はないが右辺に並べる個数は1個でも良い.

struct Triangle { ... }
struct Square { ... }

type Shape = Triangle | Square;

ここで Triangle 型または Square 型を持つデータは Shape 型でもあるということを宣言した. この関係のことを <: という記号を使って次のように書く.

Triangle <: Shape,
Square <: Shape.

型キャスト

ただしデータに紐付いた型が自動的に合併型にキャストされることはなく, すべてユーザーが明示する必要がある. 合併型の名前そのものがそれへの型キャストを行う関数として機能する.

合併型 T があって, S <: T のときに S 型を持つデータ x があるなら

T(x)

これは T 型を持ったデータを表す. x とは型が違う以外は何も変わらない.

変数の let 束縛

データに名前を付けることが出来る. これを普通のプログラミングのように変数と呼ぶ.

let <NAME> = <EXPR> ;

または型アノテーションを与えて

let <NAME> : <TYPE> = <EXPR> ;

と書く.

let x = 1 + 2;
let z: Int = x + 1;

この宣言以降で x 及び z という名前でデータを参照することが出来る. ただしいわゆる手続き型言語の変数と違う点として, cumin の変数は中の値が変更不可能な点である. したがってこの変数のスコープの中では x 及び z が指すデータは定数で変わらない.

変数のシャドーイング

やや奇妙に見えるかもしれないが, 同じ名前の変数を宣言することが出来てしまう. 例えば,

let x = 3; // 自然数データ
let x = "three";  // 文字列データ

どちらも x という名前だが中に入ってるデータは全く異なる. cumin ではこのような書き方は合法である. これは変数 x に(型まで無視して)代入をしているように見えるかもしれないが違う. 2つの変数 x は全く別物であり, 異なる変数スコープを有してるに過ぎない.

let x = 3;
// (x を使った処理 1)
let x = "three";
// (x を使った処理 2)

こう書いたときに, 処理1 で参照される x3で, 処理2で参照される x"three" となる. 逆に言えば1つ目の x という変数のスコープは次の x が宣言されるまでの間だけということになる. 細かい点として, 2つ目の x の右辺を評価する時点までは1つ目の x の範疇となっている. したがって,

let x = 1;
// ...
let x = x + 1;  // この右辺まではまだ `x=1` が通用している
// ここからは新しい x=2 だけが参照される

という書き方が許される.

このような機能を変数のシャドーイング (shadowing) という. 凝った名前を付けるほどでないデータであってスコープが限定的な場合くらいに大変便利であるが, 気をつけなければ混乱するコードにもなりかねない. 特に最後の例のような「一見再代入でもしてるか」のようなコードは避けるべきである.

関数

宣言

関数は次の形式で宣言する.

fn <NAME> ( <IDENTIFIER> : <TYPE> , ... ) = <EXPR> ;

または頭のキーワードを変数の宣言と同じく

let <NAME> ( <IDENTIFIER> : <TYPE> , ... ) = <EXPR> ;

としてもよい.

引数部は構造体と同様にデフォルト値を与えることも出来る.

fn <NAME> ( <IDENTIFIER> : <TYPE> = <EXPR> , ... ) = <EXPR> ;
let <NAME> ( <IDENTIFIER> : <TYPE> = <EXPR> , ... ) = <EXPR> ;

関数は変数と同様に宣言した以降でしか使えずそのスコープは変数と全く同様である.

適用

構造体への適用と同じく, () の中に引数を書く方法と {} の中にキーワード付きで引数を与える方法がある.

fn add(x: Int, y: Int = 1) = x + y;

add(1, 2)  // 3
add(1)     // 2
add{x=1}   // 2
add{y=2}   // ERROR!!

クロージャ

関数は宣言時点の環境を記憶しており, その時点の関数外の変数を参照出来る.

let z = 42;
fn f(x: Int) = x + z; // x + 42

f(1) // 43

宣言時点の環境であることに注意.

let z = 42;
fn f(x: Int) = x + z; // x + 42

let z = 1;
fn g(x: Int) = x + z; // x + 1

f(1) // 43
g(1) // 2

f からは1つ目の z だけが見えているため +42 される. 一方で g からはシャドーイングされて新しい z=1 だけが見えている.

外部モジュールのインポート

文だけを記述した外部ファイルを次のようにして読み込める.

use "<FILE_PATH>" ;

// mod.cumin
struct S{}
use "./mod.cumin";
// 構造体 S が使える
S()

自然数, 整数, 浮動小数点

cumin では数として自然数, 整数, 浮動小数点の3つを用意している. これらそれぞれに相当する型は Nat Int Float である. 特に自然数は 0 以上の整数に限ったものをそう呼んでいる.

それぞれ10進数で表記して値を書ける. 注意点として, 0 以上の整数として解釈できるならいつも Nat になり, 小数点 . を含むなら Float として解釈される.

let x = 1;  // Nat
let y = -1;  // Int
let z = 1.0;  // Float

また桁区切りを与えるのに _ を自由に補ってよい.

let x = 1_000_000_000;
四則演算・べき乗

数同士の計算として四則演算及びべき乗を用意している.

演算 記号 意味
\(+\) + 加算
\(-\) - 減算, 単項マイナス
\(\times\) * 乗算
\(\div\) / 除算
\(x^y\) ** べき乗

自然な型キャスト

cumin では基本的に型は明示しない限りキャストされないが, 唯一の例外として, 数は Nat <: Int <: Float の関係で暗黙的にキャストが行われる. これは let 束縛や関数適用, そして演算時に行われる. 例えば自然数同士の引き算の結果に負の値が得られた時, これは整数にキャストされる.

let f(x: Int) = x;
let x: Nat = 1;
f(x)

適用される前の x は自然数であるが, 適用の際に fInt を要求しているので, cumin は暗黙的に x を整数と見なしている.

let x: Nat = 1;
let z: Float = 1.0;
x + z

自然数と浮動小数点の足し算は浮動小数点の範囲にすれば自然に出来るので, cumin は暗黙的に x を自然数から浮動小数点にキャストする.

ただし逆の方向は出来ない. つまり, 一般的に Float の値は Int にできないし(1.5 など), Int の値を Nat にできない(負数). というわけでそちらのキャストは勝手には行わない.

文字列

ダブルクオーテーション "" で括って文字列データを表現する.

let s = "Hello, World";

改行を含む複数行の文字列を表現するにはそのまま改行すればよい. また " という文字を含めたい場合は \" というエスケープをする.

let s = "\"Hello,
World\"";

s // "\"Hello,\nWorld\""

文字列結合

+ という演算で文字列同士を結合する.

let name = "cympfh";
let s = "Hello, " + name;

真偽値

true 及び false という値があり, Bool という型がついている. Bool に対応する値はこの2つしかない.

let x: Bool = true;
let y: Bool = false;
演算 記号 意味
\(\land\) and 論理積
\(\lor\) or 論理和
\(\oplus\) xor 排他的論理和
\(\lnot\) not 単項NOT

配列

配列は同じ型のデータを0個以上, 特に個数が限定されてないようなものを順序付けて並べて持つデータである. 要素の型が T のとき, この配列の型を Array<T> で表す.

[ <EXPR> , ... ]

[1, 2, 3 + 4]  // Array<Nat>

演算

組み込み関数によって次のことが出来る.

関数 意味 使用例
concat 結合 concat([1], [], [2, 3])
reverse 逆転 reverse([3, 2, 1])

辞書(無名構造体)

構造体を定義することなしに構造体データを作ることが出来る.

{{
  <IDENTIFIER> = <EXPR> ,
  <IDENTIFIER> : <TYPE> = <EXPR> ,
  ...
}}

{{
  name = "cympfh",
  age = 17,
}}

これは次と同じ

struct S {
  name: String,
  age: Nat,
}
S("cympfh", age)

ただしこちらではデータに S という型がついている一方で, 辞書データには何も型がついていない. 型が付けられない場合に「どんなデータでも表し得る型」として Any がついている.

次は Any の配列であって正しい cumin データである.

[
    {{x=1}},
    {{y=1}},
    {{z="Hello"}},
]

構造体を与えられる箇所には出来るだけ与えることを推奨する.

ブロック式

cumin データは次の形式をしていると以前に述べた.

(文1)
(文2)
:
(文N)
(式)

これら全体を {} で括ることで, 一つの式にすることが出来る.

{
  (文1)
  (文2)
  :
  (文N)
  (式)
}

式なので, let (fn) の右辺値や, 構造体のデフォルト値等に置く事ができる.

let x = {
    let a = 1;
    let b = 2;
    a + b
}; // => 3

fn f(z: Int) = {  // z = 3
    struct S{
        x: Int = {
            fn g(x: Int) = x + z; // 10 + 3 => 13
            g(10)
        },
    }
    S()
};

f(x) // => S{x=13}

この例で let x の右辺で定義した変数 a b{} の中で定義したので, ここでは使えるが, {} の外では参照できない. このように, スコープを限定させるのにブロックは便利である.

cuminc

cuminc は cumin のためのコンパイラであり, cumin データを他のフォーマットに出力する. 今は JSON と YAML に対応している.

インストール方法

cargo が必要なのでこちらをまずインストールしてもらう. rustup という管理ツールを経由するのが圧倒的に楽で推奨.

cargo が入ったら,

cargo install cumin

とすれば cuminc コマンドがインストールされる.

Githubレポジトリ から最新版を持ってきて自分でビルドしてくれても良い.

使い方

cumin データのファイルを指定するか, 標準入力に食わせるとデフォルトでJSONを出力する.

$ cat test.cumin
[1, 2, 1 + 2]
$ cat test.cumin | cuminc
[1,2,3]
$ cuminc test.cumin
[1,2,3]

-T オプションで出力フォーマットを指定する.

$ cuminc -T yaml test.cumin
---
- 1
- 2
- 3