2457 字
12 分钟
C++ 模板笔记(八):类型属性检查、SFINAE 陷阱与编译期计算
2022-12-14

几种类型属性和受支持操作#

std::is_copy_constructible / std::is_trivially_copy_constructible / std::is_trivially_copyable#

这几种比较看起来容易混淆的概念其实非常好理解。从字面意义上的好理解。

std::is_trivially_copyable#

用来检查某个型别是否是可平凡复制的。可平凡复制简而言之就是可以用memcpy这种按字节进行底层逐位拷贝而没有问题的。平凡的意思可以直接去cppreference看。核心来说,平凡的XX函数的含义基本都是这个函数不是由用户提供的。

std::is_trivially_copy_constructible#

用来检查某个型别是否有一个平凡的拷贝构造。所以说只要满足is_trivially_copyable,就一定满足is_trivially_copy_constructible

std::is_copy_constructible#

检查某个型别是否有个可访问的拷贝构造。

总结#

所以说这三种从上到下,是拷贝类型限制的从严格到宽松。

std::is_destructible / std::is_trivially_destructible / std::is_nothrow_destructible#

std::is_trivially_destructible#

有平凡的析构函数

std::is_nothrow_destructible#

析构函数不抛出异常

std::is_destructible#

有可访问的析构函数

总结#

这一套组更加清晰直观。我们可能只需要知道怎么用一下。一般来说,如果对象是可平凡析构的。那么压根不用调用析构函数。因为析构函数啥也不干。所以在某个实现了std::optional的代码中我们可以看到这种代码段

template <class T, bool = ::std::is_trivially_destructible<T>::value>
struct optional_storage_base {
//....其他代码
~optional_storage_base() {
if (m_has_value) {
m_value.~T(); //显式调用伪析构函数。学名就叫伪析构函数。
m_has_value = false;
}
}
//....其他代码
};
// 可平凡析构的特化。
template <class T>
struct optional_storage_base<T, true> {
//....其他代码
// No destructor, so this class is trivially destructible
//....其他代码
};

延伸#

来自PVS Studio的这篇文章的Program execution: trivial types and ABI章节

  • trivially_constructible 意味着不需要初始化某些东西。
  • trivially_destructible 意味着不需要生成析构函数代码。
  • trivially_copyable 意味着除了按字节复制以外不需要做任何其他事情。
  • trivially_movable 表达的含义和trivially_copyable 一样,只不过是在移动语境下。

检查是否有用户定义的swap#

出自一个用C++11实现的std::optional

struct tag {};
template <class T>
tag swap(T &, T &);
template <class T, std::size_t N>
tag swap(T (&a)[N], T (&b)[N]);
template <class, class>
std::false_type uses_std(...);
template <class T, class U>
std::is_same<decltype(swap(std::declval<T &>(), std::declval<U &>())), tag> uses_std(int);
struct myclass {
int x;
};
void swap(myclass &, myclass &) { std::cout << "1" << std::boolalpha; }
int main() {
int a = 1;
int b = 2;
std::cout << std::boolalpha;
std::cout << decltype(swap_adl_tests::uses_std<int, int>(0))::value << std::endl;
std::cout << decltype(swap_adl_tests::uses_std<myclass, myclass>(0))::value << std::endl;
swap(a, b);
return 0;
}

有一个模板合成的swap函数。如果有用户定义的swap函数,那么模板合成的优先级低于用户定义的。用户定义的swap不可能返回tag标签类型。所以如果返回的不是tag类型,标志着不能用std::swap。之所以能找到是因为用了ADL。

相反。如果没有用户定义的swap,我们既没有using namespace std, 也没有using std::swap, 我们也是用无限定名字的swap调用。所以说他看不到std::swap,则肯定只能找到合成的返回tag类型的

获取可变参数包中的最后一个元素#

简单粗暴

auto last2 = [](auto... args) {
return (args, ...);
};

来自这里

检查是否是某个类型的特化#

template <typename T, template <typename...> typename Template>
struct is_specialization : std::false_type {};
template <template <typename...> typename Template, typename... Args>
struct is_specialization<Template<Args...>, Template> : std::true_type {
void info() { cout << __PRETTY_FUNCTION__ << endl; }
};
int main() {
static_assert(is_specialization<std::vector<int>, std::vector>{}, "");
is_specialization<std::vector<int>, std::vector>{}.info();
return 0;
}
/*
void is_specialization<Template<Args ...>, Template>::info() [with Template = std::vector; Args = {int, std::allocator<int>}]
*/

