ユーザー定義型(Type)の配列をソートする:クイックソートの実装と型安全性
開発現場でこんな絶望を味わったことはないだろうか。
「数万件のCSVデータを読み込んで構造体(ユーザー定義型)の配列に入れたはいいが、これを特定のキーで並び替える処理を入れた途端、Excelがフリーズしたかのように重くなった」
「VBAの標準機能にはオブジェクトのソートなんて気の利いたものはないし、`Collection`や`Dictionary`のキー操作は遅い、かといってワークシートに書き戻してソートするのはI/Oのオーバーヘッドが大きすぎて論外だ」
実務で巨大なデータを扱うツールを構築する際、メモリ上でいかに高速にデータをハンドリングするかは、エンジニアの腕の見せ所だ。特に、複数の属性を持つレコードデータを扱う場合、ユーザー定義型(Type)の配列をインメモリで高速にソートするアルゴリズムの選択は、ツールの生死を分ける。
今回は、VBAの限界を突破し、数万件のレコードであっても一瞬でソートを完了させる「クイックソート」の完全実装と、破綻のない型安全な設計思想を叩き込む。
—
なぜワークシートソートや単純なバブルソートではダメなのか?
業務自動化ツールを作る際、素人が最初に書くのは次のようなコードだ。
1. データをシートに書き出す
2. `Range.Sort` メソッドを使う
3. 配列に戻す
これ、実務では絶対にやってはいけないアンチパターンだ。
Excelのワークシートへのアクセス(I/O)は、VBAの内部メモリ操作に比べて圧倒的に重い。数千行程度ならごまかせても、データが1万件を超えたあたりから激しいスラッシング(性能低下)を引き起こす。
かといって、コードの簡単さだけに釣られて「バブルソート(隣接要素の比較・交換)」を実装するとどうなるか。計算量は $O(N^2)$。データが10倍になると処理時間は100倍になる。1万件のデータでバブルソートを回せば、コーヒーを飲みに行っても終わらない。
我々が求めるべきは、メモリ上で完結し、平均計算量 $O(N \log N)$ を誇るクイックソート(Quick Sort)の極限最適化である。
—
堅牢な設計:ユーザー定義型とモジュールのスコープ
VBAにおけるユーザー定義型(`Type`)は非常に強力だが、いくつかの厳格な制約がある。最大の弱点は、`Type`の配列をそのまま他のプロシージャへ「ByRef以外(値渡しなど)」で渡すことができない点、そしてモジュールを跨ぐ際のスコープ管理に癖がある点だ。
今回は、保守性とパフォーマンスを両立させるため、以下の設計方針をとる。
1. データ構造の定義: レコードを表現する `Type` を定義する。
2. 非再帰または安全な再帰処理: VBAのスタック溢れを防ぐため、標準的な再帰呼び出しによるクイックソートをスマートに実装する。
3. カプセル化: ソートロジックを独立したプライベートプロシージャに閉じ込め、メイン処理を汚さない。
—
プロダクションコード例:爆速クイックソートの実装
以下のコードは、そのままコピペして実務のツールに組み込めるプロダクションコードだ。ここでは「売上データ(日付、顧客名、金額)」の配列を、金額の降順(大きい順)、同額の場合は日付の昇順でソートする例を示す。
Option Explicit
‘ =================================================================
‘ 1. データ構造の定義(Public指定によりプロジェクト全体で共有可能)
‘ =================================================================
Public Type SalesRecord
TransactionDate As Date
CustomerName As String
Amount As Currency
End Type
‘ =================================================================
‘ 2. メイン処理(テスト実行用)
‘ =================================================================
Sub Main_RunQuickSort()
Dim data() As SalesRecord
Dim dataCount As Long
‘ テストデータの生成(実務ではファイル読み込みやDBから取得)
dataCount = 10000
ReDim data(1 To dataCount)
Dim i As Long
Randomize
For i = 1 To dataCount
data(i).TransactionDate = DateSerial(2023, 1, Int(Rnd 365) + 1)
data(i).CustomerName = “顧客_” & Format(Int(Rnd 1000) + 1, “0000”)
data(i).Amount = Int(Rnd 100000) + 1000
Next i
‘ 処理前パフォーマンス計測
Dim startTime As Double
startTime = Timer
‘ クイックソートの実行(配列の左端と右端を指定)
Call QuickSortSales(data, LBound(data), UBound(data))
Debug.Print “ソート完了: ” & (Timer – startTime) & ” 秒”
‘ 結果の検証(上位5件を出力)
For i = 1 To 5
Debug.Print i & “件目: ” & data(i).CustomerName & ” / ” & _
data(i).TransactionDate & ” / ” & data(i).Amount
Next i
End Sub
‘ =================================================================
‘ 3. クイックソート本体(再帰呼び出しによる高速ソート)
‘ =================================================================
Private Sub QuickSortSales(ByRef arr() As SalesRecord, ByVal low As Long, ByVal high As Long)
Dim i As Long, j As Long
Dim pivot As SalesRecord
Dim temp As SalesRecord
i = low
j = high
‘ 枢軸(ピボット)を中央の要素に設定
pivot = arr((low + high) \ 2)
Do While i <= j
' 比較ロジック: 金額の降順(大きいものを前へ)。同額なら日付の昇順。
' 戻り値が正の場合、左側の要素の方が「優先度が低い(後ろにあるべき)」と判定
Do While CompareSales(arr(i), pivot) < 0
i = i + 1
Loop
Do While CompareSales(arr(j), pivot) > 0
j = j – 1
Loop
If i <= j Then
' 要素の入れ替え(スワップ)
temp = arr(i)
arr(i) = arr(j)
arr(j) = temp
i = i + 1
j = j - 1
End If
Loop
' 再帰的に分割統治を適用
If low < j Then Call QuickSortSales(arr, low, j)
If i < high Then Call QuickSortSales(arr, i, high)
End Sub
' =================================================================
' 4. 比較関数(エンカプセル化された大小判定ロジック)
' =================================================================
' 戻り値:
' 負の値: a が b より「先」に来るべき場合
' 正の値: a が b より「後」に来るべき場合
' 0 : 等しい場合
Private Function CompareSales(ByRef a As SalesRecord, ByRef b As SalesRecord) As Long
' 第1優先キー: 金額の降順(大きい順)
If a.Amount > b.Amount Then
CompareSales = -1
Exit Function
ElseIf a.Amount < b.Amount Then
CompareSales = 1
Exit Function
End If
' 第2優先キー: 金額が同じ場合は日付の昇順(古い順)
If a.TransactionDate < b.TransactionDate Then
CompareSales = -1
Exit Function
ElseIf a.TransactionDate > b.TransactionDate Then
CompareSales = 1
Exit Function
End If
‘ 第3優先キー(必要に応じて文字列比較など)
If a.CustomerName < b.CustomerName Then
CompareSales = -1
Exit Function
ElseIf a.CustomerName > b.CustomerName Then
CompareSales = 1
Exit Function
End If
CompareSales = 0
End Function
—
コードの急所:なぜこの実装が「プロの仕事」なのか
1. 比較ロジックの完全分離(`CompareSales` 関数)
ソートアルゴリズムの中に直接 `If` 文を書き散らす開発者がいるが、あれは保守性の観点から最低の悪手だ。「やっぱり顧客名の昇順も入れたい」「いや、金額の昇順に変えてくれ」と言われたとき、アルゴリズム本体に手を加えると必ずバグる。
比較処理を完全に別関数(`CompareSales`)に切り出すことで、ビジネスロジック(並び順の定義)とデータ構造の整列アルゴリズムを完全に分離している。これが拡張性の高い堅牢な設計だ。
2. データ型の選定(`Currency` 型の活用)
金額を扱う際、安易に `Double` や `Single` を使うべきではない。浮動小数点演算の誤差は、ソートの比較において致命的なバグ(無限ループや順序崩れ)を生む原因になる。金銭や厳密な数値を扱う構造体では、内部的に整数として扱われる `Currency` 型を徹底すべきだ。
3. メモリの参照渡し(`ByRef`)による高速化
VBAにおいて、大きなユーザー定義型を値渡し(`ByVal`)しようとすると、メモリのコピーが発生しパフォーマンスが劇的に低下する。配列そのものも、要素の交換(スワップ)の際も、常に参照渡しと適切なスコープ管理を行うことで、1万件程度なら数ミリ秒でソートが完了する爆速性を実現している。
—
実務(ファイル・DB連携)における注意点
このクイックソートを実務のパイプラインに組み込む際、以下のトラップに注意してほしい。
- メモリリークと配列の初期化: 大量データを処理した後は、不要になった配列を `Erase data` で明示的にメモリから解放すること。これを怠ると、Excelがメモリを食いつぶし、他のアドインや処理を巻き込んで不安定になる。
- NULL値や空データのハンドリング: 外部のCSVやデータベースからデータを取得する際、文字列が長さ0の文字列(`””`)であったり、日付が無効値(`0`)であったりする場合がある。`CompareSales` 内でこれらが比較対象になると予期せぬエラーや誤った順序になるため、データを取り込んだ直後の段階でバリデーション(無害化)を挟むのがプロの鉄則だ。
まとめ
Excel VBAは「おもちゃの言語」などではない。メモリの構造を理解し、適切なアルゴリズムを実装すれば、C#やPythonの簡易スクリプトに匹敵するデータ処理スピードを叩き出すことができる。
今回紹介したユーザー定義型のクイックソートは、あなたの業務自動化ツールの処理速度を何倍にも引き上げる強力な武器となるはずだ。ぜひ、明日の開発現場でこの設計思想を取り入れてみてほしい。コードの美しさと実行速度の速さに、周囲の見る目が変わるはずだ。
