跳至正文

第12章 使用继承与扩展方法

什么是继承

基本概念

继承是面向对象编程中非常关键的概念。如果你在学习本教程的同时正在开发自己的模组,那么你可能已经在许多地方用到了继承,比如创建自己的物品时继承了 ModItem 类,修改原版物品时继承了 GlobalItem 类等。在这一章中,我们将详细学习如何使用继承。

在程序设计中,继承的问题其实是分类的问题——它反映了类与类之间的关系。如果不同的类具有通用的功能,而且这些类相互之间的关系很清晰,那么利用继承就能避免大量重复性工作。

Microsoft C# 文档中的介绍

Inheritance, together with encapsulation and polymorphism, is one of the three primary characteristics of object-oriented programming. Inheritance enables you to create new classes that reuse, extend, and modify the behavior defined in other classes. The class whose members are inherited is called the base class, and the class that inherits those members is called the derived class. A derived class can have only one direct base class. However, inheritance is transitive. If ClassC is derived from ClassB, and ClassB is derived from ClassAClassC inherits the members declared in ClassB and ClassA.

继承(以及封装和多态性)是面向对象的编程的三个主要特征之一。 通过继承,可以创建新类,以便重用、扩展和修改在其他类中定义的行为。 其成员被继承的类称为“基类”,继承这些成员的类称为“派生类”。 派生类只能有一个直接基类。 但是,继承是可传递的。 如果 ClassC 派生自 ClassB,并且 ClassB 派生自 ClassA,则 ClassC 将继承在 ClassB 和 ClassA 中声明的成员。

这些概念听起来有些太抽象了,我们直接快进到具体情境罢。

例子

假设我们现在需要对一些动物进行建模。有两种哺乳动物:马(horse)和鲸(whale),你会如何定义它们的类呢?一种方案是创建两个不同的类,然后分别实现它特有的行为,就像下面这样:

public class Horse // 这事马
{
	public void Trot() // 小跑
	{
		// ...
	}
}
public class Whale // 这事鲸
{
	public void Swim() // 游泳
	{
		// ...
	}
}

这看起来很棒,框架清晰且河里,但你要如何处理它们共有的一些行为呢?比如呼吸哺乳?很容易想到的一种方案是在每个类中分别创建 Breathe()SuckleYoung() 方法。但如果我们将来需要建模更多的哺乳动物时你该怎么做?比如人和土豚?每个类中单独的 Breathe()SuckleYoung() 方法将会成为维护的噩梦,你的代码就会像下面一样混沌不堪(RL-like code)

public class Horse // 这事马
{
	public void Trot() // 小跑
	{
		// ...
	}
	public void Breathe() // 呼吸
	{
		// ...
	}
	public void SuckleYoung() // 哺乳
	{
		// ...
	}
}
public class Whale // 这事鲸
{
	public void Breathe() // 呼吸(兄啊顺序乱了罢
	{
		// ...
	}
	public void Swim() // 游泳
	{
		// ...
	}
	public void SuckleYoung() // 哺乳
	{
		// ...
	}
}
public class Human // 这事你(划掉
{
	public void Code() // 搓码(什
	{
		// ...
	}
	public void Breathe() // 呼吸
	{
		// ...
	}
	// 哺乳忘写了(被打
}

使用继承

基本语法

所幸,C#作为一门面向对象的编程语言,早已提供了解决这种问题的方案:使用继承。我们可以定义一个 Mammal 类来对哺乳动物的共性进行建模。然后,我们让 Horse, Whale 等具体动物都从该类继承。继承的类将自动包含父类的所有功能,同时你又可以为每种具体的哺乳动物添加它独有的功能。声明一个类从另一个类继承的语法十分甚至九分地简单,观察下面的代码你就知道了:

public class Mammal // 哺乳动物
{
	public void Breathe() // 呼吸
	{
		// ...
	}
	public void SuckleYoung() // 哺乳
	{
		// ...
	}
}
public class Horse : Mammal // 继承的语法,表示Horse类从Mammal类继承
{
	public void Trot() // 小跑
	{
		// ...
	}
}
public class Whale : Mammal // 鲸,同理
{
	public void Swim() // 游泳
	{
		// ...
	}
}
public class Human : Mammal // 你,同理
{
	public void Code() // 搓代码
	{
		// ...
	}
}

可维护性大幅提高了!
我们说“继承的类将自动包含父类的所有功能”,意味着上面的 Horse, Whale, Human 类都自动地具有来自父类 Mammal 的函数。因此,你现在可以像下面这样写代码:

