C++从零开始
——何谓编程
引言
曾经有些人问我问题,问得都是一些很基础的问题,但这些人却已经能使用VC编一个对话框界面来进行必要的操作或者是文档/视界面来实时接收端口数据并动态显示曲线(还使用了多线程技术),却连那些基础的问题都不清楚,并且最严重的后果就是导致编写出拙劣的代码(虽然是多线程,但真不敢恭维),不清楚类的含义,混杂使用各种可用的技术来达到目的(连用异常代替选择语句都弄出来了),代码逻辑混乱,感觉就和金山快译的翻译效果一样。
我认为任何事情,基础都是最重要的,并且在做完我自定的最后一个项目后我就不再做编程的工作,守着这些经验也没什么意义,在此就用本系列说说我对编程的理解,帮助对电脑编程感兴趣的人快速入门(不过也许并不会想象地那么快)。由于我从没正经看完过一本C++的书(都是零碎偶尔翻翻的),并且本系列并不是教条式地将那些该死的名词及其解释罗列一遍,而是希望读者能够理解编程,而不是学会一门语言(即不止会英翻汉,还会汉翻英)。整个系列全用我自己的理解来写的,并无参考其他教材(在一些基础概念上还是参考了MSDN),所以本系列中的内容可能有和经典教材不一致的地方,但它们的本质应该还是一样的,只是角度不同而已。本系列不会仔细讲解C++的每个关键字(有些并不重要),毕竟目的不是C++语言参考,而是编程入门。如果本系列文章中有未提及的内容,还请参考MSDN中的C++语言参考(看完本系列文章后应该有能力做这件事了),而本系列给出的内容均是以VC编译器为基础,基于32位Windows操作系统的。
下面罗列一下各文章的标题和主要内容,红色修饰的文章标题表示我认为的重点。
C++从零开始(一)——何谓编程(说明编程的真正含义及两个重要却容易被忽略的基础概念)
C++从零开始(二)——何谓表达式(说明各操作符的用处,但不是全部,剩余的会在其它文章提到)
C++从零开始(三)——何谓变量(说明电脑的工作方式,阐述内存、地址等极其重要的概念)
C++从零开始(四)——赋值操作符(《C++从零开始(二)》的延续,并为指针的解释打一点基础)
C++从零开始(五)——何谓指针(阐述指针、数组等重要的概念)
C++从零开始(六)——何谓语句(讲解C++提供的各个语句,说明各自存在的理由)
C++从零开始(七)——何谓函数(说明函数及其存在的理由)
C++从零开始(八)——C++样例一(给出一两个简单算法,一步步说明如何从算法编写出C++代码)
C++从零开始(九)——何谓结构(简要说明结构、枚举等及其存在的理由)
C++从零开始(十)——何谓类(说明类及其存在的理由,以及声明、定义、头文件等概念)
C++从零开始(十一)——类的相关知识(说明派生、继承、名字空间、操作符重载等)
C++从零开始(十二)——何谓面向对象编程思想(阐述何谓编程思想,重点讲述面向对象编程思想)
何谓程序
程序,即过程的顺序,准确地说应该是顺序排列的多个过程,其是方法的描述。比如吃菜,先用筷子夹起菜,再用筷子将菜送入嘴中,最后咀嚼并吞下。其中的夹、送、咀嚼和吞下就被称作命令,而菜则是资源,其状态(如形状、位置等)随着命令的执行而不断发生变化。上面就是吃菜这个方法的描述,也就是吃菜的程序。
任何方法都是为了改变某些资源的状态而存在,因此任何方法的描述,也就是程序,也都一定有命令这个东西以及其所作用的资源。命令是由程序的执行者来实现的,比如上面的吃菜,其中的夹、送等都是由吃菜的人来实现的,而资源则一定是执行者可以改变的东西,而命令只是告诉执行者如何改变而已。
电脑程序和上面一样,是方法的描述,而这些方法就是人期望电脑能做的事(注意不是电脑要做的事,这经常一直混淆着许多人),当人需要做这些事时,人再给出某些资源以期电脑能对其做正确的改变。如计算圆周率的程序,其只是方法的描述,本身是不能发生任何效用的,直到它被执行,人为给定它一块内存(关于内存,请参考《C++从零开始(三)》),告诉它计算结果的精度及计算结果的存放位置后,其才改变人为给定的这块内存的状态以表现出计算结果。
因此,对于电脑程序,命令就是CPU的指令,而执行者也就由于是CPU的指令而必须是CPU了,而最后的资源则就是CPU可以改变其状态的内存(当然不止,如端口等,不过一般应用程序都大量使用内存罢了)。所以,电脑程序就是电脑如何改变给定资源(一般是内存,也可以是其他硬件资源)的描述,注意是描述,本身没有任何意义,除非被执行。
何谓编程
编程就是编写程序,即制订方法。为什么要有方法?方法是为了说明。而之所以要有说明就有很多原因了,但电脑编程的根本原因是因为语言不同,且不仅不同,连概念都不相通。
人类的语言五花八门,但都可以通过翻译得到正解,因为人类生存在同一个四维物理空间中,具有相同或类似的感知。而电脑程序执行时的CPU所能感受到的空间和物理空间严重不同,所以是不可能将电脑程序翻译成人类语言的描述的。这很重要,其导致了大部分程序员编写出的拙劣代码,因为人想的和电脑想的没有共性,所以他们在编写程序时就随机地无目的地编写,进而导致了拙劣却可以执行的代码。
电脑的语言就是CPU的指令,因为CPU就这一个感知途径(准确地说还有内存定位、中断响应等感知途径),不像人类还能有肢体语言,所以电脑编程就是将人类语言书写的方法翻译成相应的电脑语言,是一个翻译过程。这完全不同于一般的翻译,由于前面的红字,所以是不可能翻译的。!!是翻译但是是不同于任何两个语种之间翻译的一种翻译。
既然不可能翻译,那电脑编程到底是干甚?考虑一个木匠,我是客人。我对木匠说我要一把摇椅,躺着很舒服的那种。然后木匠开始刨木头,按照一个特殊的曲线制作摇椅下面的曲木以保证我摇的时候重心始终不变以感觉很舒服。这里我编了个简单的程序,只有一条指令——做一把摇着很舒服的摇椅。而木匠则将我的程序翻译成了刨木头、设计特定的曲木等一系列我看不懂的程序。之所以会这样,在这里就是因为我生活的空间和木工(是木工工艺,不是木匠)没有共性。这里木匠就相当于电脑程序员兼CPU(因为最后由木匠来制作摇椅),而木匠的手艺就是CPU的指令定义,而木匠就将我的程序翻译成了木工的一些规程,由木匠通过其手艺来实现这些规程,也就是执行程序。
上面由于我生活的空间和木工(指木工工艺,不是工人)没有共性,所以是不可能翻译的,但上面翻译成功了,实际是没有翻译的。在木工眼中,那个摇椅只是一些直木和曲木的拼接而已,因为木工空间中根本没有摇椅的概念,只是我要把那堆木头当作摇椅,进而使用。如果我把那堆木头当作凶器,则它就是凶器,不是什么摇椅了。
“废话加荒谬加放屁!”,也许你会这么大叫,但电脑编程就是这么一回事。CPU只能感知指令和改变内存的状态(不考虑其他的硬件资源及响应),如果我们编写了一个计算圆周率的程序,给出了一块内存,并执行,完成后就看见电脑的屏幕显示正确的结果。但一定注意,这里电脑实际只是将一些内存的数值复制、加减、乘除而已,电脑并不知道那是圆周率,而如果执行程序的人不把它说成是圆周率那么那个结果也就不是圆周率了,可能是一个随机数或其他什么的,只是运气极好地和圆周率惊人地相似。
上面的东西我将其称为语义,即语言的意义,其不仅仅可应用在电脑编程方面,实际上许多技术,如机械、电子、数学等都有自己的语言,而那些设计师则负责将客户的简单程序翻译成相应语言描述的程序。作为一个程序员是极其有必要了解到语义的重要性的(我在我的另一篇文章《语义的需要》中对代码级的语义做过较详细的阐述,有兴趣可以参考之),在后续的文章中我还将提到语义以及其对编程的影响,如果你还没有理解编程是什么意思,随着后续文章的阅读应该能够越来越明了。
!!编程是什么,现在的理解,利用编程语言去翻译现实生活的需要。
电脑编程的基础知识——编译器和连接器
我从没见过(不过应该有)任何一本C++教材有讲过何谓编译器(Compiler)及连接器(Linker)(倒是在很老的C教材中见过),现在都通过一个类似VC这样的编程环境隐藏了大量东西,将这些封装起来。在此,对它们的理解是非常重要的,本系列后面将大量运用到这两个词汇,其决定了能否理解如声明、定义、外部变量、头文件等非常重要的关键。
前面已经说明了电脑编程就是一个“翻译”过程,要把用户的程序翻译成CPU指令,其实也就是机器代码。所谓的机器代码就是用CPU指令书写的程序,被称作低级语言。而程序员的工作就是编写出机器代码。由于机器代码完全是一些数字组成(CPU感知的一切都是数字,即使是指令,也只是1代表加法、2代表减法这一类的数字和工作的映射),人要记住1是代表加法、2是代表减法将比较困难,并且还要记住第3块内存中放的是圆周率,而第4块内存中放的是有效位数。所以发明了汇编语言,用一些符号表示加法而不再用1了,如用ADD表示加法等。
由于使用了汇编语言,人更容易记住了,但是电脑无法理解(其只知道1是加法,不知道ADD是加法,因为电脑只能看见数字),所以必须有个东西将汇编代码翻译成机器代码,也就是所谓的编译器。即编译器是将一种语言翻译成另一种语言的程序。
即使使用了汇编语言,但由于其几乎只是将CPU指令中的数字映射成符号以帮助记忆而已,还是使用的电脑的思考方式进行思考的,不够接近人类的思考习惯,故而出现了纷繁复杂的各种电脑编程语言,如:PASCAL、BASIC、C等,其被称作高级语言,因为比较接近人的思考模式(尤其C++的类的概念的推出),而汇编语言则被称作低级语言(C曾被称作高级的低级语言),因为它们不是很符合人类的思考模式,人类书写起来比较困难。由于CPU同样不认识这些PASCAL、BASIC等语言定义的符号,所以也同样必须有一个编译器把这些语言编写的代码转成机器代码。对于这里将要讲到的C++语言,则是C++语言编译器(以后的编译器均指C++语言编译器)。
因此,这里所谓的编译器就是将我们书写的C++源代码转换成机器代码。由于编译器执行一个转换过程,所以其可以对我们编写的代码进行一些优化,也就是说其相当于是一个CPU指令程序员,将我们提供的程序翻译成机器代码,不过它的工作要简单一些了,因为从人类的思考方式转成电脑的思考方式这一过程已经由程序员完成了,而编译器只是进行翻译罢了(最多进行一些优化)。
还有一种编译器被称作翻译器(Translator),其和编译器的区别就是其是动态的而编译器是静态的。如前面的BASIC的编译器在早期版本就被称为翻译器,因为其是在运行时期即时进行翻译工作的,而不像编译器一次性将所有代码翻成机器代码。对于这里的“动态”、“静态”和“运行时期”等名词,不用刻意去理解它,随着后续文章的阅读就会了解了。
编译器把编译后(即翻译好的)的代码以一定格式(对于VC,就是COFF通用对象文件格式,扩展名为.obj)存放在文件中,然后再由连接器将编译好的机器代码按一定格式(在Windows操作系统下就是PortableExecutableFileFormat——PE文件格式)存储在文件中,以便以后操作系统执行程序时能按照那个格式找到应该执行的第一条指令或其他东西,如资源等。至于为什么中间还要加一个连接器以及其它细节,在后续文章中将会进一步说明。
也许你还不能了解到上面两个概念的重要性,但在后续的文章中,你将会发现它们是如此的重要以至于完全有必要在这唠叨一番。
C++从零开始(二)
——何谓表达式
本篇是此系列的开头,在学英语时,第一时间学的是字母,其是英语的基础。同样,在C++中,所有的代码都是通过标识符(Identifier)、表达式(Expression)和语句(Statement)及一些必要的符号(如大括号等)组成,在此先说明何谓标识符。
标识符
标识符是一个字母序列,由大小写英文字母、下划线及数字组成,用于标识。标识就是标出并识别,也就是名字。其可以作为后面将提到的变量或者函数或者类等的名字,也就是说用来标识某个特定的变量或者函数或者类等C++中的元素。
比如:abc就是一个合法的标识符,即abc可以作为变量、函数等元素的名字,但并不代表abc就是某个变量或函数的名字,而所谓的合法就是任何一个标识符都必须不能以数字开头,只能包括大小写英文字母、下划线及数字,不能有其它符号,如,!^等,并且不能与C++关键字相同。也就是我们在给一个变量或函数起名字的时候,必须将起的名字看作是一个标识符,并进而必须满足上面提出的要求。如12ab_C就不是一个合法的标识符,因此我们不能给某个变量或函数起12ab_C这样的名字;ab_12C就是合法的标识符,因此可以被用作变量或函数的名字。
前面提到关键字,在后续的语句及一些声明修饰符的介绍中将发现,C++提供了一些特殊的标识符作为语句的名字,用以标识某一特定语句,如if、while等;或者提供一些修饰符用以修饰变量、函数等元素以实现语义或给编译器及连接器提供一些特定信息以进行优化、查错等操作,如extern、static等。因此在命名变量或函数或其他元素时,不能使用if、extern等这种C++关键字作为名字,否则将导致编译器无法确认是一个变量(或函数或其它C++元素)还是一条语句,进而无法编译。
如果要让某个标识符是特定变量或函数或类的名字,就需要使用声明,在后续的文章中再具体说明。
数字
C++作为电脑编程语言,电脑是处理数字的,因此C++中的基础东西就是数字。C++中提供两种数字:整型数和浮点数,也就是整数和小数。但由于电脑实际并不是想象中的数字化的(详情参见《C++从零开始(三)》中的类型一节),所以整型数又分成了有符号和无符号整型数,而浮点数则由精度的区别而分成单精度和双精度浮点数,同样的整型数也根据长度分成长整型和短整型。
要在C++代码中表示一个数字,直接书写数字即可,如:123、34.23、-34.34等。由于电脑并非以数字为基础而导致了前面数字的分类,为了在代码中表现出来,C++提供了一系列的后缀进行表示,如下:
u或U表示数字是无符号整型数,如:123u,但并不说明是长整型还是短整型
l或L表示数字是长整型数,如:123l;而123ul就是无符号长整型数;而34.4l就是长双精度浮点数,等效于双精度浮点数
i64或I64表示数字是长长整型数,其是为64位操作系统定义的,长度比长整型数长。如:43i64
f或F表示数字是单精度浮点数,如:12.3f
e或E表示数字的次幂,如:34.4e-2就是0.344;0.2544e3f表示一个单精度浮点数,值为254.4
当什么后缀都没写时,则根据有无小数点及位数来决定其具体类型,如:123表示的是有符号整型数,而12341434则是有符号长整型数;而34.43表示双精度浮点数。
为什么要搞这么多事出来,还分什么有符号无符号之类的?这全是因为电脑并非基于数字的,而是基于状态的,详情在下篇中将详细说明。
作为科学计算,可能经常会碰到使用非十进制数字,如16进制、8进制等,C++也为此提供了一些前缀以进行支持。
在数字前面加上0x或0X表示这个数字是16进制表示的,如:0xF3Fa、0x11cF。而在前面加一个0则表示这个数字是用8进制表示的,如:0347,变为十进制数就为231。但16进制和8进制都不能用于表示浮点数,只能表示整型数,即0x34.343是错误的。
字符串
C++除了提供数字这种最基础的表示方式外,还提供了字符及字符串。这完全只是出于方便编写程序而提供的,C++作为电脑语言,根本没有提供字符串的必要性。不过由于人对电脑的基本要求就是显示结果,而字符和字符串都由于是人易读的符号而被用于显示结果,所以C++专门提供了对字符串的支持。
前面说过,电脑只认识数字,而字符就是文字符号,是一种图形符号。为了使电脑能够处理符号,必须通过某种方式将符号变成数字,在电脑中这通过在符号和数字之间建立一个映射来实现,也就是一个表格。表格有两列,一列就是我们欲显示的图形符号,而另一列就是一个数字,通过这么一张表就可以在图形符号和数字之间建立映射。现在已经定义出一标准表,称为ASCII码表,几乎所有的电脑硬件都支持这个转换表以将数字变成符号进而显示计算结果。
有了上面的表,当想说明结果为“A”时,就查ASCII码表,得到“A”这个图形符号对应的数字是65,然后就告诉电脑输出序号为65的字符,最后屏幕上显示“A”。
这明显地繁杂得异常,为此C++就提供了字符和字符串。当我们想得到某一个图形符号的ASCII码表的序号时,只需通过单引号将那个字符括起来即可,如:'A',其效果和65是一样的。当要使用不止一个字符时,则用双引号将多个字符括起来,也就是所谓的字符串了,如:"ABC"。因此字符串就是多个字符连起来而已。但根据前面的说明易发现,字符串也需要映射成数字,但它的映射就不像字符那么简单可以通过查表就搞定的,对于此,将在后续文章中对数组作过介绍后再说明。
操作符
电脑的基本是数字,那么电脑的所有操作都是改变数字,因此很正常地C++提供了操作数字的一些基本操作,称作操作符(Operator),如:+-*/等。任何操作符都要返回一个数字,称为操作符的返回值,因此操作符就是操作数字并返回数字的符号。作为一般性地分类,按操作符同时作用的数字个数分为一元、二元和三元操作符。
一元操作符有:
+其后接数字,原封不动地返回后接的数字。如:+4.4f的返回值是4.4;+-9.3f的返回值是-9.3。完全是出于语义的需要,如表示此数为正数。
-其后接数字,将后接的数字的符号取反。如:-34.4f的返回值是-34.4;-(-54)的返回值是54。用于表示负数。
!其后接数字,逻辑取反后接的数字。逻辑值就是“真”或“假”,为了用数字表示逻辑值,在C++中规定,非零值即为逻辑真,而零则为逻辑假。因此3、43.4、'A'都表示逻辑真,而0则表示逻辑假。逻辑值被应用于后续的判断及循环语句中。而逻辑取反就是先判断“!”后面接的数字是逻辑真还是逻辑假,然后再将相应值取反。如:
!5的返回值是0,因为先由5非零而知是逻辑真,然后取反得逻辑假,故最后返回0。
!!345.4的返回值是1,先因345.4非零得逻辑真,取反后得逻辑假,再取反得逻辑真。虽然只要非零就是逻辑真,但作为编译器返回的逻辑真,其一律使用1来代表逻辑真。
~其后接数字,取反后接的数字。取反是逻辑中定义的操作,不能应用于数字。为了对数字应用取反操作,电脑中将数字用二进制表示,然后对数字的每一位进行取反操作(因为二进制数的每一位都只能为1或0,正好符合逻辑的真和假)。如~123的返回值就为-124。先将123转成二进制数01111011,然后各位取反得10000100,最后得-124。
这里的问题就是为什么是8位而不是16位二进制数。因为123小于128,被定位为char类型,故为8位(关于char是什么将下篇介绍)。如果是~123ul,则返回值为4294967172。
为什么要有数字取反这个操作?因为CPU提供了这样的指令。并且其还有着很不错且很重要的应用,后面将介绍。
关于其他的一元操作符将在后续文章中陆续提到(但不一定全部提到)。
二元操作符有:
+
-
*
/
%其前后各接一数字,返回两数字之和、差、积、商、余数。如:
34+4.4f的返回值是38.4;3+-9.3f的返回值是-6.3。
34-4的返回值是30;5-234的返回值是-229。
3*2的返回值是6;10/3的返回值是3。
10%3的返回值是1;20%7的返回值是6。
&&
||其前后各接一逻辑值,返回两逻辑值之“与”运算逻辑值和“或”运算逻辑值。如:
'A'&&34.3f的返回值是逻辑真,为1;34&&0的返回值是逻辑假,为0。
0||'B'的返回值是逻辑真,为1;0||0的返回值是逻辑假,为0。
&
|
^其前后各接一数字,返回两数字之“与”运算、“或”运算、“异或”运算值。如前面所说,先将两侧的数字转成二进制数,然后对各位进行与、或、异或操作。如:
4&6的返回值是4,4转为00000100,6转为00000110各位相与得,00000100,为4。
4|6的返回值是6,4转为00000100,6转为00000110各位相或得,00000110,为6。
4^6的返回值是2,4转为00000100,6转为00000110各位相异或得,00000010,为2。
>
<
==
>=
<=
!=其前后各接一数字,根据两数字是否大于、小于、等于、大于等于、小于等于及不等于而返回相应的逻辑值。如:
34>34的返回值是0,为逻辑假;32<345的返回值为1,为逻辑真。
23>=23和23>=14的返回值都是1,为逻辑真;54<=4的返回值为0,为逻辑假。
56==6的返回值是0,为逻辑假;45==45的返回值是1,为逻辑真。
5!=5的返回值是0,为逻辑假;5!=35的返回值是真,为逻辑真。
>>
<<其前后各接一数字,将左侧数字右移或左移右侧数字指定的位数。与前面的~、&、|等操作一样,之所以要提供左移、右移操作主要是因为CPU提供了这些指令,主要用于编一些基于二进制数的算法。
<<将左侧的数字转成二进制数,然后将各位向左移动右侧数值的位数,如:4,转为00000100,左移2位,则变成00010000,得16。
>>与<<一样,只不过是向右移动罢了。如:6,转为00000110,右移1位,变成00000011,得3。如果移2位,则有一位超出,将截断,则6>>2的返回值就是00000001,为1。
左移和右移有什么用?用于一些基于二进制数的算法,不过还可以顺便作为一个简单的优化手段。考虑十进制数3524,我们将它左移2位,变成352400,比原数扩大了100倍,准确的说应该是扩大了10的2次方倍。如果将3524右移2位,变成35,相当于原数除以100的商。
同样,前面4>>2,等效于4/4的商;32>>3相当于32/8,即相当于32除以2的3次方的商。而4<<2等效于4*4,相当于4乘以2的2次方。因此左移和右移相当于乘法和除法,只不过只能是乘或除相应进制数的次方罢了,但它的运行速度却远远高于乘法和除法,因此说它是一种简单的优化手段。
,其前后各接一数字,简单的返回其右侧的数字。如:
34.45f,54的返回值是54;-324,4545f的返回值是4545f。
那它到底有什么用?用于将多个数字整和成一个数字,在《C++从零开始(四)》中将进一步说明。
关于其他的二元操作符将在后续文章中陆续提到(但不一定全部提到)。
三元操作符只有一个,为?:,其格式为:<数字1>?<数字2>:<数字3>。它的返回值为:如果<数字1>是逻辑真,返回<数字2>,否则返回<数字3>。如:
34?4:2的返回值就是4,因为34非零,为逻辑真,返回4。而0?4:2的返回值就是2,因为0为逻辑假,返回2。
表达式
你应该发现前面的荒谬之处了——12>435返回值为0,那为什么不直接写0还吃饱了撑了写个12>435在那?这就是表达式的意义了。
前面说“>”的前后各接一数字,但是操作符是操作数字并返回数字的符号,因为它返回数字,因此可以放在上面说的任何一个要求接数字的地方,也就形成了所谓的表达式。如:23*54/45>34的返回值就是0,因为23*54的返回值为1242;然后又将1242作为“/”的左接数字,得到新的返回值27.6;最后将27.6作为“>”的左接数字进而得到返回值0,为逻辑假。
因此表达式就是由一系列返回数字的东西和操作符组合而成的一段代码,其由于是由操作符组成的,故一定返回值。而前面说的“返回数字的东西”则可以是另一个表达式,或者一个变量,或者一个具有返回值的函数,或者具有数字类型操作符重载的类的对象等,反正只要是能返回一个数字的东西。如果对于何谓变量、函数、类等这些名词感到陌生,不需要去管它们,在后继的文章中将会一一说明。
因此34也是一个表达式,其返回值为34,只不过是没有操作符的表达式罢了(在后面将会了解到34其实是一种操作符)。故表达式的概念其实是很广的,只要有返回值的东西就可以称为表达式。
由于表达式里有很多操作符,执行操作符的顺序依赖于操作符的优先级,就和数学中的一样,*、/的优先级大于+、-,而+、-又大于>、<等逻辑操作符。不用去刻意记住操作符的优先级,当不能确定操作符的执行顺序时,可以使用小括号来进行指定。如:
((1+2)*3)+3)/4的返回值为3,而1+2*3+3/4的返回值为7。注意3/4为0,因为3/4的商是0。当希望进行浮点数除法或乘法时,只需让操作数中的某一个为浮点数即可,如:3/4.0的返回值为0.75。
&|^~等的应用
前面提过逻辑操作符“&&”、“||”、“!”等,作为表示逻辑,其被C++提供一点都不值得惊奇。但是为什么要有一个将数字转成二进制数,然后对二进制数的各位进行逻辑操作的这么一类操作符呢?首先是CPU提供了相应的指令,并且其还有着下面这个非常有意义的应用。
考虑一十字路口,每个路口有三盏红绿灯,分别指明能否左转、右转及直行。共有12盏,现在要为它编写一个控制程序,不管这程序的功能怎样,首先需要将红绿灯的状态转化为数字,因为电脑只知道数字。所以用3个数字分别表示某路口的三盏红绿灯,因此每个红绿灯的状态由一个数字来表示,假设红灯为0,绿灯为1(不考虑黄灯或其他情况)。
后来忽然发现,其实也可以用一个数字表示一个路口的三盏红绿灯状态,如用110表示左转绿灯、直行绿灯而右转红灯。上面的110是一个十进制数字,它的每一位实际都可以为0~9十个数字,但是这里只应用到了两个:0和1,感觉很浪费。故选择二进制数来表示,还是110,但是是二进制数了,转成十进制数为6,即使当为111时转成十进制数也只是7,比前面的110这个十进制数小多了,节约了……??什么??
我们在纸上写数字235425234一定比写134这个数字要更多地占用纸张(假设字都一样大)。因此记录一个大的数比记录一个小的数要花费更多的资源。简直荒谬!不管是100还是1000,都只是一个数字,为什么记录大的数字就更费资源?因为电脑并不是数字计算机,而是电子计算机,它是基于状态而不是基于数字的,这在下篇会详细说明。电脑必须使用某种表示方式来代表一个数字,而那个表示方式和二进制很像,但并不是二进制数,故出现记录大的数较小的数更耗资源,这也就是为什么上面整型数要分什么长整型短整型的原因了。
下面继续上面的思考。使用了110这个二进制数来表示三盏红绿灯的状态,那么现在要知道110这个数字代表左转红绿灯的什么状态。以数字的第三位表示左转,不过电脑并不知道这个,因此如下:110&100。这个表达式的返回值是100,非零,逻辑真。假设某路口的状态为010,则同样的010&100,返回值为0,逻辑假。因此使用“&”操作符可以将二进制数中的某一位或几位的状态提取出来。所以我们要了解一个数字代表的红绿灯状态中的左转红绿灯是否绿灯时,只需让它和100相与即可。
现在要保持其他红绿灯的状态不变,仅仅使左转红绿灯为绿灯,如当前状态为010,为了使左转红绿灯为绿灯,值应该为110,这可以通过010|100做到。如果当前状态是001,则001|100为101,正确——直行和右转的红绿灯状态均没有发生变化。因此使用“|”操作符可以给一个二进制数中的某一位或几位设置状态,但只能设置为1,如果想设置为0,如101,要关掉左转的绿灯,则101&~100,返回值为001。
上面一直提到的路口红绿灯的状态实际编写时可以使用一个变量来表示,而上面的100也可以用一个标识符来表示,如state&TS_LEFT,就可以表示检查变量state所表示的状态中的左转红绿灯的状态。
上面的这种方法被大量地运用,如创建一个窗口,一个窗口可能有二三十个风格,则通过上面的方法,就可以只用一个32位长的二进制数字就表示了窗口的风格,而不用去弄二三十个数字来分别代表每种风格是否具有。
C++从零开始(三)
——何谓变量
本篇说明内容是C++中的关键,基本大部分人对于这些内容都是昏的,但这些内容又是编程的基础中的基础,必须详细说明。
数字表示
数学中,数只有数值大小的不同,绝不会有数值占用空间的区别,即数学中的数是逻辑上的一个概念,但电脑不是。考虑算盘,每个算盘上有很多列算子,每列都分成上下两排算子。上排算子有2个,每个代表5,下排算子有4个,每个代表1(这并不重要)。因此算盘上的每列共有6个算子,每列共可以表示0到14这15个数字(因为上排算子的可能状态有0到2个算子有效,而下排算子则可能有0到4个算子有效,故为3×5=15种组合方式)。
上面的重点就是算盘的每列并没有表示0到14这15个数字,而是每列有15种状态,因此被人利用来表示数字而已(这很重要)。由于算盘的每列有15个状态,因此用两列算子就可以有15×15=225个状态,因此可以表示0到224。阿拉伯数字的每一位有0到9这10个图形符号,用两个阿拉伯数字图形符号时就能有10×10=100个状态,因此可以表示0到99这100个数。
这里的算盘其实就是一个基于15进制的记数器(可以通过维持一列算子的状态来记录一位数字),它的一列算子就相当于一位阿拉伯数字,每列有15种状态,故能表示从0到14这15个数字,超出14后就必须通过进位来要求另一列算子的加入以表示数字。电脑与此一样,其并不是数字计算机,而是电子计算机,电脑中通过一根线的电位高低来表示数字。一根线中的电位规定只有两种状态——高电位和低电位,因此电脑的数字表示形式是二进制的。
和上面的算盘一样,一根电线只有两个状态,当要表示超出1的数字时,就必须进位来要求另一根线的加入以表示数字。所谓的32位电脑就是提供了32根线(被称作数据总线)来表示数据,因此就有2的32次方那么多种状态。而16根线就能表示2的16次方那么多种状态。
所以,电脑并不是基于二进制数,而是基于状态的变化,只不过这个状态可以使用二进制数表示出来而已。即电脑并不认识二进制数,这是下面“类型”一节的基础。
内存
内存就是电脑中能记录数字的硬件,但其存储速度很快(与硬盘等低速存储设备比较),又不能较长时间保存数据,所以经常被用做草稿纸,记录一些临时信息。
前面已经说过,32位计算机的数字是通过32根线上的电位状态的组合来表示的,因此内存能记录数字,也就是能维持32根线上各自的电位状态(就好象算盘的算子拨动后就不会改变位置,除非再次拨动它)。不过依旧考虑上面的算盘,假如一个算盘上有15列算子,则一个算盘能表示15的15次方个状态,是很大的数字,但经常实际是不会用到变化那么大的数字的,因此让一个算盘只有两列算子,则只能表示225个状态,当数字超出时就使用另一个或多个算盘来一起表示。
上面不管是2列算子还是15列算子,都是算盘的粒度,粒度分得过大造成不必要的浪费(很多列算子都不使用),太小又很麻烦(需要多个算盘)。电脑与此一样。2的32次方可表示的数字很大,一般都不会用到,如果直接以32位存储在内存中势必造成相当大的资源浪费。于是如上,规定内存的粒度为8位二进制数,称为一个内存单元,而其大小称为一个字节(Byte)。就是说,内存存储数字,至少都会记录8根线上的电位状态,也就是2的8次方共256种状态。所以如果一个32位的二进制数要存储在内存中,就需要占据4个内存单元,也就是4个字节的内存空间。
我们在纸上写字,是通过肉眼判断出字在纸上的相对横坐标和纵坐标以查找到要看的字或要写字的位置。同样,由于内存就相当于草稿纸,因此也需要某种定位方式来定位,在电脑中,就是通过一个数字来定位的。这就和旅馆的房间号一样,内存单元就相当于房间(假定每个房间只能住一个人),而前面说的那个数字就相当于房间号。为了向某块内存中写入数据(就是使用某块内存来记录数据总线上的电位状态),就必须知道这块内存对应的数字,而这个数字就被称为地址。而通过给定的地址找到对应的内存单元就称为寻址。
因此地址就是一个数字,用以唯一标识某一特定内存单元。此数字一般是32位长的二进制数,也就可以表示4G个状态,也就是说一般的32位电脑都具有4G的内存空间寻址能力,即电脑最多装4G的内存,如果电脑有超过4G的内存,此时就需要增加地址的长度,如用40位长的二进制数来表示。
类型
在本系列最开头时已经说明了何谓编程,而刚才更进一步说明了电脑其实连数字都不认识,只是状态的记录,而所谓的加法也只是人为设计那个加法器以使得两个状态经过加法器的处理而生成的状态正好和数学上的加法的结果一样而已。这一切的一切都只说明一点:电脑所做的工作是什么,全视使用的人以为是什么。
因此为了利用电脑那很快的“计算”能力(实际是状态的变换能力),人为规定了如何解释那些状态。为了方便其间,对于前面提出的电位的状态,我们使用1位二进制数来表示,则上面提出的状态就可以使用一个二进制数来表示,而所谓的“如何解释那些状态”就变成了如何解释一个二进制数。
C++是高级语言,为了帮助解释那些二进制数,提供了类型这个概念。类型就是人为制订的如何解释内存中的二进制数的协议。C++提供了下面的一些标准类型定义。
signedchar表示所指向的内存中的数字使用补码形式,表示的数字为-128到+127,长度为1个字节
unsignedchar表示所指向的内存中的数字使用原码形式,表示的数字为0到255,长度为1个字节
signedshort表示所指向的内存中的数字使用补码形式,表示的数字为–32768到+32767,长度为2个字节
unsignedshort表示所指向的内存中的数字使用原码形式,表示的数字为0到65535,长度为2个字节
signedlong表示所指向的内存中的数字使用补码形式,表示的数字为-2147483648到+2147483647,长度为4个字节
unsignedlong表示所指向的内存中的数字使用原码形式,表示的数字为0到4294967295,长度为4个字节
signedint
表示所指向的内存中的数字使用补码形式,表示的数字则视编译器。如果编译器编译时被指明编译为在16位操作系统上运行,则等同于signedshort;如果是编译为32位的,则等同于signedlong;如果是编译为在64位操作系统上运行,则为8个字节长,而范围则如上一样可以自行推算出来。
unsignedint表示所指向的内存中的数字使用原码形式,其余和signedint一样,表示的是无符号数。
bool表示所指向的内存中的数字为逻辑值,取值为false或true。长度为1个字节。
float表示所指向的内存按IEEE标准进行解释,为real*4,占用4字节内存空间,等同于上篇中提到的单精度浮点数。
double表示所指向的内存按IEEE标准进行解释,为real*8,可表示数的精度较float高,占用8字节内存空间,等同于上篇提到的双精度浮点数。
longdouble表示所指向的内存按IEEE标准进行解释,为real*10,可表示数的精度较double高,但在为32位Windows操作系统编写程序时,仍占用8字节内存空间,等效于double,只是如果CPU支持此类浮点类型则还是可以进行这个精度的计算。
标准类型不止上面的几个,后面还会陆续提到。
上面的长度为2个字节也就是将两个连续的内存单元中的数字取出并合并在一起以表示一个数字,这和前面说的一个算盘表示不了的数字,就进位以加入另一个算盘帮助表示是同样的道理。
上面的signed关键字是可以去掉的,即char等同于signedchar,用以简化代码的编写。但也仅限于signed,如果是unsignedchar,则在使用时依旧必须是unsignedchar。
现在应该已经了解上篇中为什么数字还要分什么有符号无符号、长整型短整型之类的了,而上面的short、char等也都只是长度不同,这就由程序员自己根据可能出现的数字变化幅度来进行选用了。
类型只是对内存中的数字的解释,但上面的类型看起来相对简单了点,且语义并不是很强,即没有什么特殊意思。为此,C++提供了自定义类型,也就是后继文章中将要说明的结构、类等。
变量
在本系列的第一篇中已经说过,电脑编程的绝大部分工作就是操作内存,而上面说了,为了操作内存,需要使用地址来标识要操作的内存块的首地址(上面的long表示连续的4个字节内存,其第一个内存单元的地址称作这连续4个字节内存块的首地址)。为此我们在编写程序时必须记下地址。
做5+2/3-5*2的计算,先计算出2/3的值,写在草稿纸上,接着算出5*2的值,又写在草稿纸上。为了接下来的加法和减法运算,必须能够知道草稿纸上的两个数字哪个是2/3的值哪个是5*2的值。人就是通过记忆那两个数在纸上的位置来记忆的,而电脑就是通过地址来标识的。但电脑只会做加减乘除,不会去主动记那些2/3、5*2的中间值的位置,也就是地址。因此程序员必须完成这个工作,将那两个地址记下来。
问题就是这里只有两个值,也许好记一些,但如果多了,人是很难记住哪个地址对应哪个值的,但人对符号比对数字要敏感得多,即人很容易记下一个名字而不是一个数字。为此,程序员就自己写了一个表,表有两列,一列是“2/3的值”,一列是对应的地址。如果式子稍微复杂点,那么那个表可能就有个二三十行,而每写一行代码就要去翻查相应的地址,如果来个几万行代码那是人都不能忍受。
C++作为高级语言,很正常地提供了上面问题的解决之道,就是由编译器来帮程序员维护那个表,要查的时候是编译器去查,这也就是变量的功能。
变量是一个映射元素。上面提到的表由编译器维护,而表中的每一行都是这个表的一个元素(也称记录)。表有三列:变量名、对应地址和相应类型。变量名是一个标识符,因此其命名规则完全按照上一篇所说的来。当要对某块内存写入数据时,程序员使用相应的变量名进行内存的标识,而表中的对应地址就记录了这个地址,进而将程序员给出的变量名,一个标识符,映射成一个地址,因此变量是一个映射元素。而相应类型则告诉编译器应该如何解释此地址所指向的内存,是2个连续字节还是4个?是原码记录还是补码?而变量所对应的地址所标识的内存的内容叫做此变量的值。
有如下的变量解释:“可变的量,其相当于一个盒子,数字就装在盒子里,而变量名就写在盒子外面,这样电脑就知道我们要处理哪一个盒子,且不同的盒子装不同的东西,装字符串的盒子就不能装数字。”上面就是我第一次学习编程时,书上写的(是BASIC语言)。对于初学者也许很容易理解,也不能说错,但是造成的误解将导致以后的程序编写地千疮百孔。
上面的解释隐含了一个意思——变量是一块内存。这是严重错误的!如果变量是一块内存,那么C++中著名的引用类型将被弃置荒野。变量实际并不是一块内存,只是一个映射元素,这是致关重要的。
内存的种类
前面已经说了内存是什么及其用处,但内存是不能随便使用的,因为操作系统自己也要使用内存,而且现在的操作系统正常情况下都是多任务操作系统,即可同时执行多个程序,即使只有一个CPU。因此如果不对内存访问加以节制,可能会破坏另一个程序的运作。比如我在纸上写了2/3的值,而你未经我同意且未通知我就将那个值擦掉,并写上5*2的值,结果我后面的所有计算也就出错了。
因此为了使用一块内存,需要向操作系统申请,由操作系统统一管理所有程序使用的内存。所以为了记录一个long类型的数字,先向操作系统申请一块连续的4字节长的内存空间,然后操作系统就会在内存中查看,看是否还有连续的4个字节长的内存,如果找到,则返回此4字节内存的首地址,然后编译器编译的指令将其记录在前面提到的变量表中,最后就可以用它记录一些临时计算结果了。
上面的过程称为要求操作系统分配一块内存。这看起来很不错,但是如果只为了4个字节就要求操作系统搜索一下内存状况,那么如果需要100个临时数据,就要求操作系统分配内存100次,很明显地效率低下(无谓的99次查看内存状况)。因此C++发现了这个问题,并且操作系统也提出了相应的解决方法,最后提出了如下的解决之道。
栈(Stack)任何程序执行前,预先分配一固定长度的内存空间,这块内存空间被称作栈(这种说法并不准确,但由于实际涉及到线程,在此为了不将问题复杂化才这样说明),也被叫做堆栈。那么在要求一个4字节内存时,实际是在这个已分配好的内存空间中获取内存,即内存的维护工作由程序员自己来做,即程序员自己判断可以使用哪些内存,而不是操作系统,直到已分配的内存用完。
很明显,上面的工作是由编译器来做的,不用程序员操心,因此就程序员的角度来看什么事情都没发生,还是需要像原来那样向操作系统申请内存,然后再使用。
但工作只是从操作系统变到程序自己而已,要维护内存,依然要耗费CPU的时间,不过要简单多了,因为不用标记一块内存是否有人使用,而专门记录一个地址。此地址以上的内存空间就是有人正在使用的,而此地址以下的内存空间就是无人使用的。之所以是以下的空间为无人使用而不是以上,是当此地址减小到0时就可以知道堆栈溢出了(如果你已经有些基础,请不要把0认为是虚拟内存地址,关于虚拟内存将会在《C++从零开始(十八)》中进行说明,这里如此解释只是为了方便理解)。而且CPU还专门对此法提供了支持,给出了两条指令,转成汇编语言就是push和pop,表示压栈和出栈,分别减小和增大那个地址。
而最重要的好处就是由于程序一开始执行时就已经分配了一大块连续内存,用一个变量记录这块连续内存的首地址,然后程序中所有用到的,程序员以为是向操作系统分配的内存都可以通过那个首地址加上相应偏移来得到正确位置,而这很明显地由编译器做了。因此实际上等同于在编译时期(即编译器编译程序的时候)就已经分配了内存(注意,实际编译时期是不能分配内存的,因为分配内存是指程序运行时向操作系统申请内存,而这里由于使用堆栈,则编译器将生成一些指令,以使得程序一开始就向操作系统申请内存,如果失败则立刻退出,而如果不退出就表示那些内存已经分配到了,进而代码中使用首地址加偏移来使用内存也就是有效的),但坏处也就是只能在编译时期分配内存。
堆(Heap)上面的工作是编译器做的,即程序员并不参与堆栈的维护。但上面已经说了,堆栈相当于在编译时期分配内存,因此一旦计算好某块内存的偏移,则这块内存就只能那么大,不能变化了(如果变化会导致其他内存块的偏移错误)。比如要求客户输入定单数据,可能有10份定单,也可能有100份定单,如果一开始就定好了内存大小,则可能造成不必要的浪费,又或者内存不够。
为了解决上面的问题,C++提供了另一个途径,即允许程序员有两种向操作系统申请内存的方式。前一种就是在栈上分配,申请的内存大小固定不变。后一种是在堆上分配,申请的内存大小可以在运行的时候变化,不是固定不变的。
那么什么叫堆?在Windows操作系统下,由操作系统分配的内存就叫做堆,而栈可以认为是在程序开始时就分配的堆(这并不准确,但为了不复杂化问题,故如此说明)。因此在堆上就可以分配大小变化的内存块,因为是运行时期即时分配的内存,而不是编译时期已计算好大小的内存块。
变量的定义
上面说了那么多,你可能看得很晕,毕竟连一个实例都没有,全是文字,下面就来帮助加深对上面的理解。
定义一个变量,就是向上面说的由编译器维护的变量表中添加元素,其语法如下:
longa;
先写变量的类型,然后一个或多个空格或制表符(\t)或其它间隔符,接着变量的名字,最后用分号结束。要同时定义多个变量,则各变量间使用逗号隔开,如下:
longa,b,c;unsignedshorte,a_34c;
上面是两条变量定义语句,各语句间用分号隔开,而各同类型变量间用逗号隔开。而前面的式子5+2/3-5*2,则如下书写。
longa=2/3,b=5*2;longc=5+a–b;
可以不用再去记那烦人的地址了,只需记着a、b这种简单的标识符。当然,上面的式子不一定非要那么写,也可以写成:longc=5+2/3–5*2;而那些a、b等中间变量编译器会自动生成并使用(实际中编译器由于优化的原因将直接计算出结果,而不会生成实际的计算代码)。
下面就是问题的关键,定义变量就是添加一个映射。前面已经说了,这个映射是将变量名和一个地址关联,因此在定义一个变量时,编译器为了能将变量名和某个地址对应起来,帮程序员在前面提到的栈上分配了一块内存,大小就视这个变量类型的大小。如上面的a、b、c的大小都是4个字节,而e、a_34c的大小都是2个字节。
假设编译器分配的栈在一开始时的地址是1000,并假设变量a所对应的地址是1000-56,则b所对应的地址就是1000-60,而c所对应的就是1000-64,e对应的是1000-66,a_34c是1000-68。如果这时b突然不想是4字节了,而希望是8字节,则后续的c、e、a_34c都将由于还是原来的偏移位置而使用了错误的内存,这也就是为什么栈上分配的内存必须是固定大小。
考虑前面说的红色文字:“变量实际并不是一块内存,只是一个映射元素”。可是只要定义一个变量,就会相应地得到一块内存,为什么不说变量就是一块内存?上面定义变量时之所以会分配一块内存是因为变量是一个映射元素,需要一个对应地址,因此才在栈上分配了一块内存,并将其地址记录到变量表中。但是变量是可以有别名的,即另一个名字。这个说法是不准确的,应该是变量所对应的内存块有另一个名字,而不止是这个变量的名字。
为什么要有别名?这是语义的需要,表示既是什么又是什么。比如一块内存,里面记录了老板的信息,因此起名为Boss,但是老板又是另一家公司的行政经理,故变量名应该为Manager,而在程序中有段代码是老板的公司相关的,而另一段是老板所在公司相关的,在这两段程序中都要使用到老板的信息,那到底是使用Boss还是Manager?其实使用什么都不会对最终生成的机器代码产生什么影响,但此处出于语义的需要就应该使用别名,以期从代码上表现出所编写程序的意思。
在C++中,为了支持变量别名,提供了引用变量这个概念。要定义一个引用变量,在定义变量时,在变量名的前面加一个“&”,如下书写:
longa;long&a1=a,&a2=a,&a3=a2;
上面的a1、a2、a3都是a所对应的内存块的别名。这里在定义变量a时就在栈上分配了一块4字节内存,而在定义a1时却没有分配任何内存,直接将变量a所映射的地址作为变量a1的映射地址,进而形成对定义a时所分配的内存的别名。因此上面的Boss和Manager,应该如下(其中Person是一个结构或类或其他什么自定义类型,这将在后继的文章中陆续说明):
PersonBoss;Person&Manager=Boss;
由于变量一旦定义就不能改变(指前面说的变量表里的内容,不是变量的值),直到其被删除,所以上面在定义引用变量的时候必须给出欲别名的变量以初始化前面的变量表,否则编译器编译时将报错。
现在应该就更能理解前面关于变量的红字的意思了。并不是每个变量定义时都会分配内存空间的。而关于如何在堆上分配内存,将在介绍完指针后予以说明,并进而说明上一篇遗留下来的关于字符串的问题。
C++从零开始(四)
——赋值操作符
本篇是《C++从零开始(二)》的延续,说明《C++从零开始(二)》中遗留下来的关于表达式的内容,并为下篇指针的运用做一点铺垫。虽然上篇已经说明了变量是什么,但对于变量最关键的东西却由于篇幅限制而没有说明,下面先说明如何访问内存。
赋值语句
前面已经说明,要访问内存,就需要相应的地址以表明访问哪块内存,而变量是一个映射,因此变量名就相当于一个地址。对于内存的操作,在一般情况下就只有读取内存中的数值和将数值写入内存(不考虑分配和释放内存),在C++中,为了将一数值写入某变量对应的地址所标识的内存中(出于简便,以后称变量a对应的地址为变量a的地址,而直接称变量a的地址所标识的内存为变量a),只需先书写变量名,后接“=”,再接欲写入的数字(关于数字,请参考《C++从零开始(二)》)以及分号。如下:
a=10.0f;b=34;
由于接的是数字,因此就可以接表达式并由编译器生成计算相应表达式所需的代码,也就可如下:
c=a/b*120.4f;
上句编译器将会生成进行除法和乘法计算的CPU指令,在计算完毕后(也就是求得表达式a/b*120.4f的值了后),也会同时生成将计算结果放到变量c中去的CPU指令,这就是语句的基本作用(对于语句,在《C++从零开始(六)》中会详细说明)。
上面在书写赋值语句时,应该确保此语句之前已经将使用到的变量定义过,这样编译器才能在生成赋值用的CPU指令时查找到相应变量的地址,进而完成CPU指令的生成。如上面的a和b,就需要在书写上面语句前先书写类似下面的变量定义:
floata;longb;
直接书写变量名也是一条语句,其导致编译器生成一条读取相应变量的内容的语句。即可以如下书写:
a;
上面将生成一条读取内存的语句,即使从内存中读出来的数字没有任何应用(当然,如果编译器开了优化选项,则上面的语句将不会生成任何代码)。从这一点以及上面的c=a/b*120.4f;语句中,都可以看出一点——变量是可以返回数字的。而变量返回的数字就是按照变量的类型来解释变量对应内存中的内容所得到的数字。这句话也许不是那么容易理解,在看过后面的类型转换一节后应该就可以理解了。
因此为了将数据写入一块内存,使用赋值语句(即等号);要读取一块内存,书写标识内存的变量名。所以就可以这样书写:a=a+3;
假设a原来的值为1,则上面的赋值语句将a的值取出来,加上3,得到结果4,将4再写入a中去。由于C++使用“=”来代表赋值语句,很容易使人和数学中的等号混淆起来,这点应注意。
而如上的floata;语句,当还未对变量进行任何赋值操作时,a的值是什么?上帝才知道。当时的a的内容是什么(对于VC编译器,在开启了调试选项时,将会用0xCCCCCCCC填充这些未初始化内存),就用IEEE的real*4格式来解释它并得到相应的一个数字,也就是a的值。因此应在变量定义的时候就进行赋值(但是会有性能上的影响,不过很小),以初始化变量而防止出现莫名其妙的值,如:floata=0.0f;。
赋值操作符
上面的a=a+3;的意思就是让a的值增加3。在C++中,对于这种情况给出了一种简写方案,即前面的语句可以写成:a+=3;。应当注意这两条语句从逻辑上讲都是使变量a的值增3,但是它们实际是有区别的,后者可以被编译成优化的代码,因为其意思是使某一块内存的值增加一定数量,而前者是将一个数字写入到某块内存中。所以如果可能,应尽量使用后者,即a+=3;。这种语句可以让编译器进行一定的优化(但由于现在的编译器都非常智能,能够发现a=a+3;是对一块内存的增值操作而不是一块内存的赋值操作,因此上面两条语句实际上可以认为完全相同,仅仅只具有简写的功能了)。
对于上面的情况,也可以应用在减法、乘法等二元非逻辑操作符(不是逻辑值操作符,即不能a&&=3;)上,如:a*=3;a-=4;a|=34;a>>=3;等。
除了上面的简写外,C++还提供了一种简写方式,即a++;,其逻辑上等同于a+=1;。同上,在电脑编程中,加一和减一是经常用到的,因此CPU专门提供了两条指令来进行加一和减一操作(转成汇编语言就是Inc和Dec),但速度比直接通过加法或减法指令来执行要快得多。为此C++中也就提供了“++”和“—”操作符来对应Inc和Dec。所以a++;虽然逻辑上和a=a+1;等效,实际由于编译器可能做出的优化处理而不同,但还是如上,由于编译器的智能化,其是有可能看出a=a+1;可以编译成Inc指令进而即使没有使用a++;却也依然可以得到优化的代码,这样a++;将只剩下简写的意义而已。
应当注意一点,a=3;这句语句也将返回一个数字,也就是在a被赋完值后a的值。由于其可以返回数字,按照《C++从零开始(二)》中所说,“=”就属于操作符,也就可以如下书写:
c=4+(a=3);
之所以打括号是因为“=”的优先级较“+”低,而更常见和正常的应用是:c=a=3;
应该注意上面并不是将c和a赋值为3,而是在a被赋值为3后再将a赋值给c,虽然最后结果和c、a都赋值为3是一样的,但不应该这样理解。由于a++;表示的就是a+=1;就是a=a+1;,因此a++;也将返回一个数字。也由于这个原因,C++又提供了另一个简写方式,++a;。
假设a为1,则a++;将先返回a的值,1,然后再将a的值加一;而++a;先将a的值加一,再返回a的值,2。而a—和—a也是如此,只不过是减一罢了。
上面的变量a按照最上面的变量定义,是float类型的变量,对它使用++操作符并不能得到预想的优化,因为float类型是浮点类型,其是使用IEEE的real*4格式来表示数字的,而不是二进制原码或补码,而前面提到的Inc和Dec指令都是出于二进制的表示优点来进行快速增一和减一,所以如果对浮点类型的变量运用“++”操作符,将完全只是简写,没有任何的优化效果(当然,如果CPU提供了新的指令集,如MMX等,以对real*4格式进行快速增一和减一操作,且编译器支持相应指令集,则还是可以产生优化效果的)。
赋值操作符的返回值
在进一步了解++a和a++的区别前,先来了解何谓操作符的计算(Evaluate)。操作符就是将给定的数字做一些处理,然后返回一个数字。而操作符的计算也就是执行操作符的处理,并返回值。前面已经知道,操作符是个符号,其一侧或两侧都可以接数字,也就是再接其他操作符,而又由于赋值操作符也属于一种操作符,因此操作符的执行顺序变得相当重要。
对于a+b+c,将先执行a+b,再执行(a+b)+c的操作。你可能觉得没什么,那么如下,假设a之前为1:
c=(a*=2)+(a+=3);
上句执行后a为5。而c=(a+=3)+(a*=2);执行后,a就是8了。那么c呢?结果可能会大大的出乎你的意料。前者的c为10,而后者的c为16。
上面其实是一个障眼法,其中的“+”没有任何意义,即之所以会从左向右执行并不是因为“+”的缘故,而是因为(a*=2)和(a+=3)的优先级相同,而按照“()”的计算顺序,是从左向右来计算的。但为什么c的值不是预想的2+5和4+8呢?因为赋值操作符的返回值的关系。
赋值操作符返回的数字不是变量的值,而是变量对应的地址。这很重要。前面说过,光写一个变量名就会返回相应变量的值,那是因为变量是一个映射,变量名就等同于一个地址。C++中将数字看作一个很特殊的操作符,即任何一个数字都是一个操作符。而地址就和长整型、单精度浮点数这类一样,是数字的一种类型。当一个数字是地址类型时,作为操作符,其没有要操作的数字,仅仅返回将此数字看作地址而标识的内存中的内容(用这个地址的类型来解释)。地址可以通过多种途径得到,如上面光写一个变量名就可以得到其对应的地址,而得到的地址的类型也就是相应的变量的类型。如果这句话不能理解,在看过下面的类型转换一节后应该就能了解了。
所以前面的c=(a+=3)+(a*=2);,由于“()”的参与改变了优先级而先执行了两个赋值操作符,然后两个赋值操作符都返回a的地址,然后计算“+”的值,分别计算两边的数字——a的地址(a的地址也是一个操作符),也就是已经执行过两次赋值操作的a的值,得8,故最后的c为16。而另一个也由于同样的原因使得c为10。
现在考虑操作符的计算顺序。当同时出现了几个优先级相同的操作符时,不同的操作符具有不同的计算顺序。前面的“()”以及“-”、“*”等这类二元操作符的计算顺序都是从左向右计算,而“!”、负号“-”等前面介绍过的一元操作符都是从右向左计算的,如:!-!!a;,假设a为3。先计算从左朝右数第三个“!”的值,导致计算a的地址的值,得3;然后逻辑取反得0,接着再计算第二个“!”的值,逻辑取反后得1,再计算负号“-”的值,得-1,最后计算第一个“!”的值,得0。
赋值操作符都是从右向左计算的,除了后缀“++”和后缀“—”(即上面的a++和a--)。因此上面的c=a=3;,因为两个“=”优先级相同,从右向左计算,先计算a=3的值,返回a对应的地址,然后计算返回的地址而得到值3,再计算c=(a=3),将3写入c。而不是从左向右计算,即先计算c=a,返回c的地址,然后再计算第二个“=”,将3写入c,这样a就没有被赋值而出现问题。又:
a=1;c=2;c*=a+=4;
由于“*=”和“+=”的优先级相同,从右向左计算先计算a+=4,得a为5,然后返回a的地址,再计算a的地址得a的值5,计算“*=”以使得c的值为10。
因此按照前面所说,++a将返回a的地址,而a++也因为是赋值操作符而必须返回一个地址,但很明显地不能是a的地址了,因此编译器将编写代码以从栈中分配一块和a同样大小的内存,并将a的值复制到这块临时内存中,然后返回这块临时内存的地址。由于这块临时内存是因为编译器的需要而分配的,与程序员完全没有关系,因此程序员是不应该也不能写这块临时内存的(因为编译器负责编译代码,如果程序员欲访问这块内存,编译器将报错),但可以读取它的值,这也是返回地址的主要目的。所以如下的语句没有问题:
(++a)=a+=34;
但(a++)=a+=34;就会在编译时报错,因为a++返回的地址所标识的内存只能由编译器负责处理,程序员只能获得其值而已。
a++的意思是先返回a的值,也就是上面说的临时内存的地址,然后再将变量的值加一。如果同时出现多个a++,那么每个a++都需要分配一块临时内存(注意前面c=(a+=3)+(a*=2);的说明),那么将有点糟糕,而且a++的意思是先返回a的值,那么到底是什么时候的a的值呢?在VC中,当表达式中出现后缀“++”或后缀“—”时,只分配一块临时内存,然后所有的后缀“++”或后缀“—”都返回这个临时内存的地址,然后在所有的可以计算的其他操作符的值计算完毕后,再将对应变量的值写入到临时内存中,计算表达式的值,最后将对应变量的值加一或减一。
因此:a=1;c=(a++)+(a++);执行后,c的值为2,而a的值为3。而如下:
a=1;b=1;c=(++a)+(a++)+(b*=a++)+(a*=2)+(a*=a++);
执行时,先分配临时内存,然后由于5个“()”,其计算顺序是从左向右,
计算++a的值,返回增一后的a的地址,a的值为2
计算a++的值,返回临时内存的地址,a的值仍为2
计算b*=a++中的a++,返回临时内存的地址,a的值仍为2
计算b*=a++中的“*=”,将a的值写入临时内存,计算得b的值为2,返回b的地址
计算a*=2的值,返回a的地址,a的值为4
计算a*=a++中的a++,返回临时内存的地址,a的值仍为4
计算a*=a++中的“*=”,将a的值写入临时内存,返回a的地址,a的值为16
计算剩下的“+”,为了进行计算,将a的值写入临时内存,得值16+16+2+16+16为66,写入c中
计算三个a++欠下的加一,a最后变为19。
上面说了那么多,无非只是想告诫你——在表达式中运用赋值操作符是不被推崇的。因为其不符合平常的数学表达式的习惯,且计算顺序很容易搞混。如果有多个“++”操作符,最好还是将表达式分开,否则很容易导致错误的计算顺序而计算错误。并且导致计算顺序混乱的还不止上面的a++就完了,为了让你更加地重视前面的红字,下面将介绍更令人火大的东西,如果你已经同意上面的红字,则下面这一节完全可以跳过,其对编程来讲可以认为根本没有任何意义(要不是为了写这篇文章,我都不知道它的存在)。
序列点(SequencePoint)和附加效果(SideEffect)
在计算c=a++时,当c的值计算(Evaluate)出来时,a的值也增加了一,a的值加一就是计算前面表达式的附加效果。有什么问题?它可能影响表达式的计算结果。
对于a=0;b=1;(a*=2)&&(b+=2);,由于两个“()”优先级相同,从左向右计算,计算“*=”而返回a的地址,再计算“+=”而返回b的地址,最后由于a的值为0而返回逻辑假。很正常,但效率低了点。
如果“&&”左边的数字已经是0了,则不再需要计算右边的式子。同样,如果“||”左边的数字已经非零了,也不需要再计算右边的数字。因为“&&”和“||”都是数学上的,数学上不管先计算加号左边的值还是右边的值,结果都不会改变,因此“&&”和“||”才会做刚才的解释。这也是C++保证的,既满足数学的定义,又能提供优化的途径(“&&”和“||”右边的数字不用计算了)。
因此上面的式子就会被解释成——如果a在自乘了2后的值为0,则b就不用再自增2了。这很明显地违背了我们的初衷,认为b无论如何都会被自增2的。但是C++却这样保证,不仅仅是因为数学的定义,还由于代码生成的优化。但是按照操作符的优先级进行计算,上面的b+=2依旧会被执行的(这也正是我们会书写上面代码的原因)。为了实现当a为0时b+=2不会被计算,C++提出了序列点的概念。
序列点是一些特殊位置,由C++强行定义(C++并未给出序列点的定义,因此不同的编译器可能给出不同的序列点定义,VC是按照C语言定义的序列点)。当在进行操作符的计算时,如果遇到序列点,则序列点处的值必须被优先计算,以保证一些特殊用途,如上面的保证当a为0时不计算b+=2,并且序列点相关的操作符(如前面的“&&”和“||”)也将被计算完毕,然后才恢复正常的计算。
“&&”的左边数字的计算就是一个序列点,而“||”的左边数字的计算也是。C++定义了多个序列点,包括条件语句、函数参数等条件下的表达式计算,在此,不需要具体了解有哪些序列点,只需要知道由于序列点的存在而可能导致赋值操作符的计算出乎意料。下面就来分析一个例子:
a=0;b=1;(a*=2)&&(b+=++a);
按照优先级的顺序,编译器发现要先计算a*=2,再计算++a,接着“+=”,最后计算“&&”。然后编译器发现这个计算过程中,出现了“&&”左边的数字这个序列点,其要保证被优先计算,这样就有可能不用计算b+=++a了。所以编译器先计算“&&”的数字,通过上面的计算过程,编译器发现就要计算a*=2才能得到“&&”左边的数字,因此将先计算a*=2,返回a的地址,然后计算“&&”左边的数字,得a的值为0,因此就不计算b+=++a了。而不是最开始想象的由于优先级的关系先将a加一后再进行a的计算,以返回1。所以上面计算完毕后,a为0,b为1,返回0,表示逻辑假。
因此序列点的出现是为了保证一些特殊规则的出现,如上面的“&&”和“||”。再考虑“,”操作符,其操作是计算两边的值,然后返回右边的数字,即:a,b+3将返回b+3的值,但是a依旧会被计算。由于“,”的优先级是最低的(但高于前面提到的“数字”操作符),因此如果a=3,4;,那么a将为3而不是4,因为先计算“=”,返回a的地址后再计算“,”。又:
a=1;b=0;b=(a+=2)+((a*=2,b=a-1)&&(c=a));
由于“&&”左边数字是一个序列点,因此先计算a*=2,b的值,但根据“,”的返回值定义,其只返回右边的数字,因此不计算a*=2而直接计算b=a–1得0,“&&”就返回了,但是a*=2就没有被计算而导致a的值依旧为1,这违背了“,”的定义。为了消除这一点(当然可能还有其他应用“,”的情况),C++也将“,”的左边数字定为了序列点,即一定会优先执行“,”左边的数字以保证“,”的定义——计算两边的数字。所以上面就由于“,”左边数字这个序列点而导致a*=2被优先执行,并导致b为1,因此由于“&&”是序列点且其左边数字非零而必须计算完右边数字后才恢复正常优先级,而计算c=a,得2,最后才恢复正常优先级顺序,执行a+=2和“+”。结果就a为4,c为2,b为5。
所以前面的a=3,4;其实就应该是编译器先发现“,”这个序列点,而发现要计算“,”左边的值,必须先计算出a=3,因此才先计算a=3以至于感觉序列点好像没有发生作用。下面的式子请自行分析,执行后a为4,但如果将其中的“,”换成“&&”,a为2。
a=1;b=(a*=2)+((a*=3),(a-=2));
如果上面你看得很晕,没关系,因为上面的内容根本可以认为毫无意义,写在这里也只是为了进一步向你证明,在表达式中运用赋值运算符是不好的,即使它可能让你写出看起来简练的语句,但它也使代码的可维护性降低。
类型转换
在《C++从零开始(二)》中说过,数字可以是浮点数或是整型数或其他,也就是说数字是具有类型的。注意《C++从零开始(三)》中对类型的解释,类型只是说明如何解释状态,而在前面已经说过,出于方便,使用二进制数来表示状态,因此可以说类型是用于告诉编译器如何解释二进制数的。
所以,一个长整型数字是告诉编译器将得到的二进制数表示的状态按照二进制补码的格式来解释以得到一个数值,而一个单精度浮点数就是告诉编译器将得到的二进制数表示的状态按照IEEE的real*4的格式来解释以得到一个是小数的数值。很明显,同样的二进制数表示的状态,按照不同的类型进行解释将得到不同的数值,那么编译器如何知道应该使用什么类型来进行二进制数的解释?
前面已经说过,数字是一种很特殊的操作符,其没有操作数,仅仅返回由其类型而定的二进制数表示的状态(以后为了方便,将“二进制数表示的状态”称作“二进制数”)。而操作符就是执行指令并返回数字,因此所有的操作符到最后一定执行的是返回一个二进制数。这点很重要,对于后面指针的理解有着重要的意义。
先看15;,这是一条语句,因为15是一个数字。所以15被认为是char类型的数字(因为其小于128,没超出char的表示范围),将返回一个8位长的二进制数,此二进制数按照补码格式编写,为00001111。
再看15.0f,同上,其由于接了“f”这个后缀而被认为是float类型的数字,将返回一个32位长的二进制数,此二进制数按照IEEE的real*4格式编写,为1000001011100000000000000000000。
虽然上面15和15.0f的数值相等,但由于是不同的类型导致了使用不同的格式来表示,甚至连表示用的二进制数的长度都不相同。因此如果书写15.0f==15;将返回0,表示逻辑假。但实际却返回1,为什么?
上面既然15和15.0f被表示成完全不同的两个二进制数,但我们又认为15和15.0f是相等的,但它们的二进制表示不同,怎么办?将表示15.0f的二进制数用IEEE的real*4格式解释出15这个数值,然后再将其按8位二进制补码格式编写出二进制数,再与原来的表示15的二进制数比较。
为了实现上面的操作,C++提供了类型转换操作符——“()”。其看起来和括号操作符一样,但是格式不同:(<类型名>)<数字>或<类型名>(<数字>)。
上面类型转换操作符的<类型名>不是数字,因此其将不会被操作,而是作为一个参数来控制其如何操作后面的<数字>。<类型名>是一个标识符,其唯一标识一个类型,如char、float等。类型转换操作符的返回值就如其名字所示,将<数字>按照<类型名>标识的类型来解释,返回类型是<类型名>的数字。因此,上面的例子我们就需要如下编写:15==(char)15.0f;,现在其就可以返回1,表示逻辑真了。但是即使不写(char),前面的语句也返回1。这是编译器出于方便的缘故而帮我们在15前添加了(float),所以依然返回1。这被称作隐式类型转换,在后面说明类的时候,还将提到它。
某个类型可以完全代替另一个类型时,编译器就会进行上面的隐式类型转换,自动添加类型转换操作符。如:char只能表示-128到127的整数,而float很明显地能够表示这些数字,因此编译器进行了隐式类型转换。应当注意,这个隐式转换是由操作符要求的,即前面的“==”要求两面的数字类型一致,结果发现两边不同,结果编译器将char转成float,然后再执行“==”的操作。注意:在这种情况下,编译器总是将较差的类型(如前面的char)转成较好的类型(如前面的float),以保证不会发生数值截断问题。如:-41==3543;,左边是char,右边是short,由于short相对于char来显得更优(short能完全替代char),故实际为:(short)-41==3543;,返回0。而如果是-41==(char)3543;,由于char不能表示3543,则3543以补码转成二进制数0000110111010111,然后取其低8位,而导致高8位的00001101被丢弃,此被称为截断。结果(char)3543的返回值就是类型为char的二进制数11010111,为-41,结果-41==(char)3543;的返回值将为1,表示逻辑真,很明显地错误。因此前面的15==15.0f;实际将为(float)15==15.0f;(注意这里说15被编译器解释为char类型并不准确,更多的编译器是将它解释成int类型)。
注意前面之所以会朝好的方向发展(即char转成float),完全是因为“==”的缘故,其要求这么做。下面考虑“=”:shortb=3543;chara=b;。因为b的值是short类型,而“=”的要求就是一定要将“=”右边的数字转成和左边一样,这样才能进行正确的内存的写入(简单地将右边数字返回的二进制数复制到左边的地址所表示的内存中)。因此a将为-41。但是上面是编译器按照“=”的要求自行进行了隐式转换,可能是由于程序员的疏忽而没有发现这个错误(以为b的值一定在-128到127的范围内),因此编译器将对上面的情况给出一个警告,说b的值可能被截断。为了消除编译器的疑虑,如下:chara=(char)b;。这样称为显示类型转换,其告诉编译器——“我知道可能发生数据截断,但是我保证不会截断”。因此编译器将不再发出警告。但是如下:chara=(char)3543;,由于编译器可以肯定3543一定会被截断而导致错误的返回值,因此编译器将给出警告,说明3543将被截断,而不管前面的类型转换操作符是否存在。
现在应该可以推出——15+15.0f;返回的是一个float类型的数字。因此如果如下:chara=15+15.0f;,编译器将发出警告,说数据可能被截断。因此改成如下:chara=(char)15+15.0f;,但类型转换操作符“()”的优先级比“+”高,结果就是15先被转换为char然后再由于“+”的要求而被隐式转成float,最后返回float给“=”而导致编译器依旧发出警告。为此,就需要提高“+”的优先级,如下:chara=(char)(15+15.0f);就没事了(或char(15+15.0f)),其表示我保证15+15.0f不会导致数据截断。
应该注意类型转换操作符“()”和前缀“++”、“!”、负号“-”等的优先级一样,并且是从右向左计算的,因此(char)-34;将会先计算-34的值,然后再计算(char)的值,这也正好符合人的习惯。
下篇将针对数字这个特殊操作符而提出一系列的东西,因此如果理解了数字的意思,那么指针将很容易理解。
C++从零开始(五)
——何谓指针
本篇说明C++中的重中又重的关键——指针类型,并说明两个很有意义的概念——静态和动态。
数组
前面说了在C++中是通过变量来对内存进行访问的,但根据前面的说明,C++中只能通过变量来操作内存,也就是说要操作某块内存,就必须先将这块内存的首地址和一个变量名绑定起来,这是很糟糕的。比如有100块内存用以记录100个工人的工资,现在要将每个工人的工资增加5%,为了知道各个工人增加了后的工资为多少,就定义一个变量floata1;,用其记录第1个工人的工资,然后执行语句a1+=a1*0.05f;,则a1里就是增加后的工资。由于是100个工人,所以就必须有100个变量,分别记录100个工资。因此上面的赋值语句就需要有100条,每条仅仅变量名不一样。
上面需要手工重复书写变量定义语句floata1;100遍(每次变一个变量名),无谓的工作。因此想到一次向操作系统申请100*4=400个字节的连续内存,那么要给第i个工人修改工资,只需从首地址开始加上4*i个字节就行了(因为float占用4个字节)。
为了提供这个功能,C++提出了一种类型——数组。数组即一组数字,其中的各个数字称作相应数组的元素,各元素的大小一定相等(因为数组中的元素是靠固定的偏移来标识的),即数组表示一组相同类型的数字,其在内存中一定是连续存放的。在定义变量时,要表示某个变量是数组类型时,在变量名的后面加上方括号,在方括号中指明欲申请的数组元素个数,以分号结束。因此上面的记录100个工资的变量,即可如下定义成数组类型的变量:
floata[100];
上面定义了一个变量a,分配了100*4=400个字节的连续内存(因为一个float元素占用4个字节),然后将其首地址和变量名a相绑定。而变量a的类型就被称作具有100个float类型元素的数组。即将如下解释变量a所对应内存中的内容(类型就是如何解释内存的内容):a所对应的地址标识的内存是一块连续内存的首地址,这块连续内存的大小刚好能容纳下100个float类型的数字。
因此可以将前面的floatb;这种定义看成是定义了一个元素的float数组变量b。而为了能够访问数组中的某个元素,在变量名后接方括号,方括号中放一数字,数字必须是非浮点数,即使用二进制原码或补码进行表示的数字。如a[5+3]+=32;就是数组变量a的第5+3个元素的值增加32。又:
longc=23;floatb=a[(c–3)/5]+10,d=a[c–23];
上面的b的值就为数组变量a的第4个元素的值加10,而d的值就为数组变量a的第0个元素的值。即C++的数组中的元素是以0为基本序号来记数的,即a[0]实际代表的是数组变量a中的第一个元素的值,而之所以是0,表示a所对应的地址加上0*4后得到的地址就为第一个元素的地址。
应该注意不能这样写:longa[0];,定义0个元素的数组是无意义的,编译器将报错,不过在结构或类或联合中符合某些规则后可以这样写,那是C语言时代提出的一种实现结构类型的长度可变的技术,在《C++从零开始(九)》中将说明。
还应注意上面在定义数组时不能在方括号内写变量,即longb=10;floata[b];是错误的,因为编译此代码时,无法知道变量b的值为多少,进而无法分配内存。可是前面明明已经写了b=10;,为什么还说不知道b的值?那是因为无法知道b所对应的地址是多少。因为编译器编译时只是将b和一个偏移进行了绑定,并不是真正的地址,即b所对应的可能是Base-54,而其中的Base就是在程序一开始执行时动态向操作系统申请的大块内存的尾地址,因为其可能变化,故无法得知b实际对应的地址(实际在Windows平台下,由于虚拟地址空间的运用,是可以得到实际对应的虚拟地址,但依旧不是实际地址,故无法编译时期知道某变量的值)。
但是编译器仍然可以根据前面的longb=10;而推出Base-54的值为10啊?重点就是编译器看到longb=10;时,只是知道要生成一条指令,此指令将10放入Base-54的内存中,其它将不再过问(也没必要过问),故即使才写了longb=10;编译器也无法得知b的值。
上面说数组是一种类型,其实并不准确,实际应为——数组是一种类型修饰符,其定义了一种类型修饰规则。关于类型修饰符,后面将详述。
字符串
在《C++从零开始(二)》中已经说过,要查某个字符对应的ASCII码,通过在这个字符的两侧加上单引号,如'A'就等同于65。而要表示多个字符时,就使用双引号括起来,如:"ABC"。而为了记录字符,就需要记录下其对应的ASCII码,而ASCII码的数值在-128到127以内,因此使用一个char变量就可以记录一个ASCII码,而为了记录"ABC",就很正常地使用一个char的数组来记录。如下:
chara='A';charb[10];b[0]='A';b[1]='B';b[2]='C';
上面a的值为65,b[0]的值为65,b[1]为66,b[2]为67。因为b为一个10元素的数组,在这其记录了一个3个字符长度的字符串,但是当得到b的地址时,如何知道其第几个元素才是有效的字符?如上面的b[4]就没有赋值,那如何知道b[4]不应该被解释为字符?可以如下,从第0个元素开始依次检查每个char元素的值,直到遇到某个char元素的值为0(因为ASCII码表中0没有对应的字符),则其前面的所有的元素都认为是应该用ASCII码表来解释的字符。故还应b[3]=0;以表示字符串的结束。
上面的规则被广泛运用,C运行时期库中提供的所有有关字符串的操作都是基于上面的规则来解释字符串的(关于C运行时期库,可参考《C++从零开始(十九)》)。但上面为了记录一个字符串,显得烦琐了点,字符串有多长就需要写几个赋值语句,而且还需要将末尾的元素赋值为0,如果搞忘则问题严重。对于此,C++强制提供了一种简写方式,如下:
charb[10]="ABC";
上面就等效于前面所做的所有工作,其中的"ABC"是一个地址类型的数字(准确的说是一初始化表达式,在《C++从零开始(九)》中说明),其类型为char[4],即一个4个元素的char数组,多了一个末尾元素用于放0来标识字符串的结束。应当注意,由于b为char[10],而"ABC"返回的是char[4],类型并不匹配,需要隐式类型转换,但实际没有进行转换,而是做了一系列的赋值操作(就如前面所做的工作),这是C++硬性规定的,称为初始化,且仅仅对于数组定义时进行初始化有效,即如下是错误的:
charb[10];b="ABC";
而即使是charb[4];b="ABC";也依旧错误,因为b的类型是数组,表示的是多个元素,而对多个元素赋值是未定义的,即:floatd[4];floatdd[4]=d;也是错误的,因为没定义d中的元素是依次顺序放到dd中的相应各元素,还是倒序放到,所以是不能对一个数组类型的变量进行赋值的。
由于现在字符的增多(原来只用英文字母,现在需要能表示中文、日文等多种字符),原来使用char类型来表示字符,最多也只能表示255种字符(0用来表示字符串结束),所以出现了所谓的多字节字符串(MultiByte),用这种表示方式记录的文本文件称为是MBCS格式的,而原来使用char类型进行表示的字符串称为单字节字符串(SingleByte),用这种表示方式记录的文本文件称为是ANSI格式的。
由于char类型可以表示负数,则当从字符串中提取字符时,如果所得元素的数值是负的,则将此元素和下一个char元素联合起来形成一short类型的数字,再按照Unicode编码规则(一种编码规则,等同于前面提过的ASCII码表)来解释这个short类型的数字以得到相应的字符。
而上面的"ABC"返回的就是以多字节格式表示的字符串,因为没有汉字或特殊符号,故好象是用单字节格式表示的,但如果:charb[10]="AB汉C";,则b[2]为-70,b[5]为0,而不是想象的由于4个字符故b[4]为0,因为“汉”这个字符占用了两个字节。
上面的多字节格式的坏处是每个字符的长度不固定,如果想取字符串中的第3个字符的值,则必须从头开始依次检查每个元素的值而不能是3乘上某个固定长度,降低了字符串的处理速度,且在显示字符串时由于需要比较检查当前字符的值是否小于零而降低效率,故又推出了第三种字符表示格式:宽字节字符串(WideChar),用这种表示方式记录的文本文件称为是Unicode格式的。其与多字节的区别就是不管这个字符是否能够用ASCII表示出来,都用一个short类型的数字来表示,即每个字符的长度固定为2字节,C++对此提供了支持。
shortb[10]=L"AB汉C";
在双引号的前面加上“L”(必须是大写的,不能小写)即告诉编译器此双引号内的字符要使用Unicode格式来编码,故上面的b数组就是使用Unicode来记录字符串的。同样,也有:shortc=L'A';,其中的c为65。
如果上面看得不是很明白,不要紧,在以后举出的例子中将会逐渐了解字符串的使用的。
静态和动态
上面依然没有解决根本问题——C++依旧只能通过变量这个映射元素来访问内存,在访问某块内存前,一定要先建立相应的映射,即定义变量。有什么坏处?让我们先来了解静态和动态是什么意思。
收银员开发票,手动,则每次开发票时,都用已经印好的发票联给客人开发票,发票联上只印了4个格子用以记录商品的名称,当客人一次买的商品超过4种以上时,就必须开两张或多张发票。这里发票联上的格子的数量就被称作静态的,即无论任何时候任何客人买东西,开发票时发票联上都印着4个记录商品名称用的格子。
超市的收银员开发票,将商品名称及数量等输入电脑,然后即时打印出一张发票给客人,则不同的客人,打印出的发票的长度可能不同(有的客人买得多而有的少),此时发票的长度就称为动态的,即不同时间不同客人买东西,开出的发票长度可能不同。
程序无论执行多少遍,在申请内存时总是申请固定大小的内存,则称此内存是静态分配的。前面提出的定义变量时,编译器帮我们从栈上分配的内存就属于静态分配。每次执行程序,根据用户输入的不同而可能申请不同大小的内存时,则称此内存是动态分配的,后面说的从堆上分配就属于动态分配。
很明显,动态比静态的效率高(发票长度的利用率高),但要求更高——需要电脑和打印机,且需要收银员的素质较高(能操作电脑),而静态的要求就较低,只需要已经印好的发票联,且也只需收银员会写字即可。
同样,静态分配的内存利用率不高或运用不够灵活,但代码容易编写且运行速度较快;动态分配的内存利用率高,不过编写代码时要复杂些,需自己处理内存的管理(分配和释放)且由于这种管理的介入而运行速度较慢并代码长度增加。
静态和动态的意义不仅仅如此,其有很多的深化,如硬编码和软编码、紧耦合和松耦合,都是静态和动态的深化。
地址
前面说过“地址就是一个数字,用以唯一标识某一特定内存单元”,而后又说“而地址就和长整型、单精度浮点数这类一样,是数字的一种类型”,那地址既是数字又是数字的类型?不是有点矛盾吗?如下:
浮点数是一种数——小数——又是一种数字类型。即前面的前者是地址实际中的运用,而后者是由于电脑只认识状态,但是给出的状态要如何处理就必须通过类型来说明,所以地址这种类型就是用来告诉编译器以内存单元的标识来处理对应的状态。
指针
已经了解到动态分配内存和静态分配内存的不同,现在要记录用户输入的定单数据,用户一次输入的定单数量不定,故选择在堆上分配内存。假设现在根据用户的输入,需申请1M的内存以对用户输入的数据进行临时记录,则为了操作这1M的连续内存,需记录其首地址,但又由于此内存是动态分配的,即其不是由编译器分配(而是程序的代码动态分配的),故未能建立一变量来映射此首地址,因此必须自己来记录此首地址。
因为任何一个地址都是4个字节长的二进制数(对32位操作系统),故静态分配一块4字节内存来记录此首地址。检查前面,可以将首地址这个数据存在unsignedlong类型的变量a中,然后为了读取此1M内存中的第4个字节处的4字节长内存的内容,通过将a的值加上4即可获得相应的地址,然后取出其后连续的4个字节内存的内容。但是如何编写取某地址对应内存的内容的代码呢?前面说了,只要返回地址类型的数字,由于是地址类型,则其会自动取相应内容的。但如果直接写:a+4,由于a是unsignedlong,则a+4返回的是unsignedlong类型,不是地址类型,怎么办?
C++对此提出了一个操作符——“*”,叫做取内容操作符(实际这个叫法并不准确)。其和乘号操作符一样,但是它只在右侧接数字,即*(a+4)。此表达式返回的就是把a的值加上4后的unsignedlong数字转成地址类型的数字。但是有个问题:a+4所表示的内存的内容如何解释?即取1个字节还是2个字节?以什么格式来解释取出的内容?如果自己编写汇编代码,这就不是问题了,但现在是编译器代我们编写汇编代码,因此必须通过一种手段告诉编译器如何解释给定的地址所对内存的内容。
C++对此提出了指针,其和上面的数组一样,是一种类型修饰符。在定义变量时,在变量名的前面加上“*”即表示相应变量是指针类型(就如在变量名后接“[]”表示相应变量是数组类型一样),其大小固定为4字节。如:
unsignedlong*pA;
上面pA就是一个指针变量,其大小因为是为32位操作系统编写代码故为4字节,当*pA;时,先计算pA的值,就是返回从pA所对应地址的内存开始,取连续4个字节的内容,然后计算“*”,将刚取到的内容转成unsignedlong的地址类型的数字,接着计算此地址类型的数字,返回以原码格式解释其内容而得到一个unsignedlong的数字,最后计算这个unsignedlong的数字而返回以原码格式解释它而得的二进制数。
也就是说,某个地址的类型为指针时,表示此地址对应的内存中的内容,应该被编译器解释成一个地址。
因为变量就是地址的映射,每个变量都有个对应的地址,为此C++又提供了一个操作符来取某个变量的地址——“&”,称作取地址操作符。其与“数字与”操作符一样,不过它总是在右侧接数字(而不是两侧接数字)。
“&”的右侧只能接地址类型的数字,它的计算(Evaluate)就是将右侧的地址类型的数字简单的类型转换成指针类型并进而返回一个指针类型的数字,正好和取内容操作符“*”相反。
上面正常情况下应该会让你很晕,下面释疑。
unsignedlonga=10,b,*pA;pA=&a;b=*pA;(*pA)++;
上面的第一句通过“*pA”定义了一个指针类型的变量pA,即编译器帮我们在栈上分配了一块4字节的内存,并将首地址和pA绑定(即形成映射)。然后“&a”由于a是一个变量,等同于地址,所以“&a”进行计算,返回一个类型为unsignedlong*(即unsignedlong的指针)的数字。
应该注意上面返回的数字虽然是指针类型,但是其值和a对应的地址相同,但为什么不直接说是unsignedlong的地址的数字,而又多一个指针类型在其中搅和?因为指针类型的数字是直接返回其二进制数值,而地址类型的数字是返回其二进制数值对应的内存的内容。因此假设上面的变量a所对应的地址为2000,则a;将返回10,而&a;将返回2000。
看下指针类型的返回值是什么。当书写pA;时,返回pA对应的地址(按照上面的假设就应该是2008),计算此地址的值,返回数字2000(因为已经pA=&a;),其类型是unsignedlong*,然后对这个unsignedlong*的数字进行计算,直接返回2000所对应的二进制数(注意前面红字的内容)。
再来看取内容操作符“*”,其右接的数字类型是指针类型或数组类型,它的计算就是将此指针类型的数字直接转换成地址类型的数字而已(因为指针类型的数字和地址类型的数字在数值上是相同的,仅仅计算规则不同)。所以:
b=*pA;
返回pA对应的地址,计算此地址的值,返回类型为unsignedlong*的数字2000,然后“*pA”返回类型unsignedlong的地址类型的数字2000,然后计算此地址类型的数字的值,返回10,然后就只是简单地赋值操作了。同理,对于++(*pA)(由于“*”的优先级低于前缀++,所以加“()”),先计算“*pA”而返回unsignedlong的地址类型的数字2000,然后计算前缀++,最后返回unsignedlong的地址类型的数字2000。
如果你还是未能理解地址类型和指针类型的区别,希望下面这句能够有用:地址类型的数字是在编译时期给编译器用的,指针类型的数字是在运行时期给代码用的。如果还是不甚理解,在看过后面的类型修饰符一节后希望能有所帮助。
在堆上分配内存
前面已经说过,所谓的在堆上分配就是运行时期向操作系统申请内存,而要向操作系统申请内存,不同的操作系统提供了不同的接口,具有不同的申请内存的方式,而这主要通过需调用的函数原型不同来表现(关于函数原型,可参考《C++从零开始(七)》)。由于C++是一门语言,不应该是操作系统相关的,所以C++提供了一个统一的申请内存的接口,即new操作符。如下:
unsignedlong*pA=newunsignedlong;*pA=10;
unsignedlong*pB=newunsignedlong[*pA];
上面就申请了两块内存,pA所指的内存(即pA的值所对应的内存)是4字节大小,而pB所指的内存是4*10=40字节大小。应该注意,由于new是一个操作符,其结构为new<类型名>[<整型数字>]。它返回指针类型的数字,其中的<类型名>指明了什么样的指针类型,而后面方括号的作用和定义数组时一样,用于指明元素的个数,但其返回的并不是数组类型,而是指针类型。
应该注意上面的new操作符是向操作系统申请内存,并不是分配内存,即其是有可能失败的。当内存不足或其他原因时,new有可能返回数值为0的指针类型的数字以表示内存分配失败。即可如下检测内存是否分配成功。
unsignedlong*pA=newunsignedlong[10000];
if(!pA)
//内存失败!做相应的工作
上面的if是判断语句,下篇将介绍。如果pA为0,则!pA的逻辑取反就是非零,故为逻辑真,进而执行相应的工作。
只要分配了内存就需要释放内存,这虽然不是必须的,但是作为程序员,它是一个良好习惯(资源是有限的)。为了释放内存,使用delete操作符,如下:
deletepA;delete[]pB;
注意delete操作符并不返回任何数字,但是其仍被称作操作符,看起来它应该被叫做语句更加合适,但为了满足其依旧是操作符的特性,C++提供了一种很特殊的数字类型——void。其表示无,即什么都不是,这在《C++从零开始(七)》中将详细说明。因此delete其实是要返回数字的,只不过返回的数字类型为void罢了。
注意上面对pA和pB的释放不同,因为pA按照最开始的书写,是newunsignedlong返回的,而pB是newunsignedlong[*pA]返回的。所以需要在释放pB时在delete的后面加上“[]”以表示释放的是数组,不过在VC中,不管前者还是后者,都能正确释放内存,无需“[]”的介入以帮助编译器来正确释放内存,因为以Windows为平台而开发程序的VC是按照Windows操作系统的方式来进行内存分配的,而Windows操作系统在释放内存时,无需知道欲释放的内存块的长度,因为其已经在内部记录下来(这种说法并不准确,实际应是C运行时期库干了这些事,但其又是依赖于操作系统来干的,即其实是有两层对内存管理的包装,在此不表)。
类型修饰符(type-specifier)
类型修饰符,即对类型起修饰作用的符号,在定义变量时用于进一步指明如何操作变量对应的内存。因为一些通用操作方式,即这种操作方式对每种类型都适用,故将它们单独分离出来以方便代码的编写,就好像水果。吃苹果的果肉、吃梨的果肉,不吃苹果的皮、不吃梨的皮。这里苹果和梨都是水果的种类,相当于类型,而“XXX的果肉”、“XXX的皮”就是用于修饰苹果或梨这种类型用的,以生成一种新的类型——苹果的果肉、梨的皮,其就相当于类型修饰符。
本文所介绍的数组和指针都是类型修饰符,之前提过的引用变量的“&”也是类型修饰符,在《C++从零开始(七)》中将再提出几种类型修饰符,到时也将一同说明声明和定义这两个重要概念,并提出声明修饰符(decl-specifier)。
类型修饰符只在定义变量时起作用,如前面的unsignedlonga,b[10],*pA=&a,&rA=a;。这里就使用了上面的三个类型修饰符——“[]”、“*”和“&”。上面的unsignedlong暂且叫作原类型,表示未被类型修饰符修饰以前的类型。下面分别说明这三个类型修饰符的作用。
数组修饰符“[]”——其总是接在变量名的后面,方括号中间放一整型数c以指明数组元素的个数,以表示当前类型为原类型c个元素连续存放,长度为原类型的长度乘以c。因此longa[10];就表示a的类型是10个long类型元素连续存放,长度为10*4=40字节。而longa[10][4];就表示a是10个long[4]类型的元素连续存放,其长度为10*(4*4)=160字节。
相信已经发现,由于可以接多个“[]”,因此就有了计算顺序的关系,为什么不是4个long[10]类型的元素连续存放而是倒过来?类型修饰符的修饰顺序是从左向右进行计算的,但当出现重复的类型修饰符时,同类修饰符之间是从右向左计算以符合人们的习惯。故short*a[10];表示的是10个类型为short*的元素连续存放,长度为10*4=40字节,而short*b[4][10];表示4个类型为short*[10]的元素连续存放,长度为4*40=160字节。
指针修饰符“*”——其总是接在变量名的前面,表示当前类型为原类型的指针。故:
shorta=10,*pA=&a,**ppA=&pA;
注意这里的ppA被称作多级指针,即其类型为short的指针的指针,也就是short**。而short**ppA=&pA;的意思就是计算pA的地址的值,得一类型为short*的地址类型的数字,然后“&”操作符将此数字转成short*的指针类型的数字,最后赋值给变量ppA。
如果上面很昏,不用去细想,只要注意类型匹配就可以了,下面简要说明一下:假设a的地址为2000,则pA的地址为2002,ppA的地址为2006。
对于pA=&a;。先计算“&a”的值,因为a等同于地址,则“&”发挥作用,直接将a的地址这个数字转成short*类型并返回,然后赋值给pA,则pA的值为2000。
对于ppA=&pA;。先计算“&pA”的值,因为pA等同于地址,则“&”发挥作用,直接将pA的地址这个数字转成short**类型(因为pA已经是short*的类型了)并返回,然后赋值给ppA,则ppA的值为2002。
引用修饰符“&”——其总是接在变量名的前面,表示此变量不用分配内存以和其绑定,而在说明类型时,则不能有它,下面说明。由于表示相应变量不用分配内存以生成映射,故其不像上述两种类型修饰符,可以多次重复书写,因为没有意义。且其一定在“*”修饰符的右边,即可以short**&b=ppA;但不能short*&*b;或short&**b;因为按照从左到右的修饰符计算顺序,short*&*表示short的指针的引用的指针,引用只是告知编译器不要为变量在栈上分配内存,实际与类型无关,故引用的指针是无意义的。而short&**则表示short的引用的指针的指针,同上,依旧无意义。同样long&a[40];也是错误的,因为其表示分配一块可连续存放类型为long的引用的40个元素的内存,引用只是告知编译器一些类型无关信息的一种手段,无法作为类型的一种而被实例化(关于实例化,请参看《C++从零开始(十)》)。
应该注意引用并不是类型(但出于方便,经常都将long的引用称作一种类型),而long**&rppA=&pA;将是错误的,因为上句表示的是不要给变量rppA分配内存,直接使用“=”后面的地址作为其对应的地址,而&pA返回的并不是地址类型的数字,而是指针类型,故编译器将报类型不匹配的错误。但是即使long**&rppA=pA;也同样失败,因为long*和long**是不同的,不过由于类型的匹配,下面是可以的(其中的rpA2很令人疑惑,将在《C++从零开始(七)》中说明):
longa=10,*pA=&a,**ppA=&pA,*&rpA1=*ppA,*&rpA2=*(ppA+1);
类型修饰符和原类型组合在一起以形成新的类型,如long*&、short*[34]等,都是新的类型,应注意前面new操作符中的<类型名>要求写入类型名称,则也可以写上前面的long*等,即:
long**ppA=newlong*[45];
即动态分配一块4*45=180字节的连续内存空间,并将首地址返回给ppA。同样也就可以:
long***pppA=newlong**[2];
而long*(*pA)[10]=newlong*[20][10];
也许看起来很奇怪,其中的pA的类型为long*(*)[10],表示是一个有10个long*元素的数组的指针,而分配的内存的长度为(4*10)*20=800字节。因为数组修饰符“[]”只能放在变量名后面,而类型修饰符又总是从左朝右计算,则想说明是一个10个long元素的数组的指针就不行,因为放在左侧的“*”总是较右侧的“[]”先进行类型修饰。故C++提出上面的语法,即将变量名用括号括起来,表示里面的类型最后修饰,故:long*(a)[10];等同于long*a[10];,而long*(&aa)[10]=a;也才能够正确,否则按照前面的规则,使用long*&aa[10]=a;将报错(前面已说明原因)。而long*(*pA)[10]=&a;也就能很正常地表示我们需要的类型了。因此还可以long*(*&rpA)[10]=pA;以及long*(**ppA)[10]=&pA;。
限于篇幅,还有部分关于指针的讨论将放到《C++从零开始(七)》中说明,如果本文看得很晕,后面在举例时将会尽量说明指针的用途及用法,希望能有所帮助。
C++从零开始(六)
——何谓语句
前面已经说过程序就是方法的描述,而方法的描述无外乎就是动作加动作的宾语,而这里的动作在C++中就是通过语句来表现的,而动作的宾语,也就是能够被操作的资源,但非常可惜地C++语言本身只支持一种资源——内存。由于电脑实际可以操作不止内存这一种资源,导致C++语言实际并不能作为底层硬件程序的编写语言(即使是C语言也不能),不过各编译器厂商都提供了自己的嵌入式汇编语句功能(也可能没提供或提供其它的附加语法以使得可以操作硬件),对于VC,通过使用__asm语句即可实现在C++代码中加入汇编代码来操作其他类型的硬件资源。对于此语句,本系列不做说明。
语句就是动作,C++中共有两种语句:单句和复合语句。复合语句是用一对大括号括起来,以在需要的地方同时放入多条单句,如:{longa=10;a+=34;}。而单句都是以“;”结尾的,但也可能由于在末尾要插入单句的地方用复合语句代替了而用“}”结尾,如:if(a){a--;a++;}。应注意大括号后就不用再写“;”了,因为其不是单句。
方法就是怎么做,而怎么做就是在什么样的情况下以什么样的顺序做什么样的动作。因为C++中能操作的资源只有内存,故动作也就很简单的只是关于内存内容的运算和赋值取值等,也就是前面说过的表达式。而对于“什么样的顺序”,C++强行规定只能从上朝下,从左朝右来执行单句或复合语句(不要和前面关于表达式的计算顺序搞混了,那只是在一个单句中的规则)。而最后对于“什么样的情况”,即进行条件的判断。为了不同情况下能执行不同的代码,C++定义了跳转语句来实现,其是基于CPU的运行规则来实现的,下面先来看CPU是如何执行机器代码的。
机器代码的运行方式
前面已经说过,C++中的所有代码到最后都要变成CPU能够认识的机器代码,而机器代码由于是方法的描述也就包含了动作和动作的宾语(也可能不带宾语),即机器指令和内存地址或其他硬件资源的标识,并且全部都是用二进制数表示的。很正常,这些代表机器代码的二进制数出于效率的考虑在执行时要放到内存中(实际也可以放在硬盘或其他存储设备中),则很正常地每个机器指令都能有一个地址和其相对应。
CPU内带一种功能和内存一样的用于暂时记录二进制数的硬件,称作寄存器,其读取速度较内存要快很多,但大小就小许多了。为了加快读取速度,寄存器被去掉了寻址电路进而一个寄存器只能存放1个32位的二进制数(对于32位电脑)。而CPU就使用其中的一个寄存器来记录当前欲运行的机器指令的位置,在此称它为指令寄存器。
CPU运行时,就取出指令寄存器的值,进而找到相应的内存,读取1个字节的内容,查看此8位二进制数对应的机器指令是什么,进而做相应的动作。由于不同的指令可能有不同数量的参数(即前面说的动作的宾语)需要,如乘法指令要两个参数以将它们乘起来,而取反操作只需要一个参数的参与。并且两个8位二进制数的乘法和两个16位二进制数的乘法也不相同,故不同的指令带不同的参数而形成的机器代码的长度可能不同。每次CPU执行完某条机器代码后,就将指令寄存器的内容加上此机器代码的长度以使指令寄存器指向下一条机器代码,进而重复上面的过程以实现程序的运行(这只是简单地说明,实际由于各种技术的加入,如高速缓冲等,实际的运行过程要比这复杂得多)。
语句的分类
在C++中,语句总共有6种:声明语句、定义语句、表达式语句、指令语句、预编译语句和注释语句。其中的声明语句下篇说明,预编译语句将在《C++从零开始(十六)》中说明,而定义语句就是前面已经见过的定义变量,后面还将说明定义函数、结构等。表达式语句则就是一个表达式直接接一个“;”,如:34;、a=34;等,以依靠操作符的计算功能的定义而生成相应的关于内存值操作的代码。注释语句就是用于注释代码的语句,即写来给人看的,不是给编译器看的。最后的指令语句就是含有下面所述关键字的语句,即它们的用处不是操作内存,而是实现前面说的“什么样的情况”。
这里的声明语句、预编译语句和注释语句都不会转换成机器代码,即这三种语句不是为了操作电脑,而是其他用途,以后将详述。而定义语句也不一定会生成机器代码,只有表达式语句和指令语句一定会生成代码(不考虑编译器的优化功能)。
还应注意可以写空语句,即;或{},它们不会生成任何代码,其作用仅仅只是为了保证语法上的正确,后面将看到这一点。下面说明注释语句和指令语句——跳转语句、判断语句和循环语句(实际不止这些,由于异常和模板技术的引入而增加了一些语句,将分别在说明异常和模板时说明)。
注释语句——//、/**/
注释,即用于解释的标注,即一些文字信息,用以向看源代码的人解释这段代码什么意思,因为人的认知空间和电脑的完全不同,这在以后说明如何编程时会具体讨论。要书写一段话用以注释,用“/*”和“*/”将这段话括起来,如下:
longa=1;
a+=1;/*a放的是人的个数,让人的个数加一*/
b*=a;/*b放的是人均花费,得到总的花费*/
上面就分别针对a+=1;和b*=a;写了两条注释语句以说明各自的语义(因为只要会C++都知道它们是一个变量的自增一和另一个变量的自乘a,但不知道意义)。上面的麻烦之处就是需要写“/*”和“*/”,有点麻烦,故C++又提供了另一种注释语句——“//”:
longa=1;
a+=1;//a放的是人的个数,让人的个数加一
b*=a;//b放的是人均花费,得到总的花费
上面和前面等效,其中的“//”表示从它开始,这一行后面的所有字符均看成注释,编译器将不予理会,即
longa=1;a+=1;//a放的是人的个数,让人的个数加一b*=a;
其中的b*=a;将不会被编译,因为前面的“//”已经告诉编译器,从“//”开始,这一行后面的所有字符均是注释,故编译器不会编译b*=a;。但如果
longa=1;a+=1;/*a放的是人的个数,让人的个数加一*/b*=a;
这样编译器依旧会编译b*=a;,因为“/*”和“*/”括起来的才是注释。
应该注意注释语句并不是语句,其不以“;”结束,其只是另一种语法以提供注释功能,就好象以后将要说明的预编译语句一样,都不是语句,都不以“;”结束,既不是单句也不是复合语句,只是出于习惯的原因依旧将它们称作语句。
跳转语句——goto
前面已经说明,源代码(在此指用C++编写的代码)中的语句依次地转变成用长度不同的二进制数表示的机器代码,然后顺序放在内存中(这种说法不准确)。如下面这段代码:
longa=1;//假设长度为5字节,地址为3000
a+=1;//则其地址为3005,假设长度为4字节
b*=a;//则其地址为3009,假设长度为6字节
上面的3000、3005和3009就表示上面3条语句在内存中的位置,而所谓的跳转语句,也就是将上面的3000、3005等语句的地址放到前面提过的指令寄存器中以使得CPU开始从给定的位置执行以表现出执行顺序的改变。因此,就必须有一种手段来表现语句的地址,C++对此给出了标号(Label)。
写一标识符,后接“:”即建立了一映射,将此标识符和其所在位置的地址绑定了起来,如下:
longa=1;//假设长度为5字节,地址为3000
P1:
a+=1;//则其地址为3005,假设长度为4字节
P2:
b*=a;//则其地址为3009,假设长度为6字节
gotoP2;
上面的P1和P2就是标号,其值分别为3005和3009,而最后的goto就是跳转语句,其格式为goto<标号>;。此语句非常简单,先通过“:”定义了一个标号,然后在编写goto时使用不同的标号就能跳到不同的位置。
应该注意上面故意让P1和P2定义时独占一行,其实也可以不用,即:
longa=1;P1:a+=1;P2:b*=a;gotoP2;
因此看起来“P1:”和“P2:”好象是单独的一条定义语句,应该注意,准确地说它们应该是语句修饰符,作用是定义标号,并不是语句,即这样是错误的:
longa=1;P1:{a+=1;P2:b*=a;P3:}gotoP2;
上面的P3:将报错,因为其没有修饰任何语句。还应注意其中的P1仍然是3005,即“{}”仅仅只是其复合的作用,实际并不产生代码进而不影响语句的地址。
判断语句——ifelse、switch
ifelse前面说过了,为了实现“什么样的情况”做“什么样的动作”,故C++非常正常地提供了条件判断语句以实现条件的不同而执行不同的代码。ifelse的格式为:
if(<数字>)<语句1>else<语句2>或者if(<数字>)<语句1>
longa=0,b=1;
P1:
a++;
b*=a;
if(a<10)
gotoP1;
longc=b;
上面的代码就表示只有当a的值小于10时,才跳转到P1以重复执行,最后的效果就是c的值为10的阶乘。
上面的<数字>表示可以在“if”后的括号中放一数字,即表达式,而当此数字的值非零时,即逻辑真,程序跳转以执行<语句1>,如果为零,即逻辑假,则执行<语句2>。即也可如此:if(a–10)gotoP1;,其表示当a–10不为零时才执行gotoP1;。这和前面的效果一样,虽然最后c仍然是10的阶乘,但意义不同,代码的可读性下降,除非出于效率的考虑,不推荐如此书写代码。
而<语句1>和<语句2>由于是语句,也就可以放任何是语句的东西,因此也可以这样:
if(a)longc;
上面可谓吃饱了撑了,在此只是为了说明<语句1>实际可以放任何是语句的东西,但由于前面已经说过,标号的定义以及注释语句和预编译语句其实都不是语句,因此下面试图当a非零时,定义标号P2和当a为零时书写注释“错误!”的意图是错误的:
if(a)P2:或者if(!a)//错误!
a++;a++;
但编译器不会报错,因为前者实际是当a非零时,将a自增一;后者实际是当a为零时,将a自增一。还应注意,由于复合语句也是语句,因此:
if(a){longc=0;c++;}
由于使用了复合语句,因此这个判断语句并不是以“;”结尾,但它依旧是一个单句,即:
if(a)
if(a<10){longc=0;c++;}
else
b*=a;
上面虽然看起来很复杂,但依旧是一个单句,应该注意当写了一个“else”时,编译器向上寻找最近的一个“if”以和其匹配,因此上面的“else”是和“if(a<10)”匹配的,而不是由于上面那样的缩进书写而和“if(a)”匹配,因此b*=a;只有在a大于等于10的时候才执行,而不是想象的a为零的时候。
还应注意前面书写的if(a)longc;。这里的意思并不是如果a非零,就定义变量c,这里涉及到作用域的问题,将在下篇说明。
switch这个语句的定义或多或少地是因为实现的原因而不是和“ifelse”一样由于逻辑的原因。先来看它的格式:switch(<整型数字>)<语句>。
上面的<整型数字>和if语句一样,只要是一个数字就可以了,但不同地必须是整型数字(后面说明原因)。然后其后的<语句>与前相同,只要是语句就可以。在<语句>中,应该使用这样的形式:case<整型常数1>:。它在它所对应的位置定义了一个标号,即前面goto语句使用的东西,表示如果<整型数字>和<整型常数1>相等,程序就跳转到“case<整型常数1>:”所标识的位置,否则接着执行后续的语句。
longa,b=3;
switch(a+3)
case2:case3:a++;
b*=a;
上面就表示如果a+3等于2或3,就跳到a++;的地址,进而执行a++,否则接着执行后面的语句b*=a;。这看起来很荒谬,有什么用?一条语句当然没意义,为了能够标识多条语句,必须使用复合语句,即如下:
longa,b=3;
switch(a+3)
{
b=0;
case2:
a++;//假设地址为3003
case3:
a--;//假设地址为3004
break;
case1:
a*=a;//假设地址为3006
}
b*=a;//假设地址为3010
应该注意上面的“2:”、“3:”、“1:”在这里看着都是整型的数字,但实际应该把它们理解为标号。因此,上面检查a+3的值,如果等于1,就跳到“1:”标识的地址,即3006;如果为2,则跳转到3003的地方执行代码;如果为3,则跳到3004的位置继续执行。而上面的break;语句是特定的,其放在switch后接的语句中表示打断,使程序跳转到switch以后,对于上面就是3010以执行b*=a;。即还可如此:
switch(a)if(a)break;
由于是跳到相应位置,因此如果a为-1,则将执行a++;,然后执行a--;,再执行break;而跳到3010地址处执行b*=a;。并且,上面的b=0;将永远不会被执行。
switch表示的是针对某个变量的值,其不同的取值将导致执行不同的语句,非常适合实现状态的选择。比如用1表示安全,2表示有点危险,3表示比较危险而4表示非常危险,通过书写一个switch语句就能根据某个怪物当前的状态来决定其应该做“逃跑”还是“攻击”或其他的行动以实现游戏中的人工智能。那不是很奇怪吗?上面的switch通过if语句也可以实现,为什么要专门提供一个switch语句?如果只是为了简写,那为什么不顺便提供多一些类似这种逻辑方案的简写,而仅仅只提供了一个分支选择的简写和后面将说的循环的简写?因为其是出于一种优化技术而提出的,就好象后面的循环语句一样,它们对逻辑的贡献都可以通过if语句来实现(毕竟逻辑就是判断),而它们的提出一定程度都是基于某种优化技术,不过后面的循环语句简写的成分要大一些。
我们给出一个数组,数组的每个元素都是4个字节大小,则对于上面的switch语句,如下:
unsignedlongAddr[3];Addr[0]=3006;Addr[1]=3003;Addr[2]=3004;
而对于switch(a+3),则使用类似的语句就可以代替:gotoAddr[a+3–1];
上面就是switch的真面目,应注意上面的goto的写法是错误的,这也正是为什么会有switch语句。编译器为我们构建一个存储地址的数组,这个数组的每个元素都是一个地址,其表示的是某条语句的地址,这样,通过不同的偏移即可实现跳转到不同的位置以执行不同的语句进而表现出状态的选择。
现在应该了解为什么上面必须是<整型数字>了,因为这些数字将用于数组的下标或者是偏移,因此必须是整数。而<整型常数1>必须是常数,因为其由编译时期告诉编译器它现在所在位置应放在地址数组的第几个元素中。
了解了switch的实现后,以后在书写switch时,应尽量将各case后接的整型常数或其倍数靠拢以减小需生成的数组的大小,而无需管常数的大小。即case1000、case1001、case1002和case2、case4、case6都只用3个元素大小的数组,而case0、case100、case101就需要102个元素大小的数组。应该注意,现在的编译器都很智能,当发现如刚才的后者这种只有3个分支却要102个元素大小的数组时,编译器是有可能使用重复的if语句来代替上面数组的生成。
switch还提供了一个关键字——default。如下:
longa,b=3;
switch(a+3)
{
case2:
a++;
break;
case3:
a+=3;
break;
default:
a--;
}
b*=a;
上面的“default:”表示当a+3不为2且不为3时,则执行a--;,即default表示缺省的状况,但也可以没有,则将直接执行switch后的语句,因此这是可以的:switch(a){}或switch(a);,只不过毫无意义罢了。
循环语句——for、while、dowhile
刚刚已经说明,循环语句的提供主要是出于简写目的,因为循环是方法描述中用得最多的,且算法并不复杂,进而对编译器的开发难度不是增加太多。
for其格式为for(<数字1>;<数字2>;<数字3>)<语句>。其中的<语句>同上,即可接单句也可接复合语句。而<数字1>、<数字2>和<数字3>由于是数字,就是表达式,进而可以做表达式语句能做的所有的工作——操作符的计算。for语句的意思是先计算<数字1>,相当于初始化工作,然后计算<数字2>。如果<数字2>的值为零,表示逻辑假,则退出循环,执行for后面的语句,否则执行<语句>,然后计算<数字3>,相当于每次循环的例行公事,接着再计算<数字2>,并重复。上面的<语句>一般被称作循环体。
上面的设计是一种面向过程的设计思想,将循环体看作是一个过程,则这个过程的初始化(<数字1>)和必定执行(<数字3>)都表现出来。一个简单的循环,如下:
longa,b;
for(a=1,b=1;a<=10;a++)
b*=a;
上面执行完后b是10的阶乘,和前面在说明if语句时举的例子相比,其要简单地多,并且可读性更好——a=1,b=1是初始化操作,每次循环都将a加一,这些信息是goto和if语句表现不出来的。由于前面一再强调的语句和数字的概念,因此可以如下:
longa,b=1;
for(;b<100;)
for(a=1,b=1;a;++a,++b)
if(b*=a)
switch(a=b)
{
case1:
a++;break;
case2:
for(b=10;b;b--)
{
a+=b*b;
case3:a*=a;
}
break;
}
上面看着很混乱,注意“case3:”在“case2:”后的一个for语句的循环体中,也就是说,当a=b返回1时,跳到a++;处,并由于break;的缘故而执行switch后的语句,也就是if后的语句,也就是第二个for语句的++a,++b。当返回2时,跳到第三个for语句处开始执行,循环完后同样由break;而继续后面的执行。当返回3时,跳到a*=a;处执行,然后计算b--,接着计算b的值,检查是否非零,然后重复循环直到b的值为零,然后继续以后的执行。上面的代码并没什么意义,在这里是故意写成这么混乱以进一步说明前面提过的语句和数字的概念,如果真正执行,大致看过去也很容易知道将是一个死循环,即永远循环无法退出的循环。
还应注意C++提出了一种特殊语法,即上面的<数字1>可以不是数字,而是一变量定义语句,即可如此:for(longa=1,b=1;a<10;++a,++b);。其中就定义了变量a和b。但是也只能接变量定义语句,而结构定义、类定义及函数定义语句将不能写在这里。这个语法的提出是更进一步地将for语句定义为记数式循环的过程,这里的变量定义语句就是用于定义此循环中充当计数器的变量(上面的a)以实现循环固定次数。
最后还应注意上面写的<数字1>、<数字2>和<数字3>都是可选的,即可以:for(;;);。
while其格式为while(<数字>)<语句>,其中的<数字>和<语句>都同上,意思很明显,当<数字>非零时,执行<语句>,否则执行while后面的语句,这里的<语句>被称作循环体。
dowhile其格式为do<语句>while(<数字>);。注意,在while后接了“;”以表示这个单句的结束。其中的<数字>和<语句>都同上,意思很明显,当<数字>非零时,执行<语句>,否则执行while后面的语句,这里的<语句>被称作循环体。
为什么C++要提供上面的三种循环语句?简写是一重要目的,但更重要的是可以提供一定的优化。for被设计成用于固定次数的循环,而while和dowhile都是用于条件决定的循环。对于前者,编译器就可以将前面提过的用于记数的变量映射成寄存器以优化速度,而后者就要视编译器的智能程度来决定是否能生成优化代码了。
while和dowhile的主要区别就是前者的循环体不一定会被执行,而后者的循环体一定至少会被执行一次。而出于简写的目的,C++又提出了continue和break语句。如下:
for(longi=0;i<10;i++)
{
if(!(i%3))
continue;
if(!(i%7))
break;
//其他语句
}
上面当i的值能被3整除时,就不执行后面的“其他语句”,而是直接计算i++,再计算i<10以决定是否继续循环。即continue就是终止当前这次循环的执行,开始下一次的循环。上面当i的值能被7整除时,就不执行后面的“其他语句”,而是跳出循环体,执行for后的语句。即break就是终止循环的运行,立即跳出循环体。如下:
while(--i)do
{{
if(i==10)if(i==10)
continue;continue;
if(i>20)if(i>20)
break;break;
//其他语句//其他语句
}}while(--i);
a=i;a=i;
上面的continue;执行时都将立即计算—i以判断是否继续循环,而break;执行时都将立即退出循环体进而执行后继的a=i;。
还应注意嵌套问题,即前面说过的else在寻找配对的if时,总是找最近的一个if,这里依旧。
longa=0;
P1:
for(longi=a;i<10;i++)
for(longj=0;j<10;j++)
{
if(!(j%3))
continue;
if(!(j%7))
break;
if(i*j)
{
a=i*j;
gotoP1;
}
//其他语句
}
上面的continue;执行后,将立即计算j++,而break;执行后,将退出第二个循环(即j的循环),进而执行i++,然后继续由i<10来决定是否继续循环。当gotoP1;执行时,程序跳到上面的P1处,即执行longi=a;,进而重新开始i的循环。
上面那样书写goto语句是不被推荐的,因为其破坏了循环,不符合人的思维习惯。在此只是要说明,for或while、dowhile等都不是循环,只是它们各自的用处最后表现出来好象是循环,实际只是程序执行位置的变化。应清楚语句的实现,这样才能清楚地了解各种语句的实际作用,进而明确他人写的代码的意思。而对于自己书写代码,了解语句的实现,将有助于进行一定的优化。但当你写出即精简又执行效率高的程序时,保持其良好的可读性是一个程序员的素养,应尽量培养自己书写可读性高的代码的习惯。
上面的longj=0在第一个循环的循环体内,被多次执行岂不是要多次定义?这属于变量的作用域的问题,下篇将说明。
本篇的内容应该是很简单的,重点只是应该理解源代码编译成机器指令后,在执行时也放在内存中,故每条语句都对应着一个地址,而通过跳转语句即可改变程序的运行顺序。下篇将对此提出一系列的概念,并说明声明和定义的区别。
C++从零开始(七)
——何谓函数
本篇之前的内容都是基础中的基础,理论上只需前面所说的内容即可编写出几乎任何只操作内存的程序,也就是本篇以后说明的内容都可以使用之前的内容自己实现,只不过相对要麻烦和复杂许多罢了。
本篇开始要比较深入地讨论C++提出的很有意义的功能,它们大多数和前面的switch语句一样,是一种技术的实现,但更为重要的是提供了语义的概念。所以,本篇开始将主要从它们提供的语义这方面来说明各自的用途,而不像之前通过实现原理来说明(不过还是会说明一下实现原理的)。为了能清楚说明这些功能,要求读者现在至少能使用VC来编译并生成一段程序,因为后续的许多例子都最好是能实际编译并观察执行结果以加深理解(尤其是声明和类型这两个概念)。为此,如果你现在还不会使用VC或其他编译器来进行编译代码,请先参看其他资料以了解如何使用VC进行编译。为了后续例子的说明,下面先说明一些预备知识。
预备知识
写出了C++代码,要如何让编译器编译?在文本文件中书写C++代码,然后将文本文件的文件名作为编译器的输入参数传递给编译器,即叫编译器编译给定文件名所对应的文件。在VC中,这些由VC这个编程环境(也就是一个软件,提供诸多方便软件开发的功能)帮我们做了,其通过项目(Project)来统一管理书写有C/C++代码的源文件。为了让VC能了解到哪些文件是源文件(因为还可能有资源文件等其他类型文件),在用文本编辑器书写了C++代码后,将其保存为扩展名为.c或.cpp(CPlusPlus)的文本文件,前者表示是C代码,而后者表示C++代码,则缺省情况下,VC就能根据不同的源文件而使用不同的编译语法来编译源文件。
前篇说过,C++中的每条语句都是从上朝下执行,每条语句都对应着一个地址,那么在源文件中的第一条语句对应的地址就是0吗?当然不是,和在栈上分配内存一样,只能得到相对偏移值,实际的物理地址由于不同的操作系统将会有各自不同的处理,如在Windows下,代码甚至可以没有物理地址,且代码对应的物理地址还能随时变化。
当要编写一个稍微正常点的程序时,就会发现一个源文件一般是不够的,需要使用多个源文件来写代码。而各源文件之间要如何连接起来?对此C++规定,凡是生成代码的语句都要放在函数中,而不能直接写在文本文件中。关于函数后面马上说明,现在只需知道函数相当于一个外壳,它通过一对“{}”将代码括起来,进而就将代码分成了一段一段,且每一段代码都由函数名这个项目内唯一的标识符来标识,因此要连接各段代码,只用通过函数名即可,后面说明。前面说的“生成代码”指的是表达式语句和指令语句,虽然定义语句也可能生成代码,但由于其代码生成的特殊性,是可以直接写在源文件内(在《C++从零开始(十)》中说明),即不用被一对“{}”括起来。
程序一开始要从哪里执行?C++强行规定,应该在源文件中定义一个名为main的函数,而代码就从这个函数处开始运行。应该注意由于C++是由编译器实现的,而它的这个规定非常的牵强,因此纵多的编译器都又自行提供了另外的程序入口点定义语法(程序入口点即最开始执行的函数),如VC,为了编写DLL文件,就不应有main函数;为了编写基于Win32的程序,就应该使用WinMain而不是main;而VC实际提供了更加灵活的手段,实际可以让程序从任何一个函数开始执行,而不一定非得是前面的WinMain、main等,这在《C++从零开始(十九)》中说明。
对于后面的说明,应知道程序从main函数开始运行,如下:
longa;voidmain(){shortb;b++;}longc;
上面实际先执行的是longa;和longc;,不过不用在意,实际有意义的语句是从shortb;开始的。
函数(Function)
机器手焊接轿车车架上的焊接点,给出焊接点的三维坐标,机器手就通过控制各关节的马达来使焊枪移到准确的位置。这里控制焊枪移动的程序一旦编好,以后要求机器手焊接车架上的200个点,就可以简单地给出200个点的坐标,然后调用前面已经编好的移动程序200次就行了,而不用再对每次移动重复编写代码。上面的移动程序就可以用一个函数来表示。
函数是一个映射元素。其和变量一样,将一个标识符(即函数名)和一个地址关联起来,且也有一类型和其关联,称作函数的返回类型。函数和变量不同的就是函数关联的地址一定是代码的地址,就好像前面说明的标号一样,但和标号不同的就是,C++将函数定义为一种类型,而标号则只是纯粹的二进制数,即函数名对应的地址可以被类型修饰符修饰以使得编译器能生成正确的代码来帮助程序员书实现上面的功能。
由于定义函数时编译器并不会分配内存,因此引用修饰符“&”不再其作用,同样,由数组修饰符“[]”的定义也能知道其不能作用于函数上面,只有留下的指针修饰符“*”可以,因为函数名对应的是某种函数类型的地址类型的数字。
前面移动程序之所以能被不同地调用200次,是因为其写得很灵活,能根据不同的情况(不同位置的点)来改变自己的运行效果。为了向移动程序传递用于说明情况的信息(即点的坐标),必须有东西来完成这件事,在C++中,这使用参数来实现,并对于此,C++专门提供了一种类型修饰符——函数修饰符“()”。在说明函数修饰符之前,让我们先来了解何谓抽象声明符(AbstractDeclarator)。
声明一个变量longa;(这看起来和定义变量一样,后面将说明它们的区别),其中的long是类型,用于修饰此变量名a所对应的地址。将声明变量时(即前面的写法)的变量名去掉后剩下的东西称作抽象声明符。比如:long*a,&b=*a,c[10],(*d)[10];,则变量a、b、c、d所对应的声明修饰符分别是long*、long&、long[10]、long(*)[10]。
函数修饰符接在函数名的后面,括号内接零个或多个抽象声明符以表示参数的类型,中间用“,”隔开。而参数就是一些内存(分别由参数名映射),用于传递一些必要的信息给函数名对应的地址处的代码以实现相应的功能。声明一个函数如下:
long*ABC(long*,long&,long[10],long(*)[10]);
上面就声明了一个函数ABC,其类型为long*(long*,long&,long[10],long(*)[10]),表示欲执行此函数对应地址处开始的代码,需要顺序提供4个参数,类型如上,返回值类型为long*。上面ABC的类型其实就是一个抽象声明符,因此也可如下:
longAB(long*(long*,long&,long[10],long(*)[10]),short,long&);
对于前面的移动程序,就可类似如下声明它:
voidMove(floatx,floaty,floatz);
上面在书写声明修饰符时又加上了参数名,以表示对应参数的映射。不过由于这里是函数的声明,上述参数名实际不产生任何映射,因为这是函数的声明,不是定义(关于声明,后面将说明)。而这里写上参数名是一种语义的体现,表示第一、二、三个参数分别代表X、Y、Z坐标值。
上面的返回类型为void,前面提过,void是C++提供的一种特殊数字类型,其仅仅只是为了保障语法的严密性而已,即任何函数执行后都要返回一个数字(后面将说明),而对于不用返回数字的函数,则可以定义返回类型为void,这样就可以保证语法的严密性。应当注意,任何类型的数字都可以转换成void类型,即可以(void)(234);或void(a);。
注意上面函数修饰符中可以一个抽象修饰符都没有,即voidABC();。它等效于voidABC(void);,表示ABC这个函数没有参数且不返回值。则它们的抽象声明符为void()或void(void),进而可以如下:
long*ABC(long*(),long(),long[10]);
由函数修饰符的意义即可看出其和引用修饰符一样,不能重复修饰类型,即不能voidA()(long);,这是无意义的。同样,由于类型修饰符从左朝右的修饰顺序,也就很正常地有:void(*pA)()。假设这里是一个变量定义语句(也可以看成是一声明语句,后面说明),则表示要求编译器在栈上分配一块4字节的空间,将此地址和pA映射起来,其类型为没有参数,返回值类型为void的函数的指针。有什么用?以后将说明。
函数定义
下面先看下函数定义,对于前面的机器手控制程序,可如下书写:
voidMove(floatx,floaty,floatz)
{
floattemp;
//根据x、y、z的值来移动焊枪
}
intmain()
{
floatx[200],y[200],z[200];
//将200个点的坐标放到数组x、y和z中
for(unsignedi=0;i<200;i++)
Move(x[i],y[i],z[i]);
return0;
}
上面定义了一个函数Move,其对应的地址为定义语句floattemp;所在的地址,但实际由于编译器要帮我们生成一些附加代码(称作函数前缀——Prolog,在《C++从零开始(十五)》中说明)以获得参数的值或其他工作(如异常的处理等),因此Move将对应在较floattemp;之前的某个地址。Move后接的类型修饰符较之前有点变化,只是把变量名加上以使其不是抽象声明符而已,其作用就是让编译器生成一映射,将加上的变量名和传递相应信息的内存的地址绑定起来,也就形成了所谓的参数。也由于此原因,就能如此书写:voidMove(floatx,float,floatz){}。由于没有给第二个参数绑定变量名,因此将无法使用第二个参数,以后将举例说明这样的意义。
函数的定义就和前面的函数的声明一样,只不过必须紧接其后书写一个复合语句(必须是复合语句,即用“{}”括起来的语句),此复合语句的地址将和此函数名绑定,但由于前面提到的函数前缀,函数名实际对应的地址在复合语句的地址的前面。
为了调用给定函数,C++提供了函数操作符“()”,其前面接函数类型的数字,而中间根据相应函数的参数类型和个数,放相应类型的数字和个数,因此上面的Move(x[i],y[i],z[i]);就是使用了函数操作符,用x[i]、y[i]、z[i]的值作为参数,并记录下当前所在位置的地址,跳转到Move所对应的地址继续执行,当从Move返回时,根据之前记录的位置跳转到函数调用处的地方,继续后继代码的执行。
函数操作符由于是操作符,因此也要返回数字,也就是函数的返回值,即可以如下:
floatAB(floatx){returnx*x;}intmain(){floatc=AB(10);return0;}
先定义了函数AB,其返回float类型的数字,其中的return语句就是用于指明函数的返回值,其后接的数字就必须是对应函数的返回值类型,而当返回类型为void时,可直接书写return;。因此上面的c的值为100,函数操作符返回的值为AB函数中的表达式x*x返回的数字,而AB(10)将10作为AB函数的参数x的值,故x*x返回100。
由于之前也说明了函数可以有指针,将函数和变量对比,则直接书写函数名,如:AB;。上面将返回AB对应的地址类型的数字,然后计算此地址类型数字,应该是以函数类型解释相应地址对应的内存的内容,考虑函数的意义,将发现这是毫无意义的,因此其不做任何事,直接返回此地址类型的数字对应的二进制数,也就相当于前面说的指针类型。因此也就可以如下:
intmain(){float(*pAB)(float)=AB;floatc=(*pAB)(10);return0;}
上面就定义了一个指针pAB,其类型为float(*)(float),一开始将AB对应的地址赋值给它。为什么没有写成pAB=&AB;而是pAB=AB;?因为前面已经说了,函数类型的地址类型的数字,将不做任何事,其效果和指针类型的数字一样,因此pAB=AB;没有问题,而pAB=&AB;就更没有问题了。可以认为函数类型的地址类型的数字编译器会隐式转换成指针类型的数字,因此既可以(*pAB)(10);,也能(*AB)(10);,因为后者编译器进行了隐式类型转换。
由于函数操作符中接的是数字,因此也可以floatc=AB(AB(10));,即c为10000。还应注意函数操作符让编译器生成一些代码来传递参数的值和跳转到相应的地址去继续执行代码,因此如下是可以的:
longAB(longx){if(x>1)returnx*AB(x-1);elsereturn1;}
上面表示当参数x的值大于1时,将x-1返回的数字作为参数,然后跳转到AB对应的地址处,也就是if(x>1)所对应的地址,重复运行。因此如果longc=AB(5);,则c为5的阶乘。上面如果不能理解,将在后面说明异常的时候详细说明函数是如何实现的,以及所谓的堆栈溢出问题。
现在应该了解main函数的意义了,其只是建立一个映射,好让连接器制定程序的入口地址,即main函数对应的地址。上面函数Move在函数main之前定义,如果将Move的定义移到main的下面,上面将发生错误,说函数Move没定义过,为什么?因为编译器只从上朝下进行编译,且只编译一次。那上面的问题怎么办?后面说明。
重载函数
前面的移动函数,如果只想移动X和Y坐标,为了不移动Z坐标,就必须如下再编写一个函数:
voidMove2(floatx,floaty);
它为了不和前面的Move函数的名字冲突而改成Move2,但Move2也表示移动,却非要变一个名字,这严重地影响语义。为了更好的从源代码上表现出语义,即这段代码的意义,C++提出了重载函数的概念。
重载函数表示函数名字一样,但参数类型及个数不同的多个函数。如下:
voidMove(floatx,floaty,floatz){}和voidMove(floatx,floaty){}
上面就定义了两个重载函数,虽然函数名相同,但实际为两个函数,函数名相同表示它们具有同样的语义——移动焊枪的程序,只是移动方式不同,前者在三维空间中移动,后者在一水平面上移动。当Move(12,43);时就调用后者,而Move(23,5,12);时就调用前者。不过必须是参数的不同,不能是返回值的不同,即如下将会报错:
floatMove(floatx,floaty){return0;}和voidMove(floata,floatb){}
上面虽然返回值不同,但编译器依旧认为上面定义的函数是同一个,则将说函数重复定义。为什么?因为在书写函数操作符时,函数的返回值类型不能保证获得,即floata=Move(1,2);虽然可以推出应该是前者,但也可以Move(1,2);,这样将无法得知应该使用哪个函数,因此不行。还应注意上面的参数名字虽然不同,但都是一样的,参数名字只是表示在那个函数的作用域内其映射的地址,后面将说明。改成如下就没有问题:
floatMove(floatx,floaty){return0;}和voidMove(floata,floatb,floatc){}
还应注意下面的问题:
floatMove(floatx,chary);floatMove(floata,shortb);Move(10,270);
上面编译器将报错,因为这里的270在计算函数操作符时将被认为是int,即整型,它即可以转成char,也可以转成short,结果编译器将无法判断应是哪一个函数。为此,应该Move(10,(char)270);。
声明和定义
声明是告诉编译器一些信息,以协助编译器进行语法分析,避免编译器报错。而定义是告诉编译器生成一些代码,并且这些代码将由连接器使用。即:声明是给编译器用的,定义是给连接器用的。这个说明显得很模糊,为什么非要弄个声明和定义在这搅和?那都是因为C++同意将程序拆成几段分别书写在不同文件中以及上面提到的编译器只从上朝下编译且对每个文件仅编译一次。
编译器编译程序时,只会一个一个源文件编译,并分别生成相应的中间文件(对VC就是.obj文件),然后再由连接器统一将所有的中间文件连接形成一个可执行文件。问题就是编译器在编译a.cpp文件时,发现定义语句而定义了变量a和b,但在编译b.cpp时,发现使用a和b的代码,如a++;,则编译器将报错。为什么?如果不报错,说因为a.cpp中已经定义了,那么先编译b.cpp再编译a.cpp将如何?如果源文件的编译顺序是特定的,将大大降低编译的灵活性,因此C++也就规定:编译a.cpp时定义的所有东西(变量、函数等)在编译b.cpp时将全部不算数,就和没编译过a.cpp一样。那么b.cpp要使用a.cpp中定义的变量怎么办?为此,C++提出了声明这个概念。
因此变量声明longa;就是告诉编译器已经有这么个变量,其名字为a,其类型为long,其对应的地址不知道,但可以先作个记号,即在后续代码中所有用到这个变量的地方做上记号,以告知连接器在连接时,先在所有的中间文件里寻找是否有个叫a的变量,其地址是多少,然后再修改所有作了记号的地方,将a对应的地址放进去。这样就实现了这个文件使用另一个文件中定义的变量。
所以声明longa;就是要告诉编译器已经有这么个变量a,因此后续代码中用到a时,不要报错说a未定义。函数也是如此,但是有个问题就是函数声明和函数定义很容易区别,因为函数定义后一定接一复合语句,但是变量定义和变量声明就一模一样,那么编译器将如何识别变量定义和变量声明?编译器遇到longa;时,统一将其认为是变量定义,为了能标识变量声明,可借助C++提出的修饰符extern。
修饰符就是声明或定义语句中使用的用以修饰此声明或定义来向编译器提供一定的信息,其总是接在声明或定义语句的前面或后面,如:
externlonga,*pA,&ra;
上面就声明(不是定义)了三个变量a、pA和ra。因为extern表示外部的意思,因此上面就被认为是告诉编译器有三个外部的变量,为a、pA和ra,故被认为是声明语句,所以上面将不分配任何内存。同样,对于函数,它也是一样的:
externvoidABC(long);或externlongAB(shortb);
上面的extern等同于不写,因为编译器根据最后的“;”就可以判断出来上面是函数声明,而且提供的“外部”这个信息对于函数来说没有意义,编译器将不予理会。extern实际还指定其后修饰的标识符的修饰方式,实际应为extern"C"或extern"C++",分别表示按照C语言风格和C++语言风格来解析声明的标识符。
C++是强类型语言,即其要求很严格的类型匹配原则,进而才能实现前面说的函数重载功能。即之所以能几个同名函数实现重载,是因为它们实际并不同名,而由各自的参数类型及个数进行了修饰而变得不同。如voidABC(),*ABC(long),ABC(long,short);,在VC中,其各自名字将分别被变成“?ABC@@YAXXZ”、“?ABC@@YAPAXJ@Z”、“?ABC@@YAXJF@Z”。而externlonga,*pA,&ra;声明的三个变量的名字也发生相应的变化,分别为“?a@@3JA”、“?pA@@3PAJA”、“?ra@@3AAJA”。上面称作C++语言风格的标识符修饰(不同的编译器修饰格式可能不同),而C语言风格的标识符修饰就只是简单的在标识符前加上“_”即可(不同的编译器的C风格修饰一定相同)。如:extern"C"longa,*pA,&ra;就变成_a、_pA、_ra。而上面的extern"C"voidABC(),*ABC(long),ABC(long,short);将报错,因为使用C风格,都只是在函数名前加一下划线,则将产生3个相同的符号(Symbol),错误。
为什么不能有相同的符号?为什么要改变标识符?不仅因为前面的函数重载。符号和标识符不同,符号可以由任意字符组成,它是编译器和连接器之间沟通的手段,而标识符只是在C++语言级上提供的一种标识手段。而之所以要改变一下标识符而不直接将标识符作为符号使用是因为编译器自己内部和连接器之间还有一些信息需要传递,这些信息就需要符号来标识,由于可能用户写的标识符正好和编译器内部自己用的符号相同而产生冲突,所以都要在程序员定义的标识符上面修改后再用作符号。既然符号是什么字符都可以,那为什么编译器不让自己内部定的符号使用标识符不能使用的字符,如前面VC使用的“?”,那不就行了?因为有些C/C++编译器及连接器沟通用的符号并不是什么字符都可以,也必须是一个标识符,所以前面的C语言风格才统一加上“_”的前缀以区分程序员定义的符号和编译器内部的符号。即上面能使用“?”来作为符号是VC才这样,也许其它的编译器并不支持,但其它的编译器一定支持加了“_”前缀的标识符。这样可以联合使用多方代码,以在更大范围上实现代码重用,在《C++从零开始(十八)》中将对此详细说明。
当书写externvoidABC(long);时,是extern"C"还是extern"C++"?在VC中,如果上句代码所在源文件的扩展名为.cpp以表示是C++源代码,则将解释成后者。如果是.c,则将解释成前者。不过在VC中还可以通过修改项目选项来改变上面的默认设置。而externlonga;也和上面是同样的。
因此如下:
extern"C++"voidABC(),*ABC(long),ABC(long,short);
intmain(){ABC();}
上面第一句就告诉编译器后续代码可能要用到这个三个函数,叫编译器不要报错。假设上面程序放在一个VC项目下的a.cpp中,编译a.cpp将不会出现任何错误。但当连接时,编译器就会说符号“?ABC@@YAXXZ”没找到,因为这个项目只包含了一个文件,连接也就只连接相应的a.obj以及其他的一些必要库文件(后续文章将会说明)。连接器在它所能连接的所有对象文件(a.obj)以及库文件中查找符号“?ABC@@YAXXZ”对应的地址是什么,不过都没找到,故报错。换句话说就是main函数使用了在a.cpp以外定义的函数voidABC();,但没找到这个函数的定义。应注意,如果写成intmain(){void(*pA)=ABC;}依旧会报错,因为ABC就相当于一个地址,这里又要求计算此地址的值(即使并不使用pA),故同样报错。
为了消除上面的错误,就应该定义函数voidABC();,既可以在a.cpp中,如main函数的后面,也可以重新生成一个.cpp文件,加入到项目中,在那个.cpp文件中定义函数ABC。因此如下即可:
extern"C++"voidABC(),*ABC(long),ABC(long,short);
intmain(){ABC();}voidABC(){}
如果你认为自己已经了解了声明和定义的区别,并且清楚了声明的意思,那我打赌有50%的可能性你并没有真正理解声明的含义,这里出于篇幅限制,将在《C++从零开始(十)》中说明声明的真正含义,如果你是有些C/C++编程经验的人,到时给出的样例应该有50%的可能性会令你大吃一惊。
调用规则
调用规则指函数的参数如何传递,返回值如何传递,以及上述的函数名标识符如何修饰。其并不属于语言级的内容,因为其表示编译器如何实现函数,而关于如何实现,各编译器都有自己的处理方式。在VC中,其定义了三个类型修饰符用以告知编译器如何实现函数,分别为:__cdecl、__stdcall和__fastcall。三种各有不同的参数、函数返回值传递方式及函数名修饰方式,后面说明异常时,在说明了函数的具体实现方式后再一一解释。由于它们是类型修饰符,则可如下修饰函数:
void*__stdcallABC(long),__fastcallDE(),*(__stdcall*pAB)(long)=&ABC;
void(__fastcall*pDE)()=DE;
变量的作用域
前面定义函数Move时,就说voidMove(floata,floatb);和voidMove(floatx,floaty);是一样的,即变量名a和b在这没什么意义。这也就是说变量a、b的作用范围只限制在前面的Move的函数体(即函数定义时的复合语句)内,同样x和y的有效范围也只在后面的Move的函数体内。这被称作变量的作用域。
//a.cpp//
longe=10;
voidmain()
{
shorta=10;
e++;
{
longe=2;
e++;
a++;
}
e++;
}
上面的第一个e的有效范围是整个a.cpp文件内,而a的有效范围是main函数内,而main函数中的e的有效范围则是括着它的那对“{}”以内。即上面到最后执行完e++;后,longe=2;定义的变量e已经不在了,也就是被释放了。而longe=10;定义的e的值为12,a的值为11。
也就是说“{}”可以一层层嵌套包含,没一层“{}”就产生了一个作用域,在这对“{}”中定义的变量只在这对“{}”中有效,出了这对“{}”就无效了,等同于没定义过。
为什么要这样弄?那是为了更好的体现出语义。一层“{}”就表示一个阶段,在执行这个阶段时可能会需要到和前面的阶段具有相同语义的变量,如排序。还有某些变量只在某一阶段有用,过了这个阶段就没有意义了,下面举个例子:
floata[10];
//赋值数组a
for(unsignedi=0;i<10;i++)
for(unsignedj=0;j<10;j++)