C# 8.0本质论
上QQ阅读APP看书,第一时间看更新

5.5 高级方法参数

之前的例子一直是通过方法的return语句返回数据。本节描述方法如何通过自己的参数返回数据,以及方法如何获取数量可变的参数。

5.5.1 值参数

参数默认采用传值方式。换言之,参数值会拷贝到目标参数中。例如在代码清单5.13中,调用Combine()时Main()使用的每个变量值都会拷贝给Combine()方法的参数。输出5.5展示了结果。

代码清单5.13 以传值方式传递变量

输出5.5

Combine()方法返回前,即使将null值赋给driveLetter、folderPath和fileName等变量,Main()中对应的变量仍会保持其初始值不变,因为在调用方法时,只是将变量的值拷贝了一份给方法。调用栈在一次调用的末尾“展开”的时候,拷贝的数据会被丢弃。

初学者主题:匹配调用者变量与参数名

在代码清单5.13中,调用者中的变量名与被调用方法中的参数名匹配。这是为了增强可读性,名称是否匹配与方法调用的行为无关。被调用方法的参数和发出调用的方法的局部变量在不同声明空间中,相互之间没有任何关系。

高级主题:比较引用类型与值类型

就本节来说,传递的参数是值类型还是引用类型并不重要。重要的是被调用的方法是否能将值写入调用者的原始变量中。由于现在是生成原始值的拷贝,所以怎么更改都影响不到调用者的变量。但不管怎样,都有必要理解值类型和引用类型的变量的区别。

从名字就能看出,对于引用类型的变量,它的值是对数据实际存储位置的引用。“运行时”如何表示引用类型变量的值,这是“运行时”的实现细节。一般都是用数据实际存储的内存地址来表示,但并非一定如此。

如引用类型的变量以传值方式传给方法,拷贝的就是引用(地址)本身。这样虽然在被调用的方法中还是更改不了引用(地址)本身,但可以更改地址处的数据。

相反,对于值类型的参数,参数获得的是值的拷贝,所以被调用的方法怎么都改变不了调用者的变量。

5.5.2 引用参数

来看看代码清单5.14的例子,它调用方法来交换两个值,输出5.6展示了结果。

代码清单5.14 以传引用的方式传递变量

输出5.6

赋给first和second的值被成功交换。这要求以传引用的方式传递变量。比较本例的Swap()调用与代码清单5.13的Combine()调用,不难发现两者最明显的区别就是本例在参数数据类型前使用了关键字ref,这使参数以传引用的方式传递,被调用的方法可用新值更新调用者的变量。

如果被调用的方法将参数指定为ref,调用者调用该方法时提供的实参应该是附加了ref前缀的变量(而不是值)。这样调用者就显式确认了目标方法可对它接收到的任何ref参数进行重新赋值。此外,调用者应初始化传引用的局部变量,因为被调用的方法可能直接从ref参数读取数据而不先对其进行赋值。例如在代码清单5.14中,temp直接从first获取数据,认为first变量已由调用者初始化。事实上,ref参数只是传递的变量的别名。换言之,引用参数的作用只是为现有变量分配参数名,而非创建新变量并将实参的值拷贝给它。

5.5.3 输出参数

如前所述,用作ref参数的变量必须在传给方法前赋值,因为被调用的方法可能直接从变量中读取值。例如,前面的Swap方法必须读写传给它的变量。但方法经常要获取一个变量引用,并向变量写入而不读取。这时更安全的做法是以传引用的方式传入一个未初始化的局部变量。

为此,代码需要用关键字out修饰参数类型。例如代码清单5.15中的TryGetPhoneButton()方法,它返回与字符对应的电话按键。

代码清单5.15 仅传出的变量

输出5.7展示了代码清单5.15的结果。

输出5.7

在本例中,如果能成功判断与character对应的电话按键,TryGetPhoneButton()方法就返回true。方法还使用out修饰的button参数返回对应的按键。

out参数功能上与ref参数完全一致,唯一区别是C#语言对别名变量的读写有不同的规定。如参数被标记为out,编译器会核实在方法所有正常返回的代码路径中,是否都对该参数进行了赋值。如发现某个代码执行路径没有对button赋值,编译器就会报错,指出代码没有对button进行初始化。在代码清单5.15中,方法最后将下划线字符赋给button,因为即使无法判断正确的电话按键,也必须对button进行赋值。

使用out参数时一个常见的编码错误是忘记在使用前声明out变量。从C# 7.0起可在调用方法前以内联的形式声明out变量。代码清单5.15在TryGetPhoneButton(character, out char button)中使用了该功能,之前完全不需要声明button变量。而在C# 7.0之前,必须先声明button变量,再用TryGetPhoneButton(character, out button)调用方法。

C# 7.0的另一个功能是允许完全放弃out参数。例如,可能只想知道某字符是不是有效的电话按键,而不实际返回对应数值。这时可用下划线放弃button参数:TryGetPhoneButton(character, out _)。

在C# 7.0元组语法之前,开发者声明一个或多个out参数来解决方法只能有一个返回类型的限制。例如,为了返回两个值,可以正常返回一个,另一个写入作为out参数传递的别名变量。虽然这种做法既常见也合法,但通常都有更好的方案能达到相同目的。例如,用C# 7.0写代码时,返回两个或更多值应首选元组语法。而在C# 7.0之前可考虑改成两个方法,每个方法返回一个值。如果非要一次返回两个,还可以使用System.ValueTuple类型(要求引用System.ValueTuple NuGet包,但不使用C# 7.0语法)。

