見出し画像

[.NET] コードを見直したくなる「値型」等価判定の思わぬ落とし穴(特殊編)

一般編に続き特殊編です。

値型の等価判定が癖のある振る舞いをすることがあります。
以下の例で、Assert.IsTrue なら () 内が 真、Assert.IsFalse なら () 内が 偽です。

Color 構造体

Assert.IsTrue(Color.Red == Color.FromName("Red"));
Assert.IsTrue(Color.Red == Color.FromKnownColor(KnownColor.Red));

ここまでは期待どおりです。

Color redFromArgb = Color.FromArgb(Color.Red.A, Color.Red.R, Color.Red.G, Color.Red.B);
Assert.IsFalse(Color.Red == redFromArgb);
Assert.IsFalse(Color.Red.Equals(redFromArgb));

ARGBをあわせたのに等価と判定されません。
なぜでしょう。
プロパティを見てみましょう。

// Name
Assert.AreEqual("Red", Color.Red.Name);
Assert.AreEqual("ffff0000", redFromArgb.Name);

// IsKnownColor
Assert.IsTrue(Color.Red.IsKnownColor);
Assert.IsFalse(redFromArgb.IsKnownColor);

// IsNamedColor
Assert.IsTrue(Color.Red.IsNamedColor);
Assert.IsFalse(redFromArgb.IsNamedColor);

違いが表れました。
これらのプロパティ値は実際の色がどうであるかではなく、Color インスタンスがどのようにして生成されたかによって変わります。
内部的には state という private フィールドに区分が格納され、オーバーライドされた Equals メソッドで比較に使用されます。

実際に表示される色が同じですので、ARGB値で比較すると等価と判定されます。

Assert.IsTrue(Color.Red.ToArgb() == redFromArgb.ToArgb());

浮動小数点型

double 型には非数を表す NaN という値があります。
※NaN (Not a Number) 自体は .NET 独自のものでなく、IEEE 754「浮動小数点数算術標準」で定められています。

Assert.IsTrue(double.IsNaN(double.NaN));

0 を浮動小数点型の 0 で割ると NaN になります。
(int や decimal の 0 で割ったときと異なり、DivideByZeroException は発生しません)

double zero = 0;
Assert.IsTrue(double.IsNaN(0 / zero));

ちなみに正の値を浮動小数点型の 0 で割ると PositiveInfinity に、負の値を割ると NegativeInfinity になります。

Assert.IsFalse(double.IsNaN(1 / zero));
Assert.IsTrue(double.IsPositiveInfinity(1 / zero));

Assert.IsFalse(double.IsNaN(-1 / zero));
Assert.IsTrue(double.IsNegativeInfinity(-1 / zero));

非数に対する計算操作の結果もまた非数となります。

Assert.IsTrue(double.IsNaN(double.NaN + 1));
Assert.IsTrue(double.IsNaN(Math.Floor(double.NaN)));

非数は数ではありませんので、演算子は NaN 自身も含め、どの値とも等価でないと判断します。

Assert.IsFalse(double.NaN == 0);
Assert.IsFalse(double.NaN < 0);
Assert.IsFalse(double.NaN >= 0);

Assert.IsFalse(double.NaN == double.NaN);
Assert.IsFalse(double.NaN < double.NaN);
Assert.IsFalse(double.NaN >= double.NaN);

すべての演算結果が false になるというわけではありません。
非等値演算子は NaN 自身との比較においても true を返しますので注意が必要です。

Assert.IsTrue(double.NaN != 0);
Assert.IsTrue(double.NaN != double.NaN);

演算子と異なり、比較メソッドでは同値判定が可能です。

Assert.IsTrue(double.NaN.Equals(double.NaN));
Assert.AreEqual(0, double.NaN.CompareTo(double.NaN));

Assert.IsFalse(double.NaN.Equals(0));
Assert.AreEqual(-1, double.NaN.CompareTo(0));

float 型にも NaN があり、double.NaN と値は同じですが、通常の値の場合と同様、暗黙の型変換ができないと Equals が false を返します。

Assert.IsFalse(double.NaN == float.NaN);
Assert.IsTrue(double.NaN.Equals(float.NaN));
// float ← double は暗黙の型変換不可
Assert.IsFalse(float.NaN.Equals(double.NaN));

浮動小数点型では精度も問題になります。

double precision15 = .333333333333333;
double precision17 = 1.0/3;

書式 "R" をつけて最大17桁まで確認してみると、有効桁数が異なることがわかります。

Assert.AreEqual("0.333333333333333", precision15.ToString("R"));
Assert.AreEqual("0.33333333333333331", precision17.ToString("R"));

有効桁数が異なるため、等価にはなりません。

Assert.IsFalse(precision15 == precision17);
Assert.IsFalse(precision15.Equals(precision17));

有効桁数を Math.Round で揃えると等価と判定されます。

Assert.IsTrue(Math.Round(precision15, 15) == Math.Round(precision17, 15));
Assert.IsTrue(Math.Round(precision15, 15).Equals(Math.Round(precision17, 15)));

列挙型

メンバとして定義していない値が格納された場合の等価判定について、テストコード中のコメントで解説させていただきます。

// 列挙型定義
private enum ZeroUndefined
{
    One = 1,
    Two
}

// フィールド
private ZeroUndefined zeroUndefinedUnassigned;

// 検証
public void EnumUndefinedMember()
{
    // 0 はメンバとして定義されていません。
    Assert.IsFalse(Enum.IsDefined(typeof(ZeroUndefined), 0));

    // ★既定値はメンバの定義がなくても 0 です。 default(T) もフィールドも同様です。
    Assert.AreEqual((ZeroUndefined)0, default(ZeroUndefined));
    Assert.AreEqual((ZeroUndefined)0, this.zeroUndefinedUnassigned);

    // ★リテラルや定数の 0 は特別に列挙型への暗黙変換が許可されているため、キャストなしで代入できます。
    ZeroUndefined undefinedZero = 0;

    /* int 型変数の代入はコンパイルエラーになります。
    int zero = 0;
    ZeroUndefined undefinedZero = zero;
    */

    // 0 はどのメンバとも一致しません。
    foreach (ZeroUndefined each in Enum.GetValues(typeof(ZeroUndefined)))
    {
        Assert.IsTrue(undefinedZero != each);
    }

    // int にキャストすると 0 になります。
    Assert.IsTrue((int)undefinedZero == 0);

    // 0 をキャストしたものと一致します。
    Assert.IsTrue(undefinedZero == (ZeroUndefined)0);

    // ★リテラルや定数の 0 は特別に列挙型への暗黙変換が許可されているため、キャストなしで等価比較できます。
    Assert.IsTrue(undefinedZero == 0);
    Assert.IsTrue(0 == undefinedZero);

    // ★int 型との Equals 比較は false になります。
    Assert.IsFalse(undefinedZero.Equals(0));
    Assert.IsFalse(0.Equals(undefinedZero));

    // 最大定義値を超える場合も数値は保持されます。
    ZeroUndefined maxValue = (ZeroUndefined)int.MaxValue;
    Assert.IsTrue(maxValue == (ZeroUndefined)int.MaxValue);
    Assert.IsTrue((int)maxValue == int.MaxValue);
}

※FlagsAttribute 属性が付与されていない列挙型では、値ゼロのメンバを定義することが推奨されています。
《参考》コード分析(FxCop)
CA1008: Enums は 0 値を含んでいなければなりません

この記事が気に入ったらサポートをしてみませんか?