《Imperfect C++中文版》——1.2 编译期契约:约束

1.2 编译期契约:约束

Imperfect C++中文版
本章讲述编译期强制,通常它也被称为“约束(constraints)”。遗憾的是,C++并不直接支持约束。

Imperfection: C++ 不直接支持约束。
C++是一门极其强大和灵活的语言,因此很多支持者(甚至包括一些C++权威)都会认为本节描述的约束实现技术已经足够了。然而,作为C++和约束的双重拥护者,我必须提出我的异议(由于一些很平常的原因)。虽然我并不买其他语言鼓吹者的账,然而我同样认为阅读因违反约束而导致的编译错误信息是很困难(甚至极其困难)的。它们通常令人眼花缭乱,有时甚至深奥无比。如果你是“肇事”代码的作者,那么问题通常并不严重,然而,当面对的是一个多层模板实例化的情形时,其失败所导致的错误信息近乎天书,难以理解,甚至非常优秀的编译器都是如此。本节后面我们会看到一些约束,以及违反它们所导致的错误信息,还有可以令错误信息稍微更具可读性的一些措施。

1.2.1 must_have_base()

这个例子是从comp.lang.c++.moderated新闻组上几乎原样照搬过来的,是Bjarne Stroustrup发的帖子,他称之为“Has base”。[Sutt2002]对此亦有描述,不过换了个名字,叫做IsDerivedFrom。而我则喜欢将约束的名字以“must_”开头,因此我把它命名为must_have_base。

程序清单1.1

template< typename D
        , typename B
        >
struct must_have_base
{
  ~must_have_base()
  {
    void(*p)(D*, B*) = constraints;
  }
private:
  static void constraints(D* pd, B* pb)
  {
    pb = pd;
  }
};

它的工作原理如下:在成员函数constraints()中尝试把D类型的指针赋值给B类型的指针(D和B都是模板参数),constraints()是一个单独的静态成员函数,这是为了确保它永远不会被调用,因此这种方案没有任何运行期负担。析构函数中则声明了一个指向constraints()的指针,从而强迫编译器至少要去评估该函数以及其中的赋值语句是否有效。

事实上,这个约束的名字有点不恰当:如果D和B是同样的类型,那么这个约束仍然能够被满足,因此,它或许应该更名为must_have_base_or_be_same_type,或者类似这样的名字。另一个替代方案是把must_have_base进一步精化,令它不接受D和B是同一类型的情况。请将你的答案写在明信片上寄给我。

另外,如果D和B的继承关系并非公有继承,那么该约束也会失败。在我看来,这是个命名方式的问题,而不是约束本身的能力缺陷问题,因为我只需要对采用公有继承的类型应用这个 约束。1

由于“must_have_base”在其定义中所进行的动作恰好体现了约束本身的语义,所以如果该约束失败,错误信息会相当直观。事实上,我手头所有编译器(见附录A)对此均提供了非常有意义的信息,即要么提到两个类型不具有继承关系,要么提到两个指针类型不能互相转换,或与此类似的信息。

1.2.2 must_be_subscriptable()

另一个有用的约束是要求一个类型可以按下标方式访问(见14.2节),实现起来很简单:

