ブログ 約6分

#includeの危険性:シニアの監視なしにC++プリプロセッサがアーキテクチャを破壊する仕組み【Blenderケーススタディ】

Share this article
#includeの危険性:C++プリプロセッサがアーキテクチャを破壊する仕組み

Blenderは世界で最も成功した高性能オープンソース3Dスイートの一つです。そのコアモジュールに対してCppDepend分析を実行すると、驚くほど強力な全体的な保守性評価が明らかになります。16,000を超える型と136,000のメソッドを抱える巨大なコードベースとしては驚異的な成果です。

優れた全体的な保守性評価を示すBlenderのCppDepend品質ダッシュボード

しかし、コアモジュール間の構造的な関係を調べると、Dependency Structure Matrix(DSM)ははるかに複雑な状況を描き出し、広範囲にわたる依存関係サイクルを浮き彫りにします:

bf_blenkernel周辺に広がる依存関係サイクルを示すBlenderコアモジュールの依存構造マトリクス

CppDependでC++ソフトウェアアーキテクチャを評価する場合、Dependency Structure Matrix(DSM)は依存関係の健全性について否定できない証拠を提供します。適切に構造化されたレイヤードシステムでは、依存関係は一方向に流れ、主対角線の片側にのみセルが配置されたクリーンな三角行列になります。

しかし、Blenderのコアエンジンの行列を調べると、bf_blenkernelの周囲に印象的な視覚的パターンが現れます。

世界クラスのエンジニアが構築したプロジェクトに、なぜこれほど多くのアーキテクチャ上のサイクルが生じるのでしょうか?

根本的な原因は開発者の不注意ではありません。犯人はCおよびC++のレガシーなプリプロセッサモデル、すなわち#includeディレクティブです。

何十年もの間、モダンなプログラミング言語は明示的なモジュールシステム、名前空間、明示的なエクスポート制御を使って依存関係グラフを管理してきました。一方、CとC++は1970年代のプリプロセッサからテキスト置換モデルを継承しました:#includeディレクティブです。

#includeディレクティブの最大の強みは、同時に最も危険な欠陥でもあります。それは極端なシンプルさと柔軟性です。テキスト置換により、開発者は厳格なコンパイル時の強制なしにコードベースのどこにでも依存関係を素早く取り込めますが、この無制限の自由はプロジェクトの拡大とともにすぐに罠となります。厳密なアーキテクチャ上の監視がなければ、#includeによって、モジュールを静かに単一の密結合モノリスへと織り込む微妙な依存関係サイクルを容易に持ち込んでしまいます。大規模なC++プロジェクトでは、こうした循環的なヘッダー連鎖が時とともに膨大な技術的負債へと累積し、ビルド時間を指数関数的に増加させ、ユニットテスト能力を麻痺させ、リファクタリングを地雷原に変えます。その結果、このようなコードベースの保守と進化はもはや日常的なエンジニアリング作業ではなくなり、言語コンパイラが果たせない構造的境界をシニア開発者が手動で強制し続ける、絶え間ない警戒が必要となります。

テキストインクルージョンの構造的欠陥

#includeがなぜアーキテクチャを損なうのかを理解するために、クラス定義やモジュール性との相互作用を考えてみましょう。

1. インクルージョンは推移的で漏洩する

FileA.hがFileB.hをインクルードし、FileB.hがFileC.hをインクルードすると、FileA.hはFileC.hに間接的に依存します。FileBの内部詳細がFileAに漏れ出します。時間が経つにつれ、開発者はモジュールが実際に何に依存しているかを見失い、暗黙的で隠れた依存関係の網が生まれます。

2. 物理構造が論理アーキテクチャを支配する

クリーンな設計では、インターフェースの境界が依存関係を決定します。#includeでは、物理的なファイル構成が構造上の決定を強制します。クラスAがB.h内で定義された単一のenumを必要とする場合、AはB.hをインクルードしなければならず、B.hが依存する他のすべての型、ポインタ、テンプレートヘッダーまで付随してきます。

3. 循環依存は物理的な必要性によって強制される

C++はレイアウトサイズを決定するために完全な型定義を必要とするため、開発者は前方宣言(class X;)で十分な場面でもヘッダーファイルに#includeディレクティブを置くことが頻繁にあります。2つのヘッダーが互いの完全な定義を必要とした瞬間、プリプロセッサは循環インクルージョンループを作り出し、型欠落エラー、インクルードガードの技巧、脆弱なヘッダー順序へとつながります。

詳細分析:bf_blenkernel ↔ bf_bmesh サイクル

Blenderにおいて、#includeのメカニズムが構造的な依存ループを作り出す例が、コアカーネルモジュール(bf_blenkernel)とメッシュ編集システム(bf_bmesh)の間に存在します。

クリーンなレイヤードアーキテクチャでは、bf_bmesh(上位レベルのインタラクティブ編集システム)がbf_blenkernel(下位レベルのデータ構造と数学カーネル)に依存すべきです。しかし、#includeディレクティブは構造的なゲートキーピングなしに境界を越えることを容易にするため、bf_blenkernelはBMeshデータ構造を直接参照しています。

bf_bmeshのコールグラフは、依存関係(使用するモジュール)を青、被依存関係(使用されるモジュール)を緑で色分けした、驚くほどクリーンなレイヤードアーキテクチャを示しています。注目すべき例外は、bf_blenkernelとの双方向サイクルです。

bf_blenkernelとの双方向サイクルが強調されたbf_bmeshのコールグラフ

カーネルがメッシュ編集モジュールから消費している正確な型とメソッドを特定するために、次のCQLinqクエリを実行できます:

bf_blenkernelがbf_bmeshから消費している型を特定するCQLinqクエリ

例として、bf_blenkernel(armature.cc)内のこの関数で使用されているBMesh構造体を確認してみましょう:

bf_blenkernelのarmature.cc内で使用されているBMesh構造体

これがアーキテクチャ上のサイクルを生む理由

  • 具象型の直接使用:カーネル関数はBKE_editmesh_bmesh_get(...)を呼び出してconst BMesh *bmへのポインタを取得し、bm->vdataに直接アクセスします。
  • 強制されるヘッダーインクルージョン:内部メンバーbm->vdataにアクセスするため、カーネルファイルは"bmesh.h"(または"bmesh_class.h")をインクルードしなければなりません。
  • 逆方向の依存:同時に、bf_bmeshのヘッダーとソースファイルは、基本データ型(Object、ID、CustomData)、メモリ管理、数学ヘルパーのためにカーネルヘッダー(BKE_*.h)をインクルードします。

これにより、厳格な双方向の依存関係サイクルが形成されます。bf_blenkernelはbf_bmeshから独立してコンパイル、ユニットテスト、再利用することができません。

リファクタリングしてサイクルを解消する方法

このサイクルを解消し、クリーンな一方向の階層(bf_bmesh → bf_blenkernel)を強制するには、カーネル内部のBMeshへの依存を反転または抽象化する必要があります。

解決策1:不透明な抽象化/CustomDataオフセットの抽出

BKE_armature_deform_coords_with_editmeshは、単一のintオフセットcd_dvert_offsetを抽出するためだけにbm->vdataにアクセスしていることに注目してください。このラッパー内で実際のメッシュトポロジー操作は行っていません。

blenkernel内でBMesh*を渡したり取得したりする代わりに、cd_dvert_offsetをカーネル関数に直接渡すか、関数ポインタ/コールバックによる抽象化を使用します:

Bmeshヘッダー不要、プレーン型とオフセットを使ったクリーン化されたカーネルシグネチャ

bf_bmesh内の呼び出し側(bmesh.hとBKE_armature.hの両方をインクルードすることがアーキテクチャ上妥当な場所)がcd_dvert_offsetを抽出してカーネルを呼び出します。bf_blenkernelはBMeshの存在を知る必要がなくなります。

解決策2:コールバックまたはデリゲートによる依存性逆転

複雑な操作中にbf_blenkernelがBMeshデータへの動的アクセスを必要とする場合は、blenkernel内に抽象インターフェースまたは関数デリゲートを定義します:

// BKE_armature.h内(bf_blenkernel)
using BMeshOffsetGetter = std::function<int(const Object &ob)>;

// BMeshロジックは上位レイヤーから注入され、
// blenkernelは具体的なBMeshレイアウトを知らない

高レベルのアーキテクチャ上の厳密さ:GRASPパターンと高凝集

C++のプリプロセッサメカニズムによって持ち込まれたヘッダーレベルの依存ループにもかかわらず、Blenderの基盤となるコード設計は非常に優れたエンジニアリングです。クラス階層とモジュール構成を詳しく見ると、GRASP(General Responsibility Assignment Software Patterns)とモダンなドメイン駆動設計の原則に厳密に従っていることがわかります:

ポリモーフィズムと抽象化:Blenderは抽象インターフェースクラス(bContext、スペース型定義、オペレーター抽象化)を多用し、高レベルのワークフローを実装詳細から切り離しています。

Blenderの抽象インターフェース型を一覧表示するCQLinqクエリ

高凝集:各モジュールは鋭いドメイン焦点を維持しています。bf_bmeshは低レベルのメッシュトポロジーを扱い、bf_nodesは実行グラフを管理し、bf_gpuはハードウェア抽象化を分離しています。これらのモジュール内の内部データ構造は高い機能的凝集性を示しています。実際、非凝集と見なされる型は3%未満です:

Blenderの型の3%未満しか非凝集でないことを示すLCOMクエリ

Protected Variation:コアサブシステムは、明確に定義された内部APIを通じて基盤となるプラットフォームやハードウェアの変動から自己を隔離し、低レベルのグラフィックスおよびOS抽象化をクリーンに保っています。

モジュール間の循環依存の存在は、ずさんな設計の症状ではなく、テキストベースの#includeディレクティブを使って数百万行のC/C++コードベースをスケールさせることの不可避的な副作用です。言語レベルのモジュール境界がなければ、凝集性が高くインターフェース駆動のアーキテクチャでさえ、最終的には推移的なヘッダー漏洩に屈してしまいます。

C++開発者のためのアーキテクチャ上の教訓

#includeメカニズムは永続的な教訓を教えてくれます:コンパイラがアーキテクチャ上の境界を強制しないとき、エントロピーが勝つのです。

自分のプロジェクトで#includeによるアーキテクチャ上の損害を軽減するには:

  • 前方宣言を優先する:ヘッダーファイルでは、継承や値インスタンスの直接保持が必要な場合を除き、#include "MyClass.h"よりも常にclass MyClass;を優先してください。
  • レイヤーリングルールを強制する:(CQLinqを備えたCppDependのような)静的解析ツールを使い、下位モジュールが上位ヘッダーをインクルードした場合にビルドを失敗させる品質ゲートを設定してください。
  • C++20モジュールへの移行:可能な場合は#includeをimportに置き換えてください。明示的なエクスポートが強制され、マクロ漏洩が回避され、テキストインクルージョンサイクルが完全に排除されます。
この記事をシェアする