关注公众号【高性能架构探索】,第一时间获取最新文章;公众号内回复【pdf】,免费获取经典书籍
C 风格字符串
字符串同样是 C++特别容易踩坑的位置。出于对 C 语言兼容、以及上一节所介绍的 C++希望将“语言”和“类型”解耦的设计理念的目的,在 C++中,字符串并没有映射为std::string
类型,而是保留 C 语言当中的处理方式。编译期会将字符串常量存储在一个全局区,然后再使用字符串常量的位置用一个指针代替。所以基本可以等价认为,字符串常量(字面量)是const char *
类型。
但是,更多的场景下,我们都会使用std::string
类型来保存和处理字符串,因为它功能更强大,使用更方便。得益于隐式构造,我们可以把一个字符串常量轻松转化为std::string
类型来处理。
但本质上来说,std::string
和const 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 值为止,那这里显然是发生越界了。
因此,如果我们给char
、signed char
、unsigned char
类型取地址时,一定要考虑会不会被识别为字符串。
int8_t 和 uint8_t
原本int8_t
和uint8_t
是用来表示“8 位整数”的,但是不巧的是,他们的定义是:
using int8_t = signed char; using uint8_t = unsigned char;
由于 C 语言历史原因,ASCII 码只有 7 位,所以“字符”类型有无符号是没区别的,而当时没有定制规范,因此不同编译器可能有不同处理。到后来干脆把char
当做独立类型了。所以char
和signed char
以及unsigned char
是不同类型。这与其他类型不同,例如int
和signed int
是同一类型。
但是类似于流的处理中,却没有把signed char
和unsigned char
单独拿出来处理,都是按照字符来处理了(这里笔者也不知道什么原因)。而int8_t
和uint8_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++中仍然可以用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);
重载 new 和 delete
之所以要引入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
修饰。而默认的情况下,可以通过接收异常来判断是否分配成功,而不用每次都对指针进行判空。
构造函数和 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 步:
-
分配内存空间
-
调用构造函数
同样,释放一个对象也应该分 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
运算符同时承担了“分配空间”和“构造对象”的任务。上一节的例子中我们是通过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 []
的语法看起来是“创建/删除数组”的语法。但其实它们也并不特殊,就是封装了一层的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
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
替换a
,Test<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”的章节介绍过,“分配空间”和“构造对象”的步骤可以拆解开来做。首先对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<int, Test> }
不过好在,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++适不适合入门?C++适不适合干活?我学 C++跟我学 java 哪个更赚钱啊?
笔者持有这样的观点:C++并不是最适合生产的语言,但 C++一定是最值得学习的语言。
如果说你单纯就是想干活,享受产出的快乐,那我不建议你学 C++,因为太容易劝退,找一些新语言,语法简单清晰容易上手,自然干活效率会高很多;但如果你希望更多地理解编程语言,全面了解一些自底层到上层的原理和进程,希望享受研究和开悟的快乐,那非 C++莫属了。掌握了 C++再去看其他语言,相信你一定会有不同的见解的。
所以到现在这个时间点,应该说,C++仍然还是我的信仰,我认为 C++将会在将来很长一段时间存在,并且以一个长老的身份发挥其在业界的作用和价值,但同时也会有越来越多新语言的诞生,他们在自己适合的地方发挥着不一样的光彩。我也不再会否认 C++的确有设计不合理的地方,不会否认其存在不擅长的领域,也不会再去鄙视那些吐槽 C++复杂的人。与此同时,我也不会拒绝涉足其他的领域,我认为,只有不断学习比较,不断总结沉淀,才能持续进步。
如果你能读到这里的话,那非常感激你的支持,听我说谢谢你,因为有你……咳咳~。这篇文章作为我学习 C++多年的一个沉淀,也希望借此把我的想法分享给读者,如果你有任何疑问或者建议,欢迎评论区留言!针对更多 C++的特性的用法、编程技巧等内容,请期待我其他系列的文章。
往期**精彩**回顾
关注公众号【高性能架构探索】,第一时间获取最新文章;公众号内回复【pdf】,免费获取计算机经典书籍
本文来自于作者公众号文章转载,链接:c++避坑指南
原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/1270
非原创文章文中已经注明原地址,如有侵权,联系删除
关注公众号【高性能架构探索】,第一时间获取最新文章
转载文章受原作者版权保护。转载请注明原作者出处!