c++在线编译工具,可快速进行实验: https://www.dooccn.com/cpp/

1. 写在前面

这段时间打算重新把c++捡起来, 实习给我的一个体会就是算法工程师是去解决实际问题的,所以呢,不能被算法或者工程局限住,应时刻提高解决问题的能力,在这个过程中,我发现cpp很重要, 正好这段时间也在接触些c++开发相关的任务,所有想借这个机会把c++重新学习一遍。 在推荐领域, 目前我接触到的算法模型方面主要是基于Python, 而线上的服务全是c++(算法侧, 业务那边基本上用go),我们所谓的模型,也一般是训练好部署上线然后提供接口而已。所以现在也终于知道,为啥只单纯熟悉Python不太行了, cpp,才是yyds。

和python一样, 这个系列是重温,依然不会整理太基础性的东西,更像是查缺补漏, 不过,c++对我来说, 已经5年没有用过了, 这个缺很大, 也差不多相当重学了, 所以接下来的时间, 重温一遍啦 😉

资料参考主要是C语言中文网光城哥写的C++教程,然后再加自己的理解和编程实验作为辅助,加深印象。

这篇文章主要学C++的类和对象, 这一块也是C++的重点内容,这篇文章内容有些多,篇幅也很长, 老规矩,各取所需即可,这里面肯定有似曾相识又恍如隔世的内容,这样才有意思哈哈。

主要包括:

  • C++类和对象理解, 我们会创建对象吗?
  • C++类的成员变量和成员函数
  • C++类成员访问权限及类的封装
  • C++对象的内存模型与函数编译原理
  • C++的构造函数和析构函数
  • C++对象数组
  • C++成员对象和封闭类
  • C++的this指针
  • C++静态成员变量和静态成员函数(static)
  • C++类的const再现(成员变量和成员函数以及const对象)
  • C++的友元函数和友元类
  • 类, 其实也是种作用域
  • C++的string, 因为重要,所以单独拎出来

Ok, let’s go!

2. C++类和对象理解

关于类, 首先两点:

  1. 类是创建对象的模板,一个类可以创建多个对象,每个对象都是类类型的一个变量,创建对象的过程也叫类的实例化。 每个对象都是类的一个具体实例,拥有类的成员变量和成员函数。
  2. 与结构体一样,类只是复杂数据类型的一种声明,不占用内存空间,而对象是类这种数据类型的一种变量,通过类这种数据类型创建出来一份实实在在的数据,所以占用内存空间

来个例子:

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

上面的类有3个成员变量和1个成员函数。 类只是一个模板(Template),编译后不占用内存空间,所以在定义类时不能对成员变量进行初始化,因为没有地方存储数据。只有在创建对象以后才会给成员变量分配内存,这个时候就可以赋值了。

类虽然可以理解成一种新的数据类型,但与char,int, float等基本数据类型不同, 类是一种复杂的数据类型,里面的成员可以包含基本类型,也可以有很多基本类型没有的特性。

有了这样的一个类,就可以创建对象,并且通过对象去访问类中的成员。

Student zhongqiang;  // 对象

// 访问对象成员可以用.
zhongqiang.name
zhongqiang.age
zhongqiang.score
zhongqiang.say();

// 指针访问方式
Student *pstu = &zhongqiang;    // 把zhongqiang的地址给到指针, pstu指向了zhongqiang

pstu -> name
pstu -> age
pstu -> score
pstu -> say()

这里注意一个问题, 上面的zhongqiang对象这种创建方式, 是栈里面创建的,这种对象都有一个名字,所以使用指针指向它并不是必须的。 对象指针真正的妙用其实是下面这种。

如果想在堆上创建对象, 就需要用到new关键字,然后用指针来指向, 这里涉及到了栈内存和堆内存的概念, 这里也不整理,后面会整理到。

Student *pStu = new Student;

通过 new 创建出来的对象就不一样了,它在堆上分配内存,没有名字,只能得到一个指向它的指针,所以必须使用一个指针变量来接收这个指针,否则以后再也无法找到这个对象了,更没有办法使用它。即,使用 new 在堆上创建出来的对象是匿名的,没法直接使用,必须要用一个指针指向它,再借助指针来访问它的成员变量或成员函数, 当对象不用的时候, 要delete掉

Student *pStu = new Student;
pStu -> name = "小明";
pStu -> age = 15;
pStu -> score = 92.5f;
pStu -> say();
delete pStu;  //删除对象