模板模板参数template就是vector,vector里面的参数就是Args

SFINAE、enable_if 和 enable_if_t 带来的陷阱#

本篇文章来自这里的Broken syntax and standard library: std::enable_if_t vs. std::void_t一节

先来看一段代码

struct X {
struct Inner {};
};
struct Y {
struct Outer {};
};
template <class T>
decltype(void(std::declval<typename T::Inner>())) fun(T) { // 1
std::cout << "f1\n";
}
template <class T>
decltype(void(std::declval<typename T::Outer>())) fun(T) { // 2
std::cout << "f2\n";
}
int main(){
X x;
Y y;
fun(x); // 应该输出 "fun1"
fun(y); // 应该输出 "fun2"
}

在“上古”年代,我们会通过这种质朴而纯真的方法来触发SFINAE。一切看起来还可以。直到后面我们有了std::enable_if,陷阱出现了

template <class T>
std::enable_if_t<std::is_same_v<typename T::Inner, typename T::Inner>> fun1(T) { // 1
std::cout << "f1\n";
}
template <class T>
std::enable_if_t<std::is_same_v<typename T::Outer, typename T::Outer>> fun1(T) { // 2
std::cout << "f2\n";
}
template <class T>
std::enable_if<std::is_same_v<typename T::Inner, typename T::Inner>> fun2(T) { // 3
std::cout << "f1\n";
}
template <class T>
std::enable_if<std::is_same_v<typename T::Outer, typename T::Outer>> fun2(T) { // 4
std::cout << "f2\n";
}
int main(){
fun1(x); // 应该输出 "f1"
fun1(y); // 应该输出 "f2"
fun2(x); // 应该输出 "f1"
fun2(y); // 应该输出 "f2"
}

注意,1和2我们使用的是std::enable_if_t, 3和4使用的是std::enable_if

看起来没啥问题对吧。但是它存在一个潜在的问题。

如果我们尝试检查fun1和fun2函数的签名。我们会发现,fun1的函数签名是我们希望的,返回类型是void。但是,fun2的函数签名,返回类型是std::enable_if<true, void>

所以说我们发现了陷阱所在。一个_t后缀可能会导致我们需要花大量时间排查。这同样适用于<type_traits>标头中的所有其他神秘生物。每个std::trait_X和std::trait_X_t在混合使用的时候会导致意图不可见。

编译期表达式计算的例子#

来自这里

核心是我们想要优化掉Vec x = a + b + c 这样带有中间结果的情况

template <typename E>
class VecExpression {
public:
static constexpr bool is_leaf = false;
double operator[](size_t i) const {
// Delegation to the actual expression type. This avoids dynamic polymorphism (a.k.a.
// virtual functions in C++)
return static_cast<E const&>(*this)[i];
}
size_t size() const { return static_cast<E const&>(*this).size(); }
};
class Vec : public VecExpression<Vec> {
std::array<double, 3> elems;
public:
static constexpr bool is_leaf = true;
decltype(auto) operator[](size_t i) const { return elems[i]; }
decltype(auto) operator[](size_t i) { return elems[i]; }
size_t size() const { return elems.size(); }
// construct Vec using initializer list
Vec(std::initializer_list<double> init) { std::copy(init.begin(), init.end(), elems.begin()); }
// A Vec can be constructed from any VecExpression, forcing its evaluation.
template <typename E>
Vec(VecExpression<E> const& expr) {
for (size_t i = 0; i != expr.size(); ++i) {
elems[i] = expr[i];
}
}
};
template <typename E1, typename E2>
class VecSum : public VecExpression<VecSum<E1, E2> > {
// cref if leaf, copy otherwise
typename std::conditional<E1::is_leaf, const E1&, const E1>::type _u;
typename std::conditional<E2::is_leaf, const E2&, const E2>::type _v;
public:
static constexpr bool is_leaf = false;
VecSum(E1 const& u, E2 const& v) : _u(u), _v(v) { assert(u.size() == v.size()); }
decltype(auto) operator[](size_t i) const { return _u[i] + _v[i]; }
size_t size() const { return _v.size(); }
};
template <typename E1, typename E2>
VecSum<E1, E2> operator+(VecExpression<E1> const& u, VecExpression<E2> const& v) {
return VecSum<E1, E2>(*static_cast<const E1*>(&u), *static_cast<const E2*>(&v));
}
int main(){
Vec v1 = {1, 2, 3};
Vec v2 = {4, 5, 6};
Vec v3 = v1 + v2;
std::cout << v3[0] << " " << v3[1] << " " << v3[2] << std::endl;
}

