【はてなスタッフ非公式ブログバトン】 Haxeの善し悪し
id:hatz48さんからバトンがまわってきました.
前々から個人的に興味があったので, 社内で言語の話題が出る度に「Haxe, Haxe」と言っていたら, 「Haxeと言えばtaraoさん」みたいになってて, なぜかHaxeについて書く羽目になってしまった. Haxeなんて1ミリも書いたことないのに! この記事は http://try.haxe.org/ でちょっと遊んだ程度のにわか知識で書かれております.
もともとはよさげなAltJSを紹介していくみたいな文脈のようだけど, 個人的には特殊用途以外では素のJavaScript書いてりゃいいんじゃね, と思うので, 文脈無視で単純に言語/処理系の善し悪しについて書くよ!
Haxeってなに
http://haxe.org/ によれば「マルチプラットフォーム オープンソース プログラミング言語」らしい. AltJSなんてケチくさいこと言ってないで複数のターゲット言語に変換して実行できるようにしようぜ, という感じの言語.
よいところ
ターゲットのプラットフォームが多い
JavaScriptはもちろん, Flashにしたり, C++に変換してiOSやAndroidで動かしたり, 仮想機械語に翻訳してサーバサイドで動かしたり, とにかく多用途. しかもプラットフォーム依存なコード, たとえばJavaScriptをターゲットにしてjQueryを使うというようなものも書ける. さらにそういうプラットフォーム依存なコードは条件コンパイルできるので複数プラットフォームで共有するコードも書ける.
サポートされているプラットフォームの完全なリストは https://github.com/HaxeFoundation/haxe にある.
速いらしい
ちょっと前にJavaScriptに変換した場合の速度がJSX並という話があったので, リッチなクライアントサイドアプリ案件にも使えそう. ターゲット言語がJavaScriptでなくてもよいならC++に変換すれば爆速だろうし, 速いのはよいことだ!
構造的部分型
ふつうの型は型を名前で区別していて, ひとつひとつの型はユニークではあるけれど同列であって, 部分型(サブタイプ)を考える場合には言語に組込みで型間の順序が定義されている(たとえばInt <: Float
)か, プログラマが自分で(extends
とかで)定義する. 自動的に部分型関係が定義されるということはない.
構造的部分型は, 型に単なる名前ではない構造をもったものも許容して, その構造から自明に決まる順序を使って部分型関係を定義する. つまりプログラマがextends
みたいなものをいちいち書く必要がない.
次の例の場合, Point3D
は自動的にPoint
の部分型になっている.
typedef Point = { x : Int, y : Int } typedef Point3D = { x : Int, y : Int, z : Int } function expectPoint(pt : Point) { ... } var pt3d : Point3D = { x : 0, y : 0, z : 0 }; expectPoint(pt3d); // OK!
これはなんかもう見るからにJavaScriptで書いてたコードをHaxe化するときにやりやすそう. そもそもクラスによる部分型も構造的部分型で模倣できる*1から, なんだ, クラスなんて要らんかったんや! Haxeにはクラスもあるけど(もしかしたら内部的には構文糖衣かもしれない).
直和型
enum
で直和型が定義できる.
enum Color { Red; Green; Blue; Gray(v : Int); RGB(r : Int, g : Int, b : Int); }
使うときはswitch
で.
function toInt(c : Color) { return switch(c) { case Red: 0xFF0000; case Green: 0x00FF00; case Blue: 0x0000FF; case Gray(v): (v<<16) | (v<<8) | v; case RGB(r,g,b): (r<<16) | (g<<8) | b; }; }
switch
が式なのがよい! ちなみに, きちんと直和型なので網羅性もチェックされる. 場合分けを忘れるとコンパイルエラーになるので実行しなくてもミスが発見できる(実際これを書くときに怒られて気づいた).
function toInt(c : Color) { return switch(c) { // Unmatched patterns: Blue case Red: 0xFF0000; case Green: 0x00FF00; case Gray(v): (v<<16) | (v<<8) | v; case RGB(r,g,b): (r<<16) | (g<<8) | b; }; }
さらに型引数をとってこんなものも書ける.
enum Cell<T> { Nil; Cons(item : T, next : Cell<T>); }
Cons(1, Cons(2, Cons(3, Nil)));
もっと実用的なものとしてはオプション型とか定義できる. みんな大好きMaybeモナド!
enum Maybe<T> { Nothing; Just(v : T); }
var s1 : Maybe<String> = Just('foo'); var s2 : Maybe<String> = Nothing; new js.JQuery("body").html(switch (s1) { case Nothing: 'nothing to print'; case Just(s): s; });
これでnull
を撲滅できるね!
まぁクラスがあれば直和型はだいたい実現できる*2のだけど, 言語によるサポートがあると構文が簡単で捗る.
多相型
多相型のない型付言語なんてゴミだよね. Haxeでは記法はアレだけど多相型が使える.
function id<T>(x : T) { return x; } id(3); id('foo');
漸進的型付け(gradual typing)
Dynamic
型として宣言した変数は, どんな型を要求するところにも使えて, どんなプロパティにもアクセスできて, どんな型の値も入れられる. JavaScriptのコードをHaxe化したいという場合はとりあえずぜんぶDynamic
型にしておいて, だんだん型を書いていくとよさそう. だんだん型をつけるので漸進的型付け(gradual typing)と呼ばれている.
var d : Dynamic = {}; d.x = 3; d.y = 5; var pt : { x : Int, y : Int } = d; d = 'foo';
Siekの分類によれば漸進的型付けには3段階ある.
- レベル1: 型検査はするが実行に際しては型をただ取り去るだけ
- レベル2:
Dynamic
型と他の型との境界で, キャストできるか実行時に検査が行なわれる - レベル3: 実行時のキャストはなるべく遅れて起きるが, 失敗時のエラーの原因として境界部分の箇所が追及される
レベル3はちょっと難しい話なので置いておくとして*3, レベル2は, Dynamic
型を使ったことでもしもエラーが起きたなら, それはDynamic
型の部分が原因, という一見当たり前に思える性質が保証されていることを意味する. けれどこれはぜんぜん当たり前ではなくて, たとえばTypeScriptではそういうことは保証されないし, HaxeでもJavaScriptに変換する場合は保証されない(つまりレベル1になっている). 確かめてみよう!
class Test { static function main(){ var x : Dynamic = new Bar(); new js.JQuery("body").html(test(x)); } static function test(foo : Foo) { return foo.foo(); // TypeError: foo.foo is not a function } } class Foo { public function new(){} public function foo(){ return "foo"; } } class Bar { public function new(){} public function bar(){ return "bar"; } }
Dynamic
型のx
はどこにでも渡せるので, コンパイルは通る. 実行してみると, foo.foo()
のところでエラーになる.
型を除去して実行したらこうなるのは当たり前だけど, これはJSだからよかったものの, たとえばC++に変換する場合で最適化も効かせるなら, ふつうに考えてtest
メソッドはFoo
クラスの値しか受け取らないのだから, foo.foo()
は静的に解決してしまおうとするはずで, そこでfoo
が実はべつのクラスでした, なんてことではもうsegmentation faultは避けられない. だから最適化したいということまで考えるなら(あるいは静的型付けって本来そういう最適化が可能なはずのものと思うなら), 当然Dynamic
でない型しか出てこない部分では予期せぬ事態は絶対に起きないことを保証すべき. それがレベル2で言っていること.
JavaScript以外のプラットフォームへの変換もサポートしているHaxeは, この辺もちゃんと考えているようで, たとえばFlashをターゲットにした場合はどうやらレベル2になっていそう.
class Test { static function main(){ var x : Dynamic = new Bar(); try { trace('test()'); test(x); } catch (err : Dynamic) { trace(err); } } static function test(foo : Foo) { trace('foo()'); return foo.foo(); } } class Foo { public function new(){} public function foo(){ return "foo"; } } class Bar { public function new(){} public function bar(){ return "bar"; } }
Test.hx:5: test() Test.hx:7: TypeError #1034
なんかエラートレースを出す方法がよくわからなかったのでprintf
デバッグ的になっていてわかりにくいけれど, foo.foo()
が呼ばれる前, もっと言うとtest()
の本体に入る前でエラーになっている. Dynamic
型の変数をFoo
型が期待されるコンテキストに渡しているところが境界なので, レベル2ならtest()
の呼び出しのところでエラーになるはず("test()"
は印字されるけど"foo()"
が印字される前に例外で脱出するはず)なので, 正しそう! やるじゃん.
まとめると, HaxeはJavaScriptをターゲットとした場合はレベル1の漸進的型付けでしかないけれど, プラットフォームによっては(すくなくとも)レベル2の漸進的型付けを実現していそう! すごい!
わるいところ
ターゲットのプラットフォームによって意味論がちがう
漸進的型付けのところで見た通り, どのプラットフォームをターゲットにするかによって言語の意味論が変わってしまう. これは言語仕様を曖昧にして, とくに静的型付言語に求められる型安全性の保証を難しくしてしまうのではないかという気がする.
多相型が型推論されない
メソッドの型を推論したとき, 基本的に推論される型は単相なので, 多相的に使いたいメソッドは型を書かないといけない.
function id(x) { return x; } id(3); // id : Int -> Int id('foo'); // コンパイルエラー "String should be Int"
function id<T>(x : T) { return x; } id(3); // id : Unknown<0> -> Unknown<0> id('foo'); // id : Unknown<0> -> Unknown<0>
まぁvar f = (function(){ return function(x){ ... }; })();
みたいに書いたときはパラメータ多相とかいろいろあるので単相な方が正しそうだけど, すくなくともfunction
宣言のときは多相になってもいいんじゃないか. OCamlとかに慣れてると正直つらい. <T>
みたいなJavaやC++っぽいのは極力書きたくない.
型推論と構造的部分型の組み合わせが微妙
なぜか以下のコードがコンパイルエラーになる.
var pt3d = { x : 1, y : 2, z : 3 }; // pt3d : { z : Int, y : Int, x : Int } function f(pt : { x : Int, y : Int }) { ... } f(pt3d); // コンパイルエラー: "{ z : Int, y : Int, x : Int } has extra field z"
pt3d
には{ z : Int, y : Int, x : Int }
という型が推論されているはずなのに, なぜか{ x : Int , y : Int }
を要求するf
に渡そうとすると怒られる. ちなみに, 以下のように書けば通る.
var pt3d : { x : Int, y : Int, z : Int } = { x : 1, y : 2, z : 3 }; // pt3d : { z : Int, y : Int, x : Int } function f(pt : { x : Int, y : Int }) { ... } f(pt3d); // OK
型推論が止まらないかもしれない
構造的部分型(の暗黙的キャストを許す場合) + 再帰型 + 多相型の組み合わせで型推論/型検査が決定可能という話は聞いたことがない(ふつうに未解決問題なんじゃないか)ので, Haxeの型推論/型検査が必ず停止するかどうかはだいぶあやしい気がする. 少なくとも止まることが保証されているという話は見つけられなかった. まぁこういうのはよくあることで, 有名どころではたとえばJavaの型検査の決定可能性は未解決問題で, 実際いままでの型検査実装では無限ループするような入力例も見つかっている*4. もしかすると, この辺をad-hocに解決しようとした結果として型推論と構造的部分型の組み合わせが微妙になっているのかもしれない.
まとめ
なんか着眼点が趣味まるだしだけどHaxeはまぁまぁいい言語だと思った. ぜんぜんベストではないけど.
これまでの記事
次回
次はid:aerealさんです!
追記: 2013-12-13
よいところの書き忘れ
細かく解説するほど推す機能ではないけれど, マクロがあるのも(個人的には)よい. なにもなくて自分でプリプロセスするくらいなら最初から用意されている方がいざというときのためにはよいと思う. マクロを使いまくるのがよいかどうかは別として.
ブックマークコメントへの返信
【はてなスタッフ非公式ブログバトン】 Haxeの善し悪し - 貳佰伍拾陸夜日記
- [Haxe]
ちょっとまって、1ミリどころか、1キロぐらい書いたことあるんだろ
2013/12/13 08:39
この記事を書く前には本当に全く書いたことがありませんでした. 数行のコード断片は見たことがありましたが, まとまった量のHaxeのコードは読んだこともありません. この記事を書きはじめるにあたって, http://try.haxe.org/ を1日触っただけです.
なんというか, Haxe自身の個々の言語パラダイムが特別新しいものではないと思える程度に複数の言語に触れたことがあれば, こんなものでは?
ブックマークコメントへの返信
【はてなスタッフ非公式ブログバトン】 Haxeの善し悪し - 貳佰伍拾陸夜日記
- [haxe]
Haxeなんて1ミリも書いたことないとか言ってるのにすごい内容が充実してる。わるいぶぶんところのコンパイルエラーはメリットになる場合のほうが多いので、ちゃんと書けばそういう挙動になる理由がわかるのでは
2013/12/13 11:31
書き方が雑だったので申し訳ないのですが, コンパイルエラーになるのが「わるいところ」だと言いたいのではなくて, 型推論が弱いことがデメリットになっているという趣旨です.
少なくとも今回指摘した例は, どちらも型推論によってより一般的な型が推論されればコンパイルエラーにならず, 型安全性を損うことなく実行できるものです. 実際に型推論に頼らずに自分で型を書けばコンパイルに成功するということを指摘しているのはそのためでした. 型推論が弱いために, 自分で型を書けばコンパイルに成功するものでも, 型推論に任せるとダメだと言われてしまうということです.
そしてこのように型推論を弱くしているのは, 強めようとすると型システムがリッチすぎて決定不能になってしまうからではないか, だとしたらリッチな型システムを採用するために型推論は妥協していることになり, それはデメリットと言えるのではないか, というのが趣旨です. もちろん型システムのリッチさについては「よいところ」として言及しているので, 全体としてはトレードオフだということが言いたいわけです.
*1:たとえば, クラスCは$Cというダミーのフィールドを必ず持っていて, CをextendsしたDは$Cと$Dというフィールドを必ず持つようにする
*2:参考: Maybeに限らずJavaで直和型を実現できるか - 貳佰伍拾陸夜日記
*3:詳しく知りたい人はSiekの解説のLevel 3のところか, WadlerのWell-typed programs can't be blamedを読もう
*4:Kennedy and Pierce. "On Decidability of Nominal Subtyping with Variance", FOOL/WOOD '07, 2007.