こんにちは。
エンジニアの遠藤です。

はじめに

みなさんは、ソースコードを読んでいて、何だか分かりにくいなと思ったことはありませんか?
一方で、このコードは綺麗で読みやすいな、と思うこともありませんか?

最近、コードの読みやすさを数値化する指標がいくつかあることを知りました。
今回はその指標の説明や、計測方法、計測してみた結果などを解説していきます。

コードの複雑さを計測する指標

コードの複雑さを計測する指標がいくつか存在しています。
今回はその中でも「Cyclomatic Complexity」と、「Cognitive Complexity」の2つについて簡単に紹介していきたいと思います。

Cyclomatic Complexity

これは、日本語では、「循環的複雑度」と訳されていることが多く、分岐の多さをカウントする考え方になります。
何も分岐のないコードでは、 Cyclomatic Complexity は「1」となります。

def funcA(number):
    number += 10
    return number

これに、分岐を与えます。

def func_a(number):
    if type(number) == int:
        number += 10
        return number
    return 0

分岐が一つ増えたので、 Cyclomatic Complexity は「2」となります。
基本的には、分岐の数をカウントしていくため、ネストなどは考慮されません。

Cognitive Complexity

続けて、もう一つの指標として Cognitive Complexity を見てみましょう。
こちらは「認知的複雑度」と訳されることが多く、 Cyclomatic Complexity よりも、より人の認識しやすさに重点を置いて数値化されます。
シンプルな関数では、基本的には同程度のスコアとなります。

以下のように、分岐も何もないコードの場合、スコアは「1」となります。

def funcA(number):
    number += 10
    return number

これに、分岐を与えます。

def func_a(number):
    if type(number) == int:
        number += 10
        return number
    return 0

分岐が一つ増えたので、 Cognitive Complexity は「2」となりました。

どっちが良いの?

では、差分が出るようなコードを試してみましょう。

例えば、以下のような権限をチェックする function を考えてみます。
これは実際のプロジェクトで使っているコードを今回の検証用に書き換えたものになります。

def check_permission(token, name, code, record, old_version=False, approve_empty=False):
    if code is None or code == "":  # if + 1, or + 1
        return False
    if not approve_empty and code == "JL":  # if + 1, and + 1
        return False
    if "id" in token:  # if + 1
        if "Item" in record and record["Item"]:  # if + 2 (nest + 1), and + 1
            if old_version:  # if + 3 (nest + 2)
                if name in record["Item"]:  # if + 4 (nest + 3)
                    return record["Item"][name]
                else:  # else + 1
                    return "code" in record["Item"]
            else:  # else + 1
                if name in record["Item"]:  # if + 4 (nest + 3)
                    return record["Item"][name]
    return False

この function の場合数値は以下の通りとなります。

  • Cyclomatic Complexity: 11
  • Cognitive Complexity: 21

コメントで + を記載した箇所は Cognitive Complexity が加算している箇所になります。

Cyclomatic Complexity の場合は、 if, and, or が単純に加算されています。
では、 Cognitive Complexity を減らすように少し書き直してみます。

def check_permission(token, name, code, record, old_version=False, approve_empty=False):
    if code is None or code == "":  # if + 1, or + 1
        return False
    if not approve_empty and code == "JL":  # if + 1, and + 1
        return False
    if "id" not in token:  # if + 1
        return False
    if "Item" not in record:  # if + 1
        return False
    if not record["Item"]:  # if + 1
        return False
    if not old_version:  # if + 1
        if name in record["Item"]:  # if + 2 (nest + 1)
            return record["Item"][name]
    if name in record["Item"]:  # if + 1
        return record["Item"][name]
    return "code" in record["Item"]

この function の場合数値は以下の通りとなります。

  • Cyclomatic Complexity: 11
  • Cognitive Complexity: 11

今回の修正は早期リターンを意識してネストを減らしただけになります。

この変更では、 Cyclomatic Complexity の値は変わりませんでした。
長くプロジェクトを運用していると、アップデートに伴い、どうしても条件が複雑になり if の数は減らせない、ということはあると思います。(if の中身を function 化して、単一の function 内の Complexity を減らすことはできます。)
それでも、 Cognitive Complexity を減らすことで、コードを読む際の脳のメモリ消費が多少減るのではないでしょうか?
ネストが深くなると、その分、今何の条件の中にいるのかを覚えておく必要があり、とてもしんどいです。

ネストの階層を上限を決めるだけでも、コードの複雑さを抑えるのに効果がある理由が分かります。

というわけで、 Cognitive Complexity の数値と、コードの読みやすさは確かにある程度一致しそうだということを感じていただけたのではないでしょうか。

SonarQube

Cognitive Complexity の数値をチェックしてくれるツールはいくつかあるようですが、今回は SonarQubeを使って計測し、その計測結果を見てみようと思います。

SonarQubeって?

SonarQube
SonarSource 社が提供している、ソースコードの品質に関する様々なチェックを行ってくれるツールです。

Docker などで起動でき、以下のようなチェックを包括的に実施してくれます。

他に、 Cloud 版の SonarCloud や IDE にインストールできる SonarLint なども提供されています。

