else 句に「// 何もしない」コメントを書く意味について
先日 Twitter で、とあるコード内コメントについて面白い話題を見つけました。
} else {
// 何もしない
}
確かに、パッと見で 「なんだこれ?」 になりますね。
意味のないコメントとして else
句ごと削除してしまって良さそうにみえます。
しかし、実はこの「何もしない」コードは、 プログラミング(理論)的に面白い側面を持っている ので、簡単に記事にまとめてみたいと思います(記事中では Swift に似た擬似コードを使用します)。
キーワード
if
式、網羅的パターンマッチ、参照透過、副作用、モナドと単位元
else
句が省略できる条件
例えば、次のコード例を考えてみます:
let array: Array<Int> = ...
var positiveArray: Array<Int> = [] // var = 可変変数
for x in array {
if x > 0 {
positiveArray.append(x)
} else {
// 何もしない
}
}
ここでは array
から positiveArray
を生成する際に、 for
文による反復と append
による挿入を行いつつ、 else
句で負またはゼロの値を除外しています。
このような単純な例の場合、可読性の向上のため else
句を書かないという選択肢も十分に考えられます。しかし、 else
を省略できるプログラムというのは本質的に「(破壊的代入などの)副作用が発生しているコード」 のことです。パフォーマンスの向上が見込める反面、参照透過でなく副作用にまみれ、かつパターンマッチの網羅性を失って、重要な副作用の記述漏れを引き起こす恐れ があるので、注意が必要です。
以下の節で、その理由を見ていきましょう。
if
式と else { /* 何もしない */ }
の正体
もし、上述のコードを破壊的代入なしに宣言的に書くとしたら:
-
Array.filter
を使い、 - 第一引数の関数に
if x > 0 { true } else { false }
を渡す
ことが考えられます:
func isPositive(x: Int) -> Bool
x > 0 ? true : false // 三項演算子を使ったパターン
}
let positiveArray = array.filter(isPositive)
ここで重要なのが 冗長に書かずに ではなく、 2. の x > 0
で十分なのでは?if
式 です (if
"文"ではないので念のため)。
if
式とは、ざっくり言うと 「値を返す if 文」 のことです。
重要な点として、 if
式には必ず else
が登場し、省略して書くことができません。
(三項演算子と思ってもらって構いません)
これは、計算論理的に if
の後の条件(Bool
型)が2値(true
と false
)を取り、そのスコット・エンコーディングを取ることで then
と else
部分の2組の関数に等しくなることから来ています。
つまり、 else
を省略することは実質 false
の存在を無視するようなもので、 Bool
が本来持つ if
のパターンマッチ機能を損ねることに等しい ことを意味します。
では、仮にこの 良く分からん 理論が正しく、 else
句の記述を必須にした方が良いと仮定して、 // 何もしない
場合はどうなるでしょうか?
let flag: Bool = if x > 0 { true } else { /* 何もしない */ }
左辺の flag
は x
が正の数でない場合に Bool
を返さないので、当然ながらコンパイルエラーになります。
(三項演算子で :
が抜けたコードを想像してみて下さい)
しかし、もし Bool
を返す代わりに「副作用」を発生させた場合はどうでしょうか?
// `x` の値によって出力するかもしれない関数
let mayPrint: Void = if x > 0 { print("正の数") } else { /* 何もしない */ }
この場合は、問題なくコンパイルできます。
つまり、/* 何もしない */
というのは 「副作用の点で何もしない」 ということになります。
ここで Swift では、上記コードが返却する値は ()
で型は Void
になっています(他言語でいう Unit
型)。
これは、 print
の返り値が ()
であり、 /* 何もしない */
こともまた返り値が ()
とみなせるからです。
もし副作用について、より厳密な型を使って議論するなら、副作用の型演算 IO
を使った純粋なプログラミング言語(例えば Haskell)にならって、IO<Void>
を返すとみなすのが良いでしょう(あるいは ST
など)。
// 副作用を表す `IO` 型演算を使った擬似コード
let mayPrint: IO<Void> = if x > 0 { print("正の数") } else { IO<Void>() }
このように書くと、いよいよ /* 何もしない */
の正体が「空の副作用 IO<Void>()
」である ことが明確になります。このコードの方が、 else
の中身が 「謎の空白」であることに比べて、「副作用が空である」という分かりやすさ があります(Haskell では return ()
が使われます)。
ここまでのまとめ
-
else { /* 何もしない */ }
とは「副作用を何も発生しない」 ということ -
else
を省略することは実質false
の存在を無視するようなもの-
Bool
が本来持つif
のパターンマッチ機能を損ねることに等しい
-
1. については、 /* 何もしない */
コメントが、 「副作用を発生させる必要がない」というコード作者の明確な意思表示がある可能性 があり、一概に無碍にはできないと言えます。
次に、2. について if
式による2値分岐を多値に拡張した switch
式の場合を見ていきます。
switch
式によるパターンマッチ
優れたプログラミング言語(あるいは作法)の特徴の1つとして、 パターンマッチを網羅的に行う ことを型システムで強制(ないし人力で意識)することが挙げられます。多くの言語では switch
キーワードで表され、2パターンの分岐に限定したものを if
と考えることもできます。
enum Pattern {
case pattern1
case pattern2
...
}
let pattern: Pattern = ...
switch pattern {
case .pattern1:
print("pattern1")
// case .pattern2: // 試しにコメントアウトしてみる
// ...
// ERROR: 全caseパターンが網羅されていないので
// `case .pattern2` or `default` 句を加えるようにコンパイラがエラーを出す
}
網羅的パターンマッチを備えたプログラミング言語は、特に リファクタリングの際に(case
を増やしたり減らした場合の)コードの破壊的変更点にコンパイル時に気付いて修正できる 点がとても強力です。
また、もし網羅的パターンマッチが行えない状況(例:言語がサポートしていない、パターンマッチの対象となる型が構成的でない、switch
の代わりに大量の if
を使う、など)でも、 default
句に何かしらのコメントメッセージを残しておくことで、後々の人力リファクタリングで役に立つ日が来るかもしれません。
例えば、以下のコード例を考えてみます:
let bool: Bool = ...
let int: Int = ... // NOTE: `Int` は構成的な型でないので網羅的パターンマッチができない
// 大量の `if` の場合も、網羅的パターンマッチができない場合が多い
if bool && int > 100 {
... // 何か副作用
} else if bool && int < 0 {
... // 何か副作用
} else if !bool && int > 100 {
... // 何か副作用
} else if ... {
... // 何か副作用
} else {
// 何もしない
}
ここで最後の else
句を見てみると、(コンパイラが自動的に挿入してくれなかった) else
を人力で加えたことにより、(コード作者が"それなりに")網羅的なパターンマッチを意識しており、かつそこに副作用が発生しないことを保証している といえます。
ここでもし、この else
句が書かれていないコードを想像してみて下さい。
そして、もし else
にも実は仕様的に副作用が必要だったとしたら ・・・
・・・
やだなぁ、怖いなぁ。
それだけ、パターンマッチの網羅性とは重要なことなのです。
まとめ
-
else { /* 何もしない */ }
とは「副作用を何も発生しない」 ということ- 副作用が空であることが明示できる
- コメントによる補足よりも
IO<Void>()
を返す言語設計の方が、なお明示的で良い
-
網羅的なパターンマッチ は重要
- コンパイラが仕事しない場合もあるので、その時は自分で書く
- 重要な副作用の記述漏れを防ぐことが可能
個人的な意見としては、一番最初のコードの例のように、よほどコード量が短く、副作用を必要としないことが一目で分かる場合に限り、 else
を省略しても良いと思います。
それ以外は、基本的に else
、 default
を書き、コメントを残しておいた方が安全です。
(もちろん、今回のような /* 何もしない */
というコメントの意味について、コード作者が十分に理解していないまま適当に書いている可能性もあるので、コードレビュー等で理由を聞いて確かめるのが良いでしょう)
P.S.
今日の話は、以下のツイートを深堀りする形で書いてみました:
P.S.2
もし一部のパターンマッチ以外を無視したい場合は、 guard
や throw
などの早期脱出を使うことが考えられます。これらについても、継続やモナド話の発展形で議論することができますが、今回の話のスコープから外れるので割愛しました。
Discussion
こんにちは。まず僕もこの議論は影から見ていて、ほとんどはこの記事の主張と同じように思っています。多いのは
else
を省略したif
ですが、多くの場合それは副作用を利用しているコードだと思います。記事でも言及があるようにif
がもし式(= 三項演算子)であったら通用しにくくなるというのももっともだと思っています。その点を踏まえたうえで、ちょっとコメントです。
mayPrint
のところは次のような参照透過が破壊される例があると、その次のIO<Void>
を使った説明へと繋げやすくなる?🤔print
の結果をいったん変数に代入しても同じとなるはずですが、これだとx
の値が何であっても大抵のプログラム言語では正の数
が出力されてしまうと思います。したがって元のプログラムは参照透過ではないということになりますIO<Void>
がある!という説明にできるような気がしましたIOモナドの単位元
というものがあるのか?IO<Void>()
が単位元だとしたら、演算をflatMap
としたときにこういう👇等式が言える?でもパッとはこういうのが分かりませんでした 😇λx: A. pure(x)
のような関数になって次のようになるのではないか?🤔みたいな議論になりましたIO
やVoid
に限定されることもなさそうですが、モノイドや単位元という定義に従っているのか?みたいなところはよく分かってませんList
では、モナドの演算とは別にappend
のようなモノイド演算と単位元Nil
が与えられているように見えます@yyu さん、コメントありがとうございます!
全く仰る通りです。
そのために IO を導入する理由を解説できれば良かったのですが、
いかんせん文章量の兼ね合いもあって説明が不足しました😓
(代わりにコメントを引用させていただきます!)
追記されている通りで、厳密にはモナドは型というより型関数なので、
型関数に対する単位元 というのが正しいです。
モナドの場合の単位元は自然変換
return
(orpure
)で表せ:ここで「一般化元」と「米田の補題」を使うと:
になるので、これが記事中の
IO<Void>()
の型です。「
return
関数を変換した結果を値としてみなしたもの」と考えることができます。また、
IO<Void>
型に対して無理矢理IO<T>
型 (T != Void
) と合成するのは難しいので、記事中では(主に初学者向けとして)厳密さを取り除いて、値同士の合成という形でモノイド風に見立てました。もし、この辺りもきちんと考えるなら、クライスリ射とその単位射で考えるのが一番正しいと思います。
ほほー、圏論はよくわかりませんが(2度目)「単位射」というのがイメージに近いですね。たぶんその関数みたいなものをどうにか値(元)にするのが「一般化元」とか「米田の補題」なのかな? 🤔 🤔 🤔 🤔
モノイドの議論は無理にこの記事(
if
の話題)でやらなくても実はいいのかもしれませんね……。個人的にはちょっと難しすぎると思いました。ご感想ありがとうございます。
今回のテーマの奥深さを知ってもらいたく、重要なキーワードをいろいろ盛り込みました。
モノイド単位元は「何もしない」そのものなので、むしろ今回の話の主役に据えて、難易度を上げてでもきちんと説明し切るのが良かったのかもしれません。
バランス調整、難しい。。
記事の趣旨がもともと
/* 何もしない */
を書くことについてであり、IO
モナドをつかって参照透過にしたコードでは/* 何もしない */
よりも適切なものとして、IO
モナドの単位元を書かけるという論旨でしたね。なので単位元のことを説明しないわけにはいかないか……😇 (モナドの単位元という概念が、テクニカルすぎるという気がどうしてもしてしまいますがw)