C言語互換を保つC++コードの書き方:extern “C”・ABI・ヘッダ設計のベストプラクティス

Cの広い互換性と実装の自由度を併せ持つC++は、システム連携や長期運用の現場で強力な選択肢になります。

本稿では、C言語から安全に呼び出せるC++コードの書き方を、extern “C”、ABI、ヘッダ設計、ビルド・テストまで一貫して解説します。

具体例とコードで、実践的なベストプラクティスを掴んでいただけます。

目次
  1. C言語互換の前提とユースケース:C++でC APIを提供する意義
  2. extern “C” の基礎:名前修飾(name mangling)抑止とリンケージ
  3. ABIと呼び出し規約:バイナリ互換を壊さないために
  4. C互換APIのヘッダ設計ベストプラクティス
  5. C++実装の活用とCラッパー:橋渡しパターン
  6. ビルドとテスト:移植性と互換性の検証
  7. まとめ

C言語互換の前提とユースケース:C++でC APIを提供する意義

どんな場面でC言語互換が必要か(組込み・プラグイン・FFI)

組込み・OSカーネル周辺

多くの組込み環境やOSの低層はC ABIが標準で、C++ランタイムや例外機構を前提にできません。

ドライバ、ミドルウェア、RTOS向けライブラリはC関数としての提供が安全です。

プラグインと動的ロード

アプリケーションがdlsymGetProcAddressで関数を解決するプラグイン方式では、名前修飾がないCシンボルが前提となることが多く、C ABIを守ることで互換性が高まります。

他言語FFI(Python、Rust、Go、Javaなど)

多くのFFIはC ABIを基準に設計されており、Cシンボルと安定したレイアウトのデータだけを前提にします。

Cで公開し内部でC++を活用すれば、他言語から容易に呼び出せます。

C言語互換の基本方針と制約の整理

公開境界は「C」、内部実装は「C++」

公開ヘッダはCコンパイラでそのまま通るように設計し、内部ではC++のクラス、テンプレート、RAII、スマートポインタを最大限活用します。

橋渡しは薄いCラッパーが担います。

互換性を壊しやすい要素の排除

例外、RTTI、関数オーバーロード、デフォルト引数、参照型(T&)、テンプレートの公開、std系コンテナやstringの公開などはABIや言語互換を壊すため、Cの公開APIには持ち込まない前提で設計します。

extern “C” の基礎:名前修飾(name mangling)抑止とリンケージ

ヘッダでのextern “C”ガード(C/C++両対応の記述例)

C++は関数名にシグネチャを埋め込む名前修飾を行います。

Cからリンク可能にするには、公開関数をextern "C"で囲みます。

ヘッダはC/C++両対応に記述します。

C言語mylib.h
// CコンパイラでもC++コンパイラでもインクルード可能な公開ヘッダ
#ifndef MYLIB_H
#define MYLIB_H

// バージョンマクロ
#define MYLIB_VERSION_MAJOR 1
#define MYLIB_VERSION_MINOR 0
#define MYLIB_VERSION_PATCH 0

// 可視性と呼び出し規約
#if defined(_WIN32) || defined(__CYGWIN__)
  #if defined(MYLIB_SHARED)
    #if defined(MYLIB_BUILD)
      #define MYLIB_API __declspec(dllexport)
    #else
      #define MYLIB_API __declspec(dllimport)
    #endif
  #else
    #define MYLIB_API
  #endif
  // 既定は__cdecl。必要ならビルド時にMYLIB_USE_STDCALLを定義
  #ifdef MYLIB_USE_STDCALL
    #define MYLIB_CALL __stdcall
  #else
    #define MYLIB_CALL __cdecl
  #endif
#else
  #if defined(MYLIB_SHARED)
    #define MYLIB_API __attribute__((visibility("default")))
  #else
    #define MYLIB_API
  #endif
  #define MYLIB_CALL
#endif

#include <stddef.h>  // size_t
#include <stdint.h>  // int32_t, int64_t

