モジュール分割とタスク分割 - 誰のためのデザイン?

困難は分割せよ
方法序説』 - デカルト

ソフトウェアの設計上重要な二つの要素、モジュール分割とタスク分割。その目的と必要性、効果とともに、初心者が陥りやすい過度な分割による弊害について書いてみたいと思います。

モジュール分割は誰のため?

まず「モジュール分割」ですが、その目的は、ソフトウェアの意味的、機能的な単位に分割することにより、可読性や再利用性、あるいは、複数人開発での分担効率をあげようというもの。ソフトウェア設計における最も基本的な部分であり、最終的な、成果物の品質を左右する重要な設計要素ですね。

さて、モジュール分割でググると、STS分割技法、TR分割技法などの技法を紹介するページがあたります。
例えば以下あたりはまとまっていて参考になります。 私の場合、基本組み込み開発の世界にいますが、開発の現場でこれら単語を聞くことは少ない気がします。組み込みソフトの開発が大規模化してきたあたりから、組み込みでもオブジェクト指向設計が浸透しているので、最近の「オブジェクト指向ネイティブ」な世代には、クラス設計とか、デザインパターンから入った人が多いからかもしれません。そういう方には以下記事が、構造化プログラミングを経てオブジェクト指向に至る歴史がよくわかっておすすめです。

さて、前振りはここまでにして、今回の主題に入りたいと思います。それは、モジュール分割はそのソフトウェアを搭載した製品やサービスのためのものではないという点です。モジュール分割の意義は、
  • 開発者のため : ソフトウェアの可読性、分業効率の向上
  • 部品の再利用のため : 成果物の一部(又は全て)を使った派生開発
にあります。

そうはいっても、実際は、ソフトウェアの品質は、それを作る「人」に依存しますので、開発効率をあげることが、プログラムの方が品質に直結するので、上の話は極論ですが、何のためかという観点は非常に重要です。この考えに立てば、「成果物として何をつくるか」よりも、「誰が作るか、どうやって作るか」、そして、「成果物の再利用のスコープはどこまでか」が、モジュール設計を考える上で重要になるからです。

なぜこんなことを強調するのかというと、ソフトウェアのモジュール設計を見ていると、過度なモジュール分割がなされているケースが多いように思うからです。 4788514346 もちろん、ソフトウェアの部品化や階層化は、再利用性という観点で非常に重要な観点なのですが、全てのプログラムがそれらを考慮しないといけないというわけではありません。むしろ、再利用性を全く必要としない部分について、過度なモジュール分割を行って、厳密なインターフェースを定義していくことで、開発者が増えたり、必要以上のドキュメントやテストが増えることになりかねません。内部設計としてきちんとモジュール化するのはもちろん必要ですが、そこを外部公開してしまったとたん、害の方が多くなることがあるので注意が必要です。また、拡張性が全く必要ないとわかっているようなケースで、過剰なフレームワークを作ったりするのも、同じく害しかありません。

本来のスコープを超えた過剰設計は、百害あって一利なしです。

タスク分割は何のため?

もう一つのテーマであるタスク分割について。モジュール分割が静的な分割であるのに対し、こちらはプログラムの動的なふるまいを決定する上で重要な要素です。タスク分割というと、uITRONや VxWorksのようなRTOSの設計がイメージされますが、Linux等の非リアルタイムシステムにおけるプロセル分割、スレッド分割でも同じような考え方は必要です。(※ 厳密にはメモリ空間の扱いとか、リアルタイム性の保証の話とかで、違う点はありますが。)
タスク分割の動機は大きく分けると以下二点になります。
  • (1) モジュール化のための分割
  • (2) 並列性、リアルタイム処理のための分割

(1)の方は、先のモジュール分割の延長ですね。その機能毎にタスクを割り当てようというものです。タスク分割にとっては(2)の方が本質で、基本的には独立した実行コンテキストをもって、並列処理させることが主な目的です。(2)をもう少し細かくわけると、
  • 処理要求の間隔とそれに対する処理時間の吸収のため、処理タスクをわける
    (例) 入力デバイスからのイベント受付と、それをキューイングして順次処理するタスクをわける
  • 互いに独立した処理の並列実行
    (例) 複数クライアント毎に処理タスクをわける
  • リアルタイム性の高い処理の優先実行
    (例) 緊急度の高いタスクに高優先度を割り当てて、優先的に割り込み処理させる。
