第 22 节 · 第三部分 面向对象

多态性

同一句调用,在不同对象上做出不同的事;重载在编译时按参数选,重写在运行时按对象的实际类型选。

时长 9:26授课计划第 8 周

学完这一节,你能

讲义

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

1多态是什么

多态的意思是:同一个行为,有多种不同的表现形式,教程概括成“一个接口,多个功能”。比如键盘上的 F1 是帮助键:在 Word 里按,弹出 Word 的帮助;在 Windows 桌面上按,弹出 Windows 的帮助和支持。同一个按键落在不同的软件上,结果不一样,这就是多态(教程里另一个 Flash 的例子已过时,Flash 在 2020 年底就停止支持了)。

2静态多态和动态多态

多态分两种:静态多态在编译的时候就定好调用哪个方法;动态多态要等程序运行的时候才决定执行哪个方法。

C# 里每个类型都是多态的,因为所有类型都继承自 Object(C# 里所有类型共同的祖先类)。下面的数组里放了整数、小数、字符串和一个 Circle 对象,对每个都调用 ToString:

C# · 第 11-15 行
11object[] items = { 42, 1.23, "Hello", new Circle() };// object 数组什么类型都能放;Circle 是个空类
12foreach (object x in items)// 挨个取出数组里的元素,放进 x
13{// foreach 开始
14 Console.WriteLine(x.ToString());// 同一句 ToString,交给不同的对象
15}// foreach 结束
运行结果
42
1.23
Hello
Circle

四个都能打印:前三个打印自己的值,Circle 自己没写 ToString,用的是从 Object 继承来的版本,打印出类型名。

3静态多态:函数重载

在编译时就把方法调用和具体的方法连起来,叫早期绑定(也叫静态绑定)。C# 用函数重载和运算符重载实现它,运算符重载是下一章的内容。C# 里函数一般叫方法,所以函数重载也常叫方法重载。

重载:同一个范围里,好几个方法用同一个名字,但参数列表必须不同,要么参数类型不同,要么参数个数不同。

C# · 第 6-13 行
6public int Add(int a, int b, int c)// 第一个 Add:接收三个整数
7{// Add(三个参数) 开始
8 return a + b + c;// 返回三个数的和
9}// Add(三个参数) 结束
10public int Add(int a, int b)// 第二个 Add:同名,只接收两个整数
11{// Add(两个参数) 开始
12 return a + b;// 返回两个数的和
13}// Add(两个参数) 结束
C# · 第 20-21 行
20int add1 = dataClass.Add(1, 2);// dataClass 是 TestData 对象;两个参数:1 + 2 = 3
21int add2 = dataClass.Add(1, 2, 3);// 三个参数,走三个参数的 Add:1 + 2 + 3 = 6

视频里把它比成分拣台:同一块 Add 名牌下面有两个入口,一个三个孔、一个两个孔,调用时带几个参数,就只插得进孔数相同的那个入口。走哪个入口,编译器在编译时就定好了,运行结果是 add1 :3、add2 :6。

查看完整代码(27 行)
C#
1using System;// 引入 System 命名空间
2namespace PolymorphismApplication// 定义命名空间 PolymorphismApplication
3{// 命名空间开始
4 public class TestData// 定义 TestData 类
5 {// TestData 类开始
6 public int Add(int a, int b, int c)// 第一个 Add:接收三个整数
7 {// Add(三个参数) 开始
8 return a + b + c;// 返回三个数的和
9 }// Add(三个参数) 结束
10 public int Add(int a, int b)// 第二个 Add:同名,参数个数不同
11 {// Add(两个参数) 开始
12 return a + b;// 返回两个数的和
13 }// Add(两个参数) 结束
14 }// TestData 类结束
15 class Program// 定义 Program 类
16 {// Program 类开始
17 static void Main(string[] args)// 程序入口 Main 方法
18 {// Main 开始
19 TestData dataClass = new TestData();// 创建一个 TestData 对象
20 int add1 = dataClass.Add(1, 2);// 两个参数,调用 Add(int, int):add1 = 3
21 int add2 = dataClass.Add(1, 2, 3);// 三个参数,调用 Add(int, int, int):add2 = 6
22
23 Console.WriteLine("add1 :" + add1);// 用 + 把文字和数字拼起来输出:add1 :3
24 Console.WriteLine("add2 :" + add2);// 输出 add2 :6
25 }// Main 结束
26 }// Program 类结束
27}// 命名空间结束

