static_assert是c++11添加的新语法,它可以使我们在编译期间检测一些断言条件是否为真,如果不满足条件将会产生一条编译错误信息。

使用静态断言可以提前暴露许多问题到编译阶段,极大的方便了我们对代码的排错,提前将一些bug扼杀在摇篮里。

然而有时候静态断言并不能如我们预期的那样工作,今天就来看看这些“不正常”的情况,我将举两个例子,每个都有一定的代表性。

为什么我的static_assert不工作

基于静态断言可以在编译期触发,我们希望实现一个模板类,类型参数不能是int,如果违反约定则会给出编译错误信息:

template <typename T>
struct Obj {
    static_assert(!std::is_same_v<T, int>, "T 不能为 int");
    // do sth with a
};
int main() {
    Obj<int> *ptr = nullptr;
}

按照预期,这段代码应该触发静态断言导致无法编译,然而实际运行的结果却是:

g++ --version
g++ (GCC) 12.2.0
Copyright © 2022 Free Software Foundation, Inc.
本程序是自由软件;请参看源代码的版权声明。本软件没有任何担保;包括没有适销性和某一专用目的下的适用性担保。
g++ -std=c++20 -Wall -Wextra error.cpp
error.cpp: 在函数‘int main()’中:
error.cpp:10:15: 警告:unused variable ‘ptr’ [-Wunused-variable]
   10 |     Obj<int> *ptr = nullptr;
      |               ^~~

事实上除了警告我们ptr没有被使用,程序被正常编译了。换clang是一样的结果。也就是说,static_assert根本没生效。

这不应该啊?我们明明用到了模板,而static_assert作为类的一部分应该也被编译器检测到并被触发才对。

答案就是,static_assert确实没有被触发。

我们先来看看模板类中static_assert在什么时候生效:当需要显式或者隐式实例化这个模板类的时候,编译器就会看见这个静态断言,然后检查断言是否通过。

但我们这里不是有Obj<int> *ptr吗,这难道不会触发实例化吗?答案在c++的标准里:

Unless a class template specialization has been explicitly instantiated (17.7.2) or explicitly specialized (17.7.3), the class template specialization is implicitly instantiated when the specialization is referenced in a context that requires a completely-defined object type or when the completeness of the class type affects the semantics of the program. -- C++17 standard §17.7.1

意思是说,除了显式实例化,模板类还会在需要它实例化的上下文里被隐式实例化,重点在于那个a completely-defined object type

这个“完整的对象类型”是什么呢?很简单,就是一个编译器能看到其完整的类型定义的类型,举个例子:

class A;
class B {
    int i = 0;
};

这里的B就是完整的,而A是不完全类型,一个更为人熟知的称法是:class A是类A的前置声明。

因为我们没有A的完整定义,所以我们只能声明A*或者A&类型的变量或者将A作为函数签名的一部分,但不能A instance或者new A。因为前两者是对A的引用,本身不需要知道完整的A是什么样的,而作为函数签名的一部分的时候并不涉及生成实际需要A的代码,因此也可以使用不完全类型。

所以当你定义一个指针或者引用变量,又或者在写函数或者类方法的签名时,他们并不关心前面的类型,只要这个类型的“名字”是存在的且合法的就行,在这些地方并不会导致模板的实例化。所以静态断言没有被触发。

如何修复这个问题?不使用模板类的指针或者引用可以解决大部分问题,把示例里的Obj<int> *ptr = nullptr改成Obj<int> ptr;,立刻就报错了:

g++ -std=c++20 -Wall -Wextra error.cpp
error.cpp: In instantiation of ‘struct Obj<int>’:
error.cpp:12:14:   required from here
error.cpp:5:25: 错误:static assertion failed: T 不能为 int
    5 |     static_assert(!std::is_same_v<T, int>, "T 不能为 int");
      |                    ~~~~~^~~~~~~~~~~~~~~~~
error.cpp:5:25: 附注:‘!(bool)std::is_same_v<int, int>’ evaluates to false
error.cpp: 在函数‘int main()’中:
error.cpp:12:14: 警告:unused variable ‘ptr’ [-Wunused-variable]
   12 |     Obj<int> ptr;
      |              ^~~

如果我就要指针呢?那也别用原始指针,请用智能指针:std::unique_ptr<Obj<int>> ptr;:

g++ -std=c++20 -Wall -Wextra error.cpp
error.cpp: In instantiation of ‘struct Obj<int>’:
/usr/include/c++/12.2.0/bits/unique_ptr.h:93:16:   required from ‘void std::default_delete<_Tp>::operator()(_Tp*) const [with _Tp = Obj<int>]’
/usr/include/c++/12.2.0/bits/unique_ptr.h:396:17:   required from ‘std::unique_ptr<_Tp, _Dp>::~unique_ptr() [with _Tp = Obj<int>; _Dp = std::default_delete<Obj<int> >]’
error.cpp:13:28:   required from here
error.cpp:6:25: 错误:static assertion failed: T 不能为 int
    6 |     static_assert(!std::is_same_v<T, int>, "T 不能为 int");
      |                    ~~~~~^~~~~~~~~~~~~~~~~
