跳转到内容

Visual Basic/优化 Visual Basic

来自 Wikibooks,开放世界的开放书籍

优化是使程序更快、更小、更简单、更少资源占用等的艺术和科学。当然,更快通常与更简单和更小相冲突,因此优化是一个平衡行为。

本章旨在包含一个工具包,其中包含可用于加速某些类型程序的技术。每种技术都将附带演示改进的代码。请注意,代码出于两个原因故意不那么理想:优化的代码几乎总是更难理解,而且目的是让读者练习这些技术以获得更深入的理解。您应该始终能够重写提供的代码以获得更大的改进,但这有时会导致可读性下降。在速度至关重要的代码中,优先选择速度而不是清晰度,在速度不那么重要的代码中,您应该始终选择最易维护的风格;请记住,多年后来到您的代码的另一位程序员依赖于您使代码说出它的意思。当您优化代码时,您通常需要格外小心地添加注释,以确保维护代码的人不会因为他不理解您为什么使用看似过于复杂的方法而扔掉您辛苦赚来的速度提升。

如果您更改代码以提高速度,您应该编写测试来证明新代码确实比旧代码快。通常这可以像在过程之前和之后记录时间并取差值一样简单。在更改代码之前和之后进行测试,看看改进是否值得。

请记住,代码首先要能工作。让它工作,然后让它更快地工作。

缓慢获得正确答案的代码几乎总是优于快速获得错误答案的代码。如果答案是较大答案的一小部分,以至于错误难以发现,这一点尤其如此。

通过编写测试、验证函数和断言来确保事物正常工作。当您更改代码以使其更快时,测试、验证函数和断言的结果不应该改变。

整数和长整型

[edit | edit source]

在 VB6 中,通常情况下,所有整型变量都被声明为 Long 的程序将比使用 Integer 的相同程序运行得更快。

使用

 Dim I as Long

不使用

 Dim I as Integer

但是,重要的是要意识到,速度的最终仲裁者是在实际情况下的性能测量;请参阅下面的测试部分。

使用 Integer 的唯一原因是在按引用将参数传递给无法更改的组件中定义的函数时。例如,VB6 GUI 引发的许多事件使用 Integer 参数而不是 Long。对于某些第三方 DLL 也是如此。大多数 Windows API 函数需要 *Long* 参数。

测试

[edit | edit source]

这里有一个简单的测试,说明在优化的世界中,事情并不总是像看起来那样。该测试是一个简单的模块,它执行两个子例程。这两个例程是相同的,只是在一个中所有整型变量都被声明为 Long,而在另一个中所有整型变量都被声明为 Integer。这些例程只是简单地反复递增一个数字。当在我的计算机上在 VB6 IDE 中执行时,结果为(连续三次运行,单位为秒)

 Long         11.607
 Integer       7.220
 Long         11.126
 Integer       7.211
 Long         11.006
 Integer       7.221


这似乎与我关于 Long 比 Integer 更快的断言相矛盾。但是,当编译并执行 IDE 之外时,结果为

 Long         0.711
 Integer      0.721
 Long         0.721
 Integer      0.711
 Long         0.721
 Integer      0.721

请注意,在编译后运行时,Long 和 Integer 的时间是无法区分的。这说明了关于优化和基准测试的几个重要要点。

时间抖动
在抢占式多任务操作系统上对算法进行计时始终是一个统计学练习,因为您无法(至少在 VB6 中不能)强制操作系统不中断您的代码,这意味着使用这些测试的简单技术进行计时包括在其他程序中或至少在系统空闲循环中花费的时间。
了解您要测量什么
在 IDE 中测量的计时至少比编译为本地代码时测量的计时长十倍(在这种情况下,优化为 *快速* 代码,整数边界检查 *开启*)。Long 和 Integer 的相对计时差异在编译代码和解释代码之间有所不同。

有关更多基准测试,请参阅 Donald Lessau 的优秀网站:VBSpeed.

