Typescript中的抽象类和接口
一、理解抽象类
在 TypeScript 中,抽象类是一种不能被实例化的类,它主要为其他类提供一个通用的基类。抽象类可以包含抽象方法和具体方法。抽象方法是没有实现体的方法,必须在子类中被重写。
1.1 抽象类的定义
使用 abstract
关键字来定义抽象类。例如:
abstract class Animal {
// 具体属性
name: string;
// 构造函数
constructor(name: string) {
this.name = name;
}
// 具体方法
eat() {
console.log(`${this.name} is eating.`);
}
// 抽象方法,没有方法体
abstract makeSound(): void;
}
在上述代码中,Animal
是一个抽象类,它有一个具体属性 name
,一个具体方法 eat
和一个抽象方法 makeSound
。
1.2 子类继承抽象类
当一个类继承抽象类时,必须实现抽象类中的所有抽象方法。例如:
class Dog extends Animal {
constructor(name: string) {
super(name);
}
makeSound() {
console.log(`${this.name} barks.`);
}
}
class Cat extends Animal {
constructor(name: string) {
super(name);
}
makeSound() {
console.log(`${this.name} meows.`);
}
}
这里 Dog
和 Cat
类继承自 Animal
抽象类,并实现了 makeSound
抽象方法。这样我们就可以创建 Dog
和 Cat
的实例:
let dog = new Dog('Buddy');
dog.eat();
dog.makeSound();
let cat = new Cat('Whiskers');
cat.eat();
cat.makeSound();
运行这段代码,我们会看到相应的输出,Buddy is eating.
,Buddy barks.
,Whiskers is eating.
和 Whiskers meows.
。
1.3 抽象类的作用
抽象类的主要作用是为一组相关的类提供一个通用的接口和实现。它定义了一些公共的属性和方法,子类可以继承并扩展这些功能。同时,通过抽象方法,强制子类实现特定的行为,保证了一定的一致性。
比如在一个图形绘制的应用中,我们可以定义一个抽象的 Shape
类:
abstract class Shape {
abstract calculateArea(): number;
abstract draw(): void;
}
class Circle extends Shape {
radius: number;
constructor(radius: number) {
super();
this.radius = radius;
}
calculateArea() {
return Math.PI * this.radius * this.radius;
}
draw() {
console.log(`Drawing a circle with radius ${this.radius}`);
}
}
class Rectangle extends Shape {
width: number;
height: number;
constructor(width: number, height: number) {
super();
this.width = width;
this.height = height;
}
calculateArea() {
return this.width * this.height;
}
draw() {
console.log(`Drawing a rectangle with width ${this.width} and height ${this.height}`);
}
}
在这个例子中,Shape
抽象类定义了 calculateArea
和 draw
抽象方法。Circle
和 Rectangle
子类分别实现了这些方法,以提供特定的行为。这样,我们可以将不同形状的对象统一管理,例如在一个数组中存储不同形状的对象,并调用它们的公共方法:
let shapes: Shape[] = [];
shapes.push(new Circle(5));
shapes.push(new Rectangle(4, 6));
for (let shape of shapes) {
console.log(`Area: ${shape.calculateArea()}`);
shape.draw();
}
这段代码会输出圆和矩形的面积以及绘制信息,通过抽象类实现了代码的复用和多态性。
二、认识接口
接口在 TypeScript 中用于定义对象的形状(shape),它描述了对象应该具有的属性和方法,但不包含具体的实现。
2.1 接口的定义
使用 interface
关键字来定义接口。例如:
interface Person {
name: string;
age: number;
greet(): void;
}
上述代码定义了一个 Person
接口,它要求实现该接口的对象必须有一个 name
属性(类型为 string
),一个 age
属性(类型为 number
)以及一个 greet
方法(无返回值)。
2.2 实现接口
一个类可以通过 implements
关键字来实现接口。例如:
class Employee implements Person {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
greet() {
console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
}
}
这里 Employee
类实现了 Person
接口,提供了接口中要求的属性和方法的具体实现。
2.3 接口的特性
接口具有以下一些特性:
- 可选属性:接口中的属性可以是可选的。例如:
interface Car {
brand: string;
model?: string;
year: number;
}
在这个 Car
接口中,model
属性是可选的。一个对象只要满足 brand
和 year
属性的要求,并且可以有 model
属性,就符合这个接口。
let myCar: Car = {
brand: 'Toyota',
year: 2020
};
- 只读属性:可以将接口中的属性定义为只读。例如:
interface Point {
readonly x: number;
readonly y: number;
}
在 Point
接口中,x
和 y
属性是只读的,一旦对象创建,这些属性的值就不能被修改。
let point: Point = {
x: 10,
y: 20
};
// point.x = 15; // 这会导致编译错误
- 函数类型接口:接口还可以定义函数的形状。例如:
interface AddFunction {
(a: number, b: number): number;
}
这个 AddFunction
接口定义了一个函数,它接受两个 number
类型的参数,并返回一个 number
类型的值。可以这样使用:
let add: AddFunction = function(a: number, b: number): number {
return a + b;
};
- 可索引接口:可以定义对象的索引类型。例如:
interface StringDictionary {
[key: string]: string;
}
StringDictionary
接口表示一个对象,它的所有键都是 string
类型,值也是 string
类型。
let dict: StringDictionary = {};
dict['name'] = 'John';
dict['city'] = 'New York';
三、抽象类与接口的比较
虽然抽象类和接口都用于定义某种规范,但它们在很多方面存在差异。
3.1 定义和实现方式
- 抽象类:抽象类使用
abstract
关键字定义,可以包含具体的属性、方法和抽象方法。子类通过extends
关键字继承抽象类,并实现抽象方法。 - 接口:接口使用
interface
关键字定义,只包含属性和方法的签名,不包含具体实现。类通过implements
关键字实现接口,提供接口中定义的属性和方法的具体实现。
3.2 实例化
- 抽象类:不能直接实例化,只能通过子类来创建实例。例如,不能
new Animal()
,但可以new Dog()
。 - 接口:接口本身更像是一种类型定义,不能被实例化。它用于约束对象或类的形状。
3.3 继承和实现关系
- 抽象类:一个类只能继承一个抽象类,因为类继承是单继承的。例如,
class Dog extends Animal
,Dog
不能同时继承另一个抽象类。 - 接口:一个类可以实现多个接口,用逗号分隔。例如:
interface Flyable {
fly(): void;
}
interface Swimmable {
swim(): void;
}
class Duck implements Flyable, Swimmable {
fly() {
console.log('Duck is flying.');
}
swim() {
console.log('Duck is swimming.');
}
}
这里 Duck
类同时实现了 Flyable
和 Swimmable
接口。
3.4 成员特性
- 抽象类:抽象类可以包含具体的属性和方法,这些属性和方法可以被子类直接继承和使用。抽象类中的抽象方法强制子类重写。例如,
Animal
抽象类中的eat
方法是具体方法,Dog
和Cat
子类可以直接使用。 - 接口:接口只定义属性和方法的签名,不包含具体实现。接口中的所有属性和方法都是抽象的,实现接口的类必须提供所有属性和方法的具体实现。
3.5 使用场景
- 抽象类:当需要定义一组相关类的通用行为和状态,并且希望有一些具体的实现可以被子类复用,同时有一些行为需要子类根据自身特点去实现时,适合使用抽象类。比如前面提到的
Shape
抽象类,它定义了图形的通用行为calculateArea
和draw
,同时具体的图形类Circle
和Rectangle
可以复用一些基础的结构。 - 接口:当需要定义对象的形状,或者希望不同类之间有统一的外部接口时,适合使用接口。例如,不同的类如
Employee
、Customer
等可能都需要实现Person
接口,以提供统一的name
、age
和greet
行为。
四、深入探讨抽象类和接口的细节
4.1 抽象类中的构造函数
抽象类可以有构造函数,并且在子类继承抽象类时,子类的构造函数必须调用 super
来调用抽象类的构造函数。这是为了确保抽象类中的属性能够正确初始化。例如:
abstract class Base {
value: number;
constructor(value: number) {
this.value = value;
}
abstract print(): void;
}
class Derived extends Base {
constructor(value: number) {
super(value);
}
print() {
console.log(`The value is ${this.value}`);
}
}
在这个例子中,Base
抽象类有一个构造函数来初始化 value
属性。Derived
子类在构造函数中通过 super(value)
调用了 Base
抽象类的构造函数,以确保 value
属性被正确初始化。
4.2 接口的合并
TypeScript 允许同名接口进行合并。当有多个同名接口定义时,它们的成员会被合并到同一个接口中。例如:
interface User {
name: string;
}
interface User {
age: number;
}
let user: User = {
name: 'Alice',
age: 30
};
这里两个 User
接口合并后,User
接口就同时有了 name
和 age
属性。这种特性在模块化开发中很有用,不同的模块可以为同一个接口添加不同的成员。
4.3 抽象类与接口的嵌套
在 TypeScript 中,抽象类和接口都可以进行嵌套定义。例如,在抽象类中定义接口:
abstract class Outer {
interface Inner {
message: string;
}
abstract getInner(): Inner;
}
class Implementation extends Outer {
getInner(): Outer.Inner {
return {
message: 'Hello from inner'
};
}
}
在这个例子中,Outer
抽象类中定义了 Inner
接口,Implementation
子类实现了 getInner
方法并返回符合 Inner
接口的对象。
同样,接口中也可以嵌套抽象类:
interface Container {
abstract class Inner {
abstract print(): void;
}
getInner(): Inner;
}
class MyContainer implements Container {
class Inner implements Container.Inner {
print() {
console.log('Printing from inner class');
}
}
getInner(): Container.Inner {
return new this.Inner();
}
}
这里 Container
接口中定义了抽象类 Inner
,MyContainer
类实现了 Container
接口,并在内部定义了具体的 Inner
类来实现 Container.Inner
抽象类的要求。
4.4 抽象类和接口与泛型的结合
泛型可以与抽象类和接口很好地结合,以提供更灵活和可复用的代码。例如,定义一个泛型抽象类:
abstract class GenericStorage<T> {
data: T[] = [];
add(item: T) {
this.data.push(item);
}
abstract get(index: number): T;
}
class NumberStorage extends GenericStorage<number> {
get(index: number): number {
return this.data[index];
}
}
class StringStorage extends GenericStorage<string> {
get(index: number): string {
return this.data[index];
}
}
在这个例子中,GenericStorage
是一个泛型抽象类,T
是类型参数。NumberStorage
和 StringStorage
子类分别指定了 T
为 number
和 string
,并实现了 get
抽象方法。
对于接口,也可以使用泛型。例如:
interface GenericMapper<T, U> {
map(item: T): U;
}
class NumberToStringMapper implements GenericMapper<number, string> {
map(item: number): string {
return item.toString();
}
}
这里 GenericMapper
是一个泛型接口,NumberToStringMapper
类实现了该接口,将 number
类型映射为 string
类型。
五、实际应用案例
5.1 图形绘制库中的应用
假设我们正在开发一个简单的图形绘制库。我们可以使用抽象类和接口来构建这个库的架构。
首先,定义一个抽象类 Graphic
作为所有图形的基类:
abstract class Graphic {
abstract draw(ctx: CanvasRenderingContext2D): void;
}
然后,定义一些具体的图形类继承自 Graphic
抽象类,比如 Rectangle
和 Circle
:
class Rectangle extends Graphic {
x: number;
y: number;
width: number;
height: number;
constructor(x: number, y: number, width: number, height: number) {
super();
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
draw(ctx: CanvasRenderingContext2D) {
ctx.fillRect(this.x, this.y, this.width, this.height);
}
}
class Circle extends Graphic {
x: number;
y: number;
radius: number;
constructor(x: number, y: number, radius: number) {
super();
this.x = x;
this.y = y;
this.radius = radius;
}
draw(ctx: CanvasRenderingContext2D) {
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI);
ctx.fill();
}
}
同时,我们可以定义一个接口 Groupable
,表示图形可以被分组:
interface Groupable {
groupName: string;
}
有些图形类可能实现这个接口,比如 GroupedRectangle
:
class GroupedRectangle extends Rectangle implements Groupable {
groupName: string;
constructor(x: number, y: number, width: number, height: number, groupName: string) {
super(x, y, width, height);
this.groupName = groupName;
}
}
这样,在绘制图形时,我们可以统一处理所有 Graphic
类型的对象,并且对于实现了 Groupable
接口的图形,可以进行分组相关的操作。
5.2 数据访问层中的应用
在一个数据访问层(DAL)的开发中,我们可以使用抽象类和接口来规范数据访问的操作。
定义一个抽象类 BaseRepository
,它包含一些通用的数据访问方法:
abstract class BaseRepository<T> {
abstract findAll(): Promise<T[]>;
abstract findById(id: string): Promise<T | null>;
abstract create(entity: T): Promise<T>;
abstract update(id: string, entity: T): Promise<T | null>;
abstract delete(id: string): Promise<boolean>;
}
然后,针对不同的实体,定义具体的仓库类。比如,对于用户实体:
class User {
id: string;
name: string;
email: string;
}
class UserRepository extends BaseRepository<User> {
async findAll(): Promise<User[]> {
// 实际实现中从数据库查询所有用户
return [];
}
async findById(id: string): Promise<User | null> {
// 实际实现中从数据库根据ID查询用户
return null;
}
async create(entity: User): Promise<User> {
// 实际实现中向数据库插入用户
return entity;
}
async update(id: string, entity: User): Promise<User | null> {
// 实际实现中更新数据库中的用户
return null;
}
async delete(id: string): Promise<boolean> {
// 实际实现中从数据库删除用户
return true;
}
}
同时,我们可以定义一个接口 Transactionable
,表示支持事务操作:
interface Transactionable {
startTransaction(): Promise<void>;
commitTransaction(): Promise<void>;
rollbackTransaction(): Promise<void>;
}
如果某些仓库需要支持事务,就可以实现这个接口。例如:
class TransactionalUserRepository extends UserRepository implements Transactionable {
async startTransaction(): Promise<void> {
// 实际实现中启动数据库事务
}
async commitTransaction(): Promise<void> {
// 实际实现中提交数据库事务
}
async rollbackTransaction(): Promise<void> {
// 实际实现中回滚数据库事务
}
}
通过这样的方式,我们可以清晰地规范数据访问的行为,并且可以根据需要灵活地扩展功能。
六、总结抽象类和接口的要点
- 抽象类:
- 用
abstract
关键字定义,不能直接实例化。 - 可以包含具体属性、方法和抽象方法。
- 子类通过
extends
继承抽象类,并实现抽象方法。 - 主要用于定义一组相关类的通用行为和状态,实现代码复用和多态。
- 用
- 接口:
- 用
interface
关键字定义,不能实例化,用于定义对象的形状。 - 只包含属性和方法的签名,不包含具体实现。
- 类通过
implements
实现接口,提供具体实现。 - 支持可选属性、只读属性、函数类型接口和可索引接口等特性。
- 主要用于定义不同类之间的统一外部接口,实现代码的灵活性和可扩展性。
- 用
- 两者比较:
- 抽象类是单继承,接口可以多实现。
- 抽象类有具体实现部分,接口完全是抽象的。
- 根据不同的场景选择使用抽象类或接口,抽象类适合有共同实现的情况,接口适合定义外部形状和规范。
在实际的 TypeScript 项目开发中,深入理解并合理运用抽象类和接口,能够构建出更加健壮、可维护和可扩展的代码结构。无论是大型的企业级应用还是小型的工具库开发,它们都是非常重要的概念。通过不断地实践和思考,开发者可以更好地发挥 TypeScript 的优势,提高代码的质量和开发效率。同时,结合泛型、嵌套等特性,可以进一步提升代码的灵活性和复用性,满足各种复杂的业务需求。在设计架构时,需要根据具体的功能需求和代码组织方式,谨慎选择使用抽象类还是接口,或者两者结合使用,以达到最佳的设计效果。例如,在一些需要继承公共行为并进行扩展的场景下,抽象类可能是首选;而在需要定义不同对象的统一接口,或者实现多态行为但不需要继承公共实现的情况下,接口则更为合适。通过合理运用这些概念,我们可以构建出更加清晰、高效且易于维护的 TypeScript 项目。