前言:实现中需要注意的一些问题。
只要你定义了一个变量,而其类型带有一个构造函数或析构函数,那么当程序的控制流到达这个变量定义式时,你便要承受构造成本;当这个变量离开作用域时,便要承受析构成本。
例子:
方法A
Widget w;
for (int i = 0; i < n; ++i) {
w = 取决于i的某个值;
// ...
}
方法B
for (int i = 0; i < n; ++i) {
Widget w(取决于i的某个值);
// ...
}
上面两种方法,哪种好?
方法A:1个构造函数 + 1个析构函数 + n个赋值操作
方法B:n个构造函数 + n个析构函数
因此,除非你知道赋值成本
比构造+析构
成本低,否则,你应该使用方法B。
C++规则的设计目标之一是,保证”类型错误”绝不可能发生。理论上,如果你的程序很”干净地”通过编译,就表示它并不企图在任何对象身上执行任何不安全,无意义,愚蠢荒谬的操作。这是一个极具价值的保证,可别草率地放弃它。
不幸的是,转型(cast)
破坏了类型系统。那可能导致任何种类的麻烦,有些容易识别,有些非常隐晦。在C++中转型
是一个你会想带着极大尊重去亲近的一个特性。(意思是,坑比较多)
(T) expression; // 将expression转型为T
T(expression); // 同上
// 通常被用来将对象的常量性移除(cast away the constness)
const_cast(expression);
// 主要用来执行"安全向下转型"(safe downcasting),也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式转型执行的动作,也是唯一可能耗费重大运行成本的转型动作
dynamic_cast(expression);
// 低级转型。实际动作及结果,可能取决于编译器,也就表示它不可移植
reinterpret_cast(expression);
// 用来强迫隐士转换(implicit conversions)。例如,将non-const对象转为cosnt对象,或将int转为double。但是,它无法将const转换为non-const,这个只有const_cast才能办到
static_cast(expression);
旧式转型仍然合法,但新式转型更受欢迎 。原因是:
* 它们很容易在代码中识别出来,不论是人工识别还是使用工具如grep,因此可以简化”找出类型系统在哪个点被破坏的过程”。
* 各转型动作的目标愈窄化。编译器可能诊断出错误的运用。例如,如果你打算将常量性去掉,除非使用新式转型中的const_cast,否则无法编译通过。
注意:许多程序员认为转型其实什么都没做,只是告诉编译器把某种类型视为另一种类型。这是错误的观念。任何一个类型转换(不论是通过转型操作而进行的显示转换,或通过编译器完成的隐式转换),往往真的令编译器编译出运行期间执行的代码。
例子:
class Base { // ... };
class Derived: public Base { // ... };
Derived d;
Base* pb = &d; // 隐式地将Derived* 转换为Base*
这里建立了一个base class指针指向一个derived class对象,但有时候上述的两个指针值并不相同。这种情况下,会有一个偏移量在运行期被施行于Derived*指针身上,用以取得正确的Base*指针值。
上面这个例子表明:单一对象(例如,一个类型为Derived的对象)可能拥有一个以上的地址(例如,以Base*指向它时的地址和以Derived*指向它时的地址)。C,Java,C#都不可能发生这种事,但C++可以。实际上,一旦使用多重继承,这事几乎一直发生着。即使在单一继承中也可能发生。意味着,你通常应该避免做出“对象在C++中如何布局”的假设。当然更不该以此假设为基础执行任何转型动作。例如,将对象地址转型为char*指针然后在它们身上进行指针算术,这几乎总是会导致
无定义
不明确的行为。
dynamic_cast
之所以需要dynamic_cast
,通常是因为你想在一个你认定为derived class
对象身上执行derived class
操作函数,但是你手上却只有一个”指向base”的pointer或reference。你只能靠它们来处理对象。
有两个方法可以避免这个问题:
使用容器并在其中存储直接指向derived class对象的指针(通常是智能指针),如此便消除了“通过base class”接口处理对象的需要。(但是,这种做法使你无法在同一个容器内存储指针,指向所有可能之各种派生类,如果真要处理多种派生类对象,那就需要多个容器)
在base class内提供virtual函数做你想对各个派生类做的事,即,虚函数的方法。
例如:
class Base {
public:
virtual void dosomething() {} // 空实现
};
class Derived : public Base {
public:
virtual void dosomething() {
// 真正的实现
}
};
typedef std::vector<std::tr1::shared_ptr > base_ptr_t;
base_ptr_t bp;
// ...
for (base_ptr_t::iterator iter = bp.begin(); iter != bp.end(); ++iter) {
(*iter)->dosomething(); // 注意,这里没有使用dynamic_cast,而使用虚函数的特性
}
请记住
* 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的代替设计。
* 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
* 宁可使用C++-style(新式)转型
,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。
不论handle是个指针,或迭代器,或reference,也不论这个handle是否为const,也不论那个返回handle的成员函数是否为const。这里的唯一关键是,有个handle被传出去了,一旦如此你就暴露在handle比其所指对象更长寿的风险下。
例子:
#include
class Point {
public:
Point(int x, int y) {
m_x = x;
m_y = y;
}
void setX(int newVal) {
m_x = newVal;
}
void setY(int newVal) {
m_y = newVal;
}
void show() const {
std::cout << m_x << "," << m_y << std::endl;
}
private:
int m_x;
int m_y;
};
class PointMgr {
public:
PointMgr() : m_point(1, 1) {
}
// error: binding of reference to type 'Point' to a value of type 'const Point' drops qualifiers
//Point& getPoint() const {
#if 0
// ok, but not suggested
Point& getPoint() {
return m_point;
}
#endif
// ok, suggested
const Point& getPoint() const {
return m_point;
}
void showPoint() const {
m_point.show();
}
private:
Point m_point;
};
int main()
{
PointMgr point_mgr;
point_mgr.showPoint(); // 1,1
// error
//point_mgr.getPoint().setX(2);
//point_mgr.getPoint().setY(2);
point_mgr.getPoint().show(); // 1,1
}
例外:
这并不意味你绝对不可以让成员函数返回handle。有时候你必须这么做。例如,operator[]
就允许你获取strings和vectors的个别元素,而这些operator[]s
就是返回reference指向容器内的数据
,那些数据会随着容器被销毁而销毁。尽管如此,这样的函数毕竟是例外,不是常态。
请记住
避免返回handles(包括reference,指针,迭代器)指向对象内部。遵循这个条款可增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊号码牌”(dangling handles)的可能性降至最低。
Strive for exception-safe code.
一个不符合异常安全的代码:
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
lock(&mutex); // 取得互斥器
delete bgImage; // 摆脱旧的背景图像
++imageChanges; // 修改图像变更次数
bgImage = new Image(imgSrc); // 安装新的背景图像
unlock(&mutex); // 释放互斥器
}
异常安全有两个条件:当异常被抛出时,带有异常安全性的函数会:
1. 不泄露任何资源。上述代码中,一旦new Image(imgSrc)
导致异常,对unlock
的调用就绝不会执行,于是互斥器就永远被把持住了。
2. 不允许数据破坏。如果new Image(imgSrc)
抛出异常,bgImage
就是指向一个已被删除的对象,imageChanges
也已被累加,而其实并没有新的图像被成功安装其起来。
异常安全函数(Exception-safe functions
)提供以下三个保证之一:
nothrow
)保证。承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型身上的所有操作都提供nothrow
保证。异常安全代码,必须提供上述三种保证之一。如果它不这样做,它就不具备异常安全性。
修改后,异常安全地代码(强烈保证):
有个一般化的设计策略,可以很典型地会导致强烈保证
,这个策略被称为copy and swap
。(原则很简单,为你打算修改的对象(原件)做出一份副本,然后在那副本身上做一切必要修改,若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap))
pimpl idiom
实现上,通常是将所有”隶属对象的数据”从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个所谓的实现对象。
struct PMImpl {
std::tr1::shared_ptr bgImage; // PMImpl = PrettyMenu Impl
int imageChanges;
};
class PrettyMenu {
public:
// ...
private:
Mutex mutex;
std::tr1::shared_ptr pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap;
Lock ml(&mutex);
std::tr1::shared_ptr pNew(new PMImpl(*pImpl)); // 获取副本
pNew->bgImage.reset(new Image(imgSrc)); // 修改副本
++pNex->imageChanges;
swap(pImpl, pNew); // 置换数据,释放mutex
}
请记住
1. 异常安全函数(Exception-safe functions)即使发生异常,也不会泄露资源,或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常性。
2. “强烈保证”往往能够以copy-and-swap
实现出来,但”强烈保证”并非对所有函数都可实现,或具备现实意义(时间和空间成本)。
3. 函数提供的”异常安全保证”,通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。
Inline函数,可以调用它们又不需蒙受函数调用所招致的额外开销。
没有白吃的午餐
inline函数,背后的整体观念是,将“对此函数的每一个调用”都以函数体替换之。但这样做可能增加你的目标代码大小。在一台内存有限的机器上,过度热衷inlining会造成程序体积太大,即使拥有虚内存,inline造成的代码膨胀亦会导致额外的换页行为,降低指令高速缓存装置的命中率(instruction cache hit rate),以及伴随而来的效率损失。
记住
inline只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出
,也可以明确提出
。
例如:
class Person {
public:
int age() const { return theAge }; // 一个隐喻的inline申请,age被定义于class定义式内
private:
int theAge;
};
例如:标准的max template
(来自
)
template<typename T>
inline const T& std::max(const T& a, const T& b)
{
return a < b ? b : a;
}
总结:
inline
函数无法随着程序库的升级而升级,也就是,如果f是程序库内的一个inline函数,客户将f函数本体编进其程序中,一旦程序库设计者决定改变f,所有用到f的客户端程序都必须重新编译。这往往是大家不愿意见到的。然而,如果f是non-inline
函数,一旦它有任何修改,客户端只需要重新连接就好,远比重新编译的负担少的多。如果程序采取动态链接,升级版函数甚至可以不知不觉地被应用程序吸纳。请记住
1. 将大多数inlining限制在小型,被频繁调用的函数身上。这可使日后的调试过程和二进制升级(binary upgradability)更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
2. 不要只因为function templates出现在头文件,就将它们声明为inline。
int main()
{
int x; // 定义一个int
Person p(params); // 定义一个Person
}
当编译器看到x
的定义式,它知道必须分配多少内存(通常位于stack内)才能够持有一个int
。(每个编译器都知道int
有多大)
当编译器看到p
的定义式,它也知道必须分配足够空间以放置一个Person
,但是,它如何知道一个Person
对象有多大呢?编译器获得这项信息的唯一办法就是询问class定义式。然而,如果class定义式可以合法地不列出实现细目,编译器该如何知道分配多少空间呢?
对于C++代码,你可以:将对象实现细目隐藏于一个指针背后。
针对Person
我们可以这样做:把Person
分隔为两个classes,一个只提供接口,另一个负责实现该接口。
例如:
class PersonImpl; // Person实现类的前置声明
class Date;
class Address;
class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::tr1::shared_ptr pImpl; // 指针,指向实现物,隐藏实现细节
};
Person
class只内含一个指针成员,指向其实现类PersonImpl
。这种设计被称为:pimpl idion (pimpl是 Pointer to implementation的缩写)。这样的设计下,Person
的客户端就完全与Date
,Addresses
以及Persons
的实现细节分离了。这些class的任何实现修改都不需要Person
客户端重新编译。同时,由于客户无法看到Person
的实现细节,也就不会写出什么:取决于内部细节的代码。这真正是“接口与实现分离”。
分离的关键在于:以“声明的依赖性”代替“定义的依赖性”。现实中,让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。
其他每一件事,都源于这个简单的设计策略:
1,如果使用object references
或object pointers
可以完成任务,就不要使用object
。(你可以只靠一个类型声明式,就定义出指向该类型的references
和pointer
;但如果定义某类型的objects
,就需要用到该类型的定义式)。
2,如果能够,尽量以class声明式替换class定义式。(注意,当你声明一个函数,而它用到某个class时,你并不需要该class的定义,即使函数以by value
的方式传递该类型参数或返回值)。
例如:定义func函数,但不需要Person的定义。但是,在调用func函数时,就需要知道Person的定义。也就是,比如一个函数库有非常多的函数,但是我们可能只用到了其中很少的函数,对我们用到的函数,在客户端通过前置声明的方式(而不是包含所有定义的方式),可以减少对不必要类型定义的依赖。
#include
class Person;
void func(Person &p)
{
printf("func\n");
}
int main()
{
printf("main\n");
return 0;
}
$g++ -o declare_var declare_var.cpp
$./declare_var
main
3,为声明式和定义式提供不同的头文件。为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义式。当然,这些文件必须保持一致性,如果有一个声明式被改变了,两个文件都得改变。因此,程序库客户应该总是#include一个声明文件而非前置声明若干函数,程序库作者也应该提供这两个头文件。
例如:
C++标准程序库头文件
内含iostream
各组件的声明式,其对应定义则分布在若干不同的头文件内,包括
,
,
和
。
像Person
这样使用pimpl idiom
的classes,往往被称为Handle classes
。意思是,对于Person
这样的class,如果要做点实事:
一种办法是,将它们的所有函数转交给相应的实现类,并由后者完成实际工作。
例如:下面是Person
两个成员函数的实现。
#include "Person.h"
#include "PersonImpl.h"
Person::Person(const std::string&name, const Date& birthday, const Address& addr) : pImpl(new PersonImpl(name, birthday, addr))
{}
std::string Person::name() const
{
return pImpl->name(); // 相同的名字
}
另一种办法是,令Person
成为一种特殊的abstract base class(抽象基类)
,称为”Interface class”。这种class的目的是详细一一描述derived classes
的接口,因此它通常不带成员变量,也没有构造函数,只有一个virtual
析构函数,以及一组pure virtual
函数,用来叙述整个接口。
例如:
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
// ...
};
class RealPerson: public Person {
public:
RealPerson(const std::string& name, const Date& birthday, const Address& addr) : theName(name), theBirthDate(birthday), theAddress(addr)
{}
virtual ~RealPerson() {}
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
利
Handle classes和Interface classes**解除了接口和实现之间的耦合关系,从而降低文件间的编译依赖。**弊
但是,这种设计使你在运行期丧失了若干速度,同时,又让你为每个对象超额付出若干内存。
在Handle classes身上,成员函数必须通过implementation pointer取得对象数据,那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加implementation pointer的大小。
在Interface classes身上,由于每个函数都是virtual,所以你必须为每次函数调用付出一个间接跳跃成本。此外,Interface class派生的对象必须内含一个vptr,这个指针可能会增加存放对象所需的内存数量。
Handle classes和Interface classes,由于设计上用来隐藏实现细节,因此无法实现inline
优化。
那是否应该使用Handle classes和Interface classes呢?你应该考虑以渐进的方式使用这些技术。在程序发展过程中使用,以求实现代码有变化时,对客户端带来最小的冲击。而当它们导致速度或大小差异成为主要矛盾时,就用具象类(concrete classes)替换Handle classes和Interface classes。
请记住
1. 支持”编译依赖最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes。
2. 程序库头文件应该以”完全且仅有声明式”的形式存在。这种做法不论是否涉及templates都适用。