《编程机制探析》第五章 命令式编程
程序员文章站
2022-03-02 20:24:38
...
《编程机制探析》第五章 命令式编程
从本章开始,我们会接触到真实的编程语言。但本书并不是一本编程语言语法入门书,本书旨在讲述最关键的编程模型核心概念,因此,本书通常会直接跳到最能够体现该语言编程模型的编程代码范例,而不会从头讲述某一种语言的讲法。有过编程经验的读者,阅读代码会感觉轻松一些。我尽量写得平实易懂,希望没有过编程经验的读者,也能够比较容易地理解。
前面讲了,所谓计算机程序,其实就是一份计算机照着执行的工作流程表。那些工作流程表都是一条条的祈使命令语句组成的。比如,先做这个,再做那个。在这种条件下做这个,在那种条件下,做那个。等等。这种命令式的、顺序执行的编程模型,叫做命令式编程(Imperative Programming),对应的编程语言自然就是命令式语言(Imperative Language)。前面提到的计算机CPU的工作语言——汇编语言,就是命令式语言的一种。可见,计算机在硬件结构上天生就是命令式的、顺序式的。
命令式编程模型简单直观,而且符合底层计算机硬件结构,因而大行其道,相应的,命令式语言也是当前的主流编程语言。当然,一般来讲,主旋律之外都少不了杂音。命令式语言之外,当然也少不了非命令式编程语言,比如,函数式编程语言(Functional Programming Language),就是一种与命令式语言概念区别甚大的一种非命令式编程语言。
本书中涉及到的编程语言主要有两种——命令式语言和函数式语言。函数式语言将在本书的后续章节中讲述,本章先对命令式语言的一些重要概念进行阐述。
汇编语言是原初形式的命令式语言,其工作模型完全是建立在内存模型(包括寄存器在内)的基础上的。高级命令式语言虽然在表面概念上比汇编语言更加抽象,更加高端,但是,其内在运行模式仍然是建立在内存模型的基础之上的。
首先,我们需要着重讲解命令式语言中最基本、最核心的语句——“赋值”语句。
赋值语句是命令式语言中最常见的语句,形式上看起来很简单,但是,概念上却极其复杂。我们必须从赋值语句的基本形式开始,一步步讲述其基本概念。
在大部分的主流命令式编程语言中,“赋值”语句是用“等号”(=)来表述的。比如,在C、C++、Java、C#、Python、Ruby等命令式编程语言中,赋值语句都是用“=”这个符号来表述的。在这些语言中,“=”也叫做赋值符号。
赋值符号(“=”)的左边部分,叫做左值。赋值符号(“=”)的右边部分,叫做右值。左值和右值的定义十分直观,很容易理解。但是,接下来的问题,却没有那么容易理解了。
到现在为止,我们已经有了一条赋值语句的基本形式:左值 = 右值
那么,左值和右值具体是什么呢?用编程术语来说,左值是变量名,右值是表达式。
赋值语句就可以写成这样:变量名 = 表达式
这就涉及到编程语言中两个十分重要的概念——变量和表达式。这两个词语源自于数学(代数)中的概念。编程语言借用这两个词语来表达编程模型中类似的概念。当然,在编程语言中,这两个词语带有了一些编程语言特有的特色,不像在代数中那么纯粹。
表达式的概念相对来说纯粹一点,我们先来讲表达式的概念。
我们可以把表达式理解为一个数学公式。可以十分简单,比如,可以就是一个数字,也可以十分复杂,比如,(1 + 2)* 15 / 26 – 13。
注:在计算机键盘上并没有乘号和除号这两个符号,因此,在计算机语言编程中,我们用 * 表示乘号,用 / 表示除号。这也是计算机编程语言的一种惯例。
变量的概念相对来说就不那么纯粹了。一方面,编程语言的变量的概念与代数中的变量概念类似,变量名通常以字母开头,代表变化的数值。另一方面,编程语言的变量又具有自己的特色。下面我们就主要讲解编程语言中的变量的诸多特性。
在编程语言的赋值语句中,变量名既可以出现在左边,也可以出现在右边的表达式。
比如,下述两个赋值语句。
x = 1
y = x + 1
x和y都是变量名。x既可以出现在赋值符号(=)的左边,也可以出现在赋值符号(=)的右边。
到现在为止,一切都显得那么和谐。上面的两条赋值语句看起来很正常,等号两边的变量或者表达式都满足相等的条件。“=”这个符号既表达了赋值符号的含义,又表达了“相等”的含义,与代数中的含义完全一致。看起来,一切都很顺利。但是,这只是假象。在编程语言中,赋值符号(=)丝毫没有“相等”的含义。之所以看起来好像“相等”,只是因为巧合而已。下面,我们再来看一条在代数中不可能成立的赋值语句。
x = x + 1
这种表达在代数中是不成立的,在命令式编程语言中,却是完全成立的。那么,这条语句代表了什么含义呢?
我们前面说过,命令式语言是有顺序的、并且按照顺序执行的。赋值语句的执行过程是这样的,首先,执行赋值符号(=)右边的表达式,得出结果之后,再把结果存入赋值符号左边的变量名。
那么,x = x + 1 这条语句的执行过程就是这样的。
首先,计算机执行赋值符号(=)右边的表达式 x + 1。在执行这个表达式的时候,计算机首先要取得x的值,然后,再把这个值和1相加,得到一个结果数值。到此,右边表达式结束,计算机继续执行,将得到的表达式计算结果存入到x这个变量名。这条语句执行结束之后,最终,x的数值就比之前增加了1。比如,x之前是0,那么赋值语句执行之后就是1。如果x之前是15,那么赋值语句执行之后就是16。
我们可以看到,x这个变量名具有两面性。一方面,x可以被计算机读取(计算机执行右边表达式的时候);一方面,x还可以被计算机写入(计算机执行赋值语句到最后一个步骤的时候)。
在命令式编程语言中,所有的变量名都具有这种两面性。可以读,也可以写,因此,可能时时变化。这也符合了变量这个名词的字面含义——可以变化的数量值。与之相对的,就是常量这个名词。常量是具有固定数值的、不可改变的数量值。常量的概念比较简单,不再赘述。
初次接触编程语言的读者,通常会被赋值符号(=)的含义弄糊涂。所以,一定要记住,在绝大多数命令式编程语言中,“=”的含义不是“相等”,而是“赋值”。
在一些语法严格、定义规范的语言中,比如,Pascal语言,赋值符号并不是“=”,而是“:=”,即,等号前面多了一个冒号(“:”)。这种赋值符号的定义,有效地减少了概念上混淆的可能性。当然,这种赋值符号的定义方法带来了一个不便之处,那就是每次都要多写一个冒号。
我的建议是,当你初次接触编程语言时,可以先从Pascal语言开始。当学会了简单的语法和编程概念之后,再转向其他主流编程语言。这样可以避免赋值符号带来的概念上的混淆。
另外,既然本书已经涉及到了具体语法,这里就顺便给出一些编程语言语法学习方面的一些提示。
每一门编程语言都有自己的主网站。当你学习某一门编程语言的时候,最好从那门语言的主网站开始,那上面都有一些入门的简单例子,可以帮助你迅速获得一个直观印象。
如何才能找到某一门编程语言的主网站呢?有经验的读者早就知道答案了。对了,当然是用搜索引擎。比如,你想搜索python这个语言的主网站,你就可以在你就在搜索引擎(google, yahoo, baidu, bing等)中输入python这个英文单词,然后搜索。有时候,如果某一门编程语言的名字太过大众化,你还需要加上language(语言)这个英文单词,从而得到更准确的搜索结果。
你遇到语法概念不清楚的问题的时候,你想深入了解其语法定义规范的时候,你需要知道这么一个英文单词(Specification)。比如,如果你想了解Java语法规范的时候,你就在搜索引擎中使用这么几个关键字:Java Language Specification。
这样,你就可以迅速定位到该语言的语法规范定义。通过阅读语法规范,你可以对这门语言的设计初衷和实现思路有个大致了解,从而更好地了解该门语言的优缺点。
另外一个你需要掌握的关键字是BNF。这个英文缩写的含义是巴科斯范式(BNF: Backus-Naur Form 的缩写),是由 John Backus 和 Peter Naur 首次引入一种形式化符号来描述给定语言的语法。
几乎每一门编程语言都有自己的BNF语法形式描述。BNF语法形式描述直观地定义了某一门编程语言的词法分析构成,所有的语法结构和关键字都一目了然。
BNF本身也是一门语言,而且是定义语言的语言,自然也有自己的语法,不过,BNF的语法规则很简单,多看几遍就会了,不用费什么功夫。
我的建议是,当你学习某一门语言的时候,最好顺便观摩一下该语言的BNF定义。如何观摩呢?当然是在网上搜索。比如,你想看一下C语言的BNF定义,你就搜索C和BNF这两个关键字就可以了。
随着你对编程语言认识的深入,BNF的重要性就会愈发凸显出来,尤其是当你打算自己定义一门语言的时候。
好了,现在让我们回到赋值语句的主题。前面讲到,变量名既可以出现在赋值符号(=)的左边,也可以出现在赋值符号(=)的右边。当变量名出现在赋值符号右边的时候,就表示读取这个变量的值。当变量名出现在左边的时候,就表示整个表达式的执行结果将存入这个变量。那么,变量到底是个什么东西呢?
我们知道,所有的计算机程序全都是在计算机内存中运行的。变量可以读,也可以写,这非常符合内存单元的特性。很明显的,变量可以对应到一个内存单元。
如同上一章结尾时的那个例子,我们可以想象一个巨大的柜子(内存),上面布满了小格子(最小内存单元)。每个小格子都有自己单独的地址(内存地址)。其中一个小格子上面贴了一个标签,上面写着“x”。这就定义了一个名字叫做“x”的变量。
当“x”出现在赋值符号的右边表达式中的时候,就意味着,贴着“x”标签的内存单元中的内容被读出。当“x”出现在赋值符号的左边的时候,就意味着,贴着“x”标签的内存单元中的内容被改变成右边表达式的最终执行结果。
这个模型映射得很好,非常直观,一点弯也都没有绕。高级命令式语言中的变量概念可以直接映射到计算机内存结构中。
但是,现在有这样一个问题。我们需要将高级命令式语言中的变量概念直接映射到内存结构中吗?换句话说,我们既然使用了高级语言,为什么不能在更抽象、更高级的层次上编程呢?为什么一定要在脑海中把变量这个概念映射到内存单元的层次呢?
其实,高级语言确实有这样的设计初衷,希望程序员能够在更高级、更抽象的层次上思考问题,而不用考虑底层实现细节。在很多浅显的编程入门书籍中,根本就不会讲到变量与内存单元映射的这个知识点。本书特意强调这个知识点,是从我本人的实际经验出发。
在我学习和应用高级命令式语言的过程中,我发现,高级命令式语言中有不少概念含糊不清,模棱两可,含糊不清,很难说得通,但是,一旦引入内存模型的概念,一切迷雾就迎刃而解了。因此,本书特意对内存模型的概念加以强调,以便帮助读者加深对各种编程概念的理解。
掌握了变量与内存映射关系这个知识点后,我们就可以继续研究更加复杂的赋值语句了。
我们前面讲到的赋值语句是最简单的赋值语句。出现在赋值符号左边的只是一个简单的变量名。实际上,能够出现在赋值符号左边的内容远远不止如此。我们下面就来讲解更加复杂的赋值语句——复合结构的赋值。
什么叫做复合结构呢?比如,我们大家一般都用过手机,也应该都知道,手机里面有个联系人名录,里面记录了联系人的姓名、手机、住宅电话、公司电话、备注等信息。在这个例子里面,每个联系人条目就是一个包含了姓名、手机、住宅电话、公司电话、备注等信息的复合结构。
这个概念不难理解,但是,如果认真详细解释起来,还需要费一大堆口舌。本书不打算在这种简单易懂的概念上浪费口水。读者如果有什么不明白的,可以参考具体语言中的复合结构的概念和定义。比如,在C语言中,复合结构的对应数据类型叫做“structure”(结构)。在C++、Java、Python、Ruby等更加高级的面向对象的语言当中,复合结构的对应数据类型叫做“Class”(类)。
注:面向对象是一个非常重要的概念,是命令式编程语言的主流编程模型。本书后面会加以详细讲述。
现在,假设我们有一个叫做contact(联系方式)的复合结构数据,其中包含name、mobile、home_phone、office_phone、memo等属性。我们就可以这样一条条设置contact这个数据的每一个属性。
contact.name = “Tom”
contact.mobile = “1338978776”
contact.home_phone = “8978776”
contact.office_phone = contact.home_phone
contact.memo = “Tom is a SOHO. He works at home.”
上述语句中的“.”表示访问复合结构的内部数据。比如,contact.name就表示contact这个复合结构数据中的name属性。这也是高级命令式语言的一种语法惯例。
从上面的例子中可以看到,复合结构变量的属性的用法和简单变量完全相同。复合结构变量的属性既可以出现在赋值符号的左边,也可以出现在赋值符号的右边。比如,contact.home_phone先是出现在“=”的左边,接着又出现在“=”的右边。
那么,我们如何在内存结构中理解复合结构呢?首先,我们还是要给内存单元贴上标签。我们想象一下,在一个布满了小格子的大柜子里面,选出一个小格子,然后在上面贴上“contact”这个标签。然后,我们从贴上“contact”的那个小格子开始,根据每个属性的数据宽度(即占用最小内存单元的个数),依次贴上“name”、“mobile”、“home_phone”、“office_home”、“memo”等几个标签。
在这个例子中,“contact”就相当于内存中的一个基本地址,而那些属性则相当于以基本地址为基础的几个偏移地址。
当我们访问contact的属性的时候,实际上就相当于访问“contact”基本地址再加上属性偏移地址的那个单元格的内容。比如,contact.name实际上就是contact基本地址加上name属性偏移地址之后的那个单元格的内容。对于复合结构的属性的访问,实际上就是一次内存中的间接寻址。
当我们定义了一个包含了多个属性的复合结构的时候,实际上就相当于我们自己定义了一套内存结构映射方案。
这还不是最简单的情况,复合结构里面还有可能包含复合结构。事实上,复合结构的嵌套层次是没有限制的,可以嵌套到任意深度。因此,我们有可能写出这样的访问深层次属性的代码:contact.address.city.zipcode。
还是那个老问题:我们需要把复合结构的概念理解道内存结构映射的层次吗?
我还是那句老话:需要。
即使你现在不需要,以后早晚也会需要。随着你对编程语言掌握的深入,你早晚需要理解到这个层次。与其到时候费二遍功,还不如现在就一次搞定。
复合结构并非唯一的内存结构映射定义。在命令式语言中,还有一个极为常见的类型——数组类型,同样对内存结构进行了映射。
数组类型对内存结构的映射是一种十分整齐的映射。我们可以想象一列整整齐齐的单元格,每个单元格的数据宽度完全相等。因此,我们可以通过简单的等距位移来访问其中某一个单元格的内容。事实上,数组正是通过数字下标来访问其中某一个位置的数据的。
比如,假设我们有一个数组变量array。我们可以想象一列长长的宽度相同的单元格,第一个单元格上贴着一个标签“array”。
我们想访问array数组中第30个数据。我们就可以这么写,array[30],就可以定位到array数组的第30个数据单元。
“[]”这样的方括号,表示访问一个数组中的某一个位置。这也是高级命令式语言的一种语法惯例。
同样,array[30]可以出现在赋值表达式的左边,也可以出现在赋值表达式的右边。比如:
array[30] = 1
array[31] = array[30]
需要注意的是,在很多命令式语言中,数组下标是从0开始的。因此,如果我们想访问第30个数据单元,很多情况下,我们必须写成array[29]。
综上所述,出现在赋值符号左边的变量,主要就是三种——简单变量、复合结构变量、数组变量。而且,这三种变量都有一个特点,他们都可以唯一定位到内存中的某一个具体位置。这体现了赋值语句的最根本的含义——将一个表达式执行的结果存入到某一个指定的内存单元中去。我们对赋值语句的理解,必须达到这个层次,才能够正确理解随之而来的一系列相关概念。
另外,在一些更高级别的命令式编程语言(如Python、Ruby等),实现了部分函数式编程语言(Functional Programming Language)的部分特性,如模式匹配(Pattern Match)这样的特性。在这些语言中,有可能出现一次对多个变量同时赋值的赋值语句。比如:
(a, b) = (1, 2)
a, b = 1, 2
某些情况下,这种语法用起来相当方便。比如,某个复合结构中有多个属性,我们想就把其中一些属性一次性复制到多个变量中的时候,就可以这么写。
想深入了解这种语法的读者,可以去研究一下函数式编程语言中的模式匹配特性。
当然,在我个人看来,即使在函数式编程语言中,模式匹配也不是什么核心的概念,只不过是一种简化书写的语法糖。掌握不掌握,都对编程核心概念的理解没有什么本质影响。
以上讲述的赋值语句都是“显式”赋值语句,即存在明确的赋值符号(=)的赋值语句。除了显式赋值语句之外,还有一种特殊的隐式赋值语句——参数传递。
参数,也叫做参变量,是一种特殊的变量。为了说明参变量和普通变量之间的异同点,下面我们给出一段具体的代码例子。
这里需要说明的是,本书中给出的相当一部分示例代码,都是用极为简化的伪代码写的,并不能够真正运行。这样做的目的是,最大限度保证代码的简化性、易读性、通用性。
本书只有在讲解某些不运行就难以理解的复杂概念的时候,才会给出真正的可以运行的某种具体编程语言的代码。而且,这么做的时候,本书会尽量采用最为流行的编程语言。
下面,就先让我们看一段包含了参变量和变量定义的伪代码。
f(x) {
a = 2 * x
if a < 5
return 0
else if a > 10
return 2
else
return 1
}
上面的伪代码结合了Javascript和Python这两种语言的最简化表达方法。由于省略了过程声明、类型声明,写起来极为简单,一目了然。
其中,if和return这两个关键字也是编程语言中常见的,不需要过多的解释;f(x)定义了一个名字叫做f的只有一个参数x的过程;{}这对大括号则包括了过程体内部的代码,这也是C、C++、Java等主流编程语言的语法惯例。
另外需要特别说明的一点是,在命令式语言中,过程(Procedure)通常还有几种其他的叫法,比如,函数(Function),方法(Method)等。本书后面会讲到函数式编程语言(Functional Programming),为了避免混淆,本书在讲解命令式语言时,将尽量避免使用函数(Function)这个词,而是尽量使用过程(Procedure)和方法(Method)这两个词。
上述代码中,x是一个参变量(参数),a则是过程体内部定义的普通变量(另,在过程体内声明的变量也叫做局部变量)。在函数体内,我们看不到类似于“x = …”的赋值语句。那么,x是什么时候被赋值的呢?答案是,当f(x)这个函数被调用的时候,参数才会被赋值。
比如,我们有另一条语句,n = f(4)
这条语句在执行的时候,就会把4这个值赋给x,然后,进入f(x)的过程体代码,执行整个过程。
那么,这一切在内存中是怎么发生的呢?
我们又要回到前面讲过的运行栈的概念了。每个进程运行起来之后,都有自己的运行栈。
本章前面提到了数组这个数据结构的简单概念和用法。在我们的想象中,数组是一串横着摆放的内存单元格。现在,我们把这一串横着摆放的单元格竖起来,让它竖着摆放,这样,看起来,就是一个栈结构了。实际上,运行栈的基本实现结构,就是数组结构。
这个地方,要说明一下。运行栈也是内存中的一部分。运行栈中的地址,也是一种内存地址。
运行栈(stack)和内存堆(heap)是两种常见的内存分配方式。运行栈的结构很简单,就是一个数组结构加一个存放栈顶地址的内存单元。内存堆(heap)是一种比较复杂的树状数据结构,可以有效地搜寻、增、删、改内存块。一般来说,我们不必关注其具体实现。
分配在运行栈(stack)上的数据,其生命周期由过程调用来决定。分配在内存堆(heap)的数据,其生命周期超出了过程调用的范围。内存堆中的数据,需要程序员写代码显式释放,或者由系统自动回收。
我们回到例子代码。当计算机执行f(4)这个过程调用的时候,实际上是先把4这个数值放到了运行栈里面,然后,转向f(x)过程体定义的工作流程,执行其中的代码。
进入f(x)过程体后,遇到的第一条代码就是关于变量a的赋值语句“a = 2 * x”。计算机首先在运行栈为a这个变量预留一个位置。这个位置是运行栈内的一个内存单元。
我们可以想象一下,在一条竖着摆放的内存单元格中,最上面一个内存单元格上贴上了“a”这个标签。紧挨着“a”单元格下面的那个单元格上就贴着“x”这个标签。“x”单元格内的内容就是4这个数值。
接下来,计算机执行2 * x 这个表达式。得出结果后,把结果存入到运行栈中之前为a预留的内存单元中。
从上面的描述中,我们可以看出,无论是a这个普通局部变量,还是x这个参变量,具体位置都是在运行栈中分配的。所不同之处在于,a这个普通局部变量的赋值是进入f(x)过程之后发生的,而x这个参变量的赋值是在f(x)过程调用之前就发生了。
所有的过程调用都会产生一个参数值压入运行栈的动作,即,把对应的参数值压入到运行栈上预先为参数分配的位置中。因此,有时候,我们也把过程调用前的参数传递叫做参数压栈。
上面举的例子是参数压栈的最简单的例子,只有一个参数。很多情况下,过程不止需要一个参数,有可能有多个参数。这就涉及到一个参数压栈顺序的问题,是从左向右压,还是从右向左压?这是一个问题。不同的语言实现有不同的做法。不过,在我个人看来,这不是什么重要的知识点,只是一种资料性的知识,不需要费心去理解。用到的时候再查资料就行了。
在结束本章之前,我们在来问本章最后一个问题:所有的命令式语言,都是基于栈结构的吗?
不知读者有没有思考过这个问题。在我们讲解汇编语言的时候,在我们讲解高级命令式语言的时候,都提到了进程的运行栈。似乎,运行栈就是天然存在的,不需要多想。
没错,进程的运行栈就是天然存在的,汇编语言需要运行栈,操作系统也需要运行栈。我们可以说,绝大部分命令式语言都是基于栈结构的,但不是全部。比如,Python语言有一个实现,叫做Stackless Python(无栈的Python),这就是一种允许程序员脱离运行栈结构的语言实现。当然,这只是一个特例,无关大局。在绝大多数情况下,当我们考虑命令式语言的时候,脑海里应该自然而然就浮现出一个运行栈的结构。
从本章开始,我们会接触到真实的编程语言。但本书并不是一本编程语言语法入门书,本书旨在讲述最关键的编程模型核心概念,因此,本书通常会直接跳到最能够体现该语言编程模型的编程代码范例,而不会从头讲述某一种语言的讲法。有过编程经验的读者,阅读代码会感觉轻松一些。我尽量写得平实易懂,希望没有过编程经验的读者,也能够比较容易地理解。
前面讲了,所谓计算机程序,其实就是一份计算机照着执行的工作流程表。那些工作流程表都是一条条的祈使命令语句组成的。比如,先做这个,再做那个。在这种条件下做这个,在那种条件下,做那个。等等。这种命令式的、顺序执行的编程模型,叫做命令式编程(Imperative Programming),对应的编程语言自然就是命令式语言(Imperative Language)。前面提到的计算机CPU的工作语言——汇编语言,就是命令式语言的一种。可见,计算机在硬件结构上天生就是命令式的、顺序式的。
命令式编程模型简单直观,而且符合底层计算机硬件结构,因而大行其道,相应的,命令式语言也是当前的主流编程语言。当然,一般来讲,主旋律之外都少不了杂音。命令式语言之外,当然也少不了非命令式编程语言,比如,函数式编程语言(Functional Programming Language),就是一种与命令式语言概念区别甚大的一种非命令式编程语言。
本书中涉及到的编程语言主要有两种——命令式语言和函数式语言。函数式语言将在本书的后续章节中讲述,本章先对命令式语言的一些重要概念进行阐述。
汇编语言是原初形式的命令式语言,其工作模型完全是建立在内存模型(包括寄存器在内)的基础上的。高级命令式语言虽然在表面概念上比汇编语言更加抽象,更加高端,但是,其内在运行模式仍然是建立在内存模型的基础之上的。
首先,我们需要着重讲解命令式语言中最基本、最核心的语句——“赋值”语句。
赋值语句是命令式语言中最常见的语句,形式上看起来很简单,但是,概念上却极其复杂。我们必须从赋值语句的基本形式开始,一步步讲述其基本概念。
在大部分的主流命令式编程语言中,“赋值”语句是用“等号”(=)来表述的。比如,在C、C++、Java、C#、Python、Ruby等命令式编程语言中,赋值语句都是用“=”这个符号来表述的。在这些语言中,“=”也叫做赋值符号。
赋值符号(“=”)的左边部分,叫做左值。赋值符号(“=”)的右边部分,叫做右值。左值和右值的定义十分直观,很容易理解。但是,接下来的问题,却没有那么容易理解了。
到现在为止,我们已经有了一条赋值语句的基本形式:左值 = 右值
那么,左值和右值具体是什么呢?用编程术语来说,左值是变量名,右值是表达式。
赋值语句就可以写成这样:变量名 = 表达式
这就涉及到编程语言中两个十分重要的概念——变量和表达式。这两个词语源自于数学(代数)中的概念。编程语言借用这两个词语来表达编程模型中类似的概念。当然,在编程语言中,这两个词语带有了一些编程语言特有的特色,不像在代数中那么纯粹。
表达式的概念相对来说纯粹一点,我们先来讲表达式的概念。
我们可以把表达式理解为一个数学公式。可以十分简单,比如,可以就是一个数字,也可以十分复杂,比如,(1 + 2)* 15 / 26 – 13。
注:在计算机键盘上并没有乘号和除号这两个符号,因此,在计算机语言编程中,我们用 * 表示乘号,用 / 表示除号。这也是计算机编程语言的一种惯例。
变量的概念相对来说就不那么纯粹了。一方面,编程语言的变量的概念与代数中的变量概念类似,变量名通常以字母开头,代表变化的数值。另一方面,编程语言的变量又具有自己的特色。下面我们就主要讲解编程语言中的变量的诸多特性。
在编程语言的赋值语句中,变量名既可以出现在左边,也可以出现在右边的表达式。
比如,下述两个赋值语句。
x = 1
y = x + 1
x和y都是变量名。x既可以出现在赋值符号(=)的左边,也可以出现在赋值符号(=)的右边。
到现在为止,一切都显得那么和谐。上面的两条赋值语句看起来很正常,等号两边的变量或者表达式都满足相等的条件。“=”这个符号既表达了赋值符号的含义,又表达了“相等”的含义,与代数中的含义完全一致。看起来,一切都很顺利。但是,这只是假象。在编程语言中,赋值符号(=)丝毫没有“相等”的含义。之所以看起来好像“相等”,只是因为巧合而已。下面,我们再来看一条在代数中不可能成立的赋值语句。
x = x + 1
这种表达在代数中是不成立的,在命令式编程语言中,却是完全成立的。那么,这条语句代表了什么含义呢?
我们前面说过,命令式语言是有顺序的、并且按照顺序执行的。赋值语句的执行过程是这样的,首先,执行赋值符号(=)右边的表达式,得出结果之后,再把结果存入赋值符号左边的变量名。
那么,x = x + 1 这条语句的执行过程就是这样的。
首先,计算机执行赋值符号(=)右边的表达式 x + 1。在执行这个表达式的时候,计算机首先要取得x的值,然后,再把这个值和1相加,得到一个结果数值。到此,右边表达式结束,计算机继续执行,将得到的表达式计算结果存入到x这个变量名。这条语句执行结束之后,最终,x的数值就比之前增加了1。比如,x之前是0,那么赋值语句执行之后就是1。如果x之前是15,那么赋值语句执行之后就是16。
我们可以看到,x这个变量名具有两面性。一方面,x可以被计算机读取(计算机执行右边表达式的时候);一方面,x还可以被计算机写入(计算机执行赋值语句到最后一个步骤的时候)。
在命令式编程语言中,所有的变量名都具有这种两面性。可以读,也可以写,因此,可能时时变化。这也符合了变量这个名词的字面含义——可以变化的数量值。与之相对的,就是常量这个名词。常量是具有固定数值的、不可改变的数量值。常量的概念比较简单,不再赘述。
初次接触编程语言的读者,通常会被赋值符号(=)的含义弄糊涂。所以,一定要记住,在绝大多数命令式编程语言中,“=”的含义不是“相等”,而是“赋值”。
在一些语法严格、定义规范的语言中,比如,Pascal语言,赋值符号并不是“=”,而是“:=”,即,等号前面多了一个冒号(“:”)。这种赋值符号的定义,有效地减少了概念上混淆的可能性。当然,这种赋值符号的定义方法带来了一个不便之处,那就是每次都要多写一个冒号。
我的建议是,当你初次接触编程语言时,可以先从Pascal语言开始。当学会了简单的语法和编程概念之后,再转向其他主流编程语言。这样可以避免赋值符号带来的概念上的混淆。
另外,既然本书已经涉及到了具体语法,这里就顺便给出一些编程语言语法学习方面的一些提示。
每一门编程语言都有自己的主网站。当你学习某一门编程语言的时候,最好从那门语言的主网站开始,那上面都有一些入门的简单例子,可以帮助你迅速获得一个直观印象。
如何才能找到某一门编程语言的主网站呢?有经验的读者早就知道答案了。对了,当然是用搜索引擎。比如,你想搜索python这个语言的主网站,你就可以在你就在搜索引擎(google, yahoo, baidu, bing等)中输入python这个英文单词,然后搜索。有时候,如果某一门编程语言的名字太过大众化,你还需要加上language(语言)这个英文单词,从而得到更准确的搜索结果。
你遇到语法概念不清楚的问题的时候,你想深入了解其语法定义规范的时候,你需要知道这么一个英文单词(Specification)。比如,如果你想了解Java语法规范的时候,你就在搜索引擎中使用这么几个关键字:Java Language Specification。
这样,你就可以迅速定位到该语言的语法规范定义。通过阅读语法规范,你可以对这门语言的设计初衷和实现思路有个大致了解,从而更好地了解该门语言的优缺点。
另外一个你需要掌握的关键字是BNF。这个英文缩写的含义是巴科斯范式(BNF: Backus-Naur Form 的缩写),是由 John Backus 和 Peter Naur 首次引入一种形式化符号来描述给定语言的语法。
几乎每一门编程语言都有自己的BNF语法形式描述。BNF语法形式描述直观地定义了某一门编程语言的词法分析构成,所有的语法结构和关键字都一目了然。
BNF本身也是一门语言,而且是定义语言的语言,自然也有自己的语法,不过,BNF的语法规则很简单,多看几遍就会了,不用费什么功夫。
我的建议是,当你学习某一门语言的时候,最好顺便观摩一下该语言的BNF定义。如何观摩呢?当然是在网上搜索。比如,你想看一下C语言的BNF定义,你就搜索C和BNF这两个关键字就可以了。
随着你对编程语言认识的深入,BNF的重要性就会愈发凸显出来,尤其是当你打算自己定义一门语言的时候。
好了,现在让我们回到赋值语句的主题。前面讲到,变量名既可以出现在赋值符号(=)的左边,也可以出现在赋值符号(=)的右边。当变量名出现在赋值符号右边的时候,就表示读取这个变量的值。当变量名出现在左边的时候,就表示整个表达式的执行结果将存入这个变量。那么,变量到底是个什么东西呢?
我们知道,所有的计算机程序全都是在计算机内存中运行的。变量可以读,也可以写,这非常符合内存单元的特性。很明显的,变量可以对应到一个内存单元。
如同上一章结尾时的那个例子,我们可以想象一个巨大的柜子(内存),上面布满了小格子(最小内存单元)。每个小格子都有自己单独的地址(内存地址)。其中一个小格子上面贴了一个标签,上面写着“x”。这就定义了一个名字叫做“x”的变量。
当“x”出现在赋值符号的右边表达式中的时候,就意味着,贴着“x”标签的内存单元中的内容被读出。当“x”出现在赋值符号的左边的时候,就意味着,贴着“x”标签的内存单元中的内容被改变成右边表达式的最终执行结果。
这个模型映射得很好,非常直观,一点弯也都没有绕。高级命令式语言中的变量概念可以直接映射到计算机内存结构中。
但是,现在有这样一个问题。我们需要将高级命令式语言中的变量概念直接映射到内存结构中吗?换句话说,我们既然使用了高级语言,为什么不能在更抽象、更高级的层次上编程呢?为什么一定要在脑海中把变量这个概念映射到内存单元的层次呢?
其实,高级语言确实有这样的设计初衷,希望程序员能够在更高级、更抽象的层次上思考问题,而不用考虑底层实现细节。在很多浅显的编程入门书籍中,根本就不会讲到变量与内存单元映射的这个知识点。本书特意强调这个知识点,是从我本人的实际经验出发。
在我学习和应用高级命令式语言的过程中,我发现,高级命令式语言中有不少概念含糊不清,模棱两可,含糊不清,很难说得通,但是,一旦引入内存模型的概念,一切迷雾就迎刃而解了。因此,本书特意对内存模型的概念加以强调,以便帮助读者加深对各种编程概念的理解。
掌握了变量与内存映射关系这个知识点后,我们就可以继续研究更加复杂的赋值语句了。
我们前面讲到的赋值语句是最简单的赋值语句。出现在赋值符号左边的只是一个简单的变量名。实际上,能够出现在赋值符号左边的内容远远不止如此。我们下面就来讲解更加复杂的赋值语句——复合结构的赋值。
什么叫做复合结构呢?比如,我们大家一般都用过手机,也应该都知道,手机里面有个联系人名录,里面记录了联系人的姓名、手机、住宅电话、公司电话、备注等信息。在这个例子里面,每个联系人条目就是一个包含了姓名、手机、住宅电话、公司电话、备注等信息的复合结构。
这个概念不难理解,但是,如果认真详细解释起来,还需要费一大堆口舌。本书不打算在这种简单易懂的概念上浪费口水。读者如果有什么不明白的,可以参考具体语言中的复合结构的概念和定义。比如,在C语言中,复合结构的对应数据类型叫做“structure”(结构)。在C++、Java、Python、Ruby等更加高级的面向对象的语言当中,复合结构的对应数据类型叫做“Class”(类)。
注:面向对象是一个非常重要的概念,是命令式编程语言的主流编程模型。本书后面会加以详细讲述。
现在,假设我们有一个叫做contact(联系方式)的复合结构数据,其中包含name、mobile、home_phone、office_phone、memo等属性。我们就可以这样一条条设置contact这个数据的每一个属性。
contact.name = “Tom”
contact.mobile = “1338978776”
contact.home_phone = “8978776”
contact.office_phone = contact.home_phone
contact.memo = “Tom is a SOHO. He works at home.”
上述语句中的“.”表示访问复合结构的内部数据。比如,contact.name就表示contact这个复合结构数据中的name属性。这也是高级命令式语言的一种语法惯例。
从上面的例子中可以看到,复合结构变量的属性的用法和简单变量完全相同。复合结构变量的属性既可以出现在赋值符号的左边,也可以出现在赋值符号的右边。比如,contact.home_phone先是出现在“=”的左边,接着又出现在“=”的右边。
那么,我们如何在内存结构中理解复合结构呢?首先,我们还是要给内存单元贴上标签。我们想象一下,在一个布满了小格子的大柜子里面,选出一个小格子,然后在上面贴上“contact”这个标签。然后,我们从贴上“contact”的那个小格子开始,根据每个属性的数据宽度(即占用最小内存单元的个数),依次贴上“name”、“mobile”、“home_phone”、“office_home”、“memo”等几个标签。
在这个例子中,“contact”就相当于内存中的一个基本地址,而那些属性则相当于以基本地址为基础的几个偏移地址。
当我们访问contact的属性的时候,实际上就相当于访问“contact”基本地址再加上属性偏移地址的那个单元格的内容。比如,contact.name实际上就是contact基本地址加上name属性偏移地址之后的那个单元格的内容。对于复合结构的属性的访问,实际上就是一次内存中的间接寻址。
当我们定义了一个包含了多个属性的复合结构的时候,实际上就相当于我们自己定义了一套内存结构映射方案。
这还不是最简单的情况,复合结构里面还有可能包含复合结构。事实上,复合结构的嵌套层次是没有限制的,可以嵌套到任意深度。因此,我们有可能写出这样的访问深层次属性的代码:contact.address.city.zipcode。
还是那个老问题:我们需要把复合结构的概念理解道内存结构映射的层次吗?
我还是那句老话:需要。
即使你现在不需要,以后早晚也会需要。随着你对编程语言掌握的深入,你早晚需要理解到这个层次。与其到时候费二遍功,还不如现在就一次搞定。
复合结构并非唯一的内存结构映射定义。在命令式语言中,还有一个极为常见的类型——数组类型,同样对内存结构进行了映射。
数组类型对内存结构的映射是一种十分整齐的映射。我们可以想象一列整整齐齐的单元格,每个单元格的数据宽度完全相等。因此,我们可以通过简单的等距位移来访问其中某一个单元格的内容。事实上,数组正是通过数字下标来访问其中某一个位置的数据的。
比如,假设我们有一个数组变量array。我们可以想象一列长长的宽度相同的单元格,第一个单元格上贴着一个标签“array”。
我们想访问array数组中第30个数据。我们就可以这么写,array[30],就可以定位到array数组的第30个数据单元。
“[]”这样的方括号,表示访问一个数组中的某一个位置。这也是高级命令式语言的一种语法惯例。
同样,array[30]可以出现在赋值表达式的左边,也可以出现在赋值表达式的右边。比如:
array[30] = 1
array[31] = array[30]
需要注意的是,在很多命令式语言中,数组下标是从0开始的。因此,如果我们想访问第30个数据单元,很多情况下,我们必须写成array[29]。
综上所述,出现在赋值符号左边的变量,主要就是三种——简单变量、复合结构变量、数组变量。而且,这三种变量都有一个特点,他们都可以唯一定位到内存中的某一个具体位置。这体现了赋值语句的最根本的含义——将一个表达式执行的结果存入到某一个指定的内存单元中去。我们对赋值语句的理解,必须达到这个层次,才能够正确理解随之而来的一系列相关概念。
另外,在一些更高级别的命令式编程语言(如Python、Ruby等),实现了部分函数式编程语言(Functional Programming Language)的部分特性,如模式匹配(Pattern Match)这样的特性。在这些语言中,有可能出现一次对多个变量同时赋值的赋值语句。比如:
(a, b) = (1, 2)
a, b = 1, 2
某些情况下,这种语法用起来相当方便。比如,某个复合结构中有多个属性,我们想就把其中一些属性一次性复制到多个变量中的时候,就可以这么写。
想深入了解这种语法的读者,可以去研究一下函数式编程语言中的模式匹配特性。
当然,在我个人看来,即使在函数式编程语言中,模式匹配也不是什么核心的概念,只不过是一种简化书写的语法糖。掌握不掌握,都对编程核心概念的理解没有什么本质影响。
以上讲述的赋值语句都是“显式”赋值语句,即存在明确的赋值符号(=)的赋值语句。除了显式赋值语句之外,还有一种特殊的隐式赋值语句——参数传递。
参数,也叫做参变量,是一种特殊的变量。为了说明参变量和普通变量之间的异同点,下面我们给出一段具体的代码例子。
这里需要说明的是,本书中给出的相当一部分示例代码,都是用极为简化的伪代码写的,并不能够真正运行。这样做的目的是,最大限度保证代码的简化性、易读性、通用性。
本书只有在讲解某些不运行就难以理解的复杂概念的时候,才会给出真正的可以运行的某种具体编程语言的代码。而且,这么做的时候,本书会尽量采用最为流行的编程语言。
下面,就先让我们看一段包含了参变量和变量定义的伪代码。
f(x) {
a = 2 * x
if a < 5
return 0
else if a > 10
return 2
else
return 1
}
上面的伪代码结合了Javascript和Python这两种语言的最简化表达方法。由于省略了过程声明、类型声明,写起来极为简单,一目了然。
其中,if和return这两个关键字也是编程语言中常见的,不需要过多的解释;f(x)定义了一个名字叫做f的只有一个参数x的过程;{}这对大括号则包括了过程体内部的代码,这也是C、C++、Java等主流编程语言的语法惯例。
另外需要特别说明的一点是,在命令式语言中,过程(Procedure)通常还有几种其他的叫法,比如,函数(Function),方法(Method)等。本书后面会讲到函数式编程语言(Functional Programming),为了避免混淆,本书在讲解命令式语言时,将尽量避免使用函数(Function)这个词,而是尽量使用过程(Procedure)和方法(Method)这两个词。
上述代码中,x是一个参变量(参数),a则是过程体内部定义的普通变量(另,在过程体内声明的变量也叫做局部变量)。在函数体内,我们看不到类似于“x = …”的赋值语句。那么,x是什么时候被赋值的呢?答案是,当f(x)这个函数被调用的时候,参数才会被赋值。
比如,我们有另一条语句,n = f(4)
这条语句在执行的时候,就会把4这个值赋给x,然后,进入f(x)的过程体代码,执行整个过程。
那么,这一切在内存中是怎么发生的呢?
我们又要回到前面讲过的运行栈的概念了。每个进程运行起来之后,都有自己的运行栈。
本章前面提到了数组这个数据结构的简单概念和用法。在我们的想象中,数组是一串横着摆放的内存单元格。现在,我们把这一串横着摆放的单元格竖起来,让它竖着摆放,这样,看起来,就是一个栈结构了。实际上,运行栈的基本实现结构,就是数组结构。
这个地方,要说明一下。运行栈也是内存中的一部分。运行栈中的地址,也是一种内存地址。
运行栈(stack)和内存堆(heap)是两种常见的内存分配方式。运行栈的结构很简单,就是一个数组结构加一个存放栈顶地址的内存单元。内存堆(heap)是一种比较复杂的树状数据结构,可以有效地搜寻、增、删、改内存块。一般来说,我们不必关注其具体实现。
分配在运行栈(stack)上的数据,其生命周期由过程调用来决定。分配在内存堆(heap)的数据,其生命周期超出了过程调用的范围。内存堆中的数据,需要程序员写代码显式释放,或者由系统自动回收。
我们回到例子代码。当计算机执行f(4)这个过程调用的时候,实际上是先把4这个数值放到了运行栈里面,然后,转向f(x)过程体定义的工作流程,执行其中的代码。
进入f(x)过程体后,遇到的第一条代码就是关于变量a的赋值语句“a = 2 * x”。计算机首先在运行栈为a这个变量预留一个位置。这个位置是运行栈内的一个内存单元。
我们可以想象一下,在一条竖着摆放的内存单元格中,最上面一个内存单元格上贴上了“a”这个标签。紧挨着“a”单元格下面的那个单元格上就贴着“x”这个标签。“x”单元格内的内容就是4这个数值。
接下来,计算机执行2 * x 这个表达式。得出结果后,把结果存入到运行栈中之前为a预留的内存单元中。
从上面的描述中,我们可以看出,无论是a这个普通局部变量,还是x这个参变量,具体位置都是在运行栈中分配的。所不同之处在于,a这个普通局部变量的赋值是进入f(x)过程之后发生的,而x这个参变量的赋值是在f(x)过程调用之前就发生了。
所有的过程调用都会产生一个参数值压入运行栈的动作,即,把对应的参数值压入到运行栈上预先为参数分配的位置中。因此,有时候,我们也把过程调用前的参数传递叫做参数压栈。
上面举的例子是参数压栈的最简单的例子,只有一个参数。很多情况下,过程不止需要一个参数,有可能有多个参数。这就涉及到一个参数压栈顺序的问题,是从左向右压,还是从右向左压?这是一个问题。不同的语言实现有不同的做法。不过,在我个人看来,这不是什么重要的知识点,只是一种资料性的知识,不需要费心去理解。用到的时候再查资料就行了。
在结束本章之前,我们在来问本章最后一个问题:所有的命令式语言,都是基于栈结构的吗?
不知读者有没有思考过这个问题。在我们讲解汇编语言的时候,在我们讲解高级命令式语言的时候,都提到了进程的运行栈。似乎,运行栈就是天然存在的,不需要多想。
没错,进程的运行栈就是天然存在的,汇编语言需要运行栈,操作系统也需要运行栈。我们可以说,绝大部分命令式语言都是基于栈结构的,但不是全部。比如,Python语言有一个实现,叫做Stackless Python(无栈的Python),这就是一种允许程序员脱离运行栈结构的语言实现。当然,这只是一个特例,无关大局。在绝大多数情况下,当我们考虑命令式语言的时候,脑海里应该自然而然就浮现出一个运行栈的结构。
上一篇: 4.设计模式之策略模式
下一篇: 《编程机制探析》第四章 运行栈与内存寻址