8.6 类型的各种展开
最初为加入数值的类型信息而开始使用的类型的概念,随后被应用到越来越多的场合。这或许也是类型的概念变得难懂的原因之一吧。接下来,我们来看一下类型的不同应用方式。
用户定义型和面向对象
首先,使用语言中自带的基本数据类型通过组合定义新的类型的这一功能被发明出来,如 C 语言中的结构体。这被称为用户定义型 28。
28在 C 语言之前的 COBOL 语言中,可以用基本的类型组合起来定义一种带有层次结构的记录类型。另外,PL/I 语言也有能组合基本类型并创建新的类型的语句 DEFINE STRUCTURE。结构体(structure)这个术语应该就是从那时候开始使用的。
C语言
/ 整数型和字符串类型组合得到新的person类型 /
struct person {
int age;
char *name;
};
其实,不仅限于整数这样的数据,函数这样决定数据如何被处理的对象也被糅合到类型中来了。C++ 语言的设计者本贾尼·斯特劳斯特卢普把用户能自定义的类型当作构造程序的基本要素,把这种类型冠名为类。这就是第二次面向对象的发明 29。
29关于第一次面向对象为何物,请参考第 11 章。
作为功能的类型
区别公开与非公开
后来出现了类型既是功能的观念。这种观念认为,构成结构体和类的类型不应该是全部公开而是最小限度地公开,类型是否一致这个交由编译器来检查,用类型来表达功能,与功能是否一致也是由编译器来检查。因此,只需要将与外部有交互的部分作为类型公开,而实现的细节则隐藏起来 30。这样类型就被区分为公开部分和非公开部分了。学过 C++ 语言或 Java 语言的人应该知道 public 和 private 这样的访问控制标志。
30对外部公开的部分和非公开部分要区分开来,基于这一观点的访问控制被使用于诸如 Modula-2(1978 年)的模块中和 CLU(1974 年)的抽象数据类型中。
发展为接口
将类型即功能的观念进一步延伸,就产生了不包含有具体的实现细节的类型(Java 语言中的接口等)。另外把函数是否抛出异常这一信息也当作类型的语言出现了 31。
31CLU 语言和 Java 语言都会进行异常检查,详细介绍请参考第 6 章。
下面是 Java 语言中 Runnable 接口除去备注后的代码。这个接口定义了一个不带参数、不返回值(void)、带有一个名为 run 的方法的功能。
java.lang.Runnable接口
package java.lang;
public interface Runnable {
public abstract void run();
}
我们来看这一功能在何处被使用到,比如,java.lang.Thread 中有一个构造函数 Thread (Runnable target)。它的功能就是,只要是满足不带参数、不返回值、带有一个名为 run 的方法的类,不管具体的实现细节,都可以被传递给 Thread 的构造函数。
用类型实现所有功能的时代到来了吗
类型即是功能的方法得到了越来越广泛地应用,但遗憾的是,用类型来实现所有功能的想法却还没有成功。如果它能成功,就很理想了:只要类型一致就不用关心内部的实现细节,功能与类型的不一致交由编译器来检查,编译通过意味着没有 bug。然而,仍有不少类型无法表达的信息,如输入这个数据需要多少处理时间,这个处理过程需要多少内存,线程中是否可以进行这种操作等。至今,这些问题也只能通过人为地读取文档和源代码来判断。
总称型、泛型和模板
通过将不同类型进行组合得到复杂的类型后,使用中会出现想更改其中一部分却又不想全部重新定义的再利用需求。
因此出现了构成要素部分可变的类型,即总称型。想要表现不同的情况时,出现了以类型为参数创建类型的函数。C++ 语言中的模板、Java 语言中的泛型以及 Haskell 语言中的类型构造器可以说就是这种创建类型的机制 32。
32在 C++ 语言中也有以类型为参数返回函数的函数模板,这里只做简单介绍。
C++ 语言中
我们首先来看 C++ 语言中的模板。在用户定义型和面向对象一节中定义的 person 结构体中追加 something 这一字段,如下所示。
C++
#include <iostream>
template<typename T>
struct person {
int age;
char name;
T something; ❶
};
int main(){
person<int> x; ❷
x.something = 1; ❸
person<const char> y; ❹
y.something = "hoge"; ❺
std::cout << x.something << std::endl; // -> 1
std::cout << y.something << std::endl; // -> hoge
}
这里的 something 的类型目前还没有确定。通过包括在 template<typename T>… ;范围内,它其实是声明了 T 是一个类型参数后面要代入具体的类型,而❶的写法声明了 something 的类型就是后面才确定的 T 的类型。
随后,在 main 函数中,person<int>(❷)将 int 类型代入 person 的类型参数 T 中,创建了一个新的类型。这个类型中 something 是整数型,于是就可以如❸句给它赋值整数数值了。
另外,在T中代入 const char 创建新的类型后(❹),就可以如❺句给它赋值字符串(const char)的值了。
Java 语言中
同样地,在 Java 语言中也可以实现,如下所示。
Java
public class GenericsTest {
public static void main(String[] args) {
Person<Integer> x = new Person<Integer>(); ❶
x.something = 1;
Person<String> y = new Person<String>();
y.something = "hoge";
System.out.println(x.something); // -> 1
System.out.println(y.something); // -> hoge
}
}
class Person<T>{
public Integer age;
public String name;
public T something;
}
Java 语言中通过 class Person<T>,声明了这个类中的 T 是类型参数。随后与 C++ 语言中一样,通过❶句的 Person<Integer> 在类型参数中代入 Integer 类型进而创建了新的类型。
Haskell 语言中
在 Haskell 语言中可以这样实现。
Haskell
data Person a = MakePerson {age :: Int, name :: String, something :: a} ❶
x :: Person Int ❷
x = MakePerson {age = 31, name = "nishio", something = 1}
y :: Person String
y = MakePerson {age = 31, name = "nishio", something = "hoge"}
main = do
print $ something x — -> 1
print $ something y — -> "hoge"
通过❶中的 data Person a = … 声明了 a 为类型参数。随后在❷中将 Int 代入类型参数中创建新的类型,声明 x 即为这一类型。
这个例子中,使用了类型参数的地方只有一个,或许它的优势体现得不太明显。然而,在 something x 中使用的函数 something 从 Person Int 中返回 Int 类型的值,在 something y 中使用的函数 something 却从 Person String 中返回 String 类型的值 33。如果要在没有总称型功能的语言中实现,就需要针对每个现在作为参数的类型进行改写,然后逐个实现,陷入更加庞大的工作量之中。
33顺带一提,像这种同一个函数名下面具有多种函数实现方式的情况称为重载(多重定义,overload)。上一小节中讲到的加法运算符“+”既被用于两个整数的相加运算也被用于浮点数的相加运算,这也是重载的一种。
比如 Java 语言中 list 类型 java.util.ArrayList<E> 的实现中,在追加元素的方法 boolean add (E e) 和读取元素的方法 E get (int index) 等多达 52 处使用了类型参数 E,如果一处一处改写那是相当麻烦的 34。
34这里的 E 是元素(element)的首字母,它仅仅是类型参数的名称,和用 T 表示没有本质的区别。
动态类型
到目前为止,我们介绍的类型的机制中,处理器把变量名、保存数值的内存地址、内存里的内容的类型三者作为一个整体来看待。把类型的信息和数值看作整体的方式叫动态类型。作为其反义词,到目前为止介绍的类型机制都叫静态类型。
动态类型在 LISP 语言中已经得到应用,之后在 Smalltalk 语言中得以推广,随后因为计算机的高速化发展,其应用领域大为拓展。现在大多数的脚本语言都采用了动态类型。
比如使用了动态类型的脚本语言之一的 Python 语言中,变量声明时不需要声明类型,对同一个变量既可以赋值整数也可以赋值浮点数。
Python
x = 1234
x = 3.1415
如何实现
这是如何实现的呢?动态类型语言中之所以不需要声明类型,是因为在内存上使用了同等类型对待的设计方法 35。比如 Python 语言中,不管是整数还是浮点数还是字符串,全部都作为 PyObject 对待,开始部分都是一样的(图 8.13)。另外在 PyObject 类型的结构中还预留了保存值的类型信息的地方。
35这一点在其它的脚本语言中也是同样的情况,比如在 Ruby 语言中,任何数值都是 VALUE 类型的。

