atoshのブログ

一生 C++ します!

C++のためのAPIデザイン 読書メモ 第2章 その3

優れたAPIの特徴(再掲)

  • 内部実装が隠蔽されていて,
  • 使い方がわかりやすく,
  • 疎結合であること

疎結合

優れたAPIは結合度が低く凝集度が高い. コンポーネント間の結合を可能な限り低く保とう. 疎結合であるとは,

名前だけの結合

可能な限り前方宣言を使おう. ヘッダで別のヘッダをインクルードすると, 依存性が伝搬していく.

良くない例:

#include "my_object.h"

class MyObjectHolder {
public:
    MyObjectHolder(const MyObject& my_object);
    const MyObject& GetMyObject() const;
private:
    MyObject my_object_;
};

良い例:

class MyObject;  // 前方宣言

class MyObjectHolder {
public:
    MyObjectHolder(const MyObject& my_object);
    const MyObject& GetMyObject() const;
private:
    MyObject* my_object_;
};

メンバ関数よりフリー関数を使おう

フリー関数はクラスのパブリックメンバにしかアクセス出来ないので, 結合度が下がる. また, クラスの機能を最小限に保つことができる.

良くない例:

class Foo {
public:
    ...
    const std::string& GetName() const;
    void PrintName() const;  // 便利メソッドはクラスから独立させるべき
    ...
private:
    std::string name_;
};

良い例:

class Foo {
public:
    ...
    const std::string& GetName() const;
    ...
private:
    std::string name_;
};
void PrintName(const Foo& foo);

意図的な冗長性が時には正当化される

論理的にはコンポーネントAがコンポーネントBに依存しているが, ごく一部分にしか依存していない時, 依存部分だけを抽出し, Aにも同じ情報を持たせることで, A→Bの依存を断ち切ることができる.

// b.h
class B {
public:
    // たくさんのメンバ関数
private:
    std::string name_;
    // その他たくさんのメンバ変数
};

// a.h
#include "b.h"
class A {
public:
    ...
private:
    B b_;
};

上記は, AがBの名前のみ必要である場合に, 以下のようにできる.

class B {
public:
    // たくさんのメンバ関数
private:
    std::string name_;
    // その他たくさんのメンバ変数
};

// a.h
class A {
public:
    ...
private:
    std::string name_;
};

マネージャクラスでローレベルクラスをカプセル化

個人的な経験ではXxxManagerクラスは地雷である場合が多く, 特に推奨したくないのでスキップ.

コールバック, オブザーバー, 通知

イベント発生時に他のクラスに通知する場合の手法.

コールバック

モジュールBにモジュールAの関数を渡し, 必要なときにBがAの関数を呼び出す仕組み. C++11以前なら関数ポインタ, 以降ならstd::function.

class canceled : public std::runtime_error {};

class B {
public:
    B(std::function<void()> callback);
    void DoSomething();
    ...
private:
    std::function<void()> callback_;
    ...
};

B::B(std::function<void()> callback) : callback_(callback) {}
void B::DoSomething() {
    callback_();
    // do something...
}

// 状態を出力したり
B([]{
    std::cout << "Do something." << std::endl;
}).DoSomething();
// キャンセルチェックしたり
std::atomic<bool> has_canceled;
B([&has_canceled]{
    if (has_canceled) {
        throw std::runtime_error("canceled");
    }
}).DoSomething();

オブザーバーパターンは第3章で詳しく説明.

通知

互いに関連しないコンポーネント間で通知を送る仕組みに関して. シグナル/スロットがよく使われる.

// 引数なし戻り値なしのシグナルを作成
boost::signal<void()> signal;
// シグナルにスロットを接続
signal.connect([]{ std::cout << "MySlot called." << std::endl; });
// シグナルを発行することで, 接続されたスロットが呼ばれる
signal();

安定性, 文書化, テスト

  • 適切にバージョン管理されて後方互換性が保たれていること.
  • 十分に文書化されていれば, ユーザーがエラー条件やベストプラクティスなどについての明確な情報が得られる.
  • 自動テストがあれば既存のユースケースを壊さずにAPIを変更できる.

C++のためのAPIデザイン

C++のためのAPIデザイン

C++のためのAPIデザイン 読書メモ 第2章 その2

優れたAPIの特徴(再掲)

  • 内部実装が隠蔽されていて,
  • 使い方がわかりやすく,
  • 疎結合であること

優れたAPIは最小限に完全であるべき

APIに期待されている機能が十分に提供されていて, 必要以上の機能は提供されていないことが重要.

約束し過ぎないこと

つまり, 機能を追加し過ぎないこと. 一旦APIをリリースしてしまうと, 既存機能を削除するのは非常に難しい. 明白に必要なものだけ提供し, 不確かなものは書かないでおくこと.

仮想関数の追加は慎重に

仮想関数の宣言は有効かつやむを得ない必要性が無い限り, 避けること. オーバーライドによってAPIの完全性が失われる可能性があるからだ. サブクラス化も, 意味があるとき(is-a関係)のみ許可すべきだ. テンプレートメソッドパターンの使用も合わせて検討すること. 公開インターフェースを非仮想にすることで, 結合度が下がる.どうしても仮想関数をパブリックに使う場合, 以下の原則に従うこと:

  • ベースクラスに仮想デストラクタを宣言すること.
  • クラスのメソッド間の関連をドキュメント化すること.
  • コンストラクタまたはデストラクタから仮想関数を呼び出さないこと.

コンビニエンスAPI

コアAPIを最小限に保とうとすると, クライアントが簡単に使えなくなるかもしれない. そんな時はコアAPIのパブリック関数をラップする補助APIを提供することを検討しよう. その場合, 補助APIはコアAPIと完全に独立しているようにすること. こうすることで, APIを使いやすくしつつ, 最小設計を維持することができる. コアAPIクラスのメソッドを無秩序に増やさず, 基本機能と高水準の機能を別のレイヤーに構築すること.

使いやすさ

ヘッダの宣言を見ただけで使い方がわかるようになっていること. 気を散らす斬新なインターフェースではなく, 既存のモデルやパターンを活用し, ユーザーを目の前のタスクに集中させること.

解明しやすさ

付属の説明やドキュメントを読まなくても, 自分で使用法を解明できるAPIであること. そのためには,

  • 直感的で論理的なオブジェクトモデルを採用すること
  • クラスや関数にわかりやすく適切な名前をつけること(略語は使わないように!)

間違いの防止

使いやすいだけでなく, 誤用しにくいAPIであること. 例えば, フラグはbool型ではなくenum型を採用することで, 間違いをコンパイルエラーにできる.

// 良くないインターフェース
std::string FindString(const std::string& text, bool search_forward, bool case_sensitive);

// 良いインターフェース
enum class SearchDirection { FOWARD, BACKWARD };
enum class CaseSensitivity { CASE_SENSITIVE, CASE_INSENSITIVE };
std::string FindString(const std::string& text, SearchDirection direction, CaseSensitivity case_sensitivity);

さらに踏み込んだ日付型の例が本書では掲載されている.

一貫性

提供するAPI全体を通して, 一貫したデザインポリシーを持って, 使い方のルールを覚えやすく, 採用しやすくすべきである.

  • 一貫した命名. 同じ概念には同じ名前を使うこと. 例えば, begin() に対して end() を対応付けたら, start(), finish() は混ぜてはいけない. prev を使ったり previous を使ったりしてはならない. 略語はなんとしても避けるべきである.
  • 引数の順序にも一貫性を持たせること. 出力の引数が最初に出現したり, 最後に出現したりと, バラバラなのはお粗末なAPIの例である.
  • 似た役割を持つクラスは, 一貫して似たインターフェースを提供すべき. 例として, STL のコンテナは begin(), end(), size() など, 名前を同じくするメソッドを提供している. 各種コンテナを使う際に, 常に慣れた方法でプログラミングすることができる.
    • というわけで API を記述する際は STL のパターンを真似るのがベターである.

