LoginSignup
5
4

More than 5 years have passed since last update.

組込みに近いものをTDDで開発してみる〜インタフェース継承編〜

Last updated at Posted at 2018-03-25

googletest/googlemockを使って、組込みLinux上で動作するソフトウェアをTDDで開発しています。
実際にTDDで開発している手順をリアルタイムでそのまま書いているため、行き当たりばったりな部分があります
過程も楽しんで頂ければ幸いです。
不明な点、間違っている点があれば、コメント下さい。励みになります。

これまでの経緯を詳しく知りたい方は、前回までの記事をご覧下さい。
組込みに近いものをTDDで開発してみる〜準備編〜
組込みに近いものをTDDで開発してみる〜問題提起編〜
組込みに近いものをTDDで開発してみる〜file open編〜
組込みに近いものをTDDで開発してみる〜libevdev初期化/終了処理編〜
組込みに近いものをTDDで開発してみる〜キー入力検知編〜
組込みに近いものをTDDで開発してみる〜中間振り返り編〜

実装が楽しすぎて、記事に書き留めるのを忘れていました。
今回の内容は全て事後から振り返った内容になります。

前回、仕様変更して、次のような仕様を実現することを決定しました。

  • キーボードの「A」ボタンを押すと、caps lockのLEDが点灯し、5秒後に消灯する。消灯するまでは、「A」キー押下を無視する。

今回やっていることは、タイマ機能の追加です。
ただし、技術要素としてはインタフェース継承が主な内容となったため、タイトルはインタフェース継承編としました(ドラフト段階では、タイマ機能追加編でした)。

関数ポインタとC言語における継承を取り扱うので、その辺りの知識があやしい方は少し知識の補完をお願いします。

タイマ機能をどう使いたいか

さて、タイマの実装をしていく。
とりあえず、タイマをどう使いたいか考えてみる。

今回は、オーソドックスなポーリング型で検出する。検出粒度はミリ秒単位とした。

さて、API利用方法を模したテストを書いてみる。次のような使い方ができると良さそうだ。

TEST_F(TimerEventTest, AbstractUse) {
  TimeoutDetector detector = CreateTimeOutDetector();
  SetTimeOutInterval(detector, 5000);  // ミリ秒
  EXPECT_NE(nullptr, detector);
  // タイマ開始
  EXPECT_EQ(DETECTOR_SUCCESS, StartTimeoutDetector(detector));

  // 11回目で5000ミリ秒経過したと仮定する
  for (int i = 0; i < 10; i++)
    EXPECT_EQ(DETECTOR_EVENT_NOT_DETECTED, CheckTimeout(detector));
  EXPECT_EQ(DETECTOR_EVENT_DETECTED, CheckTimeout(detector));

  EXPECT_EQ(DETECTOR_SUCCESS, CleaupTimeoutDetector(detector));
}
  • (インスタンス作成)
  • 条件(タイマインターバル)設定
  • タイマスタート
  • タイムアウトチェック
  • 終了処理
  • (インスタンス破棄)

なんとなく予想していたが、これはキー入力検出と同じ流れだ。このままタイマを独自に作ると、利用側では、キー入力検出をするコードと、タイマを利用するコードとは、ほぼ同じになってしまう。
このようなほぼ同じものはうまくまとめて、重複をなくすべきだ(コードはDRYに!)。
そこで、抽象化のお時間だ。

今回はEventDetectorという抽象を導入することにした。
共通化できるインタフェースは、次の4つだろうと、この時点では考えた。

  • 検出条件設定
  • 検出開始
  • イベント検出チェック
  • 終了処理

どうだろうか?
結論から言うと、検出条件設定は必要なかった。
実装を進める中で、検出条件は、各派生クラスのインスタンス生成時に与えれば良いと考えるようになった。

なぜならば、派生クラスごとに検出条件のデータ構造が異なるからだ。
キー入力検出では、input_event構造体を検出条件として与えていた。タイマでは、int型だ。
例えば、検出条件設定をインタフェースとして抽出すると、検出条件はvoidポインタで渡すくらいしか選択肢がない。
これでは、派生クラスの型を知っていなければ、利用できないインタフェースになってしまう。例えば次ような具合だ。

