💬

else 句に「// 何もしない」コメントを書く意味について

に公開
2022/01/11
5件

先日 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 { /* 何もしない */ } の正体

もし、上述のコードを破壊的代入なしに宣言的に書くとしたら:

  1. Array.filter を使い、
  2. 第一引数の関数に if x > 0 { true } else { false } を渡す

ことが考えられます:

func isPositive(x: Int) -> Bool
  x > 0 ? true : false // 三項演算子を使ったパターン
}

let positiveArray = array.filter(isPositive)

ここで重要なのが 冗長に書かずに x > 0 で十分なのでは? ではなく、 2. の 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 () が使われます)。

ここまでのまとめ

  1. else { /* 何もしない */ } とは「副作用を何も発生しない」 ということ
  2. 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 にも実は仕様的に副作用が必要だったとしたら ・・・

・・・

やだなぁ、怖いなぁ。

それだけ、パターンマッチの網羅性とは重要なことなのです。

まとめ

  1. else { /* 何もしない */ } とは「副作用を何も発生しない」 ということ
    • 副作用が空であることが明示できる
    • コメントによる補足よりも IO<Void>() を返す言語設計の方が、なお明示的で良い
  2. 網羅的なパターンマッチ は重要
    • コンパイラが仕事しない場合もあるので、その時は自分で書く
    • 重要な副作用の記述漏れを防ぐことが可能

個人的な意見としては、一番最初のコードの例のように、よほどコード量が短く、副作用を必要としないことが一目で分かる場合に限り、 else を省略しても良いと思います。
それ以外は、基本的に else、 default を書き、コメントを残しておいた方が安全です。

(もちろん、今回のような /* 何もしない */ というコメントの意味について、コード作者が十分に理解していないまま適当に書いている可能性もあるので、コードレビュー等で理由を聞いて確かめるのが良いでしょう)

P.S.

今日の話は、以下のツイートを深堀りする形で書いてみました:

P.S.2

もし一部のパターンマッチ以外を無視したい場合は、 guard や throw などの早期脱出を使うことが考えられます。これらについても、継続やモナド話の発展形で議論することができますが、今回の話のスコープから外れるので割愛しました。

Discussion

YOSHIMURA YuuYOSHIMURA Yuu

こんにちは。まず僕もこの議論は影から見ていて、ほとんどはこの記事の主張と同じように思っています。多いのはelseを省略したifですが、多くの場合それは副作用を利用しているコードだと思います。記事でも言及があるようにifがもし式(= 三項演算子)であったら通用しにくくなるというのももっともだと思っています。

その点を踏まえたうえで、ちょっとコメントです。

  1. キーワードのところに「参照透過」があるので、mayPrintのところは次のような参照透過が破壊される例があると、その次のIO<Void>を使った説明へと繋げやすくなる?🤔
    • このような 👇コードにちょっと変更してみます
    let printOutput: Void = print("正の数")
    let mayPrint: Void = if x > 0 { printOutput } else { /* 何もしない */   }
    
    • inamiさんに言うまでもないことだとは思いますが、参照透過であるとは「あらゆる変数をその代入された式で置換してもプログラム全体の意味が同じになる」ということを意味していると思います
    • 参照透過であれば、こういう感じでprintの結果をいったん変数に代入しても同じとなるはずですが、これだとxの値が何であっても大抵のプログラム言語では正の数が出力されてしまうと思います。したがって元のプログラムは参照透過ではないということになります
    • そもそも参照透過であると何がいいんだ?というような議論にもなりそうですが、これを無理やり(?)参照透過にしたいというモチベーションでIO<Void>がある!という説明にできるような気がしました
  2. IOモナドの単位元というものがあるのか?
    • 僕は圏論をそんなにちゃんとやってないので、そういうことをあらかじめ謝っておく(?)必要があるんでが、一般的にモナドに対して単位元が定義できるのでしょうか?
      • IO<Void>()が単位元だとしたら、演算をflatMapとしたときにこういう👇等式が言える?でもパッとはこういうのが分かりませんでした 😇
        IO<Void>() flatMap f == ??? // where f: Void => IO<A> ?
        
      • 追記(1/11): 同僚と議論したところ、モナドの単位元は値というよりはλx: A. pure(x)のような関数になって次のようになるのではないか?🤔みたいな議論になりました
        IO<Void>() flatMap (x: Void => pure(x)) == IO<Void>()
        
        • こうするとIOやVoidに限定されることもなさそうですが、モノイドや単位元という定義に従っているのか?みたいなところはよく分かってません
    • たとえばモナドかつモノイド(= モナドプラス)なListでは、モナドの演算とは別にappendのようなモノイド演算と単位元Nilが与えられているように見えます
      Nil append xs == xs append Nil == xs
      
2
Yasuhiro InamiYasuhiro Inami

@yyu さん、コメントありがとうございます!

参照透過にしたいというモチベーションでIO<Void>がある!

全く仰る通りです。
そのために IO を導入する理由を解説できれば良かったのですが、
いかんせん文章量の兼ね合いもあって説明が不足しました😓
(代わりにコメントを引用させていただきます!)

一般的にモナドに対して単位元が定義できるのでしょうか?

追記されている通りで、厳密にはモナドは型というより型関数なので、
型関数に対する単位元 というのが正しいです。

モナドの場合の単位元は自然変換 return (or pure)で表せ:

return :: Monad m => a -> m a

ここで「一般化元」と「米田の補題」を使うと:

a -> m a
≅ (() -> a) -> m a
≅ m ()

になるので、これが記事中のIO<Void>() の型です。
「return 関数を変換した結果を値としてみなしたもの」と考えることができます。

また、 IO<Void> 型に対して無理矢理 IO<T> 型 (T != Void) と合成するのは難しいので、記事中では(主に初学者向けとして)厳密さを取り除いて、値同士の合成という形でモノイド風に見立てました。

もし、この辺りもきちんと考えるなら、クライスリ射とその単位射で考えるのが一番正しいと思います。

1
YOSHIMURA YuuYOSHIMURA Yuu

ほほー、圏論はよくわかりませんが(2度目)「単位射」というのがイメージに近いですね。たぶんその関数みたいなものをどうにか値(元)にするのが「一般化元」とか「米田の補題」なのかな? 🤔 🤔 🤔 🤔
モノイドの議論は無理にこの記事(ifの話題)でやらなくても実はいいのかもしれませんね……。個人的にはちょっと難しすぎると思いました。

1
Yasuhiro InamiYasuhiro Inami

ご感想ありがとうございます。
今回のテーマの奥深さを知ってもらいたく、重要なキーワードをいろいろ盛り込みました。
モノイド単位元は「何もしない」そのものなので、むしろ今回の話の主役に据えて、難易度を上げてでもきちんと説明し切るのが良かったのかもしれません。
バランス調整、難しい。。

1
YOSHIMURA YuuYOSHIMURA Yuu

記事の趣旨がもともと/* 何もしない */を書くことについてであり、IOモナドをつかって参照透過にしたコードでは/* 何もしない */よりも適切なものとして、IOモナドの単位元を書かけるという論旨でしたね。なので単位元のことを説明しないわけにはいかないか……😇 (モナドの単位元という概念が、テクニカルすぎるという気がどうしてもしてしまいますがw)

2