【C++学习笔记】C+(2),2024年最新手把手教你在Golang-Studio上分析内存泄漏

先自我介绍一下,小编浙江大学毕业,去过华为、字节跳动等大厂,目前阿里P7

深知大多数程序员,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Golang全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友。
img
img
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以添加V获取:vip1024b (备注go)
img

正文

类的成员函数也和普通函数一样,都有返回值和参数列表,它与一般函数的区别是:成员函数是一个类的成员,出现在类体中,它的作用范围由类来决定;而普通函数是独立的,作用范围是全局的,或位于某个命名空间内。

上节我们在示例中给出了 Student 类的定义,如下所示:

class Student{
public:
//成员变量
char *name;
int age;
float score;
//成员函数
void say(){
cout<<name<<“的年龄是”<<age<<“,成绩是”<<score<<endl;
}
};

这段代码在类体中定义了成员函数。你也可以只在类体中声明函数,而将函数定义放在类体外面,如下图所示:

class Student{
public:
//成员变量
char *name;
int age;
float score;
//成员函数
void say(); //函数声明
};
//函数定义
void Student::say(){
cout<<name<<“的年龄是”<<age<<“,成绩是”<<score<<endl;
}

在类体中直接定义函数时,不需要在函数名前面加上类名,因为函数属于哪一个类是不言而喻的。

但当成员函数定义在类外时,就必须在函数名前面加上类名予以限定。::被称为域解析符(也称作用域运算符或作用域限定符),用来连接类名和函数名,指明当前函数属于哪个类。

成员函数必须先在类体中作原型声明,然后在类外定义,也就是说类体的位置应在函数定义之前。

在类体中和类体外定义成员函数的区别

在类体中和类体外定义成员函数是有区别的:在类体中定义的成员函数会自动成为内联函数,在类体外定义的不会。当然,在类体内部定义的函数也可以加 inline 关键字,但这是多余的,因为类体内部定义的函数默认就是内联函数。

内联函数一般不是我们所期望的,它会将函数调用处用函数体替代,所以我建议在类体内部对成员函数作声明,而在类体外部进行定义,这是一种良好的编程习惯,实际开发中大家也是这样做的。

当然,如果你的函数比较短小,希望定义为内联函数,那也没有什么不妥的。

如果你既希望将函数定义在类体外部,又希望它是内联函数,那么可以在定义函数时加 inline 关键字。当然你也可以在函数声明处加 inline,不过这样做没有效果,编译器会忽略函数声明处的 inline,我们已在《如何规范地使用C++内联函数》中对这点进行了详细讲解。

下面是一个将内联函数定义在类外部的例子:

class Student{
public:
char *name;
int age;
float score;
void say(); //内联函数声明,可以增加 inline 关键字,但编译器会忽略
};
//函数定义
inline void Student::say(){
cout<<name<<“的年龄是”<<age<<“,成绩是”<<score<<endl;
}

这样,say() 就会变成内联函数。

这种在类体外定义 inline 函数的方式,必须将类的定义和成员函数的定义都放在同一个头文件中(或者同一个源文件中),否则编译时无法进行嵌入(将函数代码的嵌入到函数调用出),具体原因我们已在《如何规范地使用C++内联函数》中进行了讲解。

再次强调,虽然 C++ 支持将内联函数定义在类的外部,但我强烈建议将函数定义在类的内部,这样它会自动成为内联函数,何必费力不讨好地将它定义在类的外部呢,这样并没有任何优势。

C++类成员的访问权限以及类的封装

前面我们在定义类时多次使用到了 public 关键字,表示类的成员具有“公开”的访问权限,这节我们就来详细讲解。

C++通过 public、protected、private 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。所谓访问权限,就是你能不能使用该类中的成员。

JavaC# 程序员注意,C++ 中的 public、private、protected 只能修饰类的成员,不能修饰类,C++中的类没有共有私有之分。

在类的内部(定义类的代码内部),无论成员被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。

在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 private、protected 属性的成员。

本节重点讲解 public 和 private,protected 将在继承中讲解。

下面通过一个 Student 类来演示成员的访问权限:

#include
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();
return 0;
}

运行结果:
小明的年龄是15,成绩是92.5
李华的年龄是16,成绩是96

类的声明和成员函数的定义都是类定义的一部分,在实际开发中,我们通常将类的声明放在头文件中,而将成员函数的定义放在源文件中。

类中的成员变量 m_name、m_age 和m_ score 被设置成 private 属性,在类的外部不能通过对象访问。也就是说,私有成员变量和成员函数只能在类内部使用,在类外都是无效的。

成员函数 setname()、setage() 和 setscore() 被设置为 public 属性,是公有的,可以通过对象访问。

private 后面的成员都是私有的,直到有 public 出现才会变成共有的;public 之后再无其他限定符,所以 public 后面的成员都是共有的。

成员变量大都以m_开头,这是约定成俗的写法,不是语法规定的内容。以m_开头既可以一眼看出这是成员变量,又可以和成员函数中的形参名字区分开。

以 setname() 为例,如果将成员变量m_name的名字修改为name,那么 setname() 的形参就不能再叫name了,得换成诸如name1_name这样没有明显含义的名字,否则name=name;这样的语句就是给形参name赋值,而不是给成员变量name赋值。

因为三个成员变量都是私有的,不能通过对象直接访问,所以必须借助三个 public 属性的成员函数来修改它们的值,这说明在类的内部,不管函数变量是public还是private,通过一个public的函数都可以访问这些private的函数和变量。下面的代码是错误的:

Student stu;
//m_name、m_age、m_score 是私有成员变量,不能在类外部通过对象访问
stu.m_name = “小明”;
stu.m_age = 15;
stu.m_score = 92.5f;
stu.show();

简单地谈类的封装

private 关键字的作用在于更好地隐藏类的内部实现,该向外暴露的接口(能通过对象访问的成员)都声明为 public,不希望外部知道、或者只在类内部使用的、或者对外部没有影响的成员,都建议声明为 private。

根据C++软件设计规范,实际项目开发中的成员变量以及只在类内部使用的成员函数(只被成员函数调用的成员函数)都建议声明为 private,而只将允许通过对象调用的成员函数声明为 public。

另外还有一个关键字 protected,声明为 protected 的成员在类外也不能通过对象访问,但是在它的派生类内部可以访问,这点我们将在后续章节中介绍,现在你只需要知道 protected 属性的成员在类外无法访问即可。

有读者可能会提出疑问,将成员变量都声明为 private,如何给它们赋值呢,又如何读取它们的值呢?

我们可以额外添加两个 public 属性的成员函数,一个用来设置成员变量的值,一个用来获取成员变量的值。上面的代码中,setname()、setage()、setscore() 函数就用来设置成员变量的值;如果希望获取成员变量的值,可以再添加三个函数 getname()、getage()、getscore()。

给成员变量赋值的函数通常称为 set 函数,它们的名字通常以set开头,后跟成员变量的名字;读取成员变量的值的函数通常称为 get 函数,它们的名字通常以get开头,后跟成员变量的名字。

除了 set 函数和 get 函数,在创建对象时还可以调用构造函数来初始化各个成员变量,我们将在《C++构造函数》一节中展开讨论。不过构造函数只能给成员变量赋值一次,以后再修改还得借助 set 函数。

这种将成员变量声明为 private、将部分成员函数声明为 public 的做法体现了类的封装性。所谓封装,是指尽量隐藏类的内部实现,只向用户提供有用的成员函数。

有读者可能会说,额外添加 set 函数和 get 函数多麻烦,直接将成员变量设置为 public 多省事!确实,这样做 99.9% 的情况下都不是一种错误,我也不认为这样做有什么不妥;但是,将成员变量设置为 private 是一种软件设计规范,尤其是在大中型项目中,还是请大家尽量遵守这一原则。

为了减少代码量,方便说明问题,本教程中的类可能会将成员变量设置为 public,请读者不要认为这是一种错误。

对private和public的更多说明

声明为 private 的成员和声明为 public 的成员的次序任意,既可以先出现 private 部分,也可以先出现 public 部分。如果既不写 private 也不写 public,就默认为 private。

在一个类体中,private 和 public 可以分别出现多次。每个部分的有效范围到出现另一个访问限定符或类体结束时(最后一个右花括号)为止。但是为了使程序清晰,应该养成这样的习惯,使每一种成员访问限定符在类定义体中只出现一次。

下面的类声明也是完全正确的:

class Student{
private:
char *m_name;
private:
int m_age;
float m_score;
public:
void setname(char *name);
void setage(int age);
public:
void setscore(float score);
void show();
};

C++对象的内存模型

类是创建对象的模板,不占用内存空间,不存在于编译后的可执行文件中;而对象是实实在在的数据,需要内存来存储。对象被创建时会在栈区或者堆区分配内存。

直观的认识是,如果创建了 10 个对象,就要分别为这 10 个对象的成员变量和成员函数分配内存,如下图所示:

img

不同对象的成员变量的值可能不同,需要单独分配内存来存储。但是不同对象的成员函数的代码是一样的,上面的内存模型保存了 10 份相同的代码片段,浪费了不少空间,可以将这些代码片段压缩成一份。

事实上编译器也是这样做的,编译器会将成员变量和成员函数分开存储:分别为每个对象的成员变量分配内存,但是所有对象都共享同一段函数代码。如下图所示:

img

成员变量在堆区或栈区分配内存,成员函数在代码区分配内存。如果你对 C/C++ 程序的内存分区不了解,请阅读《C语言内存精讲》专题。

【示例】使用 sizeof 获取对象所占内存的大小:

#include
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;
}

运行结果:
12
12
12

Student 类包含三个成员变量,它们的类型分别是 char *、int、float,都占用 4 个字节的内存,加起来共占用 12 个字节的内存。通过 sizeof 求得的结果等于 12,恰好说明对象所占用的内存仅仅包含了成员变量。

类可以看做是一种复杂的数据类型,也可以使用 sizeof 求得该类型的大小。从运行结果可以看出,在计算类这种类型的大小时,只计算了成员变量的大小,并没有把成员函数也包含在内。

对象的大小只受成员变量的影响,和成员函数没有关系。

假设 stu 的起始地址为 0X1000,那么该对象的内存分布如下图所示:
img

m_name、m_age、m_score 按照声明的顺序依次排列,和结构体非常类似,也会有内存对齐的问题。

C++函数编译原理和成员函数的实现

从上节的分析中可以看出,对象的内存中只保留了成员变量,除此之外没有任何其他信息,程序运行时不知道 stu 的类型为 Student,也不知道它还有四个成员函数 setname()、setage()、setscore()、show(),C++ 究竟是如何通过对象调用成员函数的呢?

C++函数的编译

C++和C语言的编译方式不同。C语言中的函数在编译时名字不变,或者只是简单的加一个下划线_(不同的编译器有不同的实现),例如,func() 编译后为 func() 或 _func()。

而C++中的函数在编译时会根据它所在的命名空间、它所属的类、以及它的参数列表(也叫参数签名)等信息进行重新命名,形成一个新的函数名。这个新的函数名只有编译器知道,对用户是不可见的。对函数重命名的过程叫做名字编码(Name Mangling),是通过一种特殊的算法来实现的。

