《Effective C++》笔记(二)

《Effective C++》笔记(二)

四、设计与声明

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

首先,必须考虑客户可能做出什么样的错误。

例如:

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

Date d(30, 3, 1995);//不合理,应该(3, 30, 1995)
Date d(2, 30, 1995);//不合理,2月30号无效日期

考虑导入简单的外覆类型,具备了更高的类型安全性:

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,类型正确

请记住:

  • 好的接口很容易被正确使用,不容易被误用。应该在所有接口中努力达成这些性质。
  • “促进正确使用”,要重视接口一致性,以及与内置类型的行为兼容。
  • “阻止误用”,办法包括建立新类型,限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
  • tr1::shared_ptr支持定制删除器。这可防范DLL问题,可被用来自动解除互斥锁。
//std::shared_ptr的删除器使用示例
#include <iostream>
#include <memory>
 
struct Foo { int i; };
void foo_deleter(Foo * p)
{
    std::cout << "foo_deleter called!\n";
    delete p;
}
 
int main()
{
    std::shared_ptr<int> aptr;
 
    {
        // 创建拥有一个 Foo 和删除器的 shared_ptr
        auto foo_p = new Foo;
        std::shared_ptr<Foo> r(foo_p, foo_deleter);
        aptr = std::shared_ptr<int>(r, &r->i); // 别名使用构造函数
        // aptr 现在指向 int ,但管理整个 Foo
    } // r 被销毁(不调用删除器)
 
    // 获得指向删除器的指针:
    if(auto del_p = std::get_deleter<void(*)(Foo*)>(aptr))
    {
        std::cout << "shared_ptr<int> owns a deleter\n";
        if(*del_p == foo_deleter)
            std::cout << "...and it equals &foo_deleter\n";
    } else
        std::cout << "The deleter of shared_ptr<int> is null!\n";
} // 于此调用删除器

19.设计class犹如设计type

设计高效的class,需要面对的问题:

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

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

c++编译器的底层,reference往往以指针实现出来,因此pass-by-value通常意味真正传递的指针。因此如果你有个对象属于内置类型,pass-by-value往往比pass-by-reference的效率更高。这一点也适用于STL的迭代器和函数对象,因此习惯上它们都被设计为pass-by-value。

内置类型都相当小,因此有人认为,所有小型types都是pass-by-values的合格候选人,甚至它们都是用户自定义的class亦然,这是个不可靠的推论。对象小并不意味着其copy构造函数并不昂贵。许多对象 - 包括大多数STL容器 - 内含的东西只比一个指针多一些,但复制这种对象却需承担“复制那些指针所指的每一样东西”。那将非常昂贵。另外还有某些编译器对待“内置类型”和“用户自定义类型”的态度截然不同。纵使两者拥有相同的底层描述。如编译器拒绝把只由一个double组成的对象放进缓存器内,却很乐意在一个正规基础上对光秃秃的doubles那么做。另外作为一个用户自定义类型,其大小可能会有所变化。

请记住:

  • 尽量以pass-by-value-to-const替换pass-by-value。前者通常比较高效,并可避免切割问题
  • 以上规则并不适用于内置类型,以及STL的迭代器和函数对象。

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

在stack空间创建新对象,

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

这个函数返回一个reference指向result,但result是个local对象,而local对象在函数退出前被销毁了。

在heap空间创建对象,

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

//像下面这样就会有资源泄露问题
//例1.
Rationl w,x,y,z;
w = x * y * z; //与operator*(operator*(x, y), z)相同

除了依旧必须付出一个“构造函数调用”代价,还有一个新的问题,谁该对new出来的对象进行delete?

如代码块中例1,同一个语句内调用了两次operator*,因而两次使用new,也就需要两次delete。但却没有合理的办法让operator *使用者进行那些delete调用,因为没有合理的办法让他们去的operator *返回的reference背后隐藏的那个指针。

为了避免任何构造函数被调用,有可能想到这样的实现代码,

const Rational& operator* (const Rational& lhs, const Rational& rhs)
{
    static Rational result;
    result = /*...*/;
    return result;
}

