第 14 节 · 第二部分 流程、方法与数据

方法

方法是起了名字的一段代码,参数进、返回值出;默认传的是副本,ref 共用同一块内存,out 由方法负责赋值;方法可以调用自己(递归),也可以同名不同参数(重载)。

时长 10:11授课计划第 5 周

学完这一节,你能

讲义

跟着视频的顺序写。每行代码都有注释,代码和运行结果都是实际运行得到的。

1方法是什么

方法就是把一组相关的语句打包、起个名字,用来完成一件事。写好一次,到处都能调用,同样的代码就不用重复写。

你写过的程序里都有一个方法 Main,程序从它开始运行。完整写法的程序至少有一个 Main;用顶级语句写法(不写类和 Main,代码从第一行开始执行)时,编译器会替你生成入口方法:

C#
1Console.WriteLine("Hello, World!");// 整个程序只有这一行,照样能运行,输出 Hello, World!

使用方法分两步:先定义,再调用。

2定义方法

定义方法有五个部分:

  1. 访问修饰符:决定谁能调用,常见的有 public、private、protected、internal,不写默认是 private。
  2. 返回类型:方法交回的结果是什么类型,什么都不交回就写 void。
  3. 方法名:习惯用每个单词首字母大写的 PascalCase。
  4. 参数列表:圆括号里,方法要接收的输入,多个用逗号隔开,也可以一个都没有。
  5. 方法体:花括号里,真正干活的代码。

C# 里的方法签名只看方法名和参数列表,访问修饰符和返回类型都不算,讲重载时会用到这一点。

3调用方法

FindMax 找出两个数里较大的那个:public,返回 int,接收两个 int 参数。方法定义里接收输入的变量叫形参,调用时传进去的值叫实参。

C# · 第 8-14 行
8public int FindMax(int num1, int num2)// public、返回 int、方法名 FindMax、两个 int 形参
9{// FindMax 开始
10 if (num1 > num2)// 比较两个数:100 > 200 为假
11 return num1;// num1 大就交回 num1
12 else// 否则
13 return num2;// return 把 200 交回去
14}// FindMax 结束
C# · 第 19-22 行
19NumberHelper helper = new NumberHelper();// 先 new 一个 NumberHelper 对象
20int result = helper.FindMax(100, 200);// 实参 100、200 交给形参,返回的 200 存进 result
21
22Console.WriteLine($"最大值是:{result}"); // 最大值是:200// $"..." 里的 {result} 换成变量的值,输出“最大值是:200”

调用分四步:传实参、形参接收、执行方法体、返回结果。

FindMax 没有写 static,它属于对象,得先有对象才能调用。去掉对象,直接在 Main 里调用就报错:

C# · 第 17 行
17int result = FindMax(100, 200);// 没有对象,直接调用非 static 方法
编译报错
Program.cs(17,26): error CS0120: 对象引用对于非静态的字段、方法或属性“NumberHelper.FindMax(int, int)”是必需的

方法是 public 的,另一个类也能先创建对象再调用它。写了 static 的方法属于这个类本身,不用创建对象,后面就会见到。

4三种传参方式

  • 按值传递(默认):传的是值的副本。
  • ref(按引用传递):方法改的就是原来的变量。
  • out(输出参数):让方法把结果写回调用方。

区别在于方法和调用方用不用同一块内存。这里只讲最常用的这三种。

按值传递

C# · 第 8-14 行
8public void Swap(int x, int y)// 按值传递:x、y 是 a、b 的副本
9{// Swap 开始
10 int temp = x;// temp 先存下 x(100)
11 x = y;// x 变成 200
12 y = temp;// y 变成 100
13 Console.WriteLine($" 方法内:x={x}, y={y}"); // 交换成功// 方法里面确实换过来了
14}// Swap 结束:副本扔掉

Main 里 a 是 100、b 是 200,调用 p.Swap(a, b):

运行结果
调用前:a=100, b=200
  方法内:x=200, y=100
调用后:a=100, b=200

ref:按引用传递

定义和调用时参数前面都要写 ref(定义是 Swap(ref int x, ref int y))。这次 x 不是副本,它和 a 用的是同一块内存,交换 x 和 y 就是在交换 a 和 b。

C# · 第 21-22 行
21p.Swap(ref a, ref b); // 调用时也必须加 ref// 交换的是 a、b 本身
22Console.WriteLine($"调用后:a={a}, b={b}"); // 交换成功!// 实测输出“调用后:a=200, b=100”

调用时忘了写 ref,编译器报错 CS1620(参数必须与关键字“ref”一起传递)。ref 的变量传进去之前还必须先赋值:

C# · 第 17-18 行
17int a, b;// 只声明,没赋值
18p.Swap(ref a, ref b);// 把没赋值的变量用 ref 传进去
编译报错
Program.cs(18,24): error CS0165: 使用了未赋值的局部变量“a”
Program.cs(18,31): error CS0165: 使用了未赋值的局部变量“b”

out:输出参数

out 正好反过来:变量传进去之前不用赋值,但方法结束之前必须给 out 参数赋值,忘了就报错 CS0177(控制离开当前方法之前必须对 out 参数赋值)。

教程的 GetValues 用两个 out 参数一次交回两个值;更常见的是 TryDivide 这种写法:返回值只说成没成功,真正的结果从 out 参数交出来。

C# · 第 19-27 行
19public bool TryDivide(int a, int b, out double result)// 返回 bool 表示成没成功,结果从 out 参数 result 交出
20{// TryDivide 开始
21 if (b == 0)// 除数是 0
22 {// if 块开始
23 result = 0;// out 参数也得赋值
24 return false; // 除数为零// 返回 false:没成功
25 }// if 块结束
26 result = (double)a / b;// (double) 先把 a 转成小数再除:10 / 3 = 3.333…
27 return true;// 返回 true:成功了
查看完整代码(44 行)
C#
1using System;// 引入 System 命名空间
2
3namespace ParameterDemo// 定义命名空间 ParameterDemo
4{// 命名空间开始
5 class Program// 定义 Program 类
6 {// Program 类开始
7 // out 参数:方法负责赋值
8 public void GetValues(out int x, out int y)// 两个 out 参数,一次交回两个值
9 {// GetValues 开始
10 Console.Write("请输入第一个值:");// 提示输入,不换行
11 x = Convert.ToInt32(Console.ReadLine());// 读入一行文字,Convert.ToInt32 转成整数,写进 x
12
13 Console.Write("请输入第二个值:");// 提示输入
14 y = Convert.ToInt32(Console.ReadLine());// 读入并写进 y
15 // 注意:方法结束前必须为所有 out 参数赋值,否则编译错误
16 }// GetValues 结束
17
18 // 实用示例:TryParse 模式
19 public bool TryDivide(int a, int b, out double result)// 返回成没成功,结果从 result 交出
20 {// TryDivide 开始
21 if (b == 0)// 除数是 0
22 {// if 块开始
23 result = 0;// 也要给 result 赋值
24 return false; // 除数为零// 返回 false
25 }// if 块结束
26 result = (double)a / b;// 转成小数再除
27 return true;// 返回 true
28 }// TryDivide 结束
29
30 static void Main(string[] args)// 程序入口 Main 方法
31 {// Main 开始
32 var p = new Program();// var 让编译器自己推断类型,这里是 Program
33
34 // out 参数无需初始化
35 int a, b;// 只声明,不赋值
36 p.GetValues(out a, out b);// 两次都输入 5
37 Console.WriteLine($"a={a}, b={b}");// 打印 a=5, b=5
38
39 // TryParse 模式
40 if (p.TryDivide(10, 3, out double res))// 调用时直接声明 out 变量 res
41 Console.WriteLine($"10 / 3 = {res:F2}"); // 3.33// :F2 表示保留两位小数
42 }// Main 结束
43 }// Program 类结束
44}// 命名空间结束

运行时两次都输入 5:

运行结果
请输入第一个值:5
请输入第二个值:5
a=5, b=5
10 / 3 = 3.33

int 自带的解析方法 int.TryParse 就是这种写法。从 C# 7.0 开始,还能在调用时直接声明 out 变量:

C# · 第 10-11 行
10if (int.TryParse("123", out int number))// 把文字 "123" 解析成整数;number 不用提前声明
11 Console.WriteLine($"解析成功:{number}");// 解析成功,打印“解析成功:123”

5递归

方法调用它自己,叫递归。递归要有两样东西:基准条件让递归停下来;递归步骤把问题拆成更小的同类问题。教程的例子是阶乘:n 的阶乘等于 n 乘以 n − 1 的阶乘。

C# · 第 10-15 行
10// 基准条件:0! = 1,1! = 1
11if (n <= 1)// n 小于等于 1
12 return 1;// 直接返回 1,不再往下调用
13
14// 递归步骤:n! = n × (n-1)!
15return n * Factorial(n - 1);// 调用自己算 n - 1 的阶乘,再乘以 n

拿 4 来看:每调用一次,调用栈(记录方法调用到哪一层的一块内存)上就叠一层。4 调用 3,3 调用 2,2 调用 1,到了 1 碰上基准条件;然后从最上面一层往回交结果:1、2、6,最后是 24(实测输出 4! = 24)。Main 里再算 6、7、8 的阶乘:

运行结果
6! = 720
7! = 5040
8! = 40320

把基准条件删掉,方法会一直调用自己,调用栈越叠越高,最后栈溢出,程序直接崩溃退出:

C# · 第 10 行
10return n * Factorial(n - 1);// 去掉了 if (n <= 1) return 1;,永远在调用自己
查看完整输出(4 行)
运行结果
Stack overflow.
Repeated 174212 times:
--------------------------------
   at RecursionDemo.Program.Factorial(Int32)

这就是 StackOverflowException(栈溢出异常),try catch(捕获异常的语句,后面会讲)也接不住。递归层数可能很深时,可以考虑改用循环。

6默认参数和命名参数

参数可以设默认值,调用时不传就用默认值;命名参数是在实参前面写上参数名和冒号,写了参数名,顺序就能打乱。

C# · 第 8 行
8static double Power(double baseNum, int power = 2)// power 默认值是 2;写了 static,Main 里不用创建对象
C# · 第 17 行
17static void PrintInfo(string name, int age = 18, string city = "未知")// age 默认 18,city 默认“未知”
C# · 第 25-33 行
25Console.WriteLine(Power(3)); // 9.0(使用默认 power=2)// 只传 3,power 用默认的 2:实测打印 9
26Console.WriteLine(Power(3, 3)); // 27.0// 默认值被换掉:3 × 3 × 3,实测打印 27
27
28// 命名参数:跳过 age,只指定 city
29PrintInfo("小明", city: "北京");// city: 是命名参数;跳过 age,age 用默认的 18
30// 输出:小明, 18岁, 北京
31
32// 命名参数可以不按顺序
33PrintInfo(city: "上海", name: "小红", age: 25);// 先写 city,顺序打乱,实测输出“小红, 25岁, 上海”

double 值是整数时打印出来不带小数点,所以 Power(3) 打印 9,Power(3, 3) 打印 27。

7表达式体方法

方法体只有一句 return 时,可以去掉花括号和 return,用箭头 => 接上表达式,C# 6.0 开始支持。箭头后面只能跟一个表达式,不能放 if 这样的语句。

C# · 第 8-14 行
8public int Add(int a, int b)// 传统写法
9{// Add 开始
10 return a + b;// 只有一句 return
11}// Add 结束
12
13// 表达式体写法(等价)
14public int Add2(int a, int b) => a + b;// 箭头后面直接写要交回的表达式

两种写法算 1 加 2,都输出 3。

8方法重载

同一个类里可以有好几个同名的方法,只要参数列表不同(类型、个数或顺序不一样),这叫重载。Calculator 里有三个 Add:两个 int、三个 int、两个 double,调用时编译器看你传了什么参数,自动挑出对应的那一个。

C# · 第 19-21 行
19Console.WriteLine(calc.Add(1, 2)); // 3(调用重载 1)// 两个 int,选重载 1,打印 3
20Console.WriteLine(calc.Add(1, 2, 3)); // 6(调用重载 2)// 三个 int,选重载 2,打印 6
21Console.WriteLine(calc.Add(1.5, 2.5)); // 4.0(调用重载 3)// 两个 double,选重载 3,实测打印 4

只有返回类型不同,不算重载:

C# · 第 9 行
9public double Add(int a, int b) => a + b;// 参数和上一个 Add 完全一样,只是返回 double
编译报错
Program.cs(9,23): error CS0111: 类型“Calculator”已定义了一个名为“Add”的具有相同参数类型的成员

一个类里,方法名可以重复,不能重复的是签名。

9小结

  • 定义方法要写返回类型、方法名和参数,不返回值就写 void。
  • 按值传递改的是副本,ref 共享内存,out 必须在方法里赋值。
  • 默认参数调用时可以省略,命名参数可以不按顺序。
  • 单行方法用箭头简写,同名不同参数就是重载。

下一节:int 这种值类型,能不能“没有值”?

检查题

5 道,选完马上告诉你对不对。

  1. 1void Swap(int x, int y) 交换 x、y;a = 100, b = 200; Swap(a, b); 之后 a 是多少?

  2. 2int a, b; p.Swap(ref a, ref b); 会怎样?

  3. 3Factorial 去掉 if (n <= 1) return 1; 后调用 Factorial(4) 会怎样?

  4. 4Power(double baseNum, int power = 2),Console.WriteLine(Power(3)) 输出?

  5. 5同一个类里 int Add(int a, int b) 和 double Add(int a, int b) 能共存吗?