Name Mangling 的算法是可逆的,既可以通过现有函数名计算出新函数名,也可以通过新函数名逆向推演出原有函数名。Name Mangling 可以确保新函数名的唯一性,只要函数所在的命名空间、所属的类、包含的参数列表等有一个不同,最后产生的新函数名也不同。

如果你希望看到经 Name Mangling 产生的新函数名,可以只声明而不定义函数,这样调用函数时就会产生链接错误,从报错信息中就可以看到新函数名。请看下面的代码:

#include
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;
}

该例中声明了四个同名函数,包括两个具有重载关系的全局函数,一个位于命名空间 ns 下的函数,以及一个属于类 Demo 的函数。它们都是只声明而未定义的函数。

在 VS 下编译源代码可以看到类似下面的错误信息:
img

小括号中就是经 Name Mangling 产生的新函数名,它们都以?开始,以区别C语言中的_

上图是 VS2010 产生的错误信息,不同的编译器有不同的 Name Mangling 算法,产生的函数名也不一样。

__thiscall、cdecl 是函数调用惯例,有兴趣的读者可以猛击《函数调用惯例》一文深入了解。

除了函数,某些变量也会经 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<a<<endl;
cout<b<<endl;
}

使用obj.display()调用函数时,也会被编译成类似下面的形式:

new_function_name(&obj);

这样通过传递对象指针就完成了成员函数和成员变量的关联。这与我们从表明上看到的刚好相反,通过对象调用成员函数时,不是通过对象找函数,而是通过函数找对象。

这一切都是隐式完成的,对程序员来说完全透明,就好像这个额外的参数不存在一样。

最后需要提醒的是,Demo * const p中的 const 表示指针不能被修改,p 只能指向当前对象,不能指向其他对象。读者可以猛击《C语言const的用法详解》了解更多关系 const 的信息。

C++构造函数详解

C++中,有一种特殊的成员函数,它的名字和类名相同,没有返回值,不需要用户显式调用(用户也不能调用),而是在创建对象时自动执行。这种特殊的成员函数就是构造函数(Constructor)。

在《C++类成员的访问权限以及类的封装》一节中,我们通过成员函数 setname()、setage()、setscore() 分别为成员变量 name、age、score 赋值,这样做虽然有效,但显得有点麻烦。有了构造函数,我们就可以简化这项工作,在创建对象的同时为成员变量赋值,请看下面的代码(示例1):

#include
using namespace std;
class Student{
private:
char *m_name;
int m_age;
float m_score;
public:
//声明构造函数
Student(char *name, int age, float score);
//声明普通成员函数
void show();
};
//定义构造函数
Student::Student(char *name, int age, float score){
m_name = name;
m_age = age;
m_score = score;
}
//定义普通成员函数
void Student::show(){
cout<<m_name<<“的年龄是”<<m_age<<“,成绩是”<<m_score<<endl;
}
int main(){
//创建对象时向构造函数传参
Student stu(“小明”, 15, 92.5f);
stu.show();
//创建对象时向构造函数传参
Student *pstu = new Student(“李华”, 16, 96);
pstu -> show();
return 0;
}

运行结果:
小明的年龄是15,成绩是92.5
李华的年龄是16,成绩是96

该例在 Student 类中定义了一个构造函数Student(char *, int, float),它的作用是给三个 private 属性的成员变量赋值。要想调用该构造函数,就得在创建对象的同时传递实参,并且实参由( )包围,和普通的函数调用非常类似。

在栈上创建对象时,实参位于对象名后面,例如Student stu("小明", 15, 92.5f);在堆上创建对象时,实参位于类名后面,例如new Student("李华", 16, 96)

构造函数必须是 public 属性的,否则创建对象时无法调用。当然,设置为 private、protected 属性也不会报错,但是没有意义。

构造函数没有返回值,因为没有变量来接收返回值,即使有也毫无用处,这意味着:

  • 不管是声明还是定义,函数名前面都不能出现返回值类型,即使是 void 也不允许;
  • 函数体中不能有 return 语句。

构造函数的重载

和普通成员函数一样,构造函数是允许重载的。一个类可以有多个重载的构造函数,创建对象时根据传递的实参来判断调用哪一个构造函数。

构造函数的调用是强制性的,一旦在类中定义了构造函数,那么创建对象时就一定要调用,不调用是错误的。如果有多个重载的构造函数,那么创建对象时提供的实参必须和其中的一个构造函数匹配;反过来说,创建对象时只有一个构造函数会被调用。

对示例1中的代码,如果写作Student stu或者new Student就是错误的,因为类中包含了构造函数,而创建对象时却没有调用。

更改示例1的代码,再添加一个构造函数(示例2):

#include
using namespace std;
class Student{
private:
char *m_name;
int m_age;
float m_score;
public:
Student();
Student(char *name, int age, float score);
void setname(char *name);
void setage(int age);
void setscore(float score);
void show();
};
Student::Student(){
m_name = NULL;
m_age = 0;
m_score = 0.0;
}
Student::Student(char *name, int age, float score){
m_name = name;
m_age = age;
m_score = score;
}
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(){
if(m_name == NULL || m_age <= 0){
cout<<“成员变量还未初始化”<<endl;
}else{
cout<<m_name<<“的年龄是”<<m_age<<“,成绩是”<<m_score<<endl;
}
}
int main(){
//调用构造函数 Student(char *, int, float)
Student stu(“小明”, 15, 92.5f);
stu.show();
//调用构造函数 Student()
Student *pstu = new Student();
pstu -> show();
pstu -> setname(“李华”);
pstu -> setage(16);
pstu -> setscore(96);
pstu -> show();
return 0;
}

运行结果:
小明的年龄是15,成绩是92.5
成员变量还未初始化
李华的年龄是16,成绩是96

构造函数Student(char *, int, float)为各个成员变量赋值,构造函数Student()将各个成员变量的值设置为空,它们是重载关系。根据Student()创建对象时不会赋予成员变量有效值,所以还要调用成员函数 setname()、setage()、setscore() 来给它们重新赋值。

构造函数在实际开发中会大量使用,它往往用来做一些初始化工作,例如对成员变量赋值、预先打开文件等。

默认构造函数

如果用户自己没有定义构造函数,那么编译器会自动生成一个默认的构造函数,只是这个构造函数的函数体是空的,也没有形参,也不执行任何操作。比如上面的 Student 类,默认生成的构造函数如下:

Student(){}

一个类必须有构造函数,要么用户自己定义,要么编译器自动生成。一旦用户自己定义了构造函数,不管有几个,也不管形参如何,编译器都不再自动生成。在示例1中,Student 类已经有了一个构造函数Student(char *, int, float),也就是我们自己定义的,编译器不会再额外添加构造函数Student(),在示例2中我们才手动添加了该构造函数。

实际上编译器只有在必要的时候才会生成默认构造函数,而且它的函数体一般不为空。默认构造函数的目的是帮助编译器做初始化工作,而不是帮助程序员。这是C++的内部实现机制,这里不再深究,初学者可以按照上面说的“一定有一个空函数体的默认构造函数”来理解。

最后需要注意的一点是,调用没有参数的构造函数也可以省略括号。对于示例2的代码,在栈上创建对象可以写作Student stu()Student stu,在堆上创建对象可以写作Student *pstu = new Student()Student *pstu = new Student,它们都会调用构造函数 Student()。

以前我们就是这样做的,创建对象时都没有写括号,其实是调用了默认的构造函数。

C++构造函数初始化列表

构造函数的一项重要功能是对成员变量进行初始化,为了达到这个目的,可以在构造函数的函数体中对成员变量一一赋值,还可以采用初始化列表。

C++构造函数的初始化列表使得代码更加简洁,请看下面的例子:

#include
using namespace std;
class Student{
private:
char *m_name;
int m_age;
float m_score;
public:
Student(char *name, int age, float score);
void show();
};
//采用初始化列表
Student::Student(char *name, int age, float score): m_name(name), m_age(age), m_score(score){
//TODO:
}
void Student::show(){
cout<<m_name<<“的年龄是”<<m_age<<“,成绩是”<<m_score<<endl;
}
int main(){
Student stu(“小明”, 15, 92.5f);
stu.show();
Student *pstu = new Student(“李华”, 16, 96);
pstu -> show();
return 0;
}

运行结果:
小明的年龄是15,成绩是92.5
李华的年龄是16,成绩是96

如本例所示,定义构造函数时并没有在函数体中对成员变量一一赋值,其函数体为空(当然也可以有其他语句),而是在函数首部与函数体之间添加了一个冒号:,后面紧跟m_name(name), m_age(age), m_score(score)语句,这个语句的意思相当于函数体内部的m_name = name; m_age = age; m_score = score;语句,也是赋值的意思。

使用构造函数初始化列表并没有效率上的优势,仅仅是书写方便,尤其是成员变量较多时,这种写法非常简单明了。

初始化列表可以用于全部成员变量,也可以只用于部分成员变量。下面的示例只对 m_name 使用初始化列表,其他成员变量还是一一赋值:

Student::Student(char *name, int age, float score): m_name(name){
m_age = age;
m_score = score;
}

注意,成员变量的初始化顺序与初始化列表中列出的变量的顺序无关,它只与成员变量在类中声明的顺序有关。请看代码:

#include
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;
}

运行结果:
2130567168, 100

在初始化列表中,我们将 m_b 放在了 m_a 的前面,看起来是先给 m_b 赋值,再给 m_a 赋值,其实不然!成员变量的赋值顺序由它们在类中的声明顺序决定,在 Demo 类中,我们先声明的 m_a,再声明的 m_b,所以构造函数和下面的代码等价:

Demo::Demo(int b): m_b(b), m_a(m_b){
m_a = m_b;
m_b = b;
}

给 m_a 赋值时,m_b 还未被初始化,它的值是不确定的,所以输出的 m_a 的值是一个奇怪的数字;给 m_a 赋值完成后才给 m_b 赋值,此时 m_b 的值才是 100。

obj 在栈上分配内存,成员变量的初始值是不确定的。

初始化 const 成员变量

构造函数初始化列表还有一个很重要的作用,那就是初始化 const 成员变量。初始化 const 成员变量的唯一方法就是使用初始化列表。例如 VS/VC 不支持变长数组(数组长度不能是变量),我们自己定义了一个 VLA 类,用于模拟变长数组,请看下面的代码:

class VLA{
private:
const int m_len;
int *m_arr;
public:
VLA(int len);
};
//必须使用初始化列表来初始化 m_len
VLA::VLA(int len): m_len(len){
m_arr = new int[len];
}

VLA 类包含了两个成员变量,m_len 和 m_arr 指针,需要注意的是 m_len 加了 const 修饰,只能使用初始化列表的方式赋值,如果写作下面的形式是错误的:

class VLA{
private:
const int m_len;
int *m_arr;
public:
VLA(int len);
};
VLA::VLA(int len){
m_len = len;
m_arr = new int[len];
}

C++析构函数详解

创建对象时系统会自动调用构造函数进行初始化工作,同样,销毁对象时系统也会自动调用一个函数来进行清理工作,例如释放分配的内存、关闭打开的文件等,这个函数就是析构函数。

