Effective C++ - Implementations

前言:实现中需要注意的一些问题。


1 尽可能延后变量的定义

只要你定义了一个变量,而其类型带有一个构造函数或析构函数,那么当程序的控制流到达这个变量定义式时,你便要承受构造成本;当这个变量离开作用域时,便要承受析构成本。

例子:

方法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。

2 尽量少做转型动作

C++规则的设计目标之一是,保证”类型错误”绝不可能发生。理论上,如果你的程序很”干净地”通过编译,就表示它并不企图在任何对象身上执行任何不安全,无意义,愚蠢荒谬的操作。这是一个极具价值的保证,可别草率地放弃它

不幸的是,转型(cast)破坏了类型系统。那可能导致任何种类的麻烦,有些容易识别,有些非常隐晦。在C++中转型是一个你会想带着极大尊重去亲近的一个特性。(意思是,坑比较多)

转型语法

  1. 旧式转型(C风格)
(T) expression;   // 将expression转型为T
T(expression);    // 同上
  1. C++的风格
// 通常被用来将对象的常量性移除(cast away the constness)
const_cast<T>(expression);

// 主要用来执行"安全向下转型"(safe downcasting),也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式转型执行的动作,也是唯一可能耗费重大运行成本的转型动作
dynamic_cast<T>(expression);

// 低级转型。实际动作及结果,可能取决于编译器,也就表示它不可移植
reinterpret_cast<T>(expression);

// 用来强迫隐士转换(implicit conversions)。例如,将non-const对象转为cosnt对象,或将int转为double。但是,它无法将const转换为non-const,这个只有const_cast才能办到
static_cast<T>(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。你只能靠它们来处理对象。

两个方法可以避免这个问题:

  1. 使用容器并在其中存储直接指向derived class对象的指针(通常是智能指针),如此便消除了“通过base class”接口处理对象的需要。(但是,这种做法使你无法在同一个容器内存储指针,指向所有可能之各种派生类,如果真要处理多种派生类对象,那就需要多个容器)

  2. 在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> > 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(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。

3 避免返回handles指向对象内部成分

不论handle是个指针,或迭代器,或reference,也不论这个handle是否为const,也不论那个返回handle的成员函数是否为const。这里的唯一关键是,有个handle被传出去了,一旦如此你就暴露在handle比其所指对象更长寿的风险下。

例子:

#include <iostream>

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)的可能性降至最低

4 为”异常安全”而努力是值得的

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<Image> bgImage; // PMImpl = PrettyMenu Impl
    int imageChanges;
};

class PrettyMenu {
public:
    // ...
private:
    Mutex mutex;
    std::tr1::shared_ptr<PMImpl> pImpl;
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
    using std::swap;
    Lock ml(&mutex);

    std::tr1::shared_ptr<PMImpl> 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. 函数提供的”异常安全保证”,通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者

5 透彻了解inlining的里里外外

Inline函数,可以调用它们又不需蒙受函数调用所招致的额外开销。

没有白吃的午餐

inline函数,背后的整体观念是,将“对此函数的每一个调用”都以函数体替换之。但这样做可能增加你的目标代码大小。在一台内存有限的机器上,过度热衷inlining会造成程序体积太大,即使拥有虚内存,inline造成的代码膨胀亦会导致额外的换页行为,降低指令高速缓存装置的命中率(instruction cache hit rate),以及伴随而来的效率损失。

记住
inline只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出,也可以明确提出

  • 隐喻方式是将函数定义于class定义式内。

例如:

class Person {
public:
    int age() const { return theAge }; // 一个隐喻的inline申请,age被定义于class定义式内
private:
    int theAge;
};
  • 明确声明inline函数的做法则是在其定义式前加上关键字inline。

例如:标准的max template(来自<algorithm>

template<typename T>
inline const T& std::max(const T& a, const T& b)
{
    return a < b ? b : a;
}

总结:

  1. 一个表面上看似inline的函数是否真是inline?取决于你的建置环境,主要取决于编译器。
  2. 编译器通常不对”通过函数指针而进行的调用”实施inlining(编译器没有能力提出一个指针指向并不存在的函数)。
  3. 构造函数和析构函数,是否选择inline化,并非是个轻松的决定。因为空的构造函数里,编译器可能会做很多事情。
  4. 影响升级。inline函数无法随着程序库的升级而升级,也就是,如果f是程序库内的一个inline函数,客户将f函数本体编进其程序中,一旦程序库设计者决定改变f,所有用到f的客户端程序都必须重新编译。这往往是大家不愿意见到的。然而,如果f是non-inline函数,一旦它有任何修改,客户端只需要重新连接就好,远比重新编译的负担少的多。如果程序采取动态链接,升级版函数甚至可以不知不觉地被应用程序吸纳。
  5. 可能影响调试。大部分调试器面对inline函数都束手无策。毕竟你如何在一个并不存在的函数内设立断点呢?虽然某些建置环境勉力支持对inlined函数的调试,其他许多建置环境仅仅只能“在调试版程序中禁止发生inlining”。
  6. 80-20经验法则。平均而言,一个程序往往将80%的执行时间花费在20%的代码上头。这是一个重要的法则,因为它提醒你,作为一个软件开发者,你的目标是找出这可以有效增进程序整体效率的20%代码,然后将它inline或者竭尽所能地将它瘦身。但除非你选对目标,否则一切都是虚功。

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

6 将文件间的编译依存关系降至最低

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<PersonImpl> pImpl; // 指针,指向实现物,隐藏实现细节
};

Personclass只内含一个指针成员,指向其实现类PersonImpl。这种设计被称为:pimpl idion (pimpl是 Pointer to implementation的缩写)。这样的设计下,Person的客户端就完全与Date,Addresses以及Persons的实现细节分离了。这些class的任何实现修改都不需要Person客户端重新编译。同时,由于客户无法看到Person的实现细节,也就不会写出什么:取决于内部细节的代码。这真正是“接口与实现分离”

分离的关键在于:以“声明的依赖性”代替“定义的依赖性”。现实中,让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。

其他每一件事,都源于这个简单的设计策略:
1,如果使用object referencesobject pointers可以完成任务,就不要使用object。(你可以只靠一个类型声明式,就定义出指向该类型的referencespointer;但如果定义某类型的objects,就需要用到该类型的定义式)。
2,如果能够,尽量以class声明式替换class定义式。(注意,当你声明一个函数,而它用到某个class时,你并不需要该class的定义,即使函数以by value的方式传递该类型参数或返回值)。

例如:定义func函数,但不需要Person的定义。但是,在调用func函数时,就需要知道Person的定义。也就是,比如一个函数库有非常多的函数,但是我们可能只用到了其中很少的函数,对我们用到的函数,在客户端通过前置声明的方式(而不是包含所有定义的方式),可以减少对不必要类型定义的依赖。

#include <stdio.h>

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++标准程序库头文件<iosfwd>内含iostream各组件的声明式,其对应定义则分布在若干不同的头文件内,包括<sstream><streambuf><fstream><iostream>

Handle classes

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();   // 相同的名字
}

Interface classes

另一种办法是,令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和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都适用。

©️2020 CSDN 皮肤主题: 大白 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值