Edited at

関数宣言 vs 関数式 | ES2015+

JavaScriptで関数を定義する方法として、関数宣言(function declaration)と関数式(function expression)の二つ1があります。記事によっては片方を推奨する場合がありますが、実際の主張はバラバラでは無いかという印象です。ここにアロー関数(arrow function)を加えて、ジェネレーター、非同期(async)、classのメソッドなども考えながら考察していきたいと思います。

話を始めるにあたって、古いJavaScriptの仕様を考慮から外し、下記の構成を前提にします。


  • 仕様はES2015+とします。レガシーブラウザ向けにはBabelでの変換を前提とします。

  • モジュールとして作成し、import文及びexport文を使用します。未対応環境やレガシーブラウザ向けには、Babelで変換後にwebpackやbrowserify等を使うことを前提とします。

上記により、常に厳格モード(strict mode)であること2に注意してください。ES5の場合や非厳格モードの場合などを考慮すると、その場合は留意事項が異なると言った事態になり、場合分けが発生する可能性があるからです。


定義方法

二つあると言いましたが、大きく分けて二つであり、より多くのパターンが存在します。


  • 宣言


    • 関数宣言


      • 関数宣言 function f() {}

      • 関数宣言エクスポート export function f() {}

      • 関数宣言デフォルトエクスポート export default function f() {}



    • 無名関数宣言


      • 無名関数宣言デフォルトエクスポート3 export default function() {}






    • 関数式


      • 宣言初期化子関数式


        • var宣言初期化子関数式 var f = function() {};

        • let宣言初期化子関数式 let f = function() {};

        • const宣言初期化子関数式 const f = function() {};



      • 宣言初期化子関数式エクスポート


        • var宣言初期化子関数式エクスポート export var f = function() {};

        • let宣言初期化子関数式エクスポート export let f = function() {};

        • const宣言初期化子関数式エクスポート export const f = function() {};



      • 事前宣言変数代入関数式


        • 事前var宣言変数代入関数式 var f; f = function() {};

        • 事前let宣言変数代入関数式 let f; f = function() {};



      • 関数式デフォルトエクスポート


        • 関数式デフォルトエクスポート export (function() {});





    • 名前付き関数式


      • 宣言初期化子名前付き関数式


        • var宣言初期化子名前付き関数式 var f = function f() {};

        • let宣言初期化子名前付き関数式 let f = function f() {};

        • const宣言初期化子名前付き関数式 const f = function f() {};



      • 宣言初期化子名前付き関数式エクスポート


        • var宣言初期化子名前付き関数式エクスポート export var f = function f() {};

        • let宣言初期化子名前付き関数式エクスポート export let f = function f() {};

        • const宣言初期化子名前付き関数式エクスポート export const f = function f() {};



      • 事前宣言変数代入名前付き関数式


        • 事前var宣言変数代入名前付き関数式 var f; f = function f() {};

        • 事前let宣言変数代入名前付き関数式 let f; f = function f() {};



      • 名前付き関数式デフォルトエクスポート


        • 名前付き関数式デフォルトエクスポート export (function f() {});





    • アロー関数


      • 宣言初期化子アロー関数


        • var宣言初期化子アロー関数 var f = () => {};

        • let宣言初期化子アロー関数 let f = () => {};

        • const宣言初期化子アロー関数 const f = () => {};



      • 宣言初期化子アロー関数エクスポート


        • var宣言初期化子アロー関数エクスポート export var f = () => {};

        • let宣言初期化子アロー関数エクスポート export let f = () => {};

        • const宣言初期化子アロー関数エクスポート export const f = () => {};



      • 事前宣言変数代入アロー関数


        • 事前var宣言変数代入アロー関数 var f; f = () => {};

        • 事前let宣言変数代入アロー関数 let f; f = function() {};



      • アロー関数デフォルトエクスポート


        • アロー関数デフォルトエクスポート export default () => {};







それぞれジェネレーターと非同期を使うパターンが存在します。ただし、ジェネレーターと非同期の同時使用はできず、アロー関数のみジェネレーターはありません。4


関数定義はどうあるべきか

