C++的最佳特性(译)
如果你想学会C++的全部内容,这是一件巨大、复杂和充满陷阱的事情。如果你看到一些人使用它的方式,你可能会被吓坏。现在新的功能正在陆续加入C++标准,所以要学会语言的各个细节没有很多年的积累是不现实的。
但是你没必要学会语言的方方面面才能去动手写程序,高效地使用C++其实只需要学习它的几个基本特性。在这篇文章中,我准备向大家介绍一下我认为C++中最重要的特性之一,这个特性也是是我选择C++而不是其它编程语言的原因。
确定的对象生命周期(Determined object life-time)
你在程序中创建的每一个对象都有一个精确且确定的生命周期。一旦你确定使用某种生命周期,你就准确地知道你创建的对象的生命周期从什么时候开始,到什么时候结束。
对于局部变量(automatic variables),它们的生命周期从他们声明处开始(在正常的初始化过程结束后,没有产生异常),到离开所在的作用域为止。对于两个相邻的局部变量,定义在前面的变量生命周期开始得较早,结束得较晚。
对于函数形参,它们的生命周期刚好在函数开始之前开始,刚好在函数完成执行之后结束。
对于全局变量,它们的生命周期在main函数之前开始(译注:实际上是由系统的C Runtime进行初始化,初始化之后才调用main函数),在main函数完成执行后结束。定义在同一个编译单元(译注:translation unit,C++术语,可以理解为已经包含了引入了头文件的cpp文件)中的两个全局变量,定义在前面的变量生命周期开始得较早,结束得较晚。对于定义在不同编译单元的两个全局变量,不能对他们之间生命周期的关系做出假设(译注:有些像C++中未定义的行为,是实现相关的)。
对于几乎所有的临时变量(除了两种已经良好定义的例外),它们的生命周期从一个较长的表达式内部的函数通过传值返回(或者显式地创建)开始,到整个表达式求值完成为止。
两个例外如下:当一个临时对象绑定到一个全局或局部的引用上时,它的生命周期和那个引用的生命周期一样长。
Base && b = Derived{};
int main()
{
// ...
b.modify();
// ...
}
// Derived类型的临时对象生命周期到此为止(注意引用的类型和临时对象的类型不是一样的,而且我们可以修改这个临时对象。“临时”表示存在时间是“短暂的”,但是当它绑定到一个全局引用上时,它的生命周期就和其它任何全局变量的生命周期一样长了。)
第二个例外适用于初始化用户自定义类型的数组。在这种情况下,如果使用一个默认构造函数来初始化数组的第n个元素,而且默认构造函数有一个或多个默认参数,那么在默认参数里面创建的每个临时对象的生命周期在我们继续初始化第n+1个元素时结束。但是你很可能在写程序的过程中不需要知道这一点。
对于类内部的成员对象来说,它们的生命周期在其所在的对象生命周期开始之前开始,在所在的对象生命周期结束之后结束。
其它类型的对象的生命周期是类似的:函数局部静态变量、thread-local变量以及我们可以手动控制的变量生命周期,比如new/delete和optional等。在这些情况下,对象生命周期的开始和结束都是经过良好定义且可预测的。
在对象初始化的过程中,它的生命周期马上就要开始,但如果此时发生了异常,那么它的生命周期并没有真正开始。
简而言之,这就是确定的对象生命周期的本质。那什么是不确定的对象生命周期呢?在C++中(暂时)还没有,但是你可以在其它带有“垃圾回收”支持的语言中看到。在这种情况下,当你创建一个对象的时候,它的生命周期开始了,但是你不知道它的生命周期什么时候结束。垃圾回收保证,如果你有引用指向一个对象,那个这个对象的生命周期就一定不会结束。但是如果指向这个对象的最后一个引用不存在了,那么它存活的时间可能就是任意长的,直到整个进程的结束。
那么,为什么确定的对象生命周期如此重要呢?
析构函数
C++保证,在任何类型的对象在它们生命周期结束时调用它们的析构函数。析构函数是对象所在类的成员函数,并被确保是类的对象最后调用的函数。
所有都已经知道这一点了,但是不是所有人都了解它给我们带来的好处。首先,最重要的一点,你可以通过析构函数来清理你的对象在生命周期中所获取的资源。这种清理被封装了起来,对于用户是不可见的:用户不需要手动调用任何dispose或者close函数。所以你一般不会忘记去清理资源,你甚至不用知道你当前使用的类是否有管理着资源。而且当对象销毁时,它的资源会被立即清理,而不是在某个不确定的将来。资源越早释放越好,这会防止资源泄露。这个过程不会留下任何垃圾,也不会留下资源在为确定的时间需要清理。(当你看到“资源”这个字眼时,不要只想着内存,多想想打开数据库或者socket连接。)
确定的对象生命周期还保证了对象销毁的相对顺序。假如一个作用域中有几个局部对象,那么它们会按照与声明(和初始化)相反的顺序被销毁。类似的,对于类的内部对象来说,它们也是按照在类定义中声明(和初始化)相反的顺序被销毁。这本质上是保证资源相互依赖关系的正确性。
这个特性是由于垃圾回收的,有以下几个原因:
1. 它为所有你能想到的所有资源管理提供了统一的方式,而不仅仅是内存;
2. 资源会在它们不再被使用时立即被释放,而不是让垃圾回收来决定什么时候去清理;
3. 它不会带来像垃圾回收所带来的运行时刻的额外开销。
基于垃圾回收器的语言倾向于提供资源管理的替代方式:在C#中的using语句或者Java中的try语句。尽管它们是朝着好的方向去的,但还是不如析构函数的用法好。
1. 资源管理直接暴露给了用户:你需要知道你当前使用的类型管理着内存,然后添加额外的代码来请求释放资源;
2. 如果类的维护者决定将一个原本不管理资源的类改成管理资源的类,那么用户需要修改自己的代码,这是资源清理没有被封装带来的问题;
3. 这种方式无法与泛型编程一起使用:你不能写出对于处理和不处理资源的类的统一语法的代码。
最后(译注:作者表示这是双关,但是我没看懂什么意思),这种保护语句块(guarding statements)只能替代C++中的对于“局部”对象(也就是在函数或者某个语句块中创建的对象)的处理方式。C++还提供了其它类型的对象生命周期。比如说,你可以让一个资源管理的对象成为另外一个“主”对象的成员,通过这种方式表达:这个资源的生命周期一直持续到主对象的生命周期结束时。
考虑以下打开n个文件流然后把他们放在一个容器里面返回的函数,还有一个从这个容器里面读出这些文件流然后自动关闭这些流的函数:
vector<ifstream> produce()
{
vector<ifstream> ans;
for (int i = 0; i < 10; ++i) {
ans.emplace_back(name(i));
}
return ans;
}
void consumer()
{
vector<ifstream> files = produce();
for (ifstream& f: files) {
read(f);
}
} // 关闭所有文件如果你想通过using语句或者是try语句,你怎么实现这个功能呢?
注意这边有一个窍门。我们用到了C++中的另一个重要的特性:move构造函数,还用到了一个基本事实:std::fstream是不可拷贝,但却是可以移动的。(然而GCC 4.8.1的用户可能不会注意这个)同样的事情(传递地)发生在std::vector<std::ifstream>上。move操作像是仿真了另一个唯一的对象的生命周期。在这个过程中,我们有资源(文件句柄的集合)的“虚拟的”生命周期和“手工的”生命周期,其中这个生命周期从ans被创建开始,到定义在另外一个作用域里的不同的对象生命周期结束而结束。
注意到,整个文件句柄的集合整个的“扩展的”生命周期中,如果有异常发生,每个句柄都被保护不会泄露。即使name函数在第5次迭代时发生了异常,之前已经创建好的4个元素都会保证在produce函数被正确析构。
类似的,你无法通过“保护”语句做到做到下面的效果:
class CombinedResource
{
std::fstream f;
Socket s;
CombinedResource(string name, unsigned port)
: f{name}, s{port} {}
// 没有显式地调用析构函数
};这段代码已经给了你好几个有用的安全性保障。两个资源会在CombinedResource的生命周期结束时被释放:这是在隐式的析构函数中按照与初始化的相反的顺序来处理的,你不需要去手工写这些代码。假设在初始化第二个资源s的时候,在其构造函数中发生了异常,已经被初始化好了的f的析构函数会被立即调用,这个过程在异常从s的构造函数中向上抛出时已经完成了。你可以免费获取到这些安全性保障。
试问,你怎么通过using或者try来保证上面的安全性保障呢?
不好的方面
这边有必要提一些有些人不喜欢析构函数的原因。在某些情况下,垃圾回收比C++提供的资源管理方式要好。比如,有了垃圾回收器(如果你用得起它的话),你可以仅仅通过分配节点然后通过指针(你也可以称之为“引用”)把他们连接起来,来很好地表示一个带环的图。在C++中,你没法做到这一点,甚至用“智能”指针也不行。当然,这种通过垃圾回收来管理的图中的节点没法管理资源,因为它们可能会泄露:using或者try语句在这里不起作用,因为finalizer函数不一定会被调用。
还有,我听一些人说有一些高效的并行算法只能在垃圾回收器的
补充:软件开发 , C++ ,