3. C++类成员变量和成员函数

类这种数据类型是一个包含成员变量和成员函数的集合。两点要注意:

  • 类的成员变量和普通变量一样,有数据类型和名称,占用固定长度,But, 在定义类的时候不能对成员变量赋值,因为类只是一种模板
  • 类的成员函数和普通函数一样,都有返回值和参数列表,But, 和一般函数的区别是成员函数是一个类的成员,出现在类体中, 作用范围由类来决定。 而普通函数是独立的,作用范围是全局或位于某个命名空间。

下面看个例子:

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

上面展示的是类中函数在类体中定义和类外定义函数的例子, 这里之前一直不知道, 这两种方式是有区别的, 所以下面是新知识。

类体中和类体外定义成员函数是有区别的: 类体中定义的成员函数会自动成为内联函数,在类体外定义的不会。关于内联函数, 在第一篇文章里面有剖析。 所以这里的建议:

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

如果函数比较短小, 那么定义成内联函数也可以, 这时候,在类内定义即可。

这个确实, 看到很多大项目里面的代码,确实都是清一色在类外定义函数逻辑,并且我发现,一般都是把类的声明定义到.h里面,然后再伴随一个.cpp文件来写具体每个成员函数的实现逻辑。

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

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

  • 在类的内部, 无论成员被声明为public, protected, 还是private,都是可以互相访问,没有访问权限限制
  • 在类的外部,只能通过对象访问成员,并且只能访问pubilc属性成员,不能访问private和protected属性的成员

下面来个🌰 比较规范了:

//类的声明
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.m_name = "小明";   error   私有成员不能这么访问
    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;
}

这里面的栈上创建对象和堆上创建对象之前提到过,但这样对比更加清晰。

类的声明和成员函数的定义是类定义的一部分,实际开发中, 通常.h里面放类的声明, 而成员函数的定义放在对应的.cpp中。

类中的成员变量 m_name、m_agem_ score 被设置成 private 属性,在类的外部不能通过对象访问。也就是说,私有成员变量和成员函数只能在类内部使用,在类外都是无效的。而成员函数 setname()、setage()setscore() 被设置为 public 属性,是公有的,可以通过对象访问。

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

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

所谓封装,是指尽量隐藏类的内部实现,只向用户提供有用的成员函数。

一般的, 我们会把类的成员变量声明成private, 外界不能直接访问,但可以通过类内的一些函数(这些函数通常是public)去访问。

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

另外,还要注意一点,就是声明为 private 的成员和声明为 public 的成员的次序任意,既可以先出现 private 部分,也可以先出现 public 部分。如果既不写 private 也不写 public,就默认为 private

5. C++对象的内存模型与函数编译原理

5.1 内存模型

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

那么, 对象在内存中是怎么存储的呢?

直观的认识, 创建了10个对象, 就要分别为10个对象的成员变量和成员函数分配内存, 比如:
在这里插入图片描述
但这么玩的话, 对象一多,内存不就爆掉了,比如class people, 其实, 不同的对象之间只是成员变量的值不同, 但所用的成员函数的代码都是一样的, 没有必要一个对象一块空间把变量汉函数一块存储。 那么编译器是怎么做的呢?

编译器将成员变量和成员函数分开存储,分布为每个对象的成员变量分配内存,但所有对象共享一段函数代码, like this:

在这里插入图片描述
那么,此时就又会产生一个问题, 如果对象中只保留了成员变量,没有任何其它信息, 程序运行的时候不知道zhongqiang是个people类型, 也不知道它有成员函数eat(), sleep()等,C++如何通过对象调用成员函数呢?

5.2 C++函数编译

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

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

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

从上面第一个参考文档中拿个例子看看新函数名长啥样:
在这里插入图片描述
小括号中的就是经名字编码之后的新函数名, 以?开始,区别与C语言中的_。 那么函数有了新名字之后,并且这个成员函数,最终编译成了与对象无关的全局函数, 那么对象调用的时候应该怎么调用呢?

5.3 成员函数调用

如果函数体中没有成员变量,问题很简单,不对函数做任何处理,直接调用。 而如果成员函数中使用了成员变量,那么不经过任何处理是没办法在函数内部访问的。C++规定,编译成员函数时要额外添加一个参数,把当前对象的指针传递进去,通过指针来访问成员变量