var horse = new Horse();
horse.Trot(); // OK
horse.Breathe(); // 也是OK的
var human = new Human();
human.Code(); // OK
human.Breathe(); // 也是OK的
human.Swim(); // 在我们的情景里是不OK的(悲

由于 Human 类里并没有 Swim() 方法,所以上面的代码的最后一行会报错,不过前面几行代码都是正确的。

如你所见,使用继承避免了大量重复性工作(为每个类单独定义共同的方法等)。但除此之外,继承还会带来许多别的好处。不过先不要着急,先来让我们看看继承中的一些细节。

使用基类构造器

上面的例子都只体现了继承时子类(也叫派生类)自动包含了父类(也叫基类)的所有方法,事实上,它还自动地包含了父类的所有字段(以及许多我们没有提到的其他成员)。在创建对象时,这些字段通常应当被初始化。如果你还没有忘记前面学过的内容,你应该知道,我们通常用构造器来执行这种初始化。记住,所有都至少有一个构造器(如果你自己没有写,编译器会自动生成默认构造器)。

作为良好的编程实践,派生类的构造器在执行初始化时,最好调用一下它的基类构造器。考虑以下代码:

public class Mammal // 哺乳动物
{
	public Mammal(string name) // 构造器
	{
		Name = name;
	}
	public void Breathe() // 呼吸
	{
		// ...
	}
	public void SuckleYoung() // 哺乳
	{
		// ...
	}
	public string Name;
}
public class Horse : Mammal // 马
{
	public Horse(string name, int speed) : base(name) // 调用基类Mammal的构造器
	{
		// 可以写一些别的初始化
		// 比如Horse类定义了Speed字段
		// 就可以在这里初始化Speed = speed
	}
	public void Trot() // 小跑
	{
		// ...
	}
}

如上所示,我们在 Horse 类的构造器中通过 : base() 这一语法调用了基类 Mammal 类的构造器,然后基类的构造器会对 Name 字段进行赋值。如果你创建了一个 Horse 对象,你应该可以访问到它的 Name 字段。

System.Object类的再发现

如果你还记得我们在第8章中曾经介绍过的 System.Object 类,那你一定还记得这句话:

第8章

所有类都派生自 System.Object 。此类型的变量能引用任何对象。

如果你已经在用VS写一些东西,你一定会发现,当你在任何对象后面打出 . 号时,总能看见 ToString()GetHashCode() 这两个函数。现在我们便不难理解背后的缘故了。根据我们所说的内容,既然 System.Object 类是所有类的基类,那么所有的类自然地具有它的所有功能。同时,任何从你定义的类中派生的类也会具有它的全部功能。关于这两个函数我们在后面还会详细介绍。

所有的类都隐式地派生自 System.Object 类,但结构 struct 则不是,在后面我们很快就会介绍这一点。

类的赋值

我们在第8章已经介绍了如何用类来声明变量,以及如何通过 new 关键字创建对象来对其赋值,还解释了一些类型检查相关的规则。现在,在引入了继承的概念后,我们来考虑下面的类型转换问题:

var horse= new Horse();
var whale = new Whale();
horse= whale; // 这一行代码合法吗?

只要你对变量的类型有基本的认识,你就能判断出上面的代码无法通过编译,因为 whale 对象的类型不是 Horse,这个赋值操作是非法的。但再让我们来考虑下面的写法:

var horse = new Horse();
Mammal mammal = horse; // 合法吗?
Horse horse2 = mammal; // 合法吗?
Whale whale = mammal; // 合法吗? 
Whale whale2 = (Whale)mammal; // 似乎是合法的?

第二行代码是合法的。让我们稍微思考一下为什么可以这样做。

所有的 Horse 都是 Mammal,继承层次结构意味着我们可以将 Horse 视为特殊Mammal,只是有一些自己定义的其他内容。推而广之,你可以将一种类型的对象赋给继承层次结构中较高位置的一个类的变量。但这样做会产生一些限制,如果你试图使用 mammal 对象上的方法,你会发现你只能调用 Breathe()SuckleYoung()。为什么此时不能调用 Trot() 方法呢?我们不是已经知道它事实上是一个 Horse 对象了吗?

那么让我们先看第四行代码,这行代码是无法通过编译的。但我们要是加上强制类型转换(第五行)呢?

答案还是不行,尽管它能通过编译,但会在运行时抛出异常(第6章的内容)。原因很清楚:mammal 中存储的实际上是一个 Horse 对象而不是 Whale,所以这一操作是非法的。除非你家马全是鲸鱼。

