avatar

目录
265 - using concept

265 - Using Concept

https://gemini.google.com/u/2/app/494d211d2d28ff60

好的,作为你的 C++ 导师,我会用最清晰易懂的方式,带你一步步学习 C++20 中引入的 Concepts (概念)。

1. 标题与摘要

标题: C++20 Concepts 入门:为你的函数模板添加约束

摘要: 本次讲解我们将学习 C++20 中的 Concepts (概念)。Concepts 是一种机制,允许我们为函数模板 (function template) 添加约束,规定模板参数必须满足的条件(例如,必须是整数、浮点数或某种自定义类型)。如果尝试使用不满足约束的类型调用模板,编译器将在编译时 (compile time) 报错,从而提高代码的健壮性和可读性。

2. 详细解释

什么是 Concepts?

想象一下,你写了一个函数模板,比如一个加法函数,你希望它能处理各种数字类型。但有时候,你可能只想让它处理整数,或者只处理有特定行为的类型。在 C++20 之前,实现这种约束比较复杂,通常需要用到 SFINAE (Substitution Failure Is Not An Error) 或者 static_assert

C++20 引入的 Concepts (概念) 提供了一种更简洁、更直接的方式来表达这些约束。你可以把它看作是对模板参数的“要求”或“合同”。当你定义一个函数模板时,可以指定它的模板参数必须满足某个 Concept。

Concepts 的两大来源:

  1. 标准库 (standard library) 提供的 Concepts: C++ 标准库已经为我们预定义了很多常用的 Concepts,可以直接使用。比如用于约束整数类型的 std::integral,约束浮点数类型的 std::floating_point (浮点数类型) 等。本次讲解主要使用这些标准库 Concepts。
  2. 自定义 Concepts: 你也可以根据自己的需求定义新的 Concepts。这部分内容我们将在后续课程中学习。

使用 Concepts 的好处:

  • 更清晰的编译错误信息: 当约束不满足时,编译器会直接告诉你哪个 Concept 没有被满足,而不是像 SFINAE 那样产生冗长难懂的错误信息。
  • 将约束写在接口中: Concepts 直接出现在函数模板的声明中,使得模板的“意图”和“要求”更加明确,提高了代码的可读性。
  • 提高类型安全 (type safety): 在编译阶段就能阻止不合适的类型被用于模板,避免了潜在的运行时错误或逻辑错误。

如何为函数模板应用 Concepts?(四种语法)

C++20 提供了几种不同的语法来将 Concepts 应用于函数模板:

语法一:模板声明后的 requires 子句 (Clause)

这是最直接的一种方式。在模板参数列表之后,使用 requires 关键字 (keyword),后跟你的 Concept 和模板参数。

C++

Code
1
2
3
4
5
6
7
8
9
// 包含 Concepts 需要的头文件
#include <concepts>

template <typename T>
// 在模板参数列表后添加 requires 子句
requires std::integral<T> // T 必须满足 std::integral 这个 Concept
auto add(T a, T b) {
return a + b;
}

这里的 requires std::integral<T> 就表示:只有当调用 add 函数时提供的类型 T 是一个整数类型(如 int, char, short, long 等),这个模板才会被实例化。如果你尝试用 doublestd::string 来调用它,编译器就会报错。

  • 注意: requires 后面跟的是一个在编译时求值的布尔表达式。这意味着你甚至可以直接使用类型萃取 (Type Traits) 库里的表达式,只要它能在编译时确定结果是 truefalse。例如:requires std::is_integral_v<T> 也能达到同样的效果。当表达式为 true 时,约束满足;为 false 时,约束不满足,编译失败。

语法二:在模板参数列表中直接使用 Concept

你可以直接用 Concept 的名字替换模板参数列表中的 typenameclass

C++

Code
1
2
3
4
5
6
7
#include <concepts>

// 直接用 Concept std::integral 替代 typename
template <std::integral T> // T 必须是整数类型
auto add(T a, T b) {
return a + b;
}

这种写法更简洁,意图也非常清晰:T 不仅仅是“任意类型”,而是必须是“满足 std::integral 的类型”。它和语法一的效果是完全一样的。

语法三:结合 auto 参数使用

如果你使用 C++14/17 引入的 auto 作为函数参数类型来简化函数模板的编写(这被称为“简略函数模板”或 “abbreviated function template”),你也可以在 auto 前面加上 Concept 来约束推导出的类型。

