C++20中的概念(Concepts)到底是什么概念?
C++20中的概念(Concepts)到底是什么概念?
大家好!今天我们来聊聊C++20中的一个非常酷的新特性——概念(Concepts)。C++的模板编程功能非常强大而复杂,但也经常让人头疼,时常让我陷入自我怀疑:我真的懂C++么?我还敢在简历里面写精通C++么?😅😅😅
概念的引入可以说是为模板编程带来了福音,终于可以让我以轻松、愉悦的方式来进行模板编程了,今天就跟大家聊聊Concepts,带你了解C++20是如何让模板编程变得更简单易用的。
什么是概念(Concepts)?
先给大家一个简单的比喻。想象一下,我们在招募成员来组建一个乐队。我们当然希望招募的成员能够符合一些基本要求。比如,吉他手至少要会弹吉他,鼓手至少要会打鼓。这些基本要求就是我们所说的概念(Concepts)。
在C++中,Concepts 就是用来描述模板参数应该满足的一些条件和约束。这些条件通常在模板函数或类中显式规定,以确保传入的参数类型符合预期。
requires
表达式
requires
表达式是C++20中引入的一种新机制,用来在模板的上下文中进行约束和检查。通过requires
表达式,你可以明确地规定模板参数需要满足的条件,从而使代码更具可读性和表达力。
requires
表达式用于指定类型必须满足的条件。其基本语法如下:
template<typename T>
concept ConceptName = requires (T a, T b) {
// 要求表达式
{ a + b } -> std::convertible_to<T>;
// 其他更多的条件...
};
在上面的语法中,requires
后面跟着一个带参数的表达式块,里面列出了模板参数需要满足的条件。
requires
表达式不仅可以用来检查操作符,还可以用来验证类型特征(type traits)、函数存在性、成员变量等。你可以使用requires
表达式来构建非常复杂的概念。
例子:检查类型是否有某个成员函数
template<typename T>
concept HasSizeFunction = requires(T t) {
{ t.size() } -> std::convertible_to<std::size_t>;
};
这个概念 HasSizeFunction
要求类型 T 必须有一个 size()
成员函数,并且 size()
的返回值必须可以转换为 std::size_t
。
基本用法
让我们通过一些代码实例来看看Concepts是如何使用的。
常见案例:检查类型是否支持 +
运算符
假设我们有一个简单的模板函数需要两个参数相加。在C++20之前,我们也许会这样写:
template<typename T>
T add(T a, T b) {
return a + b;
}
看似简单,但如果你传入的是不支持加法操作的类型,这段代码就会在你编译的时候爆出一堆复杂的错误信息,让人摸不着头脑。
在C++20中,我们可以用Concepts来明确限定模板参数必须支持加法操作。看例子:
#include <concepts>
#include <iostream>
// 定义一个概念来描述需要支持加法操作的类型
template<typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::convertible_to<T>;
};
template<Addable T>
T add(T a, T b) {
return a + b;
}
int main() {
std::cout << add(1, 2) << std::endl; // 正常
std::cout << add(1.5, 2.5) << std::endl; // 正常
// std::cout << add("a", "b") << std::endl; // 编译报错,因为字符串不支持 + 运算
return 0;
}
在这个例子中:
requires(T a, T b)
:定义了两个用于测试的变量a
和b
。{ a + b } -> std::convertible_to<T>;
:表示类型T必须支持+
操作,并且a + b
的结果类型必须可以转换为T。
Addable
是一个概念,它规定了类型 T 必须支持 a + b
运算,而且结果类型必须可以转换为T。然后我们在模板函数 add
中使用这个概念来约束模板参数。
进阶案例:检查类型是否是迭代器
概念不仅可以让代码更易读,还能提高编译时的错误信息质量,使其更明确、更有指导性。让我们再看看一个例子,这次我们检查类型是否支持迭代。
template<typename T>
concept Iterable = requires(T t) {
{ t.begin() } -> std::input_iterator;
{ t.end() } -> std::input_iterator;
};
template<Iterable T>
void print_all(const T& container) {
for (const auto& item : container) {
std::cout << item << " ";
}
std::cout << std::endl;
}
int main() {
std::vector<int> vec{1, 2, 3};
print_all(vec); // 正常
// int not_iterable = 5;
// print_all(not_iterable); // 编译报错,因为int不是可迭代类型
return 0;
}
在这个例子中:
requires(T t)
:定义了一个用于测试的变量t
。{ t.begin() } -> std::input_iterator;
:表示类型 T 必须有begin()
方法,并且begin()
的返回值类型必须是std::input_iterator
。{ t.end() } -> std::input_iterator;
:表示类型 T 必须有end()
方法,并且end()
的返回值类型必须是std::input_iterator
。
Iterable
概念定义了类型T需要有 begin()
和 end()
函数,并且返回值需要是 std::input_iterator
。然后我们在 print_all
函数中使用这个概念来约束模板参数。这样,如果传入一个不是可迭代的类型,编译器会立即报错。
在C++20之前的替代方案
在C++20之前,我们没有Concepts这个工具,因此我们常用其它方法来实现类似功能,如SFINAE(Substitution Failure Is Not An Error,多重替代失败并非错误)和类型特征(type traits)。这些方法可能会更复杂,更难读懂。我们来看几个例子:
示例:检查类型是否支持 +
运算符
在C++20之前,我们可以用SFINAE和类型特征来做到这一点:
#include <iostream>
#include <type_traits>
// 辅助函数检查 T 是否支持 + 操作符
template<typename T, typename = decltype(std::declval<T>() + std::declval<T>())>
std::true_type is_addable_impl(int);
template<typename>
std::false_type is_addable_impl(...);
template<typename T>
using is_addable = decltype(is_addable_impl<T>(0));
// 使用 SFINAE 的加法模板函数
template<typename T>
typename std::enable_if<is_addable<T>::value, T>::type add(T a, T b) {
return a + b;
}
int main() {
std::cout << add(1, 2) << std::endl; // 正常
std::cout << add(1.5, 2.5) << std::endl; // 正常
// std::cout << add("a", "b") << std::endl; // 编译报错,因为字符串不支持 + 运算
return 0;
}
这里我们使用了SFINAE和std::enable_if
来检查 T 类型是否支持 +
运算符。is_addable
特征确保 T 类型支持加法,然后在模板函数 add
中使用 enable_if
来使函数仅在 T 可加时启用。
示例:检查类型是否为可迭代容器
在C++20之前,我们也可以用SFINAE和类型特征来做到这一点:
#include <iostream>
#include <vector>
#include <type_traits>
// 辅助结构判断T是否有begin()和end()方法
template<typename T, typename = void>
struct is_iterable : std::false_type {};
template<typename T>
struct is_iterable<T, std::void_t<
decltype(std::declval<T>().begin()),
decltype(std::declval<T>().end())>> : std::true_type {};
// 使用 SFINAE 的打印函数
template<typename T>
typename std::enable_if<is_iterable<T>::value, void>::type
print_all(const T& container) {
for (const auto& item : container) {
std::cout << item << " ";
}
std::cout << std::endl;
}
int main() {
std::vector<int> vec{1, 2, 3};
print_all(vec); // 正常
// int not_iterable = 5;
// print_all(not_iterable); // 编译报错,因为 int 不是可迭代类型
return 0;
}
在这个例子中,我们使用 std::void_t
和 SFINAE 创建了 is_iterable
特征,用以检查类型 T 是否有 begin()
和 end()
方法。然后在 print_all
函数模板中使用 enable_if
来限定 T 必须是可迭代的类型。
到了这里,不知道真正读懂上面代码的小伙伴有几个?是不是真真切切体会到了Concepts给C++编程带来的简化程度,简直就是一个飞越!
预定义概念(Predefined Concepts)
那么除了我们自己亲自动手定义的Concepts,是不是有一些预定义的Concepts呢?答案是肯定的。C++20 自带了很多常用的概念,这些概念能大大简化我们的代码,让你写模板代码不再抓耳挠腮。
常见的预定义概念
-
Same:检查两个类型是否相同。
#include <concepts> template<typename T, typename U> concept Same = std::is_same_v<T, U>; Same<int, int> sameType; // 没问题 Same<int, double> differentType; // 编译错误
-
Integral:检查类型是否是整数类型。
#include <concepts> template<std::integral T> T add(T a, T b) { return a + b; } add(1, 2); // 没问题 add(1.0, 2.0); // 编译错误
-
SignedIntegral 和 UnsignedIntegral:检查类型是否是有符号整数或无符号整数。
#include <concepts> template<std::signed_integral T> T subtract(T a, T b) { return a - b; } subtract(3, 1); // 没问题 subtract(3U, 1U); // 编译错误
-
FloatingPoint:检查类型是否是浮点数类型。
#include <concepts> template<std::floating_point T> T multiply(T a, T b) { return a * b; } multiply(1.0, 2.0); // 没问题 multiply(1, 2); // 编译错误
-
DefaultConstructible:检查类型是否可以默认构造。
#include <concepts> template<std::default_constructible T> T create() { return T(); } create<int>(); // 没问题 create<std::mutex>(); // 编译错误
-
ConstructibleFrom:检查类型是否可以从给定的参数构造。
#include <concepts> template<typename T, typename... Args> concept ConstructibleFrom = std::constructible_from<T, Args...>; static_assert(ConstructibleFrom<std::string, const char*>); // 没问题 static_assert(ConstructibleFrom<std::string, int>); // 编译错误
-
Destructible:检查类型是否可销毁。
#include <concepts> template<std::destructible T> void destroy(T* ptr) { delete ptr; } destroy(new int(5)); // 没问题
-
Copyable 和 Movable:检查类型是否可复制或可移动。
#include <concepts> template<std::copyable T> T copy(T a) { return a; } copy(5); // 没问题 // unique_ptr<int> 不可复制,编译会报错 template<std::movable T> void move(T&& a) { T b = std::move(a); } move(std::unique_ptr<int>(new int(5))); // 没问题
这些预定义概念很大程度上简化了我们写模板代码的过程,还让代码更加易读。
更好的编译错误
C++20 的概念不仅让代码更简洁,还大大提升了编译期错误提示的质量。来看一下对比。
没有概念时的模板错误
我们来看一个简单的模板函数,负责相加两个元素:
template<typename T>
T add(T a, T b) {
return a + b;
}
int main() {
add(1, 2); // 没问题
add("Hello", "World"); // 编译错误
}
当你尝试编译这段代码时,如果传入不支持加法操作的类型,错误信息会比较模糊,而且很难理解。比如:
error: invalid operands of types ‘const char [6]’ and ‘const char [6]’ to binary ‘operator+’
return a + b;
~ ^ ~
这个错误信息不够直观,尤其对于不熟悉细节的人来说。
使用概念后的模板错误
用概念,我们可以明确地限定模板参数:
#include <concepts>
#include <iostream>
template<typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::convertible_to<T>;
};
template<Addable T>
T add(T a, T b) {
return a + b;
}
int main() {
std::cout << add(1, 2) << std::endl; // 没问题
std::cout << add(1.5, 2.5) << std::endl; // 没问题
// std::cout << add("Hello", "World") << std::endl; // 编译错误
return 0;
}
编译时,如果类型不符合要求(比如传入的是const char*
),错误信息会更清楚:
error: constraints not satisfied for function template 'T add(T, T)'
3 | template<Addable T>
^
required for the satisfaction of 'Addable<T>' [with T = const char*]
add.cpp: In instantiation of 'T add(T, T) [with T = const char*]':
add.cpp:11:27: required from here
add.cpp:6:14: note: the expression 'a + b' is not convertible to 'const char*'
6 | { a + b } -> std::convertible_to<T>;
这个错误信息非常清晰:
- 告诉你概念
Addable<T>
的约束条件不满足。 - 明确指出不满足的条件是
a + b
的结果不能转换为T。
小结
C++20中的Concepts为模板编程带来了巨大的提升,让模板代码变得更加清晰、易读且易于维护。通过Concepts,我们可以明确指定模板参数应满足的要求,避免了在编译时因为不匹配的类型而报出一堆晦涩难懂的错误信息。
希望这个讲解能够让你对C++20中的Concepts有一个直观的认识。如果大家有任何问题或对Concepts有更多的好奇,欢迎在评论区留言讨论。感谢大家的阅读,我们下次再见!