VB の論理演算における三値論理に注意
この記事は、Visual Basic Advent Calendar 2016 の 23 日目のエントリーです。22 日目は sutefu さんの なんちゃって仮想労働者を作る2 でした。
三値論理とは
最初に、そもそも三値論理とは何か、というところの説明をします。通常プログラミング言語において、条件判定等で用いる論理値としては、真 (true)/偽 (false) の 2 つがよく用いられていますが、これにもうひとつ不明 (unknown) を加えた 3 つの値で論理体系を構成しているものが三値論理です。
SQL ではわりと馴染み深いとともに、直感的でないところがあるがゆえにハマりやすいポイントでもあります。
具体的に、三値論理での演算結果がどのようになるかを表で記します。これを真理値表といいます。
NOT | |
---|---|
true | false |
false | true |
unknown | unknown |
AND | true | false | unknown |
---|---|---|---|
true | true | false | unknown |
false | false | false | false |
unknown | unknown | false | unknown |
OR | true | false | unknown |
---|---|---|---|
true | true | true | true |
false | true | false | unknown |
unknown | true | unknown | unknown |
XOR | true | false | unknown |
---|---|---|---|
true | false | true | unknown |
false | true | false | unknown |
unknown | unknown | unknown | unknown |
NOT はともかくとして、他のやつはパッと見いまいち覚えづらいですね。なるほど直感的じゃあない...。
ただ、AND だと false→unknown の順で強く、OR だと true→unknown の順で強く、XOR だと unknown が強い、と覚えておけば導きやすいかもしれません?
Nullable 型と三値論理
実は .NET においても、Nullable 型、というか Nullable(Of Boolean)(C# だと Nullable<bool>)とともに三値論理が登場しました。動作については前述の真理値表の unknown を Nothing(C# だと null)に置き換えたものと同じですが、一応 VB 表記でもう一度書いておきます。
Not | |
---|---|
True | False |
False | True |
Nothing | Nothing |
And/AndAlso | True | False | Nothing |
---|---|---|---|
True | True | False | Nothing |
False | False | False | False |
Nothing | Nothing | False | Nothing |
Or/OrElse | True | False | Nothing |
---|---|---|---|
True | True | True | True |
False | True | False | Nothing |
Nothing | True | Nothing | Nothing |
XOr | True | False | Nothing |
---|---|---|---|
True | False | True | Nothing |
False | True | False | Nothing |
Nothing | Nothing | Nothing | Nothing |
VB における三値論理
さてここからが本題です。この三値論理ですが、VB においては非常に非直感的で凶悪な振る舞いをしますので、良く理解しておかないと SQL の三値論理並みにハマります。この先は注意すべきポイントについて、私が把握している限り紹介していきます。
なお、これらは VB 固有の問題(?)で、同じ .NET 系の言語でも C# には当てはまらなかったりします。今回は VB の Advent Calendar なので C# には触れませんが...。
制御文における三値論理
If 等の制御文において、Nothing は False と同等に扱われます。そして何より重要なのが、Not Nothing は True ではない、ということです。例えば次のコードの実行結果は、どちらも「不一致」を出力します。
Dim b As Boolean? = Nothing If b Then Console.WriteLine("一致") Else ' Nothing は True ではないのでこちら Console.WriteLine("不一致") End If If Not b Then Console.WriteLine("一致") Else ' Not Nothing はやはり Nothing であり True ではないのでこちら Console.WriteLine("不一致") End If
このあたりは MSDN のリファレンスでも説明が十分でなかった時期が存在していて、例えば While だと Visual Studio 2005 の時点で明記されていましたが、If だと Visual Studio 2012 になってやっと明記されました。
Nullable 型との比較演算子は Nullable(Of Boolean) を返す
これも注意が必要で、個人的には凶悪度ではさっきのより上だと思っています。特に直感的でなくハマりやすいのが、True/False と Nothing の等値・非等値比較も Nothing が返ってくる(True/False ではない)こと、さらには Nothing 同士の比較も常に Nothing であることでしょうか。例えば次のコードの実行結果も、やはりすべて「不一致」を出力します。マジですか...。
Dim b As Boolean? = Nothing ' 比較 1 If b <> True Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 2 If Not (b = True) Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 3 If b <> False Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 4 If Not (b = False) Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 5 If b = b Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 6 If b <> b Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If
これは最新の Visual Studio 15 のリファレンス にも書かれていません。
それより問題なのが、この動作が Equals メソッドと整合性が取れていないことでしょうか。先ほどのサンプルコードを Equals メソッドを使って書き換えてみましょう。
Dim b As Boolean? = Nothing ' 比較 1,2 If Not b.Equals(True) Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 3,4 If Not b.Equals(False) Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 5 If b.Equals(b) Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 6 If Not b.Equals(b) Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If
この実行結果は、比較 6 のみ「不一致」、他は「一致」が出力されます。これなら直感的ですし、比較演算子を使った場合でも同じ結果になってほしいものですが...。
内部的な仕掛け
なぜこのように比較演算子と Equal メソッドの不整合が起きているかというと、VB コンパイラが比較文を書き換えているからです。
次のコードは、2 つ前のサンプルコードを一旦 Visual Studio 2015 でコンパイルしてから ILSpy で逆アセンブルしたものです。
Dim b As Boolean? = Nothing ' 比較 1 Dim valueOrDefault As Boolean = (Not b).GetValueOrDefault() If valueOrDefault Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If Dim flag As Boolean? = b ' 比較 2 Dim flag2 As Boolean? = If(flag.HasValue, New Boolean?(flag.GetValueOrDefault()), Nothing) Dim valueOrDefault2 As Boolean = (If(flag2.HasValue, New Boolean?(Not flag2.GetValueOrDefault()), flag2)).GetValueOrDefault() If valueOrDefault2 Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 3 flag2 = b Dim valueOrDefault3 As Boolean = (If(flag2.HasValue, New Boolean?(flag2.GetValueOrDefault()), Nothing)).GetValueOrDefault() If valueOrDefault3 Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 4 flag2 = Not b Dim valueOrDefault4 As Boolean = (If(flag2.HasValue, New Boolean?(Not flag2.GetValueOrDefault()), flag2)).GetValueOrDefault() If valueOrDefault4 Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 5 Dim valueOrDefault5 As Boolean = (If((b.HasValue And b.HasValue), New Boolean?(b.GetValueOrDefault() = b.GetValueOrDefault()), Nothing)).GetValueOrDefault() If valueOrDefault5 Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If ' 比較 6 Dim valueOrDefault6 As Boolean = (If((b.HasValue And b.HasValue), New Boolean?(b.GetValueOrDefault() <> b.GetValueOrDefault()), Nothing)).GetValueOrDefault() If valueOrDefault6 Then Console.WriteLine("一致") Else Console.WriteLine("不一致") End If
うわあ、なんだか凄いことになっちゃったぞ。
でも内容を理解する必要はなくて、三値論理を実現するために何やら面倒なことをやっているんだな、ぐらいの認識で良いと思います。
わざわざここまでして SQL でも賛否が別れる三値論理を取り込まなくていいのに、という気がしています。Equals および等値演算子 (==) 実装のガイドライン に書かれている、「等値演算子 (==) を実装する場合は、常に Equals メソッドをオーバーライドし、同じ処理を実行させます。」という基準にも反していますし。
Dictionary のキーとしては一致と見做されるのに等値演算子では別物として扱われるということが起きえるわけで、結構問題なはずなんですが、なぜこんな言語仕様になっているのか...。条件文や比較演算子で三値論理を適用しない Option を新設してほしい、みたいなのって、Roslyn へのプルリクエストではなく単なる要望として出せないものでしょうか。
LINQ での取り扱い
LINQ to Objects で、Nullable 型のメンバを含んだエンティティクラスに対して操作を行うこともあるかと思いますが、そんなときにも Nothing との比較は Nothing であることを頭に入れておく必要があります。<> 演算子を用いるときは要注意です。
まずはクエリ式の場合から行ってみましょう。
Dim numbers As Integer?() = {1, 2, Nothing, 3} Dim filtered = From number In numbers Where number <> 2 Select number Console.WriteLine(String.Join(",", filtered))
このコードの出力結果は「1,3」になります。「1,,3」ではありません。2 と一緒に Nothing も除外されているわけです。
次に拡張メソッドで書いてみましょう。
Dim numbers As Integer?() = {1, 2, Nothing, 3} Dim filtered = numbers.Where(Function(x) x <> 2) Console.WriteLine(String.Join(",", filtered))
こちらは Ontion Strict の設定によって結果が変わってきます。
まず Option Strict On の場合、こちらはコンパイルエラーが発生します。問題なのは Where メソッドの引数で、ここには Func(Of TSource, Boolean) が要求されるのに、実際は Func(Of TSource, Boolean?) が与えられているためです。エラーメッセージは「Option Strict On では 'Boolean?' から 'Boolean' への暗黙的な変換は許可されていません。」と出力されます。
次に Option Strict Off の場合は、コンパイルは通りますが InvalidOperationException が発生します。Where メソッドの引数が暗黙のうちに Function(x) x.Value <> 2 と書き換えられるため、Nothing を処理しようとしたときに Value を参照できずにエラーになる、といった具合です。
エラーを発生させないためには、次のように書く必要があります。これの出力結果は、ひとつめが「1,,3」、ふたつめが「1,3」です。
Dim numbers As Integer?() = {1, 2, Nothing, 3} ' 結果に Nothing を含める場合 Dim filtered = numbers.Where(Function(x) Not x.HasValue OrElse x.Value <> 2) Console.WriteLine(String.Join(",", filtered)) ' 結果に Nothing を含めない場合 Dim filtered2 = numbers.Where(Function(x) x.HasValue AndAlso x.Value <> 2) Console.WriteLine(String.Join(",", filtered2))
ちなみに C# では、同じように書いたつもりでも結果が異なるので、これまた注意が必要です。下記のようにクエリ式、拡張メソッドどちらを用いても出力結果は「1,,3」です。
int?[] numbers = {1, 2, null, 3}; // クエリ式 var filtered = from number in numbers where number != 2 select number; Console.WriteLine(String.Join(",", filtered)); // 拡張メソッド(HasValue をチェックしなくてもエラーにならない) var filtered2 = numbers.Where(x => x != 2); Console.WriteLine(String.Join(",", filtered2));
あ、最初に C# には触れないといったのに触れちゃいました。それはさておき、これ、C#er が何らかの理由で VB を書くことになったときにめちゃ戸惑うと思います。やっぱり三値論理を無効化する Option の登場を強く望みます。