
VB程序设计教程:数组、函数和文件操作的基础用法
数组、函数和文件操作是编写实用 VB 程序时经常会遇到的三类基础能力:数组用来组织一组相关数据,函数用来封装可复用的计算,文件操作则负责把数据从程序内存保存到磁盘,或从磁盘读入程序。它们看起来各自简单,组合起来却涉及下标、参数传递、编码、异常处理和路径安全等问题。
本文以 VB.NET 为例,代码面向现代 .NET 控制台项目,不适用于 VB6。示例可放入新建的 VB 控制台项目中运行。项目通常由 SDK 自动引入常用命名空间;如果项目未启用隐式导入,可在文件开头显式添加 Imports System、Imports System.IO 等语句。
一、数组:保存一组同类型数据
1. 声明、初始化和访问
数组是一段按下标访问的连续数据结构。VB.NET 中数组是引用类型,数组变量保存的是数组对象的引用。最常见的声明方式,是在变量名后写括号,并用花括号提供初始值:
Option Strict On
Module Program
Sub Main()
Dim scores As Integer() = {88, 92, 76}
Console.WriteLine(scores(0))
Console.WriteLine(scores(2))
Console.WriteLine("元素个数:" & scores.Length)
End Sub
End Module
VB.NET 的数组下标默认从 0 开始。因此,上例中三个元素的下标是 0、1、2,不是 1 到 3。访问不存在的下标会触发 IndexOutOfRangeException。数组长度可通过 Length 获取,循环时常用 0 到 Length - 1 作为边界:
Dim names As String() = {"林", "周", "陈"}
For i As Integer = 0 To names.Length - 1
Console.WriteLine(i & ": " & names(i))
Next
如果不需要下标,For Each 通常更易读,也不容易写错循环边界:
For Each name As String In names
Console.WriteLine(name)
Next
要注意,For Each 中的迭代变量适合读取当前元素,不应把它当作修改数组元素的通用办法。需要按下标替换元素时,使用普通 For 循环。
2. 指定长度与默认值
如果先知道元素数量、稍后再逐项填充,可以用 New 创建数组。数组长度是元素个数;例如 New Integer(4) {} 中括号内的数字表示最大下标,所以实际长度是 5。这是初学者容易混淆的一点。
Dim values As Integer() = New Integer(4) {}
Console.WriteLine(values.Length) ' 输出 5
values(0) = 10
values(4) = 50
数组元素会得到该类型的默认值:数值类型通常为零,Boolean 为 False,引用类型为 Nothing。这不等于引用类型已经指向了一个可用对象。例如,一个长度为 3 的 String() 数组中的元素起初是 Nothing,调用其字符串方法前应先检查或赋值。
3. 多维数组和交错数组
二维数组适合表示行列结构,例如表格或棋盘。VB.NET 以逗号区分维度:
Dim grid(1, 2) As Integer
grid(0, 0) = 7
grid(1, 2) = 9
For row As Integer = 0 To grid.GetLength(0) - 1
For col As Integer = 0 To grid.GetLength(1) - 1
Console.Write(grid(row, col) & " ")
Next
Console.WriteLine()
Next
grid(1, 2) 的最大下标分别是 1 和 2,所以该数组为 2 行、3 列。GetLength(0) 返回第一维长度,GetLength(1) 返回第二维长度。多维数组各行列大小固定,适合规则网格。
交错数组则是“数组的数组”,每一行可以有不同长度。它的声明写法中有两组括号:
Dim rows As Integer()() = {
New Integer() {1, 2},
New Integer() {3, 4, 5}
}
Console.WriteLine(rows(0)(1)) ' 2
Console.WriteLine(rows(1)(2)) ' 5
规则的行列结构优先考虑二维数组;各行长度不同,或需要单独替换、扩展某一行时,交错数组更合适。交错数组的外层和内层都是数组对象,创建外层并不意味着每个内层数组都已创建,访问前要确保对应元素不是 Nothing。
4. 调整大小与数组边界
数组一旦创建,长度不会自动增长。VB.NET 提供 ReDim 重新分配数组:
Dim numbers As Integer() = {1, 2, 3}
ReDim numbers(5)
Console.WriteLine(numbers.Length) ' 6
不带 Preserve 的 ReDim 会创建新数组,原有内容不会保留。若要保留已有内容,可使用 ReDim Preserve:
Dim numbers As Integer() = {1, 2, 3}
ReDim Preserve numbers(5)
Console.WriteLine(numbers(0)) ' 1
Console.WriteLine(numbers(2)) ' 3
Preserve 会复制原数组中仍能容纳的元素,但不是免费操作;频繁扩容会产生复制开销。它只能调整数组最后一维的大小,不能用它任意改变多维数组的其他维度。若数据规模变化频繁,通常应选择 List(Of T),而不是反复重建数组。数组适合长度已知、按下标访问频繁且结构相对固定的场景。
还要注意数组变量赋值只是复制引用,并不会复制数组内容:
Dim first As Integer() = {1, 2}
Dim second As Integer() = first
second(0) = 99
Console.WriteLine(first(0)) ' 99
如果确实需要独立副本,可以使用 Clone 或 Array.Copy。但对包含对象的数组,这类操作通常是浅复制:复制数组中的引用,不会自动复制引用指向的对象。需要深复制时,必须根据对象结构明确实现。
二、函数和过程:组织可复用逻辑
1. Function 与 Sub
VB.NET 使用 Function 定义有返回值的函数,使用 Sub 定义不返回值的过程。返回类型写在参数列表之后的 As 子句中:
Function Add(a As Integer, b As Integer) As Integer
Return a + b
End Function
Sub PrintMessage(message As String)
Console.WriteLine(message)
End Sub
函数应尽量围绕清楚的输入和输出设计。例如下面的函数计算整数数组的平均值。空数组没有可计算的平均值,因此示例选择主动抛出参数异常,而不是返回一个容易误解的零:
Function Average(values As Integer()) As Double
If values Is Nothing Then
Throw New ArgumentNullException(NameOf(values))
End If
If values.Length = 0 Then
Throw New ArgumentException("数组不能是空的。", NameOf(values))
End If
Dim total As Long = 0
For Each value As Integer In values
total += value
Next
Return CDbl(total) / values.Length
End Function
这里把累计值定义为 Long,降低多个 Integer 相加时溢出的风险;不过,即使使用更大类型,仍应根据输入规模评估范围,不能假设任何数值类型永远不会溢出。计算结果转换为 Double 后再除法,可得到非整数平均值。
2. 参数传递:ByVal、ByRef 与引用类型
VB.NET 参数默认为 ByVal,建议在重要代码中显式写出,以表明函数不会通过参数变量替换调用方的变量:
Sub AddOne(ByVal value As Integer)
value += 1
End Sub
Dim n As Integer = 5
AddOne(n)
Console.WriteLine(n) ' 5
ByRef 表示过程可通过该参数读取并修改调用方变量本身:
Sub AddOneByRef(ByRef value As Integer)
value += 1
End Sub
Dim n As Integer = 5
AddOneByRef(n)
Console.WriteLine(n) ' 6
对数组、列表等引用类型,ByVal 复制的是对象引用,不是整个对象。函数可以通过该引用修改数组元素,调用方也能看到变化;但把参数变量改指向另一个数组,不会自动替换调用方变量。只有在确实需要让过程重新赋值调用方变量时,才使用 ByRef。
Sub ChangeFirst(ByVal values As Integer())
values(0) = 100
End Sub
Dim data As Integer() = {1, 2}
ChangeFirst(data)
Console.WriteLine(data(0)) ' 100
3. Optional 与 ParamArray
可选参数通过 Optional 声明,调用时可以省略。必需参数应放在可选参数之前:
Function Greet(name As String,
Optional prefix As String = "你好") As String
Return prefix & "," & name
End Function
Console.WriteLine(Greet("小林"))
Console.WriteLine(Greet("小周", "欢迎"))
可选参数的默认值要符合 VB.NET 对可选参数的编译要求,通常使用编译期可确定的常量;不要把它当成每次调用都会重新计算的表达式。若希望提供复杂的默认逻辑,可通过重载或在函数内部判断参数来实现。
ParamArray 用于接收数量不定的参数,必须是参数列表中的最后一项,并声明为数组:
Function Sum(ParamArray values As Integer()) As Integer
Dim total As Integer = 0
For Each value As Integer In values
total += value
Next
Return total
End Function
Console.WriteLine(Sum(1, 2, 3))
Console.WriteLine(Sum())
示例中的整数相加没有覆盖溢出处理;在处理不可信或可能极大的输入时,应根据业务要求检查溢出或使用适当的数值范围。ParamArray 让调用更方便,但大量参数可能产生额外数组分配,也不适合需要强制传入固定数量项目的接口。
4. 用函数检查输入
函数不仅用于计算,也适合封装校验规则。参数校验应尽可能在程序边界进行,使错误尽早暴露。例如:
Function IsValidScore(score As Integer) As Boolean
Return score >= 0 AndAlso score <= 100
End Function
AndAlso 和 OrElse 会短路求值:前一条件足以确定结果时,不再执行后一条件。与可能访问对象或数组的表达式组合时,短路操作符可以避免在前置条件不成立时继续求值。校验函数应返回清楚的结果,或对不符合契约的输入抛出适当异常;不要静默改写数据,让调用方无法判断发生了什么。
三、文件操作:读写文本与管理资源
1. 文件路径和工作目录
文件操作通常从路径开始。相对路径会根据进程当前工作目录解析,而当前工作目录不一定是可执行文件所在目录。开发环境、快捷方式和计划任务可能得到不同的工作目录,因此不要假设 "data.txt" 总会指向项目文件夹中的同一个文件。
下面示例把数据文件放在用户的本地应用数据目录下,并创建目录。此位置通常适合应用自己的非漫游数据;用户文档则应放在适合用户访问的目录,具体取决于应用用途。
2. 写入和读取文本文件
File.WriteAllText 适合一次性写入一个完整文本文件:目标文件存在时会覆盖原内容,不存在时会创建。File.ReadAllText 则适合一次性读取规模适中的文本文件:
Imports System.IO
Imports System.Text
Module Program
Sub Main()
Dim folder = Path.Combine(
Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData),
"VbBasics")
Directory.CreateDirectory(folder)
Dim filePath = Path.Combine(folder, "notes.txt")
Dim text = "第一行" & Environment.NewLine & "第二行"
File.WriteAllText(filePath, text, New UTF8Encoding(False))
Dim loaded = File.ReadAllText(filePath, Encoding.UTF8)
Console.WriteLine(loaded)
Console.WriteLine("文件:" & filePath)
End Sub
End Module
New UTF8Encoding(False) 表示写入 UTF-8 时不添加 BOM。读取时使用与文件实际编码相符的编码,可避免非 ASCII 字符出现乱码。编码选择不是“写入时设置了什么、读取时就一定能猜出来”:已有文件可能使用不同编码,处理外部文本时应遵循数据格式约定。对于具有固定格式的跨系统数据,应明确编码并在读写两端保持一致。
WriteAllText 会替换已有文件内容,不应用于“保留旧内容并在末尾追加”的需求。追加文本可使用 File.AppendAllText:
File.AppendAllText(
filePath,
Environment.NewLine & "后来追加的一行",
New UTF8Encoding(False))
如果文件很大,一次性读入内存可能消耗大量资源,应改用流式处理,而不是无条件使用 ReadAllText 或 ReadAllLines。
3. 按行读取和流式处理
对不大的文本文件,可以用 File.ReadAllLines 得到字符串数组。文件的每一行成为一个元素,之后可像其他数组一样遍历:
Dim lines As String() = File.ReadAllLines(filePath, Encoding.UTF8)
For i As Integer = 0 To lines.Length - 1
Console.WriteLine(i + 1 & ": " & lines(i))
Next
对于大文件,使用 StreamReader` 按行读取可以避免把全部行一次性保存在内存。资源必须释放;Using 结束时会自动调用释放逻辑,即使读取过程中发生异常也会执行:
Using reader As New StreamReader(filePath, Encoding.UTF8)
Dim line As String = reader.ReadLine()
While line IsNot Nothing
Console.WriteLine(line)
line = reader.ReadLine()
End While
End Using
同理,写入大量内容或需要逐步生成数据时,可使用 StreamWriter:
Using writer As New StreamWriter(filePath, append:=True,
encoding:=New UTF8Encoding(False))
writer.WriteLine("追加一行")
writer.WriteLine("再追加一行")
End Using
文本流可以逐行处理,但它不会自动解决文件格式问题。比如用逗号分隔的数据可能包含引号、逗号或换行;简单地对每行调用 Split(","c) 不能正确解析完整的 CSV 格式。遇到 CSV、JSON 等标准格式时,应采用符合格式规范的解析器,避免自行拼接产生歧义或注入问题。
4. 目录枚举与文件信息
需要查看某个目录下的文件时,可使用 Directory.EnumerateFiles。它按需枚举,适合文件数量较多的情形;Directory.GetFiles 会先把结果收集成数组:
Dim folder = Path.GetDirectoryName(filePath)
If folder IsNot Nothing AndAlso Directory.Exists(folder) Then
For Each path As String In Directory.EnumerateFiles(folder, "*.txt")
Dim info As New FileInfo(path)
Console.WriteLine(info.Name & ",字节数:" & info.Length)
Next
End If
目录不存在、权限不足或文件在枚举期间被其他程序删除,都可能导致异常或结果变化。枚举结果不能被视为稳定快照;在需要对文件做后续操作的场景中,操作时仍要处理文件状态变化。
四、错误处理、可靠性和安全边界
1. 处理可预期的文件异常
文件操作会受到权限、磁盘状态、并发修改和路径有效性影响。不要用一个空的 Catch 把所有失败吞掉。捕获预期中的异常,给出有用信息,或将错误继续交给上层处理:
Try
Dim content As String = File.ReadAllText(filePath, Encoding.UTF8)
Console.WriteLine(content)
Catch ex As FileNotFoundException
Console.WriteLine("文件不存在:" & ex.Message)
Catch ex As UnauthorizedAccessException
Console.WriteLine("没有权限读取该文件。")
Catch ex As IOException
Console.WriteLine("读取文件时发生 I/O 错误:" & ex.Message)
End Try
异常类型可能有继承关系,较具体的类型应放在较宽泛的类型之前,否则前面的捕获会先处理它。示例没有捕获所有可能异常;例如无效参数、编码配置错误等仍应依据程序契约决定如何处理。不要把异常文本直接展示给最终用户作为唯一说明,也不要为了“防止报错”而静默忽略数据读写失败。
2. 不信任外部输入生成的路径
如果文件名来自用户输入、请求参数或外部数据,不能直接把它和受限目录拼接后就认为安全。诸如 ../、绝对路径、不同平台的路径分隔符等都可能让最终路径离开预期目录。Path.Combine 负责组合路径,并不负责权限控制或阻止路径穿越;如果后一个参数是绝对路径,它还可能改变组合结果。
安全做法要根据业务设计:优先使用程序生成的文件名或受控标识;对输入文件名采用明确的白名单规则;将最终路径规范化后,检查它是否仍处于允许的根目录之内。检查应正确处理目录分隔符和大小写差异,不能只用简单的字符串前缀判断。对于对抗性环境,还需考虑符号链接、目录替换和检查之后文件状态变化造成的竞态。路径检查本身不能代替操作系统访问控制。
下面是受限用途的简化示例:将用户传入的名称限制为纯文件名,再构造目标路径。它拒绝带目录部分的输入,但不应被误认为是抵御所有恶意文件系统操作的完整安全方案;真正用于敏感场景时还要结合部署环境和权限模型评估。
Function GetSafeTextPath(root As String, fileName As String) As String
If String.IsNullOrWhiteSpace(fileName) Then
Throw New ArgumentException("文件名不能为空。", NameOf(fileName))
End If
If Path.GetFileName(fileName) <> fileName Then
Throw New ArgumentException("只能提供文件名,不能提供目录。", NameOf(fileName))
End If
If Not String.Equals(Path.GetExtension(fileName), ".txt",
StringComparison.OrdinalIgnoreCase) Then
Throw New ArgumentException("只允许使用 .txt 扩展名。", NameOf(fileName))
End If
Return Path.Combine(root, fileName)
End Function
这个示例展示输入限制的思路,不代表扩展名校验就能判断文件内容类型,也不代表写入目录自动受到保护。对于上传文件、共享服务或处理敏感文件的程序,还应限制目录权限、限制文件大小、使用安全的文件名生成策略,并根据威胁模型防范符号链接和并发替换。
3. 覆盖写入、并发与数据一致性
File.WriteAllText 会覆盖目标文件,程序崩溃、进程被终止或磁盘错误都可能影响写入结果。关键数据不宜只依赖一次覆盖操作。常见改进是先写入同一文件系统上的临时文件,确认写入成功后再替换目标文件;但替换操作的原子性和行为会受到操作系统、文件系统、目标文件状态等因素影响,不能把简单的“先写临时文件再改名”视为跨平台、跨环境的绝对保证。对重要数据还要考虑备份、校验、权限和恢复流程。
多个程序同时读写同一个文件时,可能发生竞争或相互覆盖。需要协调时,可以采用文件锁定策略、独立文件、数据库或其他适合并发访问的存储方式。若使用 FileStream 指定共享模式,应理解该模式只是控制打开期间其他句柄的共享行为,不会自动让多步业务操作变成事务。
五、综合示例:统计分数并保存结果
下面的程序把前面三部分组合起来:将分数放入数组,调用函数计算平均值,然后把统计信息写入文本文件。它可放入 VB.NET 控制台项目运行。文件写在当前工作目录下的 scores-report.txt,因此实际位置取决于启动程序时的工作目录;运行后会输出完整路径。
Option Strict On
Imports System.IO
Imports System.Text
Module Program
Sub Main()
Dim scores As Integer() = {88, 92, 76, 100}
Dim average As Double = CalculateAverage(scores)
Dim report As String =
"人数:" & scores.Length & Environment.NewLine &
"平均分:" & average.ToString("F2") & Environment.NewLine
Dim reportPath = Path.Combine(
Environment.CurrentDirectory,
"scores-report.txt")
Try
File.WriteAllText(
reportPath,
report,
New UTF8Encoding(False))
Console.WriteLine(report)
Console.WriteLine("报告已保存到:" & reportPath)
Catch ex As IOException
Console.WriteLine("无法写入报告:" & ex.Message)
Catch ex As UnauthorizedAccessException
Console.WriteLine("当前进程没有写入权限。")
End Try
End Sub
Function CalculateAverage(ByVal values As Integer()) As Double
If values Is Nothing Then
Throw New ArgumentNullException(NameOf(values))
End If
If values.Length = 0 Then
Throw New ArgumentException(
"至少需要一个分数。",
NameOf(values))
End If
Dim total As Long = 0
For Each value As Integer In values
total += value
Next
Return CDbl(total) / values.Length
End Function
End Module
这个程序假定分数已经是合法的整数,没有验证分数范围。真实业务中,应在数据进入统计函数之前或函数内部检查业务约束,例如分数是否在允许范围内;如果统计函数要承担该责任,应明确它遇到非法数据时是拒绝整个数组、忽略非法值,还是返回详细错误。不同策略会得到不同结果,不能靠含糊的默认行为代替业务规则。
六、常见错误速查
- 把长度误认为最大下标:长度为 5 的数组下标是 0 到 4。访问
array(array.Length)一定越界。 - 忘记检查空数组或 Nothing:
Nothing数组没有可访问的元素;空数组长度为 0。使用前按函数契约分别处理。 - 误以为 ByVal 会复制数组:对于引用类型,ByVal 复制引用,元素修改仍会影响调用方看到的数组。
- 误用 ReDim:不带
Preserve会丢弃旧数组内容;带Preserve不能随意调整多维数组的任意维度。 - 把 Sub 当成有返回值的函数:需要结果时使用 Function,并为结果指定合适类型。
- 依赖工作目录猜文件位置:相对路径的基准是当前工作目录,不必然是源码目录或可执行文件目录。
- 忽略编码和覆盖行为:文本读写要明确编码;
WriteAllText会覆盖目标内容。 - 忘记释放流:用
Using管理StreamReader、StreamWriter和FileStream等资源。 - 把路径拼接当安全验证:
Path.Combine不会自动阻止路径穿越,也不会替代权限控制。 - 用 Split 解析所有文本格式:CSV 等格式有转义和引用规则,应使用合适的解析器。
掌握这些要点后,可以把数组用于结构固定的数据,把函数用于表达清晰的计算与校验,把文件流用于有边界的文本读写。遇到数据规模变化、并发访问或敏感文件时,再根据需求选择列表、数据库、结构化格式和更严格的安全设计。基础语法可以很短,可靠性则来自对边界、失败方式和数据契约的明确处理。