析构函数(Destructor)也是一种特殊的成员函数,没有返回值,不需要程序员显式调用(程序员也没法显式调用),而是在销毁对象时自动执行。构造函数的名字和类名相同,而析构函数的名字是在类名前面加一个~符号。

注意:析构函数没有参数,不能被重载,因此一个类只能有一个析构函数。如果用户没有定义,编译器会自动生成一个默认的析构函数。

上节我们定义了一个 VLA 类来模拟变长数组,它使用一个构造函数为数组分配内存,这些内存在数组被销毁后不会自动释放,所以非常有必要再添加一个析构函数,专门用来释放已经分配的内存。请看下面的完整示例:

#include
using namespace std;
class VLA{
public:
VLA(int len); //构造函数
~VLA(); //析构函数
public:
void input(); //从控制台输入数组元素
void show(); //显示数组元素
private:
int *at(int i); //获取第i个元素的指针
private:
const int m_len; //数组长度
int *m_arr; //数组指针
int *m_p; //指向数组第i个元素的指针
};
VLA::VLA(int len): m_len(len){ //使用初始化列表来给 m_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; m_p=at(i); i++){ cin>>*at(i); }
}
void VLA::show(){
for(int i=0; m_p=at(i); 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 *parr = new VLA(n);
//输入数组元素
cout<<"Input “<<n<<” numbers: ";
parr -> input();
//输出数组元素
cout<<"Elements: ";
parr -> show();
//删除数组(对象)
delete parr;
return 0;
}

运行结果:
Input array length: 5
Input 5 numbers: 99 23 45 10 100
Elements: 99, 23, 45, 10, 100

~VLA()就是 VLA 类的析构函数,它的唯一作用就是在删除对象(第 53 行代码)后释放已经分配的内存。

函数名是标识符的一种,原则上标识符的命名中不允许出现~符号,在析构函数的名字中出现的~可以认为是一种特殊情况,目的是为了和构造函数的名字加以对比和区分。

注意:at() 函数只在类的内部使用,所以将它声明为 private 属性;m_len 变量不允许修改,所以用 const 进行了限制,这样就只能使用初始化列表来进行赋值。

C++ 中的 new 和 delete 分别用来分配和释放内存,它们与C语言中 malloc()、free() 最大的一个不同之处在于:用 new 分配内存时会调用构造函数,用 delete 释放内存时会调用析构函数。构造函数和析构函数对于类来说是不可或缺的,所以在C++中我们非常鼓励使用 new 和 delete。

析构函数的执行时机

析构函数在对象被销毁时调用,而对象的销毁时机与它所在的内存区域有关。不了解内存分区的读者请阅读《C语言内存精讲》专题。

在所有函数之外创建的对象是全局对象,它和全局变量类似,位于内存分区中的全局数据区,程序在结束执行时会调用这些对象的析构函数。

在函数内部创建的对象是局部对象,它和局部变量类似,位于栈区,函数执行结束时会调用这些对象的析构函数。

new 创建的对象位于堆区,通过 delete 删除时才会调用析构函数;如果没有 delete,析构函数就不会被执行。

下面的例子演示了析构函数的执行。

#include
#include
using namespace std;
class Demo{
public:
Demo(string s);
~Demo();
private:
string m_s;
};
Demo::Demo(string s): m_s(s){ }
Demo::~Demo(){ cout<<m_s<<endl; }
void func(){
//局部对象
Demo obj1(“1”);
}
//全局对象
Demo obj2(“2”);
int main(){
//局部对象
Demo obj3(“3”);
//new创建的对象
Demo *pobj4 = new Demo(“4”);
func();
cout<<“main”<<endl;

return 0;
}

运行结果:
1
main
3
2

C++对象数组(数组的每个元素都是对象)

C++ 允许数组的每个元素都是对象,这样的数组称为对象数组。

对象数组中的每个元素都需要用构造函数初始化。具体哪些元素用哪些构造函数初始化,取决于定义数组时的写法,请看下面的例子:

#include
using namespace std;
class CSample{
public:
CSample(){ //构造函数 1
cout<<“Constructor 1 Called”<<endl;
}
CSample(int n){ //构造函数 2
cout<<“Constructor 2 Called”<<endl;
}
};
int main(){
cout<<“stepl”<<endl;
CSample arrayl[2];
cout<<“step2”<<endl;
CSample array2[2] = {4, 5};
cout<<“step3”<<endl;
CSample array3[2] = {3};
cout<<“step4”<<endl;
CSample* array4 = new CSample[2];
delete [] array4;
return 0;
}

程序的输出结果是:
stepl
Constructor 1 Called
Constructor 1 Called
step2
Constructor 2 Called
Constructor 2 Called
step3
Constructor 2 Called
Constructor 1 Called
step4
Constructor 1 Called
Constructor 1 Called

第 16 行的 array1 数组中的两个元素没有指明如何初始化,那么默认调用无参构造函数初始化,因此输出两行 Constructor 1 Called。

第 19 行的 array2 数组进行了初始化,初始化列表 {4, 5} 可以看作用来初始化两个数组元素的参数,所以 array2[0] 以 4 为参数,调用构造函数 2 进行初始化;array2[1] 以 5 为参数,调用构造函数 2 进行初始化。这导致输出两行 Constructor 2 Called。

第 22 行的 array3 只指出了 array3[0] 的初始化方式,没有指出 array3[1] 的初始化方式,因此它们分别用构造函数 2 和构造函数 1 进行初始化。

第 25 行动态分配了一个 CSample 数组,其中有两个元素,没有指出和参数有关的信息,因此这两个元素都用无参构造函数初始化。

在构造函数有多个参数时,数组的初始化列表中要显式地包含对构造函数的调用。例如下面的程序:

class CTest{
public:
CTest(int n){ } //构造函数(1)
CTest(int n, int m){ } //构造函数(2)
CTest(){ } //构造函数(3)
};
int main(){
//三个元素分别用构造函数(1)、(2)、(3) 初始化
CTest arrayl [3] = { 1, CTest(1,2) };
//三个元素分别用构造函数(2)、(2)、(1)初始化
CTest array2[3] = { CTest(2,3), CTest(1,2), 1};
//两个元素指向的对象分别用构造函数(1)、(2)初始化
CTest* pArray[3] = { new CTest(4), new CTest(1,2) };
return 0;
}

上面程序中比较容易令初学者困惑的是第 13 行。pArray 数组是一个指针数组,其元素不是 CTest 类的对象,而是 CTest 类的指针。第 13 行对 pArray[0] 和 pArray[1] 进行了初始化,把它们初始化为指向动态分配的 CTest 对象的指针,而这两个动态分配出来的 CTest 对象又分别是用构造函数(1)和构造函数(2)初始化的。pArray[2] 没有初始化,其值是随机的,不知道指向哪里。

第 13 行生成了两个 CTest 对象,而不是三个,所以也只调用了两次 CTest 类的构造函数。

C++成员对象和封闭类详解

一个类的成员变量如果是另一个类的对象,就称之为“成员对象”。包含成员对象的类叫封闭类(enclosed class)。

成员对象的初始化

创建封闭类的对象时,它包含的成员对象也需要被创建,这就会引发成员对象构造函数的调用。如何让编译器知道,成员对象到底是用哪个构造函数初始化的呢?这就需要借助封闭类构造函数的初始化列表

构造函数初始化列表的写法如下:

类名::构造函数名(参数表): 成员变量1(参数表), 成员变量2(参数表), …
{
//TODO:
}

对于基本类型的成员变量,“参数表”中只有一个值,就是初始值,在调用构造函数时,会把这个初始值直接赋给成员变量。

但是对于成员对象,“参数表”中存放的是构造函数的参数,它可能是一个值,也可能是多个值,它指明了该成员对象如何被初始化。

请看下面的例子:

#include
using namespace std;
//轮胎类
class Tyre{
public:
Tyre(int radius, int width);
void show() const;
private:
int m_radius; //半径
int m_width; //宽度
};
Tyre::Tyre(int radius, int width) : m_radius(radius), m_width(width){ }
void Tyre::show() const {
cout << “轮毂半径:” << this->m_radius << “吋” << endl;
cout << “轮胎宽度:” << this->m_width << “mm” << endl;
}
//引擎类
class Engine{
public:
Engine(float displacement = 2.0);
void show() const;
private:
float m_displacement;
};
Engine::Engine(float displacement) : m_displacement(displacement) {}
void Engine::show() const {
cout << “排量:” << this->m_displacement << “L” << endl;
}
//汽车类
class Car{
public:
Car(int price, int radius, int width);
void show() const;
private:
int m_price; //价格
Tyre m_tyre;
Engine m_engine;
};
Car::Car(int price, int radius, int width): m_price(price), m_tyre(radius, width)/指明m_tyre对象的初始化方式/{ };
void Car::show() const {
cout << “价格:” << this->m_price << “¥” << endl;
this->m_tyre.show();
this->m_engine.show();
}
int main()
{
Car car(200000, 19, 245);
car.show();
return 0;
}

运行结果:
价格:200000¥
轮毂直径:19吋
轮胎宽度:245mm
排量:2L

Car 是一个封闭类,它有两个成员对象:m_tyre 和 m_engine。在编译第 51 行时,编译器需要知道 car 对象中的 m_tyre 和 m_engine 成员对象该如何初始化。

编评器已经知道这里的 car 对象是用第 42 行的 Car(int price, int radius, int width) 构造函数初始化的,那么 m_tyre 和 m_engine 该如何初始化,就要看第 42 行后面的初始化列表了。该初始化列表表明:

  • m_tyre 应以 radius 和 width 作为参数调用 Tyre(int radius, int width) 构造函数初始化。
  • 但是这里并没有说明 m_engine 该如何处理。在这种情况下,编译器就认为 m_engine 应该用 Engine 类的无参构造函数初始化。而 Engine 类确实有一个无参构造函数(因为设置了默认参数),因此,整个 car 对象的初始化问题就都解决了。

总之,生成封闭类对象的语句一定要让编译器能够弄明白其成员对象是如何初始化的,否则就会编译错误。

在上面的程序中,如果 Car 类的构造函数没有初始化列表,那么第 51 行就会编译出错,因为编译器不知道该如何初始化 car.m_tyre 对象,因为 Tyre 类没有无参构造函数,而编译器又找不到用来初始化 car.m_tyre 对象的参数。

成员对象的消亡

封闭类对象生成时,先执行所有成员对象的构造函数,然后才执行封闭类自己的构造函数。成员对象构造函数的执行次序和成员对象在类定义中的次序一致,与它们在构造函数初始化列表中出现的次序无关。

当封闭类对象消亡时,先执行封闭类的析构函数,然后再执行成员对象的析构函数,成员对象析构函数的执行次序和构造函数的执行次序相反,即先构造的后析构,这是 C++ 处理此类次序问题的一般规律。

请看下面的代码:

#include
using namespace std;
class Tyre {
public:
Tyre() { cout << “Tyre constructor” << endl; }
~Tyre() { cout << “Tyre destructor” << endl; }
};
class Engine {
public:
Engine() { cout << “Engine constructor” << endl; }
~Engine() { cout << “Engine destructor” << endl; }
};
class Car {
private:
Engine engine;
Tyre tyre;
public:
Car() { cout << “Car constructor” << endl; }
~Car() { cout << “Car destructor” << endl; }
};
int main() {
Car car;
return 0;
}

运行结果:
Engine constructor
Tyre constructor
Car constructor
Car destructor
Tyre destructor
Engine destructor

C++ this指针详解(精辟)

this 是 C++ 中的一个关键字,也是一个 const 指针,它指向当前对象,通过它可以访问当前对象的所有成员。

所谓当前对象,是指正在使用的对象。例如对于stu.show();,stu 就是当前对象,this 就指向 stu。

下面是使用 this 的一个完整示例:

#include
using namespace std;
class Student{
public:
void setname(char *name);
void setage(int age);
void setscore(float score);
void show();
private:
char *name;
int age;
float score;
};
void Student::setname(char *name){
this->name = name;
}
void Student::setage(int age){
this->age = age;
}
void Student::setscore(float score){
this->score = score;
}
void Student::show(){
cout<name<<“的年龄是”<age<<“,成绩是”<score<<endl;
}
int main(){
Student *pstu = new Student;
pstu -> setname(“李华”);
pstu -> setage(16);
pstu -> setscore(96.5);
pstu -> show();
return 0;
}

运行结果:
李华的年龄是16,成绩是96.5

this 只能用在类的内部,通过 this 可以访问类的所有成员,包括 private、protected、public 属性的。

本例中成员函数的参数和成员变量重名,只能通过 this 区分。以成员函数setname(char *name)为例,它的形参是name,和成员变量name重名,如果写作name = name;这样的语句,就是给形参name赋值,而不是给成员变量name赋值。而写作this -> name = name;后,=左边的name就是成员变量,右边的name就是形参,一目了然。

注意,this 是一个指针,要用->来访问成员变量或成员函数。

this 虽然用在类的内部,但是只有在对象被创建以后才会给 this 赋值,并且这个赋值的过程是编译器自动完成的,不需要用户干预,用户也不能显式地给 this 赋值。本例中,this 的值和 pstu 的值是相同的。

我们不妨来证明一下,给 Student 类添加一个成员函数printThis(),专门用来输出 this 的值,如下所示:

void Student::printThis(){
cout<<this<<endl;
}

然后在 main() 函数中创建对象并调用 printThis():

Student *pstu1 = new Student;
pstu1 -> printThis();
cout<<pstu1<<endl;
Student *pstu2 = new Student;
pstu2 -> printThis();
cout<<pstu2<<endl;

运行结果:
0x7b17d8
0x7b17d8
0x7b17f0
0x7b17f0

可以发现,this 确实指向了当前对象,而且对于不同的对象,this 的值也不一样。

几点注意:

  • this 是 const 指针,它的值是不能被修改的,一切企图修改该指针的操作,如赋值、递增、递减等都是不允许的。
  • this 只能在成员函数内部使用,用在其他地方没有意义,也是非法的。
  • 只有当对象被创建后 this 才有意义,因此不能在 static 成员函数中使用(后续会讲到 static 成员)。

this 到底是什么

this 实际上是成员函数的一个形参,在调用成员函数时将对象的地址作为实参传递给 this。不过 this 这个形参是隐式的,它并不出现在代码中,而是在编译阶段由编译器默默地将它添加到参数列表中。

this 作为隐式形参,本质上是成员函数的局部变量,所以只能用在成员函数的内部,并且只有在通过对象调用成员函数时才给 this 赋值。

在《C++函数编译原理和成员函数的实现》一节中讲到,成员函数最终被编译成与对象无关的普通函数,除了成员变量,会丢失所有信息,所以编译时要在成员函数中添加一个额外的参数,把当前对象的首地址传入,以此来关联成员函数和成员变量。这个额外的参数,实际上就是 this,它是成员函数和成员变量关联的桥梁。

C++ static静态成员变量详解

对象的内存中包含了成员变量,不同的对象占用不同的内存(已在《C++对象的内存模型》中提到),这使得不同对象的成员变量相互独立,它们的值不受其他对象的影响。例如有两个相同类型的对象 a、b,它们都有一个成员变量 m_name,那么修改 a.m_name 的值不会影响 b.m_name 的值。

可是有时候我们希望在多个对象之间共享数据,对象 a 改变了某份数据后对象 b 可以检测到。共享数据的典型使用场景是计数,以前面的 Student 类为例,如果我们想知道班级中共有多少名学生,就可以设置一份共享的变量,每次创建对象时让该变量加 1。

C++中,我们可以使用静态成员变量来实现多个对象共享数据的目标。静态成员变量是一种特殊的成员变量,它被关键字static修饰,例如:

class Student{
public:
Student(char *name, int age, float score);
void show();
public:
static int m_total; //静态成员变量
private:
char *m_name;
int m_age;
float m_score;
};

这段代码声明了一个静态成员变量 m_total,用来统计学生的人数。

static 成员变量属于类,不属于某个具体的对象,即使创建多个对象,也只为 m_total 分配一份内存,所有对象使用的都是这份内存中的数据。当某个对象修改了 m_total,也会影响到其他对象。

static 成员变量必须在类声明的外部初始化,具体形式为:

type class::name = value;

type 是变量的类型,class 是类名,name 是变量名,value 是初始值。将上面的 m_total 初始化:

int Student::m_total = 0;

静态成员变量在初始化时不能再加 static,但必须要有数据类型。被 private、protected、public 修饰的静态成员变量都可以用这种方式初始化。

注意:static 成员变量的内存既不是在声明类时分配,也不是在创建对象时分配,而是在(类外)初始化时分配。反过来说,没有在类外初始化的 static 成员变量不能使用。

static 成员变量既可以通过对象来访问,也可以通过类来访问。请看下面的例子:

//通过类类访问 static 成员变量
Student::m_total = 10;
//通过对象来访问 static 成员变量
Student stu(“小明”, 15, 92.5f);
stu.m_total = 20;
//通过对象指针来访问 static 成员变量
Student *pstu = new Student(“李华”, 16, 96);
pstu -> m_total = 20;

这三种方式是等效的。

注意:static 成员变量不占用对象的内存,而是在所有对象之外开辟内存,即使不创建对象也可以访问。具体来说,static 成员变量和普通的 static 变量类似,都在内存分区中的全局数据区分配内存,不了解的读者请阅读《C语言内存精讲》专题。

下面来看一个完整的例子:

#include
using namespace std;
class Student{
public:
Student(char *name, int age, float score);
void show();
private:
static int m_total; //静态成员变量
private:
char *m_name;
int m_age;
float m_score;
};
//初始化静态成员变量
int Student::m_total = 0;
Student::Student(char *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;
}
int main(){
//创建匿名对象
(new Student(“小明”, 15, 90)) -> show();
(new Student(“李磊”, 16, 80)) -> show();
(new Student(“张华”, 16, 99)) -> show();
(new Student(“王康”, 14, 60)) -> show();
return 0;
}

运行结果:
小明的年龄是15,成绩是90(当前共有1名学生)
李磊的年龄是16,成绩是80(当前共有2名学生)
张华的年龄是16,成绩是99(当前共有3名学生)
王康的年龄是14,成绩是60(当前共有4名学生)

本例中将 m_total 声明为静态成员变量,每次创建对象时,会调用构造函数使 m_total 的值加 1。

之所以使用匿名对象,是因为每次创建对象后只会使用它的 show() 函数,不再进行其他操作。不过使用匿名对象无法回收内存,会导致内存泄露,在中大型程序中不建议使用。

几点说明

  1. 一个类中可以有一个或多个静态成员变量,所有的对象都共享这些静态成员变量,都可以引用它。
  2. static 成员变量和普通 static 变量一样,都在内存分区中的全局数据区分配内存,到程序结束时才释放。这就意味着,static 成员变量不随对象的创建而分配内存,也不随对象的销毁而释放内存。而普通成员变量在对象创建时分配内存,在对象销毁时释放内存。
  3. 静态成员变量必须初始化,而且只能在类体外进行。例如:

int Student::m_total = 10;

初始化时可以赋初值,也可以不赋值。如果不赋值,那么会被默认初始化为 0。全局数据区的变量都有默认的初始值 0,而动态数据区(堆区、栈区)变量的默认值是不确定的,一般认为是垃圾值。

  1. 静态成员变量既可以通过对象名访问,也可以通过类名访问,但要遵循 private、protected 和 public 关键字的访问权限限制。当通过对象名访问时,对于不同的对象,访问的是同一份内存。

C++ static静态成员函数详解

在类中,static 除了可以声明静态成员变量,还可以声明静态成员函数。普通成员函数可以访问所有成员(包括成员变量和成员函数),静态成员函数只能访问静态成员。

编译器在编译一个普通成员函数时,会隐式地增加一个形参 this,并把当前对象的地址赋值给 this,所以普通成员函数只能在创建对象后通过对象来调用,因为它需要当前对象的地址。而静态成员函数可以通过类来直接调用,编译器不会为它增加形参 this,它不需要当前对象的地址,所以不管有没有创建对象,都可以调用静态成员函数。

普通成员变量占用对象的内存,静态成员函数没有 this 指针,不知道指向哪个对象,无法访问对象的成员变量,也就是说静态成员函数不能访问普通成员变量,只能访问静态成员变量。

普通成员函数必须通过对象才能调用,而静态成员函数没有 this 指针,无法在函数体内部访问某个对象,所以不能调用普通成员函数,只能调用静态成员函数。

静态成员函数与普通成员函数的根本区别在于:普通成员函数有 this 指针,可以访问类中的任意成员;而静态成员函数没有 this 指针,只能访问静态成员(包括静态成员变量和静态成员函数)。

下面是一个完整的例子,该例通过静态成员函数来获得学生的总人数和总成绩:

#include
using namespace std;
class Student{
public:
Student(char *name, int age, float score);
void show();
public: //声明静态成员函数
static int getTotal();
static float getPoints();
private:
static int m_total; //总人数
static float m_points; //总成绩
private:
char *m_name;
int m_age;
float m_score;
};
int Student::m_total = 0;
float Student::m_points = 0.0;
Student::Student(char *name, int age, float score): m_name(name), m_age(age), m_score(score){
m_total++;
m_points += score;
}
void Student::show(){
cout<<m_name<<“的年龄是”<<m_age<<“,成绩是”<<m_score<<endl;
}
//定义静态成员函数
int Student::getTotal(){
return m_total;
}
float Student::getPoints(){
return m_points;
}
int main(){
(new Student(“小明”, 15, 90.6)) -> show();
(new Student(“李磊”, 16, 80.5)) -> show();
(new Student(“张华”, 16, 99.0)) -> show();
(new Student(“王康”, 14, 60.8)) -> show();
int total = Student::getTotal();
float points = Student::getPoints();
cout<<“当前共有”<<total<<“名学生,总成绩是”<<points<<“,平均分是”<<points/total<<endl;
return 0;
}

运行结果:
小明的年龄是15,成绩是90.6
李磊的年龄是16,成绩是80.5
张华的年龄是16,成绩是99
王康的年龄是14,成绩是60.8
当前共有4名学生,总成绩是330.9,平均分是82.725

总人数 m_total 和总成绩 m_points 由各个对象累加得到,必须声明为 static 才能共享;getTotal()、getPoints() 分别用来获取总人数和总成绩,为了访问 static 成员变量,我们将这两个函数也声明为 static。

C++中,静态成员函数的主要目的是访问静态成员。getTotal()、getPoints() 当然也可以声明为普通成员函数,但是它们都只对静态成员进行操作,加上 static 语义更加明确。

和静态成员变量类似,静态成员函数在声明时要加 static,在定义时不能加 static。静态成员函数可以通过类来调用(一般都是这样做),也可以通过对象来调用,上例仅仅演示了如何通过类来调用。

C++ const成员变量和成员函数(常成员函数)

在类中,如果你不希望某些数据被修改,可以使用const关键字加以限定。const 可以用来修饰成员变量和成员函数。

const成员变量

const 成员变量的用法和普通 const 变量的用法相似,只需要在声明时加上 const 关键字。初始化 const 成员变量只有一种方法,就是通过构造函数的初始化列表,这点在前面已经讲到了,请猛击《C++初始化列表》回顾。

const成员函数(常成员函数)

const 成员函数可以使用类中的所有成员变量,但是不能修改它们的值,这种措施主要还是为了保护数据而设置的。const 成员函数也称为常成员函数。

我们通常将 get 函数设置为常成员函数。读取成员变量的函数的名字通常以get开头,后跟成员变量的名字,所以通常将它们称为 get 函数。

常成员函数需要在声明和定义的时候在函数头部的结尾加上 const 关键字,请看下面的例子:

class Student{
public:
Student(char *name, int age, float score);
void show();
//声明常成员函数
char *getname() const;
int getage() const;
float getscore() const;
private:
char *m_name;
int m_age;
float m_score;
};
Student::Student(char *name, int age, float score): m_name(name), m_age(age), m_score(score){ }
void Student::show(){
cout<<m_name<<“的年龄是”<<m_age<<“,成绩是”<<m_score<<endl;
}
//定义常成员函数
char * Student::getname() const{
return m_name;
}
int Student::getage() const{
return m_age;
}
float Student::getscore() const{
return m_score;
}

getname()、getage()、getscore() 三个函数的功能都很简单,仅仅是为了获取成员变量的值,没有任何修改成员变量的企图,所以我们加了 const 限制,这是一种保险的做法,同时也使得语义更加明显。

需要强调的是,必须在成员函数的声明和定义处同时加上 const 关键字。char *getname() constchar *getname()是两个不同的函数原型,如果只在一个地方加 const 会导致声明和定义处的函数原型冲突。

最后再来区分一下 const 的位置:

  • 函数开头的 const 用来修饰函数的返回值,表示返回值是 const 类型,也就是不能被修改,例如const char * getname()
  • 函数头部的结尾加上 const 表示常成员函数,这种函数只能读取成员变量的值,而不能修改成员变量的值,例如char * getname() const

C++ const对象(常对象)

C++ 中,const 也可以用来修饰对象,称为常对象。一旦将对象定义为常对象之后,就只能调用类的 const 成员(包括 const 成员变量和 const 成员函数)了。

定义常对象的语法和定义常量的语法类似:

const class object(params);
class const object(params);

当然你也可以定义 const 指针

const class *p = new class(params);
class const *p = new class(params);

class为类名,object为对象名,params为实参列表,p为指针名。两种方式定义出来的对象都是常对象。

如果你对 const 的用法不理解,请猛击《C语言const的用法详解》。

一旦将对象定义为常对象之后,不管是哪种形式,该对象就只能访问被 const 修饰的成员了(包括 const 成员变量和 const 成员函数),因为非 const 成员可能会修改对象的数据(编译器也会这样假设),C++禁止这样做。

常对象使用举例:

#include
using namespace std;
class Student{
public:
Student(char *name, int age, float score);
public:
void show();
char *getname() const;
int getage() const;
float getscore() const;
private:
char *m_name;
int m_age;
float m_score;
};
Student::Student(char *name, int age, float score): m_name(name), m_age(age), m_score(score){ }
void Student::show(){
cout<<m_name<<“的年龄是”<<m_age<<“,成绩是”<<m_score<<endl;
}
char * Student::getname() const{
return m_name;
}
int Student::getage() const{
return m_age;
}
float Student::getscore() const{
return m_score;
}
int main(){
const Student stu(“小明”, 15, 90.6);
//stu.show(); //error
cout<<stu.getname()<<“的年龄是”<<stu.getage()<<“,成绩是”<<stu.getscore()<<endl;
const Student *pstu = new Student(“李磊”, 16, 80.5);
//pstu -> show(); //error
cout<getname()<<“的年龄是”<getage()<<“,成绩是”<getscore()<<endl;
return 0;
}

本例中,stu、pstu 分别是常对象以及常对象指针,它们都只能调用 const 成员函数。

C++友元函数和友元类(C++ friend关键字)

C++ 中,一个类中可以有 public、protected、private 三种属性的成员,通过对象可以访问 public 成员,只有本类中的函数可以访问本类的 private 成员。现在,我们来介绍一种例外情况——友元(friend)。借助友元(friend),可以使得其他类中的成员函数以及全局范围内的函数访问当前类的 private 成员。

friend 的意思是朋友,或者说是好友,与好友的关系显然要比一般人亲密一些。我们会对好朋友敞开心扉,倾诉自己的秘密,而对一般人会谨言慎行,潜意识里就自我保护。在 C++ 中,这种友好关系可以用 friend 关键字指明,中文多译为“友元”,借助友元可以访问与其有好友关系的类中的私有成员。如果你对“友元”这个名词不习惯,可以按原文 friend 理解为朋友。

友元函数

在当前类以外定义的、不属于当前类的函数也可以在类中声明,但要在前面加 friend 关键字,这样就构成了友元函数。友元函数可以是不属于任何类的非成员函数,也可以是其他类的成员函数。

友元函数可以访问当前类中的所有成员,包括 public、protected、private 属性的。

1) 将非成员函数声明为友元函数。

