导读 | 在万字避坑指南!C++的缺点与思考(上)一文中,微信后盾开发工程师胡博豪,分享了C++的倒退历史、右值援用与挪动语义、类型说明符等内容,深受宽广开发者青睐!此篇,咱们邀请作者持续总结其在C++开发过程中对一些奇怪、简单的语法的了解和思考,分享C++开发的避坑指南。

static

我在后面章节吐槽了const这个命名,也吐槽了“右值援用”这个命名。那么static就是笔者下一个要重点吐槽的命名了。static这个词自身没有什么问题,其次要的槽点就在于“一词多用”,也就是说,这个词在不同场景下示意的是齐全不同的含意。(作者可能是出于节俭关键词的目标吧,明明是不同的含意,却没有用不同的关键词)。

第一,在局部变量前的static,限定的是变量的生命周期。

第二,在全局变量/函数前的static,限定的是变量/函数的作用域。

第三,在成员变量前的static,限定的是成员变量的生命周期。

第四在成员函数前的static,限定的是成员函数的调用方(或暗藏参数)。

下面是static关键字的4种不同含意,接下来我会逐个解释。

1)动态局部变量

当用static润饰局部变量时,static示意其生命周期:

void f() {  static int count = 0;  count++;}

上述例子中,count是一个局部变量,既然曾经是“局部变量”了,那么它的作用域很显著,就是f函数外部,而这里的static示意的是其生命周期。一般的全局变量在其所在函数(或代码块)完结时会被开释,而用static润饰的则不会,咱们将其称为“动态局部变量”。动态局部变量会在首次执行到定义语句时初始化,在主函数执行完结后开释,在程序执行过程中遇到定义(和初始化)语句时会被疏忽。

void f() {   static int count = 0;   count++;   std::cout << count << std::endl;}int main(int argc, const char *argv[]) {  f(); // 第一次执行时count被定义,并且初始化为0,执行后count值为1,并且不会开释  f(); // 第二次执行时因为count曾经存在,因而初始化语句会忽视,执行后count值为2,并且不会开释  f(); // 同上,执行后count值为3,不会开释} // 主函数执行完结后会开释f中的count

例如下面例程的输入后果会是:

123

2)外部全局变量/函数

当static润饰全局变量或函数时,用于限定其作用域为“以后文件内”。同理,因为曾经是“全局”变量了,生命周期肯定是合乎全局的,也就是“主函数执行前结构,主函数执行完结后开释”。至于全局函数就不用说了,函数都是全局生命周期的。因而,这时候的static不会再对生命周期有影响,而是限定了其作用域。与之对应的是extern。用extern润饰的全局变量/函数作用于整个程序内,换句话说,就是能够跨文件。

// a1.ccint g_val = 4; // 定义全局变量// a2.ccextern int g_val; // 申明全局变量void Demo() {  std::cout << g_val << std::endl; // 应用了在另一个文件中定义的全局变量}

而用static润饰的全局变量/函数则只能在以后文件中应用,不同文件间的static全局变量/函数能够同名,并且相互独立。

// a1.ccstatic int s_val1 = 1; // 定义外部全局变量static int s_val2 = 2; // 定义外部全局变量static void f1() {} // 定义外部函数// a2.ccstatic int s_val1 = 6; // 定义外部全局变量,与a1.cc中的互不影响static int s_val2; // 这里会视为定义了新的外部全局变量,而不会视为“申明”static void f1(); // 申明了一个外部函数void Demo() {  std::cout << s_val1 << std::endl; // 输入6,与a1.cc中的s_val1没有关系  std::cout << s_val2 << std::endl; // 输入0,同样不会拜访到a1.cc中的s_val2  f1(); // ERR,这里链接会报错,因为在a2.cc中没有找到f1的定义,并不会链接到a1.cc中的f1}

所以咱们发现,在这种场景下,static并不示意“动态”的含意,而是示意“外部”的含意,所以,为什么不再引入个相似于inner的关键字呢?这里很容易让程序员造成蛊惑。

3)动态成员变量

动态成员变量指的是用static润饰的成员变量。一般的成员变量其生命周期是跟其所属对象绑定的。结构对象时结构成员变量,析构对象时开释成员变量。

struct Test {  int a; // 一般成员变量};int main(int argc, const char *argv[]) {  Test t; // 同时结构t.a  auto t2 = new Test; // 同时结构t2->a  delete t2; // t2所指对象析构,同时开释t2->a} // t析构,同时开释t.a

而用static润饰后,其申明周期变为全局,也就是“主函数执行前结构,主函数执行完结后开释”,并且不再追随对象,而是全局一份。

struct Test {  static int a; // 动态成员变量(根本等同于申明全局变量)};int Test::a = 5; // 初始化动态成员变量(主函数前执行,根本等同于初始化全局变量)int main(int argc, const char *argv[]) {  std::cout << Test::a << std::endl; // 间接拜访动态成员变量  Test t;  std::cout << t.a << std::endl; // 通过任意对象实例拜访动态成员变量} // 主函数完结时开释Test::a

所以动态成员变量根本就相当于一个全局变量,而这时的类更像一个命名空间了。惟一的区别在于,通过类的实例(对象)也能够拜访到这个动态成员变量,就像下面的t.a和Test::a齐全等价。

4)动态成员函数

static关键字润饰在成员函数后面,称为“动态成员函数”。咱们晓得一般的成员函数要以对象为主调方,对象自身其实是函数的一个暗藏参数(this指针):

struct Test {  int a;  void f(); // 非动态成员函数};void Test::f() {  std::cout << this->a << std::endl;}void Demo() {  Test t;  t.f(); // 用对象主调成员函数}

下面其实等价于:

struct Test {  int a;};void f(Test *this) {  std::cout << this->a << std::endl;}void Demo() {  Test t;  f(&t); // 其实对象就是函数的暗藏参数}

也就是说,obj.f(arg)实质上就是f(&obj, arg),并且这个参数强制叫做this。这个个性在Go语言中尤为显著,Go不反对封装到类内的成员函数,也不会主动增加暗藏参数,这些行为都是显式的:

type Test struct {  a int}func(t *Test) f() {  fmt.Println(t.a) }func Demo() {  t := new(Test)  t.f()}

回到C++的动态成员函数这里来。用static润饰的成员函数示意“不须要对象作为主调方”,也就是说没有那个暗藏的this参数。

struct Test {  int a;  static void f(); // 动态成员函数};void Test::f() {  // 没有this,没有对象,只能做对象无关操作  // 也能够操作动态成员变量和其余动态成员函数}

能够看出,这时的动态成员函数,其实就相当于一个一般函数而已。这时的类同样相当于一个命名空间,而区别在于,如果这个函数传入了同类型的参数时,能够拜访公有成员,例如:

class Test { public:   static void f(const Test &t1, const Test &t2); // 动态成员函数 private:   int a; // 公有成员};void Test::f(const Test &t1, const Test &t2) {  // t1和t2是通过参数传进来的,但因为是Test类型,因而能够拜访其公有成员  std::cout << t1.a + t2.a << std::endl;}

或者咱们能够把动态成员函数了解为一个友元函数,只不过从设计角度上来说,与这个类型的关联度应该是更高的。然而从语法层面来解释,根本相当于“写在类里的一般函数”。

5)小结

其实C++中static造成的蛊惑,同样也是因为C中的缺点被放大导致的。毕竟在C中不存在结构、析构和援用链的问题。说到这个援用链,其实C++中的动态成员变量、动态局部变量和全局变量还存在一个链路程序问题,可能会导致内存反复开释、拜访野指针等状况的产生。这部分的内容详见前面“平庸、规范布局”的章节。总之,咱们须要理解static关键字有多义性,理解其在不同场景下的不同含意,更有助于咱们了解C++语言,避免踩坑。

平庸、规范布局

前阵子我和一个共事对这样一个问题进行了十分强烈的探讨:

到底应不应该定义std::string类型的全局变量

这个问题乍一看如同没什么值得探讨的中央,我置信很多程序员都在不经意间写过相似的代码,并且的确没有发现什么执行上的问题,所以可能素来没有意识到,这件事还有可能出什么问题。咱们和我共事之所以强烈探讨这个问题,所有的本源来源于谷歌的C++编程标准,其中有一条是:

Static or global variables of class type are forbidden: they cause hard-to-find bugs due to indeterminate order of construction and destruction.Objects with static storage duration, including global variables, static variables, static class member variables, and function static variables, must be Plain Old Data (POD): only ints, chars, floats, or pointers, or arrays/structs of POD.

大抵翻译一下就是说:不容许非POD类型的全局变量、动态全局变量、动态成员变量和动态局部变量,因为可能会导致难以定位的bug。 而std::string是非POD类型的,天然,依照标准,也不容许std::string类型的全局变量。(公司编程标准中并没有间接限度POD类型,而是限度了非平庸析构,它的确会比谷歌标准中用POD一刀砍会正当得多,但笔者依然感觉其实限度依然能够持续再放开些。能够参考公司C++编程标准第3.5条)

然而如果咱们真的写了,貌似也素来没有遇到过什么问题,程序也不会呈现任何bug或者异样,甚至上面的几种写法都是在日常开发中常常遇到的,但都不合乎这谷歌的这条代码标准。

全局字符串

const std::string ip = "127.0.0.1";const uint16_t port = 80;void Demo() {  // 开启某个网络连接  SocketSvr svr{ip, port};  // 记录日志  WriteLog("net linked: ip:port={%s:%hu}", ip.c_str(), port);}

动态映射表

std::string GetDesc(int code) {  static const std::unordered_map<int, std::string> ma {    {0, "SUCCESS"},    {1, "DATA_NOT_FOUND"},    {2, "STYLE_ILLEGEL"},    {-1, "SYSTEM_ERR"}  };  if (auto res = ma.find(code); res != ma.end()) {    return res->second;  }  return "UNKNOWN";}

单例模式

class SingleObj { public:  SingleObj &GetInstance();  SingleObj(const SingleObj &) = delete;  SingleObj &operator =(const SingleObj &) = delete; private:   SingleObj();   ~SingleObj();};SingleObj &SingleObj::GetInstance() {  static SingleObj single_obj;  return single_obj;}

下面的几个例子都存在“非POD类型全局或动态变量”的状况。

1)全局、动态的生命周期问题

既然谷歌标准中禁止这种状况,那肯定意味着,这种写法存在潜在危险,咱们须要搞明确危险点在哪里。首先明确变量生命周期的问题:

第一,全局变量和动态成员变量在主函数执行前结构,在主函数执行完结后开释;

第二,动态局部变量在第一次执行到定义地位时结构,在主函数执行后开释。

这件事如果在C语言中,并没有什么问题,设计也很正当。然而C++就是这样悲催,很多C当中正当的问题在C++中会变得不合理,并且缺点会被放大。

因为C当中的变量仅仅是数据,因而,它的“结构”和“开释”都没有什么副作用。但在C++当中,“结构”是要调用构造函数来实现的,“开释”之前也是要先调用析构函数。这就是问题所在!照理说,主函数应该是程序入口,那么在主函数之前不应该调用任何自定义的函数才对。但这件事放到C++当中就不肯定成立了,咱们看一下上面例程:

class Test { public:  Test();  ~Test();};Test::Test() {  std::cout << "create" << std::endl;}Test::~Test() {  std::cout << "destroy" << std::endl;}Test g_test; // 全局变量int main(int argc, const char *argv[]) {  std::cout << "main function" << std::endl;  return 0;}

运行下面程序会失去以下输入:

createmain functiondestroy

也就是说,Test的构造函数在主函数前被调用了。解释起来也很简略,因为“全局变量在主函数执行之前结构,主函数执行完结后开释”,而因为Test类型是类类型,“结构”时要调用构造函数,“开释”时要调用析构函数。所以下面的景象也就不奇怪了。

这种单一个的全局变量其实并不会呈现什么问题,但如果有多变量的依赖,这件事就不可控了,比方上面例程:

test.h

struct Test1 {  int a;};extern Test1 g_test1; // 申明全局变量

test.cc

Test1 g_test1 {4}; // 定义全局变量

main.cc

#include "test.h"class Test2 { public:  Test2(const Test1 &test1); // 传Test1类型参数 private:  int m_;};Test2::Test2(const Test1 &test1): m_(test1.a) {}Test2 g_test2{g_test1}; // 用一个全局变量来初始化另一个全局变量int main(int argc, const char *argv) {  return 0;}

下面这种状况,程序编译、链接都是没问题的,但运行时会概率性出错,问题

就在于,g_test1和g_test2都是全局变量,并且是在不同文件中定义的,并且

因为全局变量结构在主函数前,因而其初始化程序是随机的

如果g_test1在g_test2之前初始化,那么整个程序不会呈现任何问题,但如果g_test2在g_test1前初始化,那么在Test2的构造函数中,失去的就是一个未初始化的test1援用,这时候拜访test1.a就是操作野指针了。

这时咱们就能发现,全局变量出问题的本源在于全局变量的初始化程序不可控,是随机的,因而,如果呈现依赖,则会导致问题。同理,析构产生在主函数后,那么析构程序也是随机的,可能出问题,比方:

struct Test1 {  int count;};class Test2 { public:  Test2(Test1 *test1);  ~Test2(); private:  Test1 *test1_;  };Test2::Test2(Test1 *test1): test1_(test1) {  test1_->count++;}Test2::~Test2() {  test1_->count--;}Test1 g_test1 {0}; // 全局变量void Demo() {  static Test2 t2{&g_test1}; // 动态局部变量}int main(int argc, const char *argv[]) {  Demo(); // 结构了t2  return 0;}

在下面示例中,结构t2的时候应用了g_test1,因为t2是动态局部变量,因而是在第一个调用时(主函数中调用Demo时)结构。这时曾经是主函数执行过程中了,因而g_test1曾经结构结束的,所以结构时不会呈现问题。

然而,动态成员变量是在主函数执行实现后析构,这和全局变量雷同,因而,t2和g_test1的析构程序无法控制。如果t2比g_test1先析构,那么不会呈现任何问题。但如果g_test1比t2先析构,那么在析构t2时,对test1_拜访count成员这一步,就会拜访野指针。因为test1_所指向的g_test1曾经后行析构了。

那么这个时候咱们就能够确定,全局变量、动态变量之间不能呈现依赖关系,否则,因为其结构、析构程序不可控,因而可能会呈现问题。

2)谷歌规范中的规定

回到咱们方才提到的谷歌规范,这里规范的制定者正是因为放心这样的问题产生,才禁止了非POD类型的全局或动态变量。但咱们剖析后得悉,也并不是说所有的类类型全局或动态变量都会呈现问题。

而且,谷歌标准中的“POD类型”的限定也过于宽泛了。所谓“POD类型”指的是“平庸”+“规范内存布局”,这里我来解释一下这两种性质,并且剖析剖析为什么谷歌规范容许POD类型的全局或动态变量。

3)平庸

“平庸(trivial)”指的是:

领有默认无参构造函数;

领有默认析构函数;

领有默认拷贝构造函数;

领有默认挪动构造函数;

领有默认拷贝赋值函数;

领有默认挪动赋值函数。

换句话说,六大非凡函数都是默认的。这里要辨别2个概念,咱们要的是“语法上的平庸”还是“实际意义上的平庸”。语法上的平庸就是说可能被编译期辨认、认可的平庸。而实际意义上的平庸就是说外面没有额定操作。比如说:

class Test1 { public:  Test1() = default; // 默认无参构造函数  Test1(const Test1 &) = default; // 默认拷贝构造函数  Test &operator =(const Test1 &) = default; // 默认拷贝赋值函数  ~Test1() = default; // 默认析构函数};class Test2 { public:  Test2() {} // 自定义无参构造函数,但理论内容为空  ~Test2() {std::printf("destory\n");} // 自定义析构函数,但理论内容只有打印};

下面的例子中,Test1就是个真正意义上的平庸类型,语法上是平庸的,因而编译器也会认为其是平庸的。咱们能够用STL中的工具来判断一个类型是否是平庸的:

bool is_test1_tri = std::is_trivial_v<Test1>; // true

但这里的Test2,因为咱们自定义了其无参构造函数和析构函数,那么对编译器来说,它就是非平庸的,咱们用std::is_trivial来判断也会失去false_value。但其实外部并没有什么外链操作,所以其实咱们把Test2类型定义全局变量时也不会呈现任何问题,这就是所谓“实际意义上的平庸”。

C++对“平庸”的定义比拟严格,但实际上咱们看看如果要做全局变量或动态变量的时候,是不须要这样严格定义的。对于全局变量来说,只有定义全局变量时,应用的是“实际意义上平庸”的构造函数,并且领有“实际意义上平庸”的析构函数,那这个全局变量定义就不会有任何问题。而对于动态局部变量来说,只有领有“实际意义上平庸”的析构函数的就肯定不会出问题。

4)规范内存布局

规范内存布局的定义是:

所有成员领有雷同的权限(比如说都public,或都protected,或都private);

不含虚基类、虚函数;

如果含有基类,基类必须都是规范内存布局;

如果函数成员变量,成员的类型也必须是规范内存布局。

咱们同样能够用STL中的std::is_standard_layout来判断一个类型是否是规范内存布局的。这里的定义比较简单,不在赘述。

