Effective C++条款18、19、20

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

从本条款开始,就进入到全书的第四部分:设计与声明。

程序员设计接口时应本的对用户负责的态度,“如果客户企图使用某个接口而却没有获得他所预期的行为,这个代码不应该通过编译;如果代码通过了编译,那它的作为就该是客户所想要的”。

举一个书上的例子:

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

这个类看上去好像没有问题,提供的构造函数接口简单直观,然而它却做了一个假设——用户都能够按月、日、年的顺序来传参。

但事实上,一定会有不少用户记错这个顺序,比如我们常用的顺序是年、月、日,所以有同学会下意识地这样调用:

Date d(2013, 5, 28);
也许还有同学随便输入,导致传参不符合实际,比如:

Date d(5, 32, 2013);
其实当你设计的程序需要假定用户都能按你想像来进行操作的话,这个程序就存在隐患。

一种好的解决方法,是假定用户输入的数据都是不可靠的,需要对输入进行严格的检测,这是防御式编程的思想,这对那个心怀不轨的用户来说是很好的处理方法。但对于一般的用户,不合法的输入只是缺乏正确的引导。

如何去引导用户,如何让用户动最少的脑筋却能最佳地使用接口,是我们接下来要讨论的。

一种好的引导方式,是让用户在传参的时候,知道自己传的是什么,之前用是一个int,这显然不能提醒用户。那就创建相应的类,像这样:

class Month
{
private:
    int m_month;
public:
    explicit Month(int month): m_month(month){}
};

class Day
{
private:
    int m_day;
public:
    explicit Day(int day): m_day(day){}
};

class Year
{
private:
    int m_year;
public:
    explicit Year(int year): m_year(year){}
};

class Date
{
private:
    Year m_year;
    Month m_month;
    Day m_day;

public:
    Date(Year year, Month month, Day day): m_year(year), m_month(month), m_day(day){}

};

int main()
{
    Date date(Year(2018), Month(3), Day(28));
}

注意Year、Month和Day类中构造函数前有explicit关键字,也就是不允许隐式构造,诸如Date date(2018, 3, 28)等会报以下的错误:
允许的写法就是像main()函数中所示,为年、月、日提供了类封装的好处还不仅仅有这些,当用户输入一个非法数值后,可以在类中进行判断。
比如我们可以进一步对Month类的构造函数进行扩充,像这样:

explicit Month(int month): m_month(month){assert(m_month >= 1 && m_month <= 12);}
这样就限制了输入Month数据的合法性。

当用户试图传入一个Month(15)的时候,断言失败就会报错。

还有一种好的设计,就是在Month类中给出月份的枚举类型,这样用户可以更直观地使用

class Month
{
private:
    int m_month;
public:
    explicit Month(int month): m_month(month){assert(m_month >= 1 && m_month <= 12);}
    enum 
    {
        Jan = 1,
        Feb,
        Mar,
        Apr,
        May,
        Jun,
        July,
        Aus,
        Sep,
        Oct,
        Nov,
        Dec
    };
    int GetMonth() const
    {
        return m_month;
    };
};

然后在main函数中可以这样写:

 int main()
 {
     Date date(Year(2013), Month(Month::May), Day(28));
 }

总之就是时刻提醒用户知道自己传的参数是什么。
预防用户不正确使用的另一个办法是,让编译器对不正确的行为予以阻止,常见的方法是加上const,比如初学者常常这样写:
if(a = b * c){…}
很明显,初学者想表达的意思是比较两者是否相等,但代码却变成了赋值。这时如果在运算符重载时用const作为返回值,像这样:
const Object operator* (const Object& a, const Object& b);
注意这里的返回值是const,这时编译器就会识别出赋值运算符的不恰当了。

书上还提到很重要的一点,就是“尽量令你的自定义类型的行为与内置类型行为一致”,举个夸张的例子就是,不要重载乘号运算符,但里面做的却是加法。自定义类型同时也要形成统一的风格,比如长度,不要有的类型用size表示,有的类型用length表示,这会使得哪怕是一个程序老手也会犯糊涂。虽然有些IDE插件能够自动去寻找相应的方法名,但“不一致性对开发人员造成的心理和精神上的摩擦与争执,没有任何一个IDE可以完全抹除”。
最后再谈谈前几节说到的智能指针,“任何接口如果要求客户必须记得做某些事情,就有着不正使使用的倾向,因为客户可能会忘记做那件事”。所以像:
auto_ptr<Investment> createInvestment();shared_ptr<Investment> createInvestment();
使用shared_ptr会更好一些,因为它允许存在多个副本,不会在传递过程中改变原有资源管理者所持的资源,且支持自定义的删除器。书上说,自定义删除器可以有效解决”cross-DLL problem”,这个问题发生于在不同的DLL中出生(new)和删除(delete)的情况(对象生命周期横跨两个DLL,但在第二个DLL中结束生命的时候却希望调用的是第一个DLL的析构函数),自定义删除器则会在删除时仍然调用诞生时所在的那个DLL的析构函数。

