読者です 読者をやめる 読者になる 読者になる

C++11でメンバ関数を持つ列挙体のようなものを作る

みなさんナイス C++11 ライフを送っていますか.
ご存知のことと思いますが,C++11 では C から存在した列挙型 (enum) の強化が行われ,強い型付けの列挙型 (strongly-typed enum) が導入されました.これには,enum と整数型の暗黙的な型変換の禁止,スコープ指定の強制,整数型の指定,などが含まれます.

例えば,一般的なじゃんけんの手(グー、チョキ、パー)を表す列挙体を考えます.03 以前の C++ では以下の様なコードになります.

enum Janken
{
    Gu,
    Choki,
    Pa
};

Gu, Choki, Pa は Janken 列挙体のメンバですが,いずれも外の名前空間に属しており,列挙体名を明記することなくそのまま Gu などとアクセス出来てしまいます*1
11 以降の C++ では以下の様なコードで書く事もできます.

enum class Janken : unsigned char
{
    Gu,
    Choki,
    Pa
};

宣言時に enum class と書くと,強い型付けの列挙体となります.Janken 列挙体の各メンバは Janken::Gu のように,列挙体名を指定してアクセスしなくてはなりません.また,クラスの継承のような記法で整数型を書くと,その列挙体メンバが実際に用いる整数型を規定することが出来ます.この場合は unsigned char を指定しているので,Janken 列挙体の実態は 1 バイトの符号なし整数型となります.

メンバ関数を持たせたい

以上のように機能の強化が図られた C++11 の列挙体ですが,まだ出来ないこともあります.例えば,Janken 列挙体に,自分が勝つことのできる手を返す関数 defeat を追加したいとします.

// Invalid
enum class Janken
{
    Gu,
    Choki,
    Pa

    constexpr Janken defeat() const {
        return Janken((int(*this) + 1) % 3);
    }
};

// 例えばこのように使いたい.hand1 及び hand2 はそれぞれ Janken::Choki, Janken::Pa になる
const Janken hand1 = Janken::Gu.defeat();
const Janken hand2 = hand1.defeat();

しかしながら,列挙体がメンバ関数を持つことは許されておらず,このコードは C++11 や,その後継規格である C++14 でも実行することが出来ないようです.一つの解決策は,特定の構造体やクラスに属していないフリー関数として実装することです.勿論,それが妥当な解決策となる場合は多いですが,メンバ関数として提供するのが自然な場合も多々あるかと思います*2
この代替手段を提案するのが本記事のトピックです.

列挙体を諦める

早速ですが列挙体を使うことを諦めます.列挙体を使う以上,今の C++ の言語仕様ではメンバ関数を配置することが出来ません.クラスを使います.例えば,Janken 列挙体は以下のように Janken クラスとして実装することが出来ます.

Janken.h
class Janken
{
private:
    int data;

public:
    Janken() = default;
    constexpr explicit Janken(const int data) : data(data) {}
    constexpr operator int() const { return data; }
    constexpr Janken defeat() const { return Janken((int(*this) + 1) % 3); }

    static const Janken Gu;
    static const Janken Choki;
    static const Janken Pa;
};
Janken.cpp
#include "Janken.h"
const Janken Janken::Gu(0);
const Janken Janken::Choki(1);
const Janken Janken::Pa(2);

int を引数に取るコンストラクタと, int へのキャスト演算子が定義されているので,int との型変換を行うことが出来ます.また,int を引数に取るコンストラクタに explicit を付けているので,int との暗黙的な型変換を許しません.これだけでも,普通に使うだけなら充分「メンバ関数を持った列挙体」っぽく振る舞わせることが出来ます.

constexpr にしたい

上記のコードでは,Janken::Gu, Janken::Choki, Janken::Pa はいずれもコンパイル時に値が確定するにもかかわらず constexpr 修飾されていません.これは,constexpr を駆使したコードを記述する上で大きな障壁となります.列挙体として Janken を定義していた時は,列挙体のメンバはコンパイル時定数として扱われるので,これは上記のコードのデメリットとなります.また,各翻訳単位で Janken.o をリンクするまで Gu, Choki 及び Pa の値が決まらないので,コンパイラによる最適化を妨げる要因にもなりそうです.これをなんとかして解決したいです.

まずは,以下のように単純に const を constexpr に変更することを考えます.constexpr 変数として宣言することの出来る型には制限があり,クラスの場合は「リテラル型」と呼ばれるクラスである必要があります.クラスがリテラル型として扱われるためには trivial なコピーコンストラクタを持つなどの条件を満たす必要がありますが,この Janken クラスは条件を満たしているため,めでたく constexpr 変数としてインスタンスを作成することが出来ます.

Janken.h
class Janken
{
(略)
    static constexpr Janken Gu{0};
    static constexpr Janken Choki{1};
    static constexpr Janken Pa{2};
};
Janken.cpp
#include "Janken.h"
constexpr Janken Janken::Gu;
constexpr Janken Janken::Choki;
constexpr Janken Janken::Pa;