  • POD(Plain Old Data)类型

所谓POD类型就是同时合乎“平庸”和“规范内存布局”的类型。合乎这个类型的根本就是根本数据类型,加上一个一般C语言的构造体。换句话说,合乎“旧类型(C语言中的类型)行为的类型”,它不存在虚函数指针、不存在虚表,能够视为一般二进制来操作的。

因而,在C++中,只有POD类型能够用memcpy这种二进制办法来复制而不会产生副作用,其余类型的都必须用用调用拷贝结构。

以前有人向笔者提出疑难,为何vector扩容时不间接用相似于memcpy的形式来复制,而是要以此调用拷贝结构。起因正是在此,对于非POD类型的对象,其中可能会蕴含虚表、虚函数指针等数据,复制时这些内容可能会重置,并且外部可能会含有一些相似于“计数”这样操作其余援用对象的行为,因为肯定要用拷贝构造函数来保障这些行为是失常的,而不能简略粗犷地用二进制形式进行拷贝。

STL中能够用std::is_pod来判断是个类型是否是POD的。

  • 小结

咱们再回到谷歌标准中,POD的限度比拟多,因而,的确POD类型的全局/动态变量是必定不会出问题的,但间接将非POD类型的一棍子打死,笔者集体认为有点过了,没必要。

所以,笔者认为更加准确的限定应该是:对于全局变量、动态成员变量来说,初始化时必须调用的是平庸的构造函数,并且其该当领有平庸的析构函数,而且这里的“平庸”是指实际意义上的平庸,也就是说能够自定义,然而在外部没有对任何其余的对象进行操作;对于动态局部变量来说,其该当领有平庸的析构函数,同样指的是实际意义上的平庸,也就是它的析构函数中没有对任何其余的对象进行操作。

最初举几个例子:

class Test1 { public:  Test1(int a): m_(a) {}  void show() const {std::printf("%d\n", m_);} private:  int m_;};class Test2 { public:  Test2(Test1 *t): m_(t) {}  Test2(int a): m_(nullptr) {}  ~Test2() {} private:  Test1 *m_;};class Test3 {  public:   Test3(const Test1 &t): m_(&t) {}   ~Test3() {m_->show();}  private:   Test1 *m_;};class Test4 { public:  Test4(int a): m_(a) {}  ~Test4() = default; private:  Test1 m_;};

Test1是非平庸的(因为无参构造函数没有定义),但它依然能够定义全局/动态变量,因为Test1(int)构造函数是“实际意义上平庸”的。

Test2是非平庸的,并且Test2(Test1 )构造函数须要援用其余类型,因而它不能通过Test2(Test1 )定义全局变量或动态成员变量,但能够通过Test2(int)来定义全局变量或动态成员变量,因为这是一个“实际意义上平庸”的构造函数。而且因为它的析构函数是“实际意义上平庸”的,因而Test2类型能够定义动态局部变量。

Test3是非平庸的,构造函数对Test1有援用,并且析构函数中调用了Test1::show办法,因而Test3类型不能用来定义部分/动态变量。

Test4也是非平庸的,并且外部存在同样非平庸的Test1类型成员,然而因为m1_不是援用或指针,肯定会随着Test4类型的对象的结构而结构,析构而析构,不存在程序依赖问题,因而Test4能够用来定义全局/动态变量。

