Effective C++第四章笔记

本文深入探讨了C++中接口设计的原则,包括防止误用、促进正确使用,解决跨DLL问题,以及类的设计策略。强调了使用pass-by-reference-to-const、避免返回对象的reference、保持成员变量private、优先使用non-member函数和考虑异常安全的swap函数实现。文章还介绍了如何处理不同模块间的内存分配问题,以及避免类型转换带来的潜在错误。
摘要由CSDN通过智能技术生成

四、设计与声明

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

想要开发出这样的接口,需预判客户可能做出什么样的错误。

防止误用

预防传递参数的顺序错误与输入错误:导入新的类型

class Date {
public:Date(int month, int day, int year);
    ...;
};
//导入新的类型
struct Day {explicit Day(int d) : val(d) { }
    int val;};
class Month {explicit Month(int m) : val(m) { }
    static Month Jan() { return Month(1): }  //函数,返回有效的月份
    ...;
    static Month Dec() { return Month(12);}
private:explicit Month(int m);
};
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(Month::Mar(), Day(30), Year(1995));  //正确了!

预防错误的另一个方法:限制类型内可做/不可做的事(条款03例子operator=()加上const)

促进正确使用

尽量让自定义type和内置type行为/接口保持一致;避免无端与内置类型不兼容(提供行为一致的接口)。

任何接口如果让用户记得做某事,就有“不正确使用”的倾向

//条款13例
Investment* createInvestment();//需放入智能指针,避免忘记删除指针及多次删除指针
//忘了放入智能指针也很糟糕,直接放入智能指针会是更好的选择
std::tr1::shared_ptr<Investment> createInvestment()
{
    //shared_ptr使用者可能直接使用delete释放资源,这与智能指针的思路相违背。
    //可在函数内就指定资源释放函数getRidOfInvestment
    std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0), getRidOfInvestment);//为空指针指定删除器
    retVal = ...;    //令retVal指向正确的对象。当然如果已经知道所要放入的指针,直接放入更好
    return retVal;
}

cross-DLL problem(不同模块间申请和释放内存)

对象的new与delete来着不同的DLL(动态链接库),这会导致运行期错误。使用shared_ptr会避免这一问题,会自动使用原DLL的delete

shared_ptr开销巨大(动态分配内存记录用途和删除器数据、virtual调用删除器、多线程需要修改引用次数)是原始指针的两倍,但是降低接口误用相关极好。

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

本条款基本照抄,较难总结。

定义一个class就是在定义一个新的type。重载函数、操作符、控制内存、定义对象等都有你设计。因此你要带着和“语言设计者当初设计语言内置类型时”一样的谨慎来研讨class的设计。几乎每一个class都要面临如下问题:

  1. **新type的对象应该如何被创建和销毁?**这会影响到class的构造函数和构造函数以及内存分配函数和释放函数(operator new, operator new[], operator deleter和operator deleter[]),可见第八章。

  2. **对象的初始化和对象的赋值应该有什么差别?这个问题的答案,决定了构造函数和赋值(assignment)操作符的行为,以及它们之间的差异。注意!“初始化”“赋值”**是不同的,因为他们应用于不同的函数调用,可见条款4。

  3. 新type的对象如果被passed by value(以值传递),会怎样?需要记住的是,copy构造函数用来定义一个type的pass-by-value应该如何去实现。

  4. **什么是type的“合法值”?**对class的成员变量来说,只有部分数值集是有效的。这决定了class需要维护的约束条件(invariants),也决定了成员函数(特别是构造函数、赋值操作符以及所谓的“setter”函数)必须要进行错误检查工作。同时,影响函数抛出异常以及函数异常明细列。

  5. 你的新type需要配合某一个继承图系(inheritance graph)吗?

    • 继承来自某些既有的class,那么设计的新class就收到了束缚,特别是受到“它们的函数是virtual或者non-virtual”的影响。
    • 如果我们定义的class允许其他class去继承,这样会影响我们所声明的函数——尤其是析构函数——是否为virtual(详见条款7)。
  6. 你的新type需要怎样的转换?

    你的type与其他type需要转换吗?

    • 如果我们希望允许类型T1可以被隐式地转换为类型T2,就必须在class T1中写一个类型转换函数(operator T2)或者在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数
    • 如果我们只允许explicit构造函数存在,就必须写出专门负责执行转换操作的函数,且不得为类型转换操作符non-explicit-one-argument构造函数(条款15和16)。
  7. **什么样的操作符和函数对新创建的type而言是合理的?**需决定class声明哪些函数,在这些函数中,哪些是/不是member 函数。(条款23/24/46)

  8. **什么样的标准函数应该被驳回?**这些函数是必须声明为private的函数(详见条款7)。

  9. **谁该取用新的type成员?**可帮助我们决定成员是public、protected或private;帮忙决定class/function是否为friend,以及将它们嵌套到另一个中合理吗?

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

  11. **你的新的type有多么一般化?**可能需要的不是一个type,而是一整个types家族,那么更应该定义一个新的class template。

  12. **真的需要定义一个新的type吗?**如果定义derived class只是为base class添加功能,那么定义一个或多个non-member函数或者template也可以。

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

