266 - Building Concept
https://gemini.google.com/u/2/app/2b1cb1ab39ffda6d
好的,同学!欢迎来到 C++ 的世界!我是你的专属 C++ 老师,拥有丰富的开发经验和教学技巧。别担心,就算你是 C++ 新手,我也会用最清晰、最易懂的方式,一步步带你掌握 C++ 的强大功能。
今天我们要学习的是 C++20 引入的一个非常酷的特性:如何创建你自己的概念 (Concepts)。这会让你编写的模板代码更安全、更清晰、也更容易理解。
1. 标题和摘要
标题: C++ 概念:动手创建你自己的类型约束
摘要: 本节课我们将学习如何在 C++20 中定义自己的概念 (Concept)。继上一课使用标准库 (Standard Library) 提供的概念后,本课将重点讲解两种创建自定义概念的语法:一种基于类型萃取 (Type Trait),另一种使用 requires
子句 (requires clause) 来指定更复杂的语法 (syntax) 要求。我们还将学习如何将自定义概念应用到函数模板上。
2. 详细内容解释
我们上一课学习了如何使用 C++ 标准库里已经为我们准备好的概念。它们很好用,但有时我们需要更特定的约束,这时候就需要创建自己的概念了。别担心,这比听起来要简单!
为什么要创建自己的概念?
想象一下,你在写一个函数模板 (template),比如一个加法函数 add(T a, T b)
。你希望这个函数能用于整数、浮点数,但不能用于字符串或者其他不能相加的类型。在 C++20 之前,如果你传入了错误的类型,编译器 (compiler) 可能会报出一大堆难以理解的错误信息。而概念就像是给模板参数 T 定下的一系列“规矩”或“要求”,只有满足这些要求的类型才能被接受。如果传入的类型不满足要求,编译器会给出清晰的错误提示,告诉你“这个类型不满足某某概念的要求”。自定义概念让我们可以精确地定义这些“规矩”。
创建自定义概念的两种主要语法:
语法一:基于类型萃取 (Type Trait)
这是最简单的一种方式,适用于你的要求可以通过一个类型萃取来判断的情况。
语法结构:
C++
Code1
2
3
4
5
6
7
8template <typename T> // 首先,声明一个模板参数,比如 T
concept 概念名称 = std::某个类型萃取<T>::value; // 或者使用 C++17 的 _v 简化版
// concept 关键字表明你正在定义一个概念
// 概念名称 是你给这个概念起的名字(通常用大驼峰命名法)
// = 号后面是你定义的要求
// std::某个类型萃取<T>::value 是一个在编译时求值的布尔表达式。
// 如果这个表达式为 true,则类型 T 满足该概念;否则不满足。
// 别忘了最后的 ; 分号!示例:定义一个 MyIntegral 概念
假设我们想定义一个概念,要求类型必须是整型 (integral) 的(比如 int, char, long 等,但不包括 float, double)。我们可以使用标准库中的 std::is_integral 类型萃取:
C++
Code1
2
3
4#include <type_traits> // 需要包含 <type_traits> 头文件
template <typename T>
concept MyIntegral = std::is_integral_v<T>; // 使用 _v 版本更简洁,它等价于 std::is_integral<T>::value这段代码定义了一个名为
MyIntegral
的概念。任何类型T
,只要std::is_integral_v<T>
在编译时计算结果为true
,那么它就满足MyIntegral
这个概念。
语法二:使用 requires
子句 (requires clause)
当你需要的约束比较复杂,不能简单地用一个类型萃取来表示时,或者你需要检查某些表达式的语法是否有效时,就需要用到 requires
子句。
语法结构:
C++
Code1
2
3
4
5
6
7
8
9template <typename T, ...> // 可以有一个或多个模板参数
concept 概念名称 = requires(参数列表) { // 使用 requires 关键字
// 在这里列出对类型的语法要求
表达式1;
表达式2;
// ...
// 每个要求都是一个表达式语句,必须以分号结尾
// 这些表达式本身并不需要计算出有意义的值,编译器只检查它们的语法是否对给定类型有效
}; // 别忘了最后的分号!requires
关键字后面可以跟一个可选的参数列表(参数列表)
,这里的参数就像是临时创建的、用于检查语法的变量。你可以给它们命名,比如(T a, T b)
。- 花括号
{}
内部包含了一系列的要求(通常是表达式语句)。编译器会检查对于满足该概念的类型,这些语句在语法上是否都有效。
示例 1:定义一个 Multipliable 概念
我们想定义一个概念,要求两个该类型的对象能够使用 * 运算符相乘。
C++
Code1
2
3
4
5template <typename T>
concept Multipliable = requires(T a, T b) { // a 和 b 是用于语法检查的临时变量名
a * b; // 检查 a * b 这个表达式的语法是否有效
// 注意:这里只检查语法,不关心 a * b 的结果是什么,也不关心这个操作是否有意义
};如果类型
T
(比如int
或double
)的对象支持*
运算,那么它就满足Multipliable
概念。但如果T
是std::string
,因为字符串不能直接相乘,所以它不满足这个概念。示例 2:定义一个 Incrementable 概念
我们想定义一个概念,要求某个类型的对象支持自增操作(前缀 ++、后缀 ++)和加法赋值 +=。
C++
Code1
2
3
4
5
6template <typename T>
concept Incrementable = requires(T a) {
a += 1; // 检查 a += 1 是否是有效语法
++a; // 检查 ++a 是否是有效语法
a++; // 检查 a++ 是否是有效语法
};任何支持这三种操作的类型(比如
int
,double
, 甚至某些自定义的类)都满足Incrementable
概念。
重要提醒:概念检查的是语法,不是语义或值!
这一点非常重要!当你在 requires
子句中写 a * b;
时,编译器只检查对于类型 T
,写 a * b
这行代码会不会导致编译错误。它并不检查 a * b
的结果是不是你期望的,也不检查这个乘法操作在逻辑上是否有意义。同样,Incrementable
概念只检查 ++a;
等语句能否编译通过,不检查 a
的值到底增加了多少。
如何使用自定义概念?
一旦你定义了自己的概念,就可以像使用标准库概念一样,用它来约束你的模板了。主要有四种语法形式(和我们上一课学的一样):
假设我们有之前定义的 MyIntegral
概念和一个 add
函数模板:
requires
子句放在模板声明之后,函数声明之前:C++
Code1
2
3
4
5template <typename T>
requires MyIntegral<T> // 要求 T 必须满足 MyIntegral 概念
T add(T a, T b) {
return a + b;
}直接在模板参数列表中使用概念名:
C++
Code1
2
3
4template <MyIntegral T> // 直接声明 T 是一个满足 MyIntegral 的类型
T add(T a, T b) {
return a + b;
}拖尾
requires
子句 (Trailing requires clause):C++
Code1
2
3
4template <typename T>
T add(T a, T b) requires MyIntegral<T> { // requires 子句放在函数参数列表之后
return a + b;
}与
auto
结合使用(用于函数参数):C++
Code1
2
3
4
5
6
7
8
9
10
11
12
13// 对于函数参数,可以直接用 概念名 auto 的形式
// 注意:返回值类型如果是 auto,也需要约束,或者明确写出类型
MyIntegral auto add(MyIntegral auto a, MyIntegral auto b) {
return a + b;
}
// 或者写成
// auto add(MyIntegral auto a, MyIntegral auto b) -> MyIntegral auto { // C++20 返回类型推导也可以用概念
// return a + b;
// }
// 更常见的是明确返回值类型或让其自动推导(如果约束允许)
// auto add(MyIntegral auto a, MyIntegral auto b) { // 返回类型自动推导
// return a + b;
// }
这四种语法在使用上是等价的,你可以选择自己喜欢的风格。它们都能达到同样的效果:约束传递给 add
函数的参数类型 T
必须满足 MyIntegral
概念。如果尝试用不满足概念的类型(比如 double
)去调用 add
函数,编译器会给出清晰的错误信息,指出违反了 MyIntegral
概念。
3. 代码示例
下面是一个完整的代码示例,演示了如何定义和使用我们上面讨论的 MyIntegral
, Multipliable
, 和 Incrementable
概念:
C++
1 | #include <iostream> |
编译和运行:
你需要一个支持 C++20 的编译器(例如 GCC 10+ 或 Clang 10+)。编译时需要启用 C++20 标准。
例如,使用 g++:
g++ -std=c++20 your_code_file.cpp -o your_executable
然后运行:
./your_executable
你会看到成功的输出,并且如果你取消注释掉那些会导致编译错误的行,编译器会给出清晰的关于概念约束不满足的错误信息。
1 |
|
include
include
include // 需要包含
// TODO: 在这里定义你的 Printable 概念
// template
// concept Printable = requires(???) {
// ??? ; // 检查输出流操作
// ??? ; // 检查大于比较操作
// };
// TODO: 使用 Printable 概念约束模板参数 T
// ??? ??? compare_and_print(??? a, ??? b) {
// std::cout << “比较并打印: \n”;
// std::cout << “第一个值: “ << a << std::endl;
// std::cout << “第二个值: “ << b << std::endl;
// if (a > b) {
// std::cout << “第一个值更大。\n”;
// } else if (b > a) { // 为了更完整,我们也检查 b > a
// std::cout << “第二个值更大。\n”;
// } else {
// std::cout << “两个值相等(根据>判断)。\n”;
// }
// }
struct NonPrintable { // 这个结构体不支持 << 输出
int value;
bool operator>(const NonPrintable& other) const { return value > other.value; }
};
struct NonComparable { // 这个结构体不支持 > 比较
int value;
};
// 让 NonComparable 可打印
std::ostream& operator<<(std::ostream& os, const NonComparable& nc) {
os << nc.value;
return os;
}
int main() {
int x = 10, y = 5;
compare_and_print(x, y); // 应该可以工作
std::string s1 = "apple", s2 = "banana";
compare_and_print(s1, s2); // 应该可以工作 (string 支持 << 和 >)
// NonPrintable np1{1}, np2{2};
// compare_and_print(np1, np2); // TODO: 取消注释这一行,应该导致编译错误 (不满足 Printable)
// NonComparable nc1{3}, nc2{4};
// compare_and_print(nc1, nc2); // TODO: 取消注释这一行,应该导致编译错误 (不满足 Printable)
return 0;
}
1 |
|
非常棒!你做得非常好!你提交的代码完全正确地实现了我们要求的功能。
7. 解决方案评价、标准答案及解释
评价:
- 评分: ⭐⭐⭐⭐⭐ (5/5 - 非常出色!)
- 评价:
- 你准确地定义了
Printable
概念,使用了requires
子句来检查两个关键的语法要求:流输出操作 (std::cout << a;
) 和大于比较操作 (a > b;
)。参数列表(T a, T b)
的使用也很恰当。 - 你成功地将
Printable
概念应用到了compare_and_print
函数模板上,使用了简洁的template <Printable T>
语法。 - 函数体内的逻辑与概念所保证的约束一致。
- 代码结构清晰,易于理解。
- 你准确地定义了
标准答案与解释:
你的答案本身就是非常标准的答案!这里我再把它贴出来,并加上一些注释,同时展示一个稍微不同的检查流输出的方式(你的方法也是完全正确的):
1 |
|
当你取消注释掉最后两个 compare_and_print
调用并尝试编译时,你会得到清晰的编译器错误信息,指出约束 Printable
未满足,这正是概念带来的好处!
8. 建议与扩展问题 (Suggestions & Extended Questions)
你已经掌握了自定义概念的基础!为了进一步深化理解,可以思考和探索以下问题:
- 组合概念: 如果你有一个
Addable
概念检查a + b
是否有效,你如何定义一个新的概念AddableAndPrintable
,要求一个类型必须 同时 满足Addable
和Printable
两个概念?(提示:想想逻辑运算符) requires
表达式 (Requires Expressions): 在requires
子句内部,我们不仅可以写简单的表达式语句,还可以使用更复杂的requires
表达式 来进行更精细的检查。例如,如何检查a > b
不仅语法有效,而且其返回结果必须能够转换为bool
类型?C++
Code1
2
3
4template <typename T>
concept StrictComparable = requires(T a, T b) {
{ a > b } -> std::convertible_to<bool>; // 注意这种语法 { expression } -> TypeConstraint;
};你能尝试修改
Printable
概念,使其包含这个更严格的比较检查吗?- 标准库概念探索: C++ 标准库在
<concepts>
头文件中提供了许多有用的预定义概念,比如std::totally_ordered
(要求类型支持<
,>
,<=
,>=
,==
,!=
),std::equality_comparable
(要求==
,!=
),std::movable
,std::copyable
,std::invocable
等。花点时间看看这些标准概念的定义和用途,它们能让你的代码更规范。我们的Printable
概念和std::totally_ordered
以及流输出有什么关系? - 类模板的概念约束: 我们今天主要将概念用在了函数模板上。你能尝试定义一个类模板,并使用概念来约束它的模板参数吗?例如,一个只能存储
Printable
类型的Pair
类。
这些问题可以帮助你更好地理解概念的强大功能和灵活性。继续探索,你会发现概念是现代 C++ 中一个非常有用的工具!
如果你对这些扩展问题有任何想法或者尝试了新的代码,随时都可以分享给我!学习 C++ 的旅程,我们一起前进!