syghの新フラグメント置き場

プログラミングTipsやコード断片の保管場所です。お絵描きもときどき載せます。

shared_ptrとconst

(これは2011-01-17に書いた故OCNブログの記事を加筆修正したものです)

あんまり言及してる人がいないんですが、これはC++shared_ptrconstを組み合わせて使う上でとても重要な機能の話です。

通常のconstポインタあるいはconst参照経由では、const操作しかできません。

例えば、

class Vector3F
{
public:
  float x, y, z;
public:
  Vector3F() : x(), y(), z() {}
  Vector3F(float inX, float inY, float inZ) : x(inX), y(inY), z(inZ) {}
public:
  // ベクトルの長さを返す。※内部状態を変更しない。
  float GetLength() const
  { return sqrt(x * x + y * y + z * z); }
  // 自身を正規化する。※内部状態を変更する。
  void Normalize()
  {
    const float len = this->GetLength();
    if (len > 0)
    {
      this->x /= len;
      this->y /= len;
      this->z /= len;
    }
  }
};

というようなクラスがあったとして、

Vector3F vec(1, 2, 3);
const Vector3F* cpVec = &vec;
printf("%f, %f, %f\n", cpVec->x, cpVec->y, cpVec->z);
float len1 = cpVec->GetLength();
const Vector3F& crVec = vec;
float len2 = crVec.GetLength();
printf("%f, %f, %f\n", crVec.x, crVec.y, crVec.z);

はOKだけど、

cpVec->Normalize();
cpVec->x = cpVec->y = cpVec->z = 0;
crVec.Normalize();
crVec.x = crVec.y = crVec.z = 0;

コンパイルエラーになります。
※変数名のcpプレフィックスcrプレフィックスはいわゆるシステムハンガリアンで、基本的には避けるべき記法ですが、今回は便宜上付けているだけですのであしからず。

こういう「内部状態を変更しない」こと(immutableであること)をコンパイル段階で表明し、堅実なプログラムを記述するのに非常に役立つのがC++のconst修飾子(const modifier)であり、C#Javaにはない優れた機能だと思います*1。クラスメンバーを設計したり、変数の型を指定したりするうえで、constを正しく利用することをconst correctnessと呼びます。
C++プログラマーのスキル レベルやライブラリの品質を評価する場合、このconstをきちんと理解して使っているかどうか(const correctnessに配慮しているかどうか)が一つの指標になります。というかconstをきっちり使っていないC/C++ライブラリなんて怖くて使う気になれません。

ここで、型Tのconst生ポインタ型に対応するスマートポインタとして、Boostライブラリのboost::shared_ptrC++0x TR1のstd::tr1::shared_ptr、もしくはC++11のstd::shared_ptrでconstスマートポインタ型を作ることができます。このconstスマートポインタ型からはconst操作しかできなくなります。

std::shared_ptr<std::string> spStr = std::make_shared<std::string>("Hello.");
std::shared_ptr<const std::string> cspStr = spStr;
// 上記の型は std::shared_ptr<std::string const> でも可。
std::cout << *cspStr << std::endl;
//cspStr->clear(); // Compile error will occur

エイリアスを定義する場合は、

typedef shared_ptr<const T> TMySomeConstSharedPtr;
// C++11 以降は以下の書き方も可能。
using TMySomeConstSharedPtr = shared_ptr<const T>;

あるいは

typedef shared_ptr<T const> TMySomeConstSharedPtr;
// C++11 以降は以下の書き方も可能。
using TMySomeConstSharedPtr = shared_ptr<T const>;

とします。

なお、上述のコード例のように、constでないスマートポインタ型の値はconstスマートポインタ型への暗黙変換による代入が可能となっています。これはちょうど下記のように、constでない生ポインタ型の値はconst生ポインタ型への暗黙変換による代入が可能になっているのと同じです。この暗黙変換の機能を知らない人は結構多いらしく、関数内でconst操作しかしていないのに引数がconstでないスマートポインタ型になっているなど、const correctnessがおざなりになってしまっているコードをよく見かけます(constでないスマートポインタをconstスマートポインタ引数にそのまま渡せることをおそらく知らないせい)。

