Windows和Linux下进程、线程理解

六月 20th, 2011 3 Comments »

对于windows来说,进程和线程的概念都是有着明确定义的,进程的概念对应于一个程序的运行实例(instance),而线程则是程序代码执行的最小单元。也就是说windows对于进程和线程的定义是与经典OS课程中所教授的进程、线程概念相一致的。

提供API,CreateThread()用于建立一个新的线程,传递线程函数的入口地址和调用参数给新建的线程,然后新线程就开始执行了。

windows下,一个典型的线程拥有自己的堆栈、寄存器(包括程序计数器PC,用于指向下一条应该执行的指令在内存中的位置),而代码段、数据段、打开文件这些进程级资源是同一进程内多个线程所共享的。因此同一进程的不同线程可以很方便的通过全局变量(数据段)进行通信,大家都可以对数据段进行读写,这很方便,也被在安全性方面诟病,因为它要求程序员时刻意识到这些数据不是线程独立的。

对于linux来说,则没有很明确的进程、线程概念。首先linux只有进程而没有线程,然而它的进程又可以表现得像windows下的线程。linux利用fork()和exec函数族来操作多线程。fork()函数可以在进程执行的任何阶段被调用,一旦调用,当前进程就被分叉成两个进程——父进程和子进程,两者拥有相同的代码段和暂时相同的数据段(虽然暂时相同,但从分叉开的时刻就是逻辑上的两个数据段了,之所以说是逻辑上的,是因为这里是“写时复制”机制,也就是,除非万不得已有一个进程对数据段进行了写操作,否则系统不去复制数据段,这样达到了负担最小),两者的区别在于fork()函数返回值,对于子进程来说返回为0,对于父进程来说返回的是子进程id,因此可以通过if(fork()==0)…else…来让父子进程执行不同的代码段,从而实现“分叉”。

exec函数族的函数的作用则是启动另一个程序的新进程,然后完全用那个进程来代替自己(代码段被替换,数据段和堆栈被废弃,只保留原有进程id)。这样,如果在fork()之后,在子进程代码段里用exec启动另一个进程,就相当于windows下的CreateThread()的用处了,所以说linux下的进程可以表现得像windows下的线程。

然而linux下的进程不能像windows下线程那样方便地通信,因为他们没有共享数据段、地址空间等。它们之间的通信是通过所谓IPC(InterProcess Communication)来进行的。具体有管道(无名管道用于父子进程间通信,命名管道可以用于任意两个进程间的通信)、共享内存(一个进程向系统申请一块可以被共享的内存,其它进程通过标识符取得这块内存,并将其连接到自己的地址空间中,效果上类似于windows下的多线程间的共享数据段),信号量,套接字。

=================================分割线==================================

顺便谈一下自己对于linux文化的一点小感受。据我不多的了解来说,感觉linux在很多实现的风格上过于trick,它确实总让我们有“原来还可以这样!”、“真的很巧妙啊!”的惊叹,但是其实相同的问题还可以有更加一般化、更加经典、也更加容易理解的解决方案。fork()和CreateThread()的区别就很好地体现了这点。也许是因为linux总是把效率放在第一位吧。我猜,正是因为如此,正是因为linux下的这种富tricks的风格,才让linux粉们那么容易产生优越感吧,不过这一点却不怎么吸引我,因为我觉得这些实现方法上的小trick小差异并不是我们面对的问题的实质。

编程之美 2.4 “1”的数目

六月 10th, 2011 No Comments »

题目是这样的:给定一个正整数N,从1到N一共出现过多少个1?

如N=12,则f(12)=5,因为1,2,3,4,5,6,7,8,9,10,11,12共出现5次“1”。

当年第一次看这个题的时候觉得无从下手,这次到迅速有了思路,还是有进步的嘛。

思路就是分别统计个位、十位、百位…上的1的数目。先考虑个位上的1的数目,个位上的1是每10个数中会出现1次的,即1-10出现一次,11-20出现一次…因此可以计算N/10,对于N%10的部分,只要N%10>=1,就有一个1,也只能有一个1。同理再考虑十位上的1,十位上的1是每100个数字中会出现10次,如1-100出现10次(10-19),101-200出现10次(110-119)…因此可以计算N/100*10,对于N%100的部分,只有在10-19之间会出现1,因此可以分情况讨论。百位上、千位上的情况依此类推,再举一例,说一下千位上的情况吧,对于千位上的1,是每10000个数里出现1000次,例如1-10000里出现1000次(1000-1999),10001-20000里出现1000次(11000-11999)…因此可以计算N/10000*1000,对于N%10000的部分,只有在1000-1999之间会出现千位上的1,同样分情况讨论之。

代码如下:

   1:  __int64 func(__int64 N)
   2:  {
   3:      __int64 onesCount = 0;
   4:      __int64 factor = 10;
   5:      __int64 a,b,c;
   6:      do 
   7:      {
   8:          a = N/factor;
   9:          b = N%factor;
  10:          c = a*(factor/10);
  11:   
  12:          onesCount += c;
  13:   
  14:          if (b >= factor/10)
  15:          {
  16:              if (b > 2*(factor/10) - 1)
  17:              {
  18:                  onesCount += factor/10;
  19:              }
  20:              else
  21:              {
  22:                  onesCount += b - (factor/10) + 1;
  23:              }
  24:          }
  25:          factor *= 10;
  26:      } while (a != 0);
  27:   
  28:      return onesCount;
  29:  }

有一个有意思的现象是,对于N=111…110这样的数,即a个1加一个0组成的数,其f(N)恰好等于连续a个(a+1),如f(1110)=444,f(11111110)=8888888。以f(1110)=444为例,一共有四位,每一位上的一都是111个,因此f(1110)=444。

扩展问题问到了对于2进制的情况该怎么解答,例如f(11)=100,因为“0,10,11”中共出现了四个1。其实思路是一样的,也是分别考察右侧第一位上出现1的次数、右侧第二位上出现1的次数…以此类推。比如考虑f(11)=f(1011),

1,10,11,100,101,110,111,1000,1001,1010,1011

先考虑第一位上的1(第几位一律从右侧数起),第一位上的1是每两个数出现一次,因此可以先计算11/2=5,然后对于11%2=1的部分,由于等于1,因此出现了一次1,因此第一位上共出现了6次1;再考虑第二位,这一位上是每4个数出现2次1,先计算11/4*2=4,对于11%4=3部分,只有在10-11之间会出现2次1,因此第二位上出现6次1;第三位,(11/8*4)+0=4次,第四位,0+(11-7)=4。因此f(1011)=10100=20。代码就不写了吧。

