第2章 C++类和对象总结
- 与结构体一样,类只是一种复杂数据类型的声明,不占用内存空间。(个人:可以类比int这个类型,int这个类型当然不单用内存空间)
- 而对象是类这种数据类型的一个变量,或者说是通过类这种数据类型创建出来的一份实实在在的数据,所以占用内存空间。
class Student{ public: //成员变量 char *name; int age; float score; //成员函数 void say(){ cout<<name<<"的年龄是"<<age<<",成绩是"<<score<<endl; } };
C++类名的首字母一般大写,以和其他的标识符区分开(个人:例如Sales_item)。注意在类定义的最后有一个分号;,它是类定义的一部分,表示类定义结束了,不能省略。
在创建对象时,class 关键字可要可不要,但是出于习惯我们通常会省略掉 class 关键字,(个人:结构体时需要struct关键字,)例如:
class Student LiLei; //正确 Student LiLei; //同样正确
这和使用基本类型定义变量的形式类似:
int a; //定义整型变量
- 在栈上创建出来的对象都有一个名字,比如 stu,使用指针指向它不是必须的。
- 但是通过 new 创建出来的对象就不一样了,它在堆上分配内存,没有名字,只能得到一个指向它的指针,所以必须使用一个指针变量来接收这个指针,否则以后再也无法找到这个对象了,更没有办法使用它。也就是说,使用 new 在堆上创建出来的对象是匿名的,没法直接使用,必须要用一个指针指向它,再借助指针来访问它的成员变量或成员函数。
通过对象名字访问成员使用点号.,通过对象指针访问成员使用箭头->,这和结构体非常类似。
如果函数体代码比较多,需要较长的执行时间,那么函数调用机制占用的时间可以忽略;如果函数只有一两条语句,那么大部分的时间都会花费在函数调用机制上,这种时间开销就就不容忽视。为了消除函数调用的时空开销,C++ 提供一种提高效率的方法,即在编译时将函数调用处用函数体替换,类似于C语言中的宏展开。这种在函数调用处直接嵌入函数体的函数称为内联函数(Inline Function),又称内嵌函数或者内置函数。
函数比较复杂时,函数调用的时空开销可以忽略,大部分的 CPU 时间都会花费在执行函数体代码上,所以我们一般是将非常短小的函数声明为内联函数。
使用内联函数的缺点也是非常明显的,编译后的程序会存在多份相同的函数拷贝,如果被声明为内联函数的函数体非常大,那么编译后的程序体积也将会变得很大,所以再次强调,一般只将那些短小的、频繁调用的函数声明为内联函数。
- 它在形式上和函数非常相似。
- 不过不像函数,宏仅仅是字符串替换,不是按值传递,所以在编写宏时要特别注意,一不小心可能就会踩坑。
#include <iostream> using namespace std; #define SQ(y) y*y int main(){ int n, sq; cin>>n; sq = SQ(n); cout<<sq<<endl; return 0; }
输出结果:
9↙ 81
从表面上看这个宏定义是正确的,但当我们将宏调用SQ(n)换成SQ(n+1)后,就会出现意想不到的状况:
#include <iostream> using namespace std; #define SQ(y) y*y int main(){ int n, sq; cin>>n; sq = SQ(n+1); cout<<sq<<endl; return 0; }
输出结果:
9↙ 19
我们期望的结果是 100,但这里却是 19,两者大相径庭。这是因为,宏展开仅仅是字符串的替换,不会进行任何计算或传值,上面的sq = SQ(n+1);在宏展开后会变为sq = n+1*n+1;,这显然是没有道理的。如果希望得到正确的结果,应该将宏定义改为如下的形式:
#define SQ(y) (y)*(y)
这样宏调用sq = SQ(n+1);就会展开为sq = (n+1)*(n+1);,得到的结果就是 100。
如果你认为这样就万事大吉了,那下面的结果会让你觉得考虑不周:
#include <iostream> using namespace std; #define SQ(y) (y)*(y) int main(){ int n, sq; cin>>n; sq = 200 / SQ(n+1); cout<<sq<<endl; return 0; }
输出结果:
9↙ 200
之所以会出现这么奇怪的结果,是因为宏调用sq = 200 / SQ(n+1);会被展开为sq = 200 / (n+1) * (n+1);,当 n 被赋值 9 后,相当于sq = 200 / 10 * 10,结果显然是 200。要想得到正确的结果,还应该对宏加以限制,在两边增加( ),如下所示:
#define SQ(y) ( (y)*(y) )
这样宏调用sq = 200 / SQ(n+1);就会展开为sq = 200 / ( (n+1) * (n+1) );,得到的结果就是 2。
说了这么多,我最终想强调的是,宏定义是一项“细思极密”的工作,一不小心就会踩坑,而且不一定在编译和运行时发现,给程序埋下隐患。
如果我们将宏替换为内联函数,情况就没有那么复杂了,程序员就会游刃有余,请看下面的代码:
#include <iostream> using namespace std; inline int SQ(int y){ return y*y; } int main(){ int n, sq; cin>>n; //SQ(n) sq = SQ(n); cout<<sq<<endl; //SQ(n+1) sq = SQ(n+1); cout<<sq<<endl; //200 / SQ(n+1) sq = 200 / SQ(n+1); cout<<sq<<endl; return 0; }
输出结果:
9↙ 81 100 2
发生函数调用时,编译器会先对实参进行计算,再将计算的结果传递给形参,并且函数执行完毕后会得到一个值,而不是得到一个表达式,这和简单的字符串替换相比省去了很多麻烦,所以在编写C++代码时我推荐使用内联函数来替换带参数的宏。
内联函数在编译时会将函数调用处用函数体替换,编译完成后函数就不存在了,所以在链接时不会引发重复定义错误。这一点和宏很像,宏在预处理时被展开,编译时就不存在了。从这个角度讲,内联函数更像是编译期间的宏。
综合本节和上节的内容,可以看到内联函数主要有两个作用,
- 一是消除函数调用时的开销,
- 二是取代带参数的宏。
inline 关键字可以只在函数定义处添加,也可以只在函数声明处添加,也可以同时添加;但是在函数声明处添加inline关键字是无效的,编译器会忽略函数声明处的inline关键字。也就是说,inline 是一种“用于实现的关键字”,而不是一种“用于声明的关键字”。(个人:当然,在声明处,编译器就是遇到inline,没有定义,编译器也不知道如何内联展开,但是编译器不会报错,实验之,编译器只会发出警告,然后忽略这个inline,将这个声明退化成一个普通的函数声明,然后在链接阶段找这个函数的定义,也就是在函数声明处的inline是没有意义的,编译器直接忽略
)
尽管大多数教科书中在函数声明和函数定义处都增加了 inline 关键字,但我认为 inline 关键字不应该出现在函数声明处。这个细节虽然不会影响函数的功能,但是体现了高质量 C++ 程序设计风格的一个基本原则:声明与定义不可混为一谈,用户没有必要、也不应该知道函数是否需要内联。更为严格地说,内联函数不应该有声明,应该将函数定义放在本应该出现函数声明也就是头文件的地方,这是一种良好的编程风格。
在多文件编程中,我们通常将函数的定义放在源文件中,将函数的声明放在头文件中,希望调用函数时,引入对应的头文件即可,我们鼓励这种将函数定义和函数声明分开的做法。但这种做法不适用于内联函数,将内联函数的声明和定义分散到不同的文件中会出错,请看下面的例子。
main.cpp 代码:
#include <iostream> using namespace std; //内联函数声明 void func(); int main(){ func(); return 0; }
func.cpp 代码:
#include <iostream> using namespace std; //内联函数定义 inline void func(){ cout<<"inline function"<<endl; }
上面的代码能够正常编译,但在链接时会出错。
func() 是内联函数,编译期间会用它来替换函数调用处,编译完成后函数就不存在了,链接器在将多个目标文件(.o或.obj文件)合并成一个可执行文件时找不到 func() 函数的定义,所以会产生链接错误。(个人:C++是单个源文件各自编译的,main.cpp在编译时,编译器看到func()的声明,能够正常编译main.cpp,将在链接时去别的文件找func()的定义;而module.cpp在编译时,由于这个文件没有出现内联函数func()的调用,所以编译器编译完module.cpp后,内联函数就消失了,这导致在链接时main.cpp产生的目标函数找不到func()的定义)
修改之,
运行结果:
我们再修改之,
运行结果:
内联函数虽然叫做函数,在定义和声明的语法上也和普通函数一样,但它已经失去了函数的本质。
- 函数是一段可以重复使用的代码,它位于虚拟地址空间中的代码区,也占用可执行文件的体积,(个人:也就是说普通的函数,编译后会在目标文件中,普通的函数是在链接阶段使用)
- 而内联函数的代码在编译后就被消除了,不存在于虚拟地址空间中,没法重复使用。(个人:内联函数的使用发生在编译阶段,不发生在链接阶段,在编译阶段内联展开后,就消失了)
内联函数看起来简单,但是有很多细节需要注意,
- 从代码重复利用的角度讲,内联函数已经不再是函数了。
- 我认为将内联函数作为带参宏的替代方案更为靠谱,而不是真的当做函数使用。
- 在多文件编程时,我建议将内联函数的定义直接放在头文件中,并且禁用内联函数的声明(声明是多此一举)。
如果你既希望将函数定义在类体外部,又希望它是内联函数,那么可以在定义函数时加 inline 关键字。当然你也可以在函数声明处加 inline,不过这样做没有效果,编译器会忽略函数声明处的 inline,这种在类体外定义 inline 函数的方式,必须将类的定义和成员函数的定义都放在同一个头文件中(或者同一个源文件中),否则编译时无法进行嵌入(将函数代码嵌入到函数调用出)(个人:当函数体稍微有点长时,直接写在类里不方便书写,有这种需求。这种方式的本质其实还是把内联函数的定义写在头文件里,只不是相当于函数声明和内联函数的定义都放在头文件中,函数声明处的inline会被编译器忽略,因为内联是针对函数实现而言的,单独的对声明没有用,因为不知道定义,编译器到时候也不知道怎么内联展开,所以编译器不报错,直接忽略,作为一个普通的函数声明,但是编译器遇到函数定义处的inline时,会把当前文件中的所有出现这个函数名的地方编译时内联展开)。下面是一个将内联函数定义在类外部的例子,这样,say() 就会变成内联函数。。
class Student{ public: const char *name; int age; float score; void say(); //内联函数声明,可以增加 inline 关键字,但编译器会忽略 }; //函数定义 inline void Student::say(){ cout<<name<<"的年龄是"<<age<<",成绩是"<<score<<endl; }
再次强调,虽然 C++ 支持将内联函数定义在类的外部,但我强烈建议将函数定义在类的内部,这样它会自动成为内联函数,何必费力不讨好地将它定义在类的外部呢,这样并没有任何优势。
#include <iostream> using namespace std; //类的声明 class Student{ private: //私有的 char *m_name; int m_age; float m_score; public: //共有的 void setname(char *name); void setage(int age); void setscore(float score); void show(); }; //成员函数的定义 void Student::setname(char *name){ m_name = name; } void Student::setage(int age){ m_age = age; } void Student::setscore(float score){ m_score = score; } void Student::show(){ cout<<m_name<<"的年龄是"<<m_age<<",成绩是"<<m_score<<endl; } int main(){ //在栈上创建对象 Student stu; stu.setname("小明"); stu.setage(15); stu.setscore(92.5f); stu.show(); //在堆上创建对象 Student *pstu = new Student; pstu -> setname("李华"); pstu -> setage(16); pstu -> setscore(96); pstu -> show();
del pstu; return 0; }
成员变量大都以m_开头,这是约定成俗的写法,不是语法规定的内容。以m_开头既可以一眼看出这是成员变量,又可以和成员函数中的形参名字区分开。
类是创建对象的模板,不占用内存空间,不存在于编译后的可执行文件中;而对象是实实在在的数据,需要内存来存储。对象被创建时会在栈区或者堆区分配内存。
直观的认识是,如果创建了 10 个对象,就要分别为这 10 个对象的成员变量和成员函数分配内存,如下图所示:
不同对象的成员变量的值可能不同,需要单独分配内存来存储。但是不同对象的成员函数的代码是一样的,上面的内存模型保存了10分相同的代码片段,浪费了不少空间,可以将这些代码片段压缩成一份。事实上编译器也是这样做的,编译器会将成员变量和成员函数分开存储:分别为每个对象的成员变量分配内存,但是所有对象都共享同一段函数代码。如下图所示:
成员变量在堆区或栈区分配内存,成员函数在代码区分配内存。
【示例】使用 sizeof 获取对象所占内存的大小:
#include <iostream> using namespace std; class Student{ private: char *m_name; int m_age; float m_score; public: void setname(char *name); void setage(int age); void setscore(float score); void show(); }; void Student::setname(char *name){ m_name = name; } void Student::setage(int age){ m_age = age; } void Student::setscore(float score){ m_score = score; } void Student::show(){ cout<<m_name<<"的年龄是"<<m_age<<",成绩是"<<m_score<<endl; } int main(){ //在栈上创建对象 Student stu; cout<<sizeof(stu)<<endl; //在堆上创建对象 Student *pstu = new Student(); cout<<sizeof(*pstu)<<endl; //类的大小 cout<<sizeof(Student)<<endl; return 0; }
在win10 64+clion上的运行结果为:
- Student 类包含三个成员变量,它们的类型分别是 char *、int、float,char *应该占用8个字节,int、float都占用 4 个字节的内存,加起来共占用 16 个字节的内存。
- 通过 sizeof 求得的结果等于 16,恰好说明对象所占用的内存仅仅包含了成员变量。
- 类可以看做是一种复杂的数据类型,也可以使用 sizeof 求得该类型的大小。从运行结果可以看出,在计算类这种类型的大小时,只计算了成员变量的大小,并没有把成员函数也包含在内。
- 对象的大小只受成员变量的影响,和成员函数没有关系。
假设 stu 的起始地址为 0X1000,那么该对象的内存分布如下图所示(个人:这个图应该是在32位机器上的结果):
m_name、m_age、m_score 按照声明的顺序依次排列,和结构体非常类似,也会有内存对齐的问题。
C++和C语言的编译方式不同。
- C语言中的函数在编译时名字不变,或者只是简单的加一个下划线_(不同的编译器有不同的实现),例如,func() 编译后为 func() 或 _func()。
- 而C++中的函数在编译时会根据它所在的命名空间、它所属的类、以及它的参数列表(也叫参数签名)等信息进行重新命名,形成一个新的函数名。这个新的函数名只有编译器知道,对用户是不可见的。对函数重命名的过程叫做名字编码(Name Mangling),是通过一种特殊的算法来实现的。
Name Mangling 的算法是可逆的,
- 既可以通过现有函数名计算出新函数名,
- 也可以通过新函数名逆向推演出原有函数名。
Name Mangling可以确保新函数名的唯一性,只要函数所在的命名空间、所属的类、包含的参数列表等有一个不同,最后产生的新函数名也不同。
如果你希望看到经 Name Mangling 产生的新函数名,可以只声明而不定义函数,这样调用函数时就会产生链接错误,从报错信息中就可以看到新函数名。请看下面的代码:
#include <iostream> using namespace std; void display(); void display(int); namespace ns{ void display(); } class Demo{ public: void display(); }; int main(){ display(); display(1); ns::display(); Demo obj; obj.display(); return 0; }
(个人:g++编译器更加智能,能够根据Name Mangling 算法自动反推出代码中的实际上哪个函数没有定义,这样反而看不到Name Mangling 算法重新命名的函数名):
小括号中就是经 Name Mangling 产生的新函数名,它们都以?开始,以区别C语言中的_。上图是 VS2022 产生的错误信息,不同的编译器有不同的 Name Mangling 算法,产生的函数名也不一样。
除了函数,某些变量也会经 Name Mangling 算法产生新名字,这里不再赘述。
从上图可以看出,成员函数最终被编译成与对象无关的全局函数,
- 如果函数体中没有成员变量,那问题就很简单,不用对函数做任何处理,直接调用即可。
- 如果成员函数中使用到了成员变量该怎么办呢?成员变量的作用域不是全局,不经任何处理就无法在函数内部访问。
C++规定,编译成员函数时要额外添加一个参数,把当前对象的指针传递进去,通过指针来访问成员变量。
假设 Demo 类有两个 int 型的成员变量,分别是 a 和 b,并且在成员函数 display() 中使用到了,如下所示:
void Demo::display(){ cout<<a<<endl; cout<<b<<endl; }
那么编译后的代码类似于:(个人:应该是对于对象的私有成员变量,只有在成员函数的作用范围内,可以使用对象直接访问,在其他的地方不可以这样直接访问对象自己的私有成员变量)
void new_function_name(Demo * const p){ //通过指针p来访问a、b cout<<p->a<<endl; cout<<p->b<<endl; }
使用obj.display()调用函数时,也会被编译成类似下面的形式:
new_function_name(&obj);
这样通过传递对象指针就完成了成员函数和成员变量的关联。这与我们从表明上看到的刚好相反,通过对象调用成员函数时,不是通过对象找函数,而是通过函数找对象。这一切都是隐式完成的,对程序员来说完全透明,就好像这个额外的参数不存在一样。最后需要提醒的是,Demo * const p中的 const 表示指针不能被修改,p 只能指向当前对象,不能指向其他对象。(个人:也就是this指针是const的,只能指向调用这个成员的当前对象,不能改变成指向其他的对象)
在C++中,有一种特殊的成员函数,它的名字和类名相同,没有返回值,不需要用户显式调用(用户也不能调用),而是在创建对象时自动执行(个人:分配好对象的内存空间后,自动执行)。这种特殊的成员函数就是构造函数(Constructor)。
- 在栈上创建对象时,实参位于对象名后面,例如Student stu("小明", 15, 92.5f);
- 在堆上创建对象时,实参位于类名后面,例如Student *pstu = new Student("李华", 16, 96);
构造函数在实际开发中会大量使用,它往往用来做一些初始化工作,例如对成员变量赋值、预先打开文件等。
如果用户自己没有定义构造函数,那么编译器会自动生成一个默认的构造函数,只是这个构造函数的函数体是空的,也没有形参,也不执行任何操作。一个类必须有构造函数,要么用户自己定义,要么编译器自动生成。一旦用户自己定义了构造函数,不管有几个,也不管形参如何,编译器都不再自动生成。
最后需要注意的一点是,调用没有参数的构造函数也可以省略括号。
- 在栈上创建对象可以写作Student stu()或Student stu,
- 在堆上创建对象可以写作Student *pstu = new Student()或Student *pstu = new Student,
它们都会调用构造函数 Student()。创建对象时没有写括号,其实是调用了默认的构造函数。
构造函数的一项重要功能是对成员变量进行初始化,为了达到这个目的,
- 可以在构造函数的函数体中对成员变量一一赋值(个人:如果没有使用参数初始化列表,在构造函数的函数体中对变量进行赋值,此时对类的成员变量会使用默认的方式进行初始化,类成员使用默认构造函数,内置类型的值是以一个不确定的随机值,然后在函数体中进行的是赋值,对于一个常量成员变量,必须在初始化列表中进行初始化,int i;这是正确的,此时i的值是一个随机值,当我们使用时只是会发出使用的是一个未初始化的变量的警告,const int j;这在C++中是错误的,常量定义时必须进行初始化。但是在C中确是正确的,只是在使用时会发出使用未初始化变量的警告,这里的j常量是一个随机值,C和c++这里的不同主要是C++把常量当成编译时的宏,所以必须得有初始值),
- 还可以采用参数初始化表。
参数初始化表可以用于全部成员变量,也可以只用于部分成员变量。
#include <iostream> using namespace std; class Demo1 { public: Demo1(int x, int y);; void display(); private: int a; int b; }; Demo1::Demo1(int x, int y) :a(x) {} void Demo1::display() { cout << "a: " << a << "\nb: " << b << endl; } int main() { Demo1 d(222, 333); d.display(); return 0; }
由于我们在参数初始化列表中没有初始化b,所以b的值是不确定的,vs编译器对于没有初始化的变量b,定义变量的栈空间
填充的值默认是CC
,因为b
是一个int类型,那么即就是占四个字节。所以 ,未初始化的i填充的字节数就是 0xCCCCCCCC
,它刚好是-858993460在内存中的二进制表示。(注:在win64+clion+mingw64环境下,b的输出结果为0,个人猜测可能是不同编译器对未初始化填充的值不同吧,待研究)
需要注意的是,参数初始化顺序与初始化表列出的变量的顺序无关,它只与成员变量在类中声明的顺序有关。(个人:也即和变量在内存中的存储顺序有关,在内存中成员变量是按照在类中声明的顺序依次存储的)
#include <iostream> using namespace std; class Demo{ private: int m_a; int m_b; public: Demo(int b); void show(); }; Demo::Demo(int b): m_b(b), m_a(m_b){ } void Demo::show(){ cout<<m_a<<", "<<m_b<<endl; } int main(){ Demo obj(100); obj.show(); return 0; }
在win10 64+vs2022下输出:
- 给 m_a 初始化时,m_b 还未被初始化,它的值是不确定的,所以输出的 m_a 的值是一个奇怪的数字;
- 给 m_a 初始化完成后才给 m_b 初始化,此时 m_b 的值才是 100。
obj 在栈上分配内存,成员变量的初始值是不确定的。
初始化 const 成员变量的唯一方法就是使用参数初始化表。(个人:还可以通过类内初始值)
创建对象时系统会自动调用构造函数进行初始化工作,同样,销毁对象时系统也会自动调用一个函数来进行清理工作,例如释放分配的内存、关闭打开的文件等,这个函数就是析构函数。
析构函数(Destructor)也是一种特殊的成员函数,没有返回值,不需要程序员显式调用(程序员也没法显式调用),而是在销毁对象时自动执行。构造函数的名字和类名相同,而析构函数的名字是在类名前面加一个~符号。
注意:析构函数没有参数,不能被重载,因此一个类只能有一个析构函数。如果用户没有定义,编译器会自动生成一个默认的析构函数。
#include <iostream> using namespace std; class VLA{ public:
//vary length array VLA(int len); //构造函数 ~VLA(); //析构函数 public: void input(); //从控制台输入数组元素 void show(); //显示数组元素 private: int *at(int i); //获取第i个元素的指针 private: const int m_len; //数组长度 int *m_arr; //数组指针 }; VLA::VLA(int len): m_len(len){ if(len > 0){ m_arr = new int[len]; /*分配内存,由于直到运行时,才知道数组大小,所以使用了动态内存*/ } else{ m_arr = NULL; } } VLA::~VLA(){ delete[] m_arr; //释放内存 } void VLA::input(){ for(int i=0; i < m_len; i++){ cin>>*at(i); } } void VLA::show(){ for(int i=0; i < m_len; i++){ if(i == m_len - 1){ cout<<*at(i)<<endl; } else{ cout<<*at(i)<<", "; } } } int * VLA::at(int i){ if(!m_arr || i<0 || i>=m_len){ return NULL; } else{ return m_arr + i; } } int main(){ //创建一个有n个元素的数组(对象) int n; cout<<"Input array length: "; cin>>n; VLA arr = VLA(n); //输入数组元素 cout<<"Input "<<n<<" numbers: "; arr.input(); //输出数组元素 cout<<"Elements: "; arr.show(); return 0; }
C++ 中的 new 和 delete 分别用来分配和释放内存,它们与C语言中 malloc()、free() 最大的一个不同之处在于:用 new 分配内存时会调用构造函数,用 delete 释放内存时会调用析构函数。构造函数和析构函数对于类来说是不可或缺的,所以在C++中我们非常鼓励使用 new 和 delete。
析构函数在对象被销毁时调用,而对象的销毁时机与它所在的内存区域有关。
- 在所有函数之外创建的对象是全局对象,它和全局变量类似,位于内存分区中的全局数据区,程序在结束执行时会调用这些对象的析构函数。
- 在函数内部创建的对象是局部对象,它和局部变量类似,位于栈区,函数执行结束时会调用这些对象的析构函数。
- new 创建的对象位于堆区,通过 delete 删除时才会调用析构函数;如果没有 delete,析构函数就不会被执行。
// This header file contains the Circle class declaration. #ifndef CIRCLE_H #define CIRCLE_H #include <cmath> class Circle { private: double radius; // Circle radius int centerX, centerY; // Center coordinates public: Circle() // Default constructor { // accepts no arguments radius = 1.0; centerX = centerY = 0; } Circle(double r) // Constructor 2 { // accepts 1 argument radius = r; centerX = centerY = 0; } Circle(double r, int x, int y) // Constructor 3 { // accepts 3 arguments radius = r; centerX = x; centerY = y; } void setRadius(double r) { radius = r; } int getXcoord() { return centerX; } int getYcoord() { return centerY; } double findArea() { return 3.14 * pow(radius, 2); } }; // End Circle class declaration #endif
- 创建一个4个元素的对象数组:Circle circle[4]; 注意,每当使用一个没有参数的构造函数创建对象数组时,如果存在默认构造函数,则它将为数组中的每个对象执行默认构造函数。
- 也可以创建一个对象数组,并为每个对象调用另一个构造函数。为此则必须使用初始化列表。circle[NUM_CIRCLES] = {0.0, 2.0, 2.5, 10.0};这将调用构造函数接受一个double参数。
- 如果初始化列表的长度小于对象的数量,则任何剩余的对象都将由默认构造函数初始化。例如,以下语句调用构造函数,该构造函数为前3个对象接收一个 double 参数,并使默认构造函数为第 4 个对象运行。circle[NUM_CIRCLES] = {0.0, 2.0, 2.5};
- 要使用需要多个参数的构造函数,则初始化项必须釆用函数调用的形式。例如,来看下面的定义语句,它为 3 个 Circle 对象的每一个调用 3 个参数的构造函数:Circle circle[3] = {Circle(4.0, 2, 1),Circle(2.0, 1, 3),Circle (2.5, 5, -1) };
- 没有必要为数组中的每个对象调用相同的构造函数。例如,以下语句也是合法的,该语句为 circle[0] 和 circle[2] 调用 1 参数构造函数,而为 circle[1] 调用的则是 3 参数构造函数。Circle circle [3] = { 4.0,Circle (2.0, 1, 3),2.5 };
类名::构造函数名(参数表): 成员变量1(参数表), 成员变量2(参数表), ... { ... }
- 初始化列表中的成员变量既可以是成员对象,也可以是基本类型的成员变量。
- 对于成员对象,初始化列表的“参数表”中存放的是构造函数的参数(它指明了该成员对象如何初始化)。
- 对于基本类型成员变量,“参数表”中就是一个初始值。
- “参数表”中的参数可以是任何有定义的表达式,该表达式中可以包括变量甚至函数调用等,只要表达式中的标识符都是有定义的即可。
#include <iostream> using namespace std; class CTyre //轮胎类 { private: int radius; //半径 int width; //宽度 public: CTyre(int r, int w) : radius(r), width(w) { } }; class CEngine //引擎类 { }; class CCar { //汽车类 private: int price; //价格 CTyre tyre;//轮胎 CEngine engine;//引擎 public: CCar(int p, int tr, int tw); }; CCar::CCar(int p, int tr, int tw) : price(p), tyre(tr, tw) { }; int main() { CCar car(20000, 17, 225); return 0; }
编评器已经知道这里的 car 对象是用上面的 CCar(int p, int tr, int tw) 构造函数初始化的,那么 tyre 和 engine 该如何初始化,就要看第 22 行 CCar(int p,int tr,int tw) 后面的初始化列表了。该初始化列表表明,tyre 应以 tr 和 tw 作为参数调用 CTyre(intr, hit w) 构造函数初始化,但是并没有说明 engine 该如何处理。在这种情况下,编译器就认为 engine 应该用CEngine类的无参构造函数初始化。而 CEngine类确实有一个编译器自动生成的默认无参构造函数,因此,整个car对象的初始化问题就都解决了。在上面的程序中,如果 CCar 类的构造函数没有初始化列表,那么第 27 行就会编译出错,因为编译器不知道该如何初始化 car.tyre 对象,因为 CTyre 类没有无参构造函数,而编译器又找不到用来初始化 car.tyre 对象的参数。
总之,生成封闭类对象的语句一定要让编译器能够弄明白其成员对象是如何初始化的,否则就会编译错误。
- 封闭类对象生成时,先执行所有成员对象的构造函数,然后才执行封闭类自己的构造函数。成员对象构造函数的执行次序和成员对象在类定义中的次序一致,与它们在构造函数初始化列表中出现的次序无关。
- 当封闭类对象消亡时,先执行封闭类的析构函数,然后再执行成员对象的析构函数,成员对象析构函数的执行次序和构造函数的执行次序相反,即先构造的后析构,这是 C++ 处理此类次序问题的一般规律。
复制构造函数是构造函数的一种,也称拷贝构造函数,它只有一个参数,参数类型是本类的引用。
复制构造函数的参数可以是 const 引用,也可以是非 const 引用。 一般使用前者,这样既能以常量对象(初始化后值不能改变的对象)作为参数,也能以非常量对象作为参数去初始化其他对象。一个类中写两个复制构造函数,一个的参数是 const 引用,另一个的参数是非 const 引用,也是可以的。
如果类的设计者不写复制构造函数,编译器就会自动生成复制构造函数。大多数情况下,其作用是实现从源对象到目标对象逐个字节的复制,即使得目标对象的每个成员变量都变得和源对象相等。编译器自动生成的复制构造函数称为“默认复制构造函数”。
#include <iostream> using namespace std; class A { public: A() { cout << "default" << endl; } A(A &a) { cout << "copy" << endl; } }; class B { A a; }; int main() { B b1, b2(b1); return 0; }
程序的输出结果是:
(个人:这里b1是使用编译器的B的默认构造函数构造的,它的成员b1.a会使用A的默认构成函数初始化,这里b2是使用编译器的B的默认复制构造函数构造的,它的成员b2.a也会调用A的复制构造函数,恰好A有自己版本的复制构造函数,那么就调用之,传进去的参数是b1.a)
说明 b2.a 是用类 A 的复制构造函数初始化的,而且调用复制构造函数时的实参就是 b1.a。
几点注意:
- this是const指针,它的值是不能被修改的,一切企图修改该指针的操作,如赋值、递增、递减等都是不允许的。
- this只能在成员函数内部使用,用在其他地方没有意义,也是非法的。
- 只有当对象被创建后this才有意义,因此不能在 static 成员函数中使用(后续会讲到 static 成员)。
this 实际上是成员函数的一个形参,在调用成员函数时将对象的地址作为实参传递给 this。不过 this 这个形参是隐式的,它并不出现在参数列表中,而是在编译阶段由编译器默默地将它添加到参数列表中。this作为隐式形参,本质上是成员函数的局部变量,所以只能用在成员函数的内部,并且只有在通过对象调用成员函数时才给 this 赋值。
成员函数最终被编译成与对象无关的普通函数,除了成员变量,会丢失所有信息,所以编译时要在成员函数中添加一个额外的参数,把当前对象的首地址传入,以此来关联成员函数和成员变量。这个额外的参数,实际上就是 this,它是成员函数和成员变量关联的桥梁。
对象的内存中包含了成员变量,不同的对象占用不同的内存,这使得不同对象的成员变量相互独立,它们的值不受其他对象的影响。可是有时候我们希望在多个对象之间共享数据,对象 a 改变了某份数据后对象 b 可以检测到。共享数据的典型使用场景是计数,以前面的 Student 类为例,如果我们想知道班级中共有多少名学生,就可以设置一份共享的变量,每次创建对象时让该变量加 1。 在C++中,我们可以使用静态成员变量来实现多个对象共享数据的目标。静态成员变量是一种特殊的成员变量,它被关键字static修饰.
student.h文件:
#pragma once #include <iostream> #include <string> using namespace std; class Student { public: Student(string name, int age, float score); void show(); private: static int m_total; //静态成员变量 private: string m_name; int m_age; float m_score; };
Stuent.cpp文件:
#include "Student.h" #include <string> //初始化静态成员变量 int Student::m_total = 0; Student::Student(string name, int age, float score) : m_name(name), m_age(age), m_score(score) { m_total++; //操作静态成员变量 } void Student::show() { cout << m_name << "的年龄是" << m_age << ",成绩是" << m_score << "(当前共有" << m_total << "名学生)" << endl; }
main.cpp文件:
#include "Student.h" #include <cstring> #include <iostream> using namespace std; int main() { //创建匿名对象 (new Student("小明", 15, 90))->show(); (new Student("李磊", 16, 80))->show(); (new Student("张华", 16, 99))->show(); (new Student("王康", 14, 60))->show(); return 0; }
以下的原则是针对静态数据成员非类内初始化的情形,至于静态成员类内初始化的情况,见C++primer电子书里的笔记:
- static 成员变量属于类,不属于某个具体的对象,即使创建多个对象,也只为 m_total 分配一份内存,所有对象使用的都是这份内存中的数据。当某个对象修改了 m_total,也会影响到其他对象。
- static 成员变量必须在类定义的外部定义,具体形式为:int Student::m_total = 0; 静态成员变量在初始化时不能再加 static,但必须要有数据类型。(个人:猜测应该是在一个标识符的定义处加上static会将这个标识符限定在当前文件中,而在类中是对成员变量的声明,此时用上static,)
- static 成员变量的内存既不是在声明类时分配,也不是在创建对象时分配,而是在(类外)初始化时分配。反过来说,没有在类外初始化的 static 成员变量不能使用。(个人:static的定义走的不是通过构造函数的途径,构造函数不负责static的定义职责,static的定义走的是和成员函数定义类似的方式)
- static 成员变量既可以通过对象来访问,也可以通过类来访问。
-
static成员变量不占用对象的内存,而是在所有对象之外开辟内存,即使不创建对象也可以访问。具体来说,static成员变量和普通的static变量类似,都在内存分区中的全局数据区分配内存,到程序结束时才释放。这就意味着,static 成员变量不随对象的创建而分配内存,也不随对象的销毁而释放内存。而普通成员变量在对象创建时分配内存,在对象销毁时释放内存。
- 静态成员变量只能在类体外进行定义。例如:int Student::m_total = 10; 初始化时可以赋初值,也可以不赋值。如果不赋值,那么会被默认初始化为 0。全局数据区的变量都有默认的初始值 0,而动态数据区(堆区、栈区)变量的默认值是不确定的,一般认为是垃圾值。
- 被 private、protected、public 修饰的静态成员变量都可以用这种方式定义。(个人:可以类比一下,private成员函数也是这样在类外定义的)
静态成员函数与普通成员函数的根本区别在于:
- 普通成员函数有this指针,可以访问类中的任意成员;
- 而静态成员函数没有 this 指针,只能访问静态成员(包括静态成员变量和静态成员函数)。
和静态成员变量类似,静态成员函数在声明时要加 static,在定义时不能加 static。静态成员函数可以通过类来调用(一般都是这样做),也可以通过对象来调用。
const 成员变量的用法和普通 const 变量的用法相似,只需要在声明时加上 const 关键字。初始化 const 成员变量只有一种方法,就是通过构造函数的初始化列表。(个人:还有通过类内成员变量初始值)
const 成员函数可以使用类中的所有成员变量,但是不能修改它们的值,(个人:类似于java中的get方法)。这种措施主要还是为了保护数据而设置的。const 成员函数也称为常成员函数。我们通常将get函数设置为常成员函数。读取成员变量的函数的名字通常以get开头,后跟成员变量的名字,所以通常将它们称为 get 函数。需要强调的是,必须在成员函数的声明和定义处同时加上const 关键字。char *getname() const和char *getname()是两个不同的函数原型,如果只在一个地方加 const 会导致声明和定义处的函数原型冲突。
如果希望某个对象的值初始化以后就再也不被改变,则定义该对象时可以在前面加const关键字,使之成为常量对象(简称“常对象”)。例如:
class CDemo{ public: void SetValue(){ } }; const CDemo Obj; // Obj 是常量对象
在 obj 被定义为常量对象的情况下,下面这条语句是错误的,编译不能通过:Obj.SetValue();
错误的原因是,常量对象一旦初始化后,其值就再也不能更改。因此,不能通过常量对象调用普通成员函数,因为普通成员函数在执行过程中有可能修改对象的值。但是可以通过常量对象调用常量成员函数。所谓常量成员函数,就是在定义时加了 const 关键字的成员函数(声明时也要加)。例如:
#include<iostream> using namespace std; class Sample{ public: void GetValue() const; //常成员函数 }; void Sample::GetValue() const //常成员函数 { } int main(){ const Sample o; o.GetValue(); //常量对象上可以执行常量成员函数 return 0; }
常量对象上可以执行常量成员函数,是因为常量成员函数确保不会修改任何非静态成员变量的值。编译器如果发现常量成员函数内出现了有可能修改非静态成员变量的语句,就会报错。因此,常量成员函数内部也不允许调用同类的其他非常量成员函数(静态成员函数除外)。(个人:常量对象是针对对象而言的,指的是不能改变对象的状态,即改变对象内存中的对象成员变量的值,但是静态成员变量不在对象的内存中存储,不属于任何对象,是属于类的,改变静态变量的值,不会改变常量对象的状态)。
两个成员函数的名字和参数表相同,但一个是const的,一个不是,则它们算重载。
#include <iostream> using namespace std; class CTest{ private: int n; public: CTest(){n = 1;} int GetValue() const { return n; } int GetValue() { return 2*n; } }; int main(){ const CTest objTestl ; CTest objTest2; cout << objTestl.GetValue () << "," << objTest2.GetValue(); return 0; }
程序的输出结果是:1, 2
- 可以看到,通过常量对象调用 GetValue 函数,那么被调用的就是带 const 关键字的 GetValue 函数;
- 通过普通对象调用 GetValue 函数,被调用的就是不带 const 关键字的 GetValue 函数。
基本上,如果一个成员函数中没有调用非常量成员函数,也没有修改成员变量的值,那么将其写成常量成员函数是好的习惯。
借助友元可以访问与其有好友关系的类中的私有成员。
在当前类以外定义的、不属于当前类的函数也可以在类中声明,但要在前面加 friend 关键字,这样就构成了友元函数。
- 友元函数可以是不属于任何类的非成员函数,
- 也可以是其他类的成员函数。
友元函数可以访问当前类中的所有成员,包括public、protected、private属性的。
将非成员函数声明为友元函数,请大家直接看下面的例子:
#include <iostream> using namespace std; class Student{ public: Student(string name, int age, float score); public: friend void show(Student *pstu); //将show()声明为友元函数 private: string m_name; int m_age; float m_score; }; Student::Student(string name, int age, float score): m_name(name), m_age(age), m_score(score){ } //非成员函数 void show(Student *pstu){ cout<<pstu->m_name<<"的年龄是 "<<pstu->m_age<<",成绩是 "<<pstu->m_score<<endl; } int main(){ Student stu("小明", 15, 90.6); show(&stu); //调用友元函数 Student *pstu = new Student("李磊", 16, 80.5); show(pstu); //调用友元函数
delete pstu; return 0; }
运行结果:
注意,友元函数不同于类的成员函数,在友元函数中不能直接访问类的成员,必须要借助对象。下面的写法是错误的:
void show(){ cout<<m_name<<"的年龄是 "<<m_age<<",成绩是 "<<m_score<<endl; }
成员函数在调用时会隐式地增加this指针,指向调用它的对象,从而使用该对象的成员;而 show() 是非成员函数,没有 this 指针,编译器不知道使用哪个对象的成员,要想明确这一点,就必须通过参数传递对象(可以直接传递对象,也可以传递对象指针或对象引用),并在访问成员时指明对象。
将其他类的成员函数声明为友元,friend 函数不仅可以是全局函数(非成员函数),还可以是另外一个类的成员函数。请看下面的例子:
#include <iostream> using namespace std; //提前声明Address类,这里是类的声明,只出现了类的名字, //没有出现类的对象变量 class Address; //定义Student类 class Student { public: Student(const char * name, int age, float score); public: void show(Address *addr); private: const char * m_name;//姓名 int m_age;//年龄 float m_score;//分数 }; //定义Address类 class Address { private: const char * m_province; //省份 const char * m_city; //城市 const char * m_district; //区(市区) public: Address(const char * province, const char * city, const char * district); //将Student类中的成员函数show()声明为友元函数 friend void Student::show(Address *addr); }; //实现Student类 Student::Student(const char * name, int age, float score) : m_name(name), m_age(age), m_score(score) {} void Student::show(Address *addr) { cout << m_name << "的年龄是 " << m_age << ",成绩是 " << m_score << endl; cout << "家庭住址:" << addr->m_province << "省" << addr->m_city << "市" << addr->m_district << "区" << endl; } //实现Address类 Address::Address(const char * province, const char * city, const char * district) { m_province = province; m_city = city; m_district = district; } int main() { Student stu("小明", 16, 95.5f); Address addr("陕西", "西安", "雁塔"); stu.show(&addr); auto *pstu = new Student("李磊", 16, 80.5); auto *paddr = new Address("河北", "衡水", "桃城"); pstu->show(paddr); delete pstu; return 0; }
几点注意:
- 程序第 7 行对 Address 类进行了提前声明,是因为在 Address 类定义之前、在 Student 类中使用到了它,如果不提前声明,编译器会报错,提示'Address' has not been declared。类的提前声明和函数的提前声明是一个道理。
- 程序将 Student 类的定义和实现分开了,而将 Address 类的定义放在了中间,这是因为编译器从上到下编译代码,show() 函数体中用到了 Address 的成员 province、city、district,如果提前不知道 Address 的具体定义内容,就不能确定 Address 是否拥有该成员(类的定义中指明了类有哪些成员)。
- 一个函数可以被多个类声明为友元函数,这样就可以访问多个类中的 private 成员。
这里简单介绍一下类的提前声明。一般情况下,类必须在正式定义之后才能使用;但是某些情况下(如上例所示),只要做好提前声明,也可以先使用。但是应当注意,类的提前声明的使用范围是有限的,只有在正式定义一个类以后才能用它去创建对象。如果在上面程序的第7行之后增加如下所示的一条语句,编译器就会报错:
因为创建对象时要为对象分配内存,在正式定义类之前,编译器无法确定应该为对象分配多大的内存。编译器只有在“见到”类的正式定义后(其实是见到成员变量),才能确定应该为对象预留多大的内存。在对一个类作了提前声明后,可以用该类的名字去定义指向该类型对象的指针变量(本例就定义了 Address 类的指针变量)或引用变量(后续会介绍引用),因为指针变量和引用变量本身的大小是固定的,与它所指向的数据的大小无关。
- 友元的关系是单向的而不是双向的。如果声明了类 B 是类 A 的友元类,不等于类 A 是类 B 的友元类,类 A 中的成员函数不能访问类 B 中的 private 成员。
- 友元的关系不能传递。如果类 B 是类 A 的友元类,类 C 是类 B 的友元类,不等于类 C 是类 A 的友元类。
- 除非有必要,一般不建议把整个类声明为友元类,而只将某些成员函数声明为友元函数,这样更安全一些。
- 友元函数和友元类在实际开发中较少使用。
类其实也是一种作用域,每个类都会定义它自己的作用域。在类的作用域之外,
- 普通的public成员只能通过对象(可以是对象本身,也可以是对象指针或对象引用)来访问,
- 静态成员既可以通过对象访问,又可以通过类访问,
- 而 typedef 定义的类型只能通过类来访问。
下面的例子使用不同的方式访问了不同的成员:
#include<iostream> using namespace std; class A{ public: typedef int INT; static void show(); void work(); }; void A::show(){ cout<<"show()"<<endl; } void A::work(){ cout<<"work()"<<endl; } int main(){ A a; a.work(); //通过对象访问普通成员 a.show(); //通过对象访问静态成员 A::show(); //通过类访问静态成员 A::INT n = 10; //通过类访问 typedef 定义的类型 return 0; }
一个类就是一个作用域的事实能够很好的解释为什么我们在类的外部定义成员函数时必须同时提供类名和函数名。在类的外部,类内部成员的名字是不可见的。一旦遇到类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无需再次授权了。(个人:也就是一旦遇到类名,我们就进入类这个作用域里面了),请看下面的例子:
#include<iostream> using namespace std; class A{ public: typedef char* PCHAR; public: void show(PCHAR str); private: int n; }; void A::show(PCHAR str){ cout<<str<<endl; n = 10; } int main(){ A obj; obj.show("http://www.baidu.com"); return 0; }
C++ 中保留了C语言的 struct 关键字,并且加以扩充。在C语言中,struct 只能包含成员变量,不能包含成员函数。而在C++中,struct 类似于 class,既可以包含成员变量,又可以包含成员函数。
C++中的 struct 和 class 基本是通用的,唯有几个细节不同:
- 使用 class 时,类中的成员默认都是 private 属性的;而使用 struct 时,结构体中的成员默认都是 public 属性的。
- class 继承默认是 private 继承,而 struct 继承默认是 public 继承。
- class 可以使用模板,而 struct 不能。
在编写C++代码时,我强烈建议使用 class 来定义类,而使用 struct 来定义结构体,这样做语义更加明确。
许多程序都大量应用到字符串。C++为处理字符串提供了两种不同数据类型:C 字符串和 string 类。
string类库有许多处理字符串的函数,这些函数可以执行许多实用的和字符串相关的功能,并且提供了编程上的安全防护,而这正是 C 字符串处理函数所缺乏的。出于以上理由,你应该会更喜欢使用 string 类而不是 C 字符串。
尽管如此,每个 C++ 程序员都应该对C字符串有足够的了解。
- string 类构建于 C 字符串之上,所以,了解 C 字符串有助于理解 string 类。
- 此外,还有很多程序是在 string 类加入到 C++ 标准之前编写的,这样的程序需要能理解 C 字符串的程序员来维护它们。
- 最后,程序员如果需要编写和维护底层代码,例如 string 类库或操作系统的一部分,则必须使用 C 字符串来表示字符串数据。
char ch1, ch2, ch3; ch1 = '\0'; ch2 = 0; ch3 = NULL;
由于数组是一系列连续的存储位置,它们存储相同类型的值,所以 C 字符串实际上是一个以 NULL 结尾的字符数组。
在C语言中,有两种方式表示字符串:
- 一种是用字符数组来容纳字符串,例如char str[10] = "abc",这样的字符串是可读写的;
- 一种是使用字符串常量,例如const char *str = "abc",这样的字符串只能读,不能写。
两种形式总是以\0作为结束标志。
//This program demonstrates that string literals are pointers to char. #include <iostream> using namespace std; int main() { //Define variables that are pointers to char const char* p, * q; // Assign string literals to the pointers to char p = "Hello "; q = "Bailey"; // Print the pointers as C-strings! cout << p << q << endl; // Print the pointers as C-strings and as addresses cout << p << " is stored at " << int(p) << endl; cout << q << " is stored at " << int(q) << endl; // A string literal can be treated as a pointer! cout << "string literal stored at " << int("literal"); return 0; }
这是在win10+2022上以x86方式编译的结果,但是在clion+mingw64上会发生error Cast from pointer to smaller type 'int' loses information”,这是因为Linux 64位上int占用4个字节,但是指针占用8个字节。解决方案是以下面的语句代替:
或者:
cout << p << " is stored at " << static_cast<uint32_t>(reinterpret_cast<uintptr_t>(p)) << endl;
- 该程序的前两个赋值显示字符串常数是指向 char 类型的变量的 char 指针。
- 指针 p 和 q 然后保存两个字符串常数的地址。
- 通过将指针转换为 int,可以看到内存中的字符串常数存储在哪里。
- 请注意,在这种情况下,编译器已将所有字符串常数存储在连续内存位置的程序中。
下面的程序就是一个例子。它一次输出一个字符,直到当它找到 null 终止符时停止。它使用了 cin.getline 成员函数来读取要输出的字符串。
// This program cycles through a character arrayA //displaying each element until a null terminator is encountered. #include <iostream> using namespace std; int main() { const int LENGTH = 80; // Maximum length for string char line[LENGTH]; // Array of char // Read a string into the character array cout << "Enter a sentence of no more than " << LENGTH - 1 << " characters : \n"; cin.getline(line, LENGTH); cout << "The sentence you entered is:\n"; // Loop through the array printing each character for (int index = 0; line[index] != '\0'; index++) { cout << line[index]; } return 0; }
// This program illustrates dynamic allocation of storage for C-strings. #include <iostream> using namespace std; int main() { const int NAME_LENGTH = 5; // Maximum length char* pname = nullptr; // Address of array // Allocate the array pname = new char[NAME_LENGTH]; cout << "刚new时,初始化的值为:" << pname << endl; // Read a string cout << "Enter your name: "; cin.getline(pname, NAME_LENGTH); //Display the string cout << "Hello " << pname; //Release the memory delete[] pname; return 0; }
可见,在堆上动态分配的char数组和在栈上分配时的情况一样,内存中的值是一些不确定的垃圾值。
- cin.getline允许读取包含空格的字符串,它将继续读取,直到它读取至最大指定的字符数,或直到按下了回车键。
- 当cin.getline语句执行时,cin读取的字符数将比该数字少一个,为 null 终止符留出空间。 null 终止符将自动放在数组最后一个字符的后面。
C++ string 与它们在C语言中的前身截然不同。
- 首先,也是最重要的不同点,C++ string 隐藏了它所包含的字符序列的物理表示。程序设计人员不必关心数组的维数或\0方面的问题。
- string 在内部封装了与内存和容量有关的信息。具体地说,C++ string 对象知道自己在内存中的开始位置、包含的字符序列以及字符序列长度;当内存空间不足时,string 还会自动调整,让内存空间增长到足以容纳下所有字符序列的大小。
C++ string 的这种做法,极大地减少了C语言编程中三种最常见且最具破坏性的错误:
- 数组越界;
- 通过未被初始化或者被赋以错误值的指针来访问数组元紊;
- 释放了数组所占内存,但是仍然保留了“悬空”指针。
C++ 标准没有定义 string 类的内存布局,各个编译器厂商可以提供不同的实现,但必须保证 string 的行为一致。采用这种做法是为了获得足够的灵活性。
特別是,C++ 标准没有定义,在哪种确切的情况下,应该为 string 对象分配内存空间来存储字符序列。string 内存分配规则明确规定:允许但不要求以引用计数(reference counting)的方式实现。但无论是否采用引用计数,其语义都必须一致。
C++ 的这种做法和C语言不同,
- 在C语言中,每个字符型数组都占据各自的物理存储区。
- 在 C++ 中,独立的几个 string 对象可以占据也可以不占据各自特定的物理存储区,但是,如果采用引用计数避免了保存同一数据的拷贝副本,那么,各个独立的对象,(在处理上)必须看起来并表现得就像独占地拥有各自的存储区一样。例如:
#include <iostream> #include <string> using namespace std; int main() { string s1("12345"); string s2 = s1; cout << (s1 == s2) << endl; s1[0] = '6'; cout << "s1 = " << s1 << endl; //62345 cout << "s2 = " << s2 << endl; //12345 cout << (s1 == s2) << endl; return 0; }
只有当字符串被修改的时候才创建各自的拷贝,这种实现方式称为写时复制(copy-on-write)策略。当字符串只是作为值参数(value parameter)或在其他只读情形下使用,这种方法能够节省时间和空间。
不论一个库的实现是不是采用引用计数,它对 string 类的使用者来说都应该是透明的。遗憾的是,情况并不总是这样。在多线程程序中,几乎不可能安全地使用引用计数来实现。
const 成员和引用成员必须在构造函数的初始化列表中初始化,此后值不可修改。