はじめに
これは株式会社ビットキー Advent Calendar 2022 2日目の記事です。
本記事ではC言語でカプセル化を実現するための実装パターンを紹介します。
想定読者としているのは、C言語のポインタの扱いが分かるくらいの初学者で、かつ何かしらのオブジェクト指向プログラミングがサポートされた言語に習熟している方です。
C言語でのオブジェクト指向プログラミングの実践
今日のソフトウェア開発ではオブジェクト指向分析設計が不可欠ですが、オブジェクト指向プログラミングの実践にあたって言語仕様のサポートは必須ではありません。私の体験ベースでは、C言語が使われるプロジェクトでもオブジェクト指向分析設計が用いられることがほとんどでした。
C言語は言語仕様レベルではオブジェクト指向プログラミングをサポートしていません。言い換えると、C言語にはオブジェクト指向分析設計におけるオブジェクトを画一的に表現するための文法が存在しません。その代わり、プログラマがコードに与える意味づけが重要になります。「これは抽象データ型だぞ」とか「これは触っちゃいけない変数だぞ」などといった強制力のないお約束ごとの表明を、プログラマ自身でするということです。
世の中で実践されているC言語のオブジェクト表現は、いくつかの限定された実装パターンがあって、暗黙的に意味づけされていることが多いように感じます。ということは、それらの実装パターンを知っていれば、世の中の多くの人との共通認識を得られそうです。それを得ることで、知らない誰かが書いたコードを読むときに「これはクラスを表現しているんだな。ということは凝集させたいデータと手続きがここに詰まっているんだな」「これは抽象インターフェースを表現しているんだな。ということはいくつかの実装が用意されていて何かを条件に切り替えるんだな」といった具合にコード展開を予想することで素早く理解できたり、逆に自身がコードを書くときもその共通認識に則ることで他者から理解してもらいやすくできるのではないでしょうか。
この記事ではオブジェクト指向プログラミングの概念の1つであるカプセル化の、C言語での実装パターンを紹介します。オブジェクト指向プログラミングがサポートされた言語に慣れた方は「プログラマ自身によるオブジェクトの意味付け」を面倒くさそうだと思われるかもしれません。実際それは正しいです(が、いざやってみるとそう大変ではなかったりします)。
そういった方への気つけ、とまではならないかもしれませんが、以前私が開発者としての心構えに示唆を得たクリーンコードの一節を紹介します。
美しいコードは、その言語がまるでその問題を解決するために作られたかのように見せると彼は述べています。つまり、その言語を単純に見せるのは、我々の責務なのです。言語おたくはどこにでもいます。気をつけましょう!言語がプログラムを単純に見せるのではありません。プログラマが、言語を単純に見せるのです!
Robert C.Martin,花井 志生. Clean Code アジャイルソフトウェア達人の技 (Japanese Edition) (p.38).
カプセル化について
カプセル化の説明は下記がわかりやすいです。
カプセル化【encapsulation】
カプセル化とは、オブジェクト指向プログラミングにおいて、互いに関連するデータの集合とそれらに対する操作をオブジェクトとして一つの単位にまとめ、外部に対して必要な情報や手続きのみを提供すること。外から直に参照や操作をする必要のない内部の状態や構造は秘匿される。
IT用語辞典 e-Words
https://e-words.jp/w/%E3%82%AB%E3%83%97%E3%82%BB%E3%83%AB%E5%8C%96.html
カプセル化によってオブジェクトの詳細を隠蔽し、開発者の関心の分離を促進させることができます。プログラムのデータや振る舞いを人間が扱いやすい単位にまとめたり分割したりして整理するための手法です。
C++やJavaなどではカプセル化は"クラス"の言語機能でサポートされています。C言語でもこのクラスに近いものを実現することができ、カプセル化のメリットを得られます。
パターン1 基本形
#pragma once
typedef struct
{
int num1;
int num2;
} sample_class_t;
// インスタンス初期化
void sample_class_init(sample_class_t* instance, int initialize_value);
// 設定
void sample_class_set_num(sample_class_t* instance, int num);
// 計算結果取得
int sample_class_get_sum(const sample_class_t* instance);
#include "sample_class.h"
void sample_class_init(sample_class_t* instance, int initialize_value)
{
instance->num1 = initialize_value;
instance->num2 = 0;
}
void sample_class_set_num(sample_class_t* instance, int num)
{
instance->num2 = num;
}
static int get_sum(const sample_class_t* instance)
{
return instance->num1 + instance->num2;
}
int sample_class_get_sum(const sample_class_t* instance)
{
return get_sum(instance);
}
#include <stdio.h>
#include "sample_class.h"
int main(void)
{
sample_class_t instance1;
sample_class_t instance2;
sample_class_init(&instance1, 10);
sample_class_init(&instance2, 20);
sample_class_set_num(&instance1, 100);
sample_class_set_num(&instance2, 200);
printf("instance1 %d\n", sample_class_get_sum(&instance1));
printf("instance2 %d\n", sample_class_get_sum(&instance2));
return 0;
}
instance1 110
instance2 220
sample_class.hのうち、sample_class_t
という構造体がクラスのメンバ変数を保持する役割を、sample_class_t
を引数に取る関数がクラスのメンバ関数(メソッド)の役割を、それぞれ担っています。
C++やJavaなどではメンバ関数呼び出しはx.f()
という形ですが、C言語ではf(x)
が基本です。もちろん構造体メンバに関数ポインタを持たせることでx.f()
の形にすることもできますが、それだとインスタンスの初期化時に関数ポインタの初期化をする必要があるというのと、全てのインスタンスに同じ情報を持たせることになるのでメモリ効率の観点で望ましくないというのとで、デメリットが大きいように思います。
C++やJavaなどではクラスのメンバ変数やメンバ関数へのアクセスを制限するための"アクセス修飾子"という機能があり、それに則ってコーディングされていなければコンパイルでエラーにしてくれます。"public"とか"private"とかがそうですね。一方、C言語にはその機能はありません。したがって、定義が見えている構造体メンバについては全て参照することができてしまいます。上記例で言うと、クラス利用者であるpattern1.cはinstance1
、instance2
のすべてのメンバ変数にアクセスできてしまいます。ということで、隠蔽したいメンバは命名で意味付けするなどして、クラス利用者からアクセスされないように開発者自身が気をつけなければいけません。
一方、メンバ関数にあたるものについてはstatic関数とすることで利用者側から隠蔽することが可能です。sample_class.cのget_sum
関数がそれにあたります。
パターン2 構造体の隠蔽
既に述べたとおり、パターン1ではインスタンスのメンバ変数にアクセスし放題ですし、そもそも公開ヘッダにメンバ変数が見えちゃっているので、隠蔽している感が薄いです。
なぜ公開ヘッダに構造体のメンバを書き連ねる必要があるのかというと、構造体の実体を定義した翻訳単位のコンパイルを通すためには、型のサイズやメンバ配置の情報が必要になるからです。C言語では不完全型(=サイズやメンバ配置が不明である型のこと)の実体を定義できません。
しかし逆に言うと、利用する側で実体を定義する必要さえなければ構造体の詳細は見えなくて良いということです。そういうわけで構造体定義を隠蔽した実装が下記になります。
#pragma once
typedef struct sample_class2 sample_class2_t;
// インスタンス生成
sample_class2_t* sample_class2_construct(int initialize_value);
// インスタンス破棄
void sample_class2_destruct(sample_class2_t* instance);
// 設定
void sample_class2_set_num(sample_class2_t* instance, int num);
// 計算結果取得
int sample_class2_get_sum(const sample_class2_t* instance);
#include <stdlib.h>
#include "sample_class2.h"
struct sample_class2
{
int num1;
int num2;
};
sample_class2_t* sample_class2_construct(int initialize_value)
{
sample_class2_t* instance = malloc(sizeof(sample_class2_t));
if (instance)
{
instance->num1 = initialize_value;
instance->num2 = 0;
}
return instance;
}
void sample_class2_destruct(sample_class2_t* instance)
{
free(instance);
}
void sample_class2_set_num(sample_class2_t* instance, int num)
{
instance->num2 = num;
}
static int get_sum(const sample_class2_t* instance)
{
return instance->num1 + instance->num2;
}
int sample_class2_get_sum(const sample_class2_t* instance)
{
return get_sum(instance);
}
#include <stdio.h>
#include "sample_class2.h"
int main(void)
{
sample_class2_t* instance1 = sample_class2_construct(10);
if (!instance1)
{
return -1;
}
sample_class2_t* instance2 = sample_class2_construct(20);
if (!instance2)
{
return -1;
}
sample_class2_set_num(instance1, 100);
sample_class2_set_num(instance2, 200);
printf("instance1 %d\n", sample_class2_get_sum(instance1));
printf("instance2 %d\n", sample_class2_get_sum(instance2));
sample_class2_destruct(instance1);
sample_class2_destruct(instance2);
return 0;
}
instance1 110
instance2 220
構造体の前方宣言だけを公開ヘッダ(sample_class2.h)に、定義は実装ファイル(sample_class2.c)に記述しています。コンストラクタ関数内でメモリ確保して、そのポインタをクラス利用者(pattern2.c)へ返します。
クラス利用者(pattern2.c)から見たsample_class2_t
は不完全型なので、そのメンバ変数にアクセスすることができません。パターン1よりも隠蔽できている感がありますね。
ただし、この実装パターンは動的なメモリ確保ができる前提です。ヒープの利用に制限があることも多い組込みシステムのプロジェクトなどでは導入しづらいかもしれません。
パターン3 静的クラス
データと手続きを凝集させる目的でカプセル化したいけど複数の実体は不要である、そういう場面で使われるのが他言語でいうところの静的クラスです。これは要するにインスタンスを生成できない特殊なクラスのことです。
C++やJavaにおける"クラス"の言語機能には「インスタンスの生成」も含まれますが、これはカプセル化とは異なる概念です。インスタンスの生成をしない静的クラスは、より純粋なカプセル化の表現であると言えます。
C言語での実装例を示します。
#pragma once
// 初期化
void sample_class3_init(int initialize_value);
// 設定
void sample_class3_set_num(int num);
// 計算結果取得
int sample_class3_get_sum(void);
#include "sample_class3.h"
typedef struct
{
int num1;
int num2;
} sample_class3_t;
static sample_class3_t sample_class3;
void sample_class3_init(int initialize_value)
{
sample_class3.num1 = initialize_value;
sample_class3.num2 = 0;
}
void sample_class3_set_num(int num)
{
sample_class3.num2 = num;
}
int sample_class3_get_sum(void)
{
return sample_class3.num1 + sample_class3.num2;
}
#include <stdio.h>
#include "sample_class3.h"
int main(void)
{
sample_class3_init(100);
sample_class3_set_num(2000);
printf("%d\n", sample_class3_get_sum());
return 0;
}
2100
パターン1やパターン2と比較すると、利用者に公開する情報や実装の記述が少なく、プログラマによる意味づけやお約束ごともほぼ不要です。表現したいことが言語機能の中にすっぽり収まっているような感覚がありますね。これがC言語における最も自然なカプセル化の表現のように思えます。
ちなみに静的クラスはよくSingletonパターンと比較されがちです。パターン1やパターン2を使えば、SingletonパターンもC言語で実現可能です。それぞれの特性を理解した上で適切に使い分けると良いでしょう。
おまけ:世間での実装例
Linuxカーネルでもこの方式で実装されている部分があるよ、という紹介です。今回はinclude/linux/pci.hを例にとって紹介します。(他にもっとコンパクトで良い例があると思うのですが、直近で見ていたのがこれなので・・・)
このヘッダにはstruct pci_devという構造体が定義されています。これがクラスにおけるメンバ変数を保持する役割を担っています。
/* The pci_dev structure describes PCI devices */
struct pci_dev {
struct list_head bus_list; /* Node in per-bus list */
struct pci_bus *bus; /* Bus this device is on */
struct pci_bus *subordinate; /* Bus this device bridges to */
// 長すぎるので中略
u16 acs_cap; /* ACS Capability offset */
phys_addr_t rom; /* Physical address if not from BAR */
size_t romlen; /* Length if not from BAR */
/*
* Driver name to force a match. Do not set directly, because core
* frees it. Use driver_set_override() to set or clear it.
*/
const char *driver_override;
unsigned long priv_flags; /* Private flags for the PCI driver */
/* These methods index pci_reset_fn_methods[] */
u8 reset_methods[PCI_NUM_RESET_METHODS]; /* In priority order */
};
さらにこのヘッダには下記のように、struct pci_devを引数に取る関数群があります。これらがクラスにおけるメソッドの役割を担っています。
int pci_read_config_byte(const struct pci_dev *dev, int where, u8 *val);
int pci_read_config_word(const struct pci_dev *dev, int where, u16 *val);
int pci_read_config_dword(const struct pci_dev *dev, int where, u32 *val);
int pci_write_config_byte(const struct pci_dev *dev, int where, u8 val);
int pci_write_config_word(const struct pci_dev *dev, int where, u16 val);
int pci_write_config_dword(const struct pci_dev *dev, int where, u32 val);
int pcie_capability_read_word(struct pci_dev *dev, int pos, u16 *val);
int pcie_capability_read_dword(struct pci_dev *dev, int pos, u32 *val);
int pcie_capability_write_word(struct pci_dev *dev, int pos, u16 val);
int pcie_capability_write_dword(struct pci_dev *dev, int pos, u32 val);
int pcie_capability_clear_and_set_word(struct pci_dev *dev, int pos,
u16 clear, u16 set);
int pcie_capability_clear_and_set_dword(struct pci_dev *dev, int pos,
u32 clear, u32 set);
おわりに
C言語でのカプセル化の実現方法としていくつかの実装パターンを紹介しました。
C言語は他にも、オブジェクト指向プログラミングにおける継承や多態性、さらには他言語で提供されているクロージャやジェネリックといった機能を、完全ではないにせよ、実現できてしまいます。いずれも開発現場で使われることがある重要な実装パターンです。
明日の3日目の株式会社ビットキー Advent Calendar 2022は@ksk-takaが担当します。