二叉树先序、中序、后序遍历的非递归实现

十一月 23rd, 2010 2 Comments »

在网上看了一些用非递归实现先序中序后序遍历二叉树的代码,都很混乱,while、if各种组合嵌套使用,逻辑十分不清晰,把我也搞懵了。想了大半天,写了大半天,突然开了窍,实际上二叉树的这三种遍历在逻辑上是十分清晰的,所以才可以用递归实现的那么简洁。既然逻辑如此清晰,那么用非递归实现也应该是清晰的。

自认为自己的代码比网上搜到的那些都清晰得多,好理解得多。

稍微解释一下:

先序遍历。将根节点入栈,考察当前节点(即栈顶节点),先访问当前节点,然后将其出栈(已经访问过,不再需要保留),然后先将其右孩子入栈,再将其左孩子入栈(这个顺序是为了让左孩子位于右孩子上面,以便左孩子的访问先于右孩子;当然如果某个孩子为空,就不用入栈了)。如果栈非空就重复上述过程直到栈空为止,结束算法。

中序遍历。将根节点入栈,考察当前节点(即栈顶节点),如果其左孩子未被访问过(有标记),则将其左孩子入栈,否则访问当前节点并将其出栈,再将右孩子入栈。如果栈非空就重复上述过程直到栈空为止,结束算法。

后序遍历。将根节点入栈,考察当前节点(即栈顶节点),如果其左孩子未被访问过,则将其左孩子入栈,否则如果其右孩子未被访问过,则将其右孩子入栈,如果都已经访问过,则访问其自身,并将其出栈。如果栈非空就重复上述过程直到栈空为止,结束算法。

其实,这只不过是保证了先序中序后序三种遍历的定义。对于先序,保证任意一个节点先于其左右孩子被访问,还要保证其左孩子先于右孩子被访问。对于中序,保证任意一个节点,其左孩子先于它被访问,右孩子晚于它被访问。对于后序,保证任意一个节点的左孩子右孩子都先于它被访问,其中左孩子先于右孩子被访问。如是而已。

代码里应该体现得比较清楚。这里不光给出了非递归版本,也给出了递归版本。
Continue reading »

新的开始

三月 17th, 2010 No Comments »

新的域名,新的开始,新的生活,新的征程!

桶排序、基数排序;等价类、并查集;凸包

一月 5th, 2010 No Comments »

桶排序、基数排序

基本思想:用空间换时间。

要对一个无序序列排序,提供Max个桶,Max为序列各元素中最大的关键字。扫描该序列,将每个元素按其关键字放入对应的桶里,考虑最坏情况,桶的大小应该为n,n为序列长度。然后从第一个桶开始回收,先入桶的先回收(从而保证排序是稳定的),将所有桶中的元素链接起来,得到排好序的序列,考虑到对桶的初始化和遍历回收,时间复杂度为O(max(Max,n)),通常为O(Max)。

一个显而易见的缺点是空间复杂度过大,需要O(Max*n),如果Max很大,则变得不能接受。可以用链表实现桶来改进,或者使用“改进的桶排序”,即所谓“基数排序”。

基数排序主要思想:

以“取基数为10”为例。提供10个桶,每个桶大小仍然是n。用这十个桶进行m次分布,m次回收,即相当于m次桶排序。m为lg(Max),即Max的位数。第一次,按个位数分布入桶,回收得到个位有序序列;第二次,按十位分布入桶,再回收,由于桶排序是稳定的,所以这次回收得到后两位有序序列;以此类推,直到得到有序序列。

时间复杂度为O(m*n)=O(n),m视为常数。空间复杂度为O(10*n)=O(n)。

等价类、并查集

要定义等价类首先要定义等价关系。所谓关系,有序对的集合也。等价关系满足1)反身性、2)对称性、3)传递性。等价类就是具有等价关系的所有点的集合。

与等价类有关的问题有两类:离线等价类问题――关心给出集合和等价关系后,求等价类的问题;在线等价类问题――关心等价类之间的操作,找到一个点所属的等价类、合并两个点所在的等价类等,即所谓的“并查集”问题,find&union。

并查集的一个高效实现是用根节点表示法的树来实现。

凸包

首先需要定义凸多边形,对于多边形来说,多边形上任意两个点(可在边上可在内部)连线上所有的点都包含在多边形内部,这样的多边形就是凸多边形。

对于平面上的点集S,S的凸包是包含所有点的最小凸多边形。

关于优化,还真得帮帮编译器

一月 1st, 2010 1 Comment »

编译器在将C代码翻译成汇编指令时会进行一些有效的优化。但是有的时候编译器会显得很激进,有的时候却又显得很保守。

妨碍编译器进行大胆优化的障碍有这么两个:存储器别名、函数副作用。

对于略显重复的指针解引用运算,编译器是很想将其合并的,但是它又害怕这样的风险――万一两个指针值相同一块内存――即“存储器别名”――如若这样,就会使优化后的程序产生不同的行为,违反了优化的前提。

对于同一个函数的重复调用,编译器也很想用依次调用的翻倍效果来替代,因为函数调用是一项开销很大的工作。但是它同样考虑到这样一种风险――如果这个函数有“副作用”――比如会改变某个全局变量,那么不同次数的调用又会产生行为的差异,同样违反了优化的前提。

以循环程序为例,程序员应做到的优化工作包括:

1.降低循环的低效率

我经常写这样的代码:

for (string::iterator it = str.begin();it != str.end();it++)
        //...

这就是一个存在低效率的循环。由于for()的第二个测试语句每次迭代都会执行,所以str.end()每次都会被调用,而这个函数的结果在循环过程中是不变的,这就造成了很大的不必要的函数调用开销。此为一。

2.减少函数调用

如前所述,函数调用开销很大,如果循环体内有函数调用,则应考虑是否能将其转换为其它操作,不过这一条对代码行为的依赖性较大,不宜强求。

3.消除不必要的存储器引用

