Skip to content
soblin edited this page Jun 4, 2019 · 2 revisions

modern_c_programming

Build Status Coverage Status

『モダンC言語プログラミング』の勉強記録

  • 書誌情報
  • 出版元リンク
  • 著者: 花井志生
  • 出版社: アスキードワンゴ

C言語でオブジェクト指向プログラミングを試みたり,デザインパターンをいくつか実装してみたりする

C言語とオブジェクト指向

概要

C言語でプログラムを書く際によくある

  • 数百行あるような関数や点在する同じような処理
  • get2()get3()のように全体としては同じような関数なのに中の一部だけが異なるためにす数字で区別をしている関数
  • なんとか内部処理を共通化できているものの、動作を制御するために大量の引数が必要な関数
  • 使用するデータがグローバル変数でつながっていて、独立性が皆無の関数群
  • お互いに依存しあっていて、個々にテストするのが困難な関数群

。プログラムを構造化したはずなのになぜか生まれてしまう。

このようなときにデザインパターンを利用できる。デザインパターンはオブジェクト指向言語を前提としているが、うまくやればC言語でも可能。

Cのモジュール化とオブジェクト指向

Cとモジュール化(stack1, stack2)

一番単純な実装。stackに利用するデータをグローバル変数として定義し、そのデータを叩くことを前提にpush()pop()を定義する。またそのimplで利用するisStackFull()isStackEmpty()static指定しておき、stack.c内部でのみ利用する。しかし複数のstackを利用することはできない。

構造体によるデータ構造とロジックの分離(stack3)

データを構造体にひとまとめにすることで複数のstackを簡単に構築できる。

 typedef struct { int top; const size_t size; int *pBuff; } Stack; bool push(Stack *p, int val); bool pop(Stack *p, int *pRet);

また以下のようなマクロによりC言語でもC++言語のコンストラクタのような初期化ができる。

#define newStack(buf){ \ 0, sizeof(buf) / sizeof(int), (buf) \ }

Cを用いたオブジェクト指向

これからやることは、stackにpush()する値をチェックするチェッカーを持たせて、特定の条件を満たすものだけを通すこと。まず始めに 特定の範囲内の値だけ通す チェッカー機能を持たせ、次に 任意の条件を満たすチェッカー機能 へと抽象化する。

一般にチェッカーは

  • その値(ここではint)を引数に取り
  • boolを返す関数

により実現される(trueを返したらpushできる)。またチェッカーの種類によってはチェック自体にデータが必要になることがある(例えば範囲を指定するときの上限と下限など)。そこでこの関数オブジェクトのようなものをデータへのvoidポインタと関数ポインタを持つ構造体Validatorにより実現する。

チェック機能付きスタック(stack4)

構造体にpushする値の範囲をデータとして持たせて、pushするときに利用する。

範囲チェック付きスタックの問題点(stack5)

前項でつくったstackは

  • 範囲チェックなしのスタックを生成した場合にもneedRangeCheckminmaxといったメンバ変数を持つ必要があり、メモリが無駄に消費される
  • スタック内にこれとは別のチェック機能を持たせたいと思った時に、さらに別のデータを追加しないといけないため、各インスタンスが使わない機能のためのデータを余計に持つことになる

という問題がある。とりあえずそのデータへのポインタを保持するようにすることで無駄なデータを持たなくて良いようにする。

チェック機能を汎用化する(stack6)

値のチェックを行う機能としては何も上下限だけとは限らない。例えば前回pushした値以上の値しかpushできない(ほんとに?とはなるが例として)ものも考えられる。そういう任意のチェックを行えるよう、より抽象化を行う。

オブジェクト指向と多態性

オブジェクト指向のポイントは、データとその処理を 両方とも まとめるところにある。Validatorという構造体の中に検証の処理(関数ポインタvalidate)と、その検証処理が使用するデータ(voidポインタpData)とをセットで切り出すことにより、検証処理が独立し、スタックの中にさまざまな検証処理をあとから付け加えることができるようになった。

オブジェクト指向の要件として他には、多態性が挙げられる。ここまでだと、push関数は値を検証するのにValidator->validateを利用するのみで、validateが実際に指している関数の中身は一切関知していない。

継承(stack7)

オブジェクト指向の重要な概念の一つとして継承がある。先ほどの例だと、もともとのValidatorがあり、それを拡張した(とはいっても関数ポインタを代入しただけだが)範囲チェックValidator、直前の値との検証Validatorという2つのValidatorが存在している。この場合、元々のValidatorのことを親、拡張したValidatorを子と呼ぶ。