比如Demo:

void People::sleep(){
    cout<<a<<endl;
    cout<<b<<endl;
}

那么编译之后变成:

void new_function_name(People * const p){   // 注意这个p是const指针, 只能指向当前对象,不能指向其它对象
    //通过指针p来访问a、b
    cout<<p->a<<endl;
    cout<<p->b<<endl;
}

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

new_function_name(&obj);

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

6. C++构造函数与析构函数

6.1 C++构造函数

6.1.1 构造函数声明与定义

构造函数是C++类里面的一种特殊成员函数, 名字和类名相同,没有返回值,不能显式调用,而是创建对象的时候自动执行的函数。

构造函数可以帮助我们在创建对象的同时,为成员变量赋值:

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

上面的Student类中定义了一个构造函数Student(char *, int, float), 作用是给三个private属性的成员赋值。要想调用构造函数,就得在创建对象的同时传递实参。

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

关于构造函数, 有下面几点注意(敲黑板):

  1. 构造函数必须是public属性的, 否则创建对象时无法使用,虽然,设置成private、protected属性也不会报错, 但没有意义。
  2. 构造函数没有返回值,因为没有变量接收返回值,即使有也毫无意义,因此:
    1. 不管是声明还是定义, 函数名前面都不能出现返回值类型,void也不行
    2. 函数体中不能有return语句
6.1.2 构造函数重载

关于构造函数重载, 一个类可以有多个重载的构造函数,创建对象时根据传递的实参判断调用哪一个构造函数。但注意:构造函数的调用是强制性的,一旦在类中定义了构造函数,那么创建对象时就一定要调用, 不调用是错误的。 如果有多个重载的构造函数,那么创建对象时提供的实参必须和其中的一个构造函数匹配。 反过来说,创建对象时只有一个构造函数会被调用

like this:

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

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

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

Student(){}

一个类必须有构造函数,要么用户自己定义,要么编译器自动生成,一旦用户自己定义了构造函数,不管有几个,也不管形参如何,编译器都不再自动生成。这个要注意下。

实际上编译器只有在必要的时候才会生成默认构造函数,而且它的函数体一般不为空。默认构造函数的目的是帮助编译器做初始化工作,而不是帮助程序员。这是C++的内部实现机制

最后注意的一点, 调用没有参数的构造函数也可以省略括号。比如之前的 Student *pstu = new Student或者 Student *pstu = new Student(), 他们都会调用构造函数Student()

6.1.3 构造函数初始化列表

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

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:
}

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

这种方式并没有效率上的优势,只是为了书写方便,尤其是成员变量较多的时候

初始化列表可以用于全部成员变量,也可以只用于部分成员变量。

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

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

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);   // 此时的结果是 2397583947  100
    obj.show();
    return 0;
}

上面这个例子, 在写初始化列表的时候, 把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_b赋值,此时m_b为100.

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

6.1.3 初始化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_lenm_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++创建对象的过程说起。

6.1.4 C++对象创建过程

对象的创建过程经历三个阶段:①分配内存空间 ②初始化成员变量 ③调用构造方法

  1. 分配内存空间
    对于全局对象,静态对象以及分配在栈区的对象,内存分配是编译阶段完成,而堆区域的对象(new), 在程序运行阶段完成。

    内存空间分配过程需要确定分配空间大小,编译器根据类数据成员分配。对于全局对象和静态对象,编译器单独划分一个独立的段分配足够空间,一般不会涉及空间不足问题。 分配在栈区的对象,栈区域大小由编译器设置决定, 但栈空间有限,一旦对象需要空间太大,会造成栈溢出,由于栈区域分配是编译阶段完成,所以在栈区域溢出会抛出编译阶段的异常。 分配在堆区域的对象,堆内存空间分配是运行中进行,由于堆空间也有限,太大也会分配失败,但这时候是抛出运行时异常。

  2. 初始化成员变量
    先区分两个容易混淆概念: 初始化和赋值。 初始化早于赋值,它是随着对象诞生一起进行,而赋值时对象诞生之后给予的新值。对类对象的初始化,实际上是对类对象内所有数据成员初始化, C++中可以通过构造函数的初始化列表实现。 而在对象初始化之后,我们仍然可以对其赋值,这个是通过构造函数实现体实现, 之前还以为这俩哥们是一回事, 结果发现分管着两个过程。 对象的初始化是初始化列表完成,而对象的赋值时通过构造函数完成, 初始化列表先于构造函数体的代码执行
  3. 调用构造方法
    这里主要是理解三个地方:
    1. 构造函数也是函数,拥有重载的特征,在构造对象时根据参数自动选择
    2. 构造函数拥有函数参数默认值特性,使用默认值可减少构造函数个数
    3. 初始化列表可以让构造函数在被调用之前初始化,如果类成员变量是const,必须使用初始化列表初始化。 Why?