template< typename T>
struct must_be_subscriptable
{
  . . .
  static void constraints(T const &T_is_not_subscriptable)
  {
    sizeof(T_is_not_subscriptable[0]);
  }
  . . .

为了提高可读性,constraints()的形参被命名为T_is_not_subscriptable,这可以为那些违反约束的可怜的人们提供些许线索。考虑下面的例子:

struct subs
{
public:
  int operator [](size_t index) const;
}
struct not_subs
{};
must_be_subscriptable<int[]>    a; // int*可以按下标方式访问
must_be_subscriptable<int*>     b; // int*可以按下标方式访问
must_be_subscriptable<subs>     c; // subs可以按下标方式访问
must_be_subscriptable<not_subs> d; // not_subs不可以按下标方式访问,编译错误

Borland 5.6给出了令人难以置信的信息:“'operator+' not implemented in type '' for arguments of type 'int' in function must_be_subscriptable::constraints(const not_subs &)”。而当模板实例化深度多达15层时,不绞尽脑汁简直是不可能搞清楚这些错误信息的含义的。

相对而言,Digital Mars更为准确一些,不过仍然不够好:“Error: array or pointer required before '['; Had: const not_subs”。

另外一些编译器的错误信息都包含了变量名“T_is_not_subscriptable”。其中最优秀的当数Visual C++给出的错误信息:“binary '[' : 'const struct not_subs' does not define this operator or a conversion to a type acceptable to the predefined operator while compiling class-template member function 'void must_be_subscriptable::constraints (const struct not_subs &)”。

1.2.3 must_be_subscriptable_as_decayable_pointer()

在第14章中,我们会深入考察数组和指针之间的关系,并了解有关指针偏移的一些诡秘的特性,它致使offset[pointer]这种语法成为跟pointer[offset]一样合法且等价的形式(这也许使Borland编译器关于must_be_subscriptable的莫名其妙的编译错误看起来不是那么毫无意义,不过它对于我们追踪并理解约束是如何被违反的确实有很大的帮助)。由于这种颠倒形式对于重载了operator []的类(class)类型是无效的,因此must_be_subscriptable还可以被进一步精化,从而把它作用的类型约束到仅限于(原生)指针类型。

程序清单1.2

template <typename T>
struct must_be_subscriptable_as_decayable_pointer
{
  . . .
  static void constraints(T const &T_is_not_decay_subscriptable)
  {
    sizeof(0[T_is_not_decay_subscriptable]); // 译注:offset[pointer]形式,
// 仅仅作用于原生指针。
  }
  . . .
};

很明显,所有可以通过offset[pointer]形式进行索引访问的pointer也都可以接受pointer[offset]操作,因此不需要把must_be_subscriptable合并到must_be_subscriptable_as_decayable_pointer中去。不过,尽管这两种约束有着不同的结果,但利用继承机制将二者结合起来也是合适之举。

现在我们可以区分(原生)指针和其他可进行索引访问的类型了:

must_be_subscriptable<subs>                          a; // ok
must_be_subscriptable_as_decayable_pointer<subs> b; // 编译错误

1.2.4 must_be_pod()

你会在本书中多次看到must_be_pod()的使用(见19.5、19.7、21.2.1和32.2.3节)。这是我编写的第一个约束,那时我根本不知道约束是什么,甚至连POD是什么意思都不清楚(见“序”)。must_be_pod()非常简单。

C++98标准9.5节第1条说明:“如果一个类具有非平凡的(non-trivial)构造函数、非平凡的拷贝构造函数、非平凡的析构函数,或者非平凡的赋值操作符,那么其对象不能作为联合(union)的成员”。这恰好满足我们的需要,并且,我们还可以设想,这个约束和我们已经看到的那些模样差不多:有一个constraints()成员函数,其中包含一个union:

template <typename T>
struct must_be_pod
{
  . . .
  static void constraints()
  {
    union
    {
      T   T_is_not_POD_type;
    };
  }
  . . .

遗憾的是,这是编译器容易发生奇怪行为的领域,所以真正的定义没有这么简单,而是需要许多预处理器操作(见1.2.6节),但效果还是一样的。

在19.7节中,我们将会看到这个约束和一个更专门化的约束must_be_pod_or_void()一起使用,目的在于能够检查某个指针所指的是否为非平凡的类类型。为此,我们需要对must_be_pod_or_void模板进行特化[Vand2003],而其泛化的定义则与must_be_pod相同:

template <typename T>
struct must_be_pod_or_void
{
  . . . // 和must_be_pod一样
};
template <>
struct must_be_pod_or_void<void>
{
  // 该特化的定义是空的,里面什么也没有,所以不会找编译器的麻烦。
};

同样,编译器对于违反must_be_pod / must_be_pod_or_void约束所生成的信息也是各式各样的:

class NonPOD
{
public:
  virtual ~NonPOD();
};
must_be_pod<int>        a; // int是POD(见“序”)
must_be_pod<not_subs> b; // not_subs是POD(见“序”)
must_be_pod<NonPOD>   c; // NonPOD不是POD,编译错误

这一次,Digital Mars一惯的简练风格却给我们带来了麻烦,因为我们能得到的错误信息只是“Error: union members cannot have ctors or dtors”,指向约束类内部引发编译错误的那行代码。如果这是在一个大项目里的话,那么很难追溯到错误的源头,即最初引发这个错误的实例化点。而Watcom对于这么一个极小的错误给出的信息则是最多的:“Error! E183: col(10) unions cannot have members with constructors; Note! N633: col(10) template class instantiation for ''must_be_pod< NonPOD>' was in: ..constraints_test.cpp(106) (col 48)”。

1.2.5 must_be_same_size()

最后一个约束must_be_same_size()也在本书的后续部分被使用到(见21.2.1小节和25.5.5小节)。该约束类使用静态断言“无效数组大小”技术来确保两个类型的大小是一致的,我们很快就会在1.4.7节看到该技术。

程序清单1.3

template< typename T1
        , typename T2
        >
struct must_be_same_size
{
  . . .
private:
  static void constraints()
  {
    const int T1_not_same_size_as_T2
                                = sizeof(T1) == sizeof(T2);
    int       i[T1_not_same_size_as_T2];
  }
};

如果两个类型大小不一致,那么T1_not_same_size_as_T2就会被求值为(编译期)常数0,而将0作为数组大小是非法的。

至于must_be_pod_or_void,则是在两个类型中有一个或都是void时会用到它。然而,由于sizeof(void)是非法的表达式,因此我们必须提供一些额外的编译期功能。

如果两个类型都是void,则很容易,我们可以这样来特化must_be_same_size类模板:

template <>
struct must_be_same_size<void, void>
{};

然而,如果只有一个类型是void,要使其工作可就没那么直截了当了。解决方案之一是利用局部特化机制[Vand2003],然而并非所有目前广为使用的编译器都支持这种能力。进一步而言,我们还得同时为这个模板准备一个完全特化和两个局部特化版本(两个局部特化分别将第一个和第二个模板参数特化为void)。最后,我们还得构思某种方式来提供哪怕是“有点儿”意义的编译期错误信息。我没有借助于这种方法,而是通过令void成为“可size_of的”来解决这个问题。我的方案实现起来极其容易,并且不需要局部特化:

程序清单1.4

template <typename T>
struct size_of
{
  enum { value = sizeof(T) };
};
template <>
struct size_of<void>
{
  enum { value = 0 };
};

现在我们所要做的就是在must_be_same_size中用size_of来替代sizeof:

template< . . . >
struct must_be_same_size
{
  . . .
  static void constraints()
  {
    const int T1_must_be_same_size_as_T2
                        = size_of<T1>::value == size_of<T2>::value;
    int        i[T1_must_be_same_size_as_T2];
  }
};

现在我们可以对任何类型进行验证了:

must_be_same_size<int, int>   a; // ok
must_be_same_size<int, long>  b; // 依赖于硬件架构或编译器
must_be_same_size<void, void> c; // ok
must_be_same_size<void, int>  d; // 编译错误:void的“大小”是0

正如前面的约束所表现出来的,不同的编译器提供给程序员的信息量有相当大的差别。Borland和Digital Mars在这方面又败下阵来,它们提供的上下文信息极少甚至没有。在这方面,我认为Intel做得最好,它提到“zero-length bit field must be unnamed”,指出出错的行,并且提供了两个直接调用上下文,其中包括T1的实际类型和T2的实际类型,加在一起一共4行编译器输出。

1.2.6 使用约束

我比较喜欢通过宏来使用我的约束,宏的名字遵循“constraint_”的形式,2例如constraint_must_have_base()。这从多方面来说都是非常有意义的。

首先,容易无歧义地查找到它们。正因为如此,我才为约束保留了“must_”前缀。也许有人会争论说这个需求已经被满足了。然而使用宏的做法更具有“自描述”性。对观者而言,在代码中看到“constraint_must_be_pod()”,其意义更加明确。

其次,使用宏的形式更具有(语法上的)一致性。尽管我没有写过任何非模板的约束,然而并没有任何理由限制其他人那么做。此外,我发现尖括号除了导致眼睛疲劳外,没有什么其他 好处。

再次,如果约束被定义在某个名字空间中,那么在使用它们时必须加上冗长的名字限定。而宏则可以轻易地将名字限定隐藏起来,免得用户去使用淘气的using指令(见34.2.2小节)。

最后一个原因更实际。不同的编译器对相同的约束的处理具有细微的差别,为此需要对它们耍弄一些手段。例如,针对不同的编译器,constraint_must_be_pod()被定义成如下3种形式之一:

do { must_be_pod<T>::func_ptr_type const pfn =
                         must_be_pod<T>::constraint(); } while(0)

或者

do { int i = sizeof(must_be_pod<T>::constraint()); } while(0)
或者

STATIC_ASSERT(sizeof(must_be_pod<T>::constraint()) != 0)

利用宏,客户代码变得更加简洁优雅,否则它们会遭受大量的面目丑陋的代码的干扰。

1.2.7 约束和TMP
本书的一位审稿人提到部分约束可以通过TMP(template meta-programming,模板元编程)1实现,他说的很对。例如,must_be_pointer约束就可以通过结合运用静态断言(见1.4.7小节)和is_pointer_type(见33.3.2小节)来实现。如下:

#define constraint_must_be_pointer(T) \
  STATIC_ASSERT(0 != is_pointer_type<T>::value)

我之所以不采用TMP有几方面原因。首先,约束的编码看上去总是那么直观,因为一个约束只不过是对被约束类型所应该具备的行为的“模拟”。而对于TMP traits就不能这么说了,有些TMP traits可能会相当复杂。因此,约束较之模板元编程更易于阅读。

其次,在许多(尽管并非全部)情况下,要想“说服”编译器给出容易理解的信息,约束比traits(和静态断言)更容易一些。

最后,有些约束无法通过TMP trait来实现,或者至少只能在很少一部分编译器上实现。甚至可以说,约束越是简单,用TMP traits来实现它就越困难,对此must_be_pod就是一个极好的例子。

Herb Sutter在[Sutt2002]中曾示范了结合运用约束和traits的技术,当你在进行自己的实际工作中时,没有任何理由阻止你那么做。对我来说,我只不过更喜欢保持它们简洁且独立而已。

1.2.8 约束:尾声

本章所讲述的约束当然并非全部,然而,它们可以告诉你哪些东西是可实现的。约束的不足之处和静态断言一样(见1.4.8小节),那就是产生的错误信息不是特别容易理解。根据实现约束的机制的不同,你可能会看明白如“type cannot be converted”之类的错误信息,也可能会看到令人极其困惑的“Destructor for 'T' is not accessible in function ”之类的信息。

只要有可能,你应该通过为你的约束选择合适的变量或常量名字来改善错误信息。我们在本节已经看到了这方面的例子:T is not subscriptable、T is not POD type以及T1not_same_size as_T2。记住,要确保你选择的名字反映了出错的情况。想想违反了你的约束却看到诸如“T is valid type for constraint”之类的信息的可怜人吧!

最后,关于约束,还有一个值得强调的重要方面:随着我们对编译期计算和模板元编程的认识越来越清晰,我们可能会想去更新某些约束。也许你已经从本书中描述的某些组件中看出我并非模板元编程方面的大师,不过重点是,通过良好地设计用于表现和使用约束的方式,我们可以在以后学到更多的技巧时再来“无缝”地更新我们的约束。我承认我自己有好多次就是这么做的,我不觉得这是什么丢人的事情。不过,我早期在编写约束方面的尝试如果拿出来现眼倒的确令人赧颜(我没有在附录B中提到它们,因为它们还没有差劲到那个地步,远远没有)!

1这听起来像是循环论证,不过我敢保证它不是。
2正如12.4.4小节所描述的原因,把宏命名为大写形式总是好习惯。之所以我对约束的宏没有采用同样的命名习惯,是因为我想要和约束类型保持一致(小写)。事后我才发现这么做令它们看起来不怎么显眼。你自己编写的约束当然可以采用大写风格。
本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。

时间: 2024-11-08 23:32:55

《Imperfect C++中文版》——1.2 编译期契约:约束的相关文章

《Imperfect C++中文版》导读

前 言 Imperfect C++中文版 或许我不像喜欢我的孩子们那样喜爱C++,甚至或许我对C++的喜爱都比不上我对骑自行车在坡度为32°.光滑度为10%的柏油路上爬坡的热衷,1尽管有时这些喜爱之情的确十分接近.我庆幸我有这样的人生,让我得以将生命中的部分时间用来实践或阐释Frederick P.Brooks的名言:"尽量发挥想象力进行创造".我更要感激的是我能够跟这门如此强大.危险却又诱人的语言相伴. 这些话听起来似乎蛮华丽动听,但你可能是因为看到本书的书名才买下它的,以为本书是一

《Imperfect C++中文版》——1.3 运行期契约:前置条件、后置条件和不变式

1.3 运行期契约:前置条件.后置条件和不变式 Imperfect C++中文版 "如果例程的所有前置条件(precondition)已经被调用者满足了,那么该例程必须确保当它完成时所有后置条件(postconditions)(以及任何不变式)皆为真."--Hunt and Thomas, The Pragmatic Programmers [Hunt2000]. 如果我们无法执行编译期强制,那么还可以采用运行期强制.运行期强制的一个系统化的实现途径是指定函数契约.函数契约精确定义了在

《Imperfect C++中文版》——第1章 强制设计:约束、契约和断言

第1章 强制设计:约束.契约和断言 Imperfect C++中文版在我们设计软件时,我们希望软件根据设计而进行使用.这并非一句空话.在大多数情况下,很容易发生以意料之外的方式来使用软件,而这么做的结果往往是令人失望的. 大多数软件的文档几乎都是不完整,甚至是过时的,我坚信你也有这方面的经验.这并非单纯的错误或缺失,"如果还有比没有文档更糟的情形,那就是文档是错误的"[Meye1997].如果被使用的组件比较简单,使用得当,或者说是标准的或被普遍使用的,那么没有文档倒也不是什么大问题.

《Imperfect C++中文版》——第2章 对象生命期

第2章 对象生命期 Imperfect C++中文版本文仅用于学习和交流目的,不代表异步社区观点.非商业转载请注明作译者.出处,并保留本文的原始链接.

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

2.3 MIL及其优点 Imperfect C++中文版 你会在构造函数中进行初始化的东西可能包括以下7种: 1.直接父类. 2.虚基类.1 3.常量型成员变量. 4.引用型成员变量. 5.non-const.non-reference.但"具有非缺省构造函数的用户自定义类型"的成员变量. 6.non-const.non-reference的标量型成员变量,我们可以把它们看成"常规的"成员变量. 7.数组作为成员变量. 在这7种中,只有最后一种,即数组型成员变量,不

《Imperfect C++中文版》——2.2 控制你的客户端

2.2 控制你的客户端 Imperfect C++中文版 C++的一个重要且强大的特性是在编译期实施访问控制的能力.通过使用public.protected以及private[Stro1997]访问限定关键字,以及适当地使用friend关键字,我们可以控制客户端代码使用我们的类型的方式.这种控制在很多方面都是极其有用的,本书中就有很多技术利用了这种能力. 2.2.1 成员类型 控制外界对你的类实例的操纵的强大方式之一,是将类的成员声明为const和/或引用类型.因为常量和引用(以及const引用

《Imperfect C++中文版》——1.4 断言

1.4 断言 Imperfect C++中文版 在我看来,断言并非一个良好的报错机制,因为它们通常在同一个软件的调试版和发行版中的行为有着极大的差异.虽说如此,断言仍然是C++程序员确保软件质量的最重要的工具之一,特别是考虑到它被使用的程度和约束.不变式一样广泛.任何关于报错机制的文档,如果没有提到断言的话肯定不能算是完美的. 基本上,断言是一种运行期测试,通常仅被用于调试版或测试版的构建,其形式往往像这样: #ifdef NDEBUG # define assert(x) ((void)(0)

《Imperfect C++中文版》——2.1 对象生命周期

2.1 对象生命周期 Imperfect C++中文版 每个C++对象的生命周期都分为4段:不存在.部分构造.实例化.部分析构[Stro1997].此外,一个对象所占用的空间必须在该对象构造之前就分配好,并且在该对象析构之后被释放.1 对象可以以下例4种标准方式诞生. 全局对象,包括真正的"全局"对象.位于名字空间中的对象,以及作为类静态成员的对象,生存于任何函数的作用域之外.它们通常在main()函数被执行之前就已经构造完毕,并且在main()结束之后被自动销毁(见11.1节).它们

C++:显示接口&amp;amp;运行期多态 和 隐式接口&amp;amp;编译期多态

类(class)和面向对象: 显示接口(explicit interface): 即在源代码中可见, 可以在头文件内看到类的所有接口; 运行期多态(runtime polymorphism):成员函数是virtual, 传入类的引用或指针时, 在运行时, 会自动匹配接口, 可能是基类的接口, 也可能是派生类的; 模板(templates)和泛型编程(generic programming): 隐式接口(implicit interface):typename T, 在函数中, 所必须支持一组操作