存储是一个金字塔型的结构,越往上速度越快,越往下速度越慢。顶端是寄存器,速度最快。如果循环内部有频繁的写内存动作,应考虑用写寄存器代替之。有些运算――如求阶乘、求数组和等――不需要时时用每次循环的中间结果更新存储器中的目的操作数,在整个求解过程结束后再更新也不迟,这就是用寄存器代替存储器的绝好条件。方法是用一个局部变量代替指针引用,在循环推出后再用局部变量更新指针引用。注意,通常说局部变量在栈上分配,但是这是有条件的,首选是寄存器分配:1.寄存器溢出即寄存器不够用的情况;2.一个要对其产生指针的局部变量必须放在栈上。

另外还有一些比较有意思的技术可以优化程序的性能。

4.循环展开技术(loop unrolling)

对于传统的循环控制来说,每次循环执行一次单位操作,即一次迭代的代价是一次条件判断和一次跳转。如果在每次循环内执行多次迭代,则可以降低平均到一次迭代所对应的条件判断和跳转的次数。这就是循环展开技术。如下面代码就是数组求和的二次循环展开:

for (int i = 0;i < len;i += 2)
{
??????? temp += a[i];
????????temp += a[i+1];
}

5.利用处理器的并行处理能力

说到这就要说说处理器在执行指令时的抽象模型了。这几天看《深入理解计算机系统》这本书中关于处理器的介绍,真的体会到现代处理器真是一个带有浓厚工程学味道的杰作,里面包含了很多策略方面的东西,可谓精打细算。

现代处理器是并行的、乱序的、预测的和投机的。

所谓并行是说,处理器并不像我曾经以为的那样每个时钟周期执行一个基本操作,如此往复。处理器中有众多的处理单元,在一个时钟周期内,每个处理单元都可能进行工作,而实际上只要某个处理单元所作处理的依赖条件已经具备,那么它就可以进行该项处理,即使前面的指令还没有执行完毕,此为“并行”,并行之所以可行,还和“乱序”有关系。

由于处理器是并行工作的,所以每个时钟周期内处理器的正常情景是,能工作的单元一定在工作,分别在执行多条指令的不同部分。而根据程序定义,指令是有顺序的,这里的并行打乱了这种顺序,此为“乱序”。之所以可以乱,是因为超前。一条指令的执行过程往往远早于轮到该指令执行的时间,只要相应处理单元是空闲的即可。到了真该指令执行时,将早已得出的存储在处理器中某个寄存器堆上的结果直接送入目的地即可。这种处理方式在遇到可能发生跳转的地方就会遇到麻烦,现代处理器采用的解决方法是“预测”和“投机”。

处理器会大胆预测跳转的结果,然后继续它的提前化取指、译码、执行;如果预测错误,只得退回预测处重新执行,此为“预测错误处罚”。

现在考虑如何利用处理器的并行工作能力优化程序的性能。如果一次循环操作要依赖于上次循环操作的结果,那么就逼迫操作为线性而难以并行化,所以这种优化只能针对没有这种依赖性的问题上。比如将对数组求和分解成奇数项求和和偶数项求和,最后再相加,而奇数和偶数求和之间是没有依赖性的,所以可以利用上处理器的并行处理能力:

for (int i = 0; i?< len;?i += 2)
{
??????? x0 += a[i];
??????? x1 += a[i+1];
}

编译器的辞典

十二月 31st, 2009 No Comments »

这几天在看《深入理解计算机系统》,起初我对它在前言中所说的“只要求读者有C语言基础”、“从程序员角度剖析计算机系统的方方面面”能够同时为真还很怀疑,这些天看了1/3的内容后我相信了,确实只需要C语言基础和对计算机比较基础的了解,而得到的收获又确实很大,真是一本好书。

第三章《程序的机器级表示》说的是编译器将C源程序翻译成汇编代码所依据的原则和一些有限的优化,这里记录一下学习笔记,主要是程序控制方面的翻译,比较有意思,也算是加深印象,别过几天就忘了。

由于编译器的工作是将C代码翻译成汇编指令(从汇编指令生成.o\.obj的二进制可重定位目标文件是汇编器的工作),所以这些优化规则相当于编译器翻译时的辞典。

1.if

if 结构用 test\cmp\jx 来实现。这个很简单,test和cmp能进行类似对条件表达式求值的操作,然后将求值结果用标志位表示,jmp的各种变种可以用标志为指导跳转,从而完成条件转移的功能。

2.循环

循环实际上就是带有条件转移的迭代,每执行一次循环体对条件表达式求值,来决定是继续迭代还是退出循环。所以可以用if\goto的组合来完成和循环一样的控制功能。实际上用for\while\do-while等循环编写出的C源程序与用同样功能的if\goto编写出的C源程序在汇编后得到的代码是相同的。

编译器在翻译循环时,将while和for都翻译成do-while的形式,因为除了第一次迭代以外,while\for和do-while的行为是一致的,而do-while每次循环只需进行一次判断――是否退出循环;而while\for为了要兼容第一次迭代的情况,每次循环要进行两次判断――是否进行循环、是否退出循环。所以,在编译for\while循环时,编译器采取这样的编译策略:在进入循环前先判断第一次循环是否能进行,如果不能进行,跳过循环代码,如果第一次循环能够执行,那接下来的行为就和do-while一致了,便用循环中少一次跳转判断的do-while结构代替之。

3.开关switch

使用跳转表。

C眼看J――初窥JAVA

十二月 14th, 2009 1 Comment »

最近一直在学习JAVA,出发点并不是像当初学C++那样,而只是想把JAVA作为下学期参加比赛的工具,带着这种“浮躁”的心态,使得我总是在想“这个用看么?”、“那个用看么?”。

这是第一次在掌握了一门语言(C++)后学习另一门,而这两门语言又很有可比性。于是我体会到了比较两门语言异同的乐趣。

C++放荡不羁,做每一件事都提供多种途径来完成,不同途径之间又有所区别,从而使得语言特性极为丰富。外人看C++书籍,他一定会觉得C++的程序员吝啬得近乎古怪,多一个拷贝构造函数的代价都不愿意付出,想方设法地向语言特性要效率,这是因为C++的优势即在于此,如若不善加利用实在是妄用C++啊!JAVA则非常严谨,每件事情都只提供不多的做法,而且语法规则极其工整,这带来的直接好处就是易学,但是选择范围小就意味着程序优化的空间不大,当然,效率对JAVA来说从来不是主要目标。

