C++学习(2) —— 类与对象基础

文章目录

类与对象基础

面向对象思想

过程论认为:数据和逻辑是分离的、独立的,程序世界本质是过程,数据作为过程处理对象,逻辑作为过程的形式定义,世界就是各个过程不断进行的总体。

对象论认为:数据和逻辑不是分离的,而是相互依存的。相关的数据和逻辑形成个体,这些个体叫做对象,世界就是由一个个对象组成的。对象具有相对独立性,对外提供一定的服务。所谓世界的演进,是在某个"初始作用力"作用下,对象间通过相互调用而完成的交互;在没有初始作用力下,对象保持静止。这些交互并不是完全预定义的,不一定有严格的因果关系,对象间交互是"偶然的",对象间联系是"暂时的"。世界就是由各色对象组成,然后在初始作用力下,对象间的交互完成了世界的演进。过程论和对象论不是一种你死我活的绝对对立,而是一种辩证统一的对立,两者相互渗透、在一定情况下可以相互转化,是一种"你中有我、我中有你"的对立。如果将对象论中的所有交互提取出来而撇开对象,就变成了过程论,而如果对过程论中的数据和逻辑分类封装并建立交互关系,就变成了对象论。

过程论相对确定,有利于明晰演进的方向,但当事物过于庞大繁杂,将很难理清思路。因为过程繁多、过程中又有子过程,容易将整个世界看成一个纷繁交错的过程网,让人无法看清。对象论相对不确定,但是因为以对象为基本元素,即使很庞大的事物,也可以很好地分离关注,在研究一个对象的交互时,只需要关系与其相关的少数几个对象,不用总是关注整个流程和世界,对象论更有助于分析规模较大的事物。但是,对象论也有困难。例如,如何划分对象才合理?对于同一个驱动力,为什么不同情况下参与对象和交互流程不一样?如何确定?其实,这些困难也正是面向对象技术中的困难。

接下来我们以去餐厅吃饭为例,给大家讲述面向过程和面向对象编程思想的差异。

面向过程:

  1. 客人到餐厅
  2. 客人点餐
  3. 服务员打印菜品明细
  4. 后厨根据菜品明细开始做饭
  5. 服务员呈上饭菜
  6. 用户用餐
  7. ...

通过上述过程,我们可以发现面向过程编程思想非常机械粗暴、简单明了。面向过程就是把目标的实现分成了一个个的实现过程,每一步又可以继续进一步拆分成若干子步骤。

而面向对象编程思想是将上述场景中涉及到的人以及事务抽象成一个一个的对象,比如一个客人对象,一个餐厅对象。客人对象中包含了姓氏等信息, 还包含了点餐、吃饭等方法。调用当前对象的某个方法,那么便是执行了对应的操作。餐厅对象中包含了服务器、厨师等属性成员,还包含了打印菜单、做饭、端菜等方法。调用了当前对象的某个方法,便是执行了对应的操作。值得注意的是,餐厅做饭这个方法实际上底层是厨师在制作,所以面向对象的编程思想中,餐厅的做饭方法会进一步调用厨师的制作方法。

类的定义

C++用类来描述对象,类是对现实世界中相似事物的抽象,比如同是"双轮车"的摩托车和自行车,有共同点,也有许多不同点。"车"类是对摩托车、自行车、汽车等相同点的提取与抽象。

类的定义分为两个部分:

  1. 数据,相当于现实世界中的属性,称为数据成员;

  2. 对数据的操作,相当于现实世界中的行为,称为成员函数

有些地方,会将类的数据成员和成员函数统称为类的成员

从程序设计的观点来说,类就是数据类型,是用户定义的数据类型,对象可以看成某个类的实例(某类的变量)。所以说类是对象的抽象,对象是类的实例。

由对象抽象出类

由类实例化出对象

C++中用关键字class来定义一个类,其基本形式如下:类的定义和声明

cpp 复制代码
class MyClass{//类的定义
    //......
    void myFunc(){}  //成员函数
    int _a;          //数据成员
};//一定要有分号


//类也可以先声明,后完成定义
class MyClass2;//类的声明

class MyClass2{//类的定义
    //......
};//分号不能省略

访问修饰符

如下,我们定义好一个Computer的类,假设我们站在代工厂的视角,这个Computer类拥有两个属性------品牌与价格;两个行为------设置品牌与设置价格

cpp 复制代码
class Computer {
	void setBrand(const char * brand)
	{
		strcpy(_brand, brand);
	}
    
	void setPrice(float price)
	{
		_price = price;
	}
    
	char _brand[20];
	float _price;
};

按之前的理解,现在我们自定义了一个新的类------Computer类,我们需要实例化出一个对象(特定的Computer),再通过这个对象来访问数据成员或调用成员函数,如下:

cpp 复制代码
Computer pc;
pc.setPrice(10000); //error
pc._price; //error

结果发现都会报错,这是什么原因呢?事实上,class中的所有的成员都拥有自己的访问权限,分别可以用以下的三个访问修饰符进行修饰

public: 公有的访问权限,在类外可以通过对象直接访问公有成员

**protected:**保护的访问权限,在本类中和派生类中可以访问,在类外不能通过对象直接访问(后面学)

**private:**私有的访问权限,在本类之外不能访问,比较敏感的数据设为private,类定义中可以访问。

注意:

  • 类定义中访问修饰符的管理范围从当前行到下一个访问修饰符或类定义结束;

  • class定义中如果在成员定义(或声明)之前没有任何访问修饰符,其默认的访问权限为私有

    补充:public的成员函数也可以称为接口,就是该类提供给外界使用的路径,在类外可以直接通过对象进行访问

cpp 复制代码
class Computer {
public:
	void setBrand(const char * brand)
	{
		strcpy(_brand, brand);
	}
	void setPrice(float price)
	{
		_price = price;
	}
private:
	char _brand[20];
	float _price;
};
    
Computer pc;
pc.setPrice(10000); //ok
pc._price; //error,因为_price是私有的

struct与class的对比

学习了类的定义后,我们会发现它与C语言中的struct很相似。

  • C语言中的struct

回顾一下C语言中struct的写法

cpp 复制代码
struct Student{
    int number;
    char name[25];
    int score;
};

void test0(){
    struct Student s1;
    struct Student s2 = {10,"Jack",98};
}
  • C++中的struct

C++中的struct对C中的struct做了拓展,基本等同于class,默认访问权限是public.

  • C++中的class

class默认访问权限是private.

成员函数的定义

成员函数可以在类内部完成定义,也可以在类内部只进行声明,在类外部完成定义,也可以将类的声明和定义分别放到两个文件中完成。

类内部定义
cpp 复制代码
#include <iostream>
#include <cstring>
using std::cout;
using std::endl;

class Computer {
public:
    //成员函数
    void setBrand(const char * brand)
    {
        strcpy(_brand, brand);
    }

    void setPrice(float price)
    {
        _price = price;
    }

    void print(){
        cout << "brand=" << _brand << endl
            << "price=" << _price << endl;
    }
private:
    //数据成员
    char _brand[20];
    float _price;
};

int main()
{
    cout << "Hello world" << endl;
    return 0;
}
类内部声明、外部定义

实际开发中为什么采用成员函数声明和实现分离的写法?

当类中成员函数比较多(复杂),不容易看,如果只在类中进行成员函数的声明(同时配上注释),会方便理解。

cpp 复制代码
class Computer {
public:
	//成员函数
	void setBrand(const char * brand);//设置品牌

	void setPrice(float price);//设置价格
        
    void print();//打印信息
private:
	//数据成员
	char _brand[20];
	float _price;
};

void Computer::setBrand(const char * brand)
{ 
    strcpy(_brand, brand); 
}
void Computer::setPrice(float price)
{ 
    _price = price;
}
声明定义放置不同文件中

Computer.h头文件

cpp 复制代码
#include <iostream>
#include <cstring>
using std::cout;
using std::endl;

class Computer {
public:
    //成员函数
    void setBrand(const char * brand);

    void setPrice(float price);

    void print();
private:
    //数据成员
    char _brand[20];
    float _price;
};

Computer.cpp实现文件

cpp 复制代码
#include "Computer.h"

void Computer::setBrand(const char * brand)
    {
        strcpy(_brand, brand);
    }

void Computer::setPrice(float price)
{
        _price = price;
}

void Computer::print(){
        cout << "brand=" << _brand << endl
            << "price=" << _price << endl;
    }

testComputer.cpp测试文件

cpp 复制代码
#include "Computer.h"

int main()
{
    Computer c;
    c.setBrand("Apple M1");
    c.setPrice(12000.0);
    c.print();
    return 0;
}

上述三种方式其实区别很微弱。

在初学阶段,上述三种方式选择哪一种方式均可。但是随着我们学习的深入,当我们后续接触到工程代码时,此时业务逻辑会非常复杂,我们一般会倾向于选择第三种方式:即在头文件中声明成员函数,而在实现文件中进行具体的实现。

对象的创建

在之前的 Computer 类中,通过自定义的公共成员函数 setBrand 和 setPrice 实现了对数据成员的"初始化"(严格意义上是赋值)。

实际上,C++ 为类提供了一种特殊的成员函数------构造函数来完成相同的工作。

  • 构造函数的作用:就是用来初始化数据成员的

  • 构造函数的形式:

    没有返回值,即使是void也不能有;

    函数名与类名相同,再加上函数参数列表。

构造函数在对象创建时自动调用,用以完成数据成员的初始化,及其他操作(如为指针成员动态申请内存等)

