Visual Basic 初級講座 [改訂版]
VB2005 VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

第39回 LINQでのグループ化と結合

2021/7/25

この記事が対象とする製品・バージョン

VB2019 Visual Basic 2019 対象です。
VB2017 Visual Basic 2017 対象です。
VB2015 Visual Basic 2015 対象です。
VB2013 Visual Basic 2013 対象です。
VB2012 Visual Basic 2012 対象です。
VB2010 Visual Basic 2010 対象です。
VB2008 Visual Basic 2008 対象ですが、使用できない機能も含んでいます。
VB2005 Visual Basic 2005 × 対象外です。
VB.NET 2003 Visual Basic.NET 2003 × 対象外です。
VB.NET 2002 Visual Basic.NET (2002) × 対象外です。
VB6対応 Visual Basic 6.0 × 対象外です。

 

目次

 

1.LINQを使ったグループの操作

今回はLINQでグループや複数のデータソースを扱う方法や考え方を説明します。

LINQを使ったグループの集計や操作 と 複数のデータソースを関連付けた集計や操作は非常に強力で、LINQを使う価値を最大限に発揮できます。

通常のプログラムでは、頭をひねってかなりの行数のプログラムを書かなければいけないところをたった数行でプログラムできることもあります。 たとえば、2つのフォルダーを比較して、両方に存在するファイルの一覧を取得することも簡単にできます。

しかし、このレベルのLINQは扱うのが難しく、はじめての人が一度説明を読んだくらいでは習得できないのではないかと思います。今回説明するLINQは理解しなくても、Visual Basic の他の機能を習得する妨げにはなりませんので、一度読んだ後は必要になったときに戻ってくるスタンスが良いと思います。

 

2.グループ化

2-1.Group By

Group By (読み方:Group By = グループバイ)を使うとデータソースを複数のグループに分割してグループごとに集計などができます。これをグループ化と呼びます。

Group By を使用するLINQの構文は次のようになります。


From 反復変数 In データソース
Group By グループ化する基準になる値 Into 集計操作

 

参考に都道府県の人口情報を使ってプログラムしてみることにします。これから紹介するサンプルを実際に試す場合は、下記のPrefectureクラスと、GetPrefecturesメソッドを使ってください。

PrefectureクラスとGetPrefecturesメソッド

VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

Public Class Prefecture
    Public Name As String
    Public Area As String
    Public Population As Integer

    Public Sub New(name As String, area As String, population As Integer)
        Me.Name = name
        Me.Area = area
        Me.Population = population
    End Sub
End Class

