Effective C++ - Designs and Declarations

前言:软件设计,是令软件做出你希望它做的事情的步骤和做法。通常以颇为一般性的构想开始,最终演变成十足的细节,以允许特殊接口的开发。这些接口而后必须转换为C++声明式。而如何实现良好C++接口的设计和声明呢?通常的一个准则是:“让接口容易被正确使用,不容易被误用”。


    • Make interfaces easy to use correctly and hard to use incorrectly
    • Treat class design as type design
    • Prefer pass-bye-reference-to-const to pass-by-value
    • Do not try to return a reference when you must return an object
    • Declare data members private
    • Prefer non-member non-friend functions to member functions
    • Declare non-member functions when type conversions should apply to all parameters
    • Consider support for a non-throwing swap

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

理想上,如果客户企图使用某个接口而却没有获得他所预期的行为,这个代码不该通过编译;如果代码通过了编译,它的作为就该是客户所想要的。

一个例子:假设你为一个用来表现日期的class设计构造函数。

class Data {
public:
    Data(int month, int day, int year);
    // ...
};

咋见之下,这个接口通情达理(至少在美国如此),但它的客户很容易犯下至少两个错误。
1. 他们也许会以错误的次序传递参数。
2. 他们可能传递一个无效的月份或天数。

好的做法:
许多客户端错误可以因为导入新类型而获得预防。在防范”不值得拥有的代码”上,类型系统(type system)是你的主要同盟国。既然这样,我们可以导入简单的外覆类型(wrapper types)来区别天数,月份和年份,然后于Data构造函数中使用这些类型。

struct Day {
explicit Day(int d) : val(d) {}
int val;
};

struct Month {
explicit Month(int m) : val(m) {}
int val;
};

struct Year {
explicit Year(int y) : val(y) {}
int val;
};

class Date {
public:
    Date(const Month& m, const Day& d, const Year& y);
    // ...
};

Date d(30, 3, 1995);                   // 错误,类型不匹配
Date d(Day(30), Month(3), Year(1995)); // 错误,类型不匹配
Date d(Month(3), Day(30), Year(1995)); // OK 

可见,明智而审慎地导入新类型对预防”接口被误用”有神奇疗效。但是,当保证了正确的类型后,如何限制其合理的值呢。例如,一年只有12个月,所以Month应该反映这一事实。一个办法是利用enum表现月份,但enum不具备我们系统拥有的类型安全性,例如,enum可被拿来当一个int使用。比较安全的做法是,预先定义所有有效的Month

class Month {
public:
    static Month Jan() { return Month(1); }
    static Month Feb() { return Month(2); }
    // ...
    static Month Dec() { return Month(12); }

private:
    explicit Month(int m);   // 阻止生成新的月份
};

Date d(Month::Mar(), Day(30), Year(1995));

其他预防方法还包括:

  1. 限制类型内什么事可做,什么事不能做。常见的限制是加上const
  2. 除非有好理由,否则应该尽量令你的types的行为与内置types一致。
  3. 提供行为一致的接口。STL容器的接口十分一致(虽然不是完美地一致),这使它们非常容易被使用。例如,每个STL容器都有一个名为size的成员函数,它会告诉调用者目前容器内有多少个对象。有些开发人员会以为IDE能使这些不一致变得不重要,但他们错了。不一致性对开发人员造成的心理和精神上的摩擦与争执,没有任何一个IDE可以完全抹除。

注意:任何接口,如果要求客户必须记得做某些事情,就是有着“不正确使用”的倾向。因为客户可能会忘记做那件事。

例如:

// 返回一个指针指向一个动态分配对象,为避免资源泄漏,返回的指针最终必须被删除,但客户有可能忘记
Investment* createInvestment();

// 将返回值存储于一个智能指针,因而将delete责任推给智能指针
std::tr1::shared_ptr createInvestment();

返回tr1::shared_ptr让接口设计者得以阻止一大群客户犯下资源泄漏的错误。因为,tr1::shared_ptr允许当智能指针被建立起来时指定一个资源释放函数(所谓删除器,deleter)绑定于智能指针身上(但是,auto_ptr没有这个能力)。

比如,下面的方法:

std::tr1::shared_ptr createInvestment()
{
    std::tr1::shared_ptr retVal(static_cast(0), getRidofInvestment);
    retVal = ...; // 令retVal指向正确对象
    return retVal;
}

tr1::shared_ptr构造函数坚持其第一个参数必须是个指针,而0不是指针,是个int。是的,它可以被转换为指针,使用转型(cast)可以解决这个问题。

任何事情都有两面性,tr1::shared_ptr使用上有什么副作用吗?

最常见的tr1::shared_ptr实现品来自BoostBoostshared_ptr是原始指针(raw pointer)的两倍大,以动态内存作为辅助。在许多应用程序中,这些额外的执行成本并不显著,然而其“降低客户错误”的成效却是每个人都可以看到的。

请记住:
1. 好的接口,很容易被正确使用,不容易被误用。
2. “促进正确使用”的办法包括,接口的一致性,内置类型的行为兼容。
3. “阻止误用”的办法包括,建立新类型,限制类型上的操作,束缚对象值,消除客户的资源管理责任。
4. tr1::shared_ptr支持定制删除器(custom deleter)。这可防范cross-DLL problem,可被用来自动解除互斥锁等等。

