方法是类的成员函数;
方法包含:方法头 和 方法体;
int MyMethod(string str){ // str 是局部临时只读变量 int myInt = 3; // 局部变量 Console.WriteLine(str); return 0; }
字段 和 局部变量 在内存存储中的区别
字段:由于实例字段是类的成员,所以所有字段都存储在堆里,无论他们是值类型还是引用类型;
局部变量:值类型存储在栈里,引用类型的引用存储在栈里,数据存储在堆里;
局部变量使用完后,会从栈中弹出;
Var 类型推断
推荐使用 var 关键字
var 并不是表示特殊变量。
使用 var 的一些重要条件:
- var 只能用于局部变量,不能用于字段
- 只能在变量声明中包含初始化时使用
- 一旦编译器推断出变量的类型,它的类型就是固定的了且不能更改。
说明var关键字不像JavaScript的var那样可以引用不同的类型。C#中的 var 是从等号右边推断出的实际类型的速记。该var关键字并不改变C#的强类型性质。
局部常量
一旦被 初始化,值就不能改变;
局部常量的重要特点:
- 在声明时必须初始化;
- 在声明后不能改变;
void Func(){ const int MyInt = 22; // 声明MyInt必须初始化 MyInt = 33; // 错误,初始化后的常量不能修改 }
提醒:MyInt 因为是局部常量,所以其必须有初始化语句。
初始化值必须在编译期决定,通常是一个预定义简单类型或由其组成的表达式。它还可以是null引用,但其初始化值不能是某对象的引用,因为对象的引用是在运行时决定的。
返回语句 和 void方法
提前return返回:
void Func(int i){ if(i == 0){ return; // 函数是void,但是可以使用 return; 来提前返回 } Console.WriteLine("ok"); }
局部函数
C#7 , 就是函数内嵌套函数(嵌套的函数称为局部函数):
public void MyFunc(){ int NFunc(int z1){ // 声明局部函数 return z1 * 5; } // 调用局部函数 int results = NFunc(5); }
参数
分: 实参 和 形参;
形参
形参 是 局部变量,它声明再方法的参数列表中,而不是在方法体中;
形参的参数:是在方法体的外面定义并在方法开始之前初始化(但有一种类型例外,称为输出参数,我们将很快谈到它)。
实参
当代码调用一个方法时,形参的值 必须在方法的代码开始执行之前初始化。用于初始化形参的表达式或变量称作实参(actual parameter,有时也称argument)。
- 实参位于方法调用的参数列表中。
- 每一个实参必须与对应形参的类型相匹配,或是编译器必须能够把实参隐式转换为那个类型。
例如,下面的代码展示了方法PrintSum的调用,它有两个int类型的实参。
PointerToHighestPositive(int[] {111, 222, 333}); // 其中 int[] {111, 222, 333} 就是实参
当方法被调用的时候,每个实参的值都被用于初始化相应的形参,方法体随后被执行。
值参数 和 引用参数
注意:这里的 值参数/引用参数 和 值类型/引用类型 的区别,不要混淆这两组概念
值参数
当使用值参数时,通过将实参的值复制到形参的方式把数据传递给方法。
方法被调用时,系统执行如下操作:
- 在栈中为形参分配空间。
- 将实参的值复制给形参。
值参数的实参不一定是变量,它可以是任何能计算成相应数据类型的表达式。例如:
float func1(float val){ ... } float k = 1.0f; float j = 2.0f; func1(66.0f); func1(k); func1((k + j) / 3);
所谓值类型就是指类型本身包含其值。不要把值类型和这里介绍的值参数混淆,它们是完全不同的两个概念。值参数是把实参的值复制给形参。
值类型 和 引用类型 的值参数例子
class MyClass { public int Val = 20; } // f1 是引用类型的值参数、f2 是值类型的值参数 static void MyMethod(MyClass f1, int f2) // f1 和 f2 是形参 { f1.Val = f1.Val + 5; f2 = f2 + 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
- 在方法MyMethod被调用前,用作实参的变量a2已经在栈里了。
- 在方法开始时,系统在栈中为形参分配空间,并从实参复制值。
在方法开始时,系统在栈中为形参分配空间,并从实参复制值。
- 因为a1是引用类型的,所以引用被复制,结果实参和形参都引用堆中的同一个对象。
- 因为a2是值类型的,所以值被复制,产生了一个独立的数据项。
在方法的结尾, f2和对象f1的字段都被加上了5。
- 方法执行后,形参从栈中弹出。
- a2,值类型,它的值不受方法行为的影响。
- a1,引用类型,但它的值被方法的行为改变了。
引用参数 ref
使用引用参数时,必须在方法的声明和调用中都使用ref修饰符。
实参必须是变量,在用作实参前必须被赋值。如果是引用类型变量,可以赋值为一个引用或 null。
void MyFunc(ref int val){...} int y = 1; MyFunc(ref y); MyFunc(ref 1+2); // 错误,必须使用变量
在之前的内容中,我们已经认识到了,对于值参数,系统在栈上为形参分配内存。相反,引用参数具有以下特征:
- 不会在栈上为形参分配内存。
- 形参的参数名将作为实参变量的别名,指向相同的内存位置。
由于形参名和实参名指向相同的内存位置,所以在方法的执行过程中对形参做的任何改变,在方法完成后依然可见(表现在实参变量上)。
记住要在方法的声明和调用上都使用ref关键字。
例如,下面的代码再次展示了方法MyMethod,但这一次参数是引用参数而不是值参数。
class MyClass { public int Val = 20; } // f1 和 f2 用了 ref 修饰,所以这两个为 引用参数 // 这两个参数并不会在栈上开辟空间,其实实参的别名,内存指向对应实参的地址,即:&a1 和 &a2 static void MyMethod(ref MyClass f1,ref int f2) { // 修改 f1 或者 f2 ,相当于修改了实参 a1 或 a2 f1.Val = f1.Val + 5; f2 = f2 + 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
在方法MyMethod调用之前,将要被用作实参的变量a1和a2已经在栈里了。
在方法的开始,形参名f1 f2被设置为实参的别名a1 a2。变量a1和f1引用相同的内存位置,a2和f2引用相同的内存位置。
在方法的结束位置,f2和f1的对象的字段都被加上了5。
方法执行之后,形参的名称已经失效,但是值类型a2的值和引用类型a1所指向的对象的值都被方法内的行为改变了。
引用类型 作为 值参数 和 引用参数
通过在前面的学习发现,对于一个引用类型对象,不管是将其作为值参数传递还是作为引用参数传递,都可以在方法成员内部修改它的成员。
不过,我们并没有在方法内部设置形参本身,接下来,我们看看在方法内设置引用类型形参时会发生什么。
- 将引用类型对象作为值参数传递 如果在方法内创建一个新对象并赋值给形参,将切断形参与实参之间的关联,并且在方法调用结束后,新对象也将不复存在。
- 将引用类型对象作为引用参数传递 如果在方法内创建一个新对象并赋值给形参,在方法结束后该对象依然存在,并且是实参所引用的值。
第一种情况——将 引用类型对象 作为 值参数 传递:
class MyClass { public int Val = 20; } // 引用类型 作为 值参数 传递给该方法,修改 static void RefAsParameter(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}"); RefAsParameter(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
上面代码用图展示:
第二种情况——将 引用类型对象 作为 引用参数 传递:
class MyClass { public int Val = 20; } static void RefAsParameter(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}"); RefAsParameter(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
用于从方法体内把数据传出到调用代码,它们的行为与引用参数类似。
输出参数有以下要求:
- 必须在声明和调用中都使用修饰符。输出参数的修饰符是out而不是ref。
- 和引用参数相似,实参必须是变量,而不能是其他类型的表达式。这是有道理的,因为方法需要内存位置来保存返回值。
void MyFunc(out int val){ ... } int y = 1; MyFunc(out y);
与引用参数类似,输出参数的形参充当实参的别名。形参和实参都是同一块内存位置的名称。
显然,在方法内对形参做的任何改变在方法执行完成之后(通过实参变量)都是可见的
与引用参数不同,输出参数有以下要求:
- 在方法内部,给输出参数赋值之后才能读取它。这意味着与实参的初始值是无关的,而且没有必要在方法调用之前为实参赋值。
- 在方法内部,在方法返回之前,代码中每条可能的路径都必须为所有输出参数赋值。
因为 方法内的代码在读取输出参数之前必须对其写入,所以不可能使用 输出参数 把数据传入方法。事实上,如果方法中有任何执行路径试图在方法给输出参数赋值之前读取它的值,编译器就会产生一条错误消息。
public void Add2(out int outValue){ // 错误,在outValue赋值之前无法读取输出变量进行运算 int var1 = outValue + 2; }
正确:
class MyClass { public int Val = 20; } 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; // 根据out关系,没必要为a2赋值(初始化) MyMethod(out a1, out a2); Console.WriteLine($"a1.Val:{a1.Val}, a2:{a2}"); } 输出: a1.Val:25, a2:15
在方法调用之前,将要被用作实参的变量a1和a2已经在栈里了
在方法的开始,形参的名称被设置为实参的别名。你可以认为变量a1和f1指向的是相同的内存位置,也可以认为a2和f2指向的是相同的内存位置。a1和a2不在作用域之内,所以不能在MyMethod中访问。
在方法内部, 代码创建了一个MyClass类型的对象并把它赋值给f1。然后赋一个值给f1的字段,也赋一个值给f2。对f1和f2的赋值都是必需的,因为它们是输出参数。
方法执行之后,形参的名称已经失效,但是引用类型的a1和值类型的a2的值都被方法内的行为改变了。
out 升级!!
从C#7.0开始,你不再需要预先声明一个变量来用作out参数了。你可以在调用方法时在参数列表中添加一个变量类型,它将作为变量声明。
MyClass a1 = null; int a2; MyMethod(out a1, out a2); 升级成: MyMethod(out MyClass a1, out int a2);
参数数组 params
上面中,都是一个形参必须严格地对应一个实参。
参数数组则不同,它允许特定类型的零个或多个实参对应一个特定的形参。参数数组的重点如下。
- 在一个参数列表中只能有一个参数数组。
- 如果有,它必须是列表中的最后一个。
- 由参数数组表示的所有参数必须是同一类型。
声明一个参数数组时必须做的事如下
- 由参数数组表示的所有参数必须是同一类型。
- 在数据类型前使用params修饰符。
- 在数据类型后放置一组空的方括号。
void ListInts(params int[] inVals){ ... }
inVals 数组是:
- 有序的、同一类行的
- 使用索引访问该数组
- 数组是一个引用类型,因此它的所有数据项都保存在堆中
方法调用 参数数组
方法一、延伸式:
MyFunc(10, 20, 30); 编译器会: - 接受实参列表,用它们在堆中创建并初始化一个数组。 - 把数组的引用保存到栈中的形参里。 - 如果在对应形参数组的位置没有实参,编译器会创建一个有零个元素的数组来使用。
例子:
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]); } } } } int first = 5, second = 6, third = 7; MyClass mc = new MyClass(); mc.ListInts(first, second, third); Console.WriteLine($"{first}, {second}, {third}"); // 输出 50 60 70 5, 6, 7
- 方法调用之前,3个实参已经在栈里。
- 在方法的开始,3个实参被用于初始化堆中的数组,并且数组的引用被赋值给形参inVals。
- 在方法内部, 代码首先检查以确认数组引用不是null, 然后处理数组, 把每个元素乘以10并保存回去。
- 方法执行之后, 形参inVals失效。
关于参数数组,需要记住的一点是当数组在堆中被创建时,实参的值被复制到数组中。这样,它们像值参数。
- 如果数组参数是值类型,那么值被复制,实参在方法内部不受影响。
- 如果数组参数是引用类型,那么引用被复制,实参引用的对象在方法内部会受到影响。
参数数组的类型为引用类型:
public class AClass { public int Number { get; set; } } class MyClass { public void ListInts(params AClass[] inVals) { if (inVals != null && inVals.Length != 0) { for (int i = 0; i < inVals.Length; i++) { inVals[i].Number = inVals[i].Number * 10; Console.WriteLine(inVals[i].Number); } } } } static void Main() { var first = new AClass() { Number = 5 }; var second = new AClass() { Number = 6 }; var third = new AClass() { Number = 7 }; MyClass mc = new MyClass(); mc.ListInts(first, second, third); Console.WriteLine($"{first.Number}, {second.Number}, {third.Number}"); } // 输出 50 60 70 50, 60, 70
方法二:
int[] intArray = {1, 2, 3}; MyFunc(intArray);
参数类型总结
参数类型 | 修饰符 | 是否在声明时使用 | 是否在调用时使用 | 执行 |
值 | 无 | 系统把实参的值复制到形参 | ||
引用 | ref | 是 | 是 | 形参是实参的别名 |
输出 | out | 是 | 是 | 仅包含一个返回的值,形参是实参的别名 |
数组 | params | 是 | 否 | 允许传递可变数目的实参到方法 |
方法重载
一个类中可以有多个同名方法,这叫作方法重载(method overloading)。
使用相同名称的每个方法必须有一个和其他方法不同的签名(signature)。
- 方法的签名由下列信息组成,它们在方法声明的方法头中:
- 方法的名称
- 参数的数目
- 参数的数据类型和顺序
- 参数修饰符
- 返回类型不是签名的一部分,而我们往往误认为它是签名的一部分。
- 请注意,形参的名称也不是签名的一部分。
long AddValues(int a, out int b){ ... } 签名为: AddValues(int, out int);
请注意,形参的名称也不是签名的一部分。
命名参数
public int Calc(int a, int b, int c){ ... } Calc(c: 2, a: 4, b: 3);
可选参数
public int Calc(int a, int b = 3){ ... } Calc(5, 6); Calc(5);
不是所有的参数类型都可以作为可选参数。
- 只要值类型的默认值在编译的时候可以确定,就可以使用值类型作为可选参数。
- 只有在默认值是null的时候,引用类型才可以用作可选参数。
所有必填参数(required paramenter )必须在可选参数声明之前声明。如果有params参数,必须在所有可选参数之后声明。
栈帧
至此,我们已经知道了局部变量和参数是位于栈上的,下面深入探讨一下其组织。
在调用方法的时候,内存从栈的顶部开始分配,保存和方法关联的一些数据项。这块内存叫作方法的栈帧(stack frame)。
- 栈帧包含的内存保存如下内容。
- 返回地址,也就是在方法退出的时候继续执行的位置。
- 分配内存的参数,也就是方法的值参数,还可能是参数数组(如果有的话)。
- 和方法调用相关的其他管理数据项。
- 在方法调用时,整个栈帧都会压入栈。
- 在方法退出的时候,整个栈帧都会从栈上弹出。弹出栈帧有的时候也叫作 栈展开(unwind)。
static void MethodA(int par1, int par2) { Console.WriteLine($"Enter MethodA:{par1}, {par2}"); MethodB(11, 18); 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); Console.WriteLine($"Exit Main"); } // 输出: Enter Main Enter MethodA:15, 30 Enter MethodB:11, 18 Exit MethodB Exit MethodA Exit Main
下图演示了 在调用方法时栈帧压入栈的过程和方法结束时后栈展开的过程:
递归
方法调用自身。这叫作递归。
递归会产生很优雅的代码,比如下面计算阶乘数的方法就是如此:
int Factorial(int inValue) { if(inValue <= 1) { return inValue; } else { return inValue * Factorial(inValue - 1); } }
调用方法自身的机制和调用其他方法其实是完全一样的,都是为每一次方法调用把新的栈帧压人栈顶。
随着递归越来越深,栈也越来越大。
至此,方法部分已经讲完了。