あたりでしょうか。これらに該当しない処理は、実行時の要求事項という観点からは特にタスクにわける必要がないので、あるとすれば、(1)モジュール化のための分割、ということになります。

確かに、機能単位にタスクを分けるのは、ソフトウェア構造をわかりやすくする上で有用なことも多いのですが、これが過剰すぎる場合があります。分割された各タスクが各々完全独立、依存関係なく並列動作できるものなら問題ないですが、これが一連の手続きの前後、あるいは、上下関係出会ったりする場合が問題です。タスク分割するということは、タスク間のやりとりは、関数コールではなく、キューなりイベントフラグなりのメッセージになっているはずですが、この時のタスク間の機能呼び出しは、同期型のものもあれば、非同期のものもあるでしょう。ここでいう同期型は、要求メッセージを投げた後、結果応答があるまで、イベント待ちで要求元タスクを待ち状態にさせるというものを指しています。これって、単なる関数コールと何が違うのでしょうか?一件、独立性が確保されて、可読性があがったように思えるのかもしれませんが、 機能呼び出しがメッセージになると、
  • ソースコードツアーや問題発生時のバックトレースが困難になる
  • コールグラフが作れないので、静的解析が困難
  • コンテクストスイッチが入ることで、処理負荷が増える
ということで、実行効率や解析性を落とすことになります。

このようなモジュールに紐付いた不必要なタスク分割は、先に書いたような過度なモジュール分割がされたシステムでよく見かけます。おそらく、モジュール分割した単位で担当者がかわっており、それぞれがつくりやすいように個々でタスク設計をしたために、不必要なタスク間イベントリレーができてしまったのではないかと思います。タスク設計については、「分けることはいいことだ」では決してありません。システム全体としての最適化という観点を意識しての設計が必要です。

過ぎたるは猶及ばざるが如し

モジュール設計にせよ、タスク設計にせよ、ただ一つの正解というものはありません。どちらにも言えることは、「何をつくるか」というだけでなく、「誰がつくるか」、「拡張性のスコープはどこまでか」を考えないといけないということ、そして、それを踏まえて、過度な分割をしすぎていないか確認すること、が大切かと思っています。

何事も、「過ぎたるは猶及ばざるが如し」ですね。
4434159372
【関連記事】
【関連書籍】

Linuxでシリアル通信 - Ubuntu+minicom

眺めているうちに、いろいろ気づいてくる
You can observe a lot just by watching.
ジョセフ・マーフィー

組み込み系では、ターゲット端末にシリアル接続してコンソール経由で、ログとったり、デバッグしたり、あるいは設定したりということがよくあります。その時必要になるのが、シリアル接続用の端末プログラムです。WindowsだとTeraTerm、Linuxだとやっぱりminicomが定番でしょうか。Linuxでは、Qt4使ったcutecomも試してみましたが、個人的にはボタンがごちゃごちゃしてイマイチだったので、結局minicomに戻りました。

ということで、今回は、Linux(Ubuntu 14.04)のシリアル通信端末としてminicomを設定した時のメモです。以下記事を参考にさせて頂きました。
(参考)Ubuntuでminicom起動すると/dev/ttyUSB0のパーミッションがないと言われる

minicomのインストール

これはaptでinstallするだけ。
$ sudo apt-get install minicom

但し、残念ながら、これだけだと、デバイスファイルのパーミッション設定で弾かれて、エラーになります。
$ sudo minicom --device /dev/ttyUSB0
minicom: cannot open /dev/ttyUSB0: No such file or directory
No such file or directoryではなく、Permission deniedの場合もあるみたいです。

デバイスファイル(/dev/ttyUSB0)のパーミッション設定