上記のコードでは,constexpr 変数は宣言と同時に初期化を行わなければならないため,初期化処理を Janken.cpp から Janken.h に移動しています*3
上記のコードは一見正しく動きそうに見えますが,実はコンパイルが通りません.これは,Gu などは Janken クラスのインスタンスとして宣言されていますが,これらが宣言されている時点では Janken クラスの定義が終了しておらず,本当に Janken クラスがリテラル型かどうかが分からないというのが理由のようです*4.従って,リテラル型は constexpr な変数を作ることが出来るにもかかわらず,自分自身の static constexpr な定数をメンバに持つことが出来ません.アホらしいですが規格なので仕方ないです.

解決策としては,クラスを親クラスと子クラスに分割し,子クラスのメンバとして親クラスの static constexpr な定数を持つという方法が挙げられるかと思います.自然言語で説明してもわかりにくいので,実際に Janken クラスのコードを示します.

Janken.h
class Janken;

class JankenBase
{
private:
    int data;
    JankenBase() = default;
    JankenBase(const JankenBase&) = default;
    constexpr explicit JankenBase(const int data) : data(data) {}

    friend Janken;

public:
    constexpr operator int() const { return data; }
    constexpr JankenBase defeat() const { return JankenBase((int(*this) + 1) % 3); }
};

class Janken : public JankenBase
{
public:
    Janken() = default;
    constexpr explicit Janken(const int data) : JankenBase(data) {}
    constexpr Janken(const JankenBase& base) : JankenBase(base) {}

    static constexpr JankenBase Gu{0};
    static constexpr JankenBase Choki{1};
    static constexpr JankenBase Pa{2};
};
Janken.cpp
#include "Janken.h"
constexpr JankenBase Janken::Gu;
constexpr JankenBase Janken::Choki;
constexpr JankenBase Janken::Pa;

上記のコードでは,JankenBase が Janken の親クラスとなっており,子クラスの Janken が JankenBase 型の static constexpr な定数を持っています.これらの宣言時には既に JankenBase 型の宣言が終了しているので,無事に constexpr 変数を作ることが出来ます.
JankenBase の各種コンストラクタは private で隠されているので,これらを変数で受け取る時は Janken 型として受け取らざるを得ません.Janken は JankenBase を唯一の引数に取る explicit でないコンストラクタを持っているので,暗黙的に JankenBase 型の変数を Janken 型に変換することが出来ます.このように,なるべく JankenBase の存在は隠蔽して Janken 型の使用を強制するコードとなるように配慮しました*5
int へのキャスト演算子と defeat は Janken ではなく JankenBase で定義し,継承を通してそれを Janken に取り込む形になっています.これは,Janken::Gu.defeat() のような呼び出しを可能にするためです.

使用例

例えば以下のコードがそのまま動きます.このように,Janken の実態がクラスであるということを意識することなく,あたかもメンバ関数を持つ列挙体のように扱うことが出来ます*6

#include <iostream>
#include "Janken.h"

int main()
{
    constexpr Janken x = Janken::Gu;
    constexpr Janken y = Janken.Pa.defeat();
    constexpr Janken z = y.defeat().defeat();

    switch(z){
    case Janken::Gu:
        std::cout << "Gu" << std::endl;
        break;
    case Janken::Choki:
        std::cout << "Choki" << std::endl;
        break;
    case Janken::Pa:
        std::cout << "Pa" << std::endl;
        break;
    }
}

残る問題点

  • Janken::Gu を型推論した時に Janken ではなく JankenBase になってしまう
    • auto x = Janken::Gu はコンパイルエラー(JankenBase のコンストラクタが private なため)
    • auto& x = Janken::Gu は動くが,xの型は Janken& ではなく JankenBase&(ポインタや右辺値参照も同様)
  • JankenBase が外から丸見え(インスタンスは作れないけど)

*1:C++03 以前では,列挙体名を指定することすら出来ません.C++11 以降では,以前の列挙体についても,列挙体名を指定しても良いこととなっています.

*2:私事ですが,フリー関数はその置き場所に困るのであまり好きではないです.例の場合は JankenHelper のような名前空間の中,または同名のクラスの静的メンバ関数として配置するのが自然かと思いますが,タイプ量が増えてしまいますし,何か苦肉の策感があるような気がしています.折角なら Janken だけで完結させたいです.

*3:初期化時の括弧が丸括弧から波括弧に変わっていますが,これを丸括弧のままにすると,constexpr 関数の宣言と見做されてしまうため,コンパイルエラーになります.いずれにしてもコンパイルの通らないコードなのですが.

*4:c++ - static constexpr member of same type as class being defined - Stack Overflow

*5:Janken は JankenBase の子であると同時に友達 (friend class) でもあるので,隠蔽されたコンストラクタを呼び出すことが出来るようになっています.また,この friend の宣言を行うために Janken を前方宣言しています.

*6:最後の出力を行っている swich 文は,switch 文も使えるということを示すために入れましたが,出力するだけなら ostream& operator<<(ostream&, const JankenBase&) を定義するのが簡便です.