Private Function GetPrefectures() As IEnumerable(Of Prefecture)

    Dim results As New List(Of Prefecture)

    results.Add(New Prefecture("北海道", "北海道", 5383579))
    results.Add(New Prefecture("青森県", "東北", 1308649))
    results.Add(New Prefecture("岩手県", "東北", 1279814))
    results.Add(New Prefecture("宮城県", "東北", 2334215))
    results.Add(New Prefecture("秋田県", "東北", 1022839))
    results.Add(New Prefecture("山形県", "東北", 1122957))
    results.Add(New Prefecture("福島県", "東北", 1913606))
    results.Add(New Prefecture("茨城県", "関東", 2917857))
    results.Add(New Prefecture("栃木県", "関東", 1974671))
    results.Add(New Prefecture("群馬県", "関東", 1973476))

    results.Add(New Prefecture("埼玉県", "関東", 7261271))
    results.Add(New Prefecture("千葉県", "関東", 6224027))
    results.Add(New Prefecture("東京都", "関東", 13513734))
    results.Add(New Prefecture("神奈川県", "関東", 9127323))
    results.Add(New Prefecture("新潟県", "中部", 2305098))
    results.Add(New Prefecture("富山県", "中部", 1066883))
    results.Add(New Prefecture("石川県", "中部", 1154343))
    results.Add(New Prefecture("福井県", "中部", 787099))
    results.Add(New Prefecture("山梨県", "中部", 835165))
    results.Add(New Prefecture("長野県", "中部", 2099759))

    results.Add(New Prefecture("岐阜県", "中部", 2032533))
    results.Add(New Prefecture("静岡県", "中部", 3701181))
    results.Add(New Prefecture("愛知県", "中部", 7484094))
    results.Add(New Prefecture("三重県", "近畿", 1815827))
    results.Add(New Prefecture("滋賀県", "近畿", 1413184))
    results.Add(New Prefecture("京都府", "近畿", 2610140))
    results.Add(New Prefecture("大阪府", "近畿", 8838908))
    results.Add(New Prefecture("兵庫県", "近畿", 5536989))
    results.Add(New Prefecture("奈良県", "近畿", 1365008))
    results.Add(New Prefecture("和歌山県", "近畿", 963850))

    results.Add(New Prefecture("鳥取県", "中国", 573648))
    results.Add(New Prefecture("島根県", "中国", 694188))
    results.Add(New Prefecture("岡山県", "中国", 1922181))
    results.Add(New Prefecture("広島県", "中国", 2844963))
    results.Add(New Prefecture("山口県", "中国", 1405007))
    results.Add(New Prefecture("徳島県", "四国", 756063))
    results.Add(New Prefecture("香川県", "四国", 976756))
    results.Add(New Prefecture("愛媛県", "四国", 1385840))
    results.Add(New Prefecture("高知県", "四国", 728461))
    results.Add(New Prefecture("福岡県", "九州", 5102871))

    results.Add(New Prefecture("佐賀県", "九州", 833245))
    results.Add(New Prefecture("長崎県", "九州", 1377780))
    results.Add(New Prefecture("熊本県", "九州", 1786969))
    results.Add(New Prefecture("大分県", "九州", 1166729))
    results.Add(New Prefecture("宮崎県", "九州", 1104377))
    results.Add(New Prefecture("鹿児島県", "九州", 1648752))
    results.Add(New Prefecture("沖縄県", "沖縄", 1434138))

    Return results

End Function

この都道府県のデータから、全人口を求めるには既に説明したようにAggregate を使います。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = Aggregate prefecture In prefectures Into Sum(prefecture.Population)

Aggregateでは全部のデータソースを集計して1つ集計結果にしてしまいます。

「関東」や「九州」などの条件でグループごとに集計したい場合は Group By の出番です。

次のようにすると、地方ごとの人口を求めることができます。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = From prefecture In prefectures
              Group By prefecture.Area Into Sum(prefecture.Population)

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine($"{result.Area} の人口 = {result.Sum}")
    'Debug.WriteLine(result.Area & " の人口 = " & result.Sum) '←VB2013以前の場合
Next

Group By でグループ化する値を指定します。この例では Area プロパティが同じものをグループ化します。後は構文が違うだけで Aggregate に似ています。

結果は次のように表示されます。

北海道 の人口 = 5383579
東北 の人口 = 8982080
関東 の人口 = 42992359
中部 の人口 = 21466155
近畿 の人口 = 22543906
中国 の人口 = 7439987
四国 の人口 = 3847120
九州 の人口 = 13020723
沖縄 の人口 = 1434138

 

2-2.集計操作

Group Byで使用できるは集計操作は Aggregate とほぼ同じで Sum, Count, Average などです。

1つだけAggregate では使用できなかった Group という操作が可能です。これはグループ化されているここの要素をコレクション化する操作です。

具体例で見てみましょう。

次の例は都道府県の文字数でグループ化する例です。Count を使ってグループごとの数を数えます。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = From prefecture In prefectures
              Group By prefecture.Name.Length Into Count

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine($"{result.Length} 文字の都道府県の数 = {result.Count}")
    'Debug.WriteLine(result.Length & " 文字の都道府県の数 = " & result.Count) '←VB2013以前の場合
Next

実行すると次のように出力されます。

