Exceptional C++ 読書ノート

2003 年の 3 月に本書をブックストア談浜松町駅店で購入した記録がある。が、当時読書ノートを遺していなかったので、再読し、ノートをとる次第だ。

著者:Herb Sutter
訳者:浜田真理
出版社:ピアソン・エデュケーション
発行年:2000 年
ISBN:978-4-89471-270-6

ノート目次

はじめに

本書の目的、対象読者、執筆動機、そして謝辞で構成されている。

  • <C++ での実際的な商用ソフトウェアの設計を主眼としている> (p. vii)

  • <C++ の基本をすでに心得ている読者を対象としている> (p. vii)

    • 基本書として

      1. Stroustrup 本、
      2. C++ Primer
      3. Effective C++

      を勧めている。

汎用プログラミングと C++ 標準ライブラリ

イテレータ

  • アルゴリズム std::find は、指定された値が見つからなかった場合に第 2 引数を返す。
  • <--e.end() という表現が、不正であるかもしれない> (p. 2) というのは盲点だった。普通はこういう書き方はしないから気にしていなかった。
  • イテレータ使用時の 4 つの注意事項は必修。何が「無効」なのかをおさえること。

大文字小文字を区別しない文字列

まず最初に「大文字小文字を区別しない文字列クラス」を書くことを検討している。 basic_string テンプレートの traits パラメータに相当する構造体を自作する手法による。

struct ci_char_traits : public char_traits<char>
{
  static bool eq(char c1, char c2){ ... }
  static bool lt(char c1, char c2){ ... }
  static int compare(const char* s1, const char* s2, size_t n){ ... }
  static const char* find(const char* s, int n, char c){ ... }
  ...
};

typedef basic_string<char, ci_char_traits> ci_string;
  • <多くの場合、大文字小文字の区別を比較処理の機能としたほうがより便利である。しかしながら、著者の経験では、これをオブジェクトの機能とする方が便利な場合もある> (p. 7)
  • ci_string はそのままでは I/O ストリームに流せないことに注意。出力だけなら c_str() をストリームに流せばよい。
  • <標準ライブラリは、traits オブジェクトを多様的に使わない> (p. 9)

最大限に再利用できる汎用コンテナ

fixed_class という固定長 vector クラスの、コピーコンストラクタおよび代入演算子を実装することがテーマ。

<できるだけ単純な設定で重要な問題の解決に当たろうとしていることを理解してほしい> (p. 11)

  • テンプレートメンバ関数は、決してコピーコンストラクタ、代入演算子たり得ない。

  • 例外安全という用語が、本書で初めて登場する。

    template<typename O, size_t osize>
    fixed_vector<T, size>& operator=(const fixed_vector<O, osize>& other)
    {
        std::copy(other.begin(), other.begin() + std::min(size, osize), begin());
        return *this;
    }
    

    <T 代入演算子の 1 つが copy() 操作の途中で失敗すると、そのオブジェクトは矛盾した状態に陥ってしまう。私達の fixed_vector オブジェクトの内容のいくつかは、代入に失敗する前の状態を保つだろうが、他の部分はすでに更新されてしまっていることになる> (p. 17)

    今はとりあえず次の言い回しを頭の片隅に憶えておけばいい。

    • 「矛盾した状態」
    • アトミックで例外を投げない Swap()
    • 「一時オブジェクトを作成して交換する」イディオム

一時オブジェクト

  • <オブジェクトは値渡しではなく const& で渡すこと>(p. 20)
  • for ループの終了条件部で end() を呼ばぬよう注意。 <一度だけ値を計算して、ローカルオブジェクトに格納し再利用すべきである> (p. 20)
  • <前置インクリメントを使おう> (p. 21)
  • コンストラクタに explicit を指定したり、変換演算子を提供しないようにする理由は、一時オブジェクトを密かに生成される機会を牽制するためのようだ。
  • SE/SE (single-entry/single-exit) 規則なるものの存在を初めて知った。もちろん、これは一般に通用しない規則だ。

標準ライブラリの使用(もしくは、一時オブジェクト再考)

