PL真有意思(五):数据类型

523 阅读31分钟

前言

现在大多数程序设计语言中都有表达式和/或对象的类型概念。类型起着两种主要作用:

  • 为许多操作提供了隐含的上下文信息,使程序员可以在许多情况下不必显示的描述这种上下文。比如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写了,会被挤到最后去)

在对结构体的存储布局方案上,如果使用正常排序,结构中的空洞会浪费空间。但是如果通过压缩来节省空间,但是可能很带来很严重的访问时间的代价

数组

数组是最常见也是最重要的复合数据类型。记录用于组合一些不同类型的域在一起;而数组则不同,它们总是同质的。从语义上看,可以把数组想象成从一个下标类型到成员(元素)类型的映射。

有些语言要求下标类型必须是integer,也有许多语言允许任何离散类型作为下标;有些语言要求数组的元素类型只能是标量,而大多数语言则允许任意类型的元素类型。也有一些语言允许非离散类型的下标,这样产生的关联数组只能通过散列表的方式实现,而无法使用高效的连续位置方式存储,比如C++中的map,C#中的Dictionary。在本节中的讨论中我们假定数组的下标是离散的。

语法和操作

大多数的语言都通过数组名后附加下标的方式(圆括号|方括号)来引用数组里的元素。由于圆括号()一般用于界定子程序调用的实际参数,方括号在区分这两种情况则有易读的优势。Fortran的数组用圆括号,是因为当时IBM的打卡片机器上没有方括号

维数、上下界和分配

对于数组的形状在声明中就已经描述,对于这种有静态形状的数组,可以用通常的方式来管理内存:生存期是整个程序的数组使用栈分配,具有更一般的生存期的动态生成数组使用堆分配。但是对于在加工之前不知道其形状的数组,或其形状在执行期间可能改变的数组,存储管理就会更复杂一点。

  • 内情向量

在编译期间,符号表维护者程序中的每个数组的维度和边界信息。对于每个记录,它还维护着每个域的偏移量。如果数组维度的数目和边界是静态已知的,编译器就可以在符号表中找出它们,以便计算数组元素的地址。如果这些值不是静态已知的,则编译器就必须生成代码,在运行时从一个叫内情向量的数据结构来查找它

  • 栈分配

子程序参数是动态形状数组最简单的例子,其中数组的上下界在运行时才确定,调用方都会传递数组的数据和一个适当的内情向量,但是如果一个数组的形状只能到加工时才知道,这种情况下仍可以在子程序的栈帧里为数组分配空间,但是需要多做一层操作

  • 堆分配

在任意时间都可以改变形状的数组,有时被称为是完全动态的。因为大小的变化不会以先进先出的顺序进行,所以栈分配就不够用了。完全动态的数组必须在堆中分配。比如Java中的ArrayList

内存布局

大多数语言的实现里,一个数组都存放在内存的一批连续地址中,比如第二个元素紧挨着第一个,第三个紧挨着第二个元素。对于多维数组而言,则是一个矩阵,会出现行优先和列优先的选择题,这种选择题对于语言使用者而言是透明的,而对语言的实现者则需要考虑底层方面的优化问题了。

在一些语言中,还有另外一种方式,对于数组不再用连续地址分配,也不要求各行连续存放,而是允许放置在内存的任何地方,再创建一个指向各元素的辅助指针数组,如果数组的维数多于两维,就再分配一个指向指针数组的指针数组。这种方式称为行指针布局,这种方式需要更多的内存空间,但是却有两个优点:

  • 首先,可能加快访问数组里单独元素的速度;
  • 其次,允许创建不用长度的行,而且不需要再各行的最后留下对齐所用的空洞空间,这样节省下来的空间有时候可能会超过指针占据的空间。C,C++和C#都支持连续方式或行指针方式组织多维数组,从技术上讲,连续布局才是真正的多维数组,而行指针方式则只是指向数组的指针数组。

字符串

