欢迎光临散文网 会员登陆 & 注册

第 38 讲:面向对象编程(十):对象的多态

2021-05-16 08:06 作者:SunnieShine  | 我要投稿

前面,我们介绍了相当多的继承有关的有趣语法,比如继承语法,比如抽象类(abstract class)和密封类(sealed class),有抽象成员、重写成员(override 修饰的成员)和密封的重写成员(sealed override 修饰的成员)等等。

下面我们继续介绍面向对象的继承机制的一种特殊现象:多态。多态这个词语是直接翻译的 Polymorphism 这个单词,因为这个词语不属于基本单词,所以很少普通人知道这个词语。对于我们初学面向对象的朋友们而言,也不是很好理解。多态我们理解成“多种状态”。

Part 1 基类型的实例

我们还是使用前面的 Shape 的例子。Shape 类型因为被定义为抽象类,因此无法使用实例化的 new 语句。可是,我们这么写代码:

请看左右两侧的东西。左边的 s 变量用的是 Shape 类型,而右侧的实例化却是用的 Circle 类型的构造器。这个语法是正确的。为什么呢?Circle 表示“一个圆”,而 Shape 表示“一个形状”,而圆就是一种形状,所以为什么不行呢?

当然,这是从逻辑上说的,所以道理应该都明白。现在我们从代码上这么考虑一下。

假设,我们把 Shape 看成一个箱子。这个箱子里包含了你想要的东西(成员);当你从 Shape 类型派生后,Circle 类型又是一个新的箱子。不过这个新的箱子显然要和原来的 Shape 是一样的,因为基本的成员都是一致的。不过,因为你创建的 Circle 类型是你可以人为控制的,因为它可以带有一些新的、不是 Shape 里的东西,于是你可以给箱子改装一下。

现在,我们要把这个新的箱子丢给 s 变量(赋值给左边)。sShape 类型的,因此它只需要检测你给的这个箱子到底是不是包含 Shape 这些应有的东西。显然,继承下来就一定包含它们,因此箱子你怎么改装,肯定这些成员是不会丢失的,那么,自然就允许和通过检测了,赋值是成功的。

我们把这种赋值现象(基类型变量用子类型实例赋值)称为变量的多态性。因为变量本身可以是 Circle 类型的,也可以是 Shape 类型的,所以它这不就是两个“状态”了嘛。

那么这种赋值现象有什么用呢?用来把作变量类型的合取。假设我们有一个输出一个形状的方法:

这显然可以奏效,可以输出一个正常的、正确的正方形的面积。可问题就在我如果需要输出圆的面积、长方形的面积、梯形的面积和别的什么图形的面积的时候,这个方法不就得抄几遍吗?这就很麻烦了是吧。

所以,我们只需要把参数改成 Shape 类型的:

可以看到,只需要这么改变一下之后,我们什么图形都可以调用这个方法了,只要这个变量是 Shape 类型的:

既然都是 Shape 类型的,那么赋值是成功的,传参的时候,stu 也都是 Shape 类型的,也符合参数的类型规则,因此一个方法支持多个执行,当然就是没问题的了。

再说了,这么用,在 PrintArea 方法里,我们也只是用了 Area 属性。显然 Shape 自带 Area 属性,stu 肯定也都因为继承机制而自动配了 Area 属性的,所以凭什么我们又不让它参与运行呢?

这就是多态的好处。多态可以一劳永逸。当然了,你也可以这么写:

这么写也可以。

这样也可以。

Part 2 类型匹配

既然有多态,那么如果我们类型是基类型的,那么我们咋知道这个类型具体是什么类型呢?这个时候我们就需要用到两个运算符了:isas 运算符。

2-1 is 运算符

is 运算符用来检测类型是否正确。is 的左边写变量,右边写类型名称。整个表达式结果是一个 bool 类型的数值,表示是不是变量就是这个类型的。

大概这么用。

2-2 as 运算符

as 运算符可以转换数据的类型,它等价于 obj is T ? (T)obj : null 这个表达式。其中的 T 就是写在 is 后面的这个类型名称。

比如这样用。

2-3 强制转换运算符

前面我们用到了一个新鲜的类型转换。如果一个 Shape 类型原本就是从 Rectangle 这边变过来的类型的话,我们可以使用 (Rectangle)obj 的语法。这是面向对象的继承里的一大特性。

  • 如果从子类型转基类型的话,因为是一定可以转换成功的,因此是隐式转换的(即前面 Part 1 讲的东西);

  • 如果从基类型转子类型的话,因为类型不一定成功转换,因此是使用强制转换的。

