英文原文:https://victor-istomin.github.io/c-with-crosses/posts/templates-are-easy/
9. 概念:一种设置模板类型要求的现代方法
现在是时候做出转变了:使用 C++20,我们可以避免编写 SFINAE 代码并接受概念
概念(concept)是一组命名的需求。cppreference 上有一篇详细的文章,但其要点如下:它允许根据模板参数区分多个模板重载,类似于 SFINAE。然而,概念通过将声明与使用分离,并允许更简单的组合,增强了清晰度,从而使代码更简洁。
9.1 一个简单的基于特征的概念和一个要求
让我们对 makeString(String&& s) 和 makeString(Numeric value) 重载进行最小的修改,并看一下第一次尝试:
namespace traits
{
template <typename T>
inline constexpr bool isString = std::is_constructible_v<std::string, T>;
}
// a concepts that uses a constexpr bool trait to check its applicability
template <typename T>
concept IsString = traits::isString<T>;
template <IsString String>
std::string makeString(String&& s)
{
return std::string(std::forward<String>(s));
}
// a concept uses requires clause: code in braces should compile
template <typename T>
concept HasStdConversion = requires (T number) { std::to_string(number); };
std::string makeString(HasStdConversion auto number)
{
return std::to_string(number);
}
第9行:创建了一个简单概念,用于演示 constexpr bool 作为必要条件的用法。虽然 <concepts> 提供了 std::constructible_from 接口,但我们将使用一个基于自定义 trait 的概念作为展示。
第11-12行:使用 IsString 概念代替 typename 关键字,以尽量减少代码编写。请注意,不再需要 SFINAE 或尾随返回类型。
第19行引入了一个必要条件:HasStdConversion 是一个概念,它要求使用 T 函数 number 编译 std::to_string(number); 以确保 std::to_string 转换存在。这仍然不是什么高深的学问。
第21行:使用 HasStdConversion 来约束参数的 auto 类型。这种方法与第11-12行的方法等效,并且所需的绑定更少。但是,考虑到代码清晰度,我个人倾向于使用 template 关键字来清楚地表明以下方法是模板。
9.2 概念:合取和析取
现在,可以使用 <concepts> 来进一步简化代码,而无需手写 trait。此外,删除 trait 命名空间,并使用概念重写每个函数声明,而不是声明。如下所示
假设类型 T 是一个字符串,如果 std::string 是 std::constructible_from 它:
template <typename T>
concept IsString = std::constructible_from<std::string, T>;
template <IsString String>
std::string makeString(String&& s)
{
return std::string(std::forward<String>(s));
}
HasStdConversion 将要求可以在其参数上调用 std::to_string
template <typename T>
concept HasStdConversion = requires (T number) { std::to_string(number); };
template <HasStdConversion Numeric>
std::string makeString(Numeric number)
{
return std::to_string(number);
}
HasToString 是一个概念,它要求 object.to_string() 函数以 Object 对象为参数,且返回 std::convertible_to<std::string> 类型的值。在这种情况下,除了编译括号内代码的基本要求外,还会使用另一个概念来检查表达式的类型。
template <typename T>
concept HasToString = requires (T&& object)
{
{object.to_string()} -> std::convertible_to<std::string>;
};
template <HasToString Object>
std::string makeString(Object&& object)
{
return std::forward<Object>(object).to_string();
}
最后,棘手的部分。与 SFINAE 方法类似,IsContainer 要求类型可迭代:requires (T&& container) { std::begin(container); }。然而,字符串本身也是容器,这会导致调用 makeString("hello") 时出现歧义,因为两个重载都会匹配。在我们的代码中,requires 子句在要求布尔约束时很有用。
template <typename T>
concept IsContainer = requires (T&& container) { std::begin(container); };
template <typename Iterable>
requires (IsContainer<Iterable> && !IsString<Iterable>)
std::string makeString(Iterable&& iterable)
{
//...
}
不过,我更倾向于将 IsContainer 定义为可迭代对象,将 IsString 定义为可用于构造 std::string 的 IsContainer。这样,当某个对象同时满足 IsString 和 IsContainer 两个条件时,前者将具有优先权,因为编译器会优先考虑约束更严格的那个。
这些都是很棒的文章,深入解释了合取、析取和按约束排序,我强烈建议您看一下。
也许,通过提供完整的源代码来总结代码并与读者的想象力同步是一个好主意:
// makeString.hpp
#pragma once
#include <string>
#include <type_traits>
#include <concepts>
template <typename T>
concept IsContainer = requires (T&& container) { std::begin(container); };
// IsString is more constrained than IsContainer,
// so it will have a priority wherever possible
template <typename T>
concept IsString = IsContainer<T> && std::constructible_from<std::string, T>;
template <typename T>
concept HasStdConversion = requires (T number) { std::to_string(number); };
template <typename T>
concept HasToString = requires (T&& object)
{
{object.to_string()} -> std::convertible_to<std::string>;
};
template <HasStdConversion Numeric>
std::string makeString(Numeric number)
{
return std::to_string(number);
}
template <HasToString Object>
std::string makeString(Object&& object)
{
return std::forward<Object>(object).to_string();
}
template <IsString String>
std::string makeString(String&& s)
{
return std::string(std::forward<String>(s));
}
template <IsContainer Iterable>
std::string makeString(Iterable&& iterable)
{
std::string result;
for (auto&& i : iterable)
{
if (!result.empty())
result += ';';
// a constexpr if, so the compiler can omit unused branch and
// allow non-copyable types usage
if constexpr (std::is_rvalue_reference_v<decltype(iterable)>)
result += makeString(std::move(i));
else
result += makeString(i);
}
return result;
}
编译、运行:成功了!
9.3 按约束对重载进行排序
说到可扩展性,假设我们的 makeString 是一个库头文件,我们不愿意修改它。在这种情况下,考虑一下同时具有 IsContainer 和 HasToString 属性的类:
struct C
{
std::string m_string;
auto begin() const { return std::begin(m_string); }
auto begin() { return std::begin(m_string); }
auto end() const { return std::end(m_string); }
auto end() { return std::end(m_string); }
std::string to_string() const { return "C{\"" + m_string + "\"}"; }
};
int main()
{
// ...
std::cout << makeString(makeVector())
<< std::endl
<< makeString( C { "a container with its own to_string()" } )
<< std::endl;
}
makeString(C{...}) 调用中存在歧义,因为编译器无法确定 IsContainer 或 HasToString 重载是否更适合应用。
[build] main.cpp:58:18: error: call to 'makeString' is ambiguous
[build] << makeString( C { "a container with its own to_string()" } )
[build] ^~~~~~~~~~
[build] makeString.hpp:37:13: note: candidate function [with Object = C]
[build] std::string makeString(Object&& object)
[build] ^
[build] makeString.hpp:49:13: note: candidate function [with Iterable = C]
[build] std::string makeString(Iterable&& iterable)
[build] ^
熟悉 SFINAE 时代的人可以想象这场悲剧的严重性,而那些已经看过 Andrzej 关于按约束排序的文章的人可以想象解决方案。
歧义的产生是因为两个受约束的方法具有相同的优先级,并且彼此之间没有任何约束可以包含对方。解决方案很简单:引入第三个重载,使其比冲突的重载更具限制性。
struct C
{
std::string m_string;
auto begin() const { return std::begin(m_string); }
auto begin() { return std::begin(m_string); }
auto end() const { return std::end(m_string); }
auto end() { return std::end(m_string); }
std::string to_string() const { return "C{\"" + m_string + "\"}"; }
};
template <typename Container>
requires IsContainer<Container> && HasToString<Container>
std::string makeString(Container&& c)
{
return std::forward<Container>(c).to_string();
}
int main()
{
// ...
std::cout << makeString(makeVector())
<< std::endl
<< makeString( C { "a container with its own to_string()" } )
<< std::endl;
}
这样,通过引入新规则解决了歧义问题,保留了库的实现
112

被折叠的 条评论
为什么被折叠?