Question: 什么情况下必须使用初始化列表来初始化成员变量而不能使用构造函数赋值来实现?

答: 主要是三种情况(C++ Primer)

  1. 需要初始化数据成员是对象(包含了继承中,显式调用父类构造函数对父类数据成员初始化)
    如果有一个类成员,它只有一个带参构造函数,没有默认构造函数,这时候,要对这个类成员初始化,必须调用这个类成员带参数的构造函数,如果没有初始化列表,无法完成第一步
  2. 需要初始化const修饰的类成员或者初始化引用成员数据
    当类成员中含有一个const对象时,或者是一个引用时,他们也必须要通过成员初始化列表进行初始化,因为这两种对象要在声明后马上初始化,而在构造函数中,做的是对他们的赋值,这样是不被允许的
  3. 子类初始化父类的私有成员,需要且只能在参数初始化列表中显式调用父类的构造函数

6.2 C++析构函数

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

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

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

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

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

C++的new和delete分别用来分配和释放内存, 和C语言的malloc(),free()最大的一个不同之处: 用new分配内存时会调用构造函数, 用delete释放内存会调用析构函数。 构造函数和析构函数对类是不可或缺的。

不同的对象, 析构函数的执行时机是不一样的, 对象的销毁时机和它所在的内存区域有关

  • 所有函数之外创建的对象是全局对象, 和全局变量类似,位于内存分区的全局数据区,程序在结束执行时调用这些对象的析构函数
  • 函数内部创建的对象是局部对象, 和局部变量类似,位于栈区,函数执行结束时调用这些对象的析构函数
  • new创建的对象位于堆区,通过delete删除时才会调用析构函数,如果没有delete, 析构函数就不会执行

看下面这个例子:

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

这里的结果表示, func函数,由于是个局部对象, 位于栈区, 函数执行结束,先调用,输出1。然后是输出main。 然后是main函数执行结束,此时栈区对象销毁, 执行析构函数,输出3, 而通过new建立的对象由于是存放在堆里面, 如果不手动delete对象, 是不会调用析构函数的,所以上面没有4的身影。 如果把主函数改成这个

int main(){
    //局部对象
    Demo obj3("3");
    //new创建的对象
    Demo *pobj4 = new Demo("4");
    func();
    cout<<"main"<<endl;
    
    delete pobj4;
  
    return 0;
}

// 结果
1
main
4
3
2

此时就会调用堆中对象的析构函数了。所以当使用堆内存的对象使用完毕时,一定要记得delete,释放内存

7. C++ 对象数组

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

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

#include<iostream>
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    // 初始化列表的{4,5}可以看做用来初始化两个数组元素的参数,所以调第二个构造函数
Constructor 2 Called
Constructor 2 Called
step3   // 只初始化了array[0]
Constructor 2 Called
Constructor 1 Called
step4   // 两个都没有初始化
Constructor 1 Called
Constructor 1 Called

其实上面那种写法并不是很清晰,我还是感觉下面的方法比较好, 也就是构造函数有多个参数时, 数组的初始化列表中要显式的包含对构造函数的调用

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

8. C++成员对象和封闭类

一个类的成员变量如果是另一个类的对象, 就称之为"成员对象"。包含成员对象的类叫"封闭类"。

刚开始读到这句话的时候, 有点没反应过来, 所以就想了一个例子感觉挺有意思,比如一个孕妇类, 一个baby类,在孕妇类的成员变量中,会存在baby类的对象,毕竟baby也是孕妇类的一部分嘛。 这时候, 孕妇类就是封闭类, 而在孕妇类里面的baby对象, 则就是成员对象。

当然, 下面在文档里面抽出的一个例子,感觉更好哈,我这个是方便理解。

8.1 成员对象的初始化

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

//轮胎类
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;
}

在这个例子里面, Car是个封闭类, 有两个类的对象成员m_tyrem_engine。在创建car对象的时候, 编译器需要知道m_tyrem_engine这俩哥们应该如何初始化。

编译器通过上面Car类的构造函数的初始化列表,car对象是通过Car(int price, int radius, int width) 构造函数初始化的,那么 m_tyrem_engine 该如何初始化,就要看后面的初始化列表了。该初始化列表表明:

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

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

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

8.2 成员对象的消亡

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

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

看下面代码:

#include<iostream>
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

9. C++ this指针

this是C++中的一个关键字, 也是一个const指针, 它指向的是当前对象, 通过它,可以访问当前对象的所有成员。 如果熟悉Python的话, 知道Python里面的self, 类比到c++就是this。

class Student{
public:
    void setname(char *name);
    void show();
private:
    char *name;
};
void Student::setname(char *name){
    this->name = name;
}

void Student::show(){
    cout<<this->name<<endl;
}
int main(){
    Student *pstu = new Student;
    pstu -> setname("李华");
    pstu -> show();
    return 0;
}

关于this, 我们需要先知道以下内容:

  • this 只能用在类的内部,通过 this 可以访问类的所有成员,包括 private、protected、public 属性的。但它并不是对象本身的一部分,只是指向对象。
  • this 虽然用在类的内部,但是只有在对象被创建以后才会给 this 赋值,并且这个赋值的过程是编译器自动完成的,不需要用户干预,用户也不能显式地给 this 赋值。
  • this 是 const 指针(A * const),它的值是不能被修改的,一切企图修改该指针的操作,如赋值、递增、递减等都是不允许的。
  • this 只能在成员函数内部使用,用在其他地方没有意义,也是非法的。
  • 只有当对象被创建后 this 才有意义,因此不能在 static 成员函数中使用。
  • 注意,this 是一个指针,要用->来访问成员变量或成员函数。

this的本质:

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

比如上面的setname(char *name)函数, 在编译的时候,会是setname(Student *const this, char *name),在上面成员函数编译原理的时候提到过, 成员函数最终被编译成与对象无关的普通函数,除了成员变量,会丢失所有信息,所以编译时要在成员函数中添加一个额外的参数,把当前对象的首地址传入,以此来关联成员函数和成员变量。这个额外的参数,实际上就是 this,它是成员函数和成员变量关联的桥梁

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

10. C++的静态成员变量和静态成员函数

10.1 静态成员变量

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

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

上面这个声明了一个静态变量, 来统计班级人数。 为班级的每个学生共享。 所以呢, 对于static修饰的成员变量, 需要注意下面几点(划重点):

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

  • static 成员变量必须在类声明的外部初始化,具体形式为:type class::name = value; 因为它属于类嘛, 所以不能通过构造函数去进行初始化。静态成员变量在初始化时不能再加 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 变量类似,都在内存分区中的全局数据区分配内存

下面是一个使用静态成员变量统计学生人数的例子:

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() 函数,不再进行其他操作。不过使用匿名对象无法回收内存,会导致内存泄露,在中大型程序中不建议使用

小总下:

  • 一个类中可以有一个或多个静态成员变量,所有的对象都共享这些静态成员变量,都可以引用它。
  • static 成员变量和普通 static 变量一样,都在内存分区中的全局数据区分配内存,到程序结束时才释放。这就意味着,static 成员变量不随对象的创建而分配内存,也不随对象的销毁而释放内存。而普通成员变量在对象创建时分配内存,在对象销毁时释放内存。
  • 静态成员变量必须初始化,而且只能在类体外进行, 初始化时可以赋初值,也可以不赋值。如果不赋值,那么会被默认初始化为 0。全局数据区的变量都有默认的初始值 0,而动态数据区(堆区、栈区)变量的默认值是不确定的,一般认为是垃圾值
  • 静态成员变量既可以通过对象名访问,也可以通过类名访问,但要遵循 private、protected 和 public 关键字的访问权限限制。当通过对象名访问时,对于不同的对象,访问的是同一份内存。

10.2 静态成员函数

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

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

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

在C++中,静态成员函数的主要目的是访问静态成员。看个完整例子:

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

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

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

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

11. const成员变量、成员函数和对象

11.1 const成员变量和成员函数

const 成员变量的用法和普通 const 变量的用法相似,只需要在声明时加上 const 关键字。初始化 const 成员变量只有一种方法,就是通过构造函数的初始化列表

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

