共计 52286 个字符,预计需要花费 131 分钟才能阅读完成。
导读 | 在万字避坑指南!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
例如下面例程的输入后果会是:
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 ++ 语言,避免踩坑。
平庸、规范布局
前阵子我和一个共事对这样一个问题进行了十分强烈的探讨:
到底应不应该定义 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;
}
运行下面程序会失去以下输入:
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,没有问题
因为挪动构造函数和挪动赋值函数是咱们能够自定义的,因而,能够把反复析构产生的问题在这个外面思考好。例如下面的把对应指针置空,而析构时再进行判空即可。
因而,咱们得出的论断是 并不是说非平庸析构的类型就不能够应用挪动语义,而是非平庸析构类型进行挪动结构或挪动赋值时,要思考援用权开释问题。
公有继承和多继承
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 的对象模型应该是这样的:
而如果应用虚构继承:
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"] // 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 ++ 中,字符串并没有映射为 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 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++ 程序员也会更偏向于高性能代码的编写。当呈现嵌套构造的时候,就会思考复制问题了。举个最简略的例子,给一个 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 ¤t_test = iter->second;
std::cout << current_test.a << "," << current_test.b << std::endl; // 会打印 1, 2
}
不过有一些场景利用 try_emplace 会很不便,比方解决多重 key 时应用 map 嵌套 map 的场景,如果用 emplace 要写成:
void Demo() {
std::map<int, std::map<int, std::string>> ma;
// 例如想给 key 为 (1, 2) 新增 value 为 "abc" 的
// 因为无奈确定外层 key 为 1 是否曾经有了,所以要独自判断
if (ma.count(1) == 0) {ma.emplace(1, std::map<int, std::string>{});
}
ma.at(1).emplace(1, "abc");
}
然而利用 try_emplace 就能够更取巧一些:
void Demo() {
std::map<int, std::map<int, std::string>> ma;
ma.try_emplace(1).first->second.try_emplace(1, "abc");
}
解释一下,如果 ma 含有 key 为 1 的项,就返回对应迭代器,如果没有的话则会新增(因为没指定前面的参数,所以会结构一个空 map),并返回迭代器。迭代器在返回值的第一项,所以取 first 失去迭代器,迭代器指向的是 map 外部的 pair,取 second 失去外部的 map,再对其进行一次 try_emplace 插入外部的元素。
当然了,这么做的确可读性会降落很多,具体应用时还须要自行取舍。
模板的全特化
先跑个小题~,模板的「模」正确发音应该是「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.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 ++ 用来实现“多态”的语法次要是虚函数。当调用一个对象的虚函数时,会依据对象的理论类型来调用,而不是依据援用 / 指针的类型。
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; // 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 {}
语言、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、公布变更又快又稳?腾讯运维工程师教训首发
浏览原文