C++ Template: 移动语义 Move Semantics and enable_if<>

整理模板中的完美转发、特殊成员函数模板、std::enable_if 和 Concepts 的基本取舍。

C++11 引入的最重要特性之一就是 移动语义 Move Semantics,它可以优化对象的 复制赋值操作,通过 “窃取” 源对象的内部资源,将其转移到目标对象,而不是进行传统的深拷贝,这种方式在源对象 即将被销毁不再需要其原始状态 时尤其有效

移动语义 的引入对模板设计产生了重大影响,并且 C++11 也引入了一些特殊规则,以便在泛型代码中支持移动语义,包括

  • 移动构造移动赋值
  • 完美转发 Perfect Forwarding
  • enable_if<>
  • 在模板中利用 std::movestd::forward

完美转发 Perfect Forwarding

在泛型编程中,我们常常希望编写一个函数模板,使其能够根据传入参数的性质(可修改常量可移动)将参数完美地传递给另一个函数,这意味着

  • 可修改的对象 应以可修改的方式传递
  • 常量对象 应以只读方式传递
  • 可移动对象 应以可移动的方式传递

为实现这一目标,C++11 引入了 完美转发 的概念,完美转发 允许函数模板根据传入参数的类型和值类别(左值右值),将参数无损地传递给另一个函数

传统方法的问题

在没有完美转发之前,为了实现上述功能,通常需要为每种情况编写不同的函数版本

#include <iostream>
#include <utility>
 
class X {
    // 类的定义
};
 
void g(X&) {
    std::cout << "g() for variable\n";
}
 
void g(const X&) {
    std::cout << "g() for constant\n";
}
 
void g(X&&) {
    std::cout << "g() for movable object\n";
}
 
// 以下为转发函数,由 f --> g
void f(X& val) {
    g(val); // 非常量左值,调用 g(X&)
}
 
void f(const X& val) {
    g(val); // 常量左值,调用 g(const X&)
}
 
void f(X&& val) {
    g(std::move(val)); // 非常量左值,需要 std::move() 转换为右值以调用 g(X&&)
}
 
int main() {
    X v;        // 非常量对象
    const X c;  // 常量对象
 
    f(v);             // 调用 f(X&),进而调用 g(X&)
    f(c);             // 调用 f(const X&),进而调用 g(const X&)
    f(X());           // 临时对象,调用 f(X&&),进而调用 g(X&&)
    f(std::move(v));  // 可移动对象,调用 f(X&&),进而调用 g(X&&)
 
    return 0;
}

在上述代码中,我们为每种情况都定义了一个 f() 函数,以确保参数被正确地传递给 g() 函数。然而,这种方法需要编写多个函数,代码冗余且维护困难

使用完美转发

C++11 引入了右值引用和 std::forward,使我们能够编写一个通用的函数模板来实现 完美转发,具体而言,我们可以使用 转发引用(也称为 万能引用)来实现这一点

#include <iostream>
#include <utility>
 
class X {
    // 类的定义
};
 
void g(X&) {
    std::cout << "g() for variable\n";
}
 
void g(const X&) {
    std::cout << "g() for constant\n";
}
 
void g(X&&) {
    std::cout << "g() for movable object\n";
}
 
template<typename T>
void f(T&& val) {
    g(std::forward<T>(val)); // 完美转发 val 到 g()
}
 
int main() {
    X v;        // 非常量对象
    const X c;  // 常量对象
 
    f(v);             // 调用 f(X&),进而调用 g(X&)
    f(c);             // 调用 f(const X&),进而调用 g(const X&)
    f(X());           // 临时对象,调用 f(X&&),进而调用 g(X&&)
    f(std::move(v));  // 可移动对象,调用 f(X&&),进而调用 g(X&&)
 
    return 0;
}

在这个版本的代码中,我们定义了一个模板函数 f(),其参数类型为 T&&,这里的 T&& 是一个 转发引用,它可以绑定到 左值常量左值右值,通过在函数内部使用 std::forward<T>(val),我们能够根据 T 的类型和 val 的值类别,将 val 无损地传递给 g() 函数

需要注意的是,std::move() 总是将其参数转换为右值引用,而 std::forward<T>() 则根据 T 的类型决定是转换为 左值引用 还是 右值引用,从而实现完美转发

转发引用与右值引用的区别

