Effective C++ 学习笔记 第四章:设计与声明

第一章见 Effective C++ 学习笔记 第一章:让自己习惯 C++
第二章见 Effective C++ 学习笔记 第二章:构造、析构、赋值运算
第三章见 Effective C++ 学习笔记 第三章:资源管理
第四章见 Effective C++ 学习笔记 第四章:设计与声明
第五章见 Effective C++ 学习笔记 第五章:实现
第六章见 Effective C++ 学习笔记 第六章:继承与面向对象设计
第七章见 Effective C++ 学习笔记 第七章:模板与泛型编程
第八章见 Effective C++ 学习笔记 第八章:定制 new 和 delete
第九章见 Effective C++ 学习笔记 第九章:杂项讨论

条款 18: 让接口容易被正确使用,不易被误用

Make interfaces easy to use correctly and hard to use incorrectly.

首先需要考虑用户可能会如何误用你的接口,尽可能避免被误用。

  • 限制参数的值范围,用特殊类型替代一些值。
  • 约束 const。
  • 让你的接口的用法与内置类型接口保持一致。
  • 避免返回资源的接口用户忘记释放资源或错误释放的办法:直接返回智能指针类型。

总结

  • 好的接口很容易被正确使用,不容易被误用,应该努力达到这个目标。
  • 适用办法:接口一致性,与内置类型行为兼容。
  • 适用办法:建立新类型,限制类型范围,消除用户管理资源的动作。
  • 巧用 shared_ptr。

条款 19:设计 class 犹如设计 type (重要)

Treat class design as type design.
C++ 开发过程中,很多时间是在设计类型系统,class 就像你的自定义类型。应该带着语言设计者的思路来对待你设计的 class。

需要考虑以下几个问题

  • 新的类型对象应该如何被创建和销毁。谨慎设计你的构造函数、析构函数和内存分配与释放函数。
  • 注意新类型的对象初始化和对象赋值的差别。你的构造函数和 copy 函数的行为是否有差异,不要混淆二者。
  • 新的类型对象如果以值传递,会遇到什么问题。
  • 什么是新类型对象的合法输入。你应该为其设计约束条件,并做异常检查工作。
  • 新的类型是否需要使用继承。你的 class 是否可以继承其他 class 来完成,或者你的 class 是否会将来被其他 class 所继承,考虑 virtual 的使用。
  • 新的类型需要哪些类型转换。是否允许隐式类型转换,是否提供类型转换函数,或使用 explicit 约束构造函数。
  • 什么样的操作符和函数对这个类型是合理的。你将为你的类型提供哪些操作。
  • 什么样的操作这个类型不应该提供。你应当将这些操作放到 private 中。
  • 什么样的操作这个类型应当提供。你应该决定各种接口的属性,灵活使用 public, protected,或使用 friend。
  • 决定未声明接口。对效率、异常安全、资源应用上提供保证,为特定的代码设计约束条件。
  • 新的类型有多么一般化。所谓一般化是指其通用性,如果你是在定义一个类型类别,应该使用 class template 代替 class。
  • 你是否需要设计这个类型。是否通过继承其他类型便可实现功能。

总结

  • class 的设计就是类型的设计,在定义新类型之前,你需要考虑以上内容。

条款 20:宁以 pass-by-reference-to-const 替换 pass-by-value (重要)

Prefer pass-by-reference-to-const to pass-by-value.
将函数参数传值方式改为传递 const 引用方式。

话题 1:避免传参拷贝动作

为了避免传参时昂贵的拷贝操作,用引用替代传值。同时,const 也是必要的,这样保证了传值方式下,形参修改不会影响实参值的语义不变(或者说,避免参数被函数内操作所修改)。如果需要修改实参值,去掉 const 修饰,引用也可以取代传递指针方式。
这是个习惯问题,养成习惯了会改不掉。

话题 2:发挥多态特性

传引用方式也可以避免对象切割问题。想象我们想实现一个多态特性:

class W { ... }              // 定义一个基类 W
class WW : public W { ... }  // 定义一个派生类 WW
void prints(W w) { ... }     // 欲想实现一个多态特性的函数

WW ww;
prints(ww);     // 我们本想传入一个 WW 的对象,但实际上,发生了对象切割

上例中,实际传入 prints 的只有 W 类所定义的那部分内容, WW 派生出来的特性都被切割掉了,本质上没有实现多态的特性。
应当修改为引用传参:

void prints(W& w) { ... }   // 正确的多态特性函数

WW ww;
prints(ww);      // 正确的将 ww 对象完整的传入

话题 3:传值有时更好

引用的底层实现是指针,但其不是单纯的指针,所以会比指针传递大一些(为了实现引用特性)。
对于小型对象,比如内置类型,传值会更好。编译器更偏爱对小型对象做特殊操作。除内置类型外,还有 STL 的迭代器 和 函数对象。

总结

  • 尽量使用 pass-by-reference-to-const 替换 pass-by-value。前者通常更高效,而且可以避免切割问题。
  • 上一条并不适用于小型对象,如内置类型、STL 迭代器 和 函数对象。对他们而言, pass-by-value 更合适。

条款 21:必须返回对象时,别妄想返回其 reference

Don’t try to return a reference when you must return an object.
虽然条款 20 中说,参数尽量使用 带 const 的 reference 替代 传值方式,但对于函数返回来说,传引用却是个糟糕的设计,即使返回引用可以节省 copy 的代价。

reference 代表一个别名,如果这个 reference 对应的那个对象,是在函数内定义的,就会有问题。比如:

  1. 对象放到函数栈上。
// 这个函数实现 R 对象的乘法操作,返回一个 R 对象的引用
const R& operator* (const R& lhs, const R& rhs) {
    R result(lhs, rhs);
    return result;
}

当函数返回时,放到栈上的临时变量 result 会被析构,所以,它所返回的值(接收引用的对象)将引用到非法位置。

  1. 对象放到堆上。
const R& operator* (const R& lhs, const R& rhs) {
    R* result = new R(lhs * rhs);
    return *result;
}

这样做,就把 delete 的任务交给调用者来完成了,很可能调用者会忘记。另外,如果是这种:

R w, x, y, z;
w = x * y * z;

两次乘法就调用了两次 operator*,从而就在堆上产生了两个资源,但调用者连调用 delete 释放资源的机会都没有。

  1. 把要返回的值做成静态变量。
const R& operator* (const R& lhs, const R& rhs) {
    static R result;
    result = lhs * rhs;
    return result;
}

这样更糟糕,static 变量的生存期是全局作用域,如果调用者调用了两次 operator*,那么返回的值将是两次的连乘:

R w, x, y, z, a, b;
w = x * y;    // 结果是 x * y
z = a * b;    // 结果是 x * y * a * b

另外,在多线程程序中,也会有类似的问题。
当然,如果你的函数只是用来被调用一次 (如按顺序初始化 static 对象的 hack 操作,见 条款 4),而不是像本例中,被频繁调用,这么写只是有危险,但不是不可以。

综上,没啥办法能通过返回引用的方式来返回由函数内定义的值。
所以,乖乖的返回值就好,即使 copy 的代价比较大,但总比各种 hack 之后导致更麻烦的问题要好。
但也要注意,对于自定义类型,写好你的 copy 函数。

当然,现代编译器会对返回过程做优化,会把两次 copy 改成 一次(函数返回是两次 copy)。

题外话,为什么函数返回是两次 copy?对于:

const R operator* (...) {
    ...
    return result;
}
R w, x, y;
w = x * y;

调用 operator* 时,计算结果先放到 result 中,这个可能是构造函数,也可能就是 copy 函数,我们假设是构造函数。然后,result 会把内容 copy 到一个匿名临时对象中(第一次 copy,对应到硬件上,可能是放到栈上,也可能放堆上),然后析构函数内的资源,函数返回,函数返回后,再将这个匿名临时对象放到 w 对象中(第二次 copy),再析构这个匿名临时对象。所以是两次 copy。

另外注:在 C++ 11 中引入了 右值引用 的特性,提供了 移动构造函数的语义,解决了 C++ copy 动作的高代价问题。但这个条款中,返回引用会导致的问题,依然值得我们深入思考。

总结

  • 绝对不要返回指向局部栈对象的指针或引用,也不要返回指向堆对象的引用,这都会导致问题,更不要使用 static 对象来干这种事情。

条款 22: 将成员变量声明为 private

Declare data members private.

好处:

  1. 不需要用户去记住哪些是属性(不需要加小括号),哪些是函数(需要加小括号);
  2. 对属性的灵活控制,比如只读、只写、可读写访问;
  3. 封装性,可以灵活修改属性内容;
  4. 与其他事务绑定,如访问属性时的额外动作,多线程控制等,本质上还是封装带来的灵活性;