解析してくれる言語は、無料の Community Edition でも、以下の 19 の言語になります。

Java, C#, JavaScript, TypeScript, CloudFormation, Terraform, Docker, Kubernetes, Kotlin, Ruby, Go, Scala, Flex, Python, PHP, HTML, CSS, XML, VB.NET and Azure Resource Manager

https://www.sonarsource.com/products/sonarqube/downloads/

個人的に好きな言語である Obj-C, Swift が無料じゃないのはちょっと悲しいところですね...

それでは今回は、 SonarQube をローカルにインストールして、計測を行なっていきます。

環境構築

docker-compose の準備

今回はこちらの「(ナレッジ)docker-composeでsonarqubeを動かす」という記事を参考に、ローカルで docker-compose を使って SonarQube を立ち上げます。

手順としては、 sonarqube 用のディレクトリを用意し、その中で docker-compose.yml を用意してビルドするだけです。

$ mkdir sonarqube
$ cd sonarqube
$ touch docker-compose.yml
$ vi docker-compose.yml # 以下のテンプレートをコピー&ペースト
version: "3"
services:
  sonarqube:
    image: sonarqube:community
    hostname: sonarqube
    container_name: sonarqube
    depends_on:
      - db
    environment:
      SONAR_JDBC_URL: jdbc:postgresql://db:5432/sonar
      SONAR_JDBC_USERNAME: sonar
      SONAR_JDBC_PASSWORD: sonar
    volumes:
      - sonarqube_data:/opt/sonarqube/data
      - sonarqube_extensions:/opt/sonarqube/extensions
      - sonarqube_logs:/opt/sonarqube/logs
    ports:
      - "9000:9000"
  db:
    image: postgres:13
    hostname: postgresql
    container_name: postgresql
    environment:
      POSTGRES_USER: sonar
      POSTGRES_PASSWORD: sonar
      POSTGRES_DB: sonar
    volumes:
      - postgresql:/var/lib/postgresql
      - postgresql_data:/var/lib/postgresql/data

volumes:
  sonarqube_data:
  sonarqube_extensions:
  sonarqube_logs:
  postgresql:
  postgresql_data:

docker を起動します。

$ docker-compose up -d --build
[+] Running 3/3
 ⠿ Network sonarqube_postgres_default  Created
 ⠿ Container postgresql                Started
 ⠿ Container sonarqube                 Started

正常に起動したら、ブラウザで http://localhost:9000/ にアクセスします。

初期ID/パスワードは admin:admin です。
----------2023-12-02-21.51.22

計測してみよう

プロジェクトの作成

それでは、適当なプロジェクトを作成して、計測してみましょう。
ローカルからコードを localhost:9000 に対してコードをアップロードするので、「Create Project」 → 「Local Project」 を選びます。

Create a local project

----------2023-12-02-21.53.53

「Project display name」, 「Project key」 は分かりやすい任意の名前を入力します。
「Main branch name」は今回使っている Community Edition では特に使えないので、解析予定のリポジトリの default ブランチなどを記載しておくと良いと思います。

Branch analysis についての詳細は以下をご確認ください。
https://docs.sonarsource.com/sonarqube/10.3/analyzing-source-code/branches/branch-analysis/

Set up project for Clean as You Code

----------2023-12-02-22.00.53

解析対象のコードをどう設定しますか?という項目です。
まずは Previous version を選択で良いと思います。

解析の実行

----------2023-12-02-22.03.28

今回は sample-python という名前でプロジェクトを作成してみました。
リポジトリの連携方法を色々と選択することができますが、今回は 「Locally」 を選択します。

Analyze your project

SonarQube に解析結果を送信するための token を発行します。

----------2023-12-02-22.06.05

名称と期限に適当な値を入力して、 「Generate」 します。
すると、 token が発行されます。 (私の local 環境なので、伏せる必要ないのですが...)

----------2023-12-02-22.07.57

「Conitue」 すると、プロジェクトの情報を求められます。

----------2023-12-02-22.10.58

今回はお試しで python をアップロードしてみるので、 「Other」 を選択します。

----------2023-12-02-22.12.38

SonarQube を実行するには、手順に書かれている通り、 local の Mac に sonar-scanner をダウンロードして PATH を通す必要があります。
が、ちょっと面倒なので brew でインストールしてしまいます。

$ brew install sonar-scanner

https://formulae.brew.sh/formula/sonar-scanner

インストールが成功したら、解析したいプロジェクトまで移動し、書かれていたコマンドを実行しましょう。
言語は勝手に SonarQube 側が解釈してくれます。

$ sonar-scanner \
  -Dsonar.projectKey=sample-python \
  -Dsonar.sources=. \
  -Dsonar.host.url=http://localhost:9000 \
  -Dsonar.token=sqp_*****

計測結果を GUI で見てみる

さて、記事の最初で適当に書いた Python プロジェクト(Cognitive Complexity を 「21」の方)を scan してみます。

----------2023-12-02-22.41.00

こちらが結果になります。
バグはなし、脆弱性もなし。
Code Smells が1箇所。
重複もなし。
ただし、 Quality Gate で怒られていますね...