对象的创建规则

  1. 当类中没有显式定义构造函数时 ,编译器会自动生成一个默认 (无参) 构造函数 ,但并不会初始化数据成员;

    以Point类为例:

    cpp 复制代码
    class Point {
    public:
    	void print()
    	{
    		cout << "(" << _ix 
                << "," << _iy
    			<< ")" << endl;
    	}
    private:
    	int _ix;
    	int _iy;
    };
    
    void test0()
    {
    	Point pt; //调用了默认的无参构造
    	pt.print();
    }
    //运行结果显示,pt的_ix,_iy都是不确定的值

    Point pt; 这种方式创建的对象,其数据成员没有被初始化,输出的会是不确定的值

  2. 当类中显式提供了构造函数时 ,编译器就不会再自动生成默认的构造函数;

    cpp 复制代码
    class Point {
    public:
        Point(){
            cout << "Point()" << endl;
            _ix = 0;
            _iy = 0;
        }
    	void print()
    	{
    		cout << "(" << _ix 
                << "," << _iy
    			<< ")" << endl;
    	}
    private:
    	int _ix;
    	int _iy;
    };
    
    void test0()
    {
    	Point pt;
    	pt.print();
    }
    //这次创建pt对象时就调用了自定义的构造函数,而非默认构造函数
  3. 编译器自动生成的默认构造函数是无参的,构造函数也可以接收参数,在对象创建时提供更大的自由度;

    cpp 复制代码
    class Point {
    public:
        Point(int ix, int iy){
            cout << "Point(int,int)" << endl;
            _ix = ix;
            _iy = iy;
        }
    	void print()
    	{
    		cout << "(" << _ix 
                << "," << _iy
    			<< ")" << endl;
    	}
    private:
    	int _ix;
    	int _iy;
    };
    
    void test0()
    {
    	Point pt;//error,没有默认的无参构造函数可供调用
        Point pt2(10,20);
    	pt2.print();
    }
  4. 如果还希望通过无参构造函数创建对象, 则必须要手动提供一个无参构造函数;

    cpp 复制代码
    class Point {
    public:
        Point(){}
        
        Point(int ix, int iy){
            cout << "Point(int,int)" << endl;
            _ix = ix;
            _iy = iy;
        }
    	void print()
    	{
    		cout << "(" << _ix 
                << "," << _iy
    			<< ")" << endl;
    	}
    private:
    	int _ix;
    	int _iy;
    };
    
    void test0()
    {
    	Point pt;//ok
        Point pt2(10,20);
    	pt2.print();
    }
  5. 构造函数可以重载

​ 如上,一个类中可以有多种形式的构造函数,说明构造函数可以重载。事实上,真实的开发中经常会给一个类定义各种形式的构造函数,以提升代码的灵活性(可以用多种不同的数据来创建出同一类的对象)。

cpp 复制代码
class Point{
public:
    Point(){
        cout << "Point()" << endl;
        _ix = 0;
        _iy = 0;
    }
    
    Point(int x, int y = 0){
        _ix = x;
        _iy = y;
    }
    
    void print(){
        cout << "(" << _ix << "," << _iy << ")" << endl;
    }
    
private:
    int _ix;
    int _iy;
};

void test0(){
    //调用无参构造函数创建对象,后面不需要加括号
    Point pt;
    pt.print();
    
    Point pt2(1, 2);
    pt2.print();
    
    Point pt3(10);
    pt3.print();
}

对象的数据成员初始化

上述例子中,在构造函数的函数体中对数据成员进行赋值,其实严格意义上不算初始化(而是算赋值)。

在C++中,对于类中数据成员的初始化,推荐 使用初始化列表完成。

初始化列表位于构造函数形参列表之后,函数体之前,用冒号开始,如果有多个数据成员,再用逗号分隔,初始值放在一对小括号中。

cpp 复制代码
class Point {
public:
	//...
	Point(int ix, int iy)
	: _ix(ix)
    , _iy(iy)
	{
		cout << "Point(int,int)" << endl;
	}
	//...
};

如果没有在构造函数的初始化列表中显式地初始化成员,则该成员不会被初始化。

补充:数据成员的初始化并不取决于其在初始化列表中的顺序,而是取决于声明时的顺序(要求与声明顺序一致)

cpp 复制代码
#include <iostream>
using std::cout;
using std::endl;

class Point{
private:
    int _x;
    int _y;

public:
    void setX(int x){
        _x = x;
    }

    void setY(int y){
        _y = y;
    }

    //如代码所示,构造函数接收一个参数,给_y赋值,随后再次将_y的值赋值给_x,但是声明的顺序却是_x _y
    //便会出现异常
    Point(int value)
    :_y(value)
    ,_x(_y)
    {
        cout << "Point() << endl" << endl;
    }

    void print(){
        cout << "_x=" << _x << "_y=" << _y <<endl;
    }

};

int main()
{
    Point pt(3);
    pt.print();
    cout << "Hello world" << endl;
    return 0;
}
  • 构造函数的参数也可以按从右向左规则赋默认值,同样的,如果构造函数的声明和定义分开写,只用在声明或定义中的一处设置参数默认值,一般建议在声明中设置默认值。

    cpp 复制代码
    class Point {
    public:
    	Point(int ix, int iy = 0);//默认参数设置在声明时
    	//...
    };
    
    Point::Point(int ix, int iy)
    : _ix(ix)
    , _iy(iy)
    {
    	cout << "Point(int,int)" << endl;
    }
    
    void test0(){
        Point pt(10);
    }
  • C++11之后,普通的数据成员也可以在声明时就进行初始化**(类似于默认值的性质)**。

    但一些特殊的数据成员初始化只能在初始化列表中进行,故一般情况下统一推荐在初始化列表中进行数据成员初始化

cpp 复制代码
class Point {
public:
	//...
    int _ix = 0;//C++11
    int _iy = 0;
};
  • 数据成员初始化的顺序与其声明的顺序保持一致,与它们在初始化列表中的顺序无关(但初始化列表一般保持与数据成员声明的顺序一致)。

对象所占空间大小(内存对齐)

之前在讲引用的知识点时,我们提过使用引用作为函数的返回值可以避免多余的复制。内置类型的变量最大也就是long double,占16个字节。但是现在我们学习了类的定义,自定义类型对象的大小可以非常大。

使用sizeof查看一个类的大小和查看该类对象的大小,得到的结果是相同的(类是对象的模板);

cpp 复制代码
void test0(){
    Point pt(1,2);
    cout << sizeof(Point) << endl;
    cout << sizeof(pt) << endl;
 }

成员函数并不影响对象的大小,对象的大小与数据成员有关(后面学习继承、多态,对象的内存布局会更复杂);

现阶段,在不考虑继承多态的情况下,我们做以下测试。发现有时一个类所占空间大小就是其数据成员类型所占大小之和,有时则不是,这就是因为有内存对齐的机制。

cpp 复制代码
class A{
    int _num;
    double _price;
};
//sizeof(A) = 16

class B{
    int _num;
    int _price;
};
//sizeof(D) = 8
  • 为什么要进行内存对齐?

    1.平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。

    2.性能原因:CPU 对内存的读取不是连续的,而是分成块读取的,块的大小只能是1、2、4、8、16 ... 字节。若不进行内存对齐,可能需要做两次内存访问,性能会大打折扣;而进行过内存对齐仅需要一次访问。

64位系统默认以8个字节的块大小进行读取。

如果没有内存对齐机制,CPU读取_price时,需要两次总线周期来访问内存,第一次读取 _price数据前四个字节的内容,第二次读取后四个字节的内容,还要经过计算,将它们合并成一个数据。

有了内存对齐机制后,以浪费4个字节的空间为代价,读取_price时只需要一次访问,所以编译器会隐式地进行内存对齐。

规则一:

1.结构体的数据成员,第一个数据成员放在offset偏移量为0的地方,以后每个数据成员的对齐按照#pragma pack(n)指定的数值和这个数据成员自身的长度中,比较小的那个进行。

复制代码
pragma pack(n) 是 C/C++ 中的一个预处理指令,用于指定结构体或类的成员在内存中的对齐方式。其中 n 表示对齐的字节数,通常为 1、2、4、8 、16等。一般情况下,为了防止设置的不合理,可以不用去配置,采用默认行为即可。
比如对于int来说,4个字节,那么可以出现在偏移量为0、4的位置;对于double来说,8个字节,可以出现在偏移量0、8的位置

2.在数据成员完成各自对齐之后,结构体/类本身也要进行对齐,将按照#pragma pack(n)指定的数值和结构体中最大数据成员长度中,比较小的那个进行。

cpp 复制代码
#include <iostream>
using std::cout;
using std::endl;

class x{
    char a;
    int b;
    short c;
    char d;
}AA;  //12

class y{
    int b;
    char a;
    char d;
    short c;
}BB;  //8

void test(){
    cout << "sizeof(x)=" << sizeof(AA) << endl;
    cout << "sizeof(y)=" << sizeof(BB) << endl;
}

int main()
{
    test();
    cout << "Hello world" << endl;
    return 0;
}

3.如果一个结构体里有某些结构体成员,则内部结构体成员要从成员最大元素大小的整数倍和#pragma pack(n)指定的数值中最小的一个的整数倍地址开始存储。

cpp 复制代码
class AA{
    int a;
    char b;
    short c;
    int d;
    class FF{
        int a1;//
        char b1;
        short c1;
        char d1;
    }SFF;

    char e;

}SAA; // 28
cpp 复制代码
class BB{
    int a;
    char b;
    short c;
    int d;
    class FF{
        double a1;//因为出现了double 8字节类型的变量,所以最终对齐需要对齐到8字节
        char b1;
        short c1;
        char d1;
    }SFF;

    char e;
}SBB; //40
cpp 复制代码
class CC{
    char e[2];
    short h;

    //class A需要对齐到8字节边界,起始必须也从8字节整数倍开始
    class A{
        int a;
        //最大成员整数倍开始存储
        //结构体本身也需要对齐,也就是最终需要存储double 8的整数倍
        double b;
        float c;
    }SAA;

}SCC;

void test(){
    cout << "sizeof(CC)=" << sizeof(SCC) << endl;
}

还有一种特殊情况,如果定义了一个空类,这个类依然是可以创建对象的。空类对象所占空间为1个字节,这仅仅是编译器的一种占位机制。

cpp 复制代码
class A
{};

A a;

总结:

除数组外,其他类型的数据成员中,以较大的数据成员所占空间的倍数去对齐。

内存对齐还与数据成员的声明顺序有关。

指针数据成员

类的数据成员中有指针时,意味着创建该类的对象时要进行指针成员的初始化,需要申请堆空间。

在初始化列表中申请空间,在函数体中复制内容。

cpp 复制代码
class Computer {
public:
	Computer(const char * brand, double price)
	: _brand(new char[strlen(brand) + 1]())
	, _price(price)
	{
        strcpy(_brand,brand);
    }
    
private:
	char * _brand;
	double _price;
};

void test0(){
    Computer pc("Apple",12000);
}

思考一下,以上代码有没有问题?

代码运行没有报错,但使用memcheck工具检查发现发生了内存泄漏。有new表达式被执行,就要想到通过delete表达式来进行回收。如果没有对应的回收机制,对象被销毁时,它所申请的堆空间不会被回收,就会发生内存泄漏。

那么如何进行妥善的内存回收呢?这需要交给析构函数来完成。

