4 设计与声明
条款18:让接口容易被正确使用,不易被误用
1.首先必须考虑客户可能做出什么样的错误。
2.防范“ 不值得拥有的代码 ” 上, 类型系统(type system )是你的主要同盟国。
3.预防客户错误的另一个办法,限制类型内什么事可以做,什么事不能做
4.小结:
<1> 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质
<2>“促进正确使用” 的办法包括接口的一致性,以及与内置类型的行为兼容
<3>“阻止误用” 的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任
<4> tr1::shared_ptr 支持定制型删除器( custom deleter )。这可防范DLL 问题,可被用来自动解除 互斥锁(mutexes)等等
条款19:设计class犹如设计type
1.新type的对象应该如何被创建和销毁?影响你的class构造函数和析构函数以及内存分配函数和释放函数的设计。
2.对象的初始化和对象的赋值该有什么样的差别?决定了你的构造函数和赋值操作符的行为,以及其间的差异。
3.新type的对象如果被 passed by value(以值传递),意味着什么? copy 构造函数用来定义一个 type 的 pass-by-value该如何实现
4.什么是新 type 的”合法值” ?决定了你的成员函数必须进行错误检查工作。
5.你的新 type 需要配合某个继承图系吗?受到继承和被继承classes的影响,特别是受到函数为 virtual 或 non-virtual 的影响。
6.你的新type需要什么样的转换?是否允许隐式转换,是否允许explicit构造函数
7.什么样的操作符和函数对此新 type 而言是合理的?决定你将为你的class声明哪些函数,其中某些该是 member函数,某些则不是。
8.什么样的标准函数应该驳回?那些正是你必须声明private的东西。
9.谁该取用新 type 的成员?决定哪个成员是public,哪个是protected,哪个是private。这也帮助你决定哪些类或函数应该是 friend,以及将它们嵌套与另一个之内是否合法。
10.什么是新 type 的 “未声明接口” (undeclared interface)?它对效率、异常安全性以及资源运用提供什么样的保证,而且在这些方面提供的保证将为你的类实现代码加上相应的约束条件。
11.你的新type有多么一般化?如果建立的并非一个新的type,而是一个”type家族“,那就不该定义一个新类,而是定义一个新的 class template。
12.你真的需要一个新 type 吗?如果只是单纯定义新的派生类,以便为既有的类增加机能,那么说不定单纯定义一个 non-member 或者 templates,更能达到目标。
13.小结:Class 的设计就是 type 的设计,考虑以上所有问题以设计高效的class
条款20:宁以 pass-by-reference-to-const 替换 pass-by-value
1.缺省情况下C++ 以 by value方式传递对象至函数,除非你另外指定,否则函数参数都是以实际实参的副本为初值,而调用端所获得的亦是函数返回值的一个复件。这些复件系由对象的copy构造函数产出,这将使得 pass-by-value 成为费时的操作。
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); // 函数以 by value 方式接受参数
Student plato;
bool platoIsOK = validateStudent(plato); // 调用函数
以上调用,当Student 调用一次构造函数,Person 调用一次构造函数,还有Student 和Person 各两个 string 被构造,这样就是6个构造函数,析构函数同理,所以总的加起来的消耗: 6个构造函数+6个析构函数。
2.用pass-by-reference-to-const的方法就回避那些无谓的消耗。
优点①: 效率高,同时加上const作用——保证传入参数不被修改。
优点②: 避免 slicing(对象切割)问题(当一个派生类对象以by-value方式传递,就被看做基类对象,调用基类对象的构造函数,于是派生类对象的特征将被切割掉)
bool validateStudent(Person s); //不正确,参数可能被切割
Student plato;
bool platoIsOK = validateStudent(plato); // 传入的是Student类型,其会被构造成为一个Person对象,切除自身的特化信息
3.如果对象是内置类型,或STL的迭代器和函数对象,那就用 pass by value,它的效率会高一些。
4.小结:
<1>尽量以 pass-by-reference-to-const 替换 pass-by-value。前者通常比较高效,并可避免切割问题。
<2>对内置类型以及STL的迭代器和函数对象而言, pass-by-value 比较适当。
条款21:必须返回对象时,别妄想返回其reference
1.reference 只是个名称,代表某个既有对象。
2.以下述 operator* 为例,如果它返回一个 reference,后者一定指向某个既有的Rational 对象,但是,我们无法期望这样的一个 Rational对象在调用 operator* 之前就存在。所以,必须自己创建那个Rational对象。
class Rational {
public:
Rational( int numerator = 0 , int denominator = 1 );
...
private:
int n,d; // n 为分子,d 为分母
friend const Rational operator* ( const Rational& lhs, const Rational& rhs); //常规 pass-by-value方式
const Rational& operator* ( const Rational& lhs, const Rational& rhs);//返回reference的方式
};
3.在stack空间创建,就是定义一个local变量
const Rational& operator* ( const Rational& lhs, const Rational& rhs)
{
Rational result( lhs.n * rhs.n , lhs.d * rhs.d ); // 糟糕的代码
return result; //local对象在函数退出前就会被销毁,导致reference 指向的是一个已经被销毁的对象。
}
4.在 heap内构造一个对象,由 new 创建
const Rational& operator* ( const Rational& lhs, const Rational& rhs)
{
Rational* result = new Rational( lhs.n * rhs.n,lhs.d * rhs.d ); // new出来的对象谁来执行delete?
return *result;
}
5.让 operator* 返回的reference 指向一个被定义于函数内部 static 对象
const Rational& operator* ( const Rational& lhs, const Rational& rhs )
{
static Rational result;
result = ...;
return result;
}
考虑如下应用场景:
bool operator==( const Rational& lhs,const Rational& rhs); // 针对Rational而写的 operator==
Rational a,b,c,d;
...
if( (a*b) == (c*d) ) //该判断每次都是true,因为==两端比较的是static Rantional对象的"现值"!
{
// 当乘积相等,做适当动作
}
else
{
// 当乘积不等,做相应动作
}
6.老老实实让函数返回一个新对象,虽然要承担 operator* 返回值的构造和析构的成本,但至少保证了行为正确。另外C++允许编译器实现者施行最优化,用以改善产出码的效率。
inline const Rantional operator* ( const Rational& lhs,const Rational& rhs )
{
return Rational(lhs.n*rhs.n,lhs.d*rhs.d);
}
7.小结:绝不要返回 pointer 或 reference 指向一个 local stack 对象,或返回 reference 指向一个 heap-allocated对象,或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。
条款22:将成员变量声明为 private
1.从语法的一致性来看:如果成员变量不是public,唯一能够访问对象的办法就是通过成员函数。用户就不需要在打算访问class成员时迷惑地试着记住是否该使用小括号(圆括号)。
2.使用函数可以让你对成员变量的处理有更精确的控制。public,每个人都可以读写它,但如果你以函数取得或设定其值,你就可以实现”不准访问“ 、”只读访问“ 、”读写访问“ 、甚至是 ”只写访问“。
3.从封装的角度看:如果你通过函数访问成员变量,日后可改以某个计算替换这个成员变量,而class用户一点也不会知道class的内部实现已经改变。即,将成员变量隐藏在函数接口背后,可以为所有可能的实现提供弹性。
4.protected成员变量的封装性并不高过public成员变量。当取消一个public成员变量时,所有使用它的客户码都会被破坏;当取消一个protected成员变量时,所有使用它的derived classes都会被破坏,这两者都是不可知的大量。因此 protected成员变量同 public成员变量一样缺乏封装性。
5.小结:
<1>切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。
<2>protected 并不比 public 更有封装性。
条款23:宁以non-member 、non-friend替换member函数
1.假设有如下类WebBrowser ,表示网页浏览器。这样的类可能提供的众多函数中,有一些用来清除下载元素高速缓存、清除历史记录、清除cookies等:
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
}
也有需求一键清除,所以WebBrowser也提供如下member函数,其中调用以上三个函数
class WebBrowser {
public:
...
void clearEverything( ); // 调用以上两个clear和romove函数
...
};
当然这个机能也可由一个 non-member函数调用适当的member函数而提供:
void clearBrowser( WebBrowser& wb )
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
2.在许多方面 non-member 做法 比member做法好。①member函数 clearEverything带来的封装性比 non-member函数 clearBrowser低。②提供non-member函数可允许对WebBrowser相关机能有较大的包裹弹性,而那最终导致较低的编译相依度,增加WebBrowser的可延伸性。
3.推崇封装的原因——它使我们改变事物而只影响有限客户。而越多的函数能够访问对象内的数据,我们就判定它数据的封装性越低。
4.在C++,比较自然的做法是让clearBrowser成为一个non-member函数并且位于WebBrowser所在的同一个namespace内
namespace WebBrowserStuff {
class WebBrowser { ... };
void clearBrowser(WebBrowser& wb);
...
}
5.namespace 和 classes不同,namespace可以跨越多个源码文件但class不能。将所有便利函数放在多个头文件内但隶属于同一个命名空间,这意味着客户可以轻松扩展这一组便利函数。他们需要做的就是添加更多的 non-member、non-friend函数到此命名空间内。新函数就像其他旧有的便利函数那样可用且整合为一体。这是class无法提供的另一个性质。因为class定义式对客户而言是不能扩展的。
6.小结:宁可拿non-member、non-friend函数替换 member函数。这样做可以增加封装性、包裹弹性和机能扩充性。
条款24:若所有参数皆需类型转换,请为此采用non-member函数
1.假设有一个class用来表现有理数,允许整数“隐式转换”为有理数似乎颇为合理。
class Rational {
public:
Rational( int numerator = 0 , int denominator = 1 ); // 构造函数刻意不为explicit,允许 int-to-Rational 隐式转换
int numerator() const; // 分子的访问函数
int denominator() const; // 分母的访问函数
private:
...
};
在Rational class内为有理数实现 operator*
class Rational {
public:
...
const Rational operator* ( const Rational& rhs ) const;
};
2.只有当参数被列于参数列内,这个参数才是隐式类型转换的合格参与者。考虑如下应用:
result = oneHalf * 2; //正确,右边等价于oneHalf.operator*(2);这里发生了隐式类型转换,前提是non-explicit构造函数
result = 2 * oneHalf; //错误,右边等价于2.operator*(oneHalf);而整数2并没有相应的class,也就没有operator*成员函数。
第一次调用中,参数”2“在参数列内,所以其可以参与隐式类型转换,加上non-explicit构造函数,故ok。
3.让operator*成为一个non-member函数,并允许编译器在每一个实参身上执行隐式类型转换:
class Rational {
... // 不包括operator*函数
};
const Rational operator* ( const Rational& lhs , const Rational& rhs ) // operator* 成为一个 non-member函数
{
return Rational( lhs.numerator() * rhs.numerator() , lhs.denominator() * rhs.denominator() );
}
Rational oneFourth(1,4);
Rational result;
result = oneFourth * 2; // 没问题
result = 2 * oneFourth; // 终于通过编译了!
4.小结:如果你需要为某个函数的所有参数(包括被 this指针所指的 那个隐喻参数 )进行类型转换,那么这个函数必须是个non-member。
条款25:考虑写出一个不抛异常的 swap 函数
1.std::swap的典型实现
namespace std {
template<typename T> // std::swap的典型实现
void swap( T& a, T& b ) // 置换a和b
{
T temp(a);
a = b;
b = temp;
}
}
2.对于”以指针指向一个对象,内含真正数据“的类型(所谓的 “pimpl 手法”)
class WidgetImpl { // 针对Widget数据而设计的class
public:
...
private:
int a,b,c;
std::vectorK<double> v; // 意味着复制时间更长
...
};
class Widget { // 这个class使用pimpl手法
public:
Widget( const Widget& rhs );
Widget& operator=( const Widget& rhs ) // 复制Widget时,令它复制其WidgetImpl对象
{
...
*pImpl = *(rhs.pImpl);
...
}
...
private:
WidgetImpl* pImpl; //指针,所指对象内含Widget数据
};
一旦要置换两个Widget对象值,我们唯一需要做的就是置换其pImpl指针,但缺省的 swap算法不知道这一点。它不只复制三个Widgets,还复制了三个WidgetImpl对象。非常缺乏效率!
2.所以需要将std::swap针对Widget特化:
class Widget { // 与之前相同,唯一差别是增加swap函数
public:
...
void swap( Widget& other )
{
using std::swap; // 这个声明很必要
swap(pImpl,other.pImpl); // 若要置换Widgets就置换其pImpl指针
}
...
};
namespace std {
template<> // 表示它是修订后的std::swap特化版本
void swap<Widget>( Widget& a,Widget& b ) // <Widget>表示这一特化版本是针对“T是Widget”而设计
{
a.swap(b);
}
}
当一般性的swap template施行于 Widgets身上便会启用这个版本。
通常我们不能够(不被允许)改变std命名空间内的任何东西,但可以(被允许)为标准templates(如swap)制造特化版本,使它专属于我们自己的classes(例如Widget)。
3.假设Widget和WidgetImpl 都是class templates 而非 classes,则需要声明一个non-member swap让它调用member swap,但不再将那个non-member swap声明为std::swap的特化版本或重载版本。
假设Widget的所有相关机能都被置于命名空间WidgetStuff内,就是这样:
namespace WidgetStuff {
... //模板化的WidgetImpl等等
template<typename T>
class Widget { ... }; //同前,内含swap成员函数
...
template<typename T> //non-member swap函数
void swap( Widget<T>& a,Widget<T>& b ) // 这里并不属于std命名空间
{
a.swap(b);
}
}
现在,任何地点任何代码如果打算置换两个Widget对象,因而调用swap,C++的名称查找法则会找到WidgetStuff内的Widget专属版本。
4.使用中的调用顺序
template<typename T>
void doSomething(T& obj1,T& obj2)
{
using std::swap; // 令std::swap在此函数内可用
...
swap(obj1,obj2); // 为T型对象调用最佳的swap版本,不能写作std::swap(obj1,obj2);这将强迫编译器只认std内的swap
...
}
一旦编译器看到对swap的调用,它们便会查找适当的swap并调用之。
①C++的名称查找法则(name lookup rules )确保将找到global作用域或T所在之命名空间内的任何T专属的swap。如果T是Widget并位于命名空间WidgetStuff内,编译器会使用“实参取决之查找规则”找出WidgetStuff内的swap。
②如果没有T专属之swap存在,编译器就会使用std内的swap,这需要感谢using声明式让std::swap在函数内曝光。
③然而即便如此编译器还是比较喜欢std::swap的T专属特化版,而非一般化的那个template,所以如果你已针对T将std::swap特化,特化版本会被编译器挑中。
5.小结:
<1>当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
<2>如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于 classes(而非templates),也请特化 std::swap。
<3>调用swap时应针对 std::swap 使用using声明式,然后调用swap并且不带任何“命名空间资格修饰”。
<4>为“用户定义类型”进行std templates全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。