C# Notizen 4 继承、接口和抽象类

一、继承和多态
在面向对象编程中,继承(也称为派生)让您能够创建新类,而这些类继承了父类(也称为基类)的特征和行为。
继承让您能够将复杂的问题划分成多个易于处理的部分。这些部分形成了概念层,根据您看待问题的角度,这些概念层提高了具体化或通用化程度,通过使用“是一个”关系以自然的方式描述了问题的层次特征。

ps:多重继承
继承的基本理念很简单,但是很多面向对象编程语言允许派生类继承多个父类,这称为多重继承。
多重继承并没有改变类之间存在“是一个”关系的需求,它是一种功能强大的机制,但与强大功能相伴而生的是实现起来复杂得多。在试图理清类的派生链时,多重继承也可能导致二义性。因为当类有两个父类时,在它与特定基类之间将存在两条继承路径。
为消除这种二义性,同时考虑到只有多重继承才是合适解决方案的情形很少,C#只允许单继承。
通常,具体化(specialization)指的是新类添加了基类没有的数据或行为。但是当基类只声明了行为而没有实现它时,也可能发生具体化。在这种情况下,派生类将负责提供实现。
通过从现有类型派生出新类型,可继承父类型的特征和行为。继承还让派生类可对基类做多方面的修改,具体如下。

  • 新添私有数据
  • 新添行为
  • 重新定义现有的行为

在面向对象编程中,多态指的是可像使用一种类型那样使用另一种类型。通常,这是通过以下两种方式实现的。

  • 一种类型继承了另一种类型,让它能够访问的操作和方法与父类型相同
  • 两种类型都实现了一个兼容的公有接口,它们支持相同的操作和共有数据,但是实现可能不同

多态严重依赖于封装、抽象和继承的概念。没有它们,一个类几乎不可能替换另一个类。

ps:多态
单词polymorphism(多态)看似很复杂,实际上并非如此。多态是一种常见的自然现象。它由希腊词 poly(表示很多)和 morphe(表示形状或形式)组成,从字面上说,指的是很多形状或形式。
在C#中实现继承很容易,只需在类声明中指定要继承的父类即可。

Paste_Image.png

如上这种继承也叫实现继承(implementation inheritance),因为实际上从父类继承了实现。

ps:设计类层次结构
继承没有提供的功能之一是删除数据或行为。如果发现需要将行为或数据从某个派生类中删除,很可能是由于你的类层次结构设计得不正确。
设计类层次结构并非总是简单任务,通常需要尝试多次才能设计正确。为此,最佳的方法是多花些时间考虑已知对象和以后可能需要的对象之间的关系。如果类层次结构太浅(继承关系不多)或过深(继承关系太多),可能需要重新思考这些对象之间的关系。
请记住,并非类层次结构中的一切都必须相互关联。类层次结构完全可以由多个更小的层次结构组成。
在C#中,将表达式赋给变量时,其类型必须与变量的类型兼容。这意味着如下代码非法:
Car c = new Car();
Truck t = c;

这是有道理的,因为卡车和轿车是不同的东西。然而,从逻辑上说,卡车和轿车都是四轮车(FourWheelVehicle),而四轮车是车(Vehicle),因此下述代码合法:

Paste_Image.png

虽然c和v1指向的是同一个Car对象,但是存在一个重要差别。由于c被声明为一个类型为Car的变量,因此通过它可以访问Car及其基类FourWheelVehicle和Vehicle定义的成员;然而,v1被声明为一个类型Vehicle的变量,因此通过它只能访问Vehicle定义的成员。

ps:向上转型和向下转型
将派生类对象转换为基类对象称为向上转型(upcasting),而将基类对象转换为派生类对象称为向下转型(downcasting)。
虽然可以沿类层次结构上移(从派生类到基类),但是不能下移。例如,下面的代码非法:
Vehicle v1 = new Vehicle();
Car c = v1;
不能隐式地将更通用类对象赋给更具体的类对象。
要这样做,必须显式地告诉编译器,要将基类对象向下转型为派生类对象。就这里而言,可编写如下代码:
Vehicle v1 = new Vehicle();
Car c = (Car)v1;
虽然上述代码合法,但是确实带来了一个问题。如果编写了如下代码,结果将如何呢?
Vehicle v1 = new Vehicle();
Vehicle v2 = new Truck();
Car c = (Car)v2;
上述代码合法,编译时不会导致错误,但是运行时会导致InvalidCastException,指出不能将Truck对象转换为Car对象。
为避免这种问题,方法是遵循“信任并核实”原则。这意味着你相信代码能够通过编译并运行,但在执行转换前核实基类变量实际上是正确的派生类型。如下演示了各种完成“信任并核实”的方法。