前項のハンドメイドの for ループを find アルゴリズムに置き換えた上で、以下のように説く。 <ファンクタと find_if を用いて、もっと格好良くすることも可能だが、 find の単純な再利用が、プログラミング努力の節減と実行時効率にどれだけ効果的か考えてほしい> (p. 26)

例外安全に関する問題点とテクニック

本章の存在が本書の本書たる所以。

例外に対して安全なコードを書く

Cargill 氏の論文の Stack クラステンプレートを、例外安全かつ例外中立にしていく。

例外安全:
例外が発生しても適切に処理する。
例外中立:
すべての例外を呼び出し側に伝える。
  • デフォルトコンストラクタの検討は、特に問題ない。

  • デストラクタは T::~T() が例外を投げないことを仮定できればという条件付きで、問題なし。

  • コピーコンストラクタ・代入演算子の実装方法として、ヘルパー関数 NewCopy をまず定義し、これを利用して実装する。

    この NewCopy の実装のポイントは、

    1. 例外が発生した場合は Stack オブジェクトの状態を一切変更しないことと、
    2. オブジェクトの状態を、絶対に例外を発生しないコードによって変更できていることだ。
  • void Push(const T& t) についても NewCopy で実装する。

    1. NewCopy の呼び出し、
    2. operator delete[] の呼び出し、
    3. 組み込み型の代入、
    4. 主目的の v_[used_] = t
    5. 要素数の更新を、

    オブジェクトの状態に矛盾が生じない順序で処理している。

  • T Pop() を例外安全に実装するのは不可能。というより、呼び出し元で不可能になる。 <一時オブジェクトを返すように、つまり二つの処理効果を担うように書かれた Pop() では、完全な例外安全にすることができない> (p. 38)

    • void Pop(T& result) みたいな形式でならば可能だが、問題点はそういうことではない。「取得する」機能と「取り除く」機能を分離することが望ましい。

例外安全を保証する手段

基本的な保証
例外が発生しても、リソースリークを生じない。
強い保証
<例外によってある操作が終了した場合に、プログラムの状態が変更されていないこと。これは常にコミットロールバックを意味し、ある操作が失敗してもコンテナへの参照やイテレータが無効化されないことを含んでいる> (p. 42)
nothrow 保証
例外を投げない関数であること。ある種の関数は、この保証を満たしていることを要求される。

StackImpl に分離する

Stack のメモリ管理処理を分離してカプセル化する。 <StackImpl が、元の Stack のデータメンバをすべて持っていることに注意> (p. 44)

コンストラクタ
initializer で v_ に対して operator new(sizeof(T) * size)) しているのが目を引く。
デストラクタ
v_ に対して std::destroy()operator delete() を行う。
Swap
すべてのメンバーデータに対して、other のそれと std::swap() するだけ。これにより、nothrow 保証が提供できる。ここが Stack の例外安全を実現する。

StackImpl を private 継承で利用する場合

  • コンストラクタは、単に StackImpl に初期要素数を渡すだけ。

  • デストラクタは書く必要がなくなった。

  • コピーコンストラクタは StackImpl のコピーコンストラクタを呼び 出さない (そもそも禁止されている)。 StackImpl にメモリ確保だけをお願いして、 Stack 側で std::construct による T オブジェクトの構築と、 StackImpl::vused_ の更新を行う。

  • 代入演算子。以下のコードが本書最大の功績の一つだろう。

    Stack& operator=(const Stack& other)
    {
        Stack temp(other);  // これがすべての仕事を行う。
        Swap(temp);         // これは例外を投げない。
        return *this;
    }
    
  • Count()StackImpl::vused_ を返すだけ。

  • Push() でも一時オブジェクトの Swap 技法を適用する。

  • Top()StackImpl 導入前と変更なし。

  • Pop()StackImpl 導入前と変更なし。ただし std::destroy をここで行う。

<リソースの所有権を分割したクラスにカプセル化することの最大の成果は、 Stack のコンストラクタとデストラクタに見ることができる> (p. 54)

StackImpl を包含で利用する場合

基本的には private 継承の場合と同じ。結合度が低くなるのはよいが、コーディングが面倒になる。著者は包含スタイルを推奨している。

