- A+
一、方法的结构
方法是一块具有名称的代码。可以使用方法的名称从别的地方执行代码,也可以把数据传入方法并接收数据输出。
方法是类的函数成员。方法主要有两个部分,如下图所示:方法头和方法体。
- 方法头指定方法的特征,包括:
- 方法是否返回数据,如果返回,返回什么类型;
- 方法的名称;
- 哪种类型的数据可以传递给方法或从方法返回,以及应如何处理这些数据。
- 方法体包含可执行代码的语句序列。执行过程从方法体的第一条语句开始,一直到整个方法结束。
下面的示例展示了方法头的形式。接下来阐述其中的每一部分。
例如,下面的代码展示了一个名称为 MyMethod
的简单方法,它多次调用 WriteLine
方法。
void MyMethod() { Console.WriteLine("First"); Console.WriteLine("First"); }
二、方法体内部的代码执行
方法体是一个块,是大括号括起的语句序列(参见[{{< ref "01-03-CSharp Hello World.md#六语句" >}}]({{< ref "01-03-CSharp Hello World.md#六语句" >}}))。块可以包含以下项目:
- 局部变量;
- 控制流结构;
- 方法调用;
- 内嵌的块;
- 其他方法,称为局部函数。
下图展示了一个方法体及其组成元素的示例。
三、局部变量
局部变量也保存数据。字段通常保存和对象状态有关的数据,而创建局部变量经常是用于保存局部的或临时的计算数据。下表对比了局部变量和实例字段的差别。
下面这行代码展示了局部变量声明的语法。可选的初始化语句由等号和用于初始化变量的值组成。
- 局部变量的存在和生存期仅限于创建它的块及其内嵌的块。
- 从声明它的那一点开始存在。
- 在块完成执行时结束存在。
- 可以在方法体内任意位置声明局部变量,但必须在使用它们之前声明。
下面的示例展示了两个局部变量的声明和使用。第一个是 int
类型变量,第二个是 SomeClass
类型变量。
static void Main() { int myInt = 15; SomeClass sc = new SomeClass(); ... }
- | 实例字段 | 局部变量 |
---|---|---|
生存期 | 从实例被创建时开始,直到实例不再被访问的时候结束 | 从它在块中被声明的那一刻开始,在块完成执行时结束 |
隐式初始化 | 初始化成该类型的默认值 | 没有隐式初始化,如果变量在使用之前没有被赋值,编译器会产生一条错误消息 |
存储区域 | 由于实例字段是类的成员,所以所有字段都存储在堆里,无论它们是值类型的还是引用类型的 | 值类型:存储在栈里 引用类型:引用存储在栈里,数据存储在堆里 |
3.1 类型推断和 var 关键字
如果观察下面的代码、你会发现在声明的开始部分提供类型名时,你提供的是编译器能从初始化语句的右边推断出来的信息。
- 在第一个变量声明中,编译器能推断出 15 是
int
型。 - 在第二个声明中,右边的对象创建表达式返回了一个
MyExcellentClass
类型的对象。
所以在两种情况中,在声明的开始部分包括显式的类型名是多余的。
static void Main() { int total = 15; MyExcellentClass mec = new MyExcellentClass(); ... }
为了避免这种冗余,可以在变量声明的开始部分的显式类型名的位置使用新的关键字 var
,如:
static void Main() { var total = 15; var mec = new MyExcellentClass(); ... }
var
关键字并不是表示特殊变量。它只是句法上的速记,表示任何可以从初始化语句的右边推断出的类型。在第一个声明中,它是 int
的速记;在第二个声明中,它是 MyExcellentClass
的速记。前文中使用显式类型名的代码片段和使用 var
关键字的代码片段在语义上是等价的。
使用 var
关键字有一些重要的条件:
- 只能用于局部变量,不能用于字段;
- 只能在变量声明中包含初始化时使用;
- 一旦编译器推断出变量的类型,它就是固定且不能更改的。
说明
var
关键字不像 JavaScript 的var
那样可以引用不同的类型。它是从等号右边推断出的实际类型的速记。var
关键字并不改变 C# 的强类型性质。
3.2 嵌套块中的局部变量
方法体内部可以嵌套其他的块。
- 可以有任意数量的块,并且它们既可以是顺序的也可以是嵌套的。块可以嵌套到任何级别。
- 局部变量可以在嵌套块的内部声明,并且和所有的局部变量一样,它们的生存期和可见性仅限于声明它们的块及其内嵌块。
下图阐明了两个局部变量的生存期,展示了代码和栈的状态。箭头标出了刚执行过的行。
- 变量
var1
声明在方法体中,在嵌套块之前。 - 变量
var2
声明在嵌套块内部。它从被声明的那一刻开始存在,直到声明它的那个块的尾部结束。 - 当控制传出嵌套块时,它的局部变量从栈中弹出。
说明 在 C 和 C++ 中,可以先声明一个局部变量,然后在嵌套块中声明另一个名称相同的局部变量。在内部范围,内部名称掩盖了外部名称。然而,在 C# 中不管嵌套级别如何,都不能再第一个名称的有效范围内声明另一个同名的局部变量。
四、局部常量
局部常量很像局部变量,只是一旦被初始化,它的值就不能改变了。如同局部变量,局部常量必须声明在块的内部。
常量的两个最重要的特征如下:
- 在声明是必须初始化。
- 在声明后不能改变。
常量的核心声明如下所示:
const Type Identifier = Value;
语法与字段或变量的声明相同,只有如下两点不同:
- 在类型之前增加关键字
const
; - 必须有初始化语句。初始化值必须在编译期决定,通常是一个预定义简单类型或由其组成的表达式。它还可以是
null
引用,但它不能是某对象的引用,因为对象的引用是在运行时决定的。
说明 关键字
const
不是修饰符,而是核心声明的一部分。它必须直接放在类型的前面。
就像局部变量,局部常量声明在方法体或代码块里,并在声明它的块结束的地方失效。例如,在下面的代码中,类型为内嵌类型 double
的局部常量 PI
在方法 DisplayRadii
结束后失效。
void DisplayRadii() { const double PI =3.1416; for(int radius = 1; radius <= 5; radius++) { double area = radius * radius * PI; Console.WriteLine($"Radius: {radius}, Area: {area}"); } }
五、控制流
方法包含了组成程序的行为的大部分代码。剩余部分在其他的函数成员中,如属性和运算符。
术语控制流指的是程序从头到尾的执行流程。默认情况下,程序执行顺序地从一条语句到下一条语句。控制流语句允许你改变执行的顺序。
这一节只会提及一些能在代码中使用的控制语句,这一篇([{{< ref "01-10-语句.md#三控制流语句" >}}]({{< ref "01-10-语句.md#三控制流语句" >}}))会详细介绍它们。
- 选择语句 利用这些语句可以选择要执行的语句或语句块。
if
有条件地执行一条语句。if...else
有条件地执行一条或另一条语句。switch
有条件地执行一组语句中的某一条。
- 迭代语句 这些语句可以在一个语句块上循环或迭代。
for
循环——在顶部测试。while
循环——在顶部测试。do...while
循环——在底部测试。foreach
为一组中每个成员执行一次。
- 跳转语句 这些语句可以让你从代码块或方法体内部的一个地方跳到另一个地方。
break
跳出当前循环。continue
到当前循环的底部。goto
到一个命名的语句。return
返回到调用方法继续执行。
例如,下面的方法展示了两个控制流语句:
void SomeMethod() { int intVal = 3; if(intVal == 3) // if 语句 { Console.WriteLine("Value is 3."); } for(int i = 0; i < 5; i++) // for 语句 { Console.WriteLine($"Value of i: {i}"); } }
六、方法调用
可以从方法体的内部调用其他方法。
调用方法时要使用方法名并带上参数列表。参数列表将在稍后讨论。
例如,下面的类声明了一个名为 PrintDateAndTime
的方法,该方法将在 Main
方法内调用。
class MyCalss { void PrintDateAndTime() // 声明方法 { DateTime dt = DateTime.Now; // 获取当前日期和时间 Console.WriteLine($"{dt}"); // 输出 } static void Main() { MyClass mc = new MyClass(); mc.PrintDateAndTime(); // 调用方法 } }
下图阐明了调用方法时的动作顺序。
- 当前方法的执行在调用点被挂起。
- 控制转移到被调用的方法的开始。
- 被调用方法执行直到完成。
- 控制回到发起调用的方法。
七、返回值
方法可以向调用代码返回一个值。返回的值被插入到调用代码中发起调用的表达式所在的位置。
- 要返回值,方法必须在方法名前面声明一个返回类型。
- 如果方法不返回值,它必须声明
void
返回类型。
下面的代码展示了两个方法声明。第一个返回 int
型值,第二个不返回值。
int GetHour(){...} // 返回 int 类型 void DisplayHour(){...} // 无返回值
声明了返回类型的方法必须使用如下形式的返回语句从方法中返回一个值。返回语句包括关键字 return
及其后面的表达式。每一条贯穿方法的路径都必须以一条这种形式的 return
语句结束。
return Expression; // 返回一个值
例如,下面的代码展示了一个名为 GetHour
的方法,它返回 int
型值。
int GetHour() // 返回 int 型 { DateTime dt = DateTime.Now; // 获取当前日期和时间 int hour = dt.Hour; // 获取小时数 return hour; // 返回一个 int 型的值 }
也可以返回用户定义类型的对象。例如,下面的代码返回一个 MyClass
类型的对象。
MyClass Method3() { MyClass mc = new MyClass(); ... return mc; // 返回一个 MyClass 对象 }
来看另一个示例。在下面的代码中,方法 GetHour
在 Main
的 WriteLine
语句中被调用个,并在该位置返回一个 int
值到 WriteLine
语句中。
class MyClass { public int GetHour() { DateTime dt = DateTime.Now; int hour = dt.Hour; return hour; } } class Program { static void Main() { MyClass mc = new MyClass(); Console.WriteLine($"Hour: {mc.GetHour()}"); // 方法调用 } }
八、返回语句和 void 方法
在上一节,我们看到有返回值的方法必须包含返回语句。void
方法不需要返回语句。当控制流到达方法体的关闭大括号时,控制流返回到调用代码,并且没有值被插入到调用代码中.
不过,当特定条件符合的时候,找们常常会提前退出方法以简化程序逻辑。
- 可以在任何时候使用下面的返回语句退出方法,不带参数:
return;
- 这种形式的返回语句只能用于用
void
声明的方法。
例如,下面的代码展示了一个名为 SomeMethod
的 void
方法的声明。它可以在三个可能的地方返回到调用代码。前两个在 if
语句分支内。最后一个是方法体的结尾处。
void SomeMethod() { ... if(SomeCondition) // 如果... return; // 返回到调用代码 ... if(OtherCondition) // 如果... return; // 返回到调用代码 ... } // 默认返回到调用代码
下面的代码展示了一个带有一条返回语句的 void
方法示例。该方法只有当时间是下午的时候才输出一条消息,如下图所示,其过程如下:
- 首先,方法获取当前日期和时间(现在不用理解这些细节)。
- 如果小时小于12(也就是在中午之前),那么执行
return
语句,不在屏幕上输出任何东西,直接把控制返回给调用方法。 - 如果小时大于等于12,则跳过
return
语句,代码执行WriteLine
语句,在屏幕上输出信息。
class MyClass { void TimeUpdate() // void 返回类型 { DateTime dt = DateTime.Now; // 获取当前日期和时间 if(dt.Hour < 12) // 若小时数小于 12 return; // 则返回,返回到调用方法 Console.WriteLine("It's afternoon!"); // 否则,输出消息 } static void Main() { MyClass mc = new MyClass(); mc.TimeUpdate(); } }
九、局部函数
正如刚刚所解释的,方法块内的代码可以调用另一个方法。如果另一个方法在同一个类内,可以直接使用它的名称并传入所需的参数(参加下一节)进行调用。如果另一个方法在不同的类中,必须通讨这个类的一个对象实例调用它。另一个类中的方法必须使用 public
访问修饰符声明。
从 C# 7.0 开始,你可以在一个方法中声明另一个单独的方法。这样可以将嵌入的方法跟其他代码隔离开来,所以它只能在句含它的方法内调用。如果使用恰当,这可以使代码更清晰,更易于维护。这些嵌入的方法被称为局部函数。
与局部变量必须在使用之前讲行声明不同,你可以在包含方法的任意位置声明局部函数。
下面的代码演示了一个 MethodWithLocalFunction
方法,它包含了一个局部函数,叫作 MyLocalFunction
。
class Program { public void MethodWithLocalFunction() { int MyLocalFunction(int z1) { return z1 * 5; } int results = MyLocalFunction(5); // 调用局部函数 Console.WriteLine($"Results of local function call: {results}"); } static void Main(string[] args) { Program myProgram = new Program(); myProgram.MethodWithLocalFunction(); // 调用方法 } }
输出
Results of local function call: 25
十、参数
迄今为止,你已经看到方法是可以从程序中很多地方调用的命名代码单元,它能把一个值返回给调用代码。返回一个值的确有用,但如果需要返回多个值呢?还有,能在方法开始执行的时候把数据传入方法也会有用。参数就是允许你做这两件事的特殊变量。
10.1 形参
形参是局部变量,它声明在方法的参数列表中,而不是在方法体中。
下面的方法头展示了参数声明的语法。它声明了两个形参:一个是 int
型,另一个是 float
型。
public void PrintSum(int x, float y) { ... }
- 因为形参是变量,所以它们有类型和名称,并能被写入和读取。
- 和方法中的其他局部变量不同,参数在方法体的外面定义并在方法开始之前初始化(但有一种类型例外,称为输出参数,我们将很快谈到它)。
- 参数列表中可以有任意数目的形参声明,而且声明必须用逗号隔开。
形参在整个方法体内使用,在大部分地方就像其他局部变量一样。例如,下面的 PrintSum
方法的声明使用两个形参 ×
和 y
,以及一个局部变量 sum
,它们都是 int
型。
public void PrintSum(int x, int y) { int sum = x + y; Console.WriteLine($"Newsflash: {x} + {y} is {sum}"); }
10.2 实参
当代码调用一个方法时,形参的值必须在方法的代码开始执行之前初始化。用于初始化形参的表达式或变量称作实参(actual parameter,有时也称 argument)。
- 实参位于方法调用的参数列表中。
- 每一个实参必须与对应形参的类型相匹配,或是编译器必须能够把实参隐式转换为那个类型。
例如,下面的代码展示了方法 PrintSum
的调用,它有两个 int
类型的实参。
PrintSum(5,someInt);
当方法被调用的时候,每个实参的值都被用于初始化相应的形参,方法体随后被执行。下图阐明了实参和形参的关系。
注意在之前那段示例代码及上图中,实参的数量必须和形参的数量一致,并且每个实参的类型也必须和所对应的形参类型一致。这种形式的参数叫作位置参数。稍后会看其他的一些选项,现在先来看看位置参数。
位置参数示例
在如下代码中,Myclass
类声明了两个方法——一个方法接受两个整数并返回它们的和,另一个方法接受两个 float
并返回它们的平均值。对于第二次调用,注意编译器把 int
值 5 和 someInt
隐式转换成了 float
类型。
class MyClass { public int Sum(int x, int y) // 声明方法 { return x + y; // 返回和 } public float Avg(float input1,float input2) // 声明方法 { return (input1 + input2) / 2.0F; // 返回和 } } public class Program { public static void Main() { MyClass myT = new MyClass(); int someInt = 6; Console.WriteLine($"Newsflash: Sum: {5} and {someInt} is {myT.Sum(5,someInt)}"); // 调用方法 Console.WriteLine($"Newsflash: Avg: {5} and {someInt} is {myT.Avg(5,someInt)}"); // 调用方法 } }
输出
Newsflash: Sum: 5 and 6 is 11 Newsflash: Avg: 5 and 6 is 5.5
十一、值参数
参数有几种,各自以略微不同的方式从方法传入或传出数据。到目前为止,你看到的这种类型是默认的类型,称为值参数(value parameter)。
当你使用值参数时,通过将实参的值复制到形参的方式把数据传递给方法。方法被调用时,系统执行如下操作。
- 在栈中为形参分配空间。
- 将实参的值复制给形参。
值参数的实参不一定是变量,它可以是任何能计算成相应数据类型的表达式。例如,下面的代码展示了两个方法调用。在第一个方法调用中,实参是 float
类型的变量;在第二个方法调用中,实参是计算成float
的表达式。
public static void Main() { float j = 2.6F; float k = 5.1F; float fValue1 = func1(k); float fValue2 = func1((k + j) / 3); } static float func1(float val) { return val; }
用作实参之前,变量必须被赋值(除非是输出参数,稍后会介绍)。对于引用类型,变量可以被设置为一个实际的引用或 null
。
说明 这篇文章([{{< ref "01-04-类型和变量.md#八值类型和引用类型" >}}]({{< ref "01-04-类型和变量.md#八值类型和引用类型" >}}))介绍了值类型,所谓值类型就是指类型本身包含其值。不要把值类型和这里介绍的值参数混淆,它们是完全不同的两个概念。值参数是把实参的值复制给形参。
例如,下面的代码展示了一个名为 MyMethod
的方法,它有两个参数:一个 MyClass
类型的变量和一个 int
。
- 方法为类的
int
类型字段和参数都加上 5。 - 你可能还注意到
MyMethoed
使用了修饰符static
,我们还没有解释过这个关键字,现在你可以忽略它。
class MyClass { public int Val = 20; //初始化字段为 20 } class Program { static void MyMethod(MyClass f1, int f2) { f1.Val = f1.Val + 5; // 参数的字段加 5 f2 = f2 + 5; // 另一个参数加 5 Console.WriteLine($"f1.Val: {f1.Val}, f2: {f2}"); } static void Main() { MyClass a1 = new MyClass(); int a2 = 10; MyMethod(a1,a2); Console.WriteLine($"a1.Val: {a1.Val}, a2: {a2}"); } }
输出
f1.Val: 25, f2: 15 a1.Val: 25, a2: 10
下图展示了实参和形参在方法执行的不同阶段的值,它表明了一下 3 点。
- 在方法被调用前,用作实参的变量
a2
以及在栈里了。 - 在方法开始时,系统在栈中为形参分配空间,并从实参复制值。
- 因为
a1
是引用类型的,所以引用被复制,结果实参和形参都引用堆中的同一个对象。 - 因为
a2
的值类型的,所以被复制,产生了一个独立的数据项。
- 因为
- 在方法的结尾,
f2
和对象f1
的字段都被加上了 5.- 方法执行后,形参从栈中弹出。
a2
,值类型,它的值不受方法行为的影响。a1
,引用类型,它的值被方法的行为改变了。
十二、引用参数
第二种参数类型称为引用参数。
- 使用引用参数时,必须在方法的声明和调用中都使用
ref
修饰符。 - 实参必须是变量,在用作实参前必须被赋值。如果是引用类型变量,可以赋值为一个引用或
null
。
例如,下面的代码阐明了引用参数的声明和调用的语法:
void MyMethod(ref int val) // 方法声明 { ... } int y = 1; // 实参变量 MyMethod(ref y); // 方法调用 MyMethod(ref 3 + 5); // 错误!必须使用变量
在之前的内容中我们已经认识到了,对于值参数,系统在栈上为形参分配内存。相反,引用参数具有以下特征。
- 不会在栈上为形参分配内存。
- 形参的参数名将作为实参变量的别名,指向相同的内存位置。
由于形参名和实参名指向相同的内存位置,所以在方法的执行过程中,对形参做的任何改变,在方法完成后依然可见(表现在实参变量上)。
说明 记住要再方法的声明和调用上都使用
ref
关键字。
例如,下面的代码再次展示了方法 MyMethod
,但这一次参数是引用参数而不是值参数。
class MyClass { public int Val = 20; //初始化字段为 20 } class Program { static void MyMethod(ref MyClass f1, ref int f2) { f1.Val = f1.Val + 5; // 参数的字段加 5 f2 = f2 + 5; // 另一个参数加 5 Console.WriteLine($"f1.Val: {f1.Val}, f2: {f2}"); } static void Main() { MyClass a1 = new MyClass(); int a2 = 10; MyMethod(ref a1,ref a2); Console.WriteLine($"a1.Val: {a1.Val}, a2: {a2}"); } }
输出
f1.Val: 25, f2: 15 a1.Val: 25, a2: 15
注意,不管 MyClass
对象 f1
是否是通过 ref
传递给方法,f1.Val
的值都是相同的。稍后会对此进行详细的讨论。
下图阐明了再方法执行的不同阶段实参和形参的值。
- 在方法调用之前,将要被用作实参的变量
a1
和a2
已经在栈里了。 - 在方法的开始,形参名被设置为实参的别名。变量
a1
和f1
引用相同的内存位置,a2
和f2
引用相同的内存位置。 - 在方法的结束位置,
f2
和f1
的对象的字段都被加上了 5。 - 方法执行之后,形参的名称已经失效,但是值类型
a2
的值和引用类型a1
所指向的对象的值都被方法内的行为改变了。
十三、引用类型作为值参数和引用参数
在前几节中我们看到了,对于一个引用类型对象,不管是将其作为值参数传递还是作为引用参数传递,都可以在方法成员内部修改它的成员。不过,我们并没有在方法内部设置形参本身。本节来看看在方法内设置引用类型形参时会发生什么。
- 将引用类型对象作为值参数传递 如果在方法内创建一个新对象并赋值给形参,将切断形参与实参之间的关联,并且在方法调用结束后,新对象也将不复存在。
- 将引用类型对象作为引用参数传递 如果在方法内创建一个新对象并赋值给形参,在方法结束后该对象依然存在,并且是实参所引用的值。
下面的代码展示了第一种情况——将引用类型对象作为值参数传递:
class MyClass { public int Val = 20; } class Program { static void RefAsParamenter(MyClass f1) { f1.Val = 50; Console.WriteLine($"After member assignment: {f1.Val}"); f1 = new MyClass(); Console.WriteLine($"After new object creation: {f1.Val}"); } static void Main() { MyClass a1 = new MyClass(); Console.WriteLine($"Before method call: {a1.Val}"); RefAsParamenter(a1); Console.WriteLine($"After method call: {a1.Val}"); } }
输出
Before method call: 20 After member assignment: 50 After new object creation: 20 After method call: 50
下图阐明了关于上述代码的以下几点。
- 在方法开始时,实参和形参指向堆中相同的对象。
- 在为对象的成员赋值之后,它们仍指向堆中相同的对象。
- 当方法分配新的对象并赋值给形参时,(方法外部的)实参仍指向原始对象,而形参指向的是新对象。
- 在方法调用之后,实参指向原始对象,形参和新对象都会消失。
下面的代码演示了将引用类型对象作为引用参数的情况。除了方法声明和方法调用时要使用 ref
关键字外,与上面的代码完全相同。
class MyClass { public int Val = 20; } class Program { static void RefAsParamenter(ref MyClass f1) { f1.Val = 50; Console.WriteLine($"After member assignment: {f1.Val}"); f1 = new MyClass(); Console.WriteLine($"After new object creation: {f1.Val}"); } static void Main() { MyClass a1 = new MyClass(); Console.WriteLine($"Before method call: {a1.Val}"); RefAsParamenter(ref a1); Console.WriteLine($"After method call: {a1.Val}"); } }
输出
Before method call: 20 After member assignment: 50 After new object creation: 20 After method call: 20
你肯定记还记得,引用参数充当形参的别名。这样一来上面的代码就很好解释了。下图阐明了上述代码的一下几点。
- 在方法调用时,形参和实参指向堆中相同的对象。
- 对成员值的修改会同时影响到形参和实参。
- 当方法创建新的对象并赋值给形参时,形参和实参的引用都指向该新对象。
- 在方法结束后,实参指向在方法内创建的新对象。
十四、输出参数
输出参数用于从方法体内把数据传出到调用代码,它们的行为与引用参数类似。如同引用参数,输出参数有以下要求。
- 必须在声明和调用中都使用修饰符。输出参数的修饰符是
out
而不是ref
。 - 和引用参数类似,实参必须是变量,而不能是其他类型的表达式。这是有道理的,因为方法需要内存位置来保存返回值。
例如,下面的代码声明了名为 MyMethod
方法,它带有单个输出参数。
void MyMethod(out int val) { ... } ... int y = 1; MyMethod(out y); // 方法调用
与引用参数类似,输出参数的形参充当实参的别名。形参和实参都是同一块内存位置的名称。显然,在方法内对形参做的任何改变,在方法执行完成之后,(通过实参变量)都是可见的。
与引用参数不同,输出参数有以下要求。
- 在方法内部,给输出参数赋值之后才能读取它。这意味着参数的初始值是无关的,而且没有必要在方法调用之前为实参赋值。
- 在方法内部,在方法返回之前,代码中每条可能的路径都必须为所有输出参数赋值。
因为方法内的代码在读取输出参数之前必须对其写人,所以不可能使用输出参数把数据传入方法。事实上,如果方法中有任何执行路径试图在方法给输出参数赋值之前读取它的值,编译器就会产生一条错误消息。
public void Add2(out int outValue) { int var1 = outValue + 2; // 错误!在方法赋值之前,无法读取输出变量 }
例如,下面的代码再次展示了方法 MyMetod
,但这次试用了输出参数。
class MyClass { public int Val = 20; } class Program { static void MyMethod(out MyClass f1, out int f2) { f1 = new MyClass(); f1.Val = 25; f2 = 15; } static void Main() { MyClass a1 = null; int a2; MyMethod(out a1, out a2); // 调用方法 } }
下图阐述了在方法执行的不同阶段中实参和形参的值。
- 在方法调用之前,将要被用作实参的变量
a1
和a2
已经在栈里了。 - 在方法的开始,形参的名称被设置为实参的别名。你可以认为变量
a1
和f1
指向的是相同的内存位置,也可以认为a2
和f2
指向的是相同的内存位置。a1
和a2
不在作用域之内,所以不能在MyMethod
中方为。 - 在方法内部,代码创建了一个
MyClass
类型的对象并把它赋值给f1
。然后赋一个值给f1
的字段,也赋一个值给f2
。对f1
和f2
的赋值都是必须的,因为他们是输出参数。 - 方法执行之后,形参的名称已经失效,但是引用类型的
a1
和值类型的a2
的值都被方法内的行为改变了。
从 C# 7.0 开始,你不再需要预先声明一个变量来用作 out
参数了。你可以在调用方法时在参数列表中添加一个变量类型,它将作为变量声明。
例如,在之前的代码示例中,Main
方法声明了 a1
和 a2
变量,然后在调用 MyMethod
时将它们用作 out
参数,如下所示:
static void Main() { MyClass a1 = null; int a2; MyMethod(out a1,out a2); }
如果使用新的语法,你可以:
- 消除显式的变量声明;
- 直接在方法调用时加入变量类型声明。
下面的代码演示了新的形式:
static void Main() { MyMethod(out MyClass a1,out int a2); }
虽然 a1
和 a2
只在方法调用语句中进行了声明,但它们也可以在方法调用完成后继续使用,如以下代码所示:
static void Main() { MyMethod(out MyClass a1, out int a2); // 调用方法 Console.WriteLine(a2); // 使用返回的值 a2 += 5; // 写入变量 Console.WriteLine(a2); }
输出
15 20
十五、参数数组
至此,在本篇所述的参数类型中,一个形参必须严格地对应一个实参。参数数组则不同,它允许特定类型的零个或多个实参对应一个特定的形参。参数数组的重点如下。
- 在一个参数列表中只能有一个参数数组。
- 如果有,它必须是列表中的最后一个。
- 由参数数组表示的所有参数必须是同一类型。
声明一个参数数组时必须做的事如下。
- 在数据类型前使用
params
修饰符。 - 在数据类型后放置一组空的方括号。
下面的方法头展示了 int
型参数数组的声明语法。在这个示例中,形参 inVals
可以代表零个或多个 int
实参。
void ListInts(params int[] inVals) { ... }
类型名后面的空方括号指明了参数是一个整数数组。
- 数组是一组有序的同一类型的数据项。
- 数组使用一个数字索引进行访问。
- 数组是一个引用类型,因此它的所有数据项都保存在堆中。
15.1 方法调用
可以使用两种方式为参数数组提供实参。
- 一个用逗号分隔的该数据类型元素的列表。所有元素必须是方法声明中指定的类型。
ListInts(10, 20, 30); // 3 个 int
- 一个该数据类型远素的一维数组。
int intArray = {1, 2, 3}; ListInts(intArray); // 一个数组变量
请注意,在这些示例中,没有在调用时使用 params
修饰符。参数数组中修饰符的使用与其他参数类型的模式并不相符。
- 其他参数类型是一致的,要么使用修饰符,要么都不使用修饰符。
- 值参数的声明和调用都不带修饰符。
- 引用参数和输出参数在两个地方都需要修饰符。
params
修饰符的用法总结如下。- 在声明中需要修饰符。
- 在调用中不允许有修饰符。
延伸式
方法调用的第一种形式有时被称为延伸式,这种形式在调用中使用独立的实参。
例如,下面代码中的方法 ListInts
的声明可以匹配其后所有的方法调用,虽然它们的实参数目不同。
void ListInts(params int[] inVals) // 方法声明 { ... } ListInts(); // 0 个实参 ListInts(1, 2, 3); // 3 个实参 ListInts(4, 5, 6, 7); // 4 个实参 ListInts(8, 9, 10, 11, 12); // 5 个实参
在使用一个为参数数组使用独立实参的调用时,编译器做下面几件事。
- 接受实参列表,用它们在堆中创建并初始化一个数组。
- 把数组的引用保存到栈中的形参里。
- 如果在对应形参数组的位置没有实参,编译器会创建一个有零个元素的数组类使用。
例如,下面的代码声明了一个名为 ListInts
的方法,它接受有一个参数数组。Main
声明了 3 个整数并把它们传给了数组。
class MyClass { public void ListInts(params int[] inVals) { if((inVals != null) && (inVals.Length !=0)) { for(int i = 0; i < inVals.Length; i++) { inVals[i] = inVals[i] * 10; Console.WriteLine($"{inVals[i]}"); } } } } class Program { static void Main() { int first = 5, second = 6, third = 7; // 声明 3 个 int MyClass mc = new MyClass(); mc.ListInts(first, second, third); // 调用方法 Console.WriteLine($"{first}, {second}, {third}"); } }
输出
50 60 70 5, 6, 7
下图阐明了在方法执行的不同阶段实参和形参的值。
- 方法调用之前,3 个实参已经在栈里。
- 在方法的开始,3 个实参被用于初始化堆中的数组,并且数组的引用被赋值给形参
intVals
。 - 在方法内部,代码首先检查以确认数组引用不是
null
,然后处理数组,把每个元素乘以 10 并保存回去。 - 方法执行之后,形参
inVals
失效。
关于参数数组,需要记住的一点是当数组在堆中被创建时,实参的值被复制到数组中。这样,它们像值参数。
- 如果数组参数是值类型,那么值被复制,实参在方法内部不受影响。
- 如果数组参数是引用类型,那么引用被复制,实参引用的对象在方法内部会受到影响。
15.2 将数组作为实参
也可以在方法调用之前创建并组装一个数组,把单一的数组变量作为实参传递。这种情况下,编译器使用你的数组而不是重新创建一个。
例如,下面的代码使用前一个示例中声明的方法 ListInts
。在这段代码中,Main
创建一个数组,并用数组变量而不是使用独立的整数作为实参。
static void Main() { int[] myArr = new int[]{5, 6, 7}; // 创建并初始化数组 MyClass mc = new MyClass(); mc.ListInts(myArr); // 调用方法来打印值 foreach(int x in myArr) Console.WriteLine($"{x}"); // 输出每个元素 }
输出
50 60 70 50 60 70
十六、参数类型总结
因为有 4 中参数类型,所以有时很难记住它们的不同特征。下表对它们做了总结,以便于比较和对照。
参数类型 | 修饰符 | 是否在声明时使用 | 是否在调用时使用 | 执行 |
---|---|---|---|---|
值 | 无 | 系统把实参的值复制到形参 | ||
引用 | ref | 是 | 是 | 形参是实参的别名 |
输出 | out | 是 | 是 | 仅包含一个返回的值。形参是实参的别名 |
数组 | params | 是 | 否 | 允许传递可变数目的实参到方法 |
十七、ref 局部变量和 ref 返回
在本篇前面你已经看到了,你可以使用 ref
关键字传递一个对象引用给方法调用,这样在调用上下文中,对对象的任何改动在方法返回后依然可见。ref 返回功能则相反,它允许你将一个引用发送到方法外,然后在调用上下文内使用这个引用。一个相关的功能是 ref 局部变量,它允许一个变量是另一个变量的别名。
我们将从 ref
局部变量这个功能开始讲解。下面是关于 ref
局部变量功能的重要事项。
- 你可以使用这个功能创建一个变量的别名,即使引用的对象是值类型。
- 对任意一个变量的赋值都会反映到另一个变量上,因为它们引用的是相同的对象,即使是值类型。
创建别名的语法需要使用关键字 ref
两次,一次是在别名声明的类型的前面,另一次是在赋值运算符的右边,“被别名”的变量的前面,如下所示:
ref int y = ref x;
下面的代码是一个示例,其中使用 ref
局部变量功能创建了变量 ×
的一个别名,叫作 y
。当 x
改变时,y
也会变,反之亦然。
class Program { static void Main() { int x = 2; ref int y = ref x; Console.WriteLine($"x = {x}, y = {y}"); x = 5; Console.WriteLine($"x = {x}, y = {y}"); y = 6; Console.WriteLine($"x = {x}, y = {y}"); } }
输出
x = 2, y = 2 x = 5, y = 5 x = 6, y = 6
但是,别名功能不是 ref
局部变量功能最常见的用途。实际上,它经常和 ref
返回功能一起使用。ref
返回功能提供了一种使方法返回变量引用而不是变量值的方法。这里需要的额外语法也使用了 ref
关键字两次:
- 一次是在方法的返回类型声明之前
- 另一次是在
return
关键字之后,被返回对象的变量名之前
下面的代码演示了一个例子。注意,在方法调用之后,因为调用了修改 ref
局部变量的代码,所以类的字段值改变了。
class Simple { private int Score = 5; public ref int RefToValue() // 注意 ref 关键字 { return ref Score; // 注意 ref 关键字 } public void Display() { Console.WriteLine($"Value inside class object: {Score}"); } } class Program { static void Main() { Simple s = new Simple(); s.Display(); ref int v1OutSide = ref s.RefToValue(); // 注意 ref 关键字 v1OutSide = 10; // 在调用域外修改值 s.Display(); } }
输出
Value inside class object: 5 Value inside class object: 10
另一个可能有用的例子是 Math
库中 Max
方法的变形。提供两个数字类型的变量,Math.Max
能够返回两个值中较大的那个。但是,假设你想返回的是包含较大值的变量的引用,而不是实际的值。为此,你可以使用 ref
返回功能,如以下代码所示。
class Program { static ref int Max(ref int p1, ref int p2) { if(p1 > p2) { return ref p1; // 返回引用,而不是值 } else { return ref p2; // 返回引用,而不是值 } } static void Main() { int v1 = 10; int v2 = 20; Console.WriteLine("Start"); Console.WriteLine($"v1: {v1}, v2: {v2}n"); ref int max = ref Max(ref v1, ref v2); Console.WriteLine("After assignment"); Console.WriteLine($"max: {max}n"); max++; Console.WriteLine("After increment"); Console.WriteLine($"max: {max}, v1: {v1}, v2: {v2}"); } }
输出
Start v1: 10, v2: 20 After assignment max: 20 After increment max: 21, v1: 10, v2: 21
这个功能有如下额外限制。
- 你不能将返回类型是
void
的方法声明为ref
返回方法。 ref return
表达式不能返回如下内容:- 空值
- 常量
- 枚举成员
- 类或者结构体的属性
- 指向只读位置的指针
ref return
表达式只能指向原先就在调用域内的位置,或者字段。所以它不能指向方法的局部变量。ref
局部变量只能被赋值一次,也就是说,一旦初始化,它就不能指向不同的内存位置了。- 即使将一个方法声明为
ref
返回方法,如果在调用该方法时省略了ref
关键字,则返回的将是值,而不是指向值的内存位置的指针。 - 如果将
ref
局部变量作为常规的实际参数传递给其他方法,则该方法仅获取该变量的一个副本。尽管ref
局部变量包含指向内存位置的指针,但是当以这种方式使用时,它会传递值而不是引用。
十八、方法重载
一个类中可以有多个同名方法,这叫作方法重载(method overloading)。使用相同名称的每个方法必须有一个和其他方法不同的签名(signature)。
请注意,方法重载(method overloading)的概念与继承中“方法覆写”(method overriding)不同
- 方法的签名由下列信息组成,它们在方法声明的方法头中:
- 方法的名称;
- 参数的数目;
- 参数的数据类型和顺序;
- 参数修饰符。
- 返回类型不是签名的一部分,而我们往往误认为它是签名的一部分。
- 请注意,形参的名称也不是签名的一部分。
long AddValues(int a, out int b) { ... }
例如,下面 4 个方法是方法名 AddValues
的重载:
class A { long AddValues(int a, int b) { return a + b; } long AddValues(int c, int d, int e) { return c + d + e; } long AddValues(float f, float g) { return (long)(f + g); } long AddValues(long h, long m) { return h + m; } }
下面的代码展示了一个非法的方法重载。两个方法仅返回类型和形参名不同,但它们仍有相同的签名,因为它们的方法名相同,而且参数的数目、类型和顺序也相同。编译器会对这段代码生成一条错误消息。
class B { long AddValues(long a, long b) // 错误,相同的签名 { return a + b; } int AddValues(long c, long d) // 错误,相同的签名 { return c + d; } }
十九、命名参数
至此我们用到的所有参数都是位置参数,也就是说每一个实参的位置都必须与相应的形参位置对应。
此外,C# 还允许我们使用命名参数(named parameter)。只要显式指定参数的名字,就可以以任意顺序在方法调用中列出实参。细节如下。
- 方法的声明没有什么不一样。形参已经有名字了。
- 不过在调用方法的时候,形参的名字后面跟着冒号和实际的参数值或表达式,如下面的方法调用所示。在这里
a
、b
、C
是Calc
方法 3 个形参的名字。
c.Calc(c:2, a:4, b:3);
class MyClass { public int Calc(int a, int b, int c) // 方法声明和参数列表没有什么不一样 { return (a + b) * c; } static void Main() { MyClass mc = new MyClass(); int result = mc.Calc(c:2, a:4, b:3); // 在方法调用中包含参数名 Console.WriteLine($"{result}"); } }
在使用命名参数的时候,需要在方法调用中包含参数名,无序对方法声明做任何改变。
在调用的时候,你可以同时使用位置参数和命名参数,但所有位置参数必须先列出。例如,下面的代码演示了 Calc
方法的声明及其使用位置参数和命名参数不同组合的 5 种调用方式。
class MyClass { public int Calc(int a, int b, int c) // 方法声明和参数列表没有什么不一样 { return (a + b) * c; } static void Main() { MyClass mc = new MyClass(); int r0 = mc.Calc(4, 3, 2); // 位置参数 int r1 = mc.Calc(4, b:3, c:2); // 位置参数和命名参数 int r2 = mc.Calc(4, c:2, b:3); // 交换了顺序 int r3 = mc.Calc(c:2, b:3, a:4); // 所有都是命名参数 int r4 = mc.Calc(c:2, b:1+2, a:3+1); // 命名参数表达式 Console.WriteLine($"{r0}, {r1}, {r2}, {r3}, {r4}"); } }
输出
14, 14, 14, 14, 14
命名参数对于自描述的程序来说很有用,因为我们可以在方法调用的时候显示哪个值赋给哪个形参。例如,如下代码调用了两次 GetCylinderVolume
,第二次调用具有更多的信息并且更不容易出错。
class MyClass { double GetCylinderVolume(double radius, double height) { return 3.1416 * radius * radius * height; } static void Main() { MyClass mc = new MyClass(); double volume; volume = mc.GetCylinderVolume(3.0, 4.0); volume = mc.GetCylinderVolume(radius:3.0, height:4.0); // 命名参数可以在方法调用的时候显示哪个值赋给哪个形参,不容易出错 } }
二十、可选参数
C# 还允许使用可选参数(optional parameter)。所谓可选参数就是可以在调用方法的时候包含这个参数,也可以省略它。
为了表明某个参数是可选的,你需要在方法声明中为访参数提供默认值。指定默认值的语法和初始化局部变量的语法一样,如下面代码的方法声明所示。在代码中,
- 形参
b
的默认值设置成 3; - 因此,如果在调用方法的时候只有一个参数,方法会使用 3 作为第二个参数的初始值。
class MyClass { public int Calc(int a, int b = 3) { return a + b; } static void Main() { MyClass mc = new MyClass(); int r0 = mc.Calc(5,6); // 使用显示值 int r1 = mc.Calc(5); // 为 b 使用默认值 Console.WriteLine($"{r0}, {r1}"); } }
输出
11, 8
对于可选参数的声明,我们需要知道如下几个重要事项。
-
不是所有的参数类型都可以作为可选参数。下表列出了何时可以使用可选参数。
-
只要值类型的默认值在编译的时候可以确定,就可以使用值类型作为可选参数。
-
只有在默认值是
null
的时候,引用类型才可以用作可选参数。- 值 ref out params 值类型 是 否 否 否 引用类型 只允许 null 的默认值 否 否 否
-
-
所有必填参数(required paramenter)必须在可选参数声明之前声明。如果有
params
参数,必须在所有可选参数之后声明。下图演示了这种语法顺序。
在之前的示例中我们已经看到了,可以在方法调用的时候省略相应的实参,从而为可选参数使用默认值。但是,不能随意省略可选参数的组合,因为在很多情况下这么做会导致使用哪些可选参数变得不明确。规则如下:
- 你必须从可选参数列表的最后开始省略,一直到列表开头。
- 也就是说,你可以省略最后一个可选参数,或是最后 N 个可选参数,但是不能随意选择省略任意的可选参数,省略必须从最后开始。
class MyClass { public int Calc(int a = 2, int b = 3, int c = 4) { return (a + b) * c; } static void Main() { MyClass mc = new MyClass(); int r0 = mc.Calc(5,6,7); // 使用所有的显示值 int r1 = mc.Calc(5,6); // 为 c 使用默认值 int r2 = mc.Calc(5); // 为 b 和 c 使用默认值 int r3 = mc.Calc(); // 使用所有的默认值 Console.WriteLine($"{r0}, {r1}, {r2}, {r3}"); } }
输出
77, 44, 32, 20
如果需要随意省略可选参数列表中的可选参数,而不是从列表的最后开始,那么必须使用可选参数的名字来消除赋值的歧义。在这种情况下,你需要结合利用命名参数和可选参数的特性。下面的代码演示了位置参数、可选参数、和命名参数的这种用法。
class MyClass { public double GetCylinderVolume(double radius = 3.0, double height = 4.0) { return 3.1416 * radius * radius * height; } static void Main() { MyClass mc = new MyClass(); double volume; volume = mc.GetCylinderVolume(3.0,4.0); // 位置参数 Console.WriteLine($"Volume = {volume}"); volume = mc.GetCylinderVolume(radius:2.0); // 使用 height 默认值 Console.WriteLine($"Volume = {volume}"); volume = mc.GetCylinderVolume(height:2.0); // 使用 radius 默认值 Console.WriteLine($"Volume = {volume}"); volume = mc.GetCylinderVolume(); // 使用两个默认值 Console.WriteLine($"Volume = {volume}"); } }
输出
Volume = 113.0976 Volume = 50.2656 Volume = 56.5488 Volume = 113.0976
二十一、栈帧
至此,我们已经知道了局部变量和参数是位于栈上的,下面深入探讨一下其组织。
在调用方法的时候,内存从栈的顶部开始分配,保存和方法关联的一些数据项。这块内存叫作方法的栈帧(stack frame)。
- 栈帧包含的内存保存如下内容。
- 返回地址,也就是在方法退出的时候继续执行的位置。
- 分配内存的参数,也就是方法的值参数,还可能是参数数组(如果有的话)。
- 和方法调用相关的其他管理数据项。
- 在方法调用时,整个栈帧都会压人栈。
- 在方法退出的时候,整个栈帧都会从栈上弹出。弹出栈帧有的时候也叫作栈展开(unwind)。
例如,如下代码声明了 3 个方法。Main
调用 MethodA
,MethodA
又调用 MedhodB
,创建了 3 个栈桢。在方法退出的时候,栈展开。
class Program { static void MethodA(int par1, int par2) { Console.WriteLine($"Enter MethodA: {par1}, {par2}"); MethodB(11,18); // 调用 MethodB Console.WriteLine("Exit MethodA"); } static void MethodB(int par1, int par2) { Console.WriteLine($"Enter MethodB: {par1}, {par2}"); Console.WriteLine("Exit MethodB"); } static void Main() { Console.WriteLine("Enter Main"); MethodA(15,30); // 调用 MethodA Console.WriteLine("Exit Main"); } }
输出
Enter Main Enter MethodA: 15, 30 Enter MethodB: 11, 18 Exit MethodB Exit MethodA Exit Main
下图演示了在调用方法时栈帧压入栈的过程和方法结束后栈展开的过程。
二十二、递归
除了调用其他方法,方法也可以调用自身。这叫作递归。
递归会产生很优雅的代码,比如下面计算阶乘数的方法就是如此。注意在本例的方法内部,方法使用比输入参数小 1 的实参调用自身。
int Factorial(int inValue) { if(inValue <= 1) { return inValue; } else { return inValue * Factorial(inValue - 1); // 调用自身 } }
调用方法自身的机制和调用其他方法其实是完全一样的,都是为每一次方法调用把新的栈帧压入栈顶。
例如,在下面的代码中,Count
方法使用比输入参数小 1 的值调用自身,然后打印输入的参数。随着递归越来越深,栈也越来越大。
class Program { public void Count(int inVal) { if(inVal == 0) { return; } Count(inVal - 1); // 调用自身 Console.WriteLine($"{inVal}"); } static void Main() { Program pr = new Program(); pr.Count(3); } }
输出
1 2 3
下图演示了这段代码。注意,如果输入值 3,那么 Count
方法就有 4 个不同的独立栈帧。每一个都有其自己的输出参数值 inVal
。
原文链接:https://www.dotnetprimer.com/csharp/01-methods-and-parameters-in-csharp/
(完)