本节书摘来自异步社区出版社《Imperfect C++中文版》一书中的第2章,第2.2节,作者: 【美】Matthew Wilson,更多章节内容可以访问云栖社区“异步社区”公众号查看。
2.2 控制你的客户端
Imperfect C++中文版
C++的一个重要且强大的特性是在编译期实施访问控制的能力。通过使用public、protected以及private[Stro1997]访问限定关键字,以及适当地使用friend关键字,我们可以控制客户端代码使用我们的类型的方式。这种控制在很多方面都是极其有用的,本书中就有很多技术利用了这种能力。
2.2.1 成员类型
控制外界对你的类实例的操纵的强大方式之一,是将类的成员声明为const和/或引用类型。因为常量和引用(以及const引用)只可以被初始化,不能被赋值,在类内部使用它们会阻止编译器去定义拷贝赋值操作符。更重要的是,它还有助于你(类的原始作者)和任何维护你代码的人强制实施最初的设计决策。如果不违反这些限制(编译器会为你确保这一点)就不能对类型作改动的话,这就意味着设计需要改动,并且明确地指出了你对类所需进行的改动的重要性。这是典型的“苦行僧式”编程。
(注意,这种技术主要是用于在类实现内部实施设计决策,这种决策可以“传递”到日后的维护者手中。通过const成员产生的编译警告信息,来告诉用户不应该将类用在拷贝赋值操作中,是不恰当的。如果你想这么做的话,你应该采用更明确的方式,可将拷贝赋值操作符声明为受保护的或私有的)。
2.2.2 缺省构造函数
如果你自己定义了任何其他构造函数,则编译器将“自觉地”不再为你生成缺省构造函数,因此你不用为了“如何将缺省构造函数隐藏起来”而操心。如果你想定义这样一个类:其构造函数带有参数,并且该类没有缺省构造函数(即无参的构造函数),那么编译器的这种行为是有益的。考虑一个用于制约某种资源的类(见第6章),这样的类就不应该具有缺省构造函数,否则它释放什么呢?
如果在一个没有任何其他构造函数的类中将缺省构造函数“隐藏”起来,那么该类的任何实例都无法被创建出来(该类的友元或静态函数是例外,它们可以访问被隐藏起来的构造函数,从而创建该类的实例),然而这么做并不会阻止该类的实例被销毁。
2.2.3 拷贝构造函数
无论你是否定义了一个缺省或者其他构造函数,只要你没有显式地提供拷贝构造函数,编译器就会为你“合成”一个。缺省情况下,编译器所生成的拷贝构造函数会进行“逐成员(member-wise)”拷贝。对于某些特定的类,例如内部包含了指向被分配资源的指针的类,编译器的这种行为会导致两个类实例拥有同一份资源,这通常会导致令人不愉快的后果。
如果你不想要拷贝构造函数的话,你应该使用[Stro1997]中描述的惯用手法使它成为不可访问的:
class Abc
{
. . .
// 声明但不予实现
private:
Abc(Abc const &);
};
仅当你的类型是简单的值类型(见第4章)并且不拥有任何资源(见第3章)时,你才可以让编译器为你生成一个缺省的拷贝构造函数。
2.2.4 拷贝赋值
和拷贝构造函数一样,如果你没有为类定义一个拷贝赋值操作符的话,编译器就会为你生成一个。再一次提醒,只有对于简单的值类型,你才可以任由编译器为你生成缺省的拷贝赋值操作符。
如果类内部具有const成员或引用成员的话,编译器将无法生成缺省的拷贝赋值操作符,不过你不应该借助于这个特点来禁止拷贝赋值。原因有二:第一,你可能会因此招致令人恼火的编译警告,编译器试图警告你忽视了某些东西,而实际上这却是你“早有预谋”的。
第二个原因,使用const成员是一种可以用于实施设计决策的机制。然而它不但可以用于表示某个类不具有可拷贝赋值性,通常更可能表示的是该类总体上的“常性(immutability)”。如果你改变了某个类的常性假定,而你可能仍然想要禁止拷贝赋值,最好使用[Stro1997]中介绍的惯用手法,明确地将拷贝构造函数声明为私有的:
class Abc
{
. . .
// 声明但不予实现
private:
Abc &operator =(Abc const &);
};
人们时常希望同时禁止拷贝赋值和拷贝构造。如果你只要禁止其一的话,最直接的办法就是使用上面提到的惯用手法。
2.2.5 new和delete
new和delete被用于在堆上创建和析构元素(对象)。通过限制对它们的访问,可以阻止对象创建在堆上,换句话说,使对象只能被创建于栈上(全局对象、栈对象)。隐藏new和delete操作符的标准方式和隐藏拷贝构造函数以及拷贝赋值操作符的方式类似。如果你想要把它们“藏在”某个给定的类以及该类的任何派生类(当然了,那些对它们进行重新定义的类除外)之中的话,通常你可以通过只声明而不提供实现(定义)的方式来完成:
class Shy
{
. . .
// 声明但不予实现
private:
void *operator new(size_t);
void operator delete(void *);
};
除了用于访问控制之外,我们还可以基于每个类[Meye 1998, Stro1997]或者基于连接单元来实现这些操作符的自定义版本(见9.5节和32.3节)。
然而,对这些操作符使用访问控制也还是有局限性的,因为在任何派生类中都可以重写(override)它们。即使你在基类中让它们成为私有的,也没任何办法可以阻止派生类去定义它自己的公有版本。因此,限制对new和delete的访问实际上不过是个“文档策略”。
虽说如此,这种技术仍然还是有用武之地的:你可以在基类中将其声明(并定义)为protected,从而规定一个公共的分配策略,并允许任何基于堆分配的派生类通过该公共策略来实现它们自己的public版本的new和delete。
2.2.6 虚析构
关于delete,还有一个有趣的特性,当你的类具有虚析构函数时你就会看到这一点。C++标准(C++-98: 12.4;11)说“……,则应该在析构函数所属类的作用域中查找non-placement delete1操作符……”尽管在提供一个虚析构函数的同时又将delete操作符“藏”起来是相当反常的情况,然而如果你真这么做了的话,你必须为delete操作符提供一个存根(stub)实现(例如提供一个空的函数体),以避免发生连接器错误。
2.2.7 explicit
explicit关键字的作用是禁止编译器将类的构造函数用于隐式转换。例如,函数f()有一个String类型的参数,如果String有类似下面的构造函数的话,就可以通过将字面字符串隐式转换为String而使调用f("Literal C-string")生效:
class String
{
public:
String(char const *);
. . .
如果没有explicit的话,则代码中可能会出现意想不到的、代价高昂的转换。将explicit关键字应用于构造函数即意味着告诉编译器不要将其用于隐式转换。
public:
explicit String(char const *);
这是一个有着广泛的文档记载[Dewh2003,Stro1997]并被很好地理解了的概念。我建议总是对这些所谓的转换构造函数使用explicit关键字,除非你(类的作者)明确地希望允许隐式转换。
2.2.8 析构函数
通过把析构函数隐藏起来,我们可以强制性地禁止对象在全局作用域上创建,还可以禁止对指向该类的实例的指针使用delete。当我们需要可以访问和使用某一个对象却不能销毁它时,这种能力是有用的。在防止对引用计数的指针的误用方面则特别有用。
另一个值得指出的重要方面是:在类模板中,析构函数是放置“方法内”约束的首选地点(见1.2节)。考虑程序清单2.1中的类模板:
程序清单2.1
template <typename T>
class SomeT
{
public:
SomeT(char const *)
{
// 只有当这个构造函数被实例化时,其内部的约束才会起作用
constraint_must_be_pointer_type(T);
}
SomeT(double )
{
// 只有当这个构造函数被实例化时,其内部的约束才会起作用
constraint_must_be_pointer_type(T);
}
. . .
如果将约束置于某个构造函数中的话,那么只有当那个构造函数被实例化时,约束才得到检验。然而,对于模板,C++只实例化那些被需要的成员。这个特性很好,它带来了一些极其有用的技术(见33.2节)。
如果像上面所说的,那么为了确保约束得到检验,你要将其置于所有构造函数中。然而,析构函数永远只有一个,因此你可以将约束放在那里,这么做既免除了手指疲劳又带来了更好的可维护性。
. . .
~SomeT()
{
// 只要有该类的实例被创建,该约束就会起作用
constraint_must_be_pointer_type(T);
}
};
很自然,即便如此,只要你不创建该类的任何实例(例如,你只调用该类的静态成员函数),约束仍然是得不到检验的。不过,几乎在所有的情况下,将约束放在析构函数中即可满足要求。
2.2.9 友元
友元(friend)如果使用不当的话可能会破坏我们在访问控制上付出的所有努力。友元自有它的支持者(我可以对他们摆出有力的反击),不过我觉得友元的使用应该被限制在位于同一个头文件里的类之间。对于一个良好的设计[Dewh2003, Lako1996, Stro1997]来说,类和函数集应该呆在它们自己的头文件中,只有那些彼此紧密依赖的才可以被放在同一个头文件中,例如一个序列类及其迭代器类,或者一个值类型及其自由操作符函数。因此,最好离友元远一点,从长远来看它们只会给你带来麻烦。
如果你遵循“尽量采用类的成员函数来取代定义自由函数”的实践准则,那就可以戏剧性地减少对友元的需求。对此一个很好的例子是通过X::operator +=(X const &)来定义自由函数形式的 operator +(X const &, X const)。我们将会在第25章详细探讨这种技术。
在重审本节的时候,我决定把我对友元的误用次数统计一下(我已经准备好挨枪子了)。到我写这些文字为止,在STLSoft库中总共有41处使用了friend关键字,其中有29处是用于表达序列/迭代器/值类型关系的,8处与我自己实现的多维数组类的下标子类型有关(见33.2.4小节)。剩下来的4处都是用于同一个文件中的类之间的,其中包括内嵌类和外部类之间的关系。我惊讶于自己用了这么多的友元,不过还好,至少我没有搬起石头砸自己的脚。
1译者注:non-placement delete是指形如 void operator delete (void); 的成员函数。C++98中这句话的意思是,如果你提供了虚析构函数的话,编译器就会同时在该类的内部查找non-placement delete操作符,如果查找到的话,那么必须为可访问且无二义性的。之所以有这么一个规定,是为了确保有operator delete可用于多态的析构行为。事实上,你当然也可以在提供虚析构函数的同时将non-placement delete声明为private成员以阻止外界的显式调用,如果是这样的话,你必须为它提供一个空的定义体,不然即使外界不调用,连接器也会报错说“无法解析的外部符号operator delete(void)”,这是由于C++的多态实现机制使然。
本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。