時代遅れひとりFizzBuzz祭り、今回はCoffeeScriptだ。前回までのJavaScript de 処理系ネタからの惰性で選択したことは秘密なのだ。
ネットでCoffeeScriptについて情報を漁ると、肯定的な意見もあれば否定的な意見もある。否定的意見については「言語として全くダメ」というものではなく、言語としてはまあまあだけどその他諸々の要因にてダメ出しされているケースが多いように思う。
まあ私は基本的にWeb開発とは無縁な人なので、その辺りを深く考えずに触ってみた。現時点では仕事でCoffeeScriptどころかJavaScriptを触る予定すら無い。
CoffeeScriptのバージョンは1.3.3。下回りとしてNode.js v0.6.18のWindows版バイナリを使用した。今回は基本的にcoffeeコマンドで直接スクリプトを実行させている。
シンタックス
CoffeeScriptのシンタックスはJavaScriptとは随分異なる。Ruby、Python、Haskellなどの影響を受けているらしい。
よくあるスタイルのFizzBuzzを書くとこうなる。ぱっと見ただけでも随分特徴的だ。
#!/usr/bin/coffee fizzbuzz = (n) -> if n%15 is 0 return 'FizzBuzz' if n%3 is 0 return 'Fizz' if n%5 is 0 return 'Buzz' return n for i in [1..100] console.log fizzbuzz i
ブロックをインデントで表現するのはPythonの影響だ。関数の定義での `->' はHaskell由来だろうか*1。範囲オブジェクト(範囲演算子)の存在やifの条件式や関数の呼び出し部分に括弧が不要な点はRuby的にもHaskell的にも見える。
`is' はJavaScriptの `===' だ。幾つかの演算子はCoffeeScriptがエイリアスを用意している。ちなみにCoffeeScriptは `==' を `===' に置き換えるようだ。
このコードをコンパイルすると、こんなJavaScriptが生成される。
(function() { var fizzbuzz, i, _i; fizzbuzz = function(n) { if (n % 15 === 0) { return 'FizzBuzz'; } if (n % 3 === 0) { return 'Fizz'; } if (n % 5 === 0) { return 'Buzz'; } return n; }; for (i = _i = 1; _i <= 100; i = ++_i) { console.log(fizzbuzz(i)); } }).call(this);
適切にインデントされていて見やすいコードだ。
全体が即時関数でラッピングされている(コンパイル時にオプション -b を指定するとラッピングされない)。関数の定義は無名関数を変数にバインドするスタイルになる。範囲演算はここでは普通のforループに変換されている。
CoffeeScriptではRubyのif修飾子のようにifやforを右辺に記述できるので、先程のコードはこんな風に書き換えられる。
#!/usr/bin/coffee fizzbuzz = (n) -> return 'FizzBuzz' if n%15 is 0 return 'Fizz' if n%3 is 0 return 'Buzz' if n%5 is 0 return n console.log fizzbuzz i for i in [1..100]
コードの見た目は随分変わった。しかしJavaScriptにコンパイルした結果は先程と全く同じだ。興味深い。
少しだけRubyっぽく
私はPythonもHaskellも触ったことがない。その代わりRubyは時々触っている。試しに少しRubyっぽく書くとこんな感じだろうか?
#!/usr/bin/coffee fizzbuzz = (n) -> if n%15 is 0 then 'FizzBuzz' else if n%3 is 0 then 'Fizz' else if n%5 is 0 then 'Buzz' else n [1..100].forEach (i) -> console.log fizzbuzz i
Rubyではifは式で、評価結果を返す。またメソッドに明示的なreturnが無い場合は、最後に評価した式の結果が戻り値となる。CoffeeScriptは「(可能な限り)全て式」なのでifも結果を返すし、関数にreturnが無ければ最後に評価した式の評価結果を返す。
あとRubyでは範囲オブジェクトに続けてeachメソッドあたりを呼び出すパターンが常道だと思うので、Array.forEachで代用してみた。
コンパイル結果はこんな感じ。
(function() { var fizzbuzz, _i, _results; fizzbuzz = function(n) { if (n % 15 === 0) { return 'FizzBuzz'; } else if (n % 3 === 0) { return 'Fizz'; } else if (n % 5 === 0) { return 'Buzz'; } else { return n; } }; (function() { _results = []; for (_i = 1; _i <= 100; _i++){ _results.push(_i); } return _results; }).apply(this).forEach(function(i) { return console.log(fizzbuzz(i)); }); }).call(this);
関数fizzbuzzを見ると、自動的にreturnを追加しているのが分かる。範囲演算は今回は単純なforループではなく、配列を生成するコードに変換されている。
中身はJavaScript
シンタックスは随分異なるものの、CoffeeScriptはJavaScriptそのものだ。これは「コンパイルするとJavaScriptになる」ということではなく、CoffeeScriptの本質部分が何らJavaScriptと変わらないということだ。
#!/usr/bin/coffee fizzbuzz = do -> divisible = (m, s) -> (n) -> if n%m is 0 then s else '' fizz = divisible 3, 'Fizz' buzz = divisible 5, 'Buzz' (n) -> s = "#{fizz n}#{buzz n}" if s is '' then n else s console.log (fizzbuzz i for i in [1..100]).join '\n'
このコードには即時関数パターン、クロージャ、関数(というかクロージャ)を返す関数というJavaScriptで特徴的な要素を詰め込んでいる。Ruby的な文字列の式展開やPython的な配列の内包表記に騙されると判断を誤ることになる。
コンパイル結果を見てみよう。
(function() { var fizzbuzz, i; fizzbuzz = (function() { var buzz, divisible, fizz; divisible = function(m, s) { return function(n) { if (n % m === 0) { return s; } else { return ''; } }; }; fizz = divisible(3, 'Fizz'); buzz = divisible(5, 'Buzz'); return function(n) { var s; s = "" + (fizz(n)) + (buzz(n)); if (s === '') { return n; } else { return s; } }; })(); console.log(((function() { var _i, _results; _results = []; for (i = _i = 1; _i <= 100; i = ++_i) { _results.push(fizzbuzz(i)); } return _results; })()).join('\n')); }).call(this);
関数fizzbuzz周辺のコードは長さこそ膨れ上がっているものの、本質的にはCoffeeScriptのそれと全く同じだ。その反対に文字列の式展開や配列の内包表記といったJavaScriptに無い機能を使った部分には、同等の機能をシミュレートするコードが展開されている。特に配列の内包表記の部分は少しばかり冗長なコードになっている。
CoffeeScriptの本質がJavaScriptであるということは、JavaScriptと同様にCoffeeScriptのコードも処理系によって大きく変わるということだ。単純に考えてもクライアントサイドJavaScript、Node.jsなどのサーバサイド、PhantomJS、Windows Script HostのJScriptでは、基本はJavaScriptであっても実際のコードの流儀は結構違う。
例えば id:eel3:20120503:1336055939 のNode.js編で書いたFizzBuzz HTTPクライアント(GETメソッド版)をCoffeeScriptで書き直してみよう。元コードはこんな感じ。
/*jslint node: true, maxerr: 50, indent: 2 */ (function () { 'use strict'; var options = { host: 'localhost', port: 5432, path: '/fizzbuzz?count=100' }; require('http').get(options, function (response) { if (response.statusCode === 200) { response.pipe(process.stdout); response.resume(); } else { console.error('status: %d', response.statusCode); } }).on('error', function (e) { console.error('error: [%s] %s', e.name, e.message); }); }());
短いながらもNode.js的なコードだと思って選択した。
これのCoffeeScript版はこんな感じだろうか?
#!/usr/bin/coffee 'use strict' options = host: 'localhost' port: 5432 path: '/fizzbuzz?count=100' require('http').get options, (response) -> if response.statusCode is 200 response.pipe process.stdout response.resume() else console.error 'status: %d', response.statusCode return .on 'error', (e) -> console.error 'error: [%s] %s', e.name, e.message return
個々のシンタックスこそ違うものの、全体的にはNode.jsの流儀そのままだ。ここではあえて改良の余地が少ない短いコードを使っていることも大きいのだけど、Node.jsのコードをCoffeeScriptで書いたとしてもNode.jsはNode.jsのままだ。基本的にはNode.jsの流儀に従う必要がある。
ちなみに上記コードのコンパイル結果はこんな感じ。
(function() { 'use strict'; var options; options = { host: 'localhost', port: 5432, path: '/fizzbuzz?count=100' }; require('http').get(options, function(response) { if (response.statusCode === 200) { response.pipe(process.stdout); response.resume(); } else { console.error('status: %d', response.statusCode); } }).on('error', function(e) { console.error('error: [%s] %s', e.name, e.message); }); }).call(this);
元々のJavaScriptのコードと概ね同じ感じといったところか。
このHTTPクライアントと似た内容を id:eel3:20120528:1338209818 のPhantomJS編で実装している。そのコードはこんな感じだ。
/*jslint devel: true, regexp: true, maxerr: 50, indent: 2 */ /*global phantom: false, require: false */ (function () { 'use strict'; var page = require('webpage').create(); page.open('http://localhost:5432/fizzbuzz?count=100', function (status) { if (status === 'success') { console.log(page.content.replace(/<[^>]+>/g, '')); } else { console.log('failed.'); } phantom.exit(); }); }());
Node.jsによる実装とは随分異なるのが分かる。
これをCoffeeScriptで書き直したものも、やはりNode.js版とは異なっている。
#!/usr/bin/coffee 'use strict' page = require('webpage').create() page.open 'http://localhost:5432/fizzbuzz?count=100', (status) -> if status is 'success' console.log page.content.replace /<[^>]+>/g, '' else console.log 'failed.' phantom.exit() return
Node.jsによる実装はコールバックを多用しているものの基本的にはプリミティブなHTTPライブラリを叩くスタイルだ。一方でPhantomJSによる実装は、PhantomJSがヘッドレスWebブラウザであるだけにWebブラウザ的な抽象化がされたスタイルだ。
コンパイル結果は以下の通り。
(function() { 'use strict'; var page; page = require('webpage').create(); page.open('http://localhost:5432/fizzbuzz?count=100', function(status) { if (status === 'success') { console.log(page.content.replace(/<[^>]+>/g, '')); } else { console.log('failed.'); } phantom.exit(); }); }).call(this);
本質がJavaScriptそのままであるということは重要だ。CoffeeScriptでコードを書く時、実際のコードより1〜2段ほど抽象的な水準(ウォーターフォール開発の教科書的な説明における詳細設計的な局面)においてはJavaScriptでの知見がそのまま使えるし、逆に知見が無いとCoffeeScriptでよいコードを書くことができない。またJavaScriptには無い機能は同等の処理をシミュレートするコードに置き換えられる。速度が求められる場面ではそれが足かせとなる可能性がある。
それとNode.jsとPhantomJSの例のように、使用する分野や処理系によってJavaScriptと同様にコードの流儀が変わってくる。但しその流儀そのものはJavaScriptでもCoffeeScriptでも同じだ。なので例えばJavaScriptでNode.jsのコードを書くことに慣れている場合、その知見をCoffeeScriptでNode.jsのコードを書く時にそのまま適用することができる。その反面、例えばNode.jsに何らかの本質的問題があると仮定したとき、その問題はCoffeeScriptで実装していても起こりうるだろう。
まとめ
個人的見解としては、今のCoffeeScriptは「プリプロセッサによるトランスレータ」的だ。
言語処理系としてのトランスレータは「高水準言語Aで書かれたソースコードを高水準言語Bによるソースコードに変換する」というものだけど、よく見てみると2つに大別できる。1つは例えばKCL(Kyoto Common Lisp)のように全く異なる言語に変換してしまうタイプで*2、基本的にプログラマは変換元の言語に通じていれば十分で、変換先の言語のことを気にする必要はない。もう1つがRatfor(RATional FORtran)のような「変換元の言語 === 変換先の言語+α」なタイプで*3、処理系がプリプロセッサ的なことが多いのだけど、この場合は往々にしてプログラマは変換先の言語の事情を気にする必要がある(ことが多い気がする)。
CoffeeScriptの実装自体はプリプロセッサではない。コンパイラやインタプリタに比較的近い実装をしている。それでもCoffeeScriptは個々のシンタックスこそJavaScriptとは随分と異なるものの、全体的にはJavaScriptそのものだ。JavaScriptの重要な言語機能(の一部)がオミットされている訳でもなく*4 *5、JavaScriptにはないパラダイムやら大きな言語機能が追加されている訳でもない*6。KCLよりはRatfor寄りだ。
ネット上のCoffeeScriptへの賛否の多くは、突き詰めていくとこのプリプロセッサ的な部分に端を発しているように思う。
個人的にJavaScriptは好きな言語なのだけど、他のエントリで書いているように強力で面白い反面酷いところも多い。この酷い、醜いところを薄皮一枚かぶせることで隠しつつも強力で面白いところはそのままに――というのがCoffeeScriptの根幹ではないか? だから大筋(強力で面白い部分)はJavaScriptそのままに、細かい部分はJavaScriptをある程度知っている人からすると「痒いところに手が届く」変更がなされている。
まずこの細かい部分の改良がCoffeeScriptへの肯定意見に繋がっているのは間違いない。そしてこの点について肯定している人は十中八九JavaScriptを使ったことがある人――どこが痒いのか知っている人だ。
その上で、薄皮一枚の厚さやかぶせる範囲のバランスも比較的妥当だ。特に厚さ。例えば無名関数やクロージャや即時関数パターンなどはシンタックスこそ異なるもののJavaScriptと概ね同じ要領で記述し、使用することができる。標準ライブラリや(DOM/ブラウザ・オブジェクトやNode.jsのAPIなどの)処理系独自の機能、サードパーティのライブラリもほぼ同じ要領で使用できる。この「ほぼ同じ要領」の部分が今よりも小さく、代わりに独自の何かを使わなくてはならなかったとしたら、CoffeeScriptのユーザ数は今よりも遥かに少なかったのではないか?
シンタックスもRubyやPythonを含む幾つかの言語を知っている人ならそれほど戸惑わないように思う。私は「Web開発をやっている人なら(JavaScriptを含めて)2〜3の言語(特にスクリプト言語の類)は知っているだろう」と根拠*7も無く固く信じている人なので、まあ確かにスラスラ書けるようになるには時間が掛かるだろうけど、全く異質なシンタックスを学ぶよりは楽なはずだ。
まとめるとCoffeeScriptの良い点はこんな感じだろうか?
- JavaScriptの薄いラッパーであること。
- JavaScriptの醜い部分、使い勝手の悪い部分を覆い隠してくれる。
- JavaScriptの本質的な強力さ、面白さはそのまま。JavaScript的考え方がそのまま通用する。
- 既存のライブラリとその使い方等の知識を流用できる。
- 独自のシンタックスと言いつつも、実は他のスクリプト言語の経験者からすると比較的見慣れた範疇であること。
- 全く異質なシンタックスよりは学習コストが低い。
これらのメリットは、しかしデメリットでもある。というのもJavaScriptや最近流行のスクリプト言語にある程度通じている人にとってのメリットであり、そうではない人にとってはメリットとはいい難いのだ。
まずCoffeeScriptを十二分に使いこなすには、現状ではある程度JavaScriptに通じている必要がある。というのもCoffeeScriptの本質的な部分はJavaScriptそのままであり、その本質を学んでJavaScript的考え方を身に付ける方法は現時点ではJavaScriptの良書・良質なコードを読むしかないからだ。*8
また既存のライブラリはAPIリファレンスもサンプルコードもJavaScript前提で書かれているものが大半なので、使い方を学ぶにはやはりJavaScriptのコードを読めなくてはならないし、読めた上でCoffeeScriptに翻訳できなくてはならない。サードパーティのライブラリの多くも中身はJavaScriptなので、不具合調査で中に潜る必要がある人はJavaScriptを知らなくてはならない。
独自のシンタックスも、見覚えがあるといえどもスラスラ書けるようになるにはそこそこ時間がかかる訳で、そこまで余裕がない人もいるだろう。
結局、CoffeeScriptを使いこなすにはJavaScriptを知らなくてはならない。CoffeeScriptを学ぶ分だけ追加の時間的コストが掛かることになる。このコストをどう捉えるか――ここで意見が分かれる。「ある程度JavaScriptに通じているのなら、そのままJavaScriptで書けばいいじゃない」という訳だ。
更にCoffeeScriptを仕事(又は複数の開発者が関わるプロジェクト)で使うとなると「JavaScriptにある程度通じていて且つCoffeeScriptを読み書きできる人」を確保できるか否かが問題となってくる。複数人で開発する場合はCoffeeScriptを使える人も何人か必要になる。仮にクライアントサイドのコードを書く人が1人だったとしても、例えば数年後に改修の依頼が来たときにCoffeeScriptを使える人をアサインできるか否か考えておかなくてはならない。
CoffeeScript自体は悪い言語ではない。しかし仕事などで使うとなると別の力学が働く。今後の盛衰を気にしつつも今は保留、という所も多いのではないだろうか?
ちなみに、仕事で使う場合はCoffeeScript自体の流行り廃れも気になるところかもしれないけど、個人的には神経質に気にすることはないと思う。今のところCoffeeScriptが吐き出すJavaScriptは十分読める代物だ。コンパイルしたJavaScriptのコードを使うようにしておけば、仮に廃れた場合には最後の手段「コンパイル後のJavaScriptのコードに手を入れる」が使える。必要なのはJavaScriptにコンパイルする時に適度なコメントが残るようにしておくことだ。生成したJavaScriptをさらにミニファイツールにかければ、本番用のコードにコメントが残らないようにできる。
技術的な面では、
- デバッグがし難い。
- 生成されるJavaScriptのコードが(高速化が必要な部分では)遅い。
- コンパイルが必要。
などがデメリットとして挙げられるようだけど、この3点についてはCoffeeScriptのデメリットと言いきってよいものか疑問な部分もあれば、将来もデメリットのままなのか怪しい点もある。
デバッグがし難い点やコンパイルが必要な点は、例えばブラウザが直接CoffeeScriptをサポートするようになれば解消されるだろう*9。デバッグに関しては周辺ツールのサポートの有無がかなり大きな影響を与えるはずだ。この問題はCoffeeScriptを使うことで発生するものの、CoffeeScriptの言語そのものの問題ではない。
高速化に関してはCoffeeScriptのコード最適化だけでなくJavaScript処理系側の高速化やOSやハードウェアのリソースの問題も関わってくる。今は遅いかもしれないけど将来はどうだろうか?*10 それにC言語のインラインアセンブラのように、CoffeeScriptにJavaScriptのコードをインラインで書けるように拡張するという方向も考えられる。
コンパイルが必要な点については、そもそも私はCプログラマなので……コンパイルが必須な言語を使っているし、必要とあらばMakefileぐらいは書く環境の住人なので、何とも。そもそも仕事でJavaScriptを使う人ならミニファイツールにかけるので何らかの自動化の仕組みを構築しているのではないだろうか? まあシンプルでポータブルで使いやすいビルド自動化ツールがないのはツライ所ではある。makeは比較的ポータブルに書けるけど使い勝手がよいとは言えないからなあ。
上記3点の課題については周辺ツールも含めて今後の進展が気になる所だ。特に周辺ツールがDartやJSXなどの競合する言語をどの程度サポートするかによってCoffeeScriptの今後に影響が出るはずなので、注意すべきだろう。
おまけ
ところでCoffeeScriptをWSHのJScriptで実行するCoffeeScript on JScriptというラッパーがあるのだけど、「Seattle's CoffeeScript」あたりに改名すると一瞬だけウケるかもしれないと思った。
*1:プログラマが直接書くことはないけど、Objective Camlあたりでも目にする記号だ。
*2:KCLはCLtL1準拠のCommon LispのコードをC言語に変換する。ただKCLをトランスレータと言えるかどうか疑問ではある。個人的にアレはLisp処理系だと思っている。
*3:Ratforは構造化FORTRANの1種で、基本的にFORTRAN用のプリプロセッサとして実装されている……はず。
*5:ちなみにシンタックス上の都合で三項演算子が使えないことぐらいは筆者も知っている。
*6:例えば「論理プログラミングOKでっせ」とか「独自の標準ライブラリを持ってるよ!」とか。
*7:何らかの統計調査とか。
*8:但しこの点は「今後CoffeeScriptの(文法にとどまらない)良質なチュートリアルや中級者向けドキュメントがどれだけ出てくるか?」によって変わる気がする。
*9:但しサポートしてくれるようになるか否かは別の話。
*10:とはいえお仕事では「今」が重要なので……。