阅读导航
0. 概述
为何高级语言需要类型系统这个概念?在汇编时代是没有完整的数据类型系统的,结构化编程引入了结构化的控制流、为结构化设计的子程序,随之这种结构化的代码所操作的数据也进一步的“抽象化、特化”,故而有了数据类型这种概念,类型系统主要用于两个用途:
- 为许多操作提供了隐含的上下文信息,使程序员可以在许多情况下不必显示的描述这种上下文。比如int类型的两个对象相加就是整数相加、两个字符串类型的对象相加就是拼接字符串、C#中new object()隐含在背后的就是要分配内存返回对象的引用等等。
- 类型描述了其对象上一些合法的可以执行的操作集合。比如一个整数类型的对象你就不能给它一个跑步的操作、一个人的对象你就不能对它进行求sin值。编译器可以使用这个合法的集合进行错误检查,如在编译阶段强制检查的语言大部分都是强类型语言、在运行时检查的大都是弱类型语言。
一个类型系统包含一些内部类型,一种定义新类型的机制,一组有关类型等价、类型相容、类型推理的规则。
1. 类型系统
从编译方面的知识我们可以知道,计算机硬件可以按多种不同的方式去解释寄存器里的一组二进制位。处理器的不同功能单元可能把一组二进制位解释为指令、地址、字符、各种长度的整数或者浮点数等。当然,二进制位本身是无类型的,对存储器的哪些位置应该如何解释,大部分硬件也无任何保留信息。汇编语言由于仅仅是对一些二进制指令的“助记符号”翻译,它也是这种无类型情况。高级语言中则总是关联值与其类型,需要这种关联的一些原因和用途就如前面说到的上下文信息和错误检测。
一般来说,一个类型系统包含一种定义类型并将它们与特定的语言结构关联的机制;以及一些关于类型等价、类型相容、类型推理的规则。必须具有类型的结构就是那些可以有值的,或者可以引用具有值得对象的结构。类型等价规则确定两个值得类型何时相同;类型相容规则确定特定类型的值是否可以用在特定的上下文环境里;类型推理规则基于一个表达式的各部分组成部分的类型以及其外围上下文来确定这个表达式的类型。
在一些多态性变量或参数的语言中,区分表达式(如一个名字)的类型与它所引用的那个对象的类型非常重要,因为同一个名字在不同时刻有可能引用不同类型的对象。
在一些语言中,子程序也是有类型的,如果子程序是一级或者二级值,其值是动态确定的子程序,这时语言就需要通过类型信息,根据特定的子程序接口(即参数的个数和类型)提供给这种结构的可接受的值集合,那么子程序就必须具有类型信息。在那些不能动态创建子程序引用的静态作用域语言(这种语言中子程序是三级值),编译器时就能确定一个名字所引用的子程序,因此不需要子程序具有类型就可以保证子程序的正确调用。
1.1 类型检查
类型检查时一个处理过程,其目的就是保证程序遵循了语言的类型相容规则,违背这种规则的情况称为类型冲突。说一个语言是强类型的,那么就表示这个语言的实现遵循一种禁止把任何操作应用到不支持这种操作的类型对象上的规则。说一个语言是静态类型化(statically type)的,那么它就是强类型的,且所有的类型检查都能在编译时进行(现实中很少有语言是真正的静态类型,通常这一术语是指大部分类型检查可以在编译器执行,其余一小部分在运行时检查)。如C#我们通常都认为它是静态类型化的语言。
动态(运行时)类型检查是迟约束的一种形式,把大部分的检查操作都推迟到运行的时候进行。采用动态作用域规则的语言大部分都是动态类型语言,因为它的名字和对象的引用都是在运行时确定的,而确定引用对象的类型则更是要在引用确定之后才能做出的。
类型检查是把双刃剑,严格的类型检查会使编译器更早的发现一些程序上的错误,但是也会损失一部分灵活性;动态类型检查灵活性大大的,但是运行时的代价、错误的推迟检查,各种语言的实现也都在这种利弊上进行权衡。
1.2 多态性
多态性使得同一段代码体可以对多个类型的对象工作。它意味着可能需要运行时的动态检查,但也未必一定需要。在Lisp、Smalltalk以及一些脚本语言中,完全的动态类型化允许程序员把任何操作应用于任何对象,只有到了运行时采取检查一个对象是否实现了具体的操作。由于对象的类型可以看作它们的一个隐式的(未明确声明的,一个不恰当的比喻就如C#中的this)参数,动态类型化也被说成是支持隐式的参数多态性。
虽然动态类型化具有强大的威力(灵活性),但却会带来很大的运行时开销,还会推迟错误报告。一些语言如ML采用了一种复杂的类型推理系统,设法通过静态类型化支持隐式的参数多态性。
在面向对象语言里,子类型多态性允许类型T的变量X引用了从T派生的任何类型的对象,由于派生类型必定支持基类型的所有操作,因此编译器完全可以保证类型T的对象能接受的任何操作,X引用的对象也都能接受。对于简单的继承模型,子类型多态的类型检查就能完全在编译时实现。采用了这种实现的大多数语言(如C++,JAVA和C#)都提供另一种显示的参数化类型(泛型),允许程序员定义带有类型参数的类。泛型对于容器(集合)类型特别有用,如T的列表( List<T> )和T的栈( Stack<T> )等,其中T只是一个类型占位符,在初始化的这个容器对象时提供具体的类型来代替它。与子类型多态类似,泛型也可以在编译时完成类型检查。比如C++的模板完全就是编译期间的东西,编译后就完全没有了模板的痕迹;JAVA则是利用一种“擦拭法”的技术实现的泛型,需要在运行时做一些检查;而C#的泛型实现则是介于C++和JAVA之间。
现在一些脚本语言、动态语言的流行使得开发者开始质疑静态类型化的价值:“如果我们不可能在编译时检查所有的东西,那么费劲的去检查那些能检查的东西值得吗”?因为作为一般性规律,写出类型正确的代码比证明它们正确要容易的多,而静态检查就是希望得到这种证明。随着类型检查越来越复杂,静态检查的复杂性也随之暴涨,写过C#代码的同学也许会有体会到这种强类型的检查有时候逼的我们不得不多写好多代码。动态类型检查是会带来一些运行时开销、会推迟错误报告,但越来越多的开发者觉得与人的效率相比,这种代价也不是不可以接受的。就如现在常说的一种“鸭子类型”,这种类型在编译(或者说书写时)根本就不去做类型检查,而是在运行时检查是否具有某种指定的操作,就好比一个对象会“像鸭子一样呱呱叫”和“像鸭子一样走路”,那么就认为它是鸭子。
1.3 类型的分类
在不同语言里,有关类型的术语也不相同,这里说的通常都是常用的术语,大部分语言多提供的内部类型差不多就是大部分处理器所支持的类型:整数、字符、布尔和实数。
一般语言规范中都会规定数值类型的精度问题,以及一些字符的编码规定。通常特殊的一个数值类型是枚举类型,具体的语法在不同的语言中略有差异,但是其也都是一个目的(用一个字符友好的表示一个数值)。
还有一些语言中提供一种称为子界的类型,它表示一种基于基本数值的一个连续的区间。比如Pascal中表示1到100:
type test=1..100; //周一到周日 workday=mon..fri;
复合类型:由一些简单的基本类型组合成的一些类型称为复合类型,比如常见的记录、变体记录、数组、集合、指针、表等,具体的都会在后面详细介绍。
2. 类型检查
前面我们说到类型检查引出了2个概念“静态类型”和“动态类型”,这两个概念的区别就是类型检查执行的时间差异,前者是编译阶段后者是运行时。那么既然有检查的前提就是要有规则,前面说的强类型就是指这种规则比较“严格”,而弱类型则是指相对的“宽松”的规则(但也不是说没有规则)。大多数的静态类型语言中,定义一个对象都是需要描述清楚它的类型,进一步讲,这些对象出现的上下文也都是有类型的,也就是说语言中的一些规则限制了这种上下文中可以合法出现的对象类型。那么什么是合法的?从上下文得出期望的类型的角度来看,就是类型等价或者类型相容的概念;从类型来得出上下文的角度看,这个概念就是类型推理。
2.1 类型等价
在用户可以定义新类型的语言中,类型等价的定义一般基于两种形式。
- 结构等价:基于类型定义的内容,就是它们由同样的组成部分且按照同样的方式组合而成;
- 名字等价:基于类型的词法形式,可以认为是每一个名字都引进一个新的类型;
定义总是有点晦涩,看看下面代码吧:
1 struct Student{ 2 String Name; 3 int Age; 4 } 5 6 struct School{ 7 String Name; 8 int Age; 9 } 10 11 Student stu; 12 School sch;
如果语言按照结构等价,那么stu和sch就认为是类型等价的(因为它们的组成部分相同)。按照名字等价则只有stu或者再一个stu1类型等价了,因为每一个类型名字都不一样,也就只有一个类型的两个对象才会有类型等价的情况出现。
结构等价:它的准确定义在不同的语言中也不一样,因为它们要决定类型之间的哪些潜在差异是重要的,哪些是可以接受的(比如上面代码中如果School类型的Age是浮点数、或者和Name的声明顺序换了下位置,是否还认为是等价的)。结构等价是一种很直接的认识类型的方式,早期的一些语言(Algol 68、Modula-3、ML)有些事基于结构等价的,现在的大部分语言(Java、C#)大都是基于名字等价了,为何呢?因为从某种意义上看,结构等价是由底层、由实现决定的,属于比较低级的思考方式。就如一个上下文如果期望Student的对象,你给传递了一个sch,实施结构等价的编译器是不会拒绝这种情况的(假如这不是你希望的,那么你也不会得到任何提示或者错误信息,很难排查的)。
名字等价:它基于一种假设,就是说程序员花时间定义了两个类型,虽然它们的组成部分可能相同,但是程序员要表达的意思就是这是两个不同的类型。名字等价的常规判断就非常简单了,看看声明两个对象的类型是否是一个就是了。但是也会有一些特殊的情况出现,比如类型别名(C、C++的程序员很熟悉这种东西吧),比如 typedef int Age; 就为int类型重新定义了一个别名"Age"。那些认为int不等价越Age的语言称为严格名字等价,认为等价的称为宽松名字等价。其实这两种也是很容易区分的,只要能区分声明和定义两个概念的差异就可以区分。在严格名字等价中看待typedef int Age是认为定义了一个新类型Age,在宽松名字等价看来这就是一个类型声明而已,int和Age共享同一个关于整数的定义。
类型变换和转换:在静态类型的语言中,如果“a=b”,那么我们会期望b的类型和a的相同;比如func(arg1),那么我们调用的时候期望实际参数匹配arg1这个形式参数。现在假定所提供的类型和期望的类型和所提供的类型相同,那么我们在要求某个类型的上下文中使用另外一个类型时就需要显示的写出类型变换(或称为类型转换)。根据具体的变换的具体情况,在运行时执行这种变化会有以下三种主要的情况出现:
- 所涉及的类型可以认为是结构等价的,这种情况里面因为涉及的类型采用了相同的底层的表示,则这种变换纯粹就是概念上的操作,不需要运行时执行任何代码。
- 所涉及的类型具有不同的值集合,但它们的值集合具有相同的表示形式。比如一个类型和它的子类型,一个整数和一个无符号的整数。拿无符号整数变换为整数来说,由于无符号整数的最大值是整数类型所容纳不了的,则运行时就必须执行一些代码来保证这种变换的合法性,如果合法则继续下去,否则会产生一个动态语义错误。
- 所涉及的类型具有不同的底层表示,但是我们可以在它们的值之间定义某种对应关系。比如32位整数可以变换到IEEE的双精度浮点数,且不会丢失精度。浮点数也可以通过舍入或割断的形式变换成整数,但是会丢失小数部分。
非变换的类型转换:有这么一种情况,我们需要改变一个值,但是不需要改变它的二进制表示形式,更通俗点说就是我们希望按照另外一个类型的方式去解释某个类型的二进制位,这种情况称为非变换类型转换。最简单的一个例子比如说,一个byte类型的数值65,按byte类型来解释它是65,如果按照char类型来解释它就是字符“A”。比如C++中的static_cast执行类型变换,reinterpret_cast执行非变换的类型转换。c中出现的union形式的结构,就可以认为是这种非变换的类型转换的合法的安全的语言结构。在比如下面C中一般性非变换类型转换代码:
//C中执行非变换的类型转换一般方式为:先取得对象的地址,将其变换成所需类型的指针,然后再做简接操作取值。 //n是整数,r是浮点数 r=*((float *) &n); //因为C中指向整数和浮点数的指针具有相同的表现形式(都是地址值而已) //那么第一步&n就是取得n的地址值(指针) //第二步((float *) &n)就是把这个指针变成浮点数类型的指针 //第三步*((float *) &n)就是按照浮点数的解释方式去解释n位置的二进制位。
任何非变换的类型转换都极其危险的颠覆了语言的类型系统。在弱类型系统的语言中,这种颠覆可能很难发现,在强类型系统的语言中显示的使用这种非变换的类型转换,起码从代码上可以看得出来它是这么一回事,或多或少的有利于排查问题。
2.2 类型相容
大多数语言的上下文中并不要求类型等价,相应的一般都是实施较为“宽松”的类型相容规则。比如赋值语句要求右值相容与左值、参数类型相容,实际返回类型与指定的返回类型相容。在语言中,只要允许把一个类型的值用到期望的另外一个类型的上下文中,语言都必须执行一个到所期望类型的自动隐式变换,称为类型强制(比如int b;double a=b;)。就像前面说的显示的类型变换一样,隐式的类型变换也可能需要执行底层代码或者做一些动态类型检查。
一个重载的名字可能引用不同类型的对象,这种歧义性需要通过上下文信息进行解析。比如a+b这个表达式可以表示整数或者浮点数的加法运算,在没有强制的语言中,a和b必须都是整数或都是浮点数。如果是有强制的语言,那么在a或者b有一个是浮点数的情况下,编译器就必须使用浮点数的加法运算(另外一个整数强制转换为浮点数)。如果语言中+只是进行浮点数运算,那么即使a和b都是整数,也会被全部转成浮点数进行运算(这代价就高了好多了)。
通用引用类型:一些语言根据实习需求,设计有通用的引用类型,比如C中的void*、C#中的Object,任意的值都可以赋值给通用引用类型的对象。但是问题是存进去容易取出来难,当通用引用类型是右值的时候,左值的类型可能支持某些操作,然而这些操作右值对象是不具备的。为了保证通用类型到具体类型的赋值安全,一种解决办法是让对象可以自描述(也就是这个对象包含其真实类型的描述信息),C++,JAVA,C#都是这种方式,C#中如果赋值的类型不匹配则会抛出异常,而C++则是使用dynamic_cast做这种赋值操作,具体的后果呢,也是C++程序员负责。
2.3 类型推理
通过前面的类型检查我们可以保证表达式的各各组成部分具有合适的类型,那么这整个表达式的类型是什么来着?其实在大多数的语言中也是比较简单的,算术表达式的类型与运算对象相同、比较表达式总是布尔类型、函数调用的结果在函数头声明、赋值结果就是其左值的类型。在一些特殊的数据类型中,这个问题并不是那么清晰明了,比如子界类型(关于子界类型请参考这里)、复合类型。比如下面的子界类型问题(Pascal):
type Atype=0..20; type Btype=10..20; var a: Atype; var b: Btype;
那么a+b什么类型呢???它确实是不能是Atype或者Btype类型,因为它可能的结果是10-40。有人觉得那就新构造一个匿名的子界类型,边界时10到40。实际情况是Pascal给的答案是它的基础类型,也就是整数。
在Pascal中,字符串‘abc‘的类型是array[1..3] of char、而Ada则认为是一种未完全确定的类型,该类型与任何3个字符数组相容,比如在Ada中‘abc‘ & ‘defg‘其结果是一个7字符的数组,那么这个7字符数组的类型是array[1..7] of cahr呢还是某一个也是7个字符组成的类型array (weekday) of character呢,更或者是其他任意一个也是包含七个字符数组的另外一个类型。这种情况就必须依赖表达式所处的上下文信息才能推到出来具体的类型来。
类型推理中比较有意思的是ML的做法,感兴趣的可以深入了解一番,这里就不去做介绍了。
3. 记录/结构与变体/联合
一些语言中称记录为结构(struct),比如C语言。C++把结构定义为class的一种特殊形式(成员默认全局可见),Java中没有struct的概念,而C#则对struct采用值模型,对class采用引用模型。
Pascal中简单的记录类型定义如下:
1 type two_chars=packed array [1..2] of char; 2 type element - record 3 name:two_chars; 4 number:integer; 5 weight:real; 6 metallic:Boolean 7 end
C中与此对应的定义为:
1 struct element{ 2 char name[2]; 3 int number; 4 double weight; 5 Bool merallic; 6 };
记录里面的成员(如name,number...)称为域(field)。在需要引用记录中的域时,大部分语言使用“.”记法形式。比如Pascal中:
1 var copper:eement; 2 copper.name=6.34;
C的写法与Pascal相似,有些语言中会使用其他符号,比如Fortran 90中用“%”( copper%name )。有些语言则颠倒域和记录的顺序(域出现在记录前面),比如ML中( #name copper )、Common Lisp中( element-name copper )。这些语法虽然迥异,但是本质上却无任何差异,只是其具有不同的外在书写形式而已。
大部分语言中海允许记录的嵌套定义,还如Pascal中:
1 type short_string=packed array[1..30] of char; 2 type ore=record 3 name:short_string; 4 element_yielded:record /*嵌套的记录定义*/ 5 name:two_chars; 6 number:integer; 7 weight:real; 8 metallic:Boolean 9 end 10 end
换个方式也可以定义为:
1 type ore=record 2 name:short_string; 3 element_yielded:element 4 end
一些语言中只允许第二种形式(也就是说允许记录的域的类型是记录,但是不允许词法上的嵌套定义)。
3.1 存储布局和紧缩
一个记录的各个域通常被放入内存中的相邻位置。编译器在符号表中保存每个域的偏移量,装载和保存的时候通过基址寄存器和偏移量即可得到域的内存地址。类型element在32位的机器中可能的布局如下:
4 byte/32bits(黑色表示空洞-可能会有脏数据) | ||
name(2个字节) | 2个字节的空洞 | |
number(4个字节) | ||
weight (8个字节) |
||
metallic(1个字节) | 3个字节的空洞 |
总的算来element占据5*4=20byte(未压缩),其中空洞占据5个字节。如果记录的相等性判断按照按位比较的话,空洞中的可能会有一些脏数据出现,从而影响到程序的正常行为。那么解决办法大致有2种,1是压缩布局,消除空洞;2是把空洞位置置0。记录的上面的Pascal代码中有个packed关键字,不知大家注意到木有,它的意思就是说告诉编译器,对这段定义优先优化空间而不是时间,那么它优化后的结果可能如下:
4 byte/32bits | ||
name(2个字节) | number(4个字节) | |
weight(8个字节) | ||
metalic(1个字节) | 1个字节的空洞 |
这样的布局element占据4*4=16byte(已压缩)。空间是节省了,但是带来的后果却是运行时上的时间开销(对于未对齐的域的存取需要多条指令方可取出)。如是大家找到一种折中的方案(保证对齐的情况下进行压缩),效果如下:
4 byte/32bits | ||
name(2个字节) | metalic(1个字节) | 1个字节的空洞 |
number(4个字节) | ||
weight (8个字节) |
这种方案会打乱域的排列顺序,不过这也无所谓,必经这种行为属于实现层面的,对程序员来说是属于透明的,除非特殊情况不必去关心编译器是怎么安排的。
3.2 变体记录
说曹操曹操到,特殊情况的存储布局情况来了》变体记录提供2个货更多个可以选择的域,在给定的某一时刻,只有其中一种选择是有效的。变体记录源于Fortran 1的equivalence语句和Algol 68的union类型(C中引入了这种类型)。Fortran的语法形式如下:
1 integer i 2 real r 3 logical b 4 equivalence(i,r,b)
equivalence语句高速编译器i、r、b不会同时使用,应该在内存中共享存储空间。equivalence语句没能提供一组内在的方法来确定当前哪一个对象是合法的,比如说你存储了一个r,然后按照integer整数来读取,这种行为完全是可以的,因为这些都是使用者的责任,因此也会有一些隐含的安全性问题。
笔者认为变体记录的本质和上面2.2类型等价中介绍到的非变换的类型转换是一回事:一块存储区域,数据存进去了,但是到底按照什么类型来读取完全由使用者负责,唯一的不同之处在于变体记录规定了一个有限的类型集合,而非变换的类型转换却没有任何约束。当然,如果从语义上看这两者完全也没有可比性,
关于变体记录,各种语言的语法看起来真是太丑了(笔者觉得)。这里也不去做语法方面的介绍了,明白一点即可(多个域共享一块存储区域)。C的union语法看起来还是比较舒服点:
1 union Student 2 { 3 int height; 4 double weight; 5 };
上面的C语言的例子表明这个联合体可以解释为两种意思(height或者weight),但是它们在某一时刻只有一种状态是有效的。再比如C#中虽然不支持union,但是却提供了另外一种机制可以让你控制class或者struct的成员的内存布局,也可以模拟出C中union的效果来,以前写过一个IP和整数的互转的结构。这种类型的内存布局存储各位脑补一下,就不画图了。
4. 数组
数组是最常见也是最重要的复合数据类型。记录用于组合一些不同类型的域在一起;而数组则不同,它们总是同质的。从语义上看,可以把数组想象成从一个下标类型到成员(元素)类型的映射。
有些语言要求下标类型必须是integer,也有许多语言允许任何离散类型作为下标;有些语言要求数组的元素类型只能是标量,而大多数语言则允许任意类型的元素类型。也有一些语言允许非离散类型的下标,这样产生的关联数组只能通过散列表的方式实现,而无法使用高效的连续位置方式存储,比如C++中的map,C#中的Dictionary。在本节中的讨论中我们假定数组的下标是离散的。
4.1 语法和操作
大多数的语言都通过数组名后附加下标的方式(圆括号|方括号)来引用数组里的元素。由于圆括号()一般用于界定子程序调用的实际参数,方括号在区分这两种情况则有易读的优势。Fortran的数组用圆括号,是因为当时IBM的打卡片机器上没有方括号,,,
声明一个数组的语法在各语言的实现中各有不同,比如C char name[10]; ,C# char[] name; 。何时确定数组的形状(维数和上下届)对管理数组的存储有着决定性的作用,比如一下的5种可能性:
- 全局生存期,静态形状: 如果一个数组的形状在编译时已知,而且在程序执行期间一直存在,那么编译器就可以在静态的全局存储中为这种数组分配空间。
- 局部生存期,静态形状: 如果一个数组的形状在编译时已知,但它在程序执行期间不应该一直存在,则可以运行时在子程序的栈帧里为数组分配空间。
- 局部生存期,加工时完成形状约束: 如果一个数组的形状只能到加工时才知道,这种情况下仍可以在子程序的栈帧里为数组分配空间,但是需要多做一层简介操作。
- 任意生存期,加工时完成形状约束: 在C#和Java里的数组变量是对象(面向对象语言中所指的对象)的引用。声明 int[] A; 并不做空间分配,只是创建一个引用,要想这种引用确实引用某一个东西,则必须为其创建一个新对象( A=new int[10]; )或者指向另外一个数组对象,无论哪一种情况,数组一旦分配,其大小就不会改变。
- 任意生存期,动态形状: 如果一个数组的大小可以动态调整,那么久无法在栈帧里分配了,因为当数组增大时,它两边的空间可能已做他它用。为了能改变期大小,这种数组就必须在堆里分配。大多数情况下,为了增大数组,就要新分配一块更大的新空间,然后复制旧数据到新块。
4.2 存储布局
大多数语言的实现里,一个数组都存放在内存的一批连续地址中,比如第二个元素紧挨着第一个,第三个紧挨着第二个元素。对于多维数组而言,则是一个矩阵,会出现行优先和列优先的选择题,这种选择题对于语言使用者而言是透明的,而对语言的实现者则需要考虑底层方面的优化问题了。
在一些语言中,还有另外一种方式,对于数组不再用连续地址分配,也不要求各行连续存放,而是允许放置在内存的任何地方,再创建一个指向各元素的辅助指针数组,如果数组的维数多于两维,就再分配一个指向指针数组的指针数组。这种方式称为行指针布局,这种方式需要更多的内存空间,但是却有两个优点:
- 首先,可能加快访问数组里单独元素的速度;
- 其次,允许创建不用长度的行,而且不需要再各行的最后留下对齐所用的空洞空间,这样节省下来的空间有时候可能会超过指针占据的空间。
C,C++和C#都支持连续方式或行指针方式组织多维数组,从技术上讲,连续布局才是真正的多维数组,而行指针方式则只是指向数组的指针数组。
5. 字符串
许多语言中,字符串也就是字符的数组。而在另一些语言中,字符串的情况特殊,允许对它们做一些其他数组不能用的操作,比如Icon以及一些脚本语言中就有强大的字符串操作功能。
字符串是编程中非常重要的一个数据类型,故而很多语言都对字符串有特殊的处理以便优化其性能以及存储(比如C#中的字符串不可变性保证了性能,字符串驻留技术照顾了存储方面的需要),由于这些特殊的处理,故而各各语言中为字符串提供的操作集合严重依赖语言设计者对于实现的考虑。
6. 指针和递归类型
所谓的递归类型,就是可以在其对象中包含一个或多个本类型对象的引用类型。递归类型用于构造各种各样的“链接”数据结构,比如树。
在一些对变量采用引用模型的语言中,很容易在创建这种递归类型,因为每个变量都是引用;在一些对变量采用值模型的语言中,定义递归类型就需要使用指针的概念,指针就是一种变量,其值是对其他对象的引用。
- 在一些语言中,指针被严格的限制为只能指向堆里的对象,而创建指针的方式只有一种,那就是调用一个内部功能,在堆中分配一个新对象并返回指向它的地址。
- 在另一些语言中,可以用“取地址”操作创建指向非堆对象的指针。
我们通常认为指针等同于地址,实际上则不然。指针是一个高级概念,就是对对象的引用;地址是一个低级概念,是内存单元的位置。指针通常通过地址实现,但并不总是这样,在具有分段存储器体系结构的机器上,指针可以由一个段标识和一个段内偏移量组成。在那些企图捕捉所有悬空引用的语言里,指针可能包含一个地址和一个访问关键码。
对于任何允许在堆里分配新对象的语言,都存在一个问题:若这种对象不在需要了,何时以及以何种方式收回对象占用的空间?对于那些活动时间很短的程序,让不用的存储留在那里,可能还可以接受,毕竟在它不活动时系统会负责回收它所使用的任何空间。但是大部分情况下,不用的对象都必须回收,以便腾出空间,如果一个程序不能把不再使用的对象存储回收,我们就认为它存在“内存泄漏”。如果这种程序运行很长一段时间,那么它可能就会用完所有的空间而崩溃。许多早期的语言要求程序员显示的回收空间,如C,C++等,另一些语言则要求语言实现自动回收不再使用的对象,如Java,C#以及所有的函数式语言和脚本语言。显示的存储回收可以简化语言的实现,但会增加程序员忘记回收不再使用的对象(造成内存泄漏),或者不当的回收了不该回收的正在使用的对象(造成悬空引用)的可能性。自动回收可以大大简化程序员的工作,但是为语言的实现带来了复杂度。
6.1 语法和操作
对指针的操作包括堆中对象的分配和释放,对指针间接操作以访问被它们所指的对象,以及用一个指针给另一个指针赋值。这些操作的行为高度依赖于语言是函数式还是命令式,以及变量/名字使用的是引用模型还是值模型。
函数式语言一般对名字采用某种引用模型(纯的函数式语言里根本没有变量和赋值)。函数式语言里的对象倾向于采取根据需要自动分配的方式。
命令式语言里的变量可能采用值模型或引用模型,有时是两者的某种组合。比如 A=B;
- 值模型: 把B的值放入A。
- 引用模型: 使A去引用B所引用的那个对象。
对于引用模型,直截了当的实现方式是让没一个变量都表示一个地址,但是这种做法会导致内部类型操作的低效。另一种更好也更常见的方式是根据不同的情况应用不同的模型,对于那些引用可变对象(如树的节点)的变量采用地址,而那些不可变的对象(如整数,字符)采用实际值。换一种说法,每个变量在语义上都是引用,比如对于整数3的引用实现为存放3的地址还是直接存放3本身,实际上都没关系,因为“这个3”根本不会变。
Java的实现方式区分了内部类型和用户定义的类型,对内部类型采用值模型,对用户定义的类型采用则采用引用模型,C#的默认方式与Java类似,另外还提供一些附加的语言特性,比如“unsafe”可以让程序员在程序中使用指针。
6.2 悬空引用
在[程序设计语言]-02:名字、作用域和约束(Bindings)中我们列举了对象的3种存储类别:静态、栈和堆。静态对象在程序的执行期间始终是活动的,栈对象在它们的声明所在的子程序执行期间是活动的,而堆对象则没有明确定义活动时间。
在对象不在活动时,长时间运行的程序就需要回收该对象的空间,栈对象的回收将作为子程序调用序列的一部分被自动执行。而在堆中的对象,由程序员或者语言的自动回收机制负责创建或者释放,那么如果一个活动的指针并没有引用合法的活动对象,这种情况就是悬空引用。比如程序员显示的释放了仍有指针引用着的对象,就会造成悬空指针,再进一步假设,这个悬空指针原来指向的位置被其他的数据存放进去了,但是实际却不是这个悬空指针该指向的数据,如果对此存储位置的数据进行操作,就会破坏正常的程序数据。
那么如何从语言层面应对这种问题呢?Algol 68的做法是禁止任何指针指向生存周期短于这个指针本身的对象,不幸的是这条规则很难贯彻执行。因为由于指针和被指对象都可能作为子程序的参数传递,只有在所有引用参数都带有隐含的生存周期信息的情况下,才有可能动态的去执行这种规则的检查。下面列举了几种处理方式供参考:
- 碑标(tombstones):碑标是一种机制,语言可以借助它捕获所有指向栈对象或堆对象的悬空引用。
这种机制就是不让指针直接引用对象,而是引近另一层间接的操作。在堆里分配对象时(或当指针要指向栈里的对象时),运行系统就分配一个碑标,让指针里包含这个碑标的地址,在碑标里存放该对象的地址。在对象被回收时,修改碑标使之保存一个不是合法地址的值(通常是0)。对于堆对象而言,释放对象时很容易把碑标改为不合法的地址;而对于栈里的对象,在退出子程序时需要找到当前栈帧中的对象关联的那些碑标。碑标技术的空间和时间代价都可能是非常高的(碑标空间的开销,合法性检查,双重的间接操作)。
- 锁和钥匙:锁和钥匙是碑标的一种替代技术,其缺点是只能用于堆对象,而且只为悬空引用提供了一定概率上的保护。
它的机制是让每个指针里都包含一对信息,一个地址和一个钥匙。堆里每个对象的开始是一个锁。指向堆对象的指针合法的条件就是指针和钥匙与对象的锁匹配。每新建一个对象,都生成一对新钥匙和锁。这种机制也会引起显著的开销(钥匙的空间开销,指针访问时的比较的代价,同时也需要更长的指令序列)。
为了使时间和空间的开销最小,大多数编译器默认生成的代码都不包含对悬空引用的检查。
6.3 废料收集
对程序员而已,显示释放堆对象是很沉重的负担,也是程序出错的主要根源之一,为了追踪对象的生存轨迹所需的代码,会导致程序更难设计、实现,也更难维护。一种很有吸引力的方案就是让语言在实现层面去处理这个问题。随着时间的推移,自动废料收集回收都快成了大多数新生语言的标配了,虽然它的有很高的代价,但也消除了去检查悬空引用的必要性了。关于这方面的争执集中在两方:以方便和安全为主的一方,以性能为主的另一方。这也说明了一件事,编程中的很多地方的设计,架构等等方面都是在现实中做出权衡。
7. 总结
本文从语言为何需要类型系统出发,解释了类型系统为语言提供了那些有价值的用途:1是为许多操作提供隐含的上下文,使程序员在许多情况下不必显示的描述这种上下文;2是使得编译器可以捕捉更广泛的各种各样的程序错误。然后介绍了类型系统的三个重要规则:类型等价、类型相容、类型推理。以此3个规则推导出的强类型(绝不允许把任何操作应用到不支持该操作的对象上)、弱类型以及静态类型化(在编译阶段贯彻实施强类型的性质)、动态类型化的性质以及在对语言的使用方面的影响。以及后续介绍了语言中常见的一些数据类型的用途以及语言在实现这种类型方面所遇到的问题以及其大致的实现方式。最后则把重点放在了指针的概念上,以及由于语言引入指针而引发的各种问题以及其处理方式。
end。