PL真有意思(五):数据类型
- 为许多操作提供了隐含的上下文信息,使程序员可以在许多情况下不必显示的描述这种上下文。比如int类型的两个对象相加就是整数相加、两个字符串类型的对象相加就是拼接字符串、在Java和C#中new object()隐含在背后的就是要分配内存返回对象的引用等等。
- 类型描述了其对象上一些合法的可以执行的操作集合。类型系统将不允许程序员去做一个字符和一个记录的加法。编译器可以使用这个合法的集合进行错误检查,好的类型系统能够在实践中捕获很多错误
类型系统
从编译方面的知识我们可以知道,计算机硬件可以按多种不同的方式去解释寄存器里的一组二进制位。处理器的不同功能单元可能把一组二进制位解释为指令、地址、字符、各种长度的整数或者浮点数等。当然,二进制位本身是无类型的,对存储器的哪些位置应该如何解释,大部分硬件也无任何保留信息。汇编语言由于仅仅是对一些二进制指令的“助记符号”翻译,它也是这种无类型情况。高级语言中则总是关联值与其类型,需要这种关联的一些原因和用途就如前面说到的上下文信息和错误检测。
一般来说,一个类型系统包含一种定义类型并将它们与特定的语言结构关联的机制;以及一些关于类型等价、类型相容、类型推理的规则。必须具有类型的结构就是那些可以有值的,或者可以引用具有值得对象的结构。类型等价规则确定两个值得类型何时相同;类型相容规则确定特定类型的值是否可以用在特定的上下文环境里;类型推理规则基于一个表达式的各部分组成部分的类型以及其外围上下文来确定这个表达式的类型。
在一些多态性变量或参数的语言中,区分表达式(如一个名字)的类型与它所引用的那个对象的类型非常重要,因为同一个名字在不同时刻有可能引用不同类型的对象。
在一些语言中,子程序也是有类型的,如果子程序是一级或者二级值,其值是动态确定的子程序,这时语言就需要通过类型信息,根据特定的子程序接口(即参数的个数和类型)提供给这种结构的可接受的值集合,那么子程序就必须具有类型信息。在那些不能动态创建子程序引用的静态作用域语言(这种语言中子程序是三级值),编译器时就能确定一个名字所引用的子程序,因此不需要子程序具有类型就可以保证子程序的正确调用。
类型检查
类型检查时一个处理过程,其目的就是保证程序遵循了语言的类型相容规则,违背这种规则的情况称为类型冲突。说一个语言是强类型的,那么就表示这个语言的实现遵循一种禁止把任何操作应用到不支持这种操作的类型对象上的规则。说一个语言是静态类型化(statically type)的,那么它就是强类型的,且所有的类型检查都能在编译时进行(现实中很少有语言是真正的静态类型,通常这一术语是指大部分类型检查可以在编译器执行,其余一小部分在运行时检查)。如C#我们通常都认为它是静态类型化的语言。
动态(运行时)类型检查是迟约束的一种形式,把大部分的检查操作都推迟到运行的时候进行。采用动态作用域规则的语言大部分都是动态类型语言,因为它的名字和对象的引用都是在运行时确定的,而确定引用对象的类型则更是要在引用确定之后才能做出的。
类型检查是把双刃剑,严格的类型检查会使编译器更早的发现一些程序上的错误,但是也会损失一部分灵活性;动态类型检查灵活性大大的,但是运行时的代价、错误的推迟检查,各种语言的实现也都在这种利弊上进行权衡。
多态性
多态性使得同一段代码体可以对多个类型的对象工作。它意味着可能需要运行时的动态检查,但也未必一定需要。在Lisp、Smalltalk以及一些脚本语言中,完全的动态类型化允许程序员把任何操作应用于任何对象,只有到了运行时采取检查一个对象是否实现了具体的操作。由于对象的类型可以看作它们的一个隐式的(未明确声明的,一个不恰当的比喻就如C#中的this)参数,动态类型化也被说成是支持隐式的参数多态性。
虽然动态类型化具有强大的威力(灵活性),但却会带来很大的运行时开销,还会推迟错误报告。一些语言如ML采用了一种复杂的类型推理系统,设法通过静态类型化支持隐式的参数多态性。
在面向对象语言里,子类型多态性允许类型T的变量X引用了从T派生的任何类型的对象,由于派生类型必定支持基类型的所有操作,因此编译器完全可以保证类型T的对象能接受的任何操作,X引用的对象也都能接受。对于简单的继承模型,子类型多态的类型检查就能完全在编译时实现。采用了这种实现的大多数语言(如C++,JAVA和C#)都提供另一种显示的参数化类型(泛型),允许程序员定义带有类型参数的类。泛型对于容器(集合)类型特别有用,如T的列表(List
)和T的栈(Stack )等,其中T只是一个类型占位符,在初始化的这个容器对象时提供具体的类型来代替它。与子类型多态类似,泛型也可以在编译时完成类型检查。比如C++的模板完全就是编译期间的东西,编译后就完全没有了模板的痕迹;JAVA则是利用一种“擦除”的技术实现的泛型,需要在运行时做一些检查。
类型的含义
现在至少存在三种不同的考虑类型问题的方式,分别称之为指称的、构造的和基于抽象的
-
指称的
按照指称的观点,一个类型就是一组值,一个值具有某个类型的条件是他属于这个值集合,一个对象具有某个类型的条件是他的值保证属于这个值集合
-
构造的
从构造的观点看,一个类型或者是以一小组内部类型,或者是通过对一个或几个更简单些的类型,应用某个类型的构造符构造出来的
-
基于抽象的
从基于抽象的角度来看,一个类型就是一个接口,由一组定义良好而且具有相互协调的语义的操作组成。
类型的分类
在不同语言里,有关类型的术语也不相同,这里说的通常都是常用的术语,大部分语言多提供的内部类型差不多就是大部分处理器所支持的类型:整数、字符、布尔和实数。
一般语言规范中都会规定数值类型的精度问题,以及一些字符的编码规定。通常特殊的一个数值类型是枚举类型,具体的语法在不同的语言中略有差异,但是其也都是一个目的(用一个字符友好的表示一个数值)。
关于枚举类型,由一组命名元素组成。在C中可以这样写:
enum weekday { sun, mon, tue, wed, thu, fri, sat };
在C中这样的写法和直接对里面的元素直接赋值除了语法上效果完全一样。但是在之后的许多语言中,枚举类型是一个真正的类型
还有一些语言中提供一种称为子界的类型,它表示一种基于基本数值的一个连续的区间。比如Pascal中表示1到100:
type test_score = 0..100
复合类型:由一些简单的基本类型组合成的一些类型称为复合类型,比如常见的记录、变体记录、数组、集合、指针、表等,具体的都会在后面详细介绍。
类型检查
大多数的静态类型语言中,定义一个对象都是需要描述清楚它的类型,进一步讲,这些对象出现的上下文也都是有类型的,也就是说语言中的一些规则限制了这种上下文中可以合法出现的对象类型。
类型相容确定了一个特定类型的对象的能否用在一个特定上下文中。在最极端的情况下,对象可使用的条件就是它的类型与上下文所期望的类型等价。但是在大多数语言中,相容关系都比等价更宽松一些,即使对象与上下文的类型不同,它们也可以相容。
而类型推理想回答的是从一个简单的表达式出发构造另一个表达式时,这整个的表达式的类型是什么
类型等价
在用户可以定义新类型的语言中,类型等价的定义一般基于两种形式。
type R2 = record a : integer b : integer end; type R2 = record b : integer a : integer end;
-
结构等价
基于类型定义的内容,就是它们由同样的组成部分且按照同样的方式组合而成
它的准确定义在不同的语言中也不一样,因为它们要决定类型之间的哪些潜在差异是重要的,哪些是可以接受的(比如上面的两个定义,是否还认为是等价的)。结构等价是一种很直接的认识类型的方式,早期的一些语言(Algol 68、Modula-3、ML)有些事基于结构等价的,现在的大部分语言(Java、C#)大都是基于名字等价了,为何呢?因为从某种意义上看,结构等价是由底层、由实现决定的,属于比较低级的思考方式。就如一个上下文,如果你传递了一个结构等价但是不是所期待对象,实施结构等价的编译器是不会拒绝这种情况的(假如这不是你希望的,那么你也不会得到任何提示或者错误信息,很难排查的)。
-
名字等价
基于类型的词法形式,可以认为是每一个名字都引进一个新的类型;
它基于一种假设,就是说程序员花时间定义了两个类型,虽然它们的组成部分可能相同,但是程序员要表达的意思就是这是两个不同的类型。名字等价的常规判断就非常简单了,看看声明两个对象的类型是否是一个就是了。但是也会有一些特殊的情况出现,比如类型别名(C、C++的程序员很熟悉这种东西吧),比如 typedef int Age; 就为int类型重新定义了一个别名”Age”。那些认为int不等价越Age的语言称为严格名字等价,认为等价的称为宽松名字等价。其实这两种也是很容易区分的,只要能区分声明和定义两个概念的差异就可以区分。在严格名字等价中看待typedef int Age是认为定义了一个新类型Age,在宽松名字等价看来这就是一个类型声明而已,int和Age共享同一个关于整数的定义。
类型变换和转换
在静态类型的语言中,如果“a=b”,那么我们会期望b的类型和a的相同;现在假定所提供的类型和期望的类型和所提供的类型相同,那么我们在要求某个类型的上下文中使用另外一个类型时就需要显示的写出类型变换(或称为类型转换)。根据具体的变换的具体情况,在运行时执行这种变化会有以下三种主要的情况出现:
- 所涉及的类型可以认为是结构等价的,这种情况里面因为涉及的类型采用了相同的底层的表示,则这种变换纯粹就是概念上的操作,不需要运行时执行任何代码。
- 所涉及的类型具有不同的值集合,但它们的值集合具有相同的表示形式。比如一个类型和它的子类型,一个整数和一个无符号的整数。拿无符号整数变换为整数来说,由于无符号整数的最大值是整数类型所容纳不了的,则运行时就必须执行一些代码来保证这种变换的合法性,如果合法则继续下去,否则会产生一个动态语义错误。
- 所涉及的类型具有不同的底层表示,但是我们可以在它们的值之间定义某种对应关系。比如32位整数可以变换到IEEE的双精度浮点数,且不会丢失精度。浮点数也可以通过舍入或割断的形式变换成整数,但是会丢失小数部分。
非变换的类型转换
有这么一种情况,我们需要改变一个值,但是不需要改变它的二进制表示形式,更通俗点说就是我们希望按照另外一个类型的方式去解释某个类型的二进制位,这种情况称为非变换类型转换。最简单的一个例子比如说,一个byte类型的数值65,按byte类型来解释它是65,如果按照char类型来解释它就是字符“A”。比如C++中的static_cast执行类型变换,reinterpret_cast执行非变换的类型转换。c中出现的union形式的结构,就可以认为是这种非变换的类型转换的合法的安全的语言结构。在比如下面C中一般性非变换类型转换代码:
r=*((float *) &n);
任何非变换的类型转换都极其危险的颠覆了语言的类型系统。在弱类型系统的语言中,这种颠覆可能很难发现,在强类型系统的语言中显示的使用这种非变换的类型转换,起码从代码上可以看得出来它是这么一回事,或多或少的有利于排查问题。
类型相容
大多数语言的上下文中并不要求类型等价,相应的一般都是实施较为“宽松”的类型相容规则。比如赋值语句要求右值相容与左值、参数类型相容,实际返回类型与指定的返回类型相容。在语言中,只要允许把一个类型的值用到期望的另外一个类型的上下文中,语言都必须执行一个到所期望类型的自动隐式变换,称为类型强制(比如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++程序员负责。
类型推理
通过前面的类型检查我们可以保证表达式的各各组成部分具有合适的类型,那么这整个表达式的类型是什么来着?其实在大多数的语言中也是比较简单的,算术表达式的类型与运算对象相同、比较表达式总是布尔类型、函数调用的结果在函数头声明、赋值结果就是其左值的类型。在一些特殊的数据类型中,这个问题并不是那么清晰明了,比如子界类型、复合类型。比如下面的子界类型问题(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呢,更或者是其他任意一个也是包含七个字符数组的另外一个类型。这种情况就必须依赖表达式所处的上下文信息才能推到出来具体的类型来。
记录(结构)与变体(联合)
一些语言中称记录为结构(struct),比如C语言。C++把结构定义为class的一种特殊形式(成员默认全局可见),Java中没有struct的概念,而C#则对struct采用值模型,对class采用引用模型。
语法与运算
一个简单的结构体在C中可以这样定义:
struct element{ char name[2]; int number; double weight; Bool merallic; };
等价于Pascal中的:
type two_chars=packed array [1..2] of char; type element - record name:two_chars; number:integer; weight:real; metallic:Boolean end
记录里面的成员(如name,number…)称为域(field)。在需要引用记录中的域时,大部分语言使用“.”记法形式。比如Pascal中:
var copper:eement; copper.name=6.34;
大部分语言中还允许记录的嵌套定义,比如在Pascal中:
type short_string=packed array[1..30] of char; type ore=record name:short_string; element_yielded:record /*嵌套的记录定义*/ name:two_chars; number:integer; weight:real; metallic:Boolean end end
存储布局及其影响
一个记录的各个域通常被放入内存中的相邻位置。编译器在符号表中保存每个域的偏移量,装载和保存的时候通过基址寄存器和偏移量即可得到域的内存地址。类型element在32位的机器中可能的布局如下:
此处有图
(图在最后面,因为markdown的这个画表格不符合这个要求,又不想引图了,就直接用html写了,会被挤到最后去)
4 byte/32bits | ||
name(2个字节) | 2个字节的空洞 | |
number(4个字节) | ||
weight (8个字节) |
||
metallic(1个字节) | 3个字节的空洞 |