对象的销毁-析构函数

  1. 析构函数:对象在创建时会自动调用构造函数,而析构函数在对象被销毁时被自动调用,相比构造函数,析构函数要简单很多。

  2. 析构函数的作用:清理对象的数据成员申请的资源(堆空间)------ 析构函数并不负责清理数据成员(系统自动完成)

  3. 形式:【特殊的成员函数】

    • 没有返回值,即使是void也没有

    • 没有参数

    • 函数名与类名相同,在类名之前需要加上一个波浪号~

    • 对象超出其作用域被销毁时,析构函数会被自动调用

  4. 析构函数只有一个(不能重载)

  5. 析构函数默认情况下 ,系统也会自动提供一个

  6. 当对象被销毁时 ,会自动调用析构函数【非常重要】

  7. 析构函数是可以通过对象.的方式来进行显式调用的,但是不建议显式去调用;构造函数不可以通过对象.的方式来进行显式调用

自定义析构函数

之前的例子中,我们没有显式定义出析构函数,但是没有问题,系统会自动提供一个默认的析构。但是在开发过程中,我们一般需要显式自定义析构函数,为什么需要显式自定义析构函数呢??

析构函数作为一个清理数据成员申请的堆空间的接口存在。

当数据成员中有指针时,创建一个对象,会申请堆空间,销毁对象时默认析构不够用了(造成内存泄漏),此时就需要我们自定义析构函数。在析构函数中定义堆空间上内存回收的机制,就不会发生内存泄漏。

同样以Computer类为例

cpp 复制代码
class Computer {
public:
    //问题:构造函数的形参brand可以把const去掉吗??不可以,因为在C++中推荐的字符串写法便是const char *
    //问题:可以不在初始化列表中开辟内存空间,直接在构造函数中进行strcpy嘛?不可以,指针未初始化,未定义行为
	Computer(const char * brand, double price)
	: _brand(new char[strlen(brand) + 1]())
	, _price(price)
	{}
	~Computer()
	{	
        if(_brand){
            delete [] _brand;
        	_brand = nullptr;//设为空指针,安全回收
        }
		cout << "~Computer()" << endl;
	}
private:
    //问题:这里可以加上const,从而实现将形参brand赋值给_brand?操作可以,但是意味着_brand无法进行修改,符合需求吗?
	char * _brand;
	double _price;
};

析构函数:如果指针成员申请了堆空间,就回收这片空间,并将指针成员设为空指针,进行安全回收。

析构函数的规范写法为什么这样写呢?实际上,如果类中没有指针数据成员,即数据成员没有申请堆空间的情况下,默认的析构函数就够用了。

(1)如果没有进行安全回收这一步会引发很多问题,此时我们没有学习类与对象的更多知识,可以做个简单小实验,看看会发生什么情况,思考一下原因

cpp 复制代码
~Computer()
{
	if(_brand){
    	delete [] _brand;
        //_brand = nullptr//设为空指针,安全回收
       }
	cout << "~Computer()" << endl;
}

void test0(){
    Computer pc("apple",12000);
    pc.print();
    pc.~Computer();//手动调用析构函数
}

------第一次手动调用析构函数时已经回收了这片堆空间,但是_brand存的地址值依然有效,当对象销毁时自动调用析构函数,依然会进入if语句,再一次试图回收这片空间,发生double free错误。

(2)如果没有对指针成员的判断,可能会有delete一个空指针的情况。尽管一些平台,delete本身会自动检查对象是否为空,如果为空就不做操作,但是在其他的一些平台这样做可能会导致风险,所以请按照规范去定义析构函数。

注意:对象被销毁,一定会调用析构函数;

调用了析构函数,对象并不会被销毁。

上述例子中手动调用了析构函数,发现之后又自动调用了一次析构函数。

那么在手动调用析构函数之后,再次调用print函数,看看会发生什么?

cpp 复制代码
Computer pc("apple",12000);
pc.~Computer();
pc.print();

发现程序在print执行时尝试对char型空指针进行输出,导致程序中断。

结论:不建议手动调用析构函数,因为容易导致各种问题,应该让析构函数自动被调用。

**注:**析构函数是可以通过对象来调用,而构造函数不同。

构造函数是最特殊的成员函数,不是由对象来调用构造函数。而是,编译器在看到创建对象的语句时,会自动生成一段代码,在这段代码中调用构造函数,利用传入的参数来初始化对象。

析构函数的调用时机(重点)

  1. 对于全局对象整个程序结束时,自动调用全局对象的析构函数。
  2. 对于局部对象 ,在程序离开局部对象的作用域时调用对象的析构函数。
  3. 对于静态对象 ,在整个程序结束时调用析构函数。
  4. 对于 堆对象在使用 delete 删除该对象时,调用析构函数。
cpp 复制代码
#include <iostream>
#include <string.h>
using std::cout;
using std::endl;

class Computer{
private:
    char * _brand;
    double _price;

public:
    //构造函数
    Computer(const char * brand, double price)
    :_brand(new char[strlen(brand) + 1]())
    ,_price(price)
    {
        strcpy(_brand, brand);
        cout << "constructor: " << _brand << endl;
    }

    //析构函数
    ~Computer(){
        cout << "~~~brand=" << _brand << " price=" << _price << endl;
        if(_brand != NULL){
            delete [] _brand;
            _brand = NULL;
        }
    }

};
//全局对象
Computer com1("global obj", 5999.0);

void test(){
    //静态对象
    static Computer com2("static obj", 12000.0);
    //栈对象
    Computer com3("stack obj", 6899.0);
    cout << "test call end" << endl;
}

int main()
{
    test();
    Computer *com4 = new Computer("heap obj", 10000);

    delete com4;
    com4 = NULL;
    cout << "Hello world" << endl;
    return 0;
}

运行结果如下:

本类型对象的复制

拷贝构造函数

对于内置类型而言,使用一个变量初始化另一个变量是很常见的操作

cpp 复制代码
int x = 1;
int y = x;

那么对于自定义类型,我们也希望能有这样的效果,如

cpp 复制代码
Point pt1(1,2);
Point pt2 = pt1;
pt2.print();

发现这种操作也是可以通过的。执行 Point pt2 = pt1; 语句时, pt1 对象已经存在,而 pt2 对象还不存在,所以也是这句创建了 pt2 对象,既然涉及到对象的创建,就必然需要调用构造函数,但是这里面构造函数被调用了1次,而析构函数被调用了2次,此时这里调用的就是拷贝构造函数(复制构造函数)。

拷贝构造函数的定义

拷贝构造函数的形式是固定的:**类名(const 类名 &) **

  1. 该函数是一个构造函数 ------ 拷贝构造也是构造!
  2. 该函数用一个已经存在的同类型的对象,来初始化新对象,即对对象本身进行复制

没有显式定义拷贝构造函数,这条复制语句依然可以通过,说明编译器自动提供了默认的拷贝构造函数。其形式是:

cpp 复制代码
Point(const Point & rhs)
: _ix(rhs._ix)
, _iy(rhs._iy)
{}

拷贝构造函数看起来非常简单,那么我们尝试对Computer类的对象进行同样的复制操作。发现同样可以编译通过,但运行报错。思考一下为什么?

cpp 复制代码
Computer pc("Acer",4500);
Computer pc2 = pc;//调用拷贝构造函数

如果是默认的拷贝构造函数,pc2会对pc的_brand进行浅拷贝,指向同一片内存;pc2被销毁时,会调用析构函数,将这片堆空间进行回收;pc再销毁时,析构函数中又会试图回收这片空间,出现double free问题

所以,如果拷贝构造函数需要显式写出时(该类有指针成员申请堆空间),在自定义的拷贝构造函数中要换成深拷贝的方式,先申请空间,再复制内容

cpp 复制代码
Computer::Computer(const Computer & rhs)
: _brand(new char[strlen(rhs._brand) + 1]())
, _price(rhs._price)
{
	strcpy(_brand, rhs._brand);
}
拷贝构造函数的调用时机(重点)
  1. 当使用一个已经存在的对象初始化另一个同类型的新对象时;

  2. 当函数参数(实参和形参的类型都是对象),形参与实参结合时(实参初始化形参);

    ------ 为了避免这次不必要的拷贝,可以使用引用作为参数

  1. 当函数的返回值是对象,执行return语句时(编译器有优化)。

------为了避免这次多余的拷贝,可以使用引用作为返回值,但一定要确保返回值的生命周期大于函数的生命周期

第三种情况直接编译并不会显示拷贝构造函数的调用,但是底层实际是调用了的,加上去优化参数进行编译可以看到效果

C++ 复制代码
g++ CopyComputer.cc -fno-elide-constructors --std=c++11

案例2:

如果不打印拷贝构造函数,则添加以下参数:

shell 复制代码
g++ CopyCons4.cpp -fno-elide-constructors -std=c++11
cpp 复制代码
#include <iostream>
using std::cout;
using std::endl;

class Point{
private:
    int _x;
    int _y;

public:
    Point(int x, int y)
    :_x(x)
    ,_y(y)
    {
        cout << "call constructor" << _x << _y <<endl;
    }

    ~Point(){
        cout << "call deconstructor" << endl;
    }
    Point(const Point & rhs)
    :_x(rhs._x)
    ,_y(rhs._y)
    {
        cout << "call copy constrctor " << _x << _y  << endl;
    }

    void print(){
        cout << "_x=" << _x << "_y=" << _y << endl;
    }

};

Point func(){
    //创建对象,调用构造函数
    Point pt(3,6);
    //返回的时候调用一次拷贝构造函数
    return pt;
}

void test(){
    //func函数超出生命周期,创建的pt对象被销毁,
    //调用析构函数
    //满足拷贝构造函数的第一种情形,调用拷贝构造函数
    //func函数执行完之后,拷贝出来的对象销毁调用析构函数
    Point pt2 = func();
    //随后打印log
    cout << "end of test" << endl;
    //最后test作用域结束,则pt2对象销毁,调用析构函数
}


int main()
{
    test();
    return 0;
}
拷贝构造函数的形式探究*

思考1:拷贝构造函数是否可以去掉引用符号?

------ 类名(const 类名) 形式,首先编译器不允许这样写,直接报错

如果拷贝函数的参数中去掉引用符号,进行拷贝时调用拷贝构造函数的过程中会发生"实参和形参都是对象,用实参初始化形参"(拷贝构造第二种调用时机),会再一次调用拷贝构造函数。形成递归调用,直到栈溢出,导致程序崩溃。

思考2:拷贝构造函数是否可以去掉const?

------ 类名(类名 &) 形式 编译器不会报错

