《Effective C++》第4章 设计与声明(1)-读书笔记

时间:2023-03-09 04:39:16
《Effective C++》第4章 设计与声明(1)-读书笔记

章节回顾:

《Effective C++》第1章 让自己习惯C++-读书笔记

《Effective C++》第2章 构造/析构/赋值运算(1)-读书笔记

《Effective C++》第2章 构造/析构/赋值运算(2)-读书笔记

《Effective C++》第3章 资源管理(1)-读书笔记

《Effective C++》第3章 资源管理(2)-读书笔记

《Effective C++》第4章 设计与声明(1)-读书笔记

《Effective C++》第4章 设计与声明(2)-读书笔记

《Effective C++》第5章 实现-读书笔记

《Effective C++》第8章 定制new和delete-读书笔记


所谓软件设计,是“令软件做出你希望它做的事情”的步骤和做法,通常以颇为一般性的构想开始,最终演变成十足的细节,以允许特殊接口的开发。

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

很显然的道理:如果客户使用某个接口却没有得到他预期的行为,这个代码就不该通过编译;如果代码通过了编译,就应该是客户预期的行为。想要开发一个“容易被正确使用,不容易被误用”的接口,首先要考虑客户会出现什么样的错误。

举例说明,有一个与日期有关的class:我们只看它的构造函数。

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

它的客户可能这样使用:

Date d(, , );            //月和日位置放反了

Date d(, , );            //非法的日

许多客户端错误可以因为导入新类型而解决,我们看下面的修改方案:

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
{
Date(const Month& m, const Day& d, const Year& y);
};

如果客户像下面这样使用:(看代码中注释)

Date d(, , );                        //错误,explicit不提供隐式转换。
Date d(Day(), Month(), Year()); //错误,类型不对应。
Date d(Month(), Day(), Year()); //很好,不错的。

好的,当然这不能解决非法的数字输入。下面接着看:

一年只有12月份,所以可以用enum表现月份,但enum因为可以被用来当作int,所以不具备类型安全性。比较好的做法是预先定义所有有效的月份。

class Month
{
public:
static Month Jan() { return Month(); }
static Month Feb() { return Month(); }
...
static Month Dec() { return Month(); }
private:
explicit Month(int m); //禁止产生新的月份
};

当客户这样使用时:(看代码中注释)

Date d(Month::Mar(), Day(), Year());        //好的,真棒

Date d(Month(), Day(), Year());            //错误,构造函数是private的

好了,下面介绍另一个准则:“让types容易被正确使用,不容易被误用”,它的原则是:尽量让你的types的行为与内置types一致。因为客户一般知道像int这样的内置类型有什么行为。

避免与内置类型不兼容的真正理由是为了提供行为一致的接口。可以想象:没有其他性质比“一致性”更能导致接口被容易使用。

任何接口如果要求客户必须记得做某些事情,就是有着不正确使用的倾向,因为客户很可能会忘记做那件事。举工厂函数那个例子:

Investment* createInvestment();                //返回一个动态分配的对象指针

客户可能会做错两件事:忘记删除指针,或者删除两次以上指针

如果你还记得使用智能指针管理资源的话,实际上最佳的接口是这样的:

std::tr1::shared_ptr<Investment> createInvestment();            //返回一个智能指针

这个函数强迫客户必须将函数的返回值保存在智能指针内。

请记住:促进正确使用:包括接口一致性以及与内置类型的行为兼容。阻止误用:建立新类型、限制类型上的操作、束缚对象值,以及消除客户的资源管理责任。


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

当你定义了一个新的class,也就定义了一个type,所以你应该像语言设计者设计内置类型时一样的严谨来考虑class的设计。不妨试着回答下面几个问题:

(1)新type的对象该如何被创建和销毁?

(2)对象的初始化和赋值该有什么区别?

(3)新type对象如果被“值传递”会发生什么?

拷贝构造函数定义了一个type的“值传递”发生了什么。

(4)什么是新type的合法值?

(5)你的新type需要配合某个继承体系吗?

如果你继承自某些class,肯定受到那些class的设计束缚,比如virtual函数。如果你允许其他class继承你,那会影响你函数的声明,例如,virtual析构函数。

(6)你的新type需要什么样的转换?

你的类对象转换为其他对象或者其他类型对象隐式或显式转换为你的对象。

(7)什么样的操作符和函数对此新type是合理的?

(8)什么样的标准函数应该驳回?

(9)谁该取用新type成员?

(10)什么是新type的“未声明接口”?

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

你是定义一个class还是一个新的class template。

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

这些问题都是不好回答的,我也只是对某些内容知道而已,但对于为何如此考虑也是一知半解。


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

