TTYF ~earlgrey の雑記~

主に自分用メモとか

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 の登場を強く望みます。