继承
- 一个类可以通过继续另一个类,从而对原有类进行扩展和自定义。
- 子类 subclass(派生类 derived class)和父类(基类)superclass。
- 一个类只能继续一个类,但可以被多个类继承。
定义一个名为 Asset
的类:
public class Asset
{
public string name;
}
定义类 Stock
和 House
继承 Asset
类:
public class Stock : Asset // Inherits from Asset
{
public long sharesOwned;
}
public class House : Asset // Inherits from Asset
{
public decimal mortgage;
}
Stock msft = new Stock
{
name = "MSFT",
sharesOwned = 1000
};
Console.WriteLine(msft.name); // MSFT
Console.WriteLine(msft.sharesOwned); // 1000
House mansion = new House
{
name = "Mansion",
mortgage = 250000
};
Console.WriteLine(mansion.name); // Mansion
Console.WriteLine(mansion.mortgage); // 250000
1 多态
引用是多态的。意味着 x
类型的变量可以引用其子类的对象。考虑如下方法:
public static void Display(Asset asset)
{
System.Console.WriteLine(asset.name);
}
这个方法可以用来显示 Stock
和 House
的实例,因为这两个类都继承自 Asset
:
Stock msft = new Stock ... ;
House mansion = new House ... ;
Display(msft);
Display(mansion);
多态之所以能够实现,是因为子类具有基类的全部功能特性,所以参数可以是子类,反过来则不正确。如果 Display
接受 House
类,则不能将 Asset
传递给它:
static void Main()
{
Display(new Asset()); // Compile-time error
}
public static void Display(House house) // Will not accept Asset
{
System.Console.WriteLine(house.mortgage);
}
2 类型转换和引用转换
对象引用可以:
- 隐式转换为父类的引用——向上转换。
- 显式转换为子类的引用——向下转换。
各个兼容的类型的引用之间向上或想下类型转换仅执行引用转换:(逻辑上)生成一个新的引用指向同一个对象(无法改变底层的值,只能改变编译时类型)。向上转换总能够成功,向下转换只有在对象的类型符合要求时才能成功。
2.1 向上类型转换
向上类型转换创建一个父类指向子类的引用,从子类引用创建一个父类引用:
Stock msft = new Stock();
Asset a = msft; // Upcast
向上转换后,变量 a
仍然是 msft
指向 Stock
对象,被引用的对象本身不会被替换或改变:
Console.WriteLine(a == msft); // True
🍂 尽管 a
和 msft
指向同一对象,但 a
的可视范围更小:
Console.WriteLine(a.name); // OK
Console.WriteLine(a.sharesOwned); // Error: SharesOwned undefined
因为变量 a
是 Assert
类型,即使它引用了 Stock
对象,如果要访问 sharesOwned
字段,必须将 Asset
类型向下转换为 Stock
类型。
2.2 向下类型转换
向下转换是从父类引用创建一个子类引用:
Stock msft = new Stock();
Asset a = msft; // Upcast
Stock s = (Stock)a; // Downcast
Console.WriteLine(s.sharesOwned); // <No error>
Console.WriteLine(s == a); // True
Console.WriteLine(s == msft); // True
和向上转换一样,仅仅影响引用,不涉及被引用的对象。
🍂 向下转换必须是显式转换,因为可能导致运行时错误,抛出 InvalidCastException 异常:
House h = new House();
Asset a = h; // Upcast always succeeds
Stock s = (Stock)a; // Downcast fails: a is not a Stock
2.3 as 操作符
as
操作符在向下类型转换出错时返回 null
(而不是抛出异常):
Asset a = new Asset();
Stock s = a as Stock; // s is null; no exception thrown
if (s != null)
Console.WriteLine(s.SharesOwned);
as
操作符和类型转换
如果不用判断结果是否为 null
那么更推荐使用类型转换。因为如果发生错误,类型转换会抛出描述更清晰的异常:
int shares = ((Stock)a).sharesOwned; // Approach #1
int shares = (a as Stock).sharesOwned; // Approach #2
- 如果
a
不是Stock
类型,#1 会抛出 InValidCastException,很清晰地描述了错误。 - #2 会抛出 NullReferenceException。
🍂as
操作符不能用来实现自定义转换和数值的转换:
long x = 3 as long; // Compile-time error
2.4 is 操作符
is
操作符检查引用的转换是否成功,判断对象是否派生于某个类(或者实现了某个接口):
if (a is Stock)
Console.WriteLine(((Stock)a).sharesOwned);
如果拆箱转换(unboxing conversion)能成功执行,则 is
运算符也返回 true
。
🍂 is
操作符不能用来实现自定义转换和数值的转换。
2.5 is 操作符和模式变量 The is operator and pattern variables
csharp 7 开始,在使用 is
操作符的同时可以引入一个变量:
if (a is Stock s)
Console.WriteLine(s.sharesOwned);
等价于:
Stock s;
if (a is Stock)
{
s = (Stock)a;
Console.WriteLine(s.sharesOwned);
}
引入的变量可以立即使用,因此以下的代码是合法的:
if (a is Stock s && s.sharesOwned > 100000)
Console.WriteLine("Wealthy");
同时,引入的变量在 is
表达式之外仍然在作用域内:
if (a is Stock s && s.sharesOwned > 100000)
Console.WriteLine("Wealthy");
else
s = new Stock(); // s is in scope
Console.WriteLine(s.sharesOwned); // Still in scope
3 虚函数成员 Virtual Function Members
标记为 virtual
的函数可以被子类重写(overridden),包括方法、属性、索引器和事件都可以声明为 virtual
:
public class Asset
{
public string name;
public virtual decimal liability => 0; // Expression-bodied property // get {return 0;}
}
子类通过 override
修饰符重写虚方法:
public class Stock : Asset
{
public long sharesOwned;
}
public class House : Asset
{
public decimal mortgage;
public override decimal liability => mortgage;
}
public class A
{
public virtual void Foo() => Console.WriteLine("A.Foo");
}
public class B : A
{
public override void Foo() => Console.WriteLine("B.Foo");
}
A a = new A();
a.Foo(); // A.Foo
B b = new B();
b.Foo(); // B.Foo
((A)b).Foo(); // B.Foo
A x = new B();
x.Foo(); // B.Foo
🍂 父类的 virtual
方法和子类重写方法的签名、返回值以及可访问性必须完全一致。
☀️ 从构造器调用虚方法有潜在的危险性,因为重写的方法很可能最终会访问到一些方法或属性,而这些方法或属性依赖的字段还未被构造器初始化。
4 抽象类和抽象成员 Abstract Classes and Abstract Members
- 使用
abstract
声明的类是抽象类。 - 抽象类不能实例化,只有其具体的子类才能实例化。
- 抽象类中可以定义抽象成员。
- 抽象成员和虚成员类似,但抽象成员不提供具体实现,除非子类也声明为抽象类,否则子类必须提供实现(
override
进行实现)。
public abstract class Asset
{
// Note empty implementation
public abstract decimal netValue { get; }
}
public class Stock : Asset
{
public long sharesOwned;
public decimal currentPrice;
// Override like a virtual method.
public override decimal netValue => currentPrice * sharesOwned;
}
5 隐藏继承成员
父类和子类可能定义相同的成员:
public class A { public int counter = 1; }
public class B : A { public int counter = 2; }
类 B
中 counter
字段隐藏了类 A
中的 counter
字段。通常这种情况是在定义了子类成员后又意外将其添加到父类中造成的。编译器会产生一个警告,并采用下面的方法避免二义性:
A
的引用在编译时绑定到A.counter
。B
的引用在编译时绑定到B.counter
。
A a = new A();
Console.WriteLine(a.counter); // 1
B b = new B();
Console.WriteLine(b.counter); // 2
A x = new B();
Console.WriteLine(x.counter); // 1
有时需要故意隐藏一个成员,此时可以对子类的成员使用 new
修饰符,new
修饰符仅用于阻止编译器发出警告:
public class A { public int counter = 1; }
public class B : A { public new int counter = 2; }
new
修饰符可明确地表达:重复的成员是有意义的。
🍂 csharp 在不同上下文中的 new
关键字拥有完全不同的含义,特别注意 new
运算符和 new
修饰符是不同的。
new
和重写
public class BaseClass
{
public virtual void Foo() { Console.WriteLine("BaseClass.Foo"); }
}
public class Overrider : BaseClass
{
public override void Foo() { Console.WriteLine("Overrider.Foo"); }
}
public class Hider : BaseClass
{
public new void Foo() { Console.WriteLine("Hider.Foo"); }
}
以下代码展示了 Overrider 和 Hider 的不同行为:
Overrider over = new Overrider();
BaseClass b1 = over;
over.Foo(); // Overrider.Foo
b1.Foo(); // Overrider.Foo
Hider h = new Hider();
BaseClass b2 = h;
h.Foo(); // Hider.Foo
b2.Foo(); // BaseClass.Foo
6 密封函数和类 Sealing Functions and Classes
重写的函数成员可以使用 sealed
关键字密封其实现,防止其他的子类再次重写。如密封 House
类的 liability
实现,来防止继承了 House
的子类重写 liability
:
public class Asset
{
public string name;
public virtual decimal liability => 0;
}
public class House : Asset
{
public decimal mortgage;
public sealed override decimal liability => mortgage;
}
也可以在类中使用 sealed
修饰符来密封整个类,这会隐式地密封类中所有的虚函数。
🍂 可以防止重写,但是它无法阻止成员被隐藏。
7 base 关键字
base
关键字和 this
关键字很相似,它主要用于:
- 从子类访问父类里被重写的函数成员。
- 调用父类的构造器。
public class House : Asset
{
public decimal mortgage;
public override decimal liability => base.liability + mortgage;
}
通过 base
关键字可以保证访问的一定是 Asset
的 liability
字段,无论该字段是被重写还是被隐藏了。
8 构造函数和继承
- 子类必须声明自己的构造函数。
- 子类可以访问父类的构造函数,但是并非自动继承。
- 子类必须重新定义它希望对外公开的任何构造函数,不过可以使用
base
关键字调用父类的任一构造函数。
public class Baseclass
{
public int X;
public Baseclass() { }
public Baseclass(int x)
{
this.X = x;
}
}
public class Subclass : Baseclass
{
public Subclass(int x) : base(x) { }
}
下面的语句是非法的:
Subclass s = new Subclass(123);
base
关键字和 this
关键字很像,但 base
关键字调用的是父类的构造函数。
🍂 父类的构造函数总是先执行,保证了父类初始化发生在子类特定的初始化之前。
8.1 隐式调用父类无参构造函数
如果子类的构造函数没有使用 base
关键字,那么父类的无参构造函数将被隐式调用:
public class BaseClass
{
public int X;
public BaseClass() { X = 1; }
}
public class Subclass : BaseClass
{
public Subclass() { Console.WriteLine(X); } // 1
}
如果父类没有可访问的无参构造函数,子类的构造函数就必须使用 base
关键字。
8.2 构造函数和字段初始化顺序
当对象实例化时,初始化按照以下顺序进行:
- 从子类到父类
- 初始化字段。
- 计算被调用的父类构造函数中的参数。
- 从父类到子类
- 构造函数方法体的执行。
public class B
{
int x = 1; // Executes 3rd,字段在构造函数之前执行
public B(int x)
{
... // Executes 4th
}
}
public class D : B
{
int y = 1; // Executes 1st
public D(int x)
: base(x + 1) // Executes 2nd
{
... // Executes 5th
}
}
9 重载和解析 Overloading and Resolution
继承对方法的重载有着特殊的影响:
static void Foo(Asset a) { }
static void Foo(House h) { }
当重载被调用时,类型最明确的优先匹配:
House h = new House(...);
Foo(h); // Calls Foo(House)
具体调用哪个重载是在编译器静态决定的而非运行时决定:
Asset a = new House(...);
Foo(a); // Calls Foo(Asset), even though the runtime type of a is House
🍂 如果把 Asset
类转换为 dynamic
,则在运行时决定调用哪个重载,这样就会基于对象的实际类型进行选择:
Asset a = new House(...);
Foo((dynamic)a); // Calls Foo(House)