C++要向后兼容C,因此面向对象很不纯正,属于混合型语言,允许全局函数这样明显的过程语言成分的存在,但由于先入为主,C++的不正宗反而让我觉得JAVA很别扭。JAVA基本可以算是一个纯面向对象的语言,非面向对象部分只剩下基本类型,还都提供了外包类用于适应对象接口,但是JAVA将main()放在一个随机(因为放在哪里关系并不大)的类中这一点让我比较不喜欢,我觉得这是设计上的不合理,为什么程序一定要百分之百的由类定义组成呢?既然main()这么特殊为何不把它单独拿出来呢?不过这也无伤大雅了,只是给人的感觉别别扭扭的。

其实我曾经思考过这个问题,究竟人类的思维方式是面向对象的还是面向过程的?面向过程的思想就像是:我们接到一个问题后先将其划分为几个解决步骤,其中有共性的抽象出来作为函数可以重复使用,然后按步骤执行。面向对象的思想就像是:我们接到一个问题先分析其场景和场景中的要素,将其抽象为类,然后思考为解决这个问题场景中的各个要素分别应该承担那些任务或者说具备哪些功能,然后程序员所需要做的就是将这些要素组织起来、协调好工作关系,最后启动这一工作即可。总的来说,在面向过程的思想中,编程者像是一个独裁者,一个执行者,他对一起都说的算,周围的一起都像是它的工具――自动化很低的工具,他一一拿起来完成自己心中的计划;而面向对象的思想中,编程者更像一个组织者,一部电影的导演,他组织剧组里的成员,交代分工和工作时的互相配合,然后在启动拍摄后退居到幕后,具体工作由手下按照他早已做好的安排来完成。

那么究竟人类的思维方式是怎样的呢?面向过程还是面向对象?人们解决问题是更愿意扮演操控者还是组织者?这就是因人而异的问题了,说的玄乎点就是思想境界的问题了。人天生的思维方式肯定是面向过程的,就像“把大象放冰箱分三步”一样是分step one two three的,但是随着面对的问题越来越复杂凭借一己之力越来越难以掌控,人就得学着放权,学着合作,学着组织,这就很自然地过渡到了面向对象的思想。所以说,我认为人的原始思维方式是面向过程的,面向对象思想是人对问题复杂性的一种妥协。

C++非常注意效率,这是从C继承的优点,但是效率意味着安全性的缺失,所以很多本应该高级语言本身做的事情它留给了程序员,很多错误对C++编译器来说只会给出一个警告,需要程序员非常全面了解语言的特性才能完全避免此类错误。JAVA则注重安全而忽视效率,据说早期的JAVA执行速度比C/C++要慢上20-100倍!当然现在已经大幅度提高,但是效率仍然是JAVA的罩门;不过安全性就好多了,JAVA是一门真正的高级语言,高级语言就应该离机器远一些,离人类近一些,JAVA编译器为程序员默默地做很多事,比如Class类对象的内嵌,比如toString()的调用,等等,一切都是为了给程序员提供方便,帮助程序员了却一切不应理会的烦恼,而只让其集中精神于用语言解决问题。

C++更像是一个修修补补的作品,它妄图提供所有人们可能用到的武器,但是人们的需求是随着时间变化的,所以C++就不断增加自己的语言特性来适应时代,但是由于是20多年前的语言,某些方面实现地颇为踉跄,比如解决多线程的方案,比如多重继承。而JAVA更加年轻,在它出生之时就知道自己应该具备哪些本领来满足这个时代,因此它的一些特性看起来要比C++自然得多,比如继承体系中的Object,比如Thread类,比如接口和内部类的配合来实现多重继承。

这两门语言产生的背景不同,背负的使命也自然不同。C++来自洪荒,像宝剑,带着浓烈的英雄主义气息,应该为能力卓群的独行侠所佩,它更适合被主人用来完成一些惟其才能胜任的任务――斩妖除魔,而对于一般性的任务,则有杀鸡用牛刀之嫌,并无优势可言;JAVA来自现代实验室,严谨而又整齐,像工具箱,浑身散发着匠人的熟练,不是用来斩妖除魔的,虽能力有限不能杀敌,但是日常生活中林林总总的问题,都可以用它轻易解决,而且上手快好掌握。C++产生的时代,软件开发还是一个小众产业,编程人员更多是和系统啊底层啊之类的概念打交道,所以更注重效率而不注重易用性和接口性。JAVA产生的时代,软件开发已经成为一个必须要很多人通力合作才能完成的工作,而计算机硬件技术的进步也让效率的重要性降低,所以JAVA更注重接口性易用性从而使它的使用者合作起来更容易也就是很自然的事了。

C++和JAVA,无论是语法还是功能都比较接近,两者的不同点很有意思地体现出了两者各自的使命。

胡言乱语一通,其实我在C++和JAVA方面都是菜鸟,只是学习这两种语言的机会让我体会到了很多有意思的事,不记下来实在说不过去,故乱述于此。

继承和多态的学习笔记

十一月 30th, 2009 No Comments »

C++的继承和多态特性十分多,十分复杂。访问级别有public\protected\private之分,继承方式有public\protected\private之分,函数属性有non-virtual\impure-virtual\pure-virtual之分,对对象的访问又有直接访问\指针\引用之分。所有的这些在继承和多态这里交叉影响,得出了很多各异的特性。可能这也是说C++难学的一个很重要的方面吧。

这段时间分别看了《C++Primer》《Thinking in C++》《Effective C++》三本书关于继承和多态的内容,算是有了一个大概的把握,本想总结的精炼一些,不过现在觉得有点难,暂且记录下学习笔记,待有时间和有能力时,希望自己能把这一块知识好好提炼总结一下,已达到豁然开朗的程度。

学习笔记内容:

面向对象的编程思想有三个基础:
1.封装性:用类实现
2.继承性:用继承实现
3.动态绑定:用虚函数实现
继承性的思想是用旧类创建新类,新类和旧类之间有很大的相似,实现这个思想的一个比较山寨的办法是“组合”。即将旧类的一个对象作为新类的一个成员。
当然比较官方的方法还是“继承”。

《继承》
通过继承我们能够定义这样的类,它们对类型之间的关系建模,共享公共的东西,仅仅特化本质上不同的东西。派生类(derived class)能够继承基类(base class)定义的成员,派生类可以无须改变而使用那些与派生类型具体特性不相关的操作,派生类可以重定义那些与派生类型相关的成员函数,将函数特化,考虑派生类型的特性。最后,除了从基类继承的成员之外,派生类还可以定义更多的成员。
C++中,基类用virtual关键字指明那些它希望派生类重新定义的函数,希望派生类继承的函数不能定义为virtual。除了构造函数以外,任意non-static函数都可以定义为virtual。virtual关键字只用在声明处,不用在定义处。

