VB.NET の LINQ とコレクション(.NET 10 で実行)

VB.NET の LINQ を .NET 10 で実行。Group By … Into・Aggregate とメソッド構文、遅延実行の罠、匿名型の Key による等価性、XML リテラル、List と HashSet の検索速度を実測。

シリーズ:VB.NET 講座 4/6(中級)。 対象は、コレクションを For ループで手作業で回していて、LINQ を安全に使いたい VB 開発者と、既存システムで VB のクエリ構文に出会った C# 開発者です。読み終えると、両方の構文でクエリを書き、用途に合ったコレクションを選び、遅延実行と匿名型の等価性によるバグを避けられるようになります。

業務システムの VB.NET を保守していると、LINQ はたいていすでに入っています。誰も触りたがらない Aggregate の1行、C# とは見た目がまったく違う Group By … Into、設定ファイルや帳票データを組み立てる XML リテラル。VB の LINQ は C# のキーワードを置き換えただけのものではありません。C# にない句があり、匿名型の等価性がキーワード1つで変わり、他の .NET 言語にはない XML リテラルがあります。この講座ではそれらをすべて .NET 10 で実行し、実際の出力、大文字小文字を区別しない VB ならではのコンパイルエラー、そして LINQ と For ループ、List と HashSet の実測を載せます。

環境: .NET SDK 10.0.403、ランタイム 10.0.14、VB 言語バージョン 17.13(SDK の既定値)、Apple Silicon Mac(M1 Pro)。すべてのファイルは Option Strict On で始まります。時間計測は Release ビルドです。

List・Dictionary・HashSet

以下のプログラムは、それぞれ dotnet new console -lang VB で作ったプロジェクトの Program.vb 1ファイル分です。最初のプログラムは小さな受注リストを使います。

Option Strict On

Public Class Order
    Public Property Id As Integer
    Public Property Customer As String
    Public Property City As String
    Public Property Amount As Decimal
End Class

Module Program
    Sub Main()
        Dim orders As New List(Of Order) From {
            New Order With {.Id = 1, .Customer = "Aoki", .City = "Osaka", .Amount = 1200D},
            New Order With {.Id = 2, .Customer = "Baek", .City = "Busan", .Amount = 800D},
            New Order With {.Id = 3, .Customer = "Aoki", .City = "Osaka", .Amount = 450D},
            New Order With {.Id = 4, .Customer = "Chen", .City = "Busan", .Amount = 3000D},
            New Order With {.Id = 5, .Customer = "Davis", .City = "Austin", .Amount = 150D}
        }

        Console.WriteLine("1) Dictionary / HashSet")
        Dim byId = orders.ToDictionary(Function(o) o.Id)
        Dim found As Order = Nothing
        If byId.TryGetValue(4, found) Then Console.WriteLine($"  id 4 -> {found.Customer}")
        Dim cities As New HashSet(Of String)(orders.Select(Function(o) o.City))
        Console.WriteLine($"  cities: {String.Join(", ", cities)}  Add(""Osaka"")={cities.Add("Osaka")}")

(プログラムは次の節に続きます。ブロックを順につなげると1ファイルになります。)

1) Dictionary / HashSet
  id 4 -> Chen
  cities: Osaka, Busan, Austin  Add("Osaka")=False

HashSet.Add は重複のとき例外ではなく False を返すので、「もう見たか?」の判定に安く使えます。Dictionary は逆で、存在しないキーをインデクサーで読むと例外になります。

