第四章 继承

继承的类型

​ C# 中一个类可以派生自一个基类, 允许多层继承, 但不支持一个类派生自多个类, 但支持继承自多个接口. 类总是派生自System.Object或用户选择的一个类.

​ 结构总是派生自System.ValueType, 结构不支持继承其他结构或类, 但结构可以继承任意多个接口.

实现继承

​ 如果类与接口都用于派生, 则类总是放在接口的前面.

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

​ 结构只能派生自接口:

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

​ 若类没有指定基类, 则编译器默认其派生自System.Object.

虚方法

​ 把一个基类方法或属性声明为virtual, 就可以在任何派生类中重写该方法. 为简单起见, 下面的讨论主要集中在方法, 但其规则也适用于属性.

​ C# 中, 函数在默认情况下不是虚拟的, 但(构造函数除外)可以显式声明为virtual.

这遵循 C++ 的方式, 即从性能角度来看, 除非显式指定, 否则函数就不是虚拟的; 而在 Java 中, 所有函数都是虚拟的.

​ C# 的语法与 C++ 不同, 要求派生了在重写函数时, 需要使用override关键字(前置)显式声明.

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}";
}
public class Shape
{
    public virtual void Draw() => 
        Console.WriteLine($"Shape with {Position} and {Size}");
    public virtual Position Position { get; } = new Position();
    public virtual Size Size { get; } = new Size();
}

public class Rectangle: Shape
{
    public override void Draw() =>
        Console.Writeline($"Rectangle with {Position} and {Size}");
}
  • 重写基类方法时, 签名(参数列表与方法名)和返回值必须完全匹配, 否则会创建新成员而不是覆盖基类成员
  • ToStringobject的虚方法, 在需要把对象转换为字符串时会自动调用ToString方法

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

多态性

​ 使用多态性, 可以动态定义调用的方法(与 C++ 相似), 而不是在编译期定义. 编译器会为虚函数创建虚函数表(vtable), 其中列出了可以在运行期间调用的函数, 它根据运行期间的类型调用函数.

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

使用之前创建的矩形调用方法, 尽管参数是Shape引用, 但通过引用可以调用到其派生类对象的最高级虚函数, 即调用Rectangle类的Draw方法. 如果Draw方法并不是虚方法或Rectangle类没有重写Draw方法, 则会调用Shape.Draw.

隐藏方法

​ 如果签名相同的方法在基类和派生类中都进行了声明, 但该方法没有分别声明为虚方法, 派生类方法就会隐藏基类的同签名方法. (其他类型成员也可以, 不限于函数成员) 一般情况下, 在派生类中隐藏基类方法会时编译器给出编译警告, 提示使用new修饰符隐藏基类成员.

new修饰符可以显式声明隐藏基类同名成员, 阻止编译器给出编译警告.

对同一成员同时使用newoverride是错误的做法, 因为这两个修饰符的含义互斥. new修饰符会用同样的名称创建一个新成员并使原始成员变为隐藏. override修饰符会扩展继承成员的实现.

new方法修饰符主要目的是处理版本冲突, 在修改派生类后相应基类的变化.

调用方法的基类版本

​ C# 可以通过base.<MethodName>(parameters)从派生类中调用基类成员. 例如, 可以使用此语法在派生类中重用基类的代码.

public class Shape
{
    public virtual void Move(Position newPosition)
	{
    	Position.X = newPosition.X;
    	Position.Y = newPosition.Y;
    	Console.WriteLine($"moves to {Position}");
	}
    //other members
}
 public class Rectangle: Shape
 {
     public override void Move(Position newPosition)
     {
         Console.Write("Rectangle ");
         base.Move(newPosition);
     }
     //other members
 }
rectangle1.Move(new Position { X = 120, Y = 40 });
//output:Rectangle moves to X: 120, Y: 40

抽象类和抽象方法

​ 使用修饰符abstract把类和方法声明为抽象类和抽象方法(类似 C++ 的抽象类和纯虚函数). 抽象类不能实例化, 抽象方法不能直接实现, 必须在非抽象的派生类中重写. 显然, 抽象方法本身是虚拟的(但不需要也不能使用virtual关键字, 否则产生语法错误). 如果类包含抽象方法, 则该类为抽象类, 也必须声明为abstract. ​ 下面把Shape类声明为抽象类, 因为其他类需要派生自这个类.

public abstract class Shape
{
    public virtual void Draw() => 
        Console.WriteLine($"Shape with {Position} and {Size}");
    public virtual Position Position { get; } = new Position();
    public virtual Size Size { get; } = new Size();
    public abstract void Resize(int width, int height);
}

或者, 为实现相同目的, 也可以在基类虚方法中抛出NotImplementedException异常. 在开发过程中, 它通常只是一个临时的未完成的实现.

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

可以声明抽象类的变量(引用), 但无法实例化. 可以使用该引用指向任何继承自该抽象类的对象.

密封类和密封方法

​ 如果不应该创建派生自某个自定义类的类, 该类就应该秘封密封. 使用修饰符sealed密封该类后就不允许创建该类的派生类. 也可密封一个方法, 表示不能重写该方法. (类似于C++ 的final关键字) ​ sealed只能用于修饰类或虚方法. 如果在基类上就不希望有重写的方法或属性, 就不要将其声明为virtual.