ということで、ttyデバイスのパーミッション設定を行います。
最近は、RS232C端子がついているPCもなくなってきたので、USBシリアルが使うことがほとんどですね。なので、ここでも対応するデバイスファイルは/dev/ttyUSB0という前提で話を進めます。

まずは、/dev/ttyUSB0を使うためにユーザー(例.dms)をdialoutに追加します。
$ sudo adduser <ユーザー名> dialout

次に、/dev/ttyUSB0のパーミッション変更のためudevの設定を変更します。単純にchmodで直接パーミッション変更もできますが、USBシリアルの場合hotplugなので、抜き差しするたびに設定しないといけないことになります。
具体的には、ttyファイルの定義行に、MODE="0666"を追加しています。
$ sudo vi /lib/udev/rules.d/50-udev-default.rules
KERNEL=="tty[A-Z]*[0-9]|pppox[0-9]*|ircomm[0-9]*|noz[0-9]*|rfcomm[0-9]*",  GROUP="dialout", MODE="0666"

ちなみに、udevルールの書き方は以下あたりが参考になりそうです。
udev の設定をカスタマイズする - いますぐ実践! Linuxシステム管理

minicomの設定

これで、minicomで/dev/ttyUSB0にアクセスできるようになったはずなのでminicomを設定します。
minicom -sで設定起動しますので、Serial Deviceに/dev/ttyUSB0に変更します。 なお、default設定に保存する場合は、root権限がいるので、その場合はsudoつけて下さい。また、LANGが日本語だと表示が崩れるので、LANG=Cで起動した方がいいです。
$ sudo LANG=c minicom -s

ログの自動保存

シリアル通信を使う目的がログ取得なんかの場合、常にログはファイル保存しておきたいことがあると思います。minicomの起動オプションでログの保存ファイル名が指定できるので、常に日付ファイル名でログ保存するように設定しておくと、ログの取り忘れもなく便利です。
先のLANG=C設定とあわせて、以下のようなaliasを.bashrcあたりで設定しておくと便利です
(個人的に想い出深いscript)。
$ vi ~/.bashrc
alias minicom="mkdir -p ~/minilog ; LANG=C minicom -C ~/minilog/`date +%y%m%d_%H%M%S`.log"

これで、~/minilog以下に、ログが自動保存されるようになります。本当は、timestampもONにしたいのですが、これはコマンドラインからはできないみたいです。必要ならminicomのソースコード変更するしかないですね。
なお、ログ取得し忘れてもう一回、というのはよくある光景なので、minicomにかぎらず、WindowsでTeraTermという時にもぜひやっておいた方がよい設定です。(参考: Tera Termログ取得-自動でログの取得を開始する設定)。「ログが残っていれば。。。」ということはよくある光景ですので、開発プロジェクトでログ取得目的で使う場合は必須にしてもいい設定ですね。

487311585X
【関連記事】
【関連書籍】

バグは誰のものか - エラー処理の責務分担

まかせてはいるけれども、たえず頭の中で気になっている。そこでときに報告を求め、問題がある場合には、適切な助言や指示をしていく。それが経営者のあるべき姿だと思います。これは言いかえますと“まかせてまかせず”ということになると思います。まかせてまかせずというのは、文字どおり“まかせた”のであって、決して放り出したのではないということです。

前回単体テストに関する話を書いたのでその続きで。