虽然语法上 T&&X&& 看起来相似,但它们有不同的含义

  • X&& 是一个 右值引用,只能绑定到 右值
  • T&& 是一个 转发引用,可以通过 模板类型推导 同时绑定到 左值右值

这种机制使得我们能够编写更通用和高效的代码,特别是在泛型编程中

特殊成员函数模板

在 C++ 中,成员函数模板可以用于定义特殊的成员函数,例如 构造函数,然而,这可能会导致一些意想不到的行为,让我们通过一个示例来探讨这个问题

考虑以下 Person

#include <iostream>
#include <string>
#include <utility>
 
class Person {
private:
    std::string name;
 
public:
    // 构造函数:接受一个初始名称
    explicit Person(const std::string& n) : name(n) {
        std::cout << "复制构造 string-CONSTR for '" << name << "'\n";
    }
 
    explicit Person(std::string&& n) : name(std::move(n)) {
        std::cout << "移动构造 string-CONSTR for '" << name << "'\n";
    }
 
    // 拷贝构造函数
    Person(const Person& p) : name(p.name) {
        std::cout << "COPY-CONSTR Person '" << name << "'\n";
    }
 
    // 移动构造函数
    Person(Person&& p) noexcept : name(std::move(p.name)) {
        std::cout << "MOVE-CONSTR Person '" << name << "'\n";
    }
};
 
int main() {
    std::string s = "sname";
    Person p1(s);             // 使用字符串对象初始化 => 调用复制构造 string-CONSTR
    Person p2("tmp");         // 使用字符串字面量初始化 => 调用移动构造 string-CONSTR
    Person p3(p1);            // 拷贝 Person 对象 => 调用 COPY-CONSTR
    Person p4(std::move(p1)); // 移动 Person 对象 => 调用 MOVE-CONSTR
}

在上述代码中,Person 类有两个接受 std::string构造函数 用于初始化 name 成员

  1. 复制构造函数: 接受 const std::string& 参数,用于传递 仍需使用的字符串对象(左值)

  2. 移动构造函数: 接受 std::string&& 参数,用于传递 可移动的字符串对象(右值)

此外,类还定义了接受 Person 对象本身作为参数的 拷贝构造函数移动构造函数,以观察 Person 对象何时被 复制移动

使用成员函数模板

现在,我们尝试用一个通用的 成员函数模板 来替代上述两个 字符串构造函数,实现 完美转发

#include <iostream>
#include <string>
#include <utility>
 
class Person {
private:
    std::string name;
 
public:
    // 通用构造函数模板:接受任意类型的初始名称
    template <typename T>
    explicit Person(T&& n) : name(std::forward<T>(n)) {
        std::cout << "TMPL-CONSTR for '" << name << "'\n";
    }
 
    // 拷贝构造函数
    Person(const Person& p) : name(p.name) {
        std::cout << "COPY-CONSTR Person '" << name << "'\n";
    }
 
    // 移动构造函数
    Person(Person&& p) noexcept : name(std::move(p.name)) {
        std::cout << "MOVE-CONSTR Person '" << name << "'\n";
    }
};
 
int main() {
    std::string s = "sname";
    Person p1(s);             // 使用字符串对象初始化 => 调用 TMPL-CONSTR
    Person p2("tmp");         // 使用字符串字面量初始化 => 调用 TMPL-CONSTR
    Person p3(p1);            // 拷贝 Person 对象 => 调用 COPY-CONSTR
    Person p4(std::move(p1)); // 移动 Person 对象 => 调用 MOVE-CONSTR
}

在这个版本中,我们定义了一个 模板构造函数,接受 任意类型 的参数,并使用 std::forward<T>(n) 将参数 完美转发name 成员的 构造函数 (注意,转发指的就是将参数转发给成员变量的构造函数)

然而,当我们尝试复制 Person 对象时

Person p3(p1); // 错误

会出现 编译错误,原因是,在 重载解析 过程中,对于 非常量左值 Person p,成员模板:

template <typename T>
explicit Person(T&& n)

拷贝构造函数

Person(const Person& p)

更匹配,因为模板参数 T 可以被推导为 Person&,而 拷贝构造函数 需要将参数转换为 const,这导致模板构造函数被优先选择,但由于模板的实现并未处理这种情况,因而出现错误

使用 std::enable_if 解决问题

为了解决上述问题,我们可以使用 std::enable_if 来禁用模板构造函数在接受 Person 类型参数时的 实例化,具体而言,我们希望当传递的参数是 Person 类型或可以转换为 Person 类型时,禁用模板构造函数,这可以通过以下方式实现

