本文继续来介绍C++的缺陷和笔者的一些思考。先序文章请看
C++的缺陷和思考(四)
C++的缺陷和思考(三)
C++的缺陷和思考(二)
C++的缺陷和思考(一)
new
这个运算符相信大家一定不陌生,即便是非C++系其他语言一般都会保留new
这个关键字。而且这个已经成为业界的一个哏了,比如说“没有对象怎么办?不怕,new一个!”
从字面意思就能看得出,这是“新建”的意思,不过在C++中,new
远不止字面看上去这么简单。而且,delete
关键字基本算得上是C++的特色了,其他语言中基本见不到。
“堆空间”的概念同样继承自C语言,它是提供给程序手动管理、调用的内存空间。在C语言中,malloc
用于分配堆空间,free
用于回收。自然,在C++中仍然可以用malloc
和free
但使用malloc
有一个不方便的地方,我们来看一下malloc
的函数原型:
void *malloc(size_t size);
malloc
接收的是字节数,也就是我们需要手动计算出我们需要的空间是多少字节。它不能方便地通过某种类型直接算出空间,通常需要sizeof
运算。
malloc
返回值是void *
类型,是一个泛型指针,也就是没有指定默认解类型的,使用时通常需要类型转换,例如:
int *data = (int *)malloc(sizeof(int));
而new
运算符可以完美解决上面的问题,注意,在C++中new
是一个运算符:
int *data = new int;
同理,delete
也是一个运算符,用于释放空间:
delete data;
熟悉C++运算符重载的读者一定清楚,C++中运算符的本质其实就是一个函数的语法糖,例如a + b
实际上就是operator +(a, b)
,a++
实际上就是a.operator++()
,甚至仿函数、下标运算也都是函数调用,比如f()
就是f.operator()()
,a[i]
就是a.operator[](i)
。
既然new
和delete
也是运算符,那么它就应当也符合这个原理,一定有一个operator new
的函数存在,下面是它的函数原型:
void *operator new(size_t size);
void *operator new(size_t size, void *ptr);
这个跟我们直观想象可能有点不一样,它的返回值仍然是void *
,也并不是一个模板函数用来判断大小。所以,new
运算符跟其他运算符并不一样,它并不只是单纯映射成operator new
,而是做了一些额外操作。
另外,这个拥有2个参数的重载又是怎么回事呢?这个等一会再来解释。
系统内置的operator new
本质上就是malloc
,所以如果我们直接调operator new
和operator delete
的话,本质上来说,和malloc
和free
其实没什么区别:
int *data = static_cast<int *>(operator new(sizeof(int)));
operator delete(data);
而当我们用运算符的形式来书写时,编译器会自动处理类型的大小,以及返回值。new
运算符必须作用于一个类型,编译器会将这个类型的size作为参数传给operator new
,并把返回值转换为这个类型的指针,也就是说:
new T;
// 等价于
static_cast<T *>(operator new(sizeof(T)))
delete
运算符要作用于一个指针,编译器会将这个指针作为参数传给operator delete
,也就是说:
delete ptr;
// 等价于
operator delete(ptr);
之所以要引入operator new
和operator delete
还有一个原因,就是可以重载。默认情况下,它们操作的是堆空间,但是我们也可以通过重载来使得其操作自己的内存池。
std::byte buffer[16][64]; // 一个手动的内存池
std::array<void *, 16> buf_mark {nullptr}; // 统计已经使用的内存池单元
struct Test {
int a, b;
static void *operator new(size_t size) noexcept; // 重载operator new
static void operator delete(void *ptr); // 重载operator delete
};
void *Test::operator new(size_t size) noexcept {
// 从buffer中分配资源
for (int i = 0; i < 16; i++) {
if (buf_mark.at(i) == nullptr) {
buf_mark.at(i) = buffer[i];
return buffer[i];
}
}
return nullptr;
}
void Test::operator delete(void *ptr) {
for (int i = 0; i < 16; i++) {
if (buf_mark.at(i) == ptr) {
buf_mark.at(i) = nullptr;
}
}
}
void Demo() {
Test *t1 = new Test; // 会在buffer中分配
delete t1; // 释放buffer中的资源
}
另一个点,相信大家已经发现了,operator new
和operator delete
是支持异常抛出的,而我们这里引用直接用空指针来表示分配失败的情况了,于是加上了noexcept
修饰。而默认的情况下,可以通过接收异常来判断是否分配成功,而不用每次都对指针进行判空。
malloc
的另一个问题就是处理非平凡构造的类类型。当一个类是非平凡构造时,它可能含有虚函数表、虚基表,还有可能含有一些额外的构造动作(比如说分配空间等等),我们拿一个最简单的字符串处理类为例:
class String {
public:
String(const char *str);
~String();
private:
char *buf;
size_t size;
size_t capicity;
};
String::String(const char *str):
buf((char *)std::malloc(std::strlen(str) + 1)),
size(std::strlen(str)),
capicity(std::strlen(str) + 1) {
std::memcpy(buf, str, capicity);
}
String::~String() {
if (buf != nullptr) {
std::free(buf);
}
}
void Demo() {
String *str = (String *)std::malloc(sizeof(String));
// 再使用str一定是有问题的,因为没有正常构造
}
上面例子中,String
就是一个非平凡的类型,它在构造函数中创建了堆空间。如果我们直接通过malloc
分配一片String
大小的空间,然后就直接用的话,显然是会出问题的,因为构造函数没有执行,其中buf
管理的堆空间也是没有进行分配的。
所以,在C++中,创建一个对象应该分2步:
同样,释放一个对象也应该分2步:
3. 调用析构函数
4. 释放内存空间
这个理念在OC语言中贯彻得非常彻底,OC中没有默认的构造函数,都是通过实现一个类方法来进行构造的,因此构造前要先分配空间:
NSString *str = [NSString alloc]; // 分配NSString大小的内存空间
[str init]; // 调用初始化函数
// 通常简写为:
NSString *str = [[NSString alloc] init];
但是在C++中,初始化方法并不是一个普通的类方法,而是特殊的构造函数,那如何手动调用构造函数呢?
我们知道,要想调用构造函数(构造一个对象),我们首先需要一个分配好的内存空间。因此,要拿着用于构造的内存空间,以构造参数,才能构造一个对象(也就是调用构造函数)。C++管这种语法叫做就地构造(placement new)。
String *str = static_cast<String *>(std::malloc(sizeof(String))); // 分配内存空间
new(str) String("abc"); // 在str指向的位置调用String的构造函数
就地构造的语法就是new(addr) T(args...)
,看得出,这也是new
运算符的一种。这时我们再回去看operator new
的一个重载,应该就能猜到它是干什么的了:
void *operator new(size_t size, void *ptr);
就是用于支持就地构造的函数。
要注意的是,如果是通过就地构造方式构造的对象,需要再回收内存空间之前进行析构。以上面String
为例,如果不析构直接回收,那么buf
所指的空间就不能得到释放,从而造成内存泄漏:
str->~String(); // 析构
std::free(str); // 释放内存空间
看到本节的标题,相信读者会恍然大悟。C++中new
运算符同时承担了“分配空间”和“构造对象”的任务。上一节的例子中我们是通过malloc
和free
来管理的,自然,通过operator new
和operator delete
也是一样的,而且它们还支持针对类型的重载。
因此,我们说,一次new
,相当于先operator new
(分配空间)加placement new
(调用构造函数)。
String *str = new String("abc");
// 等价于
String *str = static_cast<String *>(operator new(sizeof(String)));
new(str) String("abc");
同理,一次delete
相当于先“析构”,再operator delete
(释放空间)
delete str;
// 等价于
str->~String();
operator delete(str);
这就是new
和delete
的神秘面纱,它确实和普通的运算符不一样,除了对应的operator
函数外,还有对构造、析构的处理。
但也正是由于C++总是进行一些隐藏操作,才会复杂度激增,有时也会出现一些难以发现的问题,所以我们一定要弄清楚它的本质。
new []
和delete []
的语法看起来是“创建/删除数组”的语法。但其实它们也并不特殊,就是封装了一层的new
和delete
void *operator new[](size_t size);
void operator delete[](void *ptr);
可以看出,operator new[]
和operator new
完全一样,opeator delete[]
和operator delete
也完全一样,所以区别应当在编译器的解释上。
operator new T[size]
的时候,会计算出size
个T
类型的总大小,然后调用operator new[]
,之后,会依次对每个元素进行构造。也就是说:
String *arr_str = new String [4] {"abc", "def", "123"};
// 等价于
String *arr_str = static_cast<String *>(opeartor new[](sizeof(String) * 3));
new(arr_str) String("abc");
new(arr_str + 1) String("def");
new(arr_str + 2) String("123");
new(arr_str + 3) String; // 没有写在列表中的会用无参构造函数
同理,delete []
会首先依次调用析构,然后再调用operator delete []
来释放空间:
delete [] arr_str;
// 等价于
for (int i = 0; i < 4; i++) {
arr_str[i].~String();
}
operator delete[] (arr_str);
总结下来new []
相当于一次内存分配加多次就地构造,delete []
运算符相当于多次析构加一次内存释放。
constexpr
全程叫“常量表达式(constant expression)”,顾名思义,将一个表达式定义为“常量”。
关于“常量”的概念笔者在前面“const引用”的章节已经详细叙述过,只有像1
,'a'
,2.5f
之类的才是真正的常量。储存在内存中的数据都应当叫做“变量”。
但很多时候我们在程序编写的时候,会遇到一些编译期就能确定的量,但不方便直接用常量表达的情况。最简单的一个例子就是“魔鬼数字”:
using err_t = int;
err_t Process() {
// 某些错误
return 25;
// ...
return 0;
}
作为错误码的时候,我们只能知道业界约定0
表示成功,但其他的错误码就不知道什么含义了,比如这里的25
号错误码,非常突兀,根本不知道它是什么含义。
C中的解决的办法就是定义宏,又有宏是预编译期进行替换的,因此它在编译的时候一定是作为常量存在的,我们又可以通过宏名称来增加可读性:
#define ERR_DATA_NOT_FOUNT 25
#define SUCC 0
using err_t = int;
err_t Process() {
// 某些错误
return ERR_DATA_NOT_FOUNT;
// ...
return SUCC;
}
(对于错误码的场景当然还可以用枚举来实现,这里就不再赘述了。)
用宏虽然可以解决魔数问题,但是宏本身是不推荐使用的,详情大家可以参考前面“宏”的章节,里面介绍了很多宏滥用的情况。
不过最主要的一点就是宏不是类型安全的。我们既希望定义一个类型安全的数据,又不希望这个数据成为“变量”来占用内存空间。这时,就可以使用C++11引入的constexpr
概念。
constexpr double pi = 3.141592654;
double Squ(double r) {
return pi * r * r;
}
这里的pi
虽然是double
类型的,类型安全,但因为用constexpr
修饰了,因此它会在编译期间成为“常量”,而不会占用内存空间。
用constexpr
修饰的表达式,会保留其原有的作用域和类型(例如上面的pi
就跟全局变量的作用域是一样的),只是会变成编译期常量。
既然constexpr
叫“常量表达式”,那么也就是说有一些编译期参数只能用常量,用constexpr
修饰的表达式也可以充当。
举例来说,模板参数必须是一个编译期确定的量,那么除了常量外,constexpr
修饰的表达式也可以:
template <int N>
struct Array {
int data[N];
};
constexpr int default_size = 16;
const int g_size = 8;
void Demo() {
Array<8> a1; // 常量OK
Array<default_size> a2; // 常量表达式OK
Array<g_size> a3; // ERR,非常量不可以,只读变量不是常量
}
至于其他类型的表达式,也支持constexpr
,原则在于它必须要是编译期可以确定的类型,比如说POD类型:
constexpr int arr[] {1, 2, 3};
constexpr std::array<int> arr2 {1, 2, 3};
void f() {}
constexpr void (*fp)() = f;
constexpr const char *str = "abc123";
int g_val = 5;
constexpr int *pg = &g_val;
这里可能有一些和直觉不太一样的地方,我来解释一下。首先,数组类型是编译期可确定的(你可以单纯理解为一组数,使用时按对应位置替换为值,并不会真的分配空间)。
std::array
是POD类型,那么就跟普通的结构体、数组一样,所以都可以作为编译期常量。
后面几个指针需要重点解释一下。用constexpr
修饰的除了可以是绝对的常量外,在编译期能确定的量也可以视为常量。比如这里的fp
,由于函数f
的地址,在运行期间是不会改变的,编译期间尽管不能确定其绝对地址,但可以确定它的相对地址,那么作为函数指针fp
,它就是f
将要保存的地址,所以,这就是编译期可以确定的量,也可用constexpr
修饰。
同理,str
指向的是一个字符串常量,字符串常量同样是有一个固定存放地址的,位置不会改变,所以用于指向这个数据的指针str
也可以用constexpr
修饰。要注意的是:constexpr
表达式有固定的书写位置,**与const
的位置不一定相同。比如说这里如果定义只读变量应该是const char *const str
,后面的const
修饰str
,前面的const
修饰char
。但换成常量表达式时,constexpr
要放在最前,因此不能写成const char *constexpr str
,而是要写成constexpr const char *str
。当然,少了这个const
也是不对的,因为不仅是指针不可变,指针所指数据也不可变。这个也是C++中推荐的定义字符串常量别名的方式,优于宏定义。
最后的这个pg
也是一样的道理,因为全局变量的地址也是固定的,运行期间不会改变,因此pg
也可以用常量表达式。
当然,如果运行期间可能发生改变的量(也就是编译期间不能确定的量)就不可以用常量表达式,例如:
void Demo() {
int a;
constexpr int *p = &a; // ERR,局部变量地址编译期间不能确定
static int b;
constexpr int *p2 = &b; // OK,静态变量地址可以确定
constexpr std::string str = "abc"; // ERR,非平凡POD类型不能编译期确定内部行为
}
希望读者看到这一节标题的时候不要崩溃,C++就是这么难以捉摸。
没错,虽然constexpr
已经是常量表达式了,但是用constexpr
修饰变量的时候,它仍然是“定义变量”的语法,因此C++希望它能够兼容只读变量的情况。
当且仅当一种情况下,constexpr
定义的变量会真的成为变量,那就是这个变量被取址的时候:
void Demo() {
constexpr int a = 5;
int *p = &a; // 会让a退化为const int类型
}
道理也很简单,因为只有变量才能取址。上面例子中,由于对a
进行了取地址操作,因此,a
不得不真正成为一个变量,也就是变为const int
类型。
那另一个问题就出现了,如果说,我对一个常量表达式既取了地址,又用到编译期语法中了怎么办?
template <int N>
struct Test {};
void Demo() {
constexpr int a = 5;
Test<a> t; // 用做常量
int *p = &a; // 用做变量
}
没关系,编译器会让它在编译期视为常量去给那些编译期语法(比如模板实例化)使用,之后,再把它用作变量写到内存中。
换句话说,在编译期,这里的a
相当于一个宏,所有的编译期语法会用5
替换a
,Test
就变成了Test<5>
。之后,又会让a
成为一个只读变量写到内存中,也就变成了const int a = 5;
那么int *p = &a;
自然就是合法的了。
“就地构造”这个词本身就很C++。很多程序员都能发现,到处纠结对象有没有拷贝,纠结出参还是返回值的只有C++程序员。
无奈,C++确实没法完全摆脱底层考虑,C++程序员也会更倾向于高性能代码的编写。当出现嵌套结构的时候,就会考虑复制问题了。
举个最简单的例子,给一个vector
进行push_back
操作时,会发生一次复制:
struct Test {
int a, b;
};
void Demo() {
std::vector<Test> ve;
ve.push_back(Test{1, 2}); // 用1,2构造临时对象,再移动构造
}
原因就在于,push_back
的原型是:
template <typename T>
void vector<T>::push_back(const T &);
template <typename T>
void vector<T>::push_back(T &&);
如果传入左值,则会进行拷贝构造,传入右值会移动构造。但是对于Test
来说,无论深浅复制,都是相同的复制。这多构造一次Test
临时对象本身就是多余的。
既然,我们已经有{1, 2}
的构造参数了,能否想办法跳过这一次临时对象,而是直接在vector
末尾的空间上进行构造呢?这就涉及了就地构造的问题。我们在前面“new和delete”的章节介绍过,“分配空间”和“构造对象”的步骤可以拆解开来做。首先对vector
的buffer进行扩容(如果需要的话),确定了要放置新对象的空间以后,直接使用placement new
进行就地构造。
比如针对Test
的vector
我们可以这样写:
template <>
void vector<Test>::emplace_back(int a, int b) {
// 需要时扩容
// new_ptr表示末尾为新对象分配的空间
new(new_ptr) Test{a, b};
}
STL中把容器的就地构造方法叫做emplace
,原理就是通过传递构造参数,直接在对应位置就地构造。所以更加通用的方法应该是:
template <typename T, typename... Args>
void vector<T>::emplace_back(Args &&...args) {
// new_ptr表示末尾为新对象分配的空间
new(new_ptr) T{std::forward<Args>(args)...};
}
就地构造确实能在一定程度上解决多余的对象复制问题,但如果是嵌套形式就实则没办法了,举例来说:
struct Test {
int a, b;
};
void Demo() {
std::vector<std::tuple<int, Test>> ve;
ve.emplace_back(1, Test{1, 2}); // tuple嵌套的Test没法就地构造
}
也就是说,我们没法在就地构造对象时对参数再就地构造。
这件事情放在map
或者unordered_map
上更加有趣,因为这两个容器的成员都是std::pair
,所以对它进行emplace
的时候,就地构造的是pair
而不是内部的对象:
struct Test {
int a, b;
};
void Demo() {
std::map<int, Test> ma;
ma.emplace(1, Test{1, 2}); // 这里emplace的对象是pair
}
不过好在,map
和unordered_map
提供了try_emplace
方法,可以在一定程度上解决这个问题,函数原型是:
template <typename K, typename V, typename... Args>
std::pair<iterator, bool> map<K, V>::try_emplace(const K &key, Args &&...args);
这里把key
和value
拆开了,前者还是只能通过复制的方式传递,但后者可以就地构造。(实际使用时,value
更需要就地构造,一般来说key
都是整数、字符串这些。)那么我们可用它代替emplace
:
void Demo() {
std::map<int, Test> ma;
ma.try_emplace(1, 1, 2); // 1, 2用于构造Test
}
但看这个函数名也能猜到,它是“不覆盖逻辑”。也就是如果容器中已有对应的key
,则不会覆盖。返回值中第一项表示对应项迭代器(如果是新增,就返回新增这一条的迭代器,如果是已有key
则放弃新增,并返回原项的迭代器),第二项表示是否成功新增(如果已有key
会返回false
)。
void Demo() {
std::map<int, Test> ma {{1, Test{1, 2}}};
auto [iter, is_insert] = ma.try_emplace(1, 7, 8);
auto ¤t_test = iter->second;
std::cout << current_test.a << ", " << current_test.b << std::endl; // 会打印1, 2
}
不过有一些场景利用try_emplace
会很方便,比如处理多重key
时使用map
嵌套map
的场景,如果用emplace
要写成:
void Demo() {
std::map<int, std::map<int, std::string>> ma;
// 例如想给key为(1, 2)新增value为"abc"的
// 由于无法确定外层key为1是否已经有了,所以要单独判断
if (ma.count(1) == 0) {
ma.emplace(1, std::map<int, std::string>{});
}
ma.at(1).emplace(1, "abc");
}
但是利用try_emplace
就可以更取巧一些:
void Demo() {
std::map<int, std::map<int, std::string>> ma;
ma.try_emplace(1).first->second.try_emplace(1, "abc");
}
解释一下,如果ma
含有key
为1
的项,就返回对应迭代器,如果没有的话则会新增(由于没指定后面的参数,所以会构造一个空map
),并返回迭代器。迭代器在返回值的第一项,所以取first
得到迭代器,迭代器指向的是map
内部的pair
,取second
得到内部的map
,再对其进行一次try_emplace
插入内部的元素。
当然了,这么做确实可读性会下降很多,具体使用时还需要自行取舍。
第六篇已脱稿,请看C++的缺陷和思考(六)