v4if / blog

:octocat: 用issues写博客,记录点滴
35 stars 7 forks source link

Effective C++ 笔记 #11

Open v4if opened 7 years ago

v4if commented 7 years ago

导读

被声明为explicit的构造函数,禁止编译器执行非预期的类型转换,除非有一个好的理由允许构造函数被用于隐式类型转换,否则一般声明为explicit

copy构造函数用来以同类型对象初始化自我对象,copy assignment操作符用来从另一个对象中拷贝其值到自我对象

Pass-by-value意味着调用copy构造函数,Pass-by-reference-to-const往往是比较好的选择

命名习惯

lhs和rhs,分别代表left-hand side左手端和right-hand side右手端

通常将指向一个T型对象的指针命名为pt,意思是pointer to T

让自己习惯C++

尽量以const、enum、inline替换#define

const double PI = 3.14;

在class内旧式编译器不支持static const int Magic = 0x5;static成员在其声明式上获得初值 enum hack补偿做法:一个属于枚举类型的数值可权充int被使用enum {Magic = 0x5};

temlate inline 函数可以代替宏

#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

// 可预料行为和类型安全检查
template<typename T>
inline void CallWithMax(const T& a, const T& b) {
  f(a > b ? a : b);
}

有了const、enum和inline,对预处理器(特别是#define)的需求降低了,但并非完全消除。#include仍然是必需品,而#ifdef/#ifndef仍然是控制编译的重要角色

  1. 对于单纯常量,最好以const对象或enum替换#define
  2. 对于形似函数的宏(macros),最好改用inline函数替换#define

尽可能使用const

不该被改动

const修饰常量

const出现在左边,表示被指物是常量;如果出现在右边,表示指针自身是常量

const面对函数声明时的应用

令函数返回一个常量值const T opertor*(const T& lhs, const T& rhs);

const成员函数 两个成员函数如果只是常量性不同,可以被重载

void print(const TextBlock& ctb) {
  std::cout << ctb[0]; // 调用const char& operator[](std::size_t position) const
}

mutable可以释放掉non-static成员变量的const成员函数约束

在const和non-const成员函数中避免代码重复

// 令non-const operator[]调用其const —— 需要转型操作
// 反向做法,令const版本调用non-const版本是不应该做的事
// 因为const成员函数承诺绝不改变其内部对象,non-const却没有
char& operator[](std::size_t position) {
  return const_cast<char&>(
    static_cast<const T&>(*this)[position]
  );
}

确定对象被使用前已先被初始化

读取未初始化的值会导致不明确的行为,更可能的情况是读入一些半随机bits

C++规定,对象的成员变量的初始化动作发生在进入构造函数之前,因此在构造函数内的操作都是赋值,而非初始化,初始化的发生时间更早 构造函数最好使用成员初始化列表,而不要在构造函数本体内使用赋值操作

对于成员初始化列表,C++有着十分固定的成员初始化次序,base classes更早于derived classes被初始化,而class的成员变量总是以其声明次序被初始化

构造/析构/赋值运算

了解C++默默编写的函数

copy构造函数、copy assignment操作符、析构函数、default构造函数,唯有当这些函数被需要(被调用),它们才会被编译器创建出来 所有这些函数都是public且inline的

如果在一个内含reference成员的class内支持赋值操作(assignment),必须自己定义copy assignment操作符,C++并不允许让reference改指向不同的对象

若不想使用编译器自动生成的函数,就该明确拒绝

可以将copy构造函数或copy assignment操作符声明为privete 但这个做法并不绝对安全,因为member函数和friend函数还是可以调用private函数 因此可以只声明,而且故意不去实现它们,调用时会得到一个连接错误(linkage error)

T(const T&);
T& operator=(const T&);

为多态基类声明virtual析构函数

给base classes一个virtual析构函数,这个规则只适用于带多态性质的base classes身上,这种base classes的设计目的就是为了使用指向base的指针,用来通过base class接口处理derived class对象 某些classes的设计目的是作为base classes使用,但不是为了多态用途,因此它们不需要virtual析构函数

基类不添加虚析构函数会造成内存泄漏,局部销毁 给析构函数加上virtual之后,就等于告诉编译器应该调用的是哪个析构函数的信息了,这样一来,当你的派生对象经由基类指针删除时,就会非常顺利的调用到派生类的析构函数,而不是调用到基类析构函数。 虚函数的副作用是有的,类中只要有大于等于一个虚函数时就会生成虚表 vptr,这个虚表会占用内存空间,所以,我认为当你完全了解了这个机制后,可以自己度量这个性能得失,并且做好注释工作。不过,一点点的性能损失来换取绝对的安全,这是值得的。

许多人的心得是:只有当class内含至少一个virtual函数,才为它声明virtual析构函数

不要试图继承一个不带non-virtual析构函数的class

以下代码会造成内存泄露,只是局部销毁了Base的成分

// g++ -O -g -fsanitize=address virtual_base.cpp
#include <vector>
class Base
{
public:
  ~Base(){};
};

class Derived : public Base
{
private:
  std::vector<int> m_data;
};

int main()
{
  Base *obj = new Derived();
  delete obj;

  return 0;
}

抽象class

class AWOV {
  public:
    virtual ~AWOV() = 0;
};

析构函数的运作方式是,最深层派生的那个class其析构函数最先被调用,然后是其每一个base class的析构函数被调用

别让异常逃离析构函数

抢先制不明确行为于死地

DBConn::~DBConn() {
  try {db.close();}
  catch (...) {
    // 记录下对close的调用失败
    std::abort();
  }
}

或者吞掉异常不处理

绝不在构造和析构过程中调用virtual函数

令operator=返回一个reference to *this

在operator=中处理自我赋值

潜在的自我赋值a[i] = a[j] *px = *py

// 不能处理new异常
Widget& Widget::operator=(const Widget& rhs) {
  if (this == &rhs) return *this;

  delete pb;
  pb = new Bitmap(*rhs.pb);
  return *this;
}

// 如果new Bitmap抛出异常,pb会保持原状
// 对原bitmap做了一份复件,删除原bitmap,指向新制造的bitmap
Widget& Widget::operator=(const Widget& rhs) {
  Bitmap* pOrig = pb;
  pb = new Bitmap(*rhs.pb);
  delete pOrig;
  return *this;
}

复制对象时勿忘其每一个成分

如果为class添加一个成员变量,必须同时修改copying函数 为派生类撰写copying函数,必须很小心的也复制其base class成分 Base::opreator=(rhs);

资源管理

常见的资源包括动态内存分配、文件描述器、互斥锁、数据库连接、网络socket

以对象管理资源

把资源放进对象内,依赖C++的析构函数自动调用机制确保资源被释放

  1. 获得资源后立刻放进管理对象内
    
    // not good
    // 工厂函数,返回动态分配对象
    Investment* pInv = createInvestment();
    ...
    delete pInv;

// 智能指针 std::auto_ptr pInv(createInvestment());

注意不要让多个auto_ptr同时指向同一对象
为了预防这个问题,auto_ptr有一个不寻常的性质:若通过copy构造函数或copy assignment操作符复制它们,它们会变成null,而复制所得的指针将取得资源的唯一拥有权!
auto_ptr的底层条件是:受auto_ptr管理的资源必须绝对没有一个以上的auto_ptr同时指向它

shared_ptr会持续追踪共有多少个对象指向某笔资源,并在无人指向它时自动删除该资源。但无法打破环状引用,比如两个其实已经没有被使用的对象彼此互指,因而好像还处在被使用的状态 

auto_ptr和shared_ptr两者都在其析构函数内做delete,而不是delete[]动作,意味着不要在动态分配的array身上使用它们
vector和string几乎总是可以取代动态分配而得的数组
2. 管理对象运用析构函数确保资源被释放,当对象离开作用域

### 在资源管理类中小心copy行为
资源取得时机便是初始化时机

RAII —— 资源获取即初始化,利用对象的声明周期进行底层的资源管理

可以采取的策略:
1. 禁止复制
2. 对底层资源祭出`引用计数法`
```c++
class Lock{
  public:
  // 以某个mutex初始化shared_ptr,并以unlock函数为删除器
    explicit Lock(Mutex* pm) : mutexPtr(pm, unlock) {
      lock(mutexPtr.get());
    }
  private:
    shared_ptr<Mutex> mutexPtr;
};
  1. 复制底部资源,注意深度拷贝
  2. 转移底部资源的拥有权

在资源管理类中提供对原始资源的访问

shared_ptr和auto_ptr都提供一个get成员函数,用来执行显示转换,也就是返回智能指针内部的原始指针(的复件) 就像(几乎)所有的智能指针一样,shared_ptr和auto_ptr也重载了指针取值操作符(operator->和operator* )它们允许 隐式转换至底部原始指针

成对使用new和delete时要采取相同的形式

delete[] ptr;

当使用new时,有两件事情发生,一是内存被分配出来,二是针对此内存会有构造函数被调用 使用delete时,也有两件事情发生,针对此内存会有析构函数被调用,然后内存才被释放

delete最大的问题在于:即将被删除的内存之内究竟存有多少对象 单一对象的内存布局一般而言不同于数组的内存布局。更明确的说,数组的内存布局通常还包括数组大小的记录,以便delete知道需要调用多少次析构函数

以独立语句将new的对象置入智能指针

// 在单独语句内以智能指针存储new的对象
// 如果不这样,一旦异常抛出,有可能导致难以察觉的资源泄露
shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());

设计与声明

让接口容易被正确使用,不易被误用

对string使用length,对list使用size(告诉调用者目前容器内有多少对象)

Boost的shared_ptr是原始指针的两倍大,以动态分配内存作为簿记用途和删除之专属数据,以virtual形式调用删除器,并在多线程程序修改引用次数时蒙受线程同步化的额外开销

设计class犹如设计type

  1. 新type的对象应该如何被创建和销毁:构造和析构
  2. 对象的初始化和对象的赋值该有什么样的差别:构造函数和赋值操作符
  3. 新type的对象如果被passed by value(以值传递),意味着什么:copy构造函数
  4. 什么是新type的合法值:构造、赋值操作符、setter函数必须进行有效的错误检查
  5. 新的type需要配合某个继承图系吗:尤其是析构函数,是否为virtual
  6. 新的type需要什么样的转换:隐式和显示转换
  7. 什么样的操作符和函数对此新type是合理的:class声明哪些函数,某些该是member函数,某些则否
  8. 什么样的标准函数应该驳回:那些正是应该被声明为private的
  9. 谁该取用新type的成员:考虑friends

宁以pass-by-reference-to-const替换pass-by-value

以pass-by-value的形式传递需要带来临时副本的拷贝构造和析构的额外开销 避免derived class对象切割问题

但是对于内置类型来说(如int),pass by value往往比pass by reference(通常意味着传递的是指针)的效率更高些 可以合理假设pass-by-value并不昂贵的唯一对象就是内置类型和STL的迭代器和函数对象

必须返回对象时,别妄想返回其reference

const Rational& operator*(const Rational& lhs, const Rational& rhs) {
  // 更糟糕的写法,谁来为new出来的对象实施delete
  Rational* result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
  return *result;
}
// 欲令诸如operator*这样的函数返回reference,只是浪费时间而已

将成员变量声明为private

从封装的角度看,其实只有两种访问权限:private(提供封装)和其他(不提供封装)

宁以non-member、non-friend替换member函数

将所有便利函数放在多个头文件内但隶属于同一个命名空间,意味着对于客户来说可以轻松扩展这一组函数 因为class定义式对客户而言是不能扩展的

若所有参数皆需类型转换,请为此采用non-member函数

class Rational{...}; // 不包括operator*
// non-member
const Rational operator*(const Rational& lhs, const Rational& rhs) {
  return Rational(lhs.numerator() * rhs.numerator(), 
    lhs.denominator() * rhs.denominator());
}
Rational oneHalf(1, 2);
Rational result = 2 * oneHalf;

考虑写出一个不抛异常的swap函数

namespace WidgetStuff{
  template<typename T> 
  class Widget{...}; // 内含swap成员函数

  template<typename T>
  void swap(Widget<T>& a, Widget<T>& b) {
    a.swap(b);
  }
}

实现

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

尽量少做转型动作

如果可以,尽量避免转型,尤其是dynamic_cast 许多程序员相信,转型其实什么都没做,只是告诉编译器把某种类型视为另一种类型,这是错误的观念。 任何一个类型转换(包括通过转型操作而进行的显示转换,或通过编译器完成的隐式转换)往往真的另编译器编译出运行期间执行的代码

class SpecialWindow: public Window {
  public:
    virtual void onResize() {
      Window::onResize();
      // 而不是 static_cast<Window>(*this).onResize();
    }
};

异常安全而努力是值得的

void PrettyMenu::changeBackground(std::iostream& imgSrc) {
  // 互斥器作为并发控制
  lock(&mutex);
  delete bgImage;
  ++imageChanges;
  bgImage = new Image(imgSrc);
  unlock(&mutex);
}

从异常安全性的观点看,这个函数很糟。

  1. 不泄露任何资源:一旦new Image(imgSrc)导致异常,对unlock的调用就绝不会执行,于是互斥器就永远被锁住了
  2. 不允许数据被破坏:如果new Image(imgSrc)抛出异常,bgImage就是指向一个已被删除的对象,imageChanges也已被累加
void PrettyMenu::changeBackground(std::iostream& imgSrc) {
  // 使用对象管理资源
  Lock ml(&mutex);
  Image* pIOrig = bgImage;
  bgImage = new Image(imgSrc);
  delete pIOrig;
  ++imageChanges;
}

将bgImage也交给资源管理类

class PrettyMenu{
  shared_ptr<Image> bgImage;
};
void PrettyMenu::changeBackground(std::iostream& imgSrc) {
  // 使用对象管理资源
  Lock ml(&mutex);
  bgImage.reset(new Image(imgSrc));
  ++imageChanges;
}

还有一个一般化的策略:copy and swap。为你打算修改的对象做出一份副本,然后在副本身上做一切必要修改,若有任何修改动作抛出异常,原对象保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap)

透彻了解inline的里里外外

免除函数调用成本,inline只是对编译器的一个申请,不是强制命令

所有对virtual函数的调用也都会使inline落空,因为virtual意味着等待,直到运行期才确定调用哪个函数,而inline意味着执行前,先将调用动作替换为被调用函数的本体 如果编译器无法将你要求的函数inline化,会给你一个警告信息

继承与面向对象设计

确定你的public继承塑模出is-a关系

适用于base class身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也是一个base class对象

避免遮掩继承而来的名称

class Derived: public Base {
  public:
    using Base::mf1; // 让Base class内 名为mf1的所有东西可见
};

using 声明式被放在derived class的public区域:base class内的public名称在public derived class内也应该是public using声明式会令继承而来的某给定名称之所有同名函数在derived class中都可见

区分接口继承和实现继承

  1. 声明一个pure virtual函数目的是为了让derived class只继承函数接口 virtual void draw() const = 0;
  2. 声明简朴的(非纯)impure virtual函数的目的,是让derived class继承该函数的接口和缺省实现 virtual void error(const std::string& msg);
  3. 声明non-virtual函数的目的是为了令derived class继承函数的接口及一份强制性实现 int objectID() const;

绝不重新定义继承而来的non-virtual函数

non-virtual函数都是静态绑定的

绝不重新定义继承而来的缺省参数值

Shape* ps;
Shape* pc = new Circle;
Shape* pr = new Rectangle;

ps、pc、pr都被声明为pointer-to-shape类型,所有它们的静态类型都是Shape* 所谓的对象的动态类型是指目前所指对象的类型,也就是说,动态类型可以表现出一个对象将会有什么行为 virtual函数是动态绑定,而缺省参数值却是静态绑定

virtual函数的缺省参数值来自base class而非derived class virtual函数,唯一应该覆写的东西就是动态绑定

通过复合塑模出has-a

public继承意味着,如果D是一种B,对B为真的每一件事情对D也都应该为真

C++用来解析(resolving)重载函数调用的规则是:首先确定这个函数是否是最佳匹配,然后找出最佳匹配函数后才检验其可取用性

模板与泛型编程

了解隐式接口和编译器多态

template<typename T>
void doProcessing(T& w) {
  if (w.size() > 10 && w != someNastyWidget) {
    T temp(w);
    temp.normalize();
    temp.swap(w);
  }
}
  1. w必须支持哪一种接口,是由template中执行于w身上的操作决定的。本例中w的类型T必须支持size、normalize和swap成员函数、copy构造函数、不等比较,这一组表达式便是T必须支持的一组隐式接口
  2. 以不同的template参数具现化function template会导致调用不同的函数,这便是所谓的编译器多态 类似于哪一个重载函数该被调用(发生在编译期)哪一个virtual函数该被绑定(发生在运行期)

了解typename的双重意义

typename只被用来检验嵌套从属类型名称,其他名称不该有它存在

template<typename C>          // 允许使用typename或class
void f(const C& container,    // 不允许使用typename
  typename C::iterator iter); // 一定要使用typename

typename不可出现在base class list内的嵌套从属类型名称之前,也不可在成员初始化列表中作为base class修饰符

template<>
class MsgSender<CompanyZ> {
  public:
    void sendSecret(const MsgInfo& info){}
}

class定义式最前头的template<>语法象征这既不是template也不是标准class,而是个特化版的MsgSender template,在template实参是CompanyZ时被使用

需要类型转换时请为模板定义非成员函数

唯有non-member函数才有能力在所有实参身上实施隐式类型转换

定制new和delete

new出来的内存前后安排signiture签名 C++要求所有的operator new返回的指针都有适当的对齐(取决于数据类型)