名字和作用域
为什么要取名
看着代码中遍地都是的变量,函数,或多或少的我们都应该想过,为什么会有这些名字呢?
我们知道,计算机将数据存储到对应的物理内存中去。我们的操作就是基于数据的。我们需要使用这些数据,所以一个问题就是如何寻找到这些数据。一个较为直接的方式就是为它起个名字。
联系现实生活中的,最典型的就是图书馆。一本本书,一块块数据。为了查找,我们使用的是对各个数据地址进行编码。一一映射到一组唯一的数据上,以此便于查找的唯一替代彼不便于查找的唯一。
仔细想来,这种替代的方法似乎和哈希函数的思想有思想倒是有些接近。或许其中就是使用了呢?
实际上,每个名字对应的都是实际内存中的地址。因此也必须要有一个表来存储映射关系。
所以在语言的发展过程中,对照表的设计也是一个关键之处。
作用域的演变
在早期的程序设计语言中对照表是整个程序共有的。这样的设计,虽然一定程度上解决了映射的管理的问题,但是,这就相当于现在全局的作用域的概念,一处变,处处变,一次变,次次变。为了防止变量名的重复使用,防止名字的冲突,一种方法就是使用更长的变量名。在这种过程中,各种命名法就出现了。当然,对于现在来说,合理的命名对于程序的可读性,易扩展性也是有极大的帮助的。再有就是使用作用域的限定。
随着程序规模的扩大,为了更方便管理变量,将不同层级的变量各自划分范围,将各个变量的有效范围进行约束,于是出现了作用域的概念。
正是由于原先的类似于全局作用域的设定,导致当前出现了作用范围太大,出现冲突的可能性太大,进而由此想出了解决办法——缩小作用域。
作用域随着后来的发展,出现了两种类型——动态与静态。
所谓的作用域就是指某段程序文本代码。一个声明起作用的那一段程序文本区域,则称为这个声明的作用域。静态作用域是指声明的作用域是根据程序正文在编译时就确定的,有时也称为词法作用域。而在采用动态作用域的语言中,程序中某个变量所引用的对象是在程序运行时刻根据程序的控制流信息来确定的。
从对照表的角度来分析一下静态作用域和动态作用域。
动态
动态作用域中的对照表能被全部代码读取
- 最初全局对照表中记录了x与global的映射。
- 进入函数a,准备新的全局可见的对照表。
- 函数a写入变量x的值记录在新的对照表中。
- 进入函数b,由于还未退出a,故其对照表仍有效。所以读取其中的x的值。(参照变量的时候,按照由近及远的顺序读取。若是访问该对照表中没有记录的变量时,就翻转到外层的对照表查找,这里是全局对照表)
- 退出b。
- 退出a时,作废新的对照表。
如下例子:
$x = "global";
sub a {
local $x = "local";
&b();
}
sub b {
print "$x\n";
# 输出“local”
}
&a();
把变量原来的值实现保存在函数入口处,在出口处写回变量中。这样一来,在程序中间的改写在退出函数的时候不会继续影响。但是这样就要要求,凡当函数退出时,所有地方就要毫无遗漏的加上返回值的代码。于是处于懒惰的心理,我们总希望让计算机去完成这样的工作。
1991年发布的Perl4开始,Perl语言就增加了这样的功能。它通过把变量声明为
local
,就可以长程序处理器去承担“把原来的值另存起来随后返回”的任务。
这样的作用域称为动态作用域。动态作用域中被改写的值会影响到被调用函数,因此在引用变量时是什么样的值,不看函数调用方是无从得知的。这就使得在代码规模庞大时,不便于把握。
静态(又叫字面作用域,词法域等等)
静态作用域按照函数区分对照表
- 最初全局对照表中记录了x与global的映射。
- 进入函数a,准备函数a专用的对照表。
- 函数a写入变量x的值记录在专用的的对照表中。
- 进入函数b,准备函数b专用的对照表。
- 读取变量值,会先读取b专用表,没有结果则去读取全局变量的表。
- 退出b,作废该张对照表。
- 退出a,作废该张对照表。
如下例子:
$x = "global";
sub a {
my $x = "my";
&b();
}
sub b {
print "$x\n";
# 输出“global”
}
&a();
现在说全局变量不好或者全局污染原因便是在此。实际操作中尽量减小作用范围,是一种明智的选择。
静态作用域的不足
以一开始就采用了静态作用域的python语言为例:
2000年发布的python2.0中,对照表有三个层次(作用域)。从大到小分别是内置的,全局的,局部的。简单而言,每个程序都有一张整体对照表(内置),一张文件级别的对照表(全局),一张函数级别的对照表(局部)。
内置对照表,可以在程序的任何地方使用参照,全局对照表是针对每个文件的,因为有的语言也称之为文件作用域,局部作用域则是针对每个函数的对照表。
在实际使用中,主要出现以下问题:
嵌套函数的问题
python支持函数嵌套定义。
x = "global"
def a():
x = "a"
def b():
print x
b()
a()
在python2.0最初的设计中,如上函数嵌套时,当b中的局部作用域中找不到x,接下来去找的是全局作用域。
这样的设计引来很多误解,人们常常会以为,从表面看,因为a中包含b,所以a的作用域也包含b的作用域,当b中找不到x时,参照相邻的外部a的作用域。
后来在2011年发布的python2.1中设计修改为了逐层往外寻找的策略。
外部作用域的再绑定问题
指的是采用静态作用域时,无法变更嵌套作用域外部的变量。当嵌套的内层函数变量赋值时,若是当前作用域没有该值时,就会在当前的作用域定义一个新的局部变量。对这个名字进行了一次再次绑定,为他关联了另外的值。但是,这并不会影响外部的作用域。即无法变更外部的变量。
为了解决这个小问题,2006年的python3.0提供了关键字nolocal
,在函数开始时,声明变量为nolocal
性质。即主动地声明是非本地的。
这个关键字的选取,主要考虑了对于过往代码的兼容性,他在过去的代码中,出现的频度是最低的。
这样的
nolocal
和global
关键字有区别么?
对于和python很类似的ruby而言,则是使用了方法与代码段的区分。
函数发生嵌套,形式上有两种类型。方法套方法,方法套代码段。
如下两例:
# 方法在进行嵌套时作用于不嵌套
def foo()
x = "outside"
def bar() # 方法嵌套
p x # 会出错,因为无法访问外部的x
end
# 方法中有代码段时,方法的局部作用域中有的名字,在代码段中视为方法的局部变量,除此以外被视为代码段的局部变量。
# 相同名字则为方法的,不同的,则是自己的。
def foo()
x = "old"
lambda {x = "new"; y = "new"}.call
# x -> foo(), y -> lambda的本地变量
p x # new
p y # lambda的本地变量,外部无法访问
end
作用域总结
虽然现在很少会使用动态作用域,但这一概念并不是完全没有用处。与静态作用域中作用域是源代码级别上的一块完整独立的范围不同,在动态作用域中,作用域则是进入该作用域开始直至离开这一时间轴上的完整独立的范围。与此相同的特征也体现在其他好多地方。比如,在某处理进行期间,一时改变某变量的值随后将原值返回的代码编写方式就相当于创建了自己专属的动态作用域。又如,异常处理与动态作用域也很相似,函数抛出异常时的处理方式受到调用函数的 try/catch 语句的影响。
面向对象中像private
声明这样的访问修饰符,在限制可访问范围的作用上和作用域是非常相似的。private
将可访问范围限制在类之内,而protected
将此范围扩大到其继承类。这和函数调用处的变更会影响到调用里面的操作这一动态作用域表现是相似的,两者都具有这么一个缺点,这就是影响范围没有能限制在代码的某一个地方。
比如 Java 语言,它是静态作用域语言,它的类可以在源代码的任意处被访问。这意味着类是具有全局作用域的。但是类的名字具有层次并且只有导入后才能被使用,这避免了全局变量带来的无意的名字冲突。但是不管是全局变量还是类的静态成员都可以在源代码的任意地方被变更。这提醒我们,在享受使用上的便利的同时,要谨防滥用导致的代码难以理解的情况发生。
作用域是编写易于理解的代码的有力工具,很多地方都应用了这一概念。
类型
C,java,C++等语言中的int,void,double,float等等,这些是怎么出现的?现在又有了怎样的变化与发展?
类型是什么
类型是人们给数据附加的一种追加数据。计算机中保存的数据是由 on 和 off 或 0 和 1 的组合来表达的。至于 on 和 off 的组合(比特列)是如何表达各种数值的,哪种比特列表示哪种值,这些只不过是人们简单的约定事项而已。同样的比特列,当其被解释为的数据的类型不同时,得到的数值是不同的。为了避免这一情况的发生,人们追加了关于数据的类型信息,这就是类型的起源。
计算机中的数值是整数、浮点数还是其他类型的数,为了在计算机中管理这一信息,于是催生了类型。起初,类型中只加入了数值的种类信息,最后又有多种多样的信息加入进来。比如,能在这个数值上施加的操作、此函数可能抛出的异常等信息都被加入到类型中来了。现在,像静态类型和动态类型那样连内存地址和使用时间都不一样的事物也被称为类型,这使得类型这种东西变得越来越难以捉摸。什么样的信息放在什么地方,在什么样的时间被使用,从这个视角来看反而更容易理解。
表达数字的思考
如何在电子计算机中表达数值呢?如前所述,在计算机中所有的数值都用 on 和 off 或 0 和 1 的组合来表达。为了更形象地说明,我们换个角度来思考,该如何用灯泡的点亮与熄灭来表达数值呢?为了充分利用资源,只能是用最少的技术标量来表示最多的数。
完全按个数(n个计数标量)
-> 数位(阿拉伯数字)(10个计数标量)
-> 七段数码管(7个计数标量)
-> 算盘(最少的5个,上一下四)
-> 从十进制到二进制(9也只需要4个,1001)
把二进制中某几个字符组合在一起用一个字符来表示,使之变得更容易读,这种表达方式就是八进制或十六进制。
如何表达实数
实数的复杂之处在于在正整数的基础上添加了小数和负数。所以针对这两种情况进行设计。
定点数——小数点位置确定
一种方法是确定小数点的的位置。比如,约定好把整数的小数点向左移动四位,最低四位就是小数部分。这样一来,1 变成 0.0001,100 变成 0.0100 即 0.01.这种方法有个问题,它无法表达比 0.0001 小的数,比如无法表达 0.00001。当然只要把约定改为把整数的小数点向左移动五位得到小数部分就可以,但这样针对每一个新的小数都要记一句新的约定很困难,而且还容易出错。那该怎么办呢?
浮点数(floating point number)——数值本身包含小数部分何处开始的信息
以前关于浮点数有各种不同的约定,现在都标准化为 IEEE 75415。15官方名称为“IEEE Standard for Floating-Point Arithmetic (ANSI/IEEE Std 754-2008)"。IEEE 754 最早制定于 1985 年,后于 2008 年进行了修订。另外,在此标准规定有 5 种标准类型,这里仅仅说明了其中的单精度二进制浮点数。
左边那盏灯(最高比特位),也可以称之为 MSB(most significant bit),最高有效位 代表了数的符号。该位为 0 时表示正数,为 1 时表示负数,在标准中,零区分为正的零和负的零。
接下来的 8 盏灯是表示位数的指数部分。指数部分作为整数理解的话可以表达 0~255 之间的数,减去 127 得到范围-127~128。-127 和 128 分别代表了零和无限大,剩下的-126~127 代表了小数点的位置。-126 是指小数点向左移动 126 位,127 是指小数点向右移动 127 位。
其余的 23 盏灯是尾数部分,表示了小数点以下的部分。尾数部最左边的灯泡表示 1/2(二进制中的 0.1),接下来是 1/4(二进制中的 0.01)。请看图中的 1.75 这个数,它等于 1+1/2+1/4,用二进制来表示就是 1.11。所以,1/2 位的灯泡和 1/4 位的灯泡都点亮。指数部分为 127(要减去 127 就是范围中的 0),这表示小数点的位置移动 0 位。这两点组合起来就是 1.75。
准确来讲,尾数是在二进制表达中为使得整数部分变成 1 而移动小数点得到的小数部分。
接下来的数 3.5,用二进制来表示是 11.1。小数点向左移动一位就得到 1.11。所以它的尾数部分和 1.75 一样,1/2 位和 1/4 位点亮。指数部分变成 128(减去 127 就是范围中的 1)。3.5(二进制中的 11.1)其实就是 1.75(二进制中的 1.11)的小数点向右移动一位得到的数 19。而 7.0 则是由指数部分继续加 1 得到。
19在二进制中,小数点移动一位进位不是 10 倍而是 2 倍。指数部分加 1,变成 2 倍,减 1 变成 1/2。
浮点数的问题
现今大家接触到的语言中,实数大多用浮点数 IEEE 754 表达。从实用角度来看,大部分情况下这没有任何问题。但是,这种方法要表达 3 除以 10 的答案时,十进制中可以确切表达出来的 0.3 在二进制中却变成了 0.0100110011001100110011……这样的无限循环小数,无论怎么写都有误差存在。正因为如此,会出现对0.3做十次加法并舍去某些位数后得到2这样的现象。银行和外汇交易等涉及资金操作的场合尤其不欢迎这种系统行为,所以这些场合使用的是定点数或者加三码(excess-3)这样的十进制计算方式。
为什么出现类型
在内存中记录的数值是整数还是浮点数,单靠人的记忆很难避免错误。有没有更为简易的方法呢?
一种方法是用确定的规则来表示变量名所表达的内容。比如,早期的 FORTRAN 语言使用了一系列规则,指定以 I~N 开头的变量名表示整数,除此以外的表示浮点数。
-
另一种更好的方法是告诉处理器某某变量是整数,让计算机而不是人去记忆这一信息。这就是变量的类型的声明产生的原因。比如 C 语言中,声明 int x; 表示名字为 x 的变量指向的内存被解释为整数,声明 float y; 表示名字为 y 的变量指向的内存被解释为浮点数。这样通过提供关于类型的信息,处理器在进行运算时,就能自动判断该做整数相加运算还是浮点数相加运算,而不需要人们逐个去指定。
- 整数之间、浮点数之间的运算计算机参照数据的类型来决定怎样执行。如果 x 和 y 同为整数,就做整数之间的加法运算。如果 x 和 y 同为浮点数,就做浮点数之间的加法运算。
- 整数与浮点数之间的计算,则依据语言不同采用了不同的方法。有显示使用转换函数(如早期的 FORTRAN 语言),也有隐式自动转换的(如C)。
- C 语言中采用的设计方法是由计算对象的类型来决定是否舍去小数部分。这一方法在很长时间内被很多语言使用,以至于很多程序员都非常习惯,认为理所当然。然而,这个不是恒久不变的物理法则,只不过是人们确立的设计方法而已。因此并不是所有的语言都采用这种设计。
- 一些语言使用特定的运算符来处理是否保留小数的情况。
- 1973 年问世的 ML 语言中,整数的除法运算就表达为 x div y, 而浮点数的除法运算表达为 x / y。
- OCaml 中也用 x / y 和 x /. y 来区分整数的除法运算和浮点数的除法运算。
- 1991 年问世的 Python 语言起初使用的是混杂着 C 语言风格的除法运算方式。
- 2008 年发布的 Python 3.0 中,把 x / y 作为与 x 和 y 类型无关不做舍去的除法运算,带舍去的除法运算用 x // y 来表示。
类型的发展
- 使用语言中自带的基本数据类型通过组合定义新的类型的这一功能被发明出来。这被称为用户定义型。
- 如 C 语言中的结构体。
- 在 C 语言之前的 COBOL 语言中,可以用基本的类型组合起来定义一种带有层次结构的记录类型。
- PL/I 语言也有能组合基本类型并创建新的类型的语句
DEFINE STRUCTURE
。(结构体(structure)这个术语应该就是从那时候开始使用)的。
其实,不仅限于整数这样的数据,函数这样决定数据如何被处理的对象也被糅合到类型中来了。C++ 语言的设计者本贾尼·斯特劳斯特卢普把用户能自定义的类型当作构造程序的基本要素,把这种类型冠名为类。
- 后来出现了类型既是功能的观念。这种观念认为,构成结构体和类的类型不应该是全部公开而是最小限度地公开,类型是否一致这个交由编译器来检查,用类型来表达功能,与功能是否一致也是由编译器来检查。因此,只需要将与外部有交互的部分作为类型公开,而实现的细节则隐藏起来。这样类型就被区分为公开部分和非公开部分了。
- 出现了不包含有具体的实现细节的类型(Java 语言中的接口等)。
- 另外把函数是否抛出异常这一信息也当作类型。
类型即是功能的方法得到了越来越广泛地应用,但遗憾的是,用类型来实现所有功能的想法却还没有成功。如果它能成功,就很理想了:只要类型一致就不用关心内部的实现细节,功能与类型的不一致交由编译器来检查,编译通过意味着没有 bug。然而,仍有不少类型无法表达的信息,如输入这个数据需要多少处理时间,这个处理过程需要多少内存,线程中是否可以进行这种操作等。至今,这些问题也只能通过人为地读取文档和源代码来判断。
总称型、泛型和模板
通过将不同类型进行组合得到复杂的类型后,使用中会出现想更改其中一部分却又不想全部重新定义的再利用需求。
因此出现了构成要素部分可变的类型,即总称型。想要表现不同的情况时,出现了以类型为参数创建类型的函数(C++ 语言中的模板、Java 语言中的泛型以及 Haskell 语言中的类型构造器可以说就是这种创建类型的机制)。
动态静态类型
到目前为止,我们介绍的类型的机制中,处理器把变量名、保存数值的内存地址、内存里的内容的类型三者作为一个整体来看待。把类型的信息和数值看作整体的方式叫动态类型。作为其反义词,到目前为止介绍的类型机制都叫静态类型。现在大多数的脚本语言都采用了动态类型。
动态类型如何实现的呢?
是因为在内存上使用了同等类型对待的设计方法。比如 Python 语言中,不管是整数还是浮点数还是字符串,全部都作为 PyObject 对待,开始部分都是一样的。另外在 PyObject 类型的结构中还预留了保存值的类型信息的地方。这一点在其它的脚本语言中也是同样的情况,比如在 Ruby 语言中,任何数值都是 VALUE 类型的。
※ 使用次数是指在内存管理中记录这个数值有几处被参照引用的数值(引用计数)。
※ 字符串的散列值是散列函数的计算结果(详见第 9 章),状态是表示该字符串是否记录在 Internpool 里(处理器是否把该字符串进行唯一处理的标志)。
动态类型的优势与不足
使用这种数值类型处理方法,能实现历来静态类型语言不能实现的灵活处理。运行时确定类型和改变类型成为可能。然而,它也有一些不足。静态类型语言在编译时确定类型,同时编译时也检查了类型的一致性。有了这种类型检查,在实际执行前,便能发现一部分bug。这一点动态类型语言是无法做到的。
类型推断
既不放弃编译时的类型检查,也想尽量减少麻烦的类型声明,要实现这一要求就要用到计算机自动推论确定类型的方法。
同样是使用类型推断这一术语,在不同的语言中,如何做类型推断以及类型推断的能力如何,情况是不一样的。我们来比较一下 Haskell 语言和 Scala 语言。
GHCi
> :type identity identity
identity identity :: t -> t
> identity identity 1
1
Scala 语言中类型推断的行为和 Haskell 语言是不一样的。它会首先来定义 identify 函数。
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
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
类型推断与理论推论之间有对应关系。于是有些语言发出挑战,试图通过使用比 C 语言和 Java 语言更强力的类型系统来证明程序中没有任何 bug。今后在改善类型系统的表现力和类型推断规则方面应该会开展各种研究。
比如,在一个接受 X 型参数返回 Y 型返回值的函数中传递一个 X 型的数值,会得到 Y 型的返回值。这一关于类型的描述与“X 为真在如果 X 则 Y 的情况下,Y 就为真”这一逻辑的描述是相对应的,被称为 Curry-Howard 对应。