3 文字の都道府県の数 = 44
4 文字の都道府県の数 = 3

つまり、ほとんどの都道府県は3文字なのですが、3つだけ4文字の都道府県があります。その3つは何でしょうか?

数えるだけだと、その3つが何だかわからないのですが、Groupを使うとその3つをコレクション化することができます。

Count と Group を両方指定すると次のようになります。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = From prefecture In prefectures
              Group By prefecture.Name.Length Into Count, Group

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine($"{result.Length} 文字の都道府県の数 = {result.Count}")
    'Debug.WriteLine(result.Length & " 文字の都道府県の数 = " & result.Count) '←VB2013以前の場合
    For Each prefecture In result.Group
        Debug.WriteLine("    " & prefecture.Name)
    Next   
Next

結果を表示するところでは、Group の中に入っているPrefectureをFor Eachで取り出しています。4文字の都道府県部分は次のように出力されます。

4 文字の都道府県の数 = 3
神奈川県
和歌山県
鹿児島県

 

2-3.Group By と Where の組み合わせ

メモ メモ  - Having

SQL で言うところの Having という操作に該当します。

 

Group By で集計した結果を Where 条件で絞るには次のように組み合わせます。

この例では Area ごとに人口(Population)を合計して、2千万以上になるものだけを抽出します。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = From prefecture In prefectures
              Group By prefecture.Area Into areaPopulation = Sum(prefecture.Population)
              Where areaPopulation > 20000000

Where句が Grou By句より下になるので、Where内で 反復変数 prefecture を使用することができません。(Group Byの段階でグループ化されるので個々のprefectureは操作に使用できなくなります。)

そこで、この例では Group By 時点でSumの結果を areaPopulation という変数に代入し、それをWhere句で使用しています。Where句の内部で Sumを直接使うことはできないので、この例の場合、このように変数を使用するしかないと思います。

もう1つ紹介します。

この例は、都道府県の数が6個以上あるareaだけを抽出します。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = From prefecture In prefectures
              Group By prefecture.Area Into prefCount = Count
              Where prefCount >= 6

この例の場合、Count に引数が不要なので変数(prefCount)を定義する必要はなく、次のように書き換えることもできます。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = From prefecture In prefectures
              Group By prefecture.Area Into Count
              Where Count >= 6

さらに、Groupの集計を行ってグループからCountプロパティを呼び出すこともできます。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = From prefecture In prefectures
              Group By prefecture.Area Into Group
              Where Group.Count >= 6

この前の例の Sum でこれを真似しようとすると Where Sum(prefecture.Population) や Where Group.Sum(prefecture.Population) と書くことになってしまいますが、Group Byの後で反復変数は使用できないので、エラーになります。

 

Group Byの前にWhereを付けると意味が変わります。

たとえば、人口が200万人以上の都道府県の数を地方別に集計するには次のようになります。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = From prefecture In prefectures
              Where prefecture.Population >= 2000000
              Group By prefecture.Area Into Count

もうひとひねりしてみましょう。

人口が200万以上の都道府県が、3つ以上ある地方はどこでしょうか?

次のようになります。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = From prefecture In prefectures
              Where prefecture.Population >= 2000000
              Group By prefecture.Area Into Count
              Where Count >= 3

Group By の前で200万以上の都道府県に絞って、Group Byの後で集計した個数が 3 つ以上のところに絞るので Where が2か所に登場することになります。

 

2-4.範囲でのグループ化

人口が500万以上のグループ、200万~500万のグループ、100万~200万のグループのように範囲でグループ化したい場合は、範囲を定義する分岐を記述します。

次のようになります。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = From prefecture In prefectures
              Group By Rank = 
                  If(prefecture.Population > 5000000, "500万以上",
                  If(prefecture.Population >= 2000000, "200万以上",
                  If(prefecture.Population >= 1000000, "100万以上", "100万より下"))) 
                  Into Count, Group
              Order By Rank Descending