先ほどの範囲チェックValidatorではpDataの指す先がRangeになっている。このような方は申し少し複雑になってくると面倒なことになる。例えばこの範囲チェックValidatorを拡張して、偶数あるいは奇数しか受け付けないようなValidatorを作るとする。この場合単純にRangeを拡張してしまうと

typedef struct{ const int min; const int max; const bool needOddEvenCheck; // trueなら偶奇チェックする const boll needToBeEven; // trueなら奇数でなければいけない } Range;

Rangeの位置づけが曖昧になる。Rangeは「範囲」という意味であるからここに偶奇のチェックが入るのは変だし、そのチェックが不要な場合は余計なメンバを2つ抱えることになってしまう。これをうまく解決するため、継承っぽいことをやってみる。

カプセル化

カプセル化はオブジェクトの持つ状態と振る舞いを一箇所に集め、外部とのインターフェイスを規定することで抽象化することを指す。ここで状態とは構造体の中の関数ポインタ以外のメンバ、振る舞いは関数ポインタが指している関数の動作。

fopenしてからそのファイルポインタをfreadし、最後にfcloseするみたいなことはよくあるが、Cプログラマは普通FILE構造体の中身については気にしない。そもそも<stdio.h>にかかれている実装内容は処理系により異なっており、ユーザーとしては仕様書に書かれているインターフェースだけが重要。implで使われているメンバや関数(staticにするのでは?)がどういう意味を持つのか知る必要もない。

オブジェクト指向言語ではデータを隠蔽する仕組みがあるが(C++のprivateやprotected)、C言語にはない。そういう場合はconst指定して書き換えないようにするとか、アンダースコア\_で始まるメンバは読み書きしないみたいな規則を設ける。

仮想関数テーブル

オブジェクトが保有する関数ポインタ自体が、メモリの無駄になる可能性がある。

tyepdef struct Foo { const int count; void (*func0)(struct Foo *This); void (*func1)(struct Foo *This); void (*func2)(strcut Foo *This); } Foo;

このようなオブジェクトを複数インスタンス化したとする。

Foo foo0 = {0, func0_impl, func1_impl, func2_impl}; Foo foo1 = {1, func0_impl, func1_impl, func2_impl}; Foo foo2 = {2, func0_impl, func1_impl, func2_impl};

この場合関数ポインタがメモリを無駄に使用しかねない。そこで仮想関数テーブルを導入することでこの無駄を排除できる。

typedef struct FooVtbl{ void (*func0)(struct Foo *This); void (*func1)(struct Foo *This); void (*func2)(struct Foo *This); } FooVtbl; static FooVtbl foo_vtbl = {func0_impl, func1_impl, func2_impl}; typedef struct Foo{ const int count; const FooVtbl *vptr; } Foo; Foo foo0 = {0, &foo_vtbl}; Foo foo1 = {1, &foo_vtbl}; Foo foo2 = {2, &foo_vtbl}

一方関数をcallするときはこの仮想関数テーブルを経由しないといけないので、記述が面倒になりうる。

Foo *pFoo; pFoo->vptr->func(pFoo);

まとめ

オブジェクト指向のエッセンスは多態、継承、カプセル化。

  • 多態を用いることで、振る舞いの異なるオブジェクトを同じように扱えるようになる
  • 継承は、一部のみが異なるコードの共通部分を取り出すことを容易にする
  • カプセル化によりオブジェクトの振る舞いと内部状態を一箇所に集めて抽象化を進め、扱いを容易にする

GUIライブラリなどではたとえC言語であってもオブジェクト指向的な設計になっていたりする。

C言語とデザインパターン

チェインオブレスポンシビリティパターン

前章では範囲チェックバリデータに対して継承によって偶数奇数チェックを行うチェック機能をスタックに追加した。しかし偶数奇数チェックだけでなく他のバリデータも追加したくなるかもしれない。

仮に2種類のバリデータAとBがあったら、これらを単独に使用するケース以外にA->B、B->Aの2ケースも考えられるので、これらを継承で解決しようとすると合計で4つのクラスが必要になる。バリデータの数が増えるとこの組み合わせの個数は爆発的に増えてしまう。

この問題にはChain of Responsibilityパターンを利用することで対処できる。このパターンではオブジェクトを数珠つなぎにしておいて、一方の端に処理を依頼する。処理を依頼されたオブジェクトは自分に処理が可能な処理であれば自分で処理して結果を返すが、もし自分には処理できないと判断したら次のオブジェクトに処理を依頼する。このようにしてオブジェクトをチェインしていく。

typedef struct ChaindValidator{ Validator base; Validator *pWapped; Validator *pNext; } ChainedValidator

ChainedValidatorはそれ自身検証機能を持たず、pWrappedpNextを用いて検証を行う。

bool ValidateChain(Validator *p, int val){ ChainedValidator *pThis = (ChainedValidator *)p; p = pThis->pWrapped; if(!p->validate(p, val)) return false; p = pThis->pNext; if(p == NULL) return true; return p->validate(p, val); }

処理の流れは

  1. まずpWrappedで指されれているバリデータを用いて検証し、falseが返ればそのままfalseを返す(検証失敗)。
  2. もしtrueを返したら、次にpNextを調べる。
  3. もしpNextがNULLだったら、今までのバリデータが全てtrueを返したことになる。よってtrueを最後に返す。
  4. もしpNextがNULLでなければpNextに処理を委ねる。

実装の注意点としては、このチェインにループが含まれてはいけないことである。

オブザーバーパターン

前節のコードで存在しないファイルを指定するとエラーメッセージが2回表示される。それは次のようにしてfile_errorが2回呼ばれるためである。

static bool reader(FileContext *p){ MyFileContext *pFileCtx = (MyFilecontext *)p; MyBufferContext *pBufCtx = pFileCtx->pBufCtx; long size = file_size(p); // ここで-1が返ってくる if(size == -1){ file_error(pBufCtx->pAppCtx); return false; } }

ここで1回目のエラーが表示される。次にfalseが返った後do_with_buffer内において再度

static bool do_with_buffer(BufferContext *p){ MyBufferContext *pBufCtx = (MyBufferContext *)p; MyFileContext readFileCtx = {{NULL, pBufCtx->pAppCtx->pFname, "rb", reader}, pBufCtx}; if(!access_file(&readFileCtx.base)){ file_error(pBufCtx->pAppCtx); return false; } }

のようにしてfile_errorが呼ばれるため2回目のエラーが表示されてしまう。どうも後者のコードは余分なように見える。

そもそもaccess_fileから呼び出されるユーザー関数はファイルアクセス以外の処理もするはずで、access_fileがfalseを返したからといって一律にそれをファイルエラーとして判定するのは乱暴。かといってaccess_fileではfclose()が呼ばれるのでここでのエラーを無視するわけにもいかない。

access_file関数内のfclose()呼び出し部分からfile_errorを呼びだせばよいのだろうか?しかしaccess_fileは共通関数でありfile_errorはアプリケーション関数であるから、file_errorの呼び出しをaccess_file内に埋め込むこともできない。つまりfclose()がエラーを起こすことは知りたいけれどエラー処理のコードを直接その場所に書くことはできないというジレンマを抱えている。このようにある場所での処理の状況を別の場所から管理したいけれど監視する側と監視される側をお互いに依存させたくない。このような場合にオブザーバーパターンを活用できる。

今回のファイル処理においては

bool access_file(FileAccessorContext *pThis){ assert(pThis); bool ret = pThis->processor(pThis); if(pThis->fp != NULL){ if(fclose(pThis->fp) != 0) ret = false; // !!!Candidate1!!! } return ret; } FILE *get_file_pointer(FileAccessorContext *pThis){ assert(pThis); if(pThis->fp == NULL) pThis->fp = fopen(pThis->pFname, pThis->pMode); // !!!Candidate2!!! return pThis->fp; }

これらの関数の中でエラーが起きる可能性があるのはCandidate1,2 であるから、これらのエラーを外部に通知できるようにする。まずFileAccessorContextにオブザーバーオブジェクトを登録できるようにする。

Stateパターン

組み込み系で重宝するのが、このStateパターン。組み込み系ではハードウェアの状態に応じて動作をするプログラムを書く必要があるので、状態に応じて何らかの分岐処理を実現する必要がある。

ここでは簡単なCDプレーヤを考えてみる。

ボタン 機能
[Play or Pause] 再生 / 一時停止
[Stop] 停止

状態遷移図(cd1, cd2)

複数の状態と入力によってどのように遷移するのかを表したものとして状態遷移図がある。

状態遷移表

この例ではplayflagpauseflagで状態を保持しているが、このようにフラグを使いはじめると、プログラムを拡張するたびに際限なくフラグが増えていって、すぐに手が負えなくなる。そしていつの間にかモンスターメソッドになってしまう。

このコードの問題点は、まず3つ以上の状態をフラグで管理している点にある。 フラグを使って良いのは2つの状態を管理する場合のみ である。状態が3つ以上ある場合はフラグではなく列挙体を使わなければならない。

状態遷移表

状態と入力の組み合わせを網羅するには、状態遷移図より状態遷移表を書いたほうが良い。

イベント / 状態 アイドル(ST_IDLE) 再生中(ST_PLAY) 一時停止中(ST_PAUSE)
停止(EV_STOP) 無視 アイドルへ アイドルへ
再生 / 一時停止(EV_PLAY_PAUSE) 再生へ 一時停止へ 再生へ

オブジェクト指向Stateパターン(cd3)

今の実装だと、onEvent()関数が(状態数 x 入力数)に応じて一気に肥大してしまう。そこでStateパターンを用いる。状態遷移図における各状態を「ノード」、EV_STOP、EV_PLAY_PAUSEによる移動先を「エッジ」のようにして、各ノードをグローバルに実体化しておき、現在の状態(あるいはデフォルトの状態)のノードを指すポインタにより「現在の状態はどれか」を表す。

複数の状態セットが関係するケース(cd4)

実際の機器では複数の状態セットが関係しているケースがある。例えばCDがそもそも入っているかいないかなど。しかし先程のコードの中にCDが入っているかいないかみたいな条件分岐を入れるのは好ましくない。そういう場合は、さらに状態を付け加え直す。

合成状態 状態セット1 状態セット2
アイドル(空) アイドル CD空
アイドル(有) アイドル CD有
NA 再生中 CD空
再生中 再生中 CD有
NA 一時停止中 CD空
一時停止中 一時停止中 CD有

この場合における状態遷移表は下のようになる。

イベント / 状態 IDLE_WITHOUT_CD IDLE_WITH_CD PLAY PAUSE
stop ignore() ignore() stopPlay() stopPlay()
playOrPause ignore() startPlay() pausePlay() resumePlay()
disk insertCD() removeCD() removeCD() removeCD()

テンプレートメソッドパターン

リソース(ファイル、メモリなど)は獲得したものを必ず開放しないといけないので管理がやっかい(fopen()->fclose(), malloc()->free())。例えば以下のような、指定されたファイルを読んで1行ごとに数字列を読み、その値の範囲を返すプログラム。

int range(const char *pFname){ FILE *fp = fopen(pFname, "r"); if (fp == NULL) return -1; int min = INT_MAX; int max = INT_MIN; char buf[256]; while(fgets(buf, sizeof(buf), fp) != NULL){ int val = atoi(buf); min = (min > val)? val : min; max = (max < val)? val : max; } fclose(fp); return (max - min); }

しかしもし「ファイルの中に空行が入っていたら-1を返す」という変更を行ったとしよう。ここで単純に

while(fgets(buf, sizeof(buf), fp) != NULL){ if(buf[0] == '\n') return -1; }

としてしまうとfclose()できないのでリソースリークが起きる。こういう場合、breakやgotoを用いて抜けるといった方法も考えられる。C言語でgotoが用いられている場合、多くはリソースの解法が絡んでいる。

前後に定形処理が必要なコード(template1)

リソースを扱うコードが複雑になるのは、リソースの管理コード(獲得と解放)がリソースを使うコードを挟んでいるため。このような場合にはテンプレートメソッドパターンが有効。プログラム中の一部の処理を関数(ポインタ)として差し替えられるようにすることでそれ以外の部分を定形処理として再利用できるようにする。

先ほどのプログラムで問題になっていた途中退室のケースも支障なく実装できる。リソースの管理はread_file関数に委ねられるので、range_processorをimplする人は、ファイルがNULLでないか否かを気にせず、openできている前提でロジックを書ける。このような設計パターンをloanパターンともいう。

int以外を返す

さらに抽象化してint以外を返せるようにしたい。今はread_fileにおいてprocessor関数の結果を返しているが、int以外も返したいとなると、単純に実装するとread_file関数が型の数だけ増えてしまう。そこで継承を利用することで任意の型に対して利用できるようになる。

実装の型としては、インターフェス型では 機能だけ を実装し、その関数(ポインタ)はそのインターフェス型へのthisポインタと、最低限の外から与える変数を引数に取るようにする。次にそのインターフェスを継承した自分用の構造体を作り、機能に必要なデータもメンバとして持たせる。そのときメンバ関数ポインタとしては(baseのメンバ関数ポインタ) その構造体へアップキャストして その構造体のデータを使うようにして実装した関数を登録すvる。

つまり、そのクラスを利用する関数(read_file)側としてはインターフェース型のprocessorを呼び出しているが、実際はその効果は継承型へとextendするイメージ。

figure1

他のリソースを扱う

今まではファイルを扱ったが、実行時にバッファリングが必要になる場合はリソースとしてメモリを扱うことになる。つまりmallocとfreeの間に処理を挟むことになる。

次に複数のリソースを扱う場合を考える。例えばファイルに入ったデータをメモリに読み込んでからソートして、別のファイルに書き込むケースを考えてみる。この場合の手順は以下のようになる。

  1. ファイルをオープン
  2. fseek, ftellでサイズを知る
  3. ファイルをクローズ
  4. ファイル分のメモリを確保
  5. ファイルをオープン
  6. ファイルを読み込んでメモリにコピー
  7. ファイルをクローズ
  8. メモリをソート
  9. ファイルをオープン
  10. ファイルに書き込みクローズ
  11. メモリを解放

まずファイルの読み込み、書き込みの部分はFileAccessContextというクラスに任せる。またメモリの管理はBufferCotextなるクラスに任せる。 FileAccessContextはメンバ変数としてファイル名とモード、ファイルに対する処理を表すメンバ関数processを持っている。BufferContextはバッファリングする領域とそのサイズ、バッファに対して行う処理を表すメンバ関数processを持っている。またaccess_file(AccessFileContext *)buffer(BufferContext *)はそのprocessをinvokeする関数(仮想関数呼び出し)である。

コンテキスト

前回の実装ではファイルサイズの取得のために一旦ファイルをオープンしてから閉じ、再度読み込みのために開いており、必ずしも最適な動作ではない。ここを一回のオープンとクローズにまとめられないだろうか(一応補足するとfopenにおけるモードを"ab"に指定することで読み込みと書き込みを一回で行えるがここでは行わない)。

残念ながら今回の実装ではリソースの獲得と解放がコの字に入れ子になっているため実現できない。

buffer関数は関数ポインタで指定されたメンバ関数を呼び出す前にリソースを確保して、ユーザー関数の処理が終わったらリソースを確保する。このためbuffer関数はユーザー関数を呼び出す前にバッファのサイズを知っている必要がある。しかしこれではアプリケーションの設計に制約を課してしまう。

そこでコンテキスト(Context)なるバッファの動的確保、解放という動作を行う「場」を提供するものでワンクッションおく。

ビジターパターン

バリデータの設定を表示できるようにしたいとする。例えばRangeValidatorならば"Range(0-9)"のように。一番簡単なのはバリデータ自身にこういった表示用の関数を追加することである。

typedef struct Validator{ bool (*const validate)(struct Validator *self, void *validate_data); void (*const view)(struct Validator *self, char *pBuf, size_t size); } Validator; typedef struct{ Validator base; const int min; const int max; } RangeValidator; typedef struct{ Validator base; int previousValue; } PreviousValidator; void viewRangeImpl(Validator *self, char *pBuf, size_t size); void viewPreviousImpl(Validator *self, char *pBuf, size_t size);

しかしこのような表示関数は本当にバリデータ自身が提供すべきだろうか?今回のようにひとつだけなら問題ないがこれから

  • バリデータの設定をファイルに保存する関数が必要になる
  • バリデータのチェインの中に同じバリデータが繰り返し出てくるような間違った構成を顕正するような機能が必要になる

といった可能せもある。

こうした少しでもバリデータに関係する機能をバリデータ自身に追加してしまうと、バリデータ本来の機能が薄れてしまう。このように何でもかんでもオブジェクトに詰め込んでしまうと独立性が失われ、オブジェクト指向のメリットが失われてしまう。

ここで以下のようにするとバリデータの表示結果を得たいとする。

void printfValidator(Validator *p, char *pBuf, size_t size);

この関数は受け取るバリデータに対してそのバリデータの型の固有の動作をさせなければならない(ポリモーフィズム)。ポインタではそれを判別できないので、ナイーブな解決策の一つとしてはバリデータにtype idを持たせることが考えられる。

typedef enum{ Range = 0, Previous } ValidatorType; typedef struct Validator{ const int type_id; bool (*const validate)(struct Validator *self, int val); void (*const view)(struct Validator *self, char *pBuf, size_t size); } Validator; void printValidator(Validator *object, char *pBuf, size_t size){ switch(object->type_id){ case Range: p = (RangeValidator *)object; p->view(object, pBuf, size); break; case Previous: p = (PreviousValueValidator *)object; p->view(object, pBuf, size); break; default: break; } }

しかしこうしたid管理は煩雑で、プログラムの拡張に伴って破綻しやすく、基本的には避けるべきである。そもそも外からオブジェクトの種類を調べて、それに応じて条件分岐をするのはオブジェクト指向のやり方に反している。

オブジェクトにtype idを持たせたくなったら黄色信号

オブジェクト指向ではポリモーフィズムにより、型に応じた条件分岐を削除する。ビジターパターンもこの方針に従う。