以下是代码

 Option Explicit
 
 Private Const ml_INNER_LOOPS As Long = 32000
 Private Const ml_OUTER_LOOPS As Long = 10000
 
 Private Const mi_INNER_LOOPS As Integer = 32000
 Private Const mi_OUTER_LOOPS As Integer = 10000
 
 
 Public Sub Main()
   
   Dim nTimeLong As Double
   Dim nTimeInteger As Double
   
   xTestLong nTimeLong
   xTestInteger nTimeInteger
   
   Debug.Print "Long", Format$(nTimeLong, "0.000")
   Debug.Print "Integer", Format$(nTimeInteger, "0.000")
   
   MsgBox "   Long: " & Format$(nTimeLong, "0.000") & vbCrLf _
          & "Integer: " & Format$(nTimeInteger, "0.000")
   
 End Sub
 
 
 Private Sub xTestInteger(ByRef rnTime As Double)
   
   Dim nStart As Double
   
   Dim iInner As Integer
   Dim iOuter As Integer
   Dim iNum As Integer
   
   nStart = Timer
   For iOuter = 1 To mi_OUTER_LOOPS
     iNum = 0
     For iInner = 1 To mi_INNER_LOOPS
       iNum = iNum + 1
     Next iInner
   Next iOuter
   
   rnTime = Timer - nStart
   
 End Sub
 
 
 Private Sub xTestLong(ByRef rnTime As Double)
   
   Dim nStart As Double
   
   Dim lInner As Long
   Dim lOuter As Long
   Dim lNum As Long
   
   nStart = Timer
   For lOuter = 1 To ml_OUTER_LOOPS
     lNum = 0
     For lInner = 1 To ml_INNER_LOOPS
       lNum = lNum + 1
     Next lInner
   Next lOuter
   
   rnTime = Timer - nStart
   
 End Sub

字符串

[edit | edit source]

如果您有许多执行大量字符串连接的代码,您应该考虑使用字符串构建器。字符串构建器通常作为类提供,但原理非常简单,不需要任何面向对象的封装。

字符串构建器解决的问题是反复分配和释放内存所使用的时间。这个问题的出现是因为 VB 字符串被实现为指向内存位置的指针,并且每次您连接两个字符串时,实际上发生的是您为结果字符串分配一个新的内存块。即使新字符串替换了旧字符串,也会发生这种情况,如以下语句所示

 s = s & "Test"

分配和释放内存的行为在 CPU 周期方面非常昂贵。字符串构建器通过维护一个比实际字符串更长的缓冲区来工作,这样要添加到结尾的文本只需简单地复制到内存位置。当然,缓冲区必须足够长才能容纳结果字符串,因此我们首先计算结果的长度,并检查缓冲区是否足够长;如果不是,我们分配一个新的更长的字符串。

字符串构建器的代码可以非常简单

 Private Sub xAddToStringBuf(ByRef rsBuf As String, _
                             ByRef rlLength As Long, _
                             ByRef rsAdditional As String)
   
   If (rlLength + Len(rsAdditional)) > Len(rsBuf) Then
     rsBuf = rsBuf & Space$(rlLength + Len(rsAdditional))
   End If
   Mid$(rsBuf, rlLength + 1, Len(rsAdditional)) = rsAdditional
   rlLength = rlLength + Len(rsAdditional)
   
 End Sub

此子例程取代了字符串连接运算符(*&*)。请注意,还有一个额外的参数 *rlLength*。这是必需的,因为缓冲区的长度与字符串的长度不同。

我们像这样调用它

 dim lLen as long
 xAddToString s, lLen, "Test"

lLen 是字符串的长度。如果您查看下面的执行时间表,您可以看到,对于 Count 的值约为 100 时,简单方法更快,但高于此,简单连接的时间大致呈指数增长,而字符串构建器的时间大致呈线性增长(在 IDE 中测试,1.8GHz CPU,1GB 内存)。您的机器上的实际时间会不同,但总体趋势应该相同。

重要的是批判性地看待这样的测量结果,并尝试了解它们是否适用于您正在编写的应用程序。如果您在内存中构建长文本字符串,那么字符串构建器是一个有用的工具,但如果您只连接几个字符串,那么本机运算符会更快更简单。

测试

[edit | edit source]

反复连接单词 *Test* 会得到以下结果

时间(秒)
计数 简单 构建器
10 0.000005 0.000009
100 0.000037 0.000043
1000 0.001840 0.000351
5000 0.045 0.002
10000 0.179 0.004
20000 0.708 0.008
30000 1.583 0.011
40000 2.862 0.016
50000 4.395 0.019
60000 6.321 0.023
70000 13.641 0.033
80000 27.687 0.035

ByRef 与 ByVal

[edit | edit source]

许多关于 VB 的书籍告诉程序员始终使用 ByVal。他们以 ByVal 既快又安全为理由。

您可以通过 测试 的结果看到 ByVal 稍微快一些。

空函数

 xTestByRef     13.4190000000017 
 xTestByVal     13.137999999999 

在函数中使用简单的算术表达式

 xTestAddByRef  15.7870000000039 
 xTestAddByVal  15.3669999999984 

您还可以看到差异很小。在解释编码基准时要小心,即使是最简单的基准也可能产生误导,如果您认为存在瓶颈,那么始终明智的做法是对实际代码进行分析。

