变量自身迭代 - 不同类型的不同行为

Variable iterating on itself - different behavior with different types

请看post末尾的最新更新。

具体参见更新 4:变体比较 Curse


我已经看到伙伴们用头撞墙来了解变体的工作原理,但从未想过我会遇到自己的糟糕时刻。

我成功使用了以下VBA构造:

For i = 1 to i

i 整数 或任何数字类型时,这非常有效,从 1 迭代到 原始值 i。我在 iByVal 参数的情况下这样做 - 你可能会说懒惰 - 以免自己声明新变量。

当这个构造按预期“停止”工作时,我遇到了一个错误。经过一些艰苦的调试,我发现当 i 没有声明为显式数值类型,而是 Variant 时,它的工作方式并不相同。问题是双重的:

1- ForFor Each 循环的确切语义是什么?我的意思是编译器执行的操作顺序是什么以及顺序是什么?例如,限制的评估是否先于计数器的初始化?这个限制是否在循环开始之前复制并“固定”在某处?等等同样的问题也适用于For Each

2- 如何解释变体和显式数字类型的不同结果?有人说变体是(不可变的)引用类型,这个定义可以解释观察到的行为吗?

我已经为涉及 ForFor Each 语句的不同(独立)场景准备了一个 MCVE,结合了整数、变体和对象。令人惊讶的结果敦促明确地定义语义,或者至少检查这些结果是否符合定义的语义。

欢迎所有见解,包括解释一些令人惊讶的结果或它们的矛盾的部分见解。

谢谢。

Sub testForLoops()
    Dim i As Integer, v As Variant, vv As Variant, obj As Object, rng As Range

    Debug.Print vbCrLf & "Case1 i --> i    ",
    i = 4
    For i = 1 To i
        Debug.Print i,      ' 1, 2, 3, 4
    Next

    Debug.Print vbCrLf & "Case2 i --> v    ",
    v = 4
    For i = 1 To v  ' (same if you use a variant counter: For vv = 1 to v)
        v = i - 1   ' <-- doesn't affect the loop's outcome
        Debug.Print i,          ' 1, 2, 3, 4
    Next

    Debug.Print vbCrLf & "Case3 v-3 <-- v ",
    v = 4
    For v = v To v - 3 Step -1
       Debug.Print v,           ' 4, 3, 2, 1
    Next

    Debug.Print vbCrLf & "Case4 v --> v-0 ",
    v = 4
    For v = 1 To v - 0
        Debug.Print v,          ' 1, 2, 3, 4
    Next

    '  So far so good? now the serious business

    Debug.Print vbCrLf & "Case5 v --> v    ",
    v = 4
    For v = 1 To v
        Debug.Print v,          ' 1      (yes, just 1)
    Next

    Debug.Print vbCrLf & "Testing For-Each"

    Debug.Print vbCrLf & "Case6 v in v[]",
    v = Array(1, 1, 1, 1)
    i = 1
    ' Any of the Commented lines below generates the same RT error:
    'For Each v In v  ' "This array is fixed or temporarily locked"
    For Each vv In v
        'v = 4
        'ReDim Preserve v(LBound(v) To UBound(v))
        If i < UBound(v) Then v(i + 1) = i + 1 ' so we can alter the entries in the array, but not the array itself
        i = i + 1
         Debug.Print vv,            ' 1, 2, 3, 4
    Next

    Debug.Print vbCrLf & "Case7 obj in col",
    Set obj = New Collection: For i = 1 To 4: obj.Add Cells(i, i): Next
    For Each obj In obj
        Debug.Print obj.Column,    ' 1 only ?
    Next

    Debug.Print vbCrLf & "Case8 var in col",
    Set v = New Collection: For i = 1 To 4: v.Add Cells(i, i): Next
    For Each v In v
        Debug.Print v.column,      ' nothing!
    Next

    ' Excel Range
    Debug.Print vbCrLf & "Case9 range as var",
    ' Same with collection? let's see
    Set v = Sheet1.Range("A1:D1") ' .Cells ok but not .Value => RT err array locked
    For Each v In v ' (implicit .Cells?)
        Debug.Print v.Column,       ' 1, 2, 3, 4
    Next

    ' Amazing for Excel, no need to declare two vars to iterate over a range
    Debug.Print vbCrLf & "Case10 range in range",
    Set rng = Range("A1:D1") '.Cells.Cells add as many as you want
    For Each rng In rng ' (another implicit .Cells here?)
        Debug.Print rng.Column,     ' 1, 2, 3, 4
    Next