#include <iostream>
#include <string>
#include <type_traits>
#include <utility>
 
class Person {
private:
    std::string name;
 
public:
    // 通用构造函数模板:接受任意类型的初始名称
    template <typename T, typename = std::enable_if_t<!std::is_same_v<std::decay_t<T>, Person>>>
    explicit Person(T&& n) : name(std::forward<T>(n)) {
        std::cout << "TMPL-CONSTR for '" << name << "'\n";
    }
 
    // 拷贝构造函数
    Person(const Person& p) : name(p.name) {
        std::cout << "COPY-CONSTR Person '" << name << "'\n";
    }
 
    // 移动构造函数
    Person(Person&& p) noexcept : name(std::move(p.name)) {
        std::cout << "MOVE-CONSTR Person '" << name << "'\n";
    }
};
 
int main() {
    std::string s = "sname";
    Person p1(s);             // 使用字符串对象初始化 => 调用 TMPL-CONSTR
    Person p2("tmp");         // 使用字符串字面量初始化 => 调用 TMPL-CONSTR
    Person p3(p1);            // 拷贝 Person 对象 => 调用 COPY-CONSTR
    Person p4(std::move(p1)); // 移动 Person 对象 => 调用 MOVE-CONSTR
}

在这个版本中,我们对模板构造函数添加了一个额外的模板参数,并使用 std::enable_if_t 进行 SFINAE(Substitution Failure Is Not An Error) 限制,具体而言,当且仅当传递的参数类型不是 Person 时,模板构造函数才会参与重载解析,这样,当我们传递 Person 类型的参数时,模板构造函数 被排除,编译器会选择 拷贝构造函数移动构造函数,从而避免了之前的问题

使用 std::enable_if<> 禁用模板

自 C++11 起,C++ 标准库提供了一个辅助模板 std::enable_if<>,用于在特定的编译期条件下有选择地启用或禁用函数模板,这在泛型编程中尤为重要,特别是在需要根据类型特性来控制函数重载或模板特化时

std::enable_if<> 是一个基于 编译期常量表达式类型萃取 type trait,其主要作用是根据给定的布尔条件来定义或禁用某些类型,具体而言,std::enable_if 定义如下

template<bool B, class T = void>
struct enable_if {};
  • 当布尔条件 Btrue 时,std::enable_if 包含一个名为 type 的公共成员类型,等同于类型 T

  • Bfalse 时,type 成员不存在,这种机制利用了模板的 SFINAE(Substitution Failure Is Not An Error) 特性,即模板参数替换失败并不会导致编译错误,而是使该模板被忽略

从 C++14 开始,提供了一个别名模板 std::enable_if_t<>,使得语法更加简洁

template<bool B, class T = void>
using enable_if_t = typename enable_if<B, T>::type;

所以不再需要 enable_if<B, T>::type,而是直接使用 enable_if_t

示例:根据类型大小选择性启用函数模板

假设我们有一个函数模板 foo(),我们希望仅当类型 T 的大小大于 4 字节时才使其有效,可以使用 std::enable_if<> 实现如下

#include <type_traits>
 
template<typename T>
std::enable_if_t<(sizeof(T) > 4)>
foo() {
    // 函数实现
}

在这个例子中,std::enable_if_t<(sizeof(T) > 4)> 仅在 sizeof(T) > 4true 时有效,此时它等同于 void,也就是

void
foo() {
    // 函数实现
}

如果条件为 false,则 std::enable_if_t 无法定义 type,导致函数模板被忽略

需要注意的是,将 std::enable_if<> 用作函数返回类型时,可能会导致语法复杂且可读性下降,因此,更常见的做法是将 std::enable_if<> 作为额外的模板参数

template<typename T, typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo() {
    // 函数实现
}

这种方式使得函数声明更为简洁,同时也更容易理解

使用别名模板提高可读性

为了提高代码的可读性,可以为特定的 std::enable_if<> 条件定义别名模板

#include <type_traits>
 
template<typename T>
using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T) > 4)>;
 
template<typename T, typename = EnableIfSizeGreater4<T>>
void foo() {
    // 函数实现
}

通过这种方式,可以使代码的意图更加明确,增强可读性