4参数类型不同也是重载

Printdata 里有三个 print,参数个数一样,类型分别是 int、double、string,编译器按传进去的值是什么类型来挑。

C# · 第 22-28 行
22Printdata p = new Printdata();// 创建一个 Printdata 对象
23// 调用 print 来打印整数
24p.print(1);// 1 是 int,走 print(int i)
25// 调用 print 来打印浮点数
26p.print(1.23);// 1.23 是 double,走 print(double f)
27// 调用 print 来打印字符串
28p.print("Hello Runoob");// 双引号里是 string,走 print(string s)
查看完整输出(3 行)
运行结果
输出整型: 1
输出浮点型: 1.23
输出字符串: Hello Runoob
查看完整代码(32 行)
C#
1using System;// 引入 System 命名空间
2namespace PolymorphismApplication// 定义命名空间 PolymorphismApplication
3{// 命名空间开始
4 class Printdata// 定义 Printdata 类
5 {// Printdata 类开始
6 void print(int i)// 第一个 print:参数是 int
7 {// print(int) 开始
8 Console.WriteLine("输出整型: {0}", i );// {0} 是占位符,会换成后面的 i
9 }// print(int) 结束
10
11 void print(double f)// 第二个 print:参数是 double(小数)
12 {// print(double) 开始
13 Console.WriteLine("输出浮点型: {0}" , f);// 输出“输出浮点型: ”加上 f
14 }// print(double) 结束
15
16 void print(string s)// 第三个 print:参数是 string(字符串)
17 {// print(string) 开始
18 Console.WriteLine("输出字符串: {0}", s);// 输出“输出字符串: ”加上 s
19 }// print(string) 结束
20 static void Main(string[] args)// 程序入口 Main 方法
21 {// Main 开始
22 Printdata p = new Printdata();// 创建一个 Printdata 对象
23 // 调用 print 来打印整数
24 p.print(1);// 1 是 int,走 print(int)
25 // 调用 print 来打印浮点数
26 p.print(1.23);// 1.23 是 double,走 print(double)
27 // 调用 print 来打印字符串
28 p.print("Hello Runoob");// 字符串,走 print(string)
29 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
30 }// Main 结束
31 }// Printdata 类结束
32}// 命名空间结束

5只有返回类型不同,不能重载

在 TestData 里再写一个返回 double 的 Add,参数还是两个整数:

C# · 第 10 行
10public double Add(int a, int b)// 新加的 Add:返回 double,参数是两个 int
C# · 第 14 行
14public int Add(int a, int b)// 原来的 Add:参数也是两个 int,编译器在这一行报错
编译报错
Program.cs(14,20): error CS0111: 类型“TestData”已定义了一个名为“Add”的具有相同参数类型的成员

调用时只看参数,不看返回类型,两个 Add 的参数一模一样,编译器分不出该用哪一个。

6动态多态:抽象类

动态多态靠两样东西:抽象类和虚方法。抽象类用关键字 abstract 声明,里面可以有抽象方法:只写方法头(返回类型、名字、参数那一行)、不写方法体(花括号里的代码),具体怎么做留给派生类用 override(重写:在派生类里重新写这个方法)去实现。下面的 Shape 前面写了 abstract,Rectangle 继承 Shape。

C# · 第 6 行
6abstract public int area();// 抽象方法 area:分号结尾,没有 { }
C# · 第 17 行
17public override int area ()// Rectangle 用 override 写出 area:返回宽 × 长
运行结果
Rectangle 类的面积:
面积: 70
查看完整代码(34 行)
C#
1using System;// 引入 System 命名空间
2namespace PolymorphismApplication// 定义命名空间 PolymorphismApplication
3{// 命名空间开始
4 abstract class Shape// 抽象类 Shape
5 {// Shape 类开始
6 abstract public int area();// 抽象方法 area:不写方法体
7 }// Shape 类结束
8 class Rectangle: Shape// Rectangle 继承 Shape
9 {// Rectangle 类开始
10 private int length;// 字段:长
11 private int width;// 字段:宽
12 public Rectangle( int a=0, int b=0)// 构造函数,a=0 表示不传时默认是 0
13 {// Rectangle 构造函数开始
14 length = a;// 长 = a
15 width = b;// 宽 = b
16 }// Rectangle 构造函数结束
17 public override int area ()// 用 override 实现抽象方法 area
18 {// area 开始
19 Console.WriteLine("Rectangle 类的面积:");// 打印提示
20 return (width * length);// 返回宽 × 长
21 }// area 结束
22 }// Rectangle 类结束
23
24 class RectangleTester// 定义 RectangleTester 类
25 {// RectangleTester 类开始
26 static void Main(string[] args)// 程序入口 Main 方法
27 {// Main 开始
28 Rectangle r = new Rectangle(10, 7);// 造一个长 10、宽 7 的长方形
29 double a = r.area();// 调用 area:打印提示,返回 7 × 10 = 70
30 Console.WriteLine("面积: {0}",a);// 输出“面积: 70”
31 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
32 }// Main 结束
33 }// RectangleTester 类结束
34}// 命名空间结束