默认情况下C++以by value方式(继承自C的方式传递对象至函数调用端获得的也是返回值的一个复件

继承体系中,按值传递需要调用大量构造和析构函数

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构造函数、一次Person copy构造函数、四次string copy构造函数及六次对应析构函数。

解决方案:pass-by-reference-to-const可大大减少调用次数,const可避免传入对像的修改。

Slicing(对象切割)问题:当derived class对象以by value方式传递并视为base class对象,会导致derived部分被切割掉。

解决方案:pass-by-reference-to-const

C++编译器底层,reference以指针实现,pass by reference通常传递的是指针

因此,如传递的是内置类型、STL迭代器和函数对象,pass by value更高效;其他情况下pass-by-reference-to-const是更好的选择。

编译器对自定义类型和内置类型会有完全不同的处理,即使两者放的东西一样。自定义类型在将来或许会变大。

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

过度强调pass-by-reference-to-const(条款20),会犯一个致命错误:传递一些reference指向其实并不存在的对象

class Rational {
public:
    Rational(int numerator = 0, int denominator = 1);//不加explicit,详见条款24
    ...;//分子numerator和分母denominator
private:
    int n, d;
friend const Rational operator* (const Rational& lhs, const Rational& rhs);
};

operator*()通过by value传递,必然会有构造和析构成本。采用reference则无需负担该成本,但是使用时需明确reference的对象是什么?

Rational a(1, 2);       //a = 1/2
Rational b(3, 5);       //b = 3/5
Rational c = a * b;     //c应该是3/10

不可能期望一个(内含乘积)Rational对象在调用operator*之前就存在,不存在就无法返回其reference,想要这么写必须自己创建Rational对象。

创建对象的途径:

  1. 在stack空间

    这种方法返回的是loacl对象,在退出函数体后对象就会销毁,这会导致无定义行为。

  2. 在heap空间

    这需要调用构造函数并new一个对象,谁来delete对象?没有方法让他们取得operator*返回的reference背后隐藏的指针。

  3. static对象

    const Rational& operator* (const Rational& lhs, const Rational& rhs){
        static Rational result;//烂代码!定义static对象,该函数将返回其reference
        result = ... ;//将lhs乘以rhs,并将结果置于result之内
        return result; 
    }
    //一个针对Rational而写的operator==
    bool operator==(const Rational& lhs, const Rational& rhs);   
    Rational a, b, c, d;
    if ((a * b) == (c * d))乘积相等所执行的动作;
    else 乘积不等所执行的动作;
    //将(a * b) == (c * d)展开
    if (operator==(operator*(a, b), operator*(c, d))
    

    两次operator*都各自改变了static Rational对象值,但是都返回了相同的reference,因此该判断永远为真。

以上三种都不是好方法,最好的方法是直接返回新对象,当然必须承担构造和析构成本。

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

C++允许编译器进行优化,通常operator*返回值的构造和析构可安全消除

总结:当必须在“返回reference和一个object”间抉择,选择行为正确的那个,让编译器进行优化。决不让pointer或reference指向local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或reference指向一个local static对象。条款4已为“单线程环境中合理返回reference指向一个local static对象”提供一份设计实例。

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

成员变量声明为private的优点:

  1. 语法一致性:非public成员变量,可都用函数来访问,避免了要不要加圆括号的纠结。
  2. 细微划分之访问控制:成员变量可实现“不准访问”、“只读访问”、“读写访问”及“只写访问”。每个成员变量都需要一个getter函数和setter函数控制也是很少见。
  3. 封装:更改内部实现而不改接口,提供class作者充分的实现弹性(如果遵守条款31甚至不用重新编译),例如可使成员变量被读或被写时轻松通知其他对象、可验证class的约束条件及函数的前提及事后状态、可在多线程环境中执行同步控制等。可确保class的约束条件总获得维护。public指不封装也就不可改变。

protected成员同样适用于语法一致性细微划分之访问控制,就像对public一样适用。

但是**protected成员并不比public成员更具封装性。**当修改public成员,所有使用它的代码都要修改。当修改protected成员,所有使用它derived class的代码都要修改。因此,protected和public同样缺乏封装性。从封装的角度来看,只有两种访问权限:private(提供封装)和其他(不提供封装)。

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

以浏览器清理为例:

class WebBrowser {
public:
    ...
    void clearCache();
    void clearHistory();
    void removeCookies();
    void clearEverything();  //调用上面三个函数
};
//也可以通过non-member且non-friend函数实现
void clearBrowser (WebBrowser& wb)
{
    wb.clearCache();
    wb.clearHistory();
    wb.removeCookies();
}

比较member函数non-member non-friend函数哪种更好?

对于面向对象守则的要求,数据及操作数据的那些函数应该被捆绑在一起。这就意味着使用member函数可能是一个比较好的选择。然而这个建议是一种误解!

**面向对象守则要求数据应该尽可能地被封装。**越少的代码可以访问数据,那封装越好。如何测量“有多少代码可访问一块数据”?可粗略计算能访问该数据的函数数量,越多封装越差。

在member函数和non-member non-friend函数中,member函数还可以访问private数据及函数、enums、typedef等,而non-member+non-friend函数都不行,反而封装性更好,是更好的选择。

有两件事值得注意:

  1. 以上论述只适用于non-member+non-friend函数。public无封装性;private只有member函数和friend函数能访问,两者对封装的冲击也相同。从封装角度看,选择的关键在在member函数和non-member non-friend函数之间。(封装并不是唯一要考虑的,条款24解释了当考虑隐式类型转换,应在member和non-member之间抉择)。
  2. 一个class的non-member并不意味着不能是另一个class的member,包裹弹性较好

最自然的做法是将non-member non-friend函数放入同一个namespace中,机能拓展性较好。类似于C++标准库的组织方式,用于数个头文件都在命名空间std中,需要用到相关组件时才会添加相关头文件。这种做法降低了编译的依赖性(条款31)。

总结:宁可拿non-member non-friend函数替换member。这样就可以增加封装性、包裹弹性(packing flexibility)和机能扩展性。

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

导读部分建议不支持class的隐式类型转换,但有时不得不需要隐式转换。例外,当要设计一个有理数计算类,允许整数“隐式转换”为有理数颇为合理。

class Rational {
public:
    Rational(int numerator = 0,     //构造函数刻意不是explicit
             int denominator = 1);  //允许int-to-rational进行隐式转换
    int numerator() const;          //分子的访问函数
    int denominator() const;        //分母的访问函数
private:
    ...
};

按照面向对象的思想operator*()应该放入类中,但条例23指出这反而会对面向对象守则发生冲突。

这里假设用member函数的写法:

class Rational {
public:...;
    const Rational operator* (const Rational& rhs) const;//参考条款3、20和21
};
Rational oneEight(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEight;   //成功!
result = result * oneEight;             //成功!
//但是,这种写法不能实现混合计算
result=oneHalf*2;//成功!oneHalf.operator*(2);这里发生了隐式类型转换
result=2*oneHalf;//错误!2.operator*(oneHalf);*this无法进行隐式类型转换,只有参数列中参数才能转换

用non-member函数所有参数都可以进行隐式类型转换:

//构成了一个non-member函数
const Rational operator*(const Rational& lhs, const Rational& rhs)
{return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());}

Rational oneForth(1, 4);
Rational result;
result = oneForth * 2;      //成功了!
result = 2 * oneForth;      //成功了!!

考虑要不要作为friend函数?

本例是否定的,所有计算均可在public接口完成。member函数的反面是non-member函数,不是friend函数(如果可以避免friend函数尽量不用)。

当从Object-Oriented C++变为Templete C++,该条款需要重新考虑(见条款46)。

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

原本只是STL的一部分,后来成为异常安全性编程的核心(条款29),以及用来处理自我赋值可能性(见条款11),也因此复杂性极高。

复习模板特化:特化必须在同一命名空间下进行,可以特化类模板也可以特化函数模板,但类模板可以偏特化和全特化,而函数模板只能全特化。 偏特化定义了一个参数集合模板,需要进一步实例化才能确定签名。

swap在标准库的典型实现

只要类型T支持copying(copy构造和copy赋值)函数,可以抛出异常。

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

swap交换使用“pimpl手法”的对象

如要交换的对象,使用“pimpl手法”(pointer to implement即条款31)效率反而不高。

原本交换指针就能高效完成交换,swap函数会复制所指向的对象,例子如下:

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

class Widget {          //该class使用pimpl手法
public:
    Widget(const Widget& rhs);//复制Widget时,令它复制其WidgetImpl对象
    Widget& operator=(const Widget& rhs){//operator=的实现见条款10~12
        ...;
        *pImpl = *(rhs.pImpl);
        ...;
    }
    ...;
private:
    WidgetImpl* pImpl;    //指针,所指的对象就是内含Widget数据
};

解决方案:使用std::swap的Widget特化版本,考虑到pImpl是private成员,调用分两步。

  1. 先在Widget声明一个swap的public成员函数(不能抛出异常为异常安全性核心)

  2. 然后将std::swap全特化(函数禁止偏特化),令其调用该成员。

class Widget {
public:
    ...;
    void swap(Widget& other){
        using std::swap;//这个声明是非常必要的
        swap(pImpl, other.pImpl);//若要置换Widget,就置换其pImpl指针
    }
    ...;
};
namespace std {//修订后的std::swap特化版本
    tempate<> void swap<Widget>(Widget& a, Widget& b){
        a.swap(b);//如果要置换Widgets,调用其swap成员函数
    }
}

该做法与STL容器一致,同时提供public swap成员函数和std::swap特化版本(调用前者)。

swap交换class templete对象

如果Widget和WidgetImpl都是class template而非class,按照上面方案在第二步swap特化将遇到问题!

C++只允许class template偏特化,无法对function template偏特化(部分编译器会错误的接受该写法)

template<typename T> class WidgetImpl { ... };
template<typename T> class Widget { ... };
namespace std {
    template<typename T>
    void swap< Widget<T> > ( Widget<T>& a, Widget<T>& b){//偏特化错误!
    	a.swap(b);}
}

改写为全特化:

namespace std {
    template<typename T>    //std::swap的一个重载版本
    void swap(Widget<T>& a, //需要注意的是,swap后面没有" <...> "
              Widget<T>& b) //但是这样也是不合法的!
    { a.swap(b); }
}

仍然不合法原因:可全特化std内template,但不能加新template到std。这样做能通过编译,但是行为不明确不建议使用。

再次改写,既然std不能加template,换个namespace放template函数就好了:

namespace WidgetStuff {
    ...;                        //模板化的WidgetImpl等等
    template<typename T>        //和前面一样,内含swap成员函数
    class Widget { ... };
    ...;
    template<typename T>        //non-member swap函数
    void swap(Widget<T>& a,     //这里并不属于std命名空间
              Widget<T>& b){ a.swap(b); }
}

以上写法似乎在暗示WidgetStuff::swap就都能用,不需要写class全特化版std::swap,实际上还是有反例存在的,当用户乱加修饰符时:

std::swap(obj1,obj2);

这将强制使用std内的版本,这也是要再写一个**class全特化版本std::swap(非class template)**的含义。

swap在客户角度调用

从客户角度来看,希望为T型对象调用最佳swap版本,次之是std::swap()。

根据C++名称查找法则(argument-dependent loopup或Koenig lookup),首先将查找global作用域和T所在namespace任何T专属swap;然后用“实参取决之查找规则”挑选函数。

  • 如果T是Widget并位于命名空间WidgetStuff内,编译器就会使用**“实参取决的查找规则”(argument-dependent lookup)**找出WidgetStuff内的swap。
  • 如果已有T专属的std::swap存在(全特化),这个特化版本将优先使用(前提调用using std::swap;)。
  • 如果没有T专属的std::swap存在,编译器就会使用std内的swap(前提调用using std::swap;)。

总结

首先,如果swap的默认实现对我们的class或class template效率较好,不需要做其他的。

其次,如果swap的默认实现效率不足(因class或class template用了某种pimpl手法)则:

  1. 提供一个public swap成员函数,让它高效地置换对应类型的两个对象值。这个函数绝不能抛出异常!
  2. 在我们的class或template所在的命名空间内提供一个non-member swap,并令它调用上述swap成员函数。
  3. 如果我们正在编写一个class(非class template),为我们的class特化std::swap。并令它调用我们的swap成员函数。

最后,调用时记得写using std::swap;并直接使用swap,编译器会帮忙挑选最符合的。

其他事项:

  • 默认版swap却可以抛出异常,因为必然会用到copying函数(可抛出异常);

  • 成员版swap绝不能抛出异常,它是异常安全性的基石(见条款29)。

  • 自定义版swap(成员版)提供高效置换及不抛出异常。(高效置换通常指内置类型操作,内置类型必不抛出异常)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值