请大家直接看下面的例子:

#include
using namespace std;
class Student{
public:
Student(char *name, int age, float score);
public:
friend void show(Student *pstu); //将show()声明为友元函数
private:
char *m_name;
int m_age;
float m_score;
};
Student::Student(char *name, int age, float score): m_name(name), m_age(age), m_score(score){ }
//非成员函数
void show(Student *pstu){
cout<m_name<<"的年龄是 “<m_age<<”,成绩是 "<m_score<<endl;
}
int main(){
Student stu(“小明”, 15, 90.6);
show(&stu); //调用友元函数
Student *pstu = new Student(“李磊”, 16, 80.5);
show(pstu); //调用友元函数
return 0;
}

运行结果:
小明的年龄是 15,成绩是 90.6
李磊的年龄是 16,成绩是 80.5

show() 是一个全局范围内的非成员函数,它不属于任何类,它的作用是输出学生的信息。m_name、m_age、m_score 是 Student 类的 private 成员,原则上不能通过对象访问,但在 show() 函数中又必须使用这些 private 成员,所以将 show() 声明为 Student 类的友元函数。读者可以亲自测试一下,将上面程序中的第 8 行删去,观察编译器的报错信息。

注意,友元函数不同于类的成员函数,在友元函数中不能直接访问类的成员,必须要借助对象。下面的写法是错误的:

