やれletを使え、やれconstを使え、と言われているES2015+時代。varがなぜ問題だったのか考えます。
他のC風言語とは違う!
JavaScript(ECMAScript)はC風言語(C-family programming languages)の一つと言われています。List of C-family programming languagesの仲間であり、その中の一つとして数えられています。他にメジャーなものでは、C、C++、Java、C#、Perl、PHPあたりでしょう。PerlとPHPは変数名の規則に違いがある1ので除くとしても、他の言語とJavaScriptとでは変数の取り扱い方に大きな違いがありました。
ブロックスコープでは無い
まず、C、C++、Java、C#とES5以前のJavaScriptにはスコープに対する決定的な違いがありました。それはif文やwhile文等のブロックがJavaScriptではスコープを作らなかったと言うことです。
# include <stdio.h>
int main(void)
{
int i = 42;
char *str = "hoge";
for (int i = 0; i < 10; i++) {
char *str = "fuga";
printf("%s %d\n", str, i);
}
printf("%s %d\n", str, i); // => hoge 42
return 0;
}
var i = 42;
var str = "hoge"
for (var i = 0; i < 10; i++) {
var str = "fuga";
console.log(str, i);
}
console.log(str, i); // => fuga 10
上のCとJavaScriptでは同じようなことをしているように見えますが、最後に表示される数値が異なります。ES5以前ではfor文でブロックスコープを作らないため、varで宣言されたiやstrのスコープは全体となり、for文内とiやstrも同じものであるという扱いになります。これは、C等の他のC風言語を扱っている人から見ると奇妙に思えるものでした。そこで登場したのがletです。
ES2015からはブロックスコープが導入され、letで宣言された場合は変数がブロックスコープになるようになりました。
let i = 42;
let str = "hoge";
for (let i = 0; i < 10; i++) {
let str = "fuga";
console.log(str, i);
}
console.log(str, i); // => hoge 42
letで書き換えると、Cと同じ動作になることがわかります。ただし、ES2015+であっても、varで宣言した変数は関数スコープ(トップレベルの場合はグローバルスコープやモジュールスコープ)のままです。
勘違いしないで欲しいのは「if等でブロックスコープが作られないことが悪い」ということではありません。C風言語ではないPythonやRubyではif等でブロックスコープが作られません2。ですが、if等で{}を使っているわけでは無いため、それほど混乱することはありません。C風言語では文のひとまとめに{}を使うため、これで一つのブロックを形成し、一つのまともまりを持つと考えるのが多数です。そのため、その中で宣言されたローカル変数はそのブロック内でしか使えないというのが半ば常識のように扱われていました。その常識から逸脱するJavaSriptのvarは奇妙であり、勘違いによるバグを産み出す原因の一つになっていたと考えられます。
なお、constについてもletと同様です。
宣言の巻き上げ
varには巻き上げという現象が起きます。Cと比べて見ましょう。
# include <stdio.h>
int i = 42;
int main(void){
printf("%d\n", i); // => 42
int i = 2;
printf("%d\n", i); // => 2
}
var i = 42;
function main() {
console.log(i); // => undefined
var i = 2;
console.log(i); // => 2
}
main();
二番目のiはCとJavaScriptとでは対象となっているiが違います。Cの場合はグローバル変数のiですが、JavaScriptではmain関数のローカル変数であるiです。JavaScriptのvarはどこで宣言したとしても、そのスコープの最初に宣言したものと見なします。そして、初期化前であれば、暗黙的にundefinedが入っていると見なします。つまり、下記のコードと同じです。
var i = 42;
function main() {
var i;
console.log(i); // => undefined
i = 2;
console.log(i); // => 2
}
main();
サンプルのコードはすぐ近くにあるので気づけますが、関数の行数が多い場合(それ自体はあまり良いものとは言えませんが)、二番目のようなiがトップレベルのiだと勘違いしてしまうかも知れません。問題なのは、このコードがエラーにならないと言うことです。
ES2015のletを使えば、この勘違いに即座に気づけます。
let i = 42;
function main() {
console.log(i); // => ReferenceError
let i = 2;
console.log(i);
}
main();
上のコードではReferenceErrorが発生し、コードは停止しますので、テストの時に気づくことができます。注意して欲しいのはCのような動作では無いと言うことです。letであっても宣言自体の巻き上げは発生します。しかし、暗黙的にundefinedを入れると言うことをするのではなく、初期化前に評価されるとエラーが発生するようにしています。
なお、constについてもletと同様です。
多重宣言
C等では同じ変数名で二重に宣言することはできません。往々にして、多重宣言は変数名の重複を意味し、どこか間違っているからです。
# include <stdio.h>
int main(void)
{
int i = 42;
int i = 54; // => コンパイルエラー
printf("%d\n", i);
return 0;
}
しかし、JavaScriptのvarでは何のエラーも無くできてしまいます。
var i = 42;
var i = 54;
console.log(i); // => 54
これも同じく、letを使えば防ぐことができます。
let i = 42;
let i = 54; // => SyntaxError
なお、constについてもletと同様です。
再代入可能
varには再代入を抑止する方法がありません。しかし、他の言語では再代入できないようにすることは重要なことだと考えられています。
# include <stdio.h>
int main(void)
{
char *const str = "hoge";
str = "fuga"; // => コンパイルエラー
printf("%s\n", str);
return 0;
}
ES2015以降では、このような再代入禁止を行う為にconstが用意されました。
const str = "hoge";
str = "fuga"; // = > TypeError
console.log(str);
constは宣言時の初期化子でのみ代入が可能であり、それ以降は代入ができません。
そして、解決したのか?
C風に合わせただけ
色々見てきましたが、letやconstがしていることは、他のC風言語と同じような動きに合わせただけと言っても過言ではありません。もっと言えば、別にvarを使ってもうまくいっていたのです。ただ、C風な文法なのにC風の動きでは無いという所が多くのプログラマーを惑わせ、わかりにくくしていたと考えられます。
逆に言うとC風の文法で無ければ、varのような動きでもかまわないと考えられます。実際、PythonやRubyのローカル変数の動きはvarに近い物がありますし、PythonとRubyの文法を参考に作られたCoffeeScriptでは、バージョン2を作るときもシンプルさを重視し、letとconstを採用しませんでした。
JavaScriptの不幸は、すべてJavaの文字にかかっています。JavaScriptが純粋にSchemeをブラウザの世界に持ってきたものであれば、LISPのような構文になっていれば、この問題は起きなかったはずです。しかし、それを無理矢理Javaと似たもの、つまり、C風の文法にしてしまったのが、このC風言語開発者にとって奇妙に思うようなvarが産まれた理由と推測されます。一度できてしまい、ブラウザ戦争で急激に発展し、Ajaxによってブレイクして、Web周りの中心技術の一つになったとき、やっと、当時の間違いに気付いたのです。
つまり、letやconstでJavaScriptが便利になったというのでは無く、やっと、C風言語の仲間入りができたというものと思われます。もっと別の文法であれば、きっとそれらは必要なかったのだと思います。
constと言う名前は適切か?
Javaではfinal、C#では(フィールドだけですが)readonlyが使われています。C風とはいうのはいささか無理があるかも知れませんが、Scalaはvalを使っています。でも、なぜconstなのでしょうか?
C/C++はconstがありますが、型にかかります。const char *sとchar *const sでは意味が違います。JavaScriptのconstは後者の意味に近いですが、前者の意味だと勘違いするものも多々見られます。C#にもconstはありますが、こちらはコンパイル時定数の意味であり、Cで言えばマクロ定数、C++で言えばconstexprに近いものであり、さらに様相が異なります。
考えてみればconstの前に「定数」はとても曖昧です。「定数」という言葉は
- 変数の評価値が定まっている。しばしば、コンパイル時に評価され、それに置き換えられている。
- 変数が束縛されているオブジェクトの値が定まっている。
- 変数が束縛されているオブジェクトが定まっている。
の三つの意味で使われる場合があります。JavaScriptのconstは3.ですが、C#のconstは1.であり、C/C++のconstは(かかっている型に対して)2.になります。constという言葉はとても曖昧なような気がします。対してJavaのfinalやC#のreadonlyは3.の意味で使われます。
finalやreadonly、valといった名前の方が良かったのでは無いかと私は少し思っています。
結局何が言いたいか?
JavaScriptをC風言語の一つとして扱いたければ、letやconstは勘違いを防ぐという意味で大変優良です。これからは積極的に使っていくべきです。ただ、一旦JavaScriptを離れ、C風言語という柵から解放されたとき、変数に三つのの種類があると言うことはどうなのだろうと思ってしまいます。過去の互換性のためにvarをなくすことはできなかったと言っても、JavaScriptの難しさが一段上がっただけのような気もしないでは無いです。