今日のメニュー:特殊調味料:const

今日のポイント

const という調味料、何にふりかけるかでいろいろ使い道がある。変数やポインターにもかけられるし、関数にもかけられる。いずれの場合も基本は問題のスコープの中でふりかけたもの(const キーワードの直後に現れたオブジェクト)の状態を変えないという意味である(味を変えないわけだから調味料と呼ぶのはあまり適切な喩えではないが)。

 

特殊調味料:const を変数、ポインター、参照にふりかけた場合

変数に const をふりかけた場合、つまり「定数」オブジェクトから始めよう。「定数」オブジェクトとは、一度そのインスタンスを生成したらその状態を変更できないオブジェクトのことである。TypeA がすでに定義されているとして

const TypeA c = VALUE1; // 定数
TypeA v       = c;      // 変数

のように書いた場合を考える。最初の行は cTypeA 型の値 VALUE1 を持つ「定数」オブジェクトであることを宣言すると同時に、「そのインスタンスを生成し」初期値を与えることでそれを定義している。このような初期値付きの宣言では、デフォールトコンストラクターで生成されたインスタンスにその値が代入演算子を使って代入されるのでなく、コピーコンストラクターを使って与えられた値を持ったインスタンスが直接生成されることはすでに constructor & destructor の巻で述べた。その必然性がこの例から分かる。実際、実行文中で例えば

const int c; // NG (コンパイルエラー)

と書くとコンパイルエラーになる(後で述べるようにクラスの定義の中のデータメンバーの宣言ではこれが正しい)。c は不定のままで値の設定のしようのない無用の定数となってしまうからである。しかし、実行文中で

const TypeA c;

が許されるかどうかは、C++ の実装によるようだ(ISO C++ の仕様はどうなのだろうか?)。素朴に考えると、デフォールトコンストラクターでデータメンバーの少なくとも一部が初期化されるならば意味があるのでコンパイルが通り、デフォールトコンストラクターでデータメンバーがどれも全く初期化されないならばコンパイルエラーになるというのが自然だが、gcc3.1 の実装では、データメンバーが初期化されるか否かにかかわらず、デフォールトコンストラクターが明示的に実装されていさえすればコンパイルは通る。 つまり gcc3.1 では

class TypeA {
  public:
#if 0
    TypeA(int n = 0) : fN(n) {} // より良い実装
#else

    TypeA() {} // これでも TypeA 型の定数をデフォールトコンストラクトできてしまう
#endif
  private:
    int fN;
};

int main()
{
  const TypeA c;
  return 0;
}

は通ってしまう。もちろん、データメンバー fN の値はめちゃめちゃであるが、もはや変更できない。当然、実際のコーディングでは上の例の「より良い実装」を採用すべきである。

次に、同じことをクラスの定義の中でやってみる。すでに TypeA が「より良い実装」で定義されているとして

class TypeB {
    private:
        const TypeA fC1; // OK
        const TypeA fC2 = VALUE2; // NG (警告:fC2 を static に変更)
};

とすると、fC1 の宣言は良いが、fC2 の宣言は怒られる(static const なら良いわけは static の巻で考える)。つまり、初期値付き宣言はできない。これは、上に述べたように、初期値つき宣言がコピーコンストラクターの実行を伴うからである。データメンバーのインスタンスの生成は、そのクラスのオブジェクトのインスタンスが生成される時、すなわちそのクラスのオブジェクトのコンストラクターが呼ばれてはじめて行われので、データメンバーの宣言ではコンストラクターを呼べないのである。このことは const をとっても同様である。
「定数」データメンバーの初期値はコンストラクターの初期化リストで initilizer を使って

class TypeB {
   public:
    
TypeB(int n1 = 0, int n2 = VALUE2) : fC1(n1), fC2(n2) {} // これなら文句なし
   private:
        const TypeA fC1;
        const TypeA fC2;
};

のようにするのが正しい。

ふ〜っ!たかが const、されど const で、「定数」オブジェクトだけでこんなに長くなってしまった。次にその「定数」オブジェクトが何かのポインターである場合を考えよう。再び TypeA がすでに定義されているとして、

const TypeA c = VALUE1; // 定数
TypeA v       = c;      // 変数

のような、始めに考えた設定に立ち戻る。まず始めは、「定数」型のオブジェクトへの「変数」型のポインターである。

const TypeA * cVPtr  = &c; // OK: variable pointer to "const TypeA"

これに対して、「変数」型のオブジェクトへの「定数」型のポインターが考えられる。

TypeA * const vCPtr2 = &v; // OK: const poiner to variable "TypeA"

const はふりかけられたものに効くのが原則だったので、この場合はポインターのさしているアドレスがもはや変更不能ということである。もちろんこのアドレスの代入は