'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine(result.Rank & " " & result.Count)

    For Each prefecture In result.Group
        Debug.WriteLine("    " & prefecture.Name)
    Next
Next

もっと複雑なロジックでグループを定義する場合や、Group By の中に分岐処理を書きたくない場合は、このような分岐を行うメソッドを別途定義してGroup Byからそのメソッドを呼び出すこともできます。言語統合クエリの強みですね。

この例を実行すると次のように表示されます。

500万以上 9
    北海道
    埼玉県
    …以下略
200万以上 8
    宮城県
    茨城県
    …以下略
100万以上 21
    青森県
    岩手県
    …以下略
100万より下 9
    福井県
    山梨県
    …以下略

 

2-5.複数の値でのグループ化

グループ化する基準になる値は、複数指定することもできます。

たとえば、地方と都道府県の文字数するには次のようにします。(あまりにも意味のない例で恐縮ですが)

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefectures As IEnumerable(Of Prefecture) = GetPrefectures()

'LINQで処理を定義
Dim results = From prefecture In prefectures
              Group By prefecture.Area, prefecture.Name.Length Into Count

 

3.複数のデータソース

3-1.デカルト結合

複数のデータソースを扱う場合 LINQの便利さが際立ちます。

まずは、あまり役には立たないけれどもシンプルな例を紹介します。

複数のデータソースを扱うシンプルな方法は単純に From を2回記述することです。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim numbers = {1, 2, 3}
Dim values = {"A", "B"}

'LINQで処理を定義
Dim results = From number In numbers From value In values

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine(result.number & result.value)
Next

この例では From で numbers と values の2つのデータソースを指定しています。それ以外は何もしていません。

より簡略化して、1つのFromの中でデータソースをカンマで分ける書き方もできます。

VB2008 VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim numbers = {1, 2, 3}
Dim values = {"A", "B"}

'LINQで処理を定義
Dim results = From number In numbers, value In values

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine(result.number & result.value)
Next

この場合、LINQはこの2つのデータソースを結合して1つのコレクションにします。

結合するときに、何かの大会の総当たり戦のようにすべての組み合わせを生成します。このような結合方式を「デカルト結合」と呼びます。(クロス結合、デカルト積、直積等とも呼びます。)

つまり、この例を実行すると次の通りの結果が出力されます。

1A
1B
2A
2B
3A
3B

この処理に Where を追加して、 value を "B" だけに限定してみましょう。

VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim numbers = {1, 2, 3}
Dim values = {"A", "B"}

'LINQで処理を定義
Dim results = From number In numbers, value In values
              Where value = "B"

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine(result.number & result.value)
Next

この結果は簡単に予測できると思います。さきほどの総当たり戦から A が除外されて、次の通りの結果になります。

1B
2B
3B

 

3-2.暗黙的な内部結合

メモ メモ  - INNER JOIN

SQL で言うところの INNER JOIN という操作に該当します。

ここまでできるようになると、実際に役に立つ処理が作れます。

たとえば、フォルダーAとフォルダーBの両方に存在するファイル名を抽出するプログラムを簡単に記述できます。

VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim filesA = IO.Directory.GetFiles("C:\xxxxx\folderA")
Dim filesB = IO.Directory.GetFiles("C:\xxxxx\folderB")

'LINQで処理を定義
Dim results = From fileA In filesA, fileB In filesB
              Where IO.Path.GetFileName(fileA) = IO.Path.GetFileName(fileB)
              Select IO.Path.GetFileName(fileA)

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine(result)
Next

Where句でそれぞれのデータソースがお互いを制約するように条件を記述しているのがポイントです。このような使用方法を「内部結合」と呼びます。

LINQ内の反復変数 fileA と fileB はファイルのフルパスになってしまうため、Whereでファイル名だけを比較するように IO.Path.GetFileNameメソッドを使用しています。

この処理の意味がイメージしにくい場合は、最初に説明したデカルト結合を想像してください。フォルダーAにあるファイルとフォルダーBにあるファイルをすべて組み合わせたコレクションが作成されます。その中からWhereを使ってファイル名が一致するものだけを抽出しているということです。

また、一致するものを抽出する処理ですから、結果としての残るファイル名は fileA 由来のものでも fileB 由来のものでも同じなので、代表して fileA を IO.Path.GetFileName したものだけを結果として返すように Select してみました。

LINQの中で IO.File.GetFileName が何度もでてくるので、Let句を使って、変数にすることもできます。次のようになります。

VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim filesA = IO.Directory.GetFiles("C:\xxxxx\folderA")
Dim filesB = IO.Directory.GetFiles("C:\xxxxx\folderB")

'LINQで処理を定義
Dim results = From fileA In filesA, fileB In filesB
              Let fileNameA = IO.Path.GetFileName(fileA), fileNameB = IO.Path.GetFileName(fileB)
              Where fileNameA = fileNameB
              Select fileNameA

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine(result)
Next

Letを使うバージョンの方が IO.Path.GetFileName の回数が1回減るのでほんの少しだけ効率的です。ただ、このくらいなら特に気になるレベルではないので、見やすいと思う方をお好みで使えばよいと思います。

 

3-3.明示的な内部結合

メモ メモ  - INNER JOIN

これも、 SQL で言うところの INNER JOIN という操作に該当します。

LINQのキーワード JOIN (読み方:JOIN=ジョイン)を使って、同じ処理を記述することもできます。

VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim filesA = IO.Directory.GetFiles("C:\xxxxx\folderA")
Dim filesB = IO.Directory.GetFiles("C:\xxxxx\folderB")

'LINQで処理を定義
Dim results = From fileA In filesA
              Join fileB In filesB On IO.Path.GetFileName(fileA) Equals IO.Path.GetFileName(fileB)
              Select IO.Path.GetFileName(fileA)

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine(result)
Next

Whereを使って結合している例と結果は同じです。

Whereは結合以外にも、条件を指定した抽出でも使われるキーワードであるのに対し、JOINは結合専用のキーワードです。そのため、JOINを使って結合する場合、これを「明示的なJOIN」と表現します。

JOINを使う場合、From ではデータソースを1つしか指定せず、JOINを使って結合対象のデータソースと結合条件を指定します。


From 反復変数1 In データソース1
Join 反復変数2 In データソース2 On 結合条件1 [AND 結合条件2 ...]

JOINでの結合条件の指定は各種制約があり、Whereの方がはるかに柔軟です。たとえば、結合条件の式では = ではなく、 Equals を使う必要があります。結合式の中で必ず反復変数を使用する必要があります。複数の結合条件がある場合 AND で結合できますが、Or での結合はできません。

これだけ制約が厳しいので、JOINを使わないで最初に紹介したWhereを使った暗黙のJOINを行う方が楽です。

JOIN を使う場合はどういう場合でしょうか?

この点、正直に書くと私にも確信がありません。インターネットで調べると JOINを使った方が性能が良い ということを挙げている書き込みはありました。

c# - What is the difference between where and join? - Stack Overflow

たしかに、これだけ制約が厳しいのですから、この制約を前提とした処理を行うことで、何の制約もなくいろいろなことを考慮しなければいけないWhereより高速に結合できるというのはありそうな話です。

 

4.グループ結合

4-1.内部結合では結果が得られない場合

GROUP JOIN(読み方:GROUP JOIN)を使用するとグループ化して結合します。これをグループ結合と呼びます。

Group Join 句 - Visual Basic | Microsoft Docs

普通に内部結合を下のでは実現できない、「外部結合」という種類の結合を実現することができます。これも複雑なので順を追って説明します。

実験のため動物のコレクションと目レベルの分類のコレクションを作成する次の2つのクラスと2つのメソッドを用意することにします。

VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