另一个说法是 ByVal 更安全。通常解释为,所有参数都以 ByVal 声明的函数无法更改调用方变量的值,因为 VB 复制而不是使用指针。然后,程序员可以自由地以任何他或她认为合适的方式使用传入的参数,特别是可以为它们分配新值而不干扰调用方中的原始值。

这种安全性的好处通常会被编译器无法执行类型检查的事实所抵消。在 Visual Basic Classic 中,变量在必要且可能的情况下会在字符串数字类型之间自动转换。如果参数以 ByVal 声明,则调用方可以在期望Long的地方提供String。编译器将默默地编译必要的指令以将String转换为Long并继续。在运行时,您可能发现问题,也可能没有发现问题。如果字符串包含数字,则它将被转换,代码将“工作”,如果它不包含数字,则代码将在调用函数的位置失败。

 Function IntByVal(ByVal z as Double) as Long
   IntByVal = Int(z)
 End Function
 
 Function IntByRef(ByRef z as Double) as Long
   IntByRef = Int(z)
 End Function
 
 Dim s As String
 's = "0-471-30460-3"
 s = "0"
 Debug.Print IntByVal(s)
 Debug.Print IntByRef(s)
 

如果您尝试编译上面的代码片段,您将看到编译器在该行停止

 Debug.Print IntByRef(s)

它突出显示s并显示一个消息框,内容为“ByRef 参数类型不匹配”。如果您现在注释掉该行并再次运行,您将不会收到错误。现在取消注释此行

 s = "0-471-30460-3"

并注释掉此行

 's = "0"

再次运行程序。现在程序失败,但只在运行时失败,并显示“运行时错误 13:类型不匹配”。

道德是

  • 使用 ByRef 使编译器在函数调用中对参数进行类型检查,
  • 除非是输出参数,否则永远不要为函数的参数赋值。

查看 编码标准 章节中的 过程参数 部分,了解有关如何命名参数的一些建议,以便始终清楚哪些是输入参数,哪些是输出参数。

测试

[edit | edit source]

使用空函数

[edit | edit source]
 Option Explicit
 
 Private Const mlLOOPS As Long = 100000000
 Private mnStart As Double
 Private mnFinish As Double
 
 Public Sub main()
   xTestByRef 1#, 2#
   Debug.Print "xTestByRef", mnFinish - mnStart
   xTestByVal 1#, 2#
   Debug.Print "xTestByVal", mnFinish - mnStart  
 End Sub
   
 Private Sub xTestByRef(ByRef a As Double, ByRef b As Double)
   Dim lLoop As Long
   Dim n As Double
   mnStart = Timer
   For lLoop = 1 To mlLOOPS
     n = xByRef(a, b)
   Next lLoop    
   mnFinish = Timer
 End Sub
 
 Private Sub xTestByVal(ByVal a As Double, ByVal b As Double)  
   Dim lLoop As Long
   Dim n As Double
   mnStart = Timer
   For lLoop = 1 To mlLOOPS
     n = xByVal(a, b)
   Next lLoop
   mnFinish = Timer
 End Sub
 
 Private Function xByRef(ByRef a As Double, ByRef b As Double) As Double
 End Function
 
 Private Function xByVal(ByVal a As Double, ByVal b As Double) As Double
 End Function

使用简单的算术

[edit | edit source]
 Attribute VB_Name = "modMain"
 Option Explicit
   
 Private Const mlLOOPS As Long = 100000000
 Private mnStart As Double
 Private mnFinish As Double
 
 Public Sub main()  
   xTestAddByRef 1#, 2#
   Debug.Print "xTestAddByRef", mnFinish - mnStart
   xTestAddByVal 1#, 2#
   Debug.Print "xTestAddByVal", mnFinish - mnStart
 End Sub
 
 Private Sub xTestAddByRef(ByRef a As Double, ByRef b As Double)
   Dim lLoop As Long
   Dim n As Double
   mnStart = Timer
   For lLoop = 1 To mlLOOPS
     n = xAddByRef(a, b)
   Next lLoop
   mnFinish = Timer
 End Sub
 
 Private Sub xTestAddByVal(ByVal a As Double, ByVal b As Double)  
   Dim lLoop As Long
   Dim n As Double
   mnStart = Timer
   For lLoop = 1 To mlLOOPS
     n = xAddByVal(a, b)
   Next lLoop
   mnFinish = Timer
 End Sub
 
 Private Function xAddByRef(ByRef a As Double, ByRef b As Double) As Double
   xAddByRef = a + b
 End Function
   
 Private Function xAddByVal(ByVal a As Double, ByVal b As Double) As Double
   xAddByVal = a + b
 End Function

集合

[edit | edit source]

