Python、Reactにおける循環的複雑度制限(Cyclomatic Complexity)のすすめ

BLOG TITLE
Resonalエンジニアリング部

Resonalエンジニアリング部

2025/12/15

コードレビューや初めて触れるコードをみた際、そのコードを読み解くのがしんどいと感じることがしばしばあります。複雑にif文入り組んでいたり、何重にもネストされたループ、一見しただけでは理解できないビジネスロジック。こうしたギョッとする瞬間は、チーム開発においてよく遭遇する問題かと思われます。

こういった問題を未然に防ぐために、循環的複雑度(Cyclomatic Complexity)という指標を使ってコードの複雑さを数値化して、一定の基準を超えないように制限をかける手法があります。今回は、PythonとReact/TypeScriptの両方に循環的複雑度の制限を導入する方法について開設します。

本記事では、循環的複雑度とは何か、なぜ制限を設けるべきなのか、そしてPython(Ruff)とReact/TypeScript(ESLint)での具体的な設定方法について、実際のプロジェクトでの経験を交えて解説していきます。

循環的複雑度とは

循環的複雑度(Cyclomatic Complexity)は、1976年にThomas J. McCabeによって提唱されたソフトウェアメトリクスで、プログラムの制御フローの複雑さを数値化したものです。

計算方法

循環的複雑度は、プログラム内の「独立した実行パスの数」、つまり分岐の数を数値化したものです。計算式としては「分岐の数 + 1」というシンプルなものです。

具体的には、ifやfor文といった制御構文、論理演算子、三項演算子、さらにPythonであれば try / except、JavaScriptであれば switch 文の各 case など、コードの実行経路を分岐させる要素が1つ増えるごとに、循環的複雑度が1ずつ増加していきます。Pythonでのリスト内包表記の中に条件分岐がある場合も同様にカウントされます。

ここで重要なのは、循環的複雑度は単純なコードの行数を測定するものではないという点です。長いコードでも分岐がなければ複雑度は低いままですし、逆に短いコードでも分岐が多ければ複雑度は高くなります。

例えば、以下のような処理は8行ありますが、分岐が全くないため複雑度は1です。

def calculate_total_price(base_price, tax_rate, shipping_fee, handling_fee, insurance_fee):
  subtotal = base_price
  tax = subtotal * tax_rate
  total_with_tax = subtotal + tax
  total_with_shipping = total_with_tax + shipping_fee
  total_with_handling = total_with_shipping + handling_fee
  total_with_insurance = total_with_handling + insurance_fee
  final_total = total_with_insurance

  return final_total

一方で、少し極端ですが、以下のように三項演算子を連鎖させたコードは1行ですが、4つの分岐があるため複雑度は5になります。

def get_discount(user):
  return 0.2 if user.isPremium else 0.15 if user.isGold else 0.1 if user.isSilver else 0.05 if user.isBronze else 0

ただし、実際の業務で書くプログラムのコードは行数と複雑度に相関関係があることも事実です。100行の関数で分岐がゼロということはほぼありえないため、長い関数は結果的に複雑度も高くなる傾向があります。

なぜ循環的複雑度を制限すべきか

循環的複雑度が高い関数に遭遇すると、まず端的にコードを読むことへの負荷が高まります。実行パスが多すぎて、ロジックを追うだけで一苦労です。さらに、そういった関数を修正しようとすると、「ここを変えたら他のところに影響が出るんじゃないか...」という不安がつきまといます。テストを書こうにも、すべてのパスをカバーするテストケースを網羅的に用意するのは困難な可能性が高いです。

循環的複雑度の提唱者であるMcCabeは当初、複雑度10を推奨値としました。これは実務でも一般的な基準となっており、複雑度1〜5であれば理解しやすくテストも容易、6〜10であればやや複雑だが管理可能な範囲、11〜20になるとリファクタリングを推奨、21以上は危険信号で即座に改善が必要とされています。

単一責任の原則(SRP)の実現

循環的複雑度の制限は、単一責任の原則(Single Responsibility Principle)の実現にも大きく貢献します。複雑度が高い関数は、往々にして複数の責任を抱え込んでいることが多いのです。

