VB6 和 VBA:创建指向相同数据的变量和数组
这是访问字符串(或其他)数据的一种非常快速的方法,内存消耗极小。
引言
这个想法源于我读了 Sam 关于将 `string` 解析为 JSON 对象的一篇精彩的文章。Sam 决定将 `string` 的字符复制到一个整数数组中,这样读取和解析数据会快得多。 我曾对我们获得的性能提升感到惊讶,尽管花费了复制数据的时间。
问题在于,当我们需要缓冲非常大的 `string` 时。在这种情况下,我们需要创建一个相同大小的数组,并将 `string` 数据复制到其中。这将消耗大量内存和复制数据的时间。
解决方案
我的想法是创建一个与 `string` 共享数据的数组。换句话说,数组的第一个整数就是 `string` 的第一个字符,改变其中任何一个都会改变另一个。
在这种情况下,我们只需要创建一个很小的数组(一个或两个元素),然后改变它的底层长度和数据,使其指向字符串的长度和字符。
背景
Visual Basic 6 和 Visual Basic for Applications 都是基于 COM 结构构建的,它们的所有数据类型都是 Automation 数据类型。因此,大多数变量都是 COM 结构的指针。
在这里,我们关注两种数据类型:字符串和数组。
1. 字符串
字符串以 Unicode 字符的形式保存在 BSTR 结构中。我们不需要了解 BSTR。要知道,我们可以通过著名的未文档化函数 ‘StrPtr ()
’ 来获取指向 `string` 字符序列的指针。
2. 数组
在这里,情况更复杂,因为我们需要改变数组的底层结构(长度和数据指针)。
数组保存在一个 `SAFEARRAY` 结构中,定义如下:
typedef struct tagSAFEARRAY {
USHORT cDims; //The number of dimensions.
USHORT Features; //Flags.
ULONG cbElements; //The size of a single element.
ULONG cLocks; //Number of locks.
PVOID pvData; //The data.
SAFEARRAYBOUND rgsabound[1]; //one bound for each dimension.
} SAFEARRAY, *LPSAFEARRAY
因此,当我们获得 safearray 指针时,我们需要改变数组的数据指针(`pvData`),它位于 `SAFEARRAY` 结构开头之后的 12 个字节处。
我们还需要更改保存在 ‘rgsabound
’ 成员中的数组大小,以反映 `string` 的大小。SAFEARRAYBOUND
结构定义如下:
typedef struct tagSAFEARRAYBOUND {
ULONG cElements; //The number of elements in the dimension.
ULONG lLbound; //The lower bound of the dimension.
} SAFEARRAYBOUND, *LPSAFEARRAYBOUND;
因此,我们需要将 `cElements`(位于 `SAFEARRAY` 结构开头之后的 16 个字节处)更改为反映我们数组所需的元素数量。
注意 1:如果您想更改数组的下界,也可以更改 `SAFEARRAY` 结构开头之后的 20 个字节处的 `lLbound`。
注意 2:如果您想创建多维数组,请小心。在这种情况下,每个维度都将有一个单独的 `SAFEARRAYBOUND` 元素存储在 `rgsabound` 中。
如何指向 SAFEARRAY 结构
通过直接使用 `VarPtr ()` 函数,可以轻松地在 VB6 或 VBA 中获取任何对象的指针。这适用于所有对象,除了数组!!如果您尝试将数组传递给该函数,将会收到错误。
为了解决这个问题,我们需要为 `VarPtr ()` 函数创建一个新的声明,该声明接受数组作为参数。这种方法的缺点是我们必须在更改 VB 版本时更改声明……下面的代码描述了如何做到这一点:
'For VB6 users:
Private Declare Function VarPtrArray Lib "msvbvm60.dll" Alias "VarPtr" (var () As Any) As Long
'For VB5 users:
Private Declare Function VarPtrArray Lib "msvbvm50.dll" Alias "VarPtr" (var () As Any) As Long
'For VBA users with Office 2010+:
Private Declare Function VarPtrArray Lib "VBE7" Alias "VarPtr" (var () As Any) As Long
'For VBA users with Office before 2010:
Private Declare Function VarPtrArray Lib "VBE6" Alias "VarPtr" (var () As Any) As Long
如何应用此方法
1. 获取字符串
如果您有一个小的 `string`,请不要浪费时间使用这种复杂的方法。
Dim S as string, Count as long
S = “Some large string”
Count = Len(s)
2. 创建一个缓冲区数组
我们将创建一个非常小的整数数组来创建底层的 `SAFEARRAY` 数据。
Dim Buffer (1) as Integer ‘Two elements array
3. 获取 SAFEARRAY 指针
正如我们之前所说,`VarPtrArray` 函数将返回一个指向 `SAFEARRAY` 结构的指针的指针,因此我们使用 `CopyMemoryToAny` 函数来获取指针值。
Private Declare Sub CopyMemoryToAny Lib "kernel32.dll" _
Alias "RtlMoveMemory"(ByRef Destination As Any, ByVal Source As Long, ByVal length As Long)
Dim pArray as Long, pSafeArray as Long
pArray = VarPtrArray(Buffer ())
CopyMemoryToAny pSafeArray, pArray, 4
4. 备份 SAFEARRAY 的原始数据
备份 `SAFEARRAY` 结构的老值,然后在 VB 清理该变量之前恢复它们非常重要。如果我们在这方面失败,原始值将不会被清理,我们可能会遇到内存泄漏问题。
Dim pOldData as Long
CopyMemoryToAny pOldData, pSafeArray + 12, 4
5. 将 SAFEARRAY 数据更改为字符串数据
在这里,我们需要更改 safe array 的数据指针(位于开头之后 12 个字节处),然后更改元素数量(位于开头之后 16 个字节处)。
Private Declare Sub CopyAnyToMemory Lib "kernel32.dll" _
Alias "RtlMoveMemory"(ByVal Destination As Long, ByRef Source As Any, ByVal length As Long)
CopyAnyToMemory pSafeArray + 12, StrPtr (S), 4
CopyAnyToMemory pSafeArray + 16, Count, 4
6. 进行您的工作
现在您可以开始操作、解析甚至更改 `string` 数据,而无需改变其大小。请注意不要擦除您的 `string` 变量或数组数据。这可能会导致意外行为。
7. 恢复原始 SAFEARRAY 数据
现在我们可以通过恢复 `SAFEARRAY` 结构的旧值来完成我们的工作,这样当 VB 开始清理未使用的内存时,它将清理数组的正确数据。
CopyAnyToMemory pSafeArray + 12, pOldData, 4
CopyAnyToMemory pSafeArray + 16, 2, 4
替代方案
我们还可以通过使用未文档化的函数 `GetMem2` 直接访问 `string` 数据,该函数可以声明为:
‘For VB6 users
Private Declare Sub GetInteger Lib "MSVBVM60.dll" _
Alias "GetMem2" (ByRef Src As Any, ByRef Dst As Integer)
‘For VB5 users
Private Declare Sub GetInteger Lib "MSVBVM50.dll" _
Alias "GetMem2" (ByRef Src As Any, ByRef Dst As Integer)
这些函数有两个问题:
- 它们不适用于 VBA 用户。
- 它们比通过数组直接访问(我们之前描述的)慢,因为调用 DLL 导入函数时有一些开销。
示例
在附加的示例中,我们创建了一个 20 MB 的 `string`,然后使用以下方法开始计算其中的数字字符:三种五种方法
- 使用 `Mid$()` 函数(字符串访问)
- 创建一个整数缓冲区数组并将 `string` 数据复制到其中(数组访问)
- 使用 `GetMem2()` 函数(内存访问)
- 创建一个整数数组指向 `string` 数据(直接访问)
- 使用使用 C++ 2012 编写的 DLL 函数(C++ API 访问),这仅在 EXE 文件中有效,在 IDE 中无效,除非您更改了其声明。
与 `String` 访问相比,性能提升非常巨大(在 EXE 中快 30 倍),数组和直接访问之间的速度提升非常小……但实际上,由于我们没有复制数据来解析 `string`,内存节省了约 40 MB。另一方面,内存访问给了我们良好的性能,没有内存过载且代码更简单,比最后两种方法慢约 2-3 倍。当然,表现最佳的是 C++ API 访问,它是最快的,但比直接访问方法快不了多少。
警告!!!
此方法有一个奇怪的行为……当您在 IDE 中运行应用程序时,它会完美运行,但当您编译它时,会引发“下标越界(错误 9)”,除非您在编译时移除边界检查。您可以在编译项目时通过按 Option 按钮,然后转到 compile 选项卡,然后按 Advanced Optimizations 按钮,然后勾选 Remove array bound checks 来移除它。
更多工作要做...
在本文中,我们解释了如何通过直接访问其字符来解析 `string`,使用整数数组。如果更改其长度,您也可以更改其数据,例如,格式化 `string`s 来将某些字符转换为大写。您还可以使用相同的概念通过数组访问任何类型的内存数据,例如,通过 GDI+ 位图的 `Lockbits` 函数来访问其数据。
历史
- 2014 年 3 月 10 日:初始版本