  • 所以全局std::string变量到底能够不能够?

最初回到这个问题上,笔者认为定义一个全局的std::string类型的变量并不会呈现什么问题,在std::string的外部,数据空间是通过new的形式申请的,并且个别状况下都不会被其余全局变量所援用,在std::string对象析构时,对这片空间会进行delete,所以并不会呈现析构程序问题。

然而,如果你用的不是默认的内存分配器,而是自定义了内存分配器的话,那的确要思考结构析构程序的问题了,你要保障在对象结构前,内存分配器是存在的,并且内存分配器的析构要在所有对象之后。

当然了,如果你仅仅是想给字符串常量起个别名的话,有一种更好的形式:

constexpr const char *ip = "127.0.0.1";

毕竟指针肯定是平庸类型,而且用constexpr润饰后能够变为编译期常量。这里详情能够在前面“constexpr”的章节理解。

而至于其余类型的动态局部变量(比如说单例模式,或者部分内的map之类的映射表),只有让它不被析构就好了,所以能够用堆空间的形式:

static Test &Test::GetInstance() {  static Test &inst = *new Test;  return inst;}
std::string GetDesc(int code) {  static const auto &desc = *new std::map<int, std::string> {    {1, "desc1"},  {2, "desc2"},  };  auto iter = desc.find(code);  return iter == desc.end() ? "no_desc" : iter->second;}

5)非平庸析构类型的挪动语义

在探讨完平庸类型后,咱们发现平庸析构其实是更加值得关注的场景。这里就引申出非平庸析构的挪动语义问题,请看例程:

class Buffer { public:  Buffer(size_t size): buf(new int[size]), size(size) {}  ~Buffer() {delete [] buf;}  Buffer(const Buffer &ob): buf(new int[ob.size]), size(ob.size) {}  Buffer(Buffer &&ob): buf(ob.buf), size(ob.size) {} private:  int *buf;  size_t size;};void Demo() {  Buffer buf{16};  Buffer nb = std::move(buf);} // 这里会报错

还是这个简略的缓冲区的例子,如果咱们调用Demo函数,那么完结时会报反复开释内存的异样。

那么在下面例子中,buf和nb中的buf指向的是同一片空间,当Demo函数完结时,buf销毁会触发一次Buffer的析构,nb析构时也会触发一次Buffer的析构。而析构函数中是delete操作,所以堆空间会被开释两次,导致报错。

这也就是说,对于非平庸析构类型,其产生挪动语义后,该当放弃对原始空间的管制。

如果咱们批改一下代码,那么这种问题就不会产生:

class Buffer { public:  Buffer(size_t size): buf(new int[size]), size(size) {}  ~Buffer();  Buffer(const Buffer &ob): buf(new int[ob.size]), size(ob.size) {}  Buffer(Buffer &&ob): buf(ob.buf), size(ob.size) {ob.buf = nullptr;} // 重点在这里 private:  int *buf;};Buffer::~Buffer() {  if (buf != nullptr) {    delete [] buf;  }}void Demo() {  Buffer buf{16};  Buffer nb = std::move(buf);} // OK,没有问题

因为挪动构造函数和挪动赋值函数是咱们能够自定义的,因而,能够把反复析构产生的问题在这个外面思考好。例如下面的把对应指针置空,而析构时再进行判空即可。

因而,咱们得出的论断是并不是说非平庸析构的类型就不能够应用挪动语义,而是非平庸析构类型进行挪动结构或挪动赋值时,要思考援用权开释问题

公有继承和多继承

1)C++是多范式语言

在解说公有继承和多继承之前,笔者要先廓清一件事:C++不是单纯的面相对象的语言。同样地,它也不是单纯的面向过程的语言,也不是函数式语言,也不是接口型语言……

真的要说,C++是一个多范式语言,也就是说它并不是为了某种编程范式来创立的。C++的语法体系残缺且宏大,很多范式都能够用C++来展示。因而,不要试图用任一一种语言范式来解释C++语法,不然你总能找到各种破绽和奇怪的中央。

举例来说,C++中的“继承”指的是一种语法景象,而面向对象实践中的“继承”指的是一品种之间的关系。这二者是有本质区别的,请读者肯定肯定要辨别分明。

以面向对象为例,C++当然能够面向对象编程(OOP),但因为C++并不是专为OOP创立的语言,天然就有OOP实践解释不了的语法景象。比如说多继承,比如说公有继承。

C++与java不同,java是齐全依照OOP实践来创立的,因而所谓“抽象类”,“接口(协定)类”的语义是明确能够和OOP对应上的,并且,在OOP实践中,“继承”关系该当是"A is a B"的关系,所以不会存在A既是B又是C的这种状况,天然也就不会呈现“多继承”这样的语法。

然而在C++中,思考的是对象的布局,而不是OOP的实践,所以呈现公有继承、多继承等这样的语法也就不奇怪了。

笔者已经听有人持有上面这样相似的观点:

虚函数都应该是纯虚的;

含有虚函数的类不该当反对实例化(创建对象);

能实例化的类不该当被继承,有子类的类不该当被实例化;

一个类至少有一个“属性父类”,但能够有多个“协定父类”。

等等这些观点,它们其实都有一个独特的前提,那就是“我要用C++来反对OOP范式”。如果咱们用OOP范式来束缚C++,那么下面这些观点都是十分正确的,否则将不合乎OOP的实践,例如:

class Pet {};class Cat : public Pet {};class Dog : public Pet {};void Demo() {  Pet pet; // 一个不属于猫、狗等具体类型,仅仅属于“宠物”的实例,显然不合理}

Pet既然作为一个抽象概念存在,天然就不该当有实体。同理,如果一个类含有未齐全实现的虚函数,就证实这个类属于某种形象,它就不应该容许创立实例。而能够创立实例的类,肯定就是最“具象”的定义了,它就不该当再被继承。

在OOP的实践下,多继承也是不合理的:

class Cat {};class Dog {};class SomeProperty : public Cat, public Dog {}; // 啥玩意会既是猫也是狗?

但如果是“协定父类”的多继承就是正当的:

class Pet { // 协定类 public:  virtual void Feed() = 0; // 定义了喂养形式就能够成为宠物};class Animal {};class Cat : public Animal, public Pet { // 恪守协定,实现其需办法 public:  void Feed() override; // 实现协定办法};

下面例子中,Cat尽管有2个父类,但Animal才是真正意义上的父类,也就是Cat is a (kind of) Animal的关系,而Pet是协定父类,也就是Cat could be a Pet,只有一个类型能够实现某些行为,那么它就能够“作为”这样一种类型。

在java中,这两种类型是被严格辨别开的:

interface Pet { // 接口类  public void Feed();}abstract class Animal {} // 抽象类,不可创立实例class Cat extends Animal implements Pet {  public void Feed() {}}

子类与父类的关系叫“继承”,与协定(或者叫接口)的关系叫“实现”。

与C++同源的Objective-C同样是C的超集,但从名称上就可看出,这是“面向对象的C”,语法天然也是针对OOP实践的,所以OC依然只反对单继承链,但能够定义协定类(相似于java中的接口类),“继承”和“恪守(相似于java中的实现语义)”依然是两个拆散的概念:

@protocol Pet <NSObject> // 定义协定- (void)Feed;@end@interface Animal : NSObject@end@interface Cat : Animal<Pet> // 继承自Animal类,恪守Pet协定- (void)Feed;@end@implementation Cat- (void)Feed {  // 实现协定接口}@end

相比,C++只能说“能够”用做OOP编程,但OOP并不是其惟一范式,也就不会针对于OOP实践来限度其语法。这一点,心愿读者肯定要明确。

2)公有继承与EBO

  • 公有继承实质不是「继承」

在此强调,这个题目中,第一个“继承”指的是一种C++语法,也就是class A : B {};这种写法。而第二个“继承”指的是OOP(面向对象编程)的实践,也就是A is a B的形象关系,相似于“狗”继承自“动物”的这种关系。

所以咱们说,公有继承实质是示意组合的,而不是继承关系,要验证这个说法,只须要做一个小试验即可。咱们晓得最能体现继承关系的应该就是多态了,如果父类指针可能指向子类对象,那么即可实现多态效应。请看上面的例程:

class Base {};class A : public Base {};class B : private Base {};class C : protected Base {};void Demo() {  A a;  B b;  C c;  Base *p = &a; // OK  p = &b; // ERR  p = &c; // ERR}

这里咱们给Base类别离编写了A、B、C三个子类,别离是public、private和protected继承。而后用Base *类型的指针去别离指向a、b、c。发现只有public继承的a对象能够用p间接指向,而b和c都会报这样的错:

Cannot cast 'B' to its private base class 'Base'Cannot cast 'C' to its protected base class 'Base'

也就是说,公有继承是不反对多态的,那么也就印证了,他并不是OOP实践中的“继承关系”,然而,因为公有继承会继承成员变量,也就是能够通过b和c去应用a的成员,那么其实这是一种组合关系。或者,大家能够了解为,把b.a.member改写成了b.A::member而已。

那么公有继承既然是用来示意组合关系的,那咱们为什么不间接用成员对象呢?为什么要应用公有继承?这是因为用成员对象在某种状况下是有缺点的。

  • 空类大小

在解释公有继承的意义之前,咱们先来看一个问题,请看上面例程

class T {};// sizeof(T) = ?

T是一个空类,外面什么都没有,那么这时T的大小是多少?照理说,空类的大小就是应该是0,但如果真的设置为0的话,会有很重大的副作用,请看例程:

class T {};void Demo() {  T arr[10];  sizeof(arr); // 0  T *p = arr + 5;  // 此时p==arr  p++; // ++其实有效}

发现了吗?如果T的大小是0,那么T指针的偏移量就永远是0,T类型的数组大小也将是0,而如果它成为了一个成员的话,问题会更重大:

struct Test {  T t;  int a;};// t和a首地址雷同

因为T是0大小,那么此时Test构造体中,t和a就会在同一首地址。所以,为了防止这种0长的问题,编译器会针对于空类主动补一个字节的大小,也就是说其实sizeof(T)是1,而不是0

这里须要留神的是,不仅是相对的空类会有这样的问题,只有是不含有非动态成员变量的类都有同样的问题,例如上面例程中的几个类都能够认为是空类:

class A {};class B {  static int m1;  static int f();};class C {public:  C();  ~C();  void f1();  double f2(int arg) const;};

有了主动补1字节,T的长度变成了1,那么T*的偏移量也会变成1,就不会呈现0长的问题。然而,这么做就会引入另一个问题,请看例程:

class Empty {};class Test {  Empty m1;  long m2;};// sizeof(Test)==16

因为Empty是空类,编译器补了1字节,所以此时m1是1字节,而m2是8字节,m1之后要进行字节对齐,因而Test变成了16字节。如果Test中呈现了很多空类成员,这种问题就会被持续放大。

这就是用成员对象来示意组合关系时,可能会呈现的问题,而公有继承就是为了解决这个问题的。

  • 空基类成员压缩(EBO,Empty Base Class Optimization)

在上一节最初的历程中,为了让m1不再占用空间,但又能让Test中继承Empty类的其余内容(例如函数、类型重定义等),咱们思考将其改为继承来实现,EBO就是说,当父类为空类的时候,子类中不会再去调配父类的空间,也就是说这种状况下编译器不会再去补那1字节了,节俭了空间。但如果应用public继承会怎么样?

class Empty {};class Test : public Empty {  long m2;};// 如果这里有一个函数让传Empty类对象void f(const Empty &obj) {}// 那么上面的调用将会非法void Demo() {  Test t;  f(t); // OK}

Test因为是Empty的子类,所以会触发多态性,t会当做Empty类型传入f中。这显然问题很大呀!如果用这个例子看不出问题的话,咱们换一个例子:

class Alloc {public:  void *Create();  void Destroy();};class Vector : public Alloc {};// 这个函数用来创立buffervoid CreateBuffer(const Alloc &alloc) {  void *buffer = alloc.Create(); // 调用分配器的Create办法创立空间}void Demo() {  Vector ve; // 这是一个容器  CreateBuffer(ve); // 语法上是能够通过的,然而显然不合理}

内存分配器往往就是个空类,因为它只提供一些办法,不提供具体成员。Vector是一个容器,如果这里用public继承,那么容器将成为分配器的一种,而后调用CreateBuffer的时候能够传一个容器进去,这显然很不合理呀!那么此时,用公有继承就能够完满解决这个问题了

class Alloc {public:  void *Create();  void Destroy();};class Vector : private Alloc {private:  void *buffer;  size_t size;  // ...};// 这个函数用来创立buffervoid CreateBuffer(const Alloc &alloc) {  void *buffer = alloc.Create(); // 调用分配器的Create办法创立空间}void Demo() {  Vector ve; // 这是一个容器  CreateBuffer(ve); // ERR,会报错,公有继承关系不可触发多态}

此时,因为公有继承不可触发多态,那么Vector就并不是Alloc的一种,也就是说,从OOP实践上来说,他们并不是继承关系。而因为有了公有继承,在Vector中能够调用Alloc里的办法以及类型重命名,所以这其实是一种组合关系。而又因为EBO,所以也不必放心Alloc占用Vector的成员空间的问题。

谷歌标准中规定了继承必须是public的,这次要还是在贴近OOP实践。另一方面就是说,尽管应用公有继承是为了压缩空间,但肯定水平上也是就义了代码的可读性,让咱们不太容易看得出两种类型之间的关系,因而在绝大多数状况下,还是该当应用public继承。不过笔者依然持有“万事皆不可一棒子打死”的观点,如果咱们的确须要EBO的个性否则会大幅度就义性能的话,那么还是该当容许应用公有继承。

3)多继承

与公有继承相似,C++的多继承同样是“语法上”的继承,而实际意义上可能并不是OOP中的“继承”关系。再以后面章节的Pet为例:

class Pet { public:  virtual void Feed() = 0;};class Animal {};class Cat : public Animal, public Pet { public:  void Feed() override;};

从模式上来说,Cat同时继承自Anmial和Pet,但从OOP实践上来说,Cat和Animal是继承关系,而和Pet是实现关系,后面章节曾经介绍得很具体了,这里不再赘述。

但因为C++并不是齐全针对OOP的,因而反对真正意义上的多继承,也就是说,即使父类不是这种纯虚类,也同样反对集成,从语义上来说,相似于“穿插分类”。请看示例:

class Organic { // 有机物};class Inorganic { // 无机物};class Acid { // 酸};class Salt { // 盐};class AceticAcid : public Organic, public Acid { // 乙酸};class HydrochloricAcid : public Inorganic, public Acid { // 盐酸};class SodiumCarbonate : public Inorganic, public Salt { // 碳酸钠};

下面就是一个穿插分类法的例子,应用多继承语法荒诞不经。如果换做其余OOP语言,可能会强行把“酸”或者“有机物”定义为协定类,而后用继承+实现的形式来实现。但如果从化学分类上来看,无论是“酸碱盐”还是“有机物无机物”,都是一种强分类,比如说“碳酸钠”,它就是一种“无机物”,也是一种“盐”,你并不能用相似于“猫是一种动物,能够作为宠物”的实践来解释,不能说“碳酸钠是一种盐,能够作为一种无机物”。

因而C++中的多继承是哪种具体意义,取决于父类自身是什么。如果父类是个协定类,那这里就是“实现”语义,而如果父类自身就是个理论类,那这里就是“继承”语义。当然了,像公有继承的话示意是“组合”语义。不过C++自身并不在意这种语义,有时为了不便,咱们也可能用私有继承来示意组合语义,比如说:

class Point { public:  double x, y;};class Circle : public Point { public:  double r; // 半径};

这里Circle继承了Point,但显然不是说“圆是一个点”,这里想表白的就是圆类“蕴含了”点类的成员,所以只是为了复用。从意义上来说,Circle类中继承来的x和y显然表白的是圆心的坐标。不过这样写并不合乎设计规范,但笔者用这个例子心愿解释的是C++并不在意类之间理论是什么关系,它在意的是数据复用,因而咱们更须要理解一下多继承体系中的内存布局。

对于一个一般的类来说,内存布局就是依照成员的申明程序来布局的,与C语言中构造体布局雷同,例如:

class Test1 { public:  char a;  int b;  short c;};

那么Test1的内存布局就是

字节编号内容
0a
1~3内存对齐保留字节
4~7b
8~9c
9~11内存对齐保留字节

但如果类中含有虚函数,那么还会在开端增加虚函数表的指针,例如:

class Test1 { public:  char a;  int b;  short c;  virtual void f() {}};
字节编号内容
0a
1~3内存对齐保留字节
4~7b
8~9c
9~15内存对齐保留字节
16~23虚函数表指针

多继承时,第一父类的虚函数表会与本类合并,其余父类的虚函数表独自存在,并排列在本类成员的前面。

4)菱形继承与虚构继承

C++因为反对“普适意义上的多继承”,那么就会有一种非凡状况——菱形继承,请看例程:

struct A {  int a1, a2;};struct B : A {  int b1, b2;};struct C : A {  int c1, c2;};struct D : B, C {  int d1, d2;};

依据内存布局准则,D类首先是B类的元素,而后D类本人的元素,最初是C类元素:

字节序号意义
0~15B类元素
16~19d1
20~23d2
24~31C类元素

如果再开展,会变成这样:

字节序号意义
0~3a1(B类继承自A类的)
4~7a2(B类继承自A类的)
8~11b1
12~15b2
16~19d1
20~23d2
24~27a1(C类继承自A类的)
28~31a2(C类继承自A类的)
32~35c1
36~39c2

能够发现,A类的成员呈现了2份,这就是所谓“菱形继承”产生的副作用。这也是C++的内存布局当中的一种缺点,多继承时第一个父类作为主父类合并,而其余父类则是间接向后扩写,这个过程中没有去重的逻辑(详情参考上一节)。这样的话不仅节约空间,还会呈现二义性问题,例如d.a1到底是指从B继承来的a1还是从C里继承来的呢?

C++引入虚构继承的概念就是为了解决这一问题。但怎么说呢,C++的复杂性往往都是因为为了解决一种缺点而引入了另一种缺点,虚构继承就是十分典型的例子,如果你间接去解释虚构继承(比如说和一般继承的区别)你肯定会感觉莫名其妙,为什么要引入一种这样奇怪的继承形式。所以这里须要咱们理解到,它是为了解决菱形继承时空间爆炸的问题而不得不引入的。

首先咱们来看一下一般的继承和虚构继承的区别:一般继承:

struct A {  int a1, a2;};struct B : A {  int b1, b2;};

B的对象模型应该是这样的: 

