博文
C++从零开始(十一)(下)——类的相关知识 (2006-08-30 16:55:00)
摘要:本文的中篇已经介绍了虚的意思,就是要间接获得,并且举例说明电视机的频道就是让人间接获得电视台频率的,因此其从这个意义上说是虚的,因为它可能操作失败——某个频道还未调好而导致一片雪花。并且说明了间接的好处,就是只用编好一段代码(按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++也给出了“另一段代码”,和上面一样,通过在派生类的构造函数中填写虚函数表,根据当前派生类的情况来书写虚函数表。它一定将某虚函数表填充为当前派生类下,类型、名字和原来被定义为虚函数的那个函数尽量匹配的函数的地址。如:
struc......
C++从零开始(十一)(中)——类的相关知识(2006-08-30 16:55:00)
摘要:由于篇幅限制,本篇为《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::),还要生成继承来的映射元素,各映射元......
C++从零开始(十一)(上)——类的相关知识(2006-08-30 16:54:00)
摘要:前面已经介绍了自定义类型的成员变量和成员函数的概念,并给出它们各自的语义,本文继续说明自定义类型剩下的内容,并说明各自的语义。
权限
成员函数的提供,使得自定义类型的语义从资源提升到了具有功能的资源。什么叫具有功能的资源?比如要把收音机映射为数字,需要映射的操作有调整收音机的频率以接收不同的电台;调整收音机的音量;打开和关闭收音机以防止电力的损耗。为此,收音机应映射为结构,类似下面:
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的成员函数?因为收音机具有这样的功能?那么对于选西瓜、切西瓜和吃西瓜,难道要定义一个结构,然后给它定义三个选、切、吃的成员函数??不是很荒谬吗?前者的三个操作是对结构的成员变量......
C++从零开始(十)——何谓类 (2006-08-30 16:53:00)
摘要:160前篇说明了结构只不过是定义了内存布局而已,提到类型定义符前还可以书写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 ); };
上面就定义了一个映射元素——第一个变量A......
C++从零开始(九)——何谓结构(2006-08-30 16:52:00)
摘要:前篇已经说明编程时,拿到算法后该干的第一件事就是把资源映射成数字,而前面也说过“类型就是人为制订的如何解释内存中的二进制数的协议”,也就是说一个数字对应着一块内存(可能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......
C++从零开始(八)——C++样例一 (2006-08-30 16:51:00)
摘要:前篇说明了函数的部分实现方式,但并没有说明函数这个语法的语义,即函数有什么用及为什么被使用。对于此,本篇及后续会零散提到一些,在《C++从零开始(十二)》中再较详细地说明。本文只是就程序员的基本要求——拿得出算法,给得出代码——给出一些样例,以说明如何从算法编写出C++代码,并说明多个基础且重要的编程概念(即独立于编程语言而存在的概念)。
由算法得出代码
本系列一开头就说明了何谓程序,并说明由于CPU的世界和人们存在的客观物理世界的不兼容而导致根本不能将人编写的程序(也就是算法)翻译成CPU指令,但为了能够翻译,就必须让人觉得CPU世界中的某些东西是人以为的算法所描述的某些东西。如电脑屏幕上显示的图片,通过显示器对不同象素显示不同颜色而让人以为那是一幅图片,而电脑只知道那是一系列数字,每个数字代表了一个象素的颜色值而已。
为了实现上面的“让人觉得是”,得到算法后要做的的第一步就是找出算法中要操作的资源。前面已经说过,任何程序都是描述如何操作资源的,而C++语言本身只能操作内存的值这一种资源,因此编程要做的第一步就是将算法中操作的东西映射成内存的值。由于内存单元的值以及内存单元地址的连续性都可以通过二进制数表示出来,因此要做的第一步就是把算法中操作的东西用数字表示出来。
上面做的第一步就相当于数学建模——用数学语言将问题表述出来,而这里只不过是用数字把被操作的资源表述出来罢了(应注意数字和数的区别,数字在C++中是一种操作符,其有相关的类型,由于最后对它进行计算得到的还是二进制数故使用数字进行表示而不是二进制数,以增强语义)。接着第二步就是将算法中对资源的所有操作都映射成语句或函数。
用数学语言对算法进行表述时,比如将每10分钟到车站等车的人的数量映射为一随机变量,也就前述的第一步。随后定此随机变量服从泊松分布,也就是上面的第二步。到站等车的人的数量是被操作的资源,而给出的算法是每隔10分种改变这个资源,将它的值变成按给定参数的泊松函数分布的一随机值。
在C++中,前面已经将资源映射成了数字,接着就要将对资源的操作映射成对数字的操作。C++中能操作数字的就只有操作符,也就是将算法中对资源的所有操作都映射成表达式语句。
当......
C++从零开始(七)——何谓函数 (2006-08-30 16:51:00)
摘要:本篇之前的内容都是基础中的基础,理论上只需前面所说的内容即可编写出几乎任何只操作内存的程序,也就是本篇以后说明的内容都可以使用之前的内容自己实现,只不过相对要麻烦和复杂许多罢了。
本篇开始要比较深入地讨论C++提出的很有意义的功能,它们大多数和前面的switch语句一样,是一种技术的实现,但更为重要的是提供了语义的概念。所以,本篇开始将主要从它们提供的语义这方面来说明各自的用途,而不像之前通过实现原理来说明(不过还是会说明一下实现原理的)。为了能清楚说明这些功能,要求读者现在至少能使用VC来编译并生成一段程序,因为后续的许多例子都最好是能实际编译并观察执行结果以加深理解(尤其是声明和类型这两个概念)。为此,如果你现在还不会使用VC或其他编译器来进行编译代码,请先参看其他资料以了解如何使用VC进行编译。为了后续例子的说明,下面先说明一些预备知识。
预备知识
写出了C++代码,要如何让编译器编译?在文本文件中书写C++代码,然后将文本文件的文件名作为编译器的输入参数传递给编译器,即叫编译器编译给定文件名所对应的文件。在VC中,这些由VC这个编程环境(也就是一个软件,提供诸多方便软件开发的功能)帮我们做了,其通过项目(Project)来统一管理书写有C/C++代码的源文件。为了让VC能了解到哪些文件是源文件(因为还可能有资源文件等其他类型文件),在用文本编辑器书写了C++代码后,将其保存为扩展名为.c或.cpp(C Plus Plus)的文本文件,前者表示是C代码,而后者表示C++代码,则缺省情况下,VC就能根据不同的源文件而使用不同的编译语法来编译源文件。
前篇说过,C++中的每条语句都是从上朝下执行,每条语句都对应着一个地址,那么在源文件中的第一条语句对应的地址就是0吗?当然不是,和在栈上分配内存一样,只能得到相对偏移值,实际的物理地址由于不同的操作系统将会有各自不同的处理,如在Windows下,代码甚至可以没有物理地址,且代码对应的物理地址还能随时变化。
当要编写一个稍微正常点的程序时,就会发现一个源文件一般是不够的,需要使用多个源文件来写代码。而各源文件之间要如何连接起来?对此C++规定,凡是生成代码的语句都要放在函数中,而不能直接写在文本文件中。关于函数......
C++从零开始(六)——何谓语句 (2006-08-30 16:50:00)
摘要:前面已经说过程序就是方法的描述,而方法的描述无外乎就是动作加动作的宾语,而这里的动作在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内带一种功能和内存一样的用于暂时记录二进制数的硬件,称作寄存器,其读取速度较内存要快很多,但大小就小许多了。为了加快读取速度,......
C++从零开始(五)——何谓指针 (2006-08-30 16:49:00)
摘要:本篇说明C++中的重中又重的关键——指针类型,并说明两个很有意义的概念——静态和动态。
数组
前面说了在C++中是通过变量来对内存进行访问的,但根据前面的说明,C++中只能通过变量来操作内存,也就是说要操作某块内存,就必须先将这块内存的首地址和一个变量名绑定起来,这是很糟糕的。比如有100块内存用以记录100个工人的工资,现在要将每个工人的工资增加5%,为了知道各个工人增加了后的工资为多少,就定义一个变量float a1;,用其记录第1个工人的工资,然后执行语句a1 += a1 * 0.05f;,则a1里就是增加后的工资。由于是100个工人,所以就必须有100个变量,分别记录100个工资。因此上面的赋值语句就需要有100条,每条仅仅变量名不一样。
上面需要手工重复书写变量定义语句float a1;100遍(每次变一个变量名),无谓的工作。因此想到一次向操作系统申请100*4=400个字节的连续内存,那么要给第i个工人修改工资,只需从首地址开始加上4*i个字节就行了(因为float占用4个字节)。
为了提供这个功能,C++提出了一种类型——数组。数组即一组数字,其中的各个数字称作相应数组的元素,各元素的大小一定相等(因为数组中的元素是靠固定的偏移来标识的),即数组表示一组相同类型的数字,其在内存中一定是连续存放的。在定义变量时,要表示某个变量是数组类型时,在变量名的后面加上方括号,在方括号中指明欲申请的数组元素个数,以分号结束。因此上面的记录100个工资的变量,即可如下定义成数组类型的变量:
float a[100];
上面定义了一个变量a,分配了100*4=400个字节的连续内存(因为一个float元素占用4个字节),然后将其首地址和变量名a相绑定。而变量a的类型就被称作具有100个float类型元素的数组。即将如下解释变量a所对应内存中的内容(类型就是如何解释内存的内容):a所对应的地址标识的内存是一块连续内存的首地址,这块连续内存的大小刚好能容纳下100个float类型的数字。
因此可以将前面的float b;这种定义看成是定义了一个元素的float数组变量b。而为了能够访问数组中的某个元素,在变量名后接方括号,方括号中放一数字,数字必须是非浮点数,即使用二进制原码或补码进行表示的数字。如a[ 5......
C++从零开始(四)——赋值操作符 (2006-08-30 16:48:00)
摘要:本篇是《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;语句中,都可以看出一点——变量是可以返回数字的。而变量返回的数字就是按照变量的类型来解释变量对应内存中的内容所得到的数字。这句话也许不是那么容易理解,在看过后面的类型转换一节后应该就可以理解了。
因此为了将数据写入一块内存,使用赋值语句(即等号);要读......