《Imperfect C++中文版》——2.3 MIL及其优点

简介:

本节书摘来自异步社区出版社《Imperfect C++中文版》一书中的第2章,第2.3节,作者: 【美】Matthew Wilson,更多章节内容可以访问云栖社区“异步社区”公众号查看。

2.3 MIL及其优点

Imperfect C++中文版
你会在构造函数中进行初始化的东西可能包括以下7种:

1.直接父类。

2.虚基类。1

3.常量型成员变量。

4.引用型成员变量。

5.non-const、non-reference、但“具有非缺省构造函数的用户自定义类型”的成员变量。

6.non-const、non-reference的标量型成员变量,我们可以把它们看成“常规的”成员变量。

7.数组作为成员变量。

在这7种中,只有最后一种,即数组型成员变量,不能在MIL(member initializer list,成员初始化列表)中进行初始化。前面6种则可以,尤其是前5种,必须在成员初始化列表中初始化。通常,non-const、non-reference的标量型成员变量既可以在初始化列表中初始化,又可以在构造函数体中“初始化”。实际上,对于后面一种情况,它们是被赋值而非初始化。尽管对于该类的用户来说,这看起来可能无异于初始化,且对于标量来说甚至不会引入任何额外的指令,然而这种“初始化”(实际上是赋值)却无法确保按照诸成员被声明的顺序逐一进行。如果你又确实依赖于成员声明/初始化顺序的话(这种情况非常罕见,并且属于糟糕的编程实践(见2.3.2小节)),你会遭遇令人惊讶的行为。退一步说,即使不论成员初始化列表在这种罕见的情况下能够确保代码正确性的问题,也有很好的其他理由支持对它的使用[Stro1997]:如果在构造函数体中进行“初始化”的话,你可能是在对一个已经因为非平凡的构造而付出代价的对象进行代价高昂的赋值操作,从而既损失了运行期的速度,同时什么好处都没得到。2

如果你跟我一样,都有使用const变量的偏好,毫无疑问你也会大量使用初始化列表,而我则会在任何时候都建议人们这么做。它不仅能帮助你避免“不恰当地使用具有非平凡类型的成员变量”(例如代价高昂的“缺省构造+赋值”),还能改善一致性。尽管一致性是良好开发的软件的显著特征之一,然而在软件开发中,人们还是在相当大的程度上低估了它的作用[Kern1999]。另外,在32.2节我们将会看到,在存在异常的情况下,初始化列表还可以带来一些好处。

从苦行僧式编程的角度来说,成员初始化列表还促进了对const成员的使用(如前一小节所述)。此外,你还可以通过它来避免像程序清单2.2中那样的示例代码,这段代码来自一个真实的代码库,而我则被要求对它进行“改进”:

程序清单2.2

class Abc
  : public Base
{
// 成员
protected:
  CString  m_str1;
  int      m_int1;
  int      m_int2;
  CString  m_str2;
  CString  m_str3;
  int      m_int3;
  CString  m_str4;
  int      m_int4;
  CString  m_str5;
  int      m_int5;
  int      m_int6;
  . . . // 接下来还有!
};
Abc::Abc(int i1, int i2, int i3, int i4, int i4
        , LPCTSTR pcsz1, LPCTSTR pcsz2
        , LPCTSTR pcsz3, LPCTSTR pcsz4)
  : Base(int i)
  , m_str1(pcsz1)
  , m_str2(pcsz2)
{
  m_str3 = pcsz3;
  m_int1 = i1;
  m_int2 = i2;
  m_int3 = i3;

  m_str2 = pcsz2;

  . . . // 许多行之后...
  m_int3 = i3;

  m_str2 = pcsz4; // 哎呀!

  m_int2 = i2;


  m_int6 = i6;
  . . . // 下面还有...
}

在这个构造函数体中足足有20多个多余且具有潜在危险的赋值操作。从该类实现的剩余部分可以看出,其中有些成员变量一经赋值就再也没有改变过,将它们改成const的之后立即就会发现这些问题。既然问题已经很明显了,接下来我就把那一堆赋值操作通过一个自动工具统统移到初始化列表里面,接着编译器就友好地为我指出了那些重复初始化的变量。整个过程只用了10分钟,结果就使该类摆脱了原来做法中存在的相当程度上的浪费,以及若干个错误。