当然,使用智能指针也是需要代价的,它比原始指针大(Boost库中实现的shared_ptr体积是原始指针的2倍)且慢,而且使用辅助动态内存。任何事物都具有两面性,权衡一下就会发现,智能指针能避免的资源泄露问题(好的接口),相较于它的空间和时间代价而言,都是值得的。

最后总结一下:

  1. 好的接口容易被正确使用,不容易被误用;
  2. 促进正确使用的办法包括接口的一致性,以及与内置类型的行为兼容;
  3. 阻止误用的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任;
  4. 多多使用shared_ptr来代替原始指针

条款十九:设计class犹如设计type

这里初看到”class”和”type”,感觉他们是说的是同一样东西,但仔细读了一下,两者在文中还是有区别的。class侧重于自定义的类,而type侧重于系统预定义的类(像int、double、string、vector)。设计好的class,使之像设计type一样,就是说要使自己设计的类像系统预定义的类那样好用,这对设计思想提出了较高的要求。

要设计高效的class,需要回答以下的问题:

1、对象如何去创建和销毁?
这是构造函数和析构函数的问题,当然如果想要有堆分配方式,还需要重载new、new[]、delete和delete[]运算符;

2、对象初始化与对象的赋值应该有什么样的差别?
初始化是构造函数和拷贝构造函数的事(这时候对象还没有产生),但对象的赋值却不同,因为此时等号左边的对象已经存在了。

3、新type如果被pass-by-value意味着什么?
会调用相应的拷贝构造函数,要注意是否需要一个深拷贝。

4、什么是新type的合法值?
要对对象的合法值进行限定,通常在构造函数中就对成员变量的范围给出限定,警惕不安全的输入值!

5、你的新type需要配合某个继承图系吗?
就是判断自己设计的class是否需要继承或被继承,是基类还是派生类。如果是基类的话,要注意是否允许这个基类生成对象(是否需要利用纯虚函数设计成抽象类),以及要将析构函数前面加上virtual。

6、新type需要什么样的转换?
主要是针对隐式转换,operator OtherType() const,但通常情况下隐式转换也意味着隐患,所以设计时要谨慎。另外,构造函数中也要当心,如果不想让隐式构造发生,就要在前面加上explicit关键字。举个例子:

class A
{
private:
         int a;
public:
         A(int b):a(b){}
};
void fun(A obj);

若调用fun(3),则编译器也能接受,因为编译器自动作了fun(A(3))的处理,这就是隐式构造。而如果用户自己写fun(A(3)),这是显式构造。当A的构造函数前有explicit时,fun(3)的调用将通不过编译器。通常情况下,隐式转换是安全的。
7、什么样的操作符和函数对此新type而言是合理的?
就是设计什么样的成员函数,以及重载哪些运算符。

8、 什么样的标准函数应该驳回?
是说哪些函数对外公开,哪些函数对内使用,这就是private,public和protected的功能啦,protected只有在有继承关系的类中使用才能发挥它真正的力量,普通的类用private和public就足够了。

9、谁该取用新type成员?
与上面一条类似,就是类的封装性问题,一些而言,成员变量都应该是private的,而在public函数里面提供对这些成员变量的访问get和set函数。如果需要的话,可以使用友元,但友元也要慎用,因为有些编译器对之支持的不是很好,另一方面,友元成为破坏封装性的特例,会带来安全的隐患。

10、什么是新type的未声明接口?

这个不大明白意思,先放在这里……

11、 你的新type有多么一般化?

这牵涉到泛型编程了,就是模板的概念,这是C++比C要方便的多的多的地方——它将需要的类型交给编译器自动生成。

12、你真的需要一个新type吗?

其实我觉得应该把这个问题放在第一个,因为设计最初首先要问的就是这个问题,如果都没有必要设计了,还需要回答前十一个问题吗?

最后总结一下:

class的设计就是type的设计,在设计之前先要把上述12个问题过一遍,这样可以有把握设计一个成功的类。

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

默认情况下,C++的函数调用是传值调用,也就是说形参拷贝实参的内容。举书上的例子:

class Person
{
private:
    string name;
    string address;
public:
    Person(){}
    virtual ~Person(){}
};

class Student: public Person
{
private:
    string schoolName;
    string schoolAddress;
public:
    Student(){}
    ~Student(){}
};

假设有个函数接口:

void PrintStudent(Student ss);
那么如果这样调用:

Student s;
PrintStudent(s);