Car c = new Car();
Truck t = new Truck();
Vehicle v1 = c;
Vehicle v2 = t;

if(typeof(Car).IsAssignableFrom(v1.GetType()))
{
    c = (Car)v1;
    Console.WriteLine(c.GetType());
}

if (v1 is Car)
{
    c = (Car)v1;
    Console.WriteLine(c.GetType());
}

c = v1 as Car;
if (c != null)
{
    Console.WriteLine(c.GetType());
}

第一种方法使用C#底层类型系统判断能否将v1的类型(v1.GetType()的结果)赋给类型Car(方法调用typeof(Car)的结果),如果答案是肯定的,就将v1显式地转换为Car。总是可以将派生类对象赋给基类变量。
第二种方法更简单些,它询问类型系统:v1是否是Car,如果是,就将v1显式地转换为Car。
第三种方法最简单,它指出:如果v1可转换为Car,就执行转换并返回结果;否则,返回null。

ps:构造函数串接和默认构造函数
如果没有显式地串接基类的一个构造函数,那么编译器将尝试串接默认构造函数。
这里的问题是,并非所有类都有公有的默认构造函数,因此如果没有显式地串接正确的基类构造函数,就可能导致编译错误。
如下示例派生类的构造函数:

Paste_Image.png

1.1 处理继承而来的成员
有时候,派生类需要一个名称相同但行为不完全相同的方法或属性,这可使用成员隐藏(member hiding)来实现。要隐藏基类的成员,可在派生类中声明一个成员,其签名与要隐藏的基类成员相同。由于成员隐藏是基于签名的,因此也可使用它来修改成员的返回类型。

ps:成员隐藏
隐藏基类成员可能导致意外(至少是原本不想要)的结果。虽然有时故意在派生类中隐藏基类成员,但是成员隐藏通常是这样做的结果:对基类的修改(这可能是您能够控制的,也可能是您无法控制的)导致你无意间隐藏了基类成员。
因此,在基类成员被隐藏时,编译器将发出警告,让你知道这一点。如果你确信这正是你想做的,应在声明派生类成员时使用关键字new。可以使用关键字 new 并不意味着在基类成员被隐藏时不用发出警告,而只是让成员隐藏变成显式的。
为让问题尽可能清楚,C#要求重写类成员时使用两个关键字。在基类中,必须在成员声明中包含关键字virtual,而在派生类中,必须在成员声明中包含关键字override。
通常,虚成员实现的行为比较简单—如果它们提供了行为。虚成员主要用于确保在任何情况下,派生类都有该成员,且将执行某种微不足道的默认行为。派生类将重写虚成员,使其行为更具体、更合适。
就像构造函数一样,可使用关键字 base 来访问虚成员的基类实现。关键字 base 类似于关键字this,但指的是基类,而不是当前类。

不同于成员隐藏,成员重写有一定的限制,具体如下:

  • 重写成员的声明不能改变虚成员声明的访问级别
  • 虚成员和重写成员都不能声明为private的
  • 虚成员和重写成员的签名必须相同
  • 重写成员的声明中不能包含修饰符new、static和virtual

ps:默认为虚成员
有些面向对象的编程语言(如 Java)默认将成员设置为虚拟的,但是C#不这样。这意味着对于可能要重写的成员,必须显式地使用关键字virtual声明它,因为只有被声明为虚拟的成员才能被重写。

要禁止类成员被重写或禁止类被继承,可将其密封。要密封类成员,可使用关键字sealed和override;要密封类,只需使用关键字sealed。

ps:密封类
要正确地设计类的可继承性,可能需要做大量的工作。在这方面,有3种选择:

  • 保留类为非密封的,但确保它能被安全地继承。如果没有任何人继承这个类,那么这些工作完全没有必要
  • 保留类为非密封的,但什么也不做。这要求适用房(consumer)明白如何安全地扩展你的类
  • 将类密封。如果你确信不会有人继承你的类,这可能是最好的选择。以后总是可以对类解除密封,这不应对使它的代码带来巨大影响。通过将类密封、还使得可以在JIT编译阶段进行额外的运行阶段优化

二、抽象类和抽象成员
虽然继承类有很多优点,但是有时候需要给派生类提供标准实现并要确保派生类提供特定类或方法的实现,或者要求类不能有实例。C#提供了修饰符abstract,可将其用于类和类成员。

ps:静态类
编译器实际上将静态类实现为密封的抽象类,以防对其进行实例化或继承它。
通过将类声明为抽象的,可禁止对其进行实例化。因此,抽象类的构造函数通常是protected的,而不是public的。如果没有提供默认构造函数,编译器将创建一个受保护(protected)的默认构造函数。抽象类可包含虚成员、非虚成员和抽象成员。抽象成员是使用修饰符abstract声明的,且没有提供实现。
如下所示的Vehicle类是一个抽象类,它包含一个名为Operate的抽象方法。

