继承-C#学习笔记
A awesome static site generator.
第四章 继承
继承的类型
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}");
}
- 重写基类方法时, 签名(参数列表与方法名)和返回值必须完全匹配, 否则会创建新成员而不是覆盖基类成员
ToString是object的虚方法, 在需要把对象转换为字符串时会自动调用ToString方法
成员字段和静态函数都不能声明为virtual, 因为这个概念只对类中的实例函数成员有意义.
多态性
使用多态性, 可以动态定义调用的方法(与 C++ 相似), 而不是在编译期定义. 编译器会为虚函数创建虚函数表(vtable), 其中列出了可以在运行期间调用的函数, 它根据运行期间的类型调用函数.
public static void DrawShape(Shape shape) =>
shape.Draw();
使用之前创建的矩形调用方法, 尽管参数是Shape引用, 但通过引用可以调用到其派生类对象的最高级虚函数, 即调用Rectangle类的Draw方法.
如果Draw方法并不是虚方法或Rectangle类没有重写Draw方法, 则会调用Shape.Draw.
隐藏方法
如果签名相同的方法在基类和派生类中都进行了声明, 但该方法没有分别声明为虚方法, 派生类方法就会隐藏基类的同签名方法. (其他类型成员也可以, 不限于函数成员) 一般情况下, 在派生类中隐藏基类方法会时编译器给出编译警告, 提示使用new修饰符隐藏基类成员.
new修饰符可以显式声明隐藏基类同名成员, 阻止编译器给出编译警告.
对同一成员同时使用
new和override是错误的做法, 因为这两个修饰符的含义互斥.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 | 成员或嵌套类型 | 只能在包含它的程序集, 所属的或派生的类型中访问. 事实上, 这意味着protected或internal |
| private protected | 成员或嵌套类型 | 意味着private且protected. 只允许同一程序集中的派生类型访问, 而不允许其他程序集中的派生类型访问. |
public,protected和private是逻辑访问修饰符.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);
}
is和as运算符
is as运算符用于类型转换, 但他们不同于一般的强制类型转换.
is运算符根据对象是否能转换为目标类型, 返回true或false. 如果为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
}
}