元ネタは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
クラスに変換する、暗黙的なキャスト演算子も定義する必要があります。 ↩