5. 实现

0 阅读7分钟

小结

  1. 本章讨论的是实现(即函数体、类的编写)的效率、封装性以及安全性入手
  2. 效率方面需要注意的是延后变量定义时间、尽可能少做转型、inline的使用,以及降低多文件之间的依存性,这样可以提高编译效率
  3. 封装性方面注意不要返回class的handles
  4. 安全性则是注意异常安全函数的定义:不会内存泄露以及不允许数据破坏

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

  1. 如果一次性定义完,可能会因为异常等改变代码逻辑,进而部分变量都没有被使用过
  2. 最好是延迟定义到能给出该变量初始值的时候
  3. 循环,定义外部:则会一次构造、析构,n次赋值;定义内部:则会n次构造、析构。定义外部会导致作用域扩大,优先推荐定义内部,但是如果明确知道赋值成本比构造析构成本低且效率高度敏感,则定义外部

27. 尽量少做转型动作

  1. 尽可能使用新式转型,唯一使用旧式转型T()是调用explicit构造函数将一个对象传递给一个函数:do(Widget(15));==do(static_cast<Widget>(15);==do({15}))
  2. 对于const、reinterpret转型不会有其他操作,但是对于static、dynamic、部分隐式转换自定义类型,在无优化情况下,都是会先构造临时变量,再有后续操作;

无标题.png

甚至对于指针部分,也可能会因为隐式转换而导致值不一样;

class Base{...};
class Derived:public Base{...};
Derived d;
Base* pb = &d;// 这里隐喻就是Derived* 转换成 Base*,两者地址值可能不同,与对象内存布局有关

对象内存布局和地址计算方式与编译器有关,所以不要试图去了解对象内存布局

  1. dynamic性能不高,因为至少需要比较名称字符串,有多少层继承就需要比较多少次,避免使用dynamic方式:1. 直接使用derived对象的指针;2. 借助虚函数,在base中申明所有接口,就不需要去向下转换了

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

  1. handles:referenc、指针和迭代器
  2. 返回了指向内部成员的handle,有降低对象封装性的风险,如果需要返回,也必须加上const
  3. 但是尽管加上const,依然是将handle暴露在比其所指对象更长寿的环境中,可能handle指向的对象已经被释放,但是外部代码依然保留着该handle
  4. operator[]就需要返回reference指向内部成员,但是这种情况很少

29. 为异常安全而努力是值得的

异常安全函数:1. 不泄露任何资源;2. 不允许数据破坏

  1. 不泄露资源:RAII
  2. 不允许数据破坏:
    • 基本承诺:异常抛出所有对象处于合理的状态,真实状态是未知的,比如换背景图报错,那背景图要么是之前的要么是默认的
    • 强烈保证:异常抛出程序状态不会被改变,即背景图还是之前的,即要么成功要么失败,并不是对所有函数都可实现或具备现实意义
    • 不抛掷保证:承诺不抛出异常,总能完成他们原先承诺的功能,比如作用于内置类型身上所有的操作

    noexcept,声明和定义都需要写出,承诺不抛出异常,如果直接、间接抛出(如调取的函数抛出异常)都会导致程序直接结束,单数标记这个的函数并不一定是异常安全函数,比如还存在内存泄露风险

  3. copy-and-swap能提供基本承诺,为原本要修改的对象创建副本,然后在那副本身上做一切必要修改
  4. pimpl(implementation):将所有隶属对象的数据从原对象放进另一个对象内,然后赋予原对象一个指针指向那个所谓的实现对象,互斥锁依然处于原对象,用于保护对pimpl的访问
  5. 做到上面两种方式,并不是强烈保证,见下面解释
  6. 因为cpp中并入了很多并非异常安全代码,所以cpp并不具备异常安全,如果函数中调用了传统代码且没有try-catch手段,则函数“无任何保证”

30. 绝大多数inlining的里里外外

inline只是对编译器的一个申请,而不是强制命令,可明确提出,也可隐喻提出(将函数定义于class内,甚至包括友元函数)

  1. inline函数一定是位于头文件的,因为在编译器阶段就需要进行替换,同理还有template
  2. inline与virtual其实是相反的,后者运行时才确定代码
  3. inline声明的函数可能还是会有函数本体,如果存在程序需要取某个inline函数地址,比如编译器会要求构造、析构函数的函数指正,即生成其outline副本
  4. 构造、析构其实很复杂,并不是我们所编写那么简单,比如有异常抛出,需要释放掉之前已构造好的一部分,这些是编译器加入的代码
  5. 实践:将大多数 inlining 限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级(binary upgradability)更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。

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

依存关系是指头文件之间的#include,标准库一般性能较高故不在这些条例之内,且标准库不允许前置声明

  1. 当一个类处于定义时,必须知道其定义进而确定大小
  2. 如果使用reference或pointers能完成任务,就不要使用objects,尽量以class声明式替换class定义式,如果不需要访问class成员,则都可以只提供声明
  3. 为声明式和定义式提供不同的头文件(仅针对大型项目)
  4. pimpl:原本对象中只有一个指针指向具体实现类,所以也只需要前置声明该实现类即可,该实现类一般定义在cpp之中,这样就能很好隐藏
  5. Handle class:内部还有实现类pipml,则外部类就变成了代理类
  6. Interface class:Base为抽象类,并声明所有接口,实现由Derived完成
  7. 上面两种class解除了接口和实现之间的耦合关系,从而降低了文件间的编译依存关系
class Person { // Handle Class
public:
    std::string name() const; 
    std::string birthDate() const;
private:
    struct Impl; // 实现类的前向声明(完全隐藏)
    std::unique_ptr<Impl> pImpl; // 指向实现的指针(句柄)
};

// 实现类(内部实现):包含数据和逻辑,不对外暴露
struct Person::Impl {
    std::string name; 
    Date birthday; // 实现细节(依赖 Date)
    std::string birthDate() const { return birthday.toString(); }
};

// Handle Class 的成员函数通过 pImpl 委托给 Impl
std::string Person::name() const { return pImpl->name; }
std::string Person::birthDate() const { return pImpl->birthDate(); }

如果Impl内部成员改变,如Date改为newDate,并不会影响依赖Person的文件,这就降低了依存关系

  1. 实践: 头文件应该以“完全且仅有”声明式的形式存在,模板类则提供两个h文件,一个声明一个具体实现,而普通类则是将实现写入cpp文件
  • 仅有声明:头文件中只包含 类/函数/模板的声明,不包含任何实现代码(如函数体、成员变量初始化等)。
  • 完全声明:所有需要对外暴露的接口(类、函数、模板)必须完整声明,确保用户能正确使用(如类的成员函数声明、模板的参数列表等)。