まず、関数定義というのはどうあるべきかを考えます。ここではコンストラクタ、即時関数、高階関数、関数オブジェクトとしての関数については考えません。あくまで通常の関数として使用する場合です。

関数には次のようなことが期待されます。


  1. ある名前の関数は常に同じ関数であること

  2. 関数がどこでも呼び出せること

高階関数や関数オブジェクトとして使用している場合は別ですが、時と場合によって関数の動作が異なることはいいことではありません。動作が途中で変わることは予期せぬバグを起こします。また、関数はコード内で自由にいつでも使えることが期待されます。コードの順番などによって動かなくなると、コードの整形すらままならなくなります。


エクスポートしない関数定義

まずは、エクスポートは考慮せずに考えます。


関数宣言 function f() {}

昔からあるよくある書き方です。この書き方で問題になるのは次の点です。


  1. 巻き上げが発生します。

  2. ブロック内で使用した場合にブラウザや厳格モードによって動作が異なる場合があります。

  3. 多重定義や代入によって、上書き可能です。

最初の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 = () => {};

上書きを防ぐにはvarletでは力不足です。constを使うしかありません。ということで、対抗馬はconstを使った変数へアロー関数を代入することです。この方法の欠点は下記の点です。


  1. ジェネレーターが作れません。


  2. thisがレキシカルに決定されるため、呼び出す際に動的にthisを変える事ができません。

  3. 巻き上げが発生しないため、定義の前に呼び出すことができません。

最初の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());

ifswitchは文であるため即時関数で囲むなど多少書きにくくなりますが、上のような方法も存在します。ただ、素直に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());


(独断と偏見による)まとめと結論

以上の考察から独断と偏見によるまとめを行います。


  1. 多重定義や代入による上書きを防止できるのは、const使用時と無名関数宣言デフォルトエクスポートとアロー関数デフォルトエクスポートのみである。

  2. ジェネレーターを除き、アロー関数を使わない理由が無い。

ということで、私の結論としては、以下を推奨します。


  • 関数定義にはconst宣言初期化子アロー関数 const f = () => {};を使う。

  • ただし、ジェネレーターを作る場合のみ、関数式(ジェネレーター式)を用いる。

  • エクスポートは関数定義の後にエクスポート文を書く。

  • 初期化子で関数を呼び出している変数は関数定義の後に書く。

  • main処理は最後に書く。

実行してすぐにエラーになるような間違いは問題になりません。なぜなら、必ず気づけるからです。問題になるのは、即エラーにならずに後から想定外の動作になる間違いです。それを如何に防ぐかという観点からこのような結論になりました。

なお、もう一度言いますが、これはES2015+での話です。直接ES5で書かなければならない場合や、変換後のES5コードの話でもないですし、TypeScriptやCoffeeScriptのようなAltJSの話でもありません。また、高階関数等の普通の関数以外の使い方も含めていません。これらは別に考察する必要があると思います。


検証はNode.js(--experimental-modulesオプション付き)とBabelを使用しています。特定のブラウザでは動作が異なると言うことがあれば、ご指摘下さい。

参考文献

Standard ECMA-262

JavaScript | MDN





  1. 他にnew Function("...")で関数を作成することは可能ですが、特殊な用途を除いて使用されることはないため、除外します。 



  2. モジュールとして作成されているJavaScriptは"use strict";の有無に関係無く、厳格モードになります。BabelでES5に変換した場合も、自動で"use strict";が追加されます。 



  3. 無名と書いていますが、デフォルトエクスポートによって関数名は"*default*"になります。また、デフォルトエクスポート以外で無名関数宣言は使用できません。 



  4. 非同期ジェネレーター関数(非同期イテレーター)(stage 3)とジェネレーターアロー関数(stage 1)が提案中であるため、将来できるようになるかもしれません。 



  5. モジュールとして作成している場合、多重定義はエラーになります。しかし、Babelで変換する場合は、エラーになりません。いずれにしても、代入はエラーになりません。 



  6. JavaScriptのコンストラクタは他言語のクラスに相当します。プロトタイプベースであるため、コンストラクタであるオブジェクトが、コンストラクタの処理とプロトタイプの情報をもっているという扱いになります。 



  7. 少なくともPythonとRubyでは。