class Student{
public:
    Student(char *name, int age, float score);
    void show();
    //声明常成员函数
    char *getname() const;
private:
    char *m_name;
};
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;
}

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

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

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

11.2 const对象

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

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

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

举个🌰

class Student{
public:
    Student(char *name, int age, float score);
public:
    void show();
    char *getname() const;
private:
    char *m_name;
};
Student::Student(char *name): m_name(name){ }
void Student::show(){
    cout<<m_name<<endl;
}
char * Student::getname() const{
    return m_name;
}

int main(){
    const Student stu("小明");
    //stu.show();  //error
    cout<<stu.getname()<<endl;
    const Student *pstu = new Student("李磊");
    //pstu -> show();  //error
    cout<<pstu->getname()<<endl;
    return 0;
}

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

12. C++的友元函数和友元类

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

12.1 友元函数

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

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

主要两种情况:

  1. 非成员函数声明为友元函数

    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<<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);  //调用友元函数
        return 0;
    }
    

    show() 是一个全局范围内的非成员函数,它不属于任何类,它的作用是输出学生的信息。m_name、m_age、m_score 是 Student 类的 private 成员,原则上不能通过对象访问,但在 show() 函数中又必须使用这些 private 成员,所以将 show() 声明为 Student 类的友元函数。

    注意,友元函数不同于类的成员函数,在友元函数中不能直接访问类的成员,必须要借助对象, 如果是这样:

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

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

  2. 类的成员函数声明为友元函数

    friend 函数不仅可以是全局函数(非成员函数),还可以是另外一个类的成员函数。

    class Address;  //提前声明Address类
    //Address add     //error  提前声明不能创建对象
    
    //声明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<<"家庭住址:"<<addr->m_province<<"省"<<addr->m_city<<"市"<<addr->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;
    }
    

    下面几点注意:

    1. Address 类进行了提前声明,是因为在 Address 类定义之前、在 Student 类中使用到了它,如果不提前声明,编译器会报错,提示'Address' has not been declared。类的提前声明和函数的提前声明是一个道理。
    2. 程序将 Student 类的声明和实现分开了,而将 Address 类的声明放在了中间,这是因为编译器从上到下编译代码,show() 函数体中用到了 Address 的成员 province、city、district,如果提前不知道 Address 的具体声明内容,就不能确定 Address 是否拥有该成员(类的声明中指明了类有哪些成员)。一般情况下,类必须在正式声明之后才能使用;但是某些情况下(如上例所示),只要做好提前声明,也可以先使用。但这样的提前声明,是不能够建立对象的, 比如上面的第二行, 这个编译器会报错

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

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

12.2 友元类

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

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

上面那个例子, 如果把Student声明为Address的友元类, 那么也可以通过Student的Show去访问Address的成员:

//声明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;  //区(市区)
};

关于友元, 有以下几点注意:

  1. 友元的关系是单向的而不是双向的。如果声明了类 B 是类 A 的友元类,不等于类 A 是类 B 的友元类,类 A 中的成员函数不能访问类 B 中的 private 成员。
  2. 友元的关系不能传递。如果类 B 是类 A 的友元类,类 C 是类 B 的友元类,不等于类 C 是类 A 的友元类。
  3. 不建议把整个类声明为另一个类的友元类, 而是只将类的某些函数声明为友元函数,这样会安全些

13. 类, 其实也是一种作用域

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

看个例子:

class A{
public:
    typedef int 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;
}

一个类就是一个作用域的事实能够很好的解释为什么我们在类的外部定义成员函数时必须同时提供类名和函数名。在类的外部,类内部成员的名字是不可见的。

一旦遇到类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无需再次授权了。

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 类的作用域。

But, 函数的返回值类型出现在函数名之前,当成员函数定义在类的外部时,返回值类型中使用的名字都位于类的作用域之外,此时必须指明该名字是哪个类的成员。修改上面的 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;
}

14. C++中的字符串

string 是 C++ 中常用的一个类,它非常重要,所以,单独整理下。

14.1 基础定义

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

