我们在【简单实现STL容器string代码】实现了基本的string类。
然后在【实现string类的迭代器string::iterator】中我们完善了string的各类接口和功能,但是我们仍然忽略了一些重要的问题,在本文中,我们会加以阐述。
为了简化问题,首先我给出一个CMystring的代码(不包含迭代器,只有基本实现),在最后实现了一个GetString全局函数
class CMyString {
private:
char* mptr;
public:
CMyString(const char* str = nullptr) {
cout << "CMyString(const char*)" << endl;
if (str != nullptr) {
mptr = new char[strlen(str) + 1];
strcpy(mptr, str);
} else {
mptr = new char[1];
*mptr = '\0';
}
}
~CMyString() {
cout << "~CMyString()" << endl;
delete[] mptr;
mptr = nullptr;
}
CMyString(const CMyString& str) {
cout << "CMyString(const CMyString&)" << endl;
// 复制构造函数,根据传入的CMyString对象初始化对象
mptr = new char[strlen(str.mptr) + 1];
strcpy(mptr, str.mptr);
}
CMyString& operator=(const CMyString& str) {
cout << "operator=(const CMyString&)" << endl;
if (this == &str) {
return *this;
}
delete[] mptr;
mptr = new char[strlen(str.mptr) + 1];
strcpy(mptr, str.mptr);
return *this;
}
const char* c_str() const { return mptr; }
};
CMyString GetString(CMyString& str) {
const char* pstr = str.c_str();
CMyString tmpStr(pstr); //普通构造
return tmpStr;
}
自己实现的string类存在的问题
- 以下内容均为不开编译器优化的前提下发生的,关闭编译器优化请使用:
-fno-elide-constructors
- 关于编译器的返回值优化ROV可以看这篇文章:【返回值优化(ROV)】
- 如果打开了编译器优化,不存在位置4的深拷贝
我们使用以下测试代码:
int main () {
CMyString str1("aaaaaaaaa");
CMyString str2;
str2 = GetString(str1);
cout << str2.c_str() << endl;
return 0;
}
首先我们讨论GetString的函数调用过程:
代码的运行步骤如下:
- str1 的 CMyString的普通构造
- str2 的 CMyString的普通构造
- 跳转到函数体
GetString
的tmpStr处进行普通构造。 - 对于return tmpStr就比较复杂了,他会首先到main函数栈帧调用拷贝构造函数,根据tmpStr管理的堆内存的大小,拷贝该堆内存到另一个内存块,然后自己管理新开辟的内存块。
- 然后由于tmpStr的声明周期只在该函数体内,马上就析构掉了。
既然tmpStr根本就不想要这个资源了,为什么不能直接把它交给我们的str2(调用者)呢?也就是说,我们图中的的4号位置别拷贝了tmpStr管理的堆内存,直接把它的指针指向该堆内存,然后tmpStr的指针指向空,最后去析构不可以吗?
这样就完全没有内存开辟、内存释放和数据拷贝。
也就是说我们希望拷贝构造能实现:
mptr = str.mptr;
str.mptr = nullptr;
但是肯定是不能这样写的,这样写就不能完成正常的拷贝构造的功能了。
然后我们讨论str2 = GetString(str1)
在这里,我们让str2调用一个赋值构造运算符,让他等于一个临时对象。
他本来可能也是已经指向一个空间,我们在operator=中也是先让他释放自己指向的内存资源,然后再new一块新的资源,最后拷贝源资源。
最关键的是:位置4的临时对象资源在给str2赋完值后,又马上就析构掉了,来来回回倒了两次!确实需要进行优化。我们应该把位置4管理的内存资源直接给str2,然后把自己置为空,这样才是合理的。
问题总结:
- 我们的临时对象tmpStr调用了拷贝构造到main函数的栈帧构造了又一个临时变量4,然后把其管理的资源拷贝到4,最后释放自己的资源。
没必要!完全可以拷贝构造4对象之后,让4对象的指针直接指向tmpStr管理的资源,然后tmpStr指针指向nullptr之后析构。
- 4在赋值给str2的时候,也会进行堆资源的拷贝,然后释放,而不是让str2直接指向4的堆资源
输出为:
CMyString(const char*) //str1的构造;
CMyString(const char*) //str2的构造
CMyString(const char*) //构造GetString局部对象tmpStr
CMyString(const CMyString&)//return tmpStr发生的拷贝构造Main函数栈帧的临时对象
~CMyString() //析构tmpStr临时对象
operator=(const CMyString&) //赋值构造构造str2
~CMyString() //析构main函数栈帧的临时对象
aaaaaaaaa
~CMyString() //析构str2
~CMyString() //析构str1
你别说,C++里面还真有解决方案,如下文所述。
添加带右值引用参数的拷贝构造和赋值函数
这里直接给出代码:
//带左值引用参数的拷贝构造
CMyString(const CMyString& str) {
cout << "CMyString(const CMyString&)" << endl;
mptr = new char[strlen(str.mptr) + 1];
strcpy(mptr, str.mptr);
}
//带右值引用参数的拷贝构造
CMyString(CMyString &&str) { //str引用的就是一个右值
cout << "CMyString(CMyString &&str)" << endl;
mptr = str.mptr;
str.mptr = nullptr;
}
我们可以明显看出,带右值引用参数的拷贝构造少了new一个新内存的操作;
//带左值引用参数的赋值重载函数
CMyString& operator=(const CMyString& str) {
cout << "operator=(const CMyString&)" << endl;
if (this == &str) {
return *this;
}
delete[] mptr;
mptr = new char[strlen(str.mptr) + 1];
strcpy(mptr, str.mptr);
return *this;
}
//带右值引用参数的赋值重载函数
CMyString& operator=(CMyString &&str) {
cout << "operator=(CMyString&&)" << endl;
if (this == &str)
return *this;
delete[] mptr;
mptr = str.mptr;
str.mptr = nullptr;
return *this;
}
带右值引用参数的赋值重载函数也少了一个new新内存的操作。
测试效果
然后我们再次使用上面的测试代码进行测试:
int main () {
CMyString str1("aaaaaaaaa");
CMyString str2;
str2 = GetString(str1);
cout << str2.c_str() << endl;
return 0;
}
输出如下:
CMyString(const char*)
CMyString(const char*)
CMyString(const char*)
CMyString(CMyString &&str)//return tmpStr发生的拷贝构造Main函数栈帧的临时对象
~CMyString() //析构tmpStr
operator=(CMyString&&)赋值构造构造str2
~CMyString() //析构main函数栈帧的临时对象
aaaaaaaaa
~CMyString()
~CMyString()
很明显,我们在临时对象的构造时调用了带右值引用参数的拷贝构造和赋值函数。少了内存开辟和释放的开销,非常奈斯!
关于返回值优化ROV技术
这里简单讲一下返回值优化技术,在现代C++编译器中,返回值优化(Return Value Optimization,RVO)是一种编译器优化技术,用于避免对象在函数返回时的额外拷贝构造和析构操作,从而提高程序的性能。
也就是说如果打开ROV,函数的返回值根本就不会去到main函数栈帧上做一个深拷贝,而是直接返回!
并且返回值优化是编译器默认的,想要关闭返回值优化的话在编译时添加以下命令:
-fno-elide-constructors
上述过程中,我们使用返回值优化:
CMyString(const char*)
CMyString(const char*)
CMyString(const char*) //构造GetString局部对象tmpStr
operator=(CMyString&&) //return tmpStr直接复制构造str2
~CMyString() //析构tmpStr
aaaaaaaaa
~CMyString()
~CMyString()
一起对比一下不使用ROV的情况:
CMyString(const char*)
CMyString(const char*)
CMyString(const char*)
CMyString(CMyString &&str)//return tmpStr发生的拷贝构造Main函数栈帧的临时对象
~CMyString() //析构tmpStr
operator=(CMyString&&)赋值构造构造str2
~CMyString() //析构main函数栈帧的临时对象
aaaaaaaaa
~CMyString()
~CMyString()
我们可以看到返回值优化已经直接将这个临时对象需要通过拷贝构造函数或移动构造函数复制到调用者的上下文中。
所以说不仅带右值引用参数的拷贝构造和赋值函数很重要,返回值优化同样也很重要!