2012年3月25日日曜日

私が好んで使用しているクロス・プラットフォームの実装方法(シリアル・ポート・ライブラリの実例)

クロス・プラットフォーム!

プロフェッショナル・ソフトウェア・エンジニアの場合、自分が書いたコードが一体何のプラットフォームに依存しているのかを意識して書くのが通例です。この意識の下で設計実装する事で、異なるプラットフォームへの移植要求に対して柔軟に対応できます。結果的に機能実現に投資した労力に対する効果を最大にする事ができます。まぁ、簡単に言うと喜んで頂ける方が増えるわけです。

クロス・プラットフォームの実装方法にはいくつも方法があります。
今回は私が好んで使用している方法について述べたいと思います。

ソースコードのダウンロード

今回の説明に用いたソースコードは以下からダウンロードできます。
ダウンロードはこちらから。
  • ライセンスはMITです。
  • 実際にkz_h8writeの下層で使用しているシリアル・ライブラリです。
  • 好ましいとは言えない例は、ここでは掲載しませんので実例を探してみて下さい。
好ましいとは言えない例

クロス・プラットフォームの実装方法の中で、実利的でありながらあまり好ましくない例があります。
例えば、シリアルポートに対する制御を行なうライブラリをWindowsやLinuxで提供する事を考えてみましょう。

好ましいとは言えない例の場合、例えば以下のような感じの実装だったりします。

<シリアルポートをオープンする関数>
#ifdef WIN32
    // Windows用のシリアルポートをオープンする処理
#else
    // Linux用のシリアルポートをオープンする処理
#endif

<シリアルポートに書き込む関数>
#ifdef WIN32
    // Windows用のシリアルポートに書き込む処理
#else
    // Linux用のシリアルポートに書き込む処理
#endif

<シリアルポートから読み込む関数>
#ifdef WIN32
    // Windows用のシリアルポートから読み込む処理
#else
    // Linux用のシリアルポートから読み込む処理
#endif

<シリアルポートをクローズする関数>
#ifdef WIN32
    // Windows用のシリアルポートをクローズする処理
#else
    // Linux用のシリアルポートをクローズする処理
#endif

どうですか?
見やすいでしょうか?

Windows向けの実装に目を通している時でも、Linux向けのコードも一緒に目に入ってきます。

万が一Linux向けのコードを知らずにいじってしまったらどうなるでしょう?
Windows向けの機能修正後に知らずにコミットした場合には目も当てられません。

Windows向けの機能修正後にLinux側のテストもしてくれるでしょうか?
本来は「テストして当たり前」の事なのですが、実際に期待通りテストしてくれるとは限りません。

フローが曖昧な現場やプロジェクトもあれば、テストしない乱暴な人もいるからです。
プロジェクトにおいて緊急対応などになればなおさらです。
そんな事はあってはならないのですが、実際にはよくある話です。


残念な事に、実際の開発現場では上記のようなコードが多く存在します。

業務上の開発において、あまりこの手のコードを信用する気になれません。
ペアになって存在すべきテスト用コードが無ければなおさらです。

プラットフォームの切り替えがコードに記述されているという事は、依存する定義ファイルへのincludeも#ifdefで切り替えている事になります。対象プラットフォームのビルド環境に依存して、依存する定義ファイルが異なる場合、上記のプラットフォーム切り替え以外の#ifdefも混在します。
プラットフォーム依存コードだけの問題でない事になります。

また、上記の実装はソースコードがビルド・スクリプトにも依存している事を暗に示しています。
これも私が上記のような実装を好まない理由の1つです。
実装詳細を知らないとビルド・スクリプトが書けない事を意味し、更にこの手のコードが数十個、数百個存在して、なおかつそれらが相反する要求になっていた場合どうなるのだろう?と考えただけで嫌になります。

例えばWIN32なの?それともWINDOWSなの?えーとWINなの?とかビルド・スクリプトを書く時に考えたくないですよね?

そして#ifdefの中身が実際にコードとしてコンパイルされているのか?などと余計な事を考えなくてはなりません。これは本来考えなくて良い内容です。

私が好んで使用している方法

私は好ましいとは言えない例に挙げたようなコードを実際の開発現場で多く見てきました。

更にこういった実装をしている下層ライブラリを使う事になった場合、本当に不安になるわけです。
不安の背景には、先に挙げた小さな要素が複数存在します。

私の場合、以下のようなアプローチで先に挙げた不安をできるだけ解消するようにしています。
  • インターフェースはヘッダで、実装の詳細はソースで、を厳密に扱う。(当たり前) 
  • プラットフォーム毎に実装を分離する。プラットフォーム切り替えのために#ifdefは使わない。
  • ハンドラを賢く実装する。
