関数を実行するときに、あらかじめ定められた前提条件を満たさなければ、早めに return をしてネストを浅くしたり、余計なことを考えなくてよくしたりする早期リターンという考え方がある。
私も一時期は早期リターン信者だったことがあるが、早期リターンは使いどころを間違えると却って複雑になってしまう。その理由をプログラミング経験を経て言語化できるようになったので、つらつらと書いていく。
複数の return 文の是非
早期リターンでは複数の return 文が出現しうる。これについては肯定的な意見と否定的な意見があり、リーダブルコードでは、「関数で複数の return を使ってはいけない」という主張に対して「アホくさ」と論じている(pp.91)1。
いっぽうで組込みソフトウェア開発向け コーディング作法ガイド2では、
- 関数は、1つのreturn文で終了させる
- 処理の途中で復帰する return 文は、異常復帰の場合のみとする
とある(M3.1.5)。組込みソフトウェア開発はリアルタイム性やメモリの制約が強い特殊な状況下ということもあり、リーダブルコードの内容と対比させるには適切でないかもしれないが、ここでは反対意見のひとつとして取り上げた。
私はかつて組込みソフトウェア開発をしていたことがあり、return 文はひとつの方が望ましいと教えられてきた。関数の出口をひとつにすることで、心理的安心が得られるからだ。このプロジェクトでは関数の途中で return されることがない、ということがわかっていれば、途中リターンを考えなくてよく、コードを読む時の負担も少なくなる。
しかし、途中でリターンできないことは別の問題も生む。ひとつめの問題は return 文をひとつにするために余分なコードを要求されることだ。以下のコードの result は本来不要な変数だが、関数の最後で if-else の合流のために必要になっている。これだけならまだしも、初期値として使用されない第三の値が指定されている。
function foobar(x) {
let result = 0
if (...) {
result = 100
} else {
result = 200
}
return result
}第三の値は厄介者だ。実際には使われないものの、コード中では未定義値としての役割を任されている。我々はこのコードを読むときに、result の行く末を追っていく必要がある。
ふたつめに、関数を適切に分割できないと、ネストが深くなり、正しく状態を管理するためのフラグ管理が必要になる。熟練したプログラマであればこの問題に対処することは容易いが、そうでなくてもロジックが複雑になりすぎないうちに 早期リターンするのは悪い判断ではないはずだ。不正な入力を弾いて、不要な計算をすることを防ぐのはいいことである。
しかし、早期リターンに問題がないわけでもない。ひとつは何らかのリソースを保持している場合に、早期リターンしたときに適切なリソース解放が必要になるということだ。複数の return 文があれば、その数だけリソース解放処理が必要になり、却って複雑なコードになってしまうかもしれない。
ネストは回避すべきか?
状況による。ネストは悪者扱いされやすいが、それは3つも4つもネストしたときのことであって、経験則的に1つや2つであれば問題ないし、ネストでしか得られない利点もある。
ひとつ例としてObjective-Cのインスタンス初期化のコードを見てみよう。ここでは self を初期化するためのコードとして、if 文によるネストを使ったコードを使っている。以下のコードは「[super init] が NIL でなかったら、self を初期化する」と読む。
- (instancetype)initWith:(int) value {
if (self = [super init]) {
if (value < 0) {
...
}
}
return self;
}これは極端な例だが、if 文が肯定形で使われている限りは、ある程度ネストしても問題ないのではないか?とも感じる。なぜなら、ネストによって指定した条件が保証されている部分であることが視覚的に判断できるからだ。
早期リターンはネストという視覚的情報が失われる上に、その後のコードで早期リターン条件の否定をずっと頭の中に抱えることになる。小さいコードであればいいかもしれないが、画面の表示領域を超える長さのコードになったときには、難しい仕事になってくるだろう。
否定との付き合いかた
条件に沿わないときに return するということは、なんらかの状態の否定をとっていることが多い。つまり ! 否定演算子が登場することを意味している。さてそれじゃあ、その否定する相手に注目してみよう。
通常メソッドやプロパティは肯定形で宣言することが好ましい。人間の感覚とも一致するし、その方が自然だからだ。ものごとを悲観的に捉えていて、失敗しているかどうかを第一に考える人もいるにはいるだろう。ではそうしたひねくれ者がいたとして、失敗の否定を考えたときに、それは成功ということになるのだろうか?
ほとんどの場合ではそれは成功だろうが、時と場合によっては不定だったり、また別の失敗している状況かもしれない。それに否定形の否定(二重否定)は混乱しやすくもある。
これを緩和する方法はいくつかある。ひとつは否定形のメソッドやプロパティを作ってしまうことだ。オイオイ、肯定形が望ましいとか言っておきながら矛盾しているぞという人もいるかもしれないが、こうすることで、実際には否定形ではありつつも、否定演算子は必要なくなるし、数学的な意味よりも文章的な意味を持たせることで、脳内で否定を計算する手間を減らせる。
get isEmpty() { return ... }
get isNotEmpty() { return !this.isEmpty() }しかし、肯定形と否定形の表現がそれぞれ生まれてしまうことで、どちらを軸にしたらいいのか悩ましい状況も発生することは留意しておかなければならない。
いくつかのエラー種別があってそれを真偽値で判定する場合には、この解決方法がうまく働かないことが多い。そういうときには、エラーごとに真偽値で判定するのではなく、むしろその状態を列挙体で宣言したほうがよい。列挙体を拡張できるプログラミング言語では、列挙体にメソッドやプロパティを持たせることでコードも構造的でまとまったものになる。
まとめ
今の私は早期リターンに対してやや否定的な立場だ。しかし完全否定するほどのことでもない。用法用量を守って使えば強力な武器となる。ということで、早期リターンについては、次の条件を追加で設けている。
- 複数の
returnは書いてもよいが、なるべく行数的に近いほうが好ましい。 - 否定形に注意する。コード上で否定を避けるような工夫を凝らすこと。
- 理解のしやすさに焦点を置く。早期リターンは手段であって目的ではない。
これは私の一意見なので、実際には早期リターンの利点と欠点を明確にした上で、どういう基準で採用するかはチームやプロジェクト、採用するプログラミング言語に応じて決めるべきだ。