C#高级编程(第10版) C# 6 & .NET Core 1.0 (.NET开发经典名著)
上QQ阅读APP看书,第一时间看更新

4.3 实现继承

如果要声明派生自另一个类的一个类,就可以使用下面的语法:

        class MyDerivedClass: MyBaseClass
        {
          // members
        }

如果类(或结构)也派生自接口,则用逗号分隔列表中的基类和接口:

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

如果类和接口都用于派生,则类总是必须放在接口的前面。

对于结构,语法如下(只能用于接口继承):

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

如果在类定义中没有指定基类,C#编译器就假定System.Object是基类。因此,派生自Object类(或使用object关键字),与不定义基类的效果是相同的。

        class MyClass // implicitly derives from System.Object
        {
          // members
        }

下面的例子定义了基类Shape。无论是矩形还是椭圆,形状都有一些共同点:形状都有位置和大小。定义相应的类时,位置和大小应包含在Shape类中。Shape类定义了只读属性Position和Shape,它们使用自动属性初始化器来初始化(代码文件VirtualMethods /Shape.cs):

        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()
          {
            WriteLine($"Shape with {Position} and {Size}");
          }
        }

如果实现代码只有一行,在C# 6中,也可以把virtual关键字和表达式体的方法(使用lambda运算符)一起使用。这个语法可以独立于修饰符,单独使用:

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

也可以把属性声明为virtual。对于虚属性或重写属性,语法与非虚属性相同,但要在定义中添加关键字virtual,其语法如下所示:

        public virtual Size Size { get; set; }

当然,也可以给虚属性使用完整的属性语法:

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

为了简单起见,下面的讨论将主要集中于方法,但其规则也适用于属性。

C#中虚函数的概念与标准OOP的概念相同:可以在派生类中重写虚函数。在调用方法时,会调用该类对象的合适方法。在C#中,函数在默认情况下不是虚拟的,但(除了构造函数以外)可以显式地声明为virtual。这遵循C++的方式,即从性能的角度来看,除非显式指定,否则函数就不是虚拟的。而在Java中,所有的函数都是虚拟的。但C#的语法与C++的语法不同,因为C#要求在派生类的函数重写另一个函数时,要使用override关键字显式声明(代码文件VirtualMethods/Concrete-Shapes.cs):

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

重写方法的语法避免了C++中很容易发生的潜在运行错误:当派生类的方法签名无意中与基类版本略有差别时,该方法就不能重写基类的方法。在C#中,这会出现一个编译错误,因为编译器会认为函数已标记为override,但没有重写其基类的方法。

Size和Position类型重写了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}";
        }

注意:基类Object的成员参见第3章。

注意:重写基类的方法时,签名(所有参数类型和方法名)和返回类型必须完全匹配。否则,以后创建的新成员就不覆盖基类成员。

在Main()方法中,实例化的矩形r,初始化其属性,调用其方法Draw() (代码文件VirtualMethods/ Program.cs):

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

运行程序,查看Draw()方法的输出:

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

成员字段和静态函数都不能声明为virtual,因为这个概念只对类中的实例函数成员有意义。

4.3.2 多态性

使用多态性,可以动态地定义调用的方法,而不是在编译期间定义。编译器创建一个虚拟方法表(vtable),其中列出了可以在运行期间调用的方法,它根据运行期间的类型调用方法。

在下面的例子中,DrawShape()方法接收一个Shape参数,并调用Shape类的Draw()方法(代码文件VirtualMethods/Program.cs):

        public static void DrawShape(Shape shape)
        {
          shape.Draw();
        }

使用之前创建的矩形调用方法。尽管方法声明为接收一个Shape对象,但任何派生Shape的类型(包括Rectangle)都可以传递给这个方法:

        DrawShape(r);

运行这个程序,查看Rectangle.Draw方法()而不是Shape.Draw()方法的输出。输出行从Rectangle开始。如果基类的方法不是虚拟方法或没有重写派生类的方法,就使用所声明对象(Shape)的类型的Draw()方法,因此输出从Shape开始:

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

4.3.3 隐藏方法

如果签名相同的方法在基类和派生类中都进行了声明,但该方法没有分别声明为virtual和override,派生类方法就会隐藏基类方法。

在大多数情况下,是要重写方法,而不是隐藏方法,因为隐藏方法会造成对于给定类的实例调用错误方法的危险。但是,如下面的例子所示,C#语法可以确保开发人员在编译时收到这个潜在错误的警告,从而使隐藏方法(如果这确实是用户的本意)更加安全。这也是类库开发人员得到的版本方面的好处。