では、本題の Complexity を見てみます。
プロジェクト上部から 「Measures」 を選び、サイドメニューの 「Complexity」 を開きます。

すると、フォルダごと、ファイルごとに Complexity を一覧で表示してくれます。
今回は1ファイルしか用意しなかったので、やや分かりにくいですが。

----------2023-12-02-22.38.04

そのままファイルを選択すると、コードを表示してくれます。

----------2023-12-02-22.42.50

左側が赤くなっているのは、テストのカバレッジで通過していない箇所になります。
SonarQube ではデフォルトで1つの関数の Complexity が 15 以下出ないと警告が出ます。
check_permission の下に赤波線が付いているのは、その警告になります。

それでは、修正後のコードで再度 scan を実行してみましょう。

----------2023-12-02-22.24.10

Quality Gate が Passed になりましたね。
コードの方も見てみましょう。

----------2023-12-02-22.27.56

check_permission の下の赤波線が消えましたね。
ただしまだ、1箇所警告が出ていますので、見てみましょう。

----------2023-12-02-22.50.34

Merge this if statement with the enclosing one.

If 文を一つにまとめた方が良いよって言われています。

詳細を見に行ってみましょう。

どうしてダメなのか?

----------2023-12-02-23.00.11

どうやって直すと良いのか?

----------2023-12-02-23.00.17

などを教えてくれます。
これはすごいし、楽しいですね。

試しに修正してみると、 OK と言ってくれます。

----------2023-12-02-22.56.22

Cognitive Complexity もさらに 1 下がりました。

----------2023-12-02-22.57.27

実際のプロジェクトのコードを参照してみました

それでは、個人的に、結構コードが読みにくいんだよなーと感じているプロジェクトで、検証してみます。
このプロジェクトは Web で バックエンドをPHP, フロントエンドを Vue で書いています。
一つのプロジェクトに複数言語でもチェックしてくるようですが、今回は別々にプロジェクトを作成し、それぞれチェックしてみたいと思います。

ProjectA PHP

PHP 側ですが、以下のような結果になりました。

----------2023-12-02-23.13.54

プロジェクト全体の合計にはあまり意味はありませんので、ファイル単位で見てみます。

----------2023-12-02-23.15.04

スコアが 210 のファイルがあります。
かなりスコアが高いので、数値を減らすか、ある程度ファイルを分割することも検討した方が良いかなと思いました。
ファイルの中身を見てみると、問題が 43 箇所あり、また最も Cognitive Complexity の高い関数は 55 でした。

----------2023-12-02-23.19.34

ここではコードはお見せできませんが、確かに見てみると、一つの関数内で色んな処理やりすぎだなと感じるコードでした。
(そこまでネストが入り組んでいるわけではありませんでした)

ProjectA Vue

続いて、 Vue 側の結果です。

----------2023-12-02-23.28.07

全体合計のスコアとしては PHP よりも高いですが、 LOC も多いため、こちらが極端に複雑というわけではなさそうです。

こちらもスコアが高めのコードがあったので、中を見てみたら、最も Cognitive Complexity の高い関数は 85 となっていました。

----------2023-12-02-23.32.38

ざっくり書くと、以下のような処理がそこそこ続いていたタメでした。
確かにこれを読み解いてメンテしてねって言われたら辛い...

if condition:
    for condition:
        for condition:
            if condition:
                # 処理
            else:
                # 処理

他にもいくつか警告や、複雑だと言われたコードを見てみると、全部が全部というわけではないですが、それなりに信頼性のある指針として、とても有用そうに思えました。

おわりに

私も、10年以上コードを読み書きしていますが、分かりにくいコードに出会うことが頻繁にありますし、自分の書いたコードでも、しばらくすると、さっぱり意図が汲み取れないようなこともあります。

読みやすいコードを書くために、「リーダブルコード」(https://www.amazon.co.jp/dp/4873115655)や、リファクタリング関連の書物を読んだりしてきました。
また、 Linter を使うことである程度、プロジェクト内で一定のルールを設けるようなこともしてきました。

それでも読みにくいコードを書いてしまうことがありましたが、この辺りは感覚的なもので、どうしようもないなとずっと思い込んでいました。
そのため、例えばコードレビューなどで、読みにくいなーと思ってもなかなか指摘し辛い状況がありました。
また、指摘された側としても、納得感を持つことは難しかったでしょうし、どう直せば良いの?というところで迷うところもあったと思います。

今回、実際に計測してみて、ある程度自分の感覚とも一致する部分があり、この解析を一つの指標として、しばらくいくつかのプロジェクトを回して見たいなと思っています。

ただ、この数値を減らす作業は、結構楽しいもので、うっかりすると、数値を減らすことばかりに躍起になってしまい、本来の目的(読みやすいコードで、メンテナンス性を向上させる)から逸れてしまいそうでした。
ですので、あくまでも、指針/指標として、うまく使いこなし、さらなる品質向上とメンテナンス性の向上を目指していきたいと思います。

また、うまく運用に乗せることができた際は CI などの設定方法や、実際の効果などについてもまとめられたらなと思いました。