JavaScriptで関数を定義する方法として、関数宣言(function declaration)と関数式(function expression)の二つ1があります。記事によっては片方を推奨する場合がありますが、実際の主張はバラバラでは無いかという印象です。ここにアロー関数(arrow function)を加えて、ジェネレーター、非同期(async)、classのメソッドなども考えながら考察していきたいと思います。
話を始めるにあたって、古いJavaScriptの仕様を考慮から外し、下記の構成を前提にします。
- 仕様はES2015+とします。レガシーブラウザ向けにはBabelでの変換を前提とします。
- モジュールとして作成し、
import
文及びexport
文を使用します。未対応環境やレガシーブラウザ向けには、Babelで変換後にwebpackやRollup等を使うことを前提とします。
上記により、常に厳格モード(strict mode)であること2に注意してください。ES5の場合や非厳格モードの場合などを考慮すると、その場合は留意事項が異なると言った事態になり、場合分けが発生する可能性があるからです。
定義方法
二つあると言いましたが、大きく分けて二つであり、より多くのパターンが存在します。
- 宣言
- 関数宣言
- 関数宣言
function f() {}
- 関数宣言エクスポート
export function f() {}
- 関数宣言デフォルトエクスポート
export default function f() {}
- 関数宣言
- 無名関数宣言
- 無名関数宣言デフォルトエクスポート3
export default function() {}
- 無名関数宣言デフォルトエクスポート3
- 関数宣言
- 式
- 関数式
- 宣言初期化子関数式
- var宣言初期化子関数式
var f = function() {};
- let宣言初期化子関数式
let f = function() {};
- const宣言初期化子関数式
const f = function() {};
- var宣言初期化子関数式
- 宣言初期化子関数式エクスポート
- var宣言初期化子関数式エクスポート
export var f = function() {};
- let宣言初期化子関数式エクスポート
export let f = function() {};
- const宣言初期化子関数式エクスポート
export const f = function() {};
- var宣言初期化子関数式エクスポート
- 事前宣言変数代入関数式
- 事前var宣言変数代入関数式
var f; f = function() {};
- 事前let宣言変数代入関数式
let f; f = function() {};
- 事前var宣言変数代入関数式
- 関数式デフォルトエクスポート
- 関数式デフォルトエクスポート
export (function() {});
- 関数式デフォルトエクスポート
- 宣言初期化子関数式
- 名前付き関数式
- 宣言初期化子名前付き関数式
- var宣言初期化子名前付き関数式
var f = function f() {};
- let宣言初期化子名前付き関数式
let f = function f() {};
- const宣言初期化子名前付き関数式
const f = function f() {};
- var宣言初期化子名前付き関数式
- 宣言初期化子名前付き関数式エクスポート
- var宣言初期化子名前付き関数式エクスポート
export var f = function f() {};
- let宣言初期化子名前付き関数式エクスポート
export let f = function f() {};
- const宣言初期化子名前付き関数式エクスポート
export const f = function f() {};
- var宣言初期化子名前付き関数式エクスポート
- 事前宣言変数代入名前付き関数式
- 事前var宣言変数代入名前付き関数式
var f; f = function f() {};
- 事前let宣言変数代入名前付き関数式
let f; f = function f() {};
- 事前var宣言変数代入名前付き関数式
- 名前付き関数式デフォルトエクスポート
- 名前付き関数式デフォルトエクスポート
export (function f() {});
- 名前付き関数式デフォルトエクスポート
- 宣言初期化子名前付き関数式
- アロー関数
- 宣言初期化子アロー関数
- var宣言初期化子アロー関数
var f = () => {};
- let宣言初期化子アロー関数
let f = () => {};
- const宣言初期化子アロー関数
const f = () => {};
- var宣言初期化子アロー関数
- 宣言初期化子アロー関数エクスポート
- var宣言初期化子アロー関数エクスポート
export var f = () => {};
- let宣言初期化子アロー関数エクスポート
export let f = () => {};
- const宣言初期化子アロー関数エクスポート
export const f = () => {};
- var宣言初期化子アロー関数エクスポート
- 事前宣言変数代入アロー関数
- 事前var宣言変数代入アロー関数
var f; f = () => {};
- 事前let宣言変数代入アロー関数
let f; f = function() {};
- 事前var宣言変数代入アロー関数
- アロー関数デフォルトエクスポート
- アロー関数デフォルトエクスポート
export default () => {};
- アロー関数デフォルトエクスポート
- 宣言初期化子アロー関数
- 関数式
使用できる組合せはECMAScriptのバージョンによって異なりますが、それぞれジェネレーターと非同期(ES2017から)の片方または両方(ES2018から)を使うパターンが存在します。ただし、アロー関数にはジェネレーターがありません。4
関数定義はどうあるべきか
まず、関数定義というのはどうあるべきかを考えます。ここではコンストラクタ、即時関数、高階関数、関数オブジェクトとしての関数については考えません。あくまで通常の関数として使用する場合です。
関数には次のようなことが期待されます。
- ある名前の関数は常に同じ関数であること
- 関数がどこでも呼び出せること
高階関数や関数オブジェクトとして使用している場合は別ですが、時と場合によって関数の動作が異なることはいいことではありません。動作が途中で変わることは予期せぬバグを起こします。また、関数はコード内で自由にいつでも使えることが期待されます。コードの順番などによって動かなくなると、コードの整形すらままならなくなります。
エクスポートしない関数定義
まずは、エクスポートは考慮せずに考えます。
関数宣言 function f() {}
昔からあるよくある書き方です。この書き方で問題になるのは次の点です。
- 巻き上げが発生します。
- ブロック内で使用した場合にブラウザや厳格モードによって動作が異なる場合があります。
- 多重定義や代入によって、上書き可能です。
最初の1.を問題視する人がいますが、あまり重要ではありません。むしろこれだけなら便利と考えられます。次の2.はES2015+でモジュールとして作成している限り、厳格モードになりますので、それほど問題にはなりません。サポートが終了した古いブラウザへの考慮を考えるぐらいです。問題は最後の3.です。
通常、関数が途中で変わることを期待している人はいません。これは1.と組み合わさることでさらに混乱します。
f();
f = function() {
console.log(1);
};
f();
function f() {
console.log(2);
}
f();
f = function() {
console.log(3);
};
f();
function f() {
console.log(4);
}
f();
上のコードの結果をすぐに予測できる人は少ないでしょう。何よりも恐ろしいのは、上のコードがエラーにならないと言うことです。関数宣言はいくらでも多重定義でき、また、その関数名であることころ変数は、どこでも別のものを代入できます。最終的に、どの定義が有効であるかは、コード全体を注意深く見ないのわからなくなります。5
const宣言初期化子アロー関数 const f = () => {};
上書きを防ぐにはvar
やlet
では力不足です。const
を使うしかありません。ということで、対抗馬はconst
を使った変数へアロー関数を代入することです。この方法の欠点は下記の点です。
- ジェネレーターが作れません。
-
this
がレキシカルに決定されるため、呼び出す際に動的にthis
を変える事ができません。 - 巻き上げが発生しないため、定義の前に呼び出すことができません。
最初の1.は小さくない欠点です。仕方が無しに、ジェネレーターを使いたい場合は、const宣言初期化子関数式を使うしかありません。次の2.は逆に利点です。通常の関数として作る場合は、this
はむしろ、レキシカルに決定される方が問題が起きません。this
が動的に変わって欲しいのは、メソッドとして作る場合がほとんどであり、メソッドの定義を問題にしているわけではありません。最後の3.は少し問題ですが、書き方次第です。
最後の3.について問題になるのは「呼出し」が後からでなければならないと言うだけです。この関数定義以前の関数定義で使用できないというわけではありません。JavaScriptには、いわばmainのようなものはありませんが、コードの最後にmainとして書いた方が良いと考えられます。
const f = () => {
console.log("call f");
g(); // 後から定義する関数を書いても問題が無い。
};
const g = () => {
console.log("call g");
};
// main
f(); // 最後に実行する。
関数の定義の中で使う分には、まだ定義されていない関数を使うことは問題ありません。呼出しが定義前で無ければどこでも書くことができます。mainとなる処理を最後に書くということさえ守っていれば、ほとんど問題になりません。
残りの1.と2.について解決したい場合のみ、関数式(ジェネレーター式)を使えば良いとなります。this
が無い関数定義において、関数式を使っても、文字数が多くなるというデメリットが増えるだけです。=>
がfunction
に比べて目立たないと言うことは無く、アロー関数を使わない理由がありません。
const
を使用していますので、事前に宣言してから、後から代入という方法も使えません。
エクスポートする関数
エクスポートする場合を考えます。
関数定義後にエクスポート
あらかじめ関数を定義し、そのエクスポート文を別途記述する方法です。
const f = () => {};
export {f};
デフォルトエクスポートする場合は、export default f;
とします。const宣言初期化子アロー関数の場合はデフォルトエクスポート文を後に書く必要があります。これはf
がその時点で評価されるからです。
関数宣言エクスポート export function f() {}
関数宣言をそのままエクスポートする方法です。export default ...
とすればデフォルトエクスポートにすることも可能です。この方法には通常の関数宣言には無い利点があります。それは、export
しているものを多重に定義しようとするとエラーになると言うことです。もし、多重で宣言してしまってもそれに気付くことが可能です。しかし、それは代入の禁止までは及びません。
f = function() {
console.log(1);
};
export function f() {
console.log(2);
}
上のコードをimport
してf()
を呼び出した場合、実行されるのは、最初のコードの方になります。
無名関数宣言デフォルトエクスポート export default function() {}
関数宣言デフォルトエクスポートと似ているようですが、こちらには重要な利点があります。それは、代入によって上書きされる恐れが無いと言うことです。ただし、ファイル内で一つしか作成できないこと、関数名が"*default*"
という名前になること、そのファイル内で呼び出す方法が存在しない事に注意する必要があります。
const宣言初期化子アロー関数エクスポート export const f = () => {};
代入も防ぐにはconst
を使うしかありません。この書き方の欠点はデフォルトエクスポートができないと言うことです。
アロー関数デフォルトエクスポート export default () => {};
デフォルトエクスポートの場合はそのままアロー関数を指定することができます。無名関数宣言デフォルトエクスポートと同じく、ファイル内で一つしか作成できないこと、関数名が"*default*"
という名前になること、そのファイル内で呼び出す方法が存在しない事に注意する必要があります。
留意すべき事柄
まとめる前にその他の疑問点をQ/A方式で解説します。
アロー関数や(名前が無い)関数式では、関数名が無名になり、デバッグ等が不便になるのでは無いのか?
いいえ、ES2015+では代入によって関数名が設定されます。
const f = () => {};
console.log(f.name);
上のコードを実行すれば、関数f
に関数名が設定されることが確認できます。BabelによってES5に変換した場合も、アロー関数は名前付き関数に変換されます。また、名前付き関数は再帰関数で内部で使う(その関数をあらわす)変数が変更されないようにできるという利点がありますが、そもそもconst
を使っている限り変数は変更されることはありません。無名のように見えても、実際は無名では無く、名前を与えるために、名前付き関数や関数宣言にするようなことはする必要はありません。
コンストラクタとして関数を定義する場合はthis
がレキシカルに決定するアロー関数を使えないのでは無いのか?
はい、アロー関数は使えません。ですが、なんらかのインスタンスを作成するコンストラクタを作成するほとんどの場合は、class構文を使わない理由がありません。6
ES5以前でのコンストラクタ作成は非常に複雑でした。継承なども考えると、単純に実装できるものではありません。ES2015から追加されたclass構文は、その複雑なコンストラクタ作成を容易に実現するためのものです。イレギュラーで特殊な作りのコンストラクタでも無い限り、class構文を使った方が、コードも少なく、わかりやすく、簡潔になります。
アロー関数が使えない云々の前に、コンストラクタはclass構文を用いるべきであって、関数定義で行うべきではありません。
ユーザー関数を使って変数の初期値を与える場合、const
では後から書かないといけないのでは無いのか?
はい、後から書く必要があります。main処理さえ最後であればいいと言いましたが、それだけでは不十分です。
const PI = calcPI();
const calcPI = () => {
return Math.atan(1) * 4;
};
console.log(PI);
上のコードはエラーになります。なぜなら、定数PI
を求めるときに、未定義のcalcPI
を呼び出してしまっているからです。つまり、変数の初期値を求めるときに関数を使うのであれば、その関数は事前に定義されていなければなりません。
これは単純に、変数定義で初期値の式で関数を使うときは、事前に定義済みであると言うことに気をつける程度で十分です。実際に、多くのスクリプト言語7ではそのような作りになっています。ただ、難点を言えば、Cのような宣言だけを行うと言うことができないことですが、そこまで困ると言うことはありません。
条件によって変わる関数の場合はconst
が使えないので無いか?
いいえ、const
を使用したまま定義することは可能です。
import os from 'os';
const f = (() => {
switch(os.platform()) {
case 'win32':
return () => 'Windows';
case 'darwin':
return () => 'Mac';
case 'linux':
return () => 'Linux';
default:
return () => 'Other';
}
})();
console.log(f());
if
やswitch
は文であるため即時関数で囲むなど多少書きにくくなりますが、上のような方法も存在します。ただ、素直にlet
を使用した方が良いかもしれません。
import os from 'os';
let f;
switch(os.platform()) {
case 'win32':
f = () => 'Windows';
break;
case 'darwin':
f = () => 'Mac';
break;
case 'linux':
f = () => 'Linux';
break;
default:
f = () => 'Other';
}
console.log(f());
どちらにしてもやや冗長です。このような分岐は、OSや環境依存の部分があって異なる処理が必要などかなり特殊な用途以外は使うべきではありませんし、そもそも、関数内で処理を分岐した方が無難です。
なお、ブロック内で関数宣言を行った場合、ES2015+の厳格モードでは、ブロック内でしか関数は有効になりません。下記のように書くことはできません。
import os from 'os';
switch(os.platform()) {
case 'win32':
function f () { return 'Windows'; };
break;
case 'darwin':
function f () { return 'Mac'; };
break;
case 'linux':
function f () { return 'Linux'; };
break;
default:
function f () { return 'Other'; };
}
console.log(f());
(独断と偏見による)まとめと結論
以上の考察から独断と偏見によるまとめを行います。
- 多重定義や代入による上書きを防止できるのは、
const
使用時と無名関数宣言デフォルトエクスポートとアロー関数デフォルトエクスポートのみである。 - ジェネレーターを除き、アロー関数を使わない理由が無い。
ということで、私の結論としては、以下を推奨します。
- 関数定義にはconst宣言初期化子アロー関数
const f = () => {};
を使う。 - ただし、ジェネレーターを作る場合のみ、関数式(ジェネレーター式)を用いる。
- エクスポートは関数定義と一緒とし、const宣言初期化子アロー関数エクスポートを使う
- デフォルトエクスポートはアロー関数デフォルトエクスポートを使う。ただし、ファイル内で呼び出す必要がある場合は、const宣言初期化子アロー関数を定義後に、デフォルトエクスポート文を別途記述する。デフォルトエクスポートは最後に書くことが望ましい。
- 初期化子で関数を呼び出している変数は関数定義の後に書く。
- main処理は最後に書く。
実行してすぐにエラーになるような間違いは問題になりません。なぜなら、必ず気づけるからです。問題になるのは、即エラーにならずに後から想定外の動作になる間違いです。それを如何に防ぐかという観点からこのような結論になりました。
なお、もう一度言いますが、これはES2015+での話です。直接ES5で書かなければならない場合や、変換後のES5コードの話でもないですし、TypeScriptやCoffeeScriptのようなAltJSの話でもありません。また、高階関数等の普通の関数以外の使い方も含めていません。これらは別に考察する必要があると思います。
検証はNode.js(--experimental-modulesオプション付き)とBabelを使用しています。特定のブラウザでは動作が異なると言うことがあれば、ご指摘下さい。
参考文献
Standard ECMA-262
JavaScript | MDN
更新履歴
- 2020年2月21日
- 非同期ジェネレーター関数がES2018から正式採用となったため、記載内容を修正。
- エクスポートは宣言と同時とするに変更。一つの関数について記述箇所が二つあると、管理が複雑になると思われるため。
- デフォルトエクスポートにそのままアロー関数を書くことを許容。また、デフォルトエクスポートは最後に書くことが望ましいとした。
-
他に
new Function("...")
で関数を作成することは可能ですが、特殊な用途を除いて使用されることはないため、除外します。 ↩ -
モジュールとして作成されているJavaScriptは
"use strict";
の有無に関係無く、厳格モードになります。BabelでES5に変換した場合も、自動で"use strict";
が追加されます。 ↩ -
無名と書いていますが、デフォルトエクスポートによって関数名は
"*default*"
になります。また、デフォルトエクスポート以外で無名関数宣言は使用できません。 ↩ -
ジェネレーターアロー関数が提案されていますが、ずっとstage 1のままであるため、将来できるようになる可能性は薄そうです。 ↩
-
モジュールとして作成している場合、多重定義はエラーになります。しかし、Babelで変換する場合は、エラーになりません。いずれにしても、代入はエラーになりません。 ↩
-
JavaScriptのコンストラクタは他言語のクラスに相当します。プロトタイプベースであるため、コンストラクタであるオブジェクトが、コンストラクタの処理とプロトタイプの情報をもっているという扱いになります。 ↩
-
少なくともPythonとRubyでは。 ↩