假定有一个类Shape:

        public class Shape
        {
          // various members
        }

在将来的某一刻,要编写一个派生类Ellipse,用它给Shape基类添加某个功能,特别是要添加该基类中目前没有的方法——MoveBy():

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

过了一段时间,基类的编写者决定扩展基类的功能。为了保持一致,他也添加了一个名为MoveBy()的方法,该方法的名称和签名与前面添加的方法相同,但并不完成相同的工作。这个新方法可能声明为virtual,也可能不是。

如果重新编译派生的类,会得到一个编译器警告,因为出现了一个潜在的方法冲突。然而,也可能使用了新的基类,但没有编译派生类;只是替换了基类程序集。基类程序集可以安装在全局程序集缓存中(许多Framework程序集都安装在此)。

现在假设基类的MoveBy()方法声明为虚方法,基类本身调用MoveBy()方法。会调用哪个方法?基类的方法还是前面定义的派生类的MoveBy()方法?因为派生类的MoveBy()方法没有用override关键字定义(这是不可能的,因为基类MoveBy()方法以前不存在),编译器假定派生类的MoveBy()方法是一个完全不同的方法,与基类的方法没有任何关系,只是名字相同。这种方法的处理方式就好像它有另一个名称一样。

编译Ellipse类会生成一个编译警告,提醒使用new关键词隐藏方法。在实践中,不使用new关键字会得到相同的编译结果,但避免出现编译器警告:

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

不使用new关键字,也可以重命名方法,或者,如果基类的方法声明为virtual,且用作相同的目的,就重写它。然而,如果其他方法已经调用了此方法,简单的重命名会破坏其他代码。

注意:new方法修饰符不应该故意用于隐藏基类的成员。这个修饰符的主要目的是处理版本冲突,在修改派生类后,响应基类的变化。

4.3.4 调用方法的基类版本

C#有一种特殊的语法用于从派生类中调用方法的基类版本:base.<MethodName>()。例如,派生类Shape声明了Move()方法,想要在派生类Rectangle中调用它,以使用基类的实现代码。为了添加派生类中的功能,可以使用base调用它(代码文件VirtualMethods / Shape.cs):

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

Move()方法在Rectangle类中重写,把Rectangle一词添加到控制台。写出文本之后,使用base关键字调用基类的方法(代码文件VirtualMethods / ConcreteShapes.cs):

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

现在,矩形移动到一个新位置(代码文件VirtualMethods / Program.cs):

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

运行应用程序,输出是Rectangle和Shape类中Move()方法的结果:

        Rectangle moves to X: 120, Y: 40

注意:使用base关键字,可以调用基类的任何方法——而不仅仅是已重写的方法。

4.3.5 抽象类和抽象方法

C#允许把类和方法声明为abstract。抽象类不能实例化,而抽象方法不能直接实现,必须在非抽象的派生类中重写。显然,抽象方法本身也是虚拟的(尽管也不需要提供virtual关键字,实际上,如果提供了该关键字,就会产生一个语法错误)。如果类包含抽象方法,则该类也是抽象的,也必须声明为抽象的。

下面把Shape类改为抽象类。因为其他类需要派生自这个类。新方法Resize声明为抽象,因此它不能有在Shape类中的任何实现代码(代码文件VirtualMethods / Shape.cs):

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

从抽象基类中派生类型时,需要实现所有抽象成员。否则,编译器会报错:

        public class Ellipse : Shape
        {
          public override void Resize(int width, int height)
          {
            Size.Width = width;
            Size.Height = height;
          }
        }

当然,实现代码也可以如下面的例子所示。抛出类型NotImplementationException的异常也是一种实现方式,在开发过程中,它通常只是一个临时的实现:

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

注意:异常详见第14章。

使用抽象的Shape类和派生的Ellipse类,可以声明Shape的一个变量。不能实例化它,但是可以实例化Ellipse,并将其分配给Shape变量(代码文件VirtualMethods / Program.cs):

        Shape s1 = new Ellipse();
        DrawShape(s1);

4.3.6 密封类和密封方法

如果不应创建派生自某个自定义类的类,该自定义类就应密封。给类添加sealed修饰符,就不允许创建该类的子类。密封一个方法,表示不能重写该方法。

        sealed class FinalClass
        {
          // etc
        }
        class DerivedClass: FinalClass      // wrong. Cannot derive from sealed class.
        {
          // etc
        }

