4. 设计与声明 摘录

让接口容易被正确使用,不易被误用这一准则设立了一个舞台,让其他更专精的准则对付一大范围的题目,包括正确性、高效性、封装性、维护性、延展性、已经协议的一致性。

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

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

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

问题在于第一,它们也许会以错误的次序传递参数。第二,它们可能传递一个无效的月份或天数。

许多客户端的错误可以因为导入新类型而预防。

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 Data
{
public:
    Data(const Month& m, const Day& d, const Year& y);
...
};

明智而慎重地导入新类型对预防“接口被误用”有神奇疗效。

一旦正确的类型就定位,限制其值有时候是通情达理的。比如月份最大只有12.

预防客户错误的另一个方法是:限制类型内声明事可做,什么事不可做。

a * b = c; // 离谱

除非有好的理由,否则就应尽量让你的types的行为与内置types一致。比如int型。

避免无端与内置类型不兼容,真正的理由是为了提供行为一致的接口。

tr1::shared_ptr有一个特别好的性质是:它会自动使用它的“每个指针专属的删除器”,因而消除另一个潜在的客户错误:所谓的“cross-DLL problem”。这个问题发生于“对象在动态链接程序库DLL中被new创建,却在另一个DLL内被delete销毁”。

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

请记住:

好的接口很容易被正确使用,不容易被误用。

“促进正确使用”的方法包括建立接口的一致性,以及与内置类型的行为兼容。

“组织误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。

tr1::shared_ptr支持定制型删除器,这可防范DLL问题,可被用来自动解除互斥锁等等。

条款19: 设计class犹如设计type

重载函数和操作符、控制内存的分配合归还、定义对象的初始化和终结...全都在你手里。

好的types有自然的语法,直观的语义,以及一或多个高效实现品。

如何设计高效的class?

        新type的对象应该如何被创建和销毁?这会影响到你的class的构造函数和析构函数以及内存分配函数和释放函数的设计,前提是你打算写他们。

        对象的初始化和对象的复制应该有什么差别?决定你的构造函数和赋值操作符的行为,以及其间的差异。

        新type的对象如果被pass by value,意味着什么? 记住,copy构造函数用来定义一个type的pass-by-value该如何实现。

        什么是新type的“合法值”?对于class的成员变量而言,通常只有某些数值集是有效的。那些数值决定了你的class必须维护的约束条件(invariants),也就决定了成员函数(特别是构造函数、赋值操作符和所谓的setter函数)必须进行错误检查工作。它也影响函数抛出的异常、已经少数被使用的函数异常明细。

        你的type需要配合某个继承图系(inheritance graph)?如果i继承了某些既有的classes,你就受到哪些classes的设计的束缚,特别是受到“它们的函数是virtual”或non-virtual的影响。如果你允许其他classes继承你的class,那么会影响你所声明的函数--尤其是析构函数--是否为virtual。

        你的type对象需要说什么样的转换?你的type生存于其他types之间,因而彼此该有的转化行为吗?如果你希望允许类型T1被隐式转换为T2,则必须在class T1写一个类型转化函数,或在class T2内写一个non-explicit的构造函数。

        什么样的操作符合函数对新type而言是合理的?决定你的class声明那些函数。其中那些是member函数,那些不是。

        什么样的标准函数应该驳回?那些正是你必须声明为private者。

        谁该取用新的type成员?这个提问可以帮助你决定哪个成员为public,哪个为protected,哪个为private。它也帮助你决定哪一个class或function应该是friend,以及将它们嵌套于另一个之内是否合理。

        什么是新type的“未声明接口”(undeclared interface)? 它对效率、异常安全性以及资源运用(例如多任务锁定和动态内存)提供何种保证?你在这些方面提供的保证将为你的class实现代码加上相应的约束条件。

        你的新type有多么一般化?或许你其实并非定义一个新type,而是定义一整个types家族。果真如此你就不应该定义一个新class,而是应该定义一个新的class template。

        你真的需要一个新type吗?如果只是定义derived class以便为既有的class添加机能,那么说不定单纯定义一个或多个non-member函数或template,更能达到目标。

请记住:

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);

对次函数而言,参数的传递成本是一次Student copy构造函数调用,加上一次Student析构函数调用。但那还不是整个故事喔。Student对象内有两个string对象。此外Student继承自Person对象。所以每次构建Student对象也必须构建一个Person对象。一个Person对象又有两个string对象。

最终的结果是,以by value方式传递一个Student对象会导致调用一次Student copy构造函数,一次Person copy构造函数、四次string copy构造函数。当函数内Student副本被销毁时,每一个构造函数调用都需要一个相应的析构函数。以by value传递一个Student对象,总体成本是“六次构造和六次析构函数”!

pass by reference-to-const能够回避所有的构造和析构动作。

