函数和类

163 阅读8分钟

函数

函数类型签名

如果说变量的类型是描述了这个变量的值类型,那么函数的类型就是描述了函数入参类型与函数返回值类型

function foo(name: string): number {
  return name.length;
}

在函数类型中同样存在着类型推导。比如在这个例子中,你可以不写返回值处的类型,它也能被正确推导为 number 类型

函数表达式

可以像对变量进行类型标注那样,foo 这个变量进行类型声明

const foo: (name: string) => number = function (name) {
  return name.length
}

这里的 (name: string) => number 其实是 TypeScript 中的函数类型签名

要么直接在函数中进行参数和返回值的类型声明,要么使用类型别名将函数声明抽离出来

type FuncFoo = (name: string) => number

const foo: FuncFoo = (name) => {
  return name.length
}

如果只是为了描述这个函数的类型结构,我们甚至可以使用 interface 来进行函数声明

interface FuncFooStruct {
  (name: string): number
}

这时的 interface 被称为可调用接口( Callable Interface),看起来可能很奇怪,但我们可以这么认为,interface 就是用来描述一个类型结构的,而函数类型本质上也是一个结构固定的类型罢了。

void 类型

在 TypeScript 中,一个没有返回值(即没有调用 return 语句)的函数,其返回类型应当被标记为 void 而不是 undefined,即使它实际的值是 undefined。

// 没有调用 return 语句
function foo(): void { }

// 调用了 return 语句,但没有返回值
function bar(): void {
  return;
}

在 TypeScript 中,undefined 类型是一个实际的、有意义的类型值,而 void 才代表着空的、没有意义的类型值。 相比之下,void 类型就像是 JavaScript 中的 null 一样。因此在我们没有实际返回值时,使用 void 类型能更好地说明这个函数没有进行返回操作。但在上面的第二个例子中,其实更好的方式是使用 undefined

// 此时我们想表达的则是,这个函数进行了返回操作,但没有返回实际的值
function bar(): undefined {
  return;
}

可选参数与 rest 参数

在函数类型中我们也使用 ? 描述一个可选参数或者不传入参数时函数会使用此参数的默认值

// 在函数逻辑中注入可选参数默认值
function foo1(name: string, age?: number): number {
  const inputAge = age || 18; // 或使用 age ?? 18
  return name.length + inputAge
}

// 直接为可选参数声明默认值
function foo2(name: string, age: number = 18): number {
  const inputAge = age;
  return name.length + inputAge
}

可选参数必须位于必选参数之后

对于 rest 参数的类型标注也比较简单,由于其实际上是一个数组,这里我们也应当使用数组类型进行标注

function foo(arg1: string, ...rest: any[]) { }

当然,你也可以使用我们前面学习的元祖类型进行标注:

function foo(arg1: string, ...rest: [number, boolean]) { }

foo("foo", 18, true)

函数重载

在某些逻辑较复杂的情况下,函数可能有多组入参类型和返回值类型

function func(foo: number, bar?: boolean): string | number {
  if (bar) {
    return String(foo);
  } else {
    return foo * 599;
  }
}

在这个实例中,函数的返回类型基于其入参 bar 的值,并且从其内部逻辑中我们知道,当 bar 为 true,返回值为 string 类型,否则为 number 类型。而这里的类型签名完全没有体现这一点,我们只知道它的返回值是这么个联合类型

要想实现与入参关联的返回值类型,我们可以使用 TypeScript 提供的函数重载签名(Overload Signature),将以上的例子使用重载改写:

function func(foo: number, bar: true): string;//重载签名一,传入 bar 的值为 true 时,函数返回值为 string 类型。
function func(foo: number, bar?: false): number;//重载签名二,不传入 bar,或传入 bar 的值为 false 时,函数返回值为 number 类型。
function func(foo: number, bar?: boolean): string | number {//函数的实现签名,会包含重载签名的所有可能情况。
  if (bar) {
    return String(foo);
  } else {
    return foo * 599;
  }
}

const res1 = func(599); // number
const res2 = func(599, true); // string
const res3 = func(599, false); // number

基于重载签名,我们就实现了将入参类型和返回值类型的可能情况进行关联,获得了更精确的类型标注能力。

拥有多个重载声明的函数在被调用时,是按照重载的声明顺序往下查找的。因此在第一个重载声明中,为了与逻辑中保持一致,即在 bar 为 true 时返回 string 类型,这里我们需要将第一个重载声明的 bar 声明为必选的字面量类型。

TypeScript 中的重载更像是伪重载,它只有一个具体实现,其重载体现在方法调用的签名上而非具体实现上。而在如 C++ 等语言中,重载体现在多个名称一致但入参不同的函数实现上,这才是更广义上的函数重载。