 而如果应用虚构继承:

struct A {  int a1, a2;};struct B : virtual A {  int b1, b2;};

对象模型是这样的: 

虚构继承的排布形式就相似于虚函数的排布,子类对象会主动生成一个虚基表来指向虚基类成员的首地址。

就像方才说的那样,单纯的虚构继承看上去很离谱,因为齐全没有必要强行更换这样的内存布局,所以绝大多数状况下咱们是不会用虚构继承的。然而菱形继承的状况,就不一样了,一般的菱形继承会这样:

struct A {  int a1, a2;};struct B : A {  int b1, b2;};struct C : A {  int c1, c2;};struct D : B, C {  int d1, d2;};

D的对象模型:  

但如果应用虚构继承,则能够把每个类独自的货色抽出来,反复的内容则用指针来指向:

struct A {  int a1, a2;};struct B : virtual A {  int b1, b2;};struct C : virtual A {  int c1, c2;};struct D : B, C {  int d1, d2;};

D的对象模型将会变成:  

也就是说此时,共有的虚基类只会保留一份,这样就不会有二义性,同时也节俭了空间。

但须要留神的是,D继承自B和C时是一般继承,如果用了虚构继承,则会在D外部又额定增加一份虚基表指针。要虚构继承的是B和C对A的继承,这也是虚构继承语法十分蛊惑的中央,也就是说,菱形继承的分支处要用虚构继承,而汇聚处要用一般继承。所以咱们还是要明确其底层原理,以及引入这个语法的起因(针对解决的问题),能力更好的应用这个语法,防止出错。

隐式结构

隐式结构指的就是隐式调用构造函数。换句话说,咱们不必写出类型名,而是仅仅给出结构参数,编译期就会主动用它来结构对象。举例来说:

class Test { public:  Test(int a, int b) {}};void f(const Test &t) {}void Demo() { f({1, 2}); // 隐式结构Test长期对象,相当于f(Test{a, b})}

下面例子中,f须要承受的是Test类型的对象,然而咱们在调用时仅仅应用了结构参数,并没有指定类型,但编译器会进行隐式结构。

尤其,当结构参数只有1个的时候,能够省略大括号:

class Test { public:  Test(int a) {}  Test(int a, int b) {}};void f(const Test &t) {}void Demo() {  f(1); // 隐式结构Test{1},单参时能够省略大括号  f({2}); // 隐式结构Test{2}  f({1, 2}); // 隐式结构Test{1, 2}}

这样做的益处不言而喻,就是能够让代码简化,尤其是在结构string或者vector的时候更加显著:

void f1(const std::string &str) {}void f2(const std::vector<int> &ve) {}void Demo() {  f1("123"); // 隐式结构std::string{"123"},留神字符串常量是const char *类型  f2({1, 2, 3}); // 隐式结构std::vector,留神这里是initialize_list结构}

当然,如果遇到函数重载,原类型的优先级大于隐式结构,例如:

class Test {public:  Test(int a) {}};void f(const Test &t) {  std::cout << 1 << std::endl;}void f(int a) {  std::cout << 2 << std::endl;}void Demo() {  f(5); // 会输入2}

但如果有多种类型的隐式结构则会报二义性谬误:

class Test1 {public:  Test1(int a) {}};class Test2 {public:  Test2(int a) {}};void f(const Test1 &t) {  std::cout << 1 << std::endl;}void f(const Test2 &t) {  std::cout << 2 << std::endl;}void Demo() {  f(5); // ERR,二义性谬误}

在返回值场景也反对隐式结构,例如:

struct err_t {  int err_code;  const char *err_msg;};err_t f() {  return {0, "success"}; // 隐式结构err_t}

但隐式结构有时会让代码含意含糊,导致意义不清晰的问题(尤其是单参的构造函数),例如:

class System { public:  System(int version);};void Operate(const System &sys, int cmd) {}void Demo() {  Operate(1, 2); // 意义不明确,不容易让人意识到隐式结构}

上例中,System示意一个零碎,其结构参数是这个零碎的版本号。那么这时用版本号的隐式结构就显得很突兀,而且只通过Operate(1, 2)这种调用很难让人想到第一个参数居然是System类型的。

因而,是否该当隐式结构,取决于隐式结构的场景,例如咱们用const char *来结构std::string就很天然,用一组数据来结构一个std::vector也很天然,或者说,代码的阅读者十分直观地能反馈进去这里产生了隐式结构,那么这里就适宜隐式结构,否则,这里就该当限定必须显式结构。用explicit关键字限定的构造函数不反对隐式结构:

class Test { public:  explicit Test(int a);  explicit Test(int a, int b);  Test(int *p);};void f(const Test &t) {}void Demo() {  f(1); // ERR,f不存在int参数重载,Test的隐式结构不容许用(因为有explicit限定),所以匹配失败  f(Test{1}); // OK,显式结构  f({1, 2}); // ERR,同理,f不存在int, int参数重载,Test隐式结构不许用(因为有explicit限定),匹配失败  f(Test{1, 2}); // OK,显式结构  int a;  f(&a); // OK,隐式结构,调用Test(int *)构造函数 }

还有一种状况就是,对于变参的构造函数来说,更要优先思考要不要加explicit,因为变参包含了单参,并且默认状况下所有类型的结构(模板的所有实例,任意类型、任意个数)都会反对隐式结构,例如:

class Test { public:  template <typename... Args>  Test(Args&&... args);};void f(const Test &t) {}void Demo() {  f(1); // 隐式结构Test{1}  f({1, 2}); // 隐式结构Test{1, 2}  f("abc"); // 隐式结构Test{"abc"}  f({0, "abc"}); // 隐式结构Test{0, "abc"}}

所以防止爆炸(生成很多不可控的隐式结构),对于变参结构最好还是加上
explicit,如果不加的话肯定要慎重考虑其可能实例化的每一种状况。

在谷歌标准中,单参数构造函数必须用explicit限定(公司标准中也是这样的,能够参考公司C++编程标准第4.2条),但笔者认为这个标准并不齐全正当,在个别情况隐式结构意义十分明确的时候,还是该当容许应用隐式结构。另外,即使是多参数的构造函数,如果当隐式结构意义不明确时,同样也该当用explicit来限定。所以还是要视状况而定。C++反对隐式结构,天然思考的是一些场景下代码更简洁,但归根结底在于C++次要靠STL来扩大性能,而不是语法。举例来说,在Swift中,原生语法反对数组、map、字符串等:

let arr = [1, 2, 3] // 数组let map = [1 : "abc", 25 : "hhh", -1 : "fail"] // maplet str = "123abc" // 字符串

因而,它并不需要所谓隐式结构的场景,因为语法自身曾经表明了它的类型。

而C++不同,C++并没有原生反对std::vector、std::map、std::string等的语法,这就会让咱们在应用这些根底工具的时候很头疼,因而引入隐式结构来简化语法。所以归根结底,C++语言自身思考的是语法层面的性能,而数据逻辑层面靠STL来解决,二者并不耦合。但又心愿程序员可能更加不便地应用STL,因而引入了一些语言层面的性能,但它却像整体类型凋谢了。

举例来说,Swift中,[1, 2, 3]的语法强绑定Array类型,[k1:v1, k2,v2]的语法强绑定Map类型,因而这里的“语言”和“工具”是耦合的。但C++并不和STL耦合,他的思路是{x, y, z}就是结构参数,哪种类型都能够用,你交给vector时就是示意数组,你交给map时就是示意kv对,并不会将“语法”和“类型”做任何强绑定。因而把隐式结构和explicit都提供进去,交给开发者自行处理是否反对。

这是咱们须要领会的C++设计理念,当然,也能够算是C++的缺点。

C格调字符串

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

然而,更多的场景下,咱们都会应用std::string类型来保留和解决字符串,因为它性能更弱小,应用更不便。得益于隐式结构,咱们能够把一个字符串常量轻松转化为std::string类型来解决。

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

1)类型推导问题

在进行类型推导时,字符串常量会按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 *类型,间接匹配到了指针解决形式,而并不会触发隐式结构。

2)截断问题

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,因而在应用时肯定要小心截断问题。

3)指针意义不明问题

因为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类型取地址时,肯定要思考会不会被辨认为字符串。

4)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++的特色了,其余语言中根本见不到。

1)调配和开释空间

“堆空间”的概念同样继承自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;

2)运算符实质是函数调用

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

3)重载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润饰。而默认的状况下,能够通过接管异样来判断是否调配胜利,而不必每次都对指针进行判空。

4)构造函数和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); // 开释内存空间

5)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++总是进行一些暗藏操作,才会复杂度激增,有时也会呈现一些难以发现的问题,所以咱们肯定要弄清楚它的实质。

6)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 0using 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就跟全局变量的作用域是一样的),只是会变成编译期常量。

1)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类型不能编译期确定外部行为}

2)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)...};}

1)嵌套就地结构

就地结构的确能在肯定水平上解决多余的对象复制问题,但如果是嵌套模式就实则没方法了,举例来说:

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 &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含有key为1的项,就返回对应迭代器,如果没有的话则会新增(因为没指定前面的参数,所以会结构一个空map),并返回迭代器。迭代器在返回值的第一项,所以取first失去迭代器,迭代器指向的是map外部的pair,取second失去外部的map,再对其进行一次try_emplace插入外部的元素。

当然了,这么做的确可读性会降落很多,具体应用时还须要自行取舍。

模板的全特化

先跑个小题~,模板的「模」正确发音应该是「mú」,本来是工程上的术语,生产一种工件可能须要一种样本,但它和理论生产出的工件可能并不相同。所以说,「模板」自身并不是理论的工件,但能够用于生产出工件。更艰深来说,能够了解成一个浇注用的壳,比如说是圆柱形态,如果你往里灌铁水,那进去的就是铁柱;如果你灌铝水进去的就是铝柱;如果你灌水泥,那进去的就是水泥柱……

所以C++中用“模板”这个词特地贴切,它自身并不是理论代码,而在实例化的时候才会生成对应的代码。

而模板又存在“特化”的问题,分为“偏特化”和“全特化”。偏特化也就是局部特化,也就是半成品,实质上来说依然属于“模板”。但全特化就很非凡了,全特化的模板就曾经不是模板了,而是真正的代码了,因而这里的行为也会和模板有所不同,而更加靠近一般代码。

最简略的例子就是,模板的申明和实现个别都会写在头文件中(除非仅在某个源文件中应用)。这是因为模板是编译期代码,在编译期会生成理论代码,而“编译”过程是单文件行为,因而你必须保障每个独立的源文件都能找到这段模板定义。(include头文件实质就是文件内容的复制,所以还是相当于每个应用的源文件都获取了一份模板定义)。而如果拆开则会在编译期间找不到而报错:

demo.h

template <typename T>void f(T t);

demo.cpp

template <typename T>void f(T t) {// ...}

main.cpp

#include "demo.h" // 这里只取得了申明int main() {  f<int>(5); // ERR,链接报错,因为只有申明而没有实现  return 0;}

上例中,main.cpp蕴含了demo.h,因而取得的是f函数的申明。当main.cpp在编译期间,是不会去关联demo.cpp的,在主函数中调用了f<int>,因而会标记f<int>函数曾经申明。

而编译demo.cpp的时候,因为f并没有任何实例化,因而不会产生任何代码。

尔后链接main.cpp和demo.cpp,发现main.cpp中的f<int>没有实现,因而链接阶段报错。

所以,咱们才要求模板的实现也要写在头文件中,也就是变成:

demo.h

// 申明template <typename T>void f(T t);// ...其余内容// 定义template <typename T>void f(T t) {}

main.cpp

#include "demo.h"int main() {  f<int>(5); // OK  return 0;}

因为实现也写在了demo.h中,因而当主函数中调用了f<int>时,既会用模板f的申明生成出f<int>的申明,也会用模板f的实现生成出f<int>的实现。

然而对于全特化的模板,状况将齐全不同。因为全特化的模板曾经不是模板了,而是一个确定的函数,编译期不会再用它来生成代码,因而,这时如果你把实现也写在头文件里,就会呈现重定义谬误:

demo.h

template <typename T>void f(T t) {}// f<int>全特化template <>void f<int>(int t) {}

src1.cpp

#include "demo.h" // 这里有一份f<int>的实现

main.cpp

#include "demo.h" // 这里也有一份f<int>的实现int main() {  f<int>(a); // ERR, redefine f<int>  return 0;}

这时会报重定义谬误,因为f<int>的实现写在了demo.h中,那么src.cpp蕴含了一次,相当于实现了一次,而后main.cpp也蕴含了一次,相当于又实现了一次,所以报重定义谬误。

因而,正确的做法是把全特化模板当做一般函数来看待,只能在源文件中定义一次:

demo.h

template <typename T>void f(T t) {}// 特化f<int>的申明template <>void f<int>(int t);

demo.cpp

#include "demo.h"// 特化f<int>的定义template <>void f<int>(int t) {}

src1.cpp

#include "demo.h" // 只失去了申明,没有反复实现

main.cpp

#include "demo.h" // 只失去了申明,没有反复实现int main() {  f<int>(5); // OK,全局只有一份实现  return 0;}

所以在应用模板特化的时候,肯定要小心,如果是全特化的话,就要依照一般函数/类来看待,申明和实现须要离开。

当然了,硬要把实现写在头文件里也是能够的,只不过要用inline润饰,避免重定义。

demo.htemplate <typename T>void f(T t) {}// 特化f<int>申明template <>void f<int>(int t);// 特化f<int>内联定义template <>inline void f<int>(int t) {}

结构/析构函数调用虚函数

咱们晓得C++用来实现“多态”的语法次要是虚函数。当调用一个对象的虚函数时,会依据对象的理论类型来调用,而不是依据援用/指针的类型。

class Base { public:  virtual void f() {std::cout << "Base::f" << std::endl;}};class Child1 : public Base { public:  void f() override {std::cout << "Child1::f" << std::endl;}};class Child2 : public Base { public:  void f() override {std::cout << "Child2::f" << std::endl;}};void Demo() {  Base *obj1 = new Child1;  Child2 ch;  Base &obj2 = ch;  Base obj3;  obj1->f(); // Child1::f  obj2.f(); // Child2::f  obj3.f(); // Base::f}

但有一种非凡状况,会让多态性生效,请看上面例程:

class Base { public:  Base() {f();} // 结构函数调用虚函数  virtual void f() {std::cout << "Base::f" << std::endl;}};class Child : public Base { public:  Child() {}  void f() override {std::cout << "Child::f" << std::endl;}};void Demo() {  Child ch; // Base::f}

咱们晓得子类结构时须要先调用父类构造函数。这里因为Child中没有指定Base的构造函数,因而会调用无参的结构。在Base的无参构造函数中调用了虚函数f。照理说,咱们是在结构Child的过程中调用了f,那么应该调用的是Child的f,但理论调的是Base的f,也就是多态性生效了。

究其原因,咱们就要晓得C++结构的模式了。因为Child是Base的子类,因而会含有Base类的成员,并且结构时也要先结构。在结构Child的Base局部时,先初始化了虚函数表,因为此时还属于Base的构造函数,因而虚函数表中指向的是Base::f。虚函数表初始化后开始结构Base的成员,示例中因为是空的所以跳过。再执行Base构造函数的函数体,函数体里调用了f。以上都属于Base的结构,实现后才会持续Child独有局部的结构。首先会结构虚函数表,把f指向Child::f。而后是初始化成员,示例中为空所以跳过。最初执行Child构造函数函数体,示例中是空的。

所以,咱们看到,这里调用f的机会,是在Base结构的过程中。f因为是虚函数,因而会通过虚函数表来拜访,但又因为此时虚函数表里指向的就是Base::f,所以会调用到Base类的f。

同理,如果在析构函数中调用虚函数的话,同样会失去多态性。准则就是哪个类里调用的,理论就会调用哪个类的实现

经典二义性问题

C++中存在3个十分经典的二义性问题,并且他们的默认含意都是反直觉的。

1)长期对象传参时的二义性

请看上面的代码:

struct Test {};struct Data { explicit Data(const Test &test);};void Demo() {  Data data(Test()); // 这句是什么含意?}

下面这种类型的代码的确有时会一不留神就写进去。咱们违心是想创立一个Data类型的对象叫做data,结构参数是一个Test类型,这里咱们间接创立了一个长期对象作为结构参数。

但如果你真的这样写的话,会失去一个warning,并且data这个对象并没有创立胜利。为什么呢?因为编译期把它误以为是函数申明了。这里首先须要理解一个语法糖:

void f(void d(int));// 等价于void f(void (*d)(int));

C++中容许参数为“函数类型”,又因为函数并不是一种存储类型,因而这种语法会当做“函数指针类型”来解决。所以说当函数参数是一个函数的时候,实质上是让传一个函数指针进去。

与此同时,C++也反对了“函数取地址”和“解函数指针”的操作。函数取地址后依然是函数指针,解函数指针后依然是函数指针:

void f() {}void Demo() {  void (*p1)() = f; // 函数类型转化为函数指针(C语言只反对这种写法)  void (*p2)() = &f; // 函数类型取地址还是函数指针类型  p2(); // 函数指针间接调用相当于函数调用  (*p2)(); // 函数指针解指针后依然是函数指针  auto p3 = *p2; // 同上,p3依然是void (*)()类型  (*************p2)(); // 逐步离谱,但的确是非法的}

再回到一开始的例子,如果咱们要申明一个函数名为data,返回值是Data类型,参数是一个函数类型,一个返回值为Test,空参类型的函数。那么就是:

Data data(Test());// 或者是Data data(Test (*)());

第一种写法正好和咱们方才想示意“定义Data类型的对象名为data,参数是一个Test类型的长期对象”给撞脸了。引发了二义性。

解决办法也很简略,咱们晓得示意“值”的时候,套一层或者多层括号是不影响“值”的意义的:

// 上面都等价a;(a);((a));

那么示意“函数调用”时,传值也是能够套多层括号的:

f(a);f((a));f(((a)));

然而当你示意函数申明的时候,你就不能套多层括号了:

void f(int); // 函数申明void f((int)); // ERR,谬误语法

所以,第一种解决办法就是,套一层括号,那么就只能解释为“函数调用”而不是“函数申明”了:

Data data((Test())); // 定义对象data,不会呈现二义性

第二种办法就是不要用小括号示意结构参数,而是换成大括号:

Data data{Test{}}; // 大括号示意结构参数列表,不能示意函数类型

在要不就不要用长期对象,改用一般变量:

Test t;Data data{t};

2)模板参数嵌套时的二义性

当两个模板参数套在一起的时候,两个>会碰在一起:

std::vector<std::vector<int>> ve; // 这里呈现了一个>>

而这和参数中的右移运算给撞脸了:

std::array<int, 1 >> 5> arr; // 这里也呈现了一个>>

在C++11以前,>>会优先辨认为右移符号,因而对于模板嵌套,就必须加空格:

std::vector<std::vector<int> > ve; // 加空格防止歧义

但可能是因为模板参数右移的状况远远少过模板嵌套的状况,因而在C++11开始,把这种默认状况改了过去,遇见>>会辨认为模板嵌套:

std::vector<std::vector<int>> ve; // OK

但绝对的,如果要进行右移运算的话,就会辨认谬误,解决办法是加括号

std::array<int, 1 >> 5> arr; // ERRstd::array<int, (1 >> 5)> arr; // OK,要通过加小括号防止歧义

3)模板中类型定义和动态变量二义性

间接上代码:

template <typename T>struct Test {  void f() {    T::abc *p;  }};struct T1 {  static int abc;};struct T2 {  using abc = int;};void Demo() {  Test<T1> t1;  Test<T2> t2;}

Test是一个模板类,外面取了参数T的成员abc。对于T1的实例化来说,T1::abc是一个整型变量,所以T::abc p相当于两个变量相乘,会了解为“乘法”。

而对于T2来说,T2::abc是一个类型重命名,那么T::abc p相当于定义一个int类型的指针,会了解为指针类型。

所以,对于模板Test来说,因为T还没有实例化,所以不能确定T::abc到底是动态变量还是类型重命名。因而会呈现二义性。

解决形式是用typename关键字,强制表名这里T::abc是一个类型:

template <typename T>struct Test {  void f() {    typename T::abc *p; // 肯定示意指针定义  }};

typename关键字大家应该并不生疏,但个别都是在模板参数中见到的。其实在C++11以前,模板参数中示意“类型”参数的关键字是class,但用这个关键字会对人产生误导,其实这里不肯定非要传类类型,传根本类型也是OK的,因而C++11的时候让typename能够承当这个责任,因为它更能示意“类型名称”这种含意。但其实在此之前typename仅仅是为了解决下面二义性问题的。

另外值得阐明的一点是,C++17以前,模板参数是模板的状况时依然只能用class:

// 要求参数要传一个模板类型,其含有两个类型参数// C++14及以前版本这里必须用classtemplate <template <typename, typename> class Temp>struct Test {}template <typename T, typename R>struct T1 {}void Demo() {  Test<T1>; // 模板参数是模板的状况实例化}

C++17开始才容许这个class替换为typename:

// C++17后能够用typenametemplate <template <typename, typename> typename Temp>struct Test {}

语言、STL、编译器、编程标准

笔者认为,C++跟一些新兴语言最大的不同就在于将「语言」、「规范库」、「编译器」这三个概念划分为了三个畛域。在后面章节提到的一系列所谓“缺点”其实都跟这种畛域划分有十分大的关系。

举例来说,解决字符串相干问题,然而应用std::string就曾经能够防止踩十分多的坑了。它不会呈现0值截断问题;不会呈现拷贝时缓冲区溢出问题;配合流应用时不会呈现%s不平安的问题;传参不用在意数组进化指针问题;不用放心复制时的浅复制问题……

但问题就在于,std::string属于STL的畛域,它的呈现并没有扭转C++自身,最直观地来讲,字符串常量"abc"并没有映射到std::string类型,它依然会依照C格调字符串来解决。它就有可能导致重载、导致模板参数辨认不合乎预期。除非咱们将其转换为std::string。

所以说,尽管std::string解决了绝大对数原始字符串可能呈现的问题,但它是在STL的维度来解决的,并不是在C++语言的维度来解决的。接下来我会具体介绍这三种畛域之间的关系,以及我集体的一些思考。

1)C++与STL的关系

虽说STL是“C++的规范库”,但C++和STL的关系是不如C和C规范库的关系的。次要的区别是:

C规范库的实现根本是用汇编写的,而STL是齐全用C++写的。

听下来可能难能可贵,但认真想想这种差别堪称天壤之别。C库用汇编实现,也就意味着OS要原生反对这种性能,不同架构下的汇编是不同的。比如说Intel芯片的Mac电脑,它自带的C库就要用x86汇编(精确来说是AMD64汇编)来实现,而M系列芯片的Mac电脑,它自带的C库就要用ARM汇编来实现。

用C语言开发OS的时候的确没法应用规范库,但同时,咱们没法做到仅用C语言来开发OS,它不可避免地要和汇编进行联动。而在用C开发应用程序的时候,OS就会提供C规范库的对应实现,也就是说在编译C程序的时候,规范库的内容是不必编译的,一遍都是作为动态链接库直接参与链接。(还有一些可能是动态链接库,运行是调用,但这个就跟OS和架构无关了。)

但STL不同,STL咱们能够轻松看到其源码,它就是用C++来实现的。在C++工程编译时,STL要全程参加编译。

再说得抽象一点:你没法用C语言实现C规范库,但齐全能够用C++实现STL,与此同时,如果你要用C++来实现STL的时候,你也不能没有C规范库。所以STL单纯是一些性能、工具的封装,它并没有对语言自身进行任何扩大和扭转。

在C++诞生的时候,并没有所谓规范库,那个时候的C++其实就是给C做了一些裁减,所以用的依然是C的规范库。只不过起初有位苏联的大神利用C++写了一个工具库,所以精确地来说,STL本来就只是个第三方库,是跟C++语言自身没什么关系的,只不过起初语言规范协会把它纳入了C++规范的一部分,让它成为了规范库。

所以“容器”“迭代器”“内存分配器”等等这些概念都是STL畛域的,并不跟C++语言强绑定。另一方面,到起初STL其实是一套规定的规范,比如说规定要实现哪些容器,这些容器里该当有哪些性能。但其实实现办法是没有规定的,也就是说不同的人能够有不同的实现办法,它们的性能问题、设计的侧重点可能也不一样。历史上实在呈现过某个版本的STL实现,因为设计缺点导致求size时工夫复杂度是O(n)的状况。

之前有读者读过我的文章后有收回质疑,相似于「如果你这么放心内存透露问题的话,为什么不必智能指针?」或者「如果你感觉C格调字符串存在各种问问题为什么不必string和string_view」这样的问题。那么这里的问题点就在于,无论是string也好,还是智能指针也好,这些都是STL畛域的,并不是C++语言自身畛域的。所以一来,我心愿读者可能明确STL提供这些工具是为了解决哪些问题,为什么咱们应用了STL的这个工具就不会踩坑,工具外部是怎么避坑的;二来,给一些C++的新人解开纳闷,他们可能会奇怪,明明间接打一个双引号就是字符串了,为什么还要用string或者string_view。

明明打一颗星就是指针了,为什么还要用shared_ptr、weak_ptr等等;三来,也是提倡大家尽可能应用STL提供的工具,而不是自行应用底层语法

我已经有过一个疑难,就是说为什么C++不能在语言层面上反对STL。举例来说,"abc"为什么不罗唆间接映射成std::string类型?而是非要通过隐式结构的形式。为什么不能间接引入相似于{k1:v1, k2:v2}的语法来映射std::map?而是非要通过嵌套结构的形式。起初我大略猜到了起因,其实就是为了兼容性。构想,如果忽然引入一种类型的强绑定,那么现有代码的行为会产生很大的变动,大量的团队将不敢降级到这个新规范。另一方面,有些非凡的我的项目其实是对STL不信赖的,比如说内核开发,嵌入式开发。他们对性能要求很高,所以相似于内存的调配、开释等等这些操作,都必须十分小心,都必须齐全在本人的掌控之中。如果应用STL则不能保障外部操作完全符合预期,但与此同时又不想应用纯C,因为还心愿能应用一些C++的个性(比如说援用、类封装、函数重载等等)。那他们的抉择就是应用C++但禁用STL。一旦C++语法和STL强绑定的话,也会劝退这些团队。

所以,这就是一个取舍问题,C语言保留着最根底、最底层的性能。而须要疾速迭代、屏蔽底层细节又不是特地在乎性能的我的项目则能够抉择更高级的语言。而C++的定位就是在他们之间搭一座桥,如果你是写底层而会的C++,你也能够转型下层软件而不必学习新的语言,反之亦然。总之,C++定位就是全能,可上可下。但正犹如细胞分化一样,越全能的细胞就越不专一,当你让它去做一种比拟专一的事件的时候,它可能就显得臃肿了。但其实,C++提供宏大而简单的性能后,咱们齐全能够依据状况应用它的一个子集,实现本人的需要就好,而不必过分纠结C++自身的复杂性。

2)编译器优化

编译器的优化又属于另一个维度的事件了。所谓编译器的优化就是指,从代码字面上脱离进去,了解其含意,而后优化成更高性能的形式。

举个后面章节提到过的例子来说:

struct Test {  int a, b;};Test f() {  Test t {1, 2};  return t;};void Demo() {  Test t = f();}

如果依照语言本意来说,这里就是会产生2次复制,f外部的局部变量复制给长期区域(拷贝结构),再长期区域复制给Demo中的变量(挪动结构)。

然而编译器就能够对这种状况进行优化,它会间接拿着Demo中的t进到f中结构,也就是说,编译器“了解”了这段代码的含意,而后改写成了更高性能的形式:

struct Test {  int a, b;};void f(Test *t) {  new(t) Test {1, 2};}void Demo() {  Test t = *(Test *)operator new(sizeof(Test));  f(&t);}

这也就是编译器的RVO(Return Value Optimization,返回值优化)。

当然,编译器不止这一种优化,还会有很多优化,对于gcc来说,有3种级别的优化编译选项,-O1、-O2、-O3。会对很多状况进行优化。这么做的意义也很不言而喻,就是说让程序员能够尽可能屏蔽这些底层语法对程序行为(或者说性能)的影响,而能够更多聚焦在逻辑含意上。

但笔者心愿传播的意思是,“语言”、“库”、“编译器”是不同维度的事件。针对同一个语言“缺点”,库可能有库的解决办法,编译器有编译器的优化计划,然而不同的库实现可能倾向性不同,不同的编译器优化水平也不同。

3)编程标准

笔者认为,编程标准次要是要思考我的项目或者团队的理论状况,从而制订的一种规范。除了一些格局、代码格调上的对立以外,其余任意一条标准都肯定有其担心情理。可能是团队以前在这个点上踩过坑,也可能是以团队的平均水平来说很容易踩这个坑,而同时又有其余避坑的形式,因而罗唆规定不许怎么怎么样,必须怎么怎么样。对于集体来说,有时可能的确难以了解和承受,甚至感觉有些束手束脚。但毕竟人心都向自在,但对于团队来说,要找到的是让团队更加高效、不易出错的形式。

有人说小白都不会质疑规定,大佬才会看得出规定中有哪些不合理。从某种角度来说,笔者认为这种说法是对的,但还应该补充一句“真正的大佬则是能看得出这里为什么不合理”。如果你能看得出制订这条规定的人在放心些什么,为什么要做这样束缚的时候,那我置信你的视线会更宽,心也会更宽。

因而,如果你认为你所在团队的编程标准中槽点很多,那笔者认为,最好的形式就是晋升团队整体的程度,就拿C++来说,如果少数人都能意识到这个地位有坑,该当留神些什么,并且都能够很好的解决这部分问题的话,那我置信,标准的制定者并不会再去出于放心,而强行对大家进行解放了。

4)思考

