Effective C++第四章笔记

Effective C++第四章笔记,第1张

文章目录

  • 四、设计与声明

    • 条款18:让接口容易被正确使用,不易被误用
      • 防止误用
      • 促进正确使用
      • cross-DLL problem(不同模块间申请和释放内存)
    • 条款19:设计class犹如设计type
    • 条款20:宁以pass-by-reference-to-const替换pass-by-value
    • 条款21:必须返回对象时,别妄想返回其reference
    • 条款22:将成员变量声明为private
    • 条款23:宁以non-member non-friend函数替换member函数
    • 条款24:若所有参数皆需类型转换,请为此采用non-member函数
    • 条款25:考虑写出一个不抛异常的swap函数
      • swap在标准库的典型实现
      • swap交换使用“pimpl手法”的对象
      • swap交换class templete对象
      • swap在客户角度调用
      • 总结


四、设计与声明 条款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 createInvestment()
{
    //shared_ptr使用者可能直接使用delete释放资源,这与智能指针的思路相违背。


//可在函数内就指定资源释放函数getRidOfInvestment std::tr1::shared_ptr retVal(static_cast(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作者充分的实现d性(如果遵守条款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,包裹d性较好


最自然的做法是将non-member non-friend函数放入同一个namespace中,机能拓展性较好。


类似于C++标准库的组织方式,用于数个头文件都在命名空间std中,需要用到相关组件时才会添加相关头文件。


这种做法降低了编译的依赖性(条款31)。


总结:宁可拿non-member non-friend函数替换member。


这样就可以增加封装性、包裹d性(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
    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 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& 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 class WidgetImpl { ... };
template class Widget { ... };
namespace std {
    template
    void swap< Widget > ( Widget& a, Widget& b){//偏特化错误!
    	a.swap(b);}
}

改写为全特化:

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

仍然不合法原因:可全特化std内template,但不能加新template到std。


这样做能通过编译,但是行为不明确不建议使用。


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

namespace WidgetStuff {
    ...;                        //模板化的WidgetImpl等等
    template        //和前面一样,内含swap成员函数
    class Widget { ... };
    ...;
    template        //non-member swap函数
    void swap(Widget& a,     //这里并不属于std命名空间
              Widget& 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(成员版)提供高效置换及不抛出异常。


    (高效置换通常指内置类型 *** 作,内置类型必不抛出异常)。


欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/langs/569669.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-09
下一篇 2022-04-09

发表评论

登录后才能评论

评论列表(0条)

保存