void show(){
cout<<m_name<<"的年龄是 “<<m_age<<”,成绩是 "<<m_score<<endl;
}

成员函数在调用时会隐式地增加 this 指针,指向调用它的对象,从而使用该对象的成员;而 show() 是非成员函数,没有 this 指针,编译器不知道使用哪个对象的成员,要想明确这一点,就必须通过参数传递对象(可以直接传递对象,也可以传递对象指针或对象引用),并在访问成员时指明对象。

2) 将其他类的成员函数声明为友元函数

friend 函数不仅可以是全局函数(非成员函数),还可以是另外一个类的成员函数。请看下面的例子:

#include
using namespace std;
class Address; //提前声明Address类
//声明Student类
class Student{
public:
Student(char *name, int age, float score);
public:
void show(Address *addr);
private:
char *m_name;
int m_age;
float m_score;
};
//声明Address类
class Address{
private:
char *m_province; //省份
char *m_city; //城市
char *m_district; //区(市区)
public:
Address(char *province, char *city, char *district);
//将Student类中的成员函数show()声明为友元函数
friend void Student::show(Address *addr);
};
//实现Student类
Student::Student(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<<“家庭住址:”<m_province<<“省”<m_city<<“市”<m_district<<“区”<<endl;
}
//实现Address类
Address::Address(char *province, char *city, char *district){
m_province = province;
m_city = city;
m_district = district;
}
int main(){
Student stu(“小明”, 16, 95.5f);
Address addr(“陕西”, “西安”, “雁塔”);
stu.show(&addr);

Student *pstu = new Student(“李磊”, 16, 80.5);
Address *paddr = new Address(“河北”, “衡水”, “桃城”);
pstu -> show(paddr);
return 0;
}

运行结果:
小明的年龄是 16,成绩是 95.5
家庭住址:陕西省西安市雁塔区
李磊的年龄是 16,成绩是 80.5
家庭住址:河北省衡水市桃城区

本例定义了两个类 Student 和 Address,程序第 27 行将 Student 类的成员函数 show() 声明为 Address 类的友元函数,由此,show() 就可以访问 Address 类的 private 成员变量了。

几点注意:
① 程序第 4 行对 Address 类进行了提前声明,是因为在 Address 类定义之前、在 Student 类中使用到了它,如果不提前声明,编译器会报错,提示'Address' has not been declared。类的提前声明和函数的提前声明是一个道理。

② 程序将 Student 类的声明和实现分开了,而将 Address 类的声明放在了中间,这是因为编译器从上到下编译代码,show() 函数体中用到了 Address 的成员 province、city、district,如果提前不知道 Address 的具体声明内容,就不能确定 Address 是否拥有该成员(类的声明中指明了类有哪些成员)。

这里简单介绍一下类的提前声明。一般情况下,类必须在正式声明之后才能使用;但是某些情况下(如上例所示),只要做好提前声明,也可以先使用。

但是应当注意,类的提前声明的使用范围是有限的,只有在正式声明一个类以后才能用它去创建对象。如果在上面程序的第4行之后增加如下所示的一条语句,编译器就会报错:

Address addr; //企图使用不完整的类来创建对象

因为创建对象时要为对象分配内存,在正式声明类之前,编译器无法确定应该为对象分配多大的内存。编译器只有在“见到”类的正式声明后(其实是见到成员变量),才能确定应该为对象预留多大的内存。在对一个类作了提前声明后,可以用该类的名字去定义指向该类型对象的指针变量(本例就定义了 Address 类的指针变量)或引用变量(后续会介绍引用),因为指针变量和引用变量本身的大小是固定的,与它所指向的数据的大小无关。

③ 一个函数可以被多个类声明为友元函数,这样就可以访问多个类中的 private 成员。