#ifdef __cplusplus
extern "C" {
#endif

// 不完全型で実装を隠す
typedef struct mylib_context mylib_context;

// エラーコード(例外は使わない)
typedef enum mylib_status {
  MYLIB_OK = 0,
  MYLIB_EINVAL = 1,
  MYLIB_EOOM = 2,      // out-of-memory
  MYLIB_EINTERNAL = 3  // その他内部エラー
} mylib_status;

// コンテキスト生成・破棄(所有権は呼び出し側が持つ)
MYLIB_API mylib_status MYLIB_CALL mylib_create(mylib_context** out_ctx);
MYLIB_API void         MYLIB_CALL mylib_destroy(mylib_context* ctx);

// 設定と計算(in/out契約を明確化)
MYLIB_API mylib_status MYLIB_CALL mylib_set_scale(mylib_context* ctx, int32_t scale);
MYLIB_API mylib_status MYLIB_CALL mylib_sum_scaled(
    mylib_context* ctx,
    const int32_t* data, size_t n,
    int64_t* out_sum);

#ifdef __cplusplus
} // extern "C"
#endif

#endif // MYLIB_H

ポイント

  • extern "C"はヘッダ側で宣言に適用し、C++でもCリンケージになるようにします。
  • シンボル可視性と呼び出し規約はマクロにまとめ、プラットフォームとビルド形態(静的/共有)に応じて切り替えます。

実装側の注意点:C++からCシンボルをエクスポートする

C++実装では公開関数の定義もextern "C"で囲みます。

例外は境界を越えないよう必ず捕捉し、エラーコードへ変換します。

C++mylib.cpp
// C++で実装しCリンケージでエクスポート
#include "mylib.h"
#include <memory>
#include <vector>
#include <new>      // std::bad_alloc
#include <exception>

// 内部実装はC++の型で自由に設計
namespace mylib_detail {
  class ContextImpl {
  public:
    void set_scale(int32_t s) { scale_ = s; }
    int64_t sum_scaled(const int32_t* data, size_t n) const {
      int64_t acc = 0;
      for (size_t i = 0; i < n; ++i) {
        acc += static_cast<int64_t>(data[i]) * static_cast<int64_t>(scale_);
      }
      return acc;
    }
  private:
    int32_t scale_ = 1;
  };

  static mylib_status to_status_from_exception() noexcept {
    try {
      throw; // 直前の例外を再スローして型を判定
    } catch (const std::bad_alloc&) {
      return MYLIB_EOOM;
    } catch (...) {
      return MYLIB_EINTERNAL;
    }
  }
}

// Opaque pointerの実体(サイズやレイアウトは非公開)
struct mylib_context {
  std::unique_ptr<mylib_detail::ContextImpl> pimpl;
};

extern "C" {

MYLIB_API mylib_status MYLIB_CALL mylib_create(mylib_context** out_ctx) {
  if (!out_ctx) return MYLIB_EINVAL;
  try {
    // 例外安全:unique_ptrで確実に解放
    std::unique_ptr<mylib_context> ctx(new mylib_context);
    ctx->pimpl = std::make_unique<mylib_detail::ContextImpl>();
    *out_ctx = ctx.release();
    return MYLIB_OK;
  } catch (...) {
    return mylib_detail::to_status_from_exception();
  }
}

MYLIB_API void MYLIB_CALL mylib_destroy(mylib_context* ctx) {
  // 例外を投げないこと(noexcept違反はstd::terminate)
  delete ctx; // unique_ptrごと破棄され実装も解放
}

MYLIB_API mylib_status MYLIB_CALL mylib_set_scale(mylib_context* ctx, int32_t scale) {
  if (!ctx) return MYLIB_EINVAL;
  try {
    ctx->pimpl->set_scale(scale);
    return MYLIB_OK;
  } catch (...) {
    return mylib_detail::to_status_from_exception();
  }
}

MYLIB_API mylib_status MYLIB_CALL mylib_sum_scaled(
    mylib_context* ctx, const int32_t* data, size_t n, int64_t* out_sum) {
  if (!ctx || (!data && n != 0) || !out_sum) return MYLIB_EINVAL;
  try {
    *out_sum = ctx->pimpl->sum_scaled(data, n);
    return MYLIB_OK;
  } catch (...) {
    return mylib_detail::to_status_from_exception();
  }
}

} // extern "C"

ABIと呼び出し規約:バイナリ互換を壊さないために

呼び出し規約(cdecl/stdcall)とプラットフォーム差(Windows/Unix)

呼び出し規約の選択

Windowsでは__cdecl(呼び出し側がスタック片付け)や__stdcall(呼び出され側が片付け)などの呼び出し規約がABIに影響します。

