类的继承

继承是面向对象编程的特性之一。在 TypeScript 中,类和类之间可以继承,被继承的类称为父类,继承它的类称为子类,子类将继承父类中的所有成员,并可以定义自己独有的成员。

通过继承,能够提高代码的可复用性,并且使代码更易于组织。本节将详细介绍类的继承。

简单的继承

类使用 extends 关键字来继承另一个类,语法如下。

class 类名 extends 被继承的类名 {
    //该类的各个成员
    ...
}

继承的示例代码如下。

class Animal {
    _name: string;
    constructor(name: string) {
        this._name = name;
    };
    get name() {
        return this._name;
    }
    moveTo(localtion: string) {
        console.log(`Walking to ${localtion}.`);
    }
}

class Cat extends Animal {
    mewing(times: number) {

        for (let i = 0; i < times; i++) {
            console.log("meow!");
        }
    }
}

以上代码声明了一个 Animal 类,作为父类,它拥有一个 _name 属性、一个构造函数、一个只读的 name 存取器,以及一个 moveTo() 方法。Cat 类继承了 Animal 类,因此拥有父类的所有成员,同时它还定义了新的成员——mewing() 方法。

由于 Cat 类没有指定构造函数,因此它将默认继承父类的构造函数,在实例化 Cat 类的对象时也需要传入 name 参数。实例化后既可以使用从父类继承下来的各个成员(name 存取器和 moveTo() 方法),也可以调用自己的成员(mewing() 方法),示例代码如下。

let cat1 = new Cat("kiddy");
console.log(cat1.name); //输出kiddy
cat1.moveTo("desk");    //输出Walking to desk.
cat1.mewing(3);         //输出"meow!""meow!""meow!"

注意,类只支持从单个类继承,这意味着一个类不能同时继承两个及以上的类,否则会引起编译错误,示例代码如下。

class A { }
class B { }
//编译错误:类只能扩展一个类。ts(1174)
class C extends A, B { }

虽然 TypeScript 不支持同时继承多个类,但允许依次继承各个类,例如,以下代码可以正常执行,不会引起编译错误。

class A { }
class B extends A { }
class C extends B { }

重写父类成员

虽然子类继承了父类的成员,但在某些情况下,子类可能需要修改或扩展父类的某些成员,以满足子类的特定需求,这就需要重写父类的成员。

重写父类成员的方式很简单,只需要在子类中定义一个与父类中的成员同名的成员即可,之后在实例化子类时,使用该成员,使用子类中的代码。

  1. 重写方法和存取器

    要重写方法或存取器,只需要在子类中定义同名方法或存取器即可,且重写后的成员的参数和返回值必须和父类一致。例如,在以下代码中,父类 Animal 拥有只读存取器 namemoveTo() 方法,Bird 类继承了 Animal 类,重写了 name 存取器(在返回 _name 属性时增加了 bird 后缀)和 moveTo() 方法(将输出语句中的单词 Walking 改为 Flying),并利用 set 关键字使 name 存取器支持写入。

    class Animal {
        _name: string;
        constructor(name: string) {
            this._name = name;
        };
        get name() {
            return this._name;
        }
        moveTo(localtion: string) {
            console.log(`Walking to ${localtion}.`);
        }
    }
    
    class Bird extends Animal {
        get name() {
            return this._name + " bird";
        }
        set name(value: string) {
            this._name = value;
        }
        moveTo(localtion: string) {
            console.log(`Flying to ${localtion}.`);
        }
    }

    重写方法和存取器后,实例化子类的对象,再调用 name 存取器和 moveTo() 方法,会执行子类中定义的代码逻辑,示例代码如下。

    let bird1: Bird = new Bird("Polly"); //输出"Polly bird"
    console.log(bird1.name);
    bird1.name = "Starling"
    bird1.moveTo("a tree");              //输出"Flying to a tree."
  2. 重写构造函数

    TypeScript 中还支持重写构造函数。

    重写构造函数与重写方法和存取器有以下两点区别。

    • 构造函数的参数数量及类型不需要和父类中的保持一致,可以重新定义。

    • 重写后的构造函数必须使用 “super(参数列表)” 的形式,调用一次父类的构造函数。

    例如,以下代码定义了一个 User 类,它拥有一个 name 属性及一个带 name 参数的构造函数,Administrator 类继承了 User 类,并新增了一个成员 authority,同时子类中重写了构造函数,需要分别传入 nameauthority 两个参数。在执行构造函数代码时,会先以 “super(参数列表)” 的形式传入 name 参数来调用父类构造函数,然后再为 authority 属性赋值。

    class User {
        name: string;
        constructor(name: string) {
            this.name = name;
        };
    }
    class Administrator extends User {
        authority: string;
        constructor(name: string, authority: string) {
            super(name);
            this.authority = authority;
        };
    }

    之后在实例化 Administrator 类的对象时,需要用重写后的构造函数实例化新对象。示例代码如下。

    let admin1: Administrator = new Administrator("Duke", "Admin");
    console.log(admin1.name);      //输出"Duke"
    console.log(admin1.authority); //输出"Admin"

    如果要重写构造函数,则必须以 “super(参数列表)” 的形式调用父类构造函数;否则,会引起编译错误。示例代码如下。

    class Administrator extends User {
        authority: string;
        //编译错误:派生类的构造函数必须包含 "super" 调用。ts(2377)
        constructor(name: string, authority: string) {
            this.authority = authority;
        };
    }

    在通过 this 关键字使用任何成员之前,你必须先以 “super(参数列表)” 的形式调用父类构造函数,否则会引起编译错误。示例代码如下。

    class Administrator extends User {
        authority: string;
        constructor(name: string, authority: string) {
            //这句代码未使用this关键字,因此不会引起编译错误
            name = `${name} : ${authority}`;
            //以下代码将引起编译错误
            //编译错误:访问派生类的构造函数中的 "this" 前,要先调用 "super"。ts(17009)
            this.authority = authority;
            super(name);
        };
    }

    即使父类没有显式定义构造函数,TypeScript 也将默认它有一个无参数构造函数,因此继承该类后,子类依然要使用 “super()” 的形式调用父类构造函数,否则会引起编译错误。示例代码如下。

    class baseClass { }
    
    class ChildClass extends baseClass {
        constructor() {
            super();
        }
    }
  3. 重写成员的兼容性

    如果重写成员后,子类和基类拥有名称相同但类型不同的成员,就可能造成冲突,引起编译错误。为保证代码的可读性和可维护性,所有的冲突都应尽可能在编码时避免。

    • 属性冲突。

    如果子类和父类拥有同名但类型不同的属性,在声明时就会直接引起编译错误。例如,在以下代码中,Animal 类拥有一个 string 类型的 name 属性,WhiteMouse 类继承了 Animal 类,但它将 name 属性重写为 number 类型,因此将引起编译错误。

    class Animal {
        name: string;
    }
    
    //类型"WhiteMouse"中的属性"name"不可以分配给基类型"Animal"中的同一属性
    //编译错误:不能将类型"number"分配给类型"string"。ts(2416)
    class WhiteMouse extends Animal {
        name: number;
    }

    注意,如果父类的属性兼容子类的属性,则不会引起编译错误。例如,在以下代码中,Animal 类拥有一个 string | number 类型的 name 属性,WhiteMouse 类继承了 Animal 类,并将 name 属性重写为 number 类型,由于父类的 name 属性能够兼容 number 类型,因此不会引起编译错误。

    class Animal {
        name: string | number;
    }
    
    class WhiteMouse extends Animal {
        name: number;
    }
    • 方法冲突。