bool validateStudent(const Student& s);

pass by reference-to-const传递方式的效率高得多:没有任何构造函数或析构函数被调用,因为没有任何新对象被创建。之前是以副本形式传递的Student变量,所以将rference声明为const是必要的。

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

解决slicing问题的办法,就是以by-reference-to-const的方式传递w:

void printNameAndDisplay(const Window& w)
{
    std::cout << w.name();
    w.display();
}

如果你有个对象属于内置类型(例如int),pass by value往往比pass by reference的效率高些。

内置类型都相当小,因此有人认为,所有小型types都是pass-by-value的合格候选人,甚至它们是用户自定义class依然。这是个不可靠的推论。对象小不意味着其copy构造函数不昂贵。

某些编译器对待内置类型和用户自定义类型的态度截然不同,纵使两者拥有相同的底层表述。

小型的用户自定义不必然称为pass-by-value优良候选人的另一个理由是,作为一个用户自定义类型,其大小容易有所变化。

你可以合理假设pass-by-value并不昂贵的唯一对象就会内置类型和STL的迭代器和函数对象。至于其他任何东西都请遵守本条款的忠告。

请记住:

尽量以pass-by-reference-to-const替换pass-by-value。前者通常比较高效,并可避免切割问题。

以上规则并不适用于内置类型,以及STL的迭代器和函数对象。对于它们,pass-by-value往往比较合适。

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

开始传递一些refereence指向其实并不存在的对象,这可不是一个好事。

class Rational
{
public:
    Rational(int numerator=0, int denominator=1);
    ...
private:
    int n, d;
    friend const Rational operator*(const Rational& lhs, const Rational & rhs);

};

任何时刻看到一个reference声明式,你都应该立刻问自己,它的另一个名称是什么?

函数创建对象的途径有二:在stack空间或在heap空间创建。

// 返回临时对象的引用,糟糕极了
const Rational& operator*(const Rational& lhs, const Rational & rhs)
{
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}

任何函数如果返回一个reference指向某个local对象,都将一败涂地。

于是,让我们考虑在heap创建一个对象,并返回reference指向它。

// 返回heap上的引用,糟糕极了
const Rational& operator*(const Rational& lhs, const Rational & rhs)
{
    Rational* result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}

你还是付出了一个“构造函数调用”代价,因为分配所得的内存将以一个适当的构造函数完成初始化动作。但此外你现在又有了另一个问题:谁该对这被你new出来的对象实施delete?因为没有合理的办法让他们取得operator*返回reference背后隐藏的那个指针,这绝对是导致资源泄露。

或许你注意到了,上述不论on-the-stack或on-the-heap做法,都因为对operator*返回结果调用构造函数而受到惩罚。或许你还有返回的reference指向一个定义于函数内部的static Rational对象。

// 返回static heap上的引用,糟糕极了
const Rational& operator*(const Rational& lhs, const Rational & rhs)
{
    static Rational result;
    result = ...;
    return result;
}

就像所有用上static对象的设计一样,这一个也立刻造成我们对多线程安全性的疑虑。而且引用将会持续改变static变量的值,如果是在条件判断中,你永远看到static Rational对象的现值。

Rational a, b, c, d;
...
if (operator==(operator*(a, b), operator*(c, d)))

请记住:

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

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

首先带你看看为什么成员变量不该是public,然后让你看看所有反对public成员变量的论点同样适用于protected成员变量。

让我们从语法一致性开始。如果成员变量是public,客户需要在打算访问class成员时迷惑是否该使用小括号。

使用函数可以让你对成员变量的处理有更精确的控制。比如只读,不准访问,读写访问。

class AccessLevels
{
public:
    ...
    int getReadOnly() const { return readOnly; }
    void setReadWrite(int value) { readWrite = value; }
    void setWriteOnly(int value) { writeOnly = value; }

private:
    int noAccess;
    int readOnly;
    int readWrite;
    int writeOnly;
};

最后就是封装的作用。如果你通过函数访问成员变量,日后可改某个计算替换这个成员变量,而class客户一点也不会知道class的内部实现已经起了变化。

class SpeedDataCollection
{
public:
    void addValue(int speed);    // 添加一笔新数据
    double averageSoFar() const; // 返回平均速度
};

让我们考虑成员函数averageSoFar,做法之一是在class内设计一个成员变量,记录至今所有速度的平均值。另一个做法是零averageSoFar每次被调用时重新计算平均值。

上述第一种做法会使每一个SpeedDataCollection对象变大,因为你必须要来存放目前平均值、累计总量、数据点数的每一个成员变量分配空间。然而averageSoFar却可因此十分高效;它可以只是一个返回目前平均值的inline函数。相反地,被询问才计算平均值会使得averageSoFar执行比较慢,但每一个SpeedDataCollection对象比较小。