End Sub

更新 1

一个有趣的观察可以帮助理解其中的一些内容。关于情况 7 和 8:如果我们对正在迭代的集合持有另一个引用,则行为会完全改变:

    Debug.Print vbCrLf & "Case7 modified",
    Set obj = New Collection: For i = 1 To 4: obj.Add Cells(i, i): Next
    Dim obj2: set obj2 = obj  ' <-- This changes the whole thing !!!
    For Each obj In obj
        Debug.Print obj.Column,    ' 1, 2, 3, 4 Now !!!
    Next

这意味着在最初的情况下,正在迭代的集合在变量 obj 被分配给集合的第一个元素之后被垃圾回收(由于引用计数)。但这仍然很奇怪。编译器应该对被迭代的对象持有一些隐藏的引用!?将此与情况 6 进行比较,其中正在迭代的数组是 "locked"...

更新 2

MSDN定义的For语句的语义可以在on this page中找到。您可以看到它被明确声明 end-value 应该只被计算一次并且在循环执行之前被计算。我们是否应该将这种奇怪的行为视为编译器错误?

更新 3

又是有趣的案例7。 case7 的 反直觉 行为并不局限于变量自身的(比如说不寻常的)迭代。它可能发生在看似 "innocent" 的代码中,错误地删除了正在迭代的集合的唯一引用,从而导致其垃圾收集。

Debug.Print vbCrLf & "Case7 Innocent"
Dim col As New Collection, member As Object, i As Long
For i = 1 To 4: col.Add Cells(i, i): Next
Dim someCondition As Boolean ' say some business rule that says change the col
For Each member In col
    someCondition = True
    If someCondition Then Set col = Nothing ' or New Collection
    ' now GC has killed the initial collection while being iterated
    ' If you had maintained another reference on it somewhere, the behavior would've been "normal"
    Debug.Print member.Column, ' 1 only
Next

凭直觉,我们希望集合中保留一些隐藏的引用,以便在迭代期间保持活动状态。不仅没有,而且程序 运行 运行顺利,没有 运行 时的错误,可能导致严重错误。虽然规范没有说明任何关于在迭代下操作对象的规则,但实现恰好保护和 lock 迭代数组(案例 6)但忽略了 - 甚至不持有虚拟引用 - on一个集合(既不在字典上,我也测试过)。

关心引用计数是程序员的责任,这不是 VBA/VB6 的 "spirit" 和引用计数背后的架构动机。

更新 4:变体比较诅咒

Variants 在许多情况下表现出奇怪的行为。特别是,比较不同子类型的两个变体会产生未定义的结果。考虑这些简单的例子:

Sub Test1()
  Dim x, y: x = 30: y = "20"
  Debug.Print x > y               ' False !!
End Sub

Sub Test2()
  Dim x As Long, y: x = 30: y = "20"
  '     ^^^^^^^^
  Debug.Print x > y             ' True
End Sub

Sub Test3()
  Dim x, y As String:  x = 30: y = "20"
  '        ^^^^^^^^^
  Debug.Print x > y             ' True
End Sub

如您所见,当数字和字符串这两个变量都声明为变体时,比较未定义。当至少其中之一被显式键入时,比较成功。

比较相等时也是如此!例如,?2="2" returns 正确,但是如果您定义两个 Variant 变量,将这些值赋给它们并比较它们,则比较失败!

Sub Test4()
  Debug.Print 2 = "2"           ' True

  Dim x, y:  x = 2:  y = "2"
  Debug.Print x = y             ' False !

End Sub

请参阅下面的编辑!

For Each edits also added below under Edit2

在 Edit3

对 ForEach 和 Collections 进行了更多编辑

关于 ForEach 和集合的最后一次编辑在 Edit4

关于 Edit5 迭代行为的最后说明

当用作循环控制变量或终止条件时,变体评估语义中这种奇怪行为的部分微妙之处。

简而言之,当变体是终止值或控制变量时,终止值自然会在每次迭代中由 运行time 重新计算。但是,value 类型(例如 Integer)被压入 directly,因此不会重新计算(并且其值不会更改)。如果控制变量是 Integer,但终止值是 Variant,则 Variant 在第一次迭代时被强制转换为 Integer,并以类似方式推送。当终止条件是涉及 VariantInteger 的表达式时,会出现相同的情况 - 它被强制转换为 Integer.

在这个例子中:

Dim v as Variant
v=4
for v= 1 to v
  Debug.print v,
next

变体v赋整数值1,循环终止条件重新求值因为终止变量是变体——运行时间识别Variant 引用的存在并强制在每次迭代时重新评估。结果,由于循环内重新分配,循环完成。因为变量现在的值为 1,所以满足循环终止条件。

考虑下一个例子:

Dim v as variant
v=4
for v=1 to v-0
   Debug.Print v,
next 

当终止条件为表达式时,如"v - 0",表达式为求值 强制 常规整数 ,而不是变体,因此其硬值被推入堆栈 运行时间。因此,不会在每次循环迭代时重新评估该值。

另一个有趣的例子:

Dim i as Integer
Dim v as variant
v=4
For i = 1 to v
   v=i-1
   Debug.print i,
next

之所以如此,是因为 控制变量 是一个整数,因此终止变量也被强制为一个整数,然后被压入堆栈进行迭代。

我不能发誓这些是语义,但我相信终止条件或值被简单地压入堆栈,因此integer value 被推送,或者 Variant 的 object reference 被推送,从而在编译器意识到 variant 持有终止值时触发重新评估。当变量在循环中被重新分配,并且在循环完成时重新查询值,新值被 returned,循环终止。

抱歉,如果这有点混乱,但有点晚了,但我看到了这个,忍不住想回答一下。希望它有意义。啊,好样的'VBA :)

编辑:

从 VBMS 的语言规范中找到了一些实际信息:

The expressions [start-value], [end-value], and [step-increment] are evaluated once, in order, and prior to any of the following computations. If the value of [start-value], [end-value], and [step-increment] are not Let-coercible to Double, error 13 (Type mismatch) is raised immediately. Otherwise, proceed with the following algorithm using the original, uncoerced values.

Execution of the [for-statement] proceeds according to the following algorithm:

  1. If the data value of [step-increment] is zero or a positive number, and the value of [bound-variable-expression] is greater than the value of [end-value], then execution of the [forstatement] immediately completes; otherwise, advance to Step 2.

  2. If the data value of [step-increment] is a negative number, and the value of [bound-variable-expression] is less than the value of [end-value], execution of the [for-statement] immediately completes; otherwise, advance to Step 3.

  3. The [statement-block] is executed. If a [nested-for-statement] is present, it is then executed. Finally, the value of [bound-variable-expression] is added to the value of [step-increment] and Let-assigned back to [bound-variable-expression]. Execution then repeats at step 1.

我从这里收集到的是意图是为了终止条件值只评估一次。如果我们看到证据表明更改该值会改变循环从其初始条件开始的行为,这几乎可以肯定是由于可能被非正式地称为 意外重新评估 的原因,因为它是一个变体。如果它是无意的,我们可能只能通过轶事证据来预测它的行为。

