C++的缺陷和思考(五)

本文继续来介绍C++的缺陷和笔者的一些思考。先序文章请看
C++的缺陷和思考(四)
C++的缺陷和思考(三)
C++的缺陷和思考(二)
C++的缺陷和思考(一)

new和delete

new这个运算符相信大家一定不陌生,即便是非C++系其他语言一般都会保留new这个关键字。而且这个已经成为业界的一个哏了,比如说“没有对象怎么办?不怕,new一个!”
从字面意思就能看得出,这是“新建”的意思,不过在C++中,new远不止字面看上去这么简单。而且,delete关键字基本算得上是C++的特色了,其他语言中基本见不到。

分配和释放空间

“堆空间”的概念同样继承自C语言,它是提供给程序手动管理、调用的内存空间。在C语言中,malloc用于分配堆空间,free用于回收。自然,在C++中仍然可以用mallocfree
但使用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)
既然newdelete也是运算符,那么它就应当也符合这个原理,一定有一个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 newoperator delete的话,本质上来说,和mallocfree其实没什么区别:

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);

重载new和delete

之所以要引入operator newoperator 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 newoperator delete是支持异常抛出的,而我们这里引用直接用空指针来表示分配失败的情况了,于是加上了noexcept修饰。而默认的情况下,可以通过接收异常来判断是否分配成功,而不用每次都对指针进行判空。

构造函数和placement new

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步:

  1. 分配内存空间
  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); // 释放内存空间

new = operator new + placement new

看到本节的标题,相信读者会恍然大悟。C++中new运算符同时承担了“分配空间”和“构造对象”的任务。上一节的例子中我们是通过mallocfree来管理的,自然,通过operator newoperator 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);

这就是newdelete的神秘面纱,它确实和普通的运算符不一样,除了对应的operator函数外,还有对构造、析构的处理。
但也正是由于C++总是进行一些隐藏操作,才会复杂度激增,有时也会出现一些难以发现的问题,所以我们一定要弄清楚它的本质。

new []和delete []

new []delete []的语法看起来是“创建/删除数组”的语法。但其实它们也并不特殊,就是封装了一层的newdelete

void *operator new[](size_t size);
void operator delete[](void *ptr);

可以看出,operator new[]operator new完全一样,opeator delete[]operator delete也完全一样,所以区别应当在编译器的解释上。
operator new T[size]的时候,会计算出sizeT类型的总大小,然后调用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

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修饰的表达式也可以充当。
举例来说,模板参数必须是一个编译期确定的量,那么除了常量外,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类型不能编译期确定内部行为
}

constexpr表达式也可能变成变量

希望读者看到这一节标题的时候不要崩溃,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替换aTest<a>就变成了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进行就地构造。
比如针对Testvector我们可以这样写:

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<int, Test>
}

不过好在,mapunordered_map提供了try_emplace方法,可以在一定程度上解决这个问题,函数原型是:

template <typename K, typename V, typename... Args>
std::pair<iterator, bool> map<K, V>::try_emplace(const K &key, Args &&...args);

这里把keyvalue拆开了,前者还是只能通过复制的方式传递,但后者可以就地构造。(实际使用时,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 &current_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含有key1的项,就返回对应迭代器,如果没有的话则会新增(由于没指定后面的参数,所以会构造一个空map),并返回迭代器。迭代器在返回值的第一项,所以取first得到迭代器,迭代器指向的是map内部的pair,取second得到内部的map,再对其进行一次try_emplace插入内部的元素。
当然了,这么做确实可读性会下降很多,具体使用时还需要自行取舍。

第六篇已脱稿,请看C++的缺陷和思考(六)

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

borehole打洞哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值