// このような条件設定用のインタフェースが定義されているとする
void SetCondition(EventDetector super, void *condition);

// 利用側のコード
EventDetector detector = CreateTimeOutDetector();
int five_sec = 5000;
// detectorがTimeoutDetectorであることを知っている必要がある
SetCondition(detector, &five_sec);

これでは、せっかくEventDetectorという抽象を作ったのに、結局、派生クラスを把握していなければ使えないことになってしまう。
これは頂けない。

その代わり、必ず派生クラスを意識する必要があるCreate関数で、各派生クラス固有の検出条件を渡してもらうことにする。
これで、インタフェースは、派生クラスを意識せずに利用できる。

EventDetectorインタフェースの抽出

最終的に、EventDetectorインタフェースは次のようになった。

event_detctor.h
typedef struct EventDetectorInterfaceStruct *EventDetectorInterface;
typedef struct EventDetectorStruct *EventDetector;

typedef struct EventDetectorStruct {
  EventDetectorInterface vtable;
} EventDetectorStruct;

typedef struct EventDetectorInterfaceStruct {
  int (*Start)(EventDetector);
  int (*CheckEvent)(EventDetector);
  int (*Cleanup)(EventDetector);
} EventDetectorInterfaceStruct;

int StartEventDetector(EventDetector detector);
int CheckEvent(EventDetector detector);
int Cleanup(EventDetector detector);
event_detector.c
#include <detector/event_detector.h>

int StartEventDetector(EventDetector detector) {
  if (detector == NULL) return DETECTOR_ERROR;
  return detector->vtable->Start(detector);
}

int CheckEvent(EventDetector detector) {
  if (detector == NULL) return DETECTOR_ERROR;
  return detector->vtable->CheckEvent(detector);
}

int CleanupEventDetector(EventDetector detector) {
  if (detector == NULL) return DETECTOR_ERROR;
  return detector->vtable->Cleanup(detector);
}

いきなり関数ポインタが出てきたが、C言語でインタフェース継承を実現するには不可欠の要素だ。
EventDetectorを継承する派生クラスは、Start, CheckEvent, Cleanupの3つの関数を実装する。
それぞれの派生クラスは、StartEventDetector(), CheckEvent(), CleanupEventDetector()関数を経由して、各派生クラス固有に実装された関数を呼び出す。

以降の派生クラスでの使われ方を見た方が、動作の理解が進むと思うため、ここでわからなくても、以降と合わせて見ていって欲しい。

KeyInputDetectorの修正

EventDetectorインタフェースを継承するように、KeyInputDetectorを修正していく。
ここで、支えとなるのは、これまで作成したKeyInputDetectorのテストだ。
インタフェースが変更になる部分は、テストコードも修正する必要がある。
それ以外の部分では、こまめにテストが壊れていないか、確認しながら進めて行く。

まず、インスタンス生成用のインタフェースを修正する。
ここで、初期化および検出条件設定に必要なデータをパラメータで渡すようにした。
この時点では、SetKeyInputDetectCondition()も残っているため、テスト側の呼び出しも正しく修正できていれば、テストは問題なくパスする。

key_input_detector.h
EventDetector CreateKeyInputDetector(const char *device_file,
                                     const struct input_event *condition);

次に、テストから1つをピックアップし、SetKeyInputDetectCondition()の呼び出しを削除する。
当然、SetKeyInputDetectCondition()を削除したテストは失敗する!

そこで、SetKeyInputDetectCondition()で行っている処理を、Create()にコピーする。

void SetKeyInputDetectCondition(KeyInputDevice dev,
                                const struct input_event *ev) {
  // この処理をCreate()にコピーする。
  memcpy(&dev->target_event, ev, sizeof(struct input_event));
}

これで、SetKeyInputDetectCondition()の呼び出しを削除したテストも再びパスするようになる。
それでは、一気にSetKeyInputDetectCondition()の呼び出しをテストコードから削除する。
テストをビルドして、実行!
テストは全てパスする。これでSetKeyInputDetectCondition()はもう削除しても大丈夫だ!

