1.8:未能区分可访问性和可见性
C++语言压根儿没有实现什么数据隐藏,它实现了的是访问层级。在class中具有protected
和private
访问层级并非不可见,只是不能访问罢了。如同一切可见而不可及的事物一样(经理的形象跃入脑海),他们总是惹出各种麻烦。
最显而易见的问题就是即使是class的实现仅仅更改了一些貌似不可见的部分,也会带来必须重新编译代码的苦果。考虑一个简单的class,我们为其添加一个新的数据成员:
class C {
public:
C( int val ) : a_( val ),
b_( a_ ) // 新添加的代码
{}
int get_a() const { return a_; }
int get_b() const { return b_; } // 新添加的代码
private:
int b_; // 新添加的代码
int a_;
};```
上例中,修改造成了`class`的若干种变化。有些变化是可见的,有些则不然。
由于添加了新的数据成员,class的尺寸发生了变化,这一点是可见的。这个变化对给所有使用了该class型别的对象的、提领成该class型别的对象的或是对该class型别的指针作了指针算术运算的代码,或是以其他的什么方式引用了这个class的尺寸数据或是引用了其成员名字的代码等应用,都带来了深刻的影响。这里要特别注意的是,新的数据成员的引入所占的位置,同样也会影响旧的成员a_在`class`对象内的偏移量。一旦a_在`class`对象内的偏移量真的变了,那所有a_作为数据成员的引用,或是指涉到a_的指涉到数据成员的指针23将统统失效。顺便说一句,该`class`对象之成员初始化列表的行为是未可预期的,`b_`被初始化成了一个未有定义的值(欲知详情,请参见常见错误`52)`。
而最主要的不可见变化,在于编译器隐式提供的复制构造函数和赋值运算符的语义。默认地,这些函数被定义成`inline`的。是故,它们编译后的代码就会被插入任何使用一个C对象来初始化另一个C对象、或是使用一个C对象给另一个C对象赋值的代码中(常见错误49里提及了有关这些函数的更多信息)。
这个对`class C`简单的修改带来的最主要的结果(让我们把上面提到的一个引入的缺陷暂时搁下不提),就是几乎所有用到过`class C`的代码统统需要重新编译过。如果是大项目,这种重新编译可能会旷日持久。如果`class C`是在一个头文件里定义的,所有包含了这个源文件的代码都连带地需要重新编译过。有一个办法能够缓解此一境况,那就是使用`class C`的前置声明。具体做法倒也简明,就是当不需要除名字以外的其他信息时,像下面这样写一句非完整的`class`声明:
`class C;`
就是这么一句平凡的、非完整的声明语句,使得我们仍然可以声明基于`class C`的指针和引用,前提是我们不进行任何需要`class C`的尺寸和成员的名称的操作24,包括那些继承自`class C`的派生类初始化`class C`部分子对象的操作(可是你看看常见错误39,凡事皆有例外)。
这种手段可谓行之有效,不过要想免吃维护阶段的苦头,还要谨记严格区分“仅提供非完整的`class`声明”和“提供完整`class`定义”的代码,不要把它们写到同一个源文件中去。也就是说,想为复杂冗长的`class`定义提供上述轻量级替身的软件工程师,请不要忘记提供一个放置各种适当前置声明的25专用头文件。
比如上例中,如果`class C`的完整定义是放在c.h这个头文件中的,我们就会考虑提供一个`cfwd.h`,里面只放置非完整的`class`声明。如果所有的应用都用不着C的完整定义,那么包含c.h就不如包含`cfwd.h`。这样做有什么好处呢?因为C这个名字的含义在未来可能会发生变化,使得一个简单的前置声明不容于新环境。比如,C可能会在未来的实现中成为`typedef`:
template
class Cbase{
// ...
};
typedef Cbase C;`
很清楚,那个头文件c.h的作者是在尽力避免当前class C的用户去修改他们的源代码26,不过,任何在包含了c.h以后还想继续使用“C的不完整声明”的企图都会触发毫不留情的编译期错误:
#include "c.h"
// ...
class C; // 错误!C现在不再是class的名字,而是typedef。```
因而,如果提供了一个前置声明专用头文件cfwd.h的话,这样问题就根本不会出现了27。所以,这个锦囊妙计就在标准库中催生了iosfwd,它就是人尽皆知的`iostream`头文件对应的前置声明专用头文件。
更为常见的是,由于必须对使用了class C的代码进行重新编译,结果就使得对已经部署了软件打补丁这件事很难做。这么一来,也许最管用的解决方案就是把class的接口与其实现分离,从而要达到真正的数据隐藏之境,而其不二法门则是运用桥接设计模式(Bridge Pattern)。
桥接设计模式需要把目标型别分为两个部分,也就是接口部分和实现 部分:
class C {
public:
C( int val );
~C();
int get_a() const;
int get_b() const;
private:
Cimpl *impl_;
};`
class Cimpl {
public:
Cimpl( int val ) : a_( val ), b_( a_ ) {}
~Cimpl() {}
int get_a() const { return a_; }
int get_b() const { return b_; }
private:
int a_;
int b_;
};
C::C( int val )
: impl_( new Cimpl( val ) ) {}
C::~C()
{ delete impl_; }
int C::get_a() const
{ return impl_->get_a(); }
int C::get_b() const
{ return impl_->get_b(); }```
此新接口包含了class C的原始接口,但`class`实现则被移入了一个在一般应用中不可见的实现类里。`class C`的新版本仅仅包含了一个指涉到实现类的指针,而整个实现,包括`class C`的成员函数,现在都对使用了class C的代码不可见了28。任何对于`class C`实现的修改29,只要不改变class C的接口30,影响就会被牢牢地箝制在一个单独的实现文件里了31。
运用桥接模式显然要付出一些运行时的成本,因为一个class C对象现在需要用两个对象,而不是一个对象来表示了,而且调用所有的成员函数的动作现在由于是间接调用,也做不成`inline`的了。无论如何,它带来的好处是大幅节省了编译时间,而且不必重新编译就能发布使用了`class C`的代码的更新。这在大多数情况下,可谓物美价廉。
此项技术已经被广泛应用多年,因而也被冠以数种趣名,如“pimpl习惯用法”和“柴郡猫技术(`Cheshire Cat technique`)”32之美誉33。
不可访问的成员在通过继承接口访问时,会造成派生类成员和基类成员的语义发生变化。考虑如下的基类和派生类:
class B {
public:
void g();
private:
virtual void f(); // 新添加的代码
};
class D : public B {
public:
void f();
private:
double g; // 新添加的代码
};`
在class B
这个基类中添加了一个私有访问的虚函数,导致了原先派生类中的非虚函数变成了虚函数,添加在class D中的私有访问的数据成员则遮掩了B中的一个函数。这就是为什么继承常常被视为“白盒”复用34,因为对class
的任何修改都在非常基本的层面同时影响着基类和派生类的语义。
一种能够削弱此类问题的方法,是采用一种简明的、根据功能划分名字的命名规范。典型的办法是为型别的名字、私有访问的数据成员的名字或其他什么东西的名字使用不同的规范以示区分。在本书中,我们的规范是使用全大写的型别名字,并在数据成员的后面附加一个下划线(它们应该都只有private访问层级成员函数!),而对于其他的名字(除一些特例外)我们用小写字母打头的名字。如果遵守这样的规范,我们在上面的例子中就不会意外地遮掩基类中的成员函数了。不过,最要紧的是不要建立极复杂的命名规范,因为如此规范往往形同具文。
此外,绝对不要让变量的型别成为其名字的一部分。比如,把一个整型变量index
命名为iIndex
是对代码的理解和维护主动搞破坏。首先,名字应该描述实体的抽象意义,而不是它的实现细节(抽象性甚至在内建型别中就已经发挥了它的影响)。再有,大多数的情况下,变量的型别改变的时候,它的名字不会同步地跟着其型别变化。这么一来,变量的名字就成了迷惑维护工程师有关其型别信息的利器。
其他方法在一些别的地方时有讨论,特别在常见错误70、73、74和77中为最多。