万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

导读| 在万字避坑攻略!C++的缺陷与考虑(上)一文中,微信后台开发工程师胡博豪,共享了C++的开展前史、右值引证与移动语义、类型阐明符等内容,深受广阔开发者喜欢!此篇,咱们邀请作者继续总结其在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(); // 第2次履行时由于count现已存在,因而初始化句子会无视,履行后count值为2,而且不会开释
  f(); // 同上,履行后count值为3,不会开释
} // 主函数履行结束后会开释f中的count

例如上面例程的输出结果会是:

1
2
3

2)内部大局变量/函数

当static润饰大局变量或函数时,用于捆绑其效果域为“当前文件内”。同理,由于现已是“大局”变量了,生命周期必定是契合大局的,也便是“主函数履行前结构,主函数履行结束后开释”。至于大局函数就不必说了,函数都是大局生命周期的。因而,这时分的static不会再对生命周期有影响,而是捆绑了其效果域。与之对应的是extern。用extern润饰的大局变量/函数效果于整个程序内,换句话说,便是能够跨文件。‍

// a1.cc
int g_val = 4; // 界说大局变量
// a2.cc
extern int g_val; // 声明大局变量
void Demo() {
  std::cout << g_val << std::endl; // 运用了在另一个文件中界说的大局变量
}

而用static润饰的大局变量/函数则只能在当前文件中运用,不同文件间的static大局变量/函数能够同名,而且相互独立。

// a1.cc
static int s_val1 = 1; // 界说内部大局变量
static int s_val2 = 2; // 界说内部大局变量
static void f1() {} // 界说内部函数
// a2.cc
static 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++言语,防止踩坑。

万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!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 {
 publicSingleObj &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;
}

运转上面程序会得到以下输出:

create
main function
destroy

也便是说,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,没有问题

由于移动结构函数和移动赋值函数是咱们能够自界说的,因而,能够把重复析构产生的问题在这个里边考虑好。例如上面的把对应指针置空,而析构时再进行判空即可。

因而,咱们得出的定论是并不是说非一般析构的类型就不能够运用移动语义,而对错一般析构类型进行移动结构或移动赋值时,要考虑引证权开释问题

万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

私有承继和多承继

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 {
};
// 这个函数用来创立buffer
void 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;
  // ...
};
// 这个函数用来创立buffer
void 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的内存布局便是

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

但假设类中含有虚函数,那么还会在结尾添加虚函数表的指针,例如:

class Test1 {
 public:
  char a;
  int b;
  short c;
  virtual void f() {}
};
字节编号 内容
0 a
1~3 内存对齐保存字节
4~7 b
8~9 c
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~15 B类元素
16~19 d1
20~23 d2
24~31 C类元素

假设再展开,会变成这样:

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

能够发现,A类的成员呈现了2份,这便是所谓“菱形承继”产生的副效果。这也是C++的内存布局傍边的一种缺陷,多承继时榜首个父类作为主父类兼并,而其余父类则是直接向后扩写,这个进程中没有去重的逻辑(概况参阅上一节)。这样的话不仅浪费空间,还会呈现二义性问题,例如d.a1究竟是指从B承继来的a1仍是从C里承继来的呢?

C++引进虚拟承继的概念便是为了处理这一问题。但怎样说呢,C++的杂乱性往往都是由于为了处理一种缺陷而引进了另一种缺陷,虚拟承继便对错常典型的比方,假设你直接去解说虚拟承继(比方说和一般承继的差异)你必定会觉得不可思议,为什么要引进一种这样古怪的承继办法。所以这儿需求咱们了解到,它是为了处理菱形承继时空间爆破的问题而不得不引进的。

首要咱们来看一下一般的承继和虚拟承继的差异:一般承继:

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

B的目标模型应该是这样的:

万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

而假设运用虚拟承继:

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

目标模型是这样的:

万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!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的目标模型:

万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

但假设运用虚拟承继,则能够把每个类独自的东西抽出来,重复的内容则用指针来指向:

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的目标模型将会变成:

万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

也便是说此刻,共有的虚基类只会保存一份,这样就不会有二义性,一起也节约了空间。

但需求留意的是,D承继自B和C时是一般承继,假设用了虚拟承继,则会在D内部又额定添加一份虚基表指针。要虚拟承继的是B和C对A的承继,这也是虚拟承继语法十分迷惑的当地,也便是说,菱形承继的分支处要用虚拟承继,而汇聚处要用一般承继。所以咱们仍是要理解其底层原理,以及引进这个语法的原因(针对处理的问题),才能更好的运用这个语法,避免犯错。

万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

隐式结构

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

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"] // map
let 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言语兼容、以及上一节所介绍的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的界说又失去了本来该有的意义,所以这儿也是很简略踩坑的当地。

(这一点笔者真的没想理解为什么,分明是不同类型,但为什么没有差异开。或许相同是前史原因吧,总归这个点能够算得上真实意义上的“缺陷”了。)

万字避坑指南!C++的缺陷与思考(下)

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。

已然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 []运算符相当于屡次析构加一次内存开释。

万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

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就跟大局变量的效果域是相同的),仅仅会变成编译期常量。

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++程序员。

无奈,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插入内部的元素。

当然了,这么做的确可读性会下降许多,具体运用时还需求自行取舍。

万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

模板的全特化

先跑个小题~,模板的「模」正确发音应该是「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,因而会符号f函数现已声明。

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

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

所以,咱们才要求模板的完结也要写在头文件中,也便是变成:

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时,既会用模板f的声明生成出f的声明,也会用模板f的完结生成出f的完结。

可是关于全特化的模板,状况将完全不同。由于全特化的模板现已不是模板了,而是一个确认的函数,编译期不会再用它来生成代码,因而,这时假设你把完结也写在头文件里,就会呈现重界说过错:

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的完结写在了demo.h中,那么src.cpp包含了一次,相当于完结了一次,然后main.cpp也包含了一次,相当于又完结了一次,所以报重界说过错。

因而,正确的做法是把全特化模板作为一般函数来对待,只能在源文件中界说一次:

demo.h

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

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.h
template <typename T>
void f(T t) {}
// 特化f<int>声明
template <>
void f<int>(int t);
// 特化f<int>内联界说
template <>
inline void f<int>(int t) {}

万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

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

咱们知道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++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

经典二义性问题

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; // ERR
std::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及从前版别这儿有必要用class
template <template <typename, typename> class Temp>
struct Test {}
template <typename T, typename R>
struct T1 {}
void Demo() {
  Test<T1>; // 模板参数是模板的状况实例化
}

C++17开端才答应这个class替换为typename:

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

万字避坑指南!C++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

言语、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++的缺陷与思考(下)
万字避坑指南!C++的缺陷与思考(下)

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、发布改变又快又稳?腾讯运维工程师经验首发

万字避坑指南!C++的缺陷与思考(下)

阅读原文