※ 使用次数是指在内存管理中记录这个数值有几处被参照引用的数值(引用计数)。
※ 字符串的散列值是散列函数的计算结果(详见第 9 章),状态是表示该字符串是否记录在 Internpool 里(处理器是否把该字符串进行唯一处理的标志)。
图 8.13 Python 语言中值的开始部分结构都是一样的
Python 语言中的 x 变量在 C 语言中会表达为 PyObject* x。因此,要读取数值时,首先将内存上的比特列作为 PyObject 读取,便可知该数是整数、浮点数还是字符串了。再据此决定实际的数值以何种方式从内存中读取出来。如果是整数,表示在第三个单位里存放着整数。如果是浮点数,表示第三个单位里存放着浮点数。如果是字符串,第三个单位存放的是整数型的表示字符串长度的数,实际的字符串从第六单元开始读取。
优势与不足
使用这种数值类型处理方法,能实现历来静态类型语言不能实现的灵活处理。运行时确定类型和改变类型成为可能。然而,它也有一些不足。静态类型语言在编译时确定类型,同时编译时也检查了类型的一致性。有了这种类型检查,在实际执行前,便能发现一部分 bug。这一点动态类型语言是无法做到的。
类型推断
既不放弃编译时的类型检查,也想尽量减少麻烦的类型声明,要实现这一要求就要用到计算机自动推论确定类型的方法。
类型推断最早是 OCaml 语言和 Haskell 语言这样的 ML 语言擅长的领域,最近,在 Java VM 上运行的 Scala 语言等采用了类型推断的语言变得越来越多。
Haskell 语言和没有类型推断的 C 语言的比较
在 Haskell 语言中定义加 1 的函数 add_one 如下所示。
GHCi
> let add_one = \x -> x + 1
查询 add_one 的类型,得到的结果是取一整数返回另一整数的函数。基于 1 是一个整数而加法运算符 + 是取两个 T 类型的值返回 T 类型值的函数这两点,语言处理器得出参数和返回值都为整数的结论 36。
36严格来讲,在 Haskell 语言中 1 并不是整数型而是包含了 Float 等类型的型类 Num。这里的推论受“默认声明”的影响,篇幅所限,在此不多赘述。“6.3 Standard Haskell Classes”http://www.haskell.org/onlinereport/basic.html“4.3.4 Ambiguous Types, and Defaults for Overloaded Numeric Operations”http://www.haskell.org/onlinereport/decls.html#default-decls
GHCi
> :type add_one
add_one :: Integer -> Integer
来看看在 C 语言中如何定义加 1 的函数。这时候需要人为地声明参数和返回值的类型为 int。
C语言
int add_one(int x){
return x + 1;
}
Haskell 语言的类型推断
同样是使用类型推断这一术语,在不同的语言中,如何做类型推断以及类型推断的能力如何,情况是不一样的。我们来比较一下 Haskell 语言和 Scala 语言。
首先创建一个“取出 x 返回 x”,此外什么也不做的函数 identify,再查询一下它的类型。
GHCi
> let identity = \x -> x
> :type identity
identity :: t -> t
没有做任何类型声明的情况下创建了函数 identify,查询其类型的结果是 t->t,这表示获取某种类型的参数返回相同类型参数的函数。这和预期是一致的。那么我们把 identify 作为参数来调用函数 identify 试试看。因为 identify 是返回和参数同样的类型的函数,把 identify 作为参数调用函数的话,返回也是 identify,亦即 identify 的类型该是 t->t。另外 identify identify 1 因为是 identify 1 所以最终是 1。我们来确认一下。
GHCi
> :type identity identity
identity identity :: t -> t
> identity identity 1
1
Scala 语言的类型推断
Scala 语言中类型推断的行为和 Haskell 语言是不一样的。它会首先来定义 identify 函数。如果像 Haskell 语言中那样不指明任何类型,将导致错误(❶),有必要人为指定参数的类型导入类型参数(❷)。定义好的 identify 的类型是把 T 作为类型参数接受 T 并返回 T 的函数(❸)。虽然没有人为地写明返回值的类型,但它从参数的类型中被推论出来了。
Scala的对话终端
scala> def identity = x => x ❶
<console>:7: error: missing parameter type
def identity = x => x
^
scala> def identity[T] = (x : T) => x ❷
identity: [T]=> T => T ❸
接着来确认 identify (identify) 的类型。Scala 语言没能很好地推论出类型,显示成接受 nothing 返回 nothing 的函数(❹)。Scala 语言中的 Nothing 类型是指属于该类型的值不存在的一种特殊的类型。这意味着向该类型赋值任何参数都将导致类型错误。试着把整数的 1 赋值给它看看,的确出现了错误,指出“需要 Nothing 型的数值却被赋值 Int 型 的 1”(❺)。
Scala的对话终端
scala> identity(identity) ❹
res0: Nothing => Nothing = <function1>
scala> identity(identity)(1) ❺
<console>:9: error: type mismatch;
found : Int(1)
required: Nothing
identity(identity)(1)
^
由此可见,同样是使用类型推断的表达方法,不同语言指示的具体内容是不一样的。刚刚展示了 Scala 语言推论失败的一个例子,即使推论成功了,在实用价值上有没有优势这个问题上,大家也是有意见分歧的。即使承认它的优势而对类型推断的机制进行修改,在由此带来的作业代价与推论失败的代价之间做权衡之后,再决定否应该做改进和变更将是一个更加困难的问题。
强类型下是否可以做到程序没有 bug
类型推断与理论推论之间有对应关系 37。于是有些语言发出挑战,试图通过使用比 C 语言和 Java 语言更强力的类型系统来证明程序中没有任何 bug。今后在改善类型系统的表现力和类型推断规则方面应该会开展各种研究。
37比如,在一个接受 X 型参数返回 Y 型返回值的函数中传递一个 X 型的数值,会得到 Y 型的返回值。这一关于类型的描述与“X 为真在如果 X 则 Y 的情况下,Y 就为真”这一逻辑的描述是相对应的,被称为 Curry-Howard 对应。
然而,我们也时常听到一些关于类型的言论,有观点认为编译通过说明没有 bug 或者说可以设计出没有 bug 的程序。这些观点在多大程度上是现实的呢?这里笔者想引用日本计算机先驱人物后藤英一的文章。
当今,关于 SP38 和程序正确性的检测法方面的研究,从一开始就以没有 bug 的程序设计为目标。这个要是能百分百成功的话,到“后天”bug 就应该全部灭绝了吧。如果这个目标不能达成,大家还将继续依靠直觉和经验,使用和今天差不多的调试技术吧。也就是说,当今,比起研究如何避免 bug 产生,大家投入在研究如何及早发现 bug 并将其杀死方面的热情并不高。
——后藤英一“计算机科学的今天、明天和后天”中的程序设计语言一节 39
38SP 就是现在所讲的结构化编程(structured programming)的简称。
39Bit,Vol.10, 1976 年 , pp.87。
※ 作者注:SP 即现在所谓的结构化程序设计的略称。
他写这篇文章的时候是 1976 年,是撰写本书的 36 年前。已经过去 36 年了,bug 并没有全部灭绝,接下来到底还要花几十年的时间才能做到呢 40。
40为了尽早发现 bug,通过频繁地进行测试达到及早发现 bug 的“测试驱动型开发”和“持续集成”等方法越来越普及,这一现象值得注意。