error.cpp:6:25: 附注:‘!(bool)std::is_same_v<int, int>’ evaluates to false

模板参数可以是不完整类型,但这里智能指针在析构的时候必须要有完整的类型定义,所以同样触发了类型断言。

这里还有个坑,shared_ptr可以使用不完整类型,但从原始指针构造shared_ptr或者使用它的方法的时候,不接受非完整类型,所以上述代码用shared_ptr是不行的。

unique_ptr虽然也可以使用不完整类型,但必须在智能指针对象被析构的地方可以看到被销毁的类型的完整定义,上面的例子正是在这一步的时候需要类型的完整定义,从而触发隐式实例化,所以触发了静态断言。

如果我一定要用引用呢?通常这没有问题,因为引用需要绑定到一个对象,在函数参数里的话虽然没显式绑定也很可能在函数代码里使用了具体类型的某些方法,这些都需要完整的类型定义,从而触发断言。如果是引用作为类的成员,则必须提供一个构造函数来保证初始化这些引用,所以也没有问题。如果你真的遇到问题了,也可以实现现代c++推崇的值语义和移动语义;否则,你应该思考下自己的设计是否真的合理了。

为什么我的static_assert意外生效了

如果代码里没有实例化模板类的操作(包括显式和隐式)编译器就不会主动去生成模板的实例,是不是可以利用这点来屏蔽某些不需要的模板重载被触发呢?

看个例子:

template <typename T>
struct Wrapper {
    // 只接受std::function
    static_assert(0, "T must be a std::function");
};
template <typename T, typename... U>
struct Wrapper<std::function<T(U...)>> {
    // do sth
};
int f(int i) {
    return i*i;
}
int main() {
    std::function<int(int)> func{f};
    Wrapper<decltype(func)> w;
}

我们的Wrapper包装类只接受std::function,其他的类型不能正常工作,在c++20的concept出来之前我们只能用元编程的做法来实现类似的功能。上面的代码与SFINAE手法相比既简单有好理解。

但当我们编译程序:

g++ -std=c++20 -Wall -Wextra error.cpp
error.cpp:6:19: 错误:static assertion failed: T must be a std::function
    6 |     static_assert(0, "T must be a std::function");
      |                   ^
error.cpp: 在函数‘int main()’中:
error.cpp:20:29: 警告:unused variable ‘w’ [-Wunused-variable]
   20 |     Wrapper<decltype(func)> w;
      |                             ^

静态断言竟然被触发了?明明我们的代码里没有实例化会报错的那个模板的地方啊。

这时候看代码是没什么用的,需要看标准怎么说的:

If no valid specialization can be generated for a template definition, and that template is not instantiated, the template definition is ill-formed, no diagnostic required.

如果模板没有被使用,也没有任何针对它的特化或部分特化,且模板内部的代码有错误,编译器并不需要给出诊断信息。

重点在于“no diagnostic required”,它说不需要,但也没禁止,所以检测到模板内部的错误并报错也是正常的,不报错也是正常的,这个甚至不算undefined behavior

而且我们的静态断言里所有的内容都能在编译期的初步检查里得到,所以g++和clang++都会产生一条编译错误。

那么怎么解决问题呢?我们要确保模板里的代码至少进行模板类型推导前都是没法判断是否合法的。因此除了没什么语法上明显的错误,我们需要让静态断言依赖模板参数:

template <typename T>
struct Wrapper {
    // 只接受std::function
    static_assert(sizeof(T) < 0, "T must be a std::function");
};

所有能被sizeof计算的类型大小都不会比0小,所以这个断言总会失败,而且因为我们的断言依赖模板参数,所以除非真的实例化这个模板,否则没法判断代码是不是合法的,因此编译期也不会触发静态断言。

下面是触发断言的结果:

g++ -std=c++20 -Wall -Wextra error.cpp
error.cpp: In instantiation of ‘struct Wrapper<int>’:
error.cpp:20:18:   required from here
error.cpp:6:29: 错误:static assertion failed: T must be a std::function
    6 |     static_assert(sizeof(T) < 0, "T must be a std::function");
      |                   ~~~~~~~~~~^~~
error.cpp:6:29: 附注:the comparison reduces to ‘(4 < 0)’
error.cpp: 在函数‘int main()’中:
error.cpp:20:18: 警告:unused variable ‘w’ [-Wunused-variable]
   20 |     Wrapper<int> w;
      |                  ^

现在静态断言可以如我们预期的那样工作了。

总结

要想避免static_assert不按我们预期的情况来工作,需要遵守下面的原则:

参考

https://stackoverflow.com/questions/5246049/c11-static-assert-and-template-instantiation

https://blog.knatten.org/2018/10/19/static_assert-in-templates/

发表回复