Unhandled exception. System.Collections.Generic.KeyNotFoundException: The given key 'banana' was not present in the dictionary.
   at System.Collections.Generic.Dictionary`2.get_Item(TKey key)
   at e3.Program.Main() in ~/demo/e3/Program.vb:line 5

キーがないかもしれないときは TryGetValue を使います。Dim found As Order = Nothing の = Nothing にも理由があります。VB には out 引数がないので、初期化しないと次の警告が出ます。

warning BC42030: 変数 'found' は、値が割り当てられる前に参照渡しによって渡されています。Null 参照の例外が実行時に発生する可能性があります。
判断図。値があるかどうかを主に調べる、または重複を許さないなら HashSet(Of T)。キーで値を引くなら Dictionary(Of K, V) と TryGetValue。順序・インデックス・重複が必要なら List(Of T)。
いちばん多く投げる問いでコレクションを選ぶ。

クエリ構文とメソッド構文

VB のクエリ構文は句が大文字始まりで、C# と違って Select を省略したり、複数のフィールドを直接並べたりできます。

        Console.WriteLine("2) Query syntax")
        Dim big = From o In orders
                  Where o.Amount >= 500D
                  Order By o.Amount Descending
                  Select o.Customer, o.Amount
        For Each r In big
            Console.WriteLine($"  {r.Customer,-6}{r.Amount,8:N0}")
        Next

        Console.WriteLine("3) Method syntax, same result")
        Dim big2 = orders.Where(Function(o) o.Amount >= 500D).
                          OrderByDescending(Function(o) o.Amount).
                          Select(Function(o) New With {o.Customer, o.Amount})
        Console.WriteLine("  " & String.Join(" | ", big2.Select(Function(r) $"{r.Customer} {r.Amount:N0}")))
2) Query syntax
  Chen     3,000
  Aoki     1,200
  Baek       800
3) Method syntax, same result
  Chen 3,000 | Aoki 1,200 | Baek 800

Select o.Customer, o.Amount は New With なしで匿名型を作ります。C# なら select new { o.Customer, o.Amount } です。メソッド構文の各行末の . は、_ を使わずに次の行へ文を続けるためのものです。

Group By … Into と Aggregate

C# との違いが一番大きいのがこの2つです。Group By … Into は集計をその場で名前付きで書けるので、ループで回す IGrouping ではなく平らな行が得られます。Aggregate … Into はその場で実行され、値そのものを返します。

        Console.WriteLine("4) Group By ... Into")
        Dim perCity = From o In orders
                      Group By o.City Into Total = Sum(o.Amount), Cnt = Count()
                      Order By Total Descending
        For Each g In perCity
            Console.WriteLine($"  {g.City,-7}{g.Cnt} orders {g.Total,8:N0}")
        Next

        Console.WriteLine("5) Aggregate")
        Dim grandTotal = Aggregate o In orders Into Sum(o.Amount)
        Dim stats = Aggregate o In orders Where o.City = "Osaka" Into Cnt = Count(), Avg = Average(o.Amount)
        Console.WriteLine($"  total={grandTotal:N0}  Osaka count={stats.Cnt} avg={stats.Avg:N0}")
4) Group By ... Into
  Busan  2 orders    3,800
  Osaka  2 orders    1,650
  Austin 1 orders      150
5) Aggregate
  total=5,600  Osaka count=2 avg=825

集計が1つなら grandTotal はただの Decimal、2つ以上なら stats は Cnt と Avg を持つ匿名型です。SQL の GROUP BY に慣れた人には、C# の group … by … into g よりこちらのほうが自然に読めるはずです。

図。上段は遅延実行のパイプライン。From o In orders がソース、Where と Order By は Where・OrderBy メソッドになり、Select でクエリが終わるが、列挙するまで何も動かない。その代わりに Group By … Into を使うと GroupBy になり、Into に書いた集計が付く。下段:Aggregate o In orders Into Sum(o.Amount) はその場で実行され Decimal を返す。
VB のクエリ句と、変換先のメソッド。From で始まるクエリは遅延実行、Aggregate … Into はその場で値を返す。

よくある間違い:範囲変数とローカル変数の名前衝突

このプログラムの最初の版では、総合計の変数名を grandTotal ではなく total にしていました。これがコンパイルできませんでした。

Program.vb(45,44): error BC30978: 範囲変数 'Total' により、それを囲むブロック内の変数、または以前にクエリ式で定義された範囲変数が隠ぺいされます。

エラー位置は Group By の中の Total = Sum(o.Amount) ですが、原因は6行後ろの Dim total です。VB は大文字と小文字を区別しないので Total と total は同じ名前で、ローカル変数のスコープは宣言行より前も含めたブロック全体です。Total と total を別の名前として使い分ける習慣のある C# 経験者がよくはまります。対策: どちらかの名前を変えます。ここでは grandTotal にしました。

ラムダ:1行と複数行

Function(x) 式 は値を返し、Sub(x) 文 は返しません。複数行のラムダは End Function / End Sub で閉じます。

        Console.WriteLine("6) Multi-line lambda")
        Dim label As Func(Of Order, String) =
            Function(o)
                If o.Amount >= 1000D Then Return "big"
                If o.Amount >= 500D Then Return "mid"
                Return "small"
            End Function
        Console.WriteLine("  " & String.Join(", ", orders.Select(Function(o) $"{o.Id}:{label(o)}")))
    End Sub
End Module
6) Multi-line lambda
  1:big, 2:mid, 3:small, 4:big, 5:small

VB 特有の罠が1つあります。ラムダの中で式として書いた = は比較です。Function(o) o.Amount = 0 は Boolean を返し、代入はしません。

遅延実行・匿名型の Key・XML リテラル

2つ目のプログラムは、要約記事では警告されない3つの挙動を扱います。

Option Strict On
Imports System.Xml.Linq

Module Program
    Sub Main()
        Console.WriteLine("1) Deferred execution")
        Dim src As New List(Of Integer) From {1, 2, 3}
        Dim evens = From n In src Where n Mod 2 = 0 Select n
        src.Add(4)
        Console.WriteLine("  evens: " & String.Join(" ", evens))
        Dim calls = 0
        Dim q = src.Where(Function(n)
                              calls += 1
                              Return n > 0
                          End Function)
        Dim c1 = q.Count(), c2 = q.Count()
        Console.WriteLine($"  predicate calls after Count() x2: {calls}")
        Dim fixedList = q.ToList()
        src.Add(5)
        Console.WriteLine($"  ToList count={fixedList.Count}, live query count={q.Count()}")

        Console.WriteLine("2) Anonymous types and Key")
        Dim a1 = New With {.Name = "Aoki", .Age = 30}
        Dim a2 = New With {.Name = "Aoki", .Age = 30}
        Console.WriteLine($"  no Key  : Equals={a1.Equals(a2)}")
        Dim k1 = New With {Key .Name = "Aoki", .Age = 30}
        Dim k2 = New With {Key .Name = "Aoki", .Age = 99}
        Console.WriteLine($"  Key Name: Equals={k1.Equals(k2)} (Age differs: {k1.Age} vs {k2.Age})")
        k1.Age = 31
        Console.WriteLine($"  non-Key field is mutable: k1.Age={k1.Age}")
        Dim people = {New With {.Name = "Aoki", .Age = 30}, New With {.Name = "Aoki", .Age = 30}}
        Console.WriteLine($"  Distinct without Key: {people.Distinct().Count()}")
        Dim people2 = {New With {Key .Name = "Aoki", Key .Age = 30}, New With {Key .Name = "Aoki", Key .Age = 30}}
        Console.WriteLine($"  Distinct with Key   : {people2.Distinct().Count()}")
        Dim proj = From p In {("Aoki", 30), ("Aoki", 30)} Select p.Item1, p.Item2
        Console.WriteLine($"  query Select (auto Key): {proj.Distinct().Count()}")

        Console.WriteLine("3) XML literals + LINQ to XML")
        Dim items = {("Keyboard", 4800D), ("Mouse", 2200D), ("Monitor", 32000D)}
        Dim doc = <catalog version="1">
                      <%= From it In items
                          Where it.Item2 < 10000D
                          Select <item price=<%= it.Item2 %>><%= it.Item1 %></item> %>
                  </catalog>
        Console.WriteLine(doc.ToString())
        Console.WriteLine($"  first item: {doc.<item>.First().Value}, price attr: {doc.<item>(0).@price}")
        Dim total = Aggregate x In doc...<item> Into Sum(CDec(x.@price))
        Console.WriteLine($"  sum via axis: {total}")
        Console.WriteLine($"  type: {doc.GetType().FullName}, runtime: {Environment.Version}")
    End Sub
End Module
1) Deferred execution
  evens: 2 4
  predicate calls after Count() x2: 8
  ToList count=4, live query count=5
2) Anonymous types and Key
  no Key  : Equals=False
  Key Name: Equals=True (Age differs: 30 vs 99)
  non-Key field is mutable: k1.Age=31
  Distinct without Key: 2
  Distinct with Key   : 1
  query Select (auto Key): 1
3) XML literals + LINQ to XML
<catalog version="1">
  <item price="4800">Keyboard</item>
  <item price="2200">Mouse</item>
</catalog>
  first item: Keyboard, price attr: 4800
  sum via axis: 7000
  type: System.Xml.Linq.XElement, runtime: 10.0.14

遅延実行。 evens を定義した時点のリストは 1, 2, 3 でしたが、表示は 2 4 でした。クエリは String.Join が読んだときに実行されたからです。Count() を2回呼ぶと述語は8回(4件×2)動きました。ToList() は結果を固定します。5 を追加した後も固定したリストは4件、生きているクエリは5件を数えました。よくある不具合は、メソッドの先頭で一度クエリを定義し、その下でソースを変更し、「集計した時点」にはなかった行が帳票に紛れ込む、というものです。Aggregate は値を返してその場で実行されるので、例外です。

Key。 ここが VB だけの部分です。C# の匿名型は常に全プロパティで比較します。VB で New With で作った匿名型は Key を付けたプロパティだけ を比較し、Key が1つもなければ参照の比較になります。だから見た目が同じ2つのオブジェクトが等しくならず、Distinct() が両方を残しました。Key .Name にすると、Age が違う2つが等しくなりました。Age は無視されるからです。Key プロパティは読み取り専用にもなり、k1.Name = "Baek" は BC30526: プロパティ 'Name' は 'ReadOnly' です。 で失敗します。クエリの Select 句が作る匿名型は自動的に全部 Key になるので、proj.Distinct() は1を返しました。

XML リテラル。 VB ではコードの中に XML をそのまま書き、<%= %> で式やクエリを埋め込み、軸プロパティで読めます。.<item> が子要素、.@price が属性、...<item> が子孫要素です。結果は System.Xml.Linq の普通の XElement で、.NET 10 のコンソールテンプレートは最初から参照しています。この機能は VB 9(2008年)からあり、.NET 10.0.14 でも追加パッケージなしでコンパイル・実行できました。

実測:LINQ と For、List と HashSet

偶数の合計

乱数の int 1,000万個(New Random(42)、値は0〜999)から偶数を合計します。各方式を別プロセスで実行し(dotnet run -c Release -- for)、ウォームアップ10回の後、7回の中央値を取りました。

Option Strict On
Imports System.Diagnostics

Module Program
    ' usage: dotnet run -c Release -- for|forSorted|where|aggregate
    Sub Main(args As String())
        Dim rnd As New Random(42)
        Dim a(9_999_999) As Integer
        For i = 0 To a.Length - 1 : a(i) = rnd.Next(1000) : Next
        If args(0) = "forSorted" Then Array.Sort(a)

        Dim f As Func(Of Long)
        Select Case args(0)
            Case "for", "forSorted"
                f = Function()
                        Dim t As Long
                        For i = 0 To a.Length - 1
                            If a(i) Mod 2 = 0 Then t += a(i)
                        Next
                        Return t
                    End Function
            Case "where"
                f = Function() a.Where(Function(x) x Mod 2 = 0).Sum(Function(x) CLng(x))
            Case Else
                f = Function() Aggregate x In a Where x Mod 2 = 0 Into Sum(CLng(x))
        End Select

        For w = 1 To 10 : f() : Next                      ' warm-up (tiered JIT)
        Dim times As New List(Of Double)
        Dim result As Long
        For run = 1 To 7
            Dim sw = Stopwatch.StartNew()
            result = f()
            times.Add(sw.Elapsed.TotalMilliseconds)
        Next
        times.Sort()
        Console.WriteLine($"{args(0),-10} median {times(3),6:F1} ms  min {times(0),6:F1}  max {times(6),6:F1}  result {result}")
    End Sub
End Module
方式 プロセス1 プロセス2 プロセス3
For ループ・乱数データ 36.3 ms 36.1 ms 34.9 ms
For ループ・同じデータを並べ替え 8.4 ms 8.4 ms 8.3 ms
Where(…).Sum(…) 46.4 ms 45.6 ms 43.8 ms
Aggregate … Into Sum(…) 45.3 ms 45.4 ms 43.8 ms

4つとも結果は 2,494,710,708 で一致しました。

棒グラフ。乱数の配列を For ループで回すと36.1ミリ秒。同じ For ループを並べ替えた配列で回すと8.4ミリ秒。Where と Sum は45.6ミリ秒、VB の Aggregate クエリは45.3ミリ秒。4つとも合計値は同じ。
乱数 int 1,000万個(シード42)の偶数の合計。各方式を別プロセスで実行し、ウォームアップ10回の後7回の中央値を取り、3プロセスの真ん中の値。Release、.NET 10.0.14、Apple Silicon Mac(M1 Pro)。

分かったことは3つです。Aggregate と Where+Sum は同じコストで、VB のクエリ構文そのものにオーバーヘッドはありません。LINQ はループより約25%遅いだけで、「何倍も」ではありませんでした。そして一番大きく効いたのは LINQ ではなくデータでした。同じデータを並べ替えるだけで単純なループが4倍速くなりました。偶数・奇数の分岐が CPU にとって予測しやすくなったためです。ループが遅いとき、LINQ を疑う前にデータを見てください。

自分たちの最初の失敗も書いておきます。全方式を1つのプロセスで順に測ったときは、どれを先に動かしたかで LINQ の数値が45〜89ミリ秒の間で大きく揺れました。JIT のプロファイルに基づく最適化が、共有の LINQ コードを最初に見たラムダ向けに特化したためと推測していますが、深追いはしていません。方式ごとにプロセスを分けると揺れは消えました。

List.Contains と HashSet.Contains

偶数を100個・1万個・10万個入れたコレクションに対して1万回の検索(約半分がヒット)。最初の1周は tiered JIT のために捨て、その後ウォームアップ20回、残り5回の最良値です。

Option Strict On
Imports System.Diagnostics

Module Program
    Function CountHits(probes As Integer(), contains As Func(Of Integer, Boolean)) As Integer
        Dim hits = 0
        For Each p In probes
            If contains(p) Then hits += 1
        Next
        Return hits
    End Function

    Sub Main()
        For pass = 1 To 2                         ' pass 1 only lets tiered JIT finish
            Dim rnd As New Random(42)
            For Each size In {100, 10_000, 100_000}
                Dim list = Enumerable.Range(0, size).Select(Function(i) i * 2).ToList()
                Dim hs As New HashSet(Of Integer)(list)
                Dim probes = Enumerable.Range(0, 10_000).Select(Function(i) rnd.Next(size * 2)).ToArray()
                Dim inList As Func(Of Integer, Boolean) = AddressOf list.Contains
                Dim inSet As Func(Of Integer, Boolean) = AddressOf hs.Contains
                Dim bestList = Double.MaxValue, bestSet = Double.MaxValue, hits = 0
                For run = 1 To 25                 ' 20 warm-up runs, best of the last 5
                    Dim sw = Stopwatch.StartNew()
                    hits = CountHits(probes, inList)
                    If run > 20 Then bestList = Math.Min(bestList, sw.Elapsed.TotalMilliseconds)
                    sw.Restart()
                    If CountHits(probes, inSet) <> hits Then Throw New Exception("mismatch")
                    If run > 20 Then bestSet = Math.Min(bestSet, sw.Elapsed.TotalMilliseconds)
                Next
                If pass = 2 Then Console.WriteLine($"n={size,7:N0}: List.Contains {bestList,8:F3} ms | HashSet.Contains {bestSet,6:F3} ms | hits {hits}")
            Next
        Next
    End Sub
End Module
n=    100: List.Contains    0.126 ms | HashSet.Contains  0.089 ms | hits 5045
n= 10,000: List.Contains    6.768 ms | HashSet.Contains  0.099 ms | hits 5041
n=100,000: List.Contains   66.957 ms | HashSet.Contains  0.118 ms | hits 5025

List.Contains は要素数に比例して伸び(要素×100 → 時間×100)、HashSet.Contains はほぼ一定でした。要素100個なら差を気にする必要はありません。1周目を捨てる処理は飾りではありません。これがない最初の版では、n=100 の HashSet が0.43ミリ秒と List より遅く出ました。.NET はまず素早くコンパイルした tier-0 のコードで動き、メソッドがしばらく頻繁に呼ばれてから裏で最適化版に差し替えるからです。マイクロベンチマークの最初の行だけ妙な値が出たら、たいていこれが原因です。

正直な限界とバージョンの注記

  • .NET SDK 10.0.403 / ランタイム 10.0.14、VB 言語バージョン 17.13 でコンパイル・実行しました。2つ目のプログラムを古い -p:LangVersion で再コンパイルすると、9 では BC36716(複数行ラムダ、コレクション初期化子、暗黙の行継続、補間文字列、タプル)で失敗しましたが、XML リテラルはエラー一覧に入っていなかったので VB 9 でコンパイルできます。タプル ("Aoki", 30) には 15 が必要です。
  • VB には新しい言語機能が追加されないため、C# の新しい構文(コレクション式など)は VB にはありません。一方ライブラリは別で、.NET 10 で System.Linq に追加されたメソッドは VB からも普通に呼べます。
  • 計測は Stopwatch の手書きで、BenchmarkDotNet ではありません(追加の NuGet パッケージなし)。ばらつきが見えるよう、プロセスごとの値をすべて載せました。
  • 扱ったのは LINQ to Objects だけです。データベース向けの LINQ(IQueryable)も遅延実行は同じですが、列挙のたびにサーバーへクエリが飛ぶので、上の罠のコストはもっと大きくなります。

C# 側の同じ話題は C# LINQ の基本、C# LINQ の group by と join、C# の匿名型、C# のラムダ式を参照してください。

前回: VB.NET 講座 3:クラス・継承・インターフェイス · 次回: VB.NET 講座 5:Async と Await

よくある質問

VB.NET の匿名型の Key キーワードは何をしますか?

Equals と GetHashCode に参加するのは Key を付けたプロパティだけになり、そのプロパティは読み取り専用になります。New With {.Name = "Aoki", .Age = 30} を2つ作っても等しくならず、Distinct は両方残します。Key を付けると等しくなり、Distinct は1件を返します。クエリの Select 句で作られる匿名型は自動的に全部 Key になります。

VB.NET の XML リテラルは .NET 10 でも使えますか?

使えます。<%= %> でクエリを埋め込んだ XML リテラルと、.<子>、.@属性、...<子孫> の軸プロパティを .NET 10.0.14 でコンパイル・実行しました。結果は普通の System.Xml.Linq.XElement です。C# には同等の構文がありません。

HashSet.Contains は List.Contains よりどれくらい速いですか?

要素数次第です。1万回の検索で、要素100個の List は0.13ミリ秒、HashSet は0.09ミリ秒。要素10万個では List が約67ミリ秒、HashSet が約0.12ミリ秒でした(Release、.NET 10、M1 Pro)。List は先頭から順に探し、HashSet はハッシュで引きます。

  • #VB.NET
  • #.NET 10
  • #LINQ
  • #コレクション
  • #XML リテラル
  • #中級

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