集合是非常有用的对象。它们允许您编写比其他情况更简单的代码。例如,如果您需要保存用户提供的数字列表,您可能事先不知道会提供多少个数字。这使得使用数组变得困难,因为您必须要么分配一个不必要的大数组,以确保没有任何合理的用户会提供更多,要么必须随着新数字的出现而不断 Redim 数组。

集合允许您避免所有这些,因为它会根据需要扩展。但是,这种便利性是在某些情况下运行时间增加的代价。对于许多程序(可能大多数程序)来说,这个价格是可以接受的,但有些程序运行时间太长,您必须尝试从每一行代码中榨取最后一点性能。

这经常发生在进行科学计算的程序中(不要告诉我 C 会是更好的语言,因为 C 和所有其他语言都适用相同的约束和优化)。

集合是一种便捷工具的原因之一是,您可以使用字符串作为键,并通过键而不是索引检索项目。但是,每次您请求集合中的项目时,集合都必须先确定您是否提供了一个 Integer(或 Byte 或 Long)或一个 String,这当然需要一小段时间。如果您的应用程序没有使用通过 String 键查找项目的功能,那么使用数组而不是集合将获得更快的程序,因为 VB 不需要浪费时间检查您是否提供了一个 String 键而不是一个整数索引。

如果您想让集合只保存一个没有键的项目列表,那么您可以使用以下方式用数组来模拟这种行为(注意,此代码不是最佳的,优化它留作学生的练习)。

 Public Sub Initialize(ByRef a() As Variant, _
                       ByRef Count As Long, _
                       ByRef Allocated As Long)
   Allocated = 10
   Redim a(1 to Allocated) 
   Count = 0
 End Sub
   
 Public Sub Add(ByRef NewItem as Variant, _
                ByRef a() As Variant, _
                ByRef Count As Long, _
                ByRef Allocated As Long)
   Count = Count + 1    
   If Allocated < Count then
     Allocated = Count
     Redim Preserve a(1 to Allocated)    
   End If
   a(Count) = NewValue
 End Sub

要使用上面的代码,您必须声明一个 Variant 数组和两个 Long。调用 Initialize 子例程来启动所有操作。现在,您可以根据需要调用 Add 子例程,数组将根据需要扩展。

关于这个看似简单的子例程,有一些需要注意的地方

  • 一旦数组大小超过 10,每次添加项目时都会分配一个新的数组(替换原始数组),
  • 数组的大小(记录在 Allocated 变量中)始终与 Count 相同,当 Count 超过 10 时,
  • 没有提供删除项目的规定,
  • 项目按添加的顺序存储。
  • 所有参数都以 Byref 声明

程序员不太可能想在生产代码中包含此确切的例程。一些原因是

  • NewItem 声明为 Variant,但程序员通常知道类型,
  • 初始分配使用文字整数进行硬编码,
  • 子例程的名称过于简单,它可能会与同一命名空间中的其他子例程冲突。
  • 没有提供删除项目的规定。
  • 三块独立的信息必须放在一起,但它们没有绑定在一起。

