C言語でのオブジェクト指向設計

C++を採用できない環境でも、設計上の利点(変更容易性など)を得るために、C言語でオブジェクト指向的な構造を取ることがあります。カプセル化も多態性(ポリモーフィズム)も、C言語の範囲で表現できるためです。ただし、正しく記述するためには押さえておきたいポイントがあります。

この記事では、そのポイントを構造体と関数ポインタを中心に解説します。

C言語でオブジェクト指向を実現する動機

オブジェクト指向をC言語で書く理由は、流行への追随ではありません。コードの行数が多くなると、1箇所の変更が離れた場所に波及しやすくなり、どの関数がどの状態を書き換えるか追いにくくなります。

状態と操作関数を1つの型へ結び付けることにより、変更の影響をその型の内側にとどめやすくなります。既存のC資産を保守する場面でも、言語を移行することなくこの結び付け方を活用できます。

組み込みでC++を採用できない現場の事情

現場の事情は、技術的な制約と技術面以外の制約に分かれます。技術的な制約として、処理系がC++に対応していない、対応していても使える機能が限られるという事情があります。静的解析ツールがC言語しか扱えず、C++で書くと検証工程を通しにくい現場も少なくありません。

技術面以外の制約としては、社内のコーディング規約で使用言語をC言語に限定するケースが代表的です。またC言語とC++を比較したうえで、C言語を選ぶ判断もあり得ます。

構造体と関数ポインタでカプセル化と多態性を表現する

オブジェクト指向の基本的な要素としてあげられるのは「カプセル化」「多態性」「継承」の3つです。このうちカプセル化は、構造体と実装を隠すヘッダの書き方で表現できます。多態性は関数ポインタを束ねた構造体で、継承は親構造体を派生側の先頭メンバに埋め込む方法で表現できます。

なお、内部表現を公開しない情報隠蔽は、これら3つと並ぶ概念ではなく、カプセル化を支える実現手段に当たります。C言語では不透明型のポインタ(opaque pointer)で表現します。

構造体でクラス相当を表現する

C言語でオブジェクト指向を実現する方法は、いくつかのステップに分かれます。出発点は、構造体でクラス相当を表現するところです。ここでは、組み込みシステムでよく使われる通信ドライバを例に解説します。

型と状態を1つの構造体にまとめる

通信ドライバの状態とは、ドライバが動作中に保持し続けるデータのことです。レジスタの先頭アドレスや受信リングバッファなどが一例です。これらを1つの構造体へ収め、その構造体自体をドライバの型として扱います。構造体のメンバは、実体の状態を示します。

操作関数は第1引数にその構造体のポインタを取る形で統一します。同じ種類のデバイスが2つになっても、実体を2つ用意するだけで対応できます。なお公開APIでは、利用側へ渡す型を不透明型のポインタ(Driver*)とします。内部実装では具象構造体へのポインタ(struct DriverImpl*)を扱う2層構成にすると、どちらの層の話かが明確になります。

グローバル変数で状態を保持する場合は、実体を増やすたびに変数や操作関数が増えやすくなります。どの変数がどのデバイスのものかという対応関係が、構造体の内部で完結しない点にも注意が必要です。

初期化関数(コンストラクタ相当)と破棄関数を用意する

構造体を宣言しただけで初期化しない場合、初期値は不定となるおそれがあります。初期化関数で状態を組み立て、破棄関数で資源を返す定義をAPIで用意しておくと、呼ぶ側の手順ミスを防ぎやすくなります。C++のコンストラクタとデストラクタが担う役割を、C言語では明示的に関数として記述します。

呼び忘れに備えるには、構造体に初期化済みフラグを持たせる方法があります。ただし、自動変数として未初期化で置かれる可能性がある設計では、フラグの値が不定となるおそれがあるため不適切です。静的記憶域に置く、memsetでゼロ初期化する、利用側に = {0} を強制するといった前提を保証できる場合は成立しますが、保証しにくい場面では避けるほうが安全です。実体の宣言を利用側に任せず、初期化する関数がポインタを返す形にすると、初期化を経ていない実体を操作対象にしにくくなります。

関数ポインタで多態性を実現する

関数ポインタで処理を呼び分ける仕組みの図。呼び出し側は構造体opsのメンバreadとcalibrateを呼ぶ。readはuart_read()に、calibrateはspi_calibrate()に結び付いている。呼び出し側は実装を意識せずに処理を切り替えられることを示している。

