cumin というのは設定ファイル言語である. 最終的に得たいものが何でもありな, ふるゆわデータである以上, 例えば
[
{"type": "A", "name_of_a": "hoge"},
{"type": "B", "id_of_b": 1}
]
みたいなものを作れなきゃ困る場面は絶対ある. 今の所, 配列の中身は型を揃えなきゃいけないのでこれが出来ない.
解決策として次の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 には和集合の意味の合併型がある. 合併型の宣言は結局部分型の宣言である.
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() ]