模拟集合的性能取决于对 Add、Item 和 Remove 的调用频率(参见 #Exercises)。优化后的版本必须针对其将被使用的用途进行优化。如果没有通过键进行查找,那么就没有必要为它提供函数,尤其没有必要提供数据结构来使其高效。

练习

[edit | edit source]
  • 扩展本节中介绍的代码,以更详细地模拟 Collection 类。实现 Item 和 Remove 方法,查看 VB 帮助文件以了解确切方法声明的详细信息,但不要觉得有义务完全复制它们。
  • 编写一个测试程序来练习所有功能并计时,以便您可以判断何时使用 Collection,何时使用模拟版本。
  • 您应该能够想到至少两种不同的 Remove 实现。考虑不同实现的后果。
  • 编写一个明确描述您的 Collection 类版本满足的要求。
  • 比较内置的 Collection 类和新类的行为,注意任何差异,并举例说明差异很重要和不重要的使用情况。
  • 如果您还没有这样做,请实现通过键查找。
  • 解释一下,为什么使用完全相同的接口来模拟 Collection 类的所有功能不可能在至少一个特定用例中产生显著的性能改进。

字典

[edit | edit source]

在 VB 中,字典实际上是由脚本运行时组件提供的。它类似于集合,但它还提供了一种检索所使用键的方法。此外,与集合不同,键可以是任何数据类型,包括对象。集合和字典的主要区别如下

  • 字典具有 Keys 和 Items 属性,它们返回变体数组
  • 键可以是任何数据类型,而不仅仅是字符串
  • 使用 For Each 枚举将返回 Keys,而不是 Items
  • 有一个内置的 Exists 方法。

与集合一样,字典的便利性有时会被它们的运行时开销所抵消。字典经常被用作集合而不是集合的原因之一仅仅是因为 Exists 方法。这允许您避免覆盖现有数据或避免尝试访问不存在的数据。但是,如果数据项数量很少,那么简单的线性搜索可能更快。在这种情况下,少量可能是多达 50 个项目。您可以编写一个简单的类,可以用作字典,如下所示;注意,没有尝试复制字典的所有行为,这是有意为之的。

 'cDict.cls
 Option Explicit
 
 Private maItems() As String
 Private mlAllocated As Long
 Private mlCount As Long
 
 Public Sub Add(Key As String, Item As String)
   
   mlCount = mlCount + 1
   If mlAllocated < mlCount Then
     mlAllocated = mlAllocated + mlCount
     ReDim Preserve maItems(1 To 2, 1 To mlAllocated)
   End If
   maItems(1, mlCount) = Key
   maItems(2, mlCount) = Item
   
 End Sub
 
 Public Property Get Count() As Long
   Count = mlCount
 End Property
 
 Public Function Exists(Key As String) As Boolean
   
   Exists = IndexOf(Key)
   
 End Function
 
 Public Function IndexOf(Key As String) As Long
   
   For IndexOf = 1 To mlCount
     If maItems(1, IndexOf) = Key Then
       Exit Function
     End If
   Next IndexOf
   IndexOf = 0
   
 End Function
 
 Public Property Let Item(Key As String, RHS As String)
   
   Dim lX As Long
   lX = IndexOf(Key)
   If lX Then
     maItems(2,lX) = RHS
   Else
     Add Key, RHS
   End If
   
 End Property
 
 Public Property Get Item(Key As String) As String
   Item = maItems(2,IndexOf(Key))
 End Property
 
 Public Sub Remove(Key As String)
   
   Dim lX As Long
   lX = IndexOf(Key)
   maItems(1, lX) = maItems(1, mlCount)
   maItems(2, lX) = maItems(2, mlCount)
   mlCount = mlCount - 1
 
 End Sub
 
 Public Sub RemoveAll()
   mlCount = 0
 End Sub
 
 Public Sub Class_Initialize()
   mlAllocated = 10
   ReDim maItems(1 To 2, 1 To mlAllocated)
 End Sub
 

一个简单的测试例程可以用来演示此类比 VB 字典在某些任务中更快。例如,将 32 个项目添加到字典中,然后再次删除它们,使用 cDict 会更快,但是如果您将项目数量加倍,VB 字典会更好。道德是:为手头的任务选择正确的算法。

以下是测试例程

 Option Explicit
 
 Public gsModuleName As String
 
 
 Private mnStart As Double
 Private mnFinish As Double
 
 Private Const mlCount As Long = 10000
 
 
 Public Sub main()
   
   Dim litems As Long
   litems = 1
   Do While litems < 100
     litems = litems * 2
     Debug.Print "items=", litems
     Dim lX As Long
     mnStart = Timer
     For lX = 1 To mlCount
       xTestDictionary litems
     Next lX
     mnFinish = Timer
     Debug.Print "xTestDictionary", "Time: "; Format$(mnFinish - mnStart, "0.000")
     
     mnStart = Timer
     For lX = 1 To mlCount
       xTestcDict litems
     Next lX
     mnFinish = Timer
     Debug.Print "xTestcDict     ", "Time: "; Format$(mnFinish - mnStart, "0.000")
   Loop
   
   
 End Sub
 
 
 
 Private Sub xTestDictionary(ByRef rlItems As Long)
   
   Dim d As Dictionary
   Set d = New Dictionary
   
   Dim lX As Long
   Dim c As Double
   For lX = 1 To rlItems
     d.Add Str$(lX), Str$(lX)
   Next lX
   For lX = 1 To rlItems
     d.Remove Str$(lX)
   Next lX
   
 End Sub
 
 
 Private Sub xTestcDict(ByRef rlItems As Long)
   
   Dim d As cDict
   Set d = New cDict
   
   Dim lX As Long
   Dim c As Double
   For lX = 1 To rlItems
     d.Add Str$(lX), Str$(lX)
   Next lX
   For lX = 1 To rlItems
     d.Remove Str$(lX)
   Next lX
   
 End Sub
 
 

以下是我电脑在 IDE 中的结果(秒)

 items=         2 
 xTestDictionary             Time: 1.602
 xTestcDict                  Time: 0.120
 items=         4 
 xTestDictionary             Time: 1.663
 xTestcDict                  Time: 0.200
 items=         8 
 xTestDictionary             Time: 1.792
 xTestcDict                  Time: 0.361
 items=         16 
 xTestDictionary             Time: 2.023
 xTestcDict                  Time: 0.741
 items=         32 
 xTestDictionary             Time: 2.504
 xTestcDict                  Time: 1.632
 items=         64 
 xTestDictionary             Time: 3.455
 xTestcDict                  Time: 4.046
 items=         128 
 xTestDictionary             Time: 5.387
 xTestcDict                  Time: 11.437

练习

[edit | edit source]
  • 编写一个 cDict 类的版本,允许存储其他数据类型,例如使用 Variant 参数。
  • 使用类似的测试例程检查性能。
  • 编写一个新的测试例程来执行其他测试。新类和 VB 字典的相对性能是否发生变化?
  • 如果你尝试检索或删除一个不存在的项目会发生什么?其行为与字典的行为相比如何?

调试对象

[edit | edit source]

调试对象有两个方法

Print
将它的参数打印到立即窗口。
Assert
如果它的参数为false,则暂停程序。

这两种方法只有在 IDE 中运行时才有效;至少这是传统智慧。不幸的是,对于 Debug.Print 来说并非完全如此。当程序作为编译后的可执行文件运行时,此方法不会打印任何内容,但如果参数是函数调用,它们仍然会被计算。如果函数调用非常耗时,你会发现编译后的版本运行速度不如预期快。

可以做两件事

  • 在发布产品之前删除所有 Debug.Print 语句。
  • 只使用变量或常量作为 Debug.Print 的参数

如果你的程序既非常 CPU 密集又处于持续开发中,那么第二种方法可能更可取,这样你就不必不断添加和删除行。

Debug.Assert 不会遇到这个问题,因此完全可以安全地断言复杂且耗时的函数的真值。断言参数只有在 IDE 中运行时才会被计算。

练习

[edit | edit source]
  • 编写一个简短的程序来演示 Debug.Print 即使在编译后也会计算函数。
  • 修改它以显示 Debug.Assert 不会遇到这个问题。
  • 显示如果参数是常量或变量,则在编译后的版本中 Debug.Print 的执行时间为零。

对象实例化

[edit | edit source]

对于简单的概念来说,这些词很长。本节讨论创建对象的运行时成本。这个术语的专业术语是对象实例化,意思是创建一个实例。一个实例与一个的关系就像一台机器与它的计划的关系。对于任何给定的类,你可以有任意数量的实例。

如果一个对象的构建需要很长时间,并且你在程序运行期间创建和销毁了大量的对象,那么你可以通过不销毁它们而是将它们放在一个预制对象列表中以备后用,从而节省一些时间。

想象一个模拟动物生态的程序。可能会有两种动物类:食草动物和食肉动物。

如果你想在有生之年看到任何结果,模拟显然必须比现实生活速度更快,所以大量的食草动物尤其会出生、繁殖和被吃掉。如果这些动物中的每一个都由一个对象来表示,并且在它所表示的动物被杀死时被销毁,那么系统将反复分配和释放内存,这在 VB6 中是一项相对昂贵的业务。在这样的程序中,你知道需要不断创建相同类型的对象,所以你可以通过重用死亡的对象来避免一些内存分配开销,而不是销毁它们并创建新的对象。有很多方法可以做到这一点。你选择哪一种取决于你有多少种不同的对象类。如果很少,那么你可以为每个对象创建单独的代码,这样就可以非常高效地调整代码。另一方面,如果你有数十种不同的类(也许你已经将模拟扩展到包括竞争的食草动物),你会很快发现你遇到了维护问题。

解决方案是创建一个描述通用对象池的类和一个接口,每个类都可以实现该接口。

在我描述这个类和接口之前,这里是对需求的总结

  • 只需要一个池类。
  • 被“池化”的类只需要对初始化和终止代码进行一些小的修改以适应池化概念。
  • 使用池化对象的代码不需要更改,除了调用池的 GetObject 函数而不是使用 New 运算符。

这项技术依赖于 VB6 具有确定性终结这一事实。另一个专业术语,它只是意味着 VB6 会在对象变为未使用时立即销毁(终结)它们。每次 VB6 确定一个对象不再使用时,它都会调用该对象的 Class_Terminate 方法。我们可以做的是在每个类中添加一段简单的代码,将终止的对象放到可用对象列表中。VB 将看到该对象现在正在使用,并且不会释放它所使用的内存。稍后,我们不是使用New 运算符创建新对象,而是请求对象池提供一个不再使用的对象。即使该对象的设置时间非常短,这也会比使用New 快,因为它避免了内存分配和释放。

这里是一个示例对象池类和一个可以使用它的类

 'ObjectPool.cls
 Private moPool as Collection
 Public oTemplate As Object
 
 Private sub Class_Initialize()
   set moPool = New Collection
 End Sub
 
 Public Function GetObject() as Object
   if moPool.Count then
     Set GetObject = moPool(1)
     moPool.Remove(1)
   Else
     Set GetObject = oTemplate.NewObject
   End If
 End Function
 
 Public Sub ReturnToPool(Byref oObject as Object)
   moPool.Add oObject
 End Sub

要使用这个类,在bas 模块中声明一个公共变量,并为它分配一个新的 ObjectPool 对象

 'modPool.bas
 Public goPool As ObjectPool
 
 Public Sub Initialize()
   Set goPool = New ObjectPool
   Set goPool.oTemplate = New Herbivore
 End Sub

现在修改你的 Herbivore 类,通过在 Class_Terminate 方法中添加对 ReturnToPool 的调用,并添加一个 NewObject 方法

 Private Sub Class_Terminate()
   goPool.ReturnToPool Me
 End Sub
 
 Public Function NewObject() as Object
   Set NewObject = New Herbivore
 End Function

对于一些简单的场景,这可能甚至有效。但是,它有几个缺陷,至少有一个是主要问题。问题在于你得到的对象不一定像一个闪亮的新对象。现在对于某些应用程序来说,这并不重要,因为客户端代码会重新初始化所有内容,但是许多程序依赖于该语言的自动将新分配的变量设置为零的功能。为了满足对客户端最小更改的要求,我们应该通过在 Herbivore 中添加 ReInitialize 方法来扩展 ObjectPool 和池化对象

 Public Sub ReInitialize()
   ' Do whatever you need to do to make the object 
   ' look like new (reset some attributes to zero, 
   ' empty strings, etc).
 End Sub

不要在ReInitialize 中做任何不必要的工作。例如,如果对象的属性之一是动态分配的数组,那么可能不需要清除它;设置一个标志或计数器以指示实际上没有使用任何数组元素就足够了。

现在修改 ObjectPool 的 GetObject 方法

 Public Function GetObject() as Object
   if moPool.Count then
     Set GetObject = moPool(1)
     GetObject.ReInitialize
     moPool.Remove(1)
   Else
     Set GetObject = oTemplate.NewObject
   End If
 End Function

现在,在你使用 New Herbivore 的所有地方,都使用 goPool.GetObject 代替。如果 Herbivore 对象引用了其他对象,你可能需要(也可能不需要)通过在 Class_Terminate 方法中将它们设置为 Nothing 来释放这些引用。这取决于对象的 behaviour 和程序的其余部分,一般来说,你应该尽可能推迟执行昂贵的操作。

使用对象池可以提高某些类型程序的性能,而无需程序员彻底改变程序设计。但是,不要忘记,你也许可以通过使用更好的算法来获得类似或更大的改进;同样,计时测试是了解该领域的关键。不要假设你知道瓶颈在哪里,通过分析程序来证明它在哪里。

练习

[edit | edit source]
  • 编写一个简单的程序,创建和销毁大量对象,并计时。现在修改它以使用 ObjectPool。
  • 为池化对象定义一个接口并实现它。消除As Object 的使用是否会改变性能?
  • 注意,moPool 集合仅仅用作 FIFO 栈,即没有利用通过键查找项目的 capability。是否有更快的替代方案?
  • 栈的 FIFO 行为是否重要,也就是说,它是刻意设计的特性还是仅仅无关紧要?

一般提示和技巧

[edit | edit source]

尽可能将代码移出循环

[edit | edit source]

循环始终是你代码中最重要要优化的部分。始终尝试将尽可能多的代码移出循环。这样代码就不会重复,可以节省一些 CPU 周期。一个简单的例子

 For i = 1 to 50
   x = b		' Stays the same with every loop, get it outside of the loop!
   k = j + i
 Next i

更改为

 x = b	'Moved outside the loop
 For i = 1 to 50
   k = j + i
 Next i

这似乎显而易见,但你会惊讶地发现有多少程序员这样做。简单的规则是,如果它没有在每次循环迭代中改变,那么就将其移出循环,因为它不需要在那里。你只想在循环中包含必须在那里的代码。你可以从循环中清除的指令越多,我们就可以运行得越快。

循环展开

[edit | edit source]

循环展开可以消除一些比较和跳转指令。(比较和跳转指令用于创建循环,你在 Visual Basic 中看不到它们,它们是你在 ASM 中学习的幕后内容。)它还利用了现代 CPU 可以一次获取多个指令的能力。简而言之,通过展开循环,你可以获得良好的速度提升。

但我们需要注意循环展开的一些问题。现代计算机最大的瓶颈是内存。因此,英特尔和 AMD 等 CPU 设计者通过在他们的 CPU 上使用缓存来解决这个问题。这基本上是一个内存位置,CPU 可以比标准内存更快地访问它。你希望展开的循环能完全放入缓存中,如果不能,它可能会降低你的代码速度。因此,你可能需要在展开循环时尝试使用 gettickcount 函数。

循环示例

 For i = 1 To 100
   b = somefun(b)
 Next I

展开的示例

 For i = 1 To 100 Step 2
   b = somefun(b)
   b = somefun(b)
 Next I

根据你的操作,你可以获得高达 25% 的速度提升,你只需要进行试验。

尽量避免使用除法

[编辑 | 编辑源代码]

除法指令是 CPU 上最昂贵的指令之一,如果不是最昂贵的。乘法比除法速度更快!

 B = 40 / 2

 B = 40 * 0.5

更慢。你也可以使用减法来开发一些有趣的算法来获得结果,这些算法比使用除法快得多。如果你在循环中使用除法,必须将其更改以加快代码速度。(我原本也想建议尝试使用位移运算进行除法,但我忘记了某些版本的 Visual Basic 不包含位移运算符)。

嵌套条件

[编辑 | 编辑源代码]

在嵌套条件分支中,例如 Select Case 和嵌套的 If 语句,将最有可能为真的部分放在嵌套中的最前面,将最不可能为真的部分放在最后面。

避免使用 Variant 变量

[编辑 | 编辑源代码]

Variant 变量在刚开始学习 Visual Basic 时非常方便,但这是一个需要改掉的习惯。Variant 变量在运行时会转换为其适当的数据类型,这可能非常昂贵。

声明变量时要小心

[编辑 | 编辑源代码]

如果你在声明每个变量时没有使用 as something,那么它就是一个 Variant!例如

 Dim a, b, c as string.
   a = A   'variant
   b = A   'variant
   c = A   'string

我看到有些人使用这种表示法

 Dim x
 x = blah

这是绝对不行的!它可能有效,但会降低你的速度。

减少公共表达式

[编辑 | 编辑源代码]

有时,你的两个不同变量会使用相同计算的一部分。与其对两个变量执行完整的计算,不如消除冗余计算。

 x = a * b + c
 y = a * b + d

 t = a * b
 x = t + c
 y = t + d

如果你的冗余昂贵计算在循环中使用,尤其如此。

在计算中使用 Long 或 Integer

[编辑 | 编辑源代码]

Long 是一个 32 位数字,在 32 位处理器上更自然。避免使用其他变量,例如 Double、Single 等。

在循环中使用内联函数

[编辑 | 编辑源代码]

与其调用函数,不如将代码放在循环中。如果你的代码在足够多的循环中重复,这会使你的程序变大,并且只应该在关键位置执行此操作。原因是调用函数的开销。在程序调用函数之前,它必须将某些东西推送到堆栈中。至少它会推送指令指针(即返回地址)。内存访问速度很慢,因此我们希望在关键位置避免这种情况。

避免在循环中使用属性

[编辑 | 编辑源代码]

属性的访问速度比变量慢得多,因此使用变量代替。

 For i = 1 to 50
    text1.text = text1.text + b(i)
 Next i

 For i = 1 to 50
   strbuffer = strbuffer + b(i)
 Next i
 text1.text = strbuffer

从磁盘加载所有需要的数据

[编辑 | 编辑源代码]

与其一次加载一个文件,不如一次加载所有文件。这将避免用户以后的延迟。

充分利用 Timer 控件

[编辑 | 编辑源代码]

你可以在等待用户时进行后台处理。利用这段时间预取数据、进行需要的计算等等。

尽量减少对象中的点表示法

[编辑 | 编辑源代码]

你在对象中使用的每个点都会让 Visual Basic 进行一次调用。

 Myobject.one.two.three

 Myobject.one

一次分配足够的内存

[编辑 | 编辑源代码]

当你创建动态数组,并且想要添加尚未分配的元素时,确保为所有元素分配足够的内存,而不是一次分配一个。如果你不知道需要多少元素,将你分配的内存乘以 2。分配内存是一个昂贵的过程。

避免在循环中使用内置函数

[编辑 | 编辑源代码]

如果你有一个需要字符串长度的循环算法,确保将字符串大小缓存到缓冲区中,而不是在每次循环迭代中调用函数 len()。

 For i = 1 to 100
   sz = len(string)
   'Do processing
 Next i

 sz = len(string)
 For i = 1 to 100
   'Do Processing with sz
 Next i

慢得多。

[编辑 | 编辑源代码]

每次更新控件属性时,都会使其重新绘制。因此,如果你正在开发显示复杂图形的内容,可能需要减少这种情况的发生次数。

上一个:有效编程 目录 下一个:示例
华夏公益教科书