只管C++语言因为历史起因留下不少缺点,但随着版本迭代,STL和编译器都在做着十分多的优化,所以其实对于程序员来说,日常开发真的不必太在意太纠结这些细枝末节的货色,把更多底层的事件交给底层的工具来实现,何苦要勉强本人?

但笔者感觉,这个情理就像“我会本人做饭,但我能够不必做(有人给我做)”,和“我不会做饭,只能指望他人给我做”是齐全不同的两种状态。只管工具能够提供优化,但“我很分明底层原理,理解他们是如何优化的,而后我能够屏蔽很多底层的货色,使用方便的工具来晋升我的工作效率”和“我基本不晓得底层原理,只能没心没肺地用工具”也是不同的状态。笔者心愿把这些通知读者,这样即使工具呈现一些问题的时候,咱们也能有一个定位思路,而不会大刀阔斧。

C++11和C++20

后面章节中笔者提到,C++的迭代过程中,次要是通过STL提供更不便的工具来解决原始缺点的。但也有例外,C++11和C++20就是十分具备代表性的2次更新。

C++11引入的「主动类型推导」「右值援用」「挪动语义」「lambda表达式」「强枚举」「基于范畴的for循环」「变参模板」「常量表达式」等等的个性,其实都是对C++语言的一种裁减。C++11推出后,立即让人感觉C++不再是C的感觉了。

只不过,兼容性是C++更多用于思考的,一方面是出于对老我的项目迁徙的门槛思考,另一方面是对编译器运行形式的思考,它并没有做过多的“改过”,而是以“修补”为主。举例来说,尽管引入了lambda表达式,但并没有用它代替函数指针,代替仿函数类型。再比方尽管引入了常量表达式,但依然保留了const关键字的性质,甚至还做了向下兼容(比方后面章节提到的给常量表达式取地址后,会变为只读变量)。

之后的C++14、C++17更多的是在C++11的根底上进行了欠缺,因为你可能感觉到,这两个规范尽管提供了新的内容,但从根本上来说,它依然是C++11的理念。比方C++14能够用auto推导函数返回值,但它并没有扭转“函数返回值必须确定”这一理念,所以返回多种类型的时候只会以第一个为准。再比方C++17中引入了「折叠表达式」以及由「合并using」所诞生的很多奇技淫巧,让模板元编程更上一层楼,但它并没有解决模板元编程的实质是利用「SFINAE」,所以如果匹配失败,编译器报错会充斥非常复杂的SFINAE过程,导致开发者没法疾速获取外围信息。

在这里举个小例子,如果我想判断某个类中是否含有名为Find、空参且返回值为int的办法,如果有就能够传入Process函数中,那么用C++17的办法应该这样写:

template <typename T, typename R = void>struct HasFind : std::false_value {};template <typename T>struct HasFind<T, typename = std::void_t<decltype(&T::Find)>>: std::disjunction<     std::is_same<decltype(&T::Find), int (T::*)(void)>,      std::is_same<decltype(&T::Find), int (T::*)(void) const>,      std::is_same<decltype(&T::Find), int (T::*)(void) noexcept>,      std::is_same<decltype(&T::Find), int (T::*)(void) const noexcept>    > {};template <typename T>auto Process(const T &t) -> std::enable_if_t<HasFind<std::remove_reference_t<T>>::value, void> {}

首先要想着把T::Find抠出来,对它进行decltype,如果这个操作是非法的,就阐明T中含有这个成员,因而就能利用SFINAE准则匹配到上面HasFind的特例,否则匹配通用模板(也就是false_value了)。

其次,针对含有成员Find的类型再持续进行其类型判断,让它必须是一个返回值为int且空参的非动态成员函数,此时还不得不思考const和noexcept的问题。

最初再利用std::enable_if进行判断类型是否匹配,在其外部其实依然利用的是SFINAE准则,对于匹配不上的类型通过“只申明,不定义”的形式让它不能通过编译。

template <bool conj, typename T>struct enable_if {}; // 没有实现type,所以取type会编译不通过template <typename T>struct enable_if<true, T> {  using type = T;}; // 当第一个参数是true的时候能力编译通过,并且把T传递进去

用上例是想表明,只管C++17提供了不便的工具,但仍然逃不过“利用SFINAE匹配准则”来实现性能的理念,这一点就是从C++11继承来的。

而C++20的诞生又是一次颠覆性的,它引入的「concept」则是彻彻底底扭转了这一行为,让相似于“限定模板类型”的工作不再依附SFINAE匹配。比方下面用于判断Find办法的性能,在C++20时能够写成这样:

template <typename T>requires requires (T t) {    {t.Find()} -> std::same_as<int>;}void Process(const T &t) {    std::cout << 123 << std::endl;}

其中的类型约束条件就能够定义成一个concept,所以还能够改写成这样:

template <typename T>concept HasFind = requires (T t) {    {t.Find()} -> std::same_as<int>;};template <typename T>requires HasFind<T>void Process(const T &t) {    std::cout << 123 << std::endl;}

能够看出,这样就是彻底在“语言”层面解决“模板类型限度”的问题。这样一来语法表白更加清晰,报错信息也更加纯正(不会呈现一大堆SFINAE过程)。

因而咱们说,C++20是C++的又一次颠覆,就是在于C++20不再是一味地通过裁减STL的性能来“找补”,而是从语言维度登程,真正地“进化”C++语言。

除了concept外,C++20还提供了「module」概念,用于优化传承已久的头文件编译形式,这同样也是从语言的层面来解决问题。

因为C++20在业内并没有遍及,因而本文次要介绍C++17下的C++缺点和思考,并且以“思考”和“底层原理”为主,因而不再过多介绍语言个性。如果有读者心愿理解各版本C++新个性,以及C++20提出的新理念,那么能够期待笔者后续将会编写的其余系列的文章。

一些不便的工具

【阐明:其实我原本没想写这一章,因为次要本文以“思考”和“底层原理”为主,但鉴于读者们强烈要求,最终决定在截稿前补充这一章,介绍一些用于避坑的工具,还有一些触发缺点的代替写法,但仅做十分的简略介绍,有具体需要的读者能够期待我其余系列文章。

1)智能指针

智能指针是一个用来代替new和delete的计划,实质是一个援用计数器。shared_ptr会在最初一个指向对象的指针开释时析构对象。