C++

Code
1
2
3
4
5
6
#include <concepts>

// 对每个使用 auto 的参数,在其前面加上 Concept
auto add(std::integral auto a, std::integral auto b) { // a 和 b 的类型都必须是整数类型
return a + b;
}

这表示,编译器在推导 ab 的具体类型时,必须确保推导出的类型满足 std::integral 这个 Concept。

语法四:尾随 requires 子句

你还可以将 requires 子句放在函数参数列表的 后面

C++

Code
1
2
3
4
5
6
7
#include <concepts>

template <typename T>
auto add(T a, T b)
requires std::integral<T> { // requires 子句放在参数列表之后
return a + b;
}

这种语法在某些复杂的情况下可能更灵活,或者在约束依赖于多个模板参数时可读性更好。它和语法一的效果也是一样的。

总结: 这四种语法提供了灵活性,你可以根据具体情况和个人偏好选择最合适的。核心思想都是一样的:为模板参数添加编译时的约束。

3. 代码示例

让我们通过一个完整的例子来看看这些语法如何工作。我们将创建一个 add 函数模板,并使用 std::integral Concept 来约束它只能用于整数类型。

C++

Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <iostream>
#include <concepts> // 必须包含 <concepts> 头文件才能使用 Concepts
#include <string> // 只是为了演示非整数类型

// --- 语法一:模板声明后的 requires 子句 ---
template <typename T>
requires std::integral<T> // T 必须是整数
auto add_syntax1(T a, T b) {
std::cout << "[Syntax 1] ";
return a + b;
}

// --- 语法二:模板参数列表中的 Concept ---
template <std::integral T> // T 必须是整数
auto add_syntax2(T a, T b) {
std::cout << "[Syntax 2] ";
return a + b;
}

// --- 语法三:结合 auto 使用 ---
// 注意:返回值类型也可以用 Concept auto,或者明确指定,或者让编译器推导
// 这里为了简单,我们让编译器推导返回类型
auto add_syntax3(std::integral auto a, std::integral auto b) {
std::cout << "[Syntax 3] ";
return a + b;
}

// --- 语法四:尾随 requires 子句 ---
template <typename T>
auto add_syntax4(T a, T b)
requires std::integral<T> { // T 必须是整数
std::cout << "[Syntax 4] ";
return a + b;
}

int main() {
int i1 = 5, i2 = 10;
char c1 = 'a', c2 = 1; // char 也是整数类型
double d1 = 3.14, d2 = 2.71;
// std::string s1 = "Hello", s2 = " World"; // 字符串不是整数

// 使用整数类型调用,这些都应该编译通过
std::cout << "int: " << add_syntax1(i1, i2) << std::endl;
std::cout << "char: " << add_syntax1(c1, c2) << std::endl; // char 会被提升为 int 进行运算

std::cout << "int: " << add_syntax2(i1, i2) << std::endl;
std::cout << "char: " << add_syntax2(c1, c2) << std::endl;

std::cout << "int: " << add_syntax3(i1, i2) << std::endl;
std::cout << "char: " << add_syntax3(c1, c2) << std::endl;

std::cout << "int: " << add_syntax4(i1, i2) << std::endl;
std::cout << "char: " << add_syntax4(c1, c2) << std::endl;


// 使用 double 类型调用,这些调用会因为不满足 std::integral 约束而导致编译错误
// 取消下面行的注释会导致编译失败
// std::cout << "double: " << add_syntax1(d1, d2) << std::endl;
// std::cout << "double: " << add_syntax2(d1, d2) << std::endl;
// std::cout << "double: " << add_syntax3(d1, d2) << std::endl;
// std::cout << "double: " << add_syntax4(d1, d2) << std::endl;

// 同样,使用 std::string 调用也会失败
// std::cout << "string: " << add_syntax1(s1, s2) << std::endl; // 编译错误

// 编译器的错误信息会很清晰,例如(不同编译器略有不同):
// error: constraints not satisfied for function template 'add_syntax1' [with T = double]
// note: because 'double' does not satisfy concept 'integral'
// note: 'std::integral<double>' evaluated to false

return 0;
}

编译和运行:

你需要一个支持 C++20 的编译器(如 GCC 10+ 或 Clang 10+)。编译时需要启用 C++20 标准。

  • 使用 GCC: g++ -std=c++20 your_code.cpp -o program
  • 使用 Clang: clang++ -std=c++20 your_code.cpp -o program

当你编译上面的代码(保持 double 调用的注释状态)时,它会成功编译并运行,输出整数和字符相加的结果。如果你取消对 double 类型调用的注释,编译将会失败,并给出类似上面提到的、关于 std::integral 约束不满足的清晰错误信息。

4. 问答卡片 (QA Flash Cards)

  • 问: C++ Concepts 是什么?

    答: C++20 引入的一种机制,用于对函数模板的模板参数施加约束,规定它们必须满足的条件(如类型特性或行为)。

  • 问: 为什么要使用 Concepts?

    答: 提供更清晰的编译错误信息、将约束直接写在函数接口中提高可读性、增强编译时类型安全。

  • 问: 使用标准库 Concepts 需要包含哪个头文件?

    答: #include

  • 问: 写出至少两种为函数模板 template void func(T arg); 添加 std::integral 约束的语法。

    答:

    1. template <typename T> requires std::integral<T> void func(T arg);
    2. template <std::integral T> void func(T arg);
    3. (如果用 auto) void func(std::integral auto arg);
    4. template <typename T> void func(T arg) requires std::integral<T>;

5. 常见误解或错误

  1. 忘记 #include <concepts> 这是最常见的错误。不包含头文件就无法使用标准库定义的 Concepts。
  2. 语法混淆: 对四种语法不熟悉,比如 requires 子句放错位置,或者在 auto 前后错误地使用 Concept。
  3. 编译器不支持: 使用了不支持 C++20 或未启用 C++20 标准的编译器进行编译。
  4. 认为 Concepts 是运行时检查: Concepts 是在编译时 (compile time) 进行检查的,不是在程序运行时。如果编译通过,就意味着约束在编译层面得到了满足。
  5. 过度约束: 有时候可能添加了过于严格的 Concept,导致一些本可以正常工作的类型无法使用模板。需要仔细选择或定义合适的 Concept。
  6. 对 Concept 的要求理解不清: 不清楚某个标准 Concept(如 std::invocable, std::equality_comparable)具体要求类型具备哪些操作或特性,导致使用错误。

6. 编程练习

假设你有一个函数模板,用于打印任何可以转换为 std::string 的类型的值。请使用 Concepts(选择上面介绍的任意一种语法)来约束模板参数 T,确保只有能隐式转换为 std::string 的类型才能调用这个函数模板。

你需要查找一个合适的标准 Concept 来表达“可以转换为 std::string”。提示:可以在 <concepts> 头文件中查找与转换相关的 Concept。

C++

Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <string>
#include <concepts> // 确保包含了 concepts 头文件
#include <vector> // 用于测试不兼容类型

// TODO: 在下面的模板声明中添加合适的 Concept 约束
// 要求:类型 T 必须能够隐式转换为 std::string

/* 在这里添加你的 Concept 约束 */
template <typename T>
void print_as_string(const T& value) {
std::string s = value; // 依赖于 T 到 std::string 的隐式转换
std::cout << s << std::endl;
}

// 一个简单的、可以转换为 string 的类
struct MyStringable {
operator std::string() const { // 定义了转换到 std::string 的操作符
return "MyStringable Object";
}
};

int main() {
print_as_string("Hello C++ Concepts!"); // const char* 可以转为 string
print_as_string(std::string("I am a string.")); // string 本身

MyStringable my_obj;
print_as_string(my_obj); // 自定义类型,可以转为 string

int number = 123;
// 如果约束正确添加,下面这行应该导致编译错误,因为 int 不能直接转为 std::string
// print_as_string(number);

std::vector<int> vec = {1, 2, 3};
// 如果约束正确添加,下面这行也应该导致编译错误
// print_as_string(vec);

return 0;
}

请在你认为合适的位置填入 Concept 约束,使得 intstd::vector<int> 的调用会导致编译失败,而其他调用可以成功。完成后,请将你修改后的 print_as_string 函数模板定义发给我。

没问题!看来这个练习稍微有点挑战性,这很正常,我们一起来看看答案。

7. 练习解答与评价

你没有提供具体的尝试代码,这完全没关系!学习新知识的过程中,遇到困难是很常见的。关键在于理解为什么以及如何解决它。

正确答案:

要约束模板参数 T 必须能隐式转换为 std::string,我们需要使用 C++ 标准库在 <concepts> 头文件中提供的 std::convertible_to Concept。

以下是使用 语法一 (requires 子句) 的解决方案:

C++

Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <string>
#include <concepts> // 确保包含了 concepts 头文件
#include <vector> // 用于测试不兼容类型

// 使用 std::convertible_to Concept 约束 T
template <typename T>
requires std::convertible_to<T, std::string> // 约束:T 必须能隐式转换为 std::string
void print_as_string(const T& value) {
std::string s = value; // 依赖于 T 到 std::string 的隐式转换
std::cout << s << std::endl;
}

// --- 其他代码保持不变 ---

// 一个简单的、可以转换为 string 的类
struct MyStringable {
operator std::string() const { // 定义了转换到 std::string 的操作符
return "MyStringable Object";
}
};

int main() {
print_as_string("Hello C++ Concepts!"); // const char* 可以转为 string
print_as_string(std::string("I am a string.")); // string 本身

MyStringable my_obj;
print_as_string(my_obj); // 自定义类型,可以转为 string

int number = 123;
// 现在下面这行会导致编译错误,因为 int 不能隐式转为 std::string
// print_as_string(number);

std::vector<int> vec = {1, 2, 3};
// 现在下面这行也会导致编译错误
// print_as_string(vec);

return 0;
}

解释:

  1. std::convertible_to<From, To> 这是标准库提供的一个 Concept,用于检查 From 类型的对象是否可以隐式地转换为 To 类型。在我们的例子中,就是检查类型 T 是否能隐式转换为 std::string
  2. requires std::convertible_to<T, std::string> 这行代码是核心。它告诉编译器,只有当模板参数 T 满足“可以隐式转换为 std::string”这个条件时,print_as_string 函数模板才能被实例化。
  3. 为什么有效?
    • const char* (C 风格字符串字面量) 可以隐式转换为 std::string
    • std::string 自身当然满足条件。
    • MyStringable 类定义了一个 operator std::string(),这使得它可以隐式转换为 std::string
    • intstd::vector<int> 类型没有提供到 std::string 的隐式转换规则,因此它们不满足 std::convertible_to<T, std::string> 这个 Concept,导致编译失败。这正是我们想要的效果!

其他语法:

你也可以用其他语法实现相同的约束,例如:

  • 语法二: template <std::convertible_to<std::string> T>
  • 语法四: template <typename T> void print_as_string(const T& value) requires std::convertible_to<T, std::string>

它们的效果是完全一样的。

8. 扩展思考与建议

恭喜你接触了 Concepts 这个强大的 C++20 特性!为了更深入地理解和应用它,你可以尝试思考以下问题或进行探索:

  1. 尝试其他语法: 将上面答案中的 requires 子句改成语法二或语法四的形式,亲自验证它们的效果。
  2. 探索其他标准 Concepts:
    • std::same_as<T, U>:检查类型 T 是否与类型 U 完全相同。
    • std::derived_from<Derived, Base>:检查 Derived 是否继承自 Base
    • std::invocable<Fn, Args...>:检查类型 Fn 的对象是否可以用参数 Args... 来调用(像函数一样)。
    • std::equality_comparable<T>:检查类型 T 的对象是否支持 ==!= 操作。
    • std::totally_ordered<T>:检查类型 T 的对象是否支持所有比较操作符 (<, >, <=, >=)。
    • 尝试修改 print_as_string 或创建新的模板函数,并使用这些 Concepts 进行约束。
  3. 思考 std::convertible_to 的局限性: 这个 Concept 只检查隐式转换。如果一个类型只能 显式 转换为 std::string(例如,通过 static_cast 或只有一个 explicit 的转换构造函数/操作符),std::convertible_to 会返回 false。思考一下,如果你想允许显式转换,或者想区分隐式和显式转换,该怎么做?(这可能需要自定义 Concept 或其他技术)。
  4. 下一步:自定义 Concepts: 思考一下,标准库 Concepts 可能无法满足所有需求。如果你想定义一个 Concept,要求一个类型必须有一个名为 serialize() 的成员函数,该怎么办?这就是我们接下来要学习的——如何定义你自己的 Concepts!

继续努力,多动手实践,你会越来越熟练地掌握 C++ Concepts 的!


评论