此时再让我们回到第三行。这行代码也无法通过编译,因为你无法在运行时预先知道 mammal 中到底存储的是什么类型的对象。所以你需要像第五行一样加上强制转型才能通过编译,还要避免非法赋值(比如把 Whale 对象赋值给 horse2)。这些问题在第8章的最后一节已经讨论过(使用is/as等),此处便不再赘述。

再回到试图调用 mammal.Trot() 的问题,由于我们不知道运行时的 mammal 是否为 Horse,因此你不能调用 Trot() 方法。但我们可以保证它是 Mammal,因此调用 Breathe()SuckleYoung() 是合法的。

在使用继承时,务必牢记上面的转型问题。

继承中的高级特性

或许你早已在某处(比如从零裙)听说过面向对象编程的三个基本特性:封装、继承、多态

在理解继承层级结构时,你会逐渐了解这三个基本特性。

继承层次结构中方法的特殊关系

声明新方法——new关键字

前面我们已经提到,派生类具有基类的全部功能。那么,如果派生类中存在一个和基类具有相同签名的方法,会发生什么事呢?

我们不妨假设哺乳动物都能说话(书上如此!),然后考虑下面的代码:

public class Mammal // 哺乳动物
{
	// ...
	public void Talk() // 说话
	{
		// ...
	}
}
public class Horse : Mammal // 马
{
	public void Trot() // 小跑
	{
		// ...
	}
	public void Talk()
	{
		// 认为马的说话方式与其他哺乳动物有别
		// 于是你定义了Horse的Talk方法
	}
}

如果你真的这么写了,VS将给出警告信息,指出 Horse.Talk() 方法隐藏了继承的 Mammal.Talk() 方法。尽管方法可以通过编译,但你应该严肃对待这个警告。假如有另一个类从 Horse 派生,调用了 Talk() 方法,它可能希望调用基类 MammalTalk() 方法而非 Horse 的。这样的重名可能成为隐藏的编程错误。绝大多数时候你都不应该这样做。如果你真的希望隐藏基类的 Talk 方法,你可以使用 new 关键字来消除警告:

public class Mammal // 哺乳动物
{
	// ...
	public void Talk() // 说话
	{
		// ...
	}
}
public class Horse : Mammal // 马
{
	public void Trot() // 小跑
	{
		// ...
	}
	public new void Talk() // 声明“新”方法,隐藏Mammal.Talk()
	{
		// 认为马的说话方式与其他哺乳动物有别
		// 于是你定义了Horse的Talk方法
	}
}

许多时候这样做会造成预期之外的行为。使用更多的其实是下面将要介绍的虚方法/抽象方法/重写方法。

声明虚方法——virtual关键字

虚方法这个名字可能让你感到困惑,不过我们可以从你非常熟悉的 ToString() 方法入手来理解。众所周知,它是在 Object 类中定义的,它可以将对象转换成字符串形式。例如,如果你调用 HorseToString() 方法:

var horse = new Horse();
Console.WriteLine(horse.ToString());
// 结果:{你的命名空间名}.Horse

这是由 Object 类定义的行为——返回该对象的类型名称字符串。这很好,所有的类应该都提供一个方法将对象转换成字符串,以便进行显示或调试。但 Object 类中提供的实现显然过于简单了。如果你的 Horse 类中有很多字段,那么这个默认的 ToString() 便无法显示那些字段的内容了。如此说来,ToString() 方法不是毫无作用吗?

并不是!事实上,Object 类的 ToString() 方法是一个“虚方法”,你可以把它理解成一个占位符,提供一个默认的、简单的将对象转换为字符串的实现,然后让类的编写者在自己的每个类中提供自己版本的 ToString() 方法。而这个默认版本只是为了预防万一——有的类没有实现自己的 ToString() 方法。然后,你就可以放心大胆地在任何对象上调用 ToString(),它肯定能返回一个有内容的字符串(除非你用后文的方法对它进行了重写,或者对象为空)(后者是编程中应当尽量避免但经常发生的事情)。

以下是 Object 类定义的 ToString() 方法:

namespace System;
{
	public class Object
	{
		public virtual string ToString() // 使用virtual关键字来声明虚方法
		{
			// ...
		}
	}
}

如你所见,你可以使用 virtual 关键字来声明虚方法。而在派生类中提供自己的实现就是马上要介绍的重写方法。

声明重写方法——override关键字

闪亮登场!override 一定是你非常眼熟的关键字。无论是编写物品,NPC,还是弹幕,你一定经常在教程里看到“重写ModXXX的XXX方法”这样的描述。而现在你就会真正理解 override 关键字了。

我们还是从之前的 Mammal 类和 Horse 类说起:

