静的型付けをもつJavaScriptへのトランスコンパイル言語を味見してみた
JavaScriptを利用したWebアプリケーション開発
JavaScriptはWebアプリケーション開発において非常に重要なポジションを占める言語です。クライアントサイドにおいては、XMLHttpRequestを利用した非同期通信によるステートフルなクライアントUI作成手法がAjaxという名前で広まったことが、JavaScriptの存在を多くの開発者に見直されるきっかけとなり、以前に増して広く利用されるようになりました。サーバーサイドにおいてもnode.jsの登場により一躍注目を浴びる存在となっています。
しかし、JavaScriptは開発効率や保守性の点で問題を抱えていることがよく指摘されています。その理由は、カプセル化の難しさや、名前空間がないことによるグローバル変数の汚染、動的な型付けによる型のあいまいさなど様々です。それらは型の問題を除いて、ほとんどがJavaScriptの無名関数が作り出すレキシカルスコープをうまく利用することによって解決することができます。しかし、無名関数を利用した方法は少々記述が複雑で、お世辞にも可読性が高いとは言えません。
それでも、メンバー全員がハイスキルな玄人集団のチームがJavaScriptを利用して開発する分には、おそらく開発効率も保守性も問題にはなりにくいでしょう。現に、JavaScriptを利用した素晴らしいアプリケーションやゲームは既に多数存在しますし、長期にわたって運用されているものも少なくありません。
ただし、多くの開発現場においてはメンバー全員がJavaScriptの玄人ということはおそらくないでしょう。JavaScriptに不慣れなメンバーのいるチームでJavaScriptを利用した一定規模以上の開発、もしくは長期にわたる保守が必要な開発を行った場合、開発が進むにつれて可読性の低いソースコードで書かれた不具合の多いアプリケーションになってしまう確率は低くないはずです。もちろん、こういったチームでもCIなど集団開発の手法をうまく利用すれば品質を一定水準に保つことは可能だとは思います。
JavaScriptへのトランスコンパイル言語
少し前置きが長くなりました。集団開発の際の問題点は置いておいたとして、私個人としてはもう少し型や名前空間の面倒を見てくれる言語で書きたいなあと思っていました。そこで、しばらく前から注目されているJavaScriptにトランスコンパイルする言語の中から、静的型付けと型推論を持つ「Haxe」「JSX」「TypeScript」をちょっと味見して比較してみることにしました。
間違った説明をしないようにできる限り調査して記事にまとめていますが、どの言語も今回初めて触るものでしたので誤りがあるかもしれません。誤りを見つけた際にはご指摘をお願いします。
Haxe
Haxeはマルチプラットフォームでの動作を実現している言語です。Haxeで書いたプログラムは、JavaScriptのソースコードにコンパイルできるだけでなく、PHPやC++のコード、Flashのswf、専用のVMであるNekoVMのバイトコードにコンパイルできます。
HaxeはECMAScript4草案をベースにしているようで、ActionScript3.0と非常に似た構文を持っています。
JSX
今年になってDeNAからリリースされた言語です。出力されたJavaScriptが高度に最適化され、普通に書いたJavaScriptより高速に動作するコードであるようです。型に対する厳格さやジェネリクスなど、Javaに近い感覚の言語です。
TypeScript
10月に発表されたばかりのMicrosoftによって開発されている言語です。ECMAScriptの仕様との互換を強く意識しており、素直なJavaScriptを出力します。全体的に記述が簡潔になります。今はまだプレビュー版です。
インストールとコンパイル
今回の開発環境は下記の通りです。JSXとTypeScriptのインストールにnpmを使うので事前にインストールしておいて下さい。サンプルで書いたスクリプトの実行もNode.jsのnodeコマンドで行うと便利です。
- OS X 10.7.5
- Haxe 2.10
- JSX 0.0.2
- TypeScript 0.8.0
- Node.js 0.8.14
- npm 1.1.65
Haxe
公式サイトからインストーラをダウンロードしてインストールします。
コンパイルは以下のコマンドで行います。Haxeのソースファイルの拡張子は.hxです。
$ haxe -main "メインクラス名" -js "出力するjsファイルのパス"
Haxeはビルド設定ファイルとしてhxmlというファイルを利用することが可能です。ファイルの拡張子は.hxmlです。haxeコマンドに指定するコンパイルオプションを1行ずつ記述します。コンパイルする際のコマンドの設定は以下のように記述できます。
-main "メインクラス名" -js "出力するjsファイルのパス"
hxmlファイルをhaxeコマンドの引数として指定すると、コンパイルを実行できます。
$ haxe "hxmlファイルのパス"
JSX
GitHubリポジトリからcloneしてnpmのinstallコマンドでインストールします。
$ git clone http://github.com/jsx/JSX.git $ cd JSX/ $ npm install -g
コンパイルは以下のコマンドで行います。JSXのソースファイルの拡張子は.jsxです。
$ jsx --output "出力するjsファイルのパス" "コンパイルするjsxファイルのパス"
TypeScript
インストールとコンパイルの方法は、弊社ブログに既に記事がありますのでそちらを参照して下さい。
開発環境
Haxe
Haxe IDE and Editorsに対応している開発環境がたくさん記載されています。IDEではFlashDevelopやIntelliJ IDEA、エディタではEmacsやVim、Sublime Text 2のプラグインが用意されているようです。
JSX
今のところ、EmacsやVimのプラグインがあるらしいことしか分かりませんでした。とはいえ、使い慣れたエディタでjsもしくはas3のシンタックスハイライトを割り当てておけばとりあえずは十分です。
TypeScript
Microsoftが開発している言語なので、Visual Studio 2012がサポートしています。インテリセンスがかなり強力なようです。TypeScript Playgroundでもコード補完の恩恵を受けることができます。ブラウザ上であるため、さすがに若干動作がもっさりしてはいますが。また、EmacsやVim、Sublime Text 2のシンタックスハイライトも既に提供されています。
Sublime Text, Vi, Emacs: TypeScript enabled!
コンパイラ
Haxe
HaxeのコンパイラはOCamlで書かれています。OCamlの高いパフォーマンスのおかげでコンパイルが非常に高速です。
JSX
現在JSXはコンパイラがどの言語で書かれているかは分かりませんでした。
2012/11/16訂正。JavaScriptです。GitHubリポジトリのソースを全く見ていないのがバレバレです。コンパイラがJavaScriptで書かれているため、TypeScript同様ブラウザ上でコンパイルできます。こちらにエディタと実行環境が用意されています。
将来的にセルフホスティングする計画があるようです。
TypeScript
TypeScriptは既にセルフホスティングされており、コンパイラがTypeScriptで記述されています。これはブラウザなどのJavaScript実行環境でコンパイルができるということです。TypeScript Playgroundがブラウザ上に入力されたTypeScriptにコード補完を提供したり、即座にJavaScriptに変換できる理由はここにあります。
メインクラスと実行
Haxe
Haxeは基本的にコンパイル対象の全てのソースコードが1つの.jsファイルになって出力されます。あとはこの.jsファイルをブラウザなりNode.jsなりで実行します。スクリプトをちょっと書いて試す際は、下記のようにNode.jsから実行するのがお手軽です。
$ node "実行したい.jsファイルのパス"
HaxeはJavaのようにメインクラスとメインメソッドがあり、ここが必ず実行の起点となります。
class Main { static function main() { // ここから処理開始 } }
メインメソッドはmainという名前の静的メソッドである必要がありますが、メインクラスの名前は任意のもので大丈夫です。ただし、Haxeにはクラス名とファイル名が一致しなければいけないという制約があります。この場合はMain.hxという名前のファイルに上記コードを書く必要があります。また、コンパイル時に指定するメインクラス名とはこのクラス名のことです。
JSX
JSXもHaxeと同じく、コンパイル対象の全てのソースコードが1つの.jsファイルとして出力されます。また、こちらもメインクラスとメインメソッドがあり、そこが処理の起点となっています。
class _Main { static function main(args: string[]): void { } }
JSXの場合はメインクラスの名前も"_Main"とあらかじめ決められています。ただし、ファイル名とクラス名の間に制約はありません。
TypeScript
TypeScriptにはメインメソッドのようなエントリポイントがありません。JavaScriptと同じ感覚で、ソースファイルの任意の場所にスクリプトを書けば実行されます。このため、出力された.jsファイルが必ず一つに束ねられるといったこともありません。
HaxeとJSXはエントリポイントを設けることによって、出力されたJavaScript内でスクリプトが必ず無名関数のレキシカルスコープの中に記述されるような仕組みを作っています。一方TypeScriptはエントリポイントがないせいで、クラスやモジュールなど名前空間を定義する仕組みの外側に直接スクリプトを書くとグローバル変数を汚染します。このあたりはCoffeeScriptと同じで注意が必要です。
クラスベースOOP
3つの言語ともにクラスベースOOPの構文を提供しており、class, interfaceが利用できます。
Haxe
Haxeはconstキーワードが利用できないことを除いて、ActionScript3.0とほぼ同じ構文を提供しています。
interface Fruit { var color: String; function getName(): String; } class Apple implements Fruit { private static var type = 1; public var color = "red"; // コンストラクタ public function new() { } public function getName() { return "りんご"; } } class Fuji extends Apple { private static var type = 2; public function new() { super(); } override public function getName() { return "ふじりんご"; } }
可視性
メンバの可視性として、publicとprivateが指定できます。ただし、privateは他言語のprotectedに相当します。デフォルトの可視性はprivateです。
オーバーロードとデフォルト引数
HaxeはActionScript3.0と同様に、メソッドのオーバーロードをサポートしない代わりにデフォルト引数をサポートします。
function createVector(x = 0.0, y = 0.0, z = 0.0) { return new Vector3D(x, y, z); }
プロパティとアクセサ
Haxeのプロパティは独特の記述方法を持っています。
public var data1(default, null): Int; // 読み取り専用 public var data2(null, default): Int; // 書き込み専用 private var _data: Int; public var data(getData, setData): Int; // アクセサメソッドの指定 private function getData(): Int { return _data; } // セッターはプロパティの型を戻す関数 private function setData(data: Int) { _data = data; return _data; }
JSX
JSXが提供するクラスシステムはJavaに非常に近いです。JSXでは抽象メソッドを実装する際とメソッドオーバーライドする際にoverrideキーワードを付ける必要がありますので、知らないうちにスーパークラスのメソッドをオーバーライドしてしまうといった事故が防げます。
interface Fruit { var color: string; function getName(): string; } class Apple implements Fruit { static const type = 1; var color = "red"; // コンストラクタ function constructor() { } override function getName(): string { return "りんご"; } function getSome(s: string): string { return "test" + s; } } class Fuji extends Apple { static const type = 2; function constructor() { super(); } override function getName(): string { return "ふじりんご"; } }
可視性
3つの言語のうち、唯一メンバの可視性の制御ができません。すべてのメンバは外部に公開されます。
オーバーロードとデフォルト引数
Javaと同様にメソッドのオーバーロードがサポートされていますが、デフォルト引数はサポートされていません。
抽象クラス
3つの言語のうち、唯一抽象クラスをサポートします。
abstract class AbstractApple { abstract var color: string; abstract function getName(): string; } class RedApple extends AbstractApple { var color = "red"; function constructor() { } override function getName(): string { return "赤りんご"; } } class GreenApple extends AbstractApple { var color = "green"; function constructor() { } override function getName(): string { return "青りんご"; } }
ミックスイン
JSXはmixinキーワードを利用して実装を持ったインターフェースを宣言することができます。これによって多重継承に近いことが実現できます。RubyのmoduleやScalaのtraitと同じような機能です。ただし、Scalaの自分型アノテーションのようなミックスインする先のクラスの型に関する指定はできません。メンバの名前の重複に気を付けないと、片方のミックスインが正常に動作しないといったトラブルの原因となります。
mixin Say { abstract var name: string; function sayWithDesu(): string { return this.name + "です。"; } function sayWithDayo(): string { return this.name + "だよ!"; } } mixin Run { abstract var name: string; function run(): string { return this.name + "は走った!"; } } class Taro implements Say, Run { var name: string = "太郎"; } // ... var taro = new Taro(); log taro.sayWithDesu(); // 太郎です。 log taro.sayWithDayo(); // 太郎だよ! log taro.run(); // 太郎は走った!
定数
定数宣言にはconstキーワードを使用します。
finalキーワード
finalキーワードをクラスに付けると継承を、メソッドに付けるとサブクラスでのオーバーライドを禁止できます。
TypeScript
TypeScriptのメンバの宣言は、functionキーワードやvarキーワードを付けなくていいので非常に簡潔です。
interface Fruit { color: string; getName(): string; } class Apple implements Fruit { static type = 1; color = "red"; // コンストラクタ constructor() { } getName() { return "りんご"; } } class Fuji extends Apple { static type = 2; constructor() { super(); } getName() { return "ふじりんご"; } }
可視性
メンバの可視性として、publicとprivateが指定できます。デフォルトの可視性はpublicです。
コンストラクタでのフィールド宣言
TypeScriptはコンストラクタ引数をそのままフィールド宣言として定義できます。
class Point { constructor(public x: number, public y: number) { } } var point = new Point(10, 20); console.log(point.x + ", " + point.y); // 10, 20
オーバーロードとデフォルト引数
TypeScriptでは、Haxe同様にデフォルト引数をサポートしています。オーバーロードもサポートしていますが、実装は1メソッドしか持てません。
class Calculater { // オーバーロード calc(x: number, y: number): number; calc(x: number, y: string): string; calc(x: number, y: any): any { return x + y; } } var calculater = new Calculater(); console.log(calculater.calc(1, 2)); // 3 console.log(calculater.calc(1, "2")); // 12
プロパティとアクセサ
プロパティのアクセサメソッドはget, setキーワードを指定することで定義できます。ただし、これを利用するには、コンパイルオプションを指定してECMAScript5をターゲットにJavaScriptを出力する必要があります。
$ tsc --target ES5 "コンパイル対象ソースコードのパス"
private _data: number; get data() { return this._data; } set data(data: number) { this._data = data; }
プリミティブ値
Haxe
HaxeはRubyやScalaと同じく全ての型がオブジェクトとして定義されています。例えば、IntはFloatのサブ型です。他言語のプリミティブに相当する基本型は以下の3つです。
- Int
- Float
- Bool
JSX
以下の4つです。
- int
- number
- boolean
- string
TypeScript
TypeScriptでは、以下の5つがプリミティブ値として定義されています。NullとUndefinedはすべての型のサブ型として定義されています。
- Number(キーワードはnumber)
- Boolean(キーワードはboolean)
- String(キーワードはstring)
- Null(リテラルはnull)
- Undefined(リテラルはundefined)
プリミティブ値に対するメソッド呼び出し
JSX, TypeScriptでは、プリミティブ値とそのラッパーオブジェクトとの間の自動変換をするので、プリミティブ値に対して直接メソッド呼び出しを記述できます。Haxeは全てがオブジェクトですので、もちろんメソッド呼び出しが可能です。
// Haxe, JSX, TypeScript "cat".charAt(0) // "c"を返す
関数リテラル
HaxeはJavaScriptの関数リテラルの記法と変わりませんが、他の2つの言語はアローによる記法をサポートしています。
Haxe
function(s) { return "test" + s; }; function(s) return "test" + s;
JSX
function(s: string): string { return "test" + s; }; (s: string): string -> "test" + s;
TypeScript
function(s: string) { return "test" + s }; function(s: string) => "test" + s; (s: string) => "test" + s;
型
Haxe
型に対しては厳格ですが、いくつか抜け道が用意されています。
Dynamic型
Dynamic型は任意の型を受け入れることができます。Dynamic型の変数で参照されるインスタンスのメンバへのアクセスも可能ですが、そのメンバがインスタンスに存在するかコンパイラによってチェックされません。
untyped
untypedは後続の式に対してコンパイラが型チェックしないよう指示します。
var test = 1; untyped test = "test"; // コンパイルエラーにならない
JSX
3つの言語のうち、一番型に対して厳格です。
variant型
variant型は任意の型を受け入れることができますが、参照しているインスタンスに対しては比較以外の演算が一切できません。必ずキャストしてから使用する必要があります。
TypeScript
型を指定せずなおかつ型推論が不可能な場合に自動的に後述のany型となるため、実質的には型指定が任意となっています。もちろん型を指定した場合や型推論が可能な場合は、きっちりコンパイラが型チェックをしてくれます。
any型
全ての型の上位型にあたり、HaxeのDynamic型と同様に任意の型を格納して自由にメンバにアクセスすることができます。
関数の型
どの言語でも関数が型を持っています。これによって、高階関数も型安全に利用できます。以下の例では、引数や戻り値の型を全て書いていますが、一部は型推論によって省略可能です。(JSX除く)
Haxe
関数の型の表記は下記の通りです。
[引数の型] -> [戻り値の型]
// 高階関数 var applyTwo = function(func: Int -> Int): Int return func(2); // (Int -> Int) -> Int var double = function(a: Int): Int return a * 2; var four = applyTwo(double); trace(four); // 4 // 部分適用可能な関数 var add = function(a: Int): Int -> Int // Int -> (Int -> Int) return function(b: Int): Int return a + b; var add2 = add(2); // Int -> Int var five = add2(3); // Int trace(five); // 5
JSX
関数の型の表記は下記の通りです。
([引数の型]) -> [戻り値の型]
// 高階関数 var applyTwo = (func: (int) -> int): int -> func(2); // ((int) -> int) -> int var double = (a: int): int -> a * 2; var four = applyTwo(double); log four; // 4 // 部分適用可能な関数 var add = (a: int): (int) -> int -> // (int) -> (int) -> int (b: int): int -> a + b; var add2 = add(2); // (int) -> int var five = add2(3); // int log five; // 5
TypeScript
関数の型の表記は下記の通りです。
([引数名]: [引数の型]) => [戻り値の型]
// 高階関数 var applyTwo = (func: (a: number) => number): number => func(2); // (func: (a: number) => number) => number var double = (a: number): number => a * 2; var four = applyTwo(double); console.log(four); // 4 // 部分適用可能な関数 var add = (a: number): (b: number) => number => // (a: number) => (b: number) => number (b: number): number => a + b; var add2 = add(2); // (b: number) => number var five = add2(3); // number console.log(five); // 5
型推論
静的型付け言語の記述量を増やしてしまう原因の一つに型指定の記述があります。型推論は変数などの型を推測するのに十分な情報がある場合に型指定の記述を省略することができる言語機能です。純粋関数型言語をはじめとして、C#やScalaなどでもサポートされています。型の指定を省略しても型推論が行われる場合は、明示的に型を指定した場合と同様に、コンパイラによる型チェックが行われます。言語に対応するIDEがある場合は、コード補完の恩恵を受けられます。
var str = "test"; // 変数strは型推論によってString型と判断される str = 2; // コンパイルエラー
どの言語もローカル変数の型推論はサポートしていますが、関数の引数・戻り値に関しては型推論できないものもあります。
Haxe
3つの言語の中では一番型推論が強力です。ローカル変数だけでなく、関数の引数や戻り値も型推論されます。そのため、Haxeでは明示的な型の指定をする必要がほとんどなさそうです。
var test = "test"; // String // getSome内でStringと"+"で結合しているため、コンパイラはString -> String型であると推論する function getSome(s) { return "test" + s; }
関数の型の項目で定義したadd関数を型推論を利用して記述したパターンです。
// Int -> (Int -> Int) var add = function(a: Int) return function(b) return a + b;
JSX
2012/11/16訂正。何が何でも関数の引数と戻り値の型を省略できないような書き方をしてしまいましたが、関数の引数と戻り値に関しても、関数の型に関して明示されている場合は型が推論されるようです。関数の引数と戻り値に関しては型推論ができません。関数の型が明らかである場合には引数と戻り値の型の指定は必要ありません。関数の型が明らかでない場合、明示的に型を指定しないとコンパイルエラーとなります。
var test = "test"; // String // 引数と戻り値の型推論ができない function getSome(s: string): string { return "test" + s; } // 関数の型が明らかであれば、引数と戻り値の指定は必要なし(2012/11/16追記) var f: (string) -> string = function(s) { return s; }; var f2: (string) -> string = (s) -> s;
TypeScript
2012/11/16訂正。JSXと同じように、関数の引数に関して、関数の型に関して明示されている場合は型が推論されるようです。関数の引数の型推論ができません。引数の型を明示的に指定しないと、any型の引数として扱われます。
var test = "test"; // String // 引数の型推論ができない function getSome(s: string) { return "test" + s; } // 関数の型が明らかであれば、引数と戻り値の指定は必要なし(2012/11/16追記) var f: (s: string) => string = function(s) { s; }; var f2: (s: string) => string = (s) => s;
関数の型の項目で定義したadd関数を型推論を利用して記述したパターンです。
var add = (a: number) => (b: number) => a + b; // (a: number) => (b: number) => number
型の説明はこちらのスライドがとても分かりやすいです。
少し長くなってきましたので、続きは次回にします。次回は、型の続きやモジュール機能、各言語固有の機能について触れた後、各言語でjQueryとNode.js + Expressを使った簡単なサンプルを作成したいと思います。(JSXでNode.jsは今のところ難しそうですので作成しない予定です)
参考サイト
TypeScript: how to customize properties