​ 使用密封除了避免继承可能导致的不稳定, 还有另一个原因: 对于密封类, 编译器知道不能派生类, 因此用于虚方法的虚表可以缩短或消除, 以提高性能. 把类标记为sealed对编译器来说是一个很好的提示.

string类是密封的, 因为没有哪个应用程序不使用字符串, 所以最好使这种类型保持最好的性能.

派生类的构造函数

​ 派生类的构造函数中需要在构造函数初始化器中使用base关键字显式调用基类构造函数.

//Shape类中
public Shape(int width, int height, int x, int y)
{
    //do something...
}

//Rectangle类(继承自Shape)中
public Rectangle(int width, int height, int x, int y)
    : base(width, height, x, y)
{}

访问修饰符

修饰符 应用于 说明
public 类型或成员 所有代码均能访问
internal 类型或成员 只能在包含它的程序集中访问
protected 成员或嵌套类型 只有所属的或派生的类型可以访问
private 成员或嵌套类型 只能在所属的类型访问
protected internal 成员或嵌套类型 只能在包含它的程序集, 所属的或派生的类型中访问. 事实上, 这意味着protectedinternal
private protected 成员或嵌套类型 意味着privateprotected. 只允许同一程序集中的派生类型访问, 而不允许其他程序集中的派生类型访问.
  • public, protectedprivate是逻辑访问修饰符. internal是物理访问修饰符, 其边界是一个程序集.

​ 类型定义可以是公有的或内部的, 这取决于是否希望在包含类型的程序集外部访问它. ​ 不能把类型定义为protected, private等, 因为这些修饰符对于包含在命名空间的类型没有意义, 只对成员有意义; 但是可以用这些修饰符定义嵌套的类型.

​ 如果有嵌套的类型, 则内部类型总是可以访问外部类型的所有成员, 甚至可以访问其私有成员.

接口

​ 如果一个类派生自一个接口(interface), 这意味着这个类将实现接口中的函数. 声明接口使用关键字interface, 语法与声明抽象类完全相同, 但不允许提供接口中任何成员的实现方式. ​ 一般情况下, 接口只能包含方法, 属性, 索引器和事件的声明, 不能有构造函数(不能实例化)与字段(这隐含了内部某些实现方式). 接口定义也不允许包含运算符重载. ​ 类似于抽象类, 永远不能实例化接口, 但可以声明接口的变量, 用来指向实现了此接口的任意对象. ​ 接口定义中还不允许声明成员修饰符. 接口成员总是隐式为public, 不能声明为virtual. 如果需要, 应该由实现的类来声明.

定义和实现接口

​ 以下例子建立在银行账户的基础上, 许多公司一致认为表示银行账户的类都应该实现接口IBankAccount. ​ 首先,定义该接口:

namespace ProCSharp.Banks
{
    public interface IBankAccount
    {
        void PayIn(decimal amount);
        bool WithDraw(decimal amount);
        decimal Balance { get; }
    }
}
  • 接口名称通常以字母 I 开头, 以便知道这是一个接口.
    • 大多数情况下, .NET 用法规则不鼓励采用 Hungarian 表示法, 即在名称前加一个表示类型的字母表示其类型. 接口是少数几个推荐使用此表示法的例外之一.

​ 下面编写表示银行账户的类, 他们都实现接口IBankAccount:

namespace ProCSharp.Banks.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}";
    }
}

SaverAccount类派生自IBankAccount, 这表示类获得了接口的所有成员. 因为接口不提供任何实现, 所以必须由类提供所有实现代码. 接口进保证其成员的存在性, 类负责确定这些成员是虚拟的还是抽象的(只有在类本身是抽象的时, 这些函数才能是抽象的).

​ 接口的引用变量可以指向实现了该接口的对象, 下面假定 Planetary Bank of Jupiter 还实现一个类GoldAccount来表示一个银行账户:

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

然后我们可以使用接口变量来引用这些类, 并通过接口变量来调用接口中的能由接口隐式转换为的(如System.Object)类型的函数. 如果要调用由类实现但不在接口中的函数, 就需要将引用变量强制转换为合适的类型.

  • 这说明了接口的意义: 在调用方法时, 不必知道对象的具体类型, 只需知道该对象实现了接口中的函数并调用即可
IBankAccount[] accounts = new IBankAccount[2];
account[0] = new SaverAccount();
account[1] = new GoldAccount();

account[0].PayIn(200);
account[0].WithDraw(100);
account[1].PayIn(100);
Console.WriteLine(account[0].ToString());
Console.WriteLine(account[1].ToString());

接口的派生

​ 接口可以彼此继承, 其继承方式与类相同(可以继承多个接口). 派生的接口拥有基接口与自身的所有成员, 这表示派生自该接口的任何类都必须实现基接口与自身的所有成员.

public interface ITransferBankAccount: IbankAccount
{
    bool TransferTo(IbankAccount destination, decimal amount);
}

isas运算符

is as运算符用于类型转换, 但他们不同于一般的强制类型转换.

is运算符根据对象是否能转换为目标类型, 返回truefalse. 如果为true, 则将对象写入声明为匹配类型的变量中.

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

as运算符类似于类层次结构中的cast运算符, 它返回对象的引用; 然而, 不同于直接类型转换, 它从不抛出InvalidCastException异常. 如果对象不是所要求的类型, as运算符就会返回null. 这里最好在使用前验证它是否为空, 否则一旦使用null引用就会抛出NullReferenceException异常.

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