现在C++的岗位几乎都要求会使用C++11以后的标准,正好微软官方有一章就是讲的“Welcome Back to C++ (Modern C++)”,我这里主要在内存方面介绍下。具体请看看官方文档:https://docs.microsoft.com/en-us/cpp/cpp/welcome-back-to-cpp-modern-cpp?view=vs-2019
先来个总纲:
Modern C++ emphasizes:
-
Stack-based scope instead of heap or static global scope.
-
Auto type inference instead of explicit type names.
-
Smart pointers instead of raw pointers.
-
std::string
andstd::wstring
types (see <string>) instead of rawchar[]
arrays. -
C++ Standard Library containers like
vector
,list
, andmap
instead of raw arrays or custom containers. See <vector>, <list>, and <map>. -
C++ Standard Library algorithms instead of manually coded ones.
-
Exceptions, to report and handle error conditions.
-
Lock-free inter-thread communication using C++ Standard Library
std::atomic<>
(see <atomic>) instead of other inter-thread communication mechanisms. -
Inline lambda functions instead of small functions implemented separately.
-
Range-based for loops to write more robust loops that work with arrays, C++ Standard Library containers, and Windows Runtime collections in the form
for ( for-range-declaration : expression )
. This is part of the Core Language support. For more information, see Range-based for Statement (C++).
1. 基于栈的内存管理,而非基于堆或者静态全局的内存管理。
先说下由于我有嵌入式开发经历,所以之前的文章里都是属于基于堆或者静态全局的内存管理。
基于堆就是用指针操作new或者malloc的内存。优点:快,没有多余代码。缺点:需要自己管理内存的释放,以及对指针操作的不安全因素。
静态全局就是在某个cpp文件下声明一个静态的全局内存。优点:快,不需管理释放。缺点:无效占用,即便没使用的时候也会占用内存。
现代C++推荐的是基于栈的内存管理。栈内存的好处是什么?比如每个函数里面的局部变量,进入函数时一个个入栈,函数结束时一个个出栈,是一种安全,自动化的内存操作。但是栈并不是无限大的,所以比较大的内存一般都是放在堆上,比如保存一张图片的数据。而堆的缺点就是需要自己管理释放,这个恰恰是很多新手玩不转的东西。如果能让堆上的内存和栈的变量一样自动弹出释放,那就是新手也可以写出安全的内存代码。
基于栈的内存管理大概就是这样的操作:申请数据内存的同时,再申请一个RefCount(统计引用计数的内存),然后把两块内存的指针打包到一个只能用栈申请的数据结构里,称这个结构为Content。栈上每增加一个Content,引用计数就增加一个,每销毁一个就减一个计数,计数为0时销毁Content指向的数据内存和引用计数内存。这样堆上的内存就被栈管理了,同时也增加额外的结构。
先看看以上这三种内存管理方式在代码里的形式:
static char sName[256] = "Static Global Name"; //静态全局,该内存只能访问,全局存在
char* GetSName()
{
return sName;
}
char* GetHeapName()
{
const char* heapname = "Heap Name";
char* hName = new char[256];//堆上内存,通过指针传递给外面使用
strcpy_s(hName,256, heapname);
return hName;
}
std::shared_ptr<char> GetRefName()
{
const char* crefname = "Ref Name";
char* temp = new char[256];
std::shared_ptr<char> refname(temp);//这里本来应该用std::string,用这个只是用来和上面进行对比,理解栈上内存管理
strcpy_s(&(*refname), 256, crefname);//同样,&(*)是为了强制使用char*指针,
return refname;
}
#define FastPrint(p) printf("%s %s\n",#p,p)
void RefCountTest()
{
char* sname = GetSName();
char* hname = GetHeapName(); //这个char*指针是需要用户来维护的,如果项目很大,这个指针会被多个模块使用,模块之间如何协调
//指针内存的释放是个大问题
//char* rname = &(*GetRefName()); //举个例子,return的临时变量会立刻出栈,所以获取的字符串指针是错误的
std::shared_ptr<char> temp= GetRefName();//这个才是正确做法
char* rname = &(*temp);//这里也是强制使用char*指针,不是好的用法
FastPrint(sname);
FastPrint(hname);
FastPrint(rname);
}
注意上面的shared_ptr就是STL库提供的一个基于栈的内存管理,也就是上面提到的Content。千万别去new 这个Content。
2.如何理解基于栈的内存管理
a.一种类设计中的低级错误
先看一段新手在自己写的类中最容易出现的内存管理错误:
class CErr
{
public:
CErr(int _size)
:Size(_size)
{
printf("CErr Constructor %p\n", this);
Buf = new char[Size];
}
~CErr()
{
printf("CErr DeConstructor %p\n", this);
if (Buf)
{
delete[]Buf;
}
}
char* Buf;
int Size;
};
CErr GetErr()
{
CErr test(256);
strcpy_s(test.Buf,256, "Data Copy Test");
return test;
}
void ErrorTest()
{
CErr a= GetErr();
printf("A:%s\n", a.Buf);
}
上面的例子中,写了一个CErr类来管理一块字符串的空间,构造的时候去创建内存,析构的时候释放内存,感觉好像没啥问题。但是一用就会崩溃。
在GetErr这个函数里面用test的临时变量申请的char数组,在退出函数的时候就会调用析构被释放掉。外部ErrorTest里面的 a变量虽然进行了Copy Constructor,但是这时内存已经释放了。函数ErrorTest结束后,会弹出栈上内存也就是a变量,这时在析构函数里会重复释放这块char数组。
如上所示,这么简单的一个类都会出这么大的问题。所以现代C++才会强调使用智能指针来进行堆上内存管理。
之所以会写出这种错误的类,主要是新手程序员缺失了一个重要的知识点:Copy Constructor和Copy Assignment,顺便一提的还有Move Constructor和Move Assignment。下面就讲讲这部分细节。
b. Copy和Move,以及LReference 和RReference
先解释下这几个词。Copy Constructor 拷贝构造,Copy Assignment 拷贝赋值。Move就是对应移动构造,移动赋值。LReference是左值引用,即&。RReference是右值引用,即&&。一个类只用拷贝就能正常工作,移动是因为有些情况下编译器不得已会产生一些临时对象。
关于临时对象和右值引用下面会带着讲一些,具体请看链接:
临时对象 https://docs.microsoft.com/en-us/cpp/cpp/temporary-objects?view=vs-2019
引用https://docs.microsoft.com/en-us/cpp/cpp/references-cpp?view=vs-2019
下面直接看代码理解吧:
//A() ,A(const A&), ~A(), A& operator=(const A& a)这四个是编译器会默认生成的,一般都需要程序员自己来重写
//拷贝还是移动取决于传参是左值引用还是右值引用。
//但是如果没有对移动的实现,则都会使用拷贝
//其中std::move可以强制变成右值引用
//其他则看传参是否为一个临时对象由编译器决定
class A
{
public:
A() { printf("Default Constructor %p\n", this); }
A(const A& a) { printf("Copy Constructor %p a:%p\n", this, &a); }
A(const A&& a)noexcept { printf("Move Constructor %p a:%p\n", this, &a); }
A& operator=(const A& a) { printf("Copy Assignment %p a:%p\n", this, &a); return *this; }
A& operator=(const A&& a)noexcept { printf("Move Assignment %p a:%p\n", this, &a); return *this; }
~A()
{
printf("Decontructor %p\n", this);
}
};
void PrintLine()
{
static int index = 0;
printf("=====================%02d===================\n", index++);
}
//先是在函数内的默认构造
//然后return的时候再进行移动构造得到return的临时对象
//最后函数外的赋值=号,调用的移动赋值
A GetA1()
{
A temp;
return temp;
//如果return A(),那么A()构造的也是一个匿名的临时对象
//则上面的移动构造就不需要了,直接用这个临时对象进行移动复制
}
//传参时先进行拷贝构造得到 a
//return的时候再进行移动构造得到临时对象
//最后还有外部对return的临时对象的移动赋值
A GetA3(A a)
{
return a;
}
//传参时的拷贝构造被左值引用给取消了,同时也说明a不是一个临时变量
//只剩下对return的临时对象的拷贝构造
//最后同样外部=号的移动赋值
A GetA4(A& a)
{
return a;
}
//这个只有一个拷贝赋值
A& GetA5(A& a)
{
return a;
}
//这个是错误示范:先用拷贝构造出a1
//然后return这句前a1出栈 析构
//最后用已经析构的a1与外部进行拷贝赋值。
//注意这里不是移动赋值,因为return的对象是引用而非匿名对象
A& GetA6(const A& a)
{
A a1(a);
return a1;
}
//先拷贝构造出a1
//然后移动构造出return的临时对象
//最后return的临时对象和外部进行移动赋值
A GetA7(const A& a)
{
A a1(a);
return a1;
}
void ConstrcutorTest()
{
A a;//默认构造
PrintLine();
A a1 = a;//拷贝构造
PrintLine();
A a2(a);//拷贝构造
PrintLine();
a1 = GetA1();//移动赋值
PrintLine();
a1 = a;//拷贝赋值
PrintLine();
a1 = std::move(a);//强行移动赋值
PrintLine();
a2 = GetA3(a);
PrintLine();
a2 = GetA4(a);
PrintLine();
a2 = GetA5(a);
PrintLine();
a2 = GetA6(a);
PrintLine();
a2 = GetA7(a);
PrintLine();
((A*)NULL)->operator=(a);//和别人说这个不能编译通过,结果可以的
}
假设你看懂了上面的代码,实际上就知道了我a.中设计的类没有拷贝构造和拷贝赋值的相关操作,所以造成了内存管理异常。
c.正确的类
class RefName
{
public:
RefName()
{
printf("Default Constructor %p\n", this);
MemCreate();
const char* refname = "RefCount Name";
strcpy_s(Buf, 256, refname);
}
~RefName()
{
printf("Decontructor %p\n", this);
DecreseRef();
}
RefName(const RefName& other)
:RefCount(nullptr)
{
printf("Copy Constructor %p other:%p\n", this, &other);
CopyAll(other);
}
RefName(const RefName&& other) noexcept
{
printf("Move Constructor %p other:%p\n", this, &other);
CopyContent(other);
}
RefName& operator=(const RefName& other)
{
printf("Copy Assignment %p other:%p\n", this, &other);
CopyAll(other);
return *this;
}
RefName& operator=(const RefName&& other) noexcept
{
printf("Move Assignment %p other:%p\n", this, &other);
CopyContent(other);
return *this;
}
char* Buf;
int* RefCount;
private:
void CopyContent(const RefName& other)
{
Buf = other.Buf;
RefCount = other.RefCount;
(*RefCount)++;
}
void CopyAll(const RefName& other)
{
if (RefCount!=nullptr&&(*RefCount)>0)
{
DecreseRef();
}
MemCreate();
memcpy_s(Buf, 256,other.Buf,256);
}
void DecreseRef()
{
(*RefCount)--;
if (*RefCount == 0)
{
MemRelease();
}
}
void MemRelease()
{
printf("MemRelease %p Buf:%p RefCount:%p\n",this, Buf, RefCount);
delete RefCount;
delete[]Buf;
}
void MemCreate()
{
Buf = new char[256];
RefCount = new int(1);
printf("MemCreate %p Buf:%p RefCount:%p\n", this, Buf, RefCount);
}
};
RefName TestGetRefName()
{
RefName temp;
strcat_s(temp.Buf, 256, "TestGetRefName");
return temp;
}
RefName TestGetRefName1(RefName ref)
{
strcat_s(ref.Buf, 256, "TestGetRefName1");
return ref;
}
#define MyPrint(idx) printf("%s\n", test##idx.Buf)
void RefTest()
{
RefName test1;
strcat_s(test1.Buf, 256, " test1");
PrintLine();
RefName test2 = test1;
strcat_s(test2.Buf, 256, " test2");
PrintLine();
RefName test3(test1);
strcat_s(test3.Buf, 256, " test3");
PrintLine();
RefName test4 = TestGetRefName();
strcat_s(test4.Buf, 256, " test4");
PrintLine();
RefName test5 = TestGetRefName1(test1);
strcat_s(test5.Buf, 256, " test5");
RefName test6 = std::move(test1);
strcat_s(test6.Buf, 256, " test6");
MyPrint(1);
MyPrint(2);
MyPrint(3);
MyPrint(4);
MyPrint(5);
MyPrint(6);
PrintLine();
}
这里的代码和上面关于copy和move的代码都差不多,我就不一一解释代码了。看代码的时候,先自己想想,RefTest中一共会new出多少char数组,然后test1到test6的输出各是什么。
上面的内容只是为了方便理解,栈和堆之间的协同原理。实际操作过程中请使用std::shared_ptr和std::vector这类标准库的类来实现代码。
最开始的总纲实际上已经说了大半,综合来讲就是尽量使用STL库。
欢迎回到现代C++!