本講座のトップページでは、C言語仕様が自由である事の功罪と、特定の個人/組織でしか通用しないローカルなルールでソフトウェア設計を束縛する事の功罪について述べました。
以上の事を踏まえ、第3回では、オブジェクト指向設計の世界で常識とされている幾つかの習慣をC言語によるソフトウェア設計に取り入れる事を提案します。今回提案する習慣は、以下の5つです。
- 変数と関数の有効範囲(スコープ)を局所化する。
- グローバル関数の呼び出しを「ソフトウェア部品名_アクション名(引数…)」で表現する。
- 初期化関数を持つ。
- ソフトウェアの構造を階層化する。
- 上位アプリケーションをパソコン上で動かす。
変数と関数の有効範囲(スコープ)を局所化する
第1回では「特定の役割り」を担う複数の「部品」によってソフトウェア全体を構成する事の重要性について述べ、その部品が備えるべき特性として、他の「部品」からは見えない/見る必要の無い、隠された「変数」や「関数」を有するという特性を挙げました。これは、オブジェクト指向設計において、当たり前のように適用されている習慣です。
この習慣を適用するための第一歩は、「グローバル変数」を極力、使わない事です。重要なデータは、何れかのソフトウェア部品内でstatic宣言して外部から隠ぺいし、データを所有するソフトウェア部品によって許可された方法、すなわちグローバル関数呼び出しによってのみ、更新または参照できるようにするのです。
これにより、データ構造も隠ぺいできます。たとえば「0から始まるユニークな番号」と、「16ビット長のデータ」を「対」で沢山、保持するソフトウェア部品があったとします。この場合、データは以下のような構造体配列で管理できます。
#include <stdint.h>
#include <stdbool.h>
#define DF_XXX_DATA_NUM 1000
typedef struct {
uint16_t no;
uint16_t data;
} ST_XxxDataType;
static ST_XxxDataType lv_XxxDataTable[DF_XX_DATA_NUM];
がしかし、このデータを参照する側のソフトウェア部品では、このようなデータ構造の詳細は知る必要が無く、参照用の関数として用意された、以下のようなグローバル関数を呼べば良いだけです。
#include <stdint.h>
#include <stdbool.h>
extern bool DB_ReadData(uint16_t no, uint16_t *pData);
第1引数の番号に相当するデータが存在しない場合はfalseを返し、存在する場合は、第2引数のポインタが指す先にデータを格納し、trueを返せば良いのです。
こうすれば、今後、何かの事情でデータ構造を変更する必要が生じた場合でも、データを参照する外部のソフトウェア部品のソースコードは変更しなくて済みます。またデバッグの目的でデータ参照のログを取りたい…という場合は、前記のグローバル関数の中でログを出力するだけで済みます。更に外部のソフトウェア部品のグローバル関数を「スタブ」と呼ばれるテスト用の関数に差し替える事で、単体テストがし易くなります。
このように設計する事に対して、関数呼び出しが1段増え、実行時間やコードサイズが増える事を嫌がる人がいますが、本講座のトップページで述べたとおり、昨今のマイクロプロセッサの高速化/フラッシュメモリの大容量化/低価格化を考えれば、許容されるレベルと思います。
関数についても、グローバル関数を減らす事を心掛け、外部から呼ぶ事のできないstaticな関数を中心に処理します。
グローバル関数の呼び出しを「ソフトウェア部品名_アクション名()」で表現する
オブジェクト指向設計では、本記事で提案する「ソフトウェア部品」は「オブジェクト(インスタンスと呼ばれる場合もあります)」に相当します。クラスは、オブジェクトの「型」を意味します。
そのオブジェクト指向設計では、オブジェクトが所有する関数の内、外部のオブジェクトから呼び出せる関数は「publicなメンバ関数」と呼ばれ、その関数呼び出しは「オブジェクト名.関数名()」で表現します。オブジェクト指向設計では、publicなメンバ関数呼び出しは、そのオブジェクトに対して「メッセージ」を送る行為と見なせます(「メッセージパッシング」といいます)。たとえばデータベースの役割りを担うオブジェクト「dataBase」に対して「データを読みたい」というメッセージを送るには、「dataBase.Read()」という関数呼び出しを実行するのです。
このようにオブジェクト指向設計では、ソフトウェア部品間で何らかのメッセージやデータ(メンバ関数の引数で渡す)をやり取りする場合、必ず「dataBase.Read()」のように
- ソフトウェア部品名(すなわちオブジェクト名)」
- メッセージ内容(すなわちpublicメンバ関数名)
を「ペア」で表現する事がプログラミング言語仕様で義務付けられています。
これに対して、C言語ではグローバル関数の呼び出しに際して、そのようなルールは存在しません。従って、本記事で提案する「幾つかのソフトウェア部品で全体を構成する」ような設計を行い、その部品間でやり取りする際、どの部品に対するアクションなのか、Cの言語仕様としては、表現する手段が無いのです。
ではどうすれば良いでしょうか?
本記事では、グローバル関数名に特別な「命名規則」を設ける事で、この問題に対処する事を提案します。具体的には、「グローバル関数名のプリフィックス(接頭語)としてソフトウェア部品名を書く」事を提案します。更に、1つのソフトウェア部品は「部品名.c」と「部品名.h」の2つのソースファイルに記述する事を提案します。ヘッダには、そのソフトウェア部品のグローバル関数呼び出しに必要な「プロトタイプ宣言」と「型宣言」「マクロ宣言」等を記述します。
たとえばデータベースの役割りを担うソフトウェア部品をCのソースファイルで「DataBase.c」「DataBase.h」として記述したとします。この場合、ソフトウェア部品名は「DataBase」になりますので、そのグローバル関数名は、たとえば「DataBase_Read()」とします。
ただしこの命名規則だけでは、static関数名と区別できないので、
- グローバル関数は、大文字で始め、static関数は小文字で始める。
- static関数には、プリフィックス(接頭語)として「l_」を付ける。
- static関数名には、アンダースコア「_」は使わない。
等、static関数にも「命名規則」を設ける必要があります。
以上のようなルールは、特定の個人や組織でしか通用しないルールなのですが、その根本は、オブジェクト指向設計のルールに基づいており、効果的なルールとして機能すると思います。
初期化関数を持つ
オブジェクト指向設計では、オブジェクトを動作させる際、予め「生成」というステップを踏む必要があるのですが、その際、「コンストラクタ」と呼ばれる、自身を初期化するための専用のメンバ関数が呼ばます。C言語によるソフトウェア設計でも、この習慣を取り入れる事を提案します。具体的には、「ソフトウェア部品名_Init()」という初期化関数を準備し、起動後にSchedulerから1度だけ呼び出すようにするのです。
ソフトウェアの構造を階層化する
C言語において、変数/関数の有効範囲(スコープ)として利用できる範囲は、以下の3種類しかありません。
- ソースファイル内(すなわちコンパイル単位)
- 関数内
- { }で囲まれたブロック内
従って、これらを有効活用してソフトウェアを設計する事になるのですが、理想的な有効範囲は、それぞれ
- 1ソースファイルは、千行以内
- 1関数は、数十行くらい
- 1ブロックは、数行くらい
だと思いますので、大規模なソフトウェア開発では、千行くらいのソースファイルを大量に書く事になります。
変数や関数に局所性を持たせたのと同じ理由で、ソフトウェア部品についても、全ての部品と関係を持つソフトウェア部品は少なくし、一部のソフトウェア部品間でのみ関係を持つように、局所化/グループ化する事をお勧めします。その際、同一グループのソースファイル群は、サブフォルダに分けて管理する事をお勧めします。
そのソフトウェア部品の局所化/グループ化に際しては、1階層の広い平面内で局所化/グループ化するだけで無く、多層化し、階層化された空間内で局所化/グループ化する事も重要です。
この階層化ですが、オブジェクト指向設計では、オブジェクト間で「継承(is-a)」や「包含(has-a)」という関係を持たせ、階層化する事が一般的ですが、C言語では、クラスが無いため、そのような設計を行う事は難しいです。(関数ポインタをメンバに持つ構造体を使えば、同じような設計ができなくは無いですが、本記事で提案したい方法ではありません)。ただ「オブジェクト指向設計では、階層化する事が一般的なのだ」という事を念頭に置いて、C言語によるソフトウェア部品の設計に際しても、階層化を意識する事は重要と考えます。
では、どのようにソフトウェア部品を階層化すれば良いでしょうか?
その1つのアイデアは、「詳細な仕事」を担うソフトウェア部品と、その機能を活用して「より抽象的な仕事」を担うソフトウェア部品を設ける事で、階層化する案があります。第2回の「擬似パソコン化」では、
- Device Driver
- Hardware Abstraction Layer
というソフトウェア部品について説明しましたが、これらは階層化された関係を持つと言えます。
上位アプリケーションをパソコン上で動かす
上位アプリケーションをパソコン上で動かす事は、オブジェクト指向設計とは直接、関係しないのですが、オブジェクト指向で設計されたソフトウェアの多くは、WindowsやLinuxが稼働するパソコン上で実行されるため、ここで説明させて頂きます。
これまで述べてきたような習慣に基づいてソフトウェアを設計した場合、階層化された設計の上位に位置するアプリケーション部は、実際のハードウェア上だけで無く、パソコン上で動かす事も容易です。この記事では、上位に位置するソフトウェア部品をWindowsやLinux上のソフトウェアとして開発される事、もしくは実際のハードウェア上でデバッグされる前に、上位に位置するソフトウェア部品だけをパソコン上で実行される事(デバッグされる事)をお勧めします。この設計方法は、一部の書籍では「デュアルターゲット」として紹介されています。
パソコン上のソフトウェアとして組み込みソフトウェアの一部を開発した場合、以下のようなメリットが得られます。
ハードウェアを入手するまでに上位アプリケーション部の単体テストが実施できる
昨今の組み込みシステムの開発では、ソフトウェアとハードウェアは同時並行開発される事が多いです。更に、ソフトウェア設計者にハードウェアが手渡される際、デバッグやテストを終えた信頼性の高いハードウェアが手渡される事は稀で、ソフトウェアのデバッグの際に、併せてハードウェアの動作検証を求められる事は少なくありません。
こういうケースでは、いきなり上位アプリケーションまで含めた全ソフトウェアをハードウェア上で動かすと、不正動作の原因が解らず、混乱を招く事が多いです。
これに対して、上位アプリケーション部は、別途、パソコン上で開発/デバッグ/単体テストまで進めておき、入手したハードウェア上では、本記事で「擬似パソコン化」として提案した一部のソフトウェア部品と、その上位に位置づけられる「試験用ソフト」だけを動作させ、別々に開発を進めた場合、前記のような混乱は、最小限に抑えられます。
高度なデバッグ機能が利用できる
たとえばVisual Studio上で開発した場合(Visual C++を利用)、未初期化のauto変数を参照したり、不正なメモリをアクセスした場合は例外が発生し、ブレークする事が多いため、この種の不具合は、初期段階で改修できる可能性が高いです。これに対して、前記のような不具合を含むソフトウェアを実機上で実行した場合、不正アクセスの直後でブレークさせる事は難しく、別の異常現象として現れますので、デバッグに時間を要する事になります。
組み込みソフトウェア技術者「以外」の技術者に協力を仰げる
これまで述べてきたような習慣に基づく上位アプリケーション部の開発は、組み込み用途で無い、一般的なソフトウェア開発と大きな違いはありません。よって、技術者を確保する際、組み込みソフトウェア技術者だけで無く、C言語特有の言語仕様(ポインタ等)を勉強してもらう前提で、C#/Java等の経験者に協力を仰ぐ事も可能になります。
ダウンロードが不要
パソコン上でソフトウェアを直接(ネイティブとして)実行する場合、ダウンロードという手順は不要で、ビルドが終われば、即、実行可能です。これに対して、組み込みソフトウェアを実機上で実行するには、ダウンロードという手順が必要となります。このダウンロードですが、累計時間で見れば、それ程、負荷の高い作業では無いですが、別の作業が実施できる程の時間では無く、思考が途絶える「待ち時間」になってしまう事が多いです。故に、ビルド時間も短く、ダウンロードが不要なソフトウェア開発は、設計者にとって生産性が格段に向上したような感覚をもたらします。
第3回は以上です。続く第4回では、これまで述べた習慣に基づき、実際に「電気ポット」のソフトウェアを設計します。