//首先,显而易见这样不是多线程安全的
//此外,看如下这种情况,
bool operator==(const Rational& lhs, const Rational& rhs);
Rational a,b,c,d;
if((a * b) == (c * d))
{
    //...
}
else
{
    //...
}
//这样表达式(a * b) == (c * d)总是被核算为true
//其等价函数形式(operator==(operator*(a, b), operator*(c, d))),
//这样子两个operator*返回的reference指向的是同一个static Rational对象

请记住:

  • (1)不要返回指针或引用指向一个local stack对象,(2)不要返回引用指向heap-allocated对象,(3)不要返回指针或引用指向一个local static对象而有可能同时需要多个这样的对象。

22.将成员变量声明为private

为什么成员变量不该是public?

  • 语法一致性。如果成员变量不是public,客户唯一能够访问对象的方法就是通过成员函数。使用函数可以让你对成员变量的处理有更精确的控制。
  • 封装性。如果你通过函数访问成员变量,日后可改以某个计算替换这个成员变量,而class客户一点也不会知道class的内部实现已经起了变化。将成员变量隐藏在函数接口的背后,可以为“所有可能的实现”提供弹性。

请记住:

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

  • protected并不比public更具封装性。

    假设我们有一个protected成员变量,而我们最终取消了它,所有使用它的子类都会被破坏,那往往也是个不可知的大量。

23.宁以non-member-no-friend替换member函数

//一个网页浏览器类
class WebBrowser{
public:
    void clearCache();//清除下载元素高速缓存区
    void clearHistory();//清除访问过的URLs历史记录
    void removeCookies();//移除系统中的所有cookies
};

如果想一整个执行所有这些动作,可以有两种方式,

//1.WebBrower也提供这样一个函数
class WebBrower{
public:
    void clearEverything()
    {
        clearCache();
        clearHistory();
        removeCookies();
    }
};

//2.提供一个普通的no-member函数
void clearBrower(WebBrower& wb)
{
    wb.clearCache();
    wb.clearHistory();
    wb.removeCookies();
}

哪一个比较好?

面向对象守则要求数据应该尽可能被封装,然而与直观相反的,member函数clearEverything带来的封装性比non-member函数clearBrowser低。此外,提供non-member函数可允许对WebBrowser相关机能有较大的包裹弹性,能有更低的编译相侬度,增加WebBrowser的可延伸性。

愈少代码可以看到数据,愈多的数据可被封装,而我们也就愈能*地改变对象数据,例如改变成员变量的数量、类型等等。成员变量应该是private,如果不是,就有无限量的函数可以访问它们,也就毫无封装性。能够访问private成员变量的函数只有class的member函数和friend函数而已。如果在一个member函数和一个non-member-non-friend函数做抉择,且两者提供相同功能,那么,使用non-member-non-friend函数封装性更好,因为它并不增加能够访问class内之private成分的函数数量。