例外を投げるデストラクタ

  • <例外で終了するようなデストラクタを書いてはいけない 。このようなデストラクタを持つクラスを書くと、オブジェクトの配列に対し、安全な new[]delete[] を保証できない> (p. 63)
  • オーバーロードした operator delete(), operator delete[]() も同様。
  • 上述の関数を含む削除系関数はすべて throw() とすること。

コードの複雑性

「基本的な保証」「強い保証」「例外を投げない保証」を人に口頭で説明する場合は、以下のように言えばよさそうだ。

<基本的な保証は、デストラクタによる削除が完全に機能し、リークが発生しないことを保証する。強い保証は、これに加えて、完全なコミットロールバックの特徴を保証する。また、例外を投げない保証は、関数が例外を投げないことを保証する> (p. 70)
  • 「強い保証」を担保することは、しばしばパフォーマンスが犠牲になる。
  • すべての関数に「強い保証」を担保することはない。

クラスの設計と継承

クラスの機構

クラスの各種メンバー関数や演算子を定義するときの、一般的なガイドラインについて列挙している。

  • オブジェクトは値渡しではなく const 参照渡しの方がよい。無意味なコピーを省く。
  • a = a @ b ではなく a @= b とする。効率の向上が望める。
  • operator@() を提供する場合、
    • operator@=() も同時に提供すること。
    • 前者は後者で実装すること。
    • @@= の関係を自然にすること。
  • 演算子をメンバ関数とするか、非メンバ関数とするかの決定は重要。ある種の規則がある。
    • = () [] -> はメンバ関数でなければならない。
    • >> << は非メンバ関数とする。
    • 関数が仮想である場合、メンバ関数とする。
    • 以下略。
  • operator<<operator>> の戻り値は、引数のストリームの参照とすること。
  • 後置インクリメントを前置インクリメントで実装すること。
  • アンダースコアで始まる識別子を作ってはいけない。 C++ コンパイラ作成者のために予約されている。

仮想関数のオーバーライド

  • 基底クラスのデストラクタを virtual とすること。ただし、派生クラスのオブジェクトを基底クラスへのポインタ経由で delete しないことになっている場合は別。
  • 継承された関数と同じ名前のメンバ関数を提供する場合、基底クラス側の関数は「隠れる」。
    • 隠す意図がない場合は、public 部に using Base::f; と宣言する。
    • 隠す意図がある場合は、private 部に using Base::f; と宣言する。
  • <オーバーロードした継承関数のデフォルトパラメータ値は、決して変更してはならない> (p. 88)
  • <デフォルトパラメータはオブジェクトの静的な型から決まる> (p. 89)

クラス間の関係

最初の著者の主張は「実装のために public 継承を用いるな」ということだ。特に p. 91 の最後の長いパラグラフが最高。

  • ところで、ここで紹介しているクラスは仮想関数がデストラクタ以外にないのだが、これは意味があるのか。
  • 仮想関数を public にすることを避けるように説いている。代わりに Template Method デザインパターンを導入することを勧めている。完全に同意できる。
  • <人間が 2 種類の仕事を受け持つとストレスを受ける> (p. 96)

継承の使用と誤用

この項目は大切なことをたくさん述べている。まとめるのが難しく、ノートを取りにくい。

コンパイラファイアウォールと Pimpl イディオム

  • <依存性の管理をうまく行うことは、強固なコードを書くための必要不可欠な部分である> (p. 114)

コンパイル時の依存性を最小限にする

  • <プログラマの多くは、習慣的に、必要以上に多くのヘッダファイルをインクルードしている。あいにく、それはビルド時間に大きな打撃を与えることになる> (p. 114)

  • #include <ostream>#include <iosfwd> に置き換えられるか調べるとよさそうだ。

  • 一般に、先行宣言で十分な場合は、対応するヘッダファイルをインクルードしないことだ。

  • <private 部がヘッダファイルで見えてしまっているので、クライアントコードは、 private 部の使用する全ての型に依存してしまう> (p. 118) private 部に変更があったときでも、クライアント側に再コンパイルを強いるのは不親切。

  • Pimpl とは著者による造語だと思われる。クラスの private 部を曝さぬように、隠蔽ポインタメンバーデータを使用するものだ。

    // x.h ファイル
    class X
    {
        // public と protected メンバ
    private:
        struct XImpl* pimpl_; // 先行宣言してあるクラスへのポインタ
    };
    
    // x.cpp ファイル
    struct X::XImpl
    {
        // X の private 部相当を実装
    };
    

    利点はコンパイルの依存性を断ち切ることであり、欠点はパフォーマンスが高くつくかもしれないこと。 XImpl のメモリ確保・削除、 XImpl - X 間の参照が面倒、等。

  • <私の結論は簡単だ。継承は、HAS-A や USES-A よりも強い関係にある。それゆえ、依存性を管理することになれば、常に継承でなく包含を選択すべきだ> (p. 122)