さて、お次は、EventDetectorのインタフェースを継承して、実装していく。
まずは、KeyInputDetectorStructを次のように修正する。

typedef struct KeyInputDetectorStruct {
  // EventDetectorStructを先頭要素に追加する
  EventDetectorStruct base;
  int fd;
  struct libevdev *evdev;
  struct input_event target_event;
  const char *device_file;
} KeyInputDetectorStruct;

このとき、EventDetectorStructを先頭要素に追加するのが肝だ。
EventDetectorStructのポインタを受け取った基底クラスは、KeyInputDetectorStructのEventDetectorStructだけをそのまま利用することができる。
これはC言語のメモリ配置を活用している(KeyInputDetectorStructもEventDetectorStructもメモリの開始位置が同じになる)。

次は、インタフェースを実装する。
とは言え、今までに作った関数はほぼそのままだ。
それぞれの関数が、EventDetectorInterfaceStructのメンバである関数ポインタ経由で呼び出されるように、インタフェースを実装する。

static EventDetectorInterfaceStruct interface = {
  .Start = InitKeyInputDetector,
  .CheckEvent = CheckKeyInputEvent,
  .Cleanup = CleanupKeyInputDetector
};

EventDetector CreateKeyInputDetector(const char *device_file,
                                     const struct input_event *ev) {
  KeyInputDetector self = calloc(1, sizeof(KeyInputDetectorStruct));
  // インタフェースを登録する
  self->base.vtable = &interface;
...
}

ここまで準備ができれば、テストからの呼び出し方を変更する。
今までは、InitKeyInputDetector()を直接呼び出していた。
これを、EventDetectorのインタフェースであるStartEventDetector()経由で呼び出して、テストが通ることを確認する。

古いテスト
  // InitKeyInputDetector()を直接呼び出し
  EXPECT_EQ(DETECTOR_SUCCESS, InitKeyInputDetector(detector_));
新しいテスト
  // StartEventDetector()経由で呼び出し
  EXPECT_EQ(DETECTOR_SUCCESS, StartEventDetector(detector_));

関数のシグネチャが異なるため、コンパイルエラーとなる(テスト側でキャストすればそのままビルドできて、テストもパスするはずだが、今回はこの段階でちゃんと実装することにした)。

