建立网站时要采用一定的链接结构可采用的基本方式有,赣州做网站设计找哪家,如何做网站软件,wordpress主题 d8 4.1C从零开始 ——何谓编程 引言 曾经有些人问我问题#xff0c;问得都是一些很基础的问题#xff0c;但这些人却已经能使用VC编一个对话框界面来进行必要的操作或者是文档/视界面来实时接收端口数据并动态显示曲线#xff08;还使用了多线程技术#xff09;#xff0c;却连… 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操作系统下就是Portable Executable File Format——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.3440.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提供了这样的指令。并且其还有着很不错且很重要的应用后面将介绍。 关于其他的一元操作符将在后续文章中陆续提到但不一定全部提到。 二元操作符有 - * / % 其前后各接一数字返回两数字之和、差、积、商、余数。如 344.4f的返回值是38.43-9.3f的返回值是-6.3。 34-4的返回值是305-234的返回值是-229。 3*2的返回值是610/3的返回值是3。 10%3的返回值是120%7的返回值是6。 || 其前后各接一逻辑值返回两逻辑值之“与”运算逻辑值和“或”运算逻辑值。如 A34.3f的返回值是逻辑真为1340的返回值是逻辑假为0。 0||B的返回值是逻辑真为 10||0的返回值是逻辑假为0。 | ^ 其前后各接一数字返回两数字之“与”运算、“或”运算、“异或”运算值。如前面所说先将两侧的数字转成二进制数然后对各位进行与、或、异或操作。如 46的返回值是44转为000001006转为00000110各位相与得00000100为4。 4|6的返回值是64转为000001006转为00000110各位相或得00000110为6。 4^6的返回值是24转为000001006转为00000110各位相异或得00000010为2。 ! 其前后各接一数字根据两数字是否大于、小于、等于、大于等于、小于等于及不等于而返回相应的逻辑值。如 3434的返回值是0为逻辑假32345的返回值为1为逻辑真。 2323和2314的返回值都是1为逻辑真544的返回值为0为逻辑假。 566的返回值是0为逻辑假4545的返回值是1为逻辑真。 5!5的返回值是0为逻辑假5!35的返回值是真为逻辑真。 其前后各接一数字将左侧数字右移或左移右侧数字指定的位数。与前面的 ~、、|等操作一样之所以要提供左移、右移操作主要是因为CPU提供了这些指令主要用于编一些基于二进制数的算法。 将左侧的数字转成二进制数然后将各位向左移动右侧数值的位数如4转为00000100左移2位则变成00010000得16。 与一样只不过是向右移动罢了。如6转为00000110右移1位变成00000011得3。如果移2位则有一位超出将截断则62的返回值就是00000001为1。 左移和右移有什么用用于一些基于二进制数的算法不过还可以顺便作为一个简单的优化手段。考虑十进制数3524我们将它左移2位变成352400比原数扩大了100倍准确的说应该是扩大了10的2次方倍。如果将3524右移2位变成35相当于原数除以100的商。 同样前面42等效于4/4的商323相当于32/8即相当于32除以2的3次方的商。而42等效于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。 表达式 你应该发现前面的荒谬之处了——12435返回值为0那为什么不直接写0还吃饱了撑了写个12435在那这就是表达式的意义了。 前面说“”的前后各接一数字但是操作符是操作数字并返回数字的符号因为它返回数字因此可以放在上面说的任何一个要求接数字的地方也就形成了所谓的表达式。如23*54/4534的返回值就是0因为23*54的返回值为1242然后又将1242作为“/”的左接数字得到新的返回值27.6最后将27.6作为“”的左接数字进而得到返回值0为逻辑假。 因此表达式就是由一系列返回数字的东西和操作符组合而成的一段代码其由于是由操作符组成的故一定返回值。而前面说的“返回数字的东西”则可以是另一个表达式或者一个变量或者一个具有返回值的函数或者具有数字类型操作符重载的类的对象等反正只要是能返回一个数字的东西。如果对于何谓变量、函数、类等这些名词感到陌生不需要去管它们在后继的文章中将会一一说明。 因此34也是一个表达式其返回值为34只不过是没有操作符的表达式罢了在后面将会了解到34其实是一种操作符。故表达式的概念其实是很广的只要有返回值的东西就可以称为表达式。 由于表达式里有很多操作符执行操作符的顺序依赖于操作符的优先级就和数学中的一样*、/的优先级大于、-而、-又大于、等逻辑操作符。不用去刻意记住操作符的优先级当不能确定操作符的执行顺序时可以使用小括号来进行指定。如 ((12)*3)3)/4的返回值为3而12*33/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这个数字代表左转红绿灯的什么状态。以数字的第三位表示左转不过电脑并不知道这个因此如下110100。这个表达式的返回值是100非零逻辑真。假设某路口的状态为010则同样的010100返回值为0逻辑假。因此使用“”操作符可以将二进制数中的某一位或几位的状态提取出来。所以我们要了解一个数字代表的红绿灯状态中的左转红绿灯是否绿灯时只需让它和100相与即可。 现在要保持其他红绿灯的状态不变仅仅使左转红绿灯为绿灯如当前状态为010为了使左转红绿灯为绿灯值应该为110这可以通过010|100做到。如果当前状态是001则001|100为101正确——直行和右转的红绿灯状态均没有发生变化。因此使用“|”操作符可以给一个二进制数中的某一位或几位设置状态但只能设置为1如果想设置为0如101要关掉左转的绿灯则101~100返回值为001。 上面一直提到的路口红绿灯的状态实际编写时可以使用一个变量来表示而上面的100也可以用一个标识符来表示如stateTS_LEFT就可以表示检查变量state所表示的状态中的左转红绿灯的状态。 上面的这种方法被大量地运用如创建一个窗口一个窗口可能有二三十个风格则通过上面的方法就可以只用一个32位长的二进制数字就表示了窗口的风格而不用去弄二三十个数字来分别代表每种风格是否具有。 C从零开始三 ——何谓变量 本篇说明内容是C中的关键基本大部分人对于这些内容都是昏的但这些内容又是编程的基础中的基础必须详细说明。 数字表示 数学中数只有数值大小的不同绝不会有数值占用空间的区别即数学中的数是逻辑上的一个概念但电脑不是。考虑算盘每个算盘上有很多列算子每列都分成上下两排算子。上排算子有2个每个代表5下排算子有4个每个代表1这并不重要。因此算盘上的每列共有6个算子每列共可以表示0到14这15个数字因为上排算子的可能状态有0到2个算子有效而下排算子则可能有0到4个算子有效故为3×515种组合方式。 上面的重点就是算盘的每列并没有表示0到14这15个数字而是每列有15种状态因此被人利用来表示数字而已这很重要。由于算盘的每列有15个状态因此用两列算子就可以有15×15225个状态因此可以表示0到224。阿拉伯数字的每一位有0到9这10个图形符号用两个阿拉伯数字图形符号时就能有10×10100个状态因此可以表示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提供了下面的一些标准类型定义。 signed char 表示所指向的内存中的数字使用补码形式表示的数字为-128到127长度为1个字节 unsigned char 表示所指向的内存中的数字使用原码形式表示的数字为0到255长度为1个字节 signed short 表示所指向的内存中的数字使用补码形式表示的数字为–32768到32767长度为2个字节 unsigned short 表示所指向的内存中的数字使用原码形式表示的数字为0到65535长度为2个字节 signed long 表示所指向的内存中的数字使用补码形式表示的数字为-2147483648到2147483647长度为4个字节 unsigned long 表示所指向的内存中的数字使用原码形式表示的数字为0到4294967295长度为4个字节 signed int 表示所指向的内存中的数字使用补码形式表示的数字则视编译器。如果编译器编译时被指明编译为在16位操作系统上运行则等同于signed short如果是编译为32位的则等同于signed long如果是编译为在64位操作系统上运行则为8个字节长而范围则如上一样可以自行推算出来。 unsigned int 表示所指向的内存中的数字使用原码形式其余和signed int一样表示的是无符号数。 bool 表示所指向的内存中的数字为逻辑值取值为false或true。长度为1个字节。 float 表示所指向的内存按IEEE标准进行解释为real*4占用4字节内存空间等同于上篇中提到的单精度浮点数。 double 表示所指向的内存按IEEE标准进行解释为real*8可表示数的精度较float高占用8字节内存空间等同于上篇提到的双精度浮点数。 long double 表示所指向的内存按IEEE标准进行解释为real*10可表示数的精度较double高但在为32位Windows操作系统编写程序时仍占用8字节内存空间等效于double只是如果CPU支持此类浮点类型则还是可以进行这个精度的计算。 标准类型不止上面的几个后面还会陆续提到。 上面的长度为2个字节也就是将两个连续的内存单元中的数字取出并合并在一起以表示一个数字这和前面说的一个算盘表示不了的数字就进位以加入另一个算盘帮助表示是同样的道理。 上面的signed关键字是可以去掉的即char等同于signed char用以简化代码的编写。但也仅限于signed如果是unsigned char则在使用时依旧必须是unsigned char。 现在应该已经了解上篇中为什么数字还要分什么有符号无符号、长整型短整型之类的了而上面的short、char等也都只是长度不同这就由程序员自己根据可能出现的数字变化幅度来进行选用了。 类型只是对内存中的数字的解释但上面的类型看起来相对简单了点且语义并不是很强即没有什么特殊意思。为此C提供了自定义类型也就是后继文章中将要说明的结构、类等。 变量 在本系列的第一篇中已经说过电脑编程的绝大部分工作就是操作内存而上面说了为了操作内存需要使用地址来标识要操作的内存块的首地址上面的long表示连续的4个字节内存其第一个内存单元的地址称作这连续4个字节内存块的首地址。为此我们在编写程序时必须记下地址。 做52/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操作系统下由操作系统分配的内存就叫做堆而栈可以认为是在程序开始时就分配的堆这并不准确但为了不复杂化问题故如此说明。因此在堆上就可以分配大小变化的内存块因为是运行时期即时分配的内存而不是编译时期已计算好大小的内存块。 变量的定义 上面说了那么多你可能看得很晕毕竟连一个实例都没有全是文字下面就来帮助加深对上面的理解。 定义一个变量就是向上面说的由编译器维护的变量表中添加元素其语法如下 long a; 先写变量的类型然后一个或多个空格或制表符\t或其它间隔符接着变量的名字最后用分号结束。要同时定义多个变量则各变量间使用逗号隔开如下 long a, b, c; unsigned short e, a_34c; 上面是两条变量定义语句各语句间用分号隔开而各同类型变量间用逗号隔开。而前面的式子52/3-5*2则如下书写。 long a 2/3, b 5*2; long c 5 a – b; 可以不用再去记那烦人的地址了只需记着a、b这种简单的标识符。当然上面的式子不一定非要那么写也可以写成long c 5 2 / 3 – 5 * 2; 而那些a、b等中间变量编译器会自动生成并使用实际中编译器由于优化的原因将直接计算出结果而不会生成实际的计算代码。 下面就是问题的关键定义变量就是添加一个映射。前面已经说了这个映射是将变量名和一个地址关联因此在定义一个变量时编译器为了能将变量名和某个地址对应起来帮程序员在前面提到的栈上分配了一块内存大小就视这个变量类型的大小。如上面的a、b、c的大小都是4个字节而e、a_34c的大小都是2个字节。 假设编译器分配的栈在一开始时的地址是1000并假设变量a所对应的地址是1000-56则b所对应的地址就是1000-60而c所对应的就是1000-64e对应的是1000-66a_34c是1000-68。如果这时b突然不想是4字节了而希望是8字节则后续的c、e、a_34c都将由于还是原来的偏移位置而使用了错误的内存这也就是为什么栈上分配的内存必须是固定大小。 考虑前面说的红色文字“变量实际并不是一块内存只是一个映射元素”。可是只要定义一个变量就会相应地得到一块内存为什么不说变量就是一块内存上面定义变量时之所以会分配一块内存是因为变量是一个映射元素需要一个对应地址因此才在栈上分配了一块内存并将其地址记录到变量表中。但是变量是可以有别名的即另一个名字。这个说法是不准确的应该是变量所对应的内存块有另一个名字而不止是这个变量的名字。 为什么要有别名这是语义的需要表示既是什么又是什么。比如一块内存里面记录了老板的信息因此起名为Boss但是老板又是另一家公司的行政经理故变量名应该为Manager而在程序中有段代码是老板的公司相关的而另一段是老板所在公司相关的在这两段程序中都要使用到老板的信息那到底是使用Boss还是Manager其实使用什么都不会对最终生成的机器代码产生什么影响但此处出于语义的需要就应该使用别名以期从代码上表现出所编写程序的意思。 在C中为了支持变量别名提供了引用变量这个概念。要定义一个引用变量在定义变量时在变量名的前面加一个“”如下书写 long a; long a1 a, a2 a, a3 a2; 上面的a1、a2、a3都是a所对应的内存块的别名。这里在定义变量a时就在栈上分配了一块4字节内存而在定义a1时却没有分配任何内存直接将变量a所映射的地址作为变量a1的映射地址进而形成对定义a时所分配的内存的别名。因此上面的Boss和Manager应该如下其中Person是一个结构或类或其他什么自定义类型这将在后继的文章中陆续说明 Person Boss; 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就需要在书写上面语句前先书写类似下面的变量定义 float a; long b; 直接书写变量名也是一条语句其导致编译器生成一条读取相应变量的内容的语句。即可以如下书写 a; 上面将生成一条读取内存的语句即使从内存中读出来的数字没有任何应用当然如果编译器开了优化选项则上面的语句将不会生成任何代码。从这一点以及上面的c a / b * 120.4f;语句中都可以看出一点——变量是可以返回数字的。而变量返回的数字就是按照变量的类型来解释变量对应内存中的内容所得到的数字。这句话也许不是那么容易理解在看过后面的类型转换一节后应该就可以理解了。 因此为了将数据写入一块内存使用赋值语句即等号要读取一块内存书写标识内存的变量名。所以就可以这样书写a a 3; 假设a原来的值为1则上面的赋值语句将a的值取出来加上3得到结果4将4再写入a中去。由于C使用“”来代表赋值语句很容易使人和数学中的等号混淆起来这点应注意。 而如上的float a;语句当还未对变量进行任何赋值操作时a的值是什么上帝才知道。当时的a的内容是什么对于VC编译器在开启了调试选项时将会用0xCCCCCCCC填充这些未初始化内存就用IEEE的real*4格式来解释它并得到相应的一个数字也就是a的值。因此应在变量定义的时候就进行赋值但是会有性能上的影响不过很小以初始化变量而防止出现莫名其妙的值如float a 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就完了为了让你更加地重视前面的红字下面将介绍更令人火大的东西如果你已经同意上面的红字则下面这一节完全可以跳过其对编程来讲可以认为根本没有任何意义要不是为了写这篇文章我都不知道它的存在。 序列点Sequence Point和附加效果Side Effect 在计算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为0b为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为4c为2b为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完全是因为“”的缘故其要求这么做。下面考虑“”short b 3543; char a b;。因为b的值是short类型而“”的要求就是一定要将“”右边的数字转成和左边一样这样才能进行正确的内存的写入简单地将右边数字返回的二进制数复制到左边的地址所表示的内存中。因此a将为-41。但是上面是编译器按照“”的要求自行进行了隐式转换可能是由于程序员的疏忽而没有发现这个错误以为b的值一定在-128到127的范围内因此编译器将对上面的情况给出一个警告说b的值可能被截断。为了消除编译器的疑虑如下char a ( char )b;。这样称为显示类型转换其告诉编译器——“我知道可能发生数据截断但是我保证不会截断”。因此编译器将不再发出警告。但是如下char a ( char )3543;由于编译器可以肯定3543一定会被截断而导致错误的返回值因此编译器将给出警告说明3543将被截断而不管前面的类型转换操作符是否存在。 现在应该可以推出——15 15.0f;返回的是一个float类型的数字。因此如果如下char a 15 15.0f;编译器将发出警告说数据可能被截断。因此改成如下char a ( char )15 15.0f;但类型转换操作符“()”的优先级比“”高结果就是15先被转换为char然后再由于“”的要求而被隐式转成float最后返回float给“”而导致编译器依旧发出警告。为此就需要提高“”的优先级如下char a ( char )( 15 15.0f );就没事了或char( 15 15.0f )其表示我保证15 15.0f不会导致数据截断。 应该注意类型转换操作符“()”和前缀“”、“!”、负号“-”等的优先级一样并且是从右向左计算的因此( char )-34;将会先计算-34的值然后再计算( char )的值这也正好符合人的习惯。 下篇将针对数字这个特殊操作符而提出一系列的东西因此如果理解了数字的意思那么指针将很容易理解。 C从零开始五 ——何谓指针 本篇说明C中的重中又重的关键——指针类型并说明两个很有意义的概念——静态和动态。 数组 前面说了在C中是通过变量来对内存进行访问的但根据前面的说明C中只能通过变量来操作内存也就是说要操作某块内存就必须先将这块内存的首地址和一个变量名绑定起来这是很糟糕的。比如有100块内存用以记录100个工人的工资现在要将每个工人的工资增加5%为了知道各个工人增加了后的工资为多少就定义一个变量float a1;用其记录第1个工人的工资然后执行语句a1 a1 * 0.05f;则a1里就是增加后的工资。由于是100个工人所以就必须有100个变量分别记录100个工资。因此上面的赋值语句就需要有100条每条仅仅变量名不一样。 上面需要手工重复书写变量定义语句float a1;100遍每次变一个变量名无谓的工作。因此想到一次向操作系统申请100*4400个字节的连续内存那么要给第i个工人修改工资只需从首地址开始加上4*i个字节就行了因为float占用4个字节。 为了提供这个功能C提出了一种类型——数组。数组即一组数字其中的各个数字称作相应数组的元素各元素的大小一定相等因为数组中的元素是靠固定的偏移来标识的即数组表示一组相同类型的数字其在内存中一定是连续存放的。在定义变量时要表示某个变量是数组类型时在变量名的后面加上方括号在方括号中指明欲申请的数组元素个数以分号结束。因此上面的记录100个工资的变量即可如下定义成数组类型的变量 float a[100]; 上面定义了一个变量a分配了100*4400个字节的连续内存因为一个float元素占用4个字节然后将其首地址和变量名a相绑定。而变量a的类型就被称作具有100个float类型元素的数组。即将如下解释变量a所对应内存中的内容类型就是如何解释内存的内容a所对应的地址标识的内存是一块连续内存的首地址这块连续内存的大小刚好能容纳下100个float类型的数字。 因此可以将前面的float b;这种定义看成是定义了一个元素的float数组变量b。而为了能够访问数组中的某个元素在变量名后接方括号方括号中放一数字数字必须是非浮点数即使用二进制原码或补码进行表示的数字。如a[ 5 3 ] 32;就是数组变量a的第5 3个元素的值增加32。又 long c 23; float b 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后得到的地址就为第一个元素的地址。 应该注意不能这样写long a[0];定义0个元素的数组是无意义的编译器将报错不过在结构或类或联合中符合某些规则后可以这样写那是C语言时代提出的一种实现结构类型的长度可变的技术在《C从零开始九》中将说明。 还应注意上面在定义数组时不能在方括号内写变量即long b 10; float a[ b ];是错误的因为编译此代码时无法知道变量b的值为多少进而无法分配内存。可是前面明明已经写了b 10;为什么还说不知道b的值那是因为无法知道b所对应的地址是多少。因为编译器编译时只是将b和一个偏移进行了绑定并不是真正的地址即b所对应的可能是Base - 54而其中的Base就是在程序一开始执行时动态向操作系统申请的大块内存的尾地址因为其可能变化故无法得知b实际对应的地址实际在Windows平台下由于虚拟地址空间的运用是可以得到实际对应的虚拟地址但依旧不是实际地址故无法编译时期知道某变量的值。 但是编译器仍然可以根据前面的long b 10;而推出Base - 54的值为10啊重点就是编译器看到long b 10;时只是知道要生成一条指令此指令将10放入Base - 54的内存中其它将不再过问也没必要过问故即使才写了long b 10;编译器也无法得知b的值。 上面说数组是一种类型其实并不准确实际应为——数组是一种类型修饰符其定义了一种类型修饰规则。关于类型修饰符后面将详述。 字符串 在《C从零开始二》中已经说过要查某个字符对应的ASCII码通过在这个字符的两侧加上单引号如A就等同于65。而要表示多个字符时就使用双引号括起来如ABC。而为了记录字符就需要记录下其对应的ASCII码而ASCII码的数值在-128到127以内因此使用一个char变量就可以记录一个ASCII码而为了记录ABC就很正常地使用一个char的数组来记录。如下 char a A; char b[10]; b[0] A; b[1] B; b[2] C; 上面a的值为65b[0]的值为65b[1]为66b[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强制提供了一种简写方式如下 char b[10] ABC; 上面就等效于前面所做的所有工作其中的ABC是一个地址类型的数字准确的说是一初始化表达式在《C从零开始九》中说明其类型为char[4]即一个4个元素的char数组多了一个末尾元素用于放0来标识字符串的结束。应当注意由于b为char[10]而ABC返回的是char[4]类型并不匹配需要隐式类型转换但实际没有进行转换而是做了一系列的赋值操作就如前面所做的工作这是C硬性规定的称为初始化且仅仅对于数组定义时进行初始化有效即如下是错误的 char b[10]; b ABC; 而即使是char b[4]; b ABC;也依旧错误因为b的类型是数组表示的是多个元素而对多个元素赋值是未定义的即float d[4]; float dd[4] d;也是错误的因为没定义d中的元素是依次顺序放到dd中的相应各元素还是倒序放到所以是不能对一个数组类型的变量进行赋值的。 由于现在字符的增多原来只用英文字母现在需要能表示中文、日文等多种字符原来使用char类型来表示字符最多也只能表示255种字符0用来表示字符串结束所以出现了所谓的多字节字符串MultiByte用这种表示方式记录的文本文件称为是MBCS格式的而原来使用char类型进行表示的字符串称为单字节字符串SingleByte用这种表示方式记录的文本文件称为是ANSI格式的。 由于char类型可以表示负数则当从字符串中提取字符时如果所得元素的数值是负的则将此元素和下一个char元素联合起来形成一short类型的数字再按照Unicode编码规则一种编码规则等同于前面提过的ASCII码表来解释这个short类型的数字以得到相应的字符。 而上面的ABC返回的就是以多字节格式表示的字符串因为没有汉字或特殊符号故好象是用单字节格式表示的但如果char b[10] AB汉C;则b[2]为-70b[5]为0而不是想象的由于4个字符故b[4]为0因为“汉”这个字符占用了两个字节。 上面的多字节格式的坏处是每个字符的长度不固定如果想取字符串中的第3个字符的值则必须从头开始依次检查每个元素的值而不能是3乘上某个固定长度降低了字符串的处理速度且在显示字符串时由于需要比较检查当前字符的值是否小于零而降低效率故又推出了第三种字符表示格式宽字节字符串WideChar用这种表示方式记录的文本文件称为是Unicode格式的。其与多字节的区别就是不管这个字符是否能够用ASCII表示出来都用一个short类型的数字来表示即每个字符的长度固定为2字节C对此提供了支持。 short b[10] LAB汉C; 在双引号的前面加上“L”必须是大写的不能小写即告诉编译器此双引号内的字符要使用Unicode格式来编码故上面的b数组就是使用Unicode来记录字符串的。同样也有short c LA;其中的c为65。 如果上面看得不是很明白不要紧在以后举出的例子中将会逐渐了解字符串的使用的。 静态和动态 上面依然没有解决根本问题——C依旧只能通过变量这个映射元素来访问内存在访问某块内存前一定要先建立相应的映射即定义变量。有什么坏处让我们先来了解静态和动态是什么意思。 收银员开发票手动则每次开发票时都用已经印好的发票联给客人开发票发票联上只印了4个格子用以记录商品的名称当客人一次买的商品超过4种以上时就必须开两张或多张发票。这里发票联上的格子的数量就被称作静态的即无论任何时候任何客人买东西开发票时发票联上都印着4个记录商品名称用的格子。 超市的收银员开发票将商品名称及数量等输入电脑然后即时打印出一张发票给客人则不同的客人打印出的发票的长度可能不同有的客人买得多而有的少此时发票的长度就称为动态的即不同时间不同客人买东西开出的发票长度可能不同。 程序无论执行多少遍在申请内存时总是申请固定大小的内存则称此内存是静态分配的。前面提出的定义变量时编译器帮我们从栈上分配的内存就属于静态分配。每次执行程序根据用户输入的不同而可能申请不同大小的内存时则称此内存是动态分配的后面说的从堆上分配就属于动态分配。 很明显动态比静态的效率高发票长度的利用率高但要求更高——需要电脑和打印机且需要收银员的素质较高能操作电脑而静态的要求就较低只需要已经印好的发票联且也只需收银员会写字即可。 同样静态分配的内存利用率不高或运用不够灵活但代码容易编写且运行速度较快动态分配的内存利用率高不过编写代码时要复杂些需自己处理内存的管理分配和释放且由于这种管理的介入而运行速度较慢并代码长度增加。 静态和动态的意义不仅仅如此其有很多的深化如硬编码和软编码、紧耦合和松耦合都是静态和动态的深化。 地址 前面说过“地址就是一个数字用以唯一标识某一特定内存单元”而后又说“而地址就和长整型、单精度浮点数这类一样是数字的一种类型”那地址既是数字又是数字的类型不是有点矛盾吗如下 浮点数是一种数——小数——又是一种数字类型。即前面的前者是地址实际中的运用而后者是由于电脑只认识状态但是给出的状态要如何处理就必须通过类型来说明所以地址这种类型就是用来告诉编译器以内存单元的标识来处理对应的状态。 指针 已经了解到动态分配内存和静态分配内存的不同现在要记录用户输入的定单数据用户一次输入的定单数量不定故选择在堆上分配内存。假设现在根据用户的输入需申请1M的内存以对用户输入的数据进行临时记录则为了操作这1M的连续内存需记录其首地址但又由于此内存是动态分配的即其不是由编译器分配而是程序的代码动态分配的故未能建立一变量来映射此首地址因此必须自己来记录此首地址。 因为任何一个地址都是4个字节长的二进制数对32位操作系统故静态分配一块4字节内存来记录此首地址。检查前面可以将首地址这个数据存在unsigned long类型的变量a中然后为了读取此1M内存中的第4个字节处的4字节长内存的内容通过将a的值加上4即可获得相应的地址然后取出其后连续的4个字节内存的内容。但是如何编写取某地址对应内存的内容的代码呢前面说了只要返回地址类型的数字由于是地址类型则其会自动取相应内容的。但如果直接写a 4由于a是unsigned long则a 4返回的是unsigned long类型不是地址类型怎么办 C对此提出了一个操作符——“*”叫做取内容操作符实际这个叫法并不准确。其和乘号操作符一样但是它只在右侧接数字即*( a 4 )。此表达式返回的就是把a的值加上4后的unsigned long数字转成地址类型的数字。但是有个问题a 4所表示的内存的内容如何解释即取1个字节还是2个字节以什么格式来解释取出的内容如果自己编写汇编代码这就不是问题了但现在是编译器代我们编写汇编代码因此必须通过一种手段告诉编译器如何解释给定的地址所对内存的内容。 C对此提出了指针其和上面的数组一样是一种类型修饰符。在定义变量时在变量名的前面加上“*”即表示相应变量是指针类型就如在变量名后接“[]”表示相应变量是数组类型一样其大小固定为4字节。如 unsigned long *pA; 上面pA就是一个指针变量其大小因为是为32位操作系统编写代码故为4字节当*pA;时先计算pA的值就是返回从pA所对应地址的内存开始取连续4个字节的内容然后计算“*”将刚取到的内容转成unsigned long的地址类型的数字接着计算此地址类型的数字返回以原码格式解释其内容而得到一个unsigned long的数字最后计算这个unsigned long的数字而返回以原码格式解释它而得的二进制数。 也就是说某个地址的类型为指针时表示此地址对应的内存中的内容应该被编译器解释成一个地址。 因为变量就是地址的映射每个变量都有个对应的地址为此C又提供了一个操作符来取某个变量的地址——“”称作取地址操作符。其与“数字与”操作符一样不过它总是在右侧接数字而不是两侧接数字。 “”的右侧只能接地址类型的数字它的计算Evaluate就是将右侧的地址类型的数字简单的类型转换成指针类型并进而返回一个指针类型的数字正好和取内容操作符“*”相反。 上面正常情况下应该会让你很晕下面释疑。 unsigned long a 10, b, *pA; pA a; b *pA; ( *pA ); 上面的第一句通过“*pA”定义了一个指针类型的变量pA即编译器帮我们在栈上分配了一块4字节的内存并将首地址和pA绑定即形成映射。然后“a”由于a是一个变量等同于地址所以“a”进行计算返回一个类型为unsigned long*即unsigned long的指针的数字。 应该注意上面返回的数字虽然是指针类型但是其值和a对应的地址相同但为什么不直接说是unsigned long的地址的数字而又多一个指针类型在其中搅和因为指针类型的数字是直接返回其二进制数值而地址类型的数字是返回其二进制数值对应的内存的内容。因此假设上面的变量a所对应的地址为2000则a;将返回10而a;将返回2000。 看下指针类型的返回值是什么。当书写pA;时返回pA对应的地址按照上面的假设就应该是2008计算此地址的值返回数字2000因为已经pA a;其类型是unsigned long*然后对这个unsigned long*的数字进行计算直接返回2000所对应的二进制数注意前面红字的内容。 再来看取内容操作符“*”其右接的数字类型是指针类型或数组类型它的计算就是将此指针类型的数字直接转换成地址类型的数字而已因为指针类型的数字和地址类型的数字在数值上是相同的仅仅计算规则不同。所以 b *pA; 返回pA对应的地址计算此地址的值返回类型为unsigned long*的数字2000然后“*pA”返回类型unsigned long的地址类型的数字2000然后计算此地址类型的数字的值返回10然后就只是简单地赋值操作了。同理对于( *pA )由于“*”的优先级低于前缀所以加“()”先计算“*pA”而返回unsigned long的地址类型的数字2000然后计算前缀最后返回unsigned long的地址类型的数字2000。 如果你还是未能理解地址类型和指针类型的区别希望下面这句能够有用地址类型的数字是在编译时期给编译器用的指针类型的数字是在运行时期给代码用的。如果还是不甚理解在看过后面的类型修饰符一节后希望能有所帮助。 在堆上分配内存 前面已经说过所谓的在堆上分配就是运行时期向操作系统申请内存而要向操作系统申请内存不同的操作系统提供了不同的接口具有不同的申请内存的方式而这主要通过需调用的函数原型不同来表现关于函数原型可参考《C从零开始七》。由于C是一门语言不应该是操作系统相关的所以C提供了一个统一的申请内存的接口即new操作符。如下 unsigned long *pA new unsigned long; *pA 10; unsigned long *pB new unsigned long[ *pA ]; 上面就申请了两块内存pA所指的内存即pA的值所对应的内存是4字节大小而pB所指的内存是4*1040字节大小。应该注意由于new是一个操作符其结构为new 类型名[整型数字]。它返回指针类型的数字其中的类型名指明了什么样的指针类型而后面方括号的作用和定义数组时一样用于指明元素的个数但其返回的并不是数组类型而是指针类型。 应该注意上面的new操作符是向操作系统申请内存并不是分配内存即其是有可能失败的。当内存不足或其他原因时new有可能返回数值为0的指针类型的数字以表示内存分配失败。即可如下检测内存是否分配成功。 unsigned long *pA new unsigned long[10000]; if( !pA ) // 内存失败做相应的工作 上面的if是判断语句下篇将介绍。如果pA为0则!pA的逻辑取反就是非零故为逻辑真进而执行相应的工作。 只要分配了内存就需要释放内存这虽然不是必须的但是作为程序员它是一个良好习惯资源是有限的。为了释放内存使用delete操作符如下 delete pA; delete[] pB; 注意delete操作符并不返回任何数字但是其仍被称作操作符看起来它应该被叫做语句更加合适但为了满足其依旧是操作符的特性C提供了一种很特殊的数字类型——void。其表示无即什么都不是这在《C从零开始七》中将详细说明。因此delete其实是要返回数字的只不过返回的数字类型为void罢了。 注意上面对pA和pB的释放不同因为pA按照最开始的书写是new unsigned long返回的而pB是new unsigned long[ *pA ]返回的。所以需要在释放pB时在delete的后面加上“[]”以表示释放的是数组不过在VC中不管前者还是后者都能正确释放内存无需“[]”的介入以帮助编译器来正确释放内存因为以Windows为平台而开发程序的VC是按照Windows操作系统的方式来进行内存分配的而Windows操作系统在释放内存时无需知道欲释放的内存块的长度因为其已经在内部记录下来这种说法并不准确实际应是C运行时期库干了这些事但其又是依赖于操作系统来干的即其实是有两层对内存管理的包装在此不表。 类型修饰符type-specifier 类型修饰符即对类型起修饰作用的符号在定义变量时用于进一步指明如何操作变量对应的内存。因为一些通用操作方式即这种操作方式对每种类型都适用故将它们单独分离出来以方便代码的编写就好像水果。吃苹果的果肉、吃梨的果肉不吃苹果的皮、不吃梨的皮。这里苹果和梨都是水果的种类相当于类型而“XXX的果肉”、“XXX的皮”就是用于修饰苹果或梨这种类型用的以生成一种新的类型——苹果的果肉、梨的皮其就相当于类型修饰符。 本文所介绍的数组和指针都是类型修饰符之前提过的引用变量的“”也是类型修饰符在《C从零开始七》中将再提出几种类型修饰符到时也将一同说明声明和定义这两个重要概念并提出声明修饰符decl-specifier。 类型修饰符只在定义变量时起作用如前面的unsigned long a, b[10], *pA a, rA a;。这里就使用了上面的三个类型修饰符——“[]”、“*”和“”。上面的unsigned long暂且叫作原类型表示未被类型修饰符修饰以前的类型。下面分别说明这三个类型修饰符的作用。 数组修饰符“[]”——其总是接在变量名的后面方括号中间放一整型数c以指明数组元素的个数以表示当前类型为原类型c个元素连续存放长度为原类型的长度乘以c。因此long a[10];就表示a的类型是10个long类型元素连续存放长度为10*440字节。而long a[10][4];就表示a是10个long[4]类型的元素连续存放其长度为10*(4*4)160字节。 相信已经发现由于可以接多个“[]”因此就有了计算顺序的关系为什么不是4个long[10]类型的元素连续存放而是倒过来类型修饰符的修饰顺序是从左向右进行计算的但当出现重复的类型修饰符时同类修饰符之间是从右向左计算以符合人们的习惯。故short *a[10];表示的是10个类型为short*的元素连续存放长度为10*440字节而short *b[4][10];表示4个类型为short*[10]的元素连续存放长度为4*40160字节。 指针修饰符“*”——其总是接在变量名的前面表示当前类型为原类型的指针。故 short a 10, *pA a, **ppA pA; 注意这里的ppA被称作多级指针即其类型为short的指针的指针也就是short**。而short **ppA pA;的意思就是计算pA的地址的值得一类型为short*的地址类型的数字然后“”操作符将此数字转成short*的指针类型的数字最后赋值给变量ppA。 如果上面很昏不用去细想只要注意类型匹配就可以了下面简要说明一下假设a的地址为2000则pA的地址为2002ppA的地址为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从零开始七》中说明 long a 10, *pA a, **ppA pA, *rpA1 *ppA, *rpA2 *( ppA 1 ); 类型修饰符和原类型组合在一起以形成新的类型如long*、short *[34]等都是新的类型应注意前面new操作符中的类型名要求写入类型名称则也可以写上前面的long*等即 long **ppA new long*[45]; 即动态分配一块4*45180字节的连续内存空间并将首地址返回给ppA。同样也就可以 long ***pppA new long**[2]; 而long *(*pA)[10] new long*[20][10]; 也许看起来很奇怪其中的pA的类型为long *(*)[10]表示是一个有10个long*元素的数组的指针而分配的内存的长度为(4*10)*20800字节。因为数组修饰符“[]”只能放在变量名后面而类型修饰符又总是从左朝右计算则想说明是一个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中共有两种语句单句和复合语句。复合语句是用一对大括号括起来以在需要的地方同时放入多条单句如{ long a 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;等以依靠操作符的计算功能的定义而生成相应的关于内存值操作的代码。注释语句就是用于注释代码的语句即写来给人看的不是给编译器看的。最后的指令语句就是含有下面所述关键字的语句即它们的用处不是操作内存而是实现前面说的“什么样的情况”。 这里的声明语句、预编译语句和注释语句都不会转换成机器代码即这三种语句不是为了操作电脑而是其他用途以后将详述。而定义语句也不一定会生成机器代码只有表达式语句和指令语句一定会生成代码不考虑编译器的优化功能。 还应注意可以写空语句即;或{}它们不会生成任何代码其作用仅仅只是为了保证语法上的正确后面将看到这一点。下面说明注释语句和指令语句——跳转语句、判断语句和循环语句实际不止这些由于异常和模板技术的引入而增加了一些语句将分别在说明异常和模板时说明。 注释语句——//、/**/ 注释即用于解释的标注即一些文字信息用以向看源代码的人解释这段代码什么意思因为人的认知空间和电脑的完全不同这在以后说明如何编程时会具体讨论。要书写一段话用以注释用“/*”和“*/”将这段话括起来如下 long a 1; a 1; /* a放的是人的个数让人的个数加一 */ b * a; /* b放的是人均花费得到总的花费 */ 上面就分别针对a 1;和b * a;写了两条注释语句以说明各自的语义因为只要会C都知道它们是一个变量的自增一和另一个变量的自乘a但不知道意义。上面的麻烦之处就是需要写“/*”和“*/”有点麻烦故C又提供了另一种注释语句——“//” long a 1; a 1; // a放的是人的个数让人的个数加一 b * a; // b放的是人均花费得到总的花费 上面和前面等效其中的“//”表示从它开始这一行后面的所有字符均看成注释编译器将不予理会即 long a 1; a 1; // a放的是人的个数让人的个数加一 b * a; 其中的b * a;将不会被编译因为前面的“//”已经告诉编译器从“//”开始这一行后面的所有字符均是注释故编译器不会编译b * a;。但如果 long a 1; a 1; /* a放的是人的个数让人的个数加一 */ b * a; 这样编译器依旧会编译b * a;因为“/*”和“*/”括起来的才是注释。 应该注意注释语句并不是语句其不以“;”结束其只是另一种语法以提供注释功能就好象以后将要说明的预编译语句一样都不是语句都不以“;”结束既不是单句也不是复合语句只是出于习惯的原因依旧将它们称作语句。 跳转语句——goto 前面已经说明源代码在此指用C编写的代码中的语句依次地转变成用长度不同的二进制数表示的机器代码然后顺序放在内存中这种说法不准确。如下面这段代码 long a 1; // 假设长度为5字节地址为3000 a 1; // 则其地址为3005假设长度为4字节 b * a; // 则其地址为3009假设长度为6字节 上面的3000、3005和3009就表示上面3条语句在内存中的位置而所谓的跳转语句也就是将上面的3000、3005等语句的地址放到前面提过的指令寄存器中以使得CPU开始从给定的位置执行以表现出执行顺序的改变。因此就必须有一种手段来表现语句的地址C对此给出了标号Label。 写一标识符后接“:”即建立了一映射将此标识符和其所在位置的地址绑定了起来如下 long a 1; // 假设长度为5字节地址为3000 P1: a 1; // 则其地址为3005假设长度为4字节 P2: b * a; // 则其地址为3009假设长度为6字节 goto P2; 上面的P1和P2就是标号其值分别为3005和3009而最后的goto就是跳转语句其格式为goto 标号;。此语句非常简单先通过“:”定义了一个标号然后在编写goto时使用不同的标号就能跳到不同的位置。 应该注意上面故意让P1和P2定义时独占一行其实也可以不用即 long a 1; P1: a 1; P2: b * a; goto P2; 因此看起来“P1:”和“P2:”好象是单独的一条定义语句应该注意准确地说它们应该是语句修饰符作用是定义标号并不是语句即这样是错误的 long a 1; P1: { a 1; P2: b * a; P3: } goto P2; 上面的P3:将报错因为其没有修饰任何语句。还应注意其中的P1仍然是3005即“{}”仅仅只是其复合的作用实际并不产生代码进而不影响语句的地址。 判断语句——if else、switch if else 前面说过了为了实现“什么样的情况”做“什么样的动作”故C非常正常地提供了条件判断语句以实现条件的不同而执行不同的代码。if else的格式为 if(数字)语句1else语句2 或者 if(数字)语句1 long a 0, b 1; P1: a; b * a; if( a 10 ) goto P1; long c b; 上面的代码就表示只有当a的值小于10时才跳转到P1以重复执行最后的效果就是c的值为10的阶乘。 上面的数字表示可以在“if”后的括号中放一数字即表达式而当此数字的值非零时即逻辑真程序跳转以执行语句1如果为零即逻辑假则执行语句2。即也可如此if( a – 10 ) goto P1;其表示当a – 10不为零时才执行goto P1;。这和前面的效果一样虽然最后c仍然是10的阶乘但意义不同代码的可读性下降除非出于效率的考虑不推荐如此书写代码。 而语句1和语句2由于是语句也就可以放任何是语句的东西因此也可以这样 if( a ) long c; 上面可谓吃饱了撑了在此只是为了说明语句1实际可以放任何是语句的东西但由于前面已经说过标号的定义以及注释语句和预编译语句其实都不是语句因此下面试图当a非零时定义标号P2和当a为零时书写注释“错误”的意图是错误的 if( a ) P2: 或者if( !a ) // 错误 a; a; 但编译器不会报错因为前者实际是当a非零时将a自增一后者实际是当a为零时将a自增一。还应注意由于复合语句也是语句因此 if( a ){ long c 0; c; } 由于使用了复合语句因此这个判断语句并不是以“;”结尾但它依旧是一个单句即 if( a ) if( a 10 ) { long c 0; c; } else b * a; 上面虽然看起来很复杂但依旧是一个单句应该注意当写了一个“else”时编译器向上寻找最近的一个“if”以和其匹配因此上面的“else”是和“if( a 10 )”匹配的而不是由于上面那样的缩进书写而和“if( a )”匹配因此b * a;只有在a大于等于10的时候才执行而不是想象的a为零的时候。 还应注意前面书写的if( a ) long c;。这里的意思并不是如果a非零就定义变量c这里涉及到作用域的问题将在下篇说明。 switch 这个语句的定义或多或少地是因为实现的原因而不是和“if else”一样由于逻辑的原因。先来看它的格式switch(整型数字)语句。 上面的整型数字和if语句一样只要是一个数字就可以了但不同地必须是整型数字后面说明原因。然后其后的语句与前相同只要是语句就可以。在语句中应该使用这样的形式case 整型常数1:。它在它所对应的位置定义了一个标号即前面goto语句使用的东西表示如果整型数字和整型常数1相等程序就跳转到“case 整型常数1:”所标识的位置否则接着执行后续的语句。 long a, b 3; switch( a 3 ) case 2: case 3: a; b * a; 上面就表示如果a 3等于2或3就跳到a;的地址进而执行a否则接着执行后面的语句b * a;。这看起来很荒谬有什么用一条语句当然没意义为了能够标识多条语句必须使用复合语句即如下 long a, b 3; switch( a 3 ) { b 0; case 2: a; // 假设地址为3003 case 3: a--; // 假设地址为3004 break; case 1: 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语句如下 unsigned long Addr[3]; Addr[0] 3006; Addr[1] 3003; Addr[2] 3004; 而对于switch( a 3 )则使用类似的语句就可以代替goto Addr[ a 3 – 1 ]; 上面就是switch的真面目应注意上面的goto的写法是错误的这也正是为什么会有switch语句。编译器为我们构建一个存储地址的数组这个数组的每个元素都是一个地址其表示的是某条语句的地址这样通过不同的偏移即可实现跳转到不同的位置以执行不同的语句进而表现出状态的选择。 现在应该了解为什么上面必须是整型数字了因为这些数字将用于数组的下标或者是偏移因此必须是整数。而整型常数1必须是常数因为其由编译时期告诉编译器它现在所在位置应放在地址数组的第几个元素中。 了解了switch的实现后以后在书写switch时应尽量将各case后接的整型常数或其倍数靠拢以减小需生成的数组的大小而无需管常数的大小。即case 1000、case1001、case 1002和case 2、case 4、case 6都只用3个元素大小的数组而case 0、case 100、case 101就需要102个元素大小的数组。应该注意现在的编译器都很智能当发现如刚才的后者这种只有3个分支却要102个元素大小的数组时编译器是有可能使用重复的if语句来代替上面数组的生成。 switch还提供了一个关键字——default。如下 long a, b 3; switch( a 3 ) { case 2: a; break; case 3: a 3; break; default: a--; } b * a; 上面的“default:”表示当a 3不为2且不为3时则执行a--;即default表示缺省的状况但也可以没有则将直接执行switch后的语句因此这是可以的switch( a ){}或switch( a );只不过毫无意义罢了。 循环语句——for、while、do while 刚刚已经说明循环语句的提供主要是出于简写目的因为循环是方法描述中用得最多的且算法并不复杂进而对编译器的开发难度不是增加太多。 for 其格式为for(数字1;数字2;数字3)语句。其中的语句同上即可接单句也可接复合语句。而数字1、数字2和数字3由于是数字就是表达式进而可以做表达式语句能做的所有的工作——操作符的计算。for语句的意思是先计算数字1相当于初始化工作然后计算数字2。如果数字2的值为零表示逻辑假则退出循环执行for后面的语句否则执行语句然后计算数字3相当于每次循环的例行公事接着再计算数字2并重复。上面的语句一般被称作循环体。 上面的设计是一种面向过程的设计思想将循环体看作是一个过程则这个过程的初始化数字1和必定执行数字3都表现出来。一个简单的循环如下 long a, b; for( a 1, b 1; a 10; a ) b * a; 上面执行完后b是10的阶乘和前面在说明if语句时举的例子相比其要简单地多并且可读性更好——a 1, b 1是初始化操作每次循环都将a加一这些信息是goto和if语句表现不出来的。由于前面一再强调的语句和数字的概念因此可以如下 long a, b 1; for( ; b 100; ) for( a 1, b 1; a; a, b ) if( b * a ) switch( a b ) { case 1: a; break; case 2: for( b 10; b; b-- ) { a b * b; case 3: a * a; } break; } 上面看着很混乱注意“case 3:”在“case 2:”后的一个for语句的循环体中也就是说当a b返回1时跳到a;处并由于break;的缘故而执行switch后的语句也就是if后的语句也就是第二个for语句的a, b。当返回2时跳到第三个for语句处开始执行循环完后同样由break;而继续后面的执行。当返回3时跳到a * a;处执行然后计算b--接着计算b的值检查是否非零然后重复循环直到b的值为零然后继续以后的执行。上面的代码并没什么意义在这里是故意写成这么混乱以进一步说明前面提过的语句和数字的概念如果真正执行大致看过去也很容易知道将是一个死循环即永远循环无法退出的循环。 还应注意C提出了一种特殊语法即上面的数字1可以不是数字而是一变量定义语句即可如此for( long a 1, b 1; a 10; a, b );。其中就定义了变量a和b。但是也只能接变量定义语句而结构定义、类定义及函数定义语句将不能写在这里。这个语法的提出是更进一步地将for语句定义为记数式循环的过程这里的变量定义语句就是用于定义此循环中充当计数器的变量上面的a以实现循环固定次数。 最后还应注意上面写的数字1、数字2和数字3都是可选的即可以for(;;);。 while 其格式为while(数字)语句其中的数字和语句都同上意思很明显当数字非零时执行语句否则执行while后面的语句这里的语句被称作循环体。 do while 其格式为do语句while(数字);。注意在while后接了“;”以表示这个单句的结束。其中的数字和语句都同上意思很明显当数字非零时执行语句否则执行while后面的语句这里的语句被称作循环体。 为什么C要提供上面的三种循环语句简写是一重要目的但更重要的是可以提供一定的优化。for被设计成用于固定次数的循环而while和do while都是用于条件决定的循环。对于前者编译器就可以将前面提过的用于记数的变量映射成寄存器以优化速度而后者就要视编译器的智能程度来决定是否能生成优化代码了。 while和do while的主要区别就是前者的循环体不一定会被执行而后者的循环体一定至少会被执行一次。而出于简写的目的C又提出了continue和break语句。如下 for( long i 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这里依旧。 long a 0; P1: for( long i a; i 10; i ) for( long j 0; j 10; j ) { if( !( j % 3 ) ) continue; if( !( j % 7 ) ) break; if( i * j ) { a i * j; goto P1; } // 其他语句 } 上面的continue;执行后将立即计算j而break;执行后将退出第二个循环即j的循环进而执行i然后继续由i 10来决定是否继续循环。当goto P1;执行时程序跳到上面的P1处即执行long i a;进而重新开始i的循环。 上面那样书写goto语句是不被推荐的因为其破坏了循环不符合人的思维习惯。在此只是要说明for或while、do while等都不是循环只是它们各自的用处最后表现出来好象是循环实际只是程序执行位置的变化。应清楚语句的实现这样才能清楚地了解各种语句的实际作用进而明确他人写的代码的意思。而对于自己书写代码了解语句的实现将有助于进行一定的优化。但当你写出即精简又执行效率高的程序时保持其良好的可读性是一个程序员的素养应尽量培养自己书写可读性高的代码的习惯。 上面的long j 0在第一个循环的循环体内被多次执行岂不是要多次定义这属于变量的作用域的问题下篇将说明。 本篇的内容应该是很简单的重点只是应该理解源代码编译成机器指令后在执行时也放在内存中故每条语句都对应着一个地址而通过跳转语句即可改变程序的运行顺序。下篇将对此提出一系列的概念并说明声明和定义的区别。 C从零开始七 ——何谓函数 本篇之前的内容都是基础中的基础理论上只需前面所说的内容即可编写出几乎任何只操作内存的程序也就是本篇以后说明的内容都可以使用之前的内容自己实现只不过相对要麻烦和复杂许多罢了。 本篇开始要比较深入地讨论C提出的很有意义的功能它们大多数和前面的switch语句一样是一种技术的实现但更为重要的是提供了语义的概念。所以本篇开始将主要从它们提供的语义这方面来说明各自的用途而不像之前通过实现原理来说明不过还是会说明一下实现原理的。为了能清楚说明这些功能要求读者现在至少能使用VC来编译并生成一段程序因为后续的许多例子都最好是能实际编译并观察执行结果以加深理解尤其是声明和类型这两个概念。为此如果你现在还不会使用VC或其他编译器来进行编译代码请先参看其他资料以了解如何使用VC进行编译。为了后续例子的说明下面先说明一些预备知识。 预备知识 写出了C代码要如何让编译器编译在文本文件中书写C代码然后将文本文件的文件名作为编译器的输入参数传递给编译器即叫编译器编译给定文件名所对应的文件。在VC中这些由VC这个编程环境也就是一个软件提供诸多方便软件开发的功能帮我们做了其通过项目Project来统一管理书写有C/C代码的源文件。为了让VC能了解到哪些文件是源文件因为还可能有资源文件等其他类型文件在用文本编辑器书写了C代码后将其保存为扩展名为.c或.cppC Plus Plus的文本文件前者表示是C代码而后者表示C代码则缺省情况下VC就能根据不同的源文件而使用不同的编译语法来编译源文件。 前篇说过C中的每条语句都是从上朝下执行每条语句都对应着一个地址那么在源文件中的第一条语句对应的地址就是0吗当然不是和在栈上分配内存一样只能得到相对偏移值实际的物理地址由于不同的操作系统将会有各自不同的处理如在Windows下代码甚至可以没有物理地址且代码对应的物理地址还能随时变化。 当要编写一个稍微正常点的程序时就会发现一个源文件一般是不够的需要使用多个源文件来写代码。而各源文件之间要如何连接起来对此C规定凡是生成代码的语句都要放在函数中而不能直接写在文本文件中。关于函数后面马上说明现在只需知道函数相当于一个外壳它通过一对“{}”将代码括起来进而就将代码分成了一段一段且每一段代码都由函数名这个项目内唯一的标识符来标识因此要连接各段代码只用通过函数名即可后面说明。前面说的“生成代码”指的是表达式语句和指令语句虽然定义语句也可能生成代码但由于其代码生成的特殊性是可以直接写在源文件内在《C从零开始十》中说明即不用被一对“{}”括起来。 程序一开始要从哪里执行C强行规定应该在源文件中定义一个名为main的函数而代码就从这个函数处开始运行。应该注意由于C是由编译器实现的而它的这个规定非常的牵强因此纵多的编译器都又自行提供了另外的程序入口点定义语法程序入口点即最开始执行的函数如VC为了编写DLL文件就不应有main函数为了编写基于Win32的程序就应该使用WinMain而不是main而VC实际提供了更加灵活的手段实际可以让程序从任何一个函数开始执行而不一定非得是前面的WinMain、main等这在《C从零开始十九》中说明。 对于后面的说明应知道程序从main函数开始运行如下 long a; void main(){ short b; b; } long c; 上面实际先执行的是long a;和long c;不过不用在意实际有意义的语句是从short b;开始的。 函数Function 机器手焊接轿车车架上的焊接点给出焊接点的三维坐标机器手就通过控制各关节的马达来使焊枪移到准确的位置。这里控制焊枪移动的程序一旦编好以后要求机器手焊接车架上的200个点就可以简单地给出200个点的坐标然后调用前面已经编好的移动程序200次就行了而不用再对每次移动重复编写代码。上面的移动程序就可以用一个函数来表示。 函数是一个映射元素。其和变量一样将一个标识符即函数名和一个地址关联起来且也有一类型和其关联称作函数的返回类型。函数和变量不同的就是函数关联的地址一定是代码的地址就好像前面说明的标号一样但和标号不同的就是C将函数定义为一种类型而标号则只是纯粹的二进制数即函数名对应的地址可以被类型修饰符修饰以使得编译器能生成正确的代码来帮助程序员书实现上面的功能。 由于定义函数时编译器并不会分配内存因此引用修饰符“”不再其作用同样由数组修饰符“[]”的定义也能知道其不能作用于函数上面只有留下的指针修饰符“*”可以因为函数名对应的是某种函数类型的地址类型的数字。 前面移动程序之所以能被不同地调用200次是因为其写得很灵活能根据不同的情况不同位置的点来改变自己的运行效果。为了向移动程序传递用于说明情况的信息即点的坐标必须有东西来完成这件事在C中这使用参数来实现并对于此C专门提供了一种类型修饰符——函数修饰符“()”。在说明函数修饰符之前让我们先来了解何谓抽象声明符Abstract Declarator。 声明一个变量long a;这看起来和定义变量一样后面将说明它们的区别其中的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的类型其实就是一个抽象声明符因此也可如下 long AB( long*( long*, long, long[10], long(*)[10] ), short, long ); 对于前面的移动程序就可类似如下声明它 void Move( float x, float y, float z ); 上面在书写声明修饰符时又加上了参数名以表示对应参数的映射。不过由于这里是函数的声明上述参数名实际不产生任何映射因为这是函数的声明不是定义关于声明后面将说明。而这里写上参数名是一种语义的体现表示第一、二、三个参数分别代表X、Y、Z坐标值。 上面的返回类型为void前面提过void是C提供的一种特殊数字类型其仅仅只是为了保障语法的严密性而已即任何函数执行后都要返回一个数字后面将说明而对于不用返回数字的函数则可以定义返回类型为void这样就可以保证语法的严密性。应当注意任何类型的数字都可以转换成void类型即可以( void )( 234 );或void( a );。 注意上面函数修饰符中可以一个抽象修饰符都没有即void ABC();。它等效于void ABC( void );表示ABC这个函数没有参数且不返回值。则它们的抽象声明符为void()或void(void)进而可以如下 long* ABC( long*(), long(), long[10] ); 由函数修饰符的意义即可看出其和引用修饰符一样不能重复修饰类型即不能void A()(long);这是无意义的。同样由于类型修饰符从左朝右的修饰顺序也就很正常地有void(*pA)()。假设这里是一个变量定义语句也可以看成是一声明语句后面说明则表示要求编译器在栈上分配一块4字节的空间将此地址和pA映射起来其类型为没有参数返回值类型为void的函数的指针。有什么用以后将说明。 函数定义 下面先看下函数定义对于前面的机器手控制程序可如下书写 void Move( float x, float y, float z ) { float temp; // 根据x、y、z的值来移动焊枪 } int main() { float x[200], y[200], z[200]; // 将200个点的坐标放到数组x、y和z中 for( unsigned i 0; i 200; i ) Move( x[ i ], y[ i ], z[ i ] ); return 0; } 上面定义了一个函数Move其对应的地址为定义语句float temp;所在的地址但实际由于编译器要帮我们生成一些附加代码称作函数前缀——Prolog在《C从零开始十五》中说明以获得参数的值或其他工作如异常的处理等因此Move将对应在较float temp;之前的某个地址。Move后接的类型修饰符较之前有点变化只是把变量名加上以使其不是抽象声明符而已其作用就是让编译器生成一映射将加上的变量名和传递相应信息的内存的地址绑定起来也就形成了所谓的参数。也由于此原因就能如此书写void Move( float x, float, float z ) { }。由于没有给第二个参数绑定变量名因此将无法使用第二个参数以后将举例说明这样的意义。 函数的定义就和前面的函数的声明一样只不过必须紧接其后书写一个复合语句必须是复合语句即用“{}”括起来的语句此复合语句的地址将和此函数名绑定但由于前面提到的函数前缀函数名实际对应的地址在复合语句的地址的前面。 为了调用给定函数C提供了函数操作符“()”其前面接函数类型的数字而中间根据相应函数的参数类型和个数放相应类型的数字和个数因此上面的Move( x[ i ], y[ i ], z[ i ] );就是使用了函数操作符用x[ i ]、y[ i ]、z[ i ]的值作为参数并记录下当前所在位置的地址跳转到Move所对应的地址继续执行当从Move返回时根据之前记录的位置跳转到函数调用处的地方继续后继代码的执行。 函数操作符由于是操作符因此也要返回数字也就是函数的返回值即可以如下 float AB( float x ) { return x * x; } int main() { float c AB( 10 ); return 0; } 先定义了函数AB其返回float类型的数字其中的return语句就是用于指明函数的返回值其后接的数字就必须是对应函数的返回值类型而当返回类型为void时可直接书写return;。因此上面的c的值为100函数操作符返回的值为AB函数中的表达式x * x返回的数字而AB( 10 )将10作为AB函数的参数x的值故x * x返回100。 由于之前也说明了函数可以有指针将函数和变量对比则直接书写函数名如AB;。上面将返回AB对应的地址类型的数字然后计算此地址类型数字应该是以函数类型解释相应地址对应的内存的内容考虑函数的意义将发现这是毫无意义的因此其不做任何事直接返回此地址类型的数字对应的二进制数也就相当于前面说的指针类型。因此也就可以如下 int main() { float (*pAB)( float ) AB; float c ( *pAB )( 10 ); return 0; } 上面就定义了一个指针pAB其类型为float(*)( float )一开始将AB对应的地址赋值给它。为什么没有写成pAB AB;而是pAB AB;因为前面已经说了函数类型的地址类型的数字将不做任何事其效果和指针类型的数字一样因此pAB AB;没有问题而pAB AB;就更没有问题了。可以认为函数类型的地址类型的数字编译器会隐式转换成指针类型的数字因此既可以( *pAB )( 10 );也能( *AB )( 10 );因为后者编译器进行了隐式类型转换。 由于函数操作符中接的是数字因此也可以float c AB( AB( 10 ) );即c为10000。还应注意函数操作符让编译器生成一些代码来传递参数的值和跳转到相应的地址去继续执行代码因此如下是可以的 long AB( long x ) { if( x 1 ) return x * AB( x - 1 ); else return 1; } 上面表示当参数x的值大于1时将x - 1返回的数字作为参数然后跳转到AB对应的地址处也就是if( x 1 )所对应的地址重复运行。因此如果long c AB( 5 );则c为5的阶乘。上面如果不能理解将在后面说明异常的时候详细说明函数是如何实现的以及所谓的堆栈溢出问题。 现在应该了解main函数的意义了其只是建立一个映射好让连接器制定程序的入口地址即main函数对应的地址。上面函数Move在函数main之前定义如果将Move的定义移到main的下面上面将发生错误说函数Move没定义过为什么因为编译器只从上朝下进行编译且只编译一次。那上面的问题怎么办后面说明。 重载函数 前面的移动函数如果只想移动X和Y坐标为了不移动Z坐标就必须如下再编写一个函数 void Move2( float x, float y ); 它为了不和前面的Move函数的名字冲突而改成Move2但Move2也表示移动却非要变一个名字这严重地影响语义。为了更好的从源代码上表现出语义即这段代码的意义C提出了重载函数的概念。 重载函数表示函数名字一样但参数类型及个数不同的多个函数。如下 void Move( float x, float y, float z ) { }和void Move( float x, float y ) { } 上面就定义了两个重载函数虽然函数名相同但实际为两个函数函数名相同表示它们具有同样的语义——移动焊枪的程序只是移动方式不同前者在三维空间中移动后者在一水平面上移动。当Move( 12, 43 );时就调用后者而Move( 23, 5, 12 );时就调用前者。不过必须是参数的不同不能是返回值的不同即如下将会报错 float Move( float x, float y ) { return 0; }和void Move( float a, float b ) { } 上面虽然返回值不同但编译器依旧认为上面定义的函数是同一个则将说函数重复定义。为什么因为在书写函数操作符时函数的返回值类型不能保证获得即float a Move( 1, 2 );虽然可以推出应该是前者但也可以Move( 1, 2 );这样将无法得知应该使用哪个函数因此不行。还应注意上面的参数名字虽然不同但都是一样的参数名字只是表示在那个函数的作用域内其映射的地址后面将说明。改成如下就没有问题 float Move( float x, float y ) { return 0; }和void Move( float a, float b, float c ) { } 还应注意下面的问题 float Move( float x, char y ); float Move( float a, short b ); 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提出了声明这个概念。 因此变量声明long a;就是告诉编译器已经有这么个变量其名字为a其类型为long其对应的地址不知道但可以先作个记号即在后续代码中所有用到这个变量的地方做上记号以告知连接器在连接时先在所有的中间文件里寻找是否有个叫a的变量其地址是多少然后再修改所有作了记号的地方将a对应的地址放进去。这样就实现了这个文件使用另一个文件中定义的变量。 所以声明long a;就是要告诉编译器已经有这么个变量a因此后续代码中用到a时不要报错说a未定义。函数也是如此但是有个问题就是函数声明和函数定义很容易区别因为函数定义后一定接一复合语句但是变量定义和变量声明就一模一样那么编译器将如何识别变量定义和变量声明编译器遇到long a;时统一将其认为是变量定义为了能标识变量声明可借助C提出的修饰符extern。 修饰符就是声明或定义语句中使用的用以修饰此声明或定义来向编译器提供一定的信息其总是接在声明或定义语句的前面或后面如 extern long a, *pA, ra; 上面就声明不是定义了三个变量a、pA和ra。因为extern表示外部的意思因此上面就被认为是告诉编译器有三个外部的变量为a、pA和ra故被认为是声明语句所以上面将不分配任何内存。同样对于函数它也是一样的 extern void ABC( long ); 或 extern long AB( short b ); 上面的extern等同于不写因为编译器根据最后的“;”就可以判断出来上面是函数声明而且提供的“外部”这个信息对于函数来说没有意义编译器将不予理会。extern实际还指定其后修饰的标识符的修饰方式实际应为externC或externC分别表示按照C语言风格和C语言风格来解析声明的标识符。 C是强类型语言即其要求很严格的类型匹配原则进而才能实现前面说的函数重载功能。即之所以能几个同名函数实现重载是因为它们实际并不同名而由各自的参数类型及个数进行了修饰而变得不同。如void ABC(), *ABC( long ), ABC( long, short );在VC中其各自名字将分别被变成“?ABCYAXXZ”、“?ABCYAPAXJZ”、“?ABCYAXJFZ”。而extern long a, *pA, ra;声明的三个变量的名字也发生相应的变化分别为“?a3JA”、“?pA3PAJA”、“?ra3AAJA”。上面称作C语言风格的标识符修饰不同的编译器修饰格式可能不同而C语言风格的标识符修饰就只是简单的在标识符前加上“_”即可不同的编译器的C风格修饰一定相同。如externC long a, *pA, ra;就变成_a、_pA、_ra。而上面的externC void ABC(), *ABC( long ), ABC( long, short );将报错因为使用C风格都只是在函数名前加一下划线则将产生3个相同的符号Symbol错误。 为什么不能有相同的符号为什么要改变标识符不仅因为前面的函数重载。符号和标识符不同符号可以由任意字符组成它是编译器和连接器之间沟通的手段而标识符只是在C语言级上提供的一种标识手段。而之所以要改变一下标识符而不直接将标识符作为符号使用是因为编译器自己内部和连接器之间还有一些信息需要传递这些信息就需要符号来标识由于可能用户写的标识符正好和编译器内部自己用的符号相同而产生冲突所以都要在程序员定义的标识符上面修改后再用作符号。既然符号是什么字符都可以那为什么编译器不让自己内部定的符号使用标识符不能使用的字符如前面VC使用的“?”那不就行了因为有些C/C编译器及连接器沟通用的符号并不是什么字符都可以也必须是一个标识符所以前面的C语言风格才统一加上“_”的前缀以区分程序员定义的符号和编译器内部的符号。即上面能使用“?”来作为符号是VC才这样也许其它的编译器并不支持但其它的编译器一定支持加了“_”前缀的标识符。这样可以联合使用多方代码以在更大范围上实现代码重用在《C从零开始十八》中将对此详细说明。 当书写extern void ABC( long );时是externC还是externC在VC中如果上句代码所在源文件的扩展名为.cpp以表示是C源代码则将解释成后者。如果是.c则将解释成前者。不过在VC中还可以通过修改项目选项来改变上面的默认设置。而extern long a;也和上面是同样的。 因此如下 externC void ABC(), *ABC( long ), ABC( long, short ); int main(){ ABC(); } 上面第一句就告诉编译器后续代码可能要用到这个三个函数叫编译器不要报错。假设上面程序放在一个VC项目下的a.cpp中编译a.cpp将不会出现任何错误。但当连接时编译器就会说符号“?ABCYAXXZ”没找到因为这个项目只包含了一个文件连接也就只连接相应的a.obj以及其他的一些必要库文件后续文章将会说明。连接器在它所能连接的所有对象文件a.obj以及库文件中查找符号“?ABCYAXXZ”对应的地址是什么不过都没找到故报错。换句话说就是main函数使用了在a.cpp以外定义的函数void ABC();但没找到这个函数的定义。应注意如果写成int main() { void ( *pA ) ABC; }依旧会报错因为ABC就相当于一个地址这里又要求计算此地址的值即使并不使用pA故同样报错。 为了消除上面的错误就应该定义函数void ABC();既可以在a.cpp中如main函数的后面也可以重新生成一个.cpp文件加入到项目中在那个.cpp文件中定义函数ABC。因此如下即可 externC void ABC(), *ABC( long ), ABC( long, short ); int main(){ ABC(); } void ABC(){} 如果你认为自己已经了解了声明和定义的区别并且清楚了声明的意思那我打赌有50%的可能性你并没有真正理解声明的含义这里出于篇幅限制将在《C从零开始十》中说明声明的真正含义如果你是有些C/C编程经验的人到时给出的样例应该有50%的可能性会令你大吃一惊。 调用规则 调用规则指函数的参数如何传递返回值如何传递以及上述的函数名标识符如何修饰。其并不属于语言级的内容因为其表示编译器如何实现函数而关于如何实现各编译器都有自己的处理方式。在VC中其定义了三个类型修饰符用以告知编译器如何实现函数分别为__cdecl、__stdcall和__fastcall。三种各有不同的参数、函数返回值传递方式及函数名修饰方式后面说明异常时在说明了函数的具体实现方式后再一一解释。由于它们是类型修饰符则可如下修饰函数 void *__stdcall ABC( long ), __fastcall DE(), *( __stdcall *pAB )( long ) ABC; void ( __fastcall *pDE )() DE; 变量的作用域 前面定义函数Move时就说void Move( float a, float b );和void Move( float x, float y );是一样的即变量名a和b在这没什么意义。这也就是说变量a、b的作用范围只限制在前面的Move的函数体即函数定义时的复合语句内同样x和y的有效范围也只在后面的Move的函数体内。这被称作变量的作用域。 //a.cpp// long e 10; void main() { short a 10; e; { long e 2; e; a; } e; } 上面的第一个e的有效范围是整个a.cpp文件内而a的有效范围是main函数内而main函数中的e的有效范围则是括着它的那对“{}”以内。即上面到最后执行完e;后long e 2;定义的变量e已经不在了也就是被释放了。而long e 10;定义的e的值为12a的值为11。 也就是说“{}”可以一层层嵌套包含没一层“{}”就产生了一个作用域在这对“{}”中定义的变量只在这对“{}”中有效出了这对“{}”就无效了等同于没定义过。 为什么要这样弄那是为了更好的体现出语义。一层“{}”就表示一个阶段在执行这个阶段时可能会需要到和前面的阶段具有相同语义的变量如排序。还有某些变量只在某一阶段有用过了这个阶段就没有意义了下面举个例子 float a[10]; // 赋值数组a for( unsigned i 0; i 10; i ) for( unsigned j 0; j 10; j ) if( a[ i ] a[ j ] ) { float temp a[ i ]; a[ i ] a[ j ]; a[ j ] temp; } 上面的temp被称作临时变量其作用域就只在if( a[ i ] a[ j ] )后的大括号内因为那表示一个阶段程序已经进入交换数组元素的阶段而只有在交换元素时temp在有意义用于辅助元素的交换。如果一开始就定义了temp则表示temp在数组元素寻找期间也有效这从语义上说是不对的虽然一开始就定义对结果不会产生任何影响但应不断地询问自己——这句代码能不能不要这句代码的意义是什么不过由于作用域的关系而可能产生性能影响这在《C从零开始十》中说明。 下篇将举例说明如何已知算法而写出C代码帮助读者做到程序员的最基本的要求——给得出算法拿得出代码。 C从零开始八 ——C样例一 前篇说明了函数的部分实现方式但并没有说明函数这个语法的语义即函数有什么用及为什么被使用。对于此本篇及后续会零散提到一些在《C从零开始十二》中再较详细地说明。本文只是就程序员的基本要求——拿得出算法给得出代码——给出一些样例以说明如何从算法编写出C代码并说明多个基础且重要的编程概念即独立于编程语言而存在的概念。 由算法得出代码 本系列一开头就说明了何谓程序并说明由于CPU的世界和人们存在的客观物理世界的不兼容而导致根本不能将人编写的程序也就是算法翻译成CPU指令但为了能够翻译就必须让人觉得CPU世界中的某些东西是人以为的算法所描述的某些东西。如电脑屏幕上显示的图片通过显示器对不同象素显示不同颜色而让人以为那是一幅图片而电脑只知道那是一系列数字每个数字代表了一个象素的颜色值而已。 为了实现上面的“让人觉得是”得到算法后要做的的第一步就是找出算法中要操作的资源。前面已经说过任何程序都是描述如何操作资源的而C语言本身只能操作内存的值这一种资源因此编程要做的第一步就是将算法中操作的东西映射成内存的值。由于内存单元的值以及内存单元地址的连续性都可以通过二进制数表示出来因此要做的第一步就是把算法中操作的东西用数字表示出来。 上面做的第一步就相当于数学建模——用数学语言将问题表述出来而这里只不过是用数字把被操作的资源表述出来罢了应注意数字和数的区别数字在C中是一种操作符其有相关的类型由于最后对它进行计算得到的还是二进制数故使用数字进行表示而不是二进制数以增强语义。接着第二步就是将算法中对资源的所有操作都映射成语句或函数。 用数学语言对算法进行表述时比如将每10分钟到车站等车的人的数量映射为一随机变量也就前述的第一步。随后定此随机变量服从泊松分布也就是上面的第二步。到站等车的人的数量是被操作的资源而给出的算法是每隔10分种改变这个资源将它的值变成按给定参数的泊松函数分布的一随机值。 在C中前面已经将资源映射成了数字接着就要将对资源的操作映射成对数字的操作。C中能操作数字的就只有操作符也就是将算法中对资源的所有操作都映射成表达式语句。 当上面都完成了则算法中剩下的就只有执行顺序了而执行顺序在C中就是从上朝下书写而当需要逻辑判断的介入而改变执行顺序时就使用前面的if和goto语句不过后者也可以通过if后接的语句来实现这样可以减少goto语句的使用因为goto的语义是跳转而不是“所以就”并可考虑是否能够使用循环语句以简化代码。即第三步为将执行流程用语句表示出来。 而前面第二步之所以还说可映射成函数即可能某个操作比较复杂还带有逻辑的意味不能直接找到对应的操作符这时就只好利用万能的函数操作符对这个操作重复刚才上面的三个步骤以将此操作映射成多条语句通过if等语句将逻辑信息表现出来而将这些语句定义为一函数供函数操作符使用以表示那个操作。 上面如果未明不要紧后面有两个例子都将分别说明各自是如何进行上述步骤的。 排序 给出三张卡片上面随便写了三个整数。有三个盒子分别标号为1、2和3。将三张卡片随机放到1、2、3这三个盒子中现在要求排序以使得1、2、3三个盒子中装的整数是由小到大的顺序。 给出一最简单的算法称1、2、3盒子中放的卡片上的整数分别为第一、二、三个数则先将第一个数和第二个数比较如果前者大则两个盒子内的卡片交换再将第一个和第三个比较如果前者大则交换这样就保证第一个数是最小的。然后将第二个数和第三个数比较如果前者大则交换至此排序完成。 第一步算法中操作的资源是装在盒子中的卡片为了将此卡片映射成数字就注意算法中的卡片和卡片之前有什么不同。算法中区分不同卡片的唯一方法就是卡片上写的整数因此在这里就使用一个long类型的数字来表示一个卡片。 算法中有三张卡片故用三个数字来表示。前面已经说过数字是装在内存中的不是变量中的变量只不过是映射地址而已。在这里需要三个long类型数字可以借用定义变量时编译器自动在栈上分配的内存来记录这些数字故可以如此定义三个变量long a1, a2, a3;来记录三个数字也就相当于装三张卡片的三个盒子。 第二步算法中的操作就是对卡片上的整数的比较和交换。前者很简单使用逻辑操作符就可以实现因为正好将卡片上的整数映射成变量a1、a2和a3中记录的数字。后者是交换两个盒子中的卡片可以先将一卡片从一盒子中取出来放在桌子上或其他地方。然后将另一盒子中的卡片取出来放在刚才空出来的盒子。最后将先取出来的卡片放进刚空出来的盒子。前面说的“桌子上或其他地方”是用来存放取出的卡片C中只有内存能够存放数字因此上面就必须再分配一临时内存来临时记录取出的数字。 第三步操作和资源都已经映射好了算法中有如果的就用if替换由什么重复多少次的就用for替换有什么重复直到怎样的就用while或do while替换如上照着算法映射过来就完了如下 void main() { long a1 34, a2 23, a3 12; if( a1 a2 ) { long temp a1; a1 a2; a2 temp; } if( a1 a3 ) { long temp a1; a1 a3; a3 temp; } if( a2 a3 ) { long temp a2; a2 a3; a3 temp; } } 上面就在每个if后面的复合语句中定义了一个临时变量temp以借助编译器的静态分配内存功能来提供临时存放卡片的内存。上面的元素交换并没有按照前面所说映射成函数是因为在这里其只有三条语句且容易理解。如果要将交换操作定义为一函数则应如下 void Swap( long *p1, long *p2 ) void Swap( long r1, long r2 ) { { long temp *p1;long temp r1; *p1 *p2; r1 r2; *p2 temp; r2 temp; } } void main() void main() { { long a1 34, a2 23, a3 12; long a1 34, a2 23, a3 12; if( a1 a2 ) if( a1 a2 ) Swap( a1, a2 ); Swap( a1, a2 ); if( a1 a3 ) if( a1 a3 ) Swap( a1, a3 ); Swap( a1, a3 ); if( a2 a3 ) if( a2 a3 ) Swap( a2, a3 ); Swap( a2, a3 ); } } 先看左侧的程序。上面定义了函数来表示给定盒子之间的交换操作注意参数类型使用了long*这里指针表示引用应注意指针不仅可以表示引用还可有其它的语义以后会提到。 什么是引用注意这里不是指C提出的那个引用变量引用表示一个连接关系。比如你有手机则手机号码就是“和你通话”的引用即只要有你的手机号码就能够实现“和你通话”。 再比如Windows操作系统提供的快捷方式其就是一个“对某文件执行操作”的引用它可以指向某个文件通过双击此快捷方式的图标就能够对其所指的文件进行“执行”操作可能是用某软件打开这个文件或是直接执行此文件等但如果删除此快捷方式却并不会删除其所指向的文件因为它只是“对某文件执行操作”的引用。 人的名字就是对“某人进行标识”的引用即说某人考上大学通过说那个人的名字则大家就可以知道具体是哪个人。同样变量也是引用它是某块内存的引用因为其映射了地址而内存块可以通过地址来被唯一表明其存在不仅仅是标识。注意其和前面的名字不同因为任何对内存块的操作只要知道内存块的首地址就可以了而要和某人面对面讲话或吃饭只知道他的名字是不够的。 应注意对某个东西的引用可以不止一个如人就可以有多个名字变量也都有引用变量手机号码也可以不止一个。 注意上面引入了函数来表示交换进而导致了盒子也就成了资源因此必须将盒子映射成数字。而前面又将盒子里装的卡片映射成了long类型的数字由于“装”这个操作因此可以想到使用能够标识装某个代表卡片的数字的内存块来作为盒子映射的数字类型也就是内存块的首地址也就是long*类型注意不是地址类型因为地址类型的数字并不返回记录它的内存的地址。所以上面的函数参数类型为long*。 下面看右侧的程序。参数类型变成long和指针一样依旧表示引用但注意它们的不同。后者表示它是一个别名即它是一个映射映射的地址是记录作为参数的数字的地址也就是说它要求调用此函数时给出的作为参数的数字一定是有地址的数字。所谓的“有地址的数字”表示此数字是程序员创建的不是编译器由于临时原因而生成的临时内存的地址如Swap( a1, a2 );就要报错。之前已经说明因为a1返回的地址是编译器内部定的就程序逻辑而言其是不存在的而Swap( a1, a2 );就是正确的。Swap( 1 3, 34 );依旧要报错因为记录1 3返回的数字的内存是编译器内部分配的就程序逻辑上来说它们并没有被程序员用某块内存记录起来也就不会有内存。 一个很简单的判定规则就是调用时给的参数类型如果是地址类型的数字则可以否则不行。 还应注意上面是long类型表示所修饰的变量不分配内存也就是编译器要静态地将参数r1、r2映射的地址定下来对于Swap( a1, a2 );就分别是a1和a2的地址但对于Swap( a2, a3 );就变成a2和a3的地址了这样是无法一次就将r1、r2映射的地址定下来即r1、r2映射的地址在程序运行时是变化的也就不能且无法编译时静态一次确定。 为了实现上面的要求编译器实际将会在栈上分配内存然后将地址传递到函数再编写代码以使得好像动态绑定了r1、r2的地址。这实际和将参数类型定为long*是一样的效果即上面的Swap( long, long );和Swap( long*, long* );是一样的只是语法书写上不同内部是相同的连语义都相同均表示引用虽然指针不仅仅只带有引用的语义。即函数参数类型为引用类型时依旧会分配内存以传递参数的地址即等效于指针类型为参数。 商人过河问题 3个商人带着3个仆人过河过河的工具只有一艘小船只能同时载两个人过河包括划船的人。在河的任何一边只要仆人的数量超过商人的数量仆人就会联合起来将商人杀死并抢夺其财物问应如何设计过河顺序才能让所有人都安全地过到河的另一边。 给出最弱却万能的算法——枚举法。坐船过河及划船回来的可能方案为一个仆人、一个商人或两个商人、两个仆人及一个商人一个仆人。 故每次从上述的五种方案中选择一个划过河去然后检查河岸两侧的人数看是否会发生仆人杀死商人如果两边都不会则再从上述的五个方案中选择一个让人把船划回来然后再检查是否会发生仆人杀死商人如果没有就又重新从五个方案中选一个划过河如上重复直到所有人都过河了。 上面在选方案时除了保证商人不被杀死还要保证此方案运行即过河或划回来后两岸的人数布局从来都没有出现过否则就形成无限循环且必须合理即没有负数。如果有一次的方案选择失败则退回去重新选另一个方案再试。如果所有方案都失败则再退回到更上一次的方案选择。如果一直退到第一次的方案选择并且已没有可选的方案则说明上题无解。 上面的算法又提出了两个基本又重要的概念——层次及容器。下面先说明容器。 容器即装东西的东西而C中操作的东西只有数字因此容器就是装数字的东西也就是内存。容器就平常的理解是能装多个东西即能装多个数字。这很简单使用之前的数组的概念就行了。但如果一个盒子能装很多苹果那它一定占很大的体积即不管装了一个苹果还是两个苹果那盒子都要占半立方米的体积。数组就好像盒子不管装一个元素还是两个元素它都是long[10]的类型而要占40个字节。 容器是用来装东西的那么要取出容器中装的东西就必须有种手段标识容器中装的东西对于数组这个东西就是数组的下标如long a[10]; a[3];就取出了第四个元素的值。由于有了标识则还要有一种手段以表示哪些标识是有效的如上面的a数组只前面两个元素记录了数字但是却a[3];得到的将是错误的值因为只有a[0]和a[1]是有意义的。 因此上面的用数组作容器有很多的问题但它非常简单并能体现各元素之间的顺序关系如元素被排序后的数组。但为了适应复杂算法必须还要其他容器的支持如链表、树、队列等。它们一般也被称做集合都是用于管理多个元素用的并各自给出了如何从众多的元素中快速找到给定标识所对应的元素而且都能在各元素间形成一种关系如后面将要提到的层次关系、前面数组的顺序关系等。关于那些容器的具体实现方式请参考其他资料在此不表。 上面算法中提到“两岸的人数布局从来都没有出现过”为了实现这点就需要将其中的资源——人数布局映射为数字并且还要将曾经出现过的所有人数布局全部记录下来也就是用一个容器记录下来由于还未说明结构等概念故在此使用数组来实现这个容器。上面还提到从已有的方案中选择一个则可选的方案也是一个容器同上依旧使用一数组来实现。 层次即关系如希望小学的三年2班的XXX、中国的四川的成都的XXX等都表现出一种层次关系这种层次关系是多个元素之间的关系因此就可以通过找一个容器那个容器的各元素间已经是层次关系则这个容器就代表了一种层次关系。树这种容器就是专门对此而设计的。 上面算法中提到的“再退回到更上一次的方案选择”也就是说第一次过河选择了一个商人一个仆人的方案接着选择了一个商人回来的方案此时如果选择两个仆人过河的方案将是错误的则将重新选择过河的方案。再假设此时所有过河的方案都失败了则只有再向后退以重新选择回来的方案如选择一个仆人回来。对于此由于这里只要求退回到上一次的状态也就是人数布局及选择的方案则可以将这些统一放在容器中而它们各自都只依靠顺序关系即第二次过河的方案一定在第一次过河的方案成功的前提下才可能考虑因此使用数组这个带有顺序关系的容器即可。 第一步上面算法的资源有两个坐船的方案和两岸的人数布局。坐船的方案最多五种在此使用一个char类型的数字来映射它即此8位二进制数的前4位用补码格式来解释得到的数字代表仆人的数量后4位则代表商人的数量。因此一个商人和一个仆人就是( 1 4 ) | 1。两岸的人数布局即两岸的商人数和仆人数由于总共才336个人这都可以使用char类型的数字就能映射但只能映射一个人数而两岸的人数实际共有4个左右两岸的商人数和仆人数则这里使用一个char[4]来实现实际最好是使用结构来映射而不是char[4]下篇说明。如char a[4];表示一人数布局则a[0]表示河岸左侧的商人数a[1]表示左侧的仆人数a[2]表示河岸右侧的商人数a[3]表示右侧的仆人数。 注意前面说的容器在此为了装可选的坐船方案故应有一容器使用数组如char sln[5];。在此还需要记录已用的坐船方案由于数组的元素具备顺序关系所以不用再生成一容器直接使用一char数字记录一下标当此数字为3时表示sln[0]、sln[1]和sln[2]都已经用过且都失败了当前可用的为sln[3]和sln[4]。同样为了装已成功的坐船方案作用后的人数布局及当时所选的方案就需要两个容器在此使用数组实际应该链表char oldLayout[200][4], cur[200];。oldLayout就是记录已成功的方案的容器其大小为200表示假定在200次内一定就已经得出结果了否则就会因为超出数组上限而可能发生内存访问违规而为什么是可能在《C从零开始十五》中说明。 前面说过数组这种容器无法确定里面的有效元素必须依靠外界来确定对此使用一unsigned char curSln;来记录oldLayout和cur中的有效元素的个数。规定当curSln为3时表示oldLayout[0][03]、oldLayout[1][03]和oldLayout[2][03]都有效同样cur[0]、cur[1]和cur[2]都有效而之后的如cur[3]等都无效。 第二步操作有执行过河方案、执行回来方案、检查方案是否成功、退回到上一次方案选择、是否所有人都过河、判断人数布局是否相同。如下 前两个操作将当前的左岸人数减去相应的方案定的人数而右岸则加上人数。要表现当前左岸人数可以用oldLayout[ curSln ][0]和oldLayout[ curSln ][1]表示而相应方案的人数则为( sln[ cur[ curSln ] ] 0xF0 ) 4和sln[ cur[ curSln ] ] 0xF。由于这两个操作非常类似只是一个是加则另一个就是减故将其定义为函数则为了在函数中能操作oldLayout、curSln等变量就需要将这些变量定义为全局变量。 检查是否成功即看是否 oldLayout[ curSln ][1] oldLayout[ curSln ][0] oldLayout[ curSln ][0]以及是否 oldLayout[ curSln ][3] oldLayout[ curSln ][2] oldLayout[ curSln ][2] 并且保证各自不为负数以及没有和原来的方案冲突。检查是否和原有方案相同就是枚举所有原由方案以和当前方案比较由于比较复杂在此将其定义为函数通过返回bool类型来表示是否冲突。 退回上一次方案或到下一个方案的选择只用curSln--或curSln即可。而是否所有人都过河则只用oldLayout[ curSln ][01]都为0而oldLayout[ curSln ][23]都为3。而判断人数布局是否相同则只用相应各元素是否相等即可。 第三步下面剩下的就没什么东西了只需要按照算法说的顺序将刚才的各操作拼凑起来并注意“重复直到所有人都过河了”转成do while即可。如下 #include // 分别表示一个商人、一个仆人、两个商人、两个仆人、一个商人一个仆人 char sln[5] { ( 1 4 ), 1, ( 2 4 ), 2, ( 1 4 ) | 1 }; unsigned char curSln 1; char oldLayout[200][4], cur[200]; void DoSolution( char b ) { unsigned long oldSln curSln - 1; // 临时变量出于效率 oldLayout[ curSln ][0] oldLayout[ oldSln ][0] - b * ( ( sln[ cur[ curSln ] ] 0xF0 ) 4 ); oldLayout[ curSln ][1] oldLayout[ oldSln ][1] - b * ( sln[ cur[ curSln ] ] 0xF ); oldLayout[ curSln ][2] oldLayout[ oldSln ][2] b * ( ( sln[ cur[ curSln ] ] 0xF0 ) 4 ); oldLayout[ curSln ][3] oldLayout[ oldSln ][3] b * ( sln[ cur[ curSln ] ] 0xF ); } bool BeRepeated( char b ) { for( unsigned long i 0; i curSln; i ) if( oldLayout[ curSln ][0] oldLayout[ i ][0] // 这里虽然4个数字比较是否相等 oldLayout[ curSln ][1] oldLayout[ i ][1] // 但总共才4个字节长实际可以 oldLayout[ curSln ][2] oldLayout[ i ][2] // 通过一次4字节长数字比较替换 oldLayout[ curSln ][3] oldLayout[ i ][3] // 四次1字节长数字比较来优化 ( ( i 1 ) ? 1 : -1 ) b ) // 保证过河后的方案之间比较回来后的方案之间比较 // i1等效于i%2i7等效于i%8i63等效于i%64 return true; return false; } void main() { char b 1; oldLayout[0][0] oldLayout[0][1] 3; cur[0] oldLayout[0][2] oldLayout[0][3] 0; for( unsigned char i 0; i 200; i ) // 初始化每次选择方案时的初始化方案为sln[0] cur[ i ] 0; // 由于cur是全局变量在VC中其已经被赋值为0 // 原因涉及到数据节在此不表 do { DoSolution( b ); if( ( oldLayout[ curSln ][1] oldLayout[ curSln ][0] oldLayout[ curSln ][0] ) || ( oldLayout[ curSln ][3] oldLayout[ curSln ][2] oldLayout[ curSln ][2] ) || oldLayout[ curSln ][0] 0 || oldLayout[ curSln ][1] 0 || oldLayout[ curSln ][2] 0 || oldLayout[ curSln ][3] 0 || BeRepeated( b ) ) { // 重新选择本次的方案 P: cur[ curSln ]; if( cur[ curSln ] 4 ) { b -b; cur[ curSln ] 0; curSln--; if( !curSln ) break; // 此题无解 goto P; // 重新检查以保证cur[ curSln ]的有效性 } continue; } b -b; curSln; } while( !( oldLayout[ curSln - 1 ][0] 0 oldLayout[ curSln - 1 ][1] 0 oldLayout[ curSln - 1 ][2] 3 oldLayout[ curSln - 1 ][3] 3 ) ); for( i 0; i curSln; i ) printf( %d %d\t %d %d\n, oldLayout[ i ][0], oldLayout[ i ][1], oldLayout[ i ][2], oldLayout[ i ][3] ); } 上面数组sln[5]的初始化方式下篇介绍。上面的预编译指令#include将在《C从零开始十》中说明这里可以不用管它。上面使用的函数printf的用法请参考其它资料这里它只是将变量的值输出在屏幕上而已。 前面说此法是枚举法其基本上属于万能方法依靠CPU的计算能力来实现一般情况下程序员第一时间就会想到这样的算法。它的缺点就是效率极其低下大量的CPU资源都浪费在无谓的计算上因此也是产生瓶颈的大多数原因。由于它的万能编程时很容易将思维陷在其中如求和1到100一般就写成如下 for( unsigned long i 1, s 0; i 100; i ) s i; 但更应该注意到还可unsigned long s ( 1 100 ) * 100 / 2;不要被枚举的万能占据了头脑。 上面的人数布局映射成一结构是最好的映射成char[4]所表现的语义不够强代码可读性较差。下篇说明结构并展示类型的意义——如何解释内存的值。 发表于 2004年07月14日 2:55 PM 评论 # 回复C从零开始八——C样例一 2004-07-23 1:10 AM ぐ落葉ζ繽紛 此两个例子看后有诸多问题也许是前面的基础还不够牢固。第一个排序只有一个问题就是我把指针类型跟地址类型弄含糊了。忘你能在此细讲一下他们的语义和区别。 第二个商人过河其算法和你列出的步骤都能理解但转化能代码就有点问题。哎连你写出来我都看不懂有点悲哀主要是1由于你定义的变量比较多2表达式的实际操作有点问题我准备学完了再来看这个例子。 对于象我这样的情况你有没有更好的方法和建议。菜鸟先谢过了~~~*_^ # 回复C从零开始八——C样例一 2004-07-23 11:45 AM lop5712 抱歉其实本系列一直基于这样的一个思想来写的——用尽量少的概念定义解释尽量多的表面现象。本系列提出了以下几个概念——数字、类型、类型修饰符、映射元素、操作符、语句、语句修饰符和类型定义符。在后续文章中由于要使用这些概念来解释C可以写出的各种语句我朋友对于我的解释认为过于抽象根本不适合初学者看。 我提出地址类型的数字完全只是为了从语法上解释C的语句在语法上要保证其严密性。一般的理解为要标识某内存块就应该给出它的首地址而指针的意思就是装地址的内存块。即指针类型的变量里面装的数字应该被编译器理解为地址是用于标识某块内存的。而编译器如何表现出它已经将某个数字理解为地址了就通过使用取内容操作符“*”来体现如 long a, *pA a; *pA 19; 上面的一般解释是因为pA的内容是一个地址因此取内容操作符就将pA给出的地址所标识的内存得到即a对应的内存。这样的解释是有逻辑漏洞的不过它要较我在文中通过类型匹配来解释更易理解。 指针类型表明相应变量里装的是一个地址编译器认为是个地址而地址能够标识内存块所以称指针具有引用的语义通过记录某个内存块的地址来实现引用。因为只要给出某个指针类型的变量就可以通过对它使用取内容操作符来得到它装的地址所标识的内存。 实际根本没有地址类型这样的东西即无法在C代码上表现出地址类型指针类型就可以使用指针类型修饰符而我提出它就是想从语法上去掉上面语句的常规解释而带来的逻辑漏洞。因此也不用非要理解它。如果你真的要理解它我只有建议你再看下《四》了我觉得那里已经将地址解释得很清楚了。 正如我上面所说我是用另外一套概念而不是什么数组、指针、函数、结构、类等常规概念来解释C的目的是要用尽量少的概念解释它的所有常规概念即认为它的常规概念之间有共性我在此将其抽象出来而已也因此本系列显得较抽象。 我认为应该先看一两本C的书以有感性认识然后如果还有兴趣可以看本系列。本系列后面的《十》《十一》《十二》都比较抽象我朋友认为并不适合初学者看在此表示抱歉。 C从零开始九 ——何谓结构 前篇已经说明编程时拿到算法后该干的第一件事就是把资源映射成数字而前面也说过“类型就是人为制订的如何解释内存中的二进制数的协议”也就是说一个数字对应着一块内存可能4字节也可能20字节而这个数字的类型则是附加信息以告诉编译器当发现有对那块内存的操作语句即某种操作符时要如何编写机器指令以实现那个操作。比如两个char类型的数字进行加法操作符操作编译器编译出来的机器指令就和两个long类型的数字进行加法操作的不一样也就是所谓的“如何解释内存中的二进制数的协议”。由于解释协议的不同导致每个类型必须有一个唯一的标识符以示区别这正好可以提供强烈的语义。 typedef 提供语义就是要尽可能地在代码上体现出这句或这段代码在人类世界中的意义比如前篇定义的过河方案使用一char类型来表示然后定义了一数组char sln[5]以期从变量名上体现出这是方案。但很明显看代码的人不一定就能看出sln是solution的缩写并进而了解这个变量的意义。但更重要的是这里有点本末倒置就好像这个东西是红苹果然后知道这个东西是苹果但它也可能是玩具、CD或其它即需要体现的语义是应该由类型来体现的而不是变量名。即char无法体现需要的语义。 对此C提供了很有意义的一个语句——类型定义语句。其格式为typedef 源类型名 标识符;。其中的源类型名表示已存在的类型名称如char、unsigned long等。而标识符就是程序员随便起的一个名字符合标识符规则用以体现语义。对于上面的过河方案则可以如下 typedef char Solution; Solution sln[5]; 上面其实是给类型char起了一个别名Solution然后使用Solution来定义sln以更好地体现语义来增加代码的可读性。而前篇将两岸的人数分布映射成char[4]为了增强语义则可以如下 typedef char PersonLayout[4]; PersonLayout oldLayout[200]; 注意上面是typedef char PersonLayout[4];而不是typedef char[4] PersonLayout;因为数组修饰符“[]”是接在被定义或被声明的标识符的后面的而指针修饰符“*”是接在前面的所以可以typedef char *ABC[4];但不能typedef char [4]ABC*;因为类型修饰符在定义或声明语句中是有固定位置的。 上面就比char oldLayout[200][4];有更好的语义体现不过由于为了体现语义而将类型名或变量名增长是否会降低编程速度如果编多了将会发现编程的大量时间不是花在敲代码上而是调试上。因此不要忌讳书写长的变量名或类型名比如在Win32的Security SDK中就提供了下面的一个函数名 BOOL ConvertSecurityDescriptorToStringSecurityDescriptor(…); 很明显此函数用于将安全描述符这种类型转换成文字形式以方便人们查看安全描述符中的信息。 应注意typedef不仅仅只是给类型起了个别名还创建了一个原类型。当书写char* a, b;时a的类型为char*b为char而不是想象的char*。因为“*”在这里是类型修饰符其是独立于声明或定义的标识符的否则对于char a[4], b;难道说b是char[4]那严重不符合人们的习惯。上面的char就被称作原类型。为了让char*为原类型则可以typedef char *PCHAR; PCHAR a, b, *c[4];。其中的a和b都是char*而c是char**[4]所以这样也就没有问题char **pA a;。 结构 再次考虑前篇为什么要将人数布局映射成char[4]因为一个人数可以用一个char就表示而人数布局有四个人数所以使用char[4]。即使用char[4]是希望只定义一个变量就代表了一个人数分布编译器就一次性在栈上分配4个字节的空间并且每个字节都各自代表一个人数。所以为了表现河岸左侧的商人数就必须写a[0]而左侧的仆人数就必须a[1]。坏处很明显从a[0]无法看出它表示的是左岸的商人数即这个映射意义左岸的商人数映射为内存块中第一个字节的内容以补码格式解释无法从代码上体现出来降低了代码的可读性。 上面其实是对内存布局的需要即内存块中的各字节二进制数如何解释。为此C提出了类型定义符“{}”。它就是一对大括号专用在定义或声明语句中以定义出一种类型称作自定义类型。即C原始缺省提供的类型不能满足要求时可自定义内存布局。其格式为类型关键字 名字 { 声明语句 …}。类型关键字只有三个struct、class和union。而所谓的结构就是在类型关键字为struct时用类型定义符定义的原类型它的类型名为名字其表示后面大括号中写的多条声明语句所定义的变量之间是串行关系后面说明如下 struct ABC { long a, *b; double c[2], d; } a, *b a; 上面是一个变量定义语句对于a表示要求编译器在栈上分配一块448*2832字节长的连续内存块然后将首地址和a绑定其类型为结构型的自定义类型简称结构ABC。对于b要求编译器分配一块4字节长的内存块将首地址和b绑定其类型为结构ABC的指针。 上面定义变量a和b时在定义语句中通过书写类型定义符“{}”定义了结构ABC则以后就可以如下使用类型名ABC来定义变量而无需每次都那样即 ABC c a, d[2]; 现在来具体看清上面的意思。首先前面语句定义了6个映射元素其中a和b分别映射着两个内存地址。而大括号中的四个变量声明也生成了四个变量各自的名字分别为ABC::a、ABC::b、ABC::c、ABC::d各自映射的是0、4、8和24各自的类型分别为long ABC::、long* ABC::、double (ABC::) [2]、double ABC::表示是偏移。其中的ABC::表示一种层次关系表示“ABC的”即ABC::a表示结构ABC中定义的变量a。应注意由于C是强类型语言它将ABC::也定义为类型修饰符进而导致出现long* ABC::这样的类型表示它所修饰的标识符是自定义类型ABC的成员称作偏移类型而这种类型的数字不能被单独使用后面说明。由于这里出现的类型不是函数故其映射的不是内存的地址而是一偏移值下篇说明。与之前不同了类型为偏移类型的即如上的类型数字是不能计算的因为偏移是一相对概念没有给出基准是无法产生任何意义的即不能ABC::a; ABC::c[1];。其中后者更是严重的错误因为数组操作符“[]”要求前面接的是数组或指针类型而这里的ABC::c是double的数组类型的结构ABC中的偏移并不是数组类型。 注意上面的偏移0、4、8、24正好等同于a、b、c、d顺次安放在内存中所形成的偏移这也正是struct这个关键字的修饰作用也就是前面所谓的各定义的变量之间是串行关系。 为什么要给偏移制订映射即为什么将a映射成偏移0字节b映射成偏移4字节因为可以给偏移添加语义。前面的“左岸的商人数映射为内存块中第一个字节的内容以补码格式解释”其实就是给定内存块的首地址偏移0字节。而现在给出一个标识符和其绑定则可以将这个标识符起名为LeftTrader来表现其语义。 由于上面定义的变量都是偏移类型根本没有分配内存以和它们建立映射它们也就很正常地不能是引用类型即struct AB{ long a, b; };将是错误的。还应注意上面的类型double (ABC::)[2]类型修饰符“ABC::”被用括号括起来因为按照从左到右来解读类型操作符的规则“ABC::”实际应该最后被解读但其必须放在标识符的左边就和指针修饰符“*”一样所以必须使用括号将其括住以表示其最后才起修饰作用。故也就有double (*ABCD::)[2]、double (**ABCD::)[2]各如下定义 struct ABCD { double ( *pD )[2]; double ( **ppD )[2]; }; 但应注意“ABCD::”并不能直接使用即double ( *ABCD:: pD )[2];是错误的要定义偏移类型的变量必须通过类型定义符“{}”来自定义类型。还应注意C也允许这样的类型double ( *ABCD::* )[2]其被称作成员指针即类型为double ( *ABCD:: )[2]的指针也就是可以如下 double ( **ABCD::*pPPD )[2] ABC::ppD, ( **ABCD::**ppPPD )[2] pPPD; 上面很奇怪回想什么叫指针类型。只有地址类型的数字才能有指针类型表示不计算那个地址类型的数字而直接返回其二进制表示也就是地址。对于变量地址就是它映射的数字而指针就表示直接返回其映射的数字因此ABCD::ppD返回的数字其实就是偏移值也就是4。 为了应用上面的偏移类型C给出了一对操作符——成员操作符“.”和“-”。前者两边接数字左边接自定义类型的地址类型的数字而右边接相应自定义类型的偏移类型的数字返回偏移类型中给出的类型的地址类型的数字比如a.ABC::d;。左边的a的类型是ABC右边的ABC::d的类型是double ABC::则a.ABC::d返回的数字是double的地址类型的数字因此可以这样a.ABC::d 10.0;。假设a对应的地址是3000则a.ABC::d返回的地址为3000243024类型为double这也就是为什么ABC::d被叫做偏移类型。由于“.”左边接的结构类型应和右边的结构类型相同因此上面的ABC::可以省略即a.d 10.0;。而对于“-”和“.”一样只不过左边接的数字是指针类型罢了即b-c[1] 10.0;。应注意b-c[1]实际是( b-c )[1]而不是b-( c[1] )因为后者是对偏移类型运用“[]”是错误的。 还应注意由于右边接偏移类型的数字所以可以如下 double ( ABC::*pA )[2] ABC::c, ( ABC::**ppA )[2] pA; ( b-**ppA )[1] 10.0; ( a.*pA )[0] 1.0; 上面之所以要加括号是因为数组操作符“[]”的优先级较“*”高但为什么不是b-( **ppA )[1]而是( b-**ppA )[1]前者是错误的。应注意括号操作符“()”并不是改变计算优先级而是它也作为一个操作符其优先级被定得很高罢了而它的计算就是计算括号内的数字。之前也说明了偏移类型是不能计算的即ABC::c;将错误而刚才的前者由于“()”的加入而导致要求计算偏移类型的数字故编译器将报错。 还应该注意成员指针是偏移类型的指针即装的是偏移则可以程序运行时期得到偏移而前面通过ABC::a这种形式得到的是编译时期由编译器帮忙映射的偏移只能实现静态的偏移而利用成员指针则可以实现动态的偏移。不过其实只需将成员定义成数组或指针类型照样可以实现动态偏移不过就和前篇没有使用结构照样映射了人数布局一样欠缺语义而代码可读性较低。成员指针的提出通过变量名就可以表现出丰富的语义以增强代码的可读性。现在可以将最开始说的人数布局定义如下 struct PersonLayout{ char LeftTrader, LeftServitor, RightTrader, RightServitor; }; PersonLayout oldLayout[200], b; 因此为了表示b这个人数分布中的左侧商人数只需b.LeftTrader;右侧的仆人数只需b.RightServitor;。因为PersonLayout::LeftTrader记录了偏移值和偏移后应以什么样的类型来解释内存故上面就可以实现原来的b[0]和b[3]。很明显前者的可读性远远地高于后者因为前者通过变量名b和PersonLayout::LeftTrader和成员操作符“.”表现了大量的语义——b的左边的商人数。 注意PersonLayout::LeftTrader被称作结构PersonLayout的成员变量而前面的ABC::d则是ABC的成员变量这种叫法说明结构定义了一种层次关系也才有所谓的成员操作符。既然有成员变量那也有成员函数这在下篇介绍。 前篇在映射过河方案时将其映射为char其中的前4位表示仆人数后4位表示商人数。对于这种使用长度小于1个字节的用法C专门提供了一种语法以支持这种情况如下 struct Solution { ServitorCount : 4; unsigned TraderCount : 4; } sln[5]; 由于是基于二进制数的位Bit来进行操作只准使用两种类型来表示数字原码解释数字或补码解释数字。对于上面ServitorCount就是补码解释而TraderCount就是原码解释各自的长度都为4位而此时Solution::ServitorCount中依旧记录的是偏移不过不再以字节为单位而是位为单位。并且由于其没有类型故也就没有成员指针了。即前篇的( sln[ cur[ curSln ] ] 0xF0 ) 4等效于sln[ cur[ curSln] ].TraderCount而sln[ cur[ curSln ] ] 0xF0等效于sln[ cur[ curSln] ].ServitorCount较之前具有了更好的可读性。 应该注意由于struct AB { long a, b; };也是一条语句并且是一条声明语句因为不生成代码但就其意义上来看更通常的叫法把它称为定义语句表示是类型定义语句但按照不生成代码的规则来判断其依旧是声明语句并进而可以放在类型定义符“{}”中即 struct ABC{ struct DB { long a, *b[2]; }; long c; DB a; }; 上面的结构DB就定义在结构ABC的声明语句中则上面就定义了四个变量类型均为偏移类型变量名依次为ABC::DB::a、ABC::DB::b、ABC::c、ABC::a类型依次为long ABC::DB::、long* (ABC::DB::)[2]、long ABC::、ABC::DB映射的数值依次为0、4、0、4。这里称结构DB嵌套在结构ABC中其体现出一种层次关系实际中这经常被使用以表现特定的语义。欲用结构DB定义一个变量则ABC::DB a;。同样也就有long* ( ABC::DB::*pB )[2] ABC::DB::b; ABC c; c.a.a 10; *( c.a.b[0] ) 20;。应注意ABC::DB::表示“ABC的DB的”而不是“DB的ABC的”因为这里是重复的类型修饰符是从右到左进行修饰的。 前面在定义结构时都指明了一个类型名如前面的ABC、ABCD等但应该注意类型名不是必须的即可以struct { long a; double b; } a; a.a 10; a.b 34.32;。这里就定义了一个变量其类型是一结构类型不过这个结构类型没有标识符和其关联以至于无法对其运用类型匹配等比较如下 struct { long a; double b; } a, b a, *c a; struct { long a; double b; } *d a; 上面的a、b、c都没有问题因为使用同一个类型来定义的即使这个类型没有标识符和其映射但d将会报错即使后写的结构的定义式和前面的一模一样但仍然不是同一个只是长得像罢了。那这有什么用后面说明。 最后还应该注意当在复合语句中书写前面的声明语句以定义结构时之前所说的变量作用域也同样适用即在某复合语句中定义的结构出了这个复合语句它就被删除等于没定义。如下 void ABC() { struct AB { long a, b; }; AB d; d.b 10; } void main() { { struct AB{ long a, b, e; }; AB c; c.e 23; } AB a; // 将报错说AB未定义但其他没有任何问题 } 初始化 初始化就是之前在定义变量的同时就给在栈上分配的内存赋值如long a 10;。当定义的变量的类型有表示多个元素时如数组类型、上面的结构类型时就需要给出多个数字。对此C专门给出了一种语法使用一对大括号将欲赋的值括起来后整体作为一个数字赋给数组或结构如下 struct ABC { long a, b; float c, d[3]; }; ABC a { 1, 2, 43.4f, { 213.0f, 3.4f, 12.4f } }; 上面就给出了为变量a初始化的语法大括号将各元素括起来而各元素之间用“,”隔开。应注意ABC::d是数组类型其对应的初始化用的数字也必须用大括号括起来因此出现上面的嵌套大括号。现在应该了解到“{}”只是用来构造一个具有多个元素的数字而已因此也可以有long a { 34 };这里“{}”就等同于没有。还应注意C同意给出的大括号中的数字个数少于相应自定义类型或数组的元素个数即ABC a { 1, 2, 34 }, b { 23, { 34 }, 65, { 23, 43 } }, c { 1, 2, { 3, { 4, 5, 6 } } }; 上面的a.d[0]、a.d[1]、a.d[2]都为0而只有b.d[2]才为0但c将会报错因为嵌套的第一个大括号将{ 4, 5, 6 }也括了起来表示c.c将被一个具有两个元素的数字赋值但c.c的类型是float只对应一个元素编译器将说初始化项目过多。而之前的a和b未赋值的元素都将被赋值为0但应注意并不是数值上的0而是简单地将未赋值的内存的值用0填充再通过那些补码原码之类的格式解释成数值后恰好为0而已并不是赋值0这个数字。 应注意C同意这样的语法long a[] { 34, 34, 23 };。这里在定义a时并没有给出元素个数而是由编译器检查赋值用的大括号包的元素个数由其来决定数组的个数因此上面的a的类型为long[3]。当多维数组时如long a[3][2] { { 1, 2 }, { 3, 4 }, { 5, 6 } };。因为每个元素又是需要多个元素的数字就和前面的ABC::d一样。再回想类型修饰符的修饰顺序是从左到右但当是重复类型修饰符时就倒过来从右到左因此上面就应该是三个long[2]而不是两个long[3]因此这样将错误long a[3][2] { { 1, 2, 3 }, { 4, 5, 6 } };。 还应注意C不止提供了上面的“{}”这一种初始化方式对于字符串其专门提供如char a[] ABC;。这里a的类型就为char[4]因为字符串ABC需要占4个字节的内存空间。除了这两种初始化方式外C还提供了一种函数式的初始化函数下篇介绍。 类型的运用 char a -34; unsigned char b ( unsigned char )a; 上面的b等于222将-34按照补码格式写成二进制数11011110然后将这个二进制数用原码格式解释得数值222。继续 float a 5.6f; unsigned long b ( unsigned long )a; 这回b等于5。为什么不是应该将5.6按照IEEE的real*4的格式写成二进制数0X40B33333这里用十六进制表示然后将这个二进制数用原码格式解释而得数值1085485875吗因为类型转换是语义上的类型转换而不是类型变换。 两个类型是否能够转换要视编译器是否定义了这两个类型之间的转换规则。如char和unsigned char之所以前面那样转换是因为编译器把char转unsigned char定义成了那样同样float转unsigned long被编译器定义成了取整而不是四舍五入。 为什么要有类型转换有什么意义的确像上面那样的转换毫无意义仅仅只是为了满足语法的严密性而已不过由于C定义了指针类型的转换而且定义得非常地好以至于有非常重要的意义。 char a -34; unsigned char b *( unsigned char* )( a ); 上面的结果和之前的一样b为222不过是通过将char*转成unsigned char*然后再用unsigned char来解释对应的内存而得到222而不是按照编译器的规定来转换的即使结果一样。因此 float a 5.6f; unsigned long b *( unsigned long* )( a ); 上面的b为1085485875也就是之前以为的结果。这里将a的地址所对应的内存用unsigned long定义的规则来解释得到的结果放在b中这体现了类型就是如何解释内存中的内容。上面之所以能实现是因为C规定所有的指针类型之间的转换数字的数值没有变化只有类型变化但由于类的继承关系也是可能会改变下篇说明因此上面才说b的值是用unsigned long来解释a对应的内存的内容所得的结果。因此前篇在比较oldLayout[ curSln ][03]和oldLayout[ i ][03]时写了四个“”以比较了四次char的数字由于这四个char数字是连续存放的因此也可如下只比较一次long数字即可将节约多余的三次比较时间。 *( long* )oldLayout[ curSln ] *( long* )oldLayout[ i ] 上面只是一种优化手段而已对于语义还是没有多大意义不过由于有了自定义类型因此 struct AB { long a1; long a2; }; struct ABC { char a, b; short c; long d; }; AB a { 53213, 32542 }; ABC *pA ( ABC* )a; char aa pA-a, bb pA-b, cc pA-c; long dd pA-d; pA-a 1; pA-b 2; pA-c 3; pA-d 4; long aa1 a.a1, aa2 a.a2; 上面执行后aa、bb、cc、dd的值依次为-35、-49、0、32542而aa1和aa2的值分别为197121和4。相信只要稍微想下就应该能理解为什么没有修改a.a1和a.a2结果它们的值却变了因为变量只不过是个映射而已而前面就是利用指针pA以结构ABC来解释并操作a所对应的内存的内容。 因此利用自定义类型和指针转换就可以实现以什么样的规则来看待某块内存的内容。有什么用传递给某函数一块内存的引用利用指针类型或引用类型此函数还另有一个参数比如是long类型。当此long类型的参数为1时表示传过去的是一张定单为2时表示传过去的是一张发货单为3时表示是一张收款单。如果再配上下面说明的枚举类型则可以编写出语义非常完善的代码。 应注意由于指针是可以随便转换的也就有如下的代码实际并没什么意义在这只为加深对成员指针的理解 long AB::*p ( long AB::* )( ABC::b ); a.a1 a.a2 0; a.*p 0XAB1234CD; 上面执行后a.a1为305450240,a.a2为171转成十六进制分别为0X1234CD00和0X000000AB。 枚举 上面欲说明1时为定单2时为发货单而3时为收款单则可以利用switch或if语句来进行判断但是语句从代码上将看见类似type 1或type 2之类无法表现出语义。C专门为此提供了枚举类型。 枚举类型的格式和前面的自定义类型很像但意义完全不同如下 enum AB { LEFT, RIGHT 2, UP 4, DOWN 3 }; AB a LEFT; switch( a ) { case LEFT:; // 做与左相应的事 case UP:;// 做与上相应的事 } 枚举也要用“{}”括住一些标识符不过这些标识符即不映射内存地址也不映射偏移而是映射整数而为什么是整数那是因为没有映射浮点数的必要后面说明。上面的RIGHT就等同于2注意是等同于2相当于给2起了个名字因此可以long b LEFT; double c UP; char d RIGHT;。但注意上面的变量a它的类型为AB即枚举类型其解释规则等同于int即编译成在16位操作系统上运行时长度为2个字节编译成在32位操作系统上运行时为4个字节但和int是属于不同的类型而前面的赋值操作之所以能没有问题可以认为编译器会将枚举类型隐式转换成int类型进而上面没有错误。但倒过来就不行了因为变量a的类型是AB则它的值必须是上面列出的四个标识符中的一个而a b;则由于b为long类型如果为10那么将无法映射上面的四个标识符中的一个所以不行。 注意上面的LEFT没有写“”此时将会从其前面的一个标识符的值自增一由于它是第一个而C规定为0故LEFT的值为0。还应注意上面映射的数字可以重复即 enum AB { LEFT, RIGHT, UP 5, DOWN, TOP 5, BOTTOM }; 上面的各标识符依次映射的数值为0、1、5、6、5、6。因此最开始说的问题就可以如下处理 enum OperationType { ORDER 1, INVOICE, CHECKOUT }; 而那个参数的类型就可以为OperationType这样所表现的语义就远远地超出原来的代码可读性高了许多。因此当将某些人类世界的概念映射成数字时发现它们的区别不表现在数字上比如吃饭、睡觉、玩表示一个人的状态现在为了映射人这个概念为数字也需要将人的状态这个概念映射成数字但很明显地没有什么方便的映射规则。这时就强行说1代表吃饭2代表睡觉3代表玩此时就可以通过将1、2、3定义成枚举以表现语义这也就是为什么枚举只定义为整数因为没有定义成浮点数的必要性。 联合 前面说过类型定义符的前面可以接struct、class和union当接union时就表示是联合型自定义类型简称联合它和struct的区别就是后者是串行分布来定义成员变量而前者是并行分布。如下 union AB { long a1, a2, a3; float b1, b2, b3; }; AB a; 变量a的长度为4个字节而不是想象的6*424个字节而联合AB中定义的6个变量映射的偏移都为0。因此a.a1 10;执行后a.a1、a.a2、a.a3的值都为10而a.b1的值为多少就用IEEE的real*4格式来解释相应内存的内容该多少是多少。 也就是说最开始的利用指针来解释不同内存的内容现在可以利用联合就完成了因此上面的代码搬到下面变为 union AB { struct { long a1; long a2; }; struct { char a, b; short c; long d; }; }; AB a { 53213, 32542 }; char aa a.a, bb a.b, cc a.c; long dd a.d; a.a 1; a.b 2; a.c 3; a.d 4; long aa1 a.a1, aa2 a.a2; 结果不变但代码要简单只用定义一个自定义类型了而且没有指针变量的运用代码的语义变得明显多了。 注意上面定义联合AB时在其中又定义了两个结构但都没有赋名字这是C的特殊用法。当在类型定义符的中间使用类型定义符时如果没有给类型定义符定义的类型绑定标识符则依旧定义那些偏移类型的变量不过这些变量就变成上层自定义类型的成员变量因此这时“{}”等同于没有唯一的意义就是通过前面的struct或class或union来指明变量的分布方式。因此可以如下 struct AB { struct { long a1, a2; }; char a, b; union { float b1; double b2; struct { long b3; float b4; char b5; }; }; short c; }; 上面的自定义类型AB的成员变量就有a1、a2、a、b、b1、b2、b3、b4、b5、c各自对应的偏移值依次为0、4、8、9、10、10、10、14、18、19类型AB的总长度为21字节。某类型的长度表示如果用这个类型定义了一个变量则编译器应该在栈上分配多大的连续空间C为此专门提供了一个操作符sizeof其右侧接数字或类型名当接数字时就返回那个数字的类型需要占的内存空间的大小而接类型名时就返回那个类型名所标识的类型需要占的内存空间的大小。 因此long a sizeof( AB ); AB d; long b sizeof d;执行后a和b的值都为40。怎么是40不应该为21吗而之前的各成员变量对应的偏移也依次实际为0、4、8、9、16、16、16、20、24、32。为什么这就是所谓的数据对齐。 CPU有某些指令需要处理多个数据则各数据间的间隔必须是4字节或8字节或16字节视不同的指令而有不同的间隔这被称作数据对齐。当各个数据间的间隔不符合要求时CPU就必须做附加的工作以对齐数据效率将下降。并且CPU并不直接从内存中读取东西而要经一个高速缓冲CPU内建的一个存取速度比内存更快的硬件缓冲一下而此缓冲的大小肯定是2的次方但又比较小因此自定义类型的大小最好能是2的次方的倍数以便高效率的利用高速缓冲。 在自定义类型时一个成员变量的偏移值一定是它所属的类型的长度的倍数即上面的a和b的偏移必须是1的倍数而c的偏移必须是2的倍数b1的偏移必须是4的倍数。但b2的偏移必须是8的倍数而b1和b2由于前面的union而导致是并行布局因此b1的偏移必须和b2及b3的相同因此上面的b1、b2、b3的偏移变成了8的倍数16而不是想象的10。 而一个自定义类型的长度必须是其成员变量中长度最长的那个成员变量的长度的倍数因此struct { long b3; float b4; char b5; };的长度是4的倍数也就是12。而上面的无名联合的成员变量中只有double b2;的长度最长为8个字节所以它的长度为16并进而导致c的偏移为b1的偏移加16故为32。由于结构AB中的成员变量只有b2的长度最长为8故AB的长度必须是8的倍数40。因此在定义结构时应尽量将成员和其长度对应起来如下 struct ABC1 { char a, b; char d; long c; }; struct ABC2 { char a, b; long c; char d; }; ABC1的长度为8个字节而ABC2的长度为12个字节其中ABC1::c和ABC2::c映射的偏移都为4。 应注意上面说的规则一般都可以通过编译选项进行一定的改变不同的编译器将给出不同的修改方式在此不表。 本篇说明了如何使用类型定义符“{}”来定义自定义类型说明了两种自定义类型实际还有许多自定义类型的内容未说明将在下篇介绍即后面介绍的类及类相关的内容都可应用在联合和结构上因为它们都是自定义类型。 发表于 2004年07月17日 2:32 PM 评论 # 回复C从零开始九——何谓结构 2004-07-23 12:53 PM ぐ落葉ζ繽紛 现在我发现自己存在一个严重的问题就是理论上完全理解但是用到实际就存在太多的问题。特别是指针和引用的应用 为什么*Pa要加而数组*pa[10];就可以不加变量名跟数组名同是映射地址或首地址。这里的表示的是什么意思是引用 也许你会觉得我的问题太简单很幼稚。但我希望你的抽空为我这种初学者讲解可否利用你的休息时间专门写一篇关于指针的实际应用 # 回复C从零开始九——何谓结构 2004-07-23 2:52 PM lop5712 你是在哪里看到*p a;的如果是下面的定义语句 int a, *p a; 这里的“*”由于是在定义语句中是指针类型修饰符表示p的类型是int*。而如果不在定义语句中对p赋值则应该p a;而不是*p a; 对于*p a[10];这里是个表达式语句即整个语句最后将返回一个数字则这里的“*”就是操作符而不是指针类型修饰符。 后者只在说明类型时如定义语句、类型转换、类型定义语句等前者只在说明数字时如表达式语句、任何接数字的地方等。 由于这里的“*”是取内容操作符我在《五》中已经说明它的操作很简单它右侧只接指针类型的数字将这个指针类型的数字换成地址类型的数字然后返回。而变量就是映射的一个地址类型的数字比如 int a, *p; // 假设a映射的地址是4000p映射的地址是4004注意不管那个变量的类型是什么它一定要映射一个地址 注意a 10;是一个表达式语句因为整个语句都由操作符和数字组成变量也算数字因为变量名映射的是一个地址对于赋值操作符“”其两边接数字左侧接的数字是4000类型为int类型的地址类型右侧接的数字是10类型为int类型则意思就是将10放到4000所对应的内存中去。 接着看*p a[10];“*”取内容操作符右侧接指针类型的数字对于此其右侧接的数字是4004类型是int的指针类型也就是int*然后“*”返回数字4004类型是int的地址类型接着就符合了“”的要求左边是地址类型的数字了将“”右边的数字放到4004所标识的内存中。 同样“”也是有两个引用类型修饰符和取地址操作符分别用于说明类型和说明数字。如何区别就和上面说的一样比如 int a, *p a, ra1 a, ra2 *p; 这里的“”就是取地址操作符而不是类型修饰符因为这里是给变量p赋初值“”右侧接的是数字。而ra1和ra2前面的“”就是引用类型修饰符因为它在定义语句中。而ra2后的“*”就由于是在“”的右边要求是数字因此是取内容操作符而不是引用类型修饰符 至于《指针的运用》我实际是干机械的过几天就工作了到时候就没硬件也没环境所以也就不会写了。打算写到《十三》如果还有时间就写《指针的运用》说明各种类型的指针各自有什么用. C从零开始十 ——何谓类 前篇说明了结构只不过是定义了内存布局而已提到类型定义符前还可以书写class即类型的自定义类型简称类它和结构根本没有区别仅有一点小小的区别下篇说明而之所以还要提供一个class实际是由于C是从C扩展而成其中的class是C自己提出的一个很重要的概念只是为了与C语言兼容而保留了struct这个关键字。不过通过前面括号中所说的小小区别也足以看出C的设计者为结构和类定义的不同语义下篇说明。 暂时可以先认为类较结构的长足进步就是多了成员函数这个概念虽然结构也可以有成员函数在了解成员函数之前先来看一种语义需求。 操作与资源 程序主要是由操作和被操作的资源组成操作的执行者就是CPU这很正常但有时候的确存在一些需要需要表现是某个资源操作了另一个资源暂时称作操作者比如游戏中经常出现的就是要映射怪物攻击了玩家。之所以需要操作者一般是因为这个操作也需要修改操作者或利用操作者记录的一些信息来完成操作比如怪物的攻击力来决定玩家被攻击后的状态。这种语义就表现为操作者具有某些功能。为了实现上面的语义如原来所说进行映射先映射怪物和玩家分别为结构如下 struct Monster { float Life; float Attack; float Defend; }; struct Player { float Life; float Attack; float Defend; }; 上面的攻击操作就可以映射为void MonsterAttackPlayer( Monster mon, Player pla );。注意这里期望通过函数名来表现操作者但和前篇说的将过河方案起名为sln一样属于一种本末倒置因为这个语义应该由类型来表现而不是函数名。为此C提供了成员函数的概念。 成员函数 与之前一样在类型定义符中书写函数的声明语句将定义出成员函数如下 struct ABC { long a; void AB( long ); }; 上面就定义了一个映射元素——第一个变量ABC::a类型为long ABC::;以及声明了一个映射元素——第二个函数ABC::AB类型为void ( ABC:: )( long )。类型修饰符ABC::在此修饰了函数ABC::AB表示其为函数类型的偏移类型即是一相对值。但由于是函数意义和变量不同即其依旧映射的是内存中的地址代码的地址但由于是偏移类型也就是相对的即是不完整的因此不能对它应用函数操作符如ABC::AB( 10 );。这里将错误因为ABC::AB是相对的其相对的东西不是如成员变量那样是个内存地址而是一个结构指针类型的参数参数名一定为this这是强行定义的后面说明。 注意由于其名字为ABC::AB而上面仅仅是对其进行了声明要定义它仍和之前的函数定义一样如下 void ABC::AB( long d ) { this-a d; } 应注意上面函数的名字为ABC::AB但和前篇说的成员变量一样不能直接书写long ABC::a;也就不能直接如上书写函数的定义语句至少函数名为ABC::AB就不符合标识符规则而必须要通过类型定义符“{}”先定义自定义类型然后再书写这会在后面说明声明时详细阐述。 注意上面使用了this这个关键字其类型为ABC*由编译器自动生成即上面的函数定义实际等同于void ABC::AB( ABC *this, long d ) { this-a d; }。而之所以要省略this参数的声明而由编译器来代劳是为了在代码上体现出前面提到的语义即成员的意义这也是为什么称ABC::AB是函数类型的偏移类型它是相对于这个this参数而言的如何相对如下 ABC a, b, c; a.ABC::AB( 10 ); b.ABC::AB( 12 ); c.AB( 14 ); 上面利用成员操作符调用ABC::AB注意执行后a.a、b.a和c.a的值分别为10、12和14即三次调用ABC::AB但通过成员操作符而导致三次的this参数的值并不相同并进而得以修改三个ABC变量的成员变量a。注意上面书写a.ABC::AB( 10 );和成员变量一样由于左右类型必须对应因此也可a.AB( 10 );。还应注意上面在定义ABC::AB时在函数体内书写this-a d;同上由于类型必须对应的关系即this必须是相应自定义类型的指针所以也可省略this-的书写进而有void ABC::AB( long d ) { a d; }。 注意这里成员操作符的作用其不再如成员变量时返回相应成员变量类型的数字而是返回一函数类型的数字但不同的就是这个函数类型是无法用语法表示出来的即C并没有提供任何关键字或类型修饰符来表现这个返回的类型VC内部提供了__thiscall这个类型修饰符进行表示不过写代码时依旧不能使用只是编译器内部使用。也就是说当成员操作符右侧接的是函数类型的偏移类型的数字时返回一个函数类型的数字表示其可被施以函数操作符函数的类型为偏移类型中给出的类型但这个类型无法表现。即a.AB将返回一个数字这个数字是函数类型在VC内部其类型为void ( __thiscall ABC:: )( long )但这个类型在C中是非法的。 C并没有提供类似__thiscall这样的关键字以修饰类型因为这个类型是要求编译器遇到函数操作符和成员操作符时如a.AB( 10 );要将成员操作符左侧的地址作为函数调用的第一个参数传进去然后再传函数操作符中给出的其余各参数。即这个类型是针对同时出现函数操作符和成员操作符这一特定情况给编译器提供一些信息以生成正确的代码而不用于修饰数字修饰数字就要求能应付所有情况。即类型是用于修饰数字的而这个类型不能修饰数字因此C并未提供类似__thiscall的关键字。 和之前一样由于ABC::AB映射的是一个地址而不是一个偏移值因此可以ABC::AB;但不能ABC::a;因为后者是偏移值。根据类型匹配很容易就知道也可有 void ( ABC::*p )( long ) ABC::AB;或void ( ABC::*p )( long ) ABC::AB; 进而就有void ( ABC::**pP )( long ) p; ( c.**pP )( 10.0f );。之所以加括号是因为函数操作符的优先级较“*”高。再回想前篇说过指针类型的转换只是类型变化数值不变下篇说明数值变化的情况因此可以有如下代码这段代码毫无意义在此仅为加深对成员函数的理解。 struct ABC { long a; void AB( long ); }; void ABC::AB( long d ) { this-a d; } struct AB { short a, b; void ABCD( short tem1, short tem2 ); void ABC( long tem ); }; void AB::ABCD( short tem1, short tem2 ) { a tem1; b tem2; } void AB::ABC( long tem ) { a short( tem / 10 ); b short( tem - tem / 10 ); } void main() { ABC a, b, c; AB d; ( c.*( void ( ABC::* )( long ) )AB::ABC )( 43 ); ( b.*( void ( ABC::* )( long ) )AB::ABCD )( 0XABCDEF12 ); ( d.*( void ( AB::* )( short, short ) )ABC::AB )( 0XABCD, 0XEF12 ); } 上面执行后c.a为0X00270004b.a为0X0000EF12d.a为0XABCDd.b为0XFFFF。对于c的函数调用由于AB::ABC映射的地址被直接转换类型进而直接被使用因此程序将跳到AB::ABC处的a short( tem / 10 );开始执行而参数tem映射的是传递参数的内存的首地址并进而用long类型解释而得到tem为43然后执行。注意b short( tem - tem / 10 );实际是this-b short( tem - tem / 10 );而this的值为c对应的地址但在这里被认为是AB*类型因为在函数AB::ABC的函数体内所以才能this-b正常ABC结构中没有b这个成员变量而b的偏移为2所以上句执行完后将结果39存放到c的地址加2所对应的内存并且以short类型解释而得到的16位的二进制数存放。对于a short( tem / 10 );也做同样事情故最后得c.a的值为0X0027004十进制39转成十六进制为0X27。 同样对于b的调用程序将跳到AB::ABCD但生成的b的调用代码时将参数0XABCDEF12按照参数类型为long的格式记录在传递参数的内存中然后跳到AB::ABCD。但编译AB::ABCD时又按照参数为两个short类型来映射参数tem1和tem2对应的地址因此容易想到tem1的值将为0XEF12tem2的值为0XABCD但实际并非如此。参数如何传递由之前说的函数调用规则决定函数调用的具体实现细节在《C从零开始十五》中说明这里只需了解到成员函数映射的仍然是地址而它的类型决定了如何使用它后面说明。 声明的含义 前面已经解释过声明是什么意思在此由于成员函数的定义规则这种新的定义语法必须重新考虑声明的意思。注意一点前面将一个函数的定义放到main函数定义的前面就可以不用再声明那个函数了同样如果定义了某个变量就不用再声明那个变量了。这也就是说定义语句具有声明的功能但上面成员函数的定义语句却不具有声明的功能下面来了解声明的真正意思。 声明是要求编译器产生映射元素的语句。所谓的映射元素就是前面介绍过的变量及函数都只有3栏或3个字段类型栏、名字栏和地址栏成员变量类型的这一栏就放偏移值。即编译器每当看到声明语句就生成一个映射元素并且将对应的地址栏空着然后留下一些信息以告诉连接器——此.obj文件编译器编译源文件后生成的文件对于VC是.obj文件需要一些符号将这些符号找到后再修改并完善此.obj文件最后连接。 回想之前说过的符号的意思它就是一字符串用于编译器和连接器之间的通信。注意符号没有类型因为连接器只是负责查找符号并完善因为有些映射元素的地址栏还是空的中间文件对于VC就是.obj文件不进行语法分析也就没有什么类型。 定义是要求编译器填充前面声明没有书写的地址栏。也就是说某变量对应的地址只有在其定义时才知道。因此实际的在栈上分配内存等工作都是由变量的定义完成的所以才有声明的变量并不分配内存。但应注意一个重点定义是生成映射元素需要的地址因此定义也就说明了它生成的是哪个映射元素的地址而如果此时编译器的映射表即之前说的编译器内部用于记录映射元素的变量表、函数表等中没有那个映射元素即还没有相应元素的声明出现过那么编译器将报错。 但前面只写一个变量或函数定义语句它照样正常并没有报错啊实际很简单只需要将声明和定义看成是一种语句只不过是向编译器提供的信息不同罢了。如void ABC( float );和void ABC( float ){}编译器对它们相同看待。前者给出了函数的类型及类型名因此编译器就只填写映射元素中的名字和类型两栏。由于其后只接了个“;”没有给出此函数映射的代码因此编译器无法填写地址栏。而后者给出了函数名、所属类型以及映射的代码空的复合语句因此编译器得到了所有要填写的信息进而将三栏的信息都填上了结果就表现出定义语句完成了声明的功能。 对于变量如long a;。同上这里给出了类型和名字因此编译器填写了类型和名字两栏。但变量对应的是栈上的某块内存的首地址这个首地址无法从代码上表现出来前面函数就通过在函数声明的后面写复合语句来表现相应函数对应的代码所在的地址而必须由编译器内部通过计算获得因此才硬性规定上面那样的书写算作变量的定义而要变量的声明就需要在前面加extern。即上面那样将导致编译器进行内部计算进而得出相应的地址而填写了映射元素的所有信息。 上面难免显得故弄玄虚那都是因为自定义类型的出现。考虑成员变量的定义如 struct ABC { long a, b; double c; }; 上面给出了类型——long ABC::、long ABC::和double ABC::给出了名字——ABC::a、ABC::b和ABC::c给出了地址即偏移——0、4和8因为是结构型自定义类型故由此语句就可以得出各成员变量的偏移。上面得出三个信息即可以填写映射元素的所有信息所以上面可以算作定义语句。对于成员函数如下 struct ABC { void AB( float ); }; 上面给出了类型——void ( ABC:: )( float )给出了名字——ABC::AB。不过由于没有给出地址因此无法填写映射元素的所有信息故上面是成员函数ABC::AB的声明。按照前面说法只要给出地址就可以了而无需去管它是定义还是声明因此也就可以这样 struct ABC { void AB( float ){} }; 上面给出类型和名字的同时给出了地址因此将可以完全填写映射元素的所有信息是定义。上面的用法有其特殊性后面说明。注意如果这时再在后面写ABC::AB的定义语句即如下将错误 struct ABC { void AB( float ){} }; void ABC::AB( float ) {} 上面将报错原因很简单因为后者只是定义它只提供了ABC::AB对应的地址这一个信息但映射元素中的地址栏已经填写了故编译器将说重复定义。再单独看成员函数的定义它给出了类型void ( ABC:: )( float )给出了名字ABC::AB也给出了地址但为什么说它只给出了地址这一信息首先名字ABC::AB是不符合标识符规则的而类型修饰符ABC::必须通过类型定义符“{}”才能够加上去这在前面已多次说明。因此上面给出的信息是给出了一个地址这个地址是类型为void ( ABC:: )( float )名字为ABC::AB的映射元素的地址。结果编译器就查找这样的映射元素如果有则填写相应的地址栏否则报错即只写一个void ABC::AB( float ){}是错误的在其前面必须先通过类型定义符“{}”声明相应的映射元素。这也就是前面说的定义仅仅填充地址栏并不生成映射元素。 声明的作用 定义的作用很明显了有意义的映射名字对地址就是它来做但声明有什么用它只是生成类型对名字为什么非得要类型对名字它只是告诉编译器不要发出错误说变量或函数未定义任何东西都有其存在的意义先看下面这段代码。 externC long ABC( long a, long b ); void main(){ long c ABC( 10, 20 ); } 假设上面代码在a.cpp中书写编译生成文件a.obj没有问题。但按照之前的说明连接时将错误因为找不到符号_ABC。因为名字_ABC对应的地址栏还空着。接着在VC中为a.cpp所在工程添加一个新的源文件b.cpp如下书写代码。 externC float ABC( float a ){ return a; } 编译并连接现在没任何问题了但相信你已经看出问题了——函数ABC的声明和定义的类型不匹配却连接成功了 注意上面关于连接的说明连接时没有类型只管符号。上面用externC使得a.obj要求_ABC的符号而b.cpp提供_ABC的符号剩余的就只是连接器将b.obj中_ABC对应的地址放到a.obj以完善a.obj最后连接a.obj和b.obj。 那么上面什么结果由于需要考虑函数的实现细节这在《C从零开始十五》中再说明而这里只要注意到一件事编译器即使没有地址也依旧可以生成代码以实现函数操作符的功能——函数调用。之所以能这样就是因为声明时一定必须同时给出类型和名字因为类型告诉编译器当某个操作符涉及到某个映射元素时如何生成代码来实现这个操作符的功能。也就是说两个char类型的数字乘法和两个long类型的数字乘法编译生成的代码不同对long ABC( long );的函数调用代码和void ABC( float )的不同。即操作符作用的数字类型的不同将导致编译器生成的代码不同。 那么上面为什么要将ABC的定义放到b.cpp中因为各源文件之间的编译是独立的如果放在a.cpp编译器就会发现已经有这么个映射元素但类型却不匹配将报错。而放到b.cpp中使得由连接器来完善a.obj到时将没有类型的存在只管符号。下面继续。 struct ABC { long a, b; void AB( long tem1, long tem2 ); void ABCD(); }; void main(){ ABC a; a.AB( 10, 20 ); } 由上面的说法这里虽然没有给出ABC::AB的定义但仍能编译成功没有任何问题。仍假设上面代码在a.cpp中然后添加b.cpp在其中书写下面的代码。 struct ABC { float b, a; void AB( long tem1, long tem2 ); long ABCD( float ); }; void ABC::AB( long tem1, long tem2 ){ a tem1; b tem2; } 这里定义了函数ABC::AB注意如之前所说由于这里的函数定义仅仅只是定义所以必须在其前面书写类型定义符“{}”以让编译器生成映射元素。但更应该注意这里将成员变量的位置换了这样b就映射的是0而a映射的是4了并且还将a、b的类型换成了float更和a.cpp中的定义大相径庭。但没有任何问题编译连接成功a.AB( 10,20 );执行后a.a为0X41A00000a.b为0X41200000而*( float* )a.a为20*( flaot* )a.b为10。 为什么因为编译器只在当前编译的那个源文件中遵循类型匹配而编译另一个源文件时编译其他源文件所生成的映射元素全部无效。因此声明将类型和名字绑定起来而名字就代表了其所关联的类型的地址类型的数字而后继代码中所有操作这个数字的操作符的编译生成都将受这个数字的类型的影响。即声明是告诉编译器如何生成代码的其不仅仅只是个语法上说明变量或函数的语句它是不可或缺的。 还应注意上面两个文件中的ABC::ABCD成员函数的声明不同而且整个工程中即a.cpp和b.cpp中都没有ABC::ABCD的定义却仍能编译连接成功因为声明并不是告诉编译器已经有什么东西了而是如何生成代码。 头文件 上面已经说明如果有个自定义类型ABC在a.cpp、b.cpp和c.cpp中都要使用它则必须在a.cpp、b.cpp和c.cpp中各自使用ABC之前用类型定义符“{}”重新定义一遍这个自定义类型。如果不小心如上面那样在a.cpp和b.cpp中写的定义不一样则将产生很难查找的错误。为此C提供了一个预编译指令来帮忙。 预编译指令就是在编译之前执行的指令它由预编译器来解释执行。预编译器是另一个程序一般情况编译器厂商都将其合并进了C编译器而只提供一个程序。在此说明预编译指令中的包含指令——#include其格式为#include 文件名。应注意预编译指令都必须单独占一行而文件名就是一个用双引号或尖括号括起来的文件名如#include abc.c、#include C:\abc.dsw或#include \abc.exe。它的作用很简单就是将引号或尖括号中书写的文件名对应的文件以ANSI格式或MBCS格式关于这两个格式可参考《C从零开始五》解释并将内容原封不动地替换到#include所在的位置比如下面是文件abc的内容。 struct ABC { long a, b; void AB( long tem1, long tem2 ); }; 则前面的a.cpp可改为 #include abc void main() { ABC a; a.AB( 10, 20 ); } 而b.cpp可改为 #include abc void ABC::AB( long tem1, long tem2 ){ a tem1; b tem2; } 这时就不会出现类似上面那样在b.cpp中将自定义类型ABC的定义写错了而导致错误的结果a.a为0X41A00000a.b为0X41200000进而a.AB( 10, 20 );执行后a.a为10a.b为20。 注意这里使用的是双引号来括住文件名的它表示当括住的只是一个文件名或相对路径而没有给出全路径时如上面的abc则先搜索此时被编译的源文件所在的目录然后搜索编译器自定的包含目录如C:\Program Files\Microsoft Visual Studio .NET 2003\Vc7\include等里面一般都放着编译器自带的SDK的头文件关于SDK将在《C从零开始十八》中说明如果仍没有找到则报错注意一般编译器都提供了一些选项以使得除了上述的目录外还可以再搜索指定的目录不同的编译器设定方式不同在此不表。 如果是用尖括号括起来则表示先搜索编译器自定的包含目录再源文件所在目录。为什么要不同只是为了防止自己起的文件名正好和编译器的包含目录下的文件重名而发生冲突因为一旦找到文件将不再搜索后继目录。 所以一般的C代码中如果要用到某个自定义类型都将那个自定义类型的定义分别装在两个文件中对于上面结构ABC则应该生成两个文件分别为ABC.h和ABC.cpp其中的ABC.h被称作头文件而ABC.cpp则称作源文件。头文件里放的是声明而源文件中放的是定义则ABC.h的内容就和前面的abc一样而ABC.cpp的内容就和b.cpp一样。然后每当工程中某个源文件里要使用结构ABC时就在那个源文件的开头包含ABC.h这样就相当于将结构ABC的所有相关声明都带进了那个文件的编译比如前面的a.cpp就通过在开头包含abc以声明了结构ABC。 为什么还要生成一个ABC.cpp如果将ABC::AB的定义语句也放到ABC.h中则a.cpp要使用ABCc.cpp也要使用ABC所以a.cpp包含ABC.h由于里面的ABC::AB的定义生成一个符号?ABABCQAEXJJZ对于VC同样c.cpp的编译也要生成这个符号然后连接时由于出现两个相同的符号连接器无法确定使用哪一个报错。因此专门定义一个ABC.cpp将函数ABC::AB的定义放到ABC.obj中这样将只有一个符号生成连接时也就不再报错。 注意上面的struct ABC { void AB( float ){} };。如果将这个放在ABC.h中由于在类型定义符中就已经将函数ABC::AB的定义给出则将会同上出现两个相同的符号然后连接失败。为了避开这个问题C规定如上在类型定义符中直接书写函数定义而定义的函数是inline函数出于篇幅下篇介绍。 成员的意义 上面从语法的角度说明了成员函数的意思如果很昏不要紧实现不能理解并不代表就不能运用而程序员重要的是对语言的运用能力而不是语言的了解程度虽然后者也很重要。下面说明成员的语义。 本文一开头提出了一种语义——某种资源具有的功能而C的自定义类型再加上成员操作符“.”和“-”的运用从代码上很容易的就表现出一种语义——从属关系。如a.b、c.d分别表示a的b和c的d。某种资源具有的功能要映射到C中就应该将这种资源映射成一自定义类型而它所具有的功能就映射成此自定义类型的成员函数如最开始提到的怪物和玩家则如下 struct Player { float Life; float Attack; float Defend; }; struct Monster { float Life; float Attack; float Defend; void AttackPlayer( Player pla ); }; Player player; Monster a; a.AttackPlayer( player ); 上面的语义就非常明显代码执行的操作是怪物a攻击玩家player而player.Life就代表玩家player的生命值。假设如下书写Monster::AttackPlayer的定义 void Monster::AttackPlayer( Player pla ) { pla.Life - Attack - pla.Defend; } 上面的语义非常明显某怪物攻击玩家的方法就是将被攻击的玩家的生命值减去自己的攻击力减被攻击的玩家的防御力的值。语义非常清晰代码的可读性好。而如原来的写法 void MonsterAttackPlayer( Monster mon, Player pla ) { pla.Life - mon.Attack - pla.Defend; } 则代码表现的语义怪物攻击玩家是个操作此操作需要操作两个资源分别为怪物类型和玩家类型。这个语义就没表现出我们本来打算表现的想法而是怪物的攻击功能的另一种解释关于这点将在《C从零开始十二》中详细阐述其更适合表现收银工作。比如收银台实现的是收钱的工作客户在柜台买了东西由营业员开出单据然后客户将单据拿到收银台交钱。这里收银台的工作就需要操作两个资源——钱和单据这时就应该将收钱这个工作映射为如上的函数而不是成员函数因为在这个算法中收银台没有被映射成自定义类型的必要性即我们对收银的工作由谁做不关心只关心它如何做。 至此介绍完了自定义类型的一半内容通过这些内容已经可以编写出能体现较复杂语义的代码了下篇将说明自定义类型的后半内容它们的提出根本可以认为就是语义的需要所以下篇将从剩余内容是如何体现语义的来说明不过依旧要说明各自是如何实现的。 C从零开始十一上篇 ——类的相关知识 前面已经介绍了自定义类型的成员变量和成员函数的概念并给出它们各自的语义本文继续说明自定义类型剩下的内容并说明各自的语义。 权限 成员函数的提供使得自定义类型的语义从资源提升到了具有功能的资源。什么叫具有功能的资源比如要把收音机映射为数字需要映射的操作有调整收音机的频率以接收不同的电台调整收音机的音量打开和关闭收音机以防止电力的损耗。为此收音机应映射为结构类似下面 struct Radiogram { double Frequency; /* 频率 */ void TurnFreq( double value ); // 改变频率 float Volume; /* 音量 */ void TurnVolume( float value ); // 改变音量 float Power; /* 电力 */ void TurnOnOff( bool bOn ); // 开关 bool bPowerOn; // 是否开启 }; 上面的Radiogram::Frequency、Radiogram::Volume和Radiogram::Power由于定义为了结构Radiogram的成员因此它们的语义分别为某收音机的频率、某收音机的音量和某收音机的电力。而其余的三个成员函数的语义也同样分别为改变某收音机的频率、改变某收音机的音量和打开或关闭某收音机的电源。注意这面的“某”表示具体是哪个收音机的还不知道只有通过成员操作符将左边的一个具体的收音机和它们结合时才知道是哪个收音机的这也是为什么它们被称作偏移类型。这一点在下一篇将详细说明。 注意问题为什么要将刚才的三个操作映射为结构Radiogram的成员函数因为收音机具有这样的功能那么对于选西瓜、切西瓜和吃西瓜难道要定义一个结构然后给它定义三个选、切、吃的成员函数不是很荒谬吗前者的三个操作是对结构的成员变量而言而后者是对结构本身而言的。那么改成吃快餐吃快餐的汉堡包、吃快餐的薯条和喝快餐的可乐。如果这里的两个吃和一个喝的操作变成了快餐的成员函数表示是快餐的功能这其实是编程思想的问题而这里其实就是所谓的面向对象编程思想它虽然是很不错的思想但并不一定是合适的下篇将详细讨论。 上面我们之所以称收音机的换台是功能是因为实际中我们自己是无法直接改变收音机的频率必须通过旋转选台的那个旋钮来改变接收的频率同样调音量也是通过调节音量旋钮来实现的而由于开机而导致的电力下降也不是我们直接导致而是间接通过收听电台而导致的。因此上面的Radiogram::Power、Radiogram::Frequency等成员变量都具有一个特殊特性——外界这台收音机以外的东西是无法改变它们的。为此C提供了一个语法来实现这种语义。在类型定义符中给出这样的格式权限:。这里的权限为public、protected和private中的一个分别称作公共的、保护的和私有的如下 class Radiogram { protected: double m_Frequency; float m_Volume; float m_Power; private: bool m_bPowerOn; public:void TurnFreq( double ); void TurnVolume( float ); void TurnOnOff( bool ); }; 可以发现它和之前的标号的定义格式相同但并不是语句修饰符即可以struct ABC{ private: };。这里不用非要在private:后面接语句因为它不是语句修饰符。从它开始直到下一个这样的语法之间所有的声明和定义而产生的成员变量或成员函数都带有了它所代表的语义。比如上面的类Radiogram其中的Radiogram::m_Frequency、Radiogram::m_Volume和Radiogram::m_Power是保护的成员变量Radiogram::m_bPowerOn是私有的成员变量而剩下的三个成员函数都是公共的成员函数。注意上面的语法是可以重复的如struct ABC { public: public: long a; private: float b; public: char d; };。 什么意思很简单公共的成员外界可以访问保护的成员外界不能访问私有的成员外界及子类不能访问。关于子类后面说明。先看公共的。对于上面如下将报错 Radiogram a; a.m_Frequency 23.0; a.m_Power 1.0f; a.m_bPowerOn true; 因为上面对a的三次操作都使用了a的保护或私有成员编译器将报错因为这两种成员外界是不能访问的。而a.TurnFreq( 10 );就没有任何问题因为成员函数Radiogram::TurnFreq是公共成员外界可以访问。那么什么叫外界对于某个自定义类型此自定义类型的成员函数的函数体内以外的一切能写代码的地方都称作外界。因此对于上面的Radiogram只有它的三个成员函数的函数体内可以访问它的成员变量。即下面的代码将没有问题。 void Radiogram::TurnFreq( double value ) { m_Frequency value; } 因为m_Frequency被使用的地方是在Radiogram::TurnFreq的函数体内不属于外界。 为什么要这样表现最开始说的语义。首先上面将成员定义成public或private对于最终生成的代码没有任何影响。然后我之前说的调节接收频率是通过调节收音机里面的共谐电容的容量来实现的这个电容的容量人必须借助元件才能做到而将接收频率映射成数字后由于是数字则CPU就能修改。如果直接a.m_Frequency 10;进行修改就代码上的意义其就为执行这个方法的人将收音机的接收频率增加10KHz这有违我们的客观世界与前面的语义不合。因此将其作为语法的一种提供由编译器来进行审查可以让我们编写出更加符合我们所生活的世界的语义的代码。 应注意可以union ABC { long a; private: short b; };。这里的ABC::a之前没有任何修饰那它是public还是protected相信从前面举的那么多例子也已经看出应该是public这也是为什么我之前一直使用struct和union来定义自定义类型否则之前的例子都将报错。而前篇说过结构和类只有一点很小的区别那就是当成员没有进行修饰时对于类那个成员将是private而不是public即如下将错误。 class ABC { long a; private: short b; }; ABC a; a.a 13; ABC::a由于前面的class而被看作private。就从这点可以看出结构用于映射资源可被直接使用的资源而类用于映射具有功能的资源。下篇将详细讨论它们在语义上的差别。 构造和析构 了解了上面所提的东西很明显就有下面的疑问 struct ABC { private: long a, b; }; ABC a { 10, 20 }; 上面的初始化赋值变量a还正确吗当然错误否则在语法上这就算一个漏洞了外界可以借此修改不能修改的成员。但有些时候的确又需要进行初始化以保证一些逻辑关系为此C提出了构造和析构的概念分别对应于初始化和扫尾工作。在了解这个之前让我们先看下什么叫实例Instance。 实例是个抽象概念表示一个客观存在其和下篇将介绍的“世界”这个概念联系紧密。比如“这是桌子”和“这个桌子”前者的“桌子”是种类后者的“桌子”是实例。这里有10只羊则称这里有10个羊的实例而羊只是一种类型。可以简单地将实例认为是客观世界的物体人类出于方便而给各种物体分了类因此给出电视机的说明并没有给出电视机的实例而拿出一台电视机就是给出了一个电视机的实例。同样程序的代码写出来了意义不大只有当它被执行时我们称那个程序的一个实例正在运行。如果在它还未执行完时又要求操作系统执行了它则对于多任务操作系统就可以称那个程序的两个实例正在被执行如同时点开两个Word文件查看则有两个Word程序的实例在运行。 在C中能被操作的只有数字一个数字就是一个实例这在下篇的说明中就可以看出更一般的称标识记录数字的内存的地址为一个实例也就是称变量为一个实例而对应的类型就是上面说的物体的种类。比如long a, *pA a, ra a;这里就生成了两个实例一个是long的实例一个是long*的实例注意由于ra是long所以并未生成实例但ra仍然是一个实例。同样对于一个自定义类型如Radiogram ab, c[3];则称生成了四个Radiogram的实例。 对于自定义类型的实例当其被生成时将调用相应的构造函数当其被销毁时将调用相应的析构函数。谁来调用编译器负责帮我们编写必要的代码以实现相应构造和析构的调用。构造函数的原型即函数名对应的类型如float AB( double, char );的原型是float( double, char )的格式为直接将自定义类型的类型名作为函数名没有返回值类型参数则随便。对于析构函数名字为相应类型名的前面加符号“~”没有返回值类型必须没有参数。如下 struct ABC { ABC(); ABC( long, long ); ~ABC(); bool Do( long ); long a, count; float *pF; }; ABC::ABC() { a 1; count 0; pF 0; } ABC::ABC( long tem1, long tem2 ) { a tem1; count tem2; pF new float[ count ]; } ABC::~ABC() { delete[] pF; } bool ABC::Do( long cou ) { float *p new float[ cou ]; if( !p ) return false; delete[] pF; pF p; count cou; return true; } extern ABC g_ABC; void main(){ ABC a, r a; a.Do( 10 ); { ABC b( 10, 30 ); } ABC *p new ABC[10]; delete[] p; } ABC g_a( 10, 34 ), g_p new ABC[5]; 上面的结构ABC就定义了两个构造函数注意是两个重载函数名字都为ABC::ABC实际将由编译器转成不同的符号以供连接之用。也定义了一个析构函数注意只能定义一个因为其必须没有参数也就无法进行重载了名字为ABC::~ABC。 再看main函数先通过ABC a;定义了一个变量因为要在栈上分配一块内存即创建了一个数字创建装数字的内存也就导致创建了数字因为内存不能不装数字进而创建了一个ABC的实例进而调用ABC的构造函数。由于这里没有给出参数后面说明因此调用了ABC::ABC()进而a.a为1a.pF和a.count都为0。接着定义了变量r但由于它是ABC所以并没有在栈上分配内存进而没有创建实例而没有调用ABC::ABC。接着调用a.Do分配了一块内存并把首地址放在a.pF中。 注意上面变量b的定义其使用了之前提到的函数式初始化方式。它通过函数调用的格式调用了ABC的构造函数ABC::ABC( long, long )以初始化ABC的实例b。因此b.a为10b.count为30b.pF为一内存块的首地址。但要注意这种初始化方式和之前提到的“{}”方式的不同前者是进行了一次函数调用来初始化而后者是编译器来初始化通过生成必要的代码。由于不调用函数所以速度要稍快些关于函数的开销在《C从零开始十五》中说明。还应注意不能ABC b { 1, 0, 0 };因为结构ABC已经定义了两个构造函数则它只能使用函数式初始化方式初始化了不能再通过“{}”方式初始化了。 上面的b在一对大括号内回想前面提过的变量的作用域因此当程序运行到ABC *p new ABC[10];时变量b已经消失了超出了其作用域即其所分配的内存语法上已经释放了实际由于是在栈上其并没有被释放进而调用ABC的析构函数将b在ABC::ABC( long, long )中分配的内存释放掉以实现扫尾功能。 对于通过new在堆上分配的内存由于是new ABC[10]因此将创建10个ABC的实例进而为每一个实例调用一次ABC::ABC()注意这里无法调用ABC::ABC( long, long )因为new操作符一次性就分配了10个实例所需要的内存空间C并没有提供语法比如使用“{}”来实现对一次性分配的10个实例进行初始化。接着调用了delete[] p;这释放刚分配的内存即销毁了10个实例因此将调用ABC的析构函数10次以进行10次扫尾工作。 注意上面声明了全局变量g_ABC由于是声明并不是定义没有分配内存因此未产生实例故不调用ABC的构造函数而g_a由于是全局变量C保证全局变量的构造函数在开始执行main函数之前就调用所有全局变量的析构函数在执行完main函数之后才调用这一点是编译器来实现的在《C从零开始十九》中将进一步讨论。因此g_a.ABC( 10, 34 )的调用是在a.ABC()之前即使它的位置在a的定义语句的后面。而全局变量g_p的初始化的数字是通过new操作符的计算得来结果将在堆上分配内存进而生成5个ABC实例而调用了ABC::ABC()5次由于是在初始化g_p的时候进行分配的因此这5次调用也在a.ABC()之前。由于g_p仅仅只是记录首地址而要释放这5个实例就必须调用delete不一定也可不调用delete依旧释放new返回的内存在《C从零开始十九》中说明但上面并没有调用因此直到程序结束都将不会调用那5个实例的析构函数那将怎样后面说明异常时再讨论所谓的内存泄露问题。 因此构造的意思就是刚分配了一块内存还未初始化则这块内存被称作原始数据Raw Data前面说过数字都必须映射成算法中的资源则就存在数字的有效性。比如映射人的年龄则这个数字就不能是负数因为没有意义。所以当得到原始数据后就应该先通过构造函数的调用以保证相应实例具有正确的意义。而析构函数就表示进行扫尾工作就像上面在某实例运作的期间即操作此实例的代码被执行的时期动态分配了一些内存则应确保其被正确释放。再或者这个实例和其他实例有关系因确保解除关系因为这个实例即将被销毁如链表的某个结点用类映射则这个结点被删除时应在其析构函数中解除它与其它结点的关系。 派生和继承 上面我们定义了类Radiogram来映射收音机如果又需要映射数字式收音机它和收音机一样即收音机具有的东西它都具有不过多了自动搜台、存储台、选台和删除台的功能。这里提出了一个类型体系即一个实例如果是数字式收音机那它一定也是收音机即是收音机的一个实例。比如苹果和梨都是水果则苹果和梨的实例一定也是水果的实例。这里提出三个类型水果、苹果和梨。其中称水果是苹果的父类父类型苹果是水果的子类子类型。同样水果也是梨的父类梨是水果的子类。这种类型体系是很有意义的因为人类就是用这种方式来认知世界的它非常符合人类的思考习惯因此C又提出了一种特殊语法来对这种语义提供支持。 在定义自定义类型时在类型名的后面接一“:”然后接public或protected或private接着再写父类的类型名最后就是类型定义符“{}”及相关书写如下 class DigitalRadiogram : public Radiogram { protected: double m_Stations[10]; public: void SearchStation();void SaveStation( unsigned long ); void SelectStation( unsigned long ); void EraseStation( unsigned long ); }; 上面就将Radiogram定义为了DigitalRadiogram的父类DigitalRadiogram定义成了Radiogram的子类被称作类Radiogram派生了类DigitalRadiogram类DigitalRadiogram继承了类Radiogram。 上面生成了5个映射元素就是上面的4个成员函数和1个成员变量但实际不止。由于是从Radiogram派生因此还将生成7个映射就是类Radiogram的7个成员但名字变化了全变成DigitalRadiogram::修饰而不是原来的Radiogram::修饰但是类型却不变化。比如其中一个映射元素的名字就为DigitalRadiogram::m_bPowerOn类型为bool Radiogram::映射的偏移值没变依旧为16。同样也有映射元素DigitalRadiogram::TurnFreq类型为void ( Radiogram:: )( double )映射的地址依旧没变为Radiogram::TurnFreq所对应的地址。因此就可以如下 void DigitalRadiogram::SaveStation( unsigned long index ) { if( index 10 ) return; m_Station[ index ] m_Frequency; m_bPowerOn true; } DigitalRadiogram a; a.TurnFreq( 10 ); a.SaveStation( 3 ); 上面虽然没有声明DigitalRadiogram::TurnFreq但依旧可以调用它因为它是从Radiogram派生来的。注意由于a.TurnFreq( 10 );没有书写全名因此实际是a.DigitalRadiogram::TurnFreq( 10 );因为成员操作符左边的数字类型是DigitalRadiogram。如果DigitalRadiogram不从Radiogram派生则不会生成上面说的7个映射结果a.TurnFreq( 10 );将错误。 注意上面的SaveStation中直接书写了m_Frequency其等同于this-m_Frequency由于this是DigitalRadiogram*因为在DigitalRadiogram::SaveStation的函数体内所以实际为this-DigitalRadiogram::m_Frequency也因此如果不是派生自Radiogram则上面将报错。并且由类型匹配很容易知道void ( Radiogram::*p )( double ) DigitalRadiogram::TurnFreq;。虽然这里是DigitalRadiogram::TurnFreq但它的类型是void ( Radiogram:: )( double )。 应注意在SaveStation中使用了m_bPowerOn这个在Radiogram中被定义成私有成员也就是说子类也没权访问而SaveStation是其子类的成员函数因此上面将报错权限不够。 上面通过派生而生成的7个映射元素各自的权限是什么先看上面的派生代码 class DigitalRadiogram : public Radiogram {…}; 这里由于使用public被称作DigitalRadiogram从Radiogram公共继承如果改成protected则称作保护继承如果是private就是私有继承。有什么区别通过公共继承而生成的映射元素指从Radiogram派生而生成的7个映射元素各自的权限属性不变化即上面的DigitalRadiogram::m_Frequency对类DigitalRadiogram来说依旧是protected而DigitalRadiogram::m_bPowerOn也依旧是private。保护继承则所有的公共成员均变成保护成员其它不变。即如果保护继承DigitalRadiogram::TurnFreq对于DigitalRadiogram来说将为protected。私有继承则将所有的父类成员均变成对于子类来说是private。因此上面如果私有继承则DigitalRadiogram::TurnFreq对于DigitalRadiogram来说是private的。 上面可以看得很简单即不管是什么继承其指定了一个权限父类中凡是高于这个权限的映射元素都要将各自的权限降低到这个权限注意是对子类来说然后再继承给子类。上面一直强调“对于子类来说”什么意思如下 struct A { long a; protected: long b; private: long c; }; struct B : protected A { void AB(); }; struct C : private B { void ABC(); }; void B::AB() { b 10; c 10; } void C::ABC() { a 10; b 10; c 10; AB(); } A a; B b; C c; a.a 10; b.a 10; b.AB(); c.AB(); 上面的B的定义等同于struct B { protected: long a, b; private: long c; public: void AB(); };。 上面的C的定义等同于struct C { private: long a, b, c; void AB(); public: void ABC(); }; 因此B::AB中的b 10;没有问题但c 10;有问题 因为编译器看出B::c是从父类继承生成的而它对于父类来说是私有成员因此子类无权访问错误。接着看C::ABCa 10;和b 10;都没问题因为它们对于B来说都是保护成员但c 10;将错误因为C::c对于父类B来说是私有成员没有权限失败。接着AB();因为C::AB对于父类B来说是公共成员没有问题。 接着是a.a 10;没问题b.a 10;错误因为B::a是B的保护成员b.AB();没有问题c.AB();错误因为C::AB是C的私有成员。应注意一点public、protected和private并不是类型修饰符只是在语法上提供了一些信息而继承所得的成员的类型都不会变化不管它保护继承还是公共继承权限起作用的地方是需要运用成员的地方与类型没有关系。什么叫运用成员的地方如下 long ( A::*p ) A::a; p A::b; void ( B::*pB )() B::AB; void ( C::*pC )() C::ABC; pC C::AB; 上面对变量p的初始化操作没有问题这里就运用了A::a。但是在p A::b;时由于运用了A::b则编译器就要检查代码所处的地方发现对于A来说属于外界因此报错权限不够。同样下面对pB的赋值没有问题但pC C::AB;就错误。而对于b.a 10;这里由于成员操作符而运用了类B的成员B::a所以在这里进行权限检查并进而发现权限不够而报错。 好那为什么要搞得这么复杂弄什么保护、私有和公共继承首先回想前面说的为什么要提供继承因为想从代码上体现类型体系说明一个实例如果是一个子类的实例则它也一定是一个父类的实例即可以按照父类的定义来操作它。虽然这也可以通过之前说的转换指针类型来实现但前者能直接从代码上表现出类型继承的语义即子类从父类派生而来而后者只能说明用不同的类型来看待同一个实例。 那为什么要给继承加上权限表示这个类不想外界或它的子类以它的父类的姿态来看待它。比如鸡可以被食用但做成标本的鸡就不能被食用。因此子类“鸡的标本”在继承时就应该保护继承父类“鸡”以表示不准外界但准许其派生类将它看作是鸡。它已经不再是鸡但它实际是由鸡转变过来的。因此私有和保护继承实际很适合表现动物的进化关系。比如人是猴子进化来的但人不是猴子。这里人就应该使用私有继承因为并不希望外界和人的子类——黑种人、黄种人、白种人等——能够把父类“人”看作是猴子。而公共继承就表示外界和子类可以将子类的实例看成父类的实例。如下 struct A { long a, b; }; struct AB : private A { long c; void ABCD(); }; struct ABB : public AB { void AAA(); }; struct AC : public A { long c; void ABCD(); }; void ABC( A *a ) { a-a 10; a-b 20; } void main() { AB b; ABC( b ); AC c; ABC( c ); } void AB::ABCD() { AB b; ABC( b ); } void AC::ABCD() { AB b; ABC( b ); } void ABB::AAA() { AB b; ABC( b ); } 上面的类AC是公共继承因此其实例c在执行ABC( c );时将由编译器进行隐式类型转换这是一个很奇特的特性本文的下篇将说明。但类AB是私有继承因此在ABC( b );时编译器不会进行隐式类型转换将报错类型不匹配。对于此只需ABC( ( A* )b );以显示进行类型转换就没问题了。 注意前面的红字私有继承表示外界和它的子类都不可以用父类的姿态来看待它因此在ABB::AAA中这是AB的子类因此这里的ABC( b );将报错。在AC::ABCD中这里对于AB来说是外界报错。在AB::ABCD中这里是自身即不是子类也不是外界所以ABC( b );将没有问题。如果将AB换成保护继承则在ABB::AAA中的ABC( b );将不再错误。 关于本文及本文下篇所讨论的语义在《C从零开始十二》中会专门提出一个概念以给出一种方案来指导如何设计类及各类的关系。由于篇幅限制本文分成了上中下三篇剩下的内容在本文的后两篇说明。 C从零开始十一中篇 ——类的相关知识 由于篇幅限制本篇为《C从零开始十一》的中篇说明多重继承、虚继承和虚函数的实现方式。 多重继承 这里有个有趣的问题如下 struct A { long a, b, c; char d; }; struct B : public A { long e, f; }; 上面的B::e和B::f映射的偏移是多少不同的编译器有不同的映射结果对于派生的实现C并没有强行规定。大多数编译器都是让B::e映射的偏移值为16即A的长度关于自定义类型的长度可参考《C从零开始九》B::f映射20。这相当于先把空间留出来排列父类的成员变量再排列自己的成员变量。但是存在这样的语义——西红柿即是蔬菜又是水果鲸鱼即是海洋生物又是脯乳动物。即一个实例既是这种类型又是那种类型对于此C提供了多重派生或称多重继承用“,”间隔各父类如下 struct A { long A_a, A_b, c; void ABC(); }; struct B { long c, B_b, B_a; void ABC(); }; struct AB : public A, public B { long ab, c; void ABCD(); }; void A::ABC() { A_a A_b 10; c 20; } void B::ABC() { B_a B_b 20; c 10; } void AB::ABCD() { A_a B_a 1; A_b B_b 2; c A::c B::c 3; } void main() { AB ab; ab.A_a 3; ab.B_b 4; ab.ABC(); } 上面的结构AB从结构A和结构B派生而来即我们可以说ab既是A的实例也是B的实例并且还是AB的实例。那么在派生AB时将生成几个映射元素照前篇的说法除了AB的类型定义符“{}”中定义的AB::ab和AB::c以外类型均为long AB::还要生成继承来的映射元素各映射元素名字的修饰换成AB::类型不变映射的值也不变。因此对于两个父类则生成8个映射元素每个类都有4个映射元素比如其中一个的名字为AB::A_b类型为long A::映射的值为4也有一个名字为AB::B_b类型为long B::映射的值依旧为4。注意A::ABC和B::ABC的名字一样因此其中两个映射元素的名字都为AB::ABC但类型则一个为void( A:: )()一个为void( B:: )()映射的地址分别为A::ABC和B::ABC。同样就有三个映射元素的名字都为AB::c类型则分别为long A::、long B::和long AB::映射的偏移值依次为8、0和28。照前面说的先排列父类的成员变量再排列子类的成员变量因此类型为long AB::的AB::c映射的值为两个父类的长度之和再加上AB::ab所带来的偏移。 注意问题上面继承生成的8个映射元素中有两对同名但不存在任何问题因为它们的类型不同而最后编译器将根据它们各自的类型而修改它们的名字以形成符号这样连接时将不会发生重定义问题但带来其他问题。ab.ABC();一定是ab.AB::ABC();的简写因为ab是AB类型的但现在由于有两个AB::ABC因此上面直接书写ab.ABC将报错因为无法知道是要哪个AB::ABC这时怎么办 回想本文上篇提到的公共、保护、私有继承其中说过公共就表示外界可以将子类的实例当作父类的实例来看待。即所有需要用到父类实例的地方如果是子类实例且它们之间是公共继承的关系则编译器将会进行隐式类型转换将子类实例转换成父类实例。因此上面的ab.A_a 3;实际是ab.AB::A_a 3;而AB::A_a的类型是long A::而成员操作符要求两边所属的类型相同左边类型为AB且AB为A的子类因此编译器将自动进行隐式类型转换将AB的实例变成A的实例然后再计算成员操作符。 注意前面说AB::A_b和AB::B_b的偏移值都为4则ab.A_b 3;岂不是等效于ab.B_b 3;即使按照上面的说法由于AB::A_b和AB::B_b的类型分别是long A::和long B::也最多只是前者转换成A的实例后者转换成B的实例AB::A_b和AB::B_b映射的偏移依旧没变啊。因此变的是成员操作符左边的数字。对于结构AB假设先排列父类A的成员变量再排列父类B的成员变量则AB::B_b映射的偏移就应该为16结构A的长度加上B::c引入的偏移但它实际映射为4因此就将成员操作符左侧的地址类型的数字加上12结构A的长度。而对于AB::A_b由于结构A的成员变量先被排列故只偏移0。假设上面ab对应的地址为3000对于ab.B_b 4;AB类型的地址类型的数字3000在“.”的左侧转成B类型的地址类型的数字3012因为偏移12然后再将“.”右侧的偏移类型的数字4加上3012最后返回类型为long的地址类型的数字3016再继续计算“”。同样也可知道ab.A_a 3;中的成员操作符最后返回long类型的地址类型的数字3000而ab.A_b将返回3004ab.ab将返回3024。 同样这样也将进行隐式类型转换long AB::*p AB::B_b;。注意AB::B_b的类型为long B::则将进行隐式类型转换。如何转换原来AB::B_b映射的偏移为4则现在将变成12416这样才能正确执行ab.*p 10;。 这时再回过来想刚才提的问题AB::ABC无法区别怎么办注意还有映射元素A::ABC和B::ABC两个AB::ABC就是由于它们两个而导致的因此可以书写ab.A::ABC();来表示调用的是映射到A::ABC的函数。这里的A::ABC的类型是void( A:: )()而ab是AB因此将隐式类型转换则上面没有任何语法问题虽然说A::ABC不是结构AB的成员但它是AB的父类的成员C允许这种情况也就是说A::ABC的名字也作为类型匹配的一部分而被使用。如假设结构C也从A派生则有C::a但就不能书写ab.C::a因为从C::a的名字可以知道它并不属于结构AB。同样ab.B::ABC();将调用B::ABC。注意上面结构A、B和AB都有一个成员变量名字为c且类型为long那么ab.c 10;是否会如前面ab.ABC();一样报错不会因为有三个AB::c其中有一个类型和ab的类型匹配其映射的偏移为28因此ab.c将会返回3028。而如果期望运用其它两个AB::c的映射则如上通过书写ab.A::c和ab.B::c来偏移ab的地址以实现。 注意由于上面的说法也就可以这样void( AB::*pABC )() B::ABC; ( ab.*pABC )();。这里的B::ABC的类型为void( B:: )()和pABC不匹配但正好B是AB的父类因此将进行隐式类型转换。如何转换因为B::ABC映射的是地址而隐式类型转换要保证在调用B::ABC之前先将this的类型变成B*因此要将其加12以从AB*转变成B*。由于需要加这个12但B::ABC又不是映射的偏移值因此pABC实际将映射两个数字一个是B::ABC对应的地址一个是偏移值12结果pABC这个指针的长度就不再如之前所说的为4个字节而变成了8个字节多出来的4个字节用于记录偏移值。 还应注意前面在AB::ABCD中直接书写的A_b、c、A::c等它们实际都应该在前面加上this-即A_b B_b 2;实际为this-A_b this-B_b 2;则同样如上this被偏移了两次以获得正确的地址。注意上面提到的隐式类型转换之所以会进行是因为继承时的权限满足要求否则将失败。即如果上面AB保护继承A而私有继承B则只有在AB的成员函数中可以如上进行转换在AB的子类的成员函数中将只能使用A的成员而不能使用B的成员因为权限受到限制。如下将失败。 struct AB : protected A, private B {…}; struct C : public AB { void ABCD(); }; void C::ABCD() { A_b 10; B_b 2; c A::c B::c 24; } 这里在C::ABCD中的B_b 2;和B::c 24;将报错因为这里是AB的子类而AB私有继承自B其子类无权将它看作B。但只是不会进行隐式类型转换罢了依旧可以通过显示类型转换来实现。而main函数中的ab.A_a 3; ab.B_b 4; ab.A::ABC();都将报错因为这是在外界发起的调用没有权限不会自动进行隐式类型转换。 注意这里C::ABCD和AB::ABCD同名按照上面所说子类的成员变量都可以和父类的成员变量同名上面AB::c和A::c及B::c同名成员函数就更没有问题。只用和前面一样按照上面所说进行类型匹配检验即可。应注意由于是函数则可以参数变化而函数名依旧相同这就成了重载函数。 虚继承 前面已经说了当生成了AB的实例它的长度实际应该为A的长度加B的长度再加上AB自己定义的成员所占有的长度。即AB的实例之所以又是A的实例又是B的实例是因为一个AB的实例它既记录了一个A的实例又记录了一个B的实例。则有这么一种情况——蔬菜和水果都是植物海洋生物和脯乳动物都是动物。即继承的两个父类又都从同一个类派生而来。假设如下 struct A { long a; }; struct B : public A { long b; }; struct C : public A { long c; }; struct D : public A, public C { long d; }; void main() { D d; d.a 10; } 上面的B的实例就包含了一个A的实例而C的实例也包含了一个A的实例。那么D的实例就包含了一个B的实例和一个C的实例则D就包含了两个A的实例。即D定义时将两个父类的映射元素继承生成两个映射元素名字都为D::a类型都为long A::映射的偏移值也正好都为0。结果main函数中的d.a 10;将报错无法确认使用哪个a。这不是很奇怪吗两个映射元素的名字、类型和映射的数字都一样编译器为什么就不知道将它们定成一个因为它们实际在D的实例中表示的偏移是不同的一个是0一个是8。同样为了消除上面的问题就书写d.B::a 1; d.C::a 2;以表示不同实例中的成员a。可是B::a和C::a的类型不都是为long A::吗但上面说过成员变量或成员函数它们自身的名字也将在类型匹配中起作用因此对于d.B::a因为左侧的类型是D则看右侧其名字表示为B正好是D的父类先隐式类型转换然后再看类型是A再次进行隐式类型转换然后返回数字。假设上面d对应的地址为3000则d.C::a先将d这个实例转换成C的实例因此将3000偏移8个字节而返回long类型的地址类型的数字3008。然后再转换成A的实例偏移0最后返回3008。 上面说明了一个问题即希望从A继承来的成员a只有一个实例而不是像上面那样有两个实例。假设动物都有个饥饿度的成员变量很明显地鲸鱼应该只需填充一个饥饿度就够了结果有两个饥饿度就显得很奇怪。对此C提出了虚继承的概念。其格式就是在继承父类时在权限语法的前面加上关键字virtual即可如下 struct A { long a, aa, aaa; void ABC(); }; struct B : virtual public A { long b; }; 这里的B就虚继承自AB::b映射的偏移为多少将不再是A的长度12而是4。而继承生成的3个映射元素还是和原来一样只是名字修饰变成B::而已映射依旧不变。那么为什么B::b是4之前的4个字节用来放什么上面等同于下面 struct B { long *p; long b; long a, aa, aaa; void ABC(); }; long BDiff[] { 0, 8 }; B::B(){ p BDiff; } 上面的B::p指向一全局数组BDiff。什么意思B的实例的开头4个字节用来记录一个地址也就相当于是一个指针变量它记录的地址所标识的内存中记录着由于虚继承而导致的偏移值。上面的BDiff[1]就表示要将B实例转成A实例就需要偏移BDiff[1]的值8而BDiff[0]就表示要将B实例转成B实例需要的偏移值0。为什么还要来个B实例转B实例后面说明。但为什么是数组因为一个类可以通过多重派生而虚继承多个类每个类需要的偏移值都会在BDiff的数组中占一个元素它被称作虚类表Virtual Class Table。 因此当书写B b; b.aaa 20; long a sizeof( b );时a的值为20因为多了一个4字节来记录上面说的指针。假设b对应的地址为3000。先将B的实例转换成A的实例本来应该偏移12而返回3012但编译器发现B是虚继承自A则通过B::p[1]得到应该的偏移值8然后返回3008接着再加上B::aaa映射的8而返回3016。同样当b.b 10;时由于B::b并不是被虚继承而来直接将3000加上B::b映射的偏移值4得3004。而对于b.ABC();将先通过B::p[1]将b转成A的实例然后调用A::ABC。 为什么要像上面那样弄得那么麻烦首先让我们来了解什么叫做虚Virtual。虚就是假象并不是真的。比如一台老式电视机有10个频道即它最多能记住10个电视台的频率。因此可以说1频道是中央1台、5频道是中央5台、7频道是四川台。这里就称频道对我们来说代表着电台频率是虚假的因为频道并不是电台频率只是记录了电台频率。当我们按5频道以换到中央5台时有可能有人已经调过电视使得5频道不再是中央5台而是另一个电视台或者根本就是一片雪花没有信号。因此虚就表示不保证其可能正确可能错误因为它一定是间接得到的其实就相当于之前说的引用。有什么好处只用记着按5频道就是中央5台当以后不想再看中央5台而换成中央2台则同样的“按5频道”却能得到不同的结果但是程序却不用再编写了只用记着“按5频道”就又能实现换到中央2台看。所以虚就是间接得到结果由于间接结果将不确定而显得更加灵活这在后面说明虚函数时就能看出来。但虚的坏处就是多了一道程序要间接获得效率更低。 由于上面的虚继承导致继承的元素都是虚的即所有对继承而来的映射元素的操作都应该间接获得相应映射元素对应的偏移值或地址但继承的映射元素对应的偏移值或地址是不变的为此红字的要求就只有通过隐式类型转换改变this的值来实现。所以上面说的B转A需要的偏移值通过一个指针B::p来间接获得以表现其是虚的。 因此开始所说的鲸鱼将会有两个饥饿度就可以让海洋生物和脯乳动物都从动物虚继承因此将间接使用脯乳动物和海洋生物的饥饿度这个成员然后在派生鲸鱼这个类时让脯乳动物和海洋生物都指向同一个动物实例因为都是间接获得动物的实例的通过虚继承来间接使用动物的成员这样当鲸鱼填充饥饿度时不管填充哪个饥饿度实际都填充同一个。而C也正好这样做了。如下 struct A { long a; }; struct B : virtual public A { long b; }; struct C : virtual public A { long c; }; struct D : public B, virtual public C { long d; }; void main() { D d; d.a 10; } 当从一个类虚继承时在排列派生类时就是决定在派生类的类型定义符“{}”中定义的各成员变量的偏移值先排列前面提到的虚类表的指针以实现间接获取偏移值再排列各父类但如果父类中又有被虚继承的父类则先将这些部分剔除。然后排列派生类自己的映射元素。最后排列刚刚被剔除的被虚继承的类此时如果发现某个被虚继承的类已经被排列过则不用再重复排列一遍那个类并且也不再为它生成相应的映射元素。 对于上面的B发现虚继承A则先排列前面说过的B::p然后排列A但发现A需要被虚继承因此剔除排列自己定义的映射元素B::b映射的偏移值为4由于B::p的占用。最后排列A而生成继承来的映射元素B::a所以B的长度为12。 对于上面的D发现要从C虚继承因此 排列D::p占4个字节。 排列父类B发现其中的A是被虚继承的剔除所以将继承映射元素B::b还有前面编译器自动生成的B::p生成D::b占4个字节编译器将B::p和D::p合并为一个后面说明虚函数时就了解了。 排列父类C发现C需要被虚继承剔除。 排列D自己定义的成员D::d其映射的偏移值就为448占4个字节。 排列A和C先排列A占4个字节生成D::a。 排列C先排列C中的A结果发现它是虚继承的并发现已经排列过A进而不再为C::a生成映射元素。接着排列C::p和C::c占8个字节生成D::c。 所以最后结构D的长度为4444824个字节并且只有一个D::a类型为long A::偏移值为0。 如果上面很昏不要紧上面只是给出一种算法以实现虚继承不同的编译器厂商会给出不同的实现方法因此上面推得的结果对某些编译器可能并不正确。不过应记住虚继承的含义——被虚继承的类的所有成员都必须被间接获得至于如何间接获得则不同的编译器有不同的处理方式。 由于需要保证间接获得所以对于long D::*pa D::a;由于是long D::*编译器发现D的继承体系中存在虚继承必须要保证其某些成员的间接获得因此pa中放的将不再是偏移值否则d.*pa 10;将导致直接获得偏移值将pa的内容取出来即可违反了虚继承的含义。为了要间接访问pa所记录的偏移值则必须保证代码执行时当pa里面放的是D::a时会间接而D::d时则不间接。很明显这要更多和更复杂的代码大多数编译器对此的处理就是全部都使用间接获得。因此pa的长度将为8字节其中一个4字节记录偏移还有一个4字节记录一个序号。这个序号则用于前面说的虚类表以获得正确的因虚继承而导致的偏移量。因此前面的B::p所指的第一个元素的值表示B实例转换成B实例是为了在这里实现全部间接获得而提供的。 注意上面的D::p对于不同的D的实例将不同只不过它们的内容都相同都是结构D的虚类表的地址。当D的实例刚刚生成时那个实例的D::p的值将是一随机数。为了保证D::p被正确初始化上面的结构D虽然没有生成构造函数但编译器将自动为D生成一缺省构造函数没有参数的构造函数以保证D::p和上面从C继承来的C::p的正确初始化结果将导致D d { 23, 4 };错误因为D已经定义了一个构造函数即使没有在代码上表现出来。 那么虚继承有什么意义呢它从功能上说是间接获得虚继承来的实例从类型上说与普通的继承没有任何区别即虚继承和前面的public等一样只是一个语法上的提供对于数字的类型没有任何影响。在了解它的意义之前先看下虚函数的含义。 虚函数 虚继承了一个函数类型的映射元素按照虚继承的说法应该是间接获得此函数的地址但结果却是间接获得this参数的值。为了间接获得函数的地址C又提出了一种语法——虚函数。在类型定义符“{}”中书写函数声明或定义时在声明或定义语句前加上关键字virtual即可如下 struct A { long a; virtual void ABC(), BCD(); }; void A::ABC() { a 10; } void A::BCD() { a 5; } 上面等同于下面 struct A { void ( A::*pF )(); long a; void ABC(), BCD(); A(); }; void A::ABC() { a 10; } void A::BCD() { a 5; } void ( A::*AVF[] )() { A::ABC, A::BCD }; void A::A() { pF AVF; } 这里A的成员A::pF和之前的虚类表一样是一个指针指向一个数组这个数组被称作虚函数表Virtual Function Table是一个函数指针的数组。这样使用A::ABC时将通过给出A::ABC在A::pF中的序号由A::pF间接获得因此A a; a.ABC();将等同于( a.*( a.pF[0] ) )();。因此结构A的长度是8字节再看下面的代码 struct B : public A { long b; void ABC(); }; struct C : public A { long c; virtual void ABC(); }; struct BB : public B { long bb; void ABC(); }; struct CC : public C { long cc; void ABC(); }; void main() { BB bb; bb.ABC(); CC cc; cc.cc 10; } 首先上面执行bb.ABC()但没有给出BB::ABC或B::ABC的定义因此上面虽然编译通过但连接时将失败。其次上面没有执行cc.ABC();但连接时却会说CC::ABC未定义以表示这里需要CC::ABC的地址为什么因为生成了CC的实例而CC::pF就需要在编译器自动为CC生成的缺省构造函数中被正确初始化其需要CC::ABC的地址来填充。接着给出如下的各函数定义。 void B::ABC() { b 13; } void C::ABC() { c 13; } void BB::ABC() { bb 13; b 10; } void CC::ABC() { cc 13; c 10; } 如上后对于bb.ABC();等同于bb.BB::ABC();虽然有三个BB::ABC的映射元素但只有一个映射元素的类型为void( BB:: )()其映射BB::ABC的地址。由于BB::ABC并没有用virtual修饰因此上面将等同于bb.BB::ABC();而不是( bb.*( pF[0] ) )();bb将为13。对于cc.ABC();也是同样的cc将为13。 对于( ( B* )bb )-ABC();因为左侧类型为B*因此将为( ( B* )bb )-B::ABC();由于B::ABC并没被定义成虚函数因此这里等同于( ( B* )bb )-B::ABC();b将为13。对于( ( C* )cc )-ABC();同样将为( ( C* )cc )-C::ABC();但C::ABC被修饰成虚函数则前面等同于C *pC cc; ( pC-*( pC-pF[0] ) )();。这里先将cc转换成C的实例偏移0。然后根据pC-pF[0]来间接获得函数的地址为CC::ABCc将为10。因为cc是CC的实例在其被构造时将填充cc.pF那么如下 void ( CC::*CCVF[] )() { CC::ABC, CC::BCD }; CC::CC() { cc.pF CCVF; } 因此导致pC-ABC();结果调用的竟是CC::ABC而不是C::ABC这正是由于虚的缘故而间接获得函数地址导致的。同样道理对于( ( A* )cc )-ABC();和( ( A* )bb )-ABC();都将分别调用CC::ABC和BB::ABC。但请注意( pC-*( pC-pF[0] ) )();中pC是C*类型的而pC-pF[0]返回的CC::ABC是void( CC:: )()类型的而上面那样做将如何进行实例的隐式类型转换如果不进行将导致操作错误的成员。可以像前面所说让CCVF的每个成员的长度为8个字节另外4个字节记录需要进行的偏移。但大多数类其实并不需要偏移如上面的CC实例转成A实例就偏移0此法有些浪费资源。VC对此给出的方法如下假设CC::ABC对应的地址为6000并假设下面标号P处的地址就为6000而CC::A_thunk对应的地址为5990。 void CC::A_thunk( void *this ) { this ( ( char* )this ) diff; P: // CC::ABC的正常代码 } 因此pC-pF[0]的值为5990而并不是CC::ABC对应的6000。上面的diff就是相应的偏移对于上面的例子diff应该为0所以实际中pC-pF[0]的值还是6000因为偏移为0没必要是5990。此法被称作thunk表示完成简单功能的短小代码。对于多重继承如下 struct D : public A { long d; }; struct E : public B, public C, public D { long e; void ABC() { e 10; } }; 上面将有三个虚函数表因为B、C和D都各自带了一个虚函数表因为从A派生。结果上面等同于 struct E { void ( E::*B_pF )(); long B_a, b; void ( E::*C_pF )(); long C_a, c; void ( E::*D_pF )(); long D_a, d; long e; void ABC() { e 10; } E(); void E_C_thunk_ABC() { this ( E* )( ( ( char* )this ) – 12 ); ABC(); } void E_D_thunk_ABC() { this ( E* )( ( ( char* )this ) – 24 ); ABC(); } }; void ( E::*E_BVF[] )() { E::ABC, E::BCD }; void ( E::*E_CVF[] )() { E::E_C_thunk_ABC, E::BCD }; void ( E::*E_DVF[] )() { E::E_D_thunk_ABC, E::BCD }; E::E() { B_pF E_BVF; C_pF E_CVF; D_pF E_DVF; } 结果E e; C *pC e; pC-ABC(); D *pD e; pD-ABC();假设e的地址为3000则pC的值为3012pD的值为3024。结果pC-pF的值就是E_CVFpD-pF的值就是E_DVF如此就解决了偏移问题。同样对于前面的虚继承当类里有多个虚类表时如 struct A {}; struct B : virtual public A{}; struct C : virtual public A{}; struct D : virtual public A{}; struct E : public B, public C, public D {}; 这是E将有三个虚类表并且每个虚类表都将在E的缺省构造函数中被正确初始化以保证虚继承的含义——间接获得。而上面的虚函数表的初始化之所以那么复杂也都只是为了保证间接获得的正确性。 应注意上面将E_BVF的类型定义为void( E::*[] )()只是由于演示希望在代码上尽量符合语法而那样写并不表示虚函数的类型只能是void( E:: )()。实际中的虚函数表只不过是一个数组每个元素的大小都为4字节以记录一个地址而已。因此也可如下 struct A { virtual void ABC(); virtual float ABC( double ); }; struct B : public A { void ABC(); float ABC( double ); }; 则B b; A *pA b; pA-ABC();将调用类型为void( B:: )()的B::ABC而pA-ABC( 34 );将调用类型为float( B:: )( double )的B::ABC。它们属于重载函数即使名字相同也都是两个不同的虚函数。还应注意virtual和之前的public等都只是从语法上提供给编译器一些信息它们给出的信息都是针对某些特殊情况的而不是所有在使用数字的地方都适用因此不能作为数字的类型。所以virtual不是类型修饰符它修饰一个成员函数只是告诉编译器在运用那个成员函数的地方都应该间接获得其地址。 为什么要提供虚这个概念即虚函数和虚继承的意义是什么出于篇幅限制将在本文的下篇给出它们意义的讨论即时说明多态性和实例复制等问题。 C从零开始十一下篇 ——类的相关知识 由于篇幅限制本篇为《C从零开始十一》的下篇讨论多态性及一些剩下的问题。 虚的含义 本文的中篇已经介绍了虚的意思就是要间接获得并且举例说明电视机的频道就是让人间接获得电视台频率的因此其从这个意义上说是虚的因为它可能操作失败——某个频道还未调好而导致一片雪花。并且说明了间接的好处就是只用编好一段代码按5频道则每次执行它时可能有不同结果今天5频道被设置成中央5台明天可以被定成中央2台进而使得前面编的程序按5频道显得很灵活。注意虚之所以能够很灵活是因为它一定通过“一种手段”来间接达到目的如每个频道记录着一个频率。但这是不够的一定还有“另一段代码”能改变那种手段的结果频道记录的频率如调台。 先看虚继承。它间接从子类的实例中获得父类实例的所在位置通过虚类表实现这是“一种手段”接着就必须能够有“另一段代码”来改变虚类表的值以表现其灵活性。首先可以自己来编写这段代码但就要求清楚编译器将虚类表放在什么地方而不同的编译器有不同的实现方法则这样编写的代码兼容性很差。C当然给出了“另一段代码”就是当某个类在同一个类继承体系中被多次虚继承时就改变虚类表的值以使各子类间接获得的父类实例是同一个。此操作的功能很差仅仅只是节约内存而已。如 struct A { long a; }; struct B : virtual public A { long b; }; struct C : virtual public A { long c; }; struct D : public B, public C { long d; }; 这里的D中有两个虚类表分别从B和C继承而来在D的构造函数中编译器会编写必要的代码以正确初始化D的两个虚类表以使得通过B继承的虚类表和通过C继承的虚类表而获得的A的实例是同一个。 再看虚函数。它的地址被间接获得通过虚函数表实现这是“一种手段”接着就必须还能改变虚函数表的内容。同上如果自己改写代码的兼容性很差而C也给出了“另一段代码”和上面一样通过在派生类的构造函数中填写虚函数表根据当前派生类的情况来书写虚函数表。它一定将某虚函数表填充为当前派生类下类型、名字和原来被定义为虚函数的那个函数尽量匹配的函数的地址。如 struct A { virtual void ABC(), BCD( float ), ABC( float ); }; struct B : public A { virtual void ABC(); }; struct C : public B { void ABC( float ), BCD( float ); virtual float CCC( double ); }; struct D : public C { void ABC(), ABC( float ), BCD( float ); }; 在A::A中将两个A::ABC和一个A::BCD的地址填写到A的虚函数表中。 在B::B中将B::ABC和继承来的B::BCD和B::ABC填充到B的虚函数表中。 在C::C中将C::ABC、C::BCD和继承来的C::ABC填充到C的虚函数表中并添加一个元素C::CCC。 在D::D中将两个D::ABC和一个D::BCD以及继承来的D::CCC填充到D的虚函数表中。 这里的D是依次继承自A、B、C并没有因为多重继承而产生两个虚函数表其只有一个虚函数表。虽然D中的成员函数没有用virtual修饰但它们的地址依旧被填到D的虚函数表中因为virtual只是表示使用那个成员函数时需要间接获得其地址与是否填写到虚函数表中没有关系。 电视机为什么要用频道来间接获得电视台的频率因为电视台的频率人不容易记并且如果知道一个频率慢慢地调整共谐电容的电容值以使电路达到那个频率效率很低下。而做10组共谐电路每组电路的电容值调好后就不再动通过切换不同的共谐电路来实现快速转换频率。因此间接还可以提高效率。还有5频道本来是中央5台后来看腻了把它换成中央2台则同样的动作按5频道将产生不同的结果“按5频道”这个程序编得很灵活。 由上面至少可以知道间接用于简化操作、提高效率和增加灵活性。这里提到的间接的三个用处都基于这么一个想法——用“一种手段”来达到目的用“另一段代码”来实现上面提的用处。而C提供的虚继承和虚函数只要使用虚继承来的成员或虚函数就完成了“一种手段”。而要实现“另一段代码”从上面的说明中可以看出需要通过派生的手段来达到。在派生类中定义和父类中声明的虚函数原型相同的函数就可以改变虚函数表而派生类的继承体系中只有重复出现了被虚继承的类才能改变虚类表而且也只是都指向同一个被虚继承的类的实例远没有虚函数表的修改方便和灵活因此虚继承并不常用而虚函数则被经常的使用。 虚的使用 由于C中实现“虚”的方式需要借助派生的手段而派生是生成类型因此“虚”一般映射为类型上的间接而不是上面频道那种通过实例一组共谐电路来实现的间接。注意“简化操作”实际就是指用函数映射复杂的操作进而简化代码的编写利用函数名映射的地址来间接执行相应的代码对于虚函数就是一种调用形式表现多种执行结果。而“提高效率”是一种算法上的改进即频道是通过重复十组共谐电路来实现的正宗的空间换时间不是类型上的间接可以实现的。因此C中的“虚”就只能增加代码的灵活性和简化操作对于上面提出的三个间接的好处。 比如动物会叫不同的动物叫的方式不同发出的声音也不同这就是在类型上需要通过“一种手段”叫来表现不同的效果猫和狗的叫法不同而这需要“另一段代码”来实现也就是通过派生来实现。即从类Animal派生类Cat和类Dog通过将“叫Gnar”声明为Animal中的虚函数然后在Cat和Dog中各自再实现相应的Gnar成员函数。如上就实现了用Animal::Gnar的调用表现不同的效果如下 Cat cat1, cat2; Dog dog; Animal *pA[] { cat1, dog, cat2 }; for( unsigned long i 0; i sizeof( pA ); i ) pA[ i ]-Gnar(); 上面的容器pA记录了一系列的Animal的实例的引用关于引用可参考《C从零开始八》其语义就是这是3个动物至于是什么不用管也不知道就好象这台电视机有10个频道至于每个是什么台则不知道然后要求这3个动物每个都叫一次调用Animal::Gnar结果依次发出猫叫、狗叫和猫叫声。这就是之前说的增加灵活性也被称作多态性指同样的Animal::Gnar调用却表现出不同的形态。上面的for循环不用再写了它就是“一种手段”而欲改变它的表现效果就再使用“另一段代码”也就是再派生不同的派生类并把派生类的实例的引用放到数组pA中即可。 因此一个类的成员函数被声明为虚函数表示这个类所映射的那种资源的相应功能应该是一个使用方法而不是一个实现方式。如上面的“叫”表示要动物“叫”不用给出参数也没有返回值直接调用即可。因此再考虑之前的收音机和数字式收音机其中有个功能为调台则相应的函数应该声明为虚函数以表示要调台就给出频率增量或减量而数字式的调台和普通的调台的实现方式很明显的不同但不管。意思就是说使用收音机的人不关心调台是如何实现的只关心怎样调台。因此虚函数表示函数的定义不重要重要的是函数的声明虚函数只有在派生类中实现有意义父类给出虚函数的定义显得多余。因此C给出了一种特殊语法以允许不给出虚函数的定义格式很简单在虚函数的声明语句的后面加上“ 0”即可被称作纯虚函数。如下 class Food; class Animal { public: virtual void Gnar() 0, Eat( Food ) 0; }; class Cat : public Animal { public: void Gnar(), Eat( Food ); }; class Dog : public Animal { void Gnar(), Eat( Food ); }; void Cat::Gnar(){} void Cat::Eat( Food ){} void Dog::Gnar(){} void Dog::Eat( Food ){} void main() { Cat cat; Dog dog; Animal ani; } 上面在声明Animal::Gnar时在语句后面书写“ 0”以表示它所映射的元素没有定义。这和不书写“ 0”有什么区别直接只声明Animal::Gnar也可以不给出定义啊。注意上面的Animal ani;将报错因为在Animal::Animal中需要填充Animal的虚函数表而它需要Animal::Gnar的地址。如果是普通的声明则这里将不会报错因为编译器会认为Animal::Gnar的定义在其他的文件中后面的连接器会处理。但这里由于使用了“ 0”以告知编译器它没有定义因此上面代码编译时就会失败编译器已经认定没有Animal::Gnar的定义。 但如果在上面加上Animal::Gnar的定义会怎样Animal ani;依旧报错因为编译器已经认定没有Animal::Gnar的定义连函数表都不会查看就否定Animal实例的生成因此给出Animal::Gnar的定义也没用。但映射元素Animal::Gnar现在的地址栏填写了数字因此当cat.Animal::Gnar();时没有任何问题。如果不给出Animal::Gnar的定义则cat.Animal::Gnar();依旧没有问题但连接时将报错。 注意上面的Dog::Gnar是private的而Animal::Gnar是public的结果dog.Gnar();将报错而dog.Animal::Gnar();却没有错误由于它是虚函数结果还是调用Dog::Gnar也就是前面所谓的public等与类型无关只是一种语法罢了。还有class Food;不用管它是声明还是定义只用看它提供了什么信息只有一个——有个类型名的名字为Food是类型的自定义类型。而声明Animal::Eat时编译器也只用知道Food是一个类型名而不是程序员不小心打错字了就行了因为这里并没有运用Food。 上面的Animal被称作纯虚基类。基类就是类继承体系中最上层的那个类虚基类就是基类带有纯虚成员函数纯虚基类就是没有成员变量和非纯虚成员函数只有纯虚成员函数的基类。上面的Animal就定义了一种规则也称作一种协议或一个接口。即动物能够Gnar而且也能够Eat且Eat时必须给出一个Food的实例表示动物能够吃食物。即Animal这个类型成了一张说明书说明动物具有的功能它的实例变得没有意义而它由于使用纯虚函数也正好不能生成实例。 如果上面的Gner和Eat不是纯虚函数呢那么它们都必须有定义进而动物就不再是一个抽象概念而可以有实例则就可以有这么一种动物它是动物但它又不是任何一种特定的动物既不是猫也不是狗。很明显这样的语义和纯虚基类表现出来的差很远。 那么虚继承呢被虚继承的类的成员将被间接操作这就是它的“一种手段”也就是说操作这个被虚继承的类的成员可能由于得到的偏移值不同而操作不同的内存。但对虚类表的修改又只限于如果重复出现则修改成间接操作同一实例因此从根本上虚继承就是为了解决上篇所说的鲸鱼有两个饥饿度的问题本身的意义就只是一种算法的实现。这导致在设计海洋生物和脯乳动物时无法确定是否要虚继承父类动物而要看派生的类中是否会出现类似鲸鱼那样的情况如果有则倒过来再将海洋生物和脯乳动物设计成虚继承自动物这不是好现象。 static静态 在《C从零开始五》中说过静态就是每次运行都没有变化而动态就是每次运行都有可能变化。C给出了static关键字和上面的public、virtual一样只是个语法标识而已不是类型修饰符。它可作用于成员前面以表示这个成员对于每个实例来说都是不变的如下 struct A { static long a; long b; static void ABC(); }; long A::a; void A::ABC() { a 10; b 0; }; void main() { A a; a.a 10; a.b 32; } 上面的A::a就是结构A的静态成员变量A::ABC就是A的静态成员函数。有什么变化上面的映射元素A::a的类型将不再是long A::而是long。同样A::ABC的类型也变成void()而不是void( A:: )()。 首先成员要对它的类的实例来说都是静态的即成员变量对于每个实例所标识的内存的地址都相同成员函数对于每个this参数进行修改的内存的地址都是不变的。上面把A::a和A::ABC变成普通类型而非偏移类型就消除了它们对A的实例的依赖进而实现上面说的静态。 由于上面对实例依赖的消除即成员函数去掉this参数成员变量映射的是一确切的内存地址而不再是偏移所以struct A { static long a; };只是对变量A::a进行了声明其名字为A::a类型为long映射的地址并没有给出即还未定义所以必须在全局空间中即不在任何一个函数体内再定义一遍进而有long A::a;。同样A::ABC的类型为void()被去除了this参数进而在A::ABC中的b 10;等同于A::b 10;发现A::b是偏移类型需要this参数则等同于this-A::b 10;。结果A::ABC没有this参数错误。而对于a 10;等同于A::a 10;而已经有这个变量故没任何问题。 注意上面的a.a 10;等同于a.A::a 10;而A::a不是偏移类型那这里不是应该报错吗对此C特别允许这种类型不匹配的现象其中的“a.”等于没有因为这正是前面我们要表现的静态成员。即A a, b; a.a 10; b.a 20;执行后a.a为20因为不管哪个实例对成员A::a的操作都修改的同一个地址所标识的内存。 什么意义它们和普通的变量的区别就是名字被A::限定进而能表现出它们的是专用于类A的。比如房子房子的门的高度和宽度都定好了有两个房子都是某个公司造的它们的门的高度和宽度相同因此门的高度和宽度就应该作为那个公司造的房子的静态成员以记录实际的高度和宽度但它们并不需要因实例的不同而变化。 除了成员C还提供了静态局部变量。局部变量就是在函数体内的变量被一对“{}”括起来被限制了作用域的变量。对于函数每次调用函数由于函数体内的局部变量都是分配在栈上按照之前说的这些变量其实是一些相对值则每次调用函数可能由于栈的原因而导致实际对应的地址不同。如下 void ABC() { long a 0; a; } void BCD() { long d 0; ABC(); } void main() { ABC(); BCD(); } 上面main中调用ABC而产生的局部变量a所对应的地址和由于调用BCD而在BCD中调用ABC而产生的a所对应的地址就不一样原理在《C从零开始十五》中说明。因此静态局部变量就表示那个变量的地址不管是通过什么途径调用它所在的函数都不变化。如下 void ABC() { static long a 0; a; } void BCD() { long d 0; d; ABC(); } void main() { ABC(); BCD(); } 上面的变量a的地址是固定值而不再是原来那种相对值了。这样从main中调用ABC和从BCD中调用ABC得到的变量a的地址是相同的。上面等同于下面 long g_ABC_a 0; void ABC() { g_ABC_a; } void BCD() { long d 0; d; ABC(); } void main() { ABC(); BCD(); } 因此上面ABC中的静态局部变量a的初始化实际在执行main之前就已经做了而不是想象的在第一次调用ABC时才初始化进而上面代码执行完后ABC中的a的值为2因为ABC的两次调用。 它的意义表示这个变量只在这个函数中才被使用而它的生命期又需要超过函数的执行期。它并不能提供什么语义因为能提供的“在这个函数才被使用”使用局部变量就可以做到只是当某些算法需要使用全局变量而此时这个算法又被映射成了一个函数则使用静态变量具有很好的命名效果——既需要全局变量的生存期又应该有局部变量的语义。 inline嵌入 函数调用的效率较低调用前需要将参数按照调用规则存放起来然后传递存放参数的内存还要记录调用时的地址以保证函数执行完后能回到调用处关于细节在《C从零开始十五》中讨论但它能降低代码的长度尤其是函数体比较大而代码中调用它的地方又比较多可以大幅度减小代码的长度就好像循环10次如果不写循环语句则需要将循环体内的代码复制10遍。但也可能倒过来调用次数少而函数体较小这时之所以还映射成函数是为了语义更明确。此时可能更注重的是执行效率而不是代码长度为此C提供了inline关键字。 在函数定义时在定义语句的前面书写inline即可表示当调用这个函数时在调用处不像原来那样书写存放、传递参数的代码而将此函数的函数体在调用处展开就好像前面说的将循环体里的代码复制10遍一样。这样将不用做传递参数等工作代码的执行效率将提高但最终生成的代码的长度可能由于过多的展开而变长。如下 void ABCD(); void main() { ABCD(); } inline void ABCD() { long a 0; a; } 上面的ABCD就是inline函数。注意ABCD的声明并没有书写inline因为inline并不是类型修饰符它只是告诉编译器在生成这个函数时要多记录一些信息然后由连接器根据这些信息在连接前视情况展开它。注意是“视情况”即编译器可能足够智能以至于在连接时发现对相应函数的调用太多而不适合展开进而不展开。对此不同的编译器给出了不同的处理方式对于VC其就提供了一个关键字__forceinline以表示相应函数必须展开不用去管它被调用的情况。 前面说过对于在类型定义符中书写的函数定义编译器将把它们看成inline函数。变成了inline函数后就不用再由于多个中间文件都给出了函数的定义而不知应该选用哪个定义所产生的地址因为所有调用这些函数的地方都不再需要函数的地址函数将直接在那里展开。 const常量 前面提到某公司造的房子的门的高度和宽度应该为静态成员变量但很明显在房子的实例存在的整个期间门的高度和宽度都不会变化。C对此专门提出了一种类型修饰符——const。它所修饰的类型表示那个类型所修饰的地址类型的数字不能被用于写操作即地址类型的数字如果是const类型将只能被读不能被修改。如const long a 10, b 20; a; a 4;注意不能cosnt long a;因为后续代码都不能修改a而a的值又不能被改变则a就没有意义了。这里a;和a 4;都将报错因为a的类型为cosnt long表示a的地址所对应的内存的值不能被改变而a;和a 4;都欲改变这个值。 由于const long是一个类型因此也就很正常地有const long*表示类型为const long的指针因此按照类型匹配有const long *p b; p a; *p 10;。这里p a;按照类型匹配很正常而p是常量的long类型的指针没有任何问题。但是*p 10;将报错因为*p将p的数字直接转换成地址类型也就成了常量的long类型的地址类型因此对它进行写入操作错误。 注意有const long* const p a; p a; *p 10;按照从左到右修饰的顺序上面的p的类型为const long* const是常量的long类型的指针的常量表示p的地址所对应的内存的值不能被修改因此后边的p a;将错误违反const的意义。同样*p 10;也错误。不过可以 long a 3, *const p a; p a; *p 10; 上面的p的类型为long* const为long类型的常量因此其必须被初始化。后续的p a;将报错因为p是long* const但*p 10;却没有任何问题因为将long*转成long后没有任何问题。所以也有 const long a 0; const long* const p a; const long* const *pp p; 只要按照从左到右的修饰顺序而所有的const修饰均由于取内容操作符“*”的转换而变成相应类型中指针类型修饰符“*”左边的类型因此*pp的类型是const long* const*p的类型是const long。 应注意C还允许如下使用 struct A { long a, b; void ABC() const; }; void A::ABC() const { a 10; b 10; } 上面的A::ABC的类型为void( A:: )() const其等同于 void A_ABC( const A *this ) { this-a 10; this-b 10; } 因此上面的a 10;和b 10;将报错因为this的类型是const A*。上面的意思就是函数A::ABC中不能修改成员变量的值因为各this的参数变成了const A*但可以修改类的静态成员变量的值如 struct A { static long c; long a, b; void ABC() const; } long A::c; void A::ABC() const { a b 10; c 20; } 等同于void A_ABC( const A *this ) { this-a this-b 10; A::c 20; }。故依旧可以修改A::c的值。 有什么意义出于篇幅有关const的语义还请参考我写的另一篇文章《语义的需要》。 friend友员 发信机具有发送电波的功能收信机具有接收电波的功能而发信机、收信机和电波这三个类首先发信机由于将信息传递给电波而必定可以修改电波的一些成员变量但电波的这些成员应该是protected否则随便一个石头都能接收或修改电波所携带的信息。同样收信机要接收电波就需要能访问电波的一些用protected修饰的成员这样就麻烦了。如果在电波中定义两个公共成员函数让发信机和收信机可以通过它们来访问被protected的成员不就行了这也正是许多人犯的毛病既然发信机可以通过那个公共成员函数修改电波的成员那石头就不能用那个成员函数修改电波吗这等于是原来没有门后来有个门却不上锁。为了消除这个问题C提出了友员的概念。 在定义某个自定义类型时在类型定义符“{}”中声明一个自定义类型或一个函数在声明或定义语句的前面加上关键字friend即可如 class Receiver; class Sender; class Wave { private: long b, c; friend class Receiver; friend class Sender; }; 上面就声明了Wave的两个友员类以表示Receiver和Sender具备了Wave的资格即如下 class A { private: long a; }; class Wave : public A { … }; void Receiver::ABC() { Wave wav; wav.a 10; wav.b 10; wav.A::a 10; } 上面由于Receiver是Wave的友员类所以在Receiver::ABC中可以直接访问Wave::a、Wave::b但wav.A::a 10;就将报错因为A::a是A的私有成员Wave不具备反问它的权限而Receiver的权限等同于Wave故权限不够。 同样也可有友员函数即给出函数的声明或定义在语句前加上friend如下 class Receiver { public: void ABC(); }; class A { private: long a; friend void Receiver::ABC(); }; 这样就将Receiver::ABC作为了A的友员函数则在Receiver::ABC中具有类A具有的所有权限。 应注意按照给出信息的思想上面还可以如下 class A { private: long a; friend void Receiver::ABC() { long a 0; } }; 这里就定义了函数Receiver::ABC由于是在类型定义符中定义的前面已经说过Receiver::ABC将被修饰为inline函数。 那么友员函数的意义呢一个操作需要同时操作两个资源中被保护了的成员则这个操作应该被映射为友员函数。如盖章需要用到文件和章两个资源则盖章映射成的函数应该为文件和章的友员函数。 名字空间 前面说明了静态成员变量它的语义是专用于某个类而又独立于类的实例它与全局变量的关键不同就是名字多了个限定符即“::”表示从属关系如A::a是A的静态成员变量则A::a这个名字就可以表现出a从属于A。因此为了表现这种从属关系就需要将变量定义为静态成员变量。 考虑一种情况映射采矿。但是在陆地上采矿和在海底采矿很明显地不同那么应该怎么办映射两个函数名字分别为MiningOnLand和MiningOnSeabed。好然后又需要映射在陆地勘探和在海底勘探怎么办映射为ProspectOnLand和ProspectOnSeabed。如果又需要映射在陆地钻井和在海底钻井在陆地爆破和在海底爆破怎么办很明显这里通过名字来表现语义已经显得牵强了而使用静态成员函数则显得更加不合理为此C提供了名字空间格式为namespace 名字 { 各声明或定义语句 }。其中的名字为定义的名字空间的名字而各声明或定义语句就是多条声明或定义语句。如下 namespace OnLand { void Mining(); void Prospect(); void ArtesianWell(){} } namespace OnSeabed { void Mining(); void Prospect(); void ArtesianWell(){} } void OnLand::Mining() { long a 0; a; } void OnLand::Prospect() { long a 0; a; } void OnSeabed::Mining() { long a 0; a; } void OnSeabed::Prospect() { long a 0; a; } 上面就定义了6个元素每个的类型都为void()。注意上面OnLand::ArtesianWell和OnSeabed::ArtesianWell的定义直接写在“{}”中将是inline函数。这样定义的六个变量它们的名字就带有限定符能够从名字上体现从属关系语义表现得比原来更好OnSeabed::Prospect就表示在海底勘探。注意也可以如下 namespace A { long b 0; long a 0; namespace B { long B 0; float a 0.0f } } namespace C { struct ABC { long a, b, c, d; void ABCD() { a b c d 12; } } ab; } namespace D { void ABC(); void ABC() { long a 0; a; } extern float bd; } 即名字空间里面可以放任何声明或定义语句也可以用于修饰自定义结构因此就可以C::ABC a; a.ABCD();。应注意C还允许给名字空间别名比如namespace AB C; AB::ABC a; a.ABCD();。这里就给名字空间C另起了个名字AB就好像之前提过的typedef一样。 还应注意自定义类型的定义的效果和名字空间很像如struct A { long a; };将生成A::a和名字空间一样为映射元素的名字加上了限定符但应该了解到结构A并不是名字空间即namespace ABC A;将失败。名字空间就好像所有成员都是静态成员的自定义结构。 为了方便名字空间的使用C提供了using关键字其后面接namespace和名字空间的名字将把相应名字空间中的所有映射元素复制一份但是去掉了名字前的所有限定符并且这些元素的有效区域就在using所在的位置如 void main() { { using namespace C; ABC a; a.ABCD(); } ABC b; b.ABCD(); } 上面的ABC b;将失败因为using namespace C;的有效区域只在前面的“{}”内出了就无效了因此应该C::ABC b; b.ABCD();。有什么用方便书写。因为每次调用OnLand::Prospect时都要写OnLand::显得有点烦琐如果知道在某个区域内并不会用到OnSeabed的成员则可以using namespace OnLand;以减小代码的繁杂度。 注意C还提供了using更好的使用方式即只希望去掉名字空间中的某一个映射元素的限定符而不用全部去掉比如只去掉OnLand::Prospect而其它的保持则可以using OnLand::Prospect; Prospect(); Mining();。这里的Mining();将失败而Prospect();将成功因为using OnLand::Prospect;只去掉了OnLand::Prospect的限定符。 至此基本上已经说明了C的大部分内容只是还剩下模板和异常没有说明还有自定义类型的操作符重载出于篇幅在《C从零开始十七》中说明它们带的语义都很少很大程度上就和switch语句一样只是一种算法的包装而已。下篇介绍面向对象编程思想并给出“世界”的概念以从语义出发来说明如何设计类及类的继承体系。