友元类

不仅可以将一个函数声明为一个类的“朋友”,还可以将整个类声明为另一个类的“朋友”,这就是友元类。友元类中的所有成员函数都是另外一个类的友元函数。

例如将类 B 声明为类 A 的友元类,那么类 B 中的所有成员函数都是类 A 的友元函数,可以访问类 A 的所有成员,包括 public、protected、private 属性的。

更改上例的代码,将 Student 类声明为 Address 类的友元类:

#include
using namespace std;
class Address; //提前声明Address类
//声明Student类
class Student{
public:
Student(char *name, int age, float score);
public:
void show(Address *addr);
private:
char *m_name;
int m_age;
float m_score;
};
//声明Address类
class Address{
public:
Address(char *province, char *city, char *district);
public:
//将Student类声明为Address类的友元类
friend class Student;
private:
char *m_province; //省份
char *m_city; //城市
char *m_district; //区(市区)
};
//实现Student类
Student::Student(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<<“家庭住址:”<m_province<<“省”<m_city<<“市”<m_district<<“区”<<endl;
}
//实现Address类
Address::Address(char *province, char *city, char *district){
m_province = province;
m_city = city;
m_district = district;
}
int main(){
Student stu(“小明”, 16, 95.5f);
Address addr(“陕西”, “西安”, “雁塔”);
stu.show(&addr);

Student *pstu = new Student(“李磊”, 16, 80.5);
Address *paddr = new Address(“河北”, “衡水”, “桃城”);
pstu -> show(paddr);
return 0;
}

第 24 行代码将 Student 类声明为 Address 类的友元类,声明语句为:

friend class Student;

有的编译器也可以不写 class 关键字,不过为了增强兼容性还是建议写上。

关于友元,有两点需要说明:

  • 友元的关系是单向的而不是双向的。如果声明了类 B 是类 A 的友元类,不等于类 A 是类 B 的友元类,类 A 中的成员函数不能访问类 B 中的 private 成员。
  • 友元的关系不能传递。如果类 B 是类 A 的友元类,类 C 是类 B 的友元类,不等于类 C 是类 A 的友元类。

除非有必要,一般不建议把整个类声明为友元类,而只将某些成员函数声明为友元函数,这样更安全一些。

类其实也是一种作用域

类其实也是一种作用域,每个类都会定义它自己的作用域。在类的作用域之外,普通的成员只能通过对象(可以是对象本身,也可以是对象指针或对象引用)来访问,静态成员既可以通过对象访问,又可以通过类访问,而 typedef 定义的类型只能通过类来访问。

下面的例子使用不同的方式访问了不同的成员:

#include
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
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://c.biancheng.net”);
return 0;
}

我们在定义 show() 函数时用到了类 A 中定义的一种类型 PCHAR,因为前面已经指明了当前正位于 A 类的作用域中,所以不用再使用A::PCHAR这样的冗余形式。同理,编译器也知道函数体中用到的变量 n 也位于 A 类的作用域。

另一方面,函数的返回值类型出现在函数名之前,当成员函数定义在类的外部时,返回值类型中使用的名字都位于类的作用域之外,此时必须指明该名字是哪个类的成员。修改上面的 show() 函数,让它的返回值类型为 PCHAR:

PCHAR A::show(PCHAR str){
cout<<str<<endl;
n = 10;
return str;
}

这种写法是错误的。因为返回值类型 PCHAR 出现在类名之前,所以事实上它是位于 A 类的作用域之外的。这种情况下要想使用 PCHAR 作为返回值类型,就必须指明哪个类定义了它,正确的写法如下所示:

A::PCHAR A::show(PCHAR str){
cout<<str<<endl;
n = 10;
return str;
}

C++ class和struct到底有什么区别

C++ 中保留了C语言的 struct 关键字,并且加以扩充。在C语言中,struct 只能包含成员变量,不能包含成员函数。而在C++中,struct 类似于 class,既可以包含成员变量,又可以包含成员函数。

C++中的 struct 和 class 基本是通用的,唯有几个细节不同:

  • 使用 class 时,类中的成员默认都是 private 属性的;而使用 struct 时,结构体中的成员默认都是 public 属性的。
  • class 继承默认是 private 继承,而 struct 继承默认是 public 继承(《C++继承与派生》一章会讲解继承)。
  • class 可以使用模板,而 struct 不能(《模板、字符串和异常》一章会讲解模板)。

C++ 没有抛弃C语言中的 struct 关键字,其意义就在于给C语言程序开发人员有一个归属感,并且能让C++编译器兼容以前用C语言开发出来的项目。

在编写C++代码时,我强烈建议使用 class 来定义类,而使用 struct 来定义结构体,这样做语义更加明确。

使用 struct 来定义类的一个反面教材:

#include
using namespace std;
struct Student{
Student(char *name, int age, float score);
void show();
char *m_name;
int m_age;
float m_score;
};
Student::Student(char *name, int age, float score): m_name(name), m_age(age), m_score(score){ }
void Student::show(){
cout<<m_name<<“的年龄是”<<m_age<<“,成绩是”<<m_score<<endl;
}
int main(){
Student stu(“小明”, 15, 92.5f);
stu.show();
Student *pstu = new Student(“李华”, 16, 96);
pstu -> show();
return 0;
}

运行结果:
小明的年龄是15,成绩是92.5
李华的年龄是16,成绩是96

这段代码可以通过编译,说明 struct 默认的成员都是 public 属性的,否则不能通过对象访问成员函数。如果将 struct 关键字替换为 class,那么就会编译报错。

C++ string详解,C++字符串详解

C++ 大大增强了对字符串的支持,除了可以使用C风格的字符串,还可以使用内置的 string 类。string 类处理起字符串来会方便很多,完全可以代替C语言中的字符数组或字符串指针

string 是 C++ 中常用的一个类,它非常重要,我们有必要在此单独讲解一下。

使用 string 类需要包含头文件<string>,下面的例子介绍了几种定义 string 变量(对象)的方法:

#include
#include
using namespace std;
int main(){
string s1;
string s2 = “c plus plus”;
string s3 = s2;
string s4 (5, ‘s’);
return 0;
}

变量 s1 只是定义但没有初始化,编译器会将默认值赋给 s1,默认值是"",也即空字符串。

变量 s2 在定义的同时被初始化为"c plus plus"。与C风格的字符串不同,string 的结尾没有结束标志'\0'

变量 s3 在定义的时候直接用 s2 进行初始化,因此 s3 的内容也是"c plus plus"

变量 s4 被初始化为由 5 个's'字符组成的字符串,也就是"sssss"

从上面的代码可以看出,string 变量可以直接通过赋值操作符=进行赋值。string 变量也可以用C风格的字符串进行赋值,例如,s2 是用一个字符串常量进行初始化的,而 s3 则是通过 s2 变量进行初始化的。

与C风格的字符串不同,当我们需要知道字符串长度时,可以调用 string 类提供的 length() 函数。如下所示:

string s = “http://c.biancheng.net”;
int len = s.length();
cout<<len<<endl;

输出结果为22。由于 string 的末尾没有'\0'字符,所以 length() 返回的是字符串的真实长度,而不是长度 +1。

转换为C风格的字符串

虽然 C++ 提供了 string 类来替代C语言中的字符串,但是在实际编程中,有时候必须要使用C风格的字符串(例如打开文件时的路径),为此,string 类为我们提供了一个转换函数 c_str(),该函数能够将 string 字符串转换为C风格的字符串,并返回该字符串的 const 指针(const char*)。请看下面的代码:

string path = “D:\demo.txt”;
FILE *fp = fopen(path.c_str(), “rt”);

为了使用C语言中的 fopen() 函数打开文件,必须将 string 字符串转换为C风格的字符串。

string 字符串的输入输出

string 类重载了输入输出运算符,可以像对待普通变量那样对待 string 变量,也就是用>>进行输入,用<<进行输出。请看下面的代码:

#include
#include
using namespace std;
int main(){
string s;
cin>>s; //输入字符串
cout<<s<<endl; //输出字符串
return 0;
}

运行结果:
http://c.biancheng.net http://vip.biancheng.net↙
http://c.biancheng.net

虽然我们输入了两个由空格隔开的网址,但是只输出了一个,这是因为输入运算符>>默认会忽略空格,遇到空格就认为输入结束,所以最后输入的http://vip.biancheng.net没有被存储到变量 s。

访问字符串中的字符

string 字符串也可以像C风格的字符串一样按照下标来访问其中的每一个字符。string 字符串的起始下标仍是从 0 开始。请看下面的代码:

#include
#include
using namespace std;
int main(){
string s = “1234567890”;
for(int i=0,len=s.length(); i<len; i++){
cout<<s[i]<<" ";
}
cout<<endl;
s[5] = ‘5’;
cout<<s<<endl;
return 0;
}

运行结果:
1 2 3 4 5 6 7 8 9 0
1234557890

本例定义了一个 string 变量 s,并赋值 “1234567890”,之后用 for 循环遍历输出每一个字符。借助下标,除了能够访问每个字符,也可以修改每个字符,s[5] = '5';就将第6个字符修改为 ‘5’,所以 s 最后为 “1234557890”。

字符串的拼接

有了 string 类,我们可以使用++=运算符来直接拼接字符串,非常方便,再也不需要使用C语言中的 strcat()、strcpy()、malloc() 等函数来拼接字符串了,再也不用担心空间不够会溢出了。

+来拼接字符串时,运算符的两边可以都是 string 字符串,也可以是一个 string 字符串和一个C风格的字符串,还可以是一个 string 字符串和一个字符数组,或者是一个 string 字符串和一个单独的字符。请看下面的例子:

#include
#include
using namespace std;
int main(){
string s1 = "first ";
string s2 = "second ";
char *s3 = "third ";
char s4[] = "fourth ";
char ch = ‘@’;
string s5 = s1 + s2;
string s6 = s1 + s3;
string s7 = s1 + s4;
string s8 = s1 + ch;

cout<<s5<<endl<<s6<<endl<<s7<<endl<<s8<<endl;
return 0;
}

运行结果:
first second
first third
first fourth
first @

string 字符串的增删改查

C++ 提供的 string 类包含了若干实用的成员函数,大大方便了字符串的增加、删除、更改、查询等操作。

一. 插入字符串

insert() 函数可以在 string 字符串中指定的位置插入另一个字符串,它的一种原型为:

string& insert (size_t pos, const string& str);

pos 表示要插入的位置,也就是下标;str 表示要插入的字符串,它可以是 string 字符串,也可以是C风格的字符串。

请看下面的代码:

#include
#include
using namespace std;
int main(){
string s1, s2, s3;
s1 = s2 = “1234567890”;
s3 = “aaa”;
s1.insert(5, s3);
cout<< s1 <<endl;
s2.insert(5, “bbb”);
cout<< s2 <<endl;
return 0;
}

运行结果:
12345aaa67890
12345bbb67890

insert() 函数的第一个参数有越界的可能,如果越界,则会产生运行时异常,我们将会在《C++异常(Exception)》一章中详细讲解如何捕获这个异常。