古いコード
// KeyInputDetectorを引数に取る
int InitKeyInputDevice(KeyInputDetector detector) {
  if(detector == NULL) return INPUT_DEV_INVALID_DEV;

  detector->fd = IO_OPEN(detector->device_file, O_RDONLY|O_NONBLOCK);
新しいコード
// 基底クラスのEventDetectorを引数に取る
int InitKeyInputDetector(EventDetector super) {
  if(super == NULL) return DETECTOR_ERROR;
  KeyInputDetector self = (KeyInputDetector)super;

基底クラス型が関数の引数として渡されるので、これを自分の派生クラス型にキャストしてあげれば、これまで通り、KeyInputDetector型として利用することができる。
これで、StartEventDetector()インタフェースに置き換えたテストが成功するようになる。

全てのインタフェースを置き換えれば、もうInitKeyInputDetectorなどは、外部モジュールから呼び出す必要がなくなる。
ヘッダファイルから削除し、static指定子を追加して、外部から呼び出せないようにしておこう。

key_input_detector.c
static int CheckKeyInputEvent(EventDetector super) {
...
}

static int InitKeyInputDetector(EventDetector super) {
...
}

static int CleanupKeyInputDetector(EventDetector super) {
...
}

static EventDetectorInterfaceStruct interface = {
  .Start = InitKeyInputDetector,
  .CheckEvent = CheckKeyInputEvent,
  .Cleanup = CleanupKeyInputDetector
};

さて、これで、KeyInputDetectorはEventDetectorインタフェースを継承した実装になった。
タイマの実装に移るとしよう。

TimeoutDetector

TimeoutDetectorもEventDetectorインタフェースを継承する。

まずは、時刻の取得方法を考える。
ユニットテストで便利なように、タイマの値を次のラッパ関数から取得するようにし、テストではモックに置き換える。

  uint32_t GET_MSEC_OF_DAY();

このGET_MSEC_OF_DAY()の実装は、OSやハードの環境によって異なる可能性がある。
プロダクトコードは、このラッパ関数で環境の差異を吸収できる。
テストに良し、プロダクトに良し、で一石二鳥だ。

Linuxでの、プロダクトコード実装例は次のようになる。
正確な現在時間を取得したいわけではないので、細かいことは考えていない(ので深くは突っ込まないでね)。

time.c
uint32_t GET_MSEC_OF_DAY() {
  struct timeval tv;
  gettimeofday(&tv, NULL);
  return (uint32_t)((tv.tv_sec*1000) + (tv.tv_usec*0.001));
}

さて、タイマのテストを考える。
5秒でタイムアウトすることをテストするコードは次のようになる。
ユニットテストで、実際に5秒待つようなテストを書くと、テストの実行速度が低下する。
そこで、モックを使って、5秒経過した状態を擬似的に再現する。

TEST_F(TimerEventTest, DetectTimeOut) {
  EXPECT_CALL(*mock_time, GET_MSEC_OF_DAY())
    .WillOnce(Return(0))
    .WillOnce(Return(4999))
    .WillOnce(Return(5000));

  detector_ = CreateTimeOutDetector(5000, TIMER_ONE_SHOT);
  StartEventDetector(detector_);  // GET_MSEC_OF_DAY() returns "0"
  EXPECT_EQ(DETECTOR_EVENT_NOT_DETECTED, CheckEvent(detector_));  // "4999"
  EXPECT_EQ(DETECTOR_EVENT_DETECTED, CheckEvent(detector_));  // "5000"
}

StartEventDetectorでは、起点となる時間を取得している。CheckEventでは、その起点となる時間からの経過時間を調べる。

Create()以外は、EventDetectorのインタフェースを使っていることに着目してほしい。つまり、KeyInputDetectorと同じ使い方ができる、ということだ。

さて、このテストはビルドすらできない。さっそくプロダクトコードの実装に取り掛かろう。
ヘッダに晒す情報は、次の通りだ。

timeout_detector.h
struct TimeOutDetectorStruct;
typedef struct TimeOutDetectorStruct *TimeOutDetector;

enum {
  TIMER_ONE_SHOT = 0,
  TIMER_REPEATEDLY = 1,
};

EventDetector CreateTimeOutDetector(const uint32_t interval_msec,
                                    const int32_t flag);
void DestroyTimeOutDetector(EventDetector self);

タイマでは、1回だけタイムアウトを検出するか、繰り返し検出するか、という使い分けを良くする。
その使い分けは、Create()時のフラグで制御することにした。
APIは作ったが、この機能はまだ未実装だ。やっているうちに繰り返しの機能が必要になれば、実装する。
このような不必要な機能を作ることは、TDDのやり方ではない。結局、このフラグは使っていないので、無駄なものを作ったことになる。後で一人で反省しておく。

TimeoutDetectorのデータ構造は次のようになった。
素直な実装で、特に不明瞭な点はないかと思う。

timeout_detector.c
typedef struct TimeOutDetectorStruct {
  EventDetectorStruct base;
  uint32_t interval_msec;
  int32_t flag;
  uint32_t start_time;
  bool timer_started;
} TimeOutDetectorStruct;

Create(), Destroy()関数は次の通りだ。
EventDetectorインタフェースを実装する関数は、この後順番に見ていく。

timeout_detector.c
static EventDetectorInterfaceStruct interface = {
  .Start = StartTimeOutDetector,
  .CheckEvent = CheckTimeOut,
  .Cleanup = CleanupTimeOutDetector
};

EventDetector CreateTimeOutDetector(const uint32_t interval_msec,
                                    const int32_t flag) {
  TimeOutDetector detector = calloc(1, sizeof(TimeOutDetectorStruct));
  detector->base.vtable = &interface;
  detector->interval_msec = interval_msec;
  detector->flag = flag;
  detector->start_time = 0;
  detector->timer_started = false;

  return (EventDetector)detector;
}

void DestroyTimeOutDetector(EventDetector self) {
  free(self);
}

まず、Start()インタフェースを実装する。
この中で、GET_MSEC_OF_DAY()を呼び出し、インスタンスのstart_timeに保持しておく。
与えられたフラグが正しいものかどうか、はヘルパー関数として抽出した。
最初は、StartTimeOutDetector()内にあったが、ifの条件式が見苦しくなったので、リファクタリングした。

timeout_detector.c
static bool IsValidFlag(int32_t flag) {
  return (flag == TIMER_ONE_SHOT || flag == TIMER_REPEATEDLY);
}

static int StartTimeOutDetector(EventDetector super) {
  TimeOutDetector self = (TimeOutDetector)super;
  if (!IsValidFlag(self->flag)) return DETECTOR_ERROR;
  self->start_time = GET_MSEC_OF_DAY();
  self->timer_started = true;

  return DETECTOR_SUCCESS;
}

次は、CheckEvent()インタフェースだ。
IsTimedOut()をヘルパー関数として抽出していること以外は、特に見るところはないだろう。

timeout_detector.c
static bool IsTimedOut(const uint32_t now,
                       const uint32_t start,
                       const uint32_t interval) {
  return (now - start >= interval);
}

static int CheckTimeOut(EventDetector super) {
  TimeOutDetector self = (TimeOutDetector)super;
  if (!self->timer_started) return DETECTOR_ERROR;

  uint32_t now = GET_MSEC_OF_DAY();
  if (IsTimedOut(now, self->start_time, self->interval_msec)) {
    self->timer_started = false;
    return DETECTOR_EVENT_DETECTED;
  }

  return DETECTOR_EVENT_NOT_DETECTED;
}

最後に、Cleanup()インタフェースだが、特にコメントはない。

timeout_detector.c
static int CleanupTimeOutDetector(EventDetector super) {
  TimeOutDetector self = (TimeOutDetector)super;
  self->start_time = 0;
  self->timer_started = false;

  return DETECTOR_SUCCESS;
}

ここまで実装すると、最初に作ったテストが無事にパスする。

作っている最中に1つ気になったことがある。
タイマは、uint32_t型としているが、値が最大値に到達して、ラップした場合でもうまく動くだろうか?
疑問に思ったことはテストしよう。
次のテストは、Start()時に、uint32_tの最大値が起点となる場合をテストしている。

TEST_F(TimerEventTest, DetectWrappedTime) {
  EXPECT_CALL(*mock_time, GET_MSEC_OF_DAY())
    .WillOnce(Return(UINT32_MAX))
    .WillOnce(Return(4998))
    .WillOnce(Return(4999));

  StartEventDetector(detector_);
  EXPECT_EQ(DETECTOR_EVENT_NOT_DETECTED, CheckEvent(detector_));
  EXPECT_EQ(DETECTOR_EVENT_DETECTED, CheckEvent(detector_));
}

このテストはプロダクトコードを修正しなくてもパスする。
では、このテストは無駄だろうか?
そうは思わない。
このテストがあることで、値がラップしても動作することが、動くドキュメントとして残ることになる。

ここまでで、EventDetectorインタフェースを継承したタイマ機能が実装できた。
さっそく、プロダクトコードで使ってみよう。

main関数の実装

今回、実現したい仕様は次の通り。

  • キーボードの「A」ボタンを押すと、caps lockのLEDが点灯し、5秒後に消灯する。消灯するまでは、「A」キー押下を無視する。

今回、修正、実装したキー入力検出とタイマ機能を使って、上記仕様を実現するmain関数は次のようになる。

main.c
#define NUM_DETECTORS 2

int main(void) {
  struct timeval kTime = {};
  const struct input_event kPressA = {kTime, EV_KEY, KEY_A, INPUT_KEY_PRESSED};

  // キー入力検出もタイマも同じEventDetectorとして扱う
  EventDetector detectors[NUM_DETECTORS+1];  // To null-terminate
  detectors[0] = CreateKeyInputDetector(KEYBOARD_DEVICE, &kPressA);
  detectors[1] = CreateTimeOutDetector(5000, TIMER_ONE_SHOT);
  detectors[2] = NULL;  // null-terminate

  LedDriver caps_led = CreateLedDriver();
  if (InitLedDriver(caps_led, LED_DEVICE) != LED_DRIVER_SUCCESS) {
    DEBUG_LOG("Fail to init led device\n");
    exit(1);
  }

  for(int i = 0; detectors[i] != NULL; i++) {
    StartEventDetector(detectors[i]);
    while(CheckEvent(detectors[i]) != DETECTOR_EVENT_DETECTED) {}

    ToggleLed(caps_led);
  }

  for(int i = 0; detectors[i] != NULL; i++) {
    CleanupEventDetector(detectors[i]);
    DestroyKeyInputDetector(detectors[i]);
  }

  CleanupLedDriver(caps_led);
  DestroyLedDriver(caps_led);

  return 0;
}

注目してほしいのは、キー入力検出もタイマも、同じEventDetectorとして扱っているところだ。
利用者は、最初にCreate()する以外は、キー入力検出機能もタイマ機能も、同じイベント発生検出、という枠組みの中で使うことができる。
もし、新しい検出機能をさらに追加したとしても、利用側は(Create()でインスタンスを作る部分を除いた)コードを変更することなく、新しい検出機能を利用することができる。

これは、2つの設計原則を満たしているからだ。
* オープン・クローズドの原則
* リスコフの置換原則

ただし、この実装には、少し誤魔化しがある。

main.c
  for(int i = 0; detectors[i] != NULL; i++) {
    StartEventDetector(detectors[i]);
    while(CheckEvent(detectors[i]) != DETECTOR_EVENT_DETECTED) {}

    ToggleLed(caps_led);  // ここ
  }

元々満たしたい仕様は、キーボードの「A」ボタンを押すと、caps lockのLEDが点灯し、5秒後に消灯する、だ。
各検出条件を満たしたときに、LEDをトグルする、というのは、結果的に同じ動作にはなっているが、LEDが点灯状態から始まるとうまく仕様を満たさない。

これは、LEDの操作に抽象化が不足しているせいだ。
そのせいで、イベント検出に対して、統一された方法でLEDを操作することができない羽目になっている。

インタフェース継承編の整理

EventDetectorインタフェースを抽出して、キー入力検出とタイマを同じインタフェースで使えるようにした。

コードは、Release1.1として公開中。
Release1.1

次回以降予告

実装は全て終わっているので、次回以降は、次のような内容を順次書いていきます。

次回は、デザインパターン編です。
今回、LEDをトグルする、という方法で無理矢理統一していた処理を、Commandパターン適用により、同じ使い方ができるようにします。
加えて、FactoryMethodパターンを使うことで、LEDを操作するインスタンスを生成するようにします。

加えて、並列処理について少し言及したいと考え、今の実装を大きく変えずに並列処理を実現するデザインパターンを1つ取り上げます。
(あくまで1つの実装例で、利用可能な場合も、そうでない場合もあると思いますが、議論の土台として取り上げます)

また、最近の投稿で良い問題を出してくれているものを発見しました。
(問題) 2秒のあいだにボタンが3回押されたかで、真・偽を返す関数を実装してください。
タイマとボタン、というこれまで実装してきたものだけで解ける問題ですので、こちらを解いていきます。
(実装は完了しているので、早めに投稿できるように頑張ります)

上記記事の中から、次の2つを取り上げます。

  • 2秒のあいだにボタンが3回押されたかで、真・偽を返す関数を実装してください。
  • ボタンが複数あって、特定のパターンに、一致したら、trueを返す、という機能の場合はどうする? (例: ↑ ↑ ↓ ↓ ← → ← → B A)

ただし、前提条件は多少無視させて頂きます。
処理系に与えられる関数と定数、真偽ではなくLEDの点灯とする、など。

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4