不一致はクラッシュの原因になります。

公開ヘッダではMYLIB_CALLのようなマクロに閉じ込め、ビルドや利用側で一貫させます。

Unix系(ELF)では一般に呼び出し規約が固定で、明示が不要なことが多いです。

実務的な勧め

  • WindowsのC APIは既定で__cdeclを採用し、必要時のみ__stdcallをサポートする分岐を用意します。
  • 共有ライブラリと静的ライブラリでエクスポート属性を切り替えるマクロを用意します(上記MYLIB_API)。

シンボル可視性・バージョニング・安定したABI設計

可視性の最小化

ELFでは-fvisibility=hiddenを既定にし、公開関数だけvisibility("default")で明示します。

Windowsでは__declspec(dllexport).defファイルで公開関数のみをエクスポートします。

内部シンボル露出を抑えると、衝突回避・最適化・起動時間に有利です。

バージョニングと後方互換

  • セマンティックバージョニング(MAJOR.MINOR.PATCH)に従い、ABI破壊はMAJORでのみ行う。
  • ELFではリンカ版スクリプトでシンボルバージョンを付け、古いシンボルも残すことで段階的移行が可能です。
mylib.map
/* ELF向けシンボルバージョン */
MYLIB_1.0 {
  global:
    mylib_create;
    mylib_destroy;
    mylib_set_scale;
    mylib_sum_scaled;
  local:
    *;
};

Shellリンク時の指定例(GCC/Clang)
g++ -shared -fvisibility=hidden -Wl,--version-script=mylib.map -o libmylib.so mylib.cpp
mylib.def
; mylib.def — エクスポートシンボルの明示
LIBRARY "mylib"
EXPORTS
  mylib_create
  mylib_destroy
  mylib_set_scale
  mylib_sum_scaled

C互換APIのヘッダ設計ベストプラクティス

POD/固定幅整数(stdint.h)/列挙・構造体のレイアウト規約

データ型の選定

公開APIにはstdint.hの固定幅整数(int32_t/uint64_t)とsize_tを使い、longのようにプラットフォームで幅が変わる型を避けます。

構造体を公開する場合はPOD(Plain Old Data)に限定し、アラインメントやパディングを意識します。

#pragma packの乱用は避け、バイナリ互換維持が難しくなる設計は控えます。

列挙体

CとC++でサイズが異なる可能性を踏まえ、ABI境界に生の列挙値を渡すだけにします。

サイズ固定が必要ならint32_tなどの実整数型を明示します。

Opaque pointer(不完全型)で内部実装を隠蔽する

不完全型の利点

typedef struct X X;のような不完全型を公開し、実体はC++側に隠します。

これにより内部レイアウト変更がABIに波及しません。

上のmylib_contextが典型例です。

例外・RTTI・関数オーバーロード・デフォルト引数の禁止

禁止事項の理由

  • 例外やRTTIはABIとランタイムに依存し、Cから扱えません。
  • 関数オーバーロードやデフォルト引数はCにはなく、名前解決やABIを複雑化します。
  • 参照型(T&)やC++専用の型(std::stringstd::vector)を公開境界に出さないでください。

必要に応じて、C風の関数名にプレフィックスを付け(mylib_)、明確な1関数1責務を徹底します。

メモリ割り当てと所有権(create/destroy, in/outバッファ)の明確化

所有権ポリシー

  • 「作る側が壊す」を原則に、create/destroyペアを提供します。
  • バッファは「呼び出し側が確保、ライブラリが書き込む」か「ライブラリが確保、専用のfree関数で解放」を関数ごとに明記します。
  • サイズ問い合わせ関数(*_get_required_size)を設けると安全です。

本稿の例では、コンテキストはライブラリが確保して返し、破棄はmylib_destroyで行うという明確な契約にしています。

C++実装の活用とCラッパー:橋渡しパターン

C++内部(クラス/テンプレート/スマートポインタ)をC APIで包む

橋渡しの要諦

内部の高度なロジックはC++の表現力を活かし、境界で型消去(opaque pointer)してC関数に落とします。

ポインタ所有権はstd::unique_ptrで厳密に管理し、C関数では生ポインタに限定して公開します。

エラーハンドリング:例外を外へ出さずエラーコード/errnoで返す

例外→ステータス変換