public class Mammal // 哺乳动物
{
	public virtual void Breathe() // 虚方法-呼吸
	{
		// ...
	}
	// ...
}
public class Horse : Mammal // 马
{
	public override void Breathe() // 重写方法
	{
		// ...
		// Horse类的实现
	}
}

假设我们在 Mammal 类中声明了 Breathe() 虚方法,那么在 Horse 类中进行重写的语法就像上面那样,想必作为modder的你已经很熟悉了吧。 此外,在重写方法中也可以使用 base.Breathe() 调用基类的 Breathe() 方法。重写方法的真正作用将在下一节中介绍。

继承层次结构中的可访问性控制

通过前面的学习,你应该已经了解 privatepublic 可访问性的区别。但这两种可访问性有点太过极端了,如果基类中的某个成员被指定为 private,那么就连它的子类也无法访问这个成员。而有时候我们又需要防止继承层次结构以外的类访问类中的字段,因此我们引入了 protected 可访问性。

public class Mammal // 哺乳动物
{
	protected virtual void Breathe() // 只能在Mammal类的派生类中访问
	{
		// ...
	}
	// ...
}
public class Horse : Mammal // 马
{
	protected override void Breathe() // 可以重写方法
	{
		// ...
		// Horse类的实现
                base.Breathe(); // 也可以访问基类的Breathe()方法
	}
}

// 在 Program.Main() 中
var horse = new Horse();
horse.Breathe(); // 不能通过编译

就像这样,你只能在 Mammal 的派生类中访问到 Mammal 类的 Breathe() 方法。而 Program 不在这个继承层次结构中,因此你不能在 Program.Main() 中调用 horse.Breathe()

使用virtual和override的注意事项

在使用 virtualoverride 关键字声明方法必须遵守以下规则:

  • 虚方法不能声明为私有(private)。虚方法的目的就是通过继承向子类公开。同理,重写方法也不能私有,因为类不能改变它所继承的方法的可访问性级别。但是我们可以用 protected 关键字声明“受保护”可访问性。
  • 虚方法和重写方法的签名必须完全一致。它们必须拥有相同的名称,相同的参数列表和相同的返回值。
  • 只有虚方法可被重写。方法能否重写应该由基类的设计者决定。
  • 只有使用了override关键字才是重写方法。否则就是隐藏基类方法,参考前文 new 关键字的介绍。
  • 重写方法隐式地成为虚方法。换言之,派生类中可以重写基类中的重写方法。你也不能将 virtual 关键字显式地用于一个重写方法。

然后我们就可以来了解什么是多态性了。

虚方法、重写方法与多态性

多态是指同一个操作可以对不同类型的数据进行不同的操作。 —— Google Gemini

我们还是从代码来看吧。如果你先前一直没有打开IDE,那么我建议你现在启动IDE,试着编写以下代码:

public class Mammal // 哺乳动物基类
{
    // ...
    public virtual string GetTypeName()
    {
        return "This is a mammal"; // 这事哺乳动物
    }
}
public class Whale : Mammal // 鲸鱼
{
    // ...
    public new string GetTypeName() // 使用了new进行隐藏
    {
        return "This is a whale"; // 这事鲸鱼
    }
}
public class Horse : Mammal // 马
{
    // ...
    public override string GetTypeName() // 使用了override进行重载
    {
        return "This is a horse"; // 这事马
    }
}
public class Aardvark : Mammal // 土豚
{
    // ...
    // 土豚类没有实现自己的 GetTypeName() 方法
}
class Program
    {
        private static void Main(string[] args) // Main方法
        {
            var myHorse= new Horse();
            var myWhale = new Whale();
            var myAardvark = new Aardvark();
            
            Mammal myMammal = myHorse;
            Console.WriteLine(myMammal.GetTypeName()); // 输出:?
            myMammal = myWhale;
            Console.WriteLine(myMammal.GetTypeName()); // 输出:?
            myMammal = myAardvark;
            Console.WriteLine(myMammal.GetTypeName()); // 输出:?
        }
    }

先别急着运行,我们来脑内编译一下,然后根据之前的知识推测一下它可能会输出什么?

