LINQ:文字列コレクションで複数キーワードのOR検索をするには?[C#、VB].NET TIPS

LINQを使って文字列コレクションでOR検索を行うには、いくつかの方法がある。本稿ではそれらの方法を示しながら、長所短所について検討する。

» 2014年12月16日 17時35分 公開
[山本康彦BluewaterSoft/Microsoft MVP for Windows Platform Development]
.NET TIPS
Insider.NET

 

「.NET TIPS」のインデックス

連載目次

対象:.NET 3.5以降


 LINQを使って文字列のコレクションを処理するとき、OR検索をしたいことがあるだろう。あらかじめ条件が決まっているならば、複雑な条件式であってもそのままWhereメソッド(System.Linq名前空間のEnumerableクラスに定義された拡張メソッド)に渡すラムダ式に記述すれば済む*1。しかし、例えばエンドユーザーからの入力を基にして検索を実行するような場合には、ORでつなぐ条件の数が動的に変化する。そのような場合はどうしたらよいだろうか? 本稿ではその方法を説明する。

AND/ORが入り混じった複雑な条件の場合

 あらかじめお断りしておくが、AND/ORやかっこが入り混じった本当に複雑な条件の場合には、ラムダ式を動的に組み立てて式ツリーを生成することになる。そのような複雑な検索条件では、構文解析が必須であろう。構文解析を行うなら、そのついでに式ツリーを生成するのはそれほど困難なことではない。式ツリーを生成する方法は、「.NET TIPS:LINQ文で動的にWhere句を組み立てるには?[3.5、C#、VB]」を参照してほしい。

 本稿では、ORだけの条件式を考える。検索機能を実装するときに、空白で区切られた語句を全てOR条件として扱うような簡易的な検索方法を想定している。また、「.NET TIPS:LINQ:文字列コレクションで『LIKE検索』(部分一致検索)をするには?[C#、VB]」で紹介したような「LIKE検索」では考慮事項が増えてしまうので、本稿では文字列中にキーワードを含んでいるかどうかの比較だけとする(比較にはSystem名前空間のStringクラスのContainsメソッドだけを使う)。

事前準備

 本稿では、Whereメソッドが実際どのように動作するのかを確認したい。OR検索ならばショートサーキット(結果が真に確定した時点で後続の条件比較を打ち切る)してほしいものである。それを検証するために、比較内容をコンソールに出力する「ContainsEx」メソッドを用意しておく(次のコード)。StringクラスのContainsメソッドと同様に機能するのだが、コンソールに出力する点が異なっている。

using System;
using System.Collections.Generic;

public static class StringExtension
{
  // StringクラスのContainsメソッドと同じだが、処理内容をコンソールに書き出すようにした
  public static bool ContainsEx(this string s, string key)
  {
    var result = s.Contains(key);
    Console.WriteLine("\"{0}\".Contains(\"{1}\") {2}", s, key, result ? "○" : "×");
    return result;
  }
}

Imports System.Runtime.CompilerServices

Module StringExtension
  ' StringクラスのContainsメソッドと同じだが、処理内容をコンソールに書き出すようにした
  <Extension()>
  Public Function ContainsEx(s As String, key As String) As Boolean
    Dim result = s.Contains(key)
    Console.WriteLine("""{0}"".Contains(""{1}"") {2}", s, key, If(result, "○", "×"))
    Return result
  End Function
End Module

比較内容をコンソールに出力する「ContainsEx」メソッド(上:C#、下:VB)
拡張メソッドとして実装してある。拡張メソッドはVisual Studio 2008で導入された機能だ。詳しくはMSDNの「拡張メソッド (C# プログラミング ガイド)」/「拡張メソッド (Visual Basic)」をご覧いただきたい。
また、このVBのコードでは、Visual Basic 2008の新機能である「If演算子」を使っている。

 以降のコードでは、StringクラスのContainsメソッドの代わりに、このContainsExメソッドを用いる。

あらかじめ条件が決まっている場合

 あらかじめOR条件の数が決まっているときは、そのままWhereメソッドに記述するだけだ。

 例えば、「"ぶた"」と「"まつり"」のいずれかを含んでいる文字列を検索するコードは次のように書ける。

using System;
using System.Collections.Generic;
using System.Linq;

class Program
{
  // コンソール出力用のメソッド
  static void WriteItems(string header, IEnumerable<string> items)
  {
    var output = string.Join(", ", items.ToArray());
    Console.WriteLine("{0}: {1}", header, output);
  }

  static void Main(string[] args)
  {
    // サンプルデータ(文字列の配列)
    string[] sampleData = { "ぶた", "こぶた", "ぶたまん", "ねぶたまつり"
                            "ねぷたまつり", "きつね", "ねこ", };
    WriteItems("sampleData", sampleData);

    Console.WriteLine();
    Console.WriteLine("OR検索0: ラムダ式中でOR条件");

    // LIKE '%ぶた%' OR LIKE '%まつり%'
    IEnumerable<string> OR検索0
      = sampleData.Where(item => item.ContainsEx("ぶた") || item.ContainsEx("まつり"));
    WriteItems("OR検索0", OR検索0);
    // → OR検索0: ぶた, こぶた, ぶたまん, ねぶたまつり, ねぷたまつり

#if DEBUG
    Console.ReadKey();
#endif
  }
}

Module Module1
  ' コンソール出力用のメソッド
  Sub WriteItems(header As String, items As IEnumerable(Of String))
    Dim output = String.Join(", ", items.ToArray())
    Console.WriteLine("{0}: {1}", header, output)
  End Sub

  Sub Main()
    ' サンプルデータ(文字列の配列)
    Dim sampleData As String() = {"ぶた", "こぶた", "ぶたまん", "ねぶたまつり",
                                  "ねぷたまつり", "きつね", "ねこ"}
    WriteItems("sampleData", sampleData)

    Console.WriteLine()
    Console.WriteLine("OR検索0: ラムダ式中でOR条件")

    ' LIKE '%ぶた%' OR LIKE '%まつり%'
    Dim OR検索0 As IEnumerable(Of String) _
      = sampleData.Where(Function(item) item.ContainsEx("ぶた") OrElse item.ContainsEx("まつり"))
    WriteItems("OR検索0", OR検索0)
    ' → OR検索0: ぶた, こぶた, ぶたまん, ねぶたまつり, ねぷたまつり

#If DEBUG Then
    Console.ReadKey()
#End If
  End Sub
End Module

あらかじめ決まっているOR条件で検索をするコード例(上:C#、下:VB)
Visual Studioからデバッグ実行したとき、コンソールがすぐに閉じてしまわないように「Console.ReadKey()」と記述してある。そこで何かキーを押すとプログラムは終了する。
このVBのコードでは、Visual Basic 2010の新機能である「配列リテラル」の記法を使ってsampleData変数を初期化している。
ここで、検索キーワードが二つに固定されているなら、コード中の「"ぶた"」と「"まつり"」の二つの文字列リテラルを変数に置き換えれば、汎用的なコードになる。本稿で考えたいのは、検索キーワードの数が不定の場合だ。

 これを実行してみると、次のような結果になる。

ラムダ式中にOR条件をハードコーディングした場合の実行結果 ラムダ式中にOR条件をハードコーディングした場合の実行結果
一つ目の条件(「"ぶた"」を含むか)が偽のときだけ二つ目の条件(「"まつり"」を含むか)の判定が行われている(「"ぶた"」/「"こぶた"」/「"ぶたまん"」/「"ねぶたまつり"」には「"ぶた"」が含まれているので後続のContainsExメソッドが実行されていない)。これがショートサーキット評価である。

 ところで、ORでつなぐ条件の数があらかじめ分かっていないときは、このようにハードコーディングすることはできない。そのようなときにはどうすればよいかが、本稿の主題である。

同じ条件式のOR検索ならばAnyメソッド

 AND検索の場合には「.NET TIPS:LINQ:文字列コレクションで複数キーワードのAND検索をするには?[C#、VB]」で述べたようなWhereメソッドをチェーンするという汎用的な解法があった。しかし、LINQでOR検索する場合には汎用的な解法がないのである(後述するように一長一短があり、「これだけを覚えておけばOK!」とはいかない)。

 先に挙げた例のような同じ条件式(=「item.ContainsEx("{キーワード}")」)をORでつなぐ場合では、Anyメソッド(System.Linq名前空間のEnumerableクラスに定義された拡張メソッド)を利用するとすっきり書ける(次のコード)。

Console.WriteLine();
Console.WriteLine("OR検索1: Anyメソッドを使う");

// Anyメソッドを利用してOR検索をする
string[] keywords = { "ぶた", "まつり", };
IEnumerable<string> OR検索1
  = sampleData.Where(item => keywords.Any(key => item.ContainsEx(key)));
WriteItems("OR検索1", OR検索1);
// → OR検索1: ぶた, こぶた, ぶたまん, ねぶたまつり, ねぷたまつり

Console.WriteLine()
Console.WriteLine("OR検索1: Anyメソッドを使う")

' Anyメソッドを利用してOR検索をする
Dim keywords As String() = {"ぶた", "まつり"}
Dim OR検索1 As IEnumerable(Of String) _
  = sampleData.Where(Function(item) keywords.Any(Function(key) item.ContainsEx(key)))
WriteItems("OR検索1", OR検索1)
' → OR検索1: ぶた, こぶた, ぶたまん, ねぶたまつり, ねぷたまつり

Anyメソッドを利用してOR検索としたコード例(上:C#、下:VB)
検索を実行する部分だけを示す(サンプルデータの作成などは前と同じ)。
Whereメソッドの引数に渡すラムダ式の中でさらにAnyメソッドを使っているので、慣れないとちょっと分かりにくいかもしれない。このAnyメソッドは、「keywords」コレクション("ぶた"と"まつり")から順にキーワードを取り出し(=「key」変数)、条件式「item.ContainsEx("{キーワード}")」に当てはめていく。そして条件式が最初に成立した時点でtrueを返すのだ。Anyメソッドがtrueを返すと、Whereメソッドはその要素(=「item」変数)を選択する。これでOR検索になるのである(次に示す実行結果を見ていただければ一目瞭然であろう)。
同じ条件式をORでつなぐ場合には、シンプルに書けるこの方法がよいだろう。

 実行結果は次の画像のようになる。期待通りショートサーキット評価になっている。

Anyメソッドを利用してOR検索とした場合の実行結果 Anyメソッドを利用してOR検索とした場合の実行結果
前述したラムダ式中にOR条件をハードコーディングした場合と同様に、ショートサーキット評価になっている。

 同じ条件式をORでつなぐ場合には、シンプルに書けるこの方法がよいだろう。

 なお、同じ条件式をANDでつなぐ場合にはAllメソッド(System.Linq名前空間のEnumerableクラスに定義された拡張メソッド)を利用して次のように書くこともできる。

Console.WriteLine();
Console.WriteLine("AND検索3: Allメソッドを使う");

IEnumerable<string> AND検索
  = sampleData.Where(item => keywords.All(key => item.ContainsEx(key)));
WriteItems("LIKE '%ぶた%' AND LIKE '%まつり%'", AND検索3);
// → LIKE '%ぶた%' AND LIKE '%まつり%': ねぶたまつり

Console.WriteLine()
Console.WriteLine("AND検索3: Allメソッドを使う")

Dim AND検索3 As IEnumerable(Of String) _
  = sampleData.Where(Function(item) keywords.All(Function(key) item.ContainsEx(key)))
WriteItems("LIKE '%ぶた%' AND LIKE '%まつり%'", AND検索3)
' → LIKE '%ぶた%' AND LIKE '%まつり%': ねぶたまつり

Allメソッドを利用してAND検索としたコード例(上:C#、下:VB)
検索を実行する部分だけを示す(サンプルデータの作成などは前と同じ)。
.NET TIPS:LINQ:文字列コレクションで複数キーワードのAND検索をするには?[C#、VB]」では、汎用的に使えるWhereメソッドチェーンを紹介した。同じ条件式をANDでつなぐ場合であれば、このようにAllメソッドを利用してすっきりと書ける。ただし、複雑な式ツリーになるせいだと思うが、Whereメソッドチェーンよりも処理に時間がかかるようである。

 このように、Allメソッド/Anyメソッドを利用すると、複数の条件式をAND/ORでつないだ検索をすっきりと記述できる。しかし、異なる条件式が混在するときには使えないので注意してほしい。例えば次のような場合だ。

// 異なる条件式をANDでつなぐ例(これだけでは実行できない)
books.Where(book => book.著者.Contains("夏目") && book.タイトル.Contains("猫"));

' 異なる条件式をANDでつなぐ例(これだけでは実行できない)
books.Where(Function(book) book.著者.Contains("夏目") AndAlso book.タイトル.Contains("猫"))

異なる条件式が混在するAND検索のコード例(上:C#、下:VB)
書籍データのコレクション(=「books」変数)から、著者名に「夏目」を含み、かつ、タイトルに「猫」を含む書籍データを抜き出している(恐らく「吾輩は猫である」がヒットするだろう)。
これをWhereメソッドのチェーンに書き直すことは可能だ。Allメソッドを利用して書くことは難しいだろう。

Whereメソッドを組み合わせればOR検索が可能ではある

 異なる条件式が混在するOR検索の場合は、どうしたらよいだろうか? OR検索は、個別に絞り込んだ結果をマージしても同じ結果になる。そのコードと実行結果を次に示す。

Console.WriteLine();
Console.WriteLine("OR検索2a: 別々にWhereしてUnion");

// 個別に絞り込んだ結果をマージすればOR検索になる
IEnumerable<string> OR検索2a = new List<string>(); // 空のコレクションを用意
OR検索2a = OR検索2a.Union(sampleData.Where(item => item.ContainsEx("ぶた")));  // 検索して結果をマージ
OR検索2a = OR検索2a.Union(sampleData.Where(item => item.ContainsEx("まつり"))); // 検索して結果をマージ
WriteItems("OR検索2a", OR検索2a);
// → OR検索2a: ぶた, こぶた, ぶたまん, ねぶたまつり, ねぷたまつり

Console.WriteLine()
Console.WriteLine("OR検索2a: 別々にWhereしてUnion")

' 個別に絞り込んだ結果をマージすればOR検索になる
Dim OR検索2a As IEnumerable(Of String) = New List(Of String)() ' 空のコレクションを用意
OR検索2a = OR検索2a.Union(sampleData.Where(Function(item) item.ContainsEx("ぶた")))  ' 検索して結果をマージ
OR検索2a = OR検索2a.Union(sampleData.Where(Function(item) item.ContainsEx("まつり"))) ' 検索して結果をマージ
WriteItems("OR検索2a", OR検索2a)
' → OR検索2a: ぶた, こぶた, ぶたまん, ねぶたまつり, ねぷたまつり

個別に絞り込んだ結果をマージしてOR検索としたコード例(上:C#、下:VB)
検索を実行する部分だけを示す(サンプルデータの作成などは前と同じ)。
出力される結果は正しいのだが、ショートサーキット評価にならない。効率を気にしないならば、検索して結果をマージしている部分をループに変えれば任意個の検索キーワードに対応できる。
なお、サンプルコードということで、前と同じ出力結果になるように二つの条件式を同じ(=ContainsExメソッド)にしてある。この二つの条件式が異なるものであっても成立することはお分かりいただけるだろう。

個別に絞り込んだ結果をマージしてOR検索とした場合の実行結果 個別に絞り込んだ結果をマージしてOR検索とした場合の実行結果
キーワードごとに個別にWhereメソッドを使い、後からUnionメソッドでマージした。そのためショートサーキット評価になっていない。コレクションの全ての要素ごとに、必ず2回ずつの比較が行われている。例えば、「"こぶた"」には「"ぶた"」が含まれているので後続の「"まつり"」との比較は(すでにOR条件が真になると判明しているので)不要なのだが、「"まつり"」との比較も実行されている。

 以上のように、個別に絞り込んだ結果をマージしてOR検索とするのは、ショートサーキット評価にならない。さらに、一つのWhereメソッドごとにUnionメソッドを一度実行するコストも大きい(この例では2回だが、検索キーワードが増えたらそれだけUnionメソッドの実行回数も増える)。Whereメソッドをチェーンできないと、効率が悪いのである。

 何とかWhereメソッドのチェーンにできないだろうか? AND検索なら、条件式が異なる場合でもチェーンで書けるのだ。そこで、ド・モルガンの法則を使ってORをANDに変えてみよう。ド・モルガンの法則は、一般には次の形で示される。

!(P || Q) == !P && !Q

 この両辺をともに否定しても同じである。

!!(P || Q) == !(!P && !Q)

 さらに左辺を展開する。

P || Q == !(!P && !Q)

 すなわち、それぞれの条件を否定してAND検索し、その結果をまた否定することで、OR検索をしたことになるのである。そのコードと実行結果を次に示す。

Console.WriteLine();
Console.WriteLine("OR検索2b: ド・モルガンの法則");

// ド・モルガンの法則を使ってOR検索をメソッドチェーンにする
IEnumerable<string> NotAnd検索2
  = sampleData.Where(item => !item.ContainsEx("ぶた"))
              .Where(item => !item.ContainsEx("まつり")); // 否定のANDを取り
IEnumerable<string> OR検索2b 
  = sampleData.Except(NotAnd検索2); // 最後に元のコレクションから除外する(=否定)
WriteItems("OR検索2b", OR検索2b);
// → OR検索2b: ぶた, こぶた, ぶたまん, ねぶたまつり, ねぷたまつり

Console.WriteLine()
Console.WriteLine("OR検索2b: ド・モルガンの法則")

' ド・モルガンの法則を使ってOR検索をメソッドチェーンにする
Dim NotAnd検索2 As IEnumerable(Of String) _
  = sampleData.Where(Function(item) Not item.ContainsEx("ぶた")) _
              .Where(Function(item) Not item.ContainsEx("まつり")) ' 否定のANDを取り
Dim OR検索2b As IEnumerable(Of String) _
  = sampleData.Except(NotAnd検索2) ' 最後に元のコレクションから除外する(=否定)
WriteItems("OR検索2b", OR検索2b)
' → OR検索2b: ぶた, こぶた, ぶたまん, ねぶたまつり, ねぷたまつり

ド・モルガンの法則を使ってOR検索をメソッドチェーンとしたコード例(上:C#、下:VB)
検索を実行する部分だけを示す(サンプルデータの作成などは前と同じ)。
Whereメソッドの呼び出しはチェーンしている。その部分をループにすれば任意個の検索キーワードに対応できる。そして最後に(ループにした場合はループを抜けてから)、Exceptメソッドを使って元のコレクションから除外することで、OR検索の結果が得られる。
次の実行結果を見てもらうとショートサーキット評価になっている。Exceptメソッド1回分のオーバーヘッドがあるだけだ。しかしこのコードは、コメントを付けるかメソッドに切り出して適切なメソッド名を付けるかしないと、何をやっているコードなのか分かりづらいという欠点がある。

ド・モルガンの法則を使ってOR検索をメソッドチェーンとした場合の実行結果 ド・モルガンの法則を使ってOR検索をメソッドチェーンとした場合の実行結果
一つ目の条件(「"ぶた"」を含むか)が偽のときだけ二つ目の条件(「"まつり"」を含むか)の判定が行われており、ショートサーキット評価になっている。
前掲のコードを見ても、Whereメソッドのチェーンの他にはExceptメソッドを最後に1回だけ余分に実行しているだけであり、先ほどのUnionメソッドを多数回実行するコードよりは効率がよいだろう。ただし、コードが理解しにくくなるのが欠点である。

 このように、ド・モルガンの法則を使ってOR検索をメソッドチェーンに変形する方法は、そこそこ効率はよいのだがコードが読みにくくなる。

 以上のように、Whereメソッドの組み合わせでOR検索を実現する方法には一長一短がある。Anyメソッドの利用を先に検討してみるのがよいだろう。

まとめ

 LINQでのOR検索は難しい。同じ条件式をORでつなぐ場合なら、Anyメソッドを利用するとよい。そうでないときは、Unionメソッドを使ったり、ド・モルガンの法則を利用してExceptメソッドを使ったりする。あるいは、本稿では示さなかったが、Whereメソッドのラムダ式で使うための条件判定メソッドを作る場合もある。

*1 Where拡張メソッドの引数には、ラムダ式を与える。ラムダ式について詳しくは、次のMSDNのドキュメントを参照していただきたい。


利用可能バージョン:.NET Framework 3.5以降
カテゴリ:クラスライブラリ 処理対象:LINQ
使用ライブラリ:Enumerableクラス(System.Linq名前空間)
使用ライブラリ:Stringクラス(System名前空間)
関連TIPS:LINQ:文字列コレクションで複数キーワードのAND検索をするには?[C#、VB]


「.NET TIPS」のインデックス

.NET TIPS

Copyright© Digital Advantage Corp. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。