第5章 方法和参数
需要新的思维来管理这种复杂性程序,==“过程式”或“结构化”==编程的基本思路就是提供对语句分组来构成单元的构造。此外,可通过结构化编程将数据传给一个语句分组,在这些语句执行完毕后返回结果。
5.1 方法的调用
初学者主题:什么是方法
设计规范
•方法总是和类型关联。类型将相关方法分为一组。
•方法通过“实参”接受数据,实参由方法的参数或形参定义。参数是调用者用于向被调用的方法传递数据的变量。方法通过返回值将数据返回调用者。
•方法调用由方法名称和实参列表构成。完全限定的方法名称包括命名空间、类型名和方法名、每部分以句点风格。
5.1.1 命名空间
•命名空间是一种分类机制,用于分组功能相关的所有类型。命名空间是分级的、级数任意,一般不超过6级。
•几乎所有的C#程序都要使用System 命名空间中的类型。
•调用方法并非一定要提供命名空间。例如调用的方法与发出调用的方法在同一个命名空间,就没必要指定命名空间。
表5.1 常用命名空间
命名空间 | 描述 |
---|---|
System | 包含基元类型、以及用于类型转换、数学计算、程序调用以及环境管理的类型 |
System.Collection.Generics | 包含使用泛型的强类型集合 |
System.Drawing | 包含在显示设备上绘图和进行图象处理的类型 |
System.IO | 包含用于文件和目录处理类型 |
System.Linq | 包含使用“语言集成查询”(LINQ)对几何数据进行查血的类和接口 |
System.Text | 包含用于处理字符串和各种文本编码的类型,以及在不同编码方式之间转换的类型 |
System.Text.RegularExpressions | 包含用于处理正则表达式的类型 |
Systme.Threading 包含用于多线程编程的类型 | |
System.Web | 包含用于实现浏览器到服务器通信的类型(一般通过HTTP进行)。该命名空间中的功能用于支持ASP.NET |
System.Windows | 包含用WPF创建富用户界面的类型,WPF用XAML进行声明性UI设计 |
System.Xml | 为XML处理提供基于标准的支持。 |
设计规范
•要为命名空间使用PascalCase大小写 *考虑组织源代码文件目录结构以匹配命名空间层次结构。
5.1.2 类型名称
类型本质是对方法及其相关数据进行分组的一种方式。例如,Console类型包含常用的Write()、ReadLine()方法。所有这些方法都是在同一个组内。
5.1.3 作用域
一个事物的“作用域”是可用非限定名称引用它的那个区域,两个方法在同一个类型了中声明,一个方法调用另一个就不需要类型限定符,因为这两个方法具有整个包容类型的作用域。
5.1.4 方法名称
每个方法调用都要指定一个方法名称。它可能用可能不用命名空间和类型名称加以限定。
5.1.5 形参和实参
调用者为形参提供的值称为实参,每个实参都要和一个形参形成对应。
5.1.6 方法返回值
System.Console.ReadLine()没有任何参数,因为该方法声明为不获取任何参数。但这个方法有返回值。可利用返回值将调用方法所产生的结果返回调用者。
class Program
{
static void Main()
{
System.Console.Write("Enter your first name:");
System.Console.WriteLine("Hello {0}!",System.Console.ReadLine());
}
}
5.1.7 对比语句和方法调用
虽然在一个语句中包含多个方法调用能减少编程量,但不一定能增强可读性,而且很少带来性能上的优势。开发者应该更注重代码的可读性,而不要将过多的精力放在写简短的代码上。
注意:为了使代码一目了然,进而在长时间里更加容易维护,可读性是关键。
5.2 方法的声明
5.2.1 参数声明
可在方法声明的圆括号中添加参数列表。列表中的每个参数都包含参数类型和参数名称,每个参数以逗号分隔。
设计规范
要为参数名使用camelCase大小写。
5.2.2 方法返回类型声明
使用return之后,在return以后的语句可能==“不可到达”==,编译器会发出警告。 C#允许提前返回,但是为了代码的可读性,且使代码更易维护,应尽量确定单一的退出位置,而不是在方法的多个代码路径中散步多个return语句。
5.2.3 表达式主体方法
有些方法过于简单,为简化这些方法定义,允许用表达式代替完整方法主体。
static string GetFullName(string firstName,string lastName)=>
表达式主体方法不是用大括号定义方法主体,而是用=>操作符。该操作符的结果数据类型必须与方法返回类型匹配。换言之,虽然没有显式的return语句,但表达式本身的返回类型必须与方法声明的返回类型匹配。
表达式主体方法是大量方法主体声明的语法快捷方法。正因如此,其应用应限于最简单的方法实现,例如单行表达式。
5.3 using指令
完全限定的名称可能很长、很笨拙。可将一个或多个命名空间的所有类型“导入”文件,这样在使用时就不需要完全限定。这可通过using指令(通常在文件顶部)来实现。
System.Console.WriteLine("Hello World");
等价于:
using System;
class HelloWorld
{
static void Main()
{
Console.WriteLine("Hello World");
}
}
虽然添加了using System,但使用System的某个子命名空间中的类型时还是不能省略System。例如,要访问System.Text中的StringBuilder类型,必须另外添加一个using System.Text指令或者对类型进行完全限定(System.Text.StringBuilder),而不能只是写Text.StringBuilder。简单地说,using指令不“导入”任何嵌套命名空间中的类型。嵌套命名空间(由命名空间中的句点符号来标识)必须显式导入。
高级主题:嵌套using指令
using指令不仅可以在文件顶部使用,还可以在命名空间声明的顶部使用。
namespace EssentialCSharp
{
using System;
class HelloWorld
{
static void Main()
{
Console.WriteLine("Hello World");
}
}
}
在文件顶部和命名空间声明的顶部使用using指令的区别在于,后者的using指令在声明的命名空间内有效。只如果在EssentialCSharp命名空间前后声明了新命名空间,新命名空间不会受别的命名空间中的using System指令的影响。但我们很少写这样的代码,特别是根据约定,每个文件只应该有一个类型声明。
5.3.1 using static 指令
using允许省略命名空间限定符来简化类型名称。而using static 指令允许将命名空间和类型名称都省略,只需写静态成员名称。例如WriteLine()。
using static System.Console;
class HeiYou
{
static void Main()
{
string firstName;
string lastName;
WriteLine("Hey you");
}
}
5.3.2 使用别名
还可利用using指令为命名空间或类型取一个别名。别名是在using指令起作用的范围内可以使用的替代名称。别名的两个最常见的用途是消除两个同名类型的歧义和缩写长名称。例如在代码清单5.10中,CountDownTimer别名引用了System.Timers.Timer类型。仅添加using System.Timers指令不足以完全限定Timer类型,原因是System.Threading也包含Timer类型,所以在代码中直接用Timer会产生歧义。
using System;
using System.Threading;
using CountDownTimer=System.Timers.Timer;
class HelloWorld
{
static void Main()
{
CountDownTimer timer;
}
}
5.4 Main()的返回值和参数
到目前为止,可执行体的所有Main()方法采用的都是最简单的声明。这些Main()方法声明不包含任何参数或非void返回类型。
但C#支持在执行程序时提供命令行参数,并允许从Main()方法返回状态标识符。“运行时”通过一个string数组参数将命令行参数传给Main()。要获取参数,访问数组就可以了,
代码清单5.12对此进行了演示。程序的目的是下载指定URL位置的文件。第一个命令行参数指定URL,第二个指定存盘文件名。代码从一个switch语句开始,根据参数数量(args.Length)采取不同操作:1.如果没有两个参数,就显示一条错误消息,指出必须提供URL和文件名;2.如果有两个参数,表明用户提供了URL和存盘文件名。
using System;
using System.IO;
using System.Net.Http;
class Program
{
static int Main(string[] args)
{
int resulut;
switch(args.Length)
{
default:
Console.WriteLine("错误:你必须提供网址和文件名");
Console.WriteLine("有效用:下载网址和文件");
result=1;
break;
case 2:
WebClient webClient=new WebClient();
webClient.DownloadFile(args[0],args[1]);
result=0;
break;
}
return result;
}
}
成功获取存盘文件名,就用它保存下载的文件。否则应显示帮助文本。Main()方法还会返回一个int,而不是像往常那样返回void。返回值对于Main()声明来说是可选的。但如果有返回值,程序就可以将状态码返回给调用者(比如脚本或批处理文件)。根据约定,非零返回值代表出错。
5.5 高级方法参数
之前一直通过方法的return语句返回数据。本届描述方法如何通过自己的参数返回数据。
5.5.1 值参数
参数默认传值,参数值会拷贝到目标参数中。
class Program
{
static void Main()
{
int num=10;
int num2=Combine(num);
Console.WriteLine(num2);
}
static int Combine(string num)
{
int nummber;
number=num-5;
return number;
}
}
Combine()方法返回后,Main()中对应的变量仍会保持其初始值不变,因为在调用方法时,只是将变量的值拷贝了一份给方法。调用栈在一次调用的末尾“展开”的时候,拷贝的数据会被丢弃。
初学者主题:匹配调用者变量与参数名
调用者的变量名与被调用方法中的参数名相匹配,这是为了增强可读性。
高级主题:比较引用类型和值类型
5.5.2 引用参数(ref)
class Program
{
static void Main()
{
string first="hello";
string second="goodbye";
Swap(ref first,ref second);
Cosole.WriteLine(first);
Cosole.WriteLine(second);
}
static void Swap(ref string x,ref string y)
{
string temp=x;
x=y;
y=temp;
}
}
结果是:
goodbye
hello
如果被调用的方法将参数指定为ref,调用者调用该方法时提供的实参应该是附加了ref前缀的变量而不是值。这样调用者就显式确认了目标方法可对它接收到的任何ref参数进行重写赋值。此外,调用者应该初始化传引用的局部变量,因为被调用的方法可能从ref参数读取数据而不先对其赋值。 换言之、作用只是为现有变量分配参数名,而非创建新变量并将实参的值拷贝给它。
5.5.3 输出参数(out)
namespace AddisonWesley.Michaelis.EssentialCSharp.Chapter05.Listing05_15
{
using System;
public class ConvertToPhoneNumber
{
public static int Main(string[] args)
{
if(args.Length == 0)
{
Console.WriteLine(
"ConvertToPhoneNumber.exe <phrase>");
Console.WriteLine(
"'_' indicates no standard phone button");
return 1;
}
foreach(string word in args)
{
foreach(char character in word)
{
#if !PRECSHARP7
if(TryGetPhoneButton(character, out char button))
#else
char button;//未被初始化
if(TryGetPhoneButton(character, out button))
#endif // PRECSHARP7
{
Console.Write(button);
}
else
{
Console.Write('_');
}
}
}
Console.WriteLine();
return 0;
}
static bool TryGetPhoneButton(char character, out char button)
{
bool success = true;
switch(char.ToLower(character))
{
case '1':
button = '1';
break;
case '2':
button = '2';
break;
case '3':
button = '3';
break;
case '4':
button = '4';
break;
case '5':
button = '5';
break;
case '6':
button = '6';
break;
case '7':
button = '7';
break;
case '8':
button = '8';
break;
case '9':
button = '9';
break;
case '0':
button = '0';
break;
case 'a':
case 'b':
case 'c':
button = '2';
break;
case 'd':
case 'e':
case 'f':
button = '3';
break;
case 'g':
case 'h':
case 'i':
button = '4';
break;
case 'j':
case 'k':
case 'l':
button = '5';
break;
case 'm':
case 'n':
case 'o':
button = '6';
break;
case 'p':
case 'q':
case 'r':
case 's':
button = '7';
break;
case 't':
case 'u':
case 'v':
button = '8';
break;
case 'w':
case 'x':
case 'y':
case 'z':
button = '9';
break;
case '+':
button = '0';
break;
case '-':
button = '-';
break;
default:
// Set the button to indicate an invalid value
button = '_';
success = false;
break;
}
return success;
}
}
}
如前所述,用作ref参数的变量必须在传给方法前赋值,因为被调用方法可能直接从变量中读取值。 这时更安全的做法是以传引用的方式传入一个未初始化的局部变量。 为此,代码需要用关键字out修饰参数类型。 注意 所有正常的代码路径都必须对out参数赋值
5.5.4 只读传引用(in)
int Method(in int number)
{
//
}
C# 7.2支持以传引用的方式传入只读值类型。该特性以传引用的方式传入值类型参数,并且让该参数不能被方法修改。这样不仅避免了每次调用方法都创建值类型的拷贝,而且不用担心值类型参数被修改。换言之,其作用是在传值时减少拷贝量,同时把它标识为只读,从而增强性能。该语法要为参数添加in修饰符。
5.5.5 返回引用
byte[] _Image;
public ref byte[] Image { get { return ref _Image};}
返回引用有两个重要的限制,两者都和对象生存期有关:(1)对象仍被引用时不应被垃圾回收;(2)对象的所有引用都消失之后,不应再占用内存。
为符合这些限制,从方法返回引用时只能返回:
•对字段或数组元素的引用。
•其他返回引用的属性或方法。
•作为参数传给“返回引用的方法”的引用。
5.5.6 参数数组
namespace AddisonWesley.Michaelis.EssentialCSharp.Chapter05.Listing05_17
{
using System;
using System.IO;
public class Program
{
public static void Main()
{
string fullName;
// ...
// Call Combine() with four parameters
fullName = Combine(
Directory.GetCurrentDirectory(),
"bin", "config", "index.html");
Console.WriteLine(fullName);
// ...
// Call Combine() with only three parameters
fullName = Combine(
Directory.GetParent(Directory.GetCurrentDirectory()).FullName,
"Temp", "index.html");
Console.WriteLine(fullName);
// ...
// Call Combine() with an array
fullName = Combine(
new string[] {
$"C:{Path.DirectorySeparatorChar}", "Data",
"HomeDir", "index.html" });
Console.WriteLine(fullName);
// ...
}
static string Combine(params string[] paths)
{
string result = string.Empty;
foreach(string path in paths)
{
result = System.IO.Path.Combine(result, path);
}
return result;
}
}
}
参数数组不一定是方法的唯一参数,但必须是最后一个,由于只能放在最后,所以最多只能有一个参数数组。
-
调用者可指定和参数数组对应的零个实参,这会使传递的参数数组包含零个数据项
-
参数数组是类型安全——实参类型必须兼容参数数组的类型
-
调用者可传递一个实际的数组,而不是传递以逗号分隔的参数列表,最终生成的CIL代码一样。
-
如目标方法的实现要求一个最起码的参数数量,请在方法声明中显式指定必须提供的参数。这样一来,遗漏必须参数会导致编译器报错。
设计规范
-
能处理任何数量包括零个额外实参的方法要使用参数数组。
5.6递归
M(x)
{
if x已达最小,不可继续分解[1]
返回结果
else
(1) 采取一些操作使问题变得更小
(2) 递归调用M来解决更小的问题
(3) 根据(1)和(2)计算结果
返回结果
}
初学者主题:无限递归错误 不遵守模式就可能出错,例如不能将问题变得更小,或者不能处理所有可能的最小情况,就会无限递归。
5.7 方法重载
一个类的所有方法都必须有唯一签名,C#根据方法名、参数数据类型或参数数量的差异来定义唯一性。注意方法返回类型不计入签名。两个方法只是返回类型不同会造成编译错误。 如一个类包含两个或者多个同名方法,就会发生方法重载。对于重载的方法,参数数量或数据类型肯定不同
**注意 ** 方法的唯一性取决于方法名、参数数据类型或参数数量的差异
方法重载是一种操作性多态。如由于数据变化造成同一个逻辑操作具有许多形式,就会发生多态。 基本思路是开发者只需在一个方法中实现核心逻辑,其他所有重载版本都调用那个方法。
注意 将核心功能放在单一方法中供其他重载方法调用,以后就只需要在核心方法中修改,其他方法将自动收益。
5.8 可选参数
可选参数
namespace AddisonWesley.Michaelis.EssentialCSharp.Chapter05.Listing05_20
{
using System.IO;
public static class LineCounter
{
public static void Main(string[] args)
{
int totalLineCount;
if(args.Length > 1)
{
totalLineCount =
DirectoryCountLines(args[0], args[1]);
}
else if(args.Length > 0)
{
totalLineCount = DirectoryCountLines(args[0]);
}
else
{
totalLineCount = DirectoryCountLines();
}
System.Console.WriteLine(totalLineCount);
}
static int DirectoryCountLines()
{
return 0;
// ...
}
/*
static int DirectoryCountLines(string directory)
{ ... }
*/
static int DirectoryCountLines(
string directory, string extension = "*.cs")
{
int lineCount = 0;
foreach(string file in
Directory.GetFiles(directory, extension))
{
lineCount += CountLines(file);
}
foreach(string subdirectory in
Directory.GetDirectories(directory))
{
lineCount += DirectoryCountLines(subdirectory);
}
return lineCount;
}
private static int CountLines(string file)
{
int lineCount = 0;
string? line;
FileStream stream =
new FileStream(file, FileMode.Open);
StreamReader reader = new StreamReader(stream);
line = reader.ReadLine();
while (line is object)
{
if (line.Trim() != "")
{
lineCount++;
}
line = reader.ReadLine();
}
reader.Dispose(); // Automatically closes the stream
return lineCount;
}
}
}
C#中,string? 表示可空的字符串类型。这意味着该变量可以存储字符串值,也可以存储空值(null)。这是C# 8.0 中引入的一种新的语法,用于表示可空引用类型。
具名参数
调用者可利用具名参数为一个参数显式赋值,而不是像以前那样只能依据参数顺序来决定哪个值赋给哪个参数。
using System.IO;
class Program
{
static void Main()
{
DisplayGreeting(first:"Inigo",lastName:"Montoya");
}
public static void DisplayGreeting(
string firstName,
string? middleName = null,
string? lastName = null)
{
//……
}
}
设计规范
要将参数名视为API的一部分;要强调API之间的版本兼容性,就避免改变名称。
高级主题:方法解析
编译器从一些列“适用”的方法中选择最终调用的方法时,依据的是那个方法最具体。假定有两个适用的方法,每个都要求将实参隐式转化为形参的类型,最终选择的是形参类型最具体(派生程度最大)的方法。 例如double比object更具体,有不是double的object,但没有不是object的double,所以double更具体。
5.9 用异常实现基本错误处理
初学者主题:42作为字符串和整数 二者类型不同,完全不一样,字符串由4和2两个字符构成,而int 42是数值42.
5.9.1 捕捉错误
#pragma warning disable CS1058 // A previous catch clause already catches all exceptions
#pragma warning disable CS0168 // Variable is declared but never used
namespace AddisonWesley.Michaelis.EssentialCSharp.Chapter05.Listing05_25
{
public class ExceptionHandling
{
public static int Main()
{
string firstName;
string ageText;
int age;
int result = 0;
System.Console.Write("Enter your first name: ");
firstName = System.Console.ReadLine();
System.Console.Write("Enter your age: ");
ageText = System.Console.ReadLine();
try
{
age = int.Parse(ageText);
System.Console.WriteLine(
$"Hi { firstName }! You are { age * 12 } months old.");
}
catch(System.FormatException exception)
{
System.Console.WriteLine(
$"The age entered ,{ageText}, is not valid.");
result = 1;
}
catch(System.Exception exception)
{
System.Console.WriteLine(
$"Unexpected error: { exception.Message }");
result = 1;
}
catch
{
System.Console.WriteLine("Unexpected error!");
result = 1;
}
finally
{
System.Console.WriteLine($"Goodbye { firstName }");
}
return result;
}
}
}
未提供任何异常处理,所以程序会向用户报告发生了**“未处理的异常”。系统中没有注册任何调试器,错误信息会出现在控制台上。 显然,这样的错误消息并不是特别有用,需要提供一个机制对错误进行恰当的处理,例如向用户报告一条更有意义的错误信息。 这个过程称为捕捉异常**。
首先用try块将可能抛出异常的代码包围起来,这个块以try关键字开始,try关键字告诉编译器,如果这里抛出了异常,那么某个catch块要尝试处理这个异常。catch块可指定异常的数据类型,只要数据类型与异常类型匹配,对应的catch块就会执行,但加入找不到合适的catch块,抛出的异常就会变成一个未处理的异常,就好像没有进行异常处理一样。
catch块的数量随意,但处理异常的顺序不要随意,catch块必须从具体到最不具体排列。System.Exception(异常)数据类型最不具体,所以它应该放到最后System.FormatException排在第一,因为它是所处理的最具体的异常。
无论try块的代码是否抛出异常,只要控制离开try块,finally块都会执行.finally提供一个位置,无论是否异常都要执行的代码.finally块最适合用来执行资源清理. 可以只写一个try块和一个finally块,而不懈任何catch块。
设计规范
避免从finally块抛出异常,因方法调用而隐式抛出的异常可以接受。 *要优先使用try/finally 而不是try/catch块来实现资源清理代码。 *要在抛出的异常中描述异常为什么发生。顺带说明为何防范更佳。
高级主题:Exception类继承
异常类型 | 描述 |
---|---|
System.EXception | 最“基本”的异常,其他所有异常类型都从它派生。 |
System.ArgumentException | 传给方法的参数无效 |
System.ApplicationException | 避免使用该异常。最开始是想区分系统异常和应用程序异常。貌似合理,实际不好用。 |
System.FormatException | 实参类型不符合规范 |
System.IndexOutOfRangeException | 试图访问不存在的数组或其他集合元素。 |
System.InvalidCastException | 无效的类型转换。 |
System.InvalidOperationException | 发生非预期的情况,应用程序不再处于有效工作状态。 |
System.NotImplementedException | 虽然找到了对应的方法签名,但该方法尚未完全实现。 |
System.NullReferenceExcption | 引用为空,没有指向一个实例 |
System.ArithmeticException | 发生被零除以外的无效数学运算。 |
System.ArrayTypeMismatchException | 试图将类型有误的元素存储到数组中 |
System.StackOverflowException | 发生非预期的深递归。 |
高级主题:常规catch
可指定一个无参的catch块,称为常规catch块,等价于获取object数据类型的catch块,例如catch(objectexception),由于所有类最终都从object派生,所有无参的catch块必须放在最后。
设计规范
•避免使用常规catch块,用捕捉System.Exception的catch块代替。
•避免捕捉无法从中完全恢复异常,这种异常不处理比不正确处理好。
•避免在重新抛出前捕捉和记录异常。要允许异常逃脱,直至它被正确处理。
5.9.2 使用throw语句报告错误
C#允许开发人员从代码中抛出异常,抛出异常需要有Exception的实例。
设计规范
•要在捕捉并重新抛出异常时使用空的throw语句,以便保留调用栈。
•要通过抛出异常而不是返回错误码来报告执行失败。
•不要让公共成员将异常作为返回值或者out参数,抛出异常来指明错误,不要把它们作为返回值来指明错误。
避免使用异常处理来处理预料之中的情况
异常是为了跟踪例外的、事先没有料到的、可能造成严重后果的情况设计的。
高级主题:
使用TryParse()执行数值交换。