コンパイラファイアウォール

  • XImpl の中に X の何を入れるべきかという問題。著者は X のすべての private メンバ(データと関数の両方)を XImpl に入れることを推奨している。ただし仮想関数は除外する。
  • XImpl には X オブジェクトへの逆参照ポインタが時には必要になる。

高速 Pimpl イディオム

この項はなかったことに────。

名前の自動照合、名前空間、インターフェイス原則

名前の自動照合とインターフェイス原則

Koenig の自動照合の簡単な説明からスタートして、「クラスとインターフェイスを同一の場所に置く」という結論に至るように議論が進む。

Koenig の自動照合

  • Koenig の自動照合を簡単に説明すると、次のようになる。 <関数の引数がクラス型の場合、コンパイラは、正しい関数名を照合するため、関数の引数の型定義を含む名前空間で、一致する関数名がないか調べる> (p. 139)

    例えば、以下のコードはコンパイルされる。

    namespace NS
    {
        class T{ };
        void f(T);
    }
    
    NS::T parm;
    
    int main()
    {
        f(parm);
    }
    

クラスの「構成要素」とインターフェイス

  • ここで、著者はインターフェイス原則を次のように提案する。

    ある関数がクラス X に対して次の条件を満たすときに、その関数は論理的にクラス X の構成要素である。

    1. X に言及している。
    2. X と一緒に提供される。
    • 定義から自動的に、任意の X のメンバ関数は構成要素ということになる。

    • クラス X と同じヘッダ内で、フリー関数の引数に X が用いられるものも、 X の構成要素である。例えば

      class X{ };
      ostream& operator<<(ostream&, const X&);  // これは構成要素
      
  • <operator+ は左辺の引数の型変換を可能にするため、メンバ関数とすべきではない> (p. 144)

  • <Koenig の自動照合は、コンパイラに適切な動作をさせる> (p. 145)

    #include <iostream> // cout
    #include <string> // 文字列の operator<<() の宣言を含む
    
    int main()
    {
        std::string hello = "Hello, world";
        std::cout << hello; // 自動照合により、std::operator<< を呼び出す。
    
        // もし自動照合が存在せず、using namespace std; をしないならば
        // こういうふうに書かなければいけなかった。
        std::operator<<(std::cout, hello);
    }
    
  • 名前空間に関数を追加することは、その名前空間の外側のコードを「破壊」する。次のコード片で AB の作者・定義場所が違うときのケースを考える。

    namespace A
    {
        class X{ };
        //void f(X); // コメントを解除するだけで B::g をコンパイル不可能にする。
    }
    
    namespace B
    {
        void f(A::X);
        void g(A::X parm)
        {
            f(parm); // A::f を生かすとオーバーロードの解決に失敗する。
        }
    }
    

opearator<<() の最適な提供手段

クラスに対する operator<<() を書く方法は主に二つ。

  1. クラスの通常のインターフェイスのみ使うフリー関数とする方法

    class X{ };
    
    ostream& operator<<(ostream& o, const X& x)
    {
        // ...
        return o;
    }
    
  2. クラスのヘルパー関数 Print() を呼び出すフリー関数とする方法

    class X
    {
    public:
        virtual ostream& print(ostream&);
    };
    
    ostream& operator<<(ostream& o, const X& x)
    {
        return x.print(o);
    }
    
  • インターフェイス原則を適用すると、どちらの方法も同程度 Xostream に依存していると見られる。
  • クラス AB が「一緒に提供される」とき、 A のメンバ関数 A::g(B)B の構成要素でもある。
  • <「一緒に提供される」とは「同じヘッダもしくは名前空間に現れる」ことと解釈すればよい> (p. 154)

