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 implicitly­typed 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#用关键字outref支持这个功能。两个关键字都告诉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的区别则不合法,因为两个签名的元数据形式完全相同。
为值类型使用outref,效果等同于以传值的方式传递引用类型。对于值类型,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 (...);   
   }  
}
View Code

下例演示了如何用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抛出异常。此外,如果支持常量性,还会给开发人员带来大量复杂性。例如,如果类型是不可变的,它的所有派生类型都不得不遵守这个约定。除此之外,在不可变的类型中,字段也必须不可变。

posted @ 2019-12-07 23:25  FH1004322  阅读(127)  评论(0)    收藏  举报