只要定义了一个变量而其类型有一个构造函数或析构函数,当程序控制流到达变量定义式时,就要承受构造成本;当变量离开其作用域时,就要承受析构成本。
参考下面的例子,它计算加密的加密版本,如果密码太多,函数会丢出一个异常:
std::string encryptPassword(const std::string& password) {
using namspace std;
string encrypted; // 过早定义 encrypted
if (password.length() < MinimunPasswordLength) {
throw logic_erroe("Password is too short");
}
...
return encrypted;
}
在本例中,enclass="superseo">crypted 并不是每次都被使用的,如果有一个异常被丢出,它就没有被使用,但是还是得付出 encrypted 的构造和析构 成本。所以最好延后 encrypted 的定义是,直到确实需要它时:
std::string encryptPassword(const std::string& password) {
using namspace std;
if (password.length() < MinimunPasswordLength) {
throw logic_erroe("Password is too short");
}
string encrypted;
...
return encrypted;
}
但是还是不够好,因为 encrypted 虽然定义却没有任何实参作为初值。条款 4 曾解释“通过 default 构造函数造出一个对象然后赋值”比“直接在构造时指定初值”效率差。
// 以 password 作为 encrypted 的初值,跳过毫无意义的构造过程
std::string encryptPassword(const std::string& password) {
string encrypted(password);
encrypt(encrypted);
return encrypted;
}
你不只应该延后变量的定义,直到非得使用该变量前一刻为止,甚至应该尝试延后这份定义直到能给它初值为止。
如果遇到循环时,应该考虑赋值成本和构造+析构成本,在考虑如何定义。
请记住:
- 尽可能延后变量定义式的出现,这样做可能增加程序的清晰度并改善程序效率。
条款 27:尽量少做转型动作
C 风格转型动作有以下几种:
(T)expression; // 将 expression 转型为 T
T(expression); // 函数转型 将 expression 转型为 T
C++ 提供的四种新形式转型:
const_cast(expression);
dynamic_cast(expression);
reinterpret_cast(expression);
static_cast(expression);
- const_cast:通常被用来移除对象的常量性。
- dynamic_cast:用来执行“安全向下转型”,也就是用来决定某对象是否归属继承体系中的某个类型。
- reinterpret_cast:意图执行低级转型,实际动作(结果)依赖于编译器,也说明它不可移植。
- static_cast:用来强制隐式转换。但无法将 const 转换成 non-const。
很多程序员认为,转型只是告诉编译器把一种类型视作另一种类型,其他并没有做什么。但是这是错误的观念。任何一个类型转换(无论是通过转型 *** 作的显式转换,还是通过编译器的隐式转换)往往真的会让编译器译出运行期间执行的代码。参考下面的例子:
Derived d;
Base* pb = &d; // 将 Derived* 转换为 Base*
这里我们建立一个 base class 指针指向一个 derived class 对象,但有时候上述的两个指针值并不相同。这种情况下会有个偏移量在运行期被实行于 Derived* 指针身上,用以获取正确的 Base* 指针值。这也表明了,单一对象(例如一个类型为 Derived 的对象)可能拥有一个以上地址(例如,以 Base* 指向它是的地址和 以 Derived* 指向它时的地址)的行为。请注意,是有时候需要一个偏移量,对象的布局方式和它们的地址计算方式随编译器不同而不同。
static_cast在参考下面的例子,有许多应用框架都要求 derived class 内的 virtual 函数代码的第一个动作就要先调用 base class 对应的函数:
class Window { // base class
public:
virtual void onResize() { ... } // base 实现代码
};
class SpecialWindow : public Window { // derived class
public:
vitual void onResize() {
static_cast(*this).onResize();
}
... // SpecialWindow 专属动作
};
这段代码中强调了转型动作,将 *this 转型为 Window 类型,因而调用函数 Window::onResize。但是,它调用的并不是当前对象上的函数,而是 *this 对象 base class 成分的临时副本身上的 onResize。也就是说,上述代码在非当前对象身上调用 Window::onResize 之后,又在当前对象上执行 SpecialWindow 专属动作。如果它在 Window::onResize 进行了数据的修改,改动的是副本而不是当前对象。
解决上述问题的方法是,移除转型动作,直接调用 Window::onResize:
class SpecialWindow : public Window { // derived class
public:
vitual void onResize() {
Window::onResize(); // 调用 Window::onResize 作用于 *this 身上
}
... // SpecialWindow 专属动作
};
dynamic_cast
dynamic_cast 的许多实现版本执行速度非常慢,但之所以需要 dynamic_cast,通常是因为你想在一个 derived class 对象身上执行 derived class *** 作函数,但是你手上只有一个指向 base 的 pointer 或 reference,你只能靠它们来处理对象。有两个一般性做法可以避免这个问题。
第一,适用容器并在其中存储直接指向 derived class 对象的指针(通常是智能指针,见条款 13),如此便消除了通过 base class 接口处理对象的需要。当然,这种做法使你无法在同一个容器内存储指向所有可能派生类的指针。如果需要处理多种窗口类型,可能需要多个容器,它们都必须具备类型安全性。
第二,在 base class 内提供 virtual 函数,做你想对各个 Window 派生类做的事。例如,只有派生类可以闪烁,但可以将闪烁函数声明于 base class 内并提供一份什么也不做的缺省实现代码:
class Window {
public:
virtual void blink() { }
};
无论哪一种写法 —— 适用类型安全容器或将 virtual 函数忘继承体系上方移动 —— 都可以提供一个可行的 dynamic_cast 替代方案,即使这也生产出来的代码又大又慢。
请记住:
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast。如果有个设计需要转型动作,试着发展无需转型的替代设计。
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
- 尽量使用 C++ 新式转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有分门别类的职掌。
条款 28:避免返回 handles(引用、指针和迭代器) 指向对象内部成分
参考下面的例子:
class Point { // 这个 class 用来描述点
public:
Point(int x, int y);
...
void setX(int newVal);
void setY(int newVal);
...
};
// 这些点数据用来表现一个矩形
struct RectData {
Point ulhc; // 左上角
Point lrhc; // 右下角
};
// 矩形
class Rectangle {
public:
...
private:
std::tr1::shared_ptr pData;
};
Rectangle 的客户必须能够计算矩形的范围,所以 class 提供两个函数用来获取坐标点:
class Rectangle {
public:
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
...
};
虽然可以通过编译,但实际上这是错误的使用。一方面这两个函数被声明为 const 成员函数,因为它们的目的只是提供获取坐标点的方法。另一方面,它们却都返回 reference 指向 private 内部数据,调用者甚至可以通过这些函数修改内部数据:
Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2); // (0, 0) -> (100, 100)
rec.upperLeft().setX(50); // (50, 0) -> (100, 100)
upperLeft 的调用者能够使用返回的 reference(指向 rec 内部 Point 成员变量)来更改成员。这也给我们两个教训:第一,成员变量的封装性最多只等于“返回其 reference”的函数的访问级别,本例中,ulhc 和 lrhc 虽然被声明为 private,但是因为 publc 函数 upperLeft 和 lowerRight 传出它们的 reference 所以它们事实上是 public 的;第二,如果 const 成员函数传出一个 reference,后者所指数据与对象自身有关联,而它又被存储在对象之外,那么这个函数的调用者可以修改那笔数据。这也是 bitwise constness 的一个附带结果(见条款 3)。
引用、指针和迭代器都是所谓的 handles,而返回一个“代表对象内部数据”的 handle,随之而来的便是“减低对象封装性”的风险。绝对不要让成员函数返回一个指针指向访问级别较低的成员函数。如果你这么做,后者实际访问级别就会提高如同前者。
解决上述问题的方法也很简单,只用对它们的返回类型加上 cosnt 即可:
class Rectangle {
public:
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }
...
};
此时客户可以读取矩形的 Point,但不能修改它们。但是即便如此,这两个函数还是返回了代表对象内部的 handle,有可能在其他场合带来问题。它可能导致 handle 指向不存在的对象。这种情况最常见的来源就是函数返回值:
class GUIObject { ... };
const Rectangle boundingBox(const GUIObject& obj);
// 客户可能会这么使用这个函数
GUIObject* pgo;
const Point* pUpperLeft = &(GUIObject(*pgo).upperLeft());
事实上 pUpperLeft 指向一个不再存在的对象;也就是说一旦产出 pUpperLeft 的那个语句结束,pUpperLeft 也就变成悬空了。
请记住:
- 避免返回 handle(包括引用、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助 const 成员函数行为像个 const,并将发生“hadle 指向不存在对象”的可能性降至最低。
条款 29:为异常安全而努力是值得的
参考下面的例子:
void PrettyMenu::changeBackground(std::istream& imgSrc) {
lock(&mutex); // 取得互斥器
delete bgIage; // 释放旧背景图像
++imageChanges; // 修改图像变更次数
bgImage = new Image(imgSrc); // 安装新背景图
unlock(&mutex); // 释放互斥器
}
异常安全有两个条件,而这个函数没有满足其中任何一条。当异常被抛出时,带有异常安全性的函数会:
- 不泄露任何资源。一旦 new Image(imgSrc) 导致异常,对 unlock 的调用就永远不会执行,于是互斥器就被锁住了。
- 不允许数据败坏。如果 new Image(imgSrc) 抛出异常,bgImage 就指向一个已经被删除的对象,而且 imageChanges 也被累加了。
异常安全函数需要提供以下三个保证之一:
- 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而破坏,所有对象都处于一种内部前后一致的状态。
- 强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功;如果函数失败,程序会恢复到调用函数之前的状态。
- 不抛出异常保证:承诺绝不跑出异常,因为它们总是能够完成它们原先承诺的功能。作用域内置类型身上的所有 *** 作都提供 nothrow 保证。这是异常安全码中一个必不可少的关键基础。
异常安全代码必须提供上述三种保证之一。如果它不这样做,它就不具备异常安全性。
copy and swap有一个策略值得去了解,这个策略被称为 copy and swap。原则很简单:为你打算修改的对象做出一个份副本,然后再那个副本身上做一切必要修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象再一个不抛出异常的 *** 作中置换。
实现上通常是将所有隶属对象的数据从原对象放进另一个对象内,然后赋予元对象一个指针,指向那个副本。这种手法北岑伟 pimpl idiom(见条款 31)。
struct PMImpl {
std::tr1::shared_ptr bgImage;
int imagesChanges;
};
class PrettyMenu {
...
private:
Mutex mutex;
std::tr1::shard_ptr pImpl;
};
void PrettyMenu::changeBackround(std::istream& imgSrc) {
using std::swap; // 见条款 25
Lock ml(&mutex); // 见条款 14 获得 mutex 的副本数据
std::tre::shared_ptr pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc)); // 修改副本
++pNew->imagesChanges;
swap(pImpl, pNew); // 置换数据,释放 mutex
}
策略是对对象状态做出“全有或全无“改变的一个很好办法,但一般而言它并不保证整个函数有强烈的异常安全性。参考下面例子:
void someFcn() {
...
f1();
f2();
...
}
如果 f1 或 f2 的异常安全性比强烈保证低,就很难让 someFcn 成为强烈异常安全;即使二者都是强烈异常安全,情况也不会有所好转。毕竟 f1 结束之后,程序状态在任何方面都有可能改变,如果 f2 随后抛出异常,程序状态和 someFcn 被调用前并不相同。
这个问题处在连带影响。如果函数之 *** 作局部性状态,便相对容易地提供强烈保证。但时当函数对非局部性数据有连带影响时,提供强力保证就困难得多。当强烈保证不切实际时,你就必须提供基本保证。
请记住:
- 异常安全函数即使发生异常也不会泄露资源或允许任何数据结构败坏。这样的函数区分未三种可能的保证:基本型、强烈型、不抛异常型。
- 强烈保证往往能够以 copy-and-swap 实现出来,但强烈保证并非对所有函数都可实现或具备现实意义。
- 函数提供的异常安全保证通常最高只等于其所调用的各个函数的异常安全保证中的最弱者(木桶效应)。
条款 30:透彻了解 inlining 的里里外外
inline 函数,类似函数,但调用它们时,又不用受函数调用所导致的额外开销。除了免除函数调用成本外,编译器还可以对它执行语境相关最优化。
inline 函数的整体观念是,将对此函数的每一个调用都以函数本体替换之。过度热衷 inline 会造成程序体积太大,inline 造成的代码膨胀也会导致额外的换页行为,降低指令高速缓存装置的击中率,以及伴随而来的效率损失。换句话说 inline 函数是以空间换时间的方式。
换个角度说,如果 inline 函数的本体很小,编译器很对函数本体所产出的代码可能比针对函数调用所产出的代码更小。记住,inline 只是对编译器的一个申请,不是强制命令。这向申请可以隐喻提出,也可以明确提出。隐喻方式是将函数定义于 class 定义式内:
class Person {
public:
int age() const { return theAge; } // 一个隐喻的 inline 申请
private:
int age;
};
明确声明 inline 函数的做法式再起定义式前加上关键字 inline。
inline 函数通常被置于头文件内,因为大多数建置环境再编译过程中进行 inlining,而为了将一个函数调用替换未被调用函数的本体,编译器必须知道那个函数长什么样子。inling 在大多数 C++ 程序中是编译期行为。
template 通常也被置于头文件内,因为它一旦被使用,编译器为了将它实例化,需要知道它长什么样子。template 的实例化与 inling 无关。如果 template 实力出来的函数都是 inline,请将此 template 声明为 inline;如果不是,就应该避免将这个 template 声明为 inline。
一个 inline 函数是否真的是 inline,取决于你的建置环境,主要取决于编译器。
inline void f() { ... } // 假设编译器有意愿 inline 对 f 的调用
void (*pf) () = f; // pf 指向 f
...
f(); // 这个调用将被 inline
pf(); // 这个调用或许不被 inline
程序库设计者必须评估将函数声明为 inline 所带来的结果:inline 函数无法随着程序库的升级而升级。换句话说如果 f 是程序库内的一个 inline 函数,客户将 f 函数本体编进期程序中,一旦程序库设计者决定改变 f,所有用到 f 的客户端程序都必须重新编译。然而如果 f 是 non-inline 函数,一旦它有任何修改,客户端只需重新连接就好,远比重新编译的负担少很多。
请记住:
- 将大多数 inline 函数限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,是程序的速度提升机会最大化。
- 不要只因为 function template 出现在头文件,就将它们声明为 inline。
条款 31:将文件间的编译依存关系降至最低
假设你对 C++ 程序的某个 class 实现文件做了些轻微修改。注意,修改的不是 class 接口,而是实现,而且只改 private 成分。然后重新建置这个程序,并预计只用花费数秒即可,毕竟只有一个 class 被修改。你按下 Build 按钮或键入 make,然后会发现整个世界都被重新编译和连接了。
问题出在 C++ 并没有把”接口从实现中分离“,class 的定义式不只详细叙述了 class 接口,还包括十足的实现细目。例如:
class Person {
public:
Person(const std::string& name, const Date& birthday);
std::string name() const;
std::string birthDate() const;
...
private:
std::string theName; // 实现细目
Date theBirthDate; // 实现细目
};
这里的 class Person 无法通过编译 —— 如果编译器没有取得其实现代码所用到的 string、Date 的定义式。这样的定义式通常由 #include 指示符提供,所以 Person 定义文件的最上方很可能存在这样的东西:
#include
#include "date.h"
这样一来 Person 定义文件和其含入文件之间形成了一种编译依存关系。如果这些头文件中有任何一个被改变,或这些头文件所依赖的其他头文件有任何改变,那么每一个包含 Person class 的文件就得重新编译,任何使用 Person class 的文件也必须重新编译。
pimpl idiom 实现细目于定义式分离但为什么不将 class 的实现细目与 class 定义式分开,类似:
namespace std {
class string; // 前置声明 错误!
}
class Date; // 前置声明
class Person {
Person(const std::string& name, const Date& birthday);
std::string name() const;
std::string birthDate() const;
};
如果这么做,Person 的客户就只需要在 Person 接口被修改过式才重新编译。
这个想法存在两个问题:第一,string 不是个 class,它是个 typedef,因此上述针对 string 而做的前置声明并不正确,正确的前置声明十分复杂,因为设计额外的 template;第二,在前置声明时,编译器必须在编译期间就知道对象的大小。考虑下面的例子:
int main() {
int x; // 定义一个 int
Person p(params); // 定义一个 Person
}
当编译器看到 x 定义式,它知道分配多少内存能够持有一个 int。但是当编译器看到 p 的定义式时,它并不知道需要多少空间才可以放下一个 Person 对象。
此问题在 Java 等语言上并不存在,因为当我们以那种语言定义兑现时,编译器之分配足够空间给一个指针(用以指向该对象)使用,也就是它们将上述代码视为:
int main() {
int x; // 定义一个 int
Person* p; // 定义一个指针指向 Person 对象
}
当然这也是合法的 C++ 代码,也就是将对象实现细目隐藏于一个指针背后。针对 Person 我们可以将 Person 分割为两个 class,一个只提供接口,另一个负责实现接口。如果负责实现的那个 implementation class 取名为 PersonImpl,Person 将定义如下:
#include
#include
class PersonImpl; // Person 实现类的前置声明
class Date; // Person 接口用到的 class 的前置声明
class Person {
Person(const std::string& name, const Date& birthday);
std::string name() const;
std::string birthDate() const;
private:
std::tr1::shared_ptr pImpl; // 指针,指向实现物
};
在这里,Person 内只含一个指针成员,指向其实现类(PersonImpl)。这般设计常被称为 pimpl idiom(pimpl 是 pointer to implementation 的缩写)。这种 class 内的指针名称往往就是 pImpl,就像上面代码一样。
这样的设计之下,Person 的客户就完全与 Date 以及 Person 的实现细目分离了,那些 class 的任何修改都不需要 Person 客户端重新编译。此外由于客户无法看到 Person 的实现细目,也就不可能写出取决于那些细目的代码。这就是接口与实现分离。
这个分离的关键在于以”声明的依存性“替换”定义的依存性“,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。其他每一件事都源自于这个简单的设计策略:
-
如果使用 object reference 或 object pointer 可以完成任务,就不要使用 object。你可以只靠一个类型声明式就定义出指向该类型的 reference 和 pointer;如果定义某类型的 object,就需要用到该类型的定义式。
-
如果能够,尽量以 class 声明式替换 class 定义式。当你声明一个函数而它用到某个 class 时,你并不需要该 class 的定义;纵使函数以 by value 方式传入该类型的参数(或返回值)亦然:
class Date; // class 声明式 Date today(); // 这里不需要 Date 的定义式 void clearAppointments(Date d); // 同上
-
为声明式和定义式提供不同的头文件。为了促进上述准则,需要两个头文件,一个用于声明式,一个用于定义式。这些文件必须保持一致性,如果有个声明式被改变了,两个文件都得改变。因此程序库客户应该总是 #include 一个声明文件而非前置声明若干函数,程序库作者也应该提供这两个头文件。
举个例子,Date 的客户如果希望声明 today 和 clearAppointments,他们不应该像先前那样手工方式前置声明 Date,而应该 #include 适当的、内含声明式的头文件:
#include "datefwd.h" // 这个头文件欸声明(但未定义)class Date Date today(); void clearAppointments(Date d);
像 Person 这样使用 pimpl idiom 的 class,往往被称为 Handle class。这样的 class 可以将它们的所有函数转交个相应的实现类(implementation class),并由后者完成实际工作。例如下面式 Person 两个成员函数的实现:
#include "Person.h" // 我们正在实现 Person class,所以必须 包含其 class 定义式
#include "PersonImpl.h" // 我们也必须包含 PersonImpl 的 class 定义式,否则无法调用其成员函数,注意 Person 和 PersonImpl 有完全相同的成员函数,两者接口完全相同
Person::Person(const std::string& name, const Date& birthday) : pImpl(new PersonImpl(name, birthday)) { }
std::string Person::name() const {
return pImpl->name();
}
注意,让 Person 变成一个 Handle class 并不会改变它做的事,指挥改变它做事的方法。
interface class另一个制作 Handle class 的办法是,令 Person 成为一种特殊的 abstract base class(抽象基类),称为 interface class。这种 class 的目的是详细一一描述 derived class 的接口(见条款 34),因此它通常不带成员变量,也没有构造函数,只有一个 virtual 析构函数(见条款 7)以及一组 pure virtual 函数,用来叙述整个接口。
interface class 类似 Java 的 interface,但 C++ 的 interface class 并不需要负担 Java 的 interface 所需承担的责任。例如,Java 不允许在 interface 内实现成员变量或成员函数,但 C++ 并不禁止这两样,C++ 有更大的d性。一个针对 Person 而写的 interface class 或许看起来像这样:
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
};
这个 class 的客户必须以 Person 的 pointer 和 reference 来撰写应用程序,因为它不可能针对含有 pure virtual 的 Person class 具现出实体。
interface class 的客户通常调用一种特殊函数,此函数扮演真正被具现化的那个 derived class 的构造函数角色。这样的函数通常被称为 factory 函数(见条款 13)或 virtual 构造函数。它们返回指针(或智能指针),指向动态分配所得对象,而该对象支持 interface class 的接口。这样的函数又往往在 interface class 内声明为 static:
class Person {
public:
static std::tr1::shared_ptr creat(const std::string& name, const Date& birthday);
};
客户这样使用它们:
std::string name;
Date dateOfBirth;
// 创建一个对象,支持 Person 接口
std::tr1::shared_ptr pp(Person::creat(name, dateOfBirth));
支持 interface class 接口的那个具象类必须被定义出来,而且真正的构造函数必须被调用。一切都在 virtual 构造函数实现代码所在的文件内发生:
class RealPerson : public Person {
public:
RealPerson(const std::string& name, const Date& birthday) : theName(name), thrBirthDate(birthday) { }
virtual ~RealPerson() { }
std::string name() const;
std::string birthDate() const;
private:
std::string theName;
Date theBirthDate;
};
std::tr1::shared_ptr Person::creat(const std::string& name, const Date& birthday) {
return std::tr1::shared_ptr(new RealPerson(name, dateOfBirth));
}
handle class 和 interface class 解除了接口和实现之间的耦合关系,从而减低文件间的编译依存性。
在 handle class 身上,成员函数必须通过 implementation pointer 取得对象数据。那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加 implementation pointer 的大小。最后,implementation pointer 必须初始化(在 handle class 构造函数内),指向一个动态分配的来的 implementation object,所以你将蒙受动态内存分配(及随后的释放)而来的额外开销,以及砸偶 bad_alloc 异常(内存不足)的可能性。
至于 interface class,由于每个函数都是 virtual,所以你必须为每次函数调用付出一个间接跳跃成本。此外 interface class 派生的对象必须内含一个 vptr(virtual table pointer,见条款 7),这个指针可能会增加存放对象所需内存数量 —— 实际取决于这个对象除了 interface class 之外是否还有其他 virtual 函数来源。
最后,不论 handle class 还是 interface class,一旦脱离 inline 函数都无法有太大作为。
请记住:
- 支持”编译依存性最小化“的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是 handle class 和 interface class。
- 程序库头文件应该以”完全且仅有声明式“的形式存在。这种做法不论是否涉及 template 都适用。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)