Tue Dec 15 2020

教訓:IoTに命を任すな

cumin に合併型だかなんだかを入れておきたい

cumin というのは設定ファイル言語である. 最終的に得たいものが何でもありな, ふるゆわデータである以上, 例えば

[
    {"type": "A", "name_of_a": "hoge"},
    {"type": "B", "id_of_b": 1}
]

みたいなものを作れなきゃ困る場面は絶対ある. 今の所, 配列の中身は型を揃えなきゃいけないのでこれが出来ない.

解決策として次の4つが思いついてる

  1. 合併型を用意する
  2. 代数的データ構造を用意する
  3. 型クラスを導入する
  4. 部分型を導入する

合併型バージョン

和集合をただとるだけ. 今採用してる意味論(JSON への変換)を考えるとこれが実装は簡単.

struct A {
    name_of_a: String
}
struct B {
    id_of_b: Nat
}

data T = A | B;

let data: Vec<T> = [
    A("hoge"),
    B(1),
]

data

A()B() がどう JSON へ変換されるかは決まっていて, T は型をまとめあげる以上のことはしないくてJSON への変換方法を何も変えない.

代数的データ構造

直和を取る方法. 合併型で例えば Int | Int と書いた時にそれが厳密に Int と同値な一方で, 直和は左右どちらの Int なのかを区別する.

enum T {
    A { name_of_a: String },
    B { id_of_b: Nat },
}
let data: Vec<T> = [
    A("hoge"),
    B(1),
];
data

あると便利だろうけど, JSON への変換方法が自明じゃない. 合併型と違って何かタグを付けて区別する必要があるからだ.

型クラス

型のひとつ上のレイヤーに型クラスという概念を定義する. 各型クラス \(C\) には型の集合 \(\overline{C}\) が割り当てられている.

型アノテーションの場面では \(C\) も型と同じように使うことが出来る.

Rustの derive マクロによる型トレイトの宣言を真似るとこんな感じ

#[derive(T)]
struct A {
    name_of_a: String
}

#[derive(T)]
struct B {
    id_of_b: Nat
}

let data: Vec<T> = [
    A("hoge"),
    B(1),
];
data

T という名前の型クラスがあることをどこにも宣言してないけど, どうせ名前だけで実態のないものだし, そのために文法用意したくないな…

部分型

型クラスとか言ってるけどこれは結局部分型でしかない. そしてそういえば, 数に関しては Nat <: Int <: Float という部分型構造をプリミティブに定義していて, 暗黙的にキャストしちゃってる. この機能をユーザーに解放すればいい. 要するにクラスの継承関係を自由に定義できるようになるということなんだけど.

#[A <: T]
struct A {
    name_of_a: String
}

#[B <: T]
struct B {
    id_of_b: Nat
}

let data: Vec<T> = [
    A("hoge"),
    B(1),
];
data

文法上で型クラスとの違いは全くない. 型クラスは利用がかなり制限された継承関係である一方でこちらは勝手なことが出来そう. 上の例で B <: A にするとか.

以上書き出してみると, 合併型が平和な気がしている.

そういえばタイプエイリアスの機能も欲しいとは思ってた.

type T = Vec<Option<Vec<Int>>>;  // alias
type S = T | Vec<String>;  // union

またパーサがややこしくなるけど, S | T | ... | U もちゃんと1つの型だとしてパースするようにしよう. 次のように入れ子になってても問題がないことにする (Future Work).

type T = Vec<Option<Vec<Int | String>> | String>

合併型がある場合に, 次の型推論はどうすべきなんだろう.

let data = [1, A("hoge"), "hoge"]

その気になれば, これには Array<Nat | A | String> という型を付けることが出来てしまう. そうするともはや型検査の意味がなくなるじゃん... アノテーションを強制すればいい問題なのかな.

let data: Vec<Nat | A | String> = [1, A("hoge"), "hoge"];

または

type T = Nat | A | String;
let data: Vec<T> = [1, A("hoge"), "hoge"];

TypeScript の合併型

や, TypeScript とか本当知らんし.

これだけ見て分かった気になっていうけど, TypeScript には和集合の意味の合併型がある. 合併型の宣言は結局部分型の宣言である.

interface A {}
interface B {}
type T = A | B;

これは結局, (先述したように) A <: T, B <: T ということを言ってる.

引数の型が T な関数へ適用するときに勝手にアップキャストを行う.

cumin もこれくらいにしようかな. 文法では type 文を一個増やす.

eval 時には環境に

type_aliases: HashMap<String, Vec<Typing>>
// type_aliases["T"] = ["A", "B"]

を追加する. キャストはこれを適宜参照する. S から T へのキャストが必要な場面で, type_aliases["T"] を参照してその中に S があったらOKということにする.

名前が衝突した場合の処理を実は現状何もしてないんだけど, struct, enum, type で定義した名前は空間が分離されていて, 名前が衝突しててもいずれかが優先的に使われるだけということにしようと思う. 将来的には衝突のチェックは入れる.

包含写像?

代数的データ構造ではそのタグ自体が包含写像として機能する. 別に合併型であってもそれがあってもいいはずだ.

struct A {
    name_of_a: String
}
struct B {
    id_of_b: Nat
}

type T = A | B;

// Array<T>
let data = [
    T(A("hoge")),
    T(B(1)),
]

data

安全ではある.

括弧が多いなら, 単に糖衣構文として

// Array<T>
let data = [
    T.A("hoge"),
    T.B(1),
]

と書けてもいい?

ラスト草案

struct A{}
struct B{}
type T = Int | A | B;
// Array<T>
[ T(1), T.A(), T.B() ]