许多语言中,字符串也就是字符的数组。而在另一些语言中,字符串的情况特殊,允许对它们做一些其他数组不能用的操作,比如Icon以及一些脚本语言中就有强大的字符串操作功能。

字符串是编程中非常重要的一个数据类型,故而很多语言都对字符串有特殊的处理以便优化其性能以及存储(比如C#中的字符串不可变性保证了性能,字符串驻留技术照顾了存储方面的需要),由于这些特殊的处理,故而各各语言中为字符串提供的操作集合严重依赖语言设计者对于实现的考虑。

集合

程序设计语言中的一个集合,也就是具有某个公共类型的任意数目的一组值的一种无序汇集。集合的元素所具有的类型叫做元类型或者基类型。现在的大多数程序设计语言都对集合提供了很大的支持,为集合提供了很多相关的操作

指针和递归类型

所谓的递归类型,就是可以在其对象中包含一个或多个本类型对象的引用类型。递归类型用于构造各种各样的“链接”数据结构,比如树。在一些对变量采用引用模型的语言中,很容易在创建这种递归类型,因为每个变量都是引用;在一些对变量采用值模型的语言中,定义递归类型就需要使用指针的概念,指针就是一种变量,其值是对其他对象的引用。

对于任何允许在堆里分配新对象的语言,都存在一个问题:若这种对象不在需要了,何时以及以何种方式收回对象占用的空间?对于那些活动时间很短的程序,让不用的存储留在那里,可能还可以接受,毕竟在它不活动时系统会负责回收它所使用的任何空间。但是大部分情况下,不用的对象都必须回收,以便腾出空间,如果一个程序不能把不再使用的对象存储回收,我们就认为它存在“内存泄漏”。如果这种程序运行很长一段时间,那么它可能就会用完所有的空间而崩溃。许多早期的语言要求程序员显示的回收空间,如C,C++等,另一些语言则要求语言实现自动回收不再使用的对象,如Java,C#以及所有的函数式语言和脚本语言。显示的存储回收可以简化语言的实现,但会增加程序员忘记回收不再使用的对象(造成内存泄漏),或者不当的回收了不该回收的正在使用的对象(造成悬空引用)的可能性。自动回收可以大大简化程序员的工作,但是为语言的实现带来了复杂度。

语法和操作

对指针的操作包括堆中对象的分配和释放,对指针间接操作以访问被它们所指的对象,以及用一个指针给另一个指针赋值。这些操作的行为高度依赖于语言是函数式还是命令式,以及变量/名字使用的是引用模型还是值模型。

函数式语言一般对名字采用某种引用模型(纯的函数式语言里根本没有变量和赋值)。函数式语言里的对象倾向于采取根据需要自动分配的方式。

命令式语言里的变量可能采用值模型或引用模型,有时是两者的某种组合。比如 A=B;

  • 值模型: 把B的值放入A。
  • 引用模型: 使A去引用B所引用的那个对象。

Java的实现方式区分了内部类型和用户定义的类型,对内部类型采用值模型,对用户定义的类型采用则采用引用模型,C#的默认方式与Java类似,另外还提供一些附加的语言特性,比如“unsafe”可以让程序员在程序中使用指针。

悬空引用

在前两篇的名字、作用域和约束中我们列举了对象的3种存储类别:静态、栈和堆。静态对象在程序的执行期间始终是活动的,栈对象在它们的声明所在的子程序执行期间是活动的,而堆对象则没有明确定义活动时间。

在对象不在活动时,长时间运行的程序就需要回收该对象的空间,栈对象的回收将作为子程序调用序列的一部分被自动执行。而在堆中的对象,由程序员或者语言的自动回收机制负责创建或者释放,那么如果一个活动的指针并没有引用合法的活动对象,这种情况就是悬空引用。比如程序员显示的释放了仍有指针引用着的对象,就会造成悬空指针,再进一步假设,这个悬空指针原来指向的位置被其他的数据存放进去了,但是实际却不是这个悬空指针该指向的数据,如果对此存储位置的数据进行操作,就会破坏正常的程序数据。

那么如何从语言层面应对这种问题呢?Algol 68的做法是禁止任何指针指向生存周期短于这个指针本身的对象,不幸的是这条规则很难贯彻执行。因为由于指针和被指对象都可能作为子程序的参数传递,只有在所有引用参数都带有隐含的生存周期信息的情况下,才有可能动态的去执行这种规则的检查。

废料收集

对程序员而已,显示释放堆对象是很沉重的负担,也是程序出错的主要根源之一,为了追踪对象的生存轨迹所需的代码,会导致程序更难设计、实现,也更难维护。一种很有吸引力的方案就是让语言在实现层面去处理这个问题。随着时间的推移,自动废料收集回收都快成了大多数新生语言的标配了,虽然它的有很高的代价,但也消除了去检查悬空引用的必要性了。关于这方面的争执集中在两方:以方便和安全为主的一方,以性能为主的另一方。这也说明了一件事,编程中的很多地方的设计,架构等等方面都是在现实中做出权衡。

废料收集一般有这两种思想,就不详细说了。

  • 引用计算
  • 追溯式收集

表具有递归定义的结构,它或者是空表,或者是一个有序对,有序对由一个对象和另一个表组成。表对于函数式或者逻辑式语言程序设计非常适用,因为那里的大多数工作都是通过递归函数或高阶函数来完成的。

在Lisp中:

(cons 'a '(b))  => (a b)
(car '(a b))    => a
(cdr '(a b c))  => (b c)

在Haskell和Python还由一个非常有用的功能,叫做列表推导。在Python中可以这样推导出一个列表

[i * i for i in range(1, 100) if i % 2 == 1]

文件和输入/输出

输入/输出(I/O)功能使程序可以与外部世界通信。在讨论这种通信时,将交互式I/O和文件I/O分开可能有些帮助。交互式IO通常意味着与人或物理设备通信,人或设备都与运行着的程序并行工作,送给程序的输入可能依赖程序在此之前的输出。文件通常对应于程序的地址空间之外的存储器,由操作系统实现。

有些语言提供了内置的File数据类型,另外一些语言将IO工作完全委托给库程序包,这些程序包导出一个file类型。所以IO也算作是一种数据类型

相等检测和赋值

对于简单的基本数据类型,如整数、浮点数和字符,相等检测和赋值相对来说都是直截了当的操作。其语义和实现也很明确,可以直接按照二进制位方式比较或复制,但是,对于更加复杂或抽象的数据类型,就可能还需要其它的比较方式

  • 相互是别名?
  • 二进制位是否都相等?
  • 包含同样的字符序列?
  • 如果打印出来,看起来完全一样?

就许多情况下,当存在引用的情况下,只有两个表达式引用相同的对象时它们才相等,这种称为浅比较。而对于引用的对象本身存在相等的含义时,这种比较称为深比较。对于复杂的数据结构,进行深比较可能要进行递归的遍历。所以相对来说,赋值也有深浅之分。深赋值时是进行完整的拷贝。

大多数的语言都使用浅比较和浅赋值

小结

本文从语言为何需要类型系统出发,解释了类型系统为语言提供了那些有价值的用途:1是为许多操作提供隐含的上下文,使程序员在许多情况下不必显示的描述这种上下文;2是使得编译器可以捕捉更广泛的各种各样的程序错误。 然后介绍了类型系统的三个重要规则:类型等价、类型相容、类型推理。以此3个规则推导出的强类型(绝不允许把任何操作应用到不支持该操作的对象上)、弱类型以及静态类型化(在编译阶段贯彻实施强类型的性质)、动态类型化的性质以及在对语言的使用方面的影响。以及后续介绍了语言中常见的一些数据类型的用途以及语言在实现这种类型方面所遇到的问题以及其大致的实现方式。

4 byte/32bits
name(2个字节) 2个字节的空洞
number(4个字节)
weight
(8个字节)
metallic(1个字节) 3个字节的空洞