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 参照の例外が実行時に発生する可能性があります。
クエリ構文とメソッド構文
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 よりこちらのほうが自然に読めるはずです。
よくある間違い:範囲変数とローカル変数の名前衝突
このプログラムの最初の版では、総合計の変数名を 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 で一致しました。
分かったことは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 はハッシュで引きます。