構造体だけでは、異なる処理を定義し、呼び分ける仕組みは作成できません。この仕組みは、C言語の関数ポインタで実現できます。

仮想関数テーブル相当を関数ポインタ構造体で実装する

読み出し、書き込み、初期化といった操作を関数ポインタとして並べた構造体を用意します。この構造体を操作表(vtable相当)と呼びます。実装ごとに操作表の中身を差し替え、各実体は自身が用いる操作表へのポインタを保持する仕組みです。呼び出す側は操作表の構造体型を知る一方で、登録された関数の実体までは把握しません。

C++では、同じ仕組みをコンパイラが仮想関数テーブルとして自動生成するのが一般的です(実装詳細であり標準規格上の規定ではありません)。C言語では設計者が通常の構造体として定義し、初期化の際に中身を書き込みます。このように、関数ポインタによる間接関数呼び出しで、C++の仮想関数と同等の構造を設計できます。

LinuxカーネルのVFSに見る関数ポインタ集合の実装

代表例として、Linuxカーネルの仮想ファイルシステム(VFS:Virtual File System)があげられます。開いたファイルの操作方法を記述する構造体は操作表に相当し、読み出し、書き込み、シーク、メモリマップといった操作の関数ポインタを保持します。各ファイルシステムは自分の実装をこの操作表へ登録します。各オープンファイル(struct file)が操作表へのポインタを持ちます。VFSは操作表(f_op)経由でread/writeなどを呼び出します。

継承と隠蔽の表現とopaque pointer

残る2つ、継承と情報隠蔽にも定石があります。継承は継承元の構造体の埋め込みで、隠蔽は不透明型のポインタで表現します。

ヘッダに実装型を出さない

公開ヘッダには型名の宣言だけを置き、構造体の中身は実装ファイルの側で定義します。内部の持ち方を利用側から見えなくする書き方であり、これを情報隠蔽と呼びます。利用側が扱えるのはポインタだけになり、メンバへ直接触る書き方は成立しないことに注意が必要です。

C言語の規格では、内容が未知の構造体型を不完全型と定めています。この不完全型という位置づけが、opaque pointerの根拠です。メンバを増やすなど内部構造を変更した場合でも、ヘッダを変えない限り、利用側の再コンパイルが不要になります(特に共有ライブラリとして提供する場合)。

親構造体を1つ目のメンバに埋め込んで継承を表す

派生側の構造体で、先頭のメンバに親の構造体を埋め込みます。この配置であれば、派生オブジェクトの先頭アドレスは親メンバのアドレスと一致するため、親型のポインタとして扱いやすくなります。ただし「継承」を言語機能として保証するものではありません。先頭以外へ埋め込んだ場合、このキャストは未定義動作となる点に注意が必要です。

大規模オープンソース実装に見るCでのオブジェクト指向プログラミング

ここからは、C言語だけで型システムやイベント機構を組み上げた例を紹介します。

シグナルとプロパティと型システムをC言語で構築した実装

デスクトップ環境の基盤として使われているライブラリに、C言語だけで型システムを構築した例があります。GLibのGObject(C言語によるオブジェクトシステム)がこれに当たります。公式ドキュメント「GObject Reference Manual」は、GObjectを基盤となる型システムおよびオブジェクトクラスと位置づけ、プレーンなC言語で実装されていると述べています。型の登録、プロパティ、そして通知の仕組みであるシグナルまでが、言語機能の追加なしに組み上げられています。Observerに相当する通知をライブラリとして提供している点は、自社でイベント機構を設計する際の参考になります。

Active Objectと階層型ステートマシンをC言語で実装する

組み込みに近い題材の場合、Active Objectと階層型ステートマシンをC言語で実装したフレームワークが読みやすい教材です。公式ドキュメントでは、このフレームワークが非同期でイベント駆動かつノンブロッキングな計算モデルを掲げていると説明されています。Active Objectの振る舞いは、階層型ステートマシンで記述するとされています。オブジェクト指向のフレームワークでありながら、規格(ISO C11)に準拠したC言語で書かれている点も参考になります。構造体と関数ポインタだけで、ここまでの構造を組めるという実例です。

組み込みソフトの世界 トップへ戻る