Effective C++读书笔记~5 实现

条款26:尽可能延后变量定义式的出现时间

Postpone variable definitions as long as possible.

为什么要延后变量定义?

因为定义一个变量会有构造成本和析构成本,而该变量可能从始至终并未使用过。因此,如果能尽量避免,就能减少这些成本。

如何延后变量定义?

1)不要过早定义变量,即将使用的时候定义

// 过早定义变量encrypted
string encrypPassword(const string& password)
{
    using namespace std;
    string encrypted; // 提前定义变量 encrypted, 保存加密后的密码
    if (password.length() < MinimumPasswordLength) { // 如果抛出异常, 就不会使用变量encrypted
        throw logic_error("Password is too short");
    }
    ...
    return encrypted;
}

==> 改进
// 延后定义变量encrypted
string encrypPassword(const string& password)
{
    using namespace std;
    
    if (password.length() < MinimumPasswordLength) { // 可能抛出异常
        throw logic_error("Password is too short");
    }
    string encrypted; // 延后定义变量 encrypted, 保存加密后的密码
    ...
    return encrypted;
}

2)定义对象时,提供构造参数
如果不提供构造参数,意味着调用default构造函数。条款4解释了“通过default构造函数构造对象,然后赋值”的效率比“直接在构造时指定初值”更低。

void encrpyt(string& s) // 对s加密
{
    ...
}

// default构造对象
string encryptPassword(const string& password)
{
    ...
    string encrypted; // 已延后定义encrypted. default构造函数 构造encrypted
    encrypted = password; // 赋值给encrypted
    encrpyt(encrypted); 
    return encrypted;
}

==> 改进
// 带参数构造对象
string encryptPassword(const string& password)
{
    ...
    string encrypted(password); // 已延后定义encrypted. 参数构造encrypted (copy 构造)
    encrpyt(encrypted);
    return encrypted;
}

3)循环体内 or 外定义变量?
对于循环,变量是定义在循环体外(方法A),还是循环体内(方法B)?
先看下面的例子,

// 方法A:定义于循环外
Widget w;
for (int i = 0; i < n; ++i) {
    w = 取决于i的某个值;
    ...
}

// 方法B:定义于循环体内
for (int i = 0; i < n; ++i) {
    Widget w(取决于i的某个值);
    ...
}

Widget函数内部定义变量的开销:
方法A:1个构造函数 + 1个析构函数 + n个赋值操作;
方法B:n个构造函数 + n个析构函数;

当class的赋值成本明显低于构造 + 析构成本时,特别n较大时,A方法较好;
否则,B方法较好,因为B的程序更容易理解。

也就是说,当1)明确知道赋值成本比“构造+析构”成本低,2)正在处理代码的效率高度敏感时,选择方法A;否则选择方法B。

小结

1)尽可能延后变了定义式的出现,因为可以增加程序的清晰度并改善程序效率。

[======]

条款27:尽量少做转型动作

Minimize casting.

为什么要减少转型动作?

转型(casts)破坏了类型系统(type system),可能导致任何种类的麻烦,难以辨识。

转型语法

旧式转型:
1)C风格转型

(T)expression // 将expression转型为T

2)函数风格转型

T(expression) // 将expression转型为T

C++新式转型(称为new-style或C++-style casts):
4种

const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)

各个转型目的:
1)const_cast 通常被用来将对象的常量性移除(cast away the constness)。唯一有此能力的C++-style转型操作符。
non-const member函数调用const member时,const_cast可用于去掉常量性。

2)dynamic_cast 主要用来执行“安全向下转型”(safe downcasting),也就是用来决定某对象是否归属继承体系中的某个类型。唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作。
一般情况下,不允许基类指针转型为派生类指针或引用,因为编译器并不知道运行时指针所指向的实际对象,是否为派生类对象。而dynamic_cast允许基类指针在运行时安全地转型为派生类指针。

3)reinterpret_cast 意图执行低级转型,实际动作(及结果)可能取决于编译器,意味着它不可移植。
慎用。

int *ip;
char *pc = reinterpret_cast<char *>(ip); // pointer from int* to char*

string str(pc); // 可能导致程序异常

