173
Help us understand the problem. What are the problem?

posted at

メソッド上にコードの複雑さを表示するVisual Studio拡張機能を作りました

ソースコードの複雑さ、不具合のリスクを定量化したコードメトリクス1というものがあります。
コードメトリクスは計算方法によっていくつか種類があり、メジャーなものだと以下のものがあります。

これらのコードメトリクスを計算し、CodeLensに表示するVisual Studio拡張機能2を作成しました。

以下のようにコードメトリクスをもとにコードが複雑かどうかを判定し、CodeLensに表示してくれます(デフォルトではCognitive Complexityをもとに計算します)。

demo

今のところ対応している言語はC#のみです。

またオプションで%表示から数値表示に切り替えたり、CodeLens上に表示するメトリクスの種類を切り替えたりすることができます。気になった方はぜひこちらからvsixファイルをダウンロードして使ってみてください。

今回は拡張機能の宣伝ついでに、C#におけるCognitive Complexityの改善方法を紹介したいと思います。

Cognitive Complexityって何?

Cognitive Complexityは「コードの読みづらさ」に主眼を当てたコードメトリクスです。コードに以下のような記述があるとメトリクスの値が増え、値が高いほどそのコードは読みづらいと判断します。

  • 条件分岐、ループ処理が多い
  • コードのネストが深い
  • 再帰呼び出し処理がある
  • 条件判定が複雑3

読みづらさを定量化できるので、リファクタリングを始める目安にも使用することができます。
静的解析ツールにもよる4のですが、1メソッドあたり5以下や15以下にするといいそうです。個人的に15を超えたコードはとてもじゃないけど読めないので15以下は必須。でも5以下はそこそこハードル高いので8以下くらいを目指すと良いかもしれません。

ちなみに似たようなメトリクスに上記でも挙げたCyclomatic Complexityがありますが、こちらは「テストの難しさ」に主眼を当てており、コードが複雑かどうかを図ることができません。

例を上げると、以下2つのコードはCyclomatic Complexityは同じですが、Cognitive Complexityは後者が圧倒的に大きいです。

// Cognitive Complexity: 1
// Cyclomatic Complexity: 4
public void Method(int n)
{
    switch (n)
    {
        case 1:
            Console.WriteLine("ichi");
            break;
        case 2:
            Console.WriteLine("ni");
            break;
        case 3:
            Console.WriteLine("san");
            break;
        default:
            Console.WriteLine("ippai");
            break;
    }
}
// Cognitive Complexity: 10
// Cyclomatic Complexity: 4
public int Method(int max)
{
    var total = 0;
    labelA:
    for (var i = 1; i <= max; i++)
    {
        for (var j = 2; j < i; j++)
        {
            if (i % j == 0)
            {
                goto labelA;
            }
        }
        total += 1;
    }

    return total;
}

詳しくは以下の記事を御覧ください。

Cognitive Complexityを下げるには

ネストを減らすこと、適切にメソッドを切り分けることを意識することでCognitive Complexityを下げる=読みやすいコードを書くことができます。

Early returnする

早期リターンとも呼ばれ、返す値が確定あるいは処理が不要になった時点で都度returnするテクニックです。
例えば以下の例だと、入力値のdataがnullの時点で一度returnすることでネストを減らすことができます。

// 改善前(Complexity: 3)
public Data Sample(Data data)
{
    if (data != null)  // +1
    {
        var result = ConvertData(data);
        if (result.IsValid)  // +2
        {
            return result;
        }
    }

    return null;
}

// 改善後(Complexity: 2)
public Data Sample(Data data)
{
    if (data == null)  // +1
    {
        return null;
    }

    var result = ConvertData(data);
    if (!result.IsValid)  // +1
    {
        return null;
    }

    return result;
}

不要なelse節を削除する

ifの最後でreturnやbreak、continueをしている場合、else節の中身は外に出すことができます。

// 改善前(Complexity: 2)
public string Sample(int input)
{
    var data = GetData(input);
    if (data != null)  // +1
    {
        return data.Name;
    }
    else  // +1
    {
        var newData = CreateData(input);
        return newData.Name;
    }
}