如果在 运行 时间计算循环的 start/end/step 值,并将这些表达式的 "value" 压入堆栈,Variant 值将 "byref wrench" 抛出到过程。如果 运行 时间没有首先 识别 变体,对其进行评估,并将 that 值作为终止条件,奇怪的行为(正如你所展示的那样)几乎肯定会随之而来。正如其他人所建议的那样,VBA 在这种情况下如何处理变体对于 pcode 分析来说是一项伟大的任务。

编辑 2:FOREACH

VB规范再次提供了对 ForEach 循环在集合和数组上的评估的见解:

The expression [collection] is evaluated once prior to any of the >following computations.

  1. If the data value of [collection] is an array:

    If the array has no elements, then execution of the [for-each-statement] immediately completes.

    If the declared type of the array is Object, then the [bound-variable-expression] is Set-assigned to the first element in the >array. Otherwise, the [bound-variable-expression] is Let-assigned to the >first element in the array.

    After [bound-variable-expression] has been set, the [statement-block] >is executed. If a [nested-for-statement] is present, it is then executed.

    Once the [statement-block] and, if present, the [nested-for-statement] >have completed execution, [bound-variable-expression] is Let-assigned to >the next element in the array (or Set-assigned if it is an array of >Object). If and only if there are no more elements in the array, then >execution of the [for-each-statement] immediately completes. Otherwise, >[statement-block] is executed again, followed by [nested-forstatement] if >present, and this step is repeated.

    When the [for-each-statement] has finished executing, the value of >[bound-variable-expression] is the data value of the last element of the >array.

  2. If the data value of [collection] is not an array:

    The data value of [collection] must be an object-reference to an >external object that supports an implementation-defined enumeration >interface. The [bound-variable-expression] is either Let-assigned or >Set-assigned to the first element in [collection] in an >implementation->defined manner.

    After [bound-variable-expression] has been set, the [statement-block] >is executed. If a [nested-for-statement] is present, it is then executed.

    Once the [statement-block] and, if present, the [nested-for-statement] >have completed execution, [bound-variable-expression] is Set-assigned to >the next element in [collection] in an implementation-defined manner. If >there are no more elements in [collection], then execution of the [for-each->statement] immediately completes. Otherwise, [statement-block] is >executed again, followed by [nested-for-statement] if present, and this >step is repeated.

    When the [for-each-statement] has finished executing, the value of >[bound-variable-expression] is the data value of the last element in >[collection].

使用它作为基础,我认为很明显,分配给变量然后成为绑定变量表达式的 Variant 在这个例子中产生了 "Array is locked" 错误:

    Dim v As Variant, vv As Variant
v = Array(1, 1, 1, 1)
i = 1
' Any of the Commented lines below generates the same RT error:
For Each v In v  ' "This array is fixed or temporarily locked"
'For Each vv In v
    'v = 4
    'ReDim Preserve v(LBound(v) To UBound(v))
    If i < UBound(v) Then v(i + 1) = i + 1 ' so we can alter the entries in the array, but not the array itself
    i = i + 1
     Debug.Print vv,            ' 1, 2, 3, 4
Next

使用 'v' 作为 [绑定变量表达式] 创建一个返回 V 的 Let 赋值,该赋值被 运行 时间阻止,因为它是支持正在进行的枚举的目标ForEach 循环本身;也就是说,运行时间锁定了变体,从而阻止了循环为变体分配不同的值,因为这必然会发生。

这也适用于 'Redim Preserve' - 调整数组大小或更改数组,从而更改变量的赋值,将违反在循环初始化时放置在枚举目标上的锁。

关于基于范围的assignments/iteration,请注意非对象元素的单独语义; "external objects" 提供了一个 实现特定的 枚举行为。一个 excel Range 对象有一个 _Default 属性 仅在被对象名称引用时被调用,就像在本例中一样,它不当用作 ForEach 的迭代目标时采用隐式锁定(因此不会产生锁定错误,因为它具有与 Variant 变体不同的语义):