《动态绑定》
动态绑定允许我们编写这样的程序,对于继承层次中的任意类型的对象都可以使用,无需关系对象的具体类型,无需区分函数是在基类还是在派生类中定义的。
在C++中,通过基类的引用(或指针)调用虚函数时,发生动态绑定。实现动态绑定的关键是:基类的引用和指针既可以指向基类对象也可以指向派生类对象。究竟调用哪个类的虚函数,取决于运行时该引用(或指针)指向的对象的类型。

《如何定义基类》
基类的析构函数一般应定义为virtual。
《?》基类通常应该将派生类需要重定义的任意函数定义为虚函数,why?
对于public和private成员来说,派生类访问基类成员的权限和程序其它部分一样:可以访问public成员,不能访问private成员。
注意,甚至连派生类对象本身也不能访问基类的private成员,在这一点上,派生类和基类的普通用户没有区别。派生类的优势仅体现在protected上。对于基类希望派生类可以访问,但不希望其它代码访问的成员,应定义为protected,protected成员可以被派生类对象访问而不能被普通用户访问。

《protected成员》
派生类可以访问基类的protected对象的意思是,对于派生类对象,其从基类继承来的protected对象是可用的,而对于基类对象的protected对象,派生类仍然没有访问权。例程:
#include <iostream>
using namespace std;

class base_class
{
private:
?int i;
protected:
?int j;
};

class derived_class : base_class
{
public:
?void fun(base_class &bc,derived_class &dc)
?{
??cout<<i<<endl;??//error!base_class::i is private
??cout<<j<<endl;
??cout<<dc.j<<endl;
??cout<<bc.j<<endl;??//error!base_class::j is protected
?}
};
如果没有继承机制,类的用户就只有两种:类本身和类的客户,public和private两种访问限制体现了这两种用户之间的分隔。有了继承机制后,类有了第三种用户――派生类。应该将派生类实现操作所需要访问而不希望普通用户访问的成员定义为protected。
基类的public成员相当于派生类的public成员;基类的protected成员相当于派生类的private成员;基类的private成员对于派生类来说相当于一个与自己无关的类的private,可以使用基类提供的public接口访问它,除此别无他法。

《派生类》
定义派生类的语法为:
class classname: access-label base-class
access-label可以为public,protected,private。表示三种派生方式。有什么区别呢?

《派生方式》
从基类继承来的成员的访问级别由基类的访问标号和派生方式共同控制。派生类可以通过派生方式进一步控制但不能放松继承来的成员的访问级别。
对于基类的private成员,派生类无法访问,因此派生标号控制的是基类的public和protected成员的访问级别。又,对于派生类来说,从基类继承来的public成员和protected成员都是可以访问的,因此,实际控制的是这些继承来的成员作为派生类自己的成员时在派生类中的访问级别属性,即实际控制的是派生类的用户(包括派生类的子类)。
若是public继承,则基类成员在派生类中保持原有访问级别。public还是public,protected还是prptected。
若是protected继承,则基类中的public和protected都是protected。
若是private继承,则基类中的public和protected都是private。
public继承称为接口继承,派生类继承了基类的接口,因此任何使用基类对象的地方,理论上都可以用派生类对象代替。
protected和private继承称为实现继承,它们继承基类的接口不作为自己的接口,而是用来作为自己的实现。
类是使用接口继承还是实现继承对派生类的用户具有重要意义,迄今为止最常见的继承形式是public。
这种限制也是可以改变的,方法是在派生类的定义中在不同的访问控制标号后面使用using声明。
如果在继承的时候不指明派生方式,那么struct默认为public,class默认为private。
一种形象的说明:
public继承相当于昭告天下,D类是从B类派生而来,编译器是知道的。
protected继承是只有自己和自己的派生类知道,即只有D类和D类的派生类知道D类是从B类派生而来。
private继承则只有自己知道。

派生类继承基类的成员并且可以定义自己的附加成员。派生类对象包含两个部分:从基类继承的成员和自己定义的成员。派生类只重定义那些与基类不同或对基类进行扩展的方面。

《重定义成员函数》
实际上派生类可以重定义基类的任何成员,但是只有重定义virtual函数才能实现动态绑定。

《动态绑定》
动态绑定就是父类接口可以接受子类对象,执行哪个版本取决于父类接口(指针和引用)在运行时接受了父类对象还是派生类对象。只有virtual成员可以做到这点。
例程:
base_class bc;
derived_class dc;
base_class* pbc;
int i;
cin>>i;
if(i==0)
?pbc = &bc;
else
?pbc = &dc;
pbc->f();
如果f()是基类的虚函数,而且派生类中重定义了这个函数,那么pbc->f()执行哪个函数就取决于i的输入了。如果f()不是基类的虚函数,即使派生类中重定义了这个函数,pbc->f()也只可能执行基类的版本。这就是virtual关键字对于动态绑定的意义。因为非虚函数的调用是在编译期确定的,pbc是基类指针,只能调用基类的非虚成员函数。
动态绑定可以显式阻止,利用作用域说明符限定虚函数的版本,可以让虚函数的调用在编译期就确定下来。这一技术只能在成员函数的代码中使用。这一技术的一个用途是:可能派生类重定义的基类虚函数需要完成和基类虚函数一样的工作,然后再完成其它工作,这是可以的显式调用基类的虚函数来完成这些工作而避免将基类虚函数的代码拷贝过来。
基类的指针和引用可以指向派生类的对象,但是只能通过此指针或引用调用基类有的成员。因此,使用基类指针和引用时不能确定所引用的对象类型是基类还是派生类,编译器一致当做基类对象处理,将派生类对象作为基类对象处理是安全的,因为派生类对象含有基类对象的子对象。
动态绑定的实现依仗虚指针和虚函数表。

《派生类中虚函数的声明》
派生类中虚函数的声明必须与基类中的定义方式完全相同,除了一个特例:基类中的虚函数如果返回基类对象的引用和指针,派生类中的声明可以返回基类对象的引用或指针,也可以返回派生类对象的引用和指针。