更多 insert() 函数的原型和用法请参考:http://www.cplusplus.com/reference/string/string/insert/

二. 删除字符串

erase() 函数可以删除 string 中的一个子字符串。它的一种原型为:

string& erase (size_t pos = 0, size_t len = npos);

pos 表示要删除的子字符串的起始下标,len 表示要删除子字符串的长度。如果不指明 len 的话,那么直接删除从 pos 到字符串结束处的所有字符(此时 len = str.length - pos)。

请看下面的代码:

#include
#include
using namespace std;
int main(){
string s1, s2, s3;
s1 = s2 = s3 = “1234567890”;
s2.erase(5);
s3.erase(5, 3);
cout<< s1 <<endl;
cout<< s2 <<endl;
cout<< s3 <<endl;
return 0;
}

运行结果:
1234567890
12345
1234590

有读者担心,在 pos 参数没有越界的情况下, len 参数也可能会导致要删除的子字符串越界。但实际上这种情况不会发生,erase() 函数会从以下两个值中取出最小的一个作为待删除子字符串的长度:

  • len 的值;
  • 字符串长度减去 pos 的值。

说得简单一些,待删除字符串最多只能删除到字符串结尾。

三. 提取子字符串

substr() 函数用于从 string 字符串中提取子字符串,它的原型为:

string substr (size_t pos = 0, size_t len = npos) const;

pos 为要提取的子字符串的起始下标,len 为要提取的子字符串的长度。

请看下面的代码:

#include
#include
using namespace std;
int main(){
string s1 = “first second third”;
string s2;
s2 = s1.substr(6, 6);
cout<< s1 <<endl;
cout<< s2 <<endl;
return 0;
}

运行结果:
first second third
second

系统对 substr() 参数的处理和 erase() 类似:

  • 如果 pos 越界,会抛出异常;
  • 如果 len 越界,会提取从 pos 到字符串结尾处的所有字符。
四. 字符串查找

string 类提供了几个与字符串查找有关的函数,如下所示。

1) find() 函数

find() 函数用于在 string 字符串中查找子字符串出现的位置,它其中的两种原型为:

size_t find (const string& str, size_t pos = 0) const;
size_t find (const char* s, size_t pos = 0) const;

第一个参数为待查找的子字符串,它可以是 string 字符串,也可以是C风格的字符串。第二个参数为开始查找的位置(下标);如果不指明,则从第0个字符开始查找。

请看下面的代码:

#include
#include
using namespace std;
int main(){
string s1 = “first second third”;
string s2 = “second”;
int index = s1.find(s2,5);
if(index < s1.length())
cout<<"Found at index : "<< index <<endl;
else
cout<<“Not found”<<endl;
return 0;
}

运行结果:
Found at index : 6

find() 函数最终返回的是子字符串第一次出现在字符串中的起始下标。本例最终是在下标6处找到了 s2 字符串。如果没有查找到子字符串,那么会返回一个无穷大值 4294967295。

2) rfind() 函数

rfind() 和 find() 很类似,同样是在字符串中查找子字符串,不同的是 find() 函数从第二个参数开始往后查找,而 rfind() 函数则最多查找到第二个参数处,如果到了第二个参数所指定的下标还没有找到子字符串,则返回一个无穷大值4294967295。

请看下面的例子:

#include
#include
using namespace std;
int main(){
string s1 = “first second third”;
string s2 = “second”;
int index = s1.rfind(s2,6);
if(index < s1.length())
cout<<"Found at index : "<< index <<endl;
else
cout<<“Not found”<<endl;
return 0;
}

运行结果:
Found at index : 6

3) find_first_of() 函数

find_first_of() 函数用于查找子字符串和字符串共同具有的字符在字符串中首次出现的位置。请看下面的代码:

#include
#include
using namespace std;
int main(){
string s1 = “first second second third”;
string s2 = “asecond”;
int index = s1.find_first_of(s2);
if(index < s1.length())
cout<<"Found at index : "<< index <<endl;
else
cout<<“Not found”<<endl;
return 0;
}

运行结果:
Found at index : 3

本例中 s1 和 s2 共同具有的字符是 ’s’,该字符在 s1 中首次出现的下标是3,故查找结果返回3。

C++ string的内部究竟是什么样的?

在C语言中,有两种方式表示字符串:

  • 一种是用字符数组来容纳字符串,例如char str[10] = "abc",这样的字符串是可读写的;
  • 一种是使用字符串常量,例如char *str = "abc",这样的字符串只能读,不能写。

两种形式总是以\0作为结束标志。

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
#include
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;
}

在 GCC 下的运行结果:
1
s1 = 62345
s2 = 12345
0

只有当字符串被修改的时候才创建各自的拷贝,这种实现方式称为写时复制(copy-on-write)策略。当字符串只是作为值参数(value parameter)或在其他只读情形下使用,这种方法能够节省时间和空间。

不论一个库的实现是不是采用引用计数,它对 string 类的使用者来说都应该是透明的。遗憾的是,情况并不总是这样。在多线程程序中,几乎不可能安全地使用引用计数来实现。

C++类和对象的总结,拿去做笔记吧

类的成员有成员变量和成员函数两种。

成员函数之间可以互相调用,成员函数内部可以访问成员变量。

私有成员只能在类的成员函数内部访问。默认情况下,class 类的成员是私有的,struct 类的成员是公有的。

可以用“对象名.成员名”、“引用名.成员名”、“对象指针->成员名”的方法访问对象的成员变量或调用成员函数。成员函数被调用时,可以用上述三种方法指定函数是作用在哪个对象上的。

对象所占用的存储空间的大小等于各成员变量所占用的存储空间的大小之和(如果不考虑成员变量对齐问题的话)。

定义类时,如果一个构造函数都不写,则编译器自动生成默认(无参)构造函数和复制构造函数。如果编写了构造函数,则编译器不自动生成默认构造函数。一个类不一定会有默认构造函数,但一定会有复制构造函数。

任何生成对象的语句都要说明对象是用哪个构造函数初始化的。即便定义对象数组,也要对数组中的每个元素如何初始化进行说明。如果不说明,则编译器认为对象是用默认构造函数或参数全部可以省略的构造函数初始化。在这种情况下,如果类没有默认构造函数或参数全部可以省略的构造函数,则编译出错。

对象在消亡时会调用析构函数。

每个对象有各自的一份普通成员变量,但是静态成员变量只有一份,被所有对象所共享。静态成员函数不具体作用于某个对象。即便对象不存在,也可以访问类的静态成员。静态成员函数内部不能访问非静态成员变量,也不能调用非静态成员函数。

常量对象上面不能执行非常量成员函数,只能执行常量成员函数。

包含成员对象的类叫封闭类。任何能够生成封闭类对象的语句,都要说明对象中包含的成员对象是如何初始化的。如果不说明,则编译器认为成员对象是用默认构造函数或参数全部可以省略的构造函数初始化。

在封闭类的构造函数的初始化列表中可以说明成员对象如何初始化。封闭类对象生成时,先执行成员对象的构造函数,再执行自身的构造函数;封闭类对象消亡时,先执行自身的析构函数,再执行成员对象的析构函数。

const 成员和引用成员必须在构造函数的初始化列表中初始化,此后值不可修改。

友元分为友元函数和友元类。友元关系不能传递。

成员函数中出现的 this 指针,就是指向成员函数所作用的对象的指针。因此,静态成员函数内部不能出现 this 指针。成员函数实际上的参数个数比表面上看到的多一个,多出来的参数就是 this 指针。

C++拷贝构造函数(复制构造函数)详解

拷贝和复制是一个意思,对应的英文单词都是copy。对于计算机来说,拷贝是指用一份原有的、已经存在的数据创建出一份新的数据,最终的结果是多了一份相同的数据。例如,将 Word 文档拷贝到U盘去复印店打印,将 D 盘的图片拷贝到桌面以方便浏览,将重要的文件上传到百度网盘以防止丢失等,都是「创建一份新数据」的意思。

C++ 中,拷贝并没有脱离它本来的含义,只是将这个含义进行了“特化”,是指用已经存在的对象创建出一个新的对象。从本质上讲,对象也是一份数据,因为它会占用内存。

严格来说,对象的创建包括两个阶段,首先要分配内存空间,然后再进行初始化:

  • 分配内存很好理解,就是在堆区、栈区或者全局数据区留出足够多的字节。这个时候的内存还比较“原始”,没有被“教化”,它所包含的数据一般是零值或者随机值,没有实际的意义。
  • 初始化就是首次对内存赋值,让它的数据有意义。注意是首次赋值,再次赋值不叫初始化。初始化的时候还可以为对象分配其他的资源(打开文件、连接网络、动态分配内存等),或者提前进行一些计算(根据价格和数量计算出总价、根据长度和宽度计算出矩形的面积等)等。说白了,初始化就是调用构造函数。

很明显,这里所说的拷贝是在初始化阶段进行的,也就是用其它对象的数据来初始化新对象的内存。

那么,如何用拷贝的方式来初始化一个对象呢?其实这样的例子比比皆是,string 类就是一个典型的例子。

#include
#include
using namespace std;
void func(string str){
cout<<str<<endl;
}
int main(){
string s1 = “http://c.biancheng.net”;
string s2(s1);
string s3 = s1;
string s4 = s1 + " " + s2;
func(s1);
cout<<s1<<endl<<s2<<endl<<s3<<endl<<s4<<endl;

return 0;
}

运行结果:
http://c.biancheng.net
http://c.biancheng.net
http://c.biancheng.net
http://c.biancheng.net
http://c.biancheng.net http://c.biancheng.net

s1、s2、s3、s4 以及 func() 的形参 str,都是使用拷贝的方式来初始化的。

对于 s1,表面上看起来是将一个字符串直接赋值给了 s1,实际上在内部进行了类型转换,将 const char * 类型转换为 string 类型后才赋值的,这点我们将在《C++转换构造函数》一节中详细讲解。s4 也是类似的道理。

对于 s1、s2、s3、s4,都是将其它对象的数据拷贝给当前对象,以完成当前对象的初始化。

对于 func() 的形参 str,其实在定义时就为它分配了内存,但是此时并没有初始化,只有等到调用 func() 时,才会将其它对象的数据拷贝给 str 以完成初始化。

当以拷贝的方式初始化一个对象时,会调用一个特殊的构造函数,就是拷贝构造函数(Copy Constructor)。

下面的例子演示了拷贝构造函数的定义和使用:

#include
#include
using namespace std;
class Student{
public:
Student(string name = “”, int age = 0, float score = 0.0f); //普通构造函数
Student(const Student &stu); //拷贝构造函数(声明)
public:
void display();
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){ }
//拷贝构造函数(定义)
Student::Student(const Student &stu){
this->m_name = stu.m_name;
this->m_age = stu.m_age;
this->m_score = stu.m_score;

cout<<“Copy constructor was called.”<<endl;
}
void Student::display(){
cout<<m_name<<“的年龄是”<<m_age<<“,成绩是”<<m_score<<endl;
}
int main(){
Student stu1(“小明”, 16, 90.5);
Student stu2 = stu1; //调用拷贝构造函数
Student stu3(stu1); //调用拷贝构造函数
stu1.display();
stu2.display();
stu3.display();

return 0;
}