相互独立性

解釈が2つある.

副作用のない関数

メソッド呼び出しが副作用をもたらさないようにすべき. ある特定の状態を変更する API は, その他のどの状態をも変更してはならない. そのようにすることで, API の動作が予測可能になり, 変更の際の影響も局所化できる.

相互独立性を持ったAPIを設計する上で,

  1. 冗長性の削減: 1つの情報を2つ以上の方法で表してはいけない. アクセスメソッドを信頼できる1つに限定すること.
  2. 独立性の拡大: 開示する概念に重複する意味があってはならない. コンポーネントと概念は1対1対応するべき.

データとアルゴリズムの分離

すべての異なるオペレーションは, それぞれの使用可能なデータ型に適用できるのが良い. 例えば STL では, std::count は std::vector, std::map, std::set などのどのコンテナにも適用できる.

リソース管理

スマートポインタを使って, API を使いやすくしよう. メモリ管理でクライアントを煩わせないこと. C++でのよくあるメモリ関連エラーは std::shared_ptr, std::weak_ptr, std::unique_ptr を使うことでこの種の問題の大半が回避できる. new, delete を生で書いたら負け.

  • Nullポインタの間接参照 -> std::weak_ptr で検出可能
  • 2重開放 -> そもそも delete を書かなくて OK
  • ダングリングポインタ -> std::weak_ptr で検出可能
  • アロケータの混同(mallocしてdeleteなど) -> カスタムデリータで回避
  • 配列の不正な開放(delete[]のところをdelete] -> カスタムデリータで回避
  • メモリリーク -> std::shared_ptr, std::unique_ptr で回避

ファクトリ関数など, ポインタを返す関数がある場合,

  • クライアント側に開放する責任がある -> std::shared_ptr / std::unique_ptr で返すべき.
  • API側に所有権がある(プールなど) -> 標準のポインタで返すことができる. クライアント側でdeleteしないように明示しておくこと.

このようなリソース管理イディオムは排他制御, ファイルハンドル, ソケットなどでも有効である(RAII). 何らかのリソース割り当てと解除の機構を提供する場合, それを管理するクラスを提供して, コンストラクタで割り当て, デストラクタで開放するようにするべきである. その際, デストラクタで例外を投げないように注意すること.

プラットフォーム独立性

パブリックヘッダにプラットフォーム固有の ifdef を入れてはいけない. 実装の詳細が漏れているからだ. ifdef で囲まれた関数を使う際には, クライアントコードも ifdef を書かなければならなくなる. さもないとクライアントコードはコンパイルできない. プラットフォームのアップデートにも弱い. プラットフォーム差異は実装ファイルに隠蔽し, すべてのプラットフォームで一貫した API を提供すべきだ.

// ダメな例. 位置情報取得APIにプラットフォーム依存性がある
class MobilePhone {
public:
    bool StartCall(const std::string& number);
    bool EndCall();
#ifdef TARGET_OS_IPHONE
    bool GetGPSLocation(double* let, double* lon);
#endif
};
// 良い例
class MobilePhone {
public:
    bool StartCall(const std::string& number);
    bool EndCall();
    bool HasGPS() const;
    bool GetGPSLocation(double* let, double* lon);
};

// 以下 cpp ファイルで定義
bool MobilePhone::HasGPS() const
{
#ifdef TARGET_OS_IPHONE
    return true;
#else
    return false;
#endif
}
...

C++のためのAPIデザイン

C++のためのAPIデザイン

C++のためのAPIデザイン 読書メモ 第2章 その1

優れたAPIの特徴

結論からいうと,

  • 内部実装が隠蔽されていて,
  • 使い方がわかりやすく,
  • 疎結合であること

問題が抽象化されていること

APIは解決すべき問題の抽象概念を提供すべき. ハイレベルな概念に基づいてデザインが決定されるべきであり, 内部実装の課題を開示すべきではない. ここでいう抽象化された問題とは, 「名前と住所を管理する」といったことであり, APIで提供されるクラスと関数はその問題を解くためにあるということが表現されているべきであるということ. Personオブジェクトのリストで管理するなどという内部実装の内容はクライアントには知られてはならない.

