こんにちは。田原です。
オブジェクト指向プログラミングの3大特長といえば、「隠蔽」、「継承」、「動的なポリモーフィズム」です。今回はその内 最も強力な「隠蔽」機構について考え方・使い方を解説します。
C言語的な記述に比べて余分な手間がかかるのに、何故良いのか説明してみました。
動的なポリモーフィズムと静的なポリモーフィズム
C++には動的なものだけでなく静的なポリモーフィズムもあります。テンプレートを使って行うテンプレート・メタ・プログラミングです。例えば、STLのに含まれるような関数群は概ね該当します。静的なポリモーフィズムは動的なポリモーフィズムよりオーバーヘッドは少ないですが、動的なポリモーフィズムの方が使える場面は多いです。
最近は見かけることがかなり減りましたが、WEBページへのリンク切れの話です。
WEBページそのものが消えた場合もありますが、例えば、某社の技術情報を開示している大規模なサイトでは、時々リンク切れが発生します。情報そのものは残っているのにリンクが切れてしまうのです。
例えばWEBサイトの各ページをhtmlファイルで構成している場合、それらを保存しているフォルダ構造がそのままURLに含まれます。ですので、サイトの構成を見直し、それに伴ってhtmlファイルのフォルダを変更したらURLも変わってしまいます。
現在はhtmlデータを自動生成する際にURLも自動生成するので、リンク切れはしにくくなりました。しかし、全てのファイルが自動生成されるわけではなく、通常のファイルを表示している場合は、そのファイルのフォルダを変更するとURLが変わってしまい、リンクも切れてしまいます。
例えば、当サイトはWordpressで作っていますが、Wordpressは画像をページに貼り付けるとその画像の物理的なバスの一部がURLに含まれてしまいます。そのため、画を保管するフォルダを変更するとURLも変わってしまいます。するとそれらのページや画像へのリンクやブックマークは切れてしまいます。
実際にそれらのページや画像を消してしまったのであれば切れるのも仕方がないですが、残っているのにリンク切れはちょっと悔しいですね。
昔は「ブックマークはトップページへお願いします」等の注意書きがあるWEBページがよく有りました。様々な理由がありますが、その大きな理由の一つはフォルダ構成を変えた時にリンク切れを起こさないためでした。
要するに内部構造を変えることがあるから、直接内部へアクセスしないで欲しいとの要請ですね。
ページを表示すると当該ページのURLがWEBブラウザに表示されるため、それをブックマークとして記録することを妨げる手段がなく、このようにお願いするしかないのです。
構造体を使った場合、そのメンバ変数は構造体の外側の関数もアクセスし放題です。
ですので、他の関数がアクセスしている変数の型を変えたり、単位を変えたりした場合、アクセスしている関数については外側のものも含めて全て改造が必要になります。
内部関数ならまだしも、外部関数は非常に多数になることもあるため、改造内容によってはかなりたいへんなことになります。
そして、改造が必要な関数の改造が漏れると痛いです。改造漏れするくらいなので影響があることを見落としているでしょう。ということは、テスト対象からも漏れるかも知れません。結果バグが流出し、本稼働しているシステムで顕在化して大目玉を食らうことになります。嫌な話です。
例えば、極小さな商店の商品の店頭在庫の管理システムを想定します。(説明に必要な最低限まで単純化しました。実際のプロジェクトでは遥かに複雑です。)
次のような構造体で商品名と在庫数量を記録し、これを商品の数だけstd::vector<>で記録するものとします。
struct StockedProduct { std::string mProductName; // 商品名 unsigned mStockCount; // 在庫数量 };
上記構造体を記録したstd::vector<StockedProduct>
を処理する 次のような関数を用意することは多いでしょう。
- 初期化(商品名と初期在庫数量を設定する)
- 入庫処理(指定の商品について在庫数量を指定数増やす)
- 出庫処理(指定の商品について在庫数量を指定数減らす)
- 印刷処理(
std::vector<>
を列挙しつつ、商品名と在庫数量を印刷する。)
これらの関数は全て構造体のmProductNameとmStockCountをアクセスします。
ある時、この商店がを計り売りを始めたとします。1本の商品の中身を少しずつ販売するわけです。
それに対応するため、計り売りのために開封したパッケージの在庫残を1~99%で表現するものとしました。(0%は在庫なしなのでそのパッケージは破棄。)
そのためにmStockCountを100倍して記録し、mStockCountを100で割った値を未開封の在庫数量、100で割った余りを開封したパッケージの在庫パーセンテージで記録することにしました。
(直ぐにdoubleにしておけばよかったと後悔することになるので、何か理由がありdoubleを使いたくなかったと思って下さい。)
mStockCountをアクセスしている関数は上記4つですので、これら全てを改造する必要があります。
外部から指定された在庫数量を100倍して処理することになります。なんとなくバグりそうな改造です。100倍するのを忘れたり、100で割るべき時に100倍したり、間違って四捨五入したり。
今回は、現実にはありえないほど単純化したため4つしか関数がないですが、実際のプログラムはもっと複雑です。例えば、それが30箇所くらいあったら憂鬱です。予算不足でこのような開発工数のかかる設計は断念し30箇所の改造がもっと簡単ですむような改造にするしかないかも知れません。例えば、使いたくなかったdoubleを使うなどですね。
理由を把握しやすくするため、構造体のまま対応します。(ちょっと中途半端な状態です。)
まず、StockedProductのメンバ変数を直接使う処理は全てStockedProductのメンバ関数として実装します。(エラー処理は全て省略してます。)
struct StockedProduct { std::string mProductName; // 商品名 unsigned mStockCount; // 在庫数量 // 初期化(コンストラクタ) StockedProduct(std::string const& iProductName, unsigned iStockCount) : mProductName(iProductName), mStockCount(iStockCount) { } // 入庫処理 void stock(unsigned iCount) { mStockCount += iCount; } // 出庫処理 void delivery(unsigned iCount) { mStockCount -= iCount; } // 印刷処理 void print(std::ostream& os) { os << mProductName << ":" << mStockCount << "\n"; } // 商品名獲得 std::string const& getProductName() { return mProductName; } };
下記の各関数はStockedProductの各メンバ関数を呼び出して処理を行います。
- 初期化(商品名と初期在庫数量を設定する)
- 入庫処理(指定の商品について在庫数量を指定数増やす)
- 出庫処理(指定の商品について在庫数量を指定数減らす)
- 印刷処理(
std::vector<>
を列挙しつつ、商品名と在庫数量を印刷する。) - 入出庫処理のための商品名検索関数
以上により「隠蔽」できたものは、次の通りです。
- stock(), delivery(), print(), getProductName()の中身
- mProductNameとmStockCount
ああ、前者はソースを見れば開示されているので、どこが隠蔽されているのか分からないですよね。
それは、これらのメンバ関数を使う外部の処理(上記の1.~4.)に対して隠蔽したのです。1.~4.の処理にとって、stock()等のメンバ関数の使い方と機能が同じなら、その中身がどのように実装されていても影響を受けません。と言う意味で隠蔽です。(依存しないようにしたとも言います。)
後者はアクセスしようと思えば1.~4.やその他の処理からアクセスできますね。全く隠蔽になっていません。しかし、stock()等のメンバ関数経由でのみアクセスする限り、これらのメンバ変数がどのように実装されていても外部の関数は影響を受けません。この意味で「緩く」隠蔽されています。
非オブジェクト指向プログラミングの場合に比べて、似たような関数をStockedProductの外側と内側に分けて書く分、手間がかかりました。ですので「無駄」に手間がかかると感じる人も居ると思います。
しかし、多くのプログラムが該当すると思いますが、長期間使われ、時として機能拡張が必要になる場合は、隠蔽により無駄な工数を削減できます。次の節で説明します。
計り売りに対応できるようにします。
(初期在庫と入庫時に既に開封したパッケージを追加することは普通はないと思いますので、今回もないものとします。)
struct StockedProduct { std::string mProductName; // 商品名 unsigned mStockCount; // 在庫数量 // 初期化(コンストラクタ) StockedProduct(std::string const& iProductName, unsigned iStockCount) : mProductName(iProductName), mStockCount(iStockCount*100) { } // 入庫処理 void stock(unsigned iCount) { mStockCount += iCount*100; } // 出庫処理 void delivery(unsigned iCount, unsigned iPercent) { mStockCount -= (iCount*100+iPercent); } // 印刷処理 void print(std::ostream& os) { os << mProductName << ":" << mStockCount/100; unsigned aPercent=mStockCount % 100; if (aPercent) { os << " + " << aPercent << "%"; } os << "\n"; } // 商品名獲得 std::string const& getProductName() { return mProductName; } };
よく見て下さい。メンバ関数で外部から見た機能が変わったのは、delivery()関数だけです。これは計り売りに対応するため端数の出庫を可能にしないといけないからです。
これは呼び出し側の「3. 出庫処理(指定の商品について在庫数量を指定数減らす)」の処理も対応が必要ですが、そもそも計り売り対応のためですから、当然必要な改造です。
(このような本質的に必要な改造についてはオブジェクト指向でも他の何かでも省略はできません。)
しかし、以下の処理はその他のメンバ関数の機能が変わっていないのですから、改造不要です。
- 初期化(商品名と初期在庫数量を設定する)
- 入庫処理(指定の商品について在庫数量を指定数増やす)
出庫処理(指定の商品について在庫数量を指定数減らす)- 印刷処理(
std::vector<>
を列挙しつつ、商品名と在庫数量を印刷する。) - 入出庫処理のための商品名検索関数
つまり、内部データ表現を自由に決めることができ、その影響を外部に与えないことが可能なのです。
これにより本質的に変更不要な部分を修正する必要がなくなるのです。
ビバ!! オブジェクト指向プログラミングです。
さて、オブジェクト指向プログラミングにおける実質的な隠蔽による効果は先に示した通りです。
しかし、まだStockedProduct構造体を使うプログラムは、mProductNameとmStockCountにアクセスすることができます。
なので、冒頭で述べたWEBアクセスで「ブックマークはトップページへお願いします」のようなコーディング規約を徹底しないと、いつの間にかmProductNameとmStockCountにアクセスするプログラムが作られてしまいます。
この構造体程簡単なものが1つしか無い時に、mProductNameとmStockCountにアクセスしてはいけないことを周知漏れするとは考えにくいですが、数10個のメンバ変数とメンバ関数を持つようなクラスが多数あるようなプロジェクトで周知徹底することは困難です。
結果として、アクセスしてはいけないメンバにアクセスするプログラムを書く人が出てきてしまいます。
そして、上記の計り売り対応する時、予想外のところに予想外の影響がでてしまい、その対策に見積外の工数がかかって痛いことになるのです。完全に受託会社側の責任なので、この工数をお客様に請求するのは難しいでしょう。
しかし、アクセスしてしまったプログラマも悪意があったわけではない筈です。単にコーディング規約の関連部分を見落としただけというのが一般的と思います。
アクセスできるから間違いが発生するので、アクセスできなくすればよいのです。
もうお判りですね。第20回で解説したアクセス指定子のprivateを用いて外部からアクセスできなくすれば良いのです。
さて、classとstructはアクセス指定の初期値の差だけなのでどちらを使っても良いのですが、オブジェクト指向的に使う時はclassを用いることが多いです。
そして、「2-1.オブジェクト指向でないプログラミング例」で示したように、内部にコンストラクタ程度しかメンバ関数を実装せず、ほとんどのメンバを外部の関数からアクセスするような場合はstructを用いることが多いです。しかし、あまり厳密な使い分けはありません。悩んだ時はclassを使っておけば概ね大丈夫です。
以上を反映すると、StockedProduct構造体は、以下のようにStockedProductクラスとして定義することが望ましいです。
これにより、外部の関数からはmProductNameとmStockCountにアクセスできなくなりますので、間違ってアクセスされることはありません。(*1)
class StockedProduct { std::string mProductName; // 商品名 unsigned mStockCount; // 在庫数量 public: // 初期化(コンストラクタ) StockedProduct(std::string const& iProductName, unsigned iStockCount) : mProductName(iProductName), mStockCount(iStockCount*100) { } // 入庫処理 void stock(unsigned iCount) { mStockCount += iCount*100; } // 出庫処理 void delivery(unsigned iCount, unsigned iPercent) { mStockCount -= (iCount*100+iPercent); } // 印刷処理 void print(std::ostream& os) { os << mProductName << ":" << mStockCount/100; unsigned aPercent=mStockCount % 100; if (aPercent) { os << " + " << aPercent << "%"; } os << "\n"; } // 商品名獲得 std::string const& getProductName() { return mProductName; } };
(*1)間違ってアクセスされることはありません。
この機能は「間違って」アクセスすることを防ぐ機能です。悪意のあるプログラマからアクセスできなくする機能ではありません。
裏技を使ってprivateメンバにアクセスすると、上記のように仕様変更等で問題が生じやすいため、通常のプログラマはそのような愚かなことはしませんから、これで十分なのです。
新人プログラマには、外部から無断でprivateメンバへアクセスすると本人も含めて皆が困ることを教育しましょう。
ああ、今更ですが、今回の内容を振り返ってみると、新しいC++の文法は全く出てきませんでした。
構成を考えた時、private/publicはまだ解説していないと思い違いしてたのです。
たった2週間前に書いたことを見落としていたのはちょっとショックです。歳は取りたくないものですね。
ところで、ご飯を食べたことを忘れることはないので私は認知症ではありません。
物忘れと認知症のたいへんざっくりな見分け方は、物忘れは思い出すきっかけがあると思い出せる、認知症は完全に忘れていて指摘されても思い出せないです。ほら大丈夫です。
さて、次回はオブジェクト指向プログラミング3大特長の2番目、「継承」について解説したいと思います。オブジェクト指向プログラミングを使うと強力なプログラムを書ける要素の一つです。お楽しみに。