能用 private 就不要用 protected。从封装的角度看,只有两种访问权限,private 其他

总结

  • 切记将成员变量声明为 private。这带给用户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供 class 作者以充分的实现弹性。
  • protected 并不比 public 更具封装性。

条款 23:宁以 non-member、non-friend 替换 member 函数

Prefer non-member non-friend functions to member functions.

有些时候,使用普通函数替代 成员函数 或 友元函数 更好。
比如,class 提供了多个不同的接口功能函数(成员函数),当我们希望将这些函数打包,完成一个统一的功能时,使用普通函数。

原因:

  1. 普通函数 相比于 成员函数,其封装性更好。这里,我们将要设计的这个统一功能看做用户功能,而将其他功能看做要封装的内容,对于成员函数,其本身对 class 内信息的访问权限要大于普通函数,权限越严格,封装性越好,所以普通函数封装性更好;
  2. 我们可以将这个普通函数与 class 放到一个 namespace,不同于 class,namespace 可以跨文件在多个地方声明同一个名称的 namespace,这就大大提高了扩展的灵活性;另外,这个普通函数也可作为其他函数的成员函数,灵活性更好;
  3. 我们可以将这个函数提供给用户,由用户来修改完善其内容;同时用户也可以自己定义类似的统一功能函数,提高了可扩充性。

C++ STL 库的实现便有此类思想,在不同的文件,如<vector> <string> 等文件中,都将操作统一放到 namespace std 中,由用户来控制编译相关性。

总结

  • 宁可用 non-member non-friend 函数替换 member 函数。这样做可以增加封装性、包裹弹性和机能扩充性。

条款 24:若所有参数皆需类型转换,请为此采用 non-member 函数 (重要)

Declare non-member functions when type conversions should apply to all parameters.

当一个自定义的类型,需要类型转换,不要用成员函数或 friend 函数来实现类型转换定义,而是用普通函数。

举例来说,如果你定义了复数类型,并且想定义一个支持类型转换的乘法操作,将复数与整数相乘,如果将乘法操作作为成员函数,操作为:

result = rat * 2;  // 编译正确
result = 2 * rat;  // 编译错误,但乘法应支持交换律,无法忍受这种缺憾

如果我们将 operator* 实现在复数类型内部,作为成员函数,这两个运算本质上应该为:

result = rat.operator* (2);    // 正常运算 <1>
result = 2.operator* (rat);    // 出错,整数常量不能调用其 operator*  <2>
// 当然,编译器也会寻找下边这种函数:
result = operator* (2, rat);   // 但实际上这种函数我们也没定义

这样的话,我们实际上不能完整支持这种类型的乘法运算。
<1> 中,实际上有个隐式类型转换,将 2 转换成复数类型后,完成复数与复数的乘法(当然前提是不能对构造函数做 explicit 修饰)。而在 <2> 中,放在前边的那个整形常量,无法做类型转换。

解决方法就是,将 operator* 实现为一个普通函数。如:

class Rat { ... };  // 复数类型定义
const Rat operator* (const Rat& lhs, const Rat* rhs) {
    // 将实部和虚部分别相乘后,构造新对象返回
    return Rat(lhs.num() * rhs.num(), lhs.den() * rhs.den());
}

另外,不应该将其定义为 friend 函数,作者的观点应该是 friend 会破坏类的封装性,能不用 friend 就不用,成员函数的反面是非成员函数,而不是 friend 函数。
我认为,可以用 namespace 来将上例中的 class 和 普通函数 operaotr* 放到一个命名空间中,达到一定的包装效果。