#include <iostream>
#include <string>
using namespace std;
int main(){
    string s1;  // 变量 s1 只是定义但没有初始化,编译器会将默认值赋给 s1,默认值是"",也即空字符串
    string s2 = "c plus plus"; // 变量 s2 在定义的同时被初始化为"c plus plus"。与C风格的字符串不同,string 的结尾没有结束标志'\0'。
    string s3 = s2; // 变量 s3 在定义的时候直接用 s2 进行初始化,因此 s3 的内容也是"c plus plus"。
    string s4 (5, 's'); // 变量 s4 被初始化为由 5 个's'字符组成的字符串,也就是"sssss"。
    return 0;
}

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

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

string s = "http://c.biancheng.net";
int len = s.length();
cout<<len<<endl;  // 22 这个没有\0, 所以不是长度+1

14.2 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风格的字符串。

14.3 string 字符串的输入输出

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

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

14.4 访问

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

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

14.5 拼接

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

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

string s1 = "first ";
string s2 = "second ";
char *s3 = "third ";
char s4[] = "fourth ";
char ch = '@';
string s5 = s1 + s2; // first second
string s6 = s1 + s3; // first third
string s7 = s1 + s4; // first fourth
string s8 = s1 + ch; // first @

14.6 增删改

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

  1. 插入字符串
    insert() 函数可以在 string 字符串中指定的位置插入另一个字符串,它的一种原型为:string& insert (size_t pos, const string& str);

    int main(){
        string s1, s2, s3;
        s1 = s2 = "1234567890";
        s3 = "aaa";
        s1.insert(5, s3);
        cout<< s1 <<endl;  // 12345aaa67890
        s2.insert(5, "bbb");
        cout<< s2 <<endl;  // 12345bbb67890
        return 0;
    }
    
  2. 删除字符串
    erase() 函数可以删除 string 中的一个子字符串, 一种函数原型:string& erase (size_t pos = 0, size_t len = npos);,如果不指明 len 的话,那么直接删除从 pos 到字符串结束处的所有字符

    int main(){
        string s1, s2, s3;
        s1 = s2 = s3 = "1234567890";
        s2.erase(5);   // 12345
        s3.erase(5, 3);  // 1234590
        return 0;
    }
    
  3. 提取字符串
    substr() 函数用于从 string 字符串中提取子字符串,它的原型为:string substr (size_t pos = 0, size_t len = npos) const;

    int main(){
        string s1 = "first second third";
        string s2;
        s2 = s1.substr(6, 6);  // second
        return 0;
    }
    

    上面这些函数,如果pos越界,系统会抛出异常, 但是如果len越界, 会提取从 pos 到字符串结尾处的所有字符。

14.7 查找

这里介绍三个函数:

  1. find() 函数用于在 string 字符串中查找子字符串出现的位置, 函数原型: size_t find (const string& str, size_t pos = 0) const;

    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;   // 6
        else
            cout<<"Not found"<<endl;
        return 0;
    }
    

    find() 函数最终返回的是子字符串第一次出现在字符串中的起始下标。如果没有查找到,返回个无穷大的数。

  2. rfind()函数,和find()类似,但从后往前找

  3. find_first_of() 函数用于查找子字符串和字符串共同具有的字符在字符串中首次出现的位置

    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;   // 3 共同具有的's'
        else
            cout<<"Not found"<<endl;
        return 0;
    }
    

14.7 更深的故事 – 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++ 中,独立的几个 string 对象可以占据也可以不占据各自特定的物理存储区,但是,如果采用引用计数避免了保存同一数据的拷贝副本,那么各个独立的对象(在处理上)必须看起来并表现得就像独占地拥有各自的存储区一样。 看下面🌰

    int main() {
        string s1("12345");
        string s2 = s1;
        cout << (s1 == s2) << endl;  // 1
        s1[0] = '6';
        cout << "s1 = " << s1 << endl;  //62345
        cout << "s2 = " << s2 << endl;  //12345
        cout << (s1 == s2) << endl;   // 0
        return 0;
    }
    

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

这篇文章应该算是实习期间的最后一篇笔记, 还有几天的时间结束实习, 回学校之后再继续学习和记录了。 有了这两章的基础, 差不多能先完成手中的活了。最后这几天站好最后一班岗。

Logo

GitCode 天启AI是一款由 GitCode 团队打造的智能助手,基于先进的LLM(大语言模型)与多智能体 Agent 技术构建,致力于为用户提供高效、智能、多模态的创作与开发支持。它不仅支持自然语言对话,还具备处理文件、生成 PPT、撰写分析报告、开发 Web 应用等多项能力,真正做到“一句话,让 Al帮你完成复杂任务”。

更多推荐