让clearBrowser成为一个non-member函数并且位于WebBrowser所在的同一个namespace内,比起在class内,能降低编译依存性。

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

  • 如果你需要为某个函数的所有参数(包括this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member。(不一定是friend函数,无论何时如果你可以避免friend函数就该避免)

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

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

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

其次,如果swap缺省实现版的效率不足(大概率是class或template使用了pimpl手法),

  1. 提供一个public swap成员函数,让它高效的置换你的类型的两个对象值。

    class Widget{
    public:
        void swap(Widget& other)
        {
            using std::swap;
            swap(pImpl, other.pImpl);
        }
    private:
        WidgetImpl* pImpl;
    };
    
    namespace std {
        template<>
        void swap(Widget)(Widget& a, Widget& b)//修订后的std::swap特化版本
        {
            a.swap(b);
        }
    }
    
  2. 在你的class或template所在的命名空间内提供一个non-member swap函数,并令它调用上述swap成员函数。

    /*****************************************************/
    namespace std{
        template<typename T>
        void swap<Widget<T> >(Widget<T>& a, Widget<T>& b)//错误,不合法
        {
            a.swap(b);
        }
    };
    //这里企图偏特化一个function template(std::swap),但C++只允许对class templates偏特化,
    //在function templates身上偏特化是行不通的。
    /*****************************************************/
    
    //当你打算偏特化一个function template时,惯常做法是简单的为它添加一个重载版本。
    //如下:
    namespace std{
        template<typename T>
        void swap(Widget<T>& a, Widget<T>& b)
        {
            a.swap(b);
        }
    };
    
    //为了让其他人调用swap时能够取得我们提供的高效的template特定版本,我们还是声明一个non-member swap让它调用member swap,但不再将那个non-member swap声明为std::swap的特化版本或重载版本。
    namespace Widdget{
        template<typename T>
        class Widget { /*..同前,内含swap成员函数..*/ };
        
        template<typename T>					//non-membee swap函数
        void swap(Widget<T>& a, Widget<T>& b)	//这里不属于std命名空间
        {
            a.swap(b);
        }
    };
    
  3. 如果你正在编写一个class(非class template),为你的class特化std::swap。并令它调用你的swap成员函数。

    //如果想让你的“class专属版”swap在尽可能多的语境下调用,还是需同时在该class所在命名空间内写一个non-member版本以及一个std::swap特化版本
    
    //希望调用T专属版本,并在该版本不存在的情况下调用std内的一般化版本,
    template<typename T>
    void doSomething(T& obj1, T& obj2)
    {
        using std::swap;//令std::swap在此函数内可用
        //...
        swap(obj1, obj2);//为T类型对象调用最佳swap版本
    }
    //一旦编译器看到对swap的调用,它们会查找适当的swap并调用之
    //C++的查找法则确保将找到global作用域或T所在之命名空间内的任何T专属的swap
    

成员版的swap绝不抛出异常。高效率的swap几乎总是基于对内置类型(如pimpl手法的底层指针),而内置类型上的操作绝不会抛出异常。

请记住:

  • 当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
  • 如果你提供一个member swap,也该提供一个non-member swap来调用前者。对于classes,也请特化std::swap。
  • 调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何“命名空间修饰”。
  • 为“用户定义类型”进行std templates全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。

五、实现

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

只要定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流到达这个变量定义式时,你便得承受构造成本;当这个变量离开其作用域时,你便得承受析构成本。

“通过默认构造函数构造对象然后对它赋值”比“直接在构造时指定初值”效率差。

对于循环呢?

//方法A:定义于循环外
Widget w;
for(int i = 0; i < n; ++i)
{
    w = 取决于i的某个值;
    ...
}
//方法B:定义于循环内
for(int i = 0; i < n; ++i)
{
    Widget w(取决于i的某个值);
    ...
}

方法A:1个构造 + 1个析构 + n个赋值操作

方法B:n个构造 + n个析构

如果classes的一个赋值成本低于一组构造+析构成本,做法A大体而言比较高效,尤其当n值很大的时候。否则做法B或许比较号。此外做法A造成名称w的作用域比做法B更大,有时会对程序的可理解性和易维护性造成冲突。

因此除非(1)知道赋值成本比“构造+析构”成本低,(2)你正在处理代码钟效率高度敏感的部分,否则你应该使用做法B。

27.尽量少做转型动作

C++提供四种新式类型转换:

  • const_cast通常被用来将对象的常量性移除。它也是唯一有此能力的C++转型操作符。
  • dynamic_cast主要用来执行“安全向下转型”,也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作。
  • reinterpret_cast意图执行低级转型,实际动作及结果可能取决于编译器,这也就表示它不可移植。例如将一个int指针转型为易额int。
  • static_cast用来强迫隐式转换,例如将non-const对象转换为const对象,或将int转为double等。

需记住:

  • 如果可以,尽量避免转型,特别是注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
  • 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需要将转型放进他们自己的代码内。
  • 宁可使用C++_style转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职责。

28.避免返回handles指向对象内部成分

class Point{
public:
    Point(int x, int y);
    void setX(int newVal);
    void setY(int newVal);
};

struct RectData{
    Point ulhc;//左上角点
    Point lrhc;//右下角点
};

class Rectangle{
public:
    //可通过编译,但是是错误的,这两个成员函数
    Point& upperLeft() const { return pData->ulhc; }
    Point& lowerRight() const { return pData->lrhc; }
private:
    std::shared_ptr<RecData> pData;
};

//例:
Point coord1(0,0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2);
//upperLeft的调用者能够使用被返回reference来更改成员,但rec其实应该是不可变的
rec.upperLeft().setX(50);

这个示例带来的两个教训是,第一,成员变量的封装性最多只等于“返回其reference”的函数的访问级别。第二,如果const成员函数传出一个reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据。

class Rectangle{
public:
    const Point& upperLeft() const { return pData->ulhc; }
    const Point& lowerRight() const { return pData->lrhc; }
};

像上面这样声明的话,客户可以读取矩形的Points,但不能涂写它们。但即使如此,upperLeft和lowerRight还是返回了“代表对象内部”的handles,有可能导致dangling handles(空悬号码牌)问题。

class GUIBbject {
    //...
};

const Rectangle boundingBox(const GUIObject& obj);

//客户又可能这么使用这个函数
GUIObject* pgo;
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());
//对boundingBox的调用获得一个新的、暂时的Rectangle对象。这个对象没有名称,我们权且称它为temp。随后upperLeft作用于temp身上,返回一个reference指向temp的一个内部成分,更具体地说是指向一个用以标示temp的Points.于是pUpperLeft指向那个Point对象。到目前为止还没有什么问题,但是在这条语句结束后,temp将被销毁,而那间接导致temp内的Points析构。最终导致pUpperLeft指向一个不再存在的对象;也就是说一旦产出pUpperLeft的那个语句结束,pUpperLeft也就变成空悬,虚吊。