总结

  • 如果你需要为某个函数的所有参数(包括被 this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个 non-member。

条款 25:考虑写出一个不抛出异常的 swap 函数

Consider support for a non-throwing swap.

这个条款可能有点难以理解。
另外,标题内容不完全表示本条款内容,我认为本条款主要讲清了如何设计一个高效且实用的 swap 功能。

话题 1:设计一个高效的 swap

最简单的 swap 函数是进行值拷贝,std 中是如此实现如下:

namespace std {
template<typename T>
void swap(T& a, T& b) {
    T temp(a);
    a = b;
    b = temp;
}
}

然而,如果遇到 T 类型比较大时,copy 构造函数和 copy 赋值运算符会占用比较大的运行时间和内存。

一种能想到的改进措施是,将资源单独放在内存中,而使 T 类型中只包含指向资源的指针,然后在做交换时,只交换指针。

class WI { ... };  // 存放大量资源的类
class W {          // 我们要 swap 的类
public:
    W(const W& rhs);
    W& operator=(const W& rhs) {
        *pImpl = *(rhs.pImpl);
    }
private:
    WI* pImpl;
};

namespace std {
template<>   // 表示 std::swap<T>(){} 的全特例化版本,特例化的 T 是 W
void swap<W>(W& a, W& b) {
    swap(a.pImpl, b.pImpl);  // pImpl 是 W 类中指向资源的指针,但因为 pImpl 是私有成员,编译报错
}
}

不允许在 std 中的内容做改动,但允许对 std 中的模板类型提供全特例化版本。

因为无法编译,所以需要做一个针对 W 类的成员 swap 函数。如下设计:

class W {
public:
    void swap(W& other) {
        using std::swap;  // 这个声明是必要的
        swap(pImpl, other.pImpl);
    }
private:
    WI* pImpl;
};

namespace std {
template<>
void swap<W>(W& a, W& b) {
    a.swap(b);  // 调用类内提供的成员 swap 函数
}
}

以上设计是标准设计,在 STL 中的一些其他设计,也有此实现方式,即提供一个类内的 swap,再提供一个 std 内公有的全特例化版本 swap。

using std::swap; 这里的作用是,能够让成员 swap 函数检查到 std 中的 swap。

话题 2:再将类改成模板类试试

如果改为:

template<typename T>
class WI { ... };
template<typename T>
class W { ... };

namespace std {
template<typename T>
void swap<W<T>>(W<T>& a, W<T>& b) {  // 会编译报错
    a.swap(b);
}
}

原书中把这种写法叫偏特例化,C++ 只允许对类的偏特例化,不允许对函数的偏特例化,所以这里无法编译成功。一种改进是直接重载 swap 函数:

namespace std {  // 放在 std 中不合理
template<typename T>
void swap(W<T>& a, W<T>& b) {
    a.swap(b);
}
}

这样虽然可以,它是 template<typename T> std::swap(T& a, T& b); 的重载版本,重载参数类型,由 T &, T & 转为 W<T> & W<T> &
不过,这里依然不合理,因为 std 内不允许做这些修改,可能会导致未知的问题。
所以,我们应该把这个重载的 swap 从 std 命名空间中拿出来,可以放到另一个我们自定义的命名空间中。

话题 3:用户的使用

对用户来说,为了能找到最佳的那个 swap 函数,需要编写如下类似代码:

template<typename T>
void doSomething(T& a, T& b) {
    using std::swap;    // 让编译器可以找到 std 那个 swap
    swap(a, b);         // 由编译器自由选择最佳 swap,所以不要写作 std::swap(a, b);

编译器会根据 参数依赖查找,优先查找和参数相匹配的 swap 函数,如果没有找到,它会使用 std::swap。

话题 4:简单总结

如果默认的 std::swap 在对待大资源对象时,效率不足,可按如下 3 点处理:

  1. 提供一个 public swap 成员函数,让它能高效的置换类型对象,比如交换资源指针。
  2. 如果你的待交换对象是类(而不是模板类),针对你的对象提供 std 中的一个全特例化版本的 swap。
  3. 但为了能实现模板类对象的交换,则需要提供一个公共的 swap 函数,它的设计类似于 std::swap 的一种重载,并让该函数来调用 public swap 函数。为了代码整洁,这个普通 swap 函数因和你的类代码放到一个 namespace 中。

最后,回到标题,类成员变量的 swap 实现中一定不能抛出异常。因为公认的 swap 函数应该为 class 提供尽可能好的异常安全性而设计。这种要求不施加于普通的 swap 函数。

总结

  • 当 std::swap 对你的类型转换效率不高时,提供一个 swap 成员函数,并确保这个函数不抛出异常。
  • 如果你提供一个成员函数 swap,也应该提供一个普通的 swap 函数来调用前者,如果是普通类(非模板类),应该特例化 std::swap。
  • 调用 swap 时注意加上 using std::swap;,在调用 swap 时,不要做 std 约束。
  • 除了对 std 中的类型做全特例化以外,不要对 std 中增加其他内容。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值