LINQ:複雑な検索をするために独自のWhereメソッドを作るには?[C#、VB]:.NET TIPS
LINQを使用して複数の比較条件をAND検索する際に、独自のWhereメソッドを作成することでコードの可読性を高める方法を解説する。
対象:.NET 3.5以降
文字列のコレクションをLINQを使って次々と検索するときに、比較条件を場合分けしたいときがある。例えば、複数の「LIKE検索」をANDで結合したいときなどだ。そのままコードを書きくだせば可能なのだが、コードの可読性を上げようとして失敗することがある。本稿では、失敗する例を紹介し、その対策として「Whereメソッド」を独自に作成して使う方法を解説する。
AND/ORが入り混じった複雑な条件の場合
あらかじめお断りしておくが、AND/ORやかっこが入り混じった本当に複雑な条件の場合には、ラムダ式*1を動的に組み立てて式ツリーを生成することになる。そのような複雑な検索条件では、構文解析が必須であろう。構文解析を行うなら、そのついでに式ツリーを生成するのはそれほど困難なことではない。式ツリーを生成する方法は、「.NET TIPS:LINQ文で動的にWhere句を組み立てるには?[3.5、C#、VB]」を参照してほしい。
本稿では、ANDだけの条件式を考える。検索機能を実装するときに、空白で区切られた語句を全てAND条件として扱うような簡易的な検索方法を想定している。
事前準備
本稿では、Whereメソッド(System.Linq名前空間のEnumerableクラスに定義された拡張メソッド)が実際どのように動作するのかを確認したい。それを検証するために、比較内容をコンソールに出力する文字列比較メソッドを三つ用意しておく(次のコード)。Stringクラスの文字列比較メソッドと同様に機能するのだが、コンソールに出力する点が異なっている。
using System;
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;
}
// StringクラスのStartsWithメソッドと同じだが、処理内容をコンソールに書き出す
public static bool StartsWithEx(this string s, string key)
{
var result = s.StartsWith(key);
Console.WriteLine("\"{0}\".StartsWith(\"{1}\") {2}", s, key, result ? "○" : "×");
return result;
}
// StringクラスのEndsWithメソッドと同じだが、処理内容をコンソールに書き出す
public static bool EndsWithEx(this string s, string key)
{
var result = s.EndsWith(key);
Console.WriteLine("\"{0}\".EndsWith(\"{1}\") {2}", s, key, result ? "○" : "×");
return result;
}
}
Imports System.Runtime.CompilerServices
Module StringExtension
' StringクラスのContainsメソッドと同じだが、処理内容をコンソールに書き出す
<Extension()> _
Public Function ContainsEx(ByVal s As String, ByVal key As String) As Boolean
Dim result = s.Contains(key)
Console.WriteLine("""{0}"".Contains(""{1}"") {2}", s, key, If(result, "○", "×"))
Return result
End Function
' StringクラスのStartsWithメソッドと同じだが、処理内容をコンソールに書き出す
<Extension()> _
Public Function StartsWithEx(ByVal s As String, ByVal key As String) As Boolean
Dim result = s.StartsWith(key)
Console.WriteLine("""{0}"".StartsWith(""{1}"") {2}", s, key, If(result, "○", "×"))
Return result
End Function
' StringクラスのEndsWithメソッドと同じだが、処理内容をコンソールに書き出す
<Extension()> _
Public Function EndsWithEx(ByVal s As String, ByVal key As String) As Boolean
Dim result = s.EndsWith(key)
Console.WriteLine("""{0}"".EndsWith(""{1}"") {2}", s, key, If(result, "○", "×"))
Return result
End Function
End Module
拡張メソッドとして実装してある。拡張メソッドはVisual Studio 2008で導入された機能だ。詳しくはMSDNの「拡張メソッド (C# プログラミング ガイド)」/「拡張メソッド (Visual Basic)」をご覧いただきたい。
また、このVBのコードでは、Visual Basic 2008の新機能である「If演算子」を使っている。
特定のケースをハードコーディングしてみる
SQLのLIKE演算子のような文字列検索をLINQで実現するには、「.NET TIPS:LINQ:文字列コレクションで「LIKE検索」(部分一致検索)をするには?[C#、VB]」で述べたように、検索語に含まれている「%」の位置によって文字列比較の条件式を使い分ける。また、AND検索は「.NET TIPS:LINQ:文字列コレクションで複数キーワードのAND検索をするには?[C#、VB]」で説明したように、Whereメソッドを次々にチェーンすればよい。
いきなり汎用的なコードを書くのは難しいので、まずは検索語を固定して、特定のケースだけに対応できるコードを書いてみよう。次のコードのように「LIKE検索」のANDを表現できる。
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)
{
// サンプルデータ(文字列の配列)
IEnumerable<string> sampleData = new string[] { "ぶた", "こぶた", "ぶたまん", "ねぶたまつり",
"ねぷたまつり", "きつね", "ねこ", };
WriteItems("sampleData", sampleData);
// LIKE 'ね%' AND LIKE '%り' AND LIKE '%ぶた%' のような検索をしたい
// ハードコーディングしてみる
Console.WriteLine();
Console.WriteLine("特定ケースをハードコーディング");
var case1 = sampleData.Where(item => item.StartsWithEx("ね"))
.Where(item => item.EndsWithEx("り"))
.Where(item => item.ContainsEx("ぶた"));
WriteItems("Whereメソッド内をハードコーディング", case1);
// → Whereメソッド内をハードコーディング: ねぶたまつり
#if DEBUG
Console.ReadKey();
#endif
}
}
Module Module1
' コンソール出力用のメソッド
Sub WriteItems(ByVal header As String, ByVal items As IEnumerable(Of String))
Dim output = String.Join(", ", items.ToArray())
Console.WriteLine("{0}: {1}", header, output)
End Sub
Sub Main()
' サンプルデータ(文字列の配列)
Dim sampleData As IEnumerable(Of String) _
= New String() {"ぶた", "こぶた", "ぶたまん", "ねぶたまつり", _
"ねぷたまつり", "きつね", "ねこ"}
WriteItems("sampleData", sampleData)
' LIKE 'ね%' AND LIKE '%り' AND LIKE '%ぶた%' のような検索をしたい
' ハードコーディングしてみる
Console.WriteLine()
Console.WriteLine("特定ケースをハードコーディング")
Dim case1 = sampleData.Where(Function(item) item.StartsWithEx("ね")) _
.Where(Function(item) item.EndsWithEx("り")) _
.Where(Function(item) item.ContainsEx("ぶた"))
WriteItems("Whereメソッド内をハードコーディング", case1)
' → Whereメソッド内をハードコーディング: ねぶたまつり
#If DEBUG Then
Console.ReadKey()
#End If
End Sub
End Module
Visual Studioからデバッグ実行したとき、コンソールがすぐに閉じてしまわないように「Console.ReadKey()」と記述してある。そこで何かキーを押すとプログラムは終了する。
このLINQ式ではWhereメソッドを三つチェーンして、「"ね"で始まり、かつ、"り"で終わり、かつ、"ぶた"を含む」文字列を検索している。
このコードの実行結果は、次の画像のようになる。
特定のケースをハードコーディングした場合の実行結果
「"ね"で始まり、かつ、"り"で終わり、かつ、"ぶた"を含む」文字列として、正しく「ねぶたまつり」が出力されている。
AND検索についてはショートサーキット評価になっている(「"ぶた"」や「"こぶた"」は"ね"で始まっていないので、最初のStartsWithメソッドによる比較をしただけでそれ以降の比較を打ち切っている)。この効率をなるべく落とさずに汎用的なコードに書き直したいのである。
このコードでは、与える検索語の「%」の位置を変えたいときに対応できない(どの検索語でどの文字列比較メソッドを使うかが決め打ちになっている)。また、与える検索語の数も固定されている。これを汎用的なコードに書き直したいのだ。
素直にループ化してみる
上のコードを汎用化してみよう。検索語の数が変更できるようにループにする。検索語の「%」の位置によって利用する文字列比較メソッドを切り分けるのは、ループの中で行おう。すると、次のコードのように書ける。
string[] keywords = { "ね%", "%り", "%ぶた%", }; // 検索語(「%」付き)
Console.WriteLine();
Console.WriteLine("Whereメソッド内をどうするかループ内で判定");
var case2 = sampleData;
foreach (var keyword in keywords)
{
var k = keyword.Trim("%".ToCharArray());
if (keyword.StartsWith("%") && keyword.EndsWith("%"))
{
Console.WriteLine("キーワード'{0}'にはContainsExを使う", keyword); // ☆
case2 = case2.Where(item => item.ContainsEx(k));
}
else if (keyword.StartsWith("%"))
{
Console.WriteLine("キーワード'{0}'にはEndsWithExを使う", keyword); // ☆
case2 = case2.Where(item => item.EndsWithEx(k));
}
else if (keyword.EndsWith("%"))
{
Console.WriteLine("キーワード'{0}'にはStartsWithExを使う", keyword); // ☆
case2 = case2.Where(item => item.StartsWithEx(k));
}
else
{
Console.WriteLine("キーワード'{0}'にはEqualsを使う", keyword); // ☆
case2 = case2.Where(item => item.Equals(k));
}
}
WriteItems("Whereメソッド内をどうするかループ内で判定", case2); // ☆
// → Whereメソッド内をどうするかループ内で判定: ねぶたまつり
Dim keywords = New String() {"ね%", "%り", "%ぶた%"} ' 検索語(「%」付き)
Console.WriteLine()
Console.WriteLine("Whereメソッド内をどうするかループ内で判定")
Dim case2 = sampleData
For Each keyword In keywords
Dim k = keyword.Trim("%".ToCharArray())
If (keyword.StartsWith("%") AndAlso keyword.EndsWith("%")) Then
Console.WriteLine("キーワード'{0}'にはContainsExを使う", keyword) ' ☆
case2 = case2.Where(Function(item) item.ContainsEx(k))
ElseIf (keyword.StartsWith("%")) Then
Console.WriteLine("キーワード'{0}'にはEndsWithExを使う", keyword) ' ☆
case2 = case2.Where(Function(item) item.EndsWithEx(k))
ElseIf (keyword.EndsWith("%")) Then
Console.WriteLine("キーワード'{0}'にはStartsWithExを使う", keyword) ' ☆
case2 = case2.Where(Function(item) item.StartsWithEx(k))
Else
Console.WriteLine("キーワード'{0}'にはEqualsを使う", keyword) ' ☆
case2 = case2.Where(Function(item) item.Equals(k))
End If
Next
WriteItems("Whereメソッド内をどうするかループ内で判定", case2)
' → Whereメソッド内をどうするかループ内で判定: ねぶたまつり
検索を実行する部分だけを示す(サンプルデータの作成などは前と同じ)。
ここでは、検索語に「%」が含まれない場合も想定している(そのときはStringクラスのEqualsメソッドでの比較とした)。
コメントで「☆」印を付けた行に注目してほしい。これらは実際のコードでは不要なのだが、if文が実行されたタイミングを見るために入れてある(次の実行結果画面を参照)。
どの文字列比較を使うかを動的に決定するために、if文だらけのコードになってしまった。このコードが長いメソッドの途中に入っていた場合には、コードの可読性が高いとはいえないだろう。このループの中身を別のメソッドに切り出してシンプルにしたいのである。
この実行結果は次の画像のようになる。
素直にループ化した場合の実行結果
検索語ごとに1回だけ、if文による判定処理が実行されている(2行目〜4行目)。
全ての判定処理が終わってからLINQの検索処理が実行されている点にも注目してほしい*2(5行目以降)。検索処理自体は、前述の特定のケースをハードコーディングした場合と全く同様に行われている。
汎用的なコードにできたものの、このままでは可読性に難がある。もっとスマートなコードにしたいのだ。
「LIKE」判定を行うメソッドを作る(失敗例)
上のコードの可読性を上げるには、Whereメソッドに与える条件判定式を汎用的なものにすればよい、という発想があり得る。汎用的な条件判定メソッドを作れば、ループ内のif文をなくせるのである。やってみよう。
まず、条件判定メソッド「LikeEx」を、冒頭に示したContainsExメソッドなどのStringExtensionクラスの中に拡張メソッドとして作成する(次のコード)。
public static class StringExtension
{
// ……既存コード省略……
// LIKE判定を行うメソッド
public static bool LikeEx(this string item, string keyword)
{
var k = keyword.Trim("%".ToCharArray());
if (keyword.StartsWith("%") && keyword.EndsWith("%"))
{
Console.WriteLine("キーワード'{0}'にはContainsExを使う", keyword); // ☆
return item.ContainsEx(k);
}
else if (keyword.StartsWith("%"))
{
Console.WriteLine("キーワード'{0}'にはEndsWithExを使う", keyword); // ☆
return item.EndsWithEx(k);
}
else if (keyword.EndsWith("%"))
{
Console.WriteLine("キーワード'{0}'にはStartsWithExを使う", keyword); // ☆
return item.StartsWithEx(k);
}
else
{
Console.WriteLine("キーワード'{0}'にはEqualsを使う", keyword); // ☆
return item.Equals(k);
}
}
}
Imports System.Runtime.CompilerServices
Module StringExtension
' ……既存コード省略……
' LIKE判定を行うメソッド
<Extension()> _
Public Function LikeEx(ByVal item As String, ByVal keyword As String) As Boolean
Dim k = keyword.Trim("%".ToCharArray())
If (keyword.StartsWith("%") AndAlso keyword.EndsWith("%")) Then
Console.WriteLine("キーワード'{0}'にはContainsExを使う", keyword) ' ☆
Return item.ContainsEx(k)
ElseIf (keyword.StartsWith("%")) Then
Console.WriteLine("キーワード'{0}'にはEndsWithExを使う", keyword) ' ☆
Return item.EndsWithEx(k)
ElseIf (keyword.EndsWith("%")) Then
Console.WriteLine("キーワード'{0}'にはStartsWithExを使う", keyword) ' ☆
Return item.StartsWithEx(k)
Else
Console.WriteLine("キーワード'{0}'にはEqualsを使う", keyword) ' ☆
Return item.Equals(k)
End If
End Function
End Module
「Like」というメソッド名にしなかったのは、VBのLike演算子と重複するからである。
先ほどのコードと同様に、if文が実行されたタイミングを見るために「☆」印の行を入れてある(後の実行結果画面を参照)。
すると、LINQで検索を実行する部分のコードは、次のようにシンプルに書ける。
Console.WriteLine();
Console.WriteLine("Whereメソッドのラムダ式を「LikeEx」メソッドで統一");
var case3 = sampleData;
foreach (var keyword in keywords)
{
var k = keyword; // VS2012以降では、この変数への代入は不要
case3 = case3.Where(item => item.LikeEx(k));
}
WriteItems("Whereメソッドのラムダ式を「LikeEx」メソッドで統一", case3);
// → Whereメソッドのラムダ式を「LikeEx」メソッドで統一: ねぶたまつり
Console.WriteLine()
Console.WriteLine("Whereメソッドのラムダ式を「LikeEx」メソッドで統一")
Dim case3 = sampleData
For Each keyword In keywords
Dim k = keyword ' VS2012以降では、この変数への代入は不要
case3 = case3.Where(Function(item) item.LikeEx(k))
Next
WriteItems("Whereメソッドのラムダ式を「LikeEx」メソッドで統一", case3)
' → Whereメソッドのラムダ式を「LikeEx」メソッドで統一: ねぶたまつり
検索を実行する部分だけを示す(サンプルデータの作成などは前と同じ)。
これなら短いコメント(「WhereのチェーンでAND検索」といった程度)を付けておくだけで、後から頭を抱えずに済むだろう。
メインのコードが短くなり、可読性は上がった。ところが、これを実行すると次の画像のような結果になってしまう。
作成した「LikeEx」を使ってループ内を書き直した場合の実行結果
最終的に出力される結果は正しい。しかし、文字列を比較するごとにif文による判定処理が実行されてしまっている。先ほどの素直にループ化した場合には、判定処理は3回だけだった。処理効率を追求したいときには、これはよくないだろう。
「LIKE」判定メソッドを作る方法は、効率がよくないと分かった。なぜそうなったのだろうか? Whereメソッドの引数に与えるラムダ式は、コレクションの要素ごとに呼び出されることを思い出してほしい。その呼び出しの中に判定処理を記述してしまったのだから、要素ごとに実行されてしまったのである(そして、3回ループしているので、要素ごとに最大3回まで繰り返し呼び出されることになる)。
独自の「Whereメソッド」を作る
上の失敗を避けるにはどうしたらよいだろうか? Whereメソッドの引数に与えるラムダ式の中に判定処理を記述できないとしたら、どこに書けばよいのか? 独自の「Whereメソッド」を作って、そこで判定処理を行えばよいのである。
独自の「Whereメソッド」として「WhereLike」という名前のメソッドを作る(次のコード)。LINQのチェーンで使いたいので、IEnumerable<string>インターフェース(C#)/IEnumerable(Of String)インターフェース(VB)を返す拡張メソッドとして実装する。この「WhereLike」メソッドの中身は、前述した素直にループ化したコード例のループ内を切り出した形になる。
using System;
using System.Collections.Generic;
using System.Linq;
public static class LinqExtension
{
public static IEnumerable<string> WhereLike(
this IEnumerable<string> items, string keyword)
{
var k = keyword.Trim("%".ToCharArray());
if (keyword.StartsWith("%") && keyword.EndsWith("%"))
{
Console.WriteLine("キーワード'{0}'にはContainsExを使う", keyword); // ☆
return items.Where(item => item.ContainsEx(k));
}
else if (keyword.StartsWith("%"))
{
Console.WriteLine("キーワード'{0}'にはEndsWithExを使う", keyword); // ☆
return items.Where(item => item.EndsWithEx(k));
}
else if (keyword.EndsWith("%"))
{
Console.WriteLine("キーワード'{0}'にはStartsWithExを使う", keyword); // ☆
return items.Where(item => item.StartsWithEx(k));
}
else
{
Console.WriteLine("キーワード'{0}'にはEqualsを使う", keyword); // ☆
return items.Where(item => item.Equals(k));
}
}
}
Imports System.Runtime.CompilerServices
Module LinqExtension
<Extension()> _
Public Function WhereLike(ByVal items As IEnumerable(Of String), _
ByVal keyword As String) As IEnumerable(Of String)
Dim k = keyword.Trim("%".ToCharArray())
If (keyword.StartsWith("%") AndAlso keyword.EndsWith("%")) Then
Console.WriteLine("キーワード'{0}'にはContainsExを使う", keyword) ' ☆
Return items.Where(Function(item) item.ContainsEx(k))
ElseIf (keyword.StartsWith("%")) Then
Console.WriteLine("キーワード'{0}'にはEndsWithExを使う", keyword) ' ☆
Return items.Where(Function(item) item.EndsWithEx(k))
ElseIf (keyword.EndsWith("%")) Then
Console.WriteLine("キーワード'{0}'にはStartsWithExを使う", keyword) ' ☆
Return items.Where(Function(item) item.StartsWithEx(k))
Else
Console.WriteLine("キーワード'{0}'にはEqualsを使う", keyword) ' ☆
Return items.Where(Function(item) item.Equals(k))
End If
End Function
End Module
検索語によって場合分けをし、それぞれでWhereメソッドの異なる呼び出し方をしている。LINQのチェーンで使えるメソッドを作る場合に、このように内部でLINQのメソッドを呼び出す形で作るのは簡単なのである(そうでないときは、IEnumerable<T>/IEnumerable(Of T)インターフェースを返すためにyield/Yieldステートメントを使うことになり、やや難しくなる)。
先ほどまでのコードと同様に、if文が実行されたタイミングを見るために「☆」印の行を入れてある(後の実行結果画面を参照)。
なお、ここでは分かりやすいように「WhereLike」というメソッド名にしたが、「Where」としても問題ない。既存のWhereメソッドとは引数が異なるため衝突しないからだ。そのような例は、「.NET TIPS:LINQ文で動的にWhere句を組み立てるには?[3.5、C#、VB]」で紹介した「LINQ動的クエリライブラリ」に見られる。
これを使うと、LINQで検索を実行する部分は次のコードのようになる。
Console.WriteLine();
Console.WriteLine("独自の「Whereメソッド」を作る");
var case4 = sampleData;
foreach (var keyword in keywords)
{
case4 = case4.WhereLike(keyword);
}
WriteItems("独自の「Whereメソッド」を作る", case4);
// → 独自の「Whereメソッド」を作る: ねぶたまつり
Console.WriteLine()
Console.WriteLine("独自の「Whereメソッド」を作る")
Dim case4 = sampleData
For Each keyword In keywords
case4 = case4.WhereLike(keyword)
Next
WriteItems("独自の「Whereメソッド」を作る", case4)
' → 独自の「Whereメソッド」を作る: ねぶたまつり
検索を実行する部分だけを示す(サンプルデータの作成などは前と同じ)。
これも短いコメント(「WhereのチェーンでAND検索」といった程度)を付けるくらいで理解できるコードになっているだろう(サンプルコードの一部として付けた「case4」という変数名は変更を要す)。
この実行結果を見ると、無駄な判定処理は行っていない(次の画像)。
作成した「WhereLike」を使ってループ内を書き直した場合の実行結果
先ほどの素直にループ化した場合と同様の結果が得られた。
「WhereLike」メソッド中で呼び出しているWhereメソッドは遅延実行されるので、このように判定処理だけが先に全て実行され、それからWhereメソッドが実行される。
このように独自の「Whereメソッド」を作ることで、効率を落とさずにコードの可読性を改善できた。
まとめ
LINQの引数に与えるラムダ式の中でコレクションの要素以外のもの(本稿の例では検索語)を対象とする処理を行うのは効率が悪い。そのようなときには、独自の「Whereメソッド」の作成を検討しよう(本稿の例はそれがうまく行った場合である)。
LINQのチェーンで使える独自の「Whereメソッド」(あるいは、その他好きな処理を行うメソッド)は、シグネチャ(拡張メソッドで、最初の引数と返値はIEnumerable<T>/IEnumerable(Of T)インターフェースであること)に気を付ければ、作るのはそれほど難しくない。
*1 Where拡張メソッドの引数には、ラムダ式を与える。ラムダ式について詳しくは、次のMSDNのドキュメントを参照していただきたい。
- MSDN:ラムダ式 (C# プログラミング ガイド)
- MSDN:ラムダ式(Visual Basic)
*2 この機能は「クエリの遅延評価(または遅延実行)」と呼ばれる。詳しくはMSDNに掲載されている「LINQ: .NET 統合言語クエリ」(Don Box、Anders Hejlsberg著)の「LINQ プロジェクトをサポートする言語機能」の項中、「クエリの遅延評価」の項を参照していただきたい。
利用可能バージョン:.NET Framework 3.5以降
カテゴリ:クラスライブラリ 処理対象:LINQ
使用ライブラリ:Enumerableクラス(System.Linq名前空間)
使用ライブラリ:Stringクラス(System名前空間)
関連TIPS:LINQ:文字列コレクションで「LIKE検索」(部分一致検索)をするには?[C#、VB]
関連TIPS:LINQ:文字列コレクションで複数キーワードのAND検索をするには?[C#、VB]
Copyright© Digital Advantage Corp. All Rights Reserved.