Effective C++读书笔记(12)

来源:互联网 发布:花生壳内网穿透80端口 编辑:程序博客网 时间:2024/06/05 02:49

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

Treat class design as type design

在 C++ 中,就像其它面向对象编程语言,可以通过定义一个新的类来定义一个新的类型。作为一个C++开发者,你的大量时间就这样花费在扩张你的类型系统。这意味着你不仅仅是一个类的设计者,而且是一个类型的设计者。重载函数和运算符,控制内存分配和回收,定义对象的初始化和终结过程——这些全在你的掌控之中。因此你应该在类设计中倾注大量心血,就如语言设计者在语言内置类型设计中所倾注的大量心血。

设计良好的类是有挑战性的,因为设计良好的类型是有挑战性的。良好的类型拥有简单自然的语法,符合直觉的语义,以及一个或更多高效的实现。那么,如何才能设计高效的类呢?首先,你必须理解你所面对的问题。实际上每一个类都需要你面对下面这些问题,其答案通常就导向你的设计规范:

·    新类型的对象应该如何创建和销毁?如何做这些将影响到你的类的构造函数和析构函数,以及内存分配和回收函数(operator new,operator new[],operator delete,和 operator delete[])的设计,除非你不写它们。

·    对象的初始化和对象的赋值应该有什么不同?这个问题的答案决定了你的构造函数和赋值运算符的行为以及它们之间的不同。

·    值传递(passed by value)对于新类型的对象意味着什么?拷贝构造函数定义了一个新类型的传值如何实现。

·    新类型的合法值是什么?通常,对于一个类的数据成员来说,仅有某些值的组合是合法的。那些数值集决定了你的类必须维护的约束条件。也决定了必须在成员函数内部进行的错误检查,特别是构造函数,赋值运算符,以及"setter"函数。它可能也会影响函数抛出的异常,以及(极少被使用的)函数异常明细(exceptionspecification)。

·    你的新类型需要配合某个继承图系中?如果你从已经存在的类继承,你就受到那些类的设计约束,特别受到它们的函数是virtual还是non-virtual的影响。如果你希望允许其他类继承你的类,将影响到你是否将函数声明为virtual,特别是你的析构函数。

·    你的新类型允许哪种类型转换?你的类型身处其它类型的海洋中,所以是否要在你的类型和其它类型之间有一些转换?如果你希望允许 T1 类型的对象隐式转型为 T2 类型的对象,你就要么在T1类中写一个类型转换函数(如operator T2),要么在 T2 类中写一个non-explicit-one argument构造函数。如果你只允许显示构造函数存在,就得写出专门负责执行转换的函数,且不得为类型转换操作符或non-explicit-oneargument构造函数。

·    对于新类型哪些运算符和函数是合理的?这个问题的答案决定你为你的类声明哪些函数。其中一些是成员函数,另一些不是。

·    哪些标准函数应该驳回?你需要将那些都声明为 private。

·    你的新类型中哪些成员可以被访问?这个问题的可以帮助你决定哪些成员是 public,哪些是 protected,以及哪些是 private。它也可以帮助你决定哪些类 和/或 函数应该是友元,以及一个类嵌套在另一个类内部是否有意义。

·    什么是新类型的未声明接口 "undeclaredinterface"?它对于效率,异常安全,以及资源使用(例如,多任务锁定和动态内存)提供哪种保证?你在这些领域提供的保证将为你的类的实现代码加上相应的约束条件。

·    你的新类型有多大程度的通用性?也许你并非真的要定义一个新的类型,也许你要定义一整个类型家族。如果是这样,你就不该定义一个新的类,而应该定义一个新的类模板。

·    一个新的类型真的是你所需要的吗?是否你可以仅仅定义一个新的继承类,以便让你可以为一个已有的类增加一些功能,也许通过简单地定义一个或更多非成员函数或模板能更好地达成你的目标。

·    类设计就是类型设计。定义高效的类是有挑战性的。在C++中用户自定义类生成的类型最好可以和内建类型一样好。

 

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

Prefer pass-by-reference-to-const to pass-by-value

缺省情况下,C++以传值方式将对象传入或传出函数(这是一个从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;
};

现在,考虑以下代码,在此我们调用函数validateStudent,它得到一个Student实参(以传值方式),并返回它是否有效:

bool validateStudent(Student s); // 函数以by value方式接受Student

Student plato;

bool platoIsOK = validateStudent(plato); //call the function

很明显,Student的拷贝构造函数被调用,用plato来初始化参数s。同样明显的是,当 validateStudent返回时,s就会被销毁。所以这个函数的参数传递代价是一次Student的拷贝构造函数的调用和一次Student的析构函数的调用。