4)static_cast 用来强迫隐式转换,例如将non-const对象转为const对象(条款3),或将int转为double等。可以完成1)~3)的多种反向转换。但无法将const转为non-const(去掉常量性) -- 因为这是只有const_cast才办得到。

旧式转型与新式转型的选择

旧式转型仍然合法,但新式转型更受欢迎。原因在于:
1)容易在代码中被辨识(人眼或工具如grep);
2)各转型动作目标越窄化,编译器越可能诊断出错误的应用;

除了explicit构造函数转型,其他情况推荐使用新式转型。

去掉转型动作

有时,容易写出某些似是而非的代码,在其他语言可能正确,但在C++则不正确。

1)去掉static_cast案例

class Windows { // base class
public:
    virtual void onResize() {...} // base onResize实现
    ...
};

class SpecialWindow: public Window { // derived class
public:
    virtual void onResize() { // derived onResize实现
        static_cast<Window>(*this).onResize(); // 错误: 将*this转型为Window(副本), 然后调用其onResize
        
        ... // SpecialWindow专属行为, 作用于*this对象
    }
};

乍一看,并没有什么问题。然而,仔细分析derived class的onResize中转型行为,存在很大问题。"static_cast(this).onResize()"首先将 "this 对象的base class"拷贝构造一个临时副本,然后调用临时副本的onResize函数。调用的onResize函数既不是当前对象上的函数,也不是当前对象基类那部分的函数,而是基类对象的副本的函数。也就是说,如果onResize如果修改了基类数据(毕竟没有const限定),而下面的SpecialWindow也修改了对象,就造成了基类和派生类数据的不一致。
解决办法:去掉转型动作,代之以你真正想说的话。
不要哄骗编译器将*this视为一个base class对象,只是想调用base class版本的onResize函数,令其作用于当前对象身上。可以这么写:

class SpecialWindow: public Window {
public:
    virtual void onResize() {
        Window::onResize(); // 调用Window::onResize作用于*this身上
        ...
    }
};

2)去掉dynamic_cast案例
dynamic_cast的需要实现版本执行速度相当慢,尽量减少使用次数。
为什么需要dynamic_cast?
通常是因为你想在一个你认定为derived class对象身上执行derived class操作函数,但你手上只有一个“指向base”的pointer或reference,你只能靠它们来处理对象。有2个一般性做法可以避免该问题:
(1)使用容器,并在其中存储直接指向derived class对象的指针(通常是智能指针,条款13),以便消除“通过base接口处理对象”的需要。
假设之前的Windows/SpecialWindow继承体系的例子中,只有SpecialWindow才支持闪烁效果。

// 使用dynamic_cast效率低下做法
class Window { ... };
class SpecialWindow: public Window {
public:
    void blink();
};

typedef vector<shared_ptr<Window>> VPW; // vector存放Window对象的智能指针
VPW winPtrs;
... // 往vector装数据等操作
for (VPW::iterator iter = winPtrs.begin(); iter != winPtr.end(); ++iter) {
    if (SpecialWindow *psw = dynamic_cast<SpecialWindow*>(iter->get()) // 使用了dynamic_cast
        psw->blink();
}

==> 应该改成
// 去掉了dynamic_cast转型, 更高效
typedef vector<shared_ptr<SpecialWindow>> VPW; // vector存放SpecialWindow对象的智能指针
VPSW winPtrs;
... // 往vector装数据等操作
for(VPSW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) // 不使用了dynamic_cast
    (*iter)->blink();

这种改动方式使用简单,而且安全,但有一个缺点:无法在同一容器内 存储指针以指向所有可能的各种Window派生类。如果要处理多种窗口类型(Window派生类),可能需要多个容器,并且它们都必须具备类型安全性(type-safe)。

(2)在base class内提供virtual函数,做想对各个Window派生类做的事情。因为指针指向的对象会在运行时调用virtual函数,以实现各自想做的事情。

// 为base class添加virtual函数
class Window {
public:
    virtual void blink() { } // 缺省代码“什么都没做”,可能是个糟糕的主意, 见条款34. 这里是为了演示
    ...
};

class SpecialWindow: public Window {
public:
    virtual void blink() { ... } // 在该class内, blink做符合当前class应该做的事情
};

typedef vector<shared_ptr<Window>> VPW; // vector元素还是使用指向base class Window对象的智能指针
VPW winPtrs;
...// 往vector装数据等操作
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
    (*iter)->blink(); // 这里没有dynamic_cast, 运行时会自动调用derived class对应virtual函数

小结

1)如果可以,尽量避免转型,特别是注重效率的代码中,应极力避免dynamic_casts。如果有个设计需要转型动作,尝试发展无需转型的替代设计。
2)如果转型是必要的,试着将它隐藏在某个函数背后。客户调用该函数即可,而不需要将转型放进他们自己代码中。(隔离转型动作)
3)宁可使用C++-style(新式)转型,不要使用旧式转型。

