Rubyでlintと言えばRubocopを指すことが一般的です。Rubocopには様々なCop(英語で警察官の意味で、それぞれが特定のコード上の問題の検知に特化している)が含まれていて、ほとんどの場合は直感的な警告メッセージを表示してくれます。
ただ、コードの複雑さをチェックスするメトリックス系のCopについては、表示されるメッセージに記載されている指標の意味がわからなかったり、そもそも何をトリガーとして警告が発せられるのかがドキュメンテーションを読んでもわかりにくいかもしれません。
そこで、代表的な以下のメトリックス系のCopについて、それぞれ詳しく解説していきます。
- Perceived complexity
- Cyclomatic complexity
- Assignment Branch Condition size (AbcSize)
Perceived complexity for メソッド名 is too high.
日本語に直訳すると、このメソッドを読む人が、コードが複雑過ぎると感じる可能性が高いという警告メッセージです。
このメッセージの後に[13/8]のような分数が表示されますが、分子の部分(この例だと13)が、このメソッドの複雑性のスコアです。分母の部分(この例だと8)が許容される最大値で、分子の数が分母の数を超えるとこのメッセージが表示されます(この例では分子が9以上になる時)。分母はデフォルトで8が割り当てられていますが、低すぎたり高すぎると感じる場合は設定で変更することができます。
分母は設定を変更しない限り固定なので、分子の方に注力して見てみましょう。
まず、ポイントのカウントは0からではなく1から始まります。つまり、どんなメソッドでも最低1ポイントがつきます。例外は本文が空のメソッドで、この場合は0ポイントとなります。
次に、複雑性を増加させる特定のキーワードごとにRubocopがポイントを割り当てていきます。具体的には、以下の7種類のキーワードが該当します。
- if (elsif、else、unless、条件演算子(?:)含む)
- case (when含む)
- while
- until
- for
- rescue
- and (&&含む)
- or (||含む)
7つのキーワードのうち、ifとcaseの条件文ついては特殊な計算方法が存在しますが、それ以外については1つにつき1ポイントが加算されます。
ifの計算方法については以下の通りです。
・if単体(if..end)であれば1ポイント。unlessも同様。
・elseかelsifを含むifだったら2ポイント。elseやelsif自身のポイントではなく、あくまでifに付与されるポイントです。
・elsifは1つにつき1ポイント。
・else自体はポイントなし。
つまり、if..elsif..else..endとif..elsif..endはポイントが変らず両方とも3ポイントになります。個人的には前者の方が若干複雑な気はしますが、ともかくそういうアルゴリズムです。
なお、条件演算子(条件 ? TRUEの場合 : FALSEの場合)もifのキーワードに含まれますが、これはelseもelsifも含まれないのでif単体と同等とみなされ、1ポイント加算となります。
次にcase/whenの計算方法についてですが、以下の通りです。
もしcaseのコンディションがない場合は、if..elsif..elsifと同等とみなされ、それぞれのwhenに1ポイントが加算されます。
case
when foo == 'a' # 1ポイント
puts 'a'
when bar == 'b' # 1ポイント
puts 'b'
end
# 合計2ポイント
caseにコンディションが入る場合だと、caseに0.8、それぞれのwhenに0.2が加算され、最後に四捨五入されます。式にすると以下の通りです。
(0.8 + whenの数*0.2).round
case foo # 0.8
when 'a' # 0.2
puts 'a'
when 'b' # 0.2
puts 'b'
end
# 合計1ポイント
これを見てわかるように、Rubocopは同じ変数の値に対する条件分岐はifを使うよりもcase/whenを推奨しています。Rubocopでなくても、if foo == ‘a’, elsif foo == ‘b’と書くのは明らかにセンスが悪いのは一目瞭然ですよね。
ここまでの情報を踏まえると、perceived complexityのスコアを減らすには以下が有効です。
・メソッドを複数メソッドに分割する。
たとえ複雑性のスコアが高くなくても、そもそも1画面に収まらないような長いメソッドは理解するのが大変なので、処理をグループ分けして別のメソッドに移してしまいましょう。
・if..else..endではなく早期リターン(early return)を使う。
・ifではなくcase/whenを使う。
・1行に収まるような短い条件であれば、if条件文ではなく条件演算子を使う。
・ネストifは極力避ける。
・nil?をチェックしているif..elseの場合は||を使う。
・case/whenの代わりにHashを使う。
Cyclomatic complexity for メソッド名 is too high.
循環的複雑度(cyclomatic complexity)とは、メソッドの中の独立した実行パスの数のことです。この数が多いとそれだけレビューワーも読むのが大変になり、コードカバレッジを保つためにテストの数も増やさなければなりません。このCopは実行パスの分岐点の数を数えて、設定値を上回ると以下のような警告を出します。
Cyclomatic complexity for メソッド名 is too high. [8/7]
分子が実際の分岐の数で、分母が閾値です。閾値はデフォルトでは7が設定されています。
分岐の数の数え方ですが、まず、どのようなメソッドでもカウントは1から始まります。そして分岐点としてカウントされるキーワードは以下の通りで、それぞれが出現する度に1とカウントされます。
- if、elsif, unless、条件演算子(?:)。但しelseは実行パスの増加にならないのでカウントされません。
- when。但しcase自体はカウントされず、含まれるwhenのみカウントされます。
- while、until、for
- &&、||、and、or
def foo_bar # +1
if foo # +1
puts 'foo'
elsif bar # +1
puts 'bar'
else # 0
puts 'else'
end
end
# 合計 3
Assignment Branch Condition size for メソッド名 is too high.
代入(アサインメント)、分岐(ブランチ)、条件(コンディション)の合計値が高すぎるという警告メッセージです。それぞれの頭文字を取ってABCサイズとも呼ばれます。
代入は代入演算子(つまりイコールサイン=)、分岐はメソッドコール(インスタントメソッド、クラスメソッド両方)、条件はCyclomaticComplexityの対象キーワードと一緒で、if, while, until, for, rescue, when and, orなどのことです。
例えば、以下のコードで代入演算子に1ポイント加算されます。
a = 'hoge'
分岐の例は以下の通りです。これで1ポイントが分岐に加算されます。
a.length
条件の例は以下の通りで、これで条件スコアに1ポイント追加されます。
if a
なお、elseが含まれるifは2ポイントが条件スコアに追加されます。
また、== や<=などの比較演算子はrubyではメソッドですが、AbcSizeでは分岐ではなく条件としてカウントされます。
違反がある場合のメッセージは「Metrics/AbcSize: Assignment Branch Condition size for メソッド名 is too high. [<4, 18, 1> 18.47/17]」となり、メッセージの最後に指標が表示されます。<>の中の3つの数字は、ABCそれぞれのカウントされたポイントです。この例だと、代入が4, 分岐が18, 条件が1です。
最後の数の分母は閾値で、デフォルトでの最大ポイントは17です。18以上になると高すぎると判断されメッセージが表示されるようになります。
分子(18.47)は、3つのポイントそれぞれを2乗して足した数の平方根を小数点第2位まで四捨五入した数です。この例だと、Math.sqrt(4**2 + 18**2 + 1**2).round(2)です。
この式では、3つの指標の合計カウントが同じでも、一つの数が突出して高い方が最終スコアが高くなってしまう傾向があります。例えば合計が同じ9カウントでも、<3, 3, 3>なら5.2ですが、<1, 7, 1>なら7.14になります。そのため、もし違反メッセージが表示されてしまった場合は、一番スコアが高い指標を改善する方がインパクトがあります。
まとめ
Rubocopのメトリックス系Copの警告メッセージをどうやって消したら良いかわからないと感じていたかもしれませんが、それぞれのスコアの意味とカウントの仕方を理解すれば、対策するのはそれほど難しくありません。
但し、Rubocopのスコアは絶対的なルールではありません。必ずしもスコアを減らせば読みやすくなるかというとそうでもないケースもあるので、対策した結果むしろ読みにくいコードになってしまったら本末転倒です。あくまで一つの目安として使い、時には警告メッセージが出ていても総合判断で許容するくらいの運用の方がうまくかと思います。