加const的第一个用意:为了确保右操作数的数据成员不被改变

如果不加const,那么如下操作是可以通过的,不合理。


加const的第二个用意:为了能够复制临时对象的内容,因为非const引用不能绑定临时变量(右值)

左值:可以进行取地址操作的称为左值。

右值:不能进行取地址操作的称为右值。其中临时对象、匿名对象、临时变量、匿名变量、字面值常量均属于右值。

非const引用不可以绑定右值(临时对象),主要的原因在于它的生命周期很短,如果允许非const引用绑定右值,那么便会出现使用已经被销毁对象的引用问题

const引用绑定右值,可以延长右值的生命周期到const引用的作用域结束。

cpp 复制代码
void test2(){
 int num = 10;
 //左值,可以取地址
 &num;

 //右值,不能取地址(匿名、临时对象或者变量)
 &1;

 //非const引用只可以绑定左值,不能绑定右值
 int & ref = num;
 int & ref2 = 1; //error
 //const引用可以绑定左值,也可以绑定右值
 const int & ref3 = num;
 const int & ref4 = 1;
}


如果拷贝构造函数中去掉const

c++ 复制代码
Computer & rhs = Computer("apple",12000); //error

赋值运算符函数

赋值运算同样是一种很常见的运算,比如:

c++ 复制代码
int x = 1, y = 2;
x = y;

自定义类型当然也有这种运算,比如:

c++ 复制代码
Point pt1(1, 2), pt2(3, 4);
pt1 = pt2;//赋值操作

在执行 pt1 = pt2; 该语句时, pt1 与 pt2 都存在,所以不存在对象的构造,这要与 Point pt2 =pt1; 语句区分开,这是不同的。

赋值运算符函数的形式

在上述例子中,当 = 作用于对象时,其实是把它当成一个函数来看待的。在执行 pt1 = pt2; 该语句时,需要调用的是赋值运算符函数。其形式如下:

类名& operator=(const 类名 &)

对Point类进行测试时,会发现我们不需要显式给出赋值运算符函数,就可以执行测试。这是因为如果类中没有显式定义赋值运算符函数时,编译器会自动提供一个赋值运算符函数。对于 Point 类而言,其实现如下:

cpp 复制代码
Point & Point::operator=(const Point & rhs)
{
	_ix = rhs._ix;
	_iy = rhs._iy;
}

手动写出赋值运算符,再加上函数调用的提示语句。执行发现语句被输出,也就是说,当对象已经创建时,将另一个对象的内容复制给这个对象,会调用赋值运算符函数

那么现在又产生了问题

赋值运算符函数返回类型是Point&,那么它的返回值是什么?

这两个问题引出了一个重要的知识点------this指针

this指针
cpp 复制代码
void test(){
    Point pt(2,4);
    pt.print();

    Point pt2(1,5);
    pt2.print();
}

如上述代码所示,我们创建了两个栈对象,分别调用了该对象的print方法,打印的结果也都是显示了正确的数据。但是,我们需要思考一个问题:为什么print()显示的内容是正确的呢?是如何匹配到当前函数属于某个对象的呢?其实底层便是使用了this指针。this指针存在于每一个非静态的成员函数的第一个参数的位置,用来记录当前对象的地址。指针的两种使用形式:一种是变更指向,一种是通过指针修改数据。那么this指针可以进行何种操作呢?

  • this指针的本质

this指针的本质是一个常量指针 Type* const pointer; 它储存了调用它的对象的地址,不可被修改。这样成员函数才知道自己修改的成员变量是哪个对象的。

this是一个隐藏的指针,可以在类的成员函数中使用,它可以用来指向调用对象。当一个对象的成员函数被调用时,编译器会隐式地传递该对象的地址作为 this 指针。

this指针指向本对象

  • this指针存在哪儿

编译器在生成程序时加入了获取对象首地址的相关代码,将获取的首地址存放在了寄存器中,这就是this指针。

  • this指针的生命周期

**this 指针的生命周期开始于成员函数的执行开始。**当一个非静态成员函数被调用时,this 指针被自动设置为指向调用该函数的对象实例。在成员函数执行期间,this 指针一直有效。它可以被用来访问调用对象的成员变量和成员函数。**this指针的生命周期结束于成员函数的执行结束。**当成员函数返回时,this指针的作用范围就结束了。

要注意,this指针的生命周期与它所指向的对象的生命周期虽然并不完全相同,但是是相关的。

this指针本身只在成员函数执行期间存在,但它指向的对象可能在成员函数执行前就已经存在,并且在成员函数执行后继续存在。

如果成员函数是通过一个已经销毁或未初始化的对象调用的,this指针将是悬挂的,它的使用将会是未定义行为。

理解以下问题:

  1. 对象调用函数时,是如何找到自己本对象的数据成员的? ------ 通过this指针
  2. this指针代表的是什么? ------ 指向本对象
  3. this指针在参数列表中的什么位置? ------ 参数列表的第一位(默认自动加入,不要手动写出)
  4. this指针的形式是什么? ------ 类名 * const this (常量指针)
cpp 复制代码
Point & operator=(const Point & rhs){
    this->_ix = rhs._ix;
    this->_iy = rhs._iy;
    cout << "Point & operator=(const Point &)" << endl;
    return *this;
}

成员函数中可以加上this指针,展示本对象通过this指针找到本对象的数据成员。但是不要在参数列表中显式加上this指针,因为编译器一定会在参数列表的第一位加上this指针,如果显式再给一个,参数数量就不对了。

赋值运算符函数的定义

注意:如果对象的指针数据成员申请了堆空间,默认的赋值运算符函数就不够用了,以Computer类为例,默认的赋值运算符函数长这样

cpp 复制代码
Computer & operator=(const Computer & rhs){
    this->_brand = rhs._brand;
    this->_price = rhs._price;
    return *this;
}

这里的指针成员_brand是进行的浅拷贝,会造成问题

思考:

如果直接进行深拷贝,可行吗?

会有内存泄露,需要回收掉pc2._brand原本申请的空间

如果用delete回收掉pc2._brand原本申请的空间,再进行深拷贝,是否可行?------ 还要考虑自赋值

总结步骤------ 四步走(重点):

  1. 考虑自赋值问题
  2. 回收左操作数的数据成员原本申请的堆空间
  3. 深拷贝(以及其他的数据成员的赋值)
  4. 返回*this(本对象)
cpp 复制代码
Computer & operator=(const Computer & rhs){
    if(this != &rhs){
        delete [] _brand;
        _brand = new char[strlen(rhs._brand)]();
        strcpy(_brand,rhs._brand);
        _price = rhs._price;
    }
    return *this;
}

完整代码参考如下:

cpp 复制代码
#include <iostream>
#include <string.h>
using std::cout;
using std::endl;

class Computer{
private:
    char * _brand;
    double _price;

public:
    //构造函数
    Computer(const char * brand, double price)
    :_brand(new char[strlen(brand) + 1]())
    ,_price(price)
    {
        strcpy(_brand, brand);
        cout << "Computer构造函数" << endl;
    }

    //析构函数
    ~Computer(){
        if(_brand != nullptr){
            delete [] _brand;
            _brand = nullptr;
        }
        cout << "Computer析构函数" << endl;
    }

    //拷贝构造函数
    Computer(const Computer & rhs)
    :_brand(new char[strlen(rhs._brand) + 1]())
    ,_price(rhs._price)
    {
        strcpy(_brand, rhs._brand);
        cout << "Computer拷贝构造函数" << endl;
    }

    //赋值运算符函数
    Computer & operator=(const Computer &rhs)
    {
        cout << "赋值运算符函数" << endl;
        if(this != &rhs){
            //把原本之前申请的内存空间释放
            delete [] _brand;
            _brand = new char[strlen(rhs._brand) + 1]();
            strcpy(_brand, rhs._brand);
            _price = rhs._price;
        }
        return *this;
    }
};

//拷贝构造函数
void test(){
    Computer c1("Apple", 12000);
    
    Computer c2 = c1;
}

//赋值运算符函数
void test2(){
    Computer c1("Huawei", 7899);
    //原先c2申请的内存空间,
    //后面赋值运算符操作之后没有释放
    Computer c2("lenovo", 6599);
    c2 = c1;
}

int main()
{
    test2();
    cout << "Hello world" << endl;
    return 0;
}
赋值运算符函数的形式探究*

关于赋值运算符函数的形式探究也是面试中比较可能出现的问题,以下提出四个思考:

  1. 赋值运算符函数的返回必须是一个引用吗?
C++ 复制代码
Computer operator=(const Computer & rhs)
{
    ......
    return *this;
}

------ 会造成一次多余的拷贝,增加不必要的开销

  1. 赋值操作符函数的返回类型可以是void吗?
C++ 复制代码
void operator=(const Computer & rhs)
{
    ......
}

------ 无法处理连续赋值

c++ 复制代码
pt1 = pt2 = pt3
  1. 赋值操作符函数的参数一定要是引用吗?
C++ 复制代码
Computer & operator=(const Computer rhs)   
{
	......
	return *this;
}

------ 会造成一次多余的拷贝,增加不必要的开销(符合拷贝构造函数的第二种调用时机)

注意:此时讨论的是赋值运算符函数的参数形式,前提是拷贝构造是正常的。拷贝构造的参数依然是引用,就不会陷入拷贝构造递归调用

  1. 赋值操作符函数的参数必须是一个const引用吗?
C++ 复制代码
Computer & operator=(Computer & rhs)   
{
	......
	return *this;
}

------ 无法避免在赋值运算符函数中修改右操作数的内容,不合理

------ 而且不能处理通过右值属性的对象进行赋值的情况

三合成原则*

三合成原则很容易在面试时被问到:

拷贝构造函数、赋值运算符函数、析构函数,如果需要手动定义其中的一个,那么另外两个也需要手动定义。

特殊的数据成员

在 C++ 的类中,有4种比较特殊的数据成员,分别是常量成员、引用成员、类对象成员和静态成员,它们的初始化与普通数据成员有所不同。

常量数据成员

当数据成员用 const 关键字进行修饰以后,就成为常量成员。一经初始化,该数据成员便具有"只读属性",在程序中无法对其值修改。事实上,在构造函数体内对const 数据成员赋值是非法的,const数据成员需在初始化列表中进行初始化(C++11之后也允许在声明时就初始化)。

普通的const常量必须在声明时就初始化,初始化之后就不再允许修改值;

const成员初始化后也不再允许修改值。