《基类类型和派生类类型之间的转换》
1.概述
对于指针和引用来说,可以进行派生类到基类的自动转换,而没有基类到派生类之间的自动转换。但对于对象来说,编译器不会自动将派生类对象转换为基类类型的对象,虽然我们可以用一个派生类的对象给一个基类对象初始化或赋值。
2.引用转换不同于对象转换
设想一个接受基类引用的函数,如果传递给它一个派生类的对象,这里发生的仅仅是一个绑定,将一个基类引用绑定到一个派生类对象上,派生类对象仍然是派生类类型的,没有改变。
如果这个函数接受的是基类对象而非引用,再传递给它一个派生类对象,则该派生类的基类部分会被拷贝给形参。
后者是用派生类对象给基类对象初始化或赋值,实际上是调用函数――构造函数和赋值操作符。因为基类的拷贝构造函数和赋值操作符都以基类对象的引用为参数,而派生类对象的引用是可以自动转换为基类对象的引用的,因此,用派生类对象给基类对象初始化和赋值是可以实现的。
也就是说,当需要将一个派生类对象转换为一个基类对象时,实际发生的是:
将此派生类对象转换成一个基类对象的引用用来调用拷贝构造函数和赋值操作符,生成一个基类对象。一旦生成,得到的就是基类对象。相当于在初始化和赋值过程中,派生类对象的派生类部分被切掉了。
派生类到基类对象的转换能被那些有访问基类public成员权限的客户访问。派生类本身和其友元肯定可以,至于派生类的普通用户和下层派生类能否访问就要看派生类继承基类时的派生方式了。
3.基类到派生类的转换
没有从基类到派生类自动转换的办法,即使这个基类对象或引用实际绑定了一个派生类对象也不行。如果实际情况中知道这种转换是安全的,可以使用static_cast或者dynamic_cast进行。
《不会自动继承的成员函数》
构造函数、析构函数和operator=不会被自动继承。如果我们自己不在派生类中定义这些成员函数的话,编译器就会用将基类和各成员变量对应的成员函数组合的方式为我们提供这些函数,当然对于构造函数,编译器只能提供缺省构造函数和拷贝构造函数,对于基类中的其它版本,编译器无能为力。同样,编译器也只能通过组合的方式提供作用于相同类型的operator=,而对于基类中的其它版本无能为力。

《构造函数与复制控制》
受继承关系的影响,每个派生类构造函数除了要初始化自己的数据成员外,还要初始化从基类继承来的数据成员。
如果不提供构造函数,编译器同样会提供缺省的构造函数给派生类,与非派生类缺省构造函数不同的是,此缺省构造函数会调用基类的缺省构造函数来初始化基类部分的数据成员。
如果自定义派生类的缺省构造函数,并且在构造函数定义内没有对基类部分的对象进行初始化动作,那么调用此构造函数仍然会自动调用基类的构造函数来初始化基类部分的数据成员。
可以在派生类构造函数的定义中为基类构造函数传递实参,做这项工作需要在初始化列表中完成,而不能直接在函数体内完成。
如果派生类要定义自己的拷贝构造函数,它最好(在初始化列表中)显式地调用基类的拷贝构造函数。可以为基类的拷贝构造函数传递派生类拷贝构造函数收到的派生类对象引用作为参数,它会自动被转换成一个基类对象引用用以调用基类的拷贝构造函数。如果不显式调用基类的拷贝构造函数,基类的缺省构造函数将被调用,产生奇怪的效果:新构造出的派生类对象的基类部分是缺省初始化的,而其它部分却是拷贝初始化的。
同样,派生类的operator=也应如此定义。
析构函数则只需要负责自己的部分,编译器会保证所有层次的析构函数被调用。

《为什么基类的析构函数应该是虚函数?》
因为一个基类指针可以指向基类对象也可以指向派生类对象。而销毁这个指针的动作会导致该指针静态类型的析构函数被调用。如果该指针的静态类型与动态类型不符,那么就会导致未定义行为。即,销毁一个指向派生类对象的基类指针,导致调用基类析构函数去析构一个派生类对象这样的未定义行为。
因此,基类的析构函数应该定义为虚函数,这样在销毁基类指针时,调用哪个析构函数可以根据指针的动态类型来确定。

《函数调用所遵循的四个步骤》
1.首先确定进行函数调用的对象、引用或指针的静态类型。
2.在该类中查找函数,如果找不到,就在直接基类中查找,如此循着类的继承链往上找,直到找到该函数或者查找完最后一个类。如果不能在类或其相关基类中找到该名字,则调用是错误的。
3.一旦找到了该名字,就进行常规类型检查,查看根据找到的定义,该函数调用是否合法。
4.假定函数调用合法,编译器就生成代码。如果函数是虚函数且通过引用或指针调用,则编译器生成代码以确定根据对象的动态类型运行哪个函数版本,否则,编译器生成代码直接调用函数。

《纯虚函数》
可能我们会想定义这么一个类,只希望其它类从它继承,而不希望实例化该类的任何对象,那么应该将它定义为一个抽象基类。
含有或继承(即没有重新定义)一个或多个纯虚函数的类叫做抽象基类,抽象基类无法实例化。
纯虚函数的定义方式是在参数列表后面直接写“=0”:
double net_price(std::size_t) const = 0;
单纯继承抽象基类得到的仍然是抽象基类,只有将所有纯虚函数重新定义后才不是抽象基类。
当定义一个纯虚函数时,相当于告诉编译器在虚函数表中为该函数留一个位置而不保存任何地址,这样就得到一个有不完整虚函数表的类,这种类就是抽象基类。对于虚函数表不完整的类,编译器阻止它实例化。同样还会阻止可能发生的对象切片使抽象积累的对象被按值传递到函数内部。
纯虚函数也是可以给出定义的,直接给出函数体即可。允许这样做的目的是,也许各个派生类中对该纯虚函数的覆盖都会用到同一段代码,这样可以将这段代码作为基类中该纯虚函数的定义,然后在各个覆盖版本中显式调用它即可。

《向上映射》
可以将基类的指针和引用绑定到派生类对象上,但是要求派生方式为public,否则无法绑定,例程:
class A{};
class B:public A{};
class C:protected A{};
class D:private A{};

void fun(A&){}

int main()
{
?B b;
?C c;
?D d;
?fun(b);??//ok
?//fun(c);?//!error
?//fun(d);?//!error
?A* pa;
?pa = &b;?//ok
?//pa = &c;?//!error
?//pa = &d;?//!error
}
为什么?