名前の隠蔽

  • <該当する関数を全く見つけられなかった場合に限り、そのすぐ外を囲んでいるスコープを見にいく> (p. 155)
  • <極端な例をあげると、パラメータの型だけを見た場合、ほぼ一致するメンバ関数が、完全に一致するグローバル関数よりも好まれるということは、直感的に予想できる> (p. 156)
  • <名前空間にクラスを入れる場合、同じ名前空間にヘルパー関数を演算子もすべて置くようにする。これをしないと、他の場所のコードに驚くべき影響が出るかもしれない> (p. 157)
  • クラスを名前空間に全く入れないという選択肢もアリ。

メモリ管理

メモリ管理

C++ で使用する主なメモリ領域

詳しい表が p. 164 にある。

定数データ
  • コンパイル時に値が確定できるデータを格納する。
  • クラス型のオブジェクトはこの領域に存在できない。
  • すべて読み取り専用であり、変更しようとする動作は未定義になる。
スタック
  • 自動変数を格納する。
  • オブジェクトは、定義された時点で直ちに確保・生成され、そのスコープを出た時点で直ちに削除・解放される。
  • メモリ確保がとても速い。
フリーストア
  • 動的メモリ領域
  • 確保と解放は new/delete で行う。
ヒープ
  • 動的メモリ領域
  • 確保と解放は malloc 系の関数と free で行う。
  • ヒープとフリーストアは同じ領域ではなく、どちらかで確保したメモリを、他方で安全に解放することはできない。
グローバル・静的
  • オブジェクトは、プログラムの開始時にメモリ領域が確保される。
  • グローバル変数の、翻訳単位をまたぐ初期化順は定義されておらず、グローバルオブジェクトの依存性を管理する場合は、特別な注意が必要。
  • <特にヒープ (Heap) とフリーストア (Free Store) は通常、便宜的に使用される用語で、 2 種類の動的に確保されるメモリを区別している> (p. 163)

new/delete

  • <クラス専用の operator new(), operator new[]() は、それぞれ必ず operator delete(), operator delete[]() とのセットで提供すること> (p. 168)
  • <operator new()operator delete() は、たとえ static と宣言していなくても、必ず static メンバ関数となる。これらの関数を独自に宣言する場合、C++ では明示的に“static” と宣言することをプログラマに強制しないが、そう書いておくほうが無難だ。自分がコードを書くときだけでなく、他のプログラマがそのコードを保守する際にも目安となるからだ> (p. 169)
  • <operator delete() は static 関数であり、仮想関数とはできないが、仮想関数のように振る舞うのだ> (p. 169)

auto_ptr

  • 多くの商用ライブラリは、洗練されたスマートポインタを提供している。標準の auto_ptr は単純かつ汎用目的のスマートポインタ。

  • <auto_ptr の仕事は、動的に確保されたオブジェクトを所有し、そのオブジェクトが不要になった時点で自動的に後始末を行うことである> (p. 174)

  • auto_ptr は <単に自動変数オブジェクトとして使われるので> (p. 175) そう呼ばれる。スコープから外れる時点で削除される。

  • <関数が正常に終了しようが、例外によって中断しようが、メモリリークを生じない> (p. 175)

  • ポインタデータメンバを安全にラップすることにも使用できる。 Pimpl イディオム実装時に頻出。

    // c.h
    class C
    {
    public:
        C();
        ~C();
        //...
    private:
        struct CImpl; // 先行宣言
        auto_ptr<CImpl> pimpl_;
        C& operator=(const C&);
        C(const C&);
    };
    
    // c.cpp
    class C::CImpl{ ... };
    C::C() : pimpl_(new CImpl){ }
    C::~C(){ }
    
  • auto_ptr では、 コピー同士が等しくない。

  • auto_ptr のコピーコンストラクタ、代入演算子の引数は、非 const 参照を取るようになっているはず。

  • ある関数が 2 つの処理を含んでいるとする。このとき「関数がアトミックに振る舞う」と説明されたら、「どちらも実行した、あるいは、どちらも実行しなかった」と解釈する。

  • const auto_ptr イディオム: <auto_ptrconst をつけることによって、所有権を決して手放さなくなったことの意味は大きい> (p. 184)