[======]

条款28:避免返回handles指向对象内部成分

Avoid returning "handles" to object internals.

为什么要避免返回handles指向对象内部成分?
先来看一个例子。假设程序涉及矩形,每个矩形(Rectangle)由左上角、右下角表示。为了让Rectangle对象尽可能小,可能会决定不把定义这些矩形的点存放在Rectangle对象内,而是放在一个辅助的struct内,让再Rectangle去指它:

class Point {
public:
    Point(int x, int y);
    ...
    void setX(int newValue);
    void setY(int newValue);
    ...
};

struct RectData { // 点数据用来表现一个矩形
    Point ulhc; // upper left-hand corner 左上角
    Point urhc; // lower right-hand corner 右下角
};
class Rectangle {
    ...
private:
    shared_ptr<RectData> pData; // 智能指针指向RectData对象
};

返回reference指向对象内部成分的缺陷

Rectangle的客户需要计算Rectangle的(四个顶点)范围,所以该class提供upperLeft函数、lowerRight函数。Point是用户自定义类型,

// 可以通过编译, 但设计是错误的
class Rectangle {
public:
    ...
    Point& upperLeft() const { return pData->ulhc; } // 左上顶点, by reference方式返回用户自定义类型(根据条款20)
    Point& lowerRight() const { return pData->urhc; } // 右下顶点, by reference方式返回用户自定义类型(根据条款20)
    ...
};

上面的设计可以通过编译,但却是错误的。因为它是自我矛盾的。一方面,upperLeft, lowerRight声明为const函数,表明提供Rectangle坐标点信息,而不是让客户修改Rectangle (条款3);另一方面,2个函数都返回reference指向了private内部数据,调用者可以通过这些reference修改内部private数据。

Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2); // rec是const 矩形, 左上角(0,0) 右下角(100,100)

rec.upperLeft().setX(50); // rec现在变成了左上角(50,0)右下角(100,100)
// 也就是说, rec的private数据发生了改变, const 矩形不再是不可变的(const).

2个教训:

1)成员变量的封装性最多只等于“返回其reference”的函数的访问级别,而不再是简单的声明变量时的访问级别。
比如,pData虽然是class Rectangle的private成员变量,但是class也提供了public member返回了其引用(upperLeft, lowerRight),这样,pData的封装性不再是private,而是public。

2)如果const成员函数传出一个reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据(reference所指数据)。
比如,const成员函数upperLeft和lowerRight各传出一个reference,而 reference所指数据是左上、右下顶点,与Rectangle对象自身有关联,而且存储在Rectangle对象外的 Point对象,那么upperLeft和lowerRight的调用者可以修改reference所指的数据(Point对象)了。

返回handles与返回references

reference、指针、迭代器统统都是所谓handles(句柄,用来取得某个对象),而返回一个“代表对象内部数据”的handle,这样就会面临“降低对象封装性”的风险,同时可能导致“const成员函数却造成对象状态被修改”。

通常,对象的“内部”是指它的成员变量,不过,不被公开使用的成员函数(private或protected),也是对象“内部”的一部分。因此,也要留心不要返回它们的handles。如果确实这么做了,它的实际访问级别就会提高,因为客户可以通过指针直接调用。

如何解决返回reference,被客户修改的问题?

可以对返回类型加上const,这样客户可以读取矩形的Points,但不能修改。也就意味着,客户不能改变对象状态。

class Rectangle {
public:
    ...
    const Point& upperLeft() const { return pData->ulhc; } // 返回const对象, 客户无法修改
    const Point& lowerRIght() const { return pData->urhc; } // 返回const对象, 客户无法修改
    ...
};

上述方案也不是完美的,存在空悬指针的问题。因为pData所指的对象可能会不存在,如果还使用该指针,就会产生“野指针”问题(内存已释放)。
例如,boundingBox函数返回GUI对象的外框(bouding box),

// 外框采用矩形形式
// GUI对象
class GUIObject { ... }; 
// 以by value方式返回一个矩形
const Rectangle boundingBox(const GUIObject& obj); // 条款3谈过为什么返回类型是const

// 客户可能这样使用boundingBox函数
GUIObject* pgo;
... // 设置pgo指向的GUI对象
// 取得一个指针指向外框的左上角顶点
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft()); // 错误: boundingBox(*pgo)返回的是一个临时对象, 语句结束后释放对象, pUpperLeft成为空悬指针

boundingBox(*pgo)返回的将是一个Rectangle临时对象,而在这行语句结束后,就会释放该临时对象。而pUpperLeft却指向了其内部pData所指向的ulhc对象(Point),该对象是会随临时对象的析构而释放的,因此pUpperLeft将成为空悬指针。

虽然member函数封handle可能产生风险,但有时必须这么做,比如operator[],返回的就是对象的reference,指向内部数据。

小结

1)避免返回handle(包括reference、指针、迭代器)指向对象内部。遵守该条款可以增加封装性,帮助const member函数的行为像const,并将“空悬指针”的可能性降到最低。

[======]

条款30:透彻了解inlining的里里外外

Understand the ins and outs of inlining.

inline函数

理念:将函数的每个调用,都以函数本体替换之。

inline只是对编译器的一个申请,不是强制命令。申请分为两种方式:隐喻提出,明确提出。

  • 隐喻提出:将函数定义于class声明中。
  • 明确提出:在函数定义式前,加上关键字inline。

大部分编译器拒绝将太过复杂(如带有循环或递归)的函数inlining(内联),而所有堆virtual函数的调用也都会使inlining落空,因为virtual意味着“等待,直到运行期才确定调用哪个函数”,而inline意味着“执行前,先将调用动作替换为被调用函数的本体”。
编译器通常不对“通过函数指针进行的调用 ”实施inlining。

程序库设计者必须评估“将函数声明为inline”的冲击:inline函数无法随着程序库升级而升级。也就是说,如果f是程序库内的inline函数,客户将函数f编进程序中,一旦程序库设计者决定改变f,所有用到f的客户端程序都必须重新编译。而如果f是non-inline函数,只需要重新链接就好;如果是动态链接,升级后的函数可以直接被使用。

另外一个现实问题,大多数调速器对inline函数束手无策,因为无法为一个并不存在的函数设立断点。

建议:一开始不要将任何函数声明为inline,或者实行有限范围内的函数称为inline。待到有优化需求时,再改成inline。

小结

1)将大多数inlining限制在小型、被频繁调用的函数身上。可使日后的调试过程和二进制升级(binary upgradability)更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升计划最大化;
2)不要只因为function templates出现在头文件,就将它们声明为inline;

[======]

条款31:将文件间的编译依存关系降到最低

Minimize compilation dependencies between files.

问题引出

考虑C++ class:

// Person定义
/* 头文件 */
#include <string>
#include "date.h"
#include "address.h"

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::string theName;
    Date theBirthDate;
    Address theAddress;
};

Person定义文件include了其他文件之间形成了一种编译依存关系(compliation dependency)。如果这些头文件中有一个被改变,或者这些头文件所依赖的其他头文件有改变,那么每个include Person class的文件就得重新编译,任何使用Person class的文件也必须重新编译。这样一连串的编译依存关系会对许多项目造成难以形容的灾难。

那么,我们如何解决这个问题呢?

方法一:Handle class

可以把Person分割为2个class:一个只提供接口(Person),另一个负责实现该接口(PersonImpl)。
持有实现接口(PersonImpl对象)指针的类Person,称为Handle class。

// 接口那部分
#include <string>
#include <memory>