这就是继承的转换机制。当然了,任何类型的变量,o is object 都应该是为 true 的,因为所有类型都从 object 类型派生。

Part 3 object 类型:所有引用类型的根

下面我们来说一个新鲜玩意儿:object 类型。和 string 类型一样,object 类型也是分关键字写法和 BCL 名称写法两种的。关键字 object 对应了它的 BCL 名称 Object

object 类型是所有类型的基类型。换句话说,如果你不写继承机制的语句:: 类名,那么这个类型自动从 object 类型派生;如果这个类型写了继承语句 : 类型 的话,那么这个类型就会从这个写的这个类型派生;而写出来的这个基类型,如果没有继承语句,那么它也是自动从 object 派生的。

另外,就算是之前接触的那些值类型,intdouble 这些,它们是从一个叫做 ValueType 的类型派生下来的;但 ValueType 是从 object 派生的。因此,不论你发现到什么东西,都是从 object 派生下来的。

说这个有什么用呢?下面我们来说一些 object 类型的成员。

3-1 Equals 方法

object 类型里有一个叫做 Equals 的方法,这个方法和之前我们提到过的 ReferenceEquals 方法一点区别都没有:

这是两种写法。但执行的效果是一样的。

可能你会问我,既然是一样的,为什么要定义俩写法不同的方法呢?因为 Equals 是被 virtual 修饰过的实例方法,而 ReferenceEquals 是静态方法。

既然被 virtual 修饰过的,那么就意味着这方法可以重写。因为它是系统自动继承的,因此这个方法不管你写不写继承语句,都是自动可以使用的方法。不过,如果你要比较内部的数据的话,你可以重写 Equals 方法。

假设我们还是用 Shape 类来举例。

现在,我们把 Shape 类改装成这样。请注意第 5 行代码,我们使用了一种新的语法 abstract override 组合关键字。这里,我们用到的是 object 类里自带的 Equals 方法。那么,为什么我们可以这么组合呢?因为我们这里的 abstractoverride 都有作用:abstract 是说这个方法是抽象的,那么在派生类里就必须给我实现这个方法;override 关键字则是表示这个方法是从基类型 object 里直接拿下来的。

因为没有提供实现代码,因此大括号就不能写了;相反,使用分号结尾就可以了。

接着,我们在继承 Shape 类的时候,就需要同时实现 Area 属性和 Equals 方法了。

比如我们拿 Rectangle 类型举例。我们使用之前学的知识点来完善例子。再等下次,我们如果使用到 Equals 方法后,方法就会自动定位到这里 Rectangle 里的 Equals 而不是 objectEquals 了。这样,就和 ReferenceEquals 不再一致了。

3-2 GetHashCode 方法

要想明白这个方法为什么得以存在,地位还那么高(放在了 object 里),就得先知道一个概念:哈希码(Hash Code)。

哈希码,在 C# 里用一个 int 类型的数值表示。任何世间万物都通过一个公式(不论是系统自带的,还是你自己写的)来计算得到一个哈希码。这个哈希码用于直接区分对象是不是一致。换句话说,如果两个对象的哈希码一致,我们大概率认为这两个对象包含相同的数值;反之,如果哈希码不同,那么我们大概率认为这两个对象可能有个别数据成员的数值不同,甚至是完全不同。

为什么说是“大概率”,而不是“一定相同”或者“一定不相同”呢?世间万物都用公式计算的话,显然是不合适的;另一方面,公式也不能够完全区分两个对象是不是相同。举个例子,我有一个超长字符串(100 个字符的那种)和另外一个超长字符串(也是 100 个字符)。我如果要比较两个字符串是否一致,显然就是逐字符比较。遇到不同的字符就说明两个字符串不同。

但是,如果通过哈希码计算的话,就有一点问题。首先,在 C# 里的一个字符可以表示非常多的情况(大概 2%5E%7B16%7D 种不同的字符);那么 100 个字符就有 %7B2%5E%7B16%7D%7D%5E%7B100%7D 种情况。很显然这个数已经是天文数字了。要想每一种情况都得配好一个哈希码来的话,这肯定是不可能的事情,毕竟 GetHashCode 的默认返回值是 int 的,这个你是改不了的。

所以,我们只能尽量做到“哈希码数值不同能够表达的对象不同”,而永远不可能找到一种办法可以唯一表示任何一个字符串的通用计算哈希码的公式。当然,别的数据类型也是一样,因为毕竟大小都不一样嘛。

至于 C# 的 char(字符类型)为什么有 2%5E%7B16%7D 种情况,这一点你可能需要参考一下 UTF-16 编码,这里我们就不展开说明了。

