CLR via C#, 4th -- 【设计类型】 -- 第9章参 数
9.1 可选参数和命名参数
设计方法的参数时,可为部分或全部参数分配默认值。然后,调用这些方法的代码可以选择不提供部分实参,使用其默认值。此外,调用方法时可通过指定参数名称来传递实参。
public static class Program { private static Int32 s_n = 0; private static void M(Int32 x = 9, String s = "A", DateTime dt = default(DateTime), Guid guid = new Guid()) { Console.WriteLine("x={0}, s={1}, dt={2}, guid={3}", x, s, dt, guid); } public static void Main() { // 1. Same as: M(9, "A", default(DateTime), new Guid()); M(); // 2. Same as: M(8, "X", default(DateTime), new Guid()); M(8, "X"); // 3. Same as: M(5, "A", DateTime.Now, Guid.NewGuid()); M(5, guid: Guid.NewGuid(), dt: DateTime.Now); // 4. Same as: M(0, "1", default(DateTime), new Guid()); M(s_n++, s_n++.ToString()); // 5. Same as: String t1 = "2"; Int32 t2 = 3; // M(t2, t1, default(DateTime), new Guid()); M(s: (s_n++).ToString(), x: s_n++); } }
9.1.1 规则和原则
如果在方法中为部分参数指定了默认值
- 可为方法、构造器方法和有参属性(C#索引器)的参数指定默认值。还可为属于委托定义一部分的参数指定默认值。以后调用该委托类型的变量时可省略实参来接受默认值。
- 有默认值的参数必须放在没有默认值的所有参数之后。换言之,一旦定义了有默认值的参数,它右边的所有参数也必须有默认值。但这个规则有一个例外:“参数数组”这种参数必须放在所有参数(包括有默认值的这些)之后,而且数组本身不能有一个默认值。
- 默认值必须是编译时能确定的常量值。那么,哪些参数能设置默认值?这些参数的类型可以是C#认定的基元类型(参见第5章的表5-1)。还包括枚举类型,以及能设为null的任何引用类型。值类型的参数可将默认值设为值类型的实例,并让它的所有字段都包含零值。可以用default关键字或者new关键字来表达这个意思:两种语法将生成完全一致的IL代码。
- 不要重命名参数变量,否则任何调用者以传参数名的方式传递实参,它们的代码也必须修改。
- 如果方法从模块外部调用,更改参数的默认值具有潜在的危险性。call site(call site是发出调用的地方,可理解成调用了一个目标方法的表达式或代码行。)在它的调用中嵌入默认值。如果以后更改了参数的默认值,但没有重新编译包含callsite的代码,它在调用你的方法时就会传递旧的默认值。可考虑将默认值0/null作为哨兵值使用,从而指出默认行为。这样一来,即使更改了默认值,也不必重新编译包含了call site的全部代码。
- 如果参数用ref或out关键字进行了标识,就不能设置默认值。因为没有办法为这些参数传递有意义的默认值。
使用可选或命名参数调用方法时
- 实参可按任意顺序传递,但命名实参只能出现在实参列表的尾部。
- 可按名称将实参传给没有默认值的参数,但所有必须的实参都必须传递(无论按位置还是按名称),编译器才能编译代码。
- C#不允许省略逗号之间的实参,比如M(1,,DateTime.Now),因为这会造成对可读性的影响,程序员将被迫去数逗号。对于有默认值的参数,如果想省略它们的实参,以传参数名的方式传递实参即可。
- 如果参数要求ref/out,为了以传参数名的方式传递实参,请使用下面这样的语法:
//方法声明: private static void M(ref Int32 x){...} //方法调用: Int32 a =5; M(x:ref a);
9.1.2 DefaultParameterValueAttribute和OptionalAttribute
在C#中,一旦为参数分配了默认值,编译器就会在内部向该参数应用定制特性System.Runtime.InteropServices.OptionalAttribute。该特性会在最终生成的文件的元数据中持久性地存储下来。此外,编译器向参数应用System.Runtime.InteropServices.DefaultParameterValueAttribute特性,并将该属性持久性存储到生成的文件的元数据中。然后,会向DefaultParameterValueAttribute的构造器传递你在源代码中指定的常量值。
9.2 隐式类型的局部变量
C#能根据初始化表达式的类型推断方法中的局部变量的类型
private static void ImplicitlyTypedLocalVariables() { var name = "Jeff"; ShowVariableType(name); // Displays: System.String // var n = null; // Error: Cannot assign <null> to an implicitlytyped local variable var x = (String)null; // OK, but not much value ShowVariableType(x); // Displays: System.String var numbers = new Int32[] { 1, 2, 3, 4 }; ShowVariableType(numbers); // Displays: System.Int32[] // Less typing for complex types var collection = new Dictionary<String, Single>() { { "Grant", 4.0f } }; // Displays: System.Collections.Generic.Dictionary`2[System.String,System.Single] ShowVariableType(collection); foreach (var item in collection) { // Displays: System.Collections.Generic.KeyValuePair`2[System.String,System.Single] ShowVariableType(item); } } private static void ShowVariableType<T>(T t) { Console.WriteLine(typeof(T)); }
ImplicitlyTypedLocalVariables方法中的第一行代码使用C#的var关键字引入了一个新的局部变量。为了确定name变量的类型,编译器要检查赋值操作符(=)右侧的表达式的类型。
ImplicitlyTypedLocalVariables方法内部的第二个赋值(被注释掉了)会产生编译错误:error CS0815:无法将"<null>"赋予隐式类型的局部变量。这是由于null能隐式转型为任何引用类型或可空值类型。因此,编译器不能推断它的确切类型。但在第三个赋值语句中,我证明只要显式指定了类型(本例是String),就可以将隐式类型的局部变量初始化为null,这样做虽然可行,但意义不大,因为可以写String x=null;来获得同样效果。
在方法中使用匿名类型时必须用到C#的隐式类型局部变量,详情参见第10章“属性”
不能用var声明方法的参数类型。原因显而易见,因为编译器必须根据在call site传递的实参来推断参数类型,但call site可能一个都没有,也可能有好多个。除此之外,不能用var声明类型中的字段。C#的这个限制是出于多方面的考虑。一个原因是字段可以被多个方法访问,而C#团队认为这个协定(变量的类型)应该显式陈述。另一个原因是一旦允许,匿名类型(第10章)就会泄露到方法的外部。
不要混淆dynamic和var
用var声明局部变量只是一种简化语法,它要求编译器根据表达式推断具体数据类型。var关键字只能声明方法内部的局部变量,而dynamic关键字适用于局部变量、字段和参數。
表达式不能转型为var,但能转型为dynamic.必须显式初始化用var声明的变量,但无需初始化用dynamic声明的变量。欲知C#的dynamic类型的详情,请参见5.5节"dynamic基元类型”
9.3 以传引用的方式向方法传递参数
CLR默认所有方法参数都传值。传递引用类型的对象时,对象引用(或者说指向对象的指针)被传给方法。注意引用(或指针)本身是传值的,意味着方法能修改对象,而调用者能看到这些修改。对于值类型的实例,传给方法的是实例的一个副本,意味着方法将获得它专用的一个值类型实例副本,调用者中的实例不受影响。
CLR允许以传引用而非传值的方式传递参数。C#用关键字out或ref支持这个功能。两个关键字都告诉C#编译器生成元数据来指明该参数是传引用的。编译器将生成代码来传递参数的地址,而非传递参数本身。
CLR不区分out和ref,意味着无论用哪个关键字,都会生成相同的IL代码。另外,元数据也几乎完全一致,只有一个bit除外,它用于记录声明方法时指定的是out还是ref。但C#编译器是将这两个关键字区别对待的,而且这个区别决定了由哪个方法负责初始化所引用的对象。如果方法的参数用out来标记,表明不指望调用者在调用方法之前初始化好了对象。被调用的方法不能读取参数的值,而且在返回前必须向这个值写入。相反,如果方法的参数用ref来标记,调用者就必须在调用该方法前初始化参数的值,被调用的方法可以读取值以及/或者向值写入。
public sealed class Program { public static void Main() { Int32 x; // x is uninitialized. GetVal(out x); // x doesn’t have to be initialized. Console.WriteLine(x); // Displays "10" } private static void GetVal(out Int32 v) { v = 10; // This method must initialize v. } }
为大的值类型使用out,可提升代码的执行效率,因为它避免了在进行方法调用时复制值类型实例的字段。
public sealed class Program { public static void Main() { Int32 x = 5; // x is initialized. AddVal(ref x); // x must be initialized. Console.WriteLine(x); // Displays "15" } private static void AddVal(ref Int32 v) { v += 10; // This method can use the initialized value in v. } }
综上所述,从IL和CLR的角度看,out和ref是同一码事:都导致传递指向实例的一个指针。但从编译器的角度看,两者是有区别的。根据是out还是ref,编译器会按照不同的标准来验证你写的代码是否正确。
为什么C#要求必须在调用方法时指定out或ref?
毕竟,编译器知道被调用的方法需要的是out还是ref,所以应该能正确编译代码。事实上,C#编译器确实能自动采取正确的操作。但C#语言的设计者认为调用者应显式表明意图。只有这样,在call site(调用位置)那里,才能清楚地知道被调用的方法是否需要对传递的变量值进行更改。
另外,CLR允许根据使用的是out还是ref参数对方法进行重载。两个重载方法只有out和ref的区别则不合法,因为两个签名的元数据形式完全相同。
为值类型使用out和ref,效果等同于以传值的方式传递引用类型。对于值类型,out和ref允许方法操纵单一的值类型实例。调用者必须为实例分配内存,被调用者则操纵该内存(中的内容),对于引用类型,调用代码为一个指针分配内存(该指针指向一个引用类型的对象),被调用者则操纵这个指针。正因为如此,仅当方法“返回”对“方法知道的一个对象”的引用时,为引用类型使用out和ref才有意义。
using System; using System.IO; public sealed class Program { public static void Main() { FileStream fs; // fs is uninitialized // Open the first file to be processed. StartProcessingFiles(out fs); // Continue while there are more files to process. for (; fs != null; ContinueProcessingFiles(ref fs)) { // Process a file. fs.Read(...); } } private static void StartProcessingFiles(out FileStream fs) { fs = new FileStream(...); // fs must be initialized in this method } private static void ContinueProcessingFiles(ref FileStream fs) { fs.Close(); // Close the last file worked on. // Open the next file, or if no more files, "return" null. if (noMoreFilesToProcess) fs = null; else fs = new FileStream (...); } }
下例演示了如何用ref关键字实现一个用于交换两个引用类型的方法:
public static void Swap(ref Object a, ref Object b) { Object t = b; b = a; a = t; }
为了交换对两个String对象的引用,你或许以为代码能像下面这样写:
public static void SomeMethod() { String s1 = "Jeffrey"; String s2 = "Richter"; Swap(ref s1, ref s2); Console.WriteLine(s1); // Displays "Richter" Console.WriteLine(s2); // Displays "Jeffrey" }
对于以传引用的方式传给方法的变量,它的类型必须与方法签名中声明的类型相同.
public static void SomeMethod() { String s1 = "Jeffrey"; String s2 = "Richter"; // Variables that are passed by reference // must match what the method expects. Object o1 = s1, o2 = s2; Swap(ref o1, ref o2); // Now cast the objects back to strings. s1 = (String) o1; s2 = (String) o2; Console.WriteLine(s1); // Displays "Richter" Console.WriteLine(s2); // Displays "Jeffrey" }
9.4 向方法传递可变数量的参数
方法有时需要获取可变数量的参数。
为了接受可变数量的参数,方法参数要应用params关键字
static Int32 Add(params Int32[] values) { // NOTE: it is possible to pass the 'values' // array to other methods if you want to. Int32 sum = 0; if (values != null) { for (Int32 x = 0; x < values.Length; x++) sum += values[x]; } return sum; }
params只能应用于方法签名中的最后一个参数。
public static void Main() { // Displays "15" Console.WriteLine(Add(new Int32[] { 1, 2, 3, 4, 5 } )); }
数组能用任意数量的一组元素来初始化,再传给Add方法进行处理。尽管上述代码可以通过编译并能正确运行,但并不好看。我们当然希望能像下面这样调用Add方法:
public static void Main() { // Displays "15" Console.WriteLine(Add(1, 2, 3, 4, 5)); }
由于params关键字的存在,所以的确能这样做。params关键字告诉编译器向参数应用定制特性System.ParamArrayAttribute的一个实例。
C#编译器检测到方法调用时,会先检查所有具有指定名称、同时参数没有应用ParamArray特性的方法。找到匹配的方法,就生成调用它所需的代码。没有找到,就接着检查应用了ParamArray特性的方法。找到匹配的方法,编译器先生成代码来构造一个数组,填充它的元素,再生成代码来调用所选的方法。
上个例子并没有定义可获取5个Int32兼容实参的Add方法。但编译器发现在一个Add方法调用中传递了一组Int32值,而且有一个Add方法的Int32数组参数应用了ParamArray特性。因此,编译器认为这是一个匹配,会生成代码将实参保存到一个Int32类型的数组中,再调用Add方法并传递该数组。最终结果就是,你可以写代码直接向Add方法传递一组实参,而编译器会生成代码,像前面的第一个版本的方法调用那样,帮你构造和初始化一个数组来容纳这些实参。
只有方法的最后一个参数才可以用params关键字(ParamArrayAttribute)标记。另外,这个参数只能标识一维数组(任意类型),可为这个参数传递null值,或传递对包含零个元素的一个数组的引用。
public static void Main() { // Both of these lines display "0" Console.WriteLine(Add()); // passes new Int32[0] to Add Console.WriteLine(Add(null)); // passes null to Add: more efficient (no array allocated) }
重要提示
注意,调用参数数量可变的方法对性能有所影响(除非显式传递null)毕竟,数组对象必须在堆上分配,数组元素必须初始化,而且数组的内存最终需要垃圾回收。要减少对性能的影响,可考虑定义几个没有使用params关键字的重载版本。
9.5 参数和返回类型的设计规范
声明方法的参数类型时,应尽量指定最弱的类型,宁愿要接口也不要基类。例如,如果要写方法来处理一组数据项,最好是用接口(比如1Enumerable<T>)声明参数,而不要用强数据类型(比如List<T>)或者更强的接口类型(比如1Collection<T>或IList<T>)
// Desired: This method uses a weak parameter type public void ManipulateItems<T>(IEnumerable<T> collection) { ... } // Undesired: This method uses a strong parameter type public void ManipulateItems<T>(List<T> collection) { ... }
原因是调用第一个方法时可传递数组对象、List<T>对象、String对象或者其他对象-只要对象的类型实现了IEnumerable<T>接口。相反,第二个方法只允许传递List<T>对象,不接受数组或String对象。显然,第一个方法更好,它更灵活,适合更广泛的情形。
当然,如果方法需要的是列表(而非任何可枚举的对象),就应该将参数类型声明为IList<T>,但仍然要避免将参数类型声明为List<T>,声明为IList<T>,调用者可向方法传递数组和实现了List<T>的其他类型的对象。
// Desired: This method uses a weak parameter type public void ProcessBytes(Stream someStream) { ... } // Undesired: This method uses a strong parameter type public void ProcessBytes(FileStream fileStream) { ... }
第一个方法能处理任何流,包括FileStream,NetworkStream和MemoryStream等。第二个只能处理FileStream流,这限制了它的应用。
相反,一般最好是将方法的返回类型声明为最强的类型(防止受限于特定类型)。
// Desired: This method uses a strong return type public FileStream OpenFile() { ... } // Undesired: This method uses a weak return type public Stream OpenFile() { ... }
第一个方法是首选的,它允许方法的调用者将返回对象视为FileStream对象或者Stream对象。但第二个方法要求调用者只能将返回对象视为Stream对象。总之,要确保调用者在调用方法时有尽量大的灵活性,使方法的适用范围更大。
9.6 常量性
有的语言(比如非托管C++)允许将方法或参数声明为常量,从而禁止实例方法中的代码更改对象的任何字段,或者更改传给方法的任何对象。CLR没有提供这个功能。
首先要注意,非托管C++将实例方法或参数声明为const只能防止程序员用一般的代码来更改对象或参数。方法内部总是可以更改对象或实参的。这要么是通过强制类型转换来去掉“常量性”,要么通过获取对象/实参的地址,再向那个地址写入。
实现类型时,开发人员可以避免写操纵对象或实参的代码。例如,String类就没有提供任何能更改String对象的方法,所以字符串是不可变(immutable)的。
此外,Microsoft很难为CLR赋予验证常量对象/实参未被更改的能力。CLR将不得不对每个写入操作进行验证,确定该写入针对的不是常量对象。这对性能影响很大。当然,如果检测到有违反常量性的地方,会造成CLR抛出异常。此外,如果支持常量性,还会给开发人员带来大量复杂性。例如,如果类型是不可变的,它的所有派生类型都不得不遵守这个约定。除此之外,在不可变的类型中,字段也必须不可变。
浙公网安备 33010602011771号