実装の隠蔽

本書で繰り返し語られる超重要なテーマ. 実装が隠蔽されることによって, クライアントは内部実装に依存したコードを書けなくなり, 後でクライアントコードに影響を与えずにAPIの実装を変更できるようになる. クライアントにハックされないAPIを構築すること.

  • .hファイルに宣言を, .cppファイルに実装を書くこと. 原則的に, ヘッダには実装を書いてはならない.
  • クラスのメンバ変数はprivateにすること. publicにするとクライアントコードで予期せぬ使われ方がされ, 後の変更が不可能になる. privateにしてgetter/setterを用意し, その実装をcppファイルの中に隠すことで, 通知, 排他制御, 遅延評価, キャッシュの利用など, 後の変更が用意になる. getterのみ用意してイミュータブルオブジェクトにすることもできる. メンバ変数はprotectedにしてもいけない. クライアントがサブクラスを作成すればメンバ変数を自由に操作できてしまうからである.
  • パブリックにする必要のないメンバ関数も隠蔽すべき. クラスというものは, 何をするかを定義すべきであり, どのようにするかを定義するのではない. privateメンバ関数にするのでも不十分である. 公開されたヘッダは書き換え可能だからだ. private関数は.cppファイルに移動してstatic関数に変換し, ヘッダから削除すること.
    • 特に, privateメンバ変数を非constのポインタor参照で返すメンバ関数は内部状態のリークであり, クラスの管理なしにクライアントによって変更されうるので, 絶対に避けなければいけない.
  • 可能な限りPimplイディオムを採用すること. これにより, クラスの内部実装を完全に.cppファイルに隠蔽することができる.
  • private関数と同様の理由により, 実装の詳細のみに必要なクラスも, すべて.cppに隠蔽すべき.

例: httpサーバーからファイルダウンロード

#include <string>
#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>

// 悪い例. クライアントが知らなくて良いメソッドが多すぎ
class UrlDownloader {
public:
    UrlDownloader();
    bool DownloadToFile(const std::string& url, const std::string& localfile);
    bool SocketConnect(const std::string& host, int port);
    void SocketDisconnect();
    void IsSocketDisconnect() const;
    int GetSocket() const;  // 内部状態のリーク. クライアントが自由にソケットを直に操作できる
    bool SocketWrite(const char* buffer, size_t bytes);
    size_t SocketRead(char* buffer, size_t bytes);
    bool WriteBufferToFile(char* buffer, const std::string& filename);

private:
    int socket_id_;
    struct sockaddr_in serv_addr_;
    bool is_connected_;
};
#include <string>
#include <memory>

// 良い例. ユーザーシナリオ上必要なメソッドだけ提供
class UrlDownloader {
public:
    UrlDownloader();
    ~UrlDownloader();
    bool DownloadToFile(const std::string& url, const std::string& localfile);

private:
    // Pimplイディオムで実装を隠蔽
    struct Impl;
    std::unique_ptr<Impl> impl_;
};

C++のためのAPIデザイン

C++のためのAPIデザイン

C++のためのAPIデザイン 読書メモ 第1章

太字は個人的に気に入った文の引用。

はじめに

APIとはなにか

ソフトウェアコンポーネントに対するクライアントの操作方法を定義したもの。 ソフトウェアコンポーネントとは、ある抽象化された問題に対するソリューションを実装したもの。

APIを設計する目的

実装の詳細を完全に隠蔽しながら、コンポーネントの機能に対する論理的なインターフェースを提供すること。アプリケーションを機能、レイヤーごとにコンポーネント化し、それぞれが独立したAPIを備えることで、分業して開発されたものを統合するだけでアプリケーション開発ができる。

APIの構成要素

  1. ヘッダファイル: クライアントコードにコンパイルさせるインターフェースの集合。

  2. ライブラリ: APIの機能を実装したスタティック/ダイナミックライブラリ。クライアントアプリケーションはこれらをリンクする。

  3. ドキュメント: APIの使用に関する概要情報。