public abstract class Vehicle
{
    int wheels;

    protected Vehicle(){}
    public Vehicle(int wheels)
    {
        this.wheels = wheels;
    }

    public abstract void Operate();
}

在派生类中,可以选择性地重写虚成员。与此不同的是,在具体(非抽象)的派生类中,必须重写抽象成员。如果派生类也是抽象的,就无需重写基类的抽象成员。在派生类对其进行重写前,抽象成员没有实现,因此首次重写成员时,不能调用基类的该成员。

三、接口
C#不允许继承多个基类,因此需要精心挑选基类。所幸的是,C#提供了另一种支持多重继承的方法:接口以公有属性和方法的方式定义了一组通用的特征和行为,所有派生类都必须实现它们。可将接口视为分部类型定义或类型描述。
与抽象类一样,接口也不能直接实例化,也可以包含方法、属性和事件。接口不能包含字段、构造函数和析构函数。

ps:接口不是协定
人们通常说接口定义了任何派生类都必须实现的协定(contract),这种说法仅在某种意义上说是正确的,即接口定义了派生类可用方法签名和属性。
这并没有指定具体的实现,完全可以在满足接口要求的情况下提供毫无用处的实现。接口只是定义了继承它的类都有的属性和方法。
接口的声明方式与类的声明方式极其相似,但是需要使用关键字 interface 替换关键字class。接口可以是internal、public、protected或private的,但是如果没有显式地指定访问级别,那么接口的访问级别将默认为internal。所有接口成员都自动为public的,不能给它们指定访问修饰符。由于接口成员也自动为抽象的,因此不能给它们提供实现。
当类继承接口时,称为接口继承或接口实现,它只继承成员的名称和签名,因为接口没有提供实现。这意味着对于接口定义的所有成员,派生类都必须提供实现。要继承接口,只需像继承类那样指定要继承的接口的名称。可继承多个接口,为此只需将接口名用逗号分隔。

ps:同时继承基类和接口
C#在继承列表中使用位置表示法指定基类和接口。如果类同时继承了一个基类和一个或多个接口,总是首先列出基类。
还可以进行混合继承,即可以只继承一个基类、只继承一个或多个接口、同时继承一个基类和一个或多个接口。如果基类继承了接口,其派生类都将继承该接口的实现。
虽然接口不能继承类,但是可继承其他接口。如果将接口视为松散的协定,那么接口继承意味着要遵循该协定,还必须遵循其他协定。这让您能够创建高度具体的接口,然后将这些接口聚合成更大的接口。在有多个不相关的类需要实现类似功能,如将数据存储到压缩的ZIP 文件中时,这很有用。在这种情况下,可在接口中定义与这种功能相关的行为和特征,然后将这些接口作为定义业务对象的其他接口的一部分。

ps:接口和扩展方法
对接口来说,扩展方法很有用,因为也可以扩展接口。扩展接口后,实现该接口的所有类型都将获得相应的扩展方法。事实上,用于泛型集合的整个语言集成查询(Language Integrated Query,LINQ)就是以这种方式实现的。
接口继承另一个接口时,派生接口只定义新增的成员,而不重新定义被继承的接口的成员。当类实现该聚合接口时,它必须提供所有相关接口定义的成员。
与抽象类结合使用时,接口的威力和灵活性将发挥得淋漓尽致。由于接口的方法隐式为抽象的,因此继承接口的抽象类无需为接口成员提供实现。相反,它可使用自己的抽象成员替换接口成员,在这种情况下,派生类必须重写该成员并为其提供实现。

如果类实现了多个接口,而这些接口定义了签名相同的成员,代码如下:

interface IVehicle
{
    void Operate();
}

interface IEquipment
{
    void Operate();
}

class PoliceCar : IVehicle, IEquipment
{
    public void Operate()
    {

    }
}

在这种情况下,编译器将让公有方法与两个接口成员都匹配。如果这不是你想要的,就必须使用显式接口实现(explicit interface implementation),如下图所示,这要求你对要实现的成员名进行全限定。在显式接口实现中,不需要指定访问修饰符,因为成员隐式为公有的,但必须通过接口显式地指定要实现的成员。

interface IVehicle
{
    void Operate();
}

interface IEquipment
{
    void Operate();
}

class PoliceCar : IVehicle, IEquipment
{
    void IVehicle.Operate()
    {

    }

    void IVehicle.IEquipment()
    {

    }
}

这意味着派生类必须通过接口访问其成员,因此显式接口实现相当于隐藏了成员,以免不小心错误地使用它。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容