cpp 复制代码
class Point {
public:
	Point(int ix, int iy)
	: _ix(ix)
	, _iy(iy)
	{}
private:
	const int _ix;
	const int _iy;
};

引用数据成员

引用数据成员在初始化列表中进行初始化,C++11之后允许在声明时初始化(绑定)。

之前的学习中,我们知道了引用要绑定到已经存在的变量,引用成员同样如此。

cpp 复制代码
class Point {
public:
	Point(int ix, int iy)
	: _ix(ix)
	, _iy(iy)
	, _iz(_ix) //注意这里面的写法,绑定_x
	{}
private:
	int _ix;
	int _iy;
	int & _iz;
};

思考:构造函数再接收一个参数,用这个参数初始化引用成员可以吗?引用绑定的变量其生命周期应当不短于引用成员变量的生命周期。

cpp 复制代码
class Point
{
public:
	Point(int x,int y,int z)
	: _ix(x)
	, _iy(y)
	, _iz(z) //这样绑定可行吗
	{}
    
private:
	int _ix;
	int _iy;
	int & _iz;
};

引用成员需要绑定一个已经存在的、且在这个引用成员的生命周期内始终有效的变量(对象)。

对象成员

有时候,一个类对象会作为另一个类对象的数据成员被使用。比如一个直线类Line对象中包含两个Point对象。

对象成员在初始化列表中进行初始化。

注意:

初始化列表中写的是需要被初始化的对象成员的名称,而不是对象成员的类名。

形式1:

cpp 复制代码
class Point{
public:
    Point(){}
    
    Point(int ix, int iy)
    :_ix(ix)
    ,_iy(iy)
    {}
    
private:
    int _ix;
    int _iy;
};

class Line {
public:
	Line(int x1, int y1, int x2, int y2)
        //如果没有在Line的构造函数的初始化列表中
        //显示调用Point的构造函数
        //那么会自动调用Point的无参构造
	: _pt1(x1, y1) //Point _pt1(x1,y1)
	, _pt2(x2, y2)
	{
		cout << "Line(int,int,int,int)" << endl;
	}
private:
	Point _pt1;
	Point _pt2;
};

注意:

如果在Line类的构造函数的初始化列表中没有显式地初始化Point类对象成员,编译器会自动去调用Point类型的默认无参构造;

如果不想用Point的无参构造,那么必须在Line类的初始化列表中对Point类的对象成员进行初始化

形式2:

cpp 复制代码
#include <iostream>
using std::cout;
using std::endl;

class Point{
private:
    int _x;
    int _y;

public:
    Point(int x, int y)
    :_x(x)
    ,_y(y)
    {
        cout << "Point构造函数" << endl;
    }

    ~Point(){
        cout << "Point析构函数" << endl;
    }
    Point(const Point & rhs)
    :_x(rhs._x)
    ,_y(rhs._y)
    {
        cout << "Point拷贝构造函数" << endl;
    }
};


class Line{
private:
    //这里面会有拷贝构造函数调用
    //如果想要避免,这里面直接写引用
    Point & _pt1;
    Point & _pt2;

public:
    //这里面也需要引用,满足拷贝构造函数的调用时机2
    Line(Point & pt, Point & pt2)
    :_pt1(pt)
    ,_pt2(pt2)
    {
        cout << "Line构造函数" << endl;
    }

    ~Line(){
        cout << "Line析构函数" << endl;
    }
};

void test(){
    Point pt1(1, 2), pt2(3,5);
    Line line(pt1, pt2);
}

int main()
{
    test();
    cout << "Hello world" << endl;
    return 0;
}

有对象成员的结构下,构造函数和析构函数的调用顺序:

此例子中,创建一个Line类的对象,会首先调用Line的构造函数,在此过程中调用Point的构造函数完成Point类对象成员的初始化;

Line对象销毁时会先调用Line的析构函数,析构函数执行完后,再根据对象成员声明的反序,再调用Point的析构函数。

如下:尽管Point构造函数的调用提示在Line的构造函数之前,但这仅仅是因为Point的构造函数在Line的初始化列表中调用,执行完Point的构造函数之后,才会执行Line构造函数的函数体中的内容。

一个Line对象中包含两个Point对象,称为成员子对象。

复制代码
Line和Point的构造函数顺序是先Point后Line,而析构函数顺序却是先Line后Point,原因如下:

构造函数的执行顺序是成员的定义顺序,Point作为Line成员,所以Point的构造优先执行。
而析构函数的执行顺序与构造函数正好相反
Point所占用的空间是由Line分配的,Line的析构函数先调用,释放自己的空间,再去释放Point的空间
如果直接析构Point,由于内存依然被Line管理,会导致使用已释放内存的问题
如果先析构Line,再去析构Point,整个过程不至于出错。
总结:
构造:Point->Line
析构:Line->Point

如果Line类中有数据成员申请堆空间,Point类对象也有数据成员申请堆空间,堆空间资源的回收顺序如下

静态数据成员

C++ 允许使用 static (静态存储)修饰数据成员,这样的成员在创建对象之前被创建并初始化的。且其实例只有一个,被所有该类的对象共享,就像住在同一宿舍里的同学共享一个房间号一样。静态数据成员存储在全局/静态区,并不占据对象的存储空间

静态数据成员被整个类的所有对象共享。静态数据成员不可以在构造函数初始化列表中进行初始化。

cpp 复制代码
class Computer {
public:
	//...    
private:
	char * _brand;
	double _price;
    //数据成员的类型前面加上static关键字
    //表示这是一个static数据成员(共享)
	static double _totalPrice;
};
double Computer::_totalPrice = 0;

静态成员规则:

  1. private的静态数据成员无法在类之外直接访问(显然)
  2. 对于静态数据成员的初始化,必须放在类外(一般紧接着类的定义,这是规则1的特殊情况)
  3. 静态数据成员初始化时不能在数据类型前面加static,在数据成员名前面要加上类名+作用域限定符
  4. 如果有多条静态数据成员,那么它们的初始化顺序需要与声明顺序一致(规范)
  5. 静态成员在访问时可以通过对象访问,也可以直接通过类名::成员名的形式(更常用)

特殊的成员函数

除了特殊的数据成员以外, C++ 类中还有两种特殊的成员函数:静态成员函数和 const 成员函数。我们先来看看静态成员函数。

静态成员函数

在某一个成员函数的前面加上static关键字,这个函数就是静态成员函数。静态成员函数具有以下特点:

(1)静态成员函数不依赖于某一个对象;

(2)静态成员函数可以通过对象调用,但更常见的方式是通过类名加上作用域限定符调用

(3)静态成员函数没有this指针;

(4)静态成员函数中无法直接用成员的名字访问非静态的成员(数据成员、成员函数),只能访问静态数据成员或调用静态成员函数(因为没有this指针)。

(5)如果希望在静态成员函数中去访问非静态的成员,可以通过函数传参或者在静态成员函数中创建对象的形式间接操作

构造函数、拷贝构造、赋值运算符函数、析构函数比较特殊,可以在静态成员函数中调用。

注:但是非静态的成员函数可以访问静态成员。

cpp 复制代码
class Computer {
public:
	Computer(const char * brand, double price)
	: _brand(new char[strcpy(brand) + 1]())
	, _price(price)
	{
		_totalPrice += _price;
	}
   	//...
	//静态成员函数
	static void printTotalPrice()
	{
		cout << "totalPrice:" << _totalPrice << endl;
        cout << _price << endl;//error
	}
private:
	char * _brand;
	double _price;
	static double _totalPrice;
};
double Computer::_totalPrice = 0;

想要完成Computer类的总价计算逻辑,除了构造函数之外,还需要做哪些补充呢?请结合前面学到的知识完成这个功能:无论是创建多个Computer对象,还是进行Computer对象的复制、赋值,Computer的总价始终能够正确输出。

const成员函数

之前已经介绍了 const 的应用,实际上, const 在类成员函数中还有种特殊的用法。在成员函数的参数列表之后,函数执行体之前加上const关键字,这个函数就是const成员函数。

**形式:**void func() const {}

如下所示,下面两个函数可以同时存在。根据之前的课程,我们推测二者是属于函数重载。函数重载的实现条件要求是方法的名称、方法的参数类型、参数个数不同。而对于成员函数来说,实际上存在着一个隐形参数,那么便是this指针。

所以根据上述内容,我们推测,二者的区别在于第一个参数this指针不同而导致的函数重载。

cpp 复制代码
class Computer{
public:
    //...
    void print(){
        //....
    }
    
    void print() const{
        cout << "brand:" << _brand << endl;
        cout << "price:" << _price << endl;
    }
    //...
};

特点:

  1. const成员函数中,不能修改对象的数据成员;

  2. 当编译器发现该函数是const成员函数时,会自动将this指针设置为双重const限定的指针,const Type * const pt

对象的组织

有了自己定义的类,或者使用别人定义好的类创建对象,其机制与使用内置类型创建普通变量几乎完全一致,同样可以创建 const 对象、创建指向对象的指针、创建对象数组,还可使用 new(delete) 来创建(回收)堆对象。

const对象

类对象也可以声明为 const 对象,一般来说,能作用于 const 对象的成员函数除了构造函数和析构函数,就只有 const 成员函数了。因为 const 对象只能被创建、撤销和只读访问,写操作是不允许的。

cpp 复制代码
const Point pt(1,2);
pt.print();
cpp 复制代码
class Point{
public:
    Point(int ix, int iy)
    :_ix(ix)
    ,_iy(iy)
    {

    }

    void print() const{
        cout << "()" << this->_ix << ","
            << this->_iy << ")" << endl;
    }

    void setX(int ix){
        this->_ix = ix;
    }

    int _ix;
    int _iy;
};

void test(){
    Point pt(1, 2);
    pt.print();
    pt._ix = 100;
    pt.setX(200);
    pt.print();
    cout << "========" << endl;
    const Point pt2(3, 4);
    pt2.print();
    pt2.setX(100); //error
    pt2._ix = 200; //error
}

const对象与const成员函数的规则:

  1. 当类中有const成员函数和非const成员函数重载时,const对象会调用const成员函数,非const对象会调用非const成员函数;
  2. 当类中只有一个const成员函数时,无论const对象还是非const对象都可以调用这个版本;
  3. 当类中只有一个非const成员函数时,const对象就不能调用非const版本。

总结: 如果一个成员函数中确定不会修改数据成员,就把它定义为const成员函数。

思考1:

一个类中可以有参数形式"完全相同"的两个成员函数(const版本与非const版本),既然没有报错重定义,那么它们必然是构成了重载,为什么它们能构成重载呢?

