本章讨论如何将一组语句合成为方法。另外,还将介绍如何调用方法,向一个方法传送数据和接收方法中的数据。
除了讲解基本的方法定义和调用,本章还会提到一些高级概念——名空间,方法重载和递归。本章涉及的所有方法都是静态的。
在第一章的Helloworld程序中,你学习了如何定义一个Main()方法。本章,你将学会关于方法的更多细节,包括,使用参数传输数据。
调用方法
方法是什么?
到目前为止你写的所有代码语句都在同一个方法Main()中。随着程序的扩张,单一方法将越来越难懂,可读性下降。
一个方法就是将一些相关的语句分组,用以执行特定的动作。将语句组织成更大的结构。比如,方法main()是计算文件中行数。
清单4.1
class Program
{
static void Main(string[] args)
{
int lineCount;
string files;
DisplayHelpText();
files = GetFiles();
lineCount = CountLines(files);
DisplayLineCount(lineCount);
}
}
//...
本例,将语句分组放入了方法中。这一段就是讲上面几个方法是干什么用的。
方法总是和Class相关,这提供了分组方法的手段。调用方法的概念和发送消息到类中是一样的。
方法是通过参数接收数据。参数是一个变量,调用者将数据传递给目标方法。清单4.1中,files和lineCount作为了参数传递给CountLines()和DisplayLineCount()方法。方法也能通过返回值给调用者返回数据,GetFiles方法返回了一个值并赋值给files
现在,我们将重新审视System.Console.Write(),System.Console.WriteLine()和System.Console.ReadLine()方法。下面,来看看这些方法的常见调用,向控制台传送数据并显示出来。
清单4.2
class Program
{
static void Main(string[] args)
{
string firstName;
string lastName;
System.Console.WriteLine("Hey you!");
System.Console.Write("Enter your first name: ");
firstName = System.Console.ReadLine();
System.Console.Write("Enter your last name: ");
lastName = System.Console.ReadLine();
System.Console.WriteLine("Your full name is {0} {1}.",
firstName, lastName);
}
}
一个方法包括以下部分:名空间,类型名,方法名,参数,和返回类型。
名空间
名空间是将相关功能的类型分组的一种机制。这避免的类型名称冲突。比如,编译器可以区分在不同名空间中方法名"Program”。比如在Awl和Awl.Console的类型Program.Main()。
名空间可以再它们的名字内包含节点。这让名空间可以分级。这样做这是为了可读性。对编译器来说,都是单一层次。比如,System.Collections.Generics在System.Collectiond的下级。不过编译器会将它们看成独立的名空间
类型名字
当调用的目标方法不在同一个类中,需要类型名修饰静态方法(比如在Helloworld.Main()中调用的Console.WriteLine())。不过,当只有一个名空间是,C#允许方法省略包含类型(就像清单4.4那样)。类型民不是必须的,因为编译器可以从调用方法中推断出类型。如果编译器不能推测出,那么就必须为方法提供类型名。
本质上,类型就是将方法和相关的数据分类。比如,类型Console,它包含Write(),WriteLine()和ReadLine()等方法。所以这些方法都属于同一个组"Console”类型。
范围
你已经学习了声明的边界和作用域。范围也是定义调用的上下文。在同一个类中的两个调用就不需要类型名,因为范围是一样的。
方法名
你希望调用指定类型的方法后,就需要定义方法了After specifying which type contains the method you wish to call, it is time to identify the method itself。C#使用在类型名和方法名之间添加一个点,并在方法名后跟随一对圆括号。在圆括号中可以是方法需要的任何参数。
参数
所有方法都可以包含0~n个参数,每个参数是C#中的类型。比如,下面方法就有三个参数。
System.Console.WriteLine(“Your full name is {1}{0}”,lastName,firstName)
第一个是string类型,而后面两个是对象。编译器允许同时传送两个string类型的参数给后两个参数,所有类型可以和对象类型兼容。
方法返回值
在清单4.2中,相对于System.Console.WriteLine(),System.Console.ReadLine()方法就没有任何参数。不过,这个方法却有返回值。返回值的意思就是将方法的结果传送给调用者。将System.COnsole.ReadLine()返回值赋值给变量firstName。另外,可以将这个返回值直接作为参数。
清单4.3
class Program
{
static void Main(string[] args)
{
System.Console.Write("Enter your first name: ");
System.Console.WriteLine("Hello {0}!",
System.Console.ReadLine());
}
}
在执行时,System.Console.ReadLine()方法首先执行并将返回值直接传给System.Console.WriteLine()方法。
不是所有的方法都是返回值,System.Console.Write()和System.Console.WriteLine()都是这种方法。你会马上看到,将方法的返回类型指定成void,就像Helloworld中声明的Main方法就返回void。
语句:方法调用
清单4.3演示了语句和方法调用间的不同。 尽管System.Console.WriteLine("Hello {0}!",System.Console.ReadLine());是一个简单的语句,但它包含两个方法调用。一个语句通常会包含一个或更多表达式。在这个例子中,每个表达式都是方法调用。所以,方法调用也是语句的一部分。
尽管在一个语句内编写多个方法调用能减少代码数量,当同时也降低了可读性,而且也并不能显著提高性能。开发者需要简洁的可读性。
方法声明
本节进一步阐释声明一个方法(比如Main())包括参数,返回值。清单4.4包含了这些概念。
清单4.4
class Program
{
static void Main()
{
string firstName;
string lastName;
string fullName;
System.Console.WriteLine("Hey you!");
firstName = GetUserInput("Enter your first name: ");
lastName = GetUserInput("Enter your last name: ");
fullName = GetFullName(firstName, lastName);
DisplayGreeting(fullName);
}
static string GetUserInput(string prompt)
{
System.Console.Write(prompt);
return System.Console.ReadLine();
}
static string GetFullName(string firstName, string lastName)
{
return firstName + " " + lastName;
}
static void DisplayGreeting(string name)
{
System.Console.WriteLine("Your full name is {0}.", name);
return;
}
}
输出4.1
Hey you! |
在上面的代码中,声明了四个方法。在Main()函数中调用GetUserInput()方法,之后是调用GetFullName().这些方法都有返回值并附加了参数。另外,DisplayGreeting()没有返回任何数据。在C#中,方法不可能在类以外存在。
重构方法
将一组语句移入一个方法中,以避免产生一个巨大的方法这种形式就叫做重构。重构减少了代码复制,因为你可以在多个地方调用方法。重构也加强了代码可读性。编写代码时,最好的做法是不断的重新审视自己的代码,看看有没有机会重构。将一眼很难明白的代码块移入一个方法,并提供一个和代码动作相符合的名字。这个做法常常可以注释一个代码块。方法名就是用来描述实现了什么东西。
声明参数
看看方法DisplayGreeting()和GetFullName()的声明,在圆括号中出现的文字就是参数列表。每个参数都包含参数类型和参数名。用分号分隔每个参数。
参数实际上就是一个局部变量,命名规则通常是用驼峰风格。所以,也可以定义一个和方法内参数名相同的局部变量。
声明返回值
GetUserInput()和GetFullName()除了需要指定参数外,还都包含返回值。在方法声明时,就告诉了数据类型。对于这两个方法,其返回类型是string。方法只能有一个返回值。
一旦方法包含返回值,就要假设不会产生错误,这就需要在方法声明时为每个代码路径(或者是可以连续执行的语句块)指定返回语句。返回语句是return关键字后面跟随方法要返回的值。比如,GetFullName()方法的返回语句返回的是“firstName+“ ”+LastName”。C#编译器将强制匹配返回值和return关键字后指定数据的类型。
返回语句可以出现在方法的任何地方。只要在所有的代码路径上都有返回值。
清单4.5
class Program
{
static void Main()
{
string command;
//...
switch (command)
{
case "quit":
return;
// ...
}
// ...
}
}
返回语句就表明要调到方法的末尾,所以在switch语句就不用再使用break。一旦执行到return关键字,返回将结束调用。
如果特定的代码路径在return之后还有语句,编译器将提示一个警告,来表明有些语句不会被执行。为了代码的可读性,请使用exit,而不是在代码路径上到处使用返回语句。
用void作为返回类型表明这个方法没有返回值。这样的话,这个方法就不能赋值给某个变量或作为参数。此外,return语句后可以不跟任何值。比如,清单4.4的Main()方法是void的,它就没有return语句。而DisplayGreeting()却包含return语句,但不用返回任何结果。
名空间
名空间是为类型提供的构造机制。这提供了一个嵌套分组机制,以便明确所有类型。Developers will discover related types by examining other types。另外,通过名空间可以让在不同名空间的同名类型消除歧义。
using指令
It is possible to import types from one namespace into the enclosing namespace scope。这样,程序员就不需要提供完整的类型修饰符。C#使用using指令。通常是在文件的顶部使用。比如,清单4.6,Console就没有前缀System。
清单4.6
// The using directive imports all types from the
// specified namespace into the enclosing scope—in
// this case, the entire file.
using System;
class Program
{
static void Main()
{
// No need to qualify Console with System
// because of the using directive above.
Console.WriteLine("Hello, my name is Inigo Montoya");
}
}
名空间是嵌套的,但不分层级。比如说System是不能从一个包含System的更具体的名空间中省略。比如,代码需要访问System.Text中的类型,你可以将System.Text附加到using指令中,或者在类型上指定完整的修饰符。The using directive does not import any nested namespaces。嵌套名空间,是在名空间中由点号识别。这需要明确输入
用using指令指定名空间,代替在类型上指定完整的修饰符。实际上,所有的文件都包含using System语句。
using System指令带来的另一个有趣的影响是,String和string都是可以识别的。String是依赖System名空间,而string是个关键字。这两者都可以引用到System.String数据类型。这不会影响最终形成的CIL代码。
嵌套声明using
你还可以在名空间声明的头部包含using。就像清单4.7
清单4.7
namespace Awl.Michaelis.EssentialCSharp
{
using System;
class HelloWorld
{
static void Main()
{
// No need to qualify Console with System
// because of the using directive above.
Console.WriteLine("Hello, my name is Inigo Montoya");
}
}
}
别名
using指令可以给名空间或类型提供别名。这么用就可以了using CountDownTimer = System.Timers.Timer;
Main()的参数和返回值
当程序运行时,可以接收命令行参数,还可以从Main()方法返回状态标记。
运行时,将命令行参数传递给Main()方法的string数组。然后你需要做的就是访问这个数组来获取参数,就像清单4.40演示的那样。这个程序是从指定的URL中下载文件。第一个参数是URL,第二个是要下载的文件名。代码的开始时判断参数的数量(args.Length)。
- 如果参数数量是零,显示一个错误,指出需要提供URL。
- 如果只有一个参数,由第一个参数推测第二个参数。
- 两个参数都存在则表明用户已经提供了资源的URL位置和下载的目标文件名。
清单4.10
using System;
using System.IO;
using System.Net;
class Program
{
static void Main()
{
string targetFileName = null;
int result;
switch (args.Length)
{
case 0:
// No URL specified, so display error.
Console.WriteLine(
"ERROR: You must specify the "
+ "URL to be downloaded");
break;
case 1:
// No target filename was specified.
targetFileName = Path.GetFileName(args[0]);
targetFileName = args[1];
break;
case 2:
break;
}
if (targetFileName != null)
{
WebClient webClient = new WebClient();
webClient.DownloadFile(args[0], targetFileName);
result = 0;
}
else
{
Console.WriteLine(
"Downloader.exe <URL> <TargetFileName>");
result = 1;
}
return result
}
}
尽管命令行参数能传入main()方法的字符串数组中,不过还有另一种替代方法访问。System.Enviroment.GetCommandLineArgs()。
消除多个Main()方法的冲突
如果程序中包含两个Main()方法。这就需要在命令行上包含 /M开关。来指定Main()的详细类。
调用栈,和调用站台
代码执行的时候,方法可以调用别的方法。就像4.4中Main()方法调用GetUserInput().这叫做调用栈。随着程序复杂性的增加,调用栈也将变大。将调用从调用栈中移除的过程叫做栈平仓。 The result of method completion is that execution will return to the call site, which is the location from which the method was invoked
参数
到目前为止,本章所有的例子都有返回值,本节将演示通过方法参数返回数据。和可变数量的参数。
匹配调用者变量用参数名
在以前的代码中,你在目标方法中用参数名匹配变量名。这可带来可读性。不过这个名字和方法的行为完全不相关
值参数
默认时,参数是值型的。这就是说,变量的栈数据被拷贝到目标参数。比如清单4.11,Combine()中使用的参数都要拷贝变量。
清单4.11
class Program
{
static void Main()
{
string fullName;
string driveLetter = "C:";
string folderPath = "Data";
string fileName = "index.html";
fullName = Combine(driveLetter, folderPath, fileName);
Console.WriteLine(fullName);
}
static string Combine(
string driveLetter, string folderPath, string fileName)
{
string path;
path = string.Format("{1}{0}{2}{0}{3}",
System.IO.Path.DirectorySeparatorChar,
driveLetter, folderPath, fileName);
return path;
}
}
引用类型和值类型
参数是值还是引用并不重要。争论在于目标方法能分配调用者的原始值一个新值。一旦赋值完成,调用者拷贝就不能从赋值了。
引用类型是包含了存储数据内存指针。为引用类型赋值只是将地址拷贝给方法参数。方法还可以传递值类型,这将数值本身拷贝到参数中。
引用参数(ref)
清单4.12是交换数值的函数。
class Program
{
static void Main()
{
// ...
string first = "first";
string second = "second";
Swap(ref first, ref second);
System.Console.WriteLine(
@"first = ""{0}"", second = ""{1}""",
first, second);
// ...
}
static void Swap(ref string first, ref string second)
{
string temp = first;
first = second;
second = temp;
}
}
虽然swap方法没有返回值,两个变量值依然成功交换。这就是通过引用传递。和前面唯一的不同时,此处使用了ref关键字。这个关键字将调用类型改变成引用。所以调用者用新值更新了原始值。
当调用者将参数指定成ref后,当给方法传递变量时也要在变量前面添加ref。这样子,调用者就能很清楚的知道目标方法中被重新分配成ref的参数。此外,作为ref传递的变量需要初始化,因为目标方法能从ref参数中读取数据,而不用为他们赋值。在清单4.2中。temp被赋值了first。在变量传递前first已经被调用者初始化了。
输出参数(out)
将参数标记out关键字后,还可以只用于输出数据。在清单4.13中的GetPhoneButton()会返回电话按键对应的数字。
清单4.13
class Program
{
static void Main()
{
char button;
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 (GetPhoneButton(character, out button))
{
Console.Write(button);
}
else
{
Console.Write('_');
}
}
}
Console.WriteLine();
return 0;
}
static bool GetPhoneButton(char character, out char button)
{
bool success = true;
switch (char.ToLower(character))
{
case '1':
button = '1';
break;
case '2':
case 'a':
case 'b':
case 'c':
button = '2';
break;
// ...
case '-':
button = '-';
break;
default:
// Set the button to indicate an invalid value
button = '_';
success = false;
break;
}
return success;
}
}
GetPhoneButton()方法如果能正确的将字符转换成电话按键就返回true。用out修饰参数button,就能返回对应的按键。
当参数被标记为out后,编译器将检查方法内的所有代码路径。如果代码没有给button赋值,编译器将产生一个错误,来指示button没有被初始化。清单4.13中如果没有正确的电话按键将给button赋值“_ " 这将保证一直会被赋值。
参数数组
但目前为止所有的例子,参数的数目和目标方法声明的是一样的。然而,有时参数数量是可以变化的。看看清单4.11中Combine方法。在这个方法中,你传递了driverletter,folderpath和filename。如果路径下有多个文件,并且调用者想让此方法把这些文件组合成完整的路径怎么办?也许最好的方法是传递一个文件夹的字符串数组。然而这会让代码编写有些复杂。因为这需要构建一个数组作为参数传递。
还有一种简单的途径,C#提供了一个关键字用来编写可变参数数量的代码,在我们讨论方法声明以前,先观察清单4.14中的Main()声明。
清单4.14
class Program
{
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(
Environment.SystemDirectory,
"Temp", "index.html");
Console.WriteLine(fullName);
// ...
// Call Combine() with an array
fullName = Combine(new string[] { "C:\\", "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;
}
}
第一个调用,给方法传递了四个参数, 第二个只包含三个参数,最后一个传递了一个数组。换句话说,Combine()方法有一个可变数量的参数。而分隔它们的就是逗号,或是一个数组。
- Places params immediately before the last parameter in the method declaration
- 将数组声明为最后一个参数
对于一个数组参数声明,是可以访问数组参数中的每一个成员。在Combine()方法实现中,你迭代了paths数组中的元素,并调用了System.IO.Path.Combine()。这个方法自动将不同部分组合成路径,并使用恰当的路径分隔符。
下面是一些关于数组参数的值得注意的特性。
- 数组参数不一定是方法中唯一的参数,但数组参数必须是方法声明的最后一个参数,并且只能有一个数组参数
- 调用者不为数组参数指定任何值,那么这个数组也不会有任何元素。
- 数组参数是类型安全的——类型必须匹配数组的类型定义
- 不管调用者用一个数组作为参数还是用逗号分隔符,最终代码都可以被CIL识别。
- 如果目标方法实现中必须一个最小数量的参数,那么,这些参数应该在方法声明中明确出现。如果缺失了参数,就迫使产生编译器错误,而不是在运行期抛出错误处理。。比如,int Max(int first,parms int[] operads)就比Max(params int[] operads)要好,这样至少会有一个参数传入方法。
递归
递归有时会让方法实现变得十分简单。清单4.15是奇偶计算路径中C#源文件的行数。
清单4.15
using System.IO;
class Program
{
// Use the first argument as the directory
// to search, or default to the current directory.
public static void Main(string[] args)
{
int totalLineCount = 0;
string directory;
if (args.Length > 0)
{
directory = args[0];
}
else
{
directory = Directory.GetCurrentDirectory();
}
totalLineCount = DirectoryCountLines(directory);
System.Console.WriteLine(totalLineCount);
}
static int DirectoryCountLines(string directory)
{
int lineCount = 0;
foreach (string file in
Directory.GetFiles(directory, "*.cs"))
{
lineCount += CountLines(file);
}
foreach (string subdirectory in
Directory.GetDirectories(directory))
{
lineCount += DirectoryCountLines(subdirectory);
}
return lineCount;
}
private static int CountLines(string file)
{
string line;
int lineCount = 0;
FileStream stream =
new FileStream(file, FileMode.Open);
StreamReader reader = new StreamReader(stream);
line = reader.ReadLine();
while (line != null)
{
if (line.Trim() != "")
{
lineCount++;
}
line = reader.ReadLine();
}
reader.Close();
stream.Close();
return lineCount;
}
}
程序开始时,传递第一个参数给DirectoryCountLines(),或者当没有参数时就使用当前路径。此方法首先在当前路径下迭代所有文件,并将每个文件的行数求和。
读者会发现递归很繁琐。不管怎么样,这常常能让代码变的异常简练。尤其对于层级类型比如,文件系统。然而,尽管这具有可读性,但通常它运行并不快。如果性能是一个问题,开发者就要考虑替代递归的实现方案。这就要平衡可读性性和性能。
无穷递归错误
递归方法的实现中一个常见的程序错误是程序执行时堆栈溢出。这个发生的原因通常是无穷递归,方法会一直调用自身,而不能找到标记递归结束的出口点。唯一的解决办法就是程序员要核实所有使用递归的方法是有限调用的。
方法重载
清单4.15中DirectoryCountLines()的方法是计算*.Cs文件的行数。可是你想计算别的*.h *.cpp等文件行数,这个函数就无能为力了。所以,你就需要一个方法提供文件扩展名,但默认时依然以*.cs文件作为处理方法。
一个类中的所有方法必须是具有唯一签名的。C#通过方法名,参数类型,参数数量确定唯一性。不过并不包含参数的返回类型。比如当定义两个同名方法但返回类型不同,这将引发一个编译器错。当一个类中有多个方法有相同的名称,但参数数量或数据类型不一样,就需要重载方法。
方法重载是多态性的一类。当相同的逻辑操作,有多种实现,而数据不同,就出现了多态。调用WriterLine()传递字符串参数和传递整型参数的实现是不一样的。对于调用者来说,此方法处理写入的数据,并且不干预内部实现。
清单4.16