《C++如何实现晚捆绑》
对于每一个有虚函数的类,编译器为它维护一个单独的虚函数表,称为VTABLE,不管该类实例化了多少个对象,虚函数表都只有一个,虚函数表里存放的是该类所有的虚函数的地址。对于有虚函数的类的对象,编译器在对象结构中(通常是开头)秘密插入一个指针,称为虚指针――VPTR,因此其它结构两个相同的类,一个含有虚函数,一个不含虚函数,那么后者的sizeof比前者大4个字节――一个void*指针的大小。此虚指针存储的是该对象对应的类的虚函数表。当利用基类的地址或引用绑定某基类或派生类对象而调用虚函数时,编译器查看该对象的虚指针,从而得到该对象的类型(实际是得到虚函数表的位置),从而在函数调用的地方插入虚函数表和相对位置来定位要调用的函数(非虚函数的调用是直接插入对函数的汇编CALL的),完成函数调用的晚绑定。
在一个派生体系中,各个类的虚函数表中各函数指针的相对顺序是固定的,父类虚函数在前,子类新增的虚函数在后。
在调用任何虚函数之前安装虚函数表指针是很重要的,安装VPTR的工作在构造函数中进行。

《构造函数和虚指针》
构造函数是安装虚指针的地方,而对于派生体系中的一个对象而言,它的初始化会导致该体系中从根到它所在层次的所有相关构造函数都被调用,而每个构造函数都会做一次初始化虚指针的工作,将该对象虚指针指向该构造函数所在类的虚函数表,而下一层次的构造函数会再次给虚指针赋值以覆盖它为当前类的虚函数表,因此,该对象的VPTR状态是由最后被调用的构造函数所确定的。
在一个个构造函数被依次调用的过程中,VPTR始终指向的是此构造函数对应类的VTABLE,因此在构造函数中调用虚函数,将屏蔽虚机制而直接调用本地版本。
实际上,析构函数中也屏蔽虚机制,调用虚函数的本地版本。
构造函数和析构函数中,虚机制被屏蔽的另一个容易理解的原因是,在构造函数和析构函数被调用的过程中,对象是不完整的,可能虚机制会导致虚函数操作还未产生的成员。
《对象切片》
按传值的方式将派生类对象传递给基类对象的形参,会发生对象切片,对象切片是安全的,得到的是地地道道的基类对象,无论数据成员还是成员函数都是基类的。因为按值传递时会调用拷贝构造函数,所以切片得到的基类对象有机会在拷贝构造函数中将自己的VPTR设置为正确地指向基类的VTABLE。

《Notes》
1.基类必须是定义过的类,而不能是只声明过的类。
2.派生类可以做基类继续被其它派生类继承。
3.如果只声明派生类而不定义它,那么不能写出派生列表。
4.虚函数也可以有默认实参,但是需要注意的是,如果使用指针或引用来调用虚函数,默认实参所取的值与指针和引用类型相关,而不取决于动态绑定时实际绑定的对象类型。
5.友元属性不能被继承,基类的友元不是派生类的友元;基类是友元,派生类也不会成为友元。
6.即使被继承,在继承层次中,static成员仍然只有一份实例。访问控制同普通成员。
7.一旦你开始定义派生类的拷贝构造函数和operator=,编译器就假定你知道自己在做什么,就不会再为你自动调用基类版本的拷贝构造函数和operator=了,而如果你不定义派生类的拷贝构造函数和operator=的话,编译器是会在提供组合版本时为你调用的。因此,定义派生类的拷贝构造函数和operator=时,应显式地调用基类的拷贝构造函数和operator=,否则将默认调用基类的缺省构造函数,而基类的operator=将不会被调用。
8.static成员函数不能为virtual。
9.派生类的构造函数只能初始化自己的直接基类。
10.派生类构造函数不能初始化基类的成员且不应该对基类成员赋值。如果那些成员为 public 或 protected,派生构造函数可以在构造函数函数体中给基类成员赋值,但是,这样做会违反基类的接口。派生类应通过使用基类构造函数尊重基类的初始化意图,而不是在派生类构造函数函数体中对这些成员赋值。
11.对于一个指向派生类对象的基类指针:
B* pb = &d;
来说,
pb->f();
会导致多态调用D::f(),然而,如果指定类名:
pb->B::f();
便可以屏蔽多态,调用B::f()。
12.如果D是从B以private方式派生,那么就不能用D对象给B指针或引用赋值,因为D对象中的B部分对于D对象是不可访问的,而如此赋值的目的就是访问这些部分,所以编译器阻止,但如果如下方式赋值:
B* pb = (B*) &d;
就可以,就像B是从D以public方式派生来的一样。
13.由于构造和析构过程中对象类型的不确定性,编译器认为在构造和析构的过程中对象的类型是变化的。如果在构造函数和析构函数内调用虚函数,则虚函数的版本依构造函数和析构函数的类型而定,而不再依据对象的具体类型。
14.基类指针引用虽然可以指向派生类对象,但只能通过它们访问对象的基类部分。对象指针和引用的静态类型决定了对象的行为。
15.派生类的成员名字将屏蔽基类中的成员名字,因为派生类的名字空间嵌套在基类中,编译器先在派生类中查找该名字,一旦找到,就不再继续寻找,如果没找到,再到基类中找。因此,如果基类和派生类都定义了相同名字的成员函数,但是参数表不同。通过派生类对象调用该函数,但是传递符合基类版本的参数的话,会造成错误,因为编译器在派生类中找到同名函数后就不再继续查找了,而这个函数是不能接收基类版本函数的参数的。相似情况也适用于局部作用于中声明的函数和全局作用域中定义的函数之间的关系。
16.如果基类中的成员函数有多个重载版本,那么要想通过派生类类型调用所有这些版本,要么一个都不能重定义,要么就要全部重定义。如果只有一个版本需要重定义,而其它版本想要直接使用基类版本的话,则应该使用using声明说明基类该成员函数所有版本可用(因为using声明不说明参数表),然后重定义所需版本。
17.protected继承只是为了语言的完整性,private继承还有些许功能,最常用的应该是public继承。
18.选择继承还是组合技术的一个重要依据是两者关系是is-a还是has-a,另外,是否需要向上映射。
19.编译器会在构造函数的头部秘密插入能够设置VPTR的代码。
《Effective C++》
《条款32――确定你的public塑模出is-a关系》
public继承应该用在派生类和基类之间存在“is-a”关系的情况。适用于base-classes身上的每一件事情一定也适用于derived-classes身上,因为每个derived class对象也都是一个base class对象。编译器可以完成从派生类对象到基类对象的各种转换(指针,引用,值传递)。
《条款33――避免遮掩继承而来的名称》
通常,在public继承下,应该继承基类的所有成员函数,而不能重定义以对其遮掩,因为这违反public塑模出的is-a关系。但是,在private继承下这是有意义的。这时重新定义基类函数会造成基类中所有版本的函数都被遮掩,要让基类函数在派生类中重见天日,可以使用using声明或者转交函数,在转交函数内部显示调用基类的函数。
《条款34――区分接口继承和实现继承》
public继承表面是is-a的关系,所以在public继承下,派生类总是继承基类的接口,但是基类的成员函数属性的不同,代表了它所希望派生类去继承它的方式。
基类的函数可以有三种属性:
1.non-virtual函数――希望派生类以实现继承的方式继承,即强制性指定了实现方式,不希望派生类对其重新定义――条款33。
2.common-virtual函数――希望派生类继承此接口,并且具体实现,但是提供了一份缺省实现供不需具体实现的派生类使用。
3.pure-virtual函数――只希望派生类继承此接口,具体实现希望派生类自己定义,这类函数显示的是派生类的特性超越继承自基类的共性。
《条款36――绝不重新定义继承而来的non-virtual函数》
原因见条款32、33、34。
《条款37――绝不重新定义继承而来的缺省参数值》
因为我们已经达成共识,不会重新定义继承来的non-virtual函数――条款36,因此这里讨论的就是不要重新定义继承来的virtual函数的缺省参数值。原因很明白,virtual函数实行动态绑定,而缺省参数值却是静态绑定。所以如果重新定义了继承来的virtual函数的缺省参数值的话就可能导致这样的奇怪行为――用基类版本的缺省参数值调用派生类版本的函数。
《条款38――通过复合塑模出has-a或“根据某物实现出”》
复合――即将某类的一个对象作为新类的一个成员。复合技术和继承有些许相似之处,都是用旧类生成新类。前文提到,public继承代表的派生类和基类关系是is-a,而复合代表的新类和旧类关系有两种――has-a(应用域)和“根据某物实现出”(实现域)。比如一个Person有一个PhoneNumber,一个set用一个list实现。