------ 参数(this指针)的类型是不同的。

思考2:

const成员函数中不允许修改数据成员,const数据成员初始化后不允许修改,其效果是否相同?请动手验证下面的问题

举例,如果有一个普通的指针成员,在const成员函数中,它被如何限制?

对于普通类型的数据成员,const数据成员初始化后不允许修改,在const成员函数中无论是const数据成员还是非const数据成员,都不能修改值;

对于指针类型的数据成员:

const int * p,初始化之后在任何地方都不能修改其指向的值(无论在const成员函数中还是在非const成员函数中),在非const成员函数中可以修改指向,在const成员函数中不能修改指向;

int * p,在非const成员函数中可以修改指向,也可以修改值,在const成员函数中不能修改指向,可以修改指向的值。

cpp 复制代码
class Test{
public:
    Test(int a, int b)
    :_a(a)
    ,_b(b)
    ,_p(new int(10))
    ,_p2(new int(20))
    {

    }

    void print(){
        _a = 20;
        _b = 30;
        *_p = 40;
        _p = new int(30);
        *_p2 = 50;
        _p2 = new int(60);
    }

    void print() const{
        _a = 20;
        _b = 30;
        *_p = 40;
        _p = new int(30);
        *_p2 = 50;
        _p2 = new int(60);
    }

private:
    int _a;
    const int _b;
    int * _p;
    const int * _p2;
};

指向对象的指针

对象占据一定的内存空间,和普通变量一致, C++ 程序中采用如下形式声明指向对象的指针:

cpp 复制代码
类名 * 指针名 [=初始化表达式];

初始化表达式是可选的,既可以通过取地址(&对象名)给指针初始化,也可以通过申请动态内存给指针初始化,或者干脆不初始化(比如置为 nullptr ),在程序中再对该指针赋值。指针中存储的是对象所占内存空间的首地址。针对上述定义,则下列形式都是合法的:

cpp 复制代码
Point pt(1, 2);
Point * p1 = nullptr;
Point * p2 = &pt;
Point * p3 = new Point(3, 4);

问题:定义好这些指针后,如何利用指针去调用Point类的成员函数print?请试验一下

cpp 复制代码
p2->print();
(*p2).print();

对象数组

对象数组和标准类型数组的使用方法并没有什么不同,也有声明、初始化和使用等步骤。

  • 对象数组的声明
cpp 复制代码
Point pts[2];

这种格式会自动调用默认构造函数或所有参数都是缺省值的构造函数。

  • 对象数组的初始化(可以在声明时进行初始化)
cpp 复制代码
//为什么这里面直接绑定右值没有问题???依然是调用了拷贝构造函数,但是通过日志可能并不会看到拷贝构造函数的调用,因为编译器会进行返回值优化
Point pts[2] = {Point(1, 2), Point(3, 4)};
Point pts[] = {Point(1, 2), Point(3, 4)};
Point pts[5] = {Point(1, 2), Point(3, 4)};

堆对象

和把一个简单变量创建在动态存储区一样,可以用 new 和 delete 表达式为对象分配动态存储区,在拷贝构造函数一节中已经介绍了为类内的指针成员分配动态内存的相关范例,这里主要讨论如何为对象和对象数组动态分配内存。如:

cpp 复制代码
void test()
{
	Point * pt1 = new Point(11, 12);
	pt1->print();
	delete pt1;
	pt1 = nullptr;
    
	Point * pt2 = new Point[5]();//注意
	pt2->print();
	(pt2 + 1)->print();
	delete [] pt2;
    pt2 = nullptr;
}

new/delete表达式的工作步骤(了解)

现在我们已经学习了new和delete的基本使用,在new/delete和malloc/free作对比时提到了二者的最本质区别 ------ new/delete是表达式,而malloc/free是库函数。

那么new/delete表达式的底层工作步骤是怎样的呢?我们有必要进行了解,因为很多时候写出的bug就藏在这个工作步骤中。

new表达式的工作步骤

对于自定义类型而言:

使用new表达式时发生的三个步骤

  1. 调用名为operator new标准库函数申请未类型化的空间

  2. 在该空间上调用该类型的构造函数初始化对象

  3. 返回指向该对象的相应类型的指针

delete表达式的工作步骤

对于自定义类型而言:

使用delete表达式时发生的两个步骤

  1. 调用析构函数,回收数据成员申请的资源(堆空间)

  2. 调用operator delete库函数回收本对象所在的空间

    cpp 复制代码
    //默认的operator new
    void * operator new(size_t sz){
        void * ret = malloc(sz);
    	return ret;
    }
    
    //默认的operator delete
    void operator delete(void * p){
        free(p);
    }

    通过一个例子来认识这两个函数的用法

    cpp 复制代码
    class Student
    {
    public:
    	Student(int id, const char * name)
    	: _id(id)
    	, _name(new char[strlen(name) + 1]())
    	{
    		strcpy(_name, name);
    		cout << "Student()" << endl;
    	}
        
    	~Student()
    	{
            cout << "~Student()" << endl;
            if(_name){
                delete [] _name;
                _name = nullptr;
            }
    	}
        
    	void * operator new(size_t sz)
    	{
            cout << "operator new" << endl;
    		void * ret = malloc(sz);
    		return ret;
    	}
        
    	void operator delete(void * pointer)
    	{
            cout << "operator delete" << endl;
    		free(pointer);
    	}
        
    	void print() const
    	{
    		cout << "id:" << _id << endl
    			<< "name:" << _name << endl;
    	}
    private:
    	int _id;
    	char * _name;
    };
    
    void test0()
    {
    	Student * stu = new Student(100, "Jackie");
    	stu->print();
        delete stu;
    }

创建对象的探究

定义一个类,即使什么成员函数也不定义,依然可以创建栈对象和堆对象。之前我们知道了构造函数和析构函数会自动提供默认版本,那么能够创建堆对象、回收堆对象,说明会自动提供默认的operator new / operator delete函数。

默认的operator new / operator delete函数实际上就是通过malloc / free 实现的申请 / 回收堆空间。

请探究:

  • 创建堆对象需要什么条件?

思路:将创建、销毁对象过程中所调用到的函数一一设为私有,私有的成员函数在类外就无法被直接调用了。

需要公有的operator new、operator delete、构造函数,对析构函数没有要求;在销毁堆对象的时候,才会调用析构函数。

  • 创建栈对象需要什么条件?

需要公有的构造函数、析构函数,对operator new/operator delete没有要求。

根据探究得出的结论,仍以Student类为例,想要实现以下需求,应该怎么做

  • 只能生成栈对象 , 不能生成堆对象

可以将operator new/operator delete 设为私有。

  • 只能生成堆对象 ,不能生成栈对象

可以将析构函数设为私有。

堆对象: 析构函数可以定义为私有的。因为删除堆对象时,析构函数可以由开发者自己封装一个方法,进行内部调用。

栈对象: 析构函数必须定义为公有的。因为栈对象离开作用域时,必须由外部主动调用析构函数释放资源。 如果析构函数定义为私有,则无法在栈对象离开作用域时直接调用,就无法完成资源释放工作。

cpp 复制代码
class Student{

private:
    int _id;
    char * _name;

public:
    //构造函数
    Student(int id, const char * name)
    :_id(id),
     _name(new char[strlen(name) + 1]())
    {
        cout << "Student()" << endl;
        strcpy(_name, name);
    }

private:
    //析构函数设置为私有
    ~Student(){
        cout << "~Student()" << endl;
        if(_name != NULL){
            delete [] _name;
            _name = NULL;
        }
    }

public:
    void print(){
        cout << "id=" << _id << " and name=" << _name << endl;
    }


    void * operator new(size_t size){
        cout << "operator new" << endl;
        void * result = malloc(size);
        return result;
    }

    void operator delete(void * p){
        cout << "operator delete" << endl;
        free(p);
    }
	//自己封装一个方法,手动调用delete
     void destroy(){
         delete this;
    }
};

**总结:**我们需要了解new/delete表达式的工作步骤,以此为依据更合理地设计类的成员函数来进行对象的创建和回收。

**operator new/operator delete在平时不需要特别地写出,使用默认的即可。**只在如上的特别的需求下,可以显式定义出来,实现不同的限制效果。

单例模式(重点*)

单例模式是23种常用设计模式中最简单的设计模式之一,它提供了一种创建对象的方式,确保只有单个对象被创建。这个设计模式主要目的是想在整个系统中只能出现类的一个实例,即一个类只有一个对象。

将单例对象创建在静态区

根据已经学过的知识进行分析:

  1. 将构造函数私有;

  2. 通过静态成员函数getInstance创建局部静态对象,确保对象的生命周期和唯一性;

  3. getInstance的返回值设为引用,避免复制;

隐患:如果单例对象所占空间较大,可能会对静态区造成内存压力。

cpp 复制代码
class Point
{
public:
    static Point & getInstance(){
        static Point pt(1,2);
        return pt;
    }
    
    void print() const{
        cout << "(" << this->_ix
            << "," << this->_iy
            << ")" << endl;
    }
    
private:
     Point(int x,int y)
    : _ix(x)
    , _iy(y)
    {
        cout << "Point(int,int)" << endl;
    }
private:
    int _ix;
    int _iy;
};

void test0(){
    Point & pt = Point::getInstance();
    pt.print();

    Point & pt2 = Point::getInstance();
    pt2.print();

    cout << &pt << endl;
    cout << &pt2 << endl;
}

将单例对象创建在堆区(重点)

既然将单例对象创建在全局/静态区可能会有内存压力,那么为这个单例对象动态分配空间是比较合理的选择。请尝试实现代码:

分析:

  1. 构造函数私有;

  2. 通过静态成员函数getInstance创建堆上的对象,返回Point*类型的指针;

  3. 析构函数私有化

  4. 通过静态成员函数完成堆对象的回收。

    cpp 复制代码
    #include <iostream>
    using std::cout;
    using std::endl;
    
    class Singleton{
    private:
        //数据成员变量
        static Singleton * pInstance;
    
        Singleton(){
            //构造函数私有化
        }
    
        //析构函数也私有化
        ~Singleton(){
    
        }
    
    public:
        static Singleton * getInstance(){
            if(pInstance == NULL){
                pInstance = new Singleton();
            }
            return pInstance;
        }
    
        static void destroy(){
            if(pInstance != NULL){
                delete pInstance;
                pInstance = NULL;
            }
        }
    };
    
    //静态变量初始
    Singleton * Singleton::pInstance = NULL;
    
    void test(){
        Singleton * ps1 = Singleton::getInstance();
    
        Singleton * ps2 = Singleton::getInstance();
    
        cout << "s1=" << &(*ps1) << endl;
        cout << "s2=" << &(*ps2) << endl;
    
        cout << "pointer s1=" << &ps1 << endl;
        cout << "pointer s2=" << &ps2 <<endl;
    
        
        Singleton::destroy();
        Singleton::destroy();
    }
    
    int main()
    {
        test();
        cout << "Hello world" << endl;
        return 0;
    }