29.为“异常安全”而努力是值得的

异常安全函数提供以下三个保证之一:

  • 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态。
  • 强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需要有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回复到“调用函数之前”的状态。
  • 不抛掷(nothrow)保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能,比如内置类型身上的操作。

请记住:

  • 异常安全函数即使发生异常也不会泄露资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
  • “强烈保证”往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备现实意义。
  • 函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。

30.透彻了解inlining的里里外外

inline函数背后的整体观念是,将“对此函数的每一个调用”都以函数本体替换之。这样做可能增加你的目标码大小,在一台内存有限的机器上,过度热衷inlining会造成程序体积太大,导致额外的换页行为,降低指令高速缓存装置的击中率,以及伴随这些而来的效率损失。如果inline函数的本地很小,函数inlining可能产生较小的目标码和较高的指令高速缓存装置击中率。

inline是个申请,编译器可加以忽略。大部分编译器拒绝将太过复杂的函数inlining;而所有对virtual函数的调用也都会使inlining落空,因为virtual意味“等待,直到运行期才确定调用哪个函数”,而inline意味“执行前,先将调用动作替换为被调用函数的本体”。

有时候虽然编译器有意愿inlining某个函数,还是有可能为该函数生成一个函数本体。例如,如果程序要取某个inline函数的地址,编译器通常必须为此函数生成一个outlined函数本体,因为编译器不能提出一个指针指向并不存在的函数。与此一块提到一点,编译器通常不对“通过函数指针而进行的调用”实施inlining,这意味着对inline函数的调用有可能被inlined,也可能不被inlined,取决于该调用的实施方式。

构造函数和析构函数往往是inlining的糟糕候选人。

如果func是程序库内的一个inline函数,将"func函数本体"编进程序之后,一旦程序设计者决定改变func,所有用到func的程序都必须重新编译。然而如果func是non-inline函数,一旦它有任何修改,程序只需要重新链接就好,远比重新编译的负担少很多。

从纯粹实用观点出发,inline函数难以进行调试,大部分调试器面对inline函数都束手无策。

需记住,

  • 将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
  • 不要只因为function templates出现在头文件,就将它们声明为inline。

31.将文件间的编译依存关系降至最低

  • 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classes(通过pImpl技术–指针或引用)和Interface classes(纯虚函数)。
  • 程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及templates都适用。
上一篇:第十节


下一篇:1600数字直角三角形