TypeA * const vCPtr1 = &c; // NG: *vCPtr1 should be "const TypeA"

では警告を受ける(確信犯は型キャストしてだまらせてしまうのだが)。c が定数型のオブジェクトなので、変数型のオブジェクトへのポインター vCPtr への代入は不正だからである。

また、まれな例であるが、

const TypeA * const cCPtr = &c; // OK: const pointer to "const TypeA"

のような場合もある。この場合、cCPtr は定数型のオブジェクトへの定数型のポインターである。以上を踏まえれば以下の例は理解できるであろう。

*cVPtr1 = VALUE2; // NG: *cVpnter is "const TypeA"
 cVPtr1 = &v;     // OK: useful to make variable pseudo-constant
*cVPtr1 = VALUE3; // NG: now v looks as if it is "const TypeA"
*vCPtr2 = VALUE2; // OK:
 vCPtr2 = &v;     // NG: you cannot change "* const"
*cCPtr  = VALUE2; // NG: *cCPtr is "const TypeA"
 cCPtr  = &v;     // NG: you cannot change "* const"

注意すべきなのは定数型オブジェクトへのポインターに変数型オブジェクトのアドレスを代入することは許されていることである(2行目)。このことによって、3行目にあるように変数型のオブジェクトをあたかも定数型のオブジェクトのように見せることができる。

次に参照の場合を考えよう。次の例で十分である。

const TypeA & cRef = c; // OK: reference to "const TypeA"
TypeA & vRef = v; // OK: reference to "variable TypeA"

const TypeA & cRef2 = v; // OK: useful to make v pseudo-constant
TypeA & const vCRef = v; // NG:
コンパイルエラー

参照とそれがさす実体との結合は、参照が定義される時の1度しかできないので参照に const を付けることに意味はない(いったんできた結合を変えることはできないのでもともと参照は const であるとも言える)。const は参照にはふりかけられないのである。できるのは定数型オブジェクトへの参照を定義することである。

 

特殊調味料:const を関数の引数にふりかけた場合

次に考えるのは特殊調味料:const を関数の引数にふりかけた場合である。これは、上で考えた場合の応用問題である。

void DoSomethingTo(const TypeA & c, TypeA & vref, TypeA v)
{
  v    = VALUE; // OK: 値渡しの一時オブジェクトに代入
  c    = VALUE; // NG: 定数型オブジェクトの参照に代入は許されない
  vref = VALUE; // OK:
}

C++ のデフォールトでは引数は値渡し(pass by value)なので、関数の呼び手が指定した実引数は、それがその関数のスコープを持つ一時オブジェクトとしてコピーコンストラクトされ関数に渡される。あるオブジェクトへのポインターを引数で渡す場合も、そのポインターが一時オブジェクトとしてコピーコンストラクトされると考えるべきである。もとのポインターがさしていたアドレス値がコピーされているのでこれで期待される振る舞いをする。いずれの場合も、関数スコープを出る際にこれらの引数一時オブジェクトのデストラクターが呼ばれることになる。一方、上の例の1番目と2番目の引数のように参照渡し(pass by reference)した場合には、一時オブジェクトの生成を伴わない。その代わりに呼び手の実引数オブジェクトと対応する参照変数が結合されることになる。そこで、一般に関数の中で呼び手の引数オブジェクトを変更されたくない場合、値渡しをつかうより定数型オブジェクトの参照を引数として渡す方が不要なオブジェクトの生成消滅をしないですむので経済的である。

 

特殊調味料:const を関数にふりかけた場合

これまで使い倒してきた TypeA クラスを拡張してデータメンバーを打ち出す関数を付け加えよう。

#include <iostream>
using namespace std;

class TypeA {
  public:
    TypeA(int n = 0) : fN(n) {}
    void Print1() { cerr << fN << endl; } // データメンバーの打ち出し関数
    void Print() const { cerr << fN << endl; } // データメンバー打ち出しの const 型関数
  private:
    int fN;
};

int main()
{
  const TypeA c;
  c.Print1();
// NG: 定数オブジェクトの非定数型関数の呼び出しは不正
  c.Print();
// OK
  return 0;
}

この例のように、定数型インスタンス(c)の const ふりかけをかけていないメンバー関数(Print1())を呼び出すと怒られる(gcc3.1 ではエラーでなく警告の上、非定数型インスタンスからの呼び出しとして実行される)。const 型でないメンバー関数の呼び出しは一般にはその定数型オブジェクトの状態を変える可能性があるからである。関数名の後のにょろ括弧の前に const がつくとその関数呼び出しでオブジェクトの状態が変わらないことが保証される(にょろ括弧に const をつけたということはにょろ括弧の中にオブジェクトの状態を変える操作がないことを示す)。よって、Print() なら怒られない。このように、メンバー関数がその呼び出しの前後でオブジェクトの状態を変えない場合には const をつけておくべきである。