单例模式的应用场景

1、有频繁实例化然后销毁的情况,也就是频繁的 new 对象,可以考虑单例模式;

2、创建对象时耗时过多或者耗资源过多,但又经常用到的对象;

3、当某个资源需要在整个程序中只有一个实例时,可以使用单例模式进行管理(全局资源管理)。例如数据库连接池、日志记录器等;

4、当需要读取和管理程序配置文件时,可以使用单例模式确保只有一个实例来管理配置文件的读取和写入操作(配置文件管理);

5、在多线程编程中,线程池是一种常见的设计模式。使用单例模式可以确保只有一个线程池实例,方便管理和控制线程的创建和销毁;

6、GUI应用程序中的全局状态管理:在GUI应用程序中,可能需要管理一些全局状态,例如用户信息、应用程序配置等。使用单例模式可以确保全局状态的唯一性和一致性。

C++字符串

有了类与对象的知识基础之后,我们可以来认识一下应用广泛的两种对象------C++字符串、C++动态数组。先来看看C++字符串:

字符串处理在程序中应用广泛, C 风格字符串是以 '\0' (空字符)来结尾的字符数组,在C++中通常用const char * 表示,用" "包裹的认为是C风格字符串。

对字符串进行操作的 C 函数定义在头文件 <string.h> 或 < cstring > 中。常用的库函数如下:

cpp 复制代码
//字符检查函数(非修改式操作)
size_t strlen(const char *str);//返回str的长度,不包括null结束符
//比较lhs和rhs是否相同。lhs等于rhs,返回0; lhs大于rhs,返回正数; lhs小于rhs,返回负数
int strcmp(const char *lhs, const char *rhs);
int strncmp(const char *lhs, const char *rhs, size_t count);
//在str中查找首次出现ch字符的位置;查找不到,返回空指针
char *strchr(const char *str, int ch);
//在str中查找首次出现子串substr的位置;查找不到,返回空指针
char *strstr(const char* str, const char* substr);
//字符控制函数(修改式操作)
char *strcpy(char *dest, const char *src);//将src复制给dest,返回dest
char *strncpy(char *dest, const char *src, size_t count);
char *strcat(char *dest, const char *src);//concatenates two strings
char *strncat(char *dest, const char *src, size_t count);

在使用时,程序员需要考虑字符数组大小的开辟,结尾空字符的处理,使用起来有诸多不便。

c 复制代码
void test0()
{
	char str[] = "hello,";
	char * pstr = "world";
	//求取字符串长度
	printf("%d\n", strlen(str));
    
	//字符串拼接
	char * ptmp = (char*)malloc(strlen(str) + strlen(pstr) + 1);
	strcpy(ptmp, str);
	strcat(ptmp, pstr);
	printf("%s\n", ptmp);
    
	//查找子串
	char * p1 = strstr(ptmp, "world");
	free(ptmp);
}

C++风格字符串

C++ 提供了 std::string (后面简写为 string )类用于字符串的处理。 string 类定义在 C++ 头文件< string > 中,注意和头文件 < cstring > 区分, < cstring > 其实是对 C 标准库中的 <string.h> 的封装,其定义的是一些对 C 风格字符串的处理函数。

尽管 C++ 支持 C 风格字符串,但在 C++ 程序中最好还是不要使用它们。这是因为 C 风格字符串不仅使用起来不太方便,而且极易引发程序漏洞,是诸多安全问题的根本原因。与 C 风格字符串相比, string不必担心内存是否足够、字符串长度,结尾的空白符等等。 string 作为一个类出现,其集成的成员操作函数功能强大,几乎能满足所有的需求。从另一个角度上说,完全可以把 string 当成是 C++ 的内置数据类型,放在和 int 、 double 等内置类型同等位置上。

std::string标准库提供的一个自定义类类型basic_string,string 类本质上其实是 basic_string 类模板关于 char 型的实例化。使用起来不需要关心内存,直接使用即可。

string的构造

basic_string的常用构造------查看C++参考文档(cppreference-zh-20211231.chm)

cpp 复制代码
basic_string(); //无参构造

basic_string( size_type count,
              CharT ch,
              const Allocator& alloc = Allocator() );  //count + 字符

basic_string( const basic_string& other,
              size_type pos,
              size_type count,
              const Allocator& alloc = Allocator() ); //接收一个basic_string对象

basic_string( const CharT* s,
              size_type count,
              const Allocator& alloc = Allocator() ); //接收一个C风格字符串

basic_string是一个模板类,它是std::string的基类。这里涉及到后面继承与模板的知识,现在我们掌握使用方法即可。

在创建字符串对象时,我们可以直接使用std::string作为类名,如std::string str = "hello". 这是因为C++标准库已经为我们定义了std::string这个类型的别名。

string对象常用的构造

cpp 复制代码
string();//无参构造函数,生成一个空字符串
string(const char * rhs);//通过c风格字符串构造一个string对象
string(const char * rhs, size_type count);//通过rhs的前count个字符构造一个string对象
string(const string & rhs);//拷贝构造函数
string(const string & rhs,size_t pos, size_t count);//通过string对象的一部分创建新的string
string(size_type count, char ch);//生成一个string对象,该对象包含count个ch字符
string(InputIt first, InputIt last);//以区间[first, last)内的字符创建一个string对象
cpp 复制代码
void test(){
    string str;
    string str2("hello");
    string str3("world", 3);
    string str4(str2);
    string str5(str2, 0, 3);
    string str6(5, 'a');
    cout << str << endl;
    cout << str2 << endl;
    cout << str3 << endl;
    cout << str4 << endl;
    cout << str5 << endl;
    cout << str6 << endl;

    //接收头尾部迭代器,目前阶段就把迭代器理解成指针
    char arr[6] = "hello";
    string str7(arr, arr + 4);
    cout << str7 << endl;

    //begin()返回的是首迭代器
    //end()返回的是尾迭代器
    string str8(str2.begin(), --str2.end());
    cout << str8 << endl;
}
cpp 复制代码
void test2(){
    //迭代器详解
    char arr[6] = "hello";
    string str1(arr, arr + 5);
    cout << str1 << endl;
    //迭代器并不等同于指针,begin()返回的是string对象的首迭代器
    string::iterator it = str1.begin();
    //也可以使用auto来进行类型的自动推导
    auto it2 = str1.end();
    //迭代器的解引用操作
    cout << *it << endl;
    string str2(it, --it2);
    cout << str2 << endl; 
}

此外还可以用拼接的方式构造string

原理:basic_string对加法运算符进行了默认重载(后续会学到),其本质是通过+号进行计算后得到一个basic_string对象,再用这个对象去创建新的basic_string对象

cpp 复制代码
//采取拼接的方式创建字符串
//可以拼接string、字符、C风格字符串
	string str;
    string str2("hello");
    string str3 = str + str2;
    string str4 = str2 + "," + str3;
    string str5 = str2 + ",world";
    cout << str << endl;
    cout << str2 << endl;
    cout << str3 << endl;
    cout << str4 << endl;
    cout << str5 << endl;
string的常用操作
cpp 复制代码
const CharT* data() const;	//版本更新一些
const CHarT* c_str() const; //获取出C++字符串保存的字符串内容,以C风格字符串作为返回值

bool empty() const; //判空

size_type size() const;//获取字符数
size_type length() const;

void push_back(CharT ch);  //字符串结尾追加字符

//在字符串的末尾添加内容,返回修改后的字符串
basic_string& append(size_type count, CharT ch); //添加count个字符
basic_string& append(const basic_string& str);  //添加字符串
basic_string & append(const basic_string& str,  //从原字符串末尾添加str从pos位置的count个字符
                     size_type pos,size_type count);
basic_string& append(const charT* s);      //添加C风格字符串

//查找子串
size_type find( const basic_string& str, size_type pos = 0 ) const;  //从C++字符串的pos位开始查找C++字符串str
size_type find( CharT ch, size_type pos = 0 ) const;      //从C++字符串的pos位开始查找字符ch
size_type find( const CharT* s, size_type pos, size_type count ) const;  //从C++字符串的pos位开始,去查找C字符串s的前count个字符

实践一下string的各种操作,体会C++字符串的遍历。

cpp 复制代码
void test4(){
    string str("hello");
    string str2("world");

    cout << str.data() << endl;
    cout << str.c_str() << endl;
    cout << str.empty() << endl;
    cout << str.size() << endl;
    cout << str.length() << endl;
    str.push_back('a');
    cout << str << endl;
    cout << str.append(2, 'h') << endl;
    cout << str.append(str2) << endl;
    cout << str.append(str2, 1, 3) << endl;
    cout << str.append("c++") << endl;
    cout << str.find(str2, 0) << endl;
    cout << str.find('h', 0) << endl;
    cout << str.find("cpp", 0, 1) << endl;
}

补充:两个basic_string字符串比较,可以直接使用==等符号进行判断

原理:basic_string对==运算符进行了默认重载(后续会学到)

cpp 复制代码
//非成员函数
bool operator==(const string & lhs, const string & rhs);
bool operator!=(const string & lhs, const string & rhs);
bool operator>(const string & lhs, const string & rhs);
bool operator<(const string & lhs, const string & rhs);
bool operator>=(const string & lhs, const string & rhs);
bool operator<=(const string & lhs, const string & rhs);
cpp 复制代码
	string str("hello");
    string str2("helloworld");
    cout << (str == str2) << endl;
    cout << (str != str2) << endl;
    cout << (str < str2) << endl;
    cout << (str > str2) << endl;
    cout << (str <= str2) << endl;
    cout << (str >= str2) << endl;
string的遍历(重点)

string实际上也可以看作是一种存储char型数据的容器,对string的遍历方法是之后对各种容器遍历的一个铺垫。

(1)通过下标遍历

string 对象可以使用下标操作符\[\]进行访问。

cpp 复制代码
//使用下标遍历
for(size_t idx = 0; idx < str.size(); ++idx){
    cout << str[idx] << " ";
}
cout << endl;