2 Treat class design as type design

C++就像在其他OOP语言一样,当你定义一个新class,也就定义了一个新type。身为C++程序员,你的许多时间主要用来扩张你的类型系统。重载(overloading)函数和操作符、控制内存的分配和归还、定义对象的初始化和终结,等等,全部在你手上。

因此,你应该带着和”语言设计者当初设计语言内置类型时”一样的谨慎来研讨class的设计。

设计优秀的classes是一项艰巨的工作,因为设计好的types是一项艰巨的工作。好的types有自然的语法,直观的语义,以及一或多个高效实现品。

那么,如何设计高效地classes呢?几乎每一个class都要求你面对以下提问,你的回答往往导致你的设计规范:

  1. 新type的对象应该如何被创建和销毁?
    这会影响到你的class的构造函数和析构函数,以及内存分配函数,和释放函数的设计。
  2. 对象的初始化和对象的赋值该有什么样的差别?
    这个答案决定你的构造函数和赋值操作符的行为,以及期间的差异。很重要的是别混淆了“初始化”和”赋值”,因为它们对应于不同的函数调用。
  3. 新type的对象如果被passed by value(以值传递),意味着什么?
    记住,copy构造函数用来定义一个type的pass-by-value该如何实现。
  4. 什么是新type的“合法值”?
    对class的成员变量而言,通常只有某些数值集是有效的。那些数值集决定了你的class必须维护的约束条件(invariants),也就是决定了你的成员函数必须进行的错误检查工作。
  5. 你的新type需要配合某个继承图系(inheritance graph)吗?
    如果你继承自某系既有的classes,你就受到那些classes的设计的束缚,特别是受到“它们的函数是virtualnon-virtual”的影响。如果你允许其他classes继承你的class,那会影响你所声明的函数,尤其是析构函数,是否为virtual
  6. 你的新type需要什么样的转换?
    你的type生存于其他一海票types之间,因而彼此该有转换行为吗?如果你希望允许类型T1之物被隐式转换为类型T2之物,就必须在class T1内写一个类型转换函数,或在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数。
  7. 什么样的操作符和函数对此新type而言是合理的?
    这个问题的答案决定你将为你的class声明哪些函数。其中,某些该是memeber函数,某些则否。
  8. 什么样的标准函数应该驳回?
    那些正是你必须声明为private者。
  9. 谁该取用新type的成员?
    这个问题可以帮助你决定哪个成员为public,哪个为protected,哪个为private。它也帮助你决定哪一个classes,functions应该是friends,以及将它们嵌套于另一个之内是否合理。
  10. 什么是新type的”未声明接口”(undeclared interface)?
    它对效率,异常安全性,以及资源运用提供何种保证?你在这些方面提供的保证将为你的class实现代码加上相应的约束条件。
  11. 你的type有多么一般化?
    或许你其实并非定义一个新type,而是定义一整个types家族。果真如此,你应该定义一个新的class template
  12. 你真的需要一个新type吗?
    如果只是定义新的derived class以便为既有的class添加机能,那么说不定单纯定义一或多个non-member函数或templates,更能够达到目标。

请记住:
上述这些问题都不容易回答,所以定义出高效地classes是一种挑战。然而如果能够设计出至少像C++内置类型一样好的用户自定义classes,一切汗水便都值得。

3 Prefer pass-bye-reference-to-const to pass-by-value

缺省情况下,C++以by value方式(一个继承自C的方式)传递对象至函数。除非你另外指定,否则函数参数都是以实际实参的副本为初值,而调用端所获得的亦是函数返回值的一个副本。这些副本系由对象的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); // 函数以by value方式接受学生

Student plato;
bool platoIsOK = validateStudent(plato);

当上述函数被调用时,发生什么事?

无疑地Student的copy构造函数会被调用,以plato为蓝本将s初始化。同样,当validateStudent返回,s会被销毁。因此,对此函数而言,参数的传递成本是:一个Student copy构造函数调用,加上一次Student析构函数调用。

但那还不是故事的全部:

Student对象内有两个string对象,所以每次构造一个Student对象也就构造了两个string对象。此外,Student对象继承自Person对象,所以每次构造Student对象,也必须构造出一个Person对象。一个Person对象又有两个string对象在其中。

最终结果是:以by value方式传递一个Student对象,会导致调用一次Student的copy构造函数、一次Person的copy构造函数、四次string的copy构造函数,同时,当函数内的那个Student*复件被销毁*,每一个构造函数调用动作都需要一个对应的析构函数调用动作。因此,总体成本是,六次构造函数和六次析构函数

优化方法:

虽然上面的行为是正确的,但是不是推荐的。如何回避所有那些构造和析构呢?

// pass by reference-to-const
bool validateStudent(const Student& s);