Public Class Moku
    Public Property MokuName As String
    Public Property KoName As String

    Public Sub New(mokuName As String, koName As String)
        Me.MokuName = mokuName
        Me.KoName = koName
    End Sub
End Class

Public Class Animal
    Public Property AnimalName As String
    Public Property MokuName As String

    Public Sub New(animalName As String, mokuName As String)
        Me.AnimalName = animalName
        Me.MokuName = mokuName
    End Sub
End Class

Public Function GetAnimals() As IEnumerable(Of Animal)

    Dim animals As New List(Of Animal)

    animals.Add(New Animal("アイアイ", "霊長目"))
    animals.Add(New Animal("ボノボ", "霊長目"))
    animals.Add(New Animal("サイガ", "偶蹄目"))
    animals.Add(New Animal("トクソドン", "南蹄目"))
    animals.Add(New Animal("アオダイショウ", "有隣目"))
    animals.Add(New Animal("ホライモリ", "有尾目"))
    animals.Add(New Animal("アンフューマ", "有尾目"))
    animals.Add(New Animal("アシナシイモリ", "無足目"))
    animals.Add(New Animal("ヨタカ", "ヨタカ目"))
    animals.Add(New Animal("ラブカ", "カグラザメ目"))
    animals.Add(New Animal("マグロ", "スズキ目"))

    Return animals

End Function

Public Function GetMokus() As IEnumerable(Of Moku)

    Dim mokus As New List(Of Moku)

    mokus.Add(New Moku("霊長目", "哺乳綱"))
    mokus.Add(New Moku("偶蹄目", "哺乳綱"))
    mokus.Add(New Moku("食肉目", "哺乳綱"))
    mokus.Add(New Moku("翼手目", "哺乳綱"))
    mokus.Add(New Moku("南蹄目", "哺乳綱"))
    mokus.Add(New Moku("カメ目", "爬虫綱"))
    mokus.Add(New Moku("ワニ目", "爬虫綱"))
    mokus.Add(New Moku("有隣目", "爬虫綱"))
    mokus.Add(New Moku("有尾目", "両生綱"))
    mokus.Add(New Moku("炭竜目", "両生綱"))
    mokus.Add(New Moku("無足目", "両生綱"))

    Return mokus

End Function

GetAnimalsが生成するコレクションを見ると、アイアイが霊長目に分類されることがわかります。

GetMokusが生成するコレクションを見ると、霊長目が哺乳綱であることがわかります。

この2つを組み合わせるとアイアイが哺乳綱であることがわかります。

ヨタカ・ラブカ・マグロは目の一覧の方に対応する要素がないのがポイントです。

 

さて、Whereを使った内部結合でこの2つのコレクションを結びつけるプログラムを書いてみましょう。少し問題があることがわかります。

VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim animals = GetAnimals()
Dim mokus = GetMokus()

'LINQで処理を定義
Dim results = From animal In animals, moku In mokus
              Where animal.MokuName = moku.MokuName
              Select animal.AnimalName, animal.MokuName, moku.KoName

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine($"{result.AnimalName}  {result.MokuName} {result.KoName}")
    'Debug.WriteLine(result.AnimalName & " は " & result.MokuName & " " & result.KoName) '←VB2013以前の場合
Next

実行すると次の通り出力されます。

アイアイ は 霊長目 哺乳綱
ボノボ は 霊長目 哺乳綱
サイガ は 偶蹄目 哺乳綱
トクソドン は 南蹄目 哺乳綱
アオダイショウ は 有隣目 爬虫綱
ホライモリ は 有尾目 両生綱
アンフューマ は 有尾目 両生綱
アシナシイモリ は 無足目 両生綱

結果には、ヨタカ・ラブカ・マグロが含まれていません。この3種類の動物は、GetMokusが返すコレクションに対応する目が存在しないからです。Whereで結合した時に対応するものがないので抽出されなかったというわけです。

JOINを使って明示的な内部結合で次のようにプログラムしてもこの結果はかわりません。やはり、On で示す結合条件でこの3種類は対応するものがないからです。

VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim animals = GetAnimals()
Dim mokus = GetMokus()

'LINQで処理を定義
Dim results = From animal In animals
              Join moku In mokus
              On animal.MokuName Equals moku.MokuName
              Select animal.AnimalName, animal.MokuName, moku.KoName

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine($"{result.AnimalName}  {result.MokuName} {result.KoName}")
    'Debug.WriteLine(result.AnimalName & " は " & result.MokuName & " " & result.KoName) '←VB2013以前の場合
Next

 

4-2.限定的に使用できる外部結合

Group Join を使うと対応するものがない要素も結果に含めることができます。

VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim animals = GetAnimals()
Dim mokus = GetMokus()

'LINQで処理を定義
Dim results = From animal In animals
              Group Join moku In mokus
              On animal.MokuName Equals moku.MokuName Into Group
              Select animal.AnimalName, animal.MokuName, Group.FirstOrDefault?.KoName


'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine($"{result.AnimalName}  {result.MokuName} {result.KoName}")
    'Debug.WriteLine(result.AnimalName & " は " & result.MokuName & " " & result.KoName) '←VB2013以前の場合
Next

これの結果は次の通り出力されます。

ボノボ は 霊長目 哺乳綱
サイガ は 偶蹄目 哺乳綱
トクソドン は 南蹄目 哺乳綱
アオダイショウ は 有隣目 爬虫綱
ホライモリ は 有尾目 両生綱
アンフューマ は 有尾目 両生綱
アシナシイモリ は 無足目 両生綱
ヨタカ は ヨタカ目
ラブカ は カグラザメ目
マグロ は スズキ目

Group Join はデータソースと直接結合させるのではなく、相手のデータソースをグループ化したものと結合します。グループが空の場合でも空のグループと結合が行われるため、相手がいない要素でも結果に抽出されます。

このように相手がないない要素に対しても結合処理を行う結合操作を外部結合と呼びます。

ただ、ここで紹介した例は、常に通用するわけではありません。この後、この例の問題点と別の解決方法を説明します。

 

4-3.FirstOrDefault?.

その前に、このプログラムのもう1つポイントを説明しておきます。Selectの最後の項目が Group.FirstOrDefault?.KoName となっている点です。

グループ結合なので、結合相手はグループ化されています。また、グループの中にいくつの要素があるか事前にわかりません。今回は要素が0個のグループと要素が1個のグループがありえますが、データソースの内容によってはもっとたくさん要素があるグループが生成される場合もあります。

そのため、グループの中の要素のどれか1つを取り出して KoName プロパティを見たい場合、Group(0).KoName のように記述することになります。Group.First.KoName はこれとまったく同じ意味です。

グループの要素が0個の場合 Group(0) でエラーになります。Group.First でも同じ意味なのでエラーです。

そこで登場するのが拡張メソッド FirstOrDefault です。この拡張メソッドは便利なもので、要素が1つ以上あれば先頭の要素を返し、要素が0個であれば、既定値(この場合は Nothing)を返します。つまり、要素が0個でもエラーになりません。

とはいえ、Group.FirstOrDefault.KoName でもエラーになります。要素が0個の場合、Nothing.KoName という意味になり、Nothingのプロパティは呼び出せないからです。あと一歩です。

最後に登場するのはNull条件演算子の ?. です。 この演算子は通常の . の代わりに使用すると、値がNothing の場合にエラーにする代わりにNothingを返すようにします。

というわけで Group.FirstOrDefault?.KoName と書いておけば、エラーにはならず、グループの要素が0個の場合にはNothingを返すことになります。

 

4-4.汎用的な外部結合

メモ メモ  - LEFT JOIN

SQL で言うところの LEFT JOIN, LEFT OUTER JOINという操作に該当します。

ここからもう1段階複雑な説明をします。

次に紹介する例は汎用的に外部結合を行うことができます。

VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim animals = GetAnimals()
Dim mokus = GetMokus()

'LINQで処理を定義
Dim results = From animal In animals
              Group Join moku In mokus
              On animal.MokuName Equals moku.MokuName Into Group
              From moku In Group.DefaultIfEmpty
              Select animal.AnimalName, animal.MokuName, moku?.KoName

'クエリを実行して結果を表示
For Each result In results
    Debug.WriteLine($"{result.AnimalName}  {result.MokuName} {result.KoName}")
    'Debug.WriteLine(result.AnimalName & " は " & result.MokuName & " " & result.KoName) '←VB2013以前の場合
Next

このクエリ式のポイントは、Group Join で結合した後に、もう1度 データソースが Group である From を実行している点です。(話を単純にするために DefaultIfEmptyのことは後で説明します。)

From が2つあると何が起こるのか思い出してください。前述したようにデカルト結合が発生します。

つまり、Group Join で生成されたコレクションに対し、それぞれのGroupの要素の全組み合わせが生成されるのです。全組み合わせといっても Group が指定されているのでグループごとに全組み合わせが生成されます。

また、この例では各グループには要素が0個か1個しかないので、全部を組み合わせても0倍になるか、1倍になるかです。

0倍になるということは相手がいない要素が抽出されなくなり、実質的に内部結合になってしまうので、DefaultIfEmptyメソッドを使って、空のグループの場合、既定値(この場合はNothing) が1個だけ含まれる要素を生成し、それとデカルト結合するようにします。だから、この例ではデカルト結合の相手は必ず1つの要素があります。(その要素はNothingの場合もあります。)

 

複雑すぎて何を言っているのかわからないかもしれませんね。もうちょっとシンプルなケースでお伝えしてみます。

最初に紹介した外部結合の例と、汎用的な外部結合の例とでは、グループ化した後の要素が2個以上ある場合に差ができます。

次のサンプルで実際に結果が違うことを確認してみましょう。

VB2010 VB2012 VB2013 VB2015 VB2017 VB2019

'データソース作成
Dim prefs = {"東京都", "千葉県", "埼玉県"}
Dim cities = {New With {.Pref = "東京都", .CityName = "新宿"},
              New With {.Pref = "千葉県", .CityName = "船橋"},
              New With {.Pref = "千葉県", .CityName = "木更津"},
              New With {.Pref = "埼玉県", .CityName = "川口"}}


'LINQで処理を定義
'このLINQの結果は4件になります。
Dim results1 = From pref In prefs
               Group Join city In cities On pref Equals city.Pref Into Group
               From city In Group.DefaultIfEmpty
               Select pref, city?.CityName

'このLINQの結果は3件になります。千葉県は船橋のみが当選します。(FirstOrDefault)だから)
Dim results2 = From pref In prefs
               Group Join city In cities On pref Equals city.Pref Into Group
               Select pref, Group.FirstOrDefault?.CityName

'クエリを実行して結果を表示
Debug.WriteLine("■1つめのLINQの結果")
For Each result In results1
    Debug.WriteLine(result.pref & result.CityName)
Next

Debug.WriteLine("■2つめのLINQの結果")
For Each result In results2
    Debug.WriteLine(result.pref & result.CityName)
Next

1つ目のLINQの結果は次の通りになります。

prefプロパティ CityNameプロパティ
東京都 新宿
千葉県 船橋
千葉県 木更津
埼玉県 川口

2つ目のLINQの結果は次の通りになります。

prefプロパティ CityNameプロパティ
東京都 新宿
千葉県 船橋
埼玉県 川口

千葉県木更津はどこに行ってしまったかというと、Group化した後、FirstOrDefault を Select したので、抽出されなかったというわけです。

FirstOrDefaultではなく、LastOrDefault を使用していると木更津の方が生き残って船橋が消えます。

1つ目のLINQの方はFirstOrDefaultもLastOrDefault もなく、デカルト結合しているので、すべての結果が生き残ります。