zoukankan      html  css  js  c++  java
  • C#高级编程第11版

    导航

    第四章 Object-Oriented Programming with C#

    4.1 面向对象

    C#并不是一门纯粹的面向对象编程语言,它提供了多种编程规范(paradigms)。然而,面向对象仍然是C#里面一个重要的概念。并且它也是.NET提供的所有类库的核心成分(core principle)。

    面向对象的三个最重要的概念是继承(inheritance),封装(encapsulation)和多态(polymorphism)。第三章"对象和类型"中,我们提到了如何创建一个自定义的类,包含属性,方法和字段。当某个类型的成员被声明成private的时候,它们就无法从类外部进行访问。它们被封装在这个类之中。本章将更关注继承和多态。

    上一章我们解释了为什么所有的类最终都派生自System.Object类。本章我们将介绍如何创造类之间的层次关系并且C#之间的多态是如何实现的。我们还会介绍C#里跟继承有关的所有关键字。

    4.2 继承的类型

    让我们先回顾一些面向对象的概念,然后就继承方面的内容,看看C#是否支持这些。

    • 单继承:单继承允许一个类继承自另外一个基类,C#支持。
    • 多继承:多继承允许一个类继承自多个类,C#不支持类的多继承,但它支持一个接口继承自多个接口。
    • 多级继承:多级继承允许创建一个类继承自它的父类,而父类又继承自它的爷爷类,多级继承会是一个庞大的谱系结构。C#支持这一点。
    • 接口继承:一个接口继承自另外一个接口,这里也允许一个接口继承自多个接口。我们将会在后续"Interface"章节详细介绍这部分内容。

    现在让我们讨论一些继承和C#存在的具体问题。

    4.2.1 多重继承

    一些语言,例如C++,就支持多继承,也就是允许一个类继承自多个类。为了支持这个特性,会为代码生成的过程中带来不小的开销,就算代码中没有用到多继承。因为这一点,C#的设计者决定在C#里不再支持多继承,因为一个是中间代码生成会有额外开销,另外多继承也会带来别的方面复杂度的提升。

    C#只允许所有类型继承多个接口,这意味着C#的类型可以继承自一个基类和任意数量的接口。事实上我们还可以精确一点地说,得益于System.Object这个所有类型的基类,每一个C#的类(除了Object类)都有一个基类,并且每一个C#的类都可以拥有任意数量的接口。

    4.2.2 结构和类

    第三章比较了struct(值类型)和class(引用类型)之间的区别。使用struct的一个限制就是它们不支持继承,尽管事实上所有的struct都自动集成自System.ValueType类。虽然你无法通过继承将struct构筑成一个庞大的体系,但是struct还是可以实现接口的。换句话说,struct本身不支持继承,但它们可以支持接口的继承。

    下面让我们总结一下struct和class之间的区别:

    • struct总是派生自System.ValueType类,它可以实现任意数量的接口。
    • class则总是派生自System.Object类或者另外的某个类。它们也可以实现任意数量的接口。

    4.3 实现继承

    如果你想声明某个类继承于另外的某个类的话,你可以用以下的语法:

    class MyDerivedClass: MyBaseClass
    {
    	// members
    }
    

    如果一个类(或者一个结构)实现了接口,那么它继承的类和接口之间使用,进行分隔:

    public class MyDerivedClass: MyBaseClass, IInterface1, IInterface2
    {
    	// members
    }
    

    注意:如果同时有父类和接口,类必须是第一个,写在所有接口之前。

    而对于struct来说,它只能实现接口,它的语法就像下面这样子:

    public struct MyDerivedStruct: IInterface1, IInterface2
    {
    	// members
    }
    

    假如你在声明class的时候没有指定任何父类,C#编译器会假定System.Object就是它的基类。因此,继承于Object类的声明可以省略不写。

    class MyClass // 隐式派生自 System.Object
    {
    	// members
    }
    

    接下来让我们举一个简单的例子。我们定义了一个基类Shape。这个类中有一些内容是通用的的——不管是矩形还是椭圆——它们都拥有位置(position)和尺寸(size)属性。我们在Shape类里包含了Position和Size类的定义,并且通过自动属性初始化这俩成员:

    public class Position
    {
    	public int X { get; set; }
    	public int Y { get; set; }
    }
    public class Size
    {
    	public int Width { get; set; }
    	public int Height { get; set; }
    }
    public class Shape
    {
    	public Position Position { get; } = new Position();
    	public Size Size { get; } = new Size();
    }
    

    4.3.1 虚方法

    假如我们在基类里声明一个用virtual关键字修饰的方法,那么它的子类就可以重写它:

    public class Shape
    {
    	public virtual void Draw() => Console.WriteLine($"Shape with {Position} and {Size}");
    }
    

    因为这里方法的具体实现就一行,所以我们使用了表达式(使用lambda操作符)的方法来写,也是可以的。

    你也可以将属性声明成virtual,跟普通的实例属性使用起来其实没什么两样,就是子类可以重写而已,虚属性的语法看起来像这样:

    public virtual Size Size { get; set; }
    

    当然,你也可以使用完整的属性语法,譬如下面这样:

    private Size _size;
    public virtual Size Size
    {
    	get => _size;
    	set => _size = value;
    }
    

    简单起见,我们下面讨论的内容主要针对于方法,但事实上它同样适用于属性。

    C#的虚方法与标准面向对象的概念完全一致。你可以在子类中重写虚方法,当该方法被调用的时候,编译器会为你调用最合适的方法。C#里,方法默认不是virtual修饰的(构造函数不能声明成virtual),需要显式声明。这点主要是从C++方法里借鉴来的:出于性能的考虑,方法默认不是虚方法。而与此不同的是,在Java里,所有的函数都是虚方法。C#和C++的语法还有一点不同,当你在子类中需要重写父类的某个方法时,你需要显式地使用override关键字,就像下面这样:

    public class Rectangle : Shape
    {
    	public override void Draw() => Console.WriteLine($"Rectangle with {Position} and {Size}");
    }
    

    这个override语法要求使得你可以避免C++中经常会出现的一些运行时错误,当子类中的某个方法签名无意中跟父类中的方法出现了些许差别的时候(譬如说跟父类方法就差一个字母),会导致对父类方法的重写失败。而在C#里,因为你声明了override关键字了,如果子类的方法签名找不到相应的父类虚方法的话,就会抛出一个编译错误。

    Size和Position类就重写了基类的ToString方法,ToString方法定义在Object类中,是用virtual修饰的虚方法:

    public class Position
    {
    	public int X { get; set; }
    	public int Y { get; set; }
    	public override string ToString() => $"X: {X}, Y: {Y}";
    }
    public class Size
    {
    	public int Width { get; set; }
    	public int Height { get; set; }
    	public override string ToString() => $"Width: {Width}, Height: {Height}";
    }
    

    注意:ToString方法作为Object类的成员,已经在上一章简单的介绍过了。

    当重写基类的方法时,方法签名(所有的参数类型以及方法名)和返回值必须完全匹配,如若不然,那么你创建的是一个新方法,而不是对基类方法的重写。

    接下来,我们在Main方法里,实例化一个叫r的Rectangle变量,然后为它赋值,并调用其重写的Draw方法:

    var r = new Rectangle();
    r.Position.X = 33;
    r.Position.Y = 22;
    r.Size.Width = 200;
    r.Size.Height = 100;
    r.Draw();
    

    运行这部分代码,你将会看到如下的输出:

    Rectangle with X: 33, Y: 22 and Width: 200, Height: 100
    

    字段和静态方法不能被声明成virtual,这是因为如果不是一个实例函数成员的话,定义成virtual毫无意义。

    4.3.2 多态性

    通过多态性,方法调用并非在编译时就定死了,而是动态地进行调用。编译器会创建一个virtual方法表(vtable),里面列举了所有可以在运行时调用的方法,并且根据运行时的类型决定调用哪个方法。

    让我们来看一个例子,方法DrawShape接收一个Shape类型的参数,并且调用Shape类里的Draw方法,如下所示:

    public static void DrawShape(Shape shape) => shape.Draw();
    

    假如我们用之前创建的Rectangle实例来调用这个方法。即使这个方法声明是接收一个Shape类作为参数,但是派生自Shape的类(包括Rectangle)也可以作为参数进行传递。

    运行程序你会看到,实际调用的是Rectangle.Draw方法,替换了原有的Shape.Draw:

    Rectangle with X: 33, Y: 22 and Width: 200, Height: 100
    

    假如Shape的Draw方法不是virtual修饰的虚方法或者Rectangle没有重写Draw方法,那么将Rectangle变量当做参数传递给DrawShape,执行的最终会是Shape.Draw方法,那么输出就会以"Shape With"开头。

    4.3.3 隐藏方法

    假如基类和子类都拥有一个相同签名的方法,并且它们没有分别用virtual和override进行声明, 那么子类版本将会隐藏基类版本的方法。

    大部分情况下,你应该更想重写基类的方法而不是隐藏它。因为隐藏了基类的方法可能会导致某个类实例调用方法时出错。然而,就像下面例子所演示的,C#考虑到这种需要隐藏基类实现的情况,并设计了相应的语法来确保是开发者确定要隐藏基类方法的,这一点会在编译时进行检查。因此隐藏方法在C#里会更加安全,因为那确实是出自你明确的需求。这点对开发者管理不同版本的类库也很有用。

    假如你的类库里现在有个叫Shape的类:

    public class Shape
    {
    	// various members
    }
    

    考虑到之后的需求,你添加了一个派生自Shape的子类Ellipse,并且为它添加了某些新功能。特别的是,你新增了一个叫MoveBy的方法,这个方法在基类Shape里并没有:

    public class Ellipse: Shape
    {
    	public void MoveBy(int x, int y)
    	{
    		Position.X += x;
    		Position.Y += y;
    	}
    }
    

    在这之后,负责基类的开发人员决定为基类Shape扩展一些方法,为了简单起见,他也添加了一个叫MoveBy的方法,而且凑巧跟你的方法签名完全一样。然而,它的方法实现可能跟你完全不一样。他新增的方法可能添加了virtual修饰,也可能并没有。

    假如你重新编译你的派生类,你将会得到一个warning,因为存在一个潜在的方法冲突。但是,基类的开发人员不一定会编译派生类的程序集,他可能只是简单地替换了基类的程序集。这个程序集可能被安装到全局程序集缓存(Global Assembly Cache,GAC)里,很多Framework程序集都是这么干的。

    现在让我们假设基类的MoveBy方法被声明成了虚方法并且基类自己也调用了这个MoveBy方法,那么它调用的就是谁的MoveBy?是基类自己定义的MoveBy方法,还是子类更早之前就已经定义好的MoveBy呢?因为子类的MoveBy方法并没有使用override关键字进行声明(因为早先基类还不存在这个方法嘛,声明了也只会报错),编译器会将子类的MoveBy方法当成是一个完全不同的方法,并且跟基类没有任何关系,虽然它和基类有一样的签名,但编译器会把它当做跟基类完全不同名的方法进行处理。

    编译Ellipse类将会抛出一个编译警告,提示你使用new关键字来隐藏方法。实际上,就算你不使用new关键字,生成的程序集也是一样的,只不过你可以避免编译器警告而已:

    public class Ellipse: Shape
    {
    	new public void MoveBy(Position newPosition)
    	{
    		Position.X = newPosition.X;
    		Position.Y = newPosition.Y;
    	}
    	//... other members
    }
    

    如果你不想使用new关键字,你也可以选择重命名你的方法,或者重写基类的方法,假如存在跟你目的一致的虚方法的话。然而,万一你的方法有其他的外部引用的话,简单的修改方法名会导致代码出错。

    注意:new关键字不应该用来刻意隐藏基类的成员。它的主要目的只是为了解决版本冲突,当基类定义了某些派生类已经处理过的内容的时候。

    4.3.4 调用方法的基类版本

    C#提供了一个特殊的语法以便子类可以调用父类的方法:base.<MethodName>。举个例子,假如你在基类Shape里定义了一个Move方法,并且你想在子类Rectangle里复用它的实现,你可以通过使用base关键字实现:

    public class Shape
    {
    	public virtual void Move(Position newPosition)
    	{
    		Position.X = newPosition.X;
    		Position.Y = newPosition.Y;
    		Console.WriteLine($"moves to {Position}");
    	}
    	//...other members
    }
    

    Move方法在Rectangle里被重写了,为了在控制台上输出Rectangle的字样,然后你又想执行跟父类一样的操作,你通过base调用:

    public class Rectangle: Shape
    {
    	public override void Move(Position newPosition)
    	{
    		Console.Write("Rectangle ");
    		base.Move(newPosition);
    	}
    	//...other members
    }
    

    然后你在Main方法里调用它:

    r.Move(new Position { X = 120, Y = 40 });
    

    运行程序,你将会看到Rectangle的Move方法和Shape的Move方法都被调用了:

    Rectangle moves to X: 120, Y: 40
    

    注意:通过base关键字,你可以调用父类任意方法,并不单单只是你重写的那个方法。

    4.3.5 抽象类和抽象方法

    C#允许将类和方法声明成abstract。一个抽象类无法被实例化,而一个抽象方法不能拥有任何方法体并且继承抽象类的非抽象子类必须实现此抽象方法。显而易见,抽象方法自动就是virtual方法(并且你不能再给虚方法加一个virtual修饰符,否则会得到一个编译错误)。任何含有抽象方法的类必须是一个抽象类。

    让我们将Shape修改为abstract类,为此它有必要有子类。我们定义了一个新的抽象方法Resize,因此在Shape类里Resize不能有任何实现:

    public abstract class Shape
    {
        public Size Size { get; }
    	public abstract void Resize(int width, int height); // abstract method
    }
    

    当子类继承Shape这个抽象类时,它就必须实现抽象类里所有的抽象成员,否则编译器会报错:

    public class Ellipse : Shape
    {
        public override void Resize(int width, int height) //这里override不能少
    	{
    		Size.Width = width;
    		Size.Height = height;
    	}
    }
    

    当然你也可以像下面这样,在方法体里直接抛出一个NotImplementationException异常来作为一种方法实现,但这往往只是开发过程中的一种临时手段而已,在该方法被正常调用的时候你仍然需要实现它:

    public override void Resize(int width, int height)
    {
    	throw new NotImplementedException();
    }
    

    注意:我们将在第十四章更详细的介绍"错误和异常"。

    4.3.6 密封类和密封方法

    假如你不想让你的类能被其他类继承,你可以将它声明成sealed,这样它就不能拥有任何子类了。而如果你将某个方法声明成sealed,意味着它不允许被重写。

    sealed class FinalClass
    {
    	//...
    }
    
    class DerivedClass: FinalClass // 错误:无法从密封类"FinalClass"派生。
    {
    	//...
    }    
    

    如果你需要将某个类或者方法标记为sealed,最可能的状况就是这个类或者方法,是用于某个类库、类又或者别的还在编辑的类,用于实现一些内部操作,任何对于这个类的重写都可能会导致代码的不稳定。举个例子,假如你从未测试过继承又或者还没决定继承的设计策略的话,将你的类声明成sealed不失为一个好方法。

    使用密封类还可以有别的理由。通过sealed类,编译器知道它肯定不会有派生类,因此不需要为它创建虚方法表,这点能提高性能。string类就是密封类。因为我从没见过任何一个成型的应用程序不使用string类的,因此这个类最好拥有更高的性能。将类声明成sealed对编译器来说是一个很好的暗示(good hint)。

    将方法声明成sealed的理由跟class差不多。某个方法可能是重写了基类的虚方法,假如将它声明成sealed方法,编译器就知道后续的类不可能再扩展此方法的虚方法表(vtable)了,就到此为止。

    class MyClass: MyBaseClass
    {
    	public sealed override void FinalMethod()
    	{
    		// implementation
    	}
    }
    class DerivedClass: MyClass
    {
    	public override void FinalMethod() // 错误:继承成员"MyClass.FinalMethod()"是密封的,无法进行重写。
    	{
    	}
    }
    

    为了给方法或属性使用sealed修饰符,它们必须首先是override基类的virtual方法或者属性。假如你不想基类的方法或属性被重写,就别把它们声明成virtual方法或属性。

    4.3.7 派生类的构造函数

    第三章我们讨论了构造函数在自定义类中是如何应用的。一个很有趣的问题来了,当你开始为你自己的类定义构造函数时,它们继承的另外一些类,同样拥有自己的构造函数。

    假定你没有为你的任何类显式指定一个构造函数,这意味着编译器将为你创建一个默认的无参构造函数,当编译器这么做的时候,其实后台做了相当多的处理,但是编译器能将这一切安排得井井有条,整个class层次结构以及每一个类中的每一个字段都能正确地初始化为默认值。当你添加一个属于你自己的构造函数时,此时,你将自己实际控制整个构造过程。这将直接影响到整个派生类的生成次序,所以你必须保证你不会无意中写出一些奇葩的代码导致原来顺利的构造过程被破坏。

    你可能会疑惑为何会派生类会有那么多特殊的问题。这是因为当你创建一个派生类的实例时,不止一个构造函数会生效。你声明实例的那个类,自身的构造函数并不足以初始化整个class,它父类的构造函数也必须被调用。这也是我们为何要开始讨论整个class体系的构造过程。

    在早先的Shape例子里,属性是通过自动初始化器来完成初始化的,如下所示:

    public class Shape
    {
    	public Position Position { get; } = new Position();
    	public Size Size { get; } = new Size();
    }
    

    在后台,编译器创建了一个默认构造函数,并将所有的初始化内容都移动到构造函数里执行,因此上面的例子等价于:

    public class Shape
    {
    	public Shape()
    	{
    		Position = new Position();
    		Size = new Size();
    	}
    	public Position Position { get; };
    	public Size Size { get; };
    }
    

    当然,当你创建一个Rectangle类的实例的时候,因为它继承自Shape类,Rectangle拥有Position和Size属性,因此基类的Shape构造函数就会被调用,以便初始化继承来的属性Position和Size。

    万一你没有在默认的构造函数里初始化成员,编译器也会自动将类里的引用类型设置为null,值类型初值设置为0,布尔类型则是false。布尔类型是一个值类型,false和0是一样的,所以值类型设置为0这条规则也使用于Boolean类型。

    对于Ellipse类来说,当基类定义了一个默认的构造函数并且初始化他们所有的成员值后,其实你没必要为Ellipse显式创建一个构造函数,当然你非要写也不是不行,只是你需要通过base来调用基类的构造函数,就像下面这样:

    public class Ellipse : Shape
    {
    	public Ellipse() : base()
    	{
    	}
    }
    

    构造函数总是按照类的继承顺序依次调用的。System.Object的构造函数总是第一个,然后挨个调用,直到编译器遇到要实例化的那个类为止。为了创建Ellipse的实例,编译器先调用了Object构造函数,然后是Shape构造函数,最后才是Ellipse的构造函数。每个构造函数只处理它们自己字段的初始化。

    现在,让我们修改一下Shape类的构造函数,与前面只是按初始值初始化属性不同,我们这次按照参数给它们赋值:

    public abstract class Shape
    {
    	public Shape(int width, int height, int x, int y)
    	{
    		Size = new Size { Width = width, Height = height };
    		Position = new Position { X = x, Y = y };
    	}
    	public Position Position { get; }
    	public Size Size { get; }
    }
    

    当移除了默认的无参构造函数并重新编译之后,Ellipse和Rectangle类将无法正常编译,因为编译器不知道该为只有带参构造函数的基类传递什么值好。这里你需要为派生类创建相应的构造函数并手动调用基类的构造函数进行初始化,如下所示:

    public Rectangle(int width, int height, int x, int y) : base(width, height, x, y)
    {
        
    }
    

    将base(width, height, x, y)里会执行的初始化过程写在Rectangle的构造函数里也是没有用的,因为父类的构造函数会先于子类进行调用,如果你没有指定,编译器就会去找父类的无参构造函数进行初始化,但这里显然是找不到的,就会报错。

    万一你想通过无参构造函数来创建一个Rectangle实例,你也需要这么写,因为父类没有无参构造函数,你只需要将基类构造函数里需要的参数传递过去就行,下面这个例子我们用到了命名参数,因为如果不写名字,光传4个0你将会很难分辨哪个是width,哪个又是height,x和y:

    public Rectangle() : base( 0, height: 0, x: 0, y: 0)
    {
        
    }
    

    注意:命名参数我们已经在第三章介绍过了。

    如你所见,这整个构造过程是非常整洁的,而且这是个不错的设计。每个构造函数处理自己负责的字段变量的初始化,并且在这个过程中,你自己的类也能被正确实例化,以便后续使用。如果你在为自己的类编写构造函数时遵循同样的规则,即使再复杂的类也可以顺利初始化,不带半点毛病。

    4.4 修饰符

    你已经遇见了众多可能用在类型或者成员上,被称为修饰符(modifiers)的关键字。修饰符可以标识一个方法的可见性,譬如public或者private,或者一个项目(Item)的类型(nature),比如virtual还是abstract。C#拥有很多修饰符,在这里值得我们花点时间去完全理解它。

    4.4.1 访问修饰符

    访问修饰符标识其他代码项是否可以看到某一项。

    修饰符 应用范围 描述
    public 所有类型或成员 任意可见。
    protected 所有成员与任何嵌套类型 派生类型可见。
    internal 所有类型或成员 程序集可见。
    private 所有成员与任何嵌套类型 所属类可见。
    protected internal 所有成员与任何嵌套类型 实际上意味着protected+internal
    也就是派生类可见,程序集可见。
    private protected 所有成员与任何嵌套类型 C# 7.2开始提供的新修饰符。
    与protected internal不同的是,这里可不是private+protected。
    同一程序集下的派生类可见。

    注意:public,protected,private是逻辑访问修饰符,而internal则是物理访问修饰符,它是按程序集来界定的。

    注意顶级类可以定义成internal或者public,这只取决于你是否想让该类型被程序集以外的代码访问。

    public class MyClass
    {
    	// ...
    }
    

    你不能将包含在命名空间下的顶级类用protected,private,或者protected internal进行修饰,因为这些访问等级对它们毫无意义。因此,这些修饰符只能用在成员上。然而,你可以将这些修饰符定义在嵌套类型(就是一个类型里面再套一个其他类型)上,因为在这种情况下,内在的嵌套类型也可以当成一个成员来看,因此像下面这么写是正确的:

    public class OuterClass
    {
    	protected class InnerClass
    	{
    		// ...
    	}
    	// ...
    }
    

    假如你定义了一个嵌套类型,内在类型永远能访问外在类型的所有成员。因此,在上面的例子里,InnerClass里的所有代码都可以访问OuterClass里的全体成员,即使那些成员是private声明的也不例外。

    4.4.2 其他修饰符

    下表列出的修饰符可以应用到类型的成员上并且拥有丰富的用法。其中一部分修饰符用在类型上时也有特殊的含义。

    修饰符 应用范围 描述
    new 方法成员 隐藏了父类的同名方法。
    static 所有成员 成员不需要通过新建实例进行调用。
    virtual 方法成员 声明的成员可以被派生类重写。
    abstract 方法成员 定义了成员签名但不提供任何实现。
    override 方法成员 重写了父类的虚方法或抽象成员。
    sealed 类,方法,属性 修饰类的时候,类不可被继承。
    修饰属性和方法时,属性方法不可再被子类重写。
    修饰属性和方法时需要与override连用。
    extern 静态[DllImport]方法 成员由不同语言的外部实现。第17章介绍。

    4.5 接口

    就如前面提到的,通过继承一个接口,一个类声明了它必须实现的某些功能。因为并非所有的面向对象语言都支持接口,本节将细节地介绍C#对于接口的实现。我们以Microsoft预定义的一个接口:System.IDisposable来演示完整的接口定义。IDisposable接口包含一个方法,Dispose,用来让继承该接口的类定义回收资源的方法:

    public interface IDisposable
    {
    	void Dispose();
    }
    

    从语法上看,声明一个接口的代码和声明一个抽象类的代码非常类似。但是,要注意的是,接口不允许提供任何成员的任何实现。总的来说,接口只能包含方法,属性,索引和事件的声明。

    与抽象类不同,抽象类可以包含实现代码或者不包含实现的抽象成员。然而,接口则不能有任何具体实现代码,它是纯粹的抽象。因为接口里的所有成员肯定是abstract的,因此为接口里的成员写上abstract关键字就没有任何必要了。

    跟抽象类一样,你无法实例化一个接口。它只拥有它成员的签名,另外,你可以定义一些接口类型的变量。

    接口既没有构造函数(不能实例化的对象有构造函数也没用啊),也没有字段(因为意味着需要一些内部实现)。接口也不许包含操作符重载——虽然这个特性总是在语言设计中被提及并且未来可能会发生变化。

    接口内的成员不允许声明任何修饰符,接口成员总是隐式地声明为public,而且它们不能被virtual修饰。如何实现取决于继承接口的类。因此,由实现类来决定访问修饰符是最好的,就像下面的示例:

    class SomeClass: IDisposable
    {
    	// This class MUST contain an implementation of the
    	// IDisposable.Dispose() method, otherwise
    	// you get a compilation error.
    	public void Dispose()
    	{
    		// implementation of Dispose() method
    	}
    	// rest of class
    }
    

    在这个例子里,假如SomeClass继承了IDisposable接口,但是没有包含一个与IDisposable里定义的Dispose签名一样的方法实现,编译器将会抛出一个错误,提示"SomeClass没有实现接口成员IDisposable.Dispose()"。当然你也可以不继承IDisposable接口,单独实现Dispose方法,这在编译上没有任何问题,只是其它的代码就无法分辨SomeClass是否实现了IDisposable的特性。

    注意:IDisposable是一个相对简单的接口,因为它就定义了一个方法。大部分的接口含有各种成员。而IDisposable的具体实现也不是那么简单的,在第17章我们将会详细讲述。

    4.5.1 定义和实现接口

    本小节主要通过开发一个遵循接口继承规范的小程序,来演示如何定义和使用接口。例子基于银行账户,假定你所写的代码最终将会允许在不同银行账户间进行交易,并且有许多拥有银行账户的公司,他们彼此同意任何一个代表银行账户的类都实现了一个接口,叫IBankAccount,它包含有存钱和取钱的方法,并且有一个账户余额属性。正是这个接口使得外部代码可以分辨不同银行实现的银行账户类。虽然银行账户的目的是为了方便账户之间的金融交易,但这方面功能这里暂不介绍。

    简单起见,假定例子的代码都存在一个源文件里。当然,假如例子中的某些情况在实际中应用,你可能需要不同的银行账户类,不单只被编译成不同的程序集,还运行在不同银行的不同机器上。这些情况对于本例来说过于复杂,为了在实际环境实现这些,你可能还需要为不同公司定义不同的命名空间。但我们抛开这些复杂的业务因素,只关注接口的基本实现。

    首先,你需要定义一个IBankAccount接口:

    namespace Wrox.ProCSharp
    {
    	public interface IBankAccount
    	{
    		void PayIn(decimal amount);
    		bool Withdraw(decimal amount);
    		decimal Balance { get; }
    	}
    }
    

    留意一下接口名IBankAccount,接口最好用大写字母I开头,这样一眼就能看出它是一个接口。

    注意:第二章"核心C#"提到在大部分情况下,.NET用法指南都不鼓励使用Hungarian命名法,也就是"类型缩写+实际名称"这种命名方式,而接口是为数不多的推荐使用Hungarian命名法的一种类型。

    接下来你就可以编写代表银行账户的类。这些类彼此之间没有任何关系,它们都是不同的类,它们唯一能显示他们是一个银行账户的特征仅仅是因为它们都实现了IBankAccount接口。

    让我们先看第一个类,在Royal Bank of Venus运行的储蓄账户:

    namespace Wrox.ProCSharp.VenusBank
    {
    	public class SaverAccount: IBankAccount
    	{
    		private decimal _balance;
    		public void PayIn(decimal amount) => _balance += amount;
    		public bool Withdraw(decimal amount)
    		{
    			if (_balance >= amount)
    			{
    				_balance -= amount;
    				return true;
    			}
    			Console.WriteLine("Withdrawal attempt failed.");
    			return false;
    		}
    		public decimal Balance => _balance;
    		public override string ToString() => $"Venus Bank Saver: Balance = {_balance,6:C}";
    	}
    }
    

    这个类如何实现应该是一模了然的。你定义了一个私有字段balance,通过调整它的数量,来表示钱的存取。当账户余额不够待取金额时你显示了一行错误信息。注意为了保持代码尽可能简单,我们没有实现很多其他的属性,甚至连户主姓名都没有。在实际环境中可能这是必要信息,但对于我们的示例来说却不是必要的。

    我们唯一感兴趣的代码行应该是类的定义:

    public class SaverAccount: IBankAccount
    

    你声明了SaverAccount继承自接口IBankAccount,因为你没有显式地指定其他的父类,这意味着SaverAccount想直接继承自System.Object。顺带一提的是,实现接口和继承自某个父类是完全独立的,互不影响。

    继承了IBankAccount接口意味着SaverAccount获得了IBankAccount的所有成员,但是因为接口里并没有任何具体实现,因此SaverAccount需要提供所有接口成员的具体实现。假如有那个成员缺少了声明,你将会得到一个编译器错误。回想一下,接口成员仅仅只是定义了它成员的存在,具体这些成员如何实现,是用virtual还是abstract修饰,都取决于具体的类(仅当类是抽象类时才可以将方法声明成abstract)。当然在我们这个例子里,你没有任何必要将任何接口成员声明成virtual。

    为了演示多个不同的类可以实现同一个接口,假设Planetary Bank of Jupiter同样实现了它自己的银行账户,GoldAccount,如下所示:

    namespace Wrox.ProCSharp.JupiterBank
    {
    	public class GoldAccount: IBankAccount
    	{
    		// ...
    	}
    }
    

    这里实现细节没有具体展示,但无伤大雅。在上面这个例子里,它的实现基本上跟SaverAccount大同小异。我们要强调的是GoldAccount和SaverAccount没有任何实质上的关系,除了他们刚好都实现了IBankAccount接口之外。

    现在你已经写完了接口和类,你可以测试它们:

    using Wrox.ProCSharp;
    using Wrox.ProCSharp.VenusBank;
    using Wrox.ProCSharp.JupiterBank;
    namespace Wrox.ProCSharp
    {
    	class Program
    	{
    		static void Main()
    		{
    			IBankAccount venusAccount = new SaverAccount();
    			IBankAccount jupiterAccount = new GoldAccount();
    			venusAccount.PayIn(200);
    			venusAccount.Withdraw(100);
    			Console.WriteLine(venusAccount.ToString());
    			jupiterAccount.PayIn(500);
    			jupiterAccount.Withdraw(600);
    			jupiterAccount.Withdraw(100);
    			Console.WriteLine(jupiterAccount.ToString());
    		}
    	}
    }
    

    代码的输出是这样的:

    Venus Bank Saver: Balance = $100.00
    Withdrawal attempt failed.
    Jupiter Bank Saver: Balance = $400.00
    

    这里最主要的点是,这里声明的实例变量,都是用IBankAccount引用类型。这意味着它们可以指向任何实现了IBankAccount接口的类的实例。而且,这也意味着你可以通过这些引用调用这个接口的所有方法,假如你想调用实际类的某个非接口方法,你需要将IBankAccount类型的变量强制转换成具体类,再进行调用。在示例代码中,你还可以调用ToString方法(虽然它不是IBankAccount接口定义的),也不用通过显式的类型转换,这是因为ToString方法是由System.Object定义的,C#编译器知道它可以被所有的对象调用(接口也不例外,接口到System.Object的转换是隐式的,第6章将详细介绍强制转换)。

    接口引用完全可以当成类引用来用——但接口引用的强大之处在于它可以指向任何实现了这个接口的类。例如,你可以创建一个接口数组,然后存储不同类的实例,如下所示:

    IBankAccount[] accounts = new IBankAccount[2];
    accounts[0] = new SaverAccount();
    accounts[1] = new GoldAccount();
    

    注意,当你试图给它赋一个未实现IBankAccount接口的实例时编译器会报错:

    accounts[1] = new SomeOtherClass(); // SomeOtherClass does not implement IBankAccount
    // 错误:无法隐式地将SomeOtherClass转换成IBankAccount
    

    4.5.2 派生的接口

    接口也可以像类那样继承自另外一个接口,我们将通过一个新的接口ITransferBankAccount来演示,它跟IBankAccount的功能一样,但它还定义了一个在不同账户之间进行交易的方法:

    namespace Wrox.ProCSharp
    {
    	public interface ITransferBankAccount: IBankAccount
    	{
    		bool TransferTo(IBankAccount destination, decimal amount);
    	}
    }
    

    因为ITransferBankAccount继承自IBankAccount,它拥有IBankAccount所有的成员。这意味着实现ITransferBankAccount接口的类,实际上也需要实现IBankAccount接口中的所有成员,并且实现ITransferBankAccount接口定义的新方法TransferTo,两者中任何一个没有实现都会引发一个编译器错误。

    注意TransferTo方法使用了一个IBankAccount类型的参数,用来标识目标账户。这展示了接口最有用的一点:在方法的实现和调用过程中,你不需要知道你实际操作的对象具体是哪个类——你只需要知道那个对象只要实现了IBankAccount接口就行。

    为了更好的演示ITransferBankAccount,我们假定Planetary Bank of Jupiter还提供了一个当前账户,CurrentAccount。CurrentAccount的大部分实现跟SaverAccount还有GoldAccount并无两样(再次强调,只是为了示例演示才一切从简,跟实际情况未必相符),仅仅只是多了一个TransferTo方法:

    public class CurrentAccount: ITransferBankAccount
    {
    	private decimal _balance;
    	public void PayIn(decimal amount) => _balance += amount;
    	public bool Withdraw(decimal amount)
    	{
    		if (_balance >= amount)
    		{
    			_balance -= amount;
    			return true;
    		}
    		Console.WriteLine("Withdrawal attempt failed.");
    		return false;
    	}
    	public decimal Balance => _balance;
    	public bool TransferTo(IBankAccount destination, decimal amount)
    	{
    		bool result = Withdraw(amount);
    		if (result)
    		{
    			destination.PayIn(amount);
    		}
    		return result;
    	}
    	public override string ToString() => $"Jupiter Bank Current Account: Balance = {_balance,6:C}";
    }
    

    Main函数里我们可以这么调用:

    static void Main()
    {
    	IBankAccount venusAccount = new SaverAccount();
    	ITransferBankAccount jupiterAccount = new CurrentAccount();
    	venusAccount.PayIn(200);
    	jupiterAccount.PayIn(500);
    	jupiterAccount.TransferTo(venusAccount, 100);
    	Console.WriteLine(venusAccount.ToString());
    	Console.WriteLine(jupiterAccount.ToString());
    }
    

    执行程序,你将会看到这样的输出:

    Venus Bank Saver: Balance = $300.00
    Jupiter Bank Current Account: Balance = $400.00
    

    4.6 is 和as 运算符

    在总结类和接口的继承之前,我们需要先了解两个重要的运算符:is和as。

    你已经了解到你可以直接将某个实例对象直接赋值给它的基类或者接口声明的变量。例如,先前的SaverAccount可以直接赋值给IBankAccount变量因为SaverAccount实现了IBankAccount接口:

    IBankAccount venusAccount = new SaverAccount();
    

    让我们考虑下面这种情况,假如你有一个方法,接收一个Object类型的参数,但是你在方法体内想访问IBankAccount对象的成员时候,怎么办?Object类可不知道IBankAccount对象里究竟都有哪些成员。这个时候你就需要使用强制转换,将Object对象强制转换成IBankAccount类型,就像下面这样:

    public void WorkWithManyDifferentObjects(object o)
    {
    	IBankAccount account = (IBankAccount)o; 
    	// work with the account
    }
    

    只要你每回传过来的参数对象都是IBankAccount类型的,这个方法就不会报错。但因为参数是object类型的,总有意料之外的时候,传递过来的对象没有实现IBankAccount接口,这个时候你想进行强制转换,程序就会报错。这个时候就轮到is和as运算符出场了。

    比起直接进行强制转换,更稳妥的方式是先检查一下传递过来的参数对象是否实现了IBankAccount接口。as运算符和强制转换操作符很类似,都是转换对象,返回一个引用。但是在转换失败时,as运算符不会抛出一个InvalidCastException异常,而是返回一个空引用,如下所示:

    public void WorkWithManyDifferentObjects(object o)
    {
    	IBankAccount account = o as IBankAccount;
    	if (account != null)
    	{
    		// work with the account
    	}
    }
    

    这里在使用as运算符之前最好先判断一下参数o是否为null,如果对null值进行as转换会直接抛出一个NullReferenceException。

    除了as运算符之外,你还可以使用is运算符。is运算符会根据某个对象是否是指定类型返回true和false。假如表达式返回的是true值,则将该对象转换成指定类型并赋值给随后的变量,就像下面这样:

    public void WorkWithManyDifferentObjects(object o)
    {
    	if (o is IBankAccount account)
    	{
    		// work with the account
    	}
    }
    

    注意:is运算符最后追加变量声明是C# 7.0开始的新特性,这个特性属于模式匹配的一部分,我们将在第13章进行详细介绍。

    比起使用强制转换得到各种神奇的错误,is和as运算符显然会更加好用一些。

    4.7 小结

    本章主要介绍了C#里面代码是如何进行继承的,详细讲解了C#是如何提供接口的多重继承和类的单继承,并且解释了C#是如何提供更丰富的语法设计使得构造继承代码更加的健壮易用。

    这其中包括override关键字,它用来声明一个方法重写了基类的同名方法;也包括new关键字,它定义了子类隐藏了父类的同名方法;还包括严格的构造函数初始化规则,来确保构造函数之间以更稳健的方式进行交互。

    下一章我们将介绍C#里一个非常重要的语言特性:泛型。

  • 相关阅读:
    react-webpack-express
    React总结和遇到的坑
    vue+node+mongodb实现的功能
    webpack整体了解
    webpack踩坑
    深入了解MongoDB
    实现pdf word在线浏览和下载
    node实现爬虫
    火客声音分析
    抖音二婷衣橱分析
  • 原文地址:https://www.cnblogs.com/zenronphy/p/ProfessionalCSharp7Chapter4.html
Copyright © 2011-2022 走看看