抽象类是一种只为被继承而存在的类。它不能被实例化,并且可以声明抽象成员:没有方法体的方法和属性,每个具体的派生类都必须实现它们。除此之外,它就是一个普通的类:可以有字段、构造函数,以及派生类会继承的完整实现的方法。
声明抽象类
把类标记为 abstract,再把每个必须由派生类提供的成员也标记为 abstract,并用分号代替方法体结尾:
输出:
Circle: area 7.07, perimeter 9.42
Rectangle: area 10.00, perimeter 13.00
Describe 只在基类中写了一次,它调用了 Area 和 Perimeter,而这两个方法在 Shape 中根本不存在实现。这正是抽象成员的意义:基类可以依赖它们,而编译器保证每个具体的形状都提供了它们。忘了实现其中一个的派生类无法通过编译:
class Triangle : Shape
{
public Triangle() : base("Triangle") { }
public override double Area() => 6;
}
// error CS0534: 'Triangle' does not implement inherited abstract member 'Shape.Perimeter()'
修复方法是实现 Perimeter,或者如果 Triangle 是给更多类使用的中间基类,就把它本身也标记为 abstract。
抽象成员的规则
- 抽象成员没有方法体,并且隐式地是虚的:派生类用
override来实现它。 - 只有抽象类才能包含抽象成员。在普通类中放一个抽象成员是编译错误。
- 类中的抽象成员不能是
private(没人能重写它)或static。 - 方法、属性、索引器和事件都可以是抽象的。字段和构造函数不行。
- 抽象类不一定要包含抽象成员。只把一个类标记为
abstract,作用就是禁止实例化,适合那些只有通过子类才有意义的基类。
抽象属性声明了派生类必须提供哪些访问器:
abstract class Plan
{
public abstract decimal MonthlyPrice { get; }
public abstract string Name { get; }
}
class ProPlan : Plan
{
public override decimal MonthlyPrice => 12.99m;
public override string Name => "Pro";
}
抽象类中的构造函数
抽象类可以有构造函数。它们不能通过 new 调用,但每个派生类的构造函数都会通过 : base(...) 执行其中一个,所以共享状态就是在这里初始化和验证的。把它们声明为 protected,这正好表达了它的含义:只有派生类能使用它。
模板方法模式
编写抽象类最常见的理由,是在基类中固定一个算法的顺序,让派生类去填充各个步骤。执行这些步骤的基类方法不是虚方法,所以任何子类都不能调换或跳过这些步骤。
输出:
item,qty,price
Coffee beans,2,11.50
Filter papers,1,3.20
| Item | Qty | Price |
| --- | --- | --- |
| Coffee beans | 2 | 11.50 |
| Filter papers | 1 | 3.20 |
Total: 26.20
每一行是一个命名元组,输出是用 StringBuilder 构建的。注意这里的搭配:Header 和 Row 是 abstract,因为每种格式都必须决定它们;Footer 是带空默认实现的 virtual,因为大多数格式在那里什么都不需要。这些步骤都是 protected,所以外部代码只能调用 Export。框架中的类也采用同样的思路:Stream 和 TextWriter 都是抽象基类,它们众多的便利方法都建立在派生类提供的少数几个成员之上(流是 Read 和 Write,文本写入器是 Write(char))。
抽象类与接口
两者都能让你针对一个类型编写代码,而不需要知道具体的类。它们的区别在于能包含什么,以及一个类能拥有几个:
| 抽象类 | 接口 | |
|---|---|---|
| 一个类可以继承或实现 | 一个 | 多个 |
| 实例字段(状态) | 可以 | 不可以 |
| 构造函数 | 可以 | 不可以 |
| 带方法体的方法 | 可以 | 可以,从 C# 8 起(默认接口方法) |
| 静态成员 | 可以 | 可以,从 C# 8 起;静态抽象成员从 C# 11 起 |
| 成员上的访问修饰符 | 任意 | 默认为 public;从 C# 8 起允许其他修饰符 |
| 结构体能否使用 | 不能 | 能 |
| 之后新增成员 | 只要不是抽象成员就安全 | 会破坏实现者,除非它有默认方法体 |
默认接口方法(C# 8)缩小了两者的差距,但核心区别依然存在:
public interface ILogger
{
void Write(string message);
void Error(string message) => Write("ERROR: " + message); // default implementation: C# 8, .NET Core 3.0+
}
默认方法仍然无法访问实例字段,因为接口根本没有实例字段;它只能调用接口的其他成员。
一个实用的选择方法:
- 接口:当你描述的是互不相关的类型都可以具备的一种能力(
IComparable<T>、IDisposable、IShippingProvider),当结构体也应该能参与进来,或者当一个类需要同时扮演好几个这样的角色时。正因如此,大多数依赖注入代码都是针对接口编写的。 - 抽象类:当派生类型确实是同一种东西的不同变体,并共享状态或一个固定的算法时,比如上面的导出器。
- 两者兼用也很常见:一个接口供调用者依赖,再加一个实现该接口的抽象基类,让实现者不必重写共享的部分。接口那一页会详细讲解接口这一侧。
常见错误
- 试图实例化基类。
new Shape(...)会报 CS0144。请实例化派生类。 - 实现时忘了写
override。 在派生类中写public double Area()并不会实现抽象成员;编译器会因为缺少实现而报 CS0534,同时还会警告这个新方法隐藏了继承来的方法。 - 抽象类上的 public 构造函数。 无害,但有误导性。请用
protected。 - 没有共享代码的抽象类。 如果基类只有抽象成员而没有状态,用接口就能表达同样的意思,而且不会占用唯一的基类名额。
- 在基类构造函数中调用抽象成员。 派生类的实现会在派生类构造函数的方法体之前执行,所以那个方法体负责赋值的字段此时仍然是
0或null。
常见问题
C# 中的抽象类是什么?
标记为 abstract 的类只能用作基类:你不能用 new 创建它的实例(错误 CS0144)。它可以包含普通的字段、构造函数和方法,还可以包含没有方法体的 abstract 成员,每个非抽象的派生类都必须实现这些成员。
C# 中抽象类和接口有什么区别?
一个类只能派生自一个抽象类,却可以实现多个接口。抽象类可以保存状态(实例字段)、拥有构造函数和任意访问级别的成员,是放置共享实现的自然位置。接口描述的是一种能力,没有实例状态;从 C# 8 开始它可以包含默认方法体,但仍然不能有实例字段或构造函数。
C# 中抽象方法和虚方法有什么区别?
virtual 方法有方法体,派生类可以重写它。abstract 方法没有方法体,派生类必须重写它,否则派生类自己也是抽象的(对于具体类会报错 CS0534)。抽象方法只能出现在抽象类中。
C# 中抽象类可以有构造函数吗?
可以。它会在构造派生类时通过 : base(...) 执行,通常用来初始化共享字段。把它声明为 protected:抽象类上的 public 构造函数暗示调用者可以使用它,而实际上他们不能。
为什么不能创建抽象类的实例?
因为它可能有没有实现的抽象成员:调用 new Shape().Area() 将没有任何代码可以执行。编译器会报告 CS0144(“无法创建抽象类型或接口的实例”)。请改为创建一个具体派生类的实例,如果愿意,可以把它保存在抽象类型的变量中:Shape s = new Circle(2);。