例えば、ECサイトの注文処理を考えてみましょう。以下のような関数は、一見すると「注文を処理する」という1つの責任しか持っていないように見えますが、実際にはユーザー検証、在庫チェック、割引計算、注文作成、メール送信といった5つもの責任を抱え込んでいます。

def process_user_order(user_id, order_data):
  if not user_id:
    raise ValueError("User ID required")

  user = get_user(user_id)

  if not user:
    raise ValueError("User not found")

  for item in order_data['items']:
    stock = get_stock(item['product_id'])

    if stock < item['quantity']:
      raise ValueError(f"Insufficient stock for {item['product_id']}")

    if user.is_premium and item['price'] > 1000:
      item['price'] *= 0.9
    elif user.coupon_code:
      item['price'] *= 0.95

    order = create_order(user_id, order_data)

    if user.email:
      send_confirmation_email(user.email, order)

    return order

複雑度10という制限を設けると、こういった関数を分割せざるを得なくなります。結果として、以下のように各責任が明確に分離されたコードになります。

def validate_user(user_id):
"""ユーザー検証(複雑度 = 3)"""

  if not user_id:
    raise ValueError("User ID required")

  user = get_user(user_id)

  if not user:
    raise ValueError("User not found")

  return user
def check_stock_availability(items):
"""在庫確認(複雑度 = 2)"""
  for item in items:
    stock = get_stock(item['product_id'])

    if stock < item['quantity']:
      raise ValueError(f"Insufficient stock for {item['product_id']}")
def apply_discounts(user, items):
"""割引適用(複雑度 = 4)"""

  for item in items:
    if user.is_premium and item['price'] > 1000:
      item['price'] *= 0.9
    elif user.coupon_code:
      item['price'] *= 0.95

    return items
def process_user_order(user_id, order_data):
"""注文処理のオーケストレーション(複雑度 = 2)"""
  user = validate_user(user_id)
  check_stock_availability(order_data['items'])
  order_data['items'] = apply_discounts(user, order_data['items'])
  order = create_order(user_id, order_data)

  if user.email:
    send_confirmation_email(user.email, order)

  return order

こういったリファクタリングにより、各関数が単一の責任だけを持つようになり、テストも書きやすくなります。例えば validate_user だけを個別にテストできるため、ユーザー検証のロジックが正しく動作するかを確認するのが容易になります。

バグ発生率の低減(実証データ)

循環的複雑度とバグ発生率の相関関係については、実際にデータとして示されています。

少し古いですが、まずThomas J. McCabeが1976年にIEEE Transactions on Software Engineeringで「A Complexity Measure」という論文を発表し、循環的複雑度という指標を提唱しました。

その後、2006年にはMicrosoft Researchが自社の5つの大規模製品(Internet Explorer、DirectXなど)を対象に行った調査では、複雑度の高いモジュールに欠陥が集中するという結果が得られています。

Nagappan, Ball & Zeller, "Mining Metrics to Predict Component Failures", ICSE 2006

これらの研究から、複雑度を10以下に保つことでバグ発生率を大幅に削減できることが実証されています。単なる経験則ではなく、データに基づいた明確な根拠があります。

Python(Ruff)での設定

今回、Pythonでは、Ruffの mccabe プラグインを使用して循環的複雑度を制限します。RuffはRust製の超高速なPython用リンター&フォーマッターです。従来のツール(Flake8、isort、Black等)を1つに統合し、10〜100倍高速に動作します。

pyproject.toml の設定

いくつかの設定が含まれていますが、今回はC90のmccabeの設定にのみ注目していただければ問題ありません。

[tool.ruff.lint]
select = [
  "E",    # pycodestyle errors
  "F",    # pyflakes
  "W",    # pycodestyle warnings
  "I",    # isort (import sort)
  "N",    # pep8-naming (命名規則チェック)
  "B",    # flake8-bugbear (バグを引き起こしやすいパターンの検出)
  "C4",   # flake8-comprehensions (リスト内包表記の最適化)
  "C90",  # mccabe (循環的複雑度チェック)
]

[tool.ruff.lint.mccabe]
# 循環的複雑度の閾値(10を超えるとwarning)
max-complexity = 10

Pythonでの具体的な効果:ネストの削減