在把类或方法标记为sealed时,最可能的情形是:如果在库、类或自己编写的其他类的操作中,类或方法是内部的,则任何尝试重写它的一些功能,都可能导致代码的不稳定。例如,也许没有测试继承,就对继承的设计决策投资。如果是这样,最好把类标记为sealed。

密封类有另一个原因。对于密封类,编译器知道不能派生类,因此用于虚拟方法的虚拟表可以缩短或消除,以提高性能。string类是密封的。没有哪个应用程序不使用字符串,最好使这种类型保持最佳性能。把类标记为sealed对编译器来说是一个很好的提示。

将一个方法声明为sealed的目的类似于一个类。方法可以是基类的重写方法,但是在接下来的例子中,编译器知道,另一个类不能扩展这个方法的虚拟表;它在这里终止继承。

        class MyClass: MyBaseClass
        {
          public sealed override void FinalMethod()
          {
            // implementation
          }
        }
        class DerivedClass: MyClass
        {
          public override void FinalMethod()  // wrong. Will give compilation error
          {
          }
        }

要在方法或属性上使用sealed关键字,必须先从基类上把它声明为要重写的方法或属性。如果基类上不希望有重写的方法或属性,就不要把它声明为virtual。

4.3.7 派生类的构造函数

第3章介绍了单个类的构造函数是如何工作的。这样,就产生了一个有趣的问题,在开始为层次结构中的类(这个类继承了其他也可能有自定义构造函数的类)定义自己的构造函数时,会发生什么情况?

假定没有为任何类定义任何显式的构造函数,这样编译器就会为所有的类提供默认的初始化构造函数,在后台会进行许多操作,但编译器可以很好地解决类的层次结构中的所有问题,每个类中的每个字段都会初始化为对应的默认值。但在添加了一个我们自己的构造函数后,就要通过派生类的层次结构高效地控制构造过程,因此必须确保构造过程顺利进行,不要出现不能按照层次结构进行构造的问题。

为什么派生类会有某些特殊的问题?原因是在创建派生类的实例时,实际上会有多个构造函数起作用。要实例化的类的构造函数本身不能初始化类,还必须调用基类中的构造函数。这就是为什么要通过层次结构进行构造的原因。

在之前的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; };
        }

当然,实例化派生自Shape类的Rectangle类型,Rectangle需要Position和Size,因此在构造派生对象时,调用基类的构造函数。

如果没有在默认构造函数中初始化成员,编译器会自动把引用类型初始化为null,值类型初始化为0,布尔类型初始化为false。布尔类型是值类型,false与0是一样的,所以这个规则也适用于布尔类型。

对于Ellipse类,如果基类定义了默认构造函数,只把所有成员初始化为其默认值,就没有必要创建默认的构造函数。当然,仍可以提供一个构造函数,使用构造函数初始化器,调用基构造函数:

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

构造函数总是按照层次结构的顺序调用:先调用System.Object类的构造函数,再按照层次结构由上向下进行,直到到达编译器要实例化的类为止。为了实例化Ellipse类型,先调用Object构造函数,再调用Shape构造函数,最后调用Ellipse构造函数。这些构造函数都处理它自己类中字段的初始化。

现在,改变Shape类的构造函数。不是对Size和Position属性进行默认的初始化,而是在构造函数内赋值(代码文件InheritanceWithConstructors/Shape.cs ):

        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类,因为编译器不知道应该把什么值传递给基类唯一的非默认值构造函数。这里需要在派生类中创建一个构造函数,用构造函数初始化器初始化基类构造函数(代码文件InheritanceWithConstructors / ConcreteShapes.cs ):

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

把初始化代码放在构造函数块内太迟了,因为基类的构造函数在派生类的构造函数之前调用。这就是为什么在构造函数块之前声明了一个构造函数初始化器。

如果希望允许使用默认的构造函数创建Rectangle对象,仍然可以这样做。如果基类的构造函数没有默认的构造函数,也可以这样做,只需要在构造函数初始化器中为基类构造函数指定值,如下所示。在接下来的代码片段中,使用了命名参数,否则很难区分传递的width、height、x和y值。

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

注意:命名参数参见第3章。

这个过程非常简洁,设计也很合理。每个构造函数都负责处理相应变量的初始化。在这个过程中,正确地实例化了类,以备使用。如果在为类编写自己的构造函数时遵循同样的规则,就会发现,即便是最复杂的类也可以顺利地初始化,并且不会出现任何问题。