自学编程,从此开始

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

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

作者:null

让类型丰满起来!

课文题图

 

你好,对象!成员版

1. 从“的”说起

C++语言中有三种“的”的表达,分别是“::”、“.”和“->”,这一节课我们学齐了。

1.1 ::

四个点(两个冒号)是我们最早学到的“的”。它首先可以用在名字空间上,比如:

#include <iostream>

int main()
{
    std::cout << "Hello World." << std::endl;
}

上面代码没有写 “using namespace std;”,于是在使用cout时,必须明确地指出它的出处:

std::cout

这节课,我们学到它的另一个用法是,用在结构类型之上,用以表示某个符号的隶属(出处)是某一个结构类型:

struct Person
{
    void Hello();
};

void Person::Hello()
{
    /* ……  */
}

类的名字(比如:Person),还是名字空间的名字(比如:std),二者的共同特点是:都不是对象。对象是什么?之前课程说过,对象是“东西”、是“数据”,因此它们可能是变量、常量等,是要在程序运行期间占据内存的。但是在一个符号前面通过“::”来加限定,玩的是编译期间的名字变换游戏。比如说,你是一个部门经理,你手下有三个员工,其中两个不巧同名了,全都叫“张山”。怎么办呢?一打听,一个来自广东,一个来自吉林,于是大家分别称呼他们是“广东::张山”和“吉林::张山”。现在的问题是:广东归你部门管吗?吉林归你部门管吗?当然不是。只不过是相当于修改了两个下属的名字而已。由于C++程序需要编译,因此这种“变换名字”的过程,是在编译的过程就完了,在程序运行就不用有任何附加的运算(除了名字长一点可能会多占用一点点内存空间之外,它并不会占用你的CPU的计算资源)。

1.2 "." 和 “->”

和"::"最大的不同在于:"."和"->"前面的主角是一个对象。为什么要称之为“一个对象”而不是“一个数据”呢?因为这个数据的类型通常是用户自定义的一个复杂的结构类型,比如本课所学的:

struct Person
{
    std::string name;
    void Hello();
};

Person p1;  放初值999),假设这块堆内存的地址是123456。它在栈中也占用一块内存,用来存放999存在堆中的地址,即刚说的123456。要访问(读取或修改)堆数据最终的值,需要有一个间接操作。在本例中,先通过地址123456在堆中找到那块内存,再对其内的999这个值进行读取或修改,这个从地址到值的过程,使用取值操作符“*”来实现:

```C++
int* pi = new int(999);
cout <<  *pi ; //读取,通过*取值
*pi = 1000; //修改,通过*取值

堆数据相当于你口袋里有张银行卡,但钱(999元)其实存在银行里,你对它的操作是间接操作。

以上是之前课程所学内容。这节课的变化是:我们所要处理的数据,不是简单数据,而是其自身内部有子成员的结构数据:

Person p2 = new Person;  //p2是一个堆数据

p2是一个堆数据,既然是堆数据,想从它身上得到它的值,仍然是使用取值(*)操作符,比如:

*p2; //合法的操作

p2是一个堆数据,并且它还是一个结构数据,也就是说,它存放在堆中的东西,是一个结构。第一步,我们通过*操作,找到这个结构;第二步,要从这个结构上,找到它的内部的一个成员,那就再使用本课学习的成员操作符 . 喽:

cout << *p2.name << endl; //错误

可惜,上面这行代码编译出错,但我们离答案就差一个小学三年级的数学知识了:优先级,或者叫结合率吧。成员访问操作符(.)的优先级高于取值操作符(*),所以上述代码先计算的是 p2.name,但p2是一个堆数据,于是出错。解决办法当然也只是用到小学三年级的数学知识即可:使用圆括号 () 来明确地指定计算优先级:

cout << (*p2).name << endl; //正确
(*p2).name = "张二妞";    //正确

这就是对结构类型的堆数据访问其成员的正确答案。但视频课程里教的为什么是减大小于(->)呢?“->”就是一个为了让程序员偷懒而制定出来的符号:它只需额外输入 2个符号,而(*).这种正经方式,需要额外输入4个字符。如果硬要再说个优点的话,那就是它正好是一个箭头的形状,因此在表意上倒是更加直观了:指向。

那为什么干脆全部用最方便的点操作就好呢?确实像C#、Java等语言就只有(.)这样一个大一统的成员访问操作符。这是因为C++是一门有指针的语言(p2就是一个指针,堆数据先天就是指针形式),并且有许多重要的,有效的特性需要严格信赖对栈数据和堆数据的区分。比如我们已经学习到的:栈数据可以自生自灭,但堆数据需要程序员手工释放,这就是一个非常重要的特性。如果在代码混淆二者的用法,只会让程序员更加容易忘记什么数据需要释放,哪个不需要。

2. 假装自己是堆数据:取址操作 &

刚刚说:最好不要混淆了栈数据和堆数据,可是像C++这样一门给成年人使用的语言,如果我们有需要将栈数据假装成堆数据,它也可以满足我们。方法就是使用取值操作符&,取得一个栈数据的地址,然后再通过个地址,找回那个栈数据——听起来是不是有些无聊?如果继续前面的钱的比喻,那就更无聊了:你把钱放在左边口袋,然后在右边口袋放一张纸条,上面写着:“你的钱存储在大英帝国‘LEFT-POCKET’银行”。每次要用钱时,先从右边口袋取出纸条一看,再说一句:“哦,我的钱在左边口袋呢……”,于是手伸入左边口袋……

看似无聊,但这其实能带来巨大的用处,我们先不说,先来学习 & 的用法:

int i = 999; //如假包换的 栈数据
int* pi = &i;  //通过&取得i的地址,然后放在pi数据中。

现在pi的表现,就非常像一个堆数据了,来看如何读写它:

cout << *pi << endl; //也是要用 * (取值)
*pi = 1000;
cout << *pi << endl; //1000
cout << i << endl; //也是1000

以上完整代码过程,用口袋里的钱来比喻,就是:

int i = 999; //我在左边口袋里装入999元;
int* pi = &i;     //我在右边口袋里放一张纸条,上面写“钱在左边口袋里”;

cout << *pi << endl; //我从右边口袋里拿出纸条,看一眼,根据它的指示找到左边口袋,看一眼,哦:999元
*pi = 1000;          //我从右边口袋里拿出纸条,看一眼,根据它的指示找到左边口袋,往里扔进1元。
cout << *pi << endl; //我从右边口袋里拿出纸条,看一眼,根据它的指示找到左边口袋,看一眼,哦:1000元
cout << i << endl;   //我直接看了一眼左边口袋,看一眼,哦:1000元。

虽然装得很像,但记住,p2并不是真的堆数据,所以那块存放999的栈内存生死,仍然是自生自灭的。所以,千万不要因为混了,而写上一行: delete pi; 操作,如果这么做,你的左边口袋可能会着火,也可以会冒出一只兔子,甚至有可能你发现自己因此怀孕了——在C++中,delete一个栈中的地址,这叫未定义的行为——意思是C++不负责,不担保你的程序因此而产生的一切后果。

3. 成员数据、成员函数、this

这节课我们定义的结构,有一个成员数据和一个成员函数:

struct Person
{
    std::string name;  //成员数据  member data
    void Hello();   //成员函数  member function
};

视频课程中一直在强调:每个该类型的对象独立拥有一份成员数据,比如:

Person o1, o2;
o1.name = "张三";
o2.name = "李四";

o1和o2各有自己的name(名字),这真是再直观不过了。现在问题是:每个对象是否同样拥有自己的一份成员函数?比如,是不是o1有一个Hello()函数,o2也有一个呢?

不是的。

o1和o2共用一同一个Hello()函数——这个知识点和之前所学的自由自在的,不归属任何类型的Hello()函数保持一致。我们每次调用Hello()函数可能会得到不同的结果,原因不是Hello()函数有很多份,原因是每次调用它时,所伟传入的数据(参数)不同,所以才造成Hello()执行结果不同(输出不同的名字)。新的问题马上就来:可是,这里的Hello()函数,根本就没有入参啊!

其实是有的。一个类型的成员函数,默认会有一个入参,只不过这个入参不需要程序员来写,这个入参就是视频中提到的“this”,它的类型是当前类型的堆数据形式,也就是说,在Person类型的Hello()成员函数,经编译器偷偷处理之后,其实是长这样子的:

struct Person
{
    std::string name; 
    void Hello(Person *this); // <--- 暗藏的this入参
};

并且,这个入参既不需要,也不允许由程序员手动传入,仍然须由编译器负责传入。先看栈变量,这是我们写的代码:

Person o1;
o1.Hello();

被编译处理之后,变成这样:

Person o1;
Person* po1 = &o1; //先假装o1是堆数据
Hello(po1);//传入当前对象

堆数据就更简单了,都不用假装了,我们直接看编译器处理之后结果:

Person* o2 = new Person;
Hello(o2); //传入当前对象

o1和o2,就分别通过以上方式,传入成员函数Hello()中。传入之后,它们的名字就叫: this,我们可以使用它,比如视频中提到的“重名”的例子:

void Person::Hello()
{
    cout << name << endl; //输出成员数据name的值
}

以上代码顺利通过编译。来新增一个临时的,也叫name的变量:

void Person::Hello()
{
    std::string name = "捣乱的丁小明";
    cout << name << endl; 
}

这段代码仍然可以顺利通过编译。因为编译器认定此时的name就是那个“捣乱的丁小明”;但假如我们心里想要用到成员数据name呢?就得使用this:

void Person::Hello()
{
    std::string name = "捣乱的丁小明";
    cout << name << " 爱上了 " << this->name << endl; 
}