C++避坑指南(十二)

C 风格字符串

字符串同样是 C++特别容易踩坑的位置。出于对 C 语言兼容、以及上一节所介绍的 C++希望将“语言”和“类型”解耦的设计理念的目的,在 C++中,字符串并没有映射为std::string类型,而是保留 C 语言当中的处理方式。编译期会将字符串常量存储在一个全局区,然后再使用字符串常量的位置用一个指针代替。所以基本可以等价认为,字符串常量(字面量)是const char *类型。

但是,更多的场景下,我们都会使用std::string类型来保存和处理字符串,因为它功能更强大,使用更方便。得益于隐式构造,我们可以把一个字符串常量轻松转化为std::string类型来处理。

但本质上来说,std::stringconst char *是两种类型,所以一些场景下它还是会出问题。

类型推导问题

在进行类型推导时,字符串常量会按const char *来处理,有时会导致问题,比如:

template <typename T> void f(T t) {   std::cout << 1 << std::endl; } template <typename T> void f(T *t) {   std::cout << 2 << std::endl; } void Demo() {   f("123");   f(std::string{"123"}); }

代码的原意是将“值类型”和“指针类型”分开处理,至于字符串,照理说应当是一个“对象”,所以要按照值类型来处理。但如果我们用的是字符串常量,则会识别为const char *类型,直接匹配到了指针处理方式,而并不会触发隐式构造。

截断问题

C 风格字符串有一个约定,就是以 0 结尾。它并不会去单独存储数据长度,而是很暴力地从首地址向后查找,找到 0 为止。但std::string不同,其内部有统计个数的成员,因此不会受 0 值得影响:

std::string str1{"123\0abc"}; // 0处会截断 std::string str2{"123\0abc", 7}; // 不会截断

截断问题在传参时更加明显,比如说:

void f(const char *str) {} void Demo() {   std::string str2{"123\0abc", 7};   // 由于f只支持C风格字符串,因此转化后传入   f(str2.c_str()); // 但其实已经被截断了 }

前面的章节曾经提到过,C++没有引入额外的格式符,因此把std::string传入格式化函数的时候,也容易发生截断问题:

std::string MakeDesc(const std::string &head, double data) {   // 拼凑一个xxx:ff%的形式   char buf[128];   std::sprintf(buf, "%s:%lf%%", head.c_str(), data); // 这里有可能截断   return buf; // 这里也有可能截断 }

总之,C 风格的字符串永远难逃 0 值截断问题,而又因为 C++中仍然保留了 C 风格字符串的所有行为,并没有在语言层面直接关联std::string,因此在使用时一定要小心截断问题。

指针意义不明问题

由于 C++保留了 C 风格字符串的行为,因此在很多场景下,把const char *就默认为了字符串,都会按照字符串去解析。但有时可能会遇到一个真正的指针,那么此时就会有问题,比如说:

void Demo() {   int a;   char b;   std::cout << &a << std::endl; // 流接受指针,打印指针的值   std::cout << &b << std::endl; // 流接收char *,按字符串处理 }

STL 中所有流接收到char *const char *时,并不会按指针来解析,而是按照字符串解析。在上面例子中,&b本身应当就是个单纯指针,但是输出流却将其按照字符串处理了,也就是会持续向后搜索找到 0 值为止,那这里显然是发生越界了。

因此,如果我们给charsigned charunsigned char类型取地址时,一定要考虑会不会被识别为字符串。

int8_t 和 uint8_t

原本int8_tuint8_t是用来表示“8 位整数”的,但是不巧的是,他们的定义是:

using int8_t = signed char; using uint8_t = unsigned char;

由于 C 语言历史原因,ASCII 码只有 7 位,所以“字符”类型有无符号是没区别的,而当时没有定制规范,因此不同编译器可能有不同处理。到后来干脆把char当做独立类型了。所以charsigned char以及unsigned char是不同类型。这与其他类型不同,例如intsigned int是同一类型。

但是类似于流的处理中,却没有把signed charunsigned char单独拿出来处理,都是按照字符来处理了(这里笔者也不知道什么原因)。而int8_tuint8_t又是基于此定义的,所以也会出现奇怪问题,比如:

uint8_t n = 56; // 这里是单纯想放一个整数 std::cout << n << std::endl; // 但这里会打印出8,而不是56

原本uint8_t是想屏蔽掉char这层含义,让它单纯地表示 8 位整数的,但是在 STL 的解析中,却又让它有了“字符”的含义,去按照 ASCII 码来解析了,让uint8_t的定义又失去了原本该有的含义,所以这里也是很容易踩坑的地方。

(这一点笔者真的没想明白为什么,明明是不同类型,但为什么没有区分开。可能同样是历史原因吧,总之这个点可以算得上真正意义上的“缺陷”了。)

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

  1. 调用析构函数

  2. 释放内存空间

这个理念在 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;   const int *p = &a; // 会让a退化为const int类型 }

道理也很简单,因为只有变量才能取址。上面例子中,由于对a进行了取地址操作,因此,a不得不真正成为一个变量,也就是变为const int类型。

那另一个问题就出现了,如果说,我对一个常量表达式既取了地址,又用到编译期语法中了怎么办?

template <int N> struct Test {}; void Demo() {   constexpr int a = 5;   Test<a> t; // 用做常量   const int *p = &a; // 用做变量 }

没关系,编译器会让它在编译期视为常量去给那些编译期语法(比如模板实例化)使用,之后,再把它用作变量写到内存中。

换句话说,在编译期,这里的a相当于一个宏,所有的编译期语法会用5替换aTest<a>就变成了Test<5>。之后,又会让a成为一个只读变量写到内存中,也就变成了const int a = 5;那么const 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”的章节介绍过,“分配空间”和“构造对象”的步骤可以拆解开来做。首先对vectorbuffer进行扩容(如果需要的话),确定了要放置新对象的空间以后,直接使用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++适不适合入门?C++适不适合干活?我学 C++跟我学 java 哪个更赚钱啊?

笔者持有这样的观点:C++并不是最适合生产的语言,但 C++一定是最值得学习的语言

如果说你单纯就是想干活,享受产出的快乐,那我不建议你学 C++,因为太容易劝退,找一些新语言,语法简单清晰容易上手,自然干活效率会高很多;但如果你希望更多地理解编程语言,全面了解一些自底层到上层的原理和进程,希望享受研究和开悟的快乐,那非 C++莫属了。掌握了 C++再去看其他语言,相信你一定会有不同的见解的。

所以到现在这个时间点,应该说,C++仍然还是我的信仰,我认为 C++将会在将来很长一段时间存在,并且以一个长老的身份发挥其在业界的作用和价值,但同时也会有越来越多新语言的诞生,他们在自己适合的地方发挥着不一样的光彩。我也不再会否认 C++的确有设计不合理的地方,不会否认其存在不擅长的领域,也不会再去鄙视那些吐槽 C++复杂的人。与此同时,我也不会拒绝涉足其他的领域,我认为,只有不断学习比较,不断总结沉淀,才能持续进步。

如果你能读到这里的话,那非常感激你的支持,听我说谢谢你,因为有你……咳咳~。这篇文章作为我学习 C++多年的一个沉淀,也希望借此把我的想法分享给读者,如果你有任何疑问或者建议,欢迎评论区留言!针对更多 C++的特性的用法、编程技巧等内容,请期待我其他系列的文章。

往期**精彩**回顾

string底层实现之COW

string 性能优化之存储:栈或者堆

惯用法之CRTP

聊聊内存模型与内存序

vector初始化与否导致的巨大性能差异

问题解决了,我却不知道原因

揭开lambda的神秘面纱

多态实现-虚函数、函数指针以及变体

【Modern C++】深入理解移动语义

【Modern C++】深入理解左值、右值

智能指针-使用、避坑和实现

内存泄漏-原因、避免以及定位

GDB调试-从入门实践到原理

【线上问题】P1级公司故障,年终奖不保

【性能优化】高效内存池的设计与实现

2万字|30张图带你领略glibc内存管理精髓

关注公众号【高性能架构探索】,第一时间获取最新文章;公众号内回复【pdf】,免费获取计算机经典书籍
本文来自于作者公众号文章转载,链接:c++避坑指南

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/1270

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2022年11月2日 下午12:25
下一篇 2022年11月2日 下午2:59

相关推荐