《C++覆辙录》——1.8:未能区分可访问性和可见性

1.8:未能区分可访问性和可见性

C++语言压根儿没有实现什么数据隐藏,它实现了的是访问层级。在class中具有protectedprivate访问层级并非不可见,只是不能访问罢了。如同一切可见而不可及的事物一样(经理的形象跃入脑海),他们总是惹出各种麻烦。

最显而易见的问题就是即使是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中为最多。

时间: 2024-09-25 17:30:15

《C++覆辙录》——1.8:未能区分可访问性和可见性的相关文章

《C++覆辙录》——导读

前言 C++覆辙录 本书之渊薮乃是近20年的小小挫折.大错特错.不眠之夜和在键盘的敲击中不觉而过的无数周末.里面收集了普遍的.严重的或有意思的C++常见错误,共计九十有九.其中的大多数,(实在惭愧地说)都是我个人曾经犯过的. 术语"gotcha"1有其云谲波诡的形成历史和汗牛充栋的不同定义.但在本书中,我们将它定义为C++范畴里既普遍存在又能加以防范的编码和设计问题.这些常见错误涵盖了从无关大局的语法困扰,到基础层面上的设计瑕疵,再到源自内心的离经叛道等诸方面. 大约10年前,我开始在

《C++覆辙录》——1.4:未能区分函数重载和形参默认值

1.4:未能区分函数重载和形参默认值 函数重载和形参默认值之间其实并无干系.不过,这两个独立的语言特征有时会被混淆,因为它们会模塑出语法上非常相像的函数用法接口.当然,看似一样的接口其背后的抽象意义却大相径庭: class C1 { public: void f1( int arg = 0 ); // ... };``` // ... C1 a; a.f1(0);a.f1();`型别C1的设计者决定给予函数f1()一个形参的默认值.这样一来,C1的使用者就有了两个选择:要么显式地给函数f1()一

《C++覆辙录》——常见错误1:过分积极的注释

第1章 基础问题 C++覆辙录 说一个问题是基础的,并不就是说它不是严重的或不是普遍存在的.事实上,本章所讨论的基础问题的共同特点比起在以后章节讨论的技术复杂度而言,可能更侧重于使人警醒.这里讨论的问题,由于它们的基础性,在某种程度上可以说它们普遍存在于几乎所有的C++代码中. 常见错误1:过分积极的注释 很多注释都是画蛇添足,它们只会让源代码更难读,更难维护,并经常把维护工程师引入歧途.考虑下面的简单语句: a = b; // 将b赋值给a 这个注释难道比代码本身更能说明这个语句的意义吗?因而

《C++覆辙录》——第2章 语法问题2.1:数组定义和值初始化的语法形式混淆

第2章 语法问题 C++覆辙录C++语言的语法和词法结构博大精深.此复杂性的一部分是从C语言那里继承而来的,另一部分则是为支撑某些特定的语言特性所要求的. 本章中我们将考察一组语法相关的头疼问题.其中有些属于常见的手误,但是错误的代码仍然能够通过编译,只不过会以出人意料的方式运行罢了.另外一些则是由于一段代码的语法结构及它们的运行期行为不再互为表里.其余的部分,我们主要研究语法层面的灵活余地带来的问题:明明是一字不差的代码,不同的软件工程师能从中得出大相径庭的结论来. 2.1:数组定义和值初始化

《C++覆辙录》——1.9:使用糟糕的语言

1.9:使用糟糕的语言 当一个更大的世界入侵了C++社群原本悠然自得的乐土之时,它们带来了一些足堪天谴的语言和编码实践.本节乃是为了厘清返璞归真的C++语言所使用的正确适当.堪称典范之用语和行为. 用语 表1-1列出了最常见的用语错误,以及它们对应的正确形式. 表1-1 常见用语错误及其对应正确用语 没有什么所谓"纯虚基类".纯虚函数是有的,而包含有或是未能改写(override)此种函数的类,我们并不叫它"纯虚基类",而是叫它"抽象类". C+

《C++覆辙录》——2.9:自反初始化

2.9:自反初始化 在以下的代码里,var的值变成了多少? int var = 12; { double var = var; // ... }``` 未有定义.C++语言中,某个名字在它的初始化对象被解析到之前就进入了其辖域的话,在初始化对象引用到这个名字时,它引用到的不是别的,正是这个刚刚被声明的对象.没有几个软件工程师会写出像上面这么莫名其妙的声明代码,但也许复制.粘贴的手法会让你陷入困境: int copy = 12; // 某深藏不露的变量// ...int y = (3x+2copy

《C++覆辙录》——2.6:声明饰词次序的小聪明

2.6:声明饰词次序的小聪明 就语言本身所限,声明饰词孰先孰后纯属无关紧要的形而上之争:`int const extern size = 1024; // 合法,但有离奇不经之嫌 `无论如何,如果没有令人信服的理由去背离习惯用法,那么顶好还是接受有关声明饰词次序事实上的标准:先写连接饰词,再写量化饰词,再写型别. extern const int size = 1024; // 正常下面这个指针的型别是什么呀? int const *ptr = &size; ``` 对,这是一个指涉到常量整数型

《C++覆辙录》——1.5:对引用的认识误区

1.55:对引用的认识误区 对于引用的使用,主要存在两个常见的问题.首先,它们经常和指针搞混.其次,它们未被充分利用.好多在C++工程里使用的指针实际上只是C阵营那些老顽固的杰作,该是引用翻身的时候了. 引用并非指针.引用只是其初始化物的别名.记好了,能对引用做的唯一操作就是初始化它.一旦初始化结束,引用就是其初始化物的另一种写法罢了(凡事皆有例外,请看常见错误44).引用是没有地址的,甚至它们有可能不占任何存储: int a = 12; int &ra = a; int *ip = &r

《C++覆辙录》——1.3:全局变量

1.3:全局变量 很难找到任何理由去硬生生地声明什么全局变量.全局变量阻碍了代码重用,而且使代码变得更难维护.它们阻碍重用是因为任何使用了全局变量的代码就立刻与之耦合,这使得全局变量一改它们也非得跟着改,从而使任何重用都不可能了.它们使代码变得更难维护的原因是很难甄别出哪些代码用了某个特定的全局变量,因为任何代码都有访问它们的权限. 全局变量增加了模块间的耦合,因为它们往往作为幼稚的模块间消息传递机制的设施存在.就算它们能担此重任,从实践角度来说8,要从大型软件的源代码中去掉任何全局变量都几乎不