単体テストという言葉の定義はプロジェクトによって様々ですが、もっとも多いのは”関数”を単位とするものかと思います。関数単位のテストでは正常系のテスト(関数が適切に使われた時のテスト)以外に、異常値を与えられた時のテストも行われます。例えば以下の例。
const char*
month_name(int month)
{
  const char* name[] = {"Jan", "Feb", "Mar", "Apl", "May", "Jun",
                        "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};

  if (month < 1 || month >= 12} 
    return NULL; /* invalid month */
  return name[month - 1];
}
月を文字列に変換する関数ですが、1〜12以外はエラーとしてNULL(0)を返しています。
このような関数の単体テストでは、1〜12の値以外に、エラー値として、0や-1, 13等も異常値としてテストすることになると思います。

このように、多くの関数では、引数として取りうる値や条件が限られている場合が多く、それを満たさない場合は、エラーを返すというのはよく見られることです。しかし、このようなエラー処理は、毎回必要なことでしょうか。

例えば、この関数が以下のような関数の内部で使われている場合を考えます。
int
print_date(int month, int day)
{
  if (month < 1 || month >= 12} 
    return -1;
  .....
  printf("%s-%d\n", month_name(month), day); 
}
先ほどと同じ範囲チェックがここでもされています。もし、month_name()がここでしか使われない、つまり、この関数の専用内部関数なのであれば、month_name()内の範囲チェックは冗長です。言い換えると、month_name()内のエラーチェックは内部のバグでもない限り発生することはありません。そもそも、print_date()では、month_name()でエラー、つまり、NULLがかえってくることを想定していないので、万が一NULLが返ってきたらNULL pointerアクセスでsegmentation fault等を引き起こします。

非常に単純な例ではありますが、このようなケースでは、month_name()でのエラー処理は不要で、必要なのはアサーションになります。つまり、以下のような形です。
const char*
month_name(int month)
{
  const char* name[] = {"Jan", "Feb", "Mar", "Apl", "May", "Jun",
                        "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
  assert(month > 0 && month < 12)
  return name[month - 1];
}
こう書くことで、この関数は前提条件として、引数に1から12しかとらないということが表現されます。範囲チェックをさぼっているのではなく「上位にまかせている」ということがこれではっきりします。アサーションであれば、処理されないようなエラーを返すコードもないので、そもそも単体テストも必要ありません。万が一、上位関数のロジックが間違っていれば、assertにひっかけて早期検出も可能です。
よく混同されがちですが、アサーションはエラー処理の一種ではないのです。

じゃあ、どうやって使い分けるの?という話になりますが、必ずしも一概には言いきれないものの、一般論として言えば、外部要因によって値が不正になるような場合(例えば、ライブラリの外部公開関数や、データが外部ファイルやネットワーク、ユーザー入力に由来するなど)には、エラー処理が必要になりますが、内部関数の場合は、大抵はエラー処理ではなく、アサーションが適当な場合が多くなります。

よく、エラー処理とアサーションが混同されているコードに以下のようなものがあります。
int
print_date(int month, int day)
{
  if (month < 1 || month >= 12) {
    assert(0);
    return -1;
  }
  .....
  printf("%s-%d\n", month_name(month), day); 
}
エラー処理の中に、assert(0);が書かれており、どっちつかずになってしまっています。但し、この関数が通常外部関数で使われているものであり、デバッグ用にエラー発生次第abortするという機能として、コンパイルオプション等で有効無効が設定できるようなアサーションなのであれば、それはデバッグを効率化するひとつの方法だと思います。

ここまでの例では、引数チェックの話を例にとりましたが、戻り値のチェックでも同じことが言えます。
  ....
  ercd = loc_mtx(MTX1); /* lock mutex */
  assert(ercd == E_OK);
  ....
mutexの取得失敗をassertで確認しています。通常、mutexの取得はパラメータエラーや、同一タスクでの二重取得等の内部ロジックの不具合でしか起こらないことが多く、ここでエラーを返しても上位関数ではどうすることもできないので、このような場合は、アサーションが適当です。(失敗するはずはないという表明になる)

もし、これを以下のように書こともできますが、結果としての処理は同じでも、コードとしては通りもしないエラー処理を書くことになり、C0やC1カバレッジを必要とするような場合には、困ることになります (通らないはずのコードなのだからパスを通しようがない)。
  ....
  ercd = loc_mtx(MTX1); /* lock mutex */
  if (ercd != E_OK) {
    printf("loc_mtx failed : %d\n", ercd);
    abort();
    ....

非常に単純な話をくどくどと書いてしまいましたが、各モジュールや関数の責務をはっきりさせ、効率的にテスト、デバッグする上で結構重要な観点だと思います。
B00JEYPPOE
【関連記事】
【関連書籍】

本のおすすめ

4873115655

4274065979

4822236862

4274068579

4822255131

B00SIM19YS


プロフィール

  • Author:proger
  • 組み込み関係で仕事してます

ブログ内検索