这个方法,Visual Studio 会提示你在重写了 Equals 方法的时候重写它;或者是如果你不重写 GetHashCode 方法的时候,Visual Studio 会告诉你“Equals 方法需要重写”。虽然不能绝对保证数据不同,但 GetHashCode 确实可以用来比较数据。因为在一些场合下,哈希码计算结果一定可以唯一表示一个数据,且不同的数据产生的哈希码一定不一样。比如说我有一个叫做 Cell 的类,它包含两个字段 RowColumnCell 类型的对象表达的是一个格子的第几行第几列。那么假设整个网格最多只能 10 行 10 列的话,我们的哈希码计算公式就可以这么写:

是的,通过这个公式,我们就可以得到这个格子的哈希码,而且因为我们假设的网格最多只能 10 行 10 列,所以我们无法超过这个规格的话,用 Row * 10 就是合适的。

那么,如果我们要比较两个 Cell 类型的对象是不是一样,现在就有两种比较办法:

第一种就是纯粹比较两个对象的 RowColumn 数值是不是都一样。而第二种判别方式就比较简单了:因为哈希码能够唯一确定数据,所以我们直接通过哈希码就可以比较两个对象是不是一致。

而且可以看到哈希码计算公式相当简单,因此我们直接上手写逐数据成员比较的话,就显得代码很臃肿。毕竟,有简单的比较办法我们肯定不会用复杂的,因为两个比较办法都能得到一致的、正确的结论。这就是哈希码的存在的意义。

3-3 ToString 方法

很明显,从这个名字上就可以看出这个玩意儿用来干嘛了。ToString 方法用来把对象用字符串形式表达呈现出来。因为要输出显示一个对象的信息,我们就不得不拥有一个机制,来把对象呈现出来。那么,只要我们重写了 ToString 方法的话,就可以直接这么写代码:

就非常方便了。

一般通常,我们实现 ToString 的办法都是,把需要呈现的数据成员给提出来,然后用字符串拼接的方式把它们拼接起来,最后输出。比如,假设我们要显示一个形状,那么代码可能是这样的:

首先,这是在 Shape 里的代码。我们追加一个抽象属性 ShapeKindName 用来显示输出这个形状到底是什么。在从 Shape 类派生后,我们就不得不重写掉这个属性,比如重写的数值可以是 "Rectangle",那么就写成 public override string ShapeKindName { get { return "rectangle"; } }

写好这个属性后,我们就可以在 Shape 类里的 ToString 方法里直接使用 ShapeKindNameArea 来显示具体的数值信息。这里用到了 string.Format 这个静态方法,虽然没有讲过,但是可以告诉你的是,这个方法和 Console.WriteLine 的传参方式是完全一样的,所以不必考虑和担心参数列表到底如何书写的问题,照搬过来就可以了。只是,string.Format 方法返回的是一个字符串,而 Console.WriteLine 方法是直接把字符串结果显示出来了,它俩在呈现机制上有所不同。

稍微提一下的是,这里的 ToString 被我用 sealed 标记了,这表示我在添加别的类的继承的时候,就不许再次重写 ToString 了,你只能用这个方法,而不能改内部的执行逻辑。

3-4 ReferenceEquals 静态方法

是的,你的猜想一点都没有错。之前我们提到的 ReferenceEquals 方法其实就是来自于 object 类里,只是有所不同的地方是,这个方法一般都要写成 object.ReferenceEquals,因为它在 object 类里;但是实际上我们都没有写它,这是因为 C# 知道这个方法是 object 里的,所以不用写。

3-5 ==!= 运算符为什么要重载

实际上,object 就自带了 ==!= 这两个运算符。正是因为它是自带的,所以我们不重写的话,C# 就会自动定位到 object==!=。而大家都知道的是,==!= 实际上就是简单调用了一下 ReferenceEquals(这一点之前有说过哦),所以我们要重载运算符来避免 C# 定位到这里,只要我们重写了 Equals 方法,或者 GetHashCode 方法。

另外,顺带一提。我们之前就说过写代码要养成好习惯,如果是引用类型传入的话,就一定有可能为 null,因此,只要遇到引用类型就一定要先判断这个对象是不是为 null 数值。判断方法就是调用 ReferenceEquals 方法了。

Part 4 重写(override)、重载和覆盖(new)的区别