这种传递方式的效率高很多:没有任何构造函数和析构函数被调用,因为,没有任何新对象被创建。原先的的by value方式,调用者知道参数受到保护,函数内绝不会对传入的参数做任何改变,而只能对参数的复件(副本)做修改。而现在的by reference方式,将它声明为const是必要的,因此不这样的话调用者会忧虑函数内部会不会改变他们传入的参数。

同时,以by reference方式传递参数,也可以避免slicing(对象切割)问题

当一个derived class对象以by value方式传递,并被视为一个base class对象。base class的copy构造函数会被调用,而”造成此对对象的行为像个derived class对象”的那些特化性质全被切割掉了,仅仅留下了一个base class对象。

void printNameAndDisplay(const Window& w) // 很好,参数不会被切割
{
    std::cout << w.name();
    w.display(); // 现在,传进来的窗口是什么类型,w就表现出那种类型
}

如果窥视C++编译器的底层,references往往以指针实现出来,因此,pass by reference通常意味真正传递的是指针。如果对象为内置类型(例如,int),pass by value往往比pass by reference的效率高些。

例外:上面这个忠告,也适用于STL的迭代器和函数对象,因为,习惯上它们都被设计为passed by value。它们的设计者有责任看看它们是否高效且不受切割问题的影响。

请记住:
一般而言,你可以合理假设pass-by-value并不昂贵的唯一对象,就是内置类型,以及STL的迭代器和函数对象。至于其他任何东西都请尽量以pass-by-reference-to-const替换pass-by-value

4 Do not try to return a reference when you must return an object

虽然pass-by-value存在传值效率的问题,但是在某些情况下必须使用pass-by-value

一个“必须返回新对象”的函数的正确写法是:就是让那个函数返回一个新对象

inline const Rational operator * (const Rational& lhs, const Rational& rhs)
{
    return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

你需要承受operator *返回值的构造成本和析构成本,然而长远来看那只是为了获得正确行为而付出的一个小小代价。

但是,别忘了C++允许编译器实现者施行最优化,用以改善产出码的效率却不改变其可观察的行为。因此,某些情况下operator *返回值的构造和析构可被安全地消除。

总结:
当你必须在”返回一个reference和返回一个object之间抉择时”,你的工作就是选出行为正确的那个。让编译器厂商为”尽可能降低成本”鞠躬尽瘁,你可以享受你的生活。

请记住
绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或

5 Declare data members private

切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、许诺约束条件获得保证,并提供class作者以充分的实现弹性。

protected并不比public更具封装性。

6 Prefer non-member non-friend functions to member functions

有时,选择member函数,还是non-member函数好呢?

能够访问private成员变量的函数只有class的member函数,以及friend函数。如果你要在一个member函数和一个non-membernon-friend函数之间做抉择,而且两者提供相同机能,那么,导致较大封装的是non-membernon-friend函数,因为它并不增加“能够访问class内之private成分”的函数数量。

在C++,比较自然的做法是让clearBrowser成为一个non-member函数,并且位于WebBrowser所在的同一个namespace内。

namespace WebBrowserStuff {
    class WebBrowser { // ... };
    void clearBrowser(WebBrowser& wb);
    // ...
}

namespaceclasses不同,前者可以跨多个源码文件,而后者不能。

将所有便利函数,放在多个头文件内,但隶属同一个命名空间。意味着客户可以轻松扩展这一组便利函数。他们需要做的就是添加更多non-membernon-friend函数到此命名空间内。

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

混合式算数运算:

方法:让operator*成为一个non-member函数,这样允许编译器在每一个实参上执行隐式类型转换。

class Rational {
   // ...
};

const Rational operator* (const Rational& lhs, const Rational& rhs)
{
    return Rational(lhs.numerator() * rhs.numerator(),
                    lhs.denominator() * rhs.denominator());
}

Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2;   // ok
result = 2 * oneFourth;   // 也可以支持

8 Consider support for a non-throwing swap

缺省情况下,标准程序库提供的swap算法如下:

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

只要类型T支持copying(copy构造函数和copy assignment操作符),缺省的swap实现代码就会帮你置换类型T的对象,你不需要为此另外再做任何工作。

问题
但是,缺省的swap实现版本会涉及三个对象的复制,对某些类型而言(pimpl),这些复制操作无一必要。

pimpl手法:就是pointer to implementation,即,以指针指向一个对象,内含真正的数据。

class WidgetImpl {
public:
    // ...
private:
    int a, b, c;
    std::vector<double> v;
    // 更多数据,意味着复制时间很长
};

class Widget {
public:
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs) {
        *pImpl = *(rhs.pImpl);
        // ...
    }
private:
    WidgetImpl* pImpl;   // 指针,所指对象内含Widget数据
};

问题
置换两个Widget对象值,我们唯一需要做的就是置换其pImpl指针,但是,缺省的swap算法不知道这一点,它不只复制三个Widgets,还复制三个WidgetImpl对象,效率非常低。

我们希望,能够告诉std::swap,当Widgets被置换时真正该做的是置换其内部的pImpl指针。

一种做法是:std::swap针对Widget特化。

class Widget {
public:
    void swap(Widget& other) {
        using std::swap;           // 必要
        swap(pImpl, other.pImpl);
    }
};

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

你可能感兴趣的:(C/C++,C++沉思录)