需要注意的是操作符\[\]并不检查索引是否有效,如果索引超出范围,会引起未定义的行为。而函数 at() 会检查,如果使用 at()的时候索引无效,会抛出 out_of_range 异常

cpp 复制代码
string str("hello");
cout << str.at(4) << endl;  //输出o
cout << str.at(5) << endl;  //运行时抛出异常

(2)增强for循环遍历

针对容器,可以使用增强for循环进行遍历其中的元素。增强for循环经常和auto关键字一起使用,auto关键字可以自动推导类型。

cpp 复制代码
for(auto & ch : str){  //只要是str中的元素,就一一遍历
    cout << ch << " ";
}
cout << endl;

(3)迭代器方式进行遍历

string字符串利用连续空间存储字符,所以可以利用地址遍历。这里我们提出一个概念------迭代器。迭代器可以理解为是广义的指针。它可以像指针一样进行解引用、移位等操作。迭代器是容器用来访问元素的重要手段,容器都有相应的函数来获取特定的迭代器(此处可以简单理解为指向特定元素的指针)。在STL的阶段,我们会对迭代器进行更详细的讲解,现在我们只需要掌握它的基本使用即可。

begin函数返回首迭代器(指向首个元素的指针);

end函数返回尾后迭代器(指向最后一个元素的后一位的指针)

如指针一样,迭代器也有其固定的形式。

cpp 复制代码
//某容器的迭代器形式为 容器名::iterator
//此处auto推导出来it的类型为string::iterator
auto it = str.begin();
while(it != str.end()){
    cout << *it << " ";
	++it;
}
cout << endl;

C++动态数组

C++中,std::vector(向量)是一个动态数组容器,能存放任意类型的数据。

其动态性体现在以下几个方面:

  1. 动态大小:std::vector 可以根据需要自动调整自身的大小。它在内部管理一个动态分配的数组,可以根据元素的数量进行自动扩容或缩减。当元素数量超过当前容量时,std::vector 会重新分配内存,并将元素复制到新的内存位置。这使得 std::vector 能够根据需要动态地增长或缩小容量,而无需手动管理内存。
  2. 动态插入和删除:std::vector 允许在任意位置插入或删除元素,而不会影响其他元素的位置。当插入新元素时,std::vector 会自动调整容量,并将后续元素向后移动以腾出空间。同样地,当删除元素时,std::vector 会自动调整容量,并将后续元素向前移动以填补空缺。
  3. 动态访问:std::vector 提供了随机访问元素的能力。可以通过索引直接访问容器中的元素,而不需要遍历整个容器。这使得对元素的访问具有常数时间复杂度(O(1)),无论容器的大小如何。

vector的构造

vector常用的几种构造形式:

(1)无参构造,仅指明vector存放元素的种类,没有存放元素;

cpp 复制代码
vector <int> numbers;

(2)传入一个参数,指明vector存放元素的种类和数量,参数是存放元素的数量,每个元素的值为该类型对应的默认值;

cpp 复制代码
vector<long> numbers2(10); //存放10个0

(3)传入两个参数,第一个参数为vetor存放元素的数量,第二个参数为每个元素的值(相同);

cpp 复制代码
vector<long> numbers2(10,20); //存放10个20

(4)通过列表初始化vector,直接指明存放的所有元素的值

cpp 复制代码
vector<int> number3{1,2,3,4,5,6,7};

(5)迭代器方式初始化vector,传入两个迭代器作为参数,第一个为首迭代器,第二个为尾后迭代器;

cpp 复制代码
vector<int> number3{1,2,3,4,5,6,7};
vector<int> number4(number3.begin(),number3.end() - 3);//推测一下,number4中存了哪些元素

int arr[5] = {1, 2, 3, 4, 6};
vector<int> number5(arr, arr + 5)

vector的常用操作

cpp 复制代码
iterator begin();  //返回首位迭代器
iterator end();  //返回尾后迭代器

bool empty() const; //判空

size_type size() const; //返回容器中存放的元素个数
size_type capacity() const; //返回容器容量(最多可以存放元素的个数)

void push_back(const T& value); //在最后一个元素的后面再存放元素
void emplace_back( Args&&... args );//在最后一个元素的后面构造一个新的对象,只需要传递需要创建对象的构造函数参数即可

void pop_back(); //删除最后一个元素
void clear(); //清空所有元素,但不释放空间
void shrink_to_fit();  //释放多余的空间

void reserve(size_type new_cap);//申请空间,不存放元素
cpp 复制代码
void test(){
    vector<int> v{1, 2, 3, 4};
    //迭代器的两种写法,均正确
    vector<int>::iterator it1 = v.begin();
    auto it2 = v.end();
    while(it1 != it2){
        cout << *it1 << " ";
        ++it1;
    }
    cout << endl;
    cout << v.empty() << endl;
    cout << v.size() << endl;
    cout << v.capacity() << endl;
    v.push_back(5);
    cout << v.size() << endl;
    cout << v.capacity() << endl;
    v.pop_back();
    cout << v.size() << endl;
    cout << v.capacity() << endl;
    v.shrink_to_fit();
    cout << v.size() << endl;
    cout << v.capacity() << endl;
    v.clear();
    cout << v.size() << endl;
    cout << v.capacity() << endl;
    v.reserve(10);
    cout << v.size() << endl;
    cout << v.capacity() << endl;
}

vector的遍历:三种遍历方式

cpp 复制代码
void test2(){
    //下标方式遍历
    vector<int> nums = {1, 2, 3, 4, 5};
    for(size_t idx = 0; idx < nums.size(); ++idx){
        cout << nums[idx] << " ";
    }
    cout << endl;

    //增强for循环遍历
    for(auto & ele : nums){
        cout << ele << " ";
    }
    cout << endl;

    //迭代器方式遍历
    auto it = nums.begin();
    while(it != nums.end()){
        cout << *it << " ";
        ++it;
    }
    cout << endl;
}

vector不仅能够存放内置类型变量,也能存放自定义类型对象和其他容器

试着完成一下:

  • vector中存放自定义类型Point的对象并遍历

现需要Point类有完整定义,创建vector时 : vector< Point> nums;

cpp 复制代码
	vector<Point> nums;
    nums.reserve(10);//注释一下这行代码比较一下前后日志
    nums.push_back(Point(1, 2));
    cout << nums.size() << endl;
    cout << nums.capacity() << endl;
    nums.push_back(Point(3, 4));
    cout << nums.size() << endl;
    cout << nums.capacity() << endl;
    nums.push_back(Point(5, 6));
    cout << nums.size() << endl;
    cout << nums.capacity() << endl;
    nums.push_back(Point(7, 8));
    cout << nums.size() << endl;
    cout << nums.capacity() << endl;

使用emplace_back之后的效果:

cpp 复制代码
void test4(){
    vector<Point> nums;
    nums.reserve(10);
    nums.emplace_back(1, 2);
    cout << nums.size() << endl;
    cout << nums.capacity() << endl;
    nums.emplace_back(3, 4);
    cout << nums.size() << endl;
    cout << nums.capacity() << endl;
    nums.emplace_back(5, 6);
    cout << nums.size() << endl;
    cout << nums.capacity() << endl;
    nums.emplace_back(7, 8);
    cout << nums.size() << endl;
    cout << nums.capacity() << endl;
}
  • vector中存放string并遍历
cpp 复制代码
void test5(){
    vector<string> strs;
    strs.push_back("hello");
    strs.push_back("world");
    strs.push_back("c++");
    strs.push_back("python");
}
  • vector中存放vector

    cpp 复制代码
    void test6(){
        vector<vector<Point>> nums;
        nums.push_back(vector<Point>(1, Point(1, 2)));
        nums.push_back(vector<Point>(1, Point(3, 4)));
    }

vector的动态扩容

当vector存放满后,仍然追加存放元素,vector会进行自动扩容。

cpp 复制代码
vector<int> numbers;
cout << numbers.size() << endl;
cout << numbers.capacity() << endl;

numbers.push_back(1);
cout << numbers.size() << endl;
cout << numbers.capacity() << endl;

numbers.push_back(1);
cout << numbers.size() << endl;
cout << numbers.capacity() << endl;

numbers.push_back(1);
cout << numbers.size() << endl;
cout << numbers.capacity() << endl;
//...

多追加一些元素,看看元素数量和容器容量的关系,思考一下vector的容量是如何增长的呢?

GCC发现vector是2倍的容量扩容机制:当vector存满后再添加新的元素,容量就会变成2倍,把新的元素存入其中。

VS上是1.5倍的扩容

------ 很多技术上具体的实现,在不同的平台上细节不同。C++标准给出功能的要求,各个编译器只需要实现此功能。

其工作步骤如下:

(1)开辟空间

(2)Allocator分配(后面STL阶段学习)

(3)复制,再添加新元素

(4)回收原空间

vector的底层实现(重点*)

利用sizeof查看vector对象的大小时,发现无论存放的元素类型、数量如何,其大小始终为24个字节(64位环境)

因为vector对象是由三个指针组成

_start指向当前数组中第一个元素存放的位置

_finish指向当前数组中最后一个元素存放的下一个位置

_end_of_storage指向当前数组能够存放元素的最后一个空间的下一个位置

可以推导出

size() : _finish - _start

capacity(): _end_of_storage - start

文章大纲

相关推荐
倒流时光三十年3 小时前
Java 内存模型(JMM)通俗解释
java·开发语言
-To be number.wan3 小时前
数据库系统 | 数据库安全与完整性
数据库·学习
码兄科技3 小时前
Java AI智能体开发实战:从零构建企业级智能应用指南
java·开发语言·人工智能
czysoft3 小时前
se被限速
科技·学习·it·技术·魔法·先进·领先
zh路西法3 小时前
【现代控制理论与卡尔曼滤波】从状态空间到Python仿真实现
开发语言·python
Evand J4 小时前
【论文复现】MATLAB例程,存在测距误差的WSN无锚点分布式自定位,《WSN中存在测距误差的无锚点分布式自定位方法》
开发语言·分布式·matlab·定位·导航·wsn
techdashen4 小时前
kTLS 进入 rustls 组织:把 TLS 的数据面交给内核
开发语言·php
子不语1804 小时前
从0开始学习S7-1200+ET200SP(3)——两台S7-1200通过TCP连接
网络协议·学习·tcp/ip
Lhappy嘻嘻4 小时前
Java 并发编程(六)|并发进阶高频:CAS、锁升级
java·开发语言