Vector3F vec(1, 2, 3);
Vector3F* pVec = &vec;
pVec->Normalize();
const Vector3F* cpVec = pVec;

こういった通常の組み込み型(プリミティブ型)の仕様に準拠し、組み込み型に用意されている機能や既存のイディオムをユーザー定義型にもそのまま使えるように模倣してくれているところが、C++のテンプレート ライブラリや演算子オーバーロードの醍醐味と言えます。

ちなみに、const shared_ptr<T>shared_ptr<const T> の違いは下記を見れば一目瞭然です。要するに、shared_ptr自体に対する操作をconst操作に限定するのか、それともshared_ptrが管理しているT型のオブジェクトに対する操作をconst操作に限定するのか、ということです。

#include <iostream>
#include <memory>
#include <vector>
#include <cassert>

using TIntArray = std::vector<int>;

void Print(const std::shared_ptr<const TIntArray>& inArray)
{
  assert(inArray);
  for (auto x : *inArray)
  {
    std::cout << x << std::endl;
  }
  //inArray.reset(); // Compile error will occur
  //inArray = std::make_shared<TIntArray>(); // Compile error will occur
  //inArray->clear(); // Compile error will occur
  //*inArray = TIntArray(); // Compile error will occur
}

void Clear(const std::shared_ptr<TIntArray>& inoutArray)
{
  assert(inoutArray);
  //inoutArray.reset(); // Compile error will occur
  //inoutArray = std::make_shared<TIntArray>(); // Compile error will occur
  inoutArray->clear();
}

void Multiply(const std::shared_ptr<TIntArray>& inoutArray, int opMul)
{
  assert(inoutArray);
  for (auto& x : *inoutArray)
  {
    x *= opMul;
  }
}

void Reset(std::shared_ptr<TIntArray>& outArray)
{
  outArray.reset();
}

int main()
{
  std::shared_ptr<TIntArray> myArray = std::make_shared<TIntArray>();
  for (int i = 0; i < 10; ++i)
  {
    myArray->push_back(i);
  }
  Multiply(myArray, 2);
  Print(myArray);
  Clear(myArray);
  Reset(myArray);
  return 0;
}

shared_ptrとキャスト

生ポインタと同様、派生クラスのshared_ptrは基底クラスのshared_ptrに暗黙変換できる共変性 (covariance) を持っているので、アップキャストに関してはそのまま代入することができます。
一方、基底クラスのshared_ptrから派生クラスのshared_ptrへダウンキャストしたり、基底クラスAのshared_ptrから基底クラスBのshared_ptrへクロスキャストしたりする場合(生ポインタであればstatic_castやdynamic_castを使う場面)は、static_pointer_cast()関数やdynamic_pointer_cast()関数を使用します。

#include <cstdio>
#include <memory>

class BaseA
{
public:
  virtual ~BaseA() {}
};

class BaseB
{
public:
  virtual ~BaseB() {}
};

class Derived : public BaseA, public BaseB
{
public:
  Derived() {}
};

int main()
{
  using std::shared_ptr;

  shared_ptr<Derived> spDerived1 = std::make_shared<Derived>();
  printf("pDerived1 = 0x%p\n", spDerived1.get());
  shared_ptr<BaseA> spBaseA = spDerived1; // アップキャスト。
  printf("pBaseA = 0x%p\n", spBaseA.get());
  shared_ptr<Derived> spDerived2 = std::static_pointer_cast<Derived>(spBaseA); // ダウンキャスト。
  printf("pDerived2 = 0x%p\n", spDerived2.get());
  shared_ptr<BaseB> spBaseB = std::dynamic_pointer_cast<BaseB>(spBaseA); // クロスキャスト。
  printf("pBaseB = 0x%p\n", spBaseB.get());

  // 派生クラスのインスタンスが格納されていることが保証され、継承関係が明らかな場合はダウンキャストに static_pointer_cast を使える。
  // 派生クラスのインスタンスが格納されていることが保証されず、実行時に継承関係をチェックする必要がある場合、ダウンキャストにも dynamic_pointer_cast を使う。

  return 0;
}