C関数は決して例外を投げず、失敗は戻り値で返します。

別途mylib_last_error()のようなスレッドローカル詳細エラー取得APIを用意するパターンもあります。

ここでは典型的なenumのステータスで簡潔に表現しています。

スレッド安全性と再入可能性の確保

状態の局所化

グローバル可変状態を避け、状態はコンテキストに閉じ込めます。

関数は可能な限り再入可能(reentrant)にし、共有資源を使う場合は内部でミューテックスなどを管理します。

スレッド安全性はドキュメントに明記し、APIごとに「スレッドセーフ」「コンテキスト間は独立」などの契約を提示します。

ビルドとテスト:移植性と互換性の検証

コンパイラ差(MSVC/GCC/Clang)・リンカ設定・.def/visibility制御

クロスコンパイラの注意

  • MSVCは例外仕様や警告の既定が異なるため、/EHsc/W4を設定し、公開関数はnoexcept相当を守る実装にします。
  • GCC/Clangでは-fvisibility=hiddenとバージョンスクリプト、MSVCでは__declspec(dllexport).defで公開範囲を最小化します。

Cコンパイル通過テストとABI互換テスト(nm/objdump/ABIチェック)

Cとしてビルドが通ることの検証

公開ヘッダが「本当にC互換か」をCIで常時検証すると事故を防げます。

Shell
# Cとしてヘッダと最小プログラムをビルド
gcc -std=c11 -Wall -Wextra -Werror -c test.c -o test.o

Cのテストプログラム例と実行結果:

C言語test.c
// CからC++実装のライブラリを利用
#include "mylib.h"
#include <stdio.h>
#include <inttypes.h>

int main(void) {
  mylib_context* ctx = NULL;
  if (mylib_create(&ctx) != MYLIB_OK) {
    fprintf(stderr, "create failed\n");
    return 1;
  }
  mylib_set_scale(ctx, 2);

  int32_t data[] = {1, 2, 3};
  int64_t sum = 0;
  if (mylib_sum_scaled(ctx, data, 3, &sum) != MYLIB_OK) {
    fprintf(stderr, "sum_scaled failed\n");
    mylib_destroy(ctx);
    return 2;
  }
  printf("sum_scaled = %" PRId64 "\n", sum);
  mylib_destroy(ctx);
  return 0;
}
実行結果
sum_scaled = 12

シンボルのmangling抑止を確認

ELFの場合の確認例です。

Shell
nm -D --defined-only libmylib.so | grep mylib_
実行結果想定される出力
0000000000001120 T mylib_create
0000000000001160 T mylib_destroy
00000000000011a0 T mylib_set_scale
00000000000011f0 T mylib_sum_scaled

ABIドリフトの自動チェック

  • Linux: abi-dumper + abi-compliance-checkerでバージョン間のABI互換性をCI検証します。
  • Windows: 公開関数セット(.def)やヘッダの差分を監視し、呼び出し規約と引数型の不一致を検出します。

パッケージングと配布(pkg-config、バージョンポリシー)

pkg-configファイル

ビルドフラグを配布すれば利用側が簡単に設定できます。

INImylib.pc
# mylib.pc
prefix=/usr
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include

Name: mylib
Description: Example C-compatible C++ library
Version: 1.0.0
Libs: -L${libdir} -lmylib
Cflags: -I${includedir}

バージョンとsoname

ELFではlibmylib.so.1のようにMAJORをsonameに反映し、MINOR/PATCHは後方互換の範囲で増やします。

互換性を破る変更はMAJORを上げ、古いアプリが誤ってリンクしないようにします。

まとめ

C言語互換を保ちながらC++で実装する最短ルートは、公開境界を「純C」、内部実装を「C++」と明確に分離することです。

ヘッダではextern "C"ガード、固定幅整数とPOD、opaque pointer、明確な所有権とエラーコードといった原則を徹底します。

実装では例外を境界外に出さず、スマートポインタとRAIIで安全性と保守性を高めます。

ABI面では呼び出し規約・可視性・バージョニングを統一し、CIで「Cとしてコンパイルできること」と「ABI互換」を継続的に検証します。

これらのベストプラクティスを押さえることで、他言語FFI・プラグイン・組込みなど多様な現場に長く耐えるライブラリを、C++の表現力を損なわずに提供できます。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!