void Demo() {  auto p = std::make_shared<Test>(1, 2);  {    auto p2 = p; // 援用计数加1  } // p2开释,援用计数减1} // p开释,p目前是最初一个指针了,会析构对象

unique_ptr就是独立持有,只反对转交,不反对复制:

void Demo() {  auto p = std::make_unique<Test>(1, 2);  auto p2 = p; // ERR,unique指针不能复制  auto p3 = std::move(p); // OK,能够转交,转交后p变为nullptr,不再管制对象}

weak_ptr次要解决循环援用问题:

struct Test2;struct Test1 {  std::shared_ptr<Test2> ptr;};struct Test2 {  std::shared_ptr<Test1> ptr;};void Demo() {  auto p1 = std::make_shared<Test1>();  auto p2 = std::make_shared<Test2>();  p1->ptr = p2;  p2->ptr = p1;}; // p1和p2开释了,然而Test1对象外部的ptr和Test2对象外部的ptr还在相互援用,所以这两个对象都不能被开释

因而要将其中一个改为weak_ptr,它不会对援用计数产生作用:

struct Test2;struct Test1 {  std::shared_ptr<Test2> ptr;};struct Test2 {  std::weak_ptr<Test1> ptr;};void Demo() {  auto p1 = std::make_shared<Test1>();  auto p2 = std::make_shared<Test2>();  p1->ptr = p2;  p2->ptr = p1;}; // 能够失常开释

2)string_view

应用string次要遇到的问题是复制,尤其是获取子串的时候,肯定会产生复制:

std::string str = "abc123";auto substr = str.substr(2); // 生成新串

另外就是string是非平庸的,因而C++17引入了string_view,用于获取字符串的一个切片,它是平庸的,并且不会产生文本的复制:

std::string_view sv = "abc123"; // 数据会保留在全局区,string_view更像是一组指针auto substr = sv.substr(2); // 新的视图不会复制本来的数据

3)tuple

tuple能够了解为元组,或者是成员匿名的C格调构造体。能够比拟不便地绑定一组数据。

std::tuple tu(1, 5.0, std::string("abc"));// 获取外部成员auto &inner = std::get<1>(tu);// 全量解开int m1;double m2;std::string m3;std::tie(m1, m2, m3) = tu;// 结构化绑定auto [d1, d2, d3] = tu;

用做函数返回值也能够间接做到“返回多值”的作用:

using err_t = std::tuple<int, std::string>;err_t Process() {  if (err) {    return {err_code, "err msg"};  }  return {0, ""};};

这里比拟期待的是能用原生语法反对,比如说像Swift中,括号示意元组:

// 定义元组let tup1 = (1, 4.5, "abc")var tup2: (Int, String)tup2.1 = "123"let a = tup2.1// 函数返回元组func Process() -> (Int, String) {  return (0, "")}

4)optional

optional用于示意“可选”量,内含“存在”语义,不必独自选一个量来示意空:

void Demo() {  std::optional<int> oi; // 定义  oi = 5; // 赋值  oi.emplace(8); // 赋值  oi.reset(); // 置空  if (io.has_value()) { // 判断有无    int val = oi.value(); // 获取外部值  }}

还是跟Swift比拟一下,因为Swift原生反对可选类型,语法十分整洁:

var oi : Int? // 定义可选Int类型oi = 5 // 赋值oi = nil // 置空if (oi == nil) {  let val = oi! // 解包}class Test {  func f() -> Int {}}var obj: Test!let i = obj?.f() // i是可选Int型,如果obj为空则返回nil,否则解包后调用f函数let obj2 = obj ?? Test() // obj2是Test类型,如果obj为空则返回新Test对象,否则返回obj的解包

所以同样期待可选类型可能被原生语法反对。

总结与感悟

1)与C++的初见

想先聊聊笔者集体的经验,当年我上大学的时候一心想做iOS方向,所以我的启蒙语言是OC。已经的我还用OC去批评过C++的不合理。

起初我想做一个小型的手游,要用到cocos2d游戏引擎,cocos2d本来就是OC写的,但因为OC仅仅能用在iOS上,不能移植到Android,因而国内简直找不到OC版cocos2d的任何材料。惟一可用的就是官网文档,但官网文档的毛病就是,它是一个相似于字典的材料,你首先要晓得你要查什么,能力下来查。然而对于一个老手来说,更须要的是一个向导,通知你怎么上手,怎么写个hello world,有哪些根底组件别离怎么用,展现几个demo这种的材料。但OC版的恰好没有,有入门材料的只有cocos2d-x(C++移植版)、cocos2d-js和cocos2d-lua。其中C++版的材料最多,于是我过后就只能读C++版的材料。

但晚期版本的cocos2d-x属于OC向C++的移植版,命名、设计理念等都是跟OC保持一致的,所以那时候你读cocos2d-x的材料,而后再去做OC版原生cocos2d的开发是没什么问题的。但我当年十分不赶巧,我正好赶上那一版的cocos2d-x做C++化的革新。比方引入命名空间,把CCLayer变成了cocos2d::layer;比方做STL移植,把CCString迁徙成std::string,把CCMap迁徙成std::map;再比方设计形式上,把本来OC的init函数改成了C++构造函数,selector改成了std::function,诸多仿函数工具都转换为了lambda展示。所以那一版本的cocos2d-x我基本读不懂,要想读懂,就得先学会C++。起初思考到反正C++和OC是能够混编的,罗唆间接用C++版的cocos2d来做开发算了。我就这样糊里糊涂地学起了C++。

但这种孽缘一旦开始,就很难再停下来了。随着我对C++的不断深入学习,我逐步发现C++很乏味,而且正是因为它的简单,让我有了继续学上来的能源。每当我认为我差不多驯服了C++的时候,我就总能再发现一些我没见过的语法、没踩过的坑,而后就会促使我持续深入研究它。

2)一段自卑感极强的阶段

我在上一家公司已经做过一段时间的交换机嵌入式开发,本来那就是纯C的开发(而且还是C89规范),起初公司全面遍及编程能力,成立了一个先锋队,尝试向C++转型。我过后参加并且主导了其中一个畛域,把C89革新成C++14。

那时的一段时间,我对“本人会应用C++”这件事有着十分强的自卑感,而且,时不时会夸耀本人把握的C++的奇技淫巧

。而且那段时间我挂在嘴边最多的一句话就是“不是这玩意不合理,是你不会用! ”。那个时候基本不想抵赖C++存在缺点,或者哪里设计不合理。在我心目中,C++就是最正当的,世界上最好的编程语言。其他人感觉有问题无非就是他没有把握,而本人把握了其他人感觉简单的事件,就不得不产生了十分强的自卑感。

所以我已经感觉C++就是我的信奉,只有C++程序员才是真正的程序员,你们其余语言的懂指针吗?懂模板吗?看到那一大串模板套模板的时候你不晕菜吗?哈哈!我不仅能看懂,我还能本人手撸type_traits,了不起吧?

所以那个期间,其实是本人给本人设置了一道屏障,让本人不再去接触其余畛域的内容,得意忘形地满足于一个狭隘的畛域中。可能人就是这样,会有一段陈腐期间,过后就是一段塌实期,但最初还是会沉下来,进入沉着期。而到了沉着期,你又会有十分不同的视线。

3)沉着期后

我逐步发现,身边很多同学、敌人都“叛逃”了C++,转向了其余的(比如说Go),或者的确是因为C++的简单造成了劝退,但我感觉,须要思考一下,为什么会这样。

他们很多人都说Go是“下一个C++”,我本来并不认同,我认为C++永远都会作为一个长老的形象存在,其余那些“年轻人(新语言)”还没有经验工夫的打磨,所以不以为然。但起初我缓缓发现,这话尽管不全对,但在一些状况下是有情理的。比方互联网公司与传统软件公司不同,更多的我的项目都是没有特地久的剖析和设计工夫,所以要求疾速迭代。但C++其实并不是特地适宜这种场景,只管语言只是语言,设计才是要害,但语言也是一种工具,也有更适合的场景。

而对于Go来说,仿佛更适宜这种微服务的畛域,我就是开发一个畛域内的性能,而后对外一共一个rpc接口。那其实这种模式下,我仿佛并不需要太多的OOP设计,也不须要过分思考比方一个字符串复制所带来的性能损耗。但如果应用了C++,你不得不去思考复制问题、平庸析构问题、内存透露问题等等的事件,咱们能分心投在外围畛域的精力就会扩散。

所以之后的一段时间我学习了一些其余的语言,尤其是Go语言,我过后看的那本Go语言的材料,满篇都在有意无意地跟C++进行比拟,有的时候还用C++代码来解释Go的语言景象。那个时候我就思考,Go的这种设计到底是为了什么?它比C++强在哪里?又弱在哪里?

其实论断也是很简略的,就是说,C++是一种全能语言,而针对于某个更专精的畛域,把这部分的性能增强,受影响的缺点削弱或打消,而后去发明一个新的语言,更加适宜这种场景的语言,那天然劣势就是在这种场景下更加高效便捷。毛病也是不言而喻的,换个畛域它的专长就施展不进去了。说艰深一点就是,C++能写OS、能写后端、还能写前端(Qt理解一下!),写后盾可能拼不过Go,但Go你就写不了OS,写不了前端。所以这就是一个「通用」和「专精」的问题。

4)总结

已经有很多敌人问过我,C++适不适宜入门?C++适不适宜干活?我学C++跟我学java哪个更赚钱啊?笔者持有这样的观点:C++并不是最适宜生产的语言,但C++肯定是最值得学习的语言。如果说你单纯就是想干活,享受产出的高兴,那我不倡议你学C++,因为太容易劝退,找一些新语言,语法简略清晰容易上手,天然干活效率会高很多;但如果你心愿更多地了解编程语言,全面理解一些自底层到下层的原理和过程,心愿享受钻研和开悟的高兴,那非C++莫属了。把握了C++再去看其余语言,置信你肯定会有不同的见解的。

所以到当初这个工夫点,C++依然还是我的信奉。我认为C++将会在未来很长一段时间存在,并且以一个长老的身份施展其在业界的作用和价值,但同时也会有越来越多新语言的诞生,他们在本人适宜的中央施展着不一样的荣耀。 我也不再会否定C++确实有设计不合理的中央,不会否定其存在不善于的畛域,也不会再去鄙视那些吐槽C++简单的人。当然,对于每个开发者来说,都不该回绝涉足其余的畛域。只有一直学习比拟,一直总结积淀,能力继续提高。

公众号后盾回复C++避坑 ,取得更多相干技术精品。

腾讯工程师技术干货中转:

1、H5开屏从龟速到闪电,企微是如何做到的

2、只用2小时,开发足球射门游戏

3、闰秒终于要勾销了!一文详解其起源及影响

4、公布变更又快又稳?腾讯运维工程师教训首发

浏览原文