// 改善後(Complexity: 1)
public string Sample(int input)
{
    var data = GetData(input);
    if (data != null)  // +1
    {
        return data.Name;
    }

    var newData = CreateData(input);
    return newData.Name;
}

メソッドへ切り出す

単純な方法ですが、ネストが深くなってきたらメソッドに切り出すことを検討してみてください。
Cognitive Complexityはネストの中でネストするほど値が増えるので、メソッドへ切り出すことで大幅な改善が見込めます。

また条件判定が複雑になることでもメトリクスの値が増えるので、判定処理(バリデーション)をメソッドに切り分けるのも手です。
切り分けたところで両メソッドの値の合計は変わりませんが、1メソッドあたりの値を下げることができます。

LINQを使う

foreachの代わりにLINQを使うことでメトリクスの値を改善できます。foreachからLINQへの変換にはいくつかパターンがあるので、それぞれ覚えておくと便利です。

  • foreach内で条件分岐や処理をして、結果をリストに詰め込むパターン
// 改善前(Complexity: 3)
var result = new List<Data>();
foreach (var item in items) // +1
{
    if (IsValid(item)) {  // +2
        var data = new Data(item);
        result.Add(data)
    }
}

// 改善後(Complexity: 0)
var result = items
    .Where(IsValid)
    .Select(item => new Data(item))
    .ToList();
  • 初期値を用意して、それをforeachで逐次更新していくパターン
// 改善前(Complexity: 1)
var result = "initial value";
foreach (var item in items)  // +1
{
    result = SomeProcess(result, item);
}

// 改善後(Complexity: 0)
var result = items.Aggregate(
    (acc, item) => SomeProcess(acc, item),
    "initial value");
  • リストの中のリストを二重のforeachで処理するパターン
// 改善前(Complexity: 3)
var result = new List<Data>();
foreach (var item in items)  // +1
{
    foreach (var id in item.IDs)  // +2
    {
        var data = new Data(id);
        result.Add(data);
    }
}

// 改善後(Complexity: 0)
var result = items
    .SelectMany(item => item.IDs)
    .Select(id => new Data(id))
    .ToList();

ケース ガードを使う

switchの中で更に条件分岐するためにif文を使ってるコードをまれに見かけます。switch文にはケースガードという文法があり、caseでの一致に加えてwhen (条件)を記述することで条件を付与することができます。これを使用することで、ネストを減らしスッキリしたコードになります。

// 改善前(Complexity: 4)
switch (color)  // +1
{
    case Color.Red:
        if (opacity < 1)  // +2
        {
            return "Translucent Red";
        }
        else  // +1
        {
            return "Opaque Red";
        }
    case Color.Blue:
        return "blue";
    default:
        return string.Empty;
}

// 改善後(Complexity: 1)
switch (color)  // +1
{
    case Color.Red when opacity < 1:
        return "Translucent Red";
    case Color.Red:
        return "Opaque Red";
    case Color.Blue:
        return "blue";
    default:
        return string.Empty;
}

最後に

howmessyをインストールすると常にコードの複雑さを意識しながらコーディングすることができます。リアルタイム(保存時)に値が変わって結構楽しいのでぜひ使ってみてください。

  1. "コードメトリックス"や"コードメトリック"といった呼び方もあり、Microsoftはこちらを使用しているみたいです。記事ではGoogleで一番ヒット数の多いコードメトリクスで統一してます。

  2. 実はReSharperにも似たようなプラグインがあるのですが、READMEにあるようなリアルタイム表示がRiderでしか動かない上に、対応しているコードメトリクスが一種類だけだったので自作しました。

  3. 計算方法が少し厄介で、条件式の式木を辿り、現在のノードとLeftのノードで演算子が異なる場合は+1、同じ場合は+0としているみたいです。Leftが条件式でない場合も+1されるので、&&||を使った時点で+1されます。理不尽。

  4. Code Climateでは5以下、Sonar Qubeでは15以下が設定されてるっぽいです。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
173
Help us understand the problem. What are the problem?