罠、落とし穴、そして反イディオム

オブジェクトの同一性

代入演算子のコードにおける this != &other テストの是非について。

  • 代入のロジックが、自己代入時には通じぬようなものであってはならない。 this != &other テストが、自己代入に対する適切な動作を保証する目的ならばダメ(不必要な処理を省く最適化という文脈でならば可とする)。

ポインタ同士の比較は、<人が考えているようには行われない場合もある> (p. 188)

  • 文字列リテラル同士の比較は未定義。特に、コンパイラは <2 つの異なる文字列リテラルに対して同じポインタ値を割り当てることもでき> (p. 188) るので、比較結果が等しくなる場合がある。
  • <一般に、組み込み演算子の < <= > >= を使って任意の生のポインタを比較しても、結果は不定となる> (p. 188)

自動型変換

これまでも何度か紹介されていたが、一般に、暗黙の型変換は安全ではないとされている。理由は次の二点にある。

  1. オーバーロードの解決を阻害する。
  2. 間違ったコードのコンパイルを簡単に通してしまう。

2. の例として、stringconst char* に暗黙の型変換が存在するとすれば、次のコードのコンパイルが通る。

string s1, s2, s3;
s1 = s2 - s3; // 右辺は const char* ポインタの差となり、左辺 s1 に代入しようとする

結論:型変換演算子も、非 explicit 変換コンストラクタも書かないようにする。

オブジェクトの生存期間

エキセントリックなコード例を挙げ、そういうことはするなという議論をする。

その他のトピック

変数の初期化──それとも?

T t;  // デフォルト初期化であり、T::T() で初期化される
T t(); // T 型のオブジェクトを返す関数 t の宣言
T t(u); // 直接初期化であり、適当なコンストラクタ T::T(u) を呼び出す。
T t = u; // コピーコンストラクタが呼び出される。T t(u); または T t(T(u)) と同じ。

const の正しい利用

  • 値渡しする関数のパラメータは const 宣言しない。
  • const 値を返す関数はテンプレートの実体化の邪魔になる。組み込み型に対してはそもそも冗長。
  • 物理的には非 const メンバ関数であっても、論理的に const 関数ならばそのように宣言する。変更を加えたいメンバ変数は、元から mutable 宣言しておけばよい。
  • <mutable を正しく使うことは、const を正しく使うことの重要な一部である> (p. 212)
  • <できれば、ライブラリベンダーの怠慢への不平と、代替製品を切望している次第を傍に詳しくコメントしておくと良い> (p. 212)

キャスト

  • <const または volatile 属性をキャストで取り除くのは、通常、まずいスタイル例である。ポインタまたは参照の const 属性を合法的に取り除きたい場合のほとんどは、クラスのメンバ変数に関係しており、mutable キーワードで処理される> (p. 217)
  • dynamic_cast はクロスキャストにも用いることができる。

真偽値 (bool)

  • wchar_t は C では typedef だったが、C++ では組み込み型。

転送関数

  • <今日、コンパイラがコピーコンストラクタを取り除くことのできる状況は、戻り値の最適化 (Return Value Optimization) と、一時オブジェクトに関してのみである> (p. 224)
  • <早い話が、デフォルトではすべての関数をアウトラインにした方が良い> (p. 225)

制御フロー

  • コンストラクタの初期化リストにある 基底クラスの リストは、クラス定義にそれらが出現する順と同じにしておく。
  • コンストラクタの初期化リストにある メンバ変数の リストは、クラス定義にそれらが出現する順と同じにしておく。

あとがき

次回作の予告みたいなことが記されている。実際、More Exceptional C++ はここに書かれている内容を盛り込んでいる。

参考文献

  • Lippman98 C++ Primer はアスキーから日本語版が出ているはず。
  • Meyers98 Effective C++, Second Edition これは Third Edition が出ているはず。