异步函数、Generator 函数等类型签名

对于异步函数、Generator 函数、异步 Generator 函数的类型签名,其参数签名基本一致,而返回值类型则稍微有些区别

async function asyncFunc(): Promise<void> {}

function* genFunc(): Iterable<void> {}

async function* asyncGenFunc(): AsyncIterable<void> {}

主要结构只有构造函数、属性、方法和访问符(Accessor),我们也只需要关注这三个部分即可

属性的类型标注类似于变量,而构造函数、方法、存取器的类型编标注类似于函数

class Foo {
  prop: string;

  constructor(inputProp: string) {
    this.prop = inputProp;
  }

  print(addon: string): void {
    console.log(`${this.prop} and ${addon}`)
  }

  get propA(): string {
    return `${this.prop}+A`;
  }

  set propA(value: string) {
    this.prop = `${value}+A`
  }
}

唯一需要注意的是,setter 方法不允许进行返回值的类型标注, 可以理解为 setter 的返回值并不会被消费, 它是一个只关注过程的函数。类的方法同样可以进行函数那样的重载,且语法基本一致

修饰符

在 TypeScript 中我们能够为 Class 成员添加这些修饰符:public(公共,默认) / private(私有) / protected(被保护) / readonly(只读)。除 readonly 以外,其他三位都属于访问性修饰符,而 readonly 属于操作性修饰符(就和 interface 中的 readonly 意义一致)

  • public:此类成员在类、类的实例、子类中都能被访问。
  • private:此类成员仅能在类的内部被访问。
  • protected:此类成员仅能在类与子类中被访问,你可以将类和类的实例当成两种概念,即一旦实例化完毕(出厂零件),那就和类(工厂)没关系了,即不允许再访问受保护的成员。

可以在构造函数中对参数应用访问性修饰符

class Foo {
  constructor(public arg1: string, private arg2: boolean) { } //此时,参数会被直接作为类的成员(即实例的属性),免去后续的手动赋值。
}

new Foo("foo", true)

static静态成员 (关键字)

类的内部静态成员无法通过 this 来访问,需要通过 类名.静态成员名 这种形式进行访问

//ES5 
var Foo = /** @class */ (function () {
    function Foo() {
    }
    Foo.staticHandler = function () { };
    Foo.prototype.instanceHandler = function () { };
    return Foo;
}())

静态成员直接被挂载在函数体上,而实例成员挂载在原型上,这就是二者的最重要差异:静态成员不会被实例继承,它始终只属于当前定义的这个类(以及其子类)。而原型对象上的实例成员则会沿着原型链进行传递,也就是能够被继承

继承、实现、抽象类

TypeScript 中也使用 extends 关键字来实现继承

class Base { }

class Derived extends Base { }

对于这里的两个类,比较严谨的称呼是 基类(Base) 与 派生类(Derived)

基类中的哪些成员能够被派生类访问,完全是由其访问性修饰符决定的

除了访问以外,基类中的方法也可以在派生类中被覆盖,但我们仍然可以通过 super 访问到基类中的方法

class Base {
  print() { }
}

class Derived extends Base {
  print() {
    super.print()
    // ...
  }
}

override

override 关键字,来确保派生类尝试覆盖的方法一定在基类中存在定义

class Base {
  printWithLove() { }
}

class Derived extends Base {
  override print() {
    // ...
  }
}

抽象类

抽象类是对类结构与方法的抽象,简单来说,一个抽象类描述了一个类中应当有哪些成员(属性、方法等),一个抽象方法描述了这一方法在实际实现中的结构。我们知道类的方法和函数非常相似,包括结构,因此抽象方法其实描述的就是这个方法的入参类型与返回值类型,用 abstract 关键字声明

abstract class AbsFoo {
  abstract absProp: string;
  abstract get absGetter(): string;
  abstract absMethod(name: string): string
}

注意,抽象类中的成员也需要使用 abstract 关键字才能被视为抽象类成员

interface和abstract

抽象类是只为了类服务的,并且在运行时也会存在,而接口更多是服务对象类型的结构描述,并且在运行时就被擦除了

SOLID 原则

SOLID 原则是面向对象编程中的基本原则,它包括以下这些五项基本原则。
  • S,单一功能原则,一个类应该仅具有一种职责
  • O,开放封闭原则,一个类应该是可扩展但不可修改的
  • L,里式替换原则,一个派生类可以在程序的任何一处对其基类进行替换
  • I,接口分离原则,类的实现方应当只需要实现自己需要的那部分接口
  • D,依赖倒置原则,核心思想即是对功能的实现应该依赖于抽象层,即不同的逻辑通过实现不同的抽象类