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