由于通过成员函数返回平均值(也就是封装了它),你得以替换不同的实现方式(以及其他你可能想到的东西),客户最多只是重新编译。

将成员变量隐藏在函数接口的背后,可以为“所有可能的实现”提供弹性。

封装的重要性比你最初见到它时还重要。如果你对客户隐藏成员变量,你可以确保class的约束条件总是会获得维护,因为只有成员函数可以影响它们。更进一步地说,你保留了日后变更实现的权利。不封装意味不可改变,特别是对被广泛使用的class而言。

成员变量的封装性和“成员变量的内容改变时所破坏的代码数量成反比。一旦你将成员变量声明为public或protected而客户开始使用它,就很难改变那个成员变量所涉及的一切。太多的代码需要重写,测试,重新编写文档、重新编译。从封装的角度观之,其实只有两种访问权限:private和其他。

请记住:

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

protected并不比public更具封装性。

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

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

class WebBrowser
{
public:
    ...
    void clearEverything();
    ...
};


void clearBrowser(WebBrowser& wb)
{
    wb.clearCache();
    wb.clearHistory();
    wb.removeCookies();
}

面向对象守则要求,数据以及操作数据的那些函数应该捆绑在一块,这意味着它建议member函数一个较好的选择。不幸的是这个建议不正确。这是基于面向对象真实意义的一个误解。面向对象守则要求数据应尽可能封装,然而与直观相反地,member函数clearEverything带来的封装性比non-member函数clearBrowser低。此外,提供non-member函数可允许对WebBrowser相关机能较大的包裹弹性(package flexibility),而那最终导致较低的编译相依度,增加WebBrowser的可延伸性。

越多东西被封装,越少人能看到它。而越少人看见它,我们就有越大的弹性去变化它,因为我们的改变仅仅直接影响到改变的那些东西。

如果要你在一个member函数(它不只可以访问class内private数据,也可以取用private函数、enus、typedefs等等)和一个non-member,non-friend函数(它无法访问上述任何东西)之间做抉择,而且两者提供相同的机能,那么,导致较大封装性的是non-memeber non-friend函数,因为它并不增加能够访问class内之private成分的函数数量。

从non-member函数与member函数抉择,我们需要两点。第一,这个结论只适用于non-member non-friend函数。friends函数对class private成员的访问权力和member函数相同。第二,只因在封装性而让函数称为class的non-member并不意味着它不可以是另一个class的member。

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

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

要知道,namespace和class不同,前者可跨越多个源码文件而后者不能。这很重要,因为像clearBrowser这样的函数是个“提供便利的函数”,如果它既不是members也不是friends,就没有对WebBrowser的特殊访问权力,也就不能提供WebBrowser客户无法以其他方式取得的机能。

C++标准程序库的组织方式。允许客户只对他们所用的那一小部分系统形成编译相依。以此种方式切割机能并不适用class成员函数,因为一个class必须整体定义,不能被分割为片片断断。

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

举个例子,如果某个WebBrowser客户决定写些与影像下载相关的便利函数,他只需要在WebBrowserStuff命名空间建立一个头文件,内含那些函数声明即可。新函数就像其他旧有的便利函数那样可用且整合为一体。这是class无法提供的性质。class定义式对客户而言是不能扩展的。

请记住:

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

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

class Rational
{
public:
    Rational(int numerator=0, int denominator=1);
    int numerator() const;
    int denominator() const;
    const Rational operator*(const Rational& rhs) const;
private:
    ... 
};



Rational  oneEighth(1, 8);

Rational result = 2 * oneEighth;     // Wrong
Rational result1 = oneEighth * 2;    // OK imexplicit conversion

result1通过是因为发生了隐式类型转换。

然而你的目标不仅在一致性,也要支持混合算术运算。

结论是,只有当参数位于参数列内,这个参数才是隐式类型转换的合格参与者。地位相当于“被调用之成员函数所隶属的那个对象”--this对象。

class
{
...
};

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

member函数的反面观察是non-member,而不是friend函数。太多C++程序员假设,如果一个与某个class相关的函数不该成为一个member,就该是friend。无论何时如果你可以避免friend函数就该避免,因为就像真实世界一样,朋友带来的麻烦往往多过于其价值。

不能够只因函数不该成为member,就自动成为friend。

请记住;

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

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

swap原本是STL的一部分,而后成为异常安全性编程的脊柱,以及用来处理自我赋值可能性的一个常见机制。

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

其中最主要的是“以指针指向一个对象,内含真正数据”那种类型。这种设计的常见表现形式是所谓的pimpl手法。

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对象值,我们唯一需要做的就是置换其pImpl指针,但缺省的swap算法不知道这点。