Pythonでは特にインデントレベルが可視化されるため、複雑度制限により自然とネストが浅くなり、可読性が向上する効果が顕著です。これは筆者の個人的な意見ですが、ネストレベルが一定を超えたPythonのコードは見るのが辛いです。以前に、常にインデントが浅いコードをあげてくる知人がおり、その人のコードを見て感動したおぼえがあります。

例えば、配送料を計算する以下のような関数を考えてみましょう。配送料だけにフォーカスしているので、そこまで見づらいわけではありませんが、それでもネストレベルもある一定まで深くなっており、そこまで見やすいコードとは言えません。

リファクタリング前(複雑度 = 14)

def calculate_shipping_fee(order):
    """注文の配送料を計算する"""
    base_fee = 500
    
    # 購入金額による送料無料(会員のみ)
    if order.get('member_type'):
        if order['total_amount'] >= 10000:
            return 0
        elif order['total_amount'] >= 5000:
            # 5000円以上は半額
            base_fee = 250
    
    # 地域による追加料金
    if order['region'] in ['北海道', '沖縄', '離島']:
        if order['region'] == '北海道':
            base_fee += 500
            # 冬季は更に追加
            if order.get('season') == '冬':
                base_fee += 200
        elif order['region'] == '沖縄':
            base_fee += 800
        else:  # 離島
            base_fee += 1000
    
    # 重量と配送オプションの組み合わせ
    if order['weight'] > 5:
        if order.get('express_delivery'):
            # 重量物の特急配送は高額
            if order['weight'] > 10:
                base_fee += 800
            else:
                base_fee += 500
        else:
            # 通常配送の重量追加料金
            if order['weight'] > 10:
                base_fee += 300
            else:
                base_fee += 150
    elif order.get('express_delivery'):
        # 軽量物の特急配送
        base_fee += 300
    
    # 会員割引
    if order.get('member_type') == 'premium':
        base_fee *= 0.8
    elif order.get('member_type') == 'standard':
        base_fee *= 0.9
    
    return int(base_fee)

この関数は動作としては問題ありませんが、読みづらく、テストケースも複雑になります。複雑度10という制限を設けると、以下のようなリファクタリングが必要になります。

リファクタリング後(複雑度 = 2)

def calculate_shipping_fee(order):
    """注文の配送料を計算する"""
    # 会員の送料無料判定
    if is_free_shipping(order):
        return 0
    
    base_fee = get_base_fee(order)
    base_fee += get_region_fee(order)
    base_fee += get_delivery_fee(order)
    base_fee = apply_member_discount(base_fee, order.get('member_type'))
    
    return int(base_fee)


def is_free_shipping(order):
    """送料無料の条件を満たすか判定"""
    if not order.get('member_type'):
        return False
    return order['total_amount'] >= 10000


def get_base_fee(order):
    """基本配送料を計算"""
    if order.get('member_type') and order['total_amount'] >= 5000:
        return 250
    return 500


def get_region_fee(order):
    """地域による追加料金を計算"""
    region = order['region']
    
    if region == '北海道':
        fee = 500
        if order.get('season') == '冬':
            fee += 200
        return fee
    
    region_fees = {
        '沖縄': 800,
        '離島': 1000,
    }
    return region_fees.get(region, 0)


def get_delivery_fee(order):
    """配送方法と重量による料金を計算"""
    weight = order['weight']
    is_express = order.get('express_delivery', False)
    
    if weight <= 5:
        return 300 if is_express else 0
    
    # 重量物の配送料金
    if is_express:
        return 800 if weight > 10 else 500
    else:
        return 300 if weight > 10 else 150


def apply_member_discount(fee, member_type):
    """会員割引を適用"""
    discounts = {
        'premium': 0.8,
        'standard': 0.9,
    }
    discount_rate = discounts.get(member_type, 1.0)
    return fee * discount_rate

関数こそ増えてしまってはいますが、このリファクタリングにより、ネストが4段階から2段階以下に削減され、コードの意図が明確になりました。条件が関数ごとにわかりやすくなり、テストのしやすさも向上していいます。

React/TypeScript(ESLint)での設定

フロントエンドでは、ESLintの complexity ルールと、SonarJSプラグインの cognitive-complexity を組み合わせて使用します。

.eslintrc.js の設定