运行结果:
Copy constructor was called.
Copy constructor was called.
小明的年龄是16,成绩是90.5
小明的年龄是16,成绩是90.5
小明的年龄是16,成绩是90.5

第 8 行是拷贝构造函数的声明,第 20 行是拷贝构造函数的定义。拷贝构造函数只有一个参数,它的类型是当前类的引用,而且一般都是 const 引用。

1) 为什么必须是当前类的引用呢?

如果拷贝构造函数的参数不是当前类的引用,而是当前类的对象,那么在调用拷贝构造函数时,会将另外一个对象直接传递给形参,这本身就是一次拷贝,会再次调用拷贝构造函数,然后又将一个对象直接传递给了形参,将继续调用拷贝构造函数……这个过程会一直持续下去,没有尽头,陷入死循环。

只有当参数是当前类的引用时,才不会导致再次调用拷贝构造函数,这不仅是逻辑上的要求,也是 C++ 语法的要求。

2) 为什么是 const 引用呢?

拷贝构造函数的目的是用其它对象的数据来初始化当前对象,并没有期望更改其它对象的数据,添加 const 限制后,这个含义更加明确了。

另外一个原因是,添加 const 限制后,可以将 const 对象和非 const 对象传递给形参了,因为非 const 类型可以转换为 const 类型。如果没有 const 限制,就不能将 const 对象传递给形参,因为 const 类型不能转换为非 const 类型,这就意味着,不能使用 const 对象来初始化当前对象了。

以上面的 Student 类为例,将 const 去掉后,拷贝构造函数的原型变为:

Student::Student(Student &stu);

此时,下面的代码就会发生错误:

const Student stu1(“小明”, 16, 90.5);
Student stu2 = stu1;
Student stu3(stu1);

stu1 是 const 类型,在初始化 stu2、stu3 时,编译器希望调用Student::Student(const Student &stu),但是这个函数却不存在,又不能将 const Student 类型转换为 Student 类型去调用Student::Student(Student &stu),所以最终调用失败了。

当然,你也可以再添加一个参数为 const 引用的拷贝构造函数,这样就不会出错了。换句话说,一个类可以同时存在两个拷贝构造函数,一个函数的参数为 const 引用,另一个函数的参数为非 const 引用。

默认拷贝构造函数

在前面的教程中,我们还没有讲解拷贝构造函数,但是却已经在使用拷贝的方式创建对象了,并且也没有引发什么错误。这是因为,如果程序员没有显式地定义拷贝构造函数,那么编译器会自动生成一个默认的拷贝构造函数。这个默认的拷贝构造函数很简单,就是使用“老对象”的成员变量对“新对象”的成员变量进行一一赋值,和上面 Student 类的拷贝构造函数非常类似。

对于简单的类,默认拷贝构造函数一般是够用的,我们也没有必要再显式地定义一个功能类似的拷贝构造函数。但是当类持有其它资源时,如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认拷贝构造函数就不能拷贝这些资源,我们必须显式地定义拷贝构造函数,以完整地拷贝对象的所有数据,这点我们将在《C++深拷贝和浅拷贝》一节中深入讲解。

到底什么时候会调用拷贝构造函数?

当以拷贝的方式初始化对象时会调用拷贝构造函数。这里有两个关键点,分别是「以拷贝的方式」和「初始化对象」。

初始化对象

初始化对象是指,为对象分配内存后第一次向内存中填充数据,这个过程会调用构造函数。对象被创建后必须立即被初始化,换句话说,只要创建对象,就会调用构造函数。

初始化和赋值的区别

初始化和赋值都是将数据写入内存中,并且从表面上看起来,初始化在很多时候都是以赋值的方式来实现的,所以很容易混淆。请看下面的例子:

int a = 100; //以赋值的方式初始化
a = 200; //赋值
a = 300; //赋值
int b; //默认初始化
b = 29; //赋值
b = 39; //赋值

在定义的同时进行赋值叫做初始化(Initialization),定义完成以后再赋值(不管在定义的时候有没有赋值)就叫做赋值(Assignment)。初始化只能有一次,赋值可以有多次。

对于基本类型的数据,我们很少会区分「初始化」和「赋值」这两个概念,即使将它们混淆,也不会出现什么错误。但是对于类,它们的区别就非常重要了,因为初始化时会调用构造函数(以拷贝的方式初始化时会调用拷贝构造函数),而赋值时会调用重载过的赋值运算符。请看下面的例子:

#include
#include
using namespace std;
class Student{
public:
Student(string name = “”, int age = 0, float score = 0.0f); //普通构造函数
Student(const Student &stu); //拷贝构造函数
public:
Student & operator=(const Student &stu); //重载=运算符
void display();
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){ }
//拷贝构造函数
Student::Student(const Student &stu){
this->m_name = stu.m_name;
this->m_age = stu.m_age;
this->m_score = stu.m_score;
cout<<“Copy constructor was called.”<<endl;
}
//重载=运算符
Student & Student::operator=(const Student &stu){
this->m_name = stu.m_name;
this->m_age = stu.m_age;
this->m_score = stu.m_score;
cout<<“operator=() was called.”<<endl;

return *this;
}
void Student::display(){
cout<<m_name<<“的年龄是”<<m_age<<“,成绩是”<<m_score<<endl;
}
int main(){
//stu1、stu2、stu3都会调用普通构造函数Student(string name, int age, float score)
Student stu1(“小明”, 16, 90.5);
Student stu2(“王城”, 17, 89.0);
Student stu3(“陈晗”, 18, 98.0);

Student stu4 = stu1; //调用拷贝构造函数Student(const Student &stu)
stu4 = stu2; //调用operator=()
stu4 = stu3; //调用operator=()

Student stu5; //调用普通构造函数Student()
stu5 = stu1; //调用operator=()
stu5 = stu2; //调用operator=()

return 0;
}

运行结果:
Copy constructor was called.
operator=() was called.
operator=() was called.
operator=() was called.
operator=() was called.

以拷贝的方式初始化对象

初始化对象时会调用构造函数,不同的初始化方式会调用不同的构造函数:

  • 如果用传递进来的实参初始化对象,那么会调用普通的构造函数,我们不妨将此称为普通初始化;
  • 如果用其它对象(现有对象)的数据来初始化对象,那么会调用拷贝构造函数,这就是以拷贝的方式初始化。

在实际编程中,具体有哪些情况是以拷贝的方式来初始化对象呢?

1) 将其它对象作为实参

以上面的 Student 类为例,我们可以这样来创建一个新的对象:

Student stu1(“小明”, 16, 90.5); //普通初始化
Student stu2(stu1); //以拷贝的方式初始化

即使我们不在类中显式地定义拷贝构造函数,这种初始化方式也是有效的,因为编译器会生成默认的拷贝构造函数。

2) 在创建对象的同时赋值

接着使用 Student 类,请看下面的例子:

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Go)
img

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
拷贝构造函数非常类似。

对于简单的类,默认拷贝构造函数一般是够用的,我们也没有必要再显式地定义一个功能类似的拷贝构造函数。但是当类持有其它资源时,如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认拷贝构造函数就不能拷贝这些资源,我们必须显式地定义拷贝构造函数,以完整地拷贝对象的所有数据,这点我们将在《C++深拷贝和浅拷贝》一节中深入讲解。

到底什么时候会调用拷贝构造函数?

当以拷贝的方式初始化对象时会调用拷贝构造函数。这里有两个关键点,分别是「以拷贝的方式」和「初始化对象」。

初始化对象

初始化对象是指,为对象分配内存后第一次向内存中填充数据,这个过程会调用构造函数。对象被创建后必须立即被初始化,换句话说,只要创建对象,就会调用构造函数。

初始化和赋值的区别

初始化和赋值都是将数据写入内存中,并且从表面上看起来,初始化在很多时候都是以赋值的方式来实现的,所以很容易混淆。请看下面的例子:

int a = 100; //以赋值的方式初始化
a = 200; //赋值
a = 300; //赋值
int b; //默认初始化
b = 29; //赋值
b = 39; //赋值

在定义的同时进行赋值叫做初始化(Initialization),定义完成以后再赋值(不管在定义的时候有没有赋值)就叫做赋值(Assignment)。初始化只能有一次,赋值可以有多次。

对于基本类型的数据,我们很少会区分「初始化」和「赋值」这两个概念,即使将它们混淆,也不会出现什么错误。但是对于类,它们的区别就非常重要了,因为初始化时会调用构造函数(以拷贝的方式初始化时会调用拷贝构造函数),而赋值时会调用重载过的赋值运算符。请看下面的例子:

#include
#include
using namespace std;
class Student{
public:
Student(string name = “”, int age = 0, float score = 0.0f); //普通构造函数
Student(const Student &stu); //拷贝构造函数
public:
Student & operator=(const Student &stu); //重载=运算符
void display();
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){ }
//拷贝构造函数
Student::Student(const Student &stu){
this->m_name = stu.m_name;
this->m_age = stu.m_age;
this->m_score = stu.m_score;
cout<<“Copy constructor was called.”<<endl;
}
//重载=运算符
Student & Student::operator=(const Student &stu){
this->m_name = stu.m_name;
this->m_age = stu.m_age;
this->m_score = stu.m_score;
cout<<“operator=() was called.”<<endl;

return *this;
}
void Student::display(){
cout<<m_name<<“的年龄是”<<m_age<<“,成绩是”<<m_score<<endl;
}
int main(){
//stu1、stu2、stu3都会调用普通构造函数Student(string name, int age, float score)
Student stu1(“小明”, 16, 90.5);
Student stu2(“王城”, 17, 89.0);
Student stu3(“陈晗”, 18, 98.0);

Student stu4 = stu1; //调用拷贝构造函数Student(const Student &stu)
stu4 = stu2; //调用operator=()
stu4 = stu3; //调用operator=()

Student stu5; //调用普通构造函数Student()
stu5 = stu1; //调用operator=()
stu5 = stu2; //调用operator=()

return 0;
}

运行结果:
Copy constructor was called.
operator=() was called.
operator=() was called.
operator=() was called.
operator=() was called.

以拷贝的方式初始化对象

初始化对象时会调用构造函数,不同的初始化方式会调用不同的构造函数:

  • 如果用传递进来的实参初始化对象,那么会调用普通的构造函数,我们不妨将此称为普通初始化;
  • 如果用其它对象(现有对象)的数据来初始化对象,那么会调用拷贝构造函数,这就是以拷贝的方式初始化。

在实际编程中,具体有哪些情况是以拷贝的方式来初始化对象呢?

1) 将其它对象作为实参

以上面的 Student 类为例,我们可以这样来创建一个新的对象:

Student stu1(“小明”, 16, 90.5); //普通初始化
Student stu2(stu1); //以拷贝的方式初始化

即使我们不在类中显式地定义拷贝构造函数,这种初始化方式也是有效的,因为编译器会生成默认的拷贝构造函数。

2) 在创建对象的同时赋值

接着使用 Student 类,请看下面的例子:

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Go)
[外链图片转存中…(img-WXq5bzmR-1713174101554)]

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 7
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值