6.2 变量的作用域
在上一节中,读者可能想知道为什么需要利用函数交换数据。原因是C#中的变量仅能从代码的本地作用域访问。给定的变量有一个作用域,在这个作用域外是不能访问该变量的。
变量的作用域是一个重要主题,最好用一个示例加以说明。下面的示例将演示在一个作用域中定义变量,但试图在另一个作用域中使用该变量的情形。
试一试:变量的作用域:Ch06Ex01\Program.cs
(1)对Ch06Ex01中的Program.cs进行如下修改:
class Program
{
static void Write()
{
WriteLine($"myString = {myString}");
}
static void Main(string[] args)
{
string myString = "String defined in Main()";
Write();
ReadKey();
}
}
(2)编译代码,注意显示在任务列表中的错误和警告:
The name 'myString' does not exist in the current context The variable 'myString' is assigned but its value is never used
示例说明
什么地方出错了?不能在Write()函数中访问在应用程序主体(Main()函数)中定义的变量myString。
原因在于变量是有作用域的,在相应作用域中,变量才是有效的。这个作用域包括定义变量的代码块和直接嵌套在其中的代码块。函数中的代码块与调用它们的代码块是不同的。在Write()中,没有定义myString,在Main()中定义的myString则超出了作用域—— 它只能在Main()中使用。
实际上,在Write()中可以有一个完全独立的变量myString。修改代码,如下所示:
class Program { static void Write() { string myString = "String defined in Write()"; WriteLine("Now in Write()"); WriteLine($"myString = {myString}"); } static void Main(string[] args) { string myString = "String defined in Main()"; Write(); WriteLine("\nNow in Main()"); WriteLine($"myString = {myString}"); ReadKey(); } }
这段代码就可以编译,输出结果如图6-4所示。
图6-4
这段代码执行的操作如下:
● Main()定义和初始化字符串变量myString。
● Main()把控制权传送给Write()。
● Write()定义和初始化字符串变量myString,它与Main()中定义的myString变量完全不同。
● Write()把一个字符串输出到控制台,该字符串包含在Write()中定义的myString的值。
● Write()把控制权传送回Main()。
● Main()把一个字符串输出到控制台,该字符串包含在Main()中定义的myString的值。
其作用域以这种方式覆盖一个函数的变量称为局部变量。还有一种全局变量,其作用域可覆盖多个函数。修改代码,如下所示:
class Program { static string myString; static void Write() { string myString = "String defined in Write()"; WriteLine("Now in Write()"); WriteLine($"Local myString = {myString}"); WriteLine($"Global myString = {Program.myString}"); } static void Main(string[] args) { string myString = "String defined in Main()"; Program.myString = "Global string"; Write(); WriteLine("\nNow in Main()"); WriteLine($"Local myString = {myString}"); WriteLine($"Global myString = {Program.myString}"); ReadKey(); } }
执行结果如图6-5所示。
图6-5
这里添加了另一个变量myString,这次进一步加深了代码中的名称层次。这个变量定义如下:
static string myString;
注意,这里也需要static关键字。在此类控制台应用程序中,必须使用static或const关键字来定义这种形式的全局变量。如果要修改全局变量的值,就需要使用static,因为const禁止修改变量的值。
为区分这个变量和Main()与Write()中的同名局部变量,必须用一个完整限定的名称为变量名分类,参见第3章。这里把全局变量称为Program.myString。注意,只有在全局变量和局部变量同名时,才需要这么做。如果没有局部myString变量,就可以使用myString表示全局变量,而不需要使用Program.myString。如果局部变量和全局变量同名,会屏蔽全局变量。
全局变量的值在Main()中设置如下:
Program.myString = "Global string";
全局变量在Write()中可以通过如下语句访问:
WriteLine($"Global myString = {Program.myString}");
为什么不能使用这个技术通过函数交换数据,而要使用前面介绍的参数来交换数据?有时,这确实是一种交换数据的首选方式,例如编写一个对象,用作插件,或者在较大项目中使用的短脚本。但许多情况下不应使用这种方式。使用全局变量的最常见问题与并发性的管理相关。例如,可以编写一个全局变量来读取一个类的众多方法或读取不同的线程。如果大量的线程和方法可以写入全局变量,能确定全局变量中的值是有效数据吗?没有额外的同步代码,就不能确定。此外,全局变量的真正意图可能被遗忘,以后因为其他原因再次使用它。因此,是否使用全局变量取决于函数的用途。
使用全局变量的问题在于,它们通常不适合于“常规用途”的函数—— 这些函数能处理我们所提供的任意数据,而不仅限于处理特定全局变量中的数据。详见本章后面的内容。
6.2.1 其他结构中变量的作用域
上一节的一个要点不是只与函数之间的变量作用域有关:变量的作用域包含定义它们的代码块和直接嵌套在其中的代码块。接下来要讨论的代码可在本章下载文件的VariableScopeInLoops\Program.cs中找到。这一点也适用于其他代码块,例如分支和循环结构的代码块。考虑下面的代码:
int i; for (i = 0; i < 10; i++) { string text = "Line " + Convert.ToString(i); WriteLine($"{text}"); } WriteLine($"Last text output in loop: {text}");
字符串变量text是for循环的局部变量,这段代码不能编译,因为在该循环外部调用的WriteLine()试图使用该字符串变量,但是在循环外部该字符串变量会超出作用域。修改代码,如下所示:
int i;
string text;
for (i = 0; i < 10; i++)
{
text = "Line " + Convert.ToString(i);
WriteLine($"{text}");
}
WriteLine($"Last text output in loop: {text}");
这段代码也会失败,原因是必须在使用变量前对其进行声明和初始化,但text只在for循环中初始化。由于没有在循环外进行初始化,赋给text的值在循环块退出时就丢失了。但可以进行如下修改:
int i; string text = ""; for (i = 0; i < 10; i++) { text = "Line " + Convert.ToString(i); WriteLine($"{text}"); } WriteLine($"Last text output in loop: {text}");
这次text是在循环外部初始化的,可以访问它的值。这段简单代码的执行结果如图6-6所示。
图6-6
在循环中最后赋给text的值可以在循环外部访问。可以看出,这个主题的内容需要花一点时间来掌握。在前面的示例中,循环之前将空字符串赋给text,而在循环之后的代码中,text就不会是空字符串了,其原因可能一下子看不出来。
这种情况的解释涉及分配给text变量的内存空间,实际上任何变量都是这样。只声明一个简单变量类型,并不会引起其他变化。只有在给变量赋值后,这个值才会被分配一块内存空间。如果这种分配内存空间的行为在循环中发生,该值实际上定义为一个局部值,在循环外部会超出其作用域。
即使变量本身未局部化到循环上,其包含的值却会局部化到该循环上。但在循环外部赋值可以确保该值是主体代码的局部值,在循环内部它仍处于其作用域中。这意味着变量在退出主体代码块之前是没有超出作用域的,所以可在循环外部访问它的值。
幸好,C#编译器可检测变量作用域的问题,根据它生成的错误信息修正程序有助于我们理解变量的作用域问题。
6.2.2 参数和返回值与全局数据
本节将详细介绍如何通过全局数据以及参数和返回值与函数交换数据。首先分析下面的代码:
class Program { static void ShowDouble(ref int val) { val *= 2; WriteLine($"val doubled = {val}"); } static void Main(string[] args) { int val = 5; WriteLine($"val = {val}"); ShowDouble(ref val); WriteLine($"val = {val}"); } }
注意:这段代码与本章前面的代码稍有不同,在前面的示例中,在Main()中使用了变量名myNumber,这说明局部变量可以具有相同的名称,且不会相互干涉。
和下面的代码比较:
class Program { static int val; static void ShowDouble() { val *= 2; WriteLine($"val doubled = {val}"); } static void Main(string[] args) { val = 5; WriteLine($"val = {val}"); ShowDouble(); WriteLine($"val = {val}"); } }
这两个ShowDouble()函数的结果是相同的。
使用哪种方法并没有什么硬性规定,这两种方法都十分有效,但需要考虑一些规则。
首先,在第一次讨论这个问题时就提到过,使用全局值的ShowDouble()版本只使用全局变量val。为使用这个版本,必须使用这个全局变量。这会对该函数的灵活性有轻微的限制,如果要存储结果,就必须总是把这个全局变量值复制到其他变量中。另外,全局数据可能在应用程序的其他地方被代码修改,这会导致预料不到的结果(其值可能会改变,等我们认识到这一点时为时已晚)。
当然,也可以说,这种简化实际上使代码更难理解。显式指定参数可以一眼看出发生了什么改变。例如对于FunctionName(val1, out val2)函数调用,马上就可以知道val1和val2都是要考虑的重要变量,在函数执行完毕后,会为val2赋予一个新值。反之,如果这个函数不带参数,就不能对它处理了什么数据做任何假设。
总之,可以自由选择使用哪种技术来交换数据。一般情况下,最好使用参数,而不使用全局数据,但有时使用全局数据更合适,使用这种技术并没有错。