元ネタはEric Lippertのブログです。コードは少し改変していますが、言っていることは同じです。
クイズ
次のコードがあります。
class Program
{
static void Main()
{
object obj = GetObject(); // ★
string str;
if ((obj != null) && ((str = GetString()) != null)) // ◆
{
System.Console.WriteLine(obj + str); // ▲
}
}
static object GetObject()
{
return "hello";
}
static string GetString()
{
return "goodbye";
}
}
このコードは普通にコンパイルも通りますし動作しますが、★印をつけた7行目を次のように変更すると
dynamic obj = GetObject(); // ★ 変数の型をdynamicに変更
とたんに▲印をつけた9行目でコンパイルが通らなくなります。
Compilation error (line 9, col 44): Use of unassigned local variable 'str'
エラーメッセージは、str変数が初期化されていないというものです。
確かに、6行目で変数を定義したときは初期化していないのですが、◆印をつけた7行目のif文の条件式の中、&&演算子の右側で初期化しています。
&&の意味を考えると、9行目に到達するにはstrを初期化するコードも評価実行されるはずですし、実際最初の段階ではコンパイルできていたのですが、どうしてこうなるのでしょうか。
気になる人はオンラインのPlaygroundで試してみてください。
解説
dynamic型
C#は基本的には静的に型をチェックするタイプのプログラミング言語ですが、いくつか例外があります。その一つがこのdynamic型で、この型の値に対する操作、たとえばメソッド呼び出しやフィールド参照など、についてはその存在がコンパイル時にはチェックされず、実行時にしかチェックされなくなります。
それだけではなく、dynamic型の変数があったとすると、その変数が実際にはどんな型の値を保持しているかについて、仮定も追跡もせず、いつも不確定として扱われます。
今回のクイズのコードで言えば、GetObject()がobject型(実体はstring)を返すこと、obj変数はローカル変数なので、知らぬ間に外部から値が書き換えられたりしない1こと、などは明白なのですが、それでも◆印をつけた7行目のif文の条件式においては、objにどんな値が入っているかは実行してみるまでわからない、という風にコンパイラは扱います。
それにしても、コンパイルが通らなくなるのは不思議ですね。obj変数に「なにか謎の型の値」が入っていた場合にstrが初期化されなくなるようなことが起こり得るとでも言うのでしょうか。__実はその通り__なのです。そしてそこには、演算子オーバーロードが関係しています。
nullチェックと演算子
◆印をつけた7行目のif文の条件式を見ると、obj変数とnullを比較しています。
(obj != null)
普通はこのようなコードでnullチェックができるのですが、objの型によっては期待する動きにならない可能性もあるのです。それは、objの型が__等値演算子 == と 非等値演算子 != をオーバーロード定義して、独自の処理にしている場合__です。(==演算子と!=演算子は、かならずペアでオーバーロード定義する必要があります2。)
たとえば以下のようなWeirdクラスがあるとします。
class Weird
{
public static bool operator ==(Weird left, Weird right)
{
return true;
}
public static bool operator !=(Weird left, Weird right)
{
return true;
}
}
上のコードでは、Weird型同士に対する ==演算も!=演算も、いつでもtrueを返します。ということは、
Weird obj = null;
if (obj != null)
{
Console.WriteLine(obj.ToString());
}
こんなコードを書くと、objがnullでないことを確認したつもりが、実際はnullにもかかわらず条件式が真になってしまうので、ToString()メソッド呼び出しのところでNullReferenceExceptionが投げられてしまいます!
ここで教訓の1つ目。
等値演算子がオーバーロードされているときは、nullと比較してもnullチェックにならないことがある。
この教訓はあくまで、クラスの利用側で安全側に倒す方針を取るときのものです。クラスの定義側で等値演算子をオーバーロードするときは、nullとの比較がおかしなことになるようなコードを書くべきではありません!
とはいえ、C#を採用していることで有名なゲームプラットフォームであるUnityでは、UnityEngine.Object.Destroy()メソッドで破壊されたオブジェクトは、その後nullとの比較がtrueを返すようになります。
参考:GameObjectやComponentをnullと比較するときの落し穴
そのような、比較演算子がオーバーロードされている(かもしれない)状況で、本当にnullかどうかをチェックしたい場合は、以下のような選択肢があります3。
- パターンマッチでnullチェックをする
-
System.Object.ReferenceEquals()静的メソッドを呼び出す -
object型にアップキャストしてから比較演算子を使う
C# 7以降のパターンマッチでは、型パターンを使った場合nullとはマッチしないのでnullでないことが確かめられます。また、null定数パターンを使った場合nullにしかマッチしません。
null定数パターンでnullチェックをするコード例はこうなります。単純ですね。
(obj is null)
さて、これで最初のコードの謎が解けた……と思ったら、そうではありません。
!=演算子がオーバーロードされているとしても、trueを返すのであれば && の右側が評価されてstrは初期化されるはずだし、falseを返すのであればif文の条件式そのものがfalseになるので▲の印をつけた9行目は実行されないわけですから、コンパイルエラーになるのはおかしいのです。
では、等値演算子がbool型を返さないとしたら?
そんなことができるのか?というと、できます。演算子のオーバーロードでは、一部の単項演算子4を除き、演算結果を独自の型にすることができるのです。たとえば、Weirdクラスの==演算子と!=演算子を次のように書き直してみます。
class Weird
{
public static CustomBoolean operator ==(Weird left, Weird right)
{
return new CustomBoolean();
}
public static CustomBoolean operator !=(Weird left, Weird right)
{
return new CustomBoolean();
}
}
class CustomBoolean
{
}
このようにカスタムのCustomBooleanという型を返すように演算子を定義したとしてもWeirdクラス自体はコンパイルに通ります。
ですが、このままでは次の2つの問題があります。
- ◆印をつけた7行目の
if文の条件式にあるような、CustomBoolean型とbool型に対する&&演算をどう定義するか - もし
&&演算をしなかったらif (obj != null) {}の条件式の型はCustomBooleanになるが、if文はどう動作するのか
CustomBoolean型からbool型に暗黙のキャストをする演算子を定義すれば、どちらの問題も起きません。ですがそれは、==や!=でCustomBoolean型を返し、即座にboolに変換する、ということになりますから、わざわざ独自の型を経由する意味がありません。CustomBooleanのような型を使うのであれば、論理演算の間はCustomBoolean型の上で行い、if文などの__条件式に使うところで真偽を確定させる__ような動きになっていてほしいわけです。ということで、bool型からCustomBoolean型に暗黙のキャストをする演算子を定義すれば、論理演算の対象がCustomBoolean型同士になりますので、後はCustomBoolean型のふるまいだけの問題です。
-
CustomBoolean型同士の論理演算 -
if文などの制御構文の条件式がCustomBoolean型のときの挙動
そして、C#はこの2つをカスタマイズする仕組みが用意されています。
なるほどなるほど、str変数が初期化されないケースを作れそうな気がしてきました。ですがそこに行く前に、どうしてCustomBooleanみたいな型を定義できる仕組みが用意されているのか、それを説明しましょう。
論理演算と真偽チェックと演算子
+ - * / の算術演算子をオーバーロード定義したいケースはいろいろ思いつくと思います。標準クラスライブラリの型でいえばSystem.Numerics名前空間に含まれる BigInteger (任意精度整数)構造体や Complex (複素数)構造体などが算術演算子をオーバーロード定義することで、四則演算をやりやすくしています。
一方で、! & && | || の論理演算子をオーバーロード定義したいケースにはどのようなものがあるというのでしょうか。ひとつには独自の型でビット演算を実現することがあると思いますが、その場合は && や || は使いませんね。もうひとつは独自の型で論理演算を実現することで、この場合は&& や || が関係してきます。独自の型で論理演算をしたい代表的なケース(というより、唯一に近いケース)が 三値論理 を扱うことです。
詳細はWikipediaを読んでもらえたらと思いますが、ごく簡単に言うと、真偽値として「真(true)」「偽(false)」に加えて「不明(unknown)」という値を取れるようにした論理演算です。そして、論理演算 ! & | はunknownを含む形で拡張されます。たとえば、Aがunknownの時のA & Bの結果は、Bがfalseの時だけA & Bもfalseになりますが、それ以外の場合はA & Bはunknownになります。
三値論理が使われる代表的な例がSQLです。例としてSELECT文を考えます。WHERE句の条件式がNULLとの比較を含む場合、比較結果はtrueでもfalseでもないunknownとして扱われます。そして、SELECT文で選択される行は、WHERE句の条件式がtrueと評価される行だけです。つまり、論理演算の結果、条件式がunknownになる場合は、その行は選択されません。
SQLの場合はWHERE句やHAVING句やCASE式などが条件式の値を評価してtrueかどうか確かめます。C#の場合は、次の制御構文の中で、条件式の値を評価します。
-
if文 -
else文 -
while文 -
do文 -
for文 - 三項条件演算子
? : - 条件論理演算子
&&||
コンパイル時には条件式の型がチェックされますが、bool型、または暗黙的にbool型に変換可能な型の場合は普通にコンパイルが通ります。一方、bool型に変換できない型が条件式に使われている場合、真偽チェック専用の単項演算子である true演算子 と false演算子がオーバーロード定義されているかをコンパイラーがチェックします。(true演算子とfalse演算子は、かならずペアでオーバーロード定義する必要があります。)この2つの演算子がオーバーロード定義されている型であれば、boolに変換することなく、制御構文の中で使用できます。
class CustomBoolean
{
public static bool operator true(CustomBoolean x)
{
// 条件式でtrueと評価できる場合はtrueを返す
}
public static bool operator false(CustomBoolean x)
{
// 条件式でfalseと評価できる場合はtrueを返す
}
}
なぜbool型に変換するのではなく、true演算子とfalse演算子を使うのか、ですが、上でも述べたように3値論理のunknownは「trueでもfalseでもない値」なのですから、それを表現できるようにするためです。true演算子とfalse演算子をペアで用意することで、true演算子の適用結果も、false演算子の適用結果も、どちらもfalseを返すような実装をすることができます。
……と書きましたが、上に挙げた制御構文の条件式では、実は1つを除いて他はすべてtrueのチェックしかしません(true演算子だけを適用する形にしかコンパイルされません)。唯一例外的にfalseのチェックをする(false演算子を適用する形にコンパイルされる)__のが、&& 条件論理演算子__なのです。
三項条件演算子 ? : と 条件論理演算子 && || は、直接オーバーロード定義することができません。true演算子やfalse演算子などを使った形にコンパイルされることがC#の言語仕様書に記載されています。言語仕様書には形式的な定義が書かれていますが、それを元に、三項条件演算子 ? : と条件論理演算子 && || でCustomBooleanのような型を使うとどんな感じにコンパイルされるのか、説明してみます5。
CustomBoolean expr = GetCustomBoolean();
Foo foo = expr ? GetBar() : GetBaz();
// 以下、等価なコードのイメージ
// ただし本当は、演算子の適用をメソッド呼び出しの形で書くことはできない。
/*
Foo foo;
if (CustomBoolean.op_True(expr)) // true演算子の適用
{
foo = GetBar();
}
else
{
foo = GetBaz();
}
*/
CustomBoolean expr = GetCustomBoolean() && GetAnotherCustomBoolean();
// 以下、等価なコードのイメージ
// ただし本当は、演算子の適用をメソッド呼び出しの形で書くことはできない。
/*
CustomBoolean expr;
CustomBoolean left = GetCustomBoolean();
if (CustomBoolean.op_False(expr)) // false演算子の適用
{
expr = left;
}
else
{
CustomBoolean right = GetAnotherCustomBoolean();
expr = CustomBoolean.op_BitwiseAnd(left, right); // left & right
}
*/
CustomBoolean expr = GetCustomBoolean() || GetAnotherCustomBoolean();
// 以下、等価なコードのイメージ
// ただし本当は、演算子の適用をメソッド呼び出しの形で書くことはできない。
/*
CustomBoolean expr;
CustomBoolean left = GetCustomBoolean();
if (CustomBoolean.op_True(expr)) // true演算子の適用
{
expr = left;
}
else
{
CustomBoolean right = GetAnotherCustomBoolean();
expr = CustomBoolean.op_BitwiseOr(left, right); // left | right
}
*/
上では3つの演算子の動きをコードで書きましたが、&& については言葉でも説明しておきましょう。「左項の評価結果がfalseと判断される場合(false演算子がtrueを返した場合)はショートサーキットで右項は評価されず、左項の値がそのまま式の値となる。そうでない場合(false演算子がfalseを返した場合)は右項も評価され、最終的に左項と右項の(&演算子オーバーロードによる)論理積が式の値となる。」です。
というわけで、CustomBooleanクラスのような独自の型に対して && 演算子を適用するには、true演算子とfalse演算子に加えて、&演算子をオーバーロード定義しておく必要があります。同様に、||演算子を適用するには、true演算子とfalse演算子に加えて、|演算子をオーバーロード定義しておく必要があります。
さて、ここで問題です。もし、CustomBooleanクラスがtrue演算子とfalse演算子をオーバーロードして、どちらも常にtrueを返すようにしていたら、どんなことが起きるでしょうか?
class CustomBoolean
{
public static bool operator true(CustomBoolean x)
{
return true;
}
public static bool operator false(CustomBoolean x)
{
return true;
}
}
もし上のようなむちゃくちゃなクラスを書いてしまうと、次のコードがひどいことになります!
if (GetCustomBoolean() && GetAnotherCustomBoolean())
{
DoSomething();
}
まず、GetCustomBoolean()が呼び出され、CustomBoolean型の値を受け取ります。次に、オーバーロードされたfalse演算子が呼び出されますが、これがtrueを返すため、&& 演算子の右項は実行されず、さっき受け取ったCustomBoolean型の値がそのまま if文の条件式になります。ここで今度はtrue演算子が呼び出されますが、こちらもtrueを返します。そのため、if文直後のブロックが実行されます。__&&の右項が実行されなかったのにif直後のブロックが実行される__ことは、このようにして起こってしまうわけです6。
ここで教訓の2つ目。
&& や || といった短絡評価する論理演算の意味をtrueとfalseの演算子オーバーロードでぶっ壊すことができてしまう。
ちなみに、C#の言語設計チームが当初想定していた、カスタムの三値論理については、bool?型 (Nullable<bool>構造体)を使えば済むようになったため、あえて自分で定義する必要はほぼまったくありません。
まとめ
さて、これで▲をつけた9行目でコンパイルが通らなくなったわけを説明する材料が出そろいました。
class Program
{
static void Main()
{
dynamic obj = GetObject(); // ★
string str;
if ((obj != null) && ((str = GetString()) != null)) // ◆
{
System.Console.WriteLine(obj + str); // ▲
}
}
static object GetObject()
{
return "hello";
}
static string GetString()
{
return "goodbye";
}
}
このようなコードでは、true演算子とfalse演算子がどちらもtrueを返すようなCustomBooleanクラスと、!= 演算子がCustomBoolean型を返すようなWeirdクラスを使った場合、◆印をつけた7行目で&& の右項7が実行されないためstr変数が初期化されていないにもかかわらず、▲印をつけた9行目が実行されるというケースがありえてしまいます。
また、★印をつけた5行目を見れば、obj変数はGetObject()の戻り値が格納されることははっきりしています。ですが、変数の型をdynamicにしてしまうと、◆印をつけた7行目でobj変数が何を格納しているか、まったく想定できません。想定できないということは、それがWeirdのようなクラスのインスタンスである可能性を排除できないということです。しかも、コード上はそのようなクラスを定義していなかったとしても、参照しているアセンブリや、リフレクションを使って動的にロードするアセンブリを考えれば、Weirdみたいなクラスがどこにもまったく存在しないことまでは保証できません。
したがって、obj変数の型をdynamicに変えたとたん、▲印をつけた9行目でコンパイルエラーが起きるのは、コンパイラーに問題があるからではない、というわけです。
最後に教訓を2つ。
予想もしないコンパイルエラーが出るからと言って、コンパイラーのバグとは限らない。
演算子オーバーロードは、意味論・用法を守って正しく使いましょう。
-
Equals()とGetHashCode()もオーバーライドしないとコンパイル時に警告が出ます。ですが今回は無視します。 ↩ -
true演算子とfalse演算子はbool型しか返せません。++演算子と--演算子は元の型かその派生型しか返せません。 ↩ -
if文などの制御構文の方は細かい説明を省略しますが、true演算子の適用結果がtrue値を返すかどうかがチェックされます。 ↩ -
コンパイルを通すためには
&演算子もオーバーロード定義する必要があります。&&の右項は実行されないので、結果として&演算子も呼び出されませんが。 ↩ -
&&演算子の右項はbool型のままなので、dynamicではなく実際にWeirdのような型でコンパイルを通そうと思ったら、bool型をCustomBooleanクラスに変換する、暗黙的なキャスト演算子も定義する必要があります。 ↩