专业的编程技术博客社区

网站首页 > 博客文章 正文

C++ SFINAE 现代C++和C++20 Concept

baijin 2024-10-12 02:09:01 博客文章 16 ℃ 0 评论

什么是SFINAE?您在什么地方可以使用这种元编程技术?在现代C++中有更好的选择吗?那么C++ 20的Concept呢?

请往下阅读找出答案。

介绍

让我们从这个概念背后的一些基本想法开始:

简而言之:编译器可以拒绝对于给定类型“无法编译”的代码。

维基百科:

替换失败并非错误 (Substitution failure is not an error, SFINAE)是指C++语言在模板参数匹配失败时不认为这是一个编译错误。戴维·范德沃德最先引入SFINAE缩写描述相关编程技术。

我们在这里讨论的是一些与模板、模板替换规则和元编程相关的东西……这使得它可能成为一个可怕的领域!

一个简单的例子:

struct Bar {
    typedef double internalType;  
};

template <typename T> 
typename T::internalType foo(const T& t) { 
    cout << "foo<T>\n"; 
    return 0; 
}

int main() {
    foo(Bar());
    foo(0); // << error!
}

我们有一个很好的模板函数,它返回T::internalType,我们用Bar和int参数类型调用它。

当然,代码不能编译。第一个调用 foo(Bar()) 是正确的构造,但第二个会生成以下错误(GCC):

no matching function for call to 'foo(int)' ... template argument deduction/substitution failed:

当我们进行更正并为int类型提供适当的函数时,非常简单:

int foo(int i) { cout << "foo(int)\n"; return 0; }

即可以编译运行。

为什么?

当我们为int类型添加重载函数时,编译器可以找到一个合适的匹配项并调用代码。但在编译过程中,编译器还会“查看”模版函数声明。这个函数对于int类型是无效的,那么为什么甚至没有报告警告(就像我们在没有提供第二个函数时得到的那样)?为了理解这一点,我们需要看看为函数调用构建重载解析集的过程。

重载解析