形参ss就会把实参s完完整整地复制一遍,这其中先后调用了Person的构造函数和Student的构造函数,而每个类中又包含两个string对象,所以总共会调用6次构造函数(4个string构造、Person构造和Student构造,注意这里的构造包括构造函数和拷贝构造函数),相应也会调用6次析构函数(注意当有派生关系存在时,基类的析构函数应该声明成虚的,这个在条款七中提到过)。

但事实上,在PrintStudent里面其实只是打印Student的信息,并没有对这个对象做出修改,所以完整的拷贝显然既浪费时间,又浪费空间。如果你习惯使用C,那么会想到把形参作成指针,这样拷贝只是拷贝一个指针的内容而已,像下面这样:
void PrintStudent(Student* ss);
调用的时候只要:
PrintStudent(&s);
对于C++而言,还可以使用引用,像这样:

void PrintStudent(Student& ss);
调用的时候只要:
PrintStudent(s);

就可以了,引用在原来代码的基础上只是多加了一个&,看上去比指针更自然,这算是C++的一个优点吧,在函数体中也可以避免对指针的操作,从而减少出错的可能性。

如果看汇编代码,就可以发现,引用的底层还是用指针实现的,所以两者看上去不同,但底层的原理却是相同的,就是对一个对象的地址进行操作,因此传引用的本质是传对象的地址。

传对象的地址是对原有对象直接操作,而不是它复本,所以这其中没有任何构造函数发生,是一种非常好的传参方法。但这种方法也有一定的“危险”性,与指针的功能相同,程序员可以在这个函数内部修改Student的内容,这样会出现一种现象,在主函数中Student是学生A,但通过这个函数之后,却变成了学生B。除非你刻意这样做,但像PrintStudent这种打印函数,是不应该修改原始内容的。

所以pass-by-reference还是不够的,用pass-by-reference-to-const才更“安全”,像这void PrintStudent(const Student& ss);使程序员在函数实现中不小心修改了Student对象的内容,编译器也会把这个错误指出来。

除了上面所言,传引用调用可以减少构造函数的调用外,还可以防止对象切割,对象切割发生在存在派生关系的类中,比如:

void PrintStudent(Person ss);
调用时:

Studnet s;
PrintSudent(s);

这个时候就发生了对象切割,因为PrintStudnet的函数形参是基类,它没有派生类多出来的结构,所以当传值调用时,ss只能保存基类的内容,对于多出的派生类的内容作丢弃处理。

但如果换成传引用调用,像这样:

void PrintStudnet(Person &ss);
或者:

void PrintStudnet(Person *ss);
因为本质传的是地址,所以不存在对象的切割(地址类型Person&,可以理解为引用类型或者指针类型,存放的都是对象的地址,对于32位机而言,是4字节的整数,Person型的地址只是告诉编译器应该把这段地址的内容解释成什么)。Person&只是告诉编译器它保存的地址对应的内容是一个Person类型的,它会优先把这个内容往Person上去套,但如果里面有虚函数,即使用了virtual关键字,那么编译的时候就会往类中安插一个虚指针,这个虚指针的指向将在运行时决定,这就是多态的机制了,它会调用实际传入的那个对象的虚函数,而不是基类对象的虚函数。

如果不加引用或指针,那么形参就会复制实参,但形参是基类的,它没有实参多出来的那部分,所以它就不能复制了,只能丢弃了;但如果加了引用或指针,那么无论是什么类型的,都是保存实参的地址,地址是个好东西啊,它不会发生切割,不会丢弃。

总结一下pass-by-reference-to-const的优点:一是可以节省资源复制的时间和空间,二是可以避免切割,触发多态,在运行时决定调用谁的虚函数。

那么是不是pass-by-reference-to-const一定好呢?答案显示是否定的。一是任何事物都不是绝对的,二是C++默认是pass-by-value,自然有它的道理。

可以这样解释,因为pass-by-reference传的是地址,在32位机上,就是4字节,但如果参数是一个字节的char或者bool,那么这时候用char&或bool&来传引用就不划来了。一般地,当参数是基本类型时,还是用pass-by-value的效率更高,这个忠告也适用于STL的迭代器和函数对象。

对于复杂的类型,还是建议pass-by-reference-to-const,即使这个类很小。但事实上,一个类看似很小,比如只有一个指针,但这个指针也许指向的是一个庞然大物,在深拷贝的时候,所花费的代价还是很可观的。另一方面,谁也不能保证类的内容总是一成不变的,很有可能需要扩充,这样原本看似很小的类,过一段时间就成了“胖子”了。

最后总结一下:

  1. 尽量以pass-by-reference-to-const来代替pass-by-value,前者通常比较高效,并可以避免切割

  2. 以上规则并不适用于内置类型、STL迭代器和函数对象,对它们而言,pass-by-value是更好的选择

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值