自学编程,从此开始

上第2学堂,听有趣的编程课

课文: 《你好,对象!生死版》 (点击查看完整内容:视频+评测+讨论+……)

作者:null

定制类型,从生死开始

课文题图

 

第8节 你好,对象!生死版

1. 为什么会内存要分区?

内存干嘛要分个“长命区”,“短命区”的?不是万恶的资本主义社会才有贫民窟和富人区吗?

不要幻想哪里有大同社会,程序里也没有大同社会。“分类”无关政治,分类是宇宙间必然存在,宇宙间的合理存在。

譬如我们最早接触的数据,是一个字符串字面常量,请见 :

cout << "Hello World!" << endl;

从程序开始运行,这串 “Hello World!” 的内容就不会变——程序执行1000次,每次运行从这行代码经过1000次,输出的都是“Hello World!”。如果发生过一次变化,就足以把程序员吓得从椅子上滑下去。程序中所有这样的字面常量,应该住在同一个区域,再为它们建一道围墙;发现任何试图越过这道墙修改当中数据的操作或企图,可以马上报警。这个区域叫 “静态数据区”。

所以,不是先有“分区”,再有适合某个分区的数据,而是人们发现程序中数据大有不同,于是归纳一下,分出几大类,然后为它们设置居住的分区。贫民窟和富人区也是这样的来的:先人穷人和富人,才慢慢的,自然并且必然地,产生了贫民窟和富人区。

那么,C++程序对内存到底分成几个区呢?我们现在知道的有堆数据区、栈数据区和刚刚讲到的静态数据区。还有吗?还有,但不用去学它。没意义。有意义的是掌握数据的特点。

2. 栈数据?堆数据?为什么?

2.1 为什么要有栈区?

学生时代数学考试,除了卷子,还分有草稿纸。草稿纸就是典型的“栈”数据。它在这一场考试时出现,然后在这一场考试结束之后就被抛弃。何止草稿纸!你在草稿纸中写的内容,也是栈数据;它们写的计算的过程,但你要的结果。

用完就丢的数据无处不在,无时不在。真正1懂生活的人,都喜欢栈数据,只有年纪大了,才慢慢变得不喜欢丢东西,慢慢在家里收藏了一堆的堆数据。

为了某一次计算,我们需要大量中间数据、并且这些数据通常是临时数据。比如使用上一节所学的基于计数的循环流程在屏幕输出一个矩形:

for (int i=0; i<6; ++i)
{
    cout << "*********" << endl;
}

我们要的屏幕上的一个图案,循环中的计数变量 “i” 就是一个很适合住在“短命区”的数据。for循环开始出生,for循环结束死去。

说个好玩的往事。以前有一种在线游戏,玩家之间可以互换“宝贝(值钱的游戏道具)”。可是经常发生一个玩家当面给了另一个玩家宝贝之后,另一个玩家撒腿就跑的事。很快,大家找到了解决办法:两个人找一个无人地方,并且保持一个距离面对面站定,再各自把宝贝放在地面,接着一边彼此盯着对方,一边往对方位置走过去,一旦发现不对赶紧回跑,捡回自己的宝贝……这方法也没能坚持多久,因此很快游戏中就出现专门在这时候冒出来,捞取“鹬蚌相争,渔翁得利“好处的人。这游戏的开发者,怎么就不搞一个中立的第三方作为专门的交换机构呢?

在C++程序中要交换两个变量的值,也需要一个中间变量。比如交换两个整数的值:

int a = 123452409;
int b =  983424343;

//定义一个临时变量:
int tmp = a;  //临时保存a的值 
a = b;    //改了a
b = tmp;

大量操作需要大量的临时数据,如果由程序员来一个个一遍遍处理这些数据的死,那得多麻烦、啰嗦,多容易出错?所以,干脆设置一个区域,只要是这类数据,就在这个区域里出生;而只要是在这个区域里出生的数据,就可以用完就自动释放。这个区域就叫栈(stack)。

2.2 为什么要有堆数据

