LINQを使用して複数の比較条件をAND検索する際に、独自のWhereメソッドを作成することでコードの可読性を高める方法を解説する。
対象:.NET 3.5以降
文字列のコレクションをLINQを使って次々と検索するときに、比較条件を場合分けしたいときがある。例えば、複数の「LIKE検索」をANDで結合したいときなどだ。そのままコードを書きくだせば可能なのだが、コードの可読性を上げようとして失敗することがある。本稿では、失敗する例を紹介し、その対策として「Whereメソッド」を独自に作成して使う方法を解説する。
あらかじめお断りしておくが、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
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
このコードの実行結果は、次の画像のようになる。
このコードでは、与える検索語の「%」の位置を変えたいときに対応できない(どの検索語でどの文字列比較メソッドを使うかが決め打ちになっている)。また、与える検索語の数も固定されている。これを汎用的なコードに書き直したいのだ。
上のコードを汎用化してみよう。検索語の数が変更できるようにループにする。検索語の「%」の位置によって利用する文字列比較メソッドを切り分けるのは、ループの中で行おう。すると、次のコードのように書ける。
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メソッド内をどうするかループ内で判定: ねぶたまつり
この実行結果は次の画像のようになる。
汎用的なコードにできたものの、このままでは可読性に難がある。もっとスマートなコードにしたいのだ。
上のコードの可読性を上げるには、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
すると、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」メソッドで統一: ねぶたまつり
メインのコードが短くなり、可読性は上がった。ところが、これを実行すると次の画像のような結果になってしまう。
「LIKE」判定メソッドを作る方法は、効率がよくないと分かった。なぜそうなったのだろうか? Whereメソッドの引数に与えるラムダ式は、コレクションの要素ごとに呼び出されることを思い出してほしい。その呼び出しの中に判定処理を記述してしまったのだから、要素ごとに実行されてしまったのである(そして、3回ループしているので、要素ごとに最大3回まで繰り返し呼び出されることになる)。
上の失敗を避けるにはどうしたらよいだろうか? 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
これを使うと、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メソッド」を作ることで、効率を落とさずにコードの可読性を改善できた。
LINQの引数に与えるラムダ式の中でコレクションの要素以外のもの(本稿の例では検索語)を対象とする処理を行うのは効率が悪い。そのようなときには、独自の「Whereメソッド」の作成を検討しよう(本稿の例はそれがうまく行った場合である)。
LINQのチェーンで使える独自の「Whereメソッド」(あるいは、その他好きな処理を行うメソッド)は、シグネチャ(拡張メソッドで、最初の引数と返値はIEnumerable<T>/IEnumerable(Of T)インターフェースであること)に気を付ければ、作るのはそれほど難しくない。
*1 Where拡張メソッドの引数には、ラムダ式を与える。ラムダ式について詳しくは、次のMSDNのドキュメントを参照していただきたい。
*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.