Cの実装においてインターフェースはヘッダで、実装の詳細はソースでというのは当たり前の話でが、実際に実装されているコードを見ているとあまり厳密に扱っていない例を多く見かけます。

ヘッダで定義する内容や外部依存定義を最小限にする事で、外部依存が最小で移植性の高いソフトウェア・コンポーネントが実現できます。厳密なインターフェースの定義に対して、それぞれの実装の詳細を提供する事で機能を実現するのが基本です。

私の場合、ヘッダに対する実装という形で「ファイル名を見ただけで想像できる」ように名称をつけるようにしています。


Windows向けのライブラリの実装は、概ね以下のとおりです。

<シリアルポートをオープンする関数>
    // Windows用のシリアルポートをオープンする処理
<シリアルポートに書き込む関数>
    // Windows用のシリアルポートに書き込む処理
<シリアルポートから読み込む関数>
    // Windows用のシリアルポートから読み込む処理
<シリアルポートをクローズする関数>
    // Windows用のシリアルポートをクローズする処理

Linux向けのライブラリの実装は以下です。

<シリアルポートをオープンする関数>
    // Linux用のシリアルポートをオープンする処理
<シリアルポートに書き込む関数>
    // Linux用のシリアルポートに書き込む処理
<シリアルポートから読み込む関数>
    // Linux用のシリアルポートから読み込む処理
<シリアルポートをクローズする関数>
    // Linux用のシリアルポートをクローズする処理

先のプラットフォーム混在時の実装モデルと見比べて分かるとおり、シンプルな実装が2つのプラットフォームに対して提供されている事がわかります。そして、互いの実装は異なるファイルに実装されています。

これが「インターフェースはヘッダで、実装の詳細はソースで、を厳密に扱う。(当たり前) 」と「プラットフォーム毎に実装を分離する。プラットフォーム切り替えのために#ifdefは使わない。」の実例です。


当然ですがヘッダにはプラットフォーム依存の内容は一切書けません。
例えば、プラットフォーム依存の要素はどのように実装するのでしょうか?

例えば、ハンドラひとつとってみてもWindowsとLinuxで事情が異なるかもしれません。
Windowsの場合HANDLEが使用され、Linuxの場合intが使用されます。

これから示すのが「ハンドラを賢く実装する。」の答えです。

ヘッダでは「ハンドラの構造体をSERIALという名前の型として定義するだけ」の実装です。
ヘッダでは実装の詳細について触れず、「とにかくstruct serialをSERAILとして扱う事だけ」を宣言します。


では、実装側はどうなっているのか?を見てみます。
まずはWindows用の実装です。

そしてLinux用の実装です。
(Linux用の実装はWindowsと異なり端末状態の復旧やミューテックスが含まれています。)


こんな感じで2つのプラットフォームに対する実装は、それぞれ異なる物を提供していますが、上位からは2つの差異を気にすることなく使用できます。(Windows側はちょっと手抜き感がありますが・・・。)


設計実装の判断基準

私の設計実装の判断基準の1つは「その設計や実装がN個になったらどうなるのか?」です。

先の例ではプラットフォームはWindowsとLinuxの2つでした。
では、3つ目のプラットフォームが出てきた場合どうなるのでしょうか?
あまり好ましい実装になりそうにありませんよね?

私のアプローチの場合、3つ目のプラットフォームが現れた場合、新たにファイルを1つ追加するだけで済みます。もちろん他のプラットフォームの実装には一切影響を与えません。

こんな感じで設計実装の判断を行なっています。
様々なアプローチに対して設計実装の判断基準を明確にしておくと、設計や実装の度に迷う必要がなくなります。

まとめ

今回はシリアル・ポート・ライブラリを実例に挙げ、異なるプラットフォームに対してどのように実装を提供するとシンプルにまとめる事ができるのか?について示しました。

2 件のコメント:

  1. linux の場合、serial_read() や serial_write() でも read()/write() の戻り値を見てループするのが良いのではないでしょうか。1回の read()/write() で希望のサイズを処理できるとは限りません。

    返信削除
  2. masatoさん。
    突っ込みありがとうございます。

    「It is not an error if this number is smaller than the number of bytes requested; this may happen for example because fewer bytes are actually available right now (maybe because we were close to end-of-file, or because we are reading from a pipe, or from a terminal), or because read() was interrupted by a signal.」

    確かに御指摘の通りです。
    感謝感謝です。

    返信削除