当编译器试图编译函数调用时(简化):

  • 执行一个名字查找
  • 对于函数模版,模版参数根据实际传递给函数的参数类型推导出来。所有模版参数(返回类型和参数类型)用推导出来的类型替换如果导致非法类型(比如int::internalType),这个函数就从重载解析集中排除(SFINAE
  • 最后,我们得到一批函数可用于这个调用。如果没有找到,那么编译失败。如果不止一个函数,那我们遇到了歧义。一般来说,参数最匹配的那个函数被调用。

在我们的示例中:typename T::internalType foo(const T& t) 与int不匹配,它被重载解析集拒绝。但最后,foo(int i) 是集合中唯一的选项,因此编译器没有问题。

在哪里使用

我想您对SFINAE的功能有了一个基本的了解,但是我们可以在哪里使用这种技术呢?一般的回答是:当我们想为特定类型选择适当的函数时。

一些例子:

  • 当T有一个给定的方法时(比如toString()),调用某个函数
  • 放置窄化或者错误类型转换
  • 检测传递给构造函数的初始化列表中的对象数量
  • 为不同的type traits特化函数,比如is_integral, is_array, is_class, is_pointer等等

好了,但是我们怎么能写出这样的SFINAE呢?有辅助吗?

让我们会一会std::enable_if。

什么是std::enable_if

SFINAE的首席用例可以在std::enable_if表达式中看到。

std::enable_if是一个工具集,从C++11起就在标准库中,其内部使用SFINAE。它可以从函数模版或者类模版中包含或者排除某个重载。

比如:

// C++11:
template <class T>
typename std::enable_if<std::is_arithmetic<T>::value, T>::type 
foo(T t) {
  std::cout << "foo<arithmetic T>\n";
  return t;
}

这个函数可以用于所有的is_arithmetic类型(int, long, float…)。如果传递其他类型(比如MyClass),则实例化失败。换句话说,模版对于非算数类型的实例化从重载解析集中被排除。这个结构可用于模版参数、函数参数和返回值类型。

enable_if<condition, T>::type在condition为true的时候生成T,在false的时候替换失败。

enable_if可以和type traits一起使用,以根据trait提供最佳函数。

同时请注意,在C++14和17中有一个更好更紧凑的语法。由于_v和_t变量模版和模版别名的引入,不再需要使用enable_if和trait的::type和::value。

之前的代码变为:

// C++17:
template <class T>
typename std::enable_if_t<std::is_arithmetic_v<T>, T> // << shorter!
foo(T t) {
  std::cout << "foo<arithmetic T>\n";
  return t;
}

注意里面std::enable_if_t和std::is_arithmetic_v的使用。

表达式SFINAE

C++11有更复杂的SFINAE选项。

n2634: Solving the SFINAE problem for expressions

这篇文档明确了规范,它允许你在decltype和sizeof中使用表达式。

比如:

template <class T> auto f(T t1, T t2) -> decltype(t1 + t2);

在上面的例子中,表达式t1+t2会被检查。它可以用于两个int类型(其结果类型还是int),但是不能用于一个int和一个vector。

表达式检查对编译器增添了更多的复杂性。在重载解析一节中,我仅仅提到模版参数替换, 但是现在,编译器需要查看表达式并做完整的语义检查。

SFINAE的不好之处?

SFINAE和enable_if是引人注目的特性,但是它也很难弄对。简单的示例可能没问题,但在实际生活的场景中,您可能会遇到各种问题:

  • 模版错误:您愿意读编译器产生的模版错误信息吗?特别是当你使用STL类型时。
  • 可读性
  • 嵌套模版通常在enable_if中不能使用

我们能做的更好吗?

SFINAE替代品

我们至少有三样:

  • 标签分发tag dispatching
  • 编译时if
  • 还有……Concept

我们简短复习下。

标签分发tag dispatching

这个版本的选择哪个函数调用的可读性高的多。首先,我们定义一个核心函数,然后根据一些编译时条件决定调用实现函数的A或者B版本。

template <typename T>
int get_int_value_impl(T t, std::true_type) {
    return static_cast<int>(t+0.5f);
}

template <typename T>
int get_int_value_impl(T t, std::false_type) {
    return static_cast<int>(t);
}

template <typename T>
int get_int_value(T t) {
    return get_int_value_impl(t, std::is_floating_point<T>{});
}

当你调用get_int_value时,编译器会检查std::is_floating_point的值,然后调用匹配的_impl函数。

编译时if – C++17

从C++17开始,我们有了一个新工具,内建于语言之中,其允许你在编译时检查条件,而且不需要你写复杂的模版代码。

我们可以通过一个片段来展示:

template <typename T>
int get_int_value(T t) {
     if constexpr (std::is_floating_point_v<T>) {
         return static_cast<int>(t+0.5f);
     }
     else {
         return static_cast<int>(t);
     }
}

你可以在下面这篇博客里读到更多:Simplify code with ‘if constexpr’ in C++17。

Concept – C++20

在每一个C++标准修订中,我们都可以得到更好的技术和工具来写模版。在C++20中,我们有了一个期待已久的功能,可以革命我们写模版的方式。

使用Concept,你可以对模版参数添加限制并得到更好的编译警告。

一个基本的例子:

// define a concept:
template <class T>
concept SignedIntegral = std::is_integral_v<T> && std::is_signed_v<T>;

// use:
template <SignedIntegral T>
void signedIntsOnly(T val) { }

在上面的代码中,我们首先创建一个Concept来描述有符号和整数的类型。请注意,我们可以使用现有的类型特征。接着,我们使用它来定义一个模板函数,它只支持与Concept匹配的类型。这里我们不使用typename T,但是我们可以使用一个Concept名称。

我们现在用一个示例来结合这些知识。

一个示例

最后,我想通过一些实际例子,看看如何利用SFINAE:

测试类:

template <typename T>
class HasToString {
private:
    typedef char YesType[1];
    typedef char NoType[2];

    template <typename C> static YesType& test(decltype(&C::ToString));
    template <typename C> static NoType& test(...);

public:
    enum { value = sizeof(test<T>(0)) == sizeof(YesType) };
};

上面的模板类将用于测试某些给定类型T是否具有ToString()方法。这里…有什么,又在哪里使用了SFINAE概念?你能看见吗?

当我们想进行测试时,我们需要写下:

HasToString<T>::value

如果我们传递int会怎么样?它将类似于本文开头的第一个示例。编译器将尝试执行模板替换,但在这里会匹配失败:

template <typename C> static YesType& test( decltype(&C::ToString) ) ;

显然,没有int::ToString方法,因此第一个重载方法将从解析集中排除。但是,第二个方法将会通过 (NoType& test(...)),因为它可以在所有其他类型上调用。所以我们得到了SFINAE!一个方法被删除,只有第二个方法对此类型有效。

最后,最终的enum值被计算为:

enum { value = sizeof(test<T>(0)) == sizeof(YesType) };

由于选中的函数返回NoType,sizeof(NoType)与sizeof(YesType)不等,所以最终的值为0。

当我们测试如下类时又该如何?

class ClassWithToString {
public:
    string ToString() { return "ClassWithToString object"; }
};

现在模版替换会生成两个候选:两个test方法都有效,但是前一个更好,所以它被选中。我们得到YesType,并且最终的 HasToString<ClassWithToString>::value返回1作为结果。

怎样使用这样的检查类:

理想情况下,if语句会很方便:

if (HasToString<decltype(obj)>::value)
    return obj.ToString();
else
    return "undefined";

我们可以使用if constexpr,但是为了示例的目的,让我们继续使用C++11/14方案。

我们可以使用enable_if,并创建两个函数:一个接受带有ToString方法的类对象,另一个接受所有其他类型。

template<typename T> 
typename enable_if<HasToString<T>::value, string>::type
CallToString(T * t) {
    return t->ToString();
}

string CallToString(...) {
    return "undefined...";
}

同样,上面的代码里有SFINAE。如果你传递一个类型,HasToString<T>::value = false,则enable_if实例化失败。

上面的技术非常复杂,但是功能也很有限。比如,它没有限制函数的返回类型。

让我们看看现代C++有什么帮忙的。

现代C++来救

在文章最初版本的一个评论中,STL(stephantt.Lavavej)提到我在文章中提出的解决方案来自于旧的Cpp风格。那么新的现代风格是什么呢?

我们可以看到几个东西:

  • decltype
  • declval
  • constexpr
  • std::void_t
  • 检测范式

decltype

decltype是一个返回表达式类型的强大工具。我们已经使用过:

template <typename C> 
static YesType& test( decltype(&C::ToString) ) ;

它返回C::ToString成员方法的类型(如果这个类有这个方法的话)

declval

declval可以让你调用类型T的一个方法而不需要创建一个真正的对象。在我们的例子中,我们可以用它来得到成员方法的类型:

decltype(declval<T>().toString())

constexpr

constexpr提示编译器在编译时计算表达式(如果可能的话)。没有它的话,我们的checker方法可能只能在运行时进行评估。新方式建议为大多数方法添加constexpr。

void_t

  • SO question: Using void_t to check if a class has a method with a specific signature
  • SO question: How does void_t work

CppCon 2014: Walter E. Brown “Modern Template Metaprogramming: A Compendium, Part II” – YouTube

从29分钟开始看,特别是39分钟。

这一个不可思议的元编程模式。我不想多说什么,就看视频吧。

检测范式

  • WG21 N4436, PDF – Proposing Standard Library Support for the C++ Detection Idiom, by Walter E. Brown
  • std::is_detected
  • wikibooks: C++ Member Detector

Walter E.Brown提出了一个完整的工具类,可以用来检查给定类的接口和其他属性。当然,大部分都是基于void_t的。

改进后的代码

// default template:
template< class , class = void >
struct has_toString : false_type { };

// specialized as has_member< T , void > or sfinae
template< class T>
struct has_toString<T , void_t<decltype(&T::toString)>> : std::is_same<std::string, decltype(declval<T>().toString())>
{ };

很漂亮,对吧:

它使用基于void_t的显式检测范式. 基本上,当类中没有T::toString()方法时, 出现SFINAE,我们最终得到通用缺省模版。但如果类中有这个方法时,特化版本被选择。如果我们不关心方法的返回类型,那么到这里就结束了。但在这里,我们通过继承std::is_same来检查返回类型是不是std::string,从而得到true_type或者false_type。

Concept来救

我们可以在C++ 20中做得更好。使用此功能,我们可以声明一个新的Concept来指定类的接口:

比如:

template <typename T>
concept HasToString = requires(T v)
{
    {v.toString()} -> std::convertible_to<std::string>;
};

这就是全部。漂亮易读。

我们可以使用一些测试代码来实验:

#include <iostream>
#include <string>
#include <type_traits>

template <typename T>
concept HasToString = requires(const T v)
{
    {v.toString()} -> std::convertible_to<std::string>;
};

struct Number {
    int _num { 0 };
    std::string toString() const { return std::to_string(_num); };
};

void PrintType(HasToString auto& t) {
    std::cout << t.toString() << '\n';
}

int main() {
    Number x { 42 };
    PrintType(x);
}

如果您的类型不支持toString,那么您可能会得到如下编译器错误(GCC 10):

int x = 42;
PrintType(x);
error: use of function 'void PrintType(auto:11&) [with auto:11 = int]' with unsatisfied constraints
    |     PrintType(x);
    |                ^
   note: declared here
    | void PrintType(HasToString auto& t) {
    |      ^~~~~~~~~
In instantiation of 'void PrintType(auto:11&) [with auto:11 = int]':
required for the satisfaction of 'HasToString<auto:11>' [with auto:11 = int]
in requirements with 'const int v'
note: the required expression 'v.toString()' is invalid
    8 |     {v.toString()} -> std::convertible_to<std::string>;
      |      ~~~~~~~~~~^~

我们来到了一个全新的世界。从复杂的SFINAE代码,在C++14和C++17中得到改进,最终到C++20的简明的语法。

结论

在这篇文章中,我们介绍了SFINAE的理论和示例-一种允许您从重载解析集中拒绝代码的模板编程技术。在原始形式中,这可能有点复杂,但得益于现代C++,我们有许多工具可以帮助:例如,enable_if,std::declval以及一些其他的工具。更重要的是,如果你幸运的与最新的C++标准,你可以使用C++17的if constexpr和C++20的Concept。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表