join句のグループ化結合
前回の最後に述べたとおり、クエリ式には複数のソースを関連付けるjoin句がある。そして、join句には前回紹介した「内部結合」のほかに、「グループ化結合」と「左外部結合」が存在する。今回はこの2つを見ていこう。
前回の内部結合は、2つのソースに一致する値を見いだした時点で、続きのクエリを発動できた。例えば、「ID番号と商品名を持つソース」と、「ID番号と価格を持つソース」を使い、ID番号で内部結合を行えば、商品名とそれに対応する価格を結び付けることができた。
しかし、この機能は場合によっては十分ではない。同じ商品を異なる店舗で別の価格で販売している場合、商品名に対する価格は複数になってしまう。さらに、価格ごとに「どの店での値段か」という情報も添えねばならない。
このようなケースでは、「内部結合」ではなく「グループ化結合」を使用するとよい。グループ化結合は、join句で結合の対象となる2つのソースのうち、左側のソースの要素が、右側のソースに含まれる1つ以上の一致する要素に関連付けられる。
つまり、このケースでは以下のような関連付けが行われる。
商品名 => ((店名, 価格), (店名, 価格), ……)
|
|
実際にこれを記述した例を以下に示す。
using System;
using System.Linq;
class Program
{
class 商品情報
{
public int Id;
public string 名前;
}
class 商品販売価格
{
public int Id;
public int 価格;
public string 店名;
}
static void Main(string[] args)
{
商品情報[] 商品情報データ =
{
new 商品情報() { Id = 1, 名前="PC-8001" },
new 商品情報() { Id = 2, 名前="MZ-80K" },
new 商品情報() { Id = 3, 名前="Basic Master Level-3" },
};
商品販売価格[] 商品販売価格データ =
{
new 商品販売価格() {Id=1, 価格=168000, 店名="BitOut"},
new 商品販売価格() {Id=1, 価格=148000, 店名="富士山音響"},
new 商品販売価格() {Id=2, 価格=178000, 店名="富士山音響"},
new 商品販売価格() {Id=3, 価格=298000, 店名="マイコンセンターROM"},
new 商品販売価格() {Id=3, 価格=229000, 店名="富士山音響"},
};
var query = from x in 商品情報データ
join y in 商品販売価格データ
on x.Id equals y.Id into z
select new { Name = x.名前, 商品データ = z };
foreach (var 商品 in query)
{
Console.WriteLine("{0}", 商品.Name);
foreach (var 価格情報 in 商品.商品データ)
{
Console.WriteLine("\t{0} {1:C}",
価格情報.店名, 価格情報.価格);
}
}
}
}
|
|
リスト6 グループ化結合の例 |
PC-8001
BitOut \168,000
富士山音響 \148,000
MZ-80K
富士山音響 \178,000
Basic Master Level-3
マイコンセンターROM \298,000
富士山音響 \229,000
|
|
リスト6の実行結果 |
このように、1つの商品名に対して、複数の「店名+価格」情報が関連付けられた。
さて、「内部結合」では以下の書式が使用された。
join 〜 in 〜 on 〜 equals 〜
|
|
join句による内部結合の構文 |
これに対して、グループ化結合では次のように「intoキーワード」が追加されてさらに長くなる(このintoはinto句ではなく、join-into構文と呼ばれるものの一部。join句には厳密にはintoを持たないjoin構文と、join-into構文の2種類が存在する)。
join 〜 in 〜 on 〜 equals 〜 into 〜
|
|
join句によるグループ化結合の構文 |
intoキーワードの役割は、equalsキーワードで指定された条件を満たす右側のソースの要素をグループ化することである。左側のソースの要素が同じものごとに、グループ分けが行われる。
例えば、リスト6では「into z」と書かれているが、このzにグループ分けされたグループが格納されている。これはselect句によって、「商品データ = z」のように参照して利用できる。実際、以下のforeach文で利用されている。
foreach (var 価格情報 in 商品.商品データ)
|
|
つまり、グループ化結合は、グループごとに結合された値の集まりを列挙する。すべて列挙するには、階層化された2重の繰り返し(列挙)を必要とする。それ故に、(リスト6の)実行結果に見られるような階層化された結果を得るために使用できる。
join句の左外部結合
グループ化結合は上記のように2重の繰り返しを必要とする。これは、ある商品を販売している店舗一覧を知りたい場合には便利だが、単に「商品」と「販売店」の組み合わせリストだけが欲しい場合は冗長である。
そこで、さらにクエリ式にfrom句をもう1つ追加することで、2重の繰り返しを再度単層の繰り返しに変換させることができる。
using System;
using System.Linq;
class Program
{
class 商品情報
{
public int Id;
public string 名前;
}
class 商品販売価格
{
public int Id;
public string 店名;
}
static void Main(string[] args)
{
商品情報[] 商品情報データ =
{
new 商品情報() { Id=1, 名前="PC-8001" },
new 商品情報() { Id=2, 名前="MZ-80K" },
new 商品情報() { Id=3, 名前="Basic Master Level-3" },
new 商品情報() { Id=4, 名前="COMKIT 8060" },
};
商品販売価格[] 商品販売価格データ =
{
new 商品販売価格() { Id=1, 店名="BitOut" },
new 商品販売価格() { Id=1, 店名="富士山音響" },
new 商品販売価格() { Id=2, 店名="富士山音響" },
new 商品販売価格() { Id=3, 店名="マイコンセンターROM" },
new 商品販売価格() { Id=3, 店名="富士山音響" },
};
var query = from x in 商品情報データ
join y in 商品販売価格データ
on x.Id equals y.Id into z
from a in z
select new { Name = x.名前, 販売店 = a };
foreach (var 商品 in query)
{
Console.WriteLine("{0}", 商品.Name);
Console.WriteLine("\t{0}", 商品.販売店.店名);
}
}
}
|
|
リスト7 単層の繰り返し |
PC-8001
BitOut
PC-8001
富士山音響
MZ-80K
富士山音響
Basic Master Level-3
マイコンセンターROM
Basic Master Level-3
富士山音響
|
|
リスト7の実行結果 |
つまり、「列挙」である範囲変数zをさらにfrom句で個々の値に分解することで、select句は列挙を含まない結果を生成することができ、結果として2重の列挙を発生させないで済む。
しかし、このコードには1つだけ決定的な不満がある。それは、join句の右側に対応する項目(店名)がない左側の項目(名前)は出力されないことである。この例では、「COMKIT 8060」という名前は実行結果にまったく出てこない。
この問題を解決する結合が左外部結合である。対応する要素がない場合でも、左側のすべての種類の要素を得ることができる。
方法は簡単で、DefaultIfEmptyメソッドをfrom句に追加するだけである。
var query = from x in 商品情報データ
join y in 商品販売価格データ on x.Id equals y.Id into z
from a in z.DefaultIfEmpty(
new 商品販売価格() { 店名 = "取り扱い店なし" })
select new { Name = x.名前, 販売店 = a };
|
|
リスト8 左外部結合の例(クエリ式以外はリスト7と同様のため省略) |
PC-8001
BitOut
PC-8001
富士山音響
MZ-80K
富士山音響
Basic Master Level-3
マイコンセンターROM
Basic Master Level-3
富士山音響
COMKIT 8060
取り扱い店なし
|
|
リスト8の実行結果 |
DefaultIfEmptyメソッドはSystem.Linq.EnumerableクラスからIEnumerable<TSource>に対して提供される拡張メソッドである。指定されたシーケンスを受け取ってそのまま返すが、シーケンスが空の場合に限って、その型のデフォルト値または引数で指定されたデフォルト値を返す。デフォルト値は、引数なしで使えば型のデフォルト値(int型なら0、クラスならnullなど)が使われる。引数を記述すれば、それがデフォルト値になる。
この例では、
new 商品販売価格() { 店名 = "取り扱い店なし" }
|
|
というデフォルト値を指定している。
これにより、対応関係が存在しない要素にはこのデフォルト値が補われ、左側の要素はすべて出力される。
ちなみに、DefaultIfEmptyメソッドの引数にデフォルト値を書き込まず、結果にnullを含めさせるのも1つの方法である。対応する要素がない場合に複雑な処理を実行する必要があるときは、nullを返して、それをチェックする方が処理しやすいこともある。
単独で使うDefaultIfEmptyメソッド
余談だが、DefaultIfEmptyメソッドは左外部結合専用というわけではなく、単体で使っても役に立つ。列挙を行う際、列挙対象が空の場合に特定の値を補いたい場合には、そのまま使える。
例えば以下は、文字列の配列を出力するメソッドを記述した例だが、配列が空の場合は「リストは空です」と出力させる機能を含めた例である。
using System;
using System.Linq;
class Program
{
private static void dumpArray(string[] array)
{
foreach (var s in array.DefaultIfEmpty("リストは空です"))
{
Console.WriteLine(s);
}
}
static void Main(string[] args)
{
string[] t1 = { "Hello!", "Linq!" };
string[] t2 = { };
dumpArray(t1);
// 出力:
// Hello!
// Linq!
dumpArray(t2);
// 出力:
// リストは空です
}
}
|
|
リスト9 単体で使ったDefaultIfEmptyメソッド |
普通に書けばif文が1つ必要とされる状況だが、このようにスマートに、式の一部に補うべき値を埋め込むことができている。
内部列挙を伴うfrom句の2重使用
2重の列挙という問題でもう1つだけ触れておく価値があるのは、1つのソースに階層がある場合である。
例えば、以下のリスト10のデータは、「構成員」というリストを含む「家」オブジェクトがリストになっているという2重のリストである。これを普通にクエリ式で処理すれば、2重の列挙(家の列挙と構成員の列挙)が必要とされる。しかし、以下のようにfrom句を2重に使うと、これを単層の列挙に折りたたむことができる。
using System;
using System.Linq;
class 家
{
public string 姓;
public string[] 構成員;
}
class Program
{
static void Main(string[] args)
{
家[] 家々 =
{
new 家() {
姓 = "磯崎",
構成員 = new [] { "波平", "フネ", "カツオ", "ワカメ"},
},
new 家() {
姓 = "フグ山",
構成員 = new [] { "サザエ", "マスオ", "タラオ"},
},
};
foreach (var 人 in from その家 in 家々
from 名 in その家.構成員
select new { その家, 名 })
{
Console.WriteLine("{0}{1}", 人.その家.姓, 人.名);
}
// 出力:
// 磯崎波平
// 磯崎フネ
// 磯崎カツオ
// 磯崎ワカメ
// フグ山サザエ
// フグ山マスオ
// フグ山タラオ
}
}
|
|
リスト10 2重のリストを単層に展開した例 |
ここでは、「from その家 in 家々」というfrom句に続いて、そのfrom句の範囲変数「その家」を使った第2のfrom句が「from 名 in その家.構成員」と記述されている。このような使い方は可能であり、すべての組み合わせを列挙してくれる。もちろん、単純な総当たりではないので、「磯崎サザエ」のようなデータ上あり得ない名前が出力されることはない。