なぜかこの変換機能を知らない人も結構多いようです。
なお、shared_ptr::get()でいったん基底クラスへの生ポインタTBase*を取り出し、static_castやdynamic_castを使って得た派生クラスへの生ポインタTDerived*をその場で使うだけであればよいのですが、その生ポインタを別のスマートポインタ変数の初期化に使ったりしてはいけません。未定義動作を引き起こします。

ちなみにconst_castへのアナロジーとして、同様にconst_pointer_cast()関数も存在しますが、一応用意されているというだけなので使う機会はまずないでしょう(そう願いたい)。

shared_ptrとインテリセンス

VC++ 2008 SP1にはstd::tr1::shared_ptr(<memory>ヘッダーをインクルードすることで使用可能)が実装されていて、これはboost::shared_ptr(<boost/shared_ptr.hpp>ヘッダーをインクルードすることで使用可能)とほぼ同じものなんですが、どちらもヘッダーをインクルードした途端なぜかインテリセンス(コード補完機能)が効かなくなることがあります。こういうとき、「追加のインクルード ディレクトリ」に「./」もしくは「.\」(カレント ディレクトリの相対パス)を追加しておくと、インテリセンスがちゃんと効くようになることがあります。詳しいことはよく分かっていません。VC++ 2012などではstd::shared_ptrを使うときもちゃんと普通にインテリセンスが機能するので、一応この問題は修正されているように思います。

余談ですが、こういうディレクトリ パスやファイル パスをプロジェクト プロパティで設定するとき、スラッシュ(0x2F)とバックスラッシュ(0x5C)どちらを使っていますか? 自分はVisual C++環境変数$(SolutionDir)など)を組み合わせるときはバックスラッシュを使って、そうでないときは(UNIX環境と互換性があり、さらに日本語Windows環境で語境界が分かりやすい)スラッシュを使うようにしています。日本語環境ではフォントによってはバックスラッシュが円記号になるので、かなり見にくく(醜く)なります。UNIX標準はパス区切りがスラッシュなんですが、Windowsでは基本的にパス区切りがバックスラッシュではあるものの、アプリケーションやAPIによってはスラッシュもバックスラッシュも両方使える場面があります。
それと、自分はC/C++の#includeディレクティブに指定するパス名には、ディレクトリを含む場合は必ず区切り文字にスラッシュを使うようにしていますが、Visual C++のリソース スクリプト(*.rc)ではなぜか標準でバックスラッシュが使われていたりするので、リソース スクリプトを直接編集する場合はバックスラッシュに合わせています。区切り文字にスラッシュもバックスラッシュも許可する仕様にしてしまった(そうせざるを得なかった)Windowsなんですが、こういうあいまいな仕様はやめて欲しかったです。あとASCIIバックスラッシュをフォントによって円記号に割り当てることを最初に考えた人は本当におバカだなと思いました。

*1:C#にはconstとreadonly、Javaにはfinalがありますが、C#のconstはコンパイル時の完全定数のみ、readonlyはフィールドの初期化がコンストラクタのみで可能ということを示すだけで参照型の内部状態変更には関与しない、Javaのfinalは再代入を禁止するだけで参照型の内部状態変更には関与しないなど、C++のconstほどの強力さはありません。StringBuilderクラスのように内部状態の変更を許すmutable型と、Stringクラスのように内部状態の変更を許さないimmutable型を別々に定義する設計スタイルとなっていますが、すべての型にmutable/immutableのバリエーションが用意されているわけではありません。