注意 所有正常的代码路径都必须对out参数赋值。

5.5.4 只读传引用

C# 7.2支持以传引用的方式传入只读值类型。该特性以传引用的方式传入值类型参数,并且让该参数不能被方法修改。这样不仅避免了每次调用方法都创建值类型的拷贝,而且不用担心值类型参数被修改。换言之,其作用是在传值时减少拷贝量,同时把它标识为只读,从而增强性能。该语法要为参数添加in修饰符。例如:

使用in修饰符,方法中对number的任何重新赋值操作(例如number++)都会造成编译错误,并指示number只读。

5.5.5 返回引用

C# 7.0新增的另一个功能返回对变量的引用。例如,代码清单5.16定义了一个方法返回图片中的第一个红眼像素。

代码清单5.16 ref Return和ref局部变量声明

通过返回对变量的引用,调用者可将像素更新为不同颜色,如代码清单5.16灰色背景的行所示。检查对数组的更新,证明值现已变成黑色。

返回引用有两个重要的限制,两者都和对象生存期有关:(1)对象仍被引用时不应被垃圾回收;(2)对象的所有引用都消失之后,不应再占用内存。为符合这些限制,从方法返回引用时只能返回:

·对字段或数组元素的引用。

·其他返回引用的属性或方法。

·作为参数传给“返回引用的方法”的引用。

例如,FindFirstRedEyePixel()返回对一个image数组元素的引用,该引用是传给方法的参数。类似地,如图片作为类的字段存储,可返回对字段的引用:

此外,ref局部变量被初始化为引用一个特定变量,以后不能修改为引用其他变量。

返回引用时要注意几点:

·如决定返回引用,就必须返回一个引用。以代码清单5.16为例,即使不存在红眼像素,仍需返回一个引用字节。找不到就只有抛出异常。相反,如采取传引用参数的方式,就可以不修改参数,只是返回一个bool值代表成功,许多时候这种做法更佳。

·声明引用局部变量的同时必须初始化它。为此需要将方法返回的引用赋给它,或将一个变量引用赋给它:

·虽然C# 7.0允许声明ref局部变量,但不允许声明ref字段:

·自动实现的属性不能声明为引用类型:

·允许返回引用的属性:

·引用局部变量不能用值(比如null或常量)来初始化。必须将返回引用的成员赋给它,或者将局部变量、字段或数组赋给它:

5.5.6 参数数组

到目前为止,方法的参数数量都是在声明时确定好的。但有时我们希望参数数量可变。以代码清单5.13的Combine()方法为例,它传递了驱动器号、文件夹路径和文件名等参数。如路径中包含多个文件夹,调用者希望将额外的文件夹连接起来以构成完整路径,那么应该如何写代码?也许最好的办法就是为文件夹传递一个字符串数组,其中包含不同的文件夹名称。但这会使调用代码变复杂,因为需要事先构造好数组并将数组作为参数传递。

为简化编码,C#提供了一个特殊关键字,允许在调用方法时提供数量可变的参数,而不是事先就固定好参数数量。讨论方法声明前,先注意一下代码清单5.17的Main()方法中的调用代码。

代码清单5.17 传递长度可变的参数列表

输出5.8展示了代码清单5.17的结果。

输出5.8

第一个Combine()调用提供了4个参数。第二个只提供了3个。最后一个调用传递一个数组来作为参数。换言之,Combine()方法接受数量可变的参数,要么是以逗号分隔的字符串参数,要么是单个字符串数组。前者称为方法调用的“展开”(expanded)形式,后者称为“正常”(normal)形式。

为了获得这样的效果,Combine()方法需要:

1.在方法声明的最后一个参数前添加params关键字;

2.将最后一个参数声明为数组。

像这样声明了参数数组之后,每个参数都作为参数数组的成员来访问。Combine()方法迭代paths数组的每个元素并调用System.IO.Path.Combine()。该方法自动合并路径中的不同部分,并正确使用平台特有的目录分隔符。注意PathEx.Combine()完全等价于Path.Combine(),只是能处理数量可变的参数,而非只能处理两个。

参数数组要注意以下几点:

·参数数组不一定是方法的唯一参数,但必须是最后一个。由于只能放在最后,所以最多只能有一个参数数组。

·调用者可指定和参数数组对应的零个实参,这会使传递的参数数组包含零个数据项。

·参数数组是类型安全的——实参类型必须兼容参数数组的类型。

·调用者可传递一个实际的数组,而不是传递以逗号分隔的实参列表。最终生成的CIL代码一样。

·如目标方法的实现要求一个最小的参数数量,请在方法声明中显式指定必须提供的参数。这样一来,遗漏必需的参数会导致编译器报错,而不必依赖运行时错误处理。例如,使用int Max(int first, params int[] operands)而不是int Max(params int[] operands),确保至少有一个整数实参传给Max()。

可用参数数组将数量可变的多个同类型参数传给方法。本章后面的5.7节将讨论如何支持不同类型的、数量可变的参数。

设计规范

·能处理任何数量(包括零个)额外实参的方法要使用参数数组。

关于前面代码清单5.17中的Combine()函数,这里有一个补充说明:该函数是为了演示如何使用参数数组而刻意编写的。实际上System.IO.Path.Combine()方法已经有一个现成的重载版本可以接受参数数组。