module.exports = {
  plugins: ["sonarjs", "security", "promise"],
  extends: [
    "plugin:security/recommended-legacy",
    "plugin:promise/recommended",
  ],
  rules: {
    // 循環的複雑度の設定(段階的に厳格化)
    complexity: ["warn", 10], // 初期段階: 警告から開始、将来的に5まで厳格化予定
    "sonarjs/cognitive-complexity": ["error", 15], // SonarJSの認知的複雑度

    // コード品質ルール
    "sonarjs/no-duplicate-string": ["warn", { threshold: 3 }], // 重複文字列の検出
    "sonarjs/no-identical-functions": "error", // 同一関数の重複検出
    "sonarjs/no-nested-template-literals": "warn", // ネストしたテンプレートリテラルの警告
  },
};

Reactでの課題:Props Drilling

Reactコンポーネントでは、循環的複雑度の削減とProps Drillingがトレードオフの関係になることが多いです。複雑度を下げること重要ではありますが、Props Drillingを生むだけの過度な分割は避けるべきです。

改善前(複雑度: 12、Props Drilling: なし)

interface DashboardProps {
  userId: string;
}

function Dashboard({ userId }: DashboardProps) {
  const [userData, setUserData] = useState<User | null>(null);
  const [orders, setOrders] = useState<Order[]>([]);
  const [notifications, setNotifications] = useState<Notification[]>([]);
  
  useEffect(() => {
    fetchUserData(userId).then(setUserData);
    fetchOrders(userId).then(setOrders);
    fetchNotifications(userId).then(setNotifications);
  }, [userId]);

  const handleOrderCancel = (orderId: string) => {
    cancelOrder(orderId).then(() => {
      setOrders(orders.filter(o => o.id !== orderId));
    });
  };

  const handleNotificationRead = (notificationId: string) => {
    markAsRead(notificationId).then(() => {
      setNotifications(notifications.map(n => 
        n.id === notificationId ? { ...n, read: true } : n
      ));
    });
  };

  const handleProfileUpdate = (data: UserData) => {
    updateProfile(data).then(() => {
      setUserData({ ...userData, ...data });
    });
  };

  return (
    <div className="dashboard">
      {/* ユーザープロフィール */}
      <div className="profile-section">
        <h2>{userData?.name}</h2>
        <p>{userData?.email}</p>
        <img src={userData?.avatar} alt="avatar" />
        <button onClick={() => handleProfileUpdate({ name: 'New Name' })}>
          Update Profile
        </button>
      </div>

      {/* 注文一覧 */}
      <div className="orders-section">
        <h3>Your Orders</h3>
        {orders.map(order => (
          <div key={order.id}>
            <span>{order.productName}</span>
            <span>{order.status}</span>
            {order.status === 'pending' && (
              <button onClick={() => handleOrderCancel(order.id)}>
                Cancel
              </button>
            )}
          </div>
        ))}
      </div>

      {/* 通知一覧 */}
      <div className="notifications-section">
        <h3>Notifications</h3>
        {notifications.map(notification => (
          <div 
            key={notification.id}
            className={notification.read ? 'read' : 'unread'}
          >
            <span>{notification.message}</span>
            {!notification.read && (
              <button onClick={() => handleNotificationRead(notification.id)}>
                Mark as Read
              </button>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

改善後(各コンポーネントの複雑度: 2-4、Props Drilling: あり)

// メインコンポーネント
interface DashboardProps {
  userId: string;
}

function Dashboard({ userId }: DashboardProps) {
  const [userData, setUserData] = useState<User | null>(null);
  const [orders, setOrders] = useState<Order[]>([]);
  const [notifications, setNotifications] = useState<Notification[]>([]);
  
  useEffect(() => {
    fetchUserData(userId).then(setUserData);
    fetchOrders(userId).then(setOrders);
    fetchNotifications(userId).then(setNotifications);
  }, [userId]);

  const handleOrderCancel = (orderId: string) => {
    cancelOrder(orderId).then(() => {
      setOrders(orders.filter(o => o.id !== orderId));
    });
  };

  const handleNotificationRead = (notificationId: string) => {
    markAsRead(notificationId).then(() => {
      setNotifications(notifications.map(n => 
        n.id === notificationId ? { ...n, read: true } : n
      ));
    });
  };

  const handleProfileUpdate = (data: UserData) => {
    updateProfile(data).then(() => {
      setUserData({ ...userData, ...data });
    });
  };

  return (
    <div className="dashboard">
      <UserProfileSection 
        userData={userData}
        onUpdate={handleProfileUpdate}
      />
      <OrdersSection 
        orders={orders}
        onCancel={handleOrderCancel}
      />
      <NotificationsSection 
        notifications={notifications}
        onRead={handleNotificationRead}
      />
    </div>
  );
}

// ユーザープロフィールコンポーネント
interface UserProfileSectionProps {
  userData: User | null;
  onUpdate: (data: UserData) => void;
}

function UserProfileSection({ userData, onUpdate }: UserProfileSectionProps) {
  return (
    <div className="profile-section">
      <h2>{userData?.name}</h2>
      <p>{userData?.email}</p>
      <img src={userData?.avatar} alt="avatar" />
      <button onClick={() => onUpdate({ name: 'New Name' })}>
        Update Profile
      </button>
    </div>
  );
}

// 注文一覧コンポーネント
interface OrdersSectionProps {
  orders: Order[];
  onCancel: (orderId: string) => void;
}

function OrdersSection({ orders, onCancel }: OrdersSectionProps) {
  return (
    <div className="orders-section">
      <h3>Your Orders</h3>
      {orders.map(order => (
        <OrderItem 
          key={order.id}
          order={order}
          onCancel={onCancel}
        />
      ))}
    </div>
  );
}

// 注文アイテムコンポーネント
interface OrderItemProps {
  order: Order;
  onCancel: (orderId: string) => void;
}

function OrderItem({ order, onCancel }: OrderItemProps) {
  return (
    <div>
      <span>{order.productName}</span>
      <span>{order.status}</span>
      {order.status === 'pending' && (
        <button onClick={() => onCancel(order.id)}>
          Cancel
        </button>
      )}
    </div>
  );
}

// 通知一覧コンポーネント
interface NotificationsSectionProps {
  notifications: Notification[];
  onRead: (notificationId: string) => void;
}

function NotificationsSection({ notifications, onRead }: NotificationsSectionProps) {
  return (
    <div className="notifications-section">
      <h3>Notifications</h3>
      {notifications.map(notification => (
        <NotificationItem 
          key={notification.id}
          notification={notification}
          onRead={onRead}
        />
      ))}
    </div>
  );
}

// 通知アイテムコンポーネント
interface NotificationItemProps {
  notification: Notification;
  onRead: (notificationId: string) => void;
}

function NotificationItem({ notification, onRead }: NotificationItemProps) {
  return (
    <div className={notification.read ? 'read' : 'unread'}>
      <span>{notification.message}</span>
      {!notification.read && (
        <button onClick={() => onRead(notification.id)}>
          Mark as Read
        </button>
      )}
    </div>
  );
}

小規模な分割(2-3階層)はProps Drillingで十分対応可能であり、大規模な分割はContext APIや状態管理ライブラリを検討してもよいでしょう。ただし、状態管理ライブラリの導入は複雑度を増す可能性もある上に、その導入自体が開発の負荷を上げる可能性もあります。メトリクスを盲目的に追うのではなく、コードの可読性と保守性のバランスを取ることが重要です。

まとめ

循環的複雑度の制限を導入することで、可読性や保守性が向上します。一部の研究データが示すようにバグが混在されてしまう確率も減ります。PythonやTypeScriptだけでなく、Goなどその他の言語でもCyclomatic Complexityによるlinterは存在しておりますのでまだ試したことのない方は一度試してみてください。

導入時の注意点としては、いきなり厳しい制限を設けるのではなく、まず現状を把握してから段階的に厳格化していくことが重要です。また、なぜこの制限を設けるのかをチーム全体で理解し、合意を得ることも欠かせません。どうしても複雑な処理が必要な場合は、eslint-disable-next-line などで部分的に無効化することも可能です。加えて、Pre-commitフックで自動チェックを組み込むことで、機械的に品質を担保でき、人が指摘するまでもなくチームで開発する際にも守らないといけないルールとして整備されます。

循環的複雑度の制限は、コード品質を守る「番人」として機能します。最初は煩わしく感じるかもしれませんが、長期的には確実にプロジェクトの健全性を保つ助けとなるはずです。

Shareこのエントリーをはてなブックマークに追加