相信很多人都知道这个条款。当你以传值方式传递一个对象至函数时,函数获得的是实参的一个副本,函数的返回值也是一个副本。这两个副本由对象的copy构造函数产生。

下面举个例子:

class Person
{
public:
Person();
virtual ~Person();
...
private:
std::string name;
std::string address;
}; class Student : public Person
{
public:
Student();
virtual ~Student();
...
private:
std::string schoolName;
std::string schoolAddress;
};

有下面函数声明和调用:

bool validateStudent(Student s);            //声明一个传值调用函数
Student plato;
bool platoIsOK = validateStudent(plato); //请忽略函数名和变量名的含义

这个函数调用的消耗是这样的:一次Student拷贝构造函数,一次Person拷贝构造函数,四次string拷贝构造函数以及对应的六次析构函数。

但当你这样调用时:(当然不能直接调用,函数声明也得修改一下)

bool validateStudent(const Student& s);

效率更高:没有任何构造函数和析构函数调用,因为没有任何新对象被创建。(当然,函数内的情况我们是不知道的)

另外by reference传递参数还可以避免slicing问题。当一个派生类对象作为一个基类对象(传值)被传入时,基类的copy构造函数被调用,仅仅留下了基类部分。举例说明:

class Window
{
public:
std::string name() const;
virtual void display() const;
}; class WindowWithScrollBars: public Window
{
public:
virtual void display() const;
};

当你想打印这两个对象时,编写了如下函数:

void printNameAndDisplay(Window w)        //传值方式
{
std::cout << w.name();
w.display();
}

你是这样调用的:

WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);

不用怀疑,调用的一定是Window::name和Window::display。尽管display()是个virtual函数。

解决slicing问题的方法是,以by reference-to-const方式传递对象。

void printNameAndDisplay(const Window& w)        //好的,引用方式
{
std::cout << w.name();
w.display();
}

传入对象是什么,就表现什么行为。

通过C++编译器底层,会发现引用往往以指针实现,因此pass-by-value往往意味着真正传递的是指针。如果你有个对象属于内置类型,pass-by-value往往比by reference效率高。此外,STL的迭代器和函数对象,都被设计为pass-by-value。

还有一种观点认为:所有小型types都应该pass-by-value,包括用户自定义类型。这种观点是错误的:对象小并不意味copy构造不昂贵。例如,许多STL容器只包含比指针多一些,但复制这些对象却需要承担复制那些指针所指的每一样东西。

请记住:

(1)尽量以pass-by-reference-to-const取代pass-by-value,一般情况下它更高效而且可以避免slicing问题。

(2)内置类型,以及STL迭代器和函数对象,pass-by-value往往比较适当。


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

条款20告诉我们传对象和返回对象存在效率问题,这可能使我们盲目追求pass-by-reference。但可能有一个致命错误:传递reference指向其实并不存在的对象。举例说明:

class Rational
{
public:
Rational(int numerator = , int denominator = );
private:
int n, d; friend const Rational operator*(const Rational& lhs, const Rational& rhs); //返回const对象
};

首先要说明operator*返回对象的版本(上面的)是可取的。

假设你出于效率考虑返回一个reference,你一定要考虑这个reference只是个别名,它另外的一个名字是什么。(真正所指向的东西)

当你写下这样的函数时:

const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result; //注意,这里返回的是引用
}

reference所指向的局部对象已经被析构了,你对result做的任何操作都会出错。任何函数如果返回一个reference指向某个local对象,结果都会很可悲。(指针是同样的道理)

于是你修改了版本,为了让reference不指向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; //注意,这里返回的是引用
}

这个函数还是要付出构造函数调用的代价。核心是:由谁来调用delete。像下面这种操作,恐怕无法调用delete了。

Rational w, x, y, z;
w = x * y * z;

必须两次delete,但是调用者无法取得operator*返回的reference背后的指针。绝对会导致内存泄露。

你可能还会修改出下面的版本:

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

我知道你想避免析构带来的问题,姑且不说static造成的多线程安全问题。仅仅考虑下面的代码:

bool operator==(const Rational& lhs, const Rational& rhs);
Rational a, b, c, d;
if ((a * b) == (c * d))
{ }
else
{ }

if条件语句肯定是true。原因如下:operator==被调用前先调用两个operator*,这两个operator*确实都改变了static对象值,但两者最终都返回了reference,operator==运算肯定作用在同一个static对象上了。

请记住:

绝不要返回pointer或reference指向一个local stack对象,或返回一个reference指向heap-allocated对象,或返回一个pointer或reference指向一个local static对象而有可能需要多个这样的对象。