Loading

【C++】《Effective C++》第四章

第四章 设计与声明

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

请记住

  • 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达到这些性质。
  • "促进正确使用"的办法包括接口的一致性,以及与内置类型的行为兼容
  • "阻止误用"的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
  • shared_ptr支持定制型删除器(custom deleter)。这可防范DLL问题,可被用来自动解除互斥锁(mutexes)等等。

条款19:设计class犹如type

C++中,当定义一个新的class,也就是定义了一个新type。
那么,如果设计高效的classes呢?以下是需要面对的问题:

  • type的对象应该如何被创建和销毁?
    • 这会影响到你的class的构造函数和析构函数以及内存分配函数的释放函数(operator newoperator new []operator deleteoperator [] delete)等的设计,不过前提是如果你自定义它们。
  • 对象的初始化和对象的赋值该有什么样的差别?
    • 这会决定你的构造函数和赋值操作符的行为以及其间的差异。很重要的是别混淆了"初始化"和"赋值",因为它们对应于不同的函数调用。
  • type的对象如果被passed-by-value(以值传递),意味这什么?
    • 这会让你思考copy构造函数用来定义一个typepass-by-value该如何实现。
  • 什么是新type的"合法值"?
    • class的成员变量而言,通常只有某些数值集是有效的。那些数值集决定了你的class必须维护的约束条件,也就决定了你的成员函数必须进行的错误检查工作,它也影响函数抛出的异常、以及函数异常明细。
  • 你的新type需要配合某个继承体系吗?
    • 如果你的类继承自某些既有的类,那么你就受到那些classes的设计的束缚,特别是受到"它们的函数是virtual还是non-virtual"的影响。如果你的类允许其他classes继承,那会影响你所声明的函数,尤其是析构函数是否为virtual
  • 你的新type需要什么样的转换?
    • 如果你允许类型T1被隐式转换为T2,那么就必须在class T1内写一个类型转换函数(operator T2)或者在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数。如果你允许explicit构造函数存在,就得写出专门负责执行转换的函数,且不能为类型转换操作符或on-explicit-one-argument构造函数
  • 什么样的操作符和函数对此新type而言是合理的?
    • 这会决定你将为你的class声明哪些函数,其中某些该是member函数,某些则不是。
  • 什么样的标准函数应该被驳回?
    • 这会决定你必须声明为private的函数。
  • 谁该取用新type的成员?
    • 这会决定你哪个成员为public,哪个为protected,哪个为private。也会决定你哪一个classesfunctions应该是friends,以及将它们嵌套于另一个之内是否合理。
  • 什么是新type的"未声明接口"?
    • 这会决定你对效率、异常安全性以及资源运用(例如多任务锁定和动态内存)提供何种保证?你在这些方面提供的保证将为你的class实现代码加上相应的约束条件。
  • 你的新type有多么一般化?
    • 或许你其实并非定义一个新type,而是定义一整个types家族。果真如此你就不该定义一个新class,而是应该定义一个新的class template
  • 你真的需要一个新type吗?
    • 如果只是定义新的派生类以便为既有的添加机能,那么说不定单纯定义一个或多个non-member函数或者templates更能达到目标。

请记住

  • class的设计就是type的设计。在定义一个新type之前,请确定你已经考虑过本条款覆盖的所有讨论主题。

条款20:宁以pass-by-reference-to-const替换pass-by-value

缺省情况下C++by value方式传递对象至函数,传递过程中副本由对象的copy构造函数产出,这可能使得pass-by-value成为昂贵的操作。

class Person {
public:
    Person();
    virtual ~Person();
private:
    std::string name;
    std::string address;
};

class Student: public Person {
public:
    Student();
    ~Student();
private:
    std::string schoolName;
    std::string schoolAddress;
};

// 使用
bool validateStudent(Student s);
Student plato;
bool platoIsOk = validateStudent(plato);

分析上述代码,以by value方式传递一个Student对象会导致调用一次Student copy构造函数、一次Person copy构造函数、四次string copy构造函数,并且当函数内的那个Student副本被销毁,每一个构造函数调用动作都需要一个对应的析构函数调用动作,这就是昂贵的操作了。并且当参数接受一个基类对象但是传入一个子类对象时,传入的子类对象会被切割,只保有基类对象的部分,从而无法表现出多态。

这两个问题,通常可以通过pass-by-reference-to-const解决。因为reference往往以指针实现出来,因此它通常意味真正传递的是指针。

// 使用
bool validateStudent(const Student &s);

请记住

  • 尽量以pss-by-reference-to-const替换pass-by-value。前者通常比较高效,并可避免切割问题(slicing problem)。
  • 以上规则并不适用与内置类型,以及STL的迭代器和函数对象。对它们而言,pass-by-value往往比较恰当。但是不是所有小型对象都是pass-by-value的合格候选者。

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

必须返回对象的最常见是运算符函数:

const Rational operator*(const Rational&lhs, const Rational&rhs);

在必须返回对象时,不要企图放回reference,不然就会造成下面的情况:

  • 使用stack构造一个局部对象,返回局部函数的reference
const Rational& operator*(const Rational&lhs, const Rational&rhs) {
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}