很高兴我们能说到这里。这三个词语其实区别不大,所以经常容易分不清楚。下面我们来说一下这三个词语的区别。

  • 重写(Override):基于基类型提供的抽象成员(abstract 修饰的)或虚成员(virtual 修饰的),重新修改执行逻辑的过程;

  • 重载(Overload):重载有两层含义:运算符重载和方法重载。方法重载是参数不同构成不同重载,所以跟这里关系不大;而运算符重载是避免运算符本身在调用的时候还定位到基类型(比如指的是 object)的运算符去。因为运算符重载本身是静态的行为,所以根本谈不上用 overridevirtualabstract 或者 sealed 这类只用来修饰实例成员的修饰符;

  • 覆盖(Overwrite):覆盖和重写的区别就是是否阻断了继承链。如果是重写,那么就是基类型直接拿下来的;而覆盖则是直接把基类型的成员隐藏掉,而以后所有的继承都从这里覆盖掉的地方开始往下算,而基类型的就不再能够可以访问了。

Part 5 继承关系下的访问级别问题

如果我使用了继承关系的语法来的话,比如这样的代码:

在同一个项目下,AB 因为没有访问修饰符修饰,因此默认的修饰符应该是 internal。而 internal 只在项目里可以随便使用。如果我试着改变 AB 的访问修饰符的话,那么一共就有四种情况:

  • public class Apublic class B

  • public class Ainternal class B

  • internal class Apublic class B

  • internal class Ainternal class B

那么,这些写法都是正确的吗?从语法上它们都应该是对的,但实际上在使用的时候,我们来看一下 B : A 的继承关系约束下,B 就必须和 A 是一样级别的访问修饰符,或者比 A 要小。

按道理来说,BA 的派生类型,这就是在说,我可以使用多态机制来书写这样的代码:

两个代码在语法上都是可以的。可问题就在于,我两种写法都正确的话,就意味着我必须 AB 得是同一个级别,或者 BA 的级别要小,才可以这样。如果 BA 访问级别还要大的话,那么唯一的一组情况就只可能是 public class B(子类型)和 internal class A(父类型)了。从逻辑上来看,我能够实例化一个 B 类型的对象并暴露写在代码里,可我如果多态使用 A 类型来接收的话,而我此时 B 继承关系上又保证了它是从基类型 A 这里拿下来的,但却又不能使用多态给 new B() 赋值给 A 类型,因为 A 我又“看不见”。这不就是矛盾了吗?

如果你没有明白这段话的话,我换一个说法。面向对象意味着一个类型必须得要么走 object 这个默认类型派生,要么就必须给出一个自定义的引用类型,让该类型走这个我自定义的类型派生。那么我自己的访问级别能够被当前环境(或者叫范围吧)下看得到,那么基类型就必须得也能够看得到才行,否则我走哪里派生的我不清楚的话,别人说不定还以为我是走 object 派生的,毕竟我现在连一个基类型都看不到了嘛。这就破坏了面向对象的继承机制。所以,当前类型在当前范围下能看得到,那么它的基类型也必须能看得到。因此,我无关我当前类型什么访问修饰符,但它的基类型的访问修饰符的级别至少都得和当前类型的访问级别得是一样的,或者说基类型比当前类型的访问修饰级别还要大。所以 B(子类型)是 publicA(父类型)是 internal 的这组情况是不可能在 C# 里存在的;而其它三种情况均是可以的。

这个是类的继承关系下的访问修饰级别的问题。那么,如果是嵌套类型呢?这个时候,类型可以嵌套的话,里面的这个类型就可以使用 private 或者 protected,甚至是 protected internal 来修饰了。这个情况更为复杂,这怎么理解呢?

倘若我有一个这样的情况:

现在 Nested 类型从 B 类型派生,而 NestedB 是不同的类型。那么这个时候,组合情况就非常多了。按照我们刚才的说法,“当前类型在当前范围下能看得到,那么它的基类型也得能看得到”,因此至少 B 的访问级别不能比 Nested 的低。

比如说,如果 Nestedprivate 修饰的,那么 B 就可以什么都行,因为继承关系下,访问修饰级别是可以相同的,而最低情况下就只有 private,而它也可以,所以,此时 B 是什么修饰符都行;那如果是 protected 呢?internal 呢?

我们这里来看一个表格。

这个表格其实不用去死记硬背,因为毕竟不是上课,也不是考试。但是有了这个表格,比较熟悉了的话,写代码会轻松一些;然后,这样的情况平时用得也不多,所以大概了解一下即可。特别注意的是,即使派生类型和基类型都是相同的 protected internal 修饰符,这样的组合也不允许,原因是 protected internal 组合修饰符比较特殊,因为 protected internalprotectedinternal 两种级别都混合在一起的情况,那么我就说不清楚这个类型到底是走了一个 internal 还是 protected 修饰的类型派生下来的,所以是不允许的。

第 38 讲:面向对象编程(十):对象的多态的评论 (共 条)

分享到微博请遵守国家法律