乍一看,三个 Console.WriteLine(myMammal.GetTypeName()); 似乎都应该输出 This is a mammal ,因为每个语句都是在 myMammal 变量上调用的 GetTypeName() 方法,而该变量的类型为 Mammal。但在第一个输出语句中,这个变量实际上引用了一个 Horse 类型的对象(正如我们之前所讲,你可以将一种类型的对象赋给继承层次结构中较高位置的一个类的变量)。由于 GetTypeName() 被定义为虚方法,所以运行时判断出此时应调用 Horse 类的 GetTypeName() 方法,因此这条语句输出的是 This is a horse 那么按这个逻辑,第二个输出语句会输出 This is a whale 吗?并不会,因为它的 GetTypeName() 方法是一个新方法,事实上与基类的 GetTypeName() 没有联系,所以会调用基类 MammalGetTypeName() 方法。而第三个输出语句中 myMammal 引用的 Aardvark 对象没有重写 GetTypeName() 方法,因此调用的就是基类的方法。

所以上面的代码输出如下:

This is a horse
This is a mammal
This is a mammal

这种写法相同的语句,却能够依据上下文调用不同的方法的特性,就是多态性(Polymorphism)

多态性其实体现在很多我们习以为常的地方,比如如果你要将一个 intbool 类型的变量转换为字符串形式,自然会调用它们的 ToString() 方法,但你得到的结果不会是System.Int32或者System.Boolean,因为它们有自己的想法实现。

值类型的回顾

在之前的章节中我们已经详细地介绍了值类型,并强调了类和结构的区别。而在学习继承之后,我们可以更进一步——结构都隐式地派生自 System.ValueType

等一下,我们不是说所有类都隐式地派生自 System.Object 类么?这里并没有问题,结构是结构,类是类,而且 ValueType 类也继承自 ObjectValueType 是.NET为“基于栈的值类型”定义通用行为所采用的一种实现细节,通常你不能真的直接在代码中使用 ValueType(这一点和object不同)。此外,继承只适用于类,不适用于结构,不能从结构派生出类或结构,也不能从类或结构派生出结构。结构是隐式密封(sealed)的,这一点我们以后再来介绍。

我们之前提到的结构与类的区别可能给你带来的麻烦也包括这一点。

定义扩展方法

我们花了大把的笔墨来介绍继承,诚然,继承非常强大,允许你通过派生来扩展类的功能。但有时要扩展类的行为,继承并不一定是最佳的方案,尤其是需要快速扩展类型,又不想影响现有代码的时候。

假设你十分无聊,打算为所有 int 定义一个方法 Negate() 来获得它的相反数。

(问:为什么我们不直接用负号)

(把提问的叉出去!这是教程的一部分!)

刚学完继承准备大写特写的你可能打算定义一个 NegInt 类,让它继承自 System.Int32,然后定义 Negate() 方法。

但显然你没法这么做,原因有两点:

  • System.Int32 是个结构,它不能被继承
  • 新的方法并不适用于原来的 int ,难道你要自己把所有的 int 转换成你的 NegInt 吗?

因此,正确的实现方案是使用扩展方法。扩展方法必须定义在静态类中,作为静态方法来扩展现有的类型(无论类还是结构!)。我们直接来看代码:

public static class Utils // 其实你可以在很多地方看到这样的静态工具类
{
	public static Negate(this int num) // 语法就像这样
	{
		return -i;
	}
}
// 在 Program.Main() 中
// 如果 Utils 类和 Program 类不在同一个命名空间中
// 请自己添加using语句
int i = 1;
Console.WriteLine(i.Negate()); // -1

语法看起来有些奇怪,但你只需要记住:正是因为这个方法的第一个 int 参数附加了 this 关键字,才表明它被定义为 int 类型的扩展方法。注意, 被扩展的类型必须是这个静态方法的第一个参数,且被 this 关键字标记。当然,你也可以在后面添加更多参数,你只需要像它就是 int 类型的方法一样使用它就好。

不过,扩展方法的本质是一种语法糖(可以简单地理解为语法的简化)。你可以直接调用 Utils.Negate() 方法:

int i = 1;
Console.WriteLine(Utils.Negate(i)); // -1

此外,如果你为引用类型定义扩展方法,那么当该引用类型变量为 null 时,抛出异常的行为也会有所不同。该类型的方法在这种情况下会直接抛出 NullReferenceException,而扩展方法会在方法内部抛出异常,就像调用它的时候传入了一个 null 参数一样。

public class Horse // 今天就和马过不去了是吧
{
	public void Trot()
	{
		// ...
	}
}
public static class Utils
{
	public static void Print(this Horse o)
	{
		Console.WriteLine(o);
	}
}
// 在 Program.Main() 中
Horse horse = null;
horse.Print(); // 输出空行
horse.Trot(); // 抛出异常

因此可以将扩展方法看作是一种调用 Utils.Negate() 的简化语法。

在下一章中,我们将介绍接口和抽象类,这将是构建可扩展编程框架的关键内容。

标签:

《第12章 使用继承与扩展方法》有1个想法

发表回复