API設計の特殊性

アプリケーション開発と違うところ。

  1. APIは開発者のためのユーザーインターフェースである。使いにくいとユーザーは離れていく。

  2. API複数のアプリケーションで再利用される。APIのエラーはそれを使うすべてのアプリケーションに影響する。

  3. APIに変更を加えるたびに後方互換性に配慮することが重要。バージョンアップのたびにクライアントコードがコンパイルできなくなったり、動作が変わったりしてはならない。変更のたびにAPIレビューを行い、妥当な変更であることをチェックしなければならない。優れたAPIはクライアントに迷惑をかけずに根本的な変更や改善を行っていける

  4. 優れたドキュメントが必要。ユーザーがコードを見て使い方を類推するのでは、適切な使われ方がされない可能性がある。

  5. 自動テストが必要。APIの正確性は数千人もの開発者とエンドユーザーに影響する。変更するたびにリグレッションテストを行うこと。すべてのリリースが安定していること。

APIの必要性

自分を助けるために。 1. 実装を隠すことで、ユーザー(開発者)を混乱させずに変更を加える事ができる。クライアントコードに変更を強いるAPIはユーザーが離れていく。

  1. アプリケーションの各コンポーネントAPIの抽象インターフェースのみに依存していれば、モジュール化が促進され、疎結合となり、保守可能性が上がり、製品の寿命が伸びる。

  2. コードの重複を削除できる。コードの重複はソフトウェア工学における大罪であり、可能な限り撲滅しなければならない。インターフェースの元にロジックをまとめておけば、1箇所の変更ですべてのクライアントコードに影響をあたえることができる。

  3. ファイル名や各種定数値などをAPI呼び出しでアクセスできれば、ハードコードされた値を撃退できる。

  4. 実装が隠れているので、リファクタリングや最適化も用意になる。

  5. 優れたAPIは再利用可能であり、複数のアプリケーションに組み込むことができる。それにより開発サイクルを短縮でき、アプリケーションのコアロジックに専念できる。

  6. 並行開発が可能になる。自分が作成したAPIを同僚が使って別のプログラムを開発する際に、APIの仕様を予め取り決めておき、ヘッダとスタブ実装のみ提供しておけば、同僚はAPIの完成を待つことなく自身の作業を進められる。

APIを仕様すべきでない場合

サードパーティ製のAPI選定基準。 1. ライセンス条項による制約がある場合。商用アプリケーションに対するGPLなど。

  1. 機能が足りない、プラットフォームのサポートがない場合。

  2. APIにバグがあるのに、実装ファイルが公開されていないためにバグを追跡できない場合。開発がAPIの問題で律速される。

  3. ドキュメントがない場合。使用方法が明確でなかったり、特定の条件での動作が不明なAPIや、時間を割いて自分のコードを説明しようとしないエンジニアの仕事は信用できない

APIの種類

  1. OSAPI。POSIXだとfork()でWindowsだとCreateProcess()だとか。

  2. 言語APIC++だとlibcやSTLシステムコールを隠蔽したり、抽象アルゴリズムのセットを提供したり。

  3. 画像API。libjpeg,libtiffなど。画像ファイルの操作のまとまりを提供する。

  4. 3DグラフィックスAPIOpenGLDirectXグラフィックカードドライバへのアクセスを抽象化する。

  5. GUI API。Qt、GTK+など。OSによるGUI APIの差異を吸収する。

ファイル形式とネットワークプロトコル

独自のファイル形式を作成した場合や、クライアント/サーバー間でやり取りされるデータ形式を用意した場合、それとセットでAPIも必ず作成すること。そうすることで仕様の詳細や将来的な変更も集約させて隠蔽できる。 クライアント/サーバーがAPIのみに依存し、通信の詳細が隠されていれば、UDPからTCPへの変更なども容易となる。

C++のためのAPIデザイン

C++のためのAPIデザイン