抽象类可以包含抽象方法,一个都没有也行。

7抽象类的规则

下面每条规则都用编译器验证过。

  1. 不能直接 new 抽象类的对象。
C# · 第 28 行
28Shape s = new Shape();// 在 Main 里 new 抽象类 Shape
编译报错
Program.cs(28,20): error CS0144: 无法创建抽象类型或接口“Shape”的实例
  1. 抽象方法只能写在抽象类里面。 把 Shape 前面的 abstract 去掉:
C# · 第 4-6 行
4class Shape// 去掉了 abstract,Shape 成了普通类
5{// Shape 类开始
6 abstract public int area();// 抽象方法还留着,编译器在这一行报错
编译报错
Program.cs(6,28): error CS0513: “Shape.area()”是抽象的,但它包含在非抽象类型“Shape”中
  1. 类前面写 sealed,这个类就被密封了,别的类不能再继承它。
C# · 第 8 行
8sealed class Rectangle: Shape// sealed:Rectangle 被密封
C# · 第 24 行
24class Square : Rectangle// Square 想继承被密封的 Rectangle
编译报错
Program.cs(24,19): error CS0509: “Square”: 无法从密封类型“Rectangle”派生
  1. 抽象类不能同时是 sealed。 抽象类等着别人继承,密封又不让继承,放在一起就矛盾了。
C# · 第 4 行
4abstract sealed class Shape// 既抽象又密封

编译器报 CS0418:“Shape”: 抽象类型不能是密封的或静态的。后面还连带报了 Rectangle 无法从密封类型派生(CS0509)等错误。

8虚方法

基类里有个方法,想让每个派生类各写各的,就用关键字 virtual 把它声明成虚方法,派生类用 override 重写它。调用虚方法时执行哪个版本,要等运行时看对象实际是什么才决定。动态多态就是靠抽象类和虚方法实现的,后面要学的接口也能做到。

9例子:Draw

基类 Shape 有虚方法 Draw;Circle、Rectangle、Triangle 都重写了 Draw,先打印自己画什么,再用 base.Draw() 调用基类的版本。Main 里的 shapes 是一个 List<Shape>(能装 Shape 及其派生类对象的列表),依次放了一个 Rectangle、一个 Triangle、一个 Circle。

C# · 第 12 行
12public virtual void Draw()// Shape 里的虚方法 Draw,打印“执行基类的画图任务”
C# · 第 28-32 行
28public override void Draw()// Rectangle 用 override 重写 Draw
29{// Rectangle.Draw 开始
30 Console.WriteLine("画一个长方形");// 先打印自己画什么
31 base.Draw();// base.Draw():再调用基类 Shape 的 Draw
32}// Rectangle.Draw 结束
C# · 第 56-59 行
56foreach (var shape in shapes)// var 让编译器推断类型,这里 shape 是 Shape
57{// foreach 开始
58 shape.Draw();// 同一句调用,执行的是对象自己那一版 Draw
59}// foreach 结束

视频里用调用台演示:每个对象分两层,下层继承自 Shape,上层是自己加的。shape 指向 Rectangle 时,先打印“画一个长方形”,base.Draw() 再打印“执行基类的画图任务”;三个对象共六行,最后是“按下任意键退出。”。调用台只是比喻,真实程序是运行时按对象的实际类型,找到它重写的那个方法。

查看完整输出(7 行)
运行结果
画一个长方形
执行基类的画图任务
画一个三角形
执行基类的画图任务
画一个圆形
执行基类的画图任务
按下任意键退出。
查看完整代码(65 行)
C#
1using System;// 引入 System 命名空间
2using System.Collections.Generic;// 引入 List 所在的命名空间
3
4public class Shape// 定义基类 Shape
5{// Shape 类开始
6 public int X { get; private set; }// X 是属性(后面会讲):外面只能读,不能改
7 public int Y { get; private set; }// Y 也是属性(后面会讲):外面只读
8 public int Height { get; set; }// Height 是属性(后面会讲):可读可写
9 public int Width { get; set; }// Width 是属性(后面会讲):可读可写
10
11 // 虚方法
12 public virtual void Draw()// 虚方法 Draw
13 {// Shape.Draw 开始
14 Console.WriteLine("执行基类的画图任务");// 基类版本打印的内容
15 }// Shape.Draw 结束
16}// Shape 类结束
17
18class Circle : Shape// Circle 继承 Shape
19{// Circle 类开始
20 public override void Draw()// 重写 Draw
21 {// Circle.Draw 开始
22 Console.WriteLine("画一个圆形");// 打印自己画什么
23 base.Draw();// 再调用基类的 Draw
24 }// Circle.Draw 结束
25}// Circle 类结束
26class Rectangle : Shape// Rectangle 继承 Shape
27{// Rectangle 类开始
28 public override void Draw()// 重写 Draw
29 {// Rectangle.Draw 开始
30 Console.WriteLine("画一个长方形");// 打印自己画什么
31 base.Draw();// 再调用基类的 Draw
32 }// Rectangle.Draw 结束
33}// Rectangle 类结束
34class Triangle : Shape// Triangle 继承 Shape
35{// Triangle 类开始
36 public override void Draw()// 重写 Draw
37 {// Triangle.Draw 开始
38 Console.WriteLine("画一个三角形");// 打印自己画什么
39 base.Draw();// 再调用基类的 Draw
40 }// Triangle.Draw 结束
41}// Triangle 类结束
42
43class Program// 定义 Program 类
44{// Program 类开始
45 static void Main(string[] args)// 程序入口 Main 方法
46 {// Main 开始
47 // 创建一个 List<Shape> 对象,并向该对象添加 Circle、Triangle 和 Rectangle
48 var shapes = new List<Shape>// var:编译器按右边推断出 List<Shape>
49 {// 列表初始化开始
50 new Rectangle(),// 第 1 个:长方形
51 new Triangle(),// 第 2 个:三角形
52 new Circle()// 第 3 个:圆形
53 };// 列表初始化结束
54
55 // 使用 foreach 循环对该列表的派生类进行循环访问,并对其中的每个 Shape 对象调用 Draw 方法
56 foreach (var shape in shapes)// 依次取出列表里的对象
57 {// foreach 开始
58 shape.Draw();// 按对象的实际类型执行它的 Draw
59 }// foreach 结束
60
61 Console.WriteLine("按下任意键退出。");// 最后打印退出提示
62 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
63 }// Main 结束
64
65}// Program 类结束

10例子:CallArea

基类 Shape 的虚方法 area 返回 0,Rectangle 返回宽乘高,Triangle 返回宽乘高再除以二。Caller 类的 CallArea 方法,参数类型是 Shape。Main 里 c 是 Caller 对象,r 是 10 × 7 的 Rectangle,t 是 10 × 5 的 Triangle。

C# · 第 47 行
47a = sh.area();// CallArea 里:执行 sh 实际指向的对象的 area
C# · 第 59-60 行
59c.CallArea(r);// 传入 Rectangle:执行它的 area,10 × 7 = 70
60c.CallArea(t);// 传入 Triangle:执行它的 area,10 × 5 / 2 = 25
查看完整输出(4 行)
运行结果
Rectangle 类的面积:
面积: 70
Triangle 类的面积:
面积: 25

参数写的是 Shape,传进 Triangle,执行的仍是 Triangle 自己的 area,所以是 25 不是 0。

查看完整代码(64 行)
C#
1using System;// 引入 System 命名空间
2namespace PolymorphismApplication// 定义命名空间 PolymorphismApplication
3{// 命名空间开始
4 class Shape// 定义基类 Shape(普通类)
5 {// Shape 类开始
6 protected int width, height;// protected:派生类里也能用的宽和高
7 public Shape( int a=0, int b=0)// Shape 构造函数,默认值都是 0
8 {// Shape 构造函数开始
9 width = a;// 宽 = a
10 height = b;// 高 = b
11 }// Shape 构造函数结束
12 public virtual int area()// 虚方法 area
13 {// Shape.area 开始
14 Console.WriteLine("父类的面积:");// 基类版本的提示
15 return 0;// 基类不知道形状,返回 0
16 }// Shape.area 结束
17 }// Shape 类结束
18 class Rectangle: Shape// Rectangle 继承 Shape
19 {// Rectangle 类开始
20 public Rectangle( int a=0, int b=0): base(a, b)// : base(a, b) 把参数交给 Shape 的构造函数
21 {// Rectangle 构造函数开始
22
23 }// Rectangle 构造函数结束
24 public override int area ()// 重写 area
25 {// Rectangle.area 开始
26 Console.WriteLine("Rectangle 类的面积:");// 打印提示
27 return (width * height);// 返回宽 × 高
28 }// Rectangle.area 结束
29 }// Rectangle 类结束
30 class Triangle: Shape// Triangle 继承 Shape
31 {// Triangle 类开始
32 public Triangle(int a = 0, int b = 0): base(a, b)// 同样把参数交给 Shape 的构造函数
33 {// Triangle 构造函数开始
34
35 }// Triangle 构造函数结束
36 public override int area()// 重写 area
37 {// Triangle.area 开始
38 Console.WriteLine("Triangle 类的面积:");// 打印提示
39 return (width * height / 2);// 返回宽 × 高 ÷ 2
40 }// Triangle.area 结束
41 }// Triangle 类结束
42 class Caller// 定义 Caller 类
43 {// Caller 类开始
44 public void CallArea(Shape sh)// 参数类型是 Shape
45 {// CallArea 开始
46 int a;// 用来存面积
47 a = sh.area();// 按 sh 指向对象的实际类型调用 area
48 Console.WriteLine("面积: {0}", a);// 输出面积
49 }// CallArea 结束
50 }// Caller 类结束
51 class Tester// 定义 Tester 类
52 {// Tester 类开始
53
54 static void Main(string[] args)// 程序入口 Main 方法
55 {// Main 开始
56 Caller c = new Caller();// 创建 Caller 对象
57 Rectangle r = new Rectangle(10, 7);// 长方形:宽 10、高 7
58 Triangle t = new Triangle(10, 5);// 三角形:宽 10、高 5
59 c.CallArea(r);// 输出 Rectangle 的提示和 面积: 70
60 c.CallArea(t);// 输出 Triangle 的提示和 面积: 25
61 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
62 }// Main 结束
63 }// Tester 类结束
64}// 命名空间结束

11忘了写 override

把 Rectangle 的 area 前面的 override 去掉,其余不变:

C# · 第 24 行
24public int area ()// 漏写了 override

编译能通过,但有一条警告:

编译警告
Program.cs(24,18): warning CS0114: “Rectangle.area()”隐藏继承的成员“Shape.area()”。若要使当前成员重写该实现,请添加关键字 override。否则,添加关键字 new。
运行结果
父类的面积:
面积: 0
Triangle 类的面积:
面积: 25

没写 override,Rectangle 的 area 只是隐藏了基类的同名方法(把它盖住,但不算重写),通过 Shape 类型的 sh 调用时走的仍是 Shape 的版本,面积是 0。警告里说的“添加关键字 new”,意思是写上 new 表示有意隐藏父类的方法,不是重写。所以基类写 virtual,派生类要写 override,通过基类类型调用时才会执行派生的版本。

12小结

  • 多态:同一句调用,在不同的对象上做出不同的事。
  • 静态多态靠重载,编译时按参数选方法;只有返回类型不同,不能重载。
  • 动态多态靠抽象类和虚方法,运行时按对象的实际类型执行;抽象类不能 new,也不能是 sealed。
  • 派生类重写虚方法必须写 override,漏写只是隐藏。

下一节:两个自己写的对象,能不能像数字那样直接用加号加起来?

检查题

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

  1. 1TestData 里已有 int Add(int a, int b),再写一个 double Add(int a, int b),会怎样?

  2. 2Printdata 有 print(int)、print(double)、print(string),调用 p.print(1.23) 打印什么?

  3. 3Shape 是抽象类,Main 里写 Shape s = new Shape(); 会怎样?

  4. 4CallArea(Shape sh) 里 sh.area(),传进 Triangle(10, 5),打印面积多少?

  5. 5Rectangle 的 area 前面漏写 override,传进 Rectangle 调用 CallArea,会怎样?