終了処理とコピーコンストラクタ

コピーコンストラクタ(と代入演算子)はなかなか扱いの難しい代物ですが,今回は「デストラクタで何らかの終了処理を記述している場合」でのコピーコンストラクタの設計方針について検討します.

例えば,以下のようなファイルの入出力処理を扱うクラスを考えます.

class file_wrapper {
public:
    file_wrapper() : f_(NULL) {}
    
    explicit file_wrapper(const char* filename) : f_(NULL) {
        this->open(filename);
    }
    
    virtual ~file_wrapper() throw() {
        this->close();
    }
    
    file_wrapper& open(const char* filename) {
        f_ = fopen(filename);
        if (f_ == NULL) throw std::runtime_error("failed to open");
        return *this;
    }
    
    void close() {
        if (f_) {
            fclose(f_);
            f_ = NULL;
        }
    }
    
    file_wrapper& read(char* dest, size_t n) { ... }
    file_wrapper& write(const char* src, size_t n) { ... }
    
private:
    FILE* f_;
};

このクラスは,例えば,以下のような使われ方をするとうまく動きません.

class foo {
    explicit foo(const file_wrapper& f) : f_(f) { ... }
    ...
private:
    file_wrapper f_;
};

int main(int argc, char* argv[]) {
    foo x(file_wrapper("foo.txt"));
    ...
    return 0;
}

コピーコンストラクタを定義していないため,上記のコードは FILE* 変数の(ポインタ)値がコピーされることになるのですが,file_wrapper("foo.txt") と言う一時オブジェクトを生成しているので foo x のコンストラクタが実行された直後に file_wrapper の(一時オブジェクトの)デストラクタが実行されます.その結果,この後に foo.f_ が保持している(ポインタ)値を用いてファイル操作を行おうとすると,予期せぬ動作が起こります(多分,segmentation fault).

以下,この問題への対策方法について検討します.尚,ここで記述している事は CLX C++ Libraries - socket の修正の軌跡であったりもします.

1. non-copyable(コピー不可)にする

最も簡単な方法です.そもそもコピーを認めているからこういった問題が発生しているので,いっその事コピーを禁止してしまおうと言う設計方針です.実際,C++ の istream/ostream 辺りはこの方針を取っているようです.あるクラスを non-copyable にするには,以下のように記述します.

class file_wrapper {
public:
    file_wrapper() : f_(NULL) {}
    ...
private:
    // non-copyable
    file_wrapper(const file_wrapper& cp);
    file_wrapper& operator=(const file_wrapper& cp);
    
    FILE* f_;
};

この方針を取った場合,クラス利用者に若干の不便を強いるようになります.例えば,先に挙げた(file_wrapper を利用した)コードは次のように修正する必要があります.この辺りは,コピー可能に設計することのコスト(難しさ)とのトレードオフになるだろうと思います.

class foo {
    explicit foo(file_wrapper& f) : f_(f) { ... }
    ...
private:
    file_wrapper& f_; // メンバ変数を参照型にする.
};

int main(int argc, char* argv[]) {
    file_wrapper f("foo.txt");
    foo x(f);
    ...
    return 0;
}
2. コピーコンストラクタで特別な処理を行う

コピー可能なクラス設計にする場合,最初に考えられることは独自のコピーコンストラクタを定義して,そこに特別な処理を記述することです.例えば,初期の clx::socket では,close() が呼ばれる前に dup() でソケットを複製してしまう,と言う処理を採用していました.

この問題に対する解決策として,コピーコンストラクタ(および代入演算子)が呼ばれた場合にはdescriptorを複写することが挙げられます.descriptorを複写するための関数は,POSIXではdup(),windowsではDuplicateHandleという関数が用意されているようで,具体的には,以下のようなコードになります.

マルチプラットフォームなソケットクラスの実装に関する覚書 - Life like a clown
3. 問題となるものをコピー/被コピーオブジェクトで共有する

現在の clx::socket, および clx::unzip などコピーコンストラクタの扱いが問題となるクラスで採用している設計方針です.例えば,先に挙げた file_wrapper を修正するとすれば以下のようになります.

class file_wrapper {
public:
    file_wrapper() : f_(NULL) {}
    
    explicit file_wrapper(const char* filename) : p_() {
        this->open(filename);
    }
    
    virtual ~file_wrapper() throw() {}
    
    file_wrapper& open(const char* filename) {
        f_ = fopen(filename);
        if (f_ == NULL) throw std::runtime_error("failed to open");
        p_ = std::tr1::shared_ptr<fimpl>(new fimpl(f_));
        return *this;
    }
    
    void close() {
        if (p_) {
            p_->close();
        }
    }
    
    file_wrapper& read(char* dest, size_t n) { ... }
    file_wrapper& write(const char* src, size_t n) { ... }

private:
    class fimpl {
    public:
        explicit fimpl(FILE* f) : f_(f) {}
        virtual ~fimpl() throw() { this->close(); }
        
        void close() {
            if (f_) {
                fclose(f_);
                f_ = NULL;
            }
        }
        
        FILE* handler() { return f_; }
        
    private:
        FILE* f_;
    };
    
    std::tr1::shared_ptr<fimpl> p_;
};

file_wrapper では,コピーコンストラクタと FILE* の終了処理の兼ね合いが問題となっていました.そこで,FILE* 変数と終了処理を記述したクラスを file_wrapper の中で定義し,file_wrapper はそのクラスのスマートポインタをメンバ変数として保持しておきます.これによって,コピー/被コピーの関係にあるオブジェクトはある一つの資源(この例の場合,ファイルI/O)を共有することになります.尚,終了処理はコピー/被コピーの関係にある複数のオブジェクト中の最後のオブジェクトが消滅するタイミングで実行されます.

この設計方針がマッチするかどうかは扱う資源に依存するので,その都度検討する必要があります.また,デストラクタによる自動的な終了処理とは別に明示的な終了処理方法をユーザに提供するかどうかも注意深く検討する必要があります(終了処理を行った瞬間,資源を共有している他のオブジェクトも同時にそれらの資源を利用できなくなるため).

いずれにしても,コピー処理をどうするかは注意深く検討する必要があるようです.