为什么在许多编程语言中整数和浮点数是两种类型?结构体、数组、列表、映射……这些类型有什么关系?用户自定义的各种类型与它们又有什么关系?函数也是类型吗?强类型和弱类型意味着什么?它们的区别和类型转换有关吗?静态类型语言中的变量为什么有固定类型而动态类型则没有?多态性就是后期绑定吗?鸭子类型是怎么回事?为什么要采用它?
假如您对以上问题感兴趣,阅读完本章时就会有肯定的答案。有了这些理论基础,理解JavaScript的类型系统就变得很轻松,并且能更清晰地掌握其特点。
2.1 类型是什么
2.2 常用的数据类型
2.2.1 整数
2.2.2 浮点数
2.2.3 布尔值
2.2.4 字符
2.2.5 元组、结构体、类
2.2.6 函数
2.2.7 数组、字符串、队列、堆栈、列表
2.2.8 结构体、映射
2.2.9 深入复合类型
2.3 强类型与弱类型
2.4 名义类型和结构类型
2.5 静态类型与动态类型
2.5.1 静态类型
2.5.2 动态类型
我们将对若干变量的一系列运算抽象成一个函数,这些变量就成为函数的参数。函数对参数有类型要求,只有符合此要求的值才能被传递给该函数。然而,一个函数名所代表的抽象运算未必是特定于该函数参数的类型的。换言之,有可能对不同类型的值有共同的抽象需求。例如,加法运算对整数和浮点数类型都适用,还可以扩展到字符串、集合等类型。对这些概念上相同的运算,在代码中用同一个名称来代表是可欲的。要实现这一点,有三种不同的做法。
第一种是1.1节中介绍的重载,也就是一个名称同时绑定多个参数类型或数目不同的函数。调用函数时,语言要在同名的多个函数中,根据实际参数的类型和数目找出形式参数与之匹配的那一个。这种做法要求函数的参数有类型标注,适用于静态类型的语言。
第二种是进行自动类型转换。具有某个名称的函数只有一个,调用函数时对于与其形式参数的类型要求不一致的实际参数,只要两者的类型是兼容的,就将实际参数转化成形式参数的类型。这种做法也要求函数的参数有类型标注,适用于静态类型的语言。
第三种就是本章中有几处提到的多态性(Polymorphism)。它指的是一个函数能够接受不同类型的参数,前提是这些类型有某种共性,而函数对参数的使用就囿于这种共性。
以加法运算为例。假如为整数和浮点数的加法各定义一个函数,但都使用+符号作名称,就是重载的做法。调用加法函数时,如果两个参数的类型都是整数,语言就选用整数加法的函数;如果两个参数的类型都是浮点数,则选用浮点数加法的函数。
假如只定义了一个参数为浮点类型的加法函数,就是采用类型转换的做法。无论两个参数的类型是整数还是浮点数,调用的都是同一个函数,只不过整数类型的参数会被先转换成浮点数。重载和类型转换可以结合使用。重载方案中,如果两个参数一个是整数一个是浮点数,语言就找不到与之匹配的加法函数,但仍可以选择浮点数的版本,只需将整数类型的参数转换成浮点数。
假如定义了一个加法函数,两个参数既能接受整数,也能接受浮点数,我们就说这个函数具有多态性。此处参数类型的共性是在其上都可以定义数学上的加法运算,该加法函数的实现就是以此数学运算为基础。
多态性是一个表象的概念,只要一个函数能处理不同类型的参数,就符合其标准。至于该函数是如何做到这一点的,有若干种方法,多态性本身并无规定。函数可以通过某种机制自身实现多态性,也可以使用条件语句,根据参数的类型执行不同的路径,相当于将若干个重载函数的定义、选择和调用置于一个函数内;还可以依靠其他函数来实现多态性,比如调用重载的、进行类型转换的或具有多态性的函数,后者之多态性,又递归地可以有以上几种原因。
举例而言,我们有一个用于输出的print函数,能够接受各种类型的参数。print函数可以通过条件语句,针对不同类型的参数,执行各异的算法。或者print可以将多态性委托给一个str函数,它能将各种类型的参数转化成字符串的表现形式。这个str函数,可以自身实现多态性,可以是重载的,也可以通过再另一个函数来具备多态性。
对于以同样的名称命名不同类型的参数参与的运算的三种方法,重载和自动类型转换适用于静态类型的语言,多态性则是静态和动态类型的语言都能够采用。下面我们就通过一个例子,来认识静态类型语言普遍采用的子类型多态性和它与重载之间的区别。
假设在一门面向对象的静态类型的语言中,有两个类型Work和Number分别代表艺术作品和数字。艺术作品之间要比较哪一部更伟大,数字要比较哪一个更大,于是两个类型都有一个名为isGreaterThan的方法,分别是:
Work.isGreaterThan(Work another)
Number.isGreaterThan(Number another)
当对两种类型的实例调用这个方法时,实际上语言是在两个重载的函数间进行自动选择(方法和函数名称上的细微差别只是为了符合两者的命名习惯。):
greaterThan(Work one, Work another)
greaterThan(Number one, Number another)
设想每部艺术作品有一个唯一的编号,Work的isGreaterThan方法就可以有一个重载的版本:
Work.isGreaterThan(Number another)
为了方便以后的比较,也把它写成函数的形式:
greaterThan(Work one, Number another)
由此可见,无论是不同类型的同名方法,还是某个类型的重载方法,实质都是参数类型不同的重载函数。在代码中调用对象的某个方法时,语言根据对象和方法的参数类型确定执行的是重载函数中的哪一个。因为这个动作发生在编译时,所以被称为静态绑定或早期绑定(Early binding)。接下来,我们从艺术作品衍生出书Book和电影Movie两个子类型。无需任何代码,它们都可以调用父类的方法。
Book one = Library.getBookByNumber(3571);
Book another = Library.getBookByNumber(3572);
greaterThan(one, another);
Movie one = Library.getMovieByNumber(8263);
Movie another = Library.getMovieByNumber(8264);
greaterThan(one, another);
根据定义,greaterThan(Work one, Work another)函数已经具备了多态性。以父类型作形式参数的函数能够接受父类型及其子类型的实际参数,这种形式的多态性称为子类型多态性(Subtype polymorphism),采用它的主要是面向对象的静态类型的语言。
回过头来看,对于Book或Movie类型的参数,greaterThan的行为是没有差别的。不过我们可以覆写(Override)子类型继承的方法,也就是为子类型参数定义新的重载函数:
greaterThan(Book one, Book another)
greaterThan(Movie one, Movie another)
这时greaterThan(Work one, Work another)函数仍然可以应用到不同类型的参数上,行为却可以有所不同。这一点对于使用面向对象编程语言的读者是再熟悉不过了。实际上在面向对象编程的语境中,多态性指的就是对象的这种行为。对象的多态性又被称为其方法的后期绑定(Late binding)或动态分派(Dynamic dispatch),指的是语言根据运行时才了解的对象的具体类型选择调用方法的版本。
总而言之,在面向对象的静态类型的语言中,不同类型的同名方法、某个类型的重载方法、子类型覆写的方法,本质上都是参数类型不同的重载函数。根据对象和方法的参数类型,早期绑定是编译时语言在前两者之间自动进行的选择,后期绑定是运行时语言在后两者间自动做的选择。在子类型的对象上可以调用继承的方法,体现的则是子类型多态性。
要想让函数能接受不同类型的参数,还有一种途径,就是将它要接收的参数的类型也作为参数传递给它,函数根据这个特殊的参数来调整行为,这种形式的多态性称为参数多态性(Parametric polymorphism)。【注:参数多态性在有些语言中拥有更简单的名字。Java和C#中它叫范型(Generics),C++中它叫模板(Templates)。】为了将类型参数和普通参数区分开,下面的代码采用Java的语法,将类型参数置于尖括号中,写在函数名的前面。
greaterThan(T one, T another)
使用时先传入某个类型作参数,获得函数特定于该类型的版本,再应用到该类型的参数上。
Book one = Library.getBookByNumber(3571);
Book another = Library.getBookByNumber(3572);
greaterThan(one, another);
Movie one = Library.getMovieByNumber(8263);
Movie another = Library.getMovieByNumber(8264);
greaterThan(one, another);
看上去这种形式和重载有些相似,都是根据函数接收的参数来决定要采用的版本。但是重载需要程序员编写函数对应不同参数情况的多个版本,而参数多态性中函数对应不同类型参数的多个版本是由语言自动生成的,程序员要编写的只有一个函数。这在语言有一定的类型推断的能力时尤其明显,此时语言能根据函数接收的参数自动推断出所需的类型参数,从而省去手工填写。
greaterThan(one, another);
虽然有一个类型参数,但很明显函数greaterThan不可能应用于所有类型的参数,T能采取的类型是有限制的,此处的限制就是T必须是Work及其子类型,所以它的更精确的写法是(仍然采用Java的语法来标注对类型参数的限制):
greaterThan(T one, T another)
如此一来,采用子类型和参数两种多态性的greaterThan函数形式上似乎差别不大:
//采用子类型多态性
greaterThan(Work one, Work another)
//采用参数多态性
greaterThan(T one, T another)
这种相似性究其根源并不难理解,毕竟两种方式都是为了实现多态性,而多态性要求函数所接受的参数类型有某种共性,这里的共性便是继承自父类型Work,子类型多态性和参数多态性用不同的形式表示了这种共性。然而两者还是有差别的。首先,在类型检查方面,采用子类型多态性的函数只要求参数继承自某个父类型,而采用参数多态性的函数则将参数的类型要求精确到某个具体的类型。这一点在单个函数上不明显,但在将多态性运用到面向对象编程中的整个类型上时,用处就很大,Java和C#引入泛型的主要目的就是为了创建容纳特定类型对象的容器。
ArrayList books = new ArrayList();
//ArrayList的add方法的参数类型是Object,因此通过子类型多态性能够添加任意类型的对象,
//但是无法确保容器内的对象是同一类型,也不能对这些对象进行类型检查。
books.add(one);
//利用参数多态性,能够确保容器内的对象是同一类型。
ArrayList books = new ArrayList();
//还能在调用容器的方法时,对参数进行精确的类型检查。
books.add(one);
其次,参数多态性具备比子类型多态性更大的灵活性。多态性所要求的参数类型的共性,最一般地说,体现为这些类型具有某一组相同的属性。子类型多态性只能表示通过继承自父类型(或实现接口)而具有的共性。由继承和接口机制形成的类型层次是先天的、固定的,并且随着类型的增多显得僵化。将一个单继承体系比作一棵树,两个枝节点或叶节点可能位于树结构的截然不同的位置,但具有某种相似性。要表示这样的共性,子类型多态性就无能为力了。相反,参数多态性在表示共性时没有任何约束。前面代码样例中范型所用的语法仅仅是实现参数多态性的一种可能,参数多态性可以直接将类型的共同属性列出来。
回到艺术作品的例子,要比较两部作品孰优孰劣,当然要先有某种评判标准。用代码的语言来说就是,要通过greaterThan函数对两个参数计算出结果,参数的类型必须有某种函数可以利用来计算的共性,比如它们都具有一个评分rating属性,又比如它们都可以被传递给某个评价函数review(Java中的范型本身不支持下列语法,延续使用是为了代码外观的一致性与区分函数的类型参数和普通参数)。
//one和another是两个Book类型的实例,它们都具有rating属性,
//通过同名函数可以取得该属性值。
//greaterThan通过调用该函数,计算出结果。
greaterThan(one, another);
//one和another是两个Movie类型的实例,
//它们都可以被传递给一个批评家评价的函数reviewByCritics,
//greaterThan通过调用该函数,计算出结果。
greaterThan(one, another);
//one和another是两个歌曲Song类型的实例,
//它们都可以被传递给一个听众评价的函数reviewByAudience,
//greaterThan通过调用该函数,计算出结果。
greaterThan(one, another);
由此可见,只要参数的类型具备函数可资计算的共性,无论是以什么形式体现的,函数都可以实现参数多态性。进一步可以理解,只要具备这样的共性,参数的类型不必是一致的。
//one和another分别是Book和Movie类型的实例,
//它们都可以被传递给函数reviewByAudience。
greaterThan(one, another);
上述代码中greaterThan的第一个类型参数的唯一作用就是提供静态检查时one和another参数的类型,在动态类型的语言中,自然可以省略。结合鸭子类型的机制,类型的共性是特定属性时,只需在函数中读取,不必作为参数;共性是传递给特定函数时,可以直接将该函数作为参数。于是,上述代码被简化为:
//one和another是两个Book类型的实例,它们都具有rating属性,
//一个版本的greaterThan通过读取该属性值,计算出结果。
greaterThan(one, another);
//one和another分别是Movie和Book类型的实例,
//它们都可以被传递给一个批评家评价的函数reviewByCritics,
//另一个版本的greaterThan通过调用该函数,计算出结果。
greaterThan(reviewByCritics, one, another);
//one和another分别是歌曲Song和Movie类型的实例,
//它们都可以被传递给一个听众评价的函数reviewByAudience,
//同样上面版本的greaterThan通过调用该函数,计算出结果。
greaterThan(reviewByAudience, one, another);
至此,我们终于看到函数的形式参数不标注类型带来的额外好处——函数天然地具有多态性。虽然这种多态性也被归为参数多态性,但明显和需要传递类型参数的情况有很大分别,我们把它称为隐式参数多态性(Implicit parametric polymorphism),而需要传递类型参数的称为显式参数多态性(Explicit parametric polymorphism)。静态类型的语言(如Haskell)通过类型推断实现隐式参数多态性,动态类型的语言(如JavaScript)通过鸭子类型机制实现隐式参数多态性。
2.6 多态性
2.6.1 子类型多态性
2.6.2 参数多态性
2.7 JavaScript的类型系统
2.7.1 undefined和null
2.7.2 弱类型
2.7.3 变成强类型
2.8 鸭子类型和多态性
2.9 小结
更多内容,请参看拙著:
《JavaScript函数式编程思想》(京东)
《JavaScript函数式编程思想》(当当)
《JavaScript函数式编程思想》(亚马逊)
《JavaScript函数式编程思想》(天猫)