2.3.1 取得一块更大的场地

反对成员初始化列表的辩辞之一是:成员初始化列表所提供的“工作场地”太小了。通常人们抱怨在成员初始化列表的限制之下很难进行有效和高效的参数合法性验证或操纵。这为人们避开初始化列表以及const/reference成员提供了藉口。然而我认为这种说法很大程度上是错误的,只要稍微发挥一点想象力,我们就可以提供既健壮又具有合理的约束性(也就是说,穿上我们的“苦行衣”)同时仍然高效的初始化方案。考虑下面的类:

class String
{
// 构造函数
public:
  String(char const *s);
// 成员
private:
  char *m_s;
};

Steve Dewhurst在他的著作C++ Gotchas [Dewh2003]中为此类构造函数的实现指出了两种可能(在书中的同一部分,其他情况下他仍然对成员初始化列表持支持态度):

String::String(char const *s)
  : m_s(strcpy(new char[strlen(s ? s : "") + 1], s ? s : ""))
{}
String::String(char const *s)
{
  if(s == NULL)
  {
    s = "";
  }
  m_s = strcpy(new char[strlen(s) + 1], s);
}

Steve说第一种形式做得太过火了(我想大多数人都会赞同这一点),他偏好第二种。坦白地说,两种情况我都不会选。为什么不穿上我们的苦行衣(就像第一种形式试图做的那样),同时又给我们自己一个喘息的余地呢?解决方案非常简单(见程序清单2.3):

程序清单2.3

String
{
  . . .

// 实现

private:

  static char *create_string_(char const *s);

// 成员
private:

  char const *const m_s;


};
/* static */ char *String::create_string_(char const *s)

{

  if(s == NULL)

  {

    s = "";

  }

  return strcpy(new char[strlen(s) + 1], s);

}


String::String(char const *s)

  : m_s(create_string_(s))


{}

我们既不要类似第一种形式的冗长的代码,也不想偷偷地采用糟糕的编程实践,而是通过将逻辑提炼出来放入一个私有(静态)的辅助函数中,从而既获得了清晰的表达,又实现了正确的初始化。现在看起来很简单,不是吗?注意到String类的构造函数的异常(exception)行为并没有因此而改变,这也是该技术的一个重要方面。

String对象既可能通过一个指向字符串的指针来创建,也可能通过一个空的字面字符串("")进行创建。我们将会在15.4.3小节看到,字面量可能也可能不被“折叠”起来,即相同的字面量可能会被连接器合并成一个,因此上面的实现只是一个半成品,更完备的实现必须解决这个潜在的问题。

同时请注意对成员m_s的改动。既然Steve的例子中没有表现出任何改变String类的状态的操作,我们就可以令m_s成为一个“指向常量字符串的常量指针”,从而进一步限制我们自身。如果日后我们对该类作出了修改,加进了“改动性(mutating)”的操作,不管这种改动是对于指针所指的缓存还是指针本身而言,编译器都会提醒我们违反了最初的设计决策。这完全没有什么问题。得到这么个错误并不意味着我们做错了什么,仅仅意味着我们可能是在挑战当初的设计决策。关键在于我们将被迫对此进行思考,这从任何方面来说都只有好处,没有坏处。

改进后的形式还有另外两个优点。较小的优点是代码看起来更干净了。现在我们可以很明显地看出构造函数的意图,就是“create string ()”。

另一个更为显著的优点是:它可以把创建String的内容的动作集中到同一处(create string ()中),从而在String的定义不断充实的过程中得以复用(事实上,在我曾写过的实际的字符串类中,通常会有好几个构造函数使用同一个静态辅助创建函数。这自然提高了可维护性并缩减了代码尺寸,同时对运行期性能不会造成任何影响)。我们经常会在同一个类的多个构造函数中看到相同的逻辑。有时候,这被集中到一个Init()方法中,并且从每个构造函数体中调用它,但是那样做就丢掉了初始化列表的高效性,并且缩小了使用const/reference成员的空间。使用这种静态辅助函数技术可以促使人们把公共构造操作集中至一处,同时既不牺牲效率又不损及安全。这种技术从效果上说类似于Java具备的“在一个构造函数中调用另一个构造函数”的能力。

