`
xitonga
  • 浏览: 579460 次
文章分类
社区版块
存档分类
最新评论

effective C++: 4.设计与声明

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




条款18:让接口容易被正确使用,不易被误用
理想上,如果客户企图使用某个接口而却没有获得他所预期的行为,这个代码不该通过编译;如果代码通过了编译,它的作为就该是客户所想要的。
欲开发一个“容易被正确使用,不容易被误用”的接口,首先必须考虑客户可能做出什么样的错误。
许多客户端错误可以因为导入新类型而获得预防。在防范“不值得拥有的代码”上,类型系统是你的主要同盟国。
struct Day
{
explicit Day(int d) //explicit 避免隐式的转换。
:val(d) {}
int val;
};
对日期进行类似的类型封装,能有效地避免不恰当的日期赋值。
“除非有好的理由,否则应该尽量令你的类型(定义的类)的行为与内置类型一致”。
在资源管理方面,也许我们应该“先发制人”,即让函数返回一个资源的指针改为返回一个只能指针。
例如:
std::tr1::shared_ptr<Investment> createInvestment();
这便实质上强迫客户将返回值存储于一个tr1::shared_ptr内,几乎消除了忘记删除底部Investment对象的可能性。
tr1::shared_ptr提供的某个构造函数接受两个实参:一个是被管理的指针,另一个是引用次数变成0时被调用的“删除器”。但我们自己制定第二个参数,当然这是安全的。但是留给客户,那也许存在危险。
std::tr1::shared_ptr<Investment> //tr1::shared_ptr构造函数坚持第一个参数必须是个指针。
pInv(static_cast<Investment*>(0), getRidOfInvestment);
tr1::shared_ptr有一个特别好的性质是:它会自动使用它的“每个指针专属的删除器”,因而消除另一个潜在的客户错误:所谓的“cross-DLL problem”。因为它缺省的删除器是来自“tr1::shared_ptr诞生所在的那个DLL”的delete。


请记住:

好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
“促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
“阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
tr1::shared_ptr支持定制删除器。这可防范DLL问题,可被用来自动解除互斥量等等。




条款19:设计class犹如设计type
如何设计高效的class呢?几乎每一个class都要求你面对以下提问,而你的回答往往导致你的设计规范:
1.新Type的对象应该如何被创建和销毁?
这会影响到你的class的构造函数和析构函数以及内存分配函数和释放函数(operator new,operator new[],operator delete和operator delete[])的设计,当然前提是你打算撰写他们。

2.对象的初始化和对象的赋值该有什么样的差别?
这个答案决定你的构造函数和赋值操作符的行为,以及其间的差异。很重要的是别混淆了“初始化”和“赋值”,因为它们对应于不同的函数调用。

3.新Type的对象如果被passed by value(以值传递),意味着什么?
记住,copy构造函数用来定义一个type的pass-by-value该如何实现。

4.什么是新type的“合法值”?
对class的成员变量而言,通常只有某些数值集是有效的。那些数值集决定了你的class必须维护的约束条件,也就决定了你的成员函数(特别是构造函数、赋值操作符和所谓
“setter”函数)必须进行的错误检查工作。他也影响函数抛出的异常、以及(极少被使用的)函数异常明细列。

5.你的新type需要配合某个继承图系(inheritance graph)吗?
如果你是继承自某些既有的class,你就受到那些class的设计的束缚,特别是受到“它们的函数时visual和non-visual”的影响(条款34和36)。如果你允许其它class继承你的
class,那会影响你所声明的函数——尤其是析构函数——是否为visual(条款07)。

6.你的新type需要什么样的转换?
你的type生存于其他一海票types之间,因而彼此之间该有转换吗?如果你希望允许类型T1之物被隐式转换为类型T2之物,就必须在class T1内写一个类型转换函数(operator T2)或在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数。如果你允许explicit构造函数存在,就得写出专门负责执行转换的函数,且不得为类型转换操作符(type conversion operator)或non-explicit-one-argument构造函数(条款15)。

7.什么样的操作符和函数对此新type而言是合理的?
这个问题的答案决定你将为你的class声明那些函数。其中某些该是member函数,某些则否(条款23、24、46)。

8.什么样的标准函数应该驳回?
那些正是你必须声明为private者(条款06)。

9.谁该取用新的type的成员?
这个提问可以帮助你决定那个成员为public,那个为protected,那个为private。他也帮助你决定哪一个class和/或function应该是friend,以及它们嵌套于另一个之内是否合理。

10.什么是新type的“未声明接口”(undecleared interface)?
他对效率、异常安全性(条款29)以及资源运用(例如多任务锁定和动态内存)提供何种保证?你在这些方面提供的保证将为你的class实现代码加上相应的约束条件。

11.你的新type有多么的一般化?
或许你其实并非定义一个新type,而是定义一整个types家族。果真如此你就不该定义一个新class,而是定义一个新的class template。

12.你真的需要一个新type吗?
如果只是定义新的derived class以便为既有的class添加机能,那么说不定单纯定义一或多个non-member函数或template,更能够达到目标。

这些问题不容易回答,所以定义出搞笑的class是一种挑战,然而如果能够设计出至少像C++内置类型一样好的用户自定义类,一切汗水便都值得。

请记住:
class的设计就是type的设计,在定义一个新type之前,请确定你已经考虑过本条款覆盖的所有讨论主题。




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


缺省情况下C++以by value方式传递对象至函数。除非你另外指定,否则函数参数都是以实际实参的副本为初值,而调用端所获得的亦是返回值的一个副本。这些副本由对象的拷贝构造函数产生。所以在以对象为by value时,可能会调用相应的构造函数(成员对象的构造、基类对象的构造),然后调用对应的析构函数。所以以by value的形式开销还是比较大的。


如果我们用pass-by-reference-to-const,这种传递方式效率高得多:没有任何构造函数或析构函数被调用,因为没有任何新对象被创建。以传引用方式传递参数也可以避免对象切割问题:即当一个派生类对象以传值的方式传递并被视为一个基类对象,基类对象的拷贝构造函数会被调用,而“造成此对象的行为像个派生类对象”的那些特化性质全被切割掉了,仅仅留下了基类对象,再次基础上虚函数的调用也是基类的虚函数,这一般不是你想要的。而传引用通常意味着真正的传递指针。


所以我们一般的做法应该是这样:内置对象和STL的迭代器和函数对象,我们一般以pass-by-value方式传递,而其它的任何东西尽量以pass-by-reference-to-const的方式传递。


请记住:
尽量以pass-by-reference-to-const替代pass-by-value。前者通常比较高效,并可避免切割问题。
以上规则并不使用于内置类型,以及STL的迭代器和函数对象。对它们而言,pass-by-value往往比较适当。




条款21:必须返回对象时,别妄想返回其reference
当我们领悟条款20中传值的开销后,总是避免于少用传值,然而在返回对象时,要格外小心了,因为你可能:传递一些引用或指针指向其实已经不存在的对象。这可不是件好事。
任何时候看到一个reference声明式,你都应该立刻问自己,它的另一个名称是什么?
函数创建新对象的途径有二:在栈空间和堆空间


栈上:即在函数内的局部变量。局部变量在函数返回后就没有存在的意义,若还对它“念念不忘”,将带来灾难性后果。
堆上:在堆上构造一个对象,并返回。看似可行,也埋下了资源泄漏的危险。谁该对这对象实施delete呢?别把这种对资源的管理寄托完全寄托于用户。
可能还有一种想法:把“让返回的引用指向一个被定义于函数内部的静态对象”。出于我们对多线程安全性的疑虑,以及当线程中两个函数对单份对象的处理也可能带来不可测行为。

一个“必须返回新对象”的函数的正确写法是:就让那个函数返回一个新对象。

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




条款22:将成员变量声明为private


请记住:
切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允许约束条件获得保护,并提供class作者以充分的实现弹性。
protected并不比public更具封装性。




条款23:宁以non-member、non-friend替换member函数
一般我们相当然以为类中的成员函数更具封装性,而实际上并不是那么一回事,因为成员函数不仅可以访问private成员变量,也可以取用private函数、enums、typedefs等等。而非成员非友元函数能实现更大的封装性,因为它只能访问public函数。将所有便利函数放在多个头文件内但隶属同一个命名空间,意味客户可以轻松扩展这一组便利函数。需要做的就是添加更多non-member non-friend函数到此命名空间内。


请记住:
宁可拿non-member non-friend函数替代member函数。这样做可以增加封装性、包裹弹性和机能扩充性。




条款24:若所有参数皆需类型转换,请为此采用non-member函数
通常,令类支持隐式类型转换通常是个糟糕的主意。当然这条规则有其例外,最常见的例外是在建立数值类型时。
例:
const Rational operator*(const Rational& rhs) const;
如果定义一个有理数类,并实现*操作符为成员函数,如上所示;那么考虑一下调用:
Rational oneHalf(1, 2);
result = oneHalf * 2; // 正确,2被隐式转换为Rational(2,1)
//编译器眼中应该是这样:const Rational temp(2); result = oneHalf * temp;
result = 2 * oneHalf; // 错误,2,可不被认为是Rational对象;因此无法调用operator*
可见,这样并不准确,因为乘法(*)应该满足交换律,不是吗?
所以,支持混合式算术运算的可行之道应该是:让operator*成为一个non-member函数,允许编译器在每一个实参上执行隐式类型转换:
class Rational
{
... // contains no operator*
};
const Rational operator*(const Rational& lhs, Rational& rhs)
{
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2;
result = 2 * oneFourth; //这下两个都工作的很好,通过隐式转换实现
成员函数的方面是非成员函数,而不是友元函数。
可以用类中的public接口实现的函数,最好就是非成员函数,而不是采用友元函数。


请记住:
如果你需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member。




条款25:考虑写出一个不抛异常的swap函数
swap原本只是STL的一部分,而后成为异常安全性编程的脊柱,以及用来处理自我赋值可能性的一个常见机制。


缺省情况下swap动作可由标准程序库提供的swap算法完成:
namespace std{
template<typename T>
void swap(T& a, T& b)
{
T temp(a);
a = b;
b = temp;
}
}
但是对某些类型而言,这些复制动作无一必要:其中主要的就是“以指针指向一个对象,内含真正数据”那种类型。多为“pimpl手法”(pointer to implementation的缩写)。


设计Widget class:
class WidgetImpl
{
public:
...
protected:
private:
int a, b, c;
std::vector<double> v;
...
};
class Widget
{
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs)
{
...
*pImpl = *(rhs.pImpl);
}
protected:
private:
WidgetImpl* pImpl;
};
要置换两个Widget对象值,唯一要做的就是置换pImpl指针,缺省的swap算法不知道这一点。不只复制3个Widget还复制3个WidgetImpl对象。非常缺乏效率!
我们希望能告诉std::swap,当Widgets被置换时真正该做的是置换其内部的pImpl指针,确切时间这个思路的一个做法是:将std::swap针对Widget全特化。
namespace std{
template<>
void swap<Widget>(Widget& a, Widget& b)
{
swap(a.pImpl, b.pImpl);//不能通过编译,访问private成员变量
}
}
通常我们不能够改变std命名空间内的任何东西,但是可以为标准模板制造特化版本,使他专属有我们自己的类。


由于上面程序无法通过编译,我们可以把这个特化版本声明为friend,但和以往的规矩不太一样,我们令Widget声明一个swap的public成员函数做真正的替换工作,然后将


std::swap特化,令他调用该成员函数:
class Widget
{
public:
...
void swap(Widget& other)
{
using std::swap;
swap(pImpl, other.pImpl);
}
...
private:
WidgetImpl* pImpl;
};

namespace std{
template<> //修改后的std::swap特化版本
void swap<Widget>(Widget& a, Widget& b)
{
a.swap(b); //若要置换Widget,调用其swap成员函数
}
}
这种做法不只能通过编译,还与STL容器有一致性,因为所有STL容器也能提供public swap成员函数和std::swap特化版本。
然而假设Widget和WidgetImpl都是class templates而非classes,如下
template <typename T>
class WidgetImpl{…};
template <typename T>
class Widget{…};
但在特化std::swap时:
namespace std{
template<typename T>
void swap<Widget<T>>(Widget<T>& a, Widget<T>& b) //wrong!不合法!
{
a.swap(b);
}
}
我们企图偏特化一个function template,但是c++中只允许对class templates偏特化,在function template身上偏特化是行不通的。这段代码不该通过编译(虽然有些编译器错


误地接受了它)。
当打算偏特化一个function template时,惯常的做法是简单的为它添加一个重载版本:

namespace std{
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) //注意“swap后没有<>”
{
a.swap(b);
}
}
一般而言,重载function template没有问题,但std是个特殊的命名空间,管理也就比较特殊。客户可以全特化std内的templates,但不可以添加新的templates(或class或
function或任何其他东西)到std里头。其实跨越红线的程序几乎仍可编译执行,但他们行为没有明确定义。所以不要添加任何新东西到std里头。
为提供较高效的template特定版本。我们还是声明一个non-member swap但不再是std::swap的特化版本或重载版本:
namespace WidgetStuff{
...
template<typename T>
class Widget{...};
...
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
现在,任何地点的任何代码如果打算置换两个Widget对象,因而调用swap,C++的名称查找法则就是所谓“argument-dependent lookup”会找到WidgetStuff内的专属版本。这个做法对class和class template都行得通。但如果你想让你的“class(一般类)专属版”swap在尽可能多的语境下被调用,我们还需要为该类特化一个std::swap。所以如果你需要同时在该class所在命名空间写一个non-member版本以及一个std::swap特化版本。template<typename T>
void doSomething(T& obj1, T& obj2)
{
...
swap(obj1, obj2);
...
}
上面的应该使用哪个swap?是std既有的那个一般化版本还是某个可能存在的特化版本等?你希望应该是调用T专属版本,并在该版本不存在的情况下调用std内的一般化版本,下面是你希望发生的事:
template<typename T>
void doSomething(T& obj1, T& obj2)
{
using std::swap;//令std::swap在此函数内可用
...
swap(obj1, obj2);//为T型对象调用最佳swap版本
...
}
c++的名称查找法则(name lookup rules)确保将找到global作用域或T所在之命名空间内的任何T专属的swap。如果T是Widget并且位于命名空间WidgetStuff内,编译器会找出WidgetStuff内的swap。如果没有T专属的swap存在,编译器就是用std内的swap,然而即便如此,编译器还是比较喜欢std::swap的T专属特化版本,而非一般化的那个template。
std::swap(obj1,obj2); //这是错误的swap调用方式
这便强迫编译器只认std内的swap(包括其任何template特化),因此不再调用一个定义于他处的较适当T专属版本。那正是“你的classes对std::swap进行全特化的”重要原因:使得类型专属的swap实现版本可以被这些迷途代码所用。


总结:
如果swap的缺省实现码对你的classes或class template提供可接受的效率,不需要额外做任何事。
如果swap缺省实现版效率不足(某种pimpl):
1.提供一个public swap成员函数,这个函数绝不该抛出异常。
2.在class或template所在的命名空间内提供一个non-member swap, 并令他调用上述swap成员函数。
3.如果正编写一个class(而非class template),为你的class特化std::swap。并令他调用swap成员函数。
如果调用swap,确保包含一个using声明式,然后不加任何namespace修饰符,赤裸裸调用swap。


成员版swap绝不可抛出异常。
唯一还未明确的劝告:成员版本的swap决不可抛出异常。那是因为swap的一个最好的应用是帮助classes(class templates)提供强烈的异常安全性保障。(条款29对此提供了所有细节)此技术基于一个假设:成员版的swap绝不抛出异常。这一约束只施行于成员版!不可施行于非成员版,因为swap缺省版本是以copy构造函数和copy assignment操作符为基础,而一般情况下两者都允许抛出异常。因此当你写一个自定版本的swap,往往提供的不只是高效置换对象值的办法,而且不抛出异常。一般,这两个swap特性是连在一起的,因为高效的swaps几乎总是基于对内置类型的操作(例如pimpl手法的底层指针),而内置类型上的操作绝不会抛出异常。

请记住:
1.当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
2.如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于class(而非template),也请特化std::swap。
3.调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何“命名空间资格修饰符”。
4.为“用户定义类型”进行std template全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics