C#でインクリメント/デクリメント演算子をオーバーロードする際の注意点

C++では前置・後置で++/--のオペレータオーバーロードを別々に行う必要があるけど、C#の場合前置と後置でメソッド定義を別々にすることができない

以下はMSDNライブラリからの引用。

operator ++ または operator -- の実装は、後置表記または前置表記のいずれを使っても呼び出すことができます。2 つの表記に対して演算子を個別に実装することはできません。

7.6.5 前置インクリメント演算子と前置デクリメント演算子 (C#)

ということは、前置と後置で個別のメソッド定義ができないんじゃ、それぞれの戻り値の差別化はどうすればいいの?と疑問に思ったので調べてみた。

MSDNライブラリの説明

以下はMSDNライブラリによる前置・後置インクリメント/デクリメントの処理手順の説明。

  • ++x or --x
    1. x を評価して変数を生成します。
    2. x の値を引数として、選択された演算子を呼び出します。
    3. 演算子から返される値を x の評価によって得られる場所に格納します。
    4. 演算子から返された値が、演算の結果になります。
  • x++ or x--
    1. x を評価して変数を生成します。
    2. x の値を保存します。
    3. 保存した x の値を引数として、選択された演算子を呼び出します
    4. 演算子から返される値を x の評価によって得られる場所に格納します。
    5. 保存された x の値が、演算の結果になります

ここで「選択された演算子」というのは、x の型に関連したoperator ++/--のこと。何だかよく分からないけど、どうやら前置と後置でちゃんと戻り値が差別化されそうな予感。赤字の部分がポイント。


参考
7.6.5 前置インクリメント演算子と前置デクリメント演算子 (C#)
7.5.9 後置インクリメント演算子と後置デクリメント演算子 (C#)

実装例

実際にoperator ++をオーバーロードして動作テストをしてみた。C++とは違い、operator ++をstaticメソッドで定義する必要がある。

class Test {
    public int n { get; private set;  }

    // コンストラクタ
    public Test(int n) { this.n = n; }
    // Console.WriteLine()用
    public override string ToString() {
        return n.ToString();
    }

    // インクリメントのオーバーロード
    public static Test operator ++(Test x) {
        // 新たにオブジェクトを作り、インクリメント後の値で初期化する
        Test xx = new Test(x.n + 1);
        return xx;
    }
}

class Program {
    static void Main(string[] args) {
        Test t = new Test(10);
        Console.WriteLine("t  : " + t);
        Console.WriteLine("++t: " + ++t);
        Console.WriteLine("t++: " + t++);
        Console.WriteLine("t  : " + t);
    }
}

実行結果は以下の通り。

t  : 10
++t: 11
t++: 11
t  : 12

前置インクリメントのときはインクリメント後の値(operator ++の戻り値)が、後置インクリメントのときはインクリメント前の値がちゃんと返ってきている。C#では、operator ++/--をオーバーロードすると、前置・後置の戻り値の差別化は言語側が自動的に行ってくれるということがこれで分かった。

ダメな例

ところが、実はoperator ++/--の実装の仕方によっては前置・後置++/--の動作が上手くいかないことがある

以下、ダメな例。

class Test {
    public int n { get; private set;  }

    // コンストラクタ
    public Test(int n) { this.n = n; 
    // Console.WriteLine()用
    public override string ToString() {
        return n.ToString();
    }

    // インクリメントのオーバーロード(ダメな例)
    public static Test operator ++(Test x) {
        // 引数のオブジェクトを直接インクリメント
        x.n++;
        return x;
    }
}

実行結果はこちら。

t  : 10
++t: 11
t++: 12    // インクリメント後の値が返ってきている
t  : 12

後置インクリメントでもインクリメント後の値が返ってきてしまっていることが分かる。どうやらoperator ++/--のメソッド内では、引数のオブジェクトを直接インクリメント/デクリメントしてはいけないらしい。

ダメな例がダメな理由

MSDNライブラリの後置++/--の説明をもう一度見てみる。

  • x++ or x--
    1. x を評価して変数を生成します。
    2. x の値を保存します。
    3. 保存した x の値を引数として、選択された演算子を呼び出します
    4. 演算子から返される値を x の評価によって得られる場所に格納します。
    5. 保存された x の値が、演算の結果になります

3の「保存した x の値を引数として、選択された演算子を呼び出し」ている点と、5の「保存された x の値が、演算の結果にな」る点がポイント。
C#のclassは参照型であるため、operator ++/--に引数として与えられたclassオブジェクト x の値をメソッド内で書き換えると、3の「保存した x」の値も書き換わってしまう。そして、5でその「保存された x」の値を後置++/--の演算結果として返すため、operator ++/--のメソッド内で x を直接インクリメント/デクリメントすると、後置の場合でもインクリメント/デクリメント後の値が返ってくることになってしまう。
要は、operator ++/--の引数として与えられた x はそのまま後置++/--の演算結果となるから、値を書き換えちゃだめよということらしい。ふむふむ。

結論

以上より、C#でoperator ++/--をオーバーロードする時は以下の点に注意する必要がある。

  • operator ++/--の引数のオブジェクトを直接書き換えてはいけない
  • operator ++/--のメソッド内で新たにオブジェクトを作り、引数のオブジェクトをインクリメントした後の値を持たせて戻り値としてreturnする

これらを守らないと後置++/--の動作が前置++/--と同じになってしまう場合があるので注意。


しかし、こんな紛らわしいことになるくらいだったら、C++のように前置・後置で別々のオペレータオーバーロードができるようにした方がいいんじゃないかなーと思えてしまうのだけど、どうだろう。