如果子类和父类拥有同名方法,但它们的参数个数、参数类型、返回值中有不匹配项,就会引起编译错误。例如,在以下代码中,Animal 类拥有一个传入 string 类型 food 参数的 eat() 方法,Tiger 类继承了 Animal 类,但它重写了 eat() 方法,传入参数变成了 Animal 类型,因此将引起编译错误。

class Animal {
    name: string;
    eat(food: string) { console.log(`Eating ${food}`) }
}

//编译错误:类型"Tiger"中的属性"eat"不可分配给基类型"Animal"中的同一属性
//不能将类型"(food: Animal) => void"分配给类型"(food: string) => //void"
//不能将类型"string"分配给类型"Animal"。ts(2416)
class Tiger extends Animal {
    eat(food: Animal) { console.log(`Eating ${food.name}`) }
}

注意,如果子类的方法兼容父类的方法(和属性的兼容顺序正好相反),则不会引起编译错误。例如,在以下代码中,Tiger 类继承了 Animal 类的接口,但它将 eat() 方法的参数类型重写成 Animal | string 类型,由于子类的方法兼容父类的方法,因此不会引起编译错误。

class Animal {
    name: string;
    eat(food: string) { console.log(`Eating ${food}`); }
}

class Tiger extends Animal {
    eat(food: Animal | string) {
        console.log('Eating
        ${(typeof food == "string") ? food : food.name}`);
    }
}

复用父类成员

虽然在子类中可以重写父类的存取器或方法,但是这并不意味着父类的存取器或方法就此消失,而表示在类的外部调用同名存取器或方法时,将使用在子类中重写后的存取器或方法。但在类的内部,你依然可以通过 “super.存取器名称” 和 “super.方法名称(参数列表)” 的形式调用重写的父类成员。在实例化子类对象后,子类的成员和父类的存取器或方法实际上也拥有各自独立的内存空间,它们只被隐藏,并没有真正被覆盖。

有时,在重写某个存取器或方法时,并不需要完全改写它们的逻辑,而在原来已有的逻辑上扩展一部分新的逻辑,因此使用 super 关键字来引用父类存取器或方法。例如,以下代码声明了一个 Animal 类,它将被 Bird 类继承,并且 name 存取器和 moveTo() 方法将被 Bird 类重写。在重写的成员中,会根据 canFly 属性是否为 true 执行不同的代码分支。如果 canFlytrue,则运行新的代码逻辑;如果 canFlyfalse,则会通过 “super.存取器名称” 和 “super.方法名称(参数列表)” 的形式调用重写前的父类成员的代码逻辑。

class Animal {
    _name: string;
    constructor(name:string) {
        this._name=name;
    };
    get name() {
        return this._name;
    }
    moveTo(localtion: string) {
        console.log('Walking to ${localtion}.');
    }
}

class Bird extends Animal {
    canFly: boolean = false;
    get name() {
        if (this.canFly) {
            return this._name + " bird";
        }
        else {
            return super.name;
        }
    }
    moveTo(localtion: string) {
        if (this.canFly) {
            console.log(`Flying to ${localtion}.`);
        }
        else {
            super.moveTo(localtion);
        }
    }
}

之后再实例化两个 Bird 类的实例对象,一个将 canFly 设置为 false,另一个将 canFly 设置为 true,然后调用 name 存取器和 moveTo() 方法,可以发现它们分别执行了不同的代码逻辑。

let bird1: Bird = new Bird("penguin");
bird1.canFly = false;
console.log(bird1.name);   //输出"penguin animal"
bird1.moveTo("a iceberg"); //输出"Walking to a iceberg."

let bird2: Bird = new Bird("swallow");
bird2.canFly=true;
console.log(bird2.name);   //输出"swallow bird"
bird2.moveTo("a tree");    //输出"Flying to a tree."