我们希望告诉std::swap,当Widgets被置换成真正该做的是置换其内部的pImpl指针。确切实现这个思路的一个做法是:将std::swap针对Widget特化。以下是思路,但目前无法通过编译:

namespace std
{
    template <>
    void swap<Widget>(Widget& a, Widget& b)
    {
        swap(a.pImpl, b.pImpl);    // 直接访问private成员,无法通过编译
    }
}

这个函数一开始的"template<>"表示它是std::swap的一个全特化版本,函数名称之后<Widget>表示这一特化版本针对T是Widget而设计。这个函数目前无法通过编译,因为它企图访问a和b的pImpl指针,而那确实private。我们可以将这个特化版本声明为friend,但和以往的规矩不太一样:我们令Widget声明一个名为swap的public成员函数做正在的置换工作,然后将std::swap特化,令它调用该成员函数:

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

namespace std
{
    template <>
    void swap<Widget>(Widget& a, Widget& b)
    {
        a.swap(b);    // 通过编译
    }
}

这种做法不只能够通过编译,还与STL容器有一致性,因为所有STL容器也都提供public swap成员函数和std::swap特化版本。

假设Widget和WidgetImpl都是class template而非class,也许我们可以试着将WidgetImpl内的数据类型加以参数化:

template <typename T>
class WidgetImpl
{
    ...
};

template <typename T>
class Widget
{
    ...
};

在Widget内(以及WidgetImpl内)放个swap成员函数就像以往一样简单,但我们却在特化std::swap时遇上乱流。我们想写成这样:

namespace std
{
    template <typename T>
    void swap<Widget<T>>(Widget<T& a, Widget<T& b)    // 错误,不合法
    {
        a.swap(b);  
    }
}

因为我们企图偏特化一个function template,但C++只允许对class template偏特化,在function template身上偏特化不行。

当你打算偏特化一个function template时,惯常的做法是简单地为它添加一个重载版本,像这样:

namespace std
{
    template <typename T>
    void swap(Widget<T& a, Widget<T& b)    // 合法
    {
        a.swap(b);  
    }
}

客户可以全特化std内的template,但不可以添加新的template到std里头。std的内容完全由C++标准委员会决定。如果你希望你的软件有可预期的行为,请不要添加任何新东西到std里头。

声明一个non-member swap,让它调用member swap,但不再将那个non-member swap声明为std::swap的特化版本或重载版本。结果如下:

namespace WidgetStuff
{
    template <typename T>
    class WidgetImpl
    {
        ...
    };

    template <typename T>
    class Widget                // 同前,内含swap成员函数
    {
        ...
    };

    template <typename T>
    void swap(Widget<T& a, Widget<T& b)    // non-member swap函数
    {                                      // 这里并不属于std命名空间
        a.swap(b);  
    }
}

令适当的swap被调用是很容易的。需要小心的是,别为这一调用添加额外的修饰符,因为那会影响C++挑选适当函数。

std::swap(obj1, obj2);

这便强迫编译器只认std内的swap(包括其任何template特化),因而不在可能调用一个定义它处的较适当T专属版本。某些迷途程序员以此方式修饰swap调用式,而那正式“你的class对std::swap进行全特化”的重要原因:它使得类型专属swap实现版本也可被这些迷途代码所用。

此刻,我们已经讨论过default swap,member swap,non-member swap,std::swap特化版本,已经对swap的调用,现在让我们把整个形势做一个总结:

        首先,如果swap的缺省实现对你的class或class template提供可接受的效率,你就不需要额外做任何事。任何尝试置换swap那种对象的人都会取得缺省版本。

        其次,如果swap的缺省版本实现效率不足(那几乎意味着你的class或template使用了某种pImpl手法),尝试做以下事情:

                1. 提供一个public swap成员函数,让它高效地置换你的类型的两个对象值。稍后我将解释,这个函数绝不该抛出异常。

                2. 在你的class或template所在的命名空间内提供一个non-member swap,并令它调用上述member swap。       

                3. 如果你正编写一个class(而非class template),为你的class特化std::swap。并令它调用你的swap成员函数。

        最后,如果你调用swap,请确保包含一个using声明式,以便让std::swap在你的函数内曝光可见,然后不加任何namespace修饰符,赤裸裸地调用swap。

唯一还没明确的是我的劝告,member swap绝不可抛出异常,那是因为swap的一个最好的应用是帮助class提供强烈的异常安全性。但这一观点仅适用于member swap,对其他版swap无效。

请记住:

当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。

如果你提供一个member swap,也该提供一个non-member swap来调用前者。对于class(而非template),也请特化std::swap。

调用swap时应针对std::swap适用using声明式,然后调用swap并且不带任何命名空间资格修饰符。

为“用户定义类型”进行std template全特化是好的,但千万不要尝试在std内加入某些对std而言是全新的东西。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值