需要注意的是,std::enable_if<> 的主要作用是利用 SFINAE 特性,在编译期根据条件有选择地启用或禁用函数模板或类模板特化,这在泛型编程中非常有用,特别是在需要根据类型特性进行函数重载或模板特化时

使用 enable_if<>

在前面的讨论中,我们遇到了一个问题:当为 Person 类定义了一个通用的 构造函数模板 时,该模板可能会与编译器自动生成的特殊成员函数(如拷贝构造函数)发生冲突,导致意外行为,为了解决这个问题,我们可以使用 std::enable_if<> 来有条件地禁用特定类型的模板实例化

之前,我们为 Person 类定义了一个通用的 构造函数模板,以便能够接受各种类型的参数

template<typename STR>
explicit Person(STR&& n)
: name(std::forward<STR>(n)) {
    std::cout << "TMPL-CONSTR for '" << name << "'\n";
}

然而,当我们尝试使用 Person 的拷贝构造函数时,例如

Person p1("John");
Person p2(p1); // 期望调用拷贝构造函数

上述代码可能不会按预期工作,因为通用的 构造函数模板 可能会比默认的 拷贝构造函数 具有更高的匹配优先级,导致它被错误地选中,并且由于它又没有定义,最终导致编译错误

为了避免这种情况,我们可以使用 std::enable_if<> 来限制通用构造函数模板的实例化,仅当传递的参数类型不是 Person 或不能转换为 Person 时,才使该模板可用

首先,我们需要包含必要的头文件:

#include <type_traits>

然后,我们可以使用 std::is_convertible<> 类型特征来检查类型的可转换性,并结合 std::enable_if<> 进行条件限制

template
<
    typename STR,
    typename = std::enable_if_t
    <
        !std::is_convertible_v<STR, Person>
    >
>
explicit Person(STR&& n)
: name(std::forward<STR>(n)) {
    std::cout << "TMPL-CONSTR for '" << name << "'\n";
}

在这个定义中,只有当 STR 不能转换为 Person 时,std::enable_if_t<> 才会定义一个有效的类型,使得该构造函数模板可用,否则,该模板将被禁用,避免与拷贝或移动构造函数发生冲突

以下是完整的 Person 类定义,展示了如何使用 std::enable_if<> 来控制构造函数模板的实例化

#include <iostream>
#include <string>
#include <type_traits>
 
class Person {
private:
    std::string name;
 
public:
    // 通用构造函数模板,仅当 STR 不能转换为 Person 时可用
    template<typename STR,
             typename = std::enable_if_t<
                 !std::is_convertible_v<STR, Person>>>
    explicit Person(STR&& n)
    : name(std::forward<STR>(n)) {
        std::cout << "TMPL-CONSTR for '" << name << "'\n";
    }
 
    // 拷贝构造函数
    Person(const Person& p)
    : name(p.name) {
        std::cout << "COPY-CONSTR Person '" << name << "'\n";
    }
 
    // 移动构造函数
    Person(Person&& p) noexcept
    : name(std::move(p.name)) {
        std::cout << "MOVE-CONSTR Person '" << name << "'\n";
    }
};
 
int main() {
    std::string s = "sname";
    Person p1(s);             // 使用 string 对象初始化,调用模板构造函数
    Person p2("tmp");         // 使用字符串字面量初始化,调用模板构造函数
    Person p3(p1);            // 拷贝 Person 对象,调用拷贝构造函数
    Person p4(std::move(p1)); // 移动 Person 对象,调用移动构造函数
    return 0;
}

在上述代码中,我们使用了 C++17 提供的 _v_t 后缀(如 std::is_convertible_vstd::enable_if_t)来简化语法,如果使用的是 C++14 或更早的标准,需要使用对应的完整语法,例如 std::is_convertible<STR, Person>::valuetypename std::enable_if<...>::type

通过这种方式,我们可以确保通用构造函数模板不会与拷贝或移动构造函数发生冲突,从而实现预期的行为

禁用特殊成员函数

在 C++ 中,特殊成员函数(如 拷贝构造函数移动构造函数赋值运算符)在类的行为中扮演着关键角色,然而,在某些情况下,我们可能希望根据特定条件禁用这些函数,直接使用 std::enable_if<> 来禁用这些函数并不可行,因为 成员函数模板 不会被视为 特殊成员函数,编译器在需要 特殊成员函数 时会忽略它们

考虑以下类定义

class C {
public:
    template<typename T>
    C(T const&) {
        std::cout << "Template constructor\n";
    }
    // 其他成员...
};