关于new和delete的学习笔记

十一月 22nd, 2009 No Comments »

new & delete 有三种方式为一个对象分配内存:

1.在静态存储区,存储空间在程序运行之前就可以被分配,生命周期持续到程序结束之前。

2.进入一个左大括号,将所有此作用域内的对象的内存在栈上分配,出此右大括号时释放。

3.在程序运行时在堆上动态分配内存,用new操作符,在堆上分配的内存需要程序员负责释放,用delete操作符。如果仍然使用C库中的malloc()(及其变种)和free(),那么构造函数和析构函数得不到机会被执行。C++的做法是将malloc()、free()以及对构造函数、析构函数的调用等等一系列动作封装成两个操作符:new,delete。

new在堆上申请一块内存,并将其作为一个对象调用构造函数进行初始化,然后返回其地址(即指针): int *p = new int(2); 因为new要调用构造函数,自然就要接受构造函数所需的参数。 delete负责调用欲释放对象的构造函数,然后释放这块内存将其还给堆:

delete p;

如果用new在堆上创建了一个对象,然后通过一个void指针去对其执行delete,那么仅仅会释放内存而不会调用析构函数。用于数组的new和delete

MyType* fp = new MyType[100]; MyType* fp2 = new MyType; //分别在堆上创建了一个数组和一个对象

delete fp; //Not the desired effect

delete fp2; //OK

//因为new的时候返回的都是MyType*,这两个语句效果相同,都只调用了一个对象的构造函数,即对于fp,另外99个对象没有被析构,但是释放的内存大小却是正确的,因为这块内存是连续分配的,而大小被分配机制存储在程序某处。要做到正确的delete一个数组,应该告诉编译器这个指针指向的是一个数组: delete []fp; 这样编译器知道这是一个数组后会查找数组大小,然后进行正确的动作。原始的版本是:

delete [100]fp;

当操作符new无法得到足够的内存时会发生什么呢?会有一个称作new-handle的函数被调用,此函数的缺省动作是抛出一个异常。我们可以用自己写的函数替换它,方法是:包含new.h头文件;写一个自己的new-handle函数,名字不限,必须返回void,且无参数;以函数地址为参数调用set_new_handle()函数,完成替换。

new和delete这两个操作符也是可以重载的,用于自定义动态分配内存时的动作,然而虽然new和delete除了分配内存释放内存外还会调用构造函数和析构函数,但是用户能够自定义的只是操作内存的部分。可以重载全局的new和delete,也可以重载成员的new和delete。全局操作符重载方法:

void* operator new(size_t sz)

{

//…

}

void operator delete(void* m)

{

//…

}

重载全局版本的new和delete运算符会导致所有类型的动态分配和销毁都调用重载版本的new和delete。如果只想作用于特殊类型,则应该只重载成员操作符,方法:

void* MyType::operator new(size_t sz)

{

//…

} void MyType::operator delete(void* m) { //… } 为一个类重载了new和delete后,任何是否在堆上创建一个此类的对象都会调用该new版本,此内存的释放也一定会使用该delete版本。然而,如果在堆上分配和释放该类的一个数组,则仍然会调用全局版本的new和delete,屏蔽方法是提供数组版本的new和delete运算符:

void* MyType::operator new[](size_t sz) {

//…

}

void MyType::operator delete[](void* m)

{

?//…

}

?关于构造函数的调用:完全版本的new的工作是分配内存,返回void*指针(因为目前这只是一块内存,还无所谓类型,所以是void*,待构造函数被调用后才成为一个对象),然后调用构造函数。然而,如果内存分配失败,即返回0,那么构造函数是不会被调用的。改变new的参数表:当我们重载new时,是可以改变其参数表的,原本new只有一个size_t参数,且在调用的时候无需指定,由编译器计算并传递,我们也可以提供第二个、第三个等等参数,然后由程序员在调用new的时候负责传递。

如:

void* MyType::operator new(size_t sz,void* loc)

{

return loc;

}

//use:

int* l = new int[10];

MyType* pmt = new(l) MyType;

这个重载版本显示了这种改变new参数表技术的一个应用,即指定我们希望被分配的内存地址。