LINQ:文字列コレクションで複数キーワードのAND検索をするには?[C#、VB]:.NET TIPS
LINQでWhereメソッドをチェーンさせることで、文字列コレクションに対するAND検索を実現する方法を解説。
対象:.NET 3.5以降
LINQを使って文字列のコレクションを処理するとき、AND検索をしたいことがあるだろう。あらかじめ条件が決まっているならば、複雑な条件式であってもそのままWhereメソッド(System.Linq名前空間のEnumerableクラスに定義された拡張メソッド)に渡すラムダ式に記述すれば済む*1。しかし、例えばエンドユーザーからの入力を基にして検索を実行するような場合には、ANDでつなぐ条件の数が動的に変化する。そのような場合はどうしたらよいだろうか? 本稿ではその方法を説明する。
AND/ORが入り混じった複雑な条件の場合
あらかじめお断りしておくが、AND/ORやかっこが入り混じった本当に複雑な条件の場合には、ラムダ式を動的に組み立てて式ツリーを生成することになる。そのような複雑な検索条件では、構文解析が必須であろう。構文解析を行うなら、そのついでに式ツリーを生成するのはそれほど困難なことではない。式ツリーを生成する方法は、「.NET TIPS:LINQ文で動的にWhere句を組み立てるには?[3.5、C#、VB]」を参照してほしい。
本稿では、ANDだけの条件式を考える。検索機能を実装するときに、空白で区切られた語句を全てAND条件として扱うような簡易的な検索方法を想定している。また、「.NET TIPS:LINQ:文字列コレクションで『LIKE検索』(部分一致検索)をするには?[C#、VB]」で紹介したような「LIKE検索」では考慮事項が増えてしまうので、本稿では文字列中にキーワードを含んでいるかどうかの比較だけとする(比較にはSystem名前空間のStringクラスのContainsメソッドだけを使う)。
事前準備
本稿では、Whereメソッドが実際どのように動作するのかを確認したい。AND検索ならばショートサーキット(結果が偽に確定した時点で後続の条件比較を打ち切る)してほしいものである。それを検証するために、比較内容をコンソールに出力する「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
拡張メソッドとして実装してある。拡張メソッドはVisual Studio 2008で導入された機能だ。詳しくはMSDNの「拡張メソッド (C# プログラミング ガイド)」/「拡張メソッド (Visual Basic)」をご覧いただきたい。
また、このVBのコードでは、Visual Basic 2008の新機能である「If演算子」を使っている。
以降のコードでは、StringクラスのContainsメソッドの代わりに、このContainsExメソッドを用いる。
あらかじめ条件が決まっている場合
あらかじめAND条件の数が決まっているときは、そのまま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("AND検索0: ラムダ式中でAND条件");
// LIKE '%ぶた%' AND LIKE '%まつり%'
IEnumerable<string> AND検索0
= sampleData.Where(item => item.ContainsEx("ぶた") && item.ContainsEx("まつり"));
WriteItems("LIKE '%ぶた%' AND LIKE '%まつり%'", AND検索0);
// → LIKE '%ぶた%' AND LIKE '%まつり%': ねぶたまつり
#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("AND検索0: ラムダ式中でAND条件")
' LIKE '%ぶた%' AND LIKE '%まつり%'
Dim AND検索0 As IEnumerable(Of String) _
= sampleData.Where(Function(item) item.ContainsEx("ぶた") AndAlso item.ContainsEx("まつり"))
WriteItems("LIKE '%ぶた%' AND LIKE '%まつり%'", AND検索0)
' → LIKE '%ぶた%' AND LIKE '%まつり%': ねぶたまつり
#If DEBUG Then
Console.ReadKey()
#End If
End Sub
End Module
Visual Studioからデバッグ実行したとき、コンソールがすぐに閉じてしまわないように「Console.ReadKey()」と記述してある。そこで何かキーを押すとプログラムは終了する。
このVBのコードでは、Visual Basic 2010の新機能である「配列リテラル」の記法を使ってsampleData変数を初期化している。
ここで、検索キーワードが2つに固定されているなら、コード中の「"ぶた"」と「"まつり"」の2つの文字列リテラルを変数に置き換えれば、汎用的なコードになる。本稿で考えたいのは、検索キーワードの数が不定の場合だ。なお、別解として、任意個のキーワードを受け取る「ContainsMulti(IEnumerable<string>)」/「ContainsMulti(IEnumerable(Of String))」のような比較メソッドを作成する方法もある(ただし、汎用的な「LIKE検索」のようにキーワードを解析してロジックを切り替える必要があるときは、コレクションの要素ごとに解析ロジックが無駄に実行されるため効率が悪くなる)。
これを実行してみると、次のような結果になる。
ラムダ式中にAND条件をハードコーディングした場合の実行結果
1つ目の条件(「"ぶた"」を含むか)が真のときだけ2つ目の条件(「"まつり"」を含むか)の判定が行われている(「"きつね"」と「"ねこ"」には「"ぶた"」が含まれていないので後続のContainsExメソッドが実行されていない)。これがショートサーキット評価である。
ところで、ANDでつなぐ条件の数があらかじめ分かっていないときは、このようにハードコーディングすることはできない。そのようなときにはどうすればよいかが、本稿の主題である。結論を先に述べておくと、ループの中でWhereメソッドを繰り返し呼び出すようにすればよい。なぜそうするのか、順を追って説明していこう。
WhereメソッドをチェーンすればAND検索になる
Whereメソッドで絞り込んだ結果を、さらに絞り込めばAND検索になるはずだ。上に示したコードをWhereメソッドの連鎖(メソッドチェーン)に書き換えてみよう(次のコード)。
Console.WriteLine();
Console.WriteLine("AND検索1: Whereをチェーン");
// WhereメソッドのチェーンでAND検索
IEnumerable<string> AND検索1
= sampleData.Where(item => item.ContainsEx("ぶた"))
.Where(item => item.ContainsEx("まつり"));
WriteItems("LIKE '%ぶた%' AND LIKE '%まつり%'", AND検索1);
Console.WriteLine()
Console.WriteLine("AND検索1: Whereをチェーン")
' WhereメソッドのチェーンでAND検索
Dim AND検索1 As IEnumerable(Of String) _
= sampleData.Where(Function(item) item.ContainsEx("ぶた")) _
.Where(Function(item) item.ContainsEx("まつり"))
WriteItems("LIKE '%ぶた%' AND LIKE '%まつり%'", AND検索1)
検索を実行する部分だけを示す(サンプルデータの作成などは前と同じ)。
このコードは、sampleDataに対して最初のWhereメソッドを実行して絞り込んだコレクションを生成し、その絞り込んだコレクションに対して2番目のWhereメソッドを実行するように見えるが……。
このコードは、まず、最初の条件(「"ぶた"」を含むか)を全て判定し、それから2つ目の条件(「"まつり"」を含むか)の判定を行うように見える。最初の条件で絞り込んだコレクションを一時的に作成し、それに対してあらためて2つ目の条件で絞り込んでいるように思える。もしそうならば、先ほどとは比較の順序が変わってくるはずだ(「"ぶた"」→「"ぶた"」→「"こぶた"」→「"こぶた"」……のような順序ではなく「"ぶた"」→「"こぶた"」→……→「"ねこ"」→「"ぶた"」→「"こぶた"」→……のような順序になるはずだ)。
しかし実行してみると、前と同じ結果が得られる(次の画像)。
WhereメソッドをチェーンすればAND検索になる
上半分に条件式をハードコーディングした先ほどのコードの結果、下半分にWhereメソッドをチェーンした結果が出ている。
同じ結果が得られたことから、WhereメソッドをチェーンすればAND検索になると確認できた。
さらに注目すべきは、文字列比較の実行順序だ。StringクラスのContainsメソッドの呼び出し順序が、全く同じになっている。最初のWhereメソッドを完全に実行してから2番目のWhereメソッドの実行に掛かるわけではないのだ。処理対象のコレクション(ここでは「sampleData」)の一つ一つに対して、最初のWhereメソッドのラムダ式(=「item.ContainsEx("ぶた")」)と2番目のWhereメソッドのラムダ式(=「ContainsEx("まつり")」)が連続して実行されている(かつ、ショートサーキットしている)。これはLINQの大きな特徴だ。Whereメソッドに限らず、IEnumerable<T>(VBでは「IEnumerable(of T)」、以降では省略)インターフェースのままチェーンすると、チェーン全体が1つのループに変換されて実行されるのである。
この実行結果を見ると、予想に反して、最初のWhereメソッドを完全に実行してから、2番目のWhereメソッドを実行するわけではないと分かる(次の図)。LINQの大きな特徴として、Whereメソッドのチェーンは実行時に1つのループにまとめられるのである(Whereメソッドに限らずIEnumerable<T>インターフェースを返す拡張メソッドであれば同様)。
Whereメソッドチェーンの見掛けと実際
上のコードで示したWhereメソッドのチェーンは、まず、最初のWhereメソッドを実行して「{"ぶた", "こぶた", "ぶたまん", "ねぶたまつり", "ねぷたまつり"}」という中間結果のコレクションを生成し、それを次のWhereメソッドが受け取って処理して「{"ねぶたまつり"}」という最終結果のコレクションを生成するように見える。
しかし実際の動作は、図の下にあるように、コレクションの要素の一つずつに対して、最初のWhereメソッドと2つ目のWhereメソッドの処理が実行されていくのである。先ほどの実行結果と見比べてみてほしい。
ループの中でWhereメソッドを繰り返し呼び出す
メソッドチェーンは、一時変数に代入して個別の文に分けて書くこともできる。Whereメソッドのチェーンをそのようにして、ループに書き換えてみよう(次のコード)。
// 以前のコード
//IEnumerable<string> AND検索1
// = sampleData.Where(item => item.ContainsEx("ぶた"))
// .Where(item => item.ContainsEx("まつり"));
Console.WriteLine();
Console.WriteLine("AND検索2: Whereするごとに一時変数へ代入してループ化");
string[] keywords = { "ぶた", "まつり", };
IEnumerable<string> work = sampleData;
foreach (var key in keywords)
work = work.Where(item => item.ContainsEx(key));
WriteItems("LIKE '%ぶた%' AND LIKE '%まつり%'", work);
' 以前のコード
'Dim AND検索1 As IEnumerable(Of String) _
' = sampleData.Where(Function(item) item.ContainsEx("ぶた")) _
' .Where(Function(item) item.ContainsEx("まつり"))
Console.WriteLine()
Console.WriteLine("AND検索2: Whereするごとに一時変数へ代入してループ化")
Dim keywords As String() = {"ぶた", "まつり"}
Dim work As IEnumerable(Of String) = sampleData
For Each key In keywords
work = work.Where(Function(item) item.ContainsEx(key))
Next
WriteItems("LIKE '%ぶた%' AND LIKE '%まつり%'", work)
Whereメソッドのラムダ式に渡すキーワードを変数に入れて渡すようにして、ループに書き直した。このようにチェーンを切って一時変数に代入しても、LINQの特徴(実行時には1つのループにまとめられる)は失われない。
これで、キーワードの数が変化しても対応できるようになった。
※【2014/12/17補注】: Visual Studio 2010またはそれ以前では、このコードは正しくない。foreach/For Eachの挙動が異なるためである。対処するには、ループ変数keyをキャッシュして使う。すなわち、foreach/For Eachループ内にローカル変数を新しく宣言し、そこにループ変数keyを代入し、ContainsExメソッドの引数にはその変数を与えるように変更する。Visual Studio 2010以前を使って試していただいた読者の方にはご迷惑をかけてしまった。お詫びするとともに、補わせていただく。
一時変数に代入した時点で中間結果のコレクションが生成され、2つのWhereメソッドが個別に実行されるように思われる。果たしてどうなるであろうか? 実行結果は次の画像のようになる。
メソッドチェーンをループに書き換えた実行結果
上は、最初に示したコード(条件をハードコーディング)の実行結果だ(再掲)。
下が、メソッドチェーンをループに書き換えた実行結果である。
全く同じ順序で文字列比較が行われている。
最初に示したコード(条件をハードコーディング)の実行結果と、文字列比較の順序は全く同じになった。一時変数に代入して個別の文に分けても、メソッドチェーンのときと同じく、実行時には1つのループにまとめられていると分かる。このようにループにしても、効率よくAND検索が実現できているとお分かりいただけるだろう。
まとめ
LINQでのAND検索は、Whereメソッドの呼び出しをチェーンすればよい(ループにしてもよい)。Whereメソッドをチェーンするコードは、実行時には自動的に1つにまとめられる(Whereメソッドに限らずIEnumerable<T>インターフェースを返す拡張メソッドであれば同様)
*1 Where拡張メソッドの引数には、ラムダ式を与える。ラムダ式について詳しくは、次のMSDNのドキュメントを参照していただきたい。
- MSDN:ラムダ式 (C# プログラミング ガイド)
- MSDN:ラムダ式(Visual Basic)
利用可能バージョン:.NET Framework 3.5以降
カテゴリ:クラスライブラリ 処理対象:LINQ
使用ライブラリ:Enumerableクラス(System.Linq名前空間)
使用ライブラリ:Stringクラス(System名前空間)
関連TIPS:LINQ:文字列コレクションで「LIKE検索」(部分一致検索)をするには?[C#、VB]
Copyright© Digital Advantage Corp. All Rights Reserved.