在上述代码中,我们定义了一个接受任意类型参数的模板构造函数,然而,当我们尝试复制一个 C 类型的对象时

C x;
C y{x}; // 期望调用拷贝构造函数

编译器仍会使用 预定义拷贝构造函数,而不是我们定义的 模板构造函数,这是因为 模板成员函数 不被视为 特殊成员函数,编译器在需要调用拷贝构造函数时会忽略它们

为了解决这个问题,我们可以采用一种技巧:首先,声明一个接受 const volatile 修饰的参数的 拷贝构造函数,并将其标记为 已删除= delete),这样做会阻止 编译器隐式声明其他拷贝构造函数,然后,我们定义一个 模板构造函数,它在 非易失性 non-volatile 类型的情况下具有更高的匹配优先级

class C {
public:
    // 删除接受 const volatile 引用的拷贝构造函数
    C(C const volatile&) = delete;
 
    // 模板构造函数,用于处理其他情况
    template<typename T>
    C(T const&) {
        std::cout << "Template constructor\n";
    }
 
    // 其他成员...
};

在上述代码中,C(C const volatile&) = delete; 声明了一个接受 const volatile 修饰的参数的拷贝构造函数,并将其删除,这会阻止编译器生成其他形式的 拷贝构造函数,随后定义的 模板构造函数 将被用于处理 非易失性的类型,从而在 复制对象时 调用 模板构造函数

C x;
C y{x}; // 使用模板构造函数

在上述代码中,C y{x}; 将调用模板构造函数,因为预定义的 拷贝构造函数 已被删除

如果我们希望根据特定条件进一步控制模板构造函数的启用,例如仅当模板参数不是整型时才启用构造函数,可以使用 std::enable_if<> 进行限制

template<typename T>
class C {
public:
    // 删除接受 const volatile 引用的拷贝构造函数
    C(C const volatile&) = delete;
 
    // 当 U 不是整型时,启用模板构造函数
    template<typename U, typename = std::enable_if_t<!std::is_integral<U>::value>>
    C(C<U> const&) {
        // 构造函数实现...
    }
 
    // 其他成员...
};

在上述代码中,模板构造函数仅在 U 不是整型时可用。这通过 std::enable_if_t<!std::is_integral<U>::value> 实现,当 U 是整型时,std::enable_if_t 不会定义 type,因此该函数模板被禁用

通过上述方法,我们可以根据特定条件有效地控制 特殊成员函数 的启用和禁用,从而实现更精确的类行为控制

Concepts

在 C++20 之前,开发者通常使用 std::enable_if 结合 SFINAE(Substitution Failure Is Not An Error) 技术来对模板参数进行约束,然而,这种方法的语法较为复杂,可读性差,且错误信息难以理解,C++20 引入了 Concepts 特性,提供了一种更直观和简洁的方式来对模板参数施加约束

在 C++20 之前,我们可能会这样使用 std::enable_if

#include <type_traits>
 
template<typename T>
typename std::enable_if<std::is_integral<T>::value, bool>::type
is_even(T num) {
    return num % 2 == 0;
}

上述代码中,is_even 函数模板仅在 T 是整数类型时可用,如果 T 不是整数类型,std::enable_if 的条件不满足,导致模板替换失败,从而使该函数模板不可用

C++20 引入了 Concepts,使得上述代码可以更简洁明了

#include <concepts>
 
template<std::integral T>
bool is_even(T num) {
    return num % 2 == 0;
}

在这里,std::integral 是标准库中定义的概念,用于约束模板参数 T 必须是整数类型,这种语法比 std::enable_if 更加直观,增强了代码的可读性和可维护性

定义自定义概念

我们也可以定义自己的概念,例如,定义一个概念来约束类型 T 可以转换为 std::string

#include <type_traits>
#include <string>
 
template<typename T>
concept ConvertibleToString = std::is_convertible_v<T, std::string>;
 
template<ConvertibleToString T>
void print_name(T&& name) {
    std::string str_name = std::forward<T>(name);
    // 输出或处理 str_name
}

在这个例子中,print_name 函数模板仅在传入的参数可以转换为 std::string 时可用,这比使用 std::enable_if 的方式更为简洁明了

或者也可以使用如下形式

template<typename STR>
requires std::is_convertible_v<STR,std::string>
Person(STR&& n) : name(std::forward<STR>(n)) {
    // 实现
}