这样的话,这样 a+b+c 的类型是 VecSum<VecSum<Vec, Vec>, Vec>

Vec x = a + b + c 会调用Vec(VecExpression<E> const& expr) elems[i] = expr[i];会展开成elems[i] = a.elems[i] + b.elems[i] + c.elems[i]

这样就没有临时Vec对象了

通过值传递还是引用传递可调用对象#

来自这里

假设我们有一个函数模板g接受一个可调用对象f,我们用值传递还是引用传递?

template <class F>
void g(F f); // (1) by value
template <class F>
void g(F&& f); // (2) by reference

如果我们看看标准库,比如 std::invoke, 可调用对象是引用传递

template< class F, class... Args >
std::invoke_result_t<F, Args...>
invoke( F&& f, Args&&... args ) noexcept(/* see below */);

在其他大多数情况,比如在 <algorithm>头文件中,可调用对象是值传递

template< class T, class Compare >
const T& max( const T& a, const T& b, Compare comp );

所以按照作者意见:

  • 如果g的主要作用是调用f,则使用引用
  • 其他情况下使用值传递。

这样做的好处是可以简化一些代码,同时可以让可调用对象直接被algotirhm使用

对于那些不可拷贝,或者不想拷贝的可调用对象。可以使用std::ref包装

运算符重载#

从最近需求中学习,也感谢磊哥的指点。我们有现在的SSCCE代码

struct t1 {
int a;
int b;
};
template <typename T1>
struct F {
F(int x) : val_(x) {};
F() : val_(2) {};
template <typename T2>
friend F<T1> operator*(const F<T1>& a, const F<T2>& b) {
return a.val_ * b.val_;
}
private:
int val_;
};
int main() {
F<int> a;
F<t1> b(3);
auto s = a * b; // NOPE
cout << s.getval() << endl;
}
/*
1107.cpp:16:27: error: ‘int F<t1>::val_’ is private within this context
16 | return a.val_ * b.val_;
| ~~^~~~
1107.cpp:24:9: note: declared private here
24 | int val_;
| ^~~~
*/

我们有一个乘法运算符重载。我们不希望写为成员函数,希望是友元函数。同时我们不想暴露某些私有方法或成员。但是现在我们似乎发现F<T1>看不到F<T2>的成员。这是必然的。因为类模板F<X>和F<Y>并不是同一类型。同时,这种友元函数的定义实际上并不是一个全局模板函数,而是一个成员模板函数。在这种情况下,operator* 只有在 F<T1> 的实例内部才是友元。对于 F<T2> 类型的对象,F<T1> 的 operator* 无法访问 F<T2> 的私有成员 val_。因此编译器会报错。

所以我们用如下方法:

struct t1 {
int a;
int b;
};
template <typename T1>
struct F {
F(int x) : val_(x) {};
F() : val_(2) {};
template <typename T11, typename T2> // T11不可是T1,会shadow
friend F<T11> operator*(const F<T11>& a, const F<T2>& b);
int getval(){
return val_;
}
private:
int val_;
};
template<typename T11, typename T2> //T11可以是任何,可以是T1, 无所谓
F<T11> operator* (const F<T11>& a, const F<T2>& b){
return a.val_ * b.val_;
}
int main() {
F<int> a;
F<t1> b(3);
auto s = a * b; // GOOD
cout << s.getval() << endl;
}

这种写法,这里声明了一个独立的全局模板函数,并且声明了它为友元函数。由于这是一个独立的模板函数,它可以访问 F<T11> 和 fuckFT2> 的私有成员。这是因为编译器在第二种情况下将operator*视为所有 F<T1> 和 F<T2> 的友元函数,因此无论 F<T11> 和 F<T2> 是什么类型,它们的私有成员 val_ 都可以被访问。

可以查看这里的 模板类的非约束模板友元函数

通用学习文章:#

  1. Template Best Practices
  2. 模板友元函数
C++ 模板笔记(八):类型属性检查、SFINAE 陷阱与编译期计算
https://sterben-01.github.io/posts/模板-8/
作者
Sterben_01
发布于
2022-12-14
许可协议
CC BY-NC-SA 4.0