但这还不是全部。Student对象内部包含两个string对象,Student对象还要从一个 Person对象继承,Person对象内部又包含两个额外的string对象。最终,以传值方式传递一个Student对象的后果就是引起一次Student的拷贝构造函数的调用,一次Person的拷贝构造函数的调用,以及四次string的拷贝构造函数调用。当Student对象的拷贝被销毁时,每一个构造函数的调用都对应一个析构函数的调用,所以以传值方式传递一个Student的全部代价是六个构造函数和六个析构函数!

这是正确和值得的行为。毕竟,你希望全部对象都得到可靠的初始化和销毁。尽管如此,pass by reference-to-const方式会更好:

bool validateStudent(const Student& s);

这样做非常有效:没有任何构造函数和析构函数被调用,因为没有新的对象被构造。修改后参数声明中的const是非常重要的,原先validateStudent以by-value方式接受一个Student参数,所以调用者知道函数绝不会对它们传入的Student做任何改变,validateStudent只能改变它的复件。现在Student以引用方式传递,同时将它声明为const是必要的,否则调用者必然担心validateStudent改变了它们传入的Student。

以传引用方式传递参数还可以避免切断问题(slicing problem)。当一个派生类对象作为一个基类对象被传递(传值方式),基类的拷贝构造函数被调用,而那些使得对象行为像一个派生类对象的特化性质被“切断”了,只剩下一个纯粹的基类对象例如,假设你在一组实现一个图形窗口系统的类上工作:

class Window {
public:
...
std::string name() const; // 返回窗口名称
virtual void display() const; // 显示窗口及其内容
};

class WindowWithScrollBars: public Window {
public:
...
virtual void display() const;
};

所有Window对象都有一个名字(name函数),而且所有的窗口都可以显示(display函数)。display为 virtual的事实清楚地告诉你:基类的Window对象的显示方法有可能不同于专门的WindowWithScrollBars对象的显示方法。现在,假设你想写一个函数打印出一个窗口的名字,并随后显示这个窗口。以下是错误示范:

void printNameAndDisplay(Window w) //incorrect! 参数可能被切割
{
std::cout << w.name();
w.display();
}

考虑当你用一个 WindowWithScrollBars 对象调用这个函数时会发生什么:

WindowWithScrollBars wwsb;

printNameAndDisplay(wwsb);

参数w将被作为一个Window对象构造——它是被传值的,而且使wwsb表现得像一个 WindowWithScrollBars对象的特殊信息都被切断了。在printNameAndDisplay中,全然不顾传递给函数的那个对象的类型,w将始终表现得像一个Window 类的对象(因为其类型是Window)。因此在printNameAndDisplay中调用display将总是调用 Window::display,绝不会是WindowWithScrollBars::display。绕过切断问题的方法就是以passby reference-to-const方式传递w:

void printNameAndDisplay(const Window& w)
{ // 参数不会被切割
std::cout << w.name();
w.display();
}

现在传进来的窗口是什么类型,w就表现出那种类型。用指针实现引用是非常典型的做法,所以pass by reference实际上通常意味着传递一个指针。由此可以得出结论,如果你有一个内置类型对象(一个int),以传值方式传递它常常比传引用方式更高效;同样的建议也适用于 STL 中的迭代器和函数对象。

一个对象小,并不意味着调用它的拷贝构造函数就是廉价的。很多对象(包括大多数STL容器)内含的东西只比一个指针多一些,但是拷贝这样的对象必须同时拷贝它们指向的每一样东西,那将非常昂贵。即使当小对象有一个廉价的拷贝构造函数,也会存在性能问题。一些编译器对内置类型和用户自定义类型并不一视同仁,即使他们有同样的底层表示。例如,一些编译器拒绝将仅由一个double组成的对象放入一个寄存器中,即使通常它们非常愿意将一个纯粹的double 放入那里。当这种事发生,你以传引用方式传递这样的对象更好一些,因为编译器理所当然会将一个指针(引用的实现)放入寄存器。

小的用户定义类型不一定是传值的上等候选者的另一个原因是:作为用户定义类型,它的大小常常变化。通常情况下,你能合理地假设传值廉价的类型仅有内置类型及STL中的迭代器和函数对象。对其他任何类型,请尽量以pass-by-reference-to-const替换pass-by-value。

·    尽量以pass-by-reference-to-const替换pass-by-value。前者更高效且可以避免切断问题。

·    这条规则并不适用于内建类型及STL中的迭代器和函数对象类型。对于它们,pass-by-value通常更合适。

原创粉丝点击