某天你和女朋友去看了电影,然后后各回学校。请问,那家电影院还在吗?宾馆还在吗?女朋友还在吗?有些数据确实是公共的,比如电影院;在C++程序中可以称为”全局数据”,意思是在程序的整个运行期间,它都应该都存在才对。电影院是公共的数据,不仅仅为你而存在。我若要是说你的女朋友也是公共的,你肯定发飚;但你也应该承认,你已经换了12任女朋友了!最短的7天,最长的8个月……女朋友( 这个角色)就是一种堆数据。不太好明确她的生命存周期。

女友是时间的问题。退回刚才提的影院,其实也不一定合适设计成全局数据。假设把你的角色从学生换成一个成功的商人,天天全球到处跑;那你就能理解,当你在欧洲、北美、或非洲的某个国家活动时,中国某个城市的某家影院真的需要是一个全局的存在吗?它可能只需要在某个空间区域存在即可。把没有多大知名度的影院设计成堆数据也是可以的。

堆数据最主要作用,还是在特定范围内的不同操作场景之间,实现同一数据的传递(你可以这个过程理解成时空穿越)——一旦我们遇上这样的问题,就意味着我们的编程技能已经接近实用了;所以这事留到以后再说。今天只需要知道,有些数据没办法在它出生时明确它的死期,有些数据没办法用完就丢就够了。

有些编程语言,干脆规定了什么数据只能是栈数据,什么数据只能是堆数据——于是就有了”值数据“和”引用数据“的区分。C++语言不做这样的限制。C++认为仅从数据类型很难下这样的结论——这么说吧,女朋友真的就一定是堆数据吗?有不少大学生在读书期间交朋友,嘴上不说,心里清楚得很:毕业就分手。《启蒙》篇说过了,在C++眼里,大家都是成年人,诚实一点,不要自欺欺人了。

3. 堆数据语法

3.1取值操作符

堆数据的创建(new)和释放(delete),视频里已经说得比较清楚了。这里重点说说它的使用。

先来一个int类型的堆数据,先看它的创建:

int*  a = new int(999);  //注意a前面的星号

注意中间那个星号()号,在类型之后,名字之前。把星号往名字那边靠,写成 int a = new int(999)也是可以的。

然后我们想看看这个堆数据的值是不是真是999,怎么输出呢?还是要多一个星号:

cout << *a << endl;  //注意a前面的星号

当 “*” 作用在一个堆数据上,这个过程称作“取值操作”。也可以称为“解引用”操作。

课堂作业-1:

完成上述a数据定义、初始化、取值(输出值)的代码实践。

记住栈数据和堆数据在取值操作上语法不同:栈数据本身就能代表值,而堆数据需要使用星号来间接取值。请对比:

int i = 100;
cout << iz << endl; //直接得到栈数据的值 

int* id = new int(100);
cout << *id << endl; //间接得到堆数据的值

两个问题:

  1. 堆数据为什么得有一个取值操作的过程,不能设计成像栈数据那样直接取值吗?
  2. 如果不带*,直接输出堆数据,得到什么?

3.2 堆数据内存布局

先说一个结论:每当用常规方法定义一个堆数据,都将同时创建一个栈数据。意思是:堆数据不仅要占用堆内存,也要占用栈内存。在堆中存放真实数据,在栈中存放”存根“数据。假设有:

int* p = new i(999);

则有:在堆中分配、占用一块内存(假设地址是8900123),用于存放整数999;在栈中分配、占用一块内存,用于存放在堆中所占用内存的地址(8900123)。如图:

堆数据内存布局

所以,简单地说 例中的 ”p“ 是堆数据是有”瑕疵“的。实际上”p“是栈数据,它的值是一个地址(图中的8900123),通过这个地址到堆中一找,所找到的999那块内存,才是堆数据。 ”通过地址去找值“这个过程,就是”取值“操作,就是刚刚学习的”*“操作符。

int* p = new int(999);

//输出一个长长的数据,是一个地址,但它是p的值。(p所在的栈内存,存着这个长数字)
cout << p << endl;
//输出999,999存在堆数据中
cout << *p << endl;

来一段看图说话。上图中:

前几天我碰上一位小学同学,我听闻他已经发了好几年了,住在不知哪个富人小区。于是我问他:“同学,你住哪里?”。他面露为难,最终他报一个普通小区的地址——是的,住富人区的人总是比较想保护自己的隐私。C++程序中也是类似的行为。我们基本都是在和栈中的数据直接打交道,真要访问到堆中的数据,也是找它在栈中的那个随时可以死掉但并不可惜的代理人(经纪人?)。

正由于我们实际打交道总是通过p,所以,仍然会一直在说”p“是一个堆数据。包括我们要释放堆数据,也是直接在 ”p“ 身上执行 delete,即:

//直接在p身上执行delete:
delete p; //正确
delete *p; //错误

课堂作业-2:

完成创建一个 int 类型的堆数据p,然后输出 p 和 *p 的内容,并最后通过delete 释放堆数据。

在堆中分配的内存,遵守堆数据生命周期的特点:如果不主动delete,就不会主动释放归还。在栈中分配的,用于存放堆地址的那块内存,则遵守栈数据生命周期的特点:出了生命周期,会自动死去,自动释放。

由于在同一个程序中,存放一个地址所需要的内存大小是固定的,所以,不管什么类型的堆数据,它们在堆中占用的内存大小可能不一,但在栈占用的那一块地址,是相同的。

4. 定义结构、构造、析构

使用struct定义一个结构类型、并为它定制生与死行为的语法:

struct  S
{
    S() {……}
    ~S() {……}
};

再次提醒最后的 ”;“号。由于直接使用C语言的语法,因此定义完一个新结构之后,可以马上用它定义一个变量,比如:

//C风格中常做的事,C++中较少这样定义:
struct S
{
    ……
} s ; //<-- 注意这里的小s,它是一个变量。

严格讲,为什么要有分号,就是从这里来的。不过C++中很少这样做。所以大家可以按视频中的说法来记住这里需要一个分号:此处是在定义类型,和复合语句(包括函数体)上无关;基本上,只有复合语句的{}之后不需要分号。

有关构造函数、析构函数的调用时机、作用、视频课程中讲得很比较清楚了。简而言之,构造是用来初始化该类型对象的好时机。只不过我们现在举的例子中,结构中都只有构造和析构两个函数,没有任何数据;所以不需要初始化。

因为每个对象都可能存在不同,所以典型地,用于初始化的构造函数,是可以带参数的。以视频中的Person结构为例,假设有的娃比较爱哭,有的娃比较不爱哭;想让Person的构造过程中体现这一点,我们可以给它一个参数,表示”哇哇哇“的次数:

struct Person
{
   Person(int count /*哭几遍?*/ )
   {
       for (int i=0; i<count; ++i)
       {
           cout << "哇哇哇!";
       }

       cout << "你好,世界!" << endl;
   }

    ~Person()
    {
        cout << "呜呜呜!再见,世界!" << endl;
    }
};

课堂作业-3:

完成定制哇几次版本的构造函数上机。

自然会想到,出生时可以定制哇几次,告别时也应该可以定制呜几回啊。可是为什么视频里有说一句:”析构函数倒是永远不需要(也不能有)参数“呢?

先说答案:首先,定制告别行为肯定是可以做到的。但是,析构函数不能,也不需要参数这个不仅语法上是正确的,在道理上也是说得通的。一个娃娃的出生,他是男是女,是胖是瘦,长不长头发?是不是双眼皮?皮肤白吗?等等等等这些定制数据,我们都应该将它们理解为是上帝的恩赐或父母的基因。你总不能因为一个自家儿子出生时面容(竟然)不像吴彦祖,就揍他一顿吧?所以,这些数据可以作为构造函数,由调用者(上帝)传入。但一个人去世时,他自己会哭几声,他的亲戚会哭几声,走的时候是健健康康还是病魔缠身,走的时候是了无所憾还是遗憾终身,这一切,正如保尔柯察金所说,是要靠这个人自己一生的努力和奋斗的;是这个人临死前的”状态“。在类设计上,状态就是视频课程中所说的,一个对象的”内部“数据。

简单说法就是:构造更多靠外部先天配置,析构更多靠内部后天努力。

下一节课,我们会往Person类加入一些内部数据、也会加一些对外的能力。

这节课很重要哦,快去通过小测检查自己的哪些已经掌握,哪些还理解有误。