这样做的问题:使用reference的本意是避免构造新对象,但是一个新的对象result还是经由构造函数构造。更严重的是,这个局部对象在函数调用完成后就被销毁了,reference将指向一个被销毁的对象。

  • 使用heap构造一个局部对象,返回局部函数的reference
const Rational& operator*(const Rational&lhs, const Rational&rhs) {
    Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
    return *result;
}

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

这样做的问题:虽然不再引用一个被销毁的对象,但是多了动态内存分配的开销。而且,谁该为delete负责也成为问题。并且当多次动态分配内存时只返回最后一个的指针,这就造成了资源泄漏,比如上面的连乘操作。

  • 构造一个static局部对象,每次计算结果保存在这个对象中,返回其reference
const Rational& operator*(const Rational&lhs, const Rational&rhs) {
    static Rational result;
    result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}

Rational w, x, y, z;
if((w * x) == (y * z)) {
    // ...
}

这样做的问题:显而易见的问题是这个函数在多线程情况是不安全的,多个线程会修改相同的static对象。并且,在上面的判断语句中,不管传入的w, x, y, z是什么,由于operator*返回的reference都指向同一个static对象,因此上面的判断永远为真。

请记住

  • 绝不要返回pointer或者reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointerreference指向一个local static对象而有可能同时需要多个这样的对象。

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

为什么不能是public

  • 语法一致性:如果成员函数和成员变量一样,都是public,那么调用时会困惑于该不该使用括号,比如想获取大小时使用size,但是这到底是一个成员变量还是一个成员函数呢?
  • 更精确的访问控制:通过将成员变量声明为private,通过成员函数提供访问,可以实现更精确的访问控制。
  • 封装特性(主要):如果通过public暴露,在需要改成员变量的大量实现代码中,会直接使用当这个成员变量被修改或删除时,这样所有直接访问该成员的代码可能将会变得不可用。

为什么不能是protected

  • 理由同上面的三个。

请记住

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

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

假设有个浏览器类,包含一些功能用来清除下载元素高速缓存区、清除访问过的历史记录、以及移除系统中所有的cookies

class WebBrowser {
public:
    // ...
    void clearCache();
    void clearCookies();
    void clearHistory();
    // ...
};

此时,如果想整个执行所有这些动作,那么有两个选择,一种实现成member函数,一种实现成non-member函数。

class WebBrowser {
public:
    // ...
    void clearCache();
    void clearCookies();
    void clearHistory();

    // 实现成member函数,可以访问private成员
    void clearEverything() {
        clearCache();
        clearCookies();
        clearHistory();
    }
    // ...
};

// 实现成non-member函数,不可以访问private成员
void clearEverything(WebBrowser& wb) {
        wb.clearCache();
        wb.clearCookies();
        wb.clearHistory();
    }

关于这两种选择的抉择在于封装性

对于对象内的代码,越少代码可以看到数据(也就是访问它),越多的数据可被封装,也就越能自由地改变对象数据。作为一种粗糙的测量,越多的函数也可访问它,数据的封装性就越低。

请记住

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

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

为类支持隐式类型转换不是个好主意,但是在数值类型之间颇为合理。考虑有理数和内置类型之间的相乘运算。

具有如下有理数:

class Rational {
public:
    Rational(int n = 0, int d = 0); // 构造函数可以不为explicit,提供了int-to-Rational的隐式转换

    int numerator() const;  // 分子的访问函数
    int denominator() const; // 分母的访问函数
private:
    // ...
};

现在提供了隐式转换方式,那么operator*应该实现成member还是non-member呢?

class Rational {
public:
    // member
    const Rational operator*(const Rational&rhs) const;
}

// non-member
const Rational operator*(const Rational&lhs, const Rational&rhs);

区别在于混合运算上,如果是member,那么下面的混合运算只有一半行得通:

result = oneHalf * 2;   // 等价于oneHalf.operator*(2)  Success
result = 2 * result;   // 等价于2.operator*(oneHalf)  Error

因为内置类型没有相应的class,也就没有operator*成员函数,所以会错误。但是当实现为non-member时,具有两个参数,都能通过int-to-Rational,所以会正常。

请记住

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

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

swap原先只是STL的一部分,而后成为异常安全性编程的脊柱,以及用来处理自赋值的常见机制,可以参考条款12

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

只要类型T支持copying操作,上述实现就没问题,但是对于某些类型而言,其copying行为是不必要的,降低了程序运行的性能。

  • 最好的方案
class Widget{
public:
    void swap(Widget& other){
        using std::swap;    // 必须声明
        swap(pImpl, other.pImpl);
    }

private:
    Widget *pImpl;
};

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

这种实现不仅高效还能与STL容器兼容,因为STL容器也提供了public swap成员函数与std::swap特例化版本。

  • template下的swap实现
namespace WidgetStuff{
    template<typename T>
    class Widget { }
    template<typename T>
    void swap(Widget<T>& a,Widget<T> &b){
        a.swap(b);
    }
}

请记住

  • std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
  • 如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于classes(而非templates),也请特化std::swap
  • 调用swap时应该针对std::swap使用using声明式,然后调用swap并且不带任何"命名空间资格修饰"。
  • 为"用户定义类型"进行std templates全特化是好的,但不要尝试在std内加入某些对std而言全新的东西。
posted @ 2020-08-16 13:23  Parzulpan  阅读(154)  评论(0编辑  收藏  举报