楼主 amulee |
在声明变量之后,计算机会为其在内存中开辟一个独立的空间用于存放变量。一般来说,内存中有两个地方用于存放变量:栈和堆,这两个区域是分别存放两种类型的变量:值类型和引用类型。 以下,我们针对这两种变量类型和内存空间进行详细的解释。通过本帖的学习,你将了解到: 1、 变量存储的方式 2、 栈和堆的原理 3、 如何进一步提高效率 4、 如何利用变量存储的地址来恢复自定义功能区的运行 |
2楼 amulee |
基础篇 值类型:包括数字类型和日期类型(本质都是数字)。这部分类型的数据有固定的内存开销,这些类型的变量存放在栈中,并且直接将变量的值存放于其中。引用类型:通常为对象和文本字符串(String类型,稍后讨论)。这部分类型的数据一般没有固定大小的内存开销,其存储分为两部分:变量真实的值存放在堆中,在栈中存放了真实变量值在堆中的地址。 如上图所示,栈中的数据一般比较规整,但空间比较小,查找效率高;而堆中的数据比较零散,空间比较大,查找效率相对较低。对于常规的值类型的数据,其数值直接存放在栈中,而对于复杂的对象类型,在栈中存放了一个地址,通过该地址可以找到堆中该对象真实的值。 当改变栈中值类型变量的值的时候,系统直接将新的值写入栈中变量所在的空间。而当需要改变对象的值时,比如改变对象的某个属性的值,系统会先从栈中找出该对象的地址,然后根据该地址从堆中找到该对象,再在堆中改变其值。 |
3楼 amulee |
从变量的赋值就可以看出,存储在栈中的值使用Let语句赋值,比如变量A和B:
其中,Let是可以省略的。因而我们通常写成:
而对于存储在堆中的数据(字符串除外),我们则使用Set语句赋值:
使用Set语句赋值后,并非将对象B的值赋值给对象A,而是将B中原先存放的地址给了A,也就是B和A最终都指向同一个对象。 如上图,A原先存放了对象A的地址,其变量指向对象A,B也亦然。当执行完上述语句后,对象B的地址给了A,因而此时A指向了对象B。此后通过A进行的任何对象的操作,都将反应到B中,而不再会影响原先的对象A。 如以下程序:
最初,A和B指向了不同的单元格对象,而执行了Set A=B语句之后,程序将B中的地址给了A,因而使得A也指向B所指向的单元格,即B1。所以之后,A变量和B变量实际操作的都是同一个真实的对象,即B1单元格。 |
4楼 amulee |
进阶篇 我们已经知道了值类型放在栈中,而引用类型中的对象其实际值放在堆中。值类型的变量用Let语句赋值,而且引用类型的变量用Set语句赋值,那么文本字符串类型(String)用Let语句赋值,为何其真实值放在堆中的呢?而数组和自定义类型(Type结构)呢? 事实上,内存中栈的容量一般都很小,VBA中的栈只有1M大小。因而栈中一般存放基本的值类型数据,因为这类数据尺寸固定,占内存空间一般都不大。以Double类型为例,其占内存8个字节,对于1M的栈而言,可以声明13万个该类型的变量,对于一般的程序而言不太可能用不完。 而对于对象、数组、文本字符串等类型,由于其尺寸一般无法确定,而且其尺寸一般可以很大。例如一个对象可以包含方法,属性,事件等许多内容,占内存一般比较大。特别是相对复杂的对象,如Range对象,若将其放入栈中,则栈很快就会被占满。因而此类对象一般都放在堆中。 再来看下文本字符串,一个文本字符串长度可以达到2G,远远超过了栈中1M的大小限制,所以无法将其放入栈中,只能放入堆中。 接着我们来验证一下之前所论述的理论。首先,我们需要介绍3个VBA中隐藏的函数:VarPtr, StrPtr和ObjPtr。这三个函数都可以用来获取变量的地址,其语法为:
其中VarPtr返回变量的地址,对于值类型的变量,返回其真实的地址,对于引用类型的变量,则返回该变量存放地址的内存空间的地址(非变量的真实数值地址)。 StrPtr返回文本字符串真实存放的地址。 ObjPtr返回真实存放对象变量空间的地址。 |
5楼 amulee |
我们编写一小段程序Test2,先看以下基本的数据类型的存储情况。
运行该程序后,可以在立即窗口中出现类似于下图的输出 由于ObjPtr只对对象类型的适用,所以代码中只允许Object类型输出该地址。并且可以发现,在输出Object地址之前,对Object对象进行了赋值,否则将发生错误,并且Object的StrPtr返回的是其中一个属性Name的地址。 观察一下该输出,所有的VarPtr输出的地址段都在一个较小的地址段,其余的输出都在较大的地址段(变长的文本字符串除外,稍后讨论)。并且可以发现,变量自上而下是依次先后声明的,而地址却是由大至小输出的。其实这是由于栈的特性而决定的。 在计算机编程中,栈是一种后进先出的数据结构。可以把栈相象成一个桶,先放进去的东西会在最底下,最后放进去的东西在最上面。如图所示: 因而,放在栈中的变量,最先声明的变量放在了栈最下面的空间中,即栈的最后的地址中。随着变量的增加,不断放入之前的地址中,直到栈的空间挤满。 由此可见,一般的变量声明,符合栈的运作法则,无论是引用类型或者值类型,都会在栈中开辟一个空间,对于值类型而言,会开辟一个与其变量大小相匹配的空间用于存放其真实的值,而引用类型,则会开辟一个空间用于存放变量在堆中的真实地址。 对于ObjPtr函数,只有当对象变量指向某个对象后,该函数才能返回值,否则该函数将返回0(感兴趣的读者可以尝试一下)。可以发现,其返回值超过了栈的最大地址。实质上对象变量的真实值是放在堆中,堆中的地址一般没有规律,是由系统按照一定的算法自动分配的,因而会显得比较杂乱,无规律。但有个共同点就是,都是比栈大的地址。 |
6楼 amulee |
同样,对于StrPtr函数所返回的值,可以发现也是在堆中的地址。但在本例中有两个比较奇怪的现象:对于值类型的变量,StrPtr函数同样可以返回一个值,另外变长长度的文本字符串,其返回值为0。 对于后者,其原因与对象变量一样。当变长字符串未赋值的时候,系统未真正给该变量开辟空间,因而返回0。只有当其赋值之后,才能返回地址的值。如下例。而对于定长的文本字符串,系统在最开始就为其分配了内存空间。
以上程序运行后,将会在立即窗口中输出类似下图的内容 当变量声明后,由于未实际赋值,所以在堆中没有开辟任何空间存放变量实际的值。而赋值后,系统会在堆中为其开辟空间存放变量的值。而且有趣的现象是,每次赋值,系统都会为字符分配一个新的空间,即使新的赋值没有任何变化,系统也会重新分配空间。因而字符串的操作要比数值类型的操作更费时。 再来看下面这个例子:
运行完程序后,可以发现两次输出的地址一样。可以推断,使用MID方法进行字符串的替换将在原有的地址中执行,而不会去开辟新的空间。因而当需要替换字符串中字符时,可以采用该方法,执行效率相对高一些。 对于数字类型以及日期类型的变量,理论上StrPtr函数不应返回任何值,然而VBA的运行过程中会将变量的类型自动转换为所需要的类型。StrPtr函数接受的是一个String类型的文本,因而,对于其他类型的变量,VBA将会尝试将其转换为String类型的值。在这一过程中,VBA将会为其在堆中开辟一个临时的空间,StrPtr函数将返回该临时空间的地址。因而,以下语句也将得到一个堆中的地址。
|
7楼 amulee |
数组和自定义类型变量 根据之前的描述,我们可以大胆的假设一下数组和自定义类型存储情况,并采用上述方法进行验证。 猜测: 数组变量的尺寸可以很大,并且动态数组可以随时调整尺寸,因而猜测其真实的值存放在堆中,而在栈中只存放对真实地址的引用。 而自定义类型中,其字段可以混杂着值类型和引用类型。其变量值应存储在栈中,而字段的值则根据前述的规则执行,即值类型的值存放在栈中,而引用类型的真实值存放在堆中。 验证: 插入一个模块,输入以下代码并运行Test5
在立即窗口中可以看见类似下图的输出: 可以看见自定义类型的变量存放在栈中,而且其中的值按照之前的规则执行。 很遗憾,当显示声明数组变量时(Dim Arr()),三个获取地址的函数都无法使用。其中的元素,可以发现都存储在了堆中。而当使用Variant类型的变量,之后再次Redim一个数组时,数组变量的地址存放在了栈中,而实际的数据存放在了堆中。由此可以推断数组变量在栈中存放一个堆中真实地址的引用。 |
8楼 amulee |
小结和应用 介绍了那么多的理论知识,那么究竟有什么用途呢?从以上的论述中,我们可以发现: 1、 值类型的变量存放在栈中,其访问速度相对较快。而引用类型(对象)的访问速度较慢。因而在编写程序的时候,尽量减少对对象变量的访问。如果只是简单的字段存储,可以定义自定义类型,而避免使用类。 2、 避免反复给文本类型的变量赋值,因为每次赋值将会消耗时间去开辟新的空间。 3、 每一次的类型转换,系统都将开辟一个临时的空间用于临时存放转换的结果。因而对于数据类型,尽量在变量声明时就确定,避免使用Variant类型的变量,而造成类型转换的效率损失。 其实,获取了变量的地址,对于某些时候有极大的帮助。以下我们举一个实用的例子。 自Office 2007以来,微软采用了Ribbon替代了之前的工具栏。一般需要操作Ribbon上控件的时候,需要有一个全局对象变量来指向Ribbon对象。但是这个全局变量极不稳定,一旦有运行时错误发生或者代码重新编译后,则该全局将会清空。此时无法通过代码操作Ribbon上的控件了。 现在我们得知,在该全局变量中存放的实质是Ribbon对象的地址。Ribbon对象是在Excel工作簿打开的时候就创建的,只要工作簿存续期间,这个对象就一直存在。因而,我们只要在最开始记录这个对象的地址,当发生异常的时候将这个地址再次写入变量中即可。 这个想法是由MVP Rory Archibald 提出的,并且在Mr Excel的社区中发了帖子。此外,在Ron de Bruin的个人网站中也有些例子,感兴趣的可以去看看。 在正式开始这个例子之前,我们需要学习一个API函数。千万别把API想得很神秘,就是一个函数而已。只不过在使用它之前我们必须声明一下。这次我们要使用的这个函数叫CopyMemory,利用它,可以将地址重新写回引用变量中。该API函数的声明语句为:
该函数接受3个参数: destination:目标变量,即那个变量需要赋值 source:数据来源,即哪个数据需要赋值给变量 length:数据的长度。即第二个参数的数据的字节数。 可能有人会有疑问,为什么不能直接将地址写入变量中去呢?因为对象变量在栈中的数据是无法通过一般手段访问的,开发者总是访问到其所指向的变量。因而只能借助于API函数来实现。 |
9楼 amulee |
问题重现 创建一个自定义Ribbon的工作簿,XML代码为:
然后我们打开该文件,插入一个模块,添加以下代码:
保存关闭后再次打开该工作簿,当我们点击功能区的那个笑脸后,可以发现数字逐渐增加。 目前看起来一切运行正常。现在我们在模块中再插入一个小程序如下:
写完之后不要运行该程序,我们再回到工作表中点击那个按钮。可以发现,此时程序无法正常运行并提示: 点击[调试]并为变量IRibbon添加一个监视,可以发现此时IRibbon已经为Nothing了。因为VBA项目重新编译了,所有的变量都清空了。 继续在工作表中添加一个按钮,将其指定宏设置为“Test”。 保存并关闭工作簿后重新开启。一开始,功能区上的按钮可以正常运行。然后我们点击之前在工作表中插入的按钮,可以发现程序出现错误。因为“Test”程序中除数为0。点击[结束]后,再次点击功能区的按钮,此时发现该按钮也无法正常运作。由于出现的运行时错误,所有的变量也清空了。 |
10楼 amulee |
解决方案 为了解决该问题,我们需要在最开始记录下Ribbon功能区对象的地址。改写LoadRibbon过程如下:
以上过程会在加载功能区的时候将功能区对象的地址写入B1单元格。 再添加一个过程,将记录的地址再次恢复给变量,并添加一个按钮用于触发它。
保存并关闭工作簿后重新开启,可以发现B1单元个已经记录了功能区对象的地址。尝试点击“诱发错误的按钮”,出现错误对话框结束后,再次点击功能区的按钮,此时无法正常运行。点击“恢复Ribbon”按钮,此后功能区的按钮功能将恢复。 |
11楼 amulee |
遗留问题 当我们声明一个全局变量或者静态变量时会发生什么情况呢?感兴趣的读者可以根据之前的演示进行一下测试。 附件下载: VBA中数据存储的研究及其应用.rar |
12楼 gfp12345678 |
灵活运用的真好 |
13楼 keven |
感谢分享1! |