C++从零开始(七):面对对象(上)快速入门
🌟《C++从零开始》 系列,开始更新中…
五、面对对象入门
C++和C一大主要区别,便是引入了入面对对象。
为什么需要引入面对对象?它有什么好处?
5.1 为什么需要面对对象?
5.1.1 从面向过程说起
在传统编程中,数据和处理该数据的函数是单独的实体,它们组合在一起以产生所需的结果。
也就是面向过程通常做法。
但是由于面向过程的这种分离,传统编程通常不能提供非常直观的现实表示:
1 | goToHome(you); |
在这个例子中,行为主体you
和行为goToHome
被分隔了:you
被当做单独的数据实体,行为goToHome
被当做单独的函数实体。
面对对象则提供了更直观的表示能力。
因为面对对象将对象(行为主体)、属性和行为封装到独立、可重置的类中。 而这些属性和行为往往也被认为是密不可分的。
1 | you.goToHome(); |
显然,这让对象(you
)是谁,以及正在调用什么行为(goToHome
)更清楚了。我们不再专注于编写函数,而是专注于定义行为集的对象。
这,也就是面对对象。
得益于这种专注于对象的行为,面对对象带来以下几个好处:
- 代码模块化
- 更容易理解、直观
- 可重用性高
现在我们来举一个更具体的例子进行对比:分别使用面向过程和面向对象的方式,打印年/月/日到屏幕上。
-
面对过程
按照面对过程的思想,
Date
当做独立变量组织了“年/月/日”,“打印”则当做独立行为(函数)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Date
{
int year {};
int month {};
int day {};
};
void print(const DateStruct& date)
{
std::cout << date.year << '/' << date.month << '/' << date.day;
}
int main()
{
Date today { 2022, 1, 24 };
today.day = 25;
print(today); // 2022/1/25
return 0;
} -
面向对象
面向对象则将
Date
视作行为主体(对象),“年/月/日”被当做其成员变量,“打印”则是其成员函数。此时明确了行为主体对象Date
,建立了“年/月/日”和行为“打印”的关联。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Date // class关键字
{
public:
int _year {}; // 成员变量
int _month {};
int _day {};
void print() // 成员函数
{
std::cout << m_year << '/' << m_month << '/' << m_day;
}
};
int main()
{
Date today { 2022, 1, 24 };
today._day = 25;
// 主体对象.行为()
today.print(); // 2022/1/25
return 0;
}
相信你对面对对象有了更深刻的理解,现在来更系统地了解面对对象基本组成吧。
5.1.2 面对对象组成
成员组成
其实,前面我们已经展示了面对对象最基本的组成:成员变量和成员函数。
面对对象还可以有成员类型 、嵌套类型。
-
成员类型
成员类型规定这个类基本数据类型,这样我们只需更新类型别名,而不必替换基本类型。
这样说有点难以理解,举个例子:
vector
类规定的类型size_type
。size_t
归根究底就是类型long unsigned int
别名也就是说下面两种写法是等价的。
1
2std::vector<int>::size_type x; // 等价:unsigned long int x
std::cout<<typeid(x).name()<< " "; // m,表示unsigned long (int)stl_vector.h
变量基本都是使用的这种类型别名声明,比如我们熟悉的size()
方法声明:回到前面:这样做有什么好处?
当我们想修改vector使用的基本类型,只需要将修改类型别名就行:
1
2// #define __SIZE_TYPE__ long unsigned int
-
嵌套类型
这个很好理解,只要:类类型、结构体类型、枚举类型在类中声明,就可以称其为嵌套类型。
以枚举类型嵌套为例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Fruit
{
public:
// 此时枚举类型声明在类Fruit中
// 所以枚举类型此时是个嵌套类型
enum FruitType
{
apple,
banana,
cherry
};
private:
FruitType m_type {};
public:
Fruit(FruitType type) :m_type { type }
{}
FruitType getType() const { return m_type; }
};
int main()
{
// 注意枚举类型不限制作用范围
Fruit apple { Fruit::apple }; // 调用构造函数Fruit(FruitType type)
apple.getType() ; // FruitType::apple
return 0;
}
访问控制符
类成员访问控制符实现了类的封装,C++访问符包括:
- pprivate:私有成员,该成员仅在类内可以被访问,在类体外(包括派生类)是隐藏状态;
- public:公有成员,该成员在类内、类外也都可以被访问,是类对外提供的可访问接口。
在继承中,我们引入了第三种修饰符:
-
protected:保护成员,和私有成员类似,不过在派生类可以被访问。
最佳实践:从降低类的耦合性来说,优先考虑private而不是protect。
Base
类中三种成员访问权限也可以用下图形象表示:
具体实例:
1 | class Base |
下面这个例子,进一步说明protected访问说明符作用。
涉及到7.1 继承一些知识:
- 派生类由两个部分组成:继承的基类副本部分,派生类自身扩展的部分。
- 派生类实例化时,必须先调用基类构造函数初始化基类副本部分,再调用派生类构造函数初始化派生类部分。
假设我们有一个基类A,我们希望它:
-
A不能被实例化;
-
但能被继承,派生类类可以实例化。
为了满足第一点,我们想到可以将A的构造函数声明为private。
但这样就无法满足第二点:派生类无法实例化,因为派生类无法调用基类副本的私有构造函数。
1 | class A |
这个时候需要将A的构造函数声明为protected:
1 | protected: |
这样派生类就可以调用基类的构造函数进行初始化基类副本了。
最佳实践:类成员变量是私有的,类成员函数是公开的。
为什么这么建议?
-
对于普通人而言,遥控器就是一个简单的包括若干的按钮界面,用户只关心按钮按下的效果,但不关心具体是如何和电视通信。类似的,踩下油门踏板也不需要关心内燃机如何使得车轮转动。
-
这其实就是接口分离的思想:降低对象复杂性,使得我们在不了解对象如何工作的前提下,可以去使用对象。
三大特性之封装
结构分离思想也就是面对对象三大特性:封装,的重要组成部分。
怎么实现封装?
在 C++ 中,我们通过访问说明符实现封装。类的所有成员变量都是私有的(隐藏实现细节),大多数成员函数都是公开的(向用户公开接口)。
封装具体有什么好处?
- 降低对象复杂性,同前;
- 保护数据,类中定义的成员变量都是全局变量,意味着它可以被类任何对象修改,这很危险。使用private修饰避免了这一点,访问数据只能公共接口函数;
- 更容易调试,因为每个人都只能通过同一个公共函数修改某个值,这样值不正确时很容易进行debug;
- 更容易修改,…
另外两大特性将会在后面章节介绍:
- 继承:让某种类型(派生类)对象获得另一个类型对象(基类)的属性和方法。
- 多态:C++ 多态指相同对象收到不同消息,或不同对象收到相同消息时,产生不同的实现动作。
类和结构体这么像?
看到这你不禁想吐槽,你说类这些功能:
- 嵌套、变量、函数
结构体都支持啊?甚至你还知道更多:
- 结构体支持继承、多态…
是的,在C++中类和结构体除了默认访问符外太像了,以至于很多开发人员认为这是个错误的设计。
确实,这的确有点不合理。根据前人经验——
最佳实践:将 struct 用于纯数据结构,对同时具有数据和函数的对象使用 class。
也就是说,建议struct声明为POD(Plain Old Data)类型进行使用。
如果struct/class/enum等,只定义了常规数据类型(不含有自定义数据类型),不使用封包或者其它面对对象特征,那么就是POD类型。
1 | struct myDate // POD类型 |
5.2 构造函数及初始化
当一个类/结构体的所有成员都是公共成员时,我们可以直接列表初始化类/结构体:
1 | class Foo |
但是前面我们也强调过,成员变量一般声明为private。
此时如何初始化private修饰的成员变量?
-
如果私有成员变量仅仅是需要零值初始化,在类成员变量使用列表初始化
{}
即可1
2
3
4
5
6class Foo
{
public:
int m_x{} ; // 声明时列表初始化
int m_y{} ; // 声明时列表初始化
}; -
如果私有成员变量需要指定值,使用构造函数可以指定初始化类的成员变量。
构造函数有特定命名规则:
- 和类同名
- 没有返回类型
从默认构造函数开始吧。
5.2.1 默认构造函数、初始化及类分配
默认构造函数
不带参数(或所有参数都具有默认值)的构造函数称为默认构造函数。
【注】没有一个构造函数被显式声明时,编译器会隐式声明一个默认构造函数。
1 |
|
执行Fraction frac{}
时,会创建类Fraction
的实例 frac
,然后调用默认构造函数初始化对象frac
。
有时候你也会看到下面初始化方式:
1 | Fraction frac; |
这二者有什么不同吗?
列表初始化和直接初始化
Fraction frac{}
通常称为列表初始化,而 Fraction frac / Fraction frac()
被称为直接初始化。
它们都会在执行时调用相关构造函数,但是{}
方式还可能会使得编译器调用构造函数之前,对成员变量进行零值初始化。
验证一下。
1 |
|
输出:
1 | 0 |
可见列表初始化方式确实对成员变量进行了零值初始化,而进行直接初始化存的是垃圾值(这一点我们在1.2.4节也总结对比过,此时成员变量值都是默认初始化的垃圾值)。
类静态分配和动态分配
在这之前我们见到类的对象都是静态分配的:
1 | Fraction frac2; |
编译器静态建立一个类对象,在栈空间中分配内存,所以该对象内存不需要我们管理,编译器负责释放。
我们还可以进行动态分配(new方式):
1 | Fraction* pfrac = new Fraction{}; |
此时编译器动态建立一个类对象,在堆空间上分配内存,需要我们使用delete显式删除管理内存。
类如何实现只能静态分配或只能动态分配对象 ?
- 只能静态分配:把new、delete运算符重载为private;
- 只能动态分配:把构造设为private/protected属性,类静态函数分配对象。
下面是实例。
1 |
|
5.2.2 转换构造函数
C++ 会将任何构造函数视为隐式转换运算符,在4.X.1 用户自定义隐式转换有过详细介绍。
简单回忆一下。
1 |
|
具体过程涉及到复制初始化:
1 | dog d = dogname; // ok |
- 表达式从右到左,构造函数
dog(string)
作为转换构造函数 ,编译器先创建dog
临时匿名对象,使得=
两边操作类型一致; - 然后
dog(string)
初始化匿名对象;
至此,完成了string
类型隐式转换为dog
。接下来是复制初始化相关过程:
- 编译器创建对象
d
; - 调用复制构造函数,用匿名对象成员值复制初始化对象
d
。
初始化相关工作细节&原理请看下文。
5.2.3 再谈初始化🌟
回顾一下三种初始化方式。
下面是列表初始化{}
初始化(可能会对非静态成员进行零值初始化)。
1 | class Date |
也可以使用直接初始化方式:
1 | Date date(25); // 直接初始化 |
甚至你还可以使用复制初始化:
1 | Date date1 = 25; // 1,还进行了隐式转换,等价于Date date1 =Date(25) |
=
左侧是被初始化的对象,因为=
左右两侧操作类型要相等,所以=
右侧会先生成一个(匿名)对象,对左侧对象成员进行(复制)初始化。
但是,我们建议避免使用类进行复制初始化,因为一般效率较低且不安全。
为什么会这样?复制初始化是怎么工作的?
复制初始化
先说说直接初始化是如何工作的:
注意,构造函数没有创建对象,只进行初始化。
1 | Date date(25); // 直接初始化 |
- 编译器创建一个对象
date
; - 初始化static静态成员;
- 最后调用匹配的构造函数
Date(int day,int year=2022,int month=1)
初始化date
的非静态成员。
列表初始化类似,只是在调用构造函数前,可能还会对非静态成员进行零值初始化。
对于复制初始化,还涉及到编译器为我们隐式声明的复制构造函数。
一个空类默认会添加以下函数,包括复制构造函数:
1 | // 缺省构造函数 |
其中复制构造函数完整定义为:
1 | // 成员列表方式,下节介绍 |
回到复制初始化:
1 | Date date = Date(25); // 复制初始化 |
-
为了
=
两侧操作类型一致,编译器首先创建一个匿名对象,为方便记为tmp
;注:
Date(25)
是一种匿名对象初始化方式,它隐藏了对象的名字,等价于:1
Date tmp(25); // 匿名对象假设为tmp
-
执行
Date(25)
,(匿名对象中的)构造函数Date(int day,int year=2022,int month=1)
初始化临时对象; -
编译器创建一个对象
date
; -
最后编译器再调用(对象
date
中)拷贝构造函数,将临时匿名对象tmp
作为拷贝构造函数参数 ,date
每个成员值复制临时匿名对象成员。
只要对象在声明时用了另一个对象(可能是编译器创建的匿名对象)进行初始化,就会触发复制构造函数,也就是复制初始化。
另一方面,我们不要执着于表象,=不重要:复制初始化核心在于调用复制构造函数,而不是调用普通构造函数初始化。
下面这种方式虽然没有=
,看起来像直接初始化。但Date date5
声明时使用了对象date1
进行初始化,所以依旧是复制初始化。
1 | Date date5(date1); // 复制初始化 |
显然,这种“复制”效率可想而言是低下的,因为它还可能会创建临时匿名对象。
不过标准规定,为了提高效率,允许编译器在合适的地方进行优化,跳过创建临时匿名对象这一步,直接调用构造函数构造要创建的对象。
下面4个复制初始化哪些会执行复制构造函数?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27 class Date
{
private:
int m_year{};
int m_month{};
int m_day{};
public:
Date() = default;
Date(int day,int month=1,int year=2022)
{
m_year=year;m_month=month;m_day=day;
std::cout<< "构造函数被调用"<<std::endl;
};
Date(Date const& tmp): m_year(tmp.m_year),m_month(tmp.m_month),m_day(tmp.m_day)
{
std::cout<< "复制构造函数被调用"<<std::endl;
}
};
int main()
{
// 都是复制初始化
Date date1 = 25; // 1
Date date2 = Date(25); // 2
Date date3 = Date{25}; // 3
Date date4 = date3; // 4
}结果可能会让你有些惊讶:除了方式
4
,其余都没用调用复制构造函数:
1
2
3
4
5
6 [root@roy-cpp test]# g++ -std=c++11 test.cpp -o test.out
[root@roy-cpp test]# ./test.out
构造函数被调用
构造函数被调用
构造函数被调用
复制构造函数被调用也就是除了方式
4
,编译器都进行了优化(1、2、3生成了匿名对象,编译器可以跳过)。所以方式1、2、3等价于:
1
2
3 Date date1(25);
Date date2(25);
Date date3(25);
不仅如此,复制初始化效率低的同时还并不安全。
因为这种拷贝方式是浅拷贝,如果存在指针也只是复制其值,不会复制其指向的区域。
深拷贝和浅拷贝
浅拷贝会带来什么问题?
浅拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现double free。
1 | class Test |
编译执行出错:
给各位看官分析下原因:
-
Test ob1
构造类对象ob1,这是调用了构造函数,为ob1.p分配了内存空间; -
Test ob2 = ob1
,调用复制构造函数构造类对象ob2 = ob1; -
main()函数执行完毕,全局函数的运行周期结束,系统回收内存(析构函数除了显示delete执行,对象离开作用范围会自动执行):
- 先调用ob1的析构函数,将ob1.p指向的内存释放;
- 再调用ob2的析构函数,将ob2.p指向的内存释放。
-
但是由于ob2.p的内存已经在上一步被释放,所以造成了double free。
解决这个问题也很简单:
-
禁止使用拷贝构造函数:
使用delete关键字禁止(推荐):
1
2// C++11新特性delete
Test(Test &ob) = delete;或声明为private:
1
2private: // 尝试调用私有成员函数会出错
Test(Test &ob){...};让使用者无法使用默认构造函数。
-
深拷贝:
自己显示声明复制构造函数,对成员变量实现深拷贝(复制指针内存区域值)。
深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21class Test
{
int *p{nullptr};
public:
Test()
{
p = new int[4]();
}
~Test(){delete p;}
Test(const Test& other) // 实现深拷贝
{
if(p) // p不空
delete p;
if(other.p) // other.p不空
{
p = new int[4]();
for(int i = 0; i< 4 ; i++)
p[i] = other.p[i];
}
}
};
成员初始化列表
怎么初始化类私有成员?
看过前面文章的你肯定脱口而出:构造函数呀!
还有吗?对了,还有列表初始化。就像这样:
1 | class Date |
但如果私有成员被const修饰呢?
列表初始化依旧可以:
1 | const int m_year{ 1900 }; |
但是如果用户想使用构造函数初始化:
1 | // 带参数的构造函数,存在默认值 |
出错:
1 | const int Date::m_year |
等价于:
1 | const int m_year{ 1900 }; |
这种情况就需要用到成员初始化列表。
成员初始化列表可以让用户初始化私有变量,包括常量。
成员初始化列表在构造函数后接:
进行初始化。
1 |
|
成员初始化还可以初始化数组成员。
在 C++11之前,只能通过成员初始化列表将数组成员归零:
1 | class Something |
现在还可以赋值:
1 | class Something |
最后注意,初始化列表中的变量是按照类声明顺序进行初始化,而不是列表指定的顺序。
成员列表初始化更快?
准确来说:
- 对于内置数据类型,复合类型(指针,引用),成员初始化列表和构造函数体内进行性能没有什么差别;
- 对于用户自定义类型(类类型),成员列表初始化会快很多。
这是因为对于用户自定义类型,使用成员列表初始化会少使用一次构造函数。
1 | // 准备好classA |
理解这个问题需要一点前置知识:
-
什么时候会使用复制构造函数?声明一个对象时用另外一个已存在的对象进行初始化时,会调用复制构造函数。
1
2
3A a;
A b = A(); // 编译器生成匿名,然后调用复制构造函数初始b(不过编译器会优化)
A b = a; // 已存在的对象a调用复制构造函数初始化b -
什么时候会使用赋值运算符?
重载
=
赋值运算符在下章介绍。当一个已存在的对象用另外一个已存在的对象进行赋值时。
1
2
3A a;
A b;
b = a; // a,b之前就已存在,此时a调用赋值运算符函数对b进行赋值 -
类对象成员初始化动作早于构造函数体执行前,和成员列表同时发生。
有了这些基础知识,我们来分析为什么成员列表初始化更快。
-
成员列表初始化方式:
1
2
3
4
5
6
7
8
9
10
11
12
13class classC
{
public:
classC(const classA& a) : mA(a) {} // 对mA进行成员列表初始化
private:
classA mA;
};
int main()
{
classA class_a;
classC c(class_a);
}输出:
1
2
3
4classA()
copy classA()
~classA()
~classA()代码11、12行处等价于:
1
2classA class_a; // 输出:classA(),执行构造函数,初始化对象class_a
classA mA = a; // 输出:copy classA(),也是调用复制构造函数方式可以看到,类实例化给对象分配内存时(比如
mA
):- 同时执行成员列表初始化对
mA
J进行初始化,即classA mA = a
; - 再执行构造函数体。
- 同时执行成员列表初始化对
-
构造函数体初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class classC
{
public:
classC(const classA& a)
{
mA = a; // 函数体内初始化
}
private:
classA mA;
};
int main()
{
classA class_a;
classC c(class_a);
}输出:
1
2
3
4
5classA()
classA()
operator= in classA
~classA()
~classA()上述代码14、15行处等价于:
1
2
3classA class_a; // 输出:classA(),执行构造函数,初始化对象class_a
classA mA; // 输出:classA(),执行构造函数,初始化对象mA
mA = a; // 输出:operator= in classA,一个已存在的对象初始化另外一个已存在的对象,使用赋值初始化可以看到:
- 由于类成员分配内存早于构造函数体执行,先执行
classA mA
,调用默认构造函数对mA
进行默认初始化 ; - 再执行构造函数体 ,也就是
mA = a
,这个时候是用赋值=函数进行重新赋值;
而成员列表初始化方式,在类成员分配内存时时同时使用复制构造函数进行初始化。
- 由于类成员分配内存早于构造函数体执行,先执行
所以相比之下,成员列表初始化少调用了一次构造函数对类对象成员(mA
)进行初始化,成员列表初始化效率更高。
最佳实践:初始化选择
迄今为止我们接触了好几种初始化方式:
类实例方式:
- 列表初始化
- 直接初始化
- 复制初始化
std::move
类声明初始化位置:
- 成员初始化列表初始化
- 普通构造函数体内初始化
怎么进行选择?
类声明初始化位置:
-
类类型成员、常量成员、引用成员优先考虑成员初始化列表。因为成员初始化列表不在函数体内,效率更高;
(补充其它情况:当调用一个基类的构造函数,而构造函数拥有一组参数时;当调用一个成员类的构造函数,而它拥有一组参数。)
-
如果类成员还需要在调用构造函数前就提供默认值,成员声明时使用
{}
。1
int m_year{ 1900 };
-
最后考虑普通构造函数。
类实例方式:
- 优先考虑列表初始化。因为列表初始化还可能会进行零值初始化,更安全,但效率相比直接初始化更低点。
- 如果是临时对象初始化场景,使用
std::move
移动构造函数初始化。 - 慎重使用复制初始化。因为这种方式还会创建临时对象,效率比较低,而且不安全(浅拷贝)——一般是需要显式声明复制构造函数实现对象复制时(深拷贝)才使用。
5.2.4 移动构造函数
什么是移动构造函数?
C++11之前,对象的拷贝控制由三个函数决定:拷贝构造函数(Copy Constructor)、拷贝赋值运算符(Copy Assignment operator)和析构函数(Destructor)。
C++11新增加了两个函数:移动构造函数(Move Constructor)和移动赋值运算符(Move Assignment operator)。
在前面我们介绍了两种方法来避免指针浅拷贝造成double free的问题。
在这里我们介绍第三种方法:被复制的对象的指针成员置为NULL:
指针的浅拷贝之所以危险,究其本质是因为被析构函数释放了两次。例如上述指针p
的空间就被释放了两次,导致double free。
如果被复制的对象不再被使用,我们可以在复制构造函数进行浅拷贝后将指针成员置为NULL,析构时判断指针被置为NULL就不被释放。这样就可以避免double free。
1 | class Test |
另一方面,这里ob2
直接使用了ob1
的空间(ob2.p指向的是ob1.p原来的空间),不就大大地降低内存分配成本吗?
这也就是移动构造函数的初衷:
- 对于程序执行过程中产生的临时对象,往往只用于传递数据(没有其它的用处),并且会很快会被销毁。因此在使用临时对象初始化新对象时,我们可以将其包含的指针成员指向的内存资源直接移给新对象所有,无需再新拷贝一份,这大大提高了初始化的执行效率。
上面的拷贝构造函数已经可以称为是移动构造函数。
但这种方式也有局限性:
- 仅限于一个对象被复制后不再被使用的场景,比如这里
ob1
,因为ob1.p
指向内存已经被置为NULL,也就是说我们希望接受的引用对象是一个右值(右值在表达式中产生,运算结束后立即消失,有着“阅后即焚”的特性)。 - 但构造函数参数无法声明为
const Test& other
,虽然可以接受右值引用了(非const引用必须用左值初始化),但other.p = nullptr
修改会编译出错。
有没有办法,让移动构造函数参数能接受右值引用,又能进行修改,最好是只接受右值引用?
这个时候右值引用和std::move
便派上了用处。
右值引用和std::move
请看下例。
1 | class Test |
输出:
1 | 移动构造函数 |
可以看到移动构造函数和我们之前实现的复制构造函数(浅拷贝)基本一致。除了:
-
声明时,
Test(Test&& other)
的&&
,&&
表示右值引用,表示参数只接收右值;在本例中,如果你尝试:
1
2
3
4
5int main()
{
Test ob1;
Test ob2 = ob1;
}输出:
1
复制构造函数(浅拷贝)
因为
ob1
是右值,不会执行移动构造函数。其它例子:
1
2
3int a = 1;
const int&& pb = a; // error,只能接受右值,a是左值
const int&& pc = 2; // ok -
使用时,
Test ob2 = std::move(ob1)
的std::move
,std::move
表示将一个左值转换为右值。这里是将
ob1
转换为右值。上个例子中也可以进行转换:1
int&& pb = std::move(a); // ok
std::move
实际只是个类型转换器,实现如下:1
2
3
4
5
6template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
// 显示类型转换,类型T转换为type
return static_cast<typename remove_reference<T>::type&&>(t);
}也就是
std::move
可以理解为:1
2int&& pb = std::move(a);
int&& pb = (int&&)a;std::move
单独出现没有意义,它只将左值对象转换为右值,表明这个原本的左值对象不会再被需要或使用。它本身不会对对象做任何事,具体的移动工作由外面以右值为形参的重载函数进行。一句话总结:
std::move
只进行了移动前的准备工作(返回一个右值),配合重载了右值为形参的函数对对象进行移动。在上面的例子中:
1
2
3
4
5int main()
{
Test ob1;
Test ob2 = std::move(ob1);
}-
std::move(ob1)
将对象ob1
转换为右值或者说将亡值,为转移对象ob1
所有权做准备; -
然后匹配了重载了右值引用的移动构造函数,由移动构造函数完成“移动”这个动作:
1
2p = other.p; // 浅拷贝
other.p = nullptr; // 置为null
更深刻理解
std::move
除了返回一个右值,没有对对象进行其它操作。1
2
3
4
5
6
7int main()
{
int a = 2;
int&& p_a = std::move(a);
a++; // a照常使用
std::cout<<a<<std::endl;
}std::move
没有修改a
本身,只是提示这是个可移动的对象,需要自己调用有右值的重载函数,才会进行移动(函数内部实现了“移动”这个动作)。 -
总的来说移动构造函数有以下好处:
- 充分利用临时对象内存,避免了空间浪费;
但一切都建立在使用临时对象进行初始化这个场景下。
std::move(ob1)
还可以扩展到其它以右值引用为形参移动语义函数中,最常见是在STL中,STL类大都支持移动语义函数。
例如,std::vector
方法定义:
1 | void push_back (const value_type& val); |
显然void push_back (value_type&& val);
可以接受移动语义:
1 |
|
同样,这种做法减少了开辟内存开销。
emplace_back
类似:
1 | vec.emplace_back(std::move(str1)); // emplace_back效果相同,str1会失去原有值 |
再次强调,移动语义函数也是建立在被拷贝对象再拷贝后就不再被需要的场景下,比如这里的str1
视为“将亡值”。
5.2.5 委托构造函数
构造函数重载时,允许一个构造函数调用其它构造函数,这个过程称为委托构造函数 。
为什么要这么设计?
因为C++中不能在构造函数中调用其它构造函数,也就是不能进行构造函数嵌套 。
下面便是一个错误的示范,猜猜输出结果是什么?
1 |
|
结果可能会让你有点意外:
1 | x = -1847177920, y = 32765 |
好家伙x = -1847177920, y = 32765
是什么鬼?Point{0, 0}没有初始化p1吗?
回到案发现场:
1 | Point p1; |
我们知道在C++中定义一个对象:
- 要先分配内存(此时,非静态成员还未初始化);
- 再调用构造函数(初始化非静态成员)。
对于Point p1
:
- 编译器给对象
p1
分配了内存; - 调用构造函数
Point()
初始化,进入函数体开始执行构造函数Point(int,int)
; - 执行构造函数
Point(int,int)
时,编译器又创建了临时匿名对象; - 此时再调用
Point(int,int)
初始化匿名对象非静态成员x=0,y=0,而不是对象p1
; - 所以最终对象
p1
的x、y没有被初始化为{0,0}。
也就是构造函数发生嵌套时,不会初始化当前对象而是初始化新生成的匿名对象。
整个过程比较难理解的是第3点:为什么执行构造函数,编译器创建了临时对象,构造函数不是不会创建对象吗?
构造函数虽然不创建对象,但是在构造函数执行前,编译器会分配一个匿名对象空间,构造函数确实只是负责将其初始化了。
是的,C++就是这么魔鬼。
不过,C++大魔王大慈大悲允许你使用委托构造函数方式进行构造函数嵌套。
赶紧带着感恩的心瞧一下吧。
1 | class Point |
偷偷再告诉你两种不太优雅的方式做到类似构造函数嵌套的效果。
-
把构造函数中的公共部分抽取出来定义一个成员函数(最好修饰为private),然后在每个需要这个代码的构造函数中调用该函数即可。
-
使用placement new。因为placement new不会重新分配内存,其定义也证明了这一点:
1
2
3
4inline void *__cdecl operator new(size_t, void *_P)
{
return (_P); // 没有分配新的内存
}这样调用构造函数
Point(int,int)
时不会创建临时对象,而是依旧在对象p1
内存中执行初始化操作:1
2
3
4
5
6
7Point()
{
new (this)Point{0,0};
};
Point p1;
p1.show(); // x=0,y=0
5.3 析构函数
什么是析构函数?
析构函数是另一种特殊的类成员函数,在该类的对象被销毁时执行,帮助类清理对象。
和构造函数一样,析构函数没有参数、返回类型,与类同名。
析构函数什么时候执行?
-
当一个栈上分配的对象正常超出范围,注意区分堆上分配的对象不会自动释放,需要手动;
1
2
3
4
5
6
7Class Test
{
...
};
Tets t1; // 此时t1在栈上,超出作用范围会自动释放
Test* t2 = new Test{}; // 此时t2指向的对象分配在堆上 -
delete 关键字显式删除动态分配的对象。
类一定需要析构函数吗?
- 如果只是包含普通成员变量的值,不需要析构函数;
- 如果是类对象持有动态内存、文件或数据库句柄,需要析构函数。
例如,下面数组指针m_array
需要显示定义析构函数进行清理,否则会发生内存泄漏。
1 |
|
输出:
1 | The value of element 5 is: 6 |
RAII
RAII(资源获取即初始化)是一种编程技术,其中资源使用与具有自动生命周期的对象(例如非动态分配的对象)的生命周期相关联。在 C++ 中,RAII 是通过具有构造函数和析构函数的类来实现的。
RAII 的主要优点是它有助于防止资源泄漏(例如内存未被释放),因为所有资源持有对象都会自动清理。
- 资源(内存、文件或数据库句柄等)在对象的构造函数中获取,在对象处于活动状态时使用该资源。
- 当对象被销毁时,资源在析构函数中被释放。
前例中的m_array
持有的动态内存就是RAII一个很好的例子——在构造函数中分配,在析构函数中释放。
在标准库中,std::string
和 std::vector
同样遵循RAII——动态内存在初始化时获取,并在销毁时自动清理。
5.4 友元函数和友元类
我们一直努力尽量宣扬类数据保密(private)的好处。
但是,如果有类A和类B联系的非常紧密,类B对象确实需要用到类A对象的私有成员,这怎么办?
(这种情况通常发生于重载运算符时,此时两个类通常联系得很紧密,其它情况确实不常见。)
- 将类A的私有成员公开(public)?显然不行,这样别的类对象也可以访问A的私有成员;
- 在类A专门设置接口函数获取私有成员值?也不行,道理同上。
如果能指定只能类B访问类A的私有成员该多好啊!
5.4.1 友元函数
友元函数就做了这么一件事:指定某个普通函数或某个类的成员函数为本类的友元函数,由此可以使用本类的私有成员。
虽然这破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。但在某些场景下确实很有用。
举例说明普通函数/成员函数作为友元函数。
普通函数作为友元函数
注意:友元函数虽然声明在类中,但并不属于当前类成员。
下面这个例子展示了,在类Accumulator
把函数reset
作为友元函数。
1 | class Accumulator |
注意,我们必须将 Accumulator 对象acc传递给 reset()。
因为友元函数reset() 不是类的成员,没有 *this
指针,也没有可供使用的 Accumulator 对象,因此必须给定一个。
一个函数还可以同时是多个类的友元函数,但是每个类中都要声明这个函数。
下面外部函数printWeather
同时是类Humidity
和 Temperature
的友元函数。
1 |
|
成员函数作为友元函数
与使普通函数成为朋友类似,也可以让类成员函数作为友元函数。
下面是一个实例。
1 |
|
5.4.2 友元类
也可以让整个类成为另一个类的朋友,这样友元类的所有成员都可以访问类的私有成员。
一个简单实例。
1 |
|
5.5 隐藏的this指针
5.5.1 快速回忆
在2.1.4节,我们说过,每个类都有个隐藏的this
指针。
快速回忆下:
- 编译器隐式为每个数据成员加上
this
指针; - 编译器隐式为每个函数显示加上了第一个参数
Simple* const this
。
为方便理解,下面代码注释显示指示了this
指针的位置。
1 | class Simple |
从代码22行处,也可以知道:this
指针就是指向当前对象simple
。
大部分时候,我们都可以假装不知道this指针的存在,但在链接成员时this指针表现得很有用。
5.5.2 this指针链接成员函数
思考一下:经常使用的std::cout
是如何实现连续打印多个字符串?
1 | std::cout << "Hello " << "World"; |
此时,std::cout 是一个对象,而 operator<< 是对该对象进行操作的成员函数:
- operator<< 打印第一个字符串"Hello ",然后返回当前对象,也就是
*this
; - std::cout 对象
*this
调用operator<< 打印第二个字符串"World"。
举一个更具体的例子:
1 |
|
这种做法在类重载运算符时最常使用,下章一起来学习下吧。
更新记录
- 修改std::move相关描述
- 修改成员列表初始化相关描述
- 增加移动构造函数相关描述
- 修改复制初始化相关描述
- 第一次更新
参考资料
- 1.C++构造函数的理解 : https://www.cnblogs.com/downey-blog/p/10470782.html ↩
- 2.禁止拷贝构造,禁止bug:https://zhuanlan.zhihu.com/p/266353611 ↩
- 3.为什么使用初始化列表会快一些?https://segmentfault.com/a/1190000039294789 ↩
- 4.C++11右值引用和移动构造函数详解 :https://zhuanlan.zhihu.com/p/365412262 ↩
- 5.C++中的std::move函数到底是做什么的?https://www.zhihu.com/question/467449795 ↩