更新履歴(4件・最終更新 2026年08月02日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
- 外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
- 判断表を使うのに必要な用語(ABI、呼び出し規約、vtable、エクスポート序数など)を冒頭の表に定義しました。あわせて症状から疑うべき行を引ける逆引き表、既定実装を持つインターフェースメンバを追加できる条件、3層の関係図を追加しています。
- 本文中の関連記事へのリンクの文言が、リンク先の現在のタイトルと食い違っていたのを、実際のタイトルに揃えました。本文の内容は変えていません。
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.21590060)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「DLL・COMインターフェースの後方互換性 ── どの変更が呼び出し側を壊すのかの判断表」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21590060 https://staging.comcomponent.com/blog/dll-com-interface-backward-compatibility/
- DOI(最新版)
- 10.5281/zenodo.21590060
- DOI(この版)
- 10.5281/zenodo.21733025
「この修正、DLLを差し替えるだけでいいですか?それとも呼び出し側も再ビルドですか?」──複数のアプリから参照される共通DLLやCOMコンポーネントを保守していると、リリースのたびにこの質問に答えることになります。答えを間違えると、客先で動いている古いEXEが起動しなくなったり、もっと悪いことに、起動はするのに計算結果が静かに変わったりします。
厄介なのは、この判断が「なんとなく危なそう」という感覚で行われがちなことです。実際には、どの変更が互換性を壊すかはほぼ機械的に判定できます。ネイティブDLLにはエクスポートと呼び出し規約のルールがあり、COMには「インターフェースは不変」という明文化された鉄則があり1、.NETにはMicrosoft自身が.NETライブラリの開発で使っている互換性変更ルールの一覧があります2。
当ブログでは「COM / ActiveX / OCX とは何か」でCOMの基本を、「COM とは何か - Windows COM の設計が今でも美しい理由」でその設計思想を解説しました。この記事では、DLL・COM・.NETアセンブリのそれぞれについて「どの変更が呼び出し側を壊すのか」を判断表の形で整理し、壊さざるを得ないときの手順までまとめます。
この記事で使う用語
判断表を読むには、バイナリレベルの用語がいくつか必要になります。詳しくは各章で扱いますが、先に1行ずつ押さえておくと読み進めやすくなります。
| 用語 | 1行の意味 |
|---|---|
| ABI(Application Binary Interface) | コンパイル済みのバイナリ同士が守る取り決め。引数の渡し方、構造体のメモリ配置、シンボル名の付け方など、ソースコードではなく機械語のレベルの契約 |
| 呼び出し規約(calling convention) | ABIの一部。引数をレジスタとスタックのどちらで、どの順に渡すか、積んだ引数を呼び出し側と呼ばれた側のどちらが片付けるかの取り決め(__cdecl / __stdcall など) |
| vtable(仮想関数テーブル) | 関数ポインタを決まった順番で並べた表。COMインターフェースの実体であり、呼び出し側は「何番目のスロットか」という位置で目的のメソッドを呼ぶ |
| エクスポート序数(ordinal) | DLLのエクスポートテーブルで各関数に振られる番号。呼び出し側は関数名の代わりに、この番号でインポートすることもできる |
| IID / CLSID / ProgID | 順に、COMインターフェース(契約)の識別子、実装クラスの識別子、CLSIDに対応付けられた人間可読な別名(4.1節) |
| 強い名前(strong name) | .NETアセンブリを「名前+バージョン+カルチャ+公開鍵トークン」と署名で一意に識別する仕組み |
| バインディングリダイレクト | .NET Frameworkで、呼び出し側が要求するアセンブリバージョンを、実際にロードする別のバージョンへ読み替えさせる構成ファイルの設定 |
1. まず結論
- 互換性にはバイナリ互換(再ビルドなしで動く)・ソース互換(再ビルドすれば動く)・動作互換(振る舞いが変わらない)の3層があります。「再ビルド不要=安全」ではなく、動作互換まで含めて判断する必要があります。3
- ネイティブDLLの基本線は、「エクスポートの追加は安全、既存エクスポートの変更・削除は破壊的」です。関数シグネチャ・呼び出し規約・構造体レイアウトはバイナリ契約そのものです。
- COMのインターフェースは公開したら不変(immutable)です。公開後のメソッド追加・削除・並べ替えは仕様違反であり、変更は新しいIIDを持つ新インターフェース(IFoo→IFoo2)として追加します。41
- VB6/VBAクライアントは事前バインディングでvtable上の位置を焼き込むため、インターフェースのレイアウト変更で最も壊れやすい呼び出し側です。
- .NETでは「public APIの何が破壊的か」がMicrosoftの互換性変更ルールとして公開されており、メソッド削除やシグネチャ変更だけでなく、仮想化(virtual追加)やパラメータ名の変更まで破壊的変更に分類されています。2
- セマンティックバージョニングは「破壊的変更ならメジャーを上げる」という規約ですが、何が破壊的変更かの定義を宣言して初めて機能します。5 本記事の判断表をその定義として使えます。
- 互換性を壊さざるを得ないときは、新旧並行提供→非推奨期間→呼び出し側の棚卸し→廃止の順で進めます。いきなり差し替えない、が原則です。
この記事の知識マップ
この記事は、DLL・COM・.NETという3つの技術それぞれで、どの変更が呼び出し側を壊すのかを機械的に判定する方法を整理する。ネイティブDLLはエクスポートテーブルと呼び出し規約というABIの契約で決まり、cbSize慣習で構造体を拡張可能にできる。COMインターフェースはvtableというバイナリレイアウトゆえに公開後は不変とされ、変更は新しいIIDを持つ新インターフェースとして追加してQueryInterfaceで判別させる。事前バインディングのVB6/VBAクライアントは特にレイアウト変更で壊れやすい。.NETにはMicrosoft自身が使う互換性変更ルールがあり、バイナリ・ソース・動作互換の3層で変更を分類し、semverや強い名前のAssemblyVersion管理と組み合わせて運用する。
flowchart LR
accTitle: DLL・COMインターフェースの後方互換性の知識マップ
accDescr: ネイティブDLLのABI・エクスポート序数・cbSize慣習、COMインターフェースのvtable・IID・QueryInterfaceによる公開後不変の原則とVB6事前バインディングとの関係、そして.NETの互換性変更ルールがバイナリ・ソース・動作互換の3層およびAssemblyVersion・semverとどうつながるかを示す図
dll["DLL(ダイナミックリンクライブラリ)"]
com_interface["COMインターフェース"]
dotnet_compatibility_change_rules[".NETの互換性変更ルール"]
abi["ABI(Application Binary Interface)"]
calling_convention["呼び出し規約(calling convention)"]
export_ordinal["エクスポート序数(ordinal)"]
cbsize_convention["cbSize慣習"]
binary_compatibility["バイナリ互換"]
com["COM(コンポーネントオブジェクトモデル)"]
vtable["vtable(仮想関数テーブル)"]
iid["IID(インターフェース識別子)"]
queryinterface["QueryInterface"]
progid["ProgID(Programmatic Identifier)"]
clsid["CLSID(Class ID)"]
vb6["Visual Basic 6.0(VB6)"]
vb6_early_binding["VB6/VBAの事前バインディング"]
type_library["型ライブラリ(TLB)"]
dotnet[".NET(Core以降)"]
source_compatibility["ソース互換"]
behavioral_compatibility["動作互換"]
default_interface_member["既定インターフェースメンバ(DIM)"]
dotnet_framework[".NET Framework"]
strong_name["強い名前(strong name)"]
assembly_version["AssemblyVersion"]
binding_redirect["バインディングリダイレクト"]
semver["セマンティックバージョニング(semver)"]
package_validation_apicompat["Package Validation / ApiCompat"]
dll -->|"利用する"| abi
abi -->|"利用する"| calling_convention
dll -->|"利用する"| export_ordinal
dll -.->|"利用する"| cbsize_convention
dll -.->|"前提とする"| binary_compatibility
com -->|"利用する"| com_interface
com_interface -->|"利用する"| vtable
com_interface -->|"前提とする"| iid
com_interface -->|"前提とする"| queryinterface
com_interface -->|"前提とする"| binary_compatibility
progid -->|"利用する"| clsid
clsid -.->|"前提とする"| com_interface
vb6 -.->|"利用する"| vb6_early_binding
vb6_early_binding -->|"前提とする"| vtable
vb6_early_binding -->|"前提とする"| type_library
com_interface -.->|"両立しない"| vb6_early_binding
dotnet -->|"利用する"| dotnet_compatibility_change_rules
dotnet_compatibility_change_rules -->|"利用する"| binary_compatibility
dotnet_compatibility_change_rules -->|"利用する"| source_compatibility
dotnet_compatibility_change_rules -->|"利用する"| behavioral_compatibility
dotnet_compatibility_change_rules -.->|"利用する"| default_interface_member
default_interface_member -->|"両立しない"| dotnet_framework
dotnet -.->|"利用する"| strong_name
strong_name -->|"利用する"| assembly_version
assembly_version -.->|"前提とする"| binding_redirect
semver -.->|"前提とする"| dotnet_compatibility_change_rules
dotnet_compatibility_change_rules -->|"で確認できる"| package_validation_apicompat
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全27件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
2. 互換性の3層 ── 誰が、いつ壊れるのか
「後方互換性」と一言で言われるものは、実際には3つの層に分かれます。.NETの公式ドキュメントでも、破壊的変更はソース互換・バイナリ互換・動作互換の観点で分類されています。3
| 層 | 意味 | 壊れると起きること | 主に困る人 |
|---|---|---|---|
| バイナリ互換 | 呼び出し側を再ビルドせずに新しいDLLで動く | 起動時のエントリポイント未検出、実行時のMissingMethodException、クラッシュ |
客先で動いている古いEXE、再ビルドできない他社製アプリ |
| ソース互換 | 呼び出し側を再ビルドすれば動く | 次回ビルド時のコンパイルエラー | 社内の別チーム、ソースを持っている開発者 |
| 動作互換 | 仕様としての振る舞いが変わらない | エラーにならないまま結果・タイミング・例外の種類が変わる | エンドユーザー(そして障害調査をする全員) |
この3層は、入れ子で考えると関係がつかみやすくなります。
[動作互換]振る舞いが変わらない ← いちばん外側
└─[ソース互換]再ビルドすれば動く
└─[バイナリ互換]再ビルドせずに動く ← いちばん内側
内側ほど条件が厳しく、内側を守れているほど呼び出し側の手間は小さくなります。この3層で重要なのは、内側の層が無事でも外側の層は壊れうることです。たとえば既存関数の戻り値の意味を変える修正は、バイナリ互換もソース互換も保たれたまま、動作互換だけを壊します。この種の変更はリンクエラーもコンパイルエラーも出ないため、判断表の中で最も見落とされやすい行です。
逆に、呼び出し側が全員ソースを持っていて同時に再ビルドできる環境(単一リポジトリの社内システムなど)であれば、守るべきはソース互換と動作互換だけで、バイナリ互換は要件から外せます。「自分のDLLの呼び出し側に、再ビルドできないバイナリがいるか」が、判断表を読むうえでの最初の分岐です。
3. ネイティブDLL(C/C++)の互換性判断表
ネイティブDLLの互換性は、エクスポートテーブルと呼び出し規約、そしてメモリレイアウトで決まります。DLLがどう探されロードされるかは「Windows DLL名前解決の仕組み」で解説した通りですが、ロードに成功した後の互換性は次の表で判断できます。
| 変更内容 | バイナリ互換 | 備考 |
|---|---|---|
| エクスポート関数の追加 | 壊れない | 最も安全な拡張手段。ただし.defファイルの暗黙序数に頼っていると追加位置次第で既存の序数が振り直されるため、序数リンクのクライアントがいるなら既存序数を明示固定して末尾に足す |
| エクスポート関数の削除・改名 | 壊れる | インポート解決に失敗し、ロード時またはGetProcAddressでエラー |
| 既存関数のシグネチャ変更(引数の追加・削除・型変更、戻り値の型変更) | 壊れる | スタック・レジスタの受け渡しが食い違う。戻り値も同様で、整数(RAX)から浮動小数点(XMM0)に変えれば呼び出し側は古いABIでゴミを読む。エラーにならず暴走することもある |
呼び出し規約の変更(__cdecl↔__stdcall) |
壊れる(32bit) | x86ではスタック後始末の責任が入れ替わり、スタック破壊につながる。x64は呼び出し規約が単一でこれらの指定は実質無視されるため、この行は32bit DLLの話 |
| エクスポート序数(ordinal)の変更 | 条件付きで壊れる | 序数リンクしている呼び出し側が別関数を呼ぶ。名前リンクのみなら影響なし |
| 呼び出し側が確保する構造体へのメンバ追加 | 壊れる | 古い呼び出し側は小さいまま確保して渡してくる(後述のcbSize慣習で緩和可能) |
公開構造体のパッキング・アラインメント変更(#pragma pack、/Zp、ツールチェーン変更) |
壊れる | メンバを1つも触らなくても既存メンバのオフセットと全体サイズが変わる。cbSizeでも位置ずれは救えないので、公開ヘッダーでパッキングを明示固定しておく |
| DLL側だけが確保・解放する構造体の内部変更 | 壊れない | ポインタ(ハンドル)だけを外に見せる設計なら内部は自由に変えられる |
| 戻り値・エラーコードの意味変更 | 壊れない(が動作互換が壊れる) | リンクは成功するのに挙動が変わる、最も発見が遅れるパターン |
| C++クラスを直接エクスポートしてのデータメンバ・仮想関数の追加 | 壊れる | オブジェクトサイズやvtableレイアウトが変わる。非仮想メンバ関数の追加だけならレイアウトは変わらず既存クライアントを直接は壊さないが、そもそもC++クラスの直接エクスポートはコンパイラ間互換がなく、この判断を毎回迫られる時点でABIとして脆い |
この表は「これから何を変えるか」を決めるためのものですが、現場では逆向き、つまり「客先で出たこの症状は、どの行の変更が原因か」を引くことのほうが多くなります。上の各行が実際にどう表面化するかを対応付けておきます。
| 現場で出る症状 | 疑うべき行 |
|---|---|
起動時に「プロシージャ エントリ ポイントが見つかりません」系のダイアログが出て、そもそもアプリが立ち上がらない。NTSTATUSは0xC0000139(STATUS_ENTRYPOINT_NOT_FOUND、原文は “The procedure entry point %hs could not be located in the dynamic link library %hs.”)6 |
エクスポート関数の削除・改名。C++では、シグネチャを変えたことで名前修飾(マングル名)が変わり、結果として「別名になった」ケースも同じ症状になる |
GetProcAddressがNULLを返し、アプリが自前のエラーメッセージを出す |
同上。遅延ロード・動的ロードの呼び出し側では、こちらの形で表面化する |
DLLそのものが見つからず起動しない。NTSTATUSは0xC0000135(STATUS_DLL_NOT_FOUND)6 |
互換性ではなく配置・検索順序の問題。判断表を見る前に、DLLの探索経路を疑う |
関数から戻った直後に落ちる。デバッグビルド(/RTCsまたは/RTC1)では実行時チェックがスタックポインタの破壊として捕まえる |
呼び出し規約の変更(32bit)。Microsoftも、スタックポインタの破壊は呼び出し規約の不一致で起こりうると明記しています7。リリースビルドでは検出されず、まったく別の場所で落ちる |
| エラーは出ないが、構造体の特定のメンバだけ値が化ける。まれにバッファオーバーラン | 構造体へのメンバ追加、またはパッキング・アラインメント変更。cbSize慣習(3.1節)を入れていないと切り分けが難しい |
.NETの呼び出し側でMissingMethodExceptionが出る |
.NET側のメンバ削除・改名・シグネチャ変更(5章) |
| エラーが一切出ないのに、帳票や集計の数値が変わった | 戻り値・エラーコードの意味変更。動作互換だけが壊れた状態で、発見が最も遅れる |
この表から導かれる設計指針は昔から変わりません。境界はC ABI(extern “C”の関数と単純な構造体)に限定し、拡張は関数追加で行うことです。C#からネイティブDLLを作る場合でも同じで、「C# Native AOT DLLをC/C++から呼び出す方法」で扱ったエクスポート面もこの表の通りに管理します。
3.1 cbSize慣習 ── 構造体を拡張可能にするWin32の知恵
「構造体へのメンバ追加は壊れる」への古典的な対策が、構造体の先頭にサイズフィールドを持たせるWin32の慣習です。呼び出し側はcbSizeに自分がコンパイル時に知っていた構造体サイズを入れて渡し、DLL側はそのサイズを見て「この呼び出し側はどの世代の構造体を知っているか」を判別します。
typedef struct KS_CONFIG {
DWORD cbSize; // 呼び出し側が sizeof(KS_CONFIG) を設定する
DWORD dwMode;
DWORD dwTimeout;
// 将来のメンバは必ず末尾に追加する
} KS_CONFIG;
// DLL側: cbSize で世代を判別し、古い呼び出し側には既定値で振る舞う
if (pConfig->cbSize >= FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD)) {
timeout = pConfig->dwTimeout; // 新しい呼び出し側
} else {
timeout = DEFAULT_TIMEOUT; // 古い呼び出し側
}
判定にsizeof(KS_CONFIG)ではなくFIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD)を使っているのは、「dwTimeoutまで存在する世代か」を末尾メンバ単位で判定するためです。sizeofで判定すると、将来さらに末尾へメンバを足した瞬間に条件が厳しくなり、「dwTimeoutは知っているが新メンバは知らない」世代の呼び出し側まで古い扱いに落ちてしまいます。メンバごとにこの形で判定しておけば、拡張のたびに既存の判定を書き直さずに済みます。
実際、Windows APIのNOTIFYICONDATA構造体はまさにこの方式で世代管理されており、cbSizeに設定する値によって古いShell32.dllとの互換性を保てることが公式に文書化されています。8 自作DLLの公開構造体にも、最初のバージョンからcbSizeを入れておくと、後の拡張が「破壊的変更」から「判断表の安全側」に移ります。ただしメンバの追加は必ず末尾、既存メンバの型・順序の変更は依然として禁止です。もう1つ、出力に使う構造体ではDLL側の責務が増えます。書き込み・初期化は必ず受け取ったcbSizeの範囲に収めること。新しいsizeofぶんを無条件に書き込むと、古い呼び出し側が確保した小さいバッファをはみ出し、この慣習が防ぐはずだった破壊をDLL側が起こすことになります。
4. COMインターフェースの鉄則 ── 公開したら変更禁止
COMはこの問題に、最も明快な答えを出した技術です。COMの仕様では、インターフェースは次のルールに従います。
- インターフェースは一意なIID(インターフェースID)を持つ。1
- インターフェースは不変(immutable)である。一度作成・公開したら、定義のいかなる部分も変更してはならない。1
- メソッドの追加・削除やセマンティクスの変更は「古いインターフェースの新バージョン」ではなく、別のIIDを持つ新しいインターフェースを作ることを意味する。4
なぜここまで厳格なのかというと、COMインターフェースの実体がvtable(関数ポインタ表)というバイナリレイアウトだからです。C++やVB6のクライアントはコンパイル時に「3番目のスロットがGetName」という位置を焼き込みます。公開後にメソッドを挿入すれば、古いクライアントは何のエラーもなく別のメソッドを呼びます。だからこそCOMは「変更」という操作自体を仕様から消し、代わりに次の拡張手順を用意しました。
// v1: 公開済み。もう一切変更しない
[object, uuid(1111....)]
interface ICalc : IUnknown {
HRESULT Add([in] long a, [in] long b, [out, retval] long* result);
};
// v2: 新しいIIDを持つ新インターフェース。ICalcを継承して拡張
[object, uuid(2222....)]
interface ICalc2 : ICalc {
HRESULT AddChecked([in] long a, [in] long b, [out, retval] long* result);
};
上のIDLはuuidを1111....のように省略したイメージ例で、このままではコンパイルできません。実際にはVisual Studio付属のGUID作成ツール(guidgen)やuuidgenコマンドで生成した完全なGUIDを書きます。同じGUIDを2つのインターフェースに使うと契約を区別できなくなるため、インターフェースを増やすたびに必ず新規に生成します。
実装クラス(coclass)はICalcとICalc2の両方を実装し、古いクライアントは今まで通りICalcで、新しいクライアントはQueryInterfaceでICalc2を要求して使います。RPC/COMの公式なバージョニング理論でも、「旧インターフェースを継承した新インターフェースがマイナーバージョンアップ相当、既存メソッドや型を変えるなら継承しない完全に新しいインターフェース(メジャーバージョンアップ相当)」と整理されています。9 QueryInterfaceによって呼び出し側が対応状況を実行時に安全に確認できることが、この方式を成立させています。この仕組みの設計上の美しさは「COM とは何か」で掘り下げた通りです。
4.1 CLSID・ProgID・IIDの役割分担
COMのバージョニングを考えるときは、3種類の識別子の役割を分けて考えます。10
- IID はインターフェース(契約)の識別子。契約が変われば必ず新しいIIDになります。
- CLSID は実装クラスの識別子。同じCLSIDのまま実装を差し替えることは、公開済みインターフェースの契約を守る限り自由です。
- ProgID は人間可読な別名(
KomuraSoft.Calc.1)で、CLSIDへの対応をレジストリで引くためのものです。バージョン番号付きProgIDと、常に最新版を指すバージョン非依存ProgID(KomuraSoft.Calc)を併記する慣習があり、後者はCurVerで最新版に対応付けられます。10
つまり「実装のバージョンアップ」はCLSIDとProgIDの世界の話、「契約の変更」はIIDの世界の話で、混ぜてはいけません。レジストリ登録そのものを避けたい場合の選択肢は「Reg-Free COMとは」で扱っています。
4.2 VB6/VBAクライアントが特に壊れやすい理由
VB6やVBAから参照設定(事前バインディング)でCOMコンポーネントを使っている場合、コンパイル時にタイプライブラリを読んで呼び出しを解決します。事前バインディングはIntelliSenseと型チェックが効き、実行も速い推奨形態ですが11、その代償としてタイプライブラリのレイアウトに強く結合します。インターフェースのvtableが変わればもちろん、タイプライブラリ上の定義が変わっただけでも「プロジェクトを開いたら参照が壊れていた」「実行時にエラー430/438が出る」といった形で表面化します。
このため、VB6/VBA/Excelマクロが呼び出し側にいるコンポーネントでは、インターフェース不変の鉄則を最も厳格に守る必要があります。タイプライブラリにもバージョン(major.minor)があり、契約を増やしたら上げて管理します。.NET側からVBAへ型付きで公開する場合のタイプライブラリ生成は「.NET 8 DLLをVBAから型付きで使う方法 - COM公開とdscom TLB」で解説しています。一方、CreateObjectだけで使う遅延バインディングのクライアントは名前で解決するためレイアウト変更には強いものの、メソッドの意味変更(動作互換)の影響は同じように受けます。
5. .NETアセンブリの互換性 ── 公式ルールで機械的に判定する
.NETには、Microsoftが.NETライブラリ自身の開発で使っている「互換性のための変更ルール」が公開されており、変更が許可(✔️)・禁止(❌)・要判断(❓)に分類されています。2 自社ライブラリの判断基準としてそのまま採用できると明記されているので、主要な行を抜粋します。
| public APIへの変更 | 判定 | 補足 |
|---|---|---|
| メソッド・型・メンバの追加 | ✔️ 原則安全 | ただし既存オーバーロードの解決を変える追加は要注意。公開structへのインスタンスフィールド追加は例外で、サイズ・レイアウトが変わるため相互運用やunsafeな利用者を壊す |
| public型・メンバの削除・改名 | ❌ 破壊的 | MissingMethodException等で実行時に壊れる |
| シグネチャ変更(引数の追加・削除・順序・型、戻り値の型) | ❌ 破壊的 | バイナリ・ソース両方を壊す |
| パラメータ名の変更 | ❌ 破壊的 | C#の名前付き引数とVBの遅延バインディングを壊す。見落としやすい |
| メンバへのvirtual追加 | ❌ 破壊的 | 「追加だから安全」に見える代表的な落とし穴。呼び出しIL(call/callvirt)の食い違いが起きうる |
| virtualの削除、仮想メンバのabstract化 | ❌ 破壊的 | 派生クラスのオーバーライドが壊れる |
| 非sealedな公開型への抽象メンバ追加 | ❌ 破壊的 | 既存の派生クラスが実装を持たない |
| 型のsealed化 | ❌ 破壊的 | 既存の派生クラスがコンパイル不能に |
| インターフェースへのメンバ追加 | ❓ 要判断 | 既定実装(DIM)を付ければ既存の実装クラスを壊さずに済むが、条件が多い(下記) |
| 定数・列挙値の値の変更、列挙メンバの改名・削除 | ❌ 破壊的 | 値はコンパイル時に呼び出し側へ埋め込まれる |
| より派生した例外を投げるよう変える | ✔️ 許可 | 既存のcatchが引き続き機能するため |
| 既存コードパスで新種の例外を投げる | ❌ 破壊的 | 新しいパラメータ値でのみ投げるのは可 |
この表で唯一❓(要判断)なのが「インターフェースへのメンバ追加」で、実務でも最も迷う行です。既定実装(DIM: Default Interface Members、既定インターフェースメンバ)を付ければ、既存の実装クラスに実装漏れを起こさせずにメンバを増やせますが、公式ルールが挙げている条件は次のとおりです。2
- 利用側の最低要件が .NET Core 3.0 / C# 8.0 に上がります。DIMはこのバージョンで導入されたため、既定実装を足した時点で、それより下のランタイムを使う利用者は取り残されます。.NET Framework は対象外なので、.NET Framework上のクライアントが1つでも残っているライブラリでは、この緩和策は使えません。
- DIMに対応していない言語があります。.NETは複数の言語から使われるため、C#以外から実装されているインターフェースでは既定実装を当てにできません。
- どの既定実装を呼ぶかをランタイムが決められない場面があります。複数のインターフェースが絡む構成では、既定実装の解決が曖昧になりえます。
- C# 13以降、
ref structが実装しているインターフェースに既定インスタンスメンバを足すのはソース破壊的変更です。ref structはボックス化もインターフェース型への変換もできないため、既定実装にフォールバックできず、インスタンスメンバは必ず明示的に実装しなければなりません。
一方、静的で非abstract・非virtualなメンバの追加は許可されています。2 「メンバを追加したいが利用者に.NET Frameworkが残っている」場合は、インターフェースを触らず、4章のCOMと同じ発想で新しいインターフェースを追加するか、拡張メソッドで代替できないかを先に検討するのが安全です。
COMの「インターフェース不変」ほど単純ではありませんが、思想は同じです。公開APIは契約であり、契約への追加は許されるが、既存の契約の変更は許されない。そして仮想メソッドやパラメータ名のような「一見安全な変更」が破壊的側に分類されている点こそ、感覚ではなく表で判断すべき理由です。
5.1 強い名前と3つのバージョン番号
.NETアセンブリには複数のバージョン番号があり、役割が異なります。12
- AssemblyVersion: ランタイムがアセンブリの識別・ロードに使う唯一のバージョン。強い名前付きアセンブリでは、.NET FrameworkのCLRは厳密一致を要求するため、上げるたびに呼び出し側のバインディングリダイレクトが必要になります(.NET/.NET Coreは上位バージョンを自動で受け入れます)。公式ガイダンスは、リダイレクトを減らすためにメジャーバージョンのみをAssemblyVersionに反映させることを提案しています。
- FileVersion(AssemblyFileVersion): エクスプローラーのプロパティで見えるだけで、ランタイムの動作には影響しません。CIのビルド番号を埋める場所として推奨されています。
- InformationalVersion: 人間向けの自由な文字列。semver形式のパッケージバージョンやソースのコミットハッシュを記録します。
つまり実務では、「互換性の宣言はパッケージ/製品バージョン(semver)で行い、AssemblyVersionはメジャーのみ、FileVersionでビルドを追跡する」という三層構成が扱いやすい形です。
6. バージョン番号の付け方 ── semverは「定義」があって初めて機能する
セマンティックバージョニング(semver)の要点は3行で書けます。互換性のない変更をしたらMAJOR、後方互換のある機能追加はMINOR、後方互換のあるバグ修正はPATCHを上げる、です。5
見落とされがちなのは、semver仕様の最初の要求が「semverを使うソフトウェアは公開APIを宣言しなければならない」であることです。5 何が公開APIかを宣言していなければ、「互換性のない変更」の判定基準が存在せず、メジャーを上げるべきかどうかは担当者の気分で決まります。semverが機能していない現場の多くは、番号の付け方ではなくこの宣言を省略しています。
社内配布DLLでの現実的な運用は次の形になります。
- 公開APIの範囲を宣言する ── ネイティブDLLならエクスポート関数と公開ヘッダ、COMならIDL/タイプライブラリ、.NETならpublic型・メンバ。「これ以外は内部実装で予告なく変わる」と明記します。
- 破壊的変更の定義を採用する ── 本記事の3章・5章の判断表、および.NETの変更ルール2を「自社の定義」としてリポジトリに置きます。
- 判定を自動化する ── .NETならPackage Validation / ApiCompatツールで、前バージョンとのバイナリ互換を機械チェックできます。13 レビューでの「たぶん大丈夫」を排除できます。
- リリースノートに互換性欄を設ける ── 「再ビルド不要/再ビルド推奨/破壊的変更あり」の3値を毎回明記します。冒頭の「差し替えだけでいいですか?」への答えを、聞かれる前に文書で出す仕組みです。
7. 互換性を壊さざるを得ないときの手順
判断表で「破壊的」と判定された変更がどうしても必要になったときは、差し替えではなく並行提供で進めます。
- 新旧を並行提供する ── COMなら
IFoo2を追加してIFooを残す(4章)。ネイティブDLLなら新関数(FooEx)の追加か、別名の新DLLを併存させる。.NETならメジャーバージョンを上げた新パッケージとして出し、旧メジャーはバグ修正のみ続けます。 - 非推奨期間を設ける ── .NETなら
[Obsolete]属性でコンパイル時に警告を出せます。ネイティブ/COMではヘッダのコメントとリリースノートで宣言し、廃止予定日を明記します。「いつか消す」ではなく日付を切るのが要点です。 - 呼び出し側を棚卸しする ── 社内のソース検索、インストーラーの配布記録、COMならレジストリの参照状況から「誰がまだ旧APIを呼んでいるか」を一覧化します。ここで再ビルドできないバイナリ(退職者のツール、他社製アプリ)が見つかったら、その分だけ旧APIの寿命を延ばすか、ラッパーで橋を架けます。
- 旧APIを削除する ── 棚卸しで呼び出し側ゼロを確認してから削除し、メジャーバージョンを上げます。
この手順はコストがかかります。だからこそ逆説的に、最初の公開時に判断表を意識してAPIを小さく設計する(公開しなければ互換性の義務は生じない)ことが、最大の互換性対策になります。
8. まとめ
- 互換性はバイナリ・ソース・動作の3層で考えます。再ビルド不要でも動作互換は壊れえます。3
- ネイティブDLLは「追加は安全、既存エクスポート・シグネチャ・構造体レイアウトの変更は破壊的」。構造体には
cbSizeを持たせて拡張余地を作ります。8 - COMインターフェースは公開したら不変。変更は新IIDの新インターフェース(IFoo2)として追加し、
QueryInterfaceで判別させます。149 事前バインディングのVB6/VBAクライアントがいる場合は特に厳格に守ります。 - .NETは公式の互換性変更ルールで機械的に判定できます。仮想化・パラメータ名変更・sealed化など「一見安全な変更」が破壊的に分類されている点に注意します。2
- AssemblyVersionはメジャーのみ・FileVersionでビルド追跡・semverで互換性宣言という三層構成が現実的です。12
- semverは公開APIと破壊的変更の定義を宣言して初めて機能します。5 判断表を定義として採用し、Package Validation等で自動チェックします。13
- 壊すときは並行提供→非推奨期間→棚卸し→削除。いきなり差し替えないことが、客先の古いEXEを守ります。
関連記事
- COM / ActiveX / OCX とは何か - 違いと関係をまとめて解説
- COM とは何か - Windows COM の設計が今でも美しい理由
- .NET 8 DLLをVBAから型付きで使う方法 - COM公開とdscom TLB
- Windows DLL名前解決の仕組み - 検索順序とSxS
- Reg-Free COMとは - 登録不要でCOMを使う仕組み
- C# Native AOT DLLをC/C++から呼び出す方法
関連する相談領域
合同会社小村ソフトでは、他システムから参照されるDLL・COMコンポーネント・.NETライブラリの互換性設計、公開APIの棚卸しとバージョニング方針の整備、既存クライアントを壊さない拡張(IFoo2方式・並行提供)の設計・実装を扱っています。
参考リンク
-
Microsoft Learn, Interface Design Rules. COMオブジェクトが実装するインターフェースは一意なIIDを持たねばならないこと、作成・公開後は定義のいかなる部分も変更してはならない(不変である)ことについて。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Change rules for compatibility (.NET). .NETのAPI変更が許可・禁止・要判断に分類されていること、public型・メンバの削除や改名、シグネチャ変更、パラメータ名の変更、virtualの追加・削除、sealed化、定数・列挙値の値変更などが禁止(破壊的)であること、インターフェースへのメンバ追加が要判断であること、ライブラリ開発者が自身のライブラリの評価基準として使えることについて。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Breaking changes (.NET library guidance). 破壊的変更がソース破壊・動作破壊・バイナリ破壊に分類されること、バイナリ破壊では旧バージョンに対してコンパイルされたアセンブリがMissingMethodException等で実行時に失敗することについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Interface Pointers and Interfaces. COMインターフェースは不変であり、メソッドの追加・削除やセマンティクスの変更は旧インターフェースの新バージョンではなく新しいインターフェースを作ることを意味すること、IIDが契約を一意に定義することについて。 ↩ ↩2 ↩3
-
semver.org, Semantic Versioning 2.0.0. 互換性のないAPI変更でMAJOR、後方互換のある機能追加でMINOR、後方互換のあるバグ修正でPATCHを上げること、semverを使うソフトウェアは公開APIを宣言しなければならないこと、公開APIへの後方互換性のない変更ではMAJORバージョンを必ず上げなければならないことについて。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, MS-ERREF 2.3.1 NTSTATUS Values.
STATUS_ENTRYPOINT_NOT_FOUND(0xC0000139)が「The procedure entry point %hs could not be located in the dynamic link library %hs.」、STATUS_DLL_NOT_FOUND(0xC0000135)が「This application has failed to start because %hs was not found.」であることについて。 ↩ ↩2 -
Microsoft Learn, /RTC (Run-time error checks).
/RTCs(および/RTC1)がスタックポインタの検証を行いスタックポインタの破壊を検出すること、その破壊が呼び出し規約の不一致(DLLが__stdcallでエクスポートしている関数を__cdeclの関数ポインタ経由で呼ぶなど)によって起こりうること、/RTCはリリース(最適化)ビルドでは使えないことについて。 ↩ -
Microsoft Learn, NOTIFYICONDATAW structure (shellapi.h). cbSizeメンバに構造体サイズを設定すること、構造体が世代とともに拡張されてきたこと、cbSizeに適切な値を設定することで古いバージョンのShell32.dllと互換性を保ったまま利用できることについて。 ↩ ↩2
-
Microsoft Learn, The Versioning Theory for RPC and COM. COMでは機能拡張のために新しいインターフェースを作るのが最善であり、旧インターフェースを継承した新インターフェースがマイナーバージョン相当、既存メソッドや型の変更には継承しない完全に新しいインターフェースが必要なこと、QueryInterfaceで対応状況を確認できることについて。 ↩ ↩2
-
Microsoft Learn, COM Registry Keys. CLSIDがCOMクラスを識別するGUIDであること、ProgIDが人間可読な文字列をCLSIDに対応付けるが一意性は保証されないこと、バージョン非依存ProgIDがCurVerで最新バージョンのクラスに対応付けられること、InterfaceキーがIIDを登録することについて。 ↩ ↩2
-
Microsoft Learn, OLE programmatic identifiers, late binding, and early binding (Project). VBAにおいて参照設定による事前バインディングが推奨され、遅延バインディング(CreateObject/ProgID)はコード記述時にメンバが見えず実行性能も劣ること、事前バインディングには対象オブジェクトライブラリへの参照設定が必要なことについて。 ↩
-
Microsoft Learn, Versioning (.NET library guidance). AssemblyVersionがランタイムのロードに使われ.NET Frameworkでは強い名前付きで厳密一致が要求されること、AssemblyVersionにはメジャーバージョンのみを含めることが提案されていること、FileVersionはWindowsでの表示用で実行時動作に影響しないこと、InformationalVersionが追加のバージョン情報の記録用であること、NuGetパッケージバージョンにsemver 2.0.0の利用が推奨されることについて。 ↩ ↩2
-
Microsoft Learn, NuGet package compatibility rules. バイナリ破壊的変更を避けるべきこと、Package ValidationやApiCompatツールでベースラインバージョンとの互換性を自動検出できること、AssemblyVersionをリリース間で下げてはならないことについて。 ↩ ↩2
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
テストのないレガシー業務アプリに安全に手を入れる ── 特性化テストとリファクタリングの実践
テストのない業務アプリに安全に手を入れるために、現在の挙動を固定する特性化テスト(ゴールデンマスター法)の手順、継ぎ目(seam)の作り方、リファクタリングと機能追加を混ぜない運用ルールをC#の例で解説します。
VB6アプリはいつまで動くのか ── ランタイムのサポート状況と現実的な.NET移行の進め方
VB6アプリはいつまで動くのか。ランタイムはWindows 11でも動作対象、IDEはサポート終了という現状を整理し、全面リライト・自動変換・段階移行の判断表、移行前の棚卸し、VB6と.NETの非互換までをまとめます。
Windowsのプロセス間通信をどう選ぶか ── 名前付きパイプ / TCP / gRPC / 共有メモリ / COM 判断表
Windowsアプリ同士の連携手段をどう選ぶか。名前付きパイプ、ローカルTCP、gRPC、共有メモリ、ファイル連携、COMの得意分野と落とし穴を判断表で整理し、定番構成と名前付きパイプの実装例まで解説します。
MSMQはいつまで使えるのか ── 「非推奨ですらない」レガシーキューの移行判断
MSMQは公式の非推奨リストに載っていない一方、System.Messagingは.NET Frameworkにしか存在せず、.NETへの移行を妨げます。廃止の噂と実際の現在地を事実で整理し、使い続ける・移行するの判断基準と移行先の選び方をまとめます。
PowerShellからCOMと.NETを呼ぶ実践 ── スクリプトの届く範囲を一気に広げる
PowerShellから.NETクラスを呼ぶ方法、Add-TypeによるC#とWin32 APIの組み込み、COM操作、Excelのプロセス残留と後始末、Officeの無人実行が非サポートである理由、5.1と7の違いまで実務目線で解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
ActiveX / 移行テーマ
COM / ActiveX / OCX を残すか、包むか、置き換えるかを整理するトピックです。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
既存資産活用・移行支援
COM / ActiveX / OCX、32bit / 64bit 制約を抱える既存資産の活用と移行を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- DLLに関数を追加するだけなら、呼び出し側の再ビルドは不要ですか?
- エクスポート関数を追加するだけなら、既存の呼び出し側はそのまま動くのが原則です。既存関数の名前・シグネチャ・呼び出し規約・エクスポート序数を変えない限り、インポート解決は今まで通り成立するためです。ただし、呼び出し側が確保して渡す構造体にメンバを追加した場合や、既存関数の戻り値・エラーコードの意味を変えた場合は、再ビルドなしで動いても動作互換が壊れることがあります。「関数追加は安全、既存シグネチャの変更は破壊的」が基本線です。
- COMインターフェースに後からメソッドを追加してはいけないのはなぜですか?
- COMのインターフェースは公開したら不変(immutable)というのがCOM仕様上のルールだからです。インターフェースはvtable(関数ポインタの並び)というバイナリレイアウトの契約であり、メソッドを挿入・削除・並べ替えすると、古いバイナリはコンパイル時に焼き込んだ位置で別のメソッドを呼んでしまいます。末尾への追加なら既存スロットの位置は変わりませんが、今度は新しいクライアントが「追加済みのはず」の実装として古いコンポーネントを掴み、存在しないスロットを呼ぶ事故が起きるため、同じIIDのままの追加はやはり許されません。機能を増やしたい場合は、新しいIIDを持つ新インターフェース(IFoo2)を追加し、既存のIFooはそのまま残します。呼び出し側はQueryInterfaceで新旧どちらに対応しているかを安全に判別できます。
- .NETのAssemblyVersion・FileVersion・InformationalVersionはどう使い分けますか?
- AssemblyVersionはランタイムがアセンブリを識別・ロードするために使う唯一のバージョンで、強い名前付きでは.NET Frameworkが厳密一致を要求するため、上げるたびにバインディングリダイレクトが必要になります。このためメジャーバージョンのみを反映させる運用が公式ガイダンスで提案されています。FileVersionはエクスプローラーのプロパティに表示されるだけでランタイムの動作には影響せず、CIのビルド番号などを埋めるのに向きます。InformationalVersionは人間向けの自由な文字列で、semver形式のバージョンやコミットハッシュを記録します。
- セマンティックバージョニング(semver)を導入すれば互換性問題は解決しますか?
- semverだけでは解決しません。semverは「後方互換性のない変更をしたらメジャーを上げる」という規約ですが、その前提として「何が公開APIで、何が破壊的変更か」を宣言することを要求しています。この定義がないままバージョン番号だけを付けても、判断が人によってぶれて機能しません。ネイティブDLLなら本記事の判断表のような基準、.NETならMicrosoftの互換性変更ルールを「自社の破壊的変更の定義」として採用し、リリース手順に組み込むことで初めてsemverが意味を持ちます。