注意,为了实现所需的效果,辅助方法(例如create string ())不一定要是静态的。然而,如果它是非静态的话,就会为在其中使用成员状态而留下“后门”,然而由于此时构造函数才执行了一部分,所以成员尚处于未定义状态。因此,最好还是使用静态辅助函数(穿上我们的“苦行衣”)。

在第11章,当我们探究自适应编码技术(见11.4.2小节)时,我们将会看到一些更成熟、精巧的应用示例。

2.3.2 成员顺序依赖

使用初始化列表的一个告诫是:成员变量是按照它们被声明的顺序来初始化的,与它们在初始化列表中的出现顺序无关。这个告诫[Stro1997, Dewh2003]是在告诉你将它们按照声明的顺序放置在初始化列表中。的确,在你的维护工作中,确保声明中的变动反映在初始化列表中是一件相当耗费精力的事情,并且,当被要求作出改动时,你还得去检查其他作者所作的改动。可能会出现各种各样的麻烦[Dewh2003, Meye1998],任何一种都会带来不愉快的结果。

struct Fatal
{
  Fatal(int i)
    : y(i)
    , x(y * 2)
  {}
  int x;
  int y;
};

尽管Fatal的初始化列表看上去天真无邪,然而事实上Fatal的实例的x成员会被初始化为任意的垃圾值,这是因为在x被初始化时,y还没有被初始化。你应该把“避免这种依赖”当成一个戒条。只有GCC可以侦测到这一点并给出一个警告(当启用-Wall编译选项时)。

尽管上面的建议言之凿凿,然而,既然这本书是关于如何在现实世界中生存的,所以有时候难免还要铤而走险。当我们必须依赖成员变量的初始化顺序时,作为一个“不完美主义的实践者”,该如何保护我们的代码不被维护期的危险改动而破坏呢?答案是使用编译期断言(见1.4节)。你可以在auto_buffer类模板最初的实现中找到关于如何使用编译期断言来保护成员顺序的例子,我们将会在32.2节中了解到auto_buffer的细节。其构造函数包含了一个保护性的断言,如下:

auto_buffer:: auto_buffer(size_type cItems)
  : m_buffer((space < cItems)
                  ? allocator_type::allocate(cItems, 0)
                  : m_internal)
  , m_cItems((m_buffer != 0) ? cItems : 0)
{
  STATIC_ASSERT( offsetof(class_type, m_buffer)
                  < offsetof(class_type, m_cItems));
  . . .

如果类定义中的m_buffer和m_cItems成员的顺序被改动了的话,这段代码就通不过编译。这就意味着:依赖于成员初始化顺序的危险实践变得安全了,类实现也变得健壮并且可移植了。

唉,可惜这个类并非关于“何时应该把戒条放在一旁”的最好的例子,因为它所用到的offsetof宏在这种情况之下本身就是非标准的用法(见2.3.3小节)。

该类的最新版本是可对缓冲区大小进行调节的,因此m_cItems的常性(constness)的优势也就不复存在了。因此将构造函数重写如下可能会更好一些:

auto_buffer:: auto_buffer(size_type cItems)
  : m_buffer((space < cItems)
                  ? allocator_type::allocate(cItems, 0)
                  : m_internal)
{
  m_cItems = (m_buffer != 0) ? cItems : 0;
  . . .

现在再也不需要维持特定的成员顺序了,从而也就不再需要以一种不那么合法的方式去使用offsetof()了。

2.3.3 offsetof()

offsetof宏被用于在编译期推导出某个结构的成员相对于该结构起点的偏移,偏移量按字节计。其标准实现如下:

#define offsetof(S, m)   (size_t)&(((S*)0)->m)

这是个极其有用的宏,如果没有它的话,许多巧妙而有意义的事情将难以完成。例如,在C++中提供属性的技术(见第35章)如果没有它的帮忙就不会像现在这样高效。

唉,不过它只有被用于POD类型时才是合法的,C++标准说它所应用的类型“应该是一个POD structure或者POD union”(见C++-98: 18.1)。这就意味着将它运用在类类型(包括auto_buffer类)身上是非法的,并且有导致未定义行为的潜在可能。虽说如此,offsetof还是得到了相当广泛的使用,其中包括几个流行的库,并且,将它使用在像auto_buffer这样的类身上是完全合理的。C++标准之所以说它只可以被用于POD类型上,是因为当存在多重虚继承时它不可能产生一个正确的编译期值(即计算出正确的偏移量)。目前的规则可能过于苛刻了,不过尽管如此,如何对类型的内存进行布局仍然取决于特定的实现。

因为我是一名实用主义者,所以我会在任何我觉得必要且合适的地方使用它,当然我也会确保自己采取了防卫措施:运行期断言、静态断言(见第1章)及测试。如果你和我一样,请别忘记告诫,这样,当某个“语言律师”把你的“和标准不一致”的代码宣扬给你的同事时,你就可以坦承它的确是非标准的,先化解他的敌意,然后告诉他之所以可在特定的情况下这么做的理由以及你所测试通过的环境3,从而证明你是正确的,最终叫他哑口无言。

2.3.4 MIL:尾声

除了某些极端的情况外,我建议你尽量始终使用初始化列表,除了对数组不能使用外,几乎任何地方都可以。有时人们会以“为了和(设计得很差的)开发工具提供的向导保持一致”作为他们使用赋值操作而非初始化列表的辩护之词,我对此感到非常惊讶。为简化和增强编程实践而设计的工具中存在的缺陷,使得一代软件开发者在成长过程中养成了一些坏习惯,这似乎是一种“绝妙的”讽刺、极大的悲哀。

1不过,在现实中你几乎不会需要用到包含数据成员或构造函数的虚基类,不是吗?[Meye1998, Dewh2003, Stro1997]。
2译者注:让我们详细解释一下这句话的意思,希望不是多余的重复。如果在构造函数体中进行“初始化”的话,你可能会付出相当昂贵的代价,这种代价起因于你的“初始化”其实是赋值,而这种赋值是发生在成员既有的构造行为之后(标量除外),如果这种构造是非平凡的的话,其本身就已经付出了运行期代价,并且如果你的成员具有一个代价高昂的赋值操作行为的话,你就会损失运行期的速度,同时却什么好处也得不到。
3译者注:即编译器。
本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。

相关文章
|
机器学习/深度学习 IDE Ubuntu
《C++ Primer中文版(第5版)》学习笔记与习题完整发布!
《C++ Primer中文版(第5版)》学习笔记与习题完整发布!
455 0
《C++ Primer中文版(第5版)》学习笔记与习题完整发布!
|
JavaScript 前端开发 程序员
|
C语言 C++ 容器
C++11 FAQ中文版--转
更新至英文版October 3, 2012 译者前言: 经过C++标准委员会的不懈努力,最新的ISO C++标准C++11,也即是原来的C++0x,已经正式发布了。让我们欢迎C++11! 今天获得Stroustrup先生的许可,开始翻译由他撰写和维护的C++11 FAQ。
1580 0
|
C++
C++ Primer中文版(第5版)
http://product.china-pub.com/3802148#ml
968 0
|
程序员 C++
【转】c++.primer.plus.第五版.中文版[下载]
c++.primer.plus.第五版.中文版[下载]一共有5部分。全部下载完才可解压阅读。c++.primer.plus.第五版.中文版(一)c++.primer.plus.第五版.中文版(二)c++.primer.plus.第五版.中文版(三)c++.primer.plus.第五版.中文版(四)c++.primer.plus.第五版.中文版(五)“在遇到无法解决的问题时,我总会求助于C++ Primer一书。
1389 0
|
4天前
|
存储 编译器 C语言
c++的学习之路:5、类和对象(1)
c++的学习之路:5、类和对象(1)
19 0