Excelシートのデータ範囲を2次元配列に格納する汎用関数を修正したので紹介します。
はじめに
本記事は、下記の記事の続編です。
公開後、以下のような指摘を頂きました。
(ブログに書くと、こうやって指摘してもらえるので、本当に助かります。)
m氏
ワークシート(+α) → Range Range → 2次元配列 の2つの関数に分離して ReadCell はその2つを組み合わせたシンタックスシュガーにした方が汎用的に思えるのですがいかがでしょうか?
関数を分離すると、メモリやCPUを消費するロジックしか思いつかなかったので妥協したのです。
結論を書いておきますと、良い方法が思いつかないので今回は見送りました。
無理に分割できないこともないですが、それほどメリットが見込めなかったので保留とします。
H氏
findを使うと、
・非表示セルは検索可
・オートフィルタによる非表示セルは検索不可
完全に見落としてました。
参考にした記事 でもはっきりと「フィルタをかけている:含まない」とありました。
そうなると、如何にして最終行・最終列を求めるかが腕の見せ所ですね!
今回はこちらの問題について対策を考えてみました。
Findの挙動について調べてみた
まず、Findはオートフィルタで非表示になっているセルが無視されてしまう、という事は確認できました。
そこまでは知られている事実なので、いまさら証明しなくても良いと思います。
そこで気になったのが、末尾の空白行はどうなるのかという点。
一応データは隠れている部分に存在するので、E5になるのか。(Select出来なくて仕方なく直前のセルを返すイメージ)
それとも可視データの末尾であるE3になるのか。(探索すらしていないイメージ)
検証データ
分かりづらいですが、1~5行目が表示状態。6~7行目がフィルタで非表示状態です。
A | B | C | D | E | |
---|---|---|---|---|---|
1 | データ | データ | データ | データ | データ |
2 | データ | データ | データ | データ | データ |
3 | データ | データ | データ | データ | データ |
4 | |||||
5 | |||||
6 | 非表示 | 非表示 | 非表示 | 非表示 | 非表示 |
7 | 非表示 | 非表示 | 非表示 | 非表示 | 非表示データ |
検証結果
E3
というわけで、6,7行目は「存在しない」ものとして扱われていることが分かりました。
もう一つ気になったのが、「フィルタされた行を横断した場合に検索順序はどうなっているのか」という点。
問題の式
WS.Find("*", , xlFormulas, , xlByColumns, xlPrevious).Column
は、最終列(右端)で右下→右上、1列左に移動して下→上、1列左に移動して...という順で検索しますが、Rangeの性質を鑑みるにフィルタすると挙動が変わる可能性を考えました。
検証結果
結論としては、挙動が変わります。
文章で説明しても分かりづらいので図にしました。
まず、通常時の挙動ですが、xlByColumns, xlPrevious
という指示に従いこのような順番で検索しています。
次にフィルタ中の挙動ですが、このように非表示の部分で領域が分割されて検索されているような動きをしました。
もともとRangeオブジェクトは、複数の領域を一度に保持することのできる特殊な概念で出来ているオブジェクトです。
本件ではRange("A1:E10")
が、Range("A1:E5,A9:E10")
に変化してからFindで検索がされているのでしょう。
仮に内部の動きをVBAで表現するとしたらこんな感じですね。
'Rangeの順番を逆順に出力 Function Preview_RangeAddress_Prev() Dim rngArea As Range Set rngArea = Range("A1:E10").SpecialCells(xlCellTypeVisible) Dim rng As Range, col As Range, row As Range Dim i As Long, j As Long, k As Long For k = rngArea.Areas.Count To 1 Step -1 Set rng = rngArea.Areas(k) For j = rng.Columns.Count To 1 Step -1 Set col = rng.Columns(j) For i = col.Rows.Count To 1 Step -1 Set row = col.Rows(i) Debug.Print row.Address(False, False) Next Next Next End Function
対策を考えてみる
Findが使えないということはよく分かりましたので、フィルタ中に最終行・最終列を求めるには、どんな方法で探索するのが良いかなーと考えてみました。
頑張ってググったら何か出てきそうな気がしますが、FindとEndの勢力が強すぎて中々ヒットしない予感がするので、自力で考えることにしました。
他に良さそうなアイディアがあれば是非教えてください!
For For Ifでガリガリ探索
この方法は、セルに一つづつアクセスしているため、探索対象のセル数が増えれば増えるほど低速になる危険性があります。
かと言ってUsedRange全体を2次元配列に取り込むと、2次元配列に取り込む範囲を最小限にしたいのに「鶏が先か、卵が先か問題」が発生してしまいます。
すでに指摘されているように、メモリさえ大丈夫なら、2次元配列に取り込んでから、指定領域だけを抽出した2次元配列を再構築すればもう少し高速に処理できるかもしれません。
'直接セルにアクセスしてガリガリ検索 Function GetFinalRowCol_RangeFull(WS As Worksheet) As Long() ReDim ret(1 To 2) As Long Dim i As Long, j As Long With WS.UsedRange '最終行:Cellsは第一引数しか指定しない場合、コレクションのように動作する。 For i = .Count To 1 Step -1 If Not IsEmpty(.Cells(i).Value) Then ret(1) = .Cells(i).Row Exit For End If Next '最終列 For j = .Columns.Count To 1 Step -1 For i = .Rows.Count To 1 Step -1 If Not IsEmpty(.Cells(i, j).Value) Then '=j は 先頭列からデータが始まらない時に間違い ret(2) = .Cells(1, j).Column GoTo ExitForCol End If Next Next ExitForCol: End With GetFinalRowCol_RangeFull = ret() End Function
CountAで探索
VBAには2次元配列を処理する便利な関数が無いのですが、いくつかの処理はExcelの関数を呼び出すことで代用できます。
ExcelのCOUNTA関数を使って、データが入っているか否かを行単位、列単位で一括で算出するようにしてみました。
たぶんコレが最有力候補でしょう。
'ret(1)=最終行 ret(2)=最終列を求める Function GetFinalRowCol_CountA(WS As Worksheet) As Long() ReDim ret(1 To 2) As Long Dim i As Long, j As Long With WS.UsedRange Dim items(1 To 2) As Variant Set items(1) = .Rows Set items(2) = .Columns 'CountAで行毎、列毎に検索 For j = 1 To 2 For i = items(j).Count To 1 Step -1 If WorksheetFunction.CountA(items(j)(i)) > 0 Then ret(j) = i Exit For End If Next Next '開始位置加算 If ret(1) > 0 Then ret(1) = ret(1) + .Row - 1 If ret(2) > 0 Then ret(2) = ret(2) + .Column - 1 End With GetFinalRowCol_CountA = ret() End Function
計測結果
それぞれの手法を千回~一万回繰り返して速度を計測したら、ちょっと思いがけない結果になりました。
前回の記事ではUsedRange
とCells
はUsedRange
のほうが高速と書きましたが、巨大なデータではCells
のほうが圧倒的に高速でした。
また、10000x20以上のデータにおいては、どの手法も同等という結果でした。
そしてデータが大きくなるほど、Cells.Findが圧倒的に優位になる。という思わぬ結果になりました。
Cells.Findはデータ量に関係なく一定の処理速度になりそうな気配ですね。
しかし下記の時間は千回、万回繰り返した結果であり、全データを読み込む処理を何度も繰り返すことはないハズなので、速度面についてはそこまで意識しなくても良いような気がします。
Findはフィルタされている部分を読み込めない
という問題があり、フィルタの付け直しの動作が保証できない
以上、CountAやガチでループさせてチェックさせるのが一番無難ではないかと考えます。
完成版
ソースコード
CountAを使った手法を、前回の関数に組み込んでこのようにしました。
Option Explicit 'API #If VBA7 Then Private Declare PtrSafe Function SafeArrayAllocDescriptor Lib "oleaut32" ( _ ByVal cDims As Long, _ ByRef ppsaOut() As Any _ ) As Long #Else Private Declare Function SafeArrayAllocDescriptor Lib "oleaut32" ( _ ByVal cDims As Long, _ ByRef ppsaOut() As Any _ ) As Long #End If 'Variant型二次元配列 Property Get EmptyVariantArray2() As Variant() Dim res As Long res = SafeArrayAllocDescriptor(2, EmptyVariantArray2) If res <> 0 Then Err.Raise 9999 End Property 'シート内のセルデータ読み込み必ず二次元配列を返す ' StartRow : 読み込み開始行(1~) ' StartCol : 読み込み開始列(1~) ' ExceptRow : 読み込み除外行(0~) 末端から任意の行数消す ' ExceptCol : 読み込み除外列(0~) 末端から任意の列数消す Public Function ReadCell(ByVal WS As Worksheet, _ Optional ByVal StartRow As Long = 1, _ Optional ByVal StartCol As Long = 1, _ Optional ByVal ExceptRow As Long = 0, _ Optional ByVal ExceptCol As Long = 0) As Variant '最終行列 = 領域の末尾からデータの存在するセルを検索 Dim FinalRow As Long, FinalCol As Long, FinalRowCol() As Long FinalRowCol = GetFinalRowCol(WS) FinalRow = FinalRowCol(1) FinalCol = FinalRowCol(2) '出力行列 = 最終 - 開始 + 1 - 除外 Dim OutRow As Long, OutCol As Long OutRow = FinalRow - StartRow + 1 - ExceptRow OutCol = FinalCol - StartCol + 1 - ExceptCol '該当セル無し:-1,-1を返す If OutRow <= 0 Or OutCol <= 0 Then ReadCell = EmptyVariantArray2() '該当セルが単一セル:2次元配列として返す ElseIf OutRow = 1 And OutCol = 1 Then ReDim RetVal(1 To 1, 1 To 1) RetVal(1, 1) = WS.Cells(StartRow, StartCol).Value ReadCell = RetVal '通常の範囲データ Else ReadCell = WS.Cells(StartRow, StartCol).Resize(OutRow, OutCol).Value End If End Function 'ret(1)=最終行 ret(2)=最終列を求める Public Function GetFinalRowCol(ByVal WS As Worksheet) As Long() ReDim ret(1 To 2) As Long Dim i As Long, j As Long With WS.UsedRange Dim items(1 To 2) As Variant Set items(1) = .Rows Set items(2) = .Columns 'CountAで行毎、列毎に検索 For j = 1 To 2 For i = items(j).Count To 1 Step -1 If WorksheetFunction.CountA(items(j)(i)) > 0 Then ret(j) = i Exit For End If Next Next '開始位置加算 If ret(1) > 0 Then ret(1) = ret(1) + .row - 1 If ret(2) > 0 Then ret(2) = ret(2) + .Column - 1 End With GetFinalRowCol = ret() End Function Sub Test_ReadCell() Dim V As Variant V = ReadCell(ActiveSheet) Debug.Print UBound(V, 1), UBound(V, 2) ActiveSheet.Cells(1, 1).Resize(UBound(V, 1), UBound(V, 2)).Select Stop End Sub
補足
- 関数の戻り値を
ReDim ret(1 To 2) As Long
としているのは私の好みです。(Uboundの次元数も1~nなので0 To 1より1 To 2のほうがわかりやすいと思います) - 今回も動作テストはまだこれからなのでなんとも言えません。
蛇足
フィルタを解除してからFindで探索
ここまで試したものの、フィルタが邪魔をするならばフィルタを解除すれば良いじゃないか!という事に気が付きました。
フィルタしたまま色々な処理を行うのはバグの元となるので、ある意味これが最適解かもしれません。
(特に致命的なものとして、2次元配列の書き出し時にバグります。)
ただし、フィルタを復元するのはデータが大きいとフィルタの再適用で、かなりの負荷が掛かりそうな気がします。
フィルタの復元まで考慮しないほうが良いのではないか、という気がします。
フィルタを復元するコードは、検索したら素晴らしいコードが出てきたので、こちらのクラスを使わせていただきます。
'clsFilterReconstructure コンストラクタ Function FilterReconstructure(WS As Worksheet) As clsFilterReconstructure 'オートフィルタの状態を取得・再現するオブジェクト Dim oFtrRcn As New clsFilterReconstructure ' init にオートフィルタがあるかもしれないシートを渡す Call oFtrRcn.init(WS) ' フィルタ条件の記憶 Call oFtrRcn.StoreCriterias Set FilterReconstructure = oFtrRcn End Function 'UsedRangeから逆順Find Function GetFinalRowCol_UsedRangeFind_Filter(WS As Worksheet) As Long() ReDim ret(1 To 2) As Long With FilterReconstructure(WS) .ShowAllData With WS.UsedRange ret(1) = .Find("*", , xlFormulas, , xlByRows, xlPrevious).row ret(2) = .Find("*", , xlFormulas, , xlByColumns, xlPrevious).Column End With .ReConstructFilter End With GetFinalRowCol_UsedRangeFind_Filter = ret() End Function
おわりに
今回は前の続きで
「Excelシートのデータ範囲を2次元配列に格納する汎用関数」
を作ってみました。
シート全体を2次元配列に取り込んで、処理をして、結果を書き出す。という流れはExcel VBAを使う上で最も重要な高速化方法です。
簡単な処理の場合は、この関数を使えば手軽に高速化できるので大変便利でしょう。
しかし、読み込んだデータの利用方法によっては、クラス化したりDictionaryにしたほうが便利な場面もあります。
ちょっとした処理なら、前ページのマイルール
の書き方で十分に事足ります。
うまく使い分けていきましょう。
以上
続編
今回作成したReadCell
関数で「非表示のセルを除外する(可視セルだけを二次元配列に格納する)」ことが出来るオプションを付け足しました。
もしよろしければこちらもご覧ください。
何か御座いましたらコメント欄、またはTwitterからどうぞ♪
それではまた来週♪ ちゅんちゅん(・8・)