さて、さらにデータメンバーにアクセスする関数を付け加え TypeA クラスを改良しよう。

#include <iostream>
using namespace std;

class TypeA {
  public:
    TypeA(int n = 0) : fN(n) {}
    void Print()   const { cerr << fN << endl; } // OK: データメンバー打ち出しの const 型関数
    int  GetData() const { return fN; } // OK: データメンバーを変えない
    void SetData(int n)  { fN = n;}
// OK: const がついてないからデータメンバーを変えても良い
    void Wrong()   const { fN = 10; } // NG: コンパイルエラー(データメンバーの書き換えは禁止)

  private:
    int fN;
};

int main()
{
  const TypeA c;
  c.Print(); // OK
  cerr << c.GetData() << endl; // OK
  return 0;
}

Wrong() のように const 型のメンバー関数でデータメンバーを書き換えようとするとコンパイルエラーになる。

♪禁じ手も時には使わにゃならんこともある

しかし、もはや広い範囲で使われていてインターフェースを変更することのできない基底クラスの const 型メンバー関数をオブジェクトの状態を変えるような関数で上書きしたい場合が時々発生する(ROOT の TObject のメンバー関数であるCompare を上書きしようとする場合が典型的と言えば、身に覚えのある人も多かろう)。こう言う場合には禁じ手(this のキャスト)を使い、

class TypeC : public TypeA {
  public:
    int GetData() const
    {
      if (!TypeA::GetData()) ((TypeC *)this)->SetData(1); // OK: でもこれは通常禁じ手
      return TypeA::GetData();
    }

};

とかすることになる。おぞましいコードだが、料理の舞台裏とは概してこんなもんだ。この this のキャストをしないと怒られるのは、const 型関数の中では this が定数型オブジェクトへのポインターと見なされていることを示している。定数型オブジェクトでは const 型メンバー関数を呼ぶことしか許されていないからである。いずれにせよ、この種のコードはにょろ括弧の前の const がオブジェクトの状態を変えないという宣言であるという大原則を破り人々を混乱に陥れる危険をはらむものであり、可能な限り避けるべきであることは言うまでもない。
自分で実装したクラスが const 型の関数の中から変更することを許すようなデータメンバーを含む場合は、そのデータメンバーを mutable 宣言しておけばこのようなおぞましいことをしなくてすむ。

ここで多重定義された関数の区別について考えることは教育的である。多重定義された関数の区別(signature)は、一般に引数の組の型、数、順番でつけられるが、加えて const 型であるか否かでもつけられる。しかし戻り値の型では区別できない。これはコンパイラーがその関数の呼び手のコードを見ただけでは一般にはどの戻り値の型をとる関数を呼ぶべきなのか判断できないからである(同じ理由で、引数が値渡しか参照渡しかの違いしかない関数を多重定義することはできない)。

このことをハッキリさせるため、もう一度上の例にもどって調理実習を続けよう。ここでは、派生クラス TypeC に倍精度型データメンバー(fD)を加え、その Getter (GetData())を多重定義することを考える。

#include <iostream>
using namespace std;

class TypeA {
  public:
    TypeA(int n = 0) : fN(n) {}
    void Print()   const { cerr << fN << endl; } // OK: データメンバー打ち出しの const 型関数
    int  GetData() const { return fN; } // OK: データメンバーを変えない
    void SetData(int n)  { fN = n;}
// OK: const がついてないからデータメンバーを変えても良い

  private:
    int fN;
};

class TypeC : public TypeA {
  public:
    TypeC(double d = 2.) : fD(d) {}
    int GetData() const
    {
      if (!TypeA::GetData()) ((TypeC *)this)->SetData(1); // OK: でもこれは通常禁じ手
      return TypeA::GetData();
    }
    double GetData() const { return fD; } //
NG: 上の GetData() と区別不能
    double GetData()       { return fD; } // OK: 区別可能だがまぎらわしいよね

  private:
    double fD;

};

int main()
{
  const TypeC c;
  c.Print(); // OK
  cerr << c.GetData() << endl; // NG:
1番目と2番目のどっちを呼ぶべきか分からない
  TypeC v = c;
  cerr << v.GetData() << endl; // OK:
3番目が呼ばれる
  return 0;
}

は1つめの int を返す const 型の GetData() を定義した後では2つめの double を返す const 型の GetData() の定義は不当である。2つめを残したままコンパイルすると、c.Print() で、1つめと2つめのどちらを呼ぶべきか分からないと怒られコンパイルエラーになる。しかし、3つめの double を返す非 const 型の GetData() は紛らわしいが怒られない。コンパイラーには区別可能だからである。ちなみに上の例で、2つめの GetData() を削除した後の main の実行結果は、期待どおり

0
1
2

となる。