Debug.Print vbCrLf & "Case10 range in range",
Set rng = Range("A1:D1") '.Cells.Cells add as many as you want
For Each rng In rng ' (another implicit .Cells here?)
    Debug.Print rng.Column,     ' 1, 2, 3, 4
Next

_Default 属性 可以通过检查 VBA 对象浏览器中的 Excel 对象库来识别,方法是突出显示 Range 对象,右键单击,并选择 "Show Hidden Members").

EDIT3:集合

涉及集合的代码变得有趣且有点毛茸茸:)

Debug.Print vbCrLf & "Case7 obj in col",
Set obj = New Collection: For i = 1 To 4: obj.Add Cells(i, i): Next
For Each obj In obj
    Debug.Print obj.Column,    ' 1 only ?
Next

Debug.Print vbCrLf & "Case8 var in col",
Set v = New Collection: For i = 1 To 4: v.Add Cells(i, i): Next
For Each v In v
    Debug.Print v.column,      ' nothing!
Next

这里只需要考虑一个真正的错误。当我在 VBA 调试器中首先 运行 这两个样本时,它们 运行 与初始问题中提供的 OP 完全相同。然后,在经过几次测试后重新启动例程,然后将代码恢复到其原始形式(如此处所示)后,后一种行为任意开始匹配基于 object 的行为前辈在上面吧!只有在我停止 Excel 并重新启动它之后,才会出现后一个循环的 原始 行为(不打印任何内容),return。除了编译器错误之外,真的没有办法解释。

EDIT4 变体的可重现行为

注意到我在调试器中做了一些事情以强制通过集合的基于变体的迭代至少循环一次(与对象版本一样) ,我终于找到了一种代码可重现的方式来改变行为

考虑这个原始代码:

Dim v As Variant, vv As Variant

Set v = New Collection: For x = 1 To 4: v.Add Cells(x, x): Next x
'Set vv = v
For Each v In v
   Debug.Print v.Column
Next

这基本上是 OP 的原始情况,ForEach 循环在没有一次迭代的情况下终止。现在,取消注释 'Set vv=v' 行,然后重新 运行: 现在 For Each 将迭代一次。我认为毫无疑问,我们在 VB 运行 时间发现了变体评估机制中的一些非常(非常!)微妙的错误;另一个等于循环变量的 'Variant' 的任意设置强制执行在 For Each 评估中不发生的评估 - 我怀疑这与 Collection 在 Variant 中表示为 Variant/Object/Collection.添加这个伪造的 'set' 似乎会强制解决这个问题并使循环像基于对象的版本一样运行。

EDIT5:关于迭代和集合的最后思考

这可能是我对这个答案的最后一次编辑,但有一件事我不得不强迫自己确保我在观察奇怪的循环行为时认识到了变量被用作 'bound-variable-expression' 和限制表达式是这样的,特别是当涉及到 'Variants' 时,有时行为是由于迭代改变 'bound-variable-expresssion.' 的内容而引起的,也就是说,如果你有:

Dim v as Variant
Dim vv as Variant
Set v = new Collection(): for x = 1 to 4: v.Add Cells(x,x):next
Set vv = v ' placeholder to make the loop "kinda" work
for each v in v
   'do something
Next

重要的是要记住(至少对我而言)要记住在 For Each 中,'v' 中保存的 'bound-variable-expression' 得到 更改 凭借迭代。也就是说,当我们开始循环的时候,v持有一个Collection,枚举就开始了。但是当枚举开始时,v 的内容现在是 enumeration 的乘积——在本例中,是一个 Range 对象(来自 Cell)。这种行为可以在调试器中看到,因为您可以观察到 'v' 从 Collection 到 Range;这意味着迭代中的下一个踢 returns 无论 Range 对象的枚举上下文将提供什么,而不是 'Collection.'

这是一项很棒的研究,我感谢您的反馈。它帮助我比我想象的更好地理解事物。除非对此有更多评论或问题,否则我怀疑这将是我对答案的最后一次编辑。