/* 前置声明, 前提是不需要知道其定义 */
// 声明式 (Person定义需要用到)
class PersonImpl; // Person实现类前置声明
class Date; // Person接口用到的class的前置声明
class Address;

// Person定义(式)
class Person { // Handle class
public:
    Person(const std::string& name, const Date& birthday, const Address& addr);
    std::string name() const; // string并不是class, 而是basic_string<char>的typedef
    std::string birthDate() const;
    std::string address() const;
    // ...
private:
    // pimpl idiom (pointer to implementation)
    std::tr1::shared_ptr<PersonImpl> pImpl; // 智能指针, 指向实现类(PersonImpl)(shared_ptr见条款13)
};

分离的关键在于以“声明的依存性”替换“定义的依存性”,也就是分离 声明所需要的东西 跟 定义所需要的东西。这也是编译依存性最小化本质。现实中,让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(class XXX;)而非定义式(class XXX{ };)相依。其他每件事源于这个简单的设计策略:

  • 如果使用object references或object pointers可以完成任务,就不要使用objects
    因为如果定义某个类型的object,就要用到该类型的定义式。而reference和pointer只需要该类型的声明式即可。

  • 如果能够,尽量以class声明式替换class定义式
    也就是说,如果只是声明,可以不需要class定义式,用class声明式即可。而定义里面,能用class声明式则尽量用。

  • 为声明式和定义式提供不同的头文件
    如,声明式放到Person.h,实现式放到PersonImpl.h。

方法二:Interface class

令Person称为一种特殊的abstract base class(抽象基类),称为Interface class。类似于Java里面的interface,专门提供derived class的接口,因此不带成员变量,也没有构造函数,只有一个virtual析构函数 + 一组pure virtual函数(纯虚函数),用来描述整个接口。不过,C++并不禁止interface内实现成员变量或成员函数。

// interface class 
// 没有构造函数
class Person {
public:
    virtual ~Person(); // virtual析构函数
    virtual std::string name() const = 0; // pure virtual函数
    virtual std::string birthDate() const = 0;
    virtual std::string address() const = 0;
    // ...
    // factory函数, 创建新对象. 其实现不一定得在具象类中, 也可以在客户端
    static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
};

思考:为什么要有factory函数?
答:因为Interface class的客户必须有办法为这种class创建新对象,他们通常调用一个特殊函数,此函数扮演那个derived class的构造函数的角色。客户持有的,通常是Interface class Person类指针,而非derived class,否则程序的设计不具有复用性。

注意:factory函数不一定非得是在具象类RealPerson中实现,可以在专门的工厂中,或者客户端。关键在于,提供方法构建具象类,而绑定的指针却是接口类。

如果有了factory函数(create),客户可以这样使用:

string name;
Date dateOfBirth;
Address address;
...
// 创建一个对象, 支持Person接口
shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
cout << pp->name()
     << " was born on "
     << " and now lives at "
     << pp->address();
...

客户真正使用的,是实现Interface class接口的那个具象类(concrete class),因此该类必须被定义出来,而且构造函数必须被调用。
注意:interface class并没有构造函数。

具象类实现:

// 实现interface class的具象类
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;
};

// 一个具体的factory函数实现
shared_ptr<Person> Person::create(const string&name, const Date& birthday, const Address& addr)
{
    return shared_ptr<Person>(new RealPerson(name, birthday, addr));
}

Handle class与interface class

在handle class的方案中,handle class和实现类是依赖关系(use-a):handle class依赖于实现类,持有指向实现类指针;
在interface class的方案中,interface class和实现类是实现关系:实现类实现了interface class的接口。

什么时候考虑使用handle class和interface class?
当想要实现代码变化时,对客户带来最小冲击,就应当考虑使用handle class和interface class解耦声明与实现,以使编译依存性最小。

小结

1)支持“编译依存性最小化”的一般构想:相依于声明,不要相依于定义式。基于此构想的2个手段是Handle class和interface class。
2)程序库头文件应该以“完全且仅有声明式”(full and declaration-only forms)的形式存在。这种做法不论设计template都适用。

[======]

posted @ 2021-11-20 20:12  明明1109  阅读(92)  评论(0编辑  收藏  举报