avatar

目录
268 - Combining concepts

268 - Combining Concepts

https://gemini.google.com/u/2/app/3982bc96df1cc48f

好的,新同学你好!我是你的 C++ 老师。别担心,C++ 看起来可能有点复杂,但我们会一步一步来,用最清晰易懂的方式学习。这节课我们来学习一个 C++20 引入的很酷的特性:概念(Concept)的组合。

1. 标题和摘要

标题: C++ 概念:使用逻辑运算符组合约束

摘要: 本节课讲解如何使用逻辑与(&& - AND)和逻辑或(|| - OR)运算符将多个 C++ 概念(Concept)组合起来,对模板参数施加更复杂的约束。我们将通过示例代码学习其语法和应用场景,并了解一种不推荐的“内联”定义方式。

2. 详细解释

我们之前可能已经接触过 C++ 的模板(template),它允许我们编写通用的代码,可以处理不同类型的数据。但有时候,我们希望对这些“不同类型”进行一些限制,不是任何类型都适用。比如,一个函数可能只接受整数,或者只接受能进行比较的类型。

C++20 引入了 概念(Concept) 这个强大的特性,它允许我们明确地定义对模板参数的 要求(requirements)。这些要求可以是语法上的(比如,要求某个类型必须有某个成员函数),也可以是语义上的(比如,通过 requires 表达式检查某个表达式的值)。

这节课的核心是,我们不仅能定义单个概念,还能像拼积木一样,把多个概念 组合 起来,形成更复杂、更精确的约束条件。怎么组合呢?就是使用我们熟悉的 逻辑运算符(logical operators)

  • 逻辑与 (Logical AND - &&):表示 同时满足 多个概念。如果一个类型需要满足 ConceptA && ConceptB,那么它必须 满足 ConceptA 满足 ConceptB
  • 逻辑或 (Logical OR - ||):表示 满足其中任意一个 概念即可。如果一个类型需要满足 ConceptA || ConceptB,那么它 要么 满足 ConceptA要么 满足 ConceptB,或者两者都满足。

让我们来看一个例子:TinyType 概念

在讲解组合之前,我们先回顾(或学习)一个简单的概念 TinyType

C++

Code
1
2
3
4
5
6
7
8
template <typename T>
concept TinyType = requires(T t) {
// 简单要求 (simple requirement): 检查 sizeof(T) < 4 这个语法是否有效
sizeof(T) < 4;
// 嵌套要求 (nested requirement): 不仅检查语法,还要求表达式的值必须为 true
{ sizeof(T) < 4 } -> std::convertible_to<bool>; // C++20 语法,确保结果可转为 bool
requires sizeof(T) < 4; // 更直接的方式强制要求表达式为 true
};

这个 TinyType 概念是用来检查一个类型 T 所占用的内存大小(用 sizeof(T) 获取)是否小于 4 个字节。

  • sizeof(T) < 4; 这是一个 简单要求(simple requirement),它只检查这个表达式在语法上是否有效。
  • requires sizeof(T) < 4; 这是一个 嵌套要求(nested requirement)。它不仅检查语法,还 强制要求 sizeof(T) < 4 这个表达式的计算结果必须是 true。如果结果是 false,即使语法没错,这个概念也不会被满足。

如何在一个函数模板中使用和组合概念?

我们通常在函数模板的声明中使用 requires 子句来应用概念。

C++

Code
1
2
3
4
5
6
7
template <typename T>
// requires 子句 (requires clause) 开始
requires ConceptA<T> && ConceptB<T> // 或者 ConceptA<T> || ConceptB<T>
T func(T param) {
// 函数体
return param;
}

这里的 requires 关键字(keyword)后面跟着的就是对模板参数 T 的约束条件。

组合示例 1:使用逻辑或 (||)

假设我们想让一个函数 add 既能处理整数类型,也能处理浮点数类型。我们可以使用标准库里预定义的 std::integralstd::floating_point 概念,并用 || 把它们组合起来。

C++

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

template <typename T>
requires std::integral<T> || std::floating_point<T> // 要求 T 要么是整数,要么是浮点数
T add(T a, T b) {
return a + b;
}
  • std::integral<T>:检查类型 T 是否是整数类型(如 int, char, long long 等)。
  • std::floating_point<T>:检查类型 T 是否是浮点数类型(如 float, double 等)。
  • ||:表示 T 只需要满足这两个概念中的 任何一个 就可以了。

所以,你可以用 int 或者 double 来调用 add 函数,都没问题。但是如果你尝试用 std::string 或者其他不满足这两个概念中任何一个的类型来调用,编译器就会报错,因为它不满足我们设定的约束。

组合示例 2:使用逻辑与 (&&)

现在,假设我们想让 add 函数的要求更严格:它处理的类型 必须 是整数类型,并且 这个类型的内存大小必须小于 4 字节(满足我们之前定义的 TinyType)。这时我们就需要用 &&

C++

Code
1
2
3
4
5
6
7
// (假设 TinyType 概念已定义如上)

template <typename T>
requires std::integral<T> && TinyType<T> // 要求 T 必须同时是整数 并且 满足 TinyType
T add(T a, T b) {
return a + b;
}
  • &&:表示 T 必须 同时满足 std::integral<T> TinyType<T> 这两个条件。

那么:

  • 如果你用 int (通常占用 4 字节或更少,具体取决于系统,但我们假设它在某些系统上是 4 字节,那么它可能不满足 < 4,但如果是 shortchar 通常可以) 调用 add,这取决于 int 的实际大小。如果 sizeof(int) 是 4,那么 TinyType<int>false,调用失败。如果 sizeof(short) 是 2,那么 short 类型既是 integral 也是 TinyType,可以调用。
  • 如果你用 long long int (通常占用 8 字节) 调用 add,它满足 std::integral<long long int>,但不满足 TinyType<long long int> (因为 8 不小于 4),所以调用会失败。
  • 如果你用 double (浮点数) 调用 add,它不满足 std::integral<double>,所以调用也会失败(即使它可能满足或不满足 TinyType,但 && 要求两者都满足)。

一种不推荐的方式:内联定义概念

C++ 语法甚至允许你不在 requires 子句中直接使用已命名的概念,而是直接把概念的定义(requires 表达式)写在里面。

C++

Code
1
2
3
4
5
template <typename T>
requires std::integral<T> && requires(T t) { requires sizeof(T) < 4; } // 直接把 TinyType 的核心要求写在这里
T func(T param) {
// ...
}

你看,requires std::integral<T> && requires(T t) { requires sizeof(T) < 4; } 这一长串就是约束。虽然语法上是合法的,并且能达到和 requires std::integral<T> && TinyType<T> 一样的效果,但它让函数声明变得非常冗长和难以阅读。通常我们强烈建议将复杂的约束封装成一个命名良好的概念,然后在 requires 子句中使用这个名字,这样代码会清晰得多。

总结一下:

  • 使用 &&|| 可以组合多个概念。
  • && 表示必须同时满足所有条件。
  • || 表示只需满足任意一个条件。
  • 组合概念可以用在函数模板的 requires 子句中,来精确控制允许使用的类型。
  • 避免在 requires 子句中直接写复杂的 requires 表达式(内联定义),优先使用命名概念。

3. 代码示例

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
73
74
75
#include <iostream>
#include <concepts> // 引入概念库
#include <string> // 引入字符串库

// 定义 TinyType 概念
template <typename T>
concept TinyType = requires(T t) {
// 要求 sizeof(T) < 4 这个表达式必须为 true
requires sizeof(T) < 4;
};

// 示例函数 1: 使用 || 组合概念
template <typename T>
requires std::integral<T> || std::floating_point<T> // T 必须是整数或浮点数
T add_or(T a, T b) {
std::cout << "使用 add_or (整数或浮点数): ";
return a + b;
}

// 示例函数 2: 使用 && 组合概念
template <typename T>
requires std::integral<T> && TinyType<T> // T 必须同时是整数并且小于4字节
T add_and(T a, T b) {
std::cout << "使用 add_and (整数且小于4字节): ";
return a + b;
}

// 示例函数 3: (不推荐) 内联定义概念
template <typename T>
requires std::integral<T> && requires(T t) { requires sizeof(T) < 4; } // T 必须是整数并且小于4字节 (内联)
T add_inline(T a, T b) {
std::cout << "使用 add_inline (内联定义): ";
return a + b;
}


int main() {
int a = 5, b = 3; // int: 整数, 通常 4 字节
short c = 1, d = 2; // short: 整数, 通常 2 字节 (< 4)
double e = 1.5, f = 2.5; // double: 浮点数, 通常 8 字节
long long g = 10, h = 20; // long long: 整数, 通常 8 字节
std::string s1 = "a", s2 = "b"; // string: 不是整数也不是浮点数

// 测试 add_or (||)
std::cout << add_or(a, b) << std::endl; // 输出: 使用 add_or (整数或浮点数): 8 (int 满足 integral)
std::cout << add_or(c, d) << std::endl; // 输出: 使用 add_or (整数或浮点数): 3 (short 满足 integral)
std::cout << add_or(e, f) << std::endl; // 输出: 使用 add_or (整数或浮点数): 4 (double 满足 floating_point)
std::cout << add_or(g, h) << std::endl; // 输出: 使用 add_or (整数或浮点数): 30 (long long 满足 integral)
// std::cout << add_or(s1, s2) << std::endl; // 编译错误!string 既不是 integral 也不是 floating_point

std::cout << "\n";

// 测试 add_and (&&)
// std::cout << add_and(a, b) << std::endl; // 编译错误(如果 sizeof(int) >= 4)!int 满足 integral 但不满足 TinyType
std::cout << add_and(c, d) << std::endl; // 输出: 使用 add_and (整数且小于4字节): 3 (short 满足 integral 和 TinyType)
// std::cout << add_and(e, f) << std::endl; // 编译错误!double 不满足 integral
// std::cout << add_and(g, h) << std::endl; // 编译错误!long long 不满足 TinyType (sizeof >= 4)
// std::cout << add_and(s1, s2) << std::endl; // 编译错误!string 不满足 integral

std::cout << "\n";

// 测试 add_inline (与 add_and 效果相同)
std::cout << add_inline(c, d) << std::endl; // 输出: 使用 add_inline (内联定义): 3 (short 满足 integral 和 TinyType)
// 其他调用同样会根据是否同时满足 integral 和 sizeof < 4 来决定成功或失败

// 打印一些类型的大小以供参考 (结果可能因系统而异)
std::cout << "\n类型大小参考:\n";
std::cout << "sizeof(int): " << sizeof(int) << std::endl;
std::cout << "sizeof(short): " << sizeof(short) << std::endl;
std::cout << "sizeof(double): " << sizeof(double) << std::endl;
std::cout << "sizeof(long long): " << sizeof(long long) << std::endl;


return 0;
}

注意: sizeof(int) 的大小在不同系统或编译器设置下可能是 4 字节或更大/更小。上述代码假设 sizeof(short) < 4,而 sizeof(int) 可能等于或大于 4, sizeof(long long) 大于 4。你需要根据你的实际编译环境来判断 int 是否满足 TinyType

4. QA 闪卡 (QA Flash Cards)

问题 (Question) 答案 (Answer)
如何组合两个 C++ 概念,要求类型同时满足两者? 使用逻辑与运算符 && (AND)。例如:requires ConceptA<T> && ConceptB<T>
如何组合两个 C++ 概念,要求类型满足其中任意一个即可? 使用逻辑或运算符 `
std::integral<T> 这个概念是检查什么的? 检查类型 T 是否是 C++ 标准定义的整数类型之一。
为什么不推荐在 requires 子句中直接写复杂的 requires 表达式(内联定义)? 会让函数模板的声明变得非常冗长、难以阅读和维护。最好定义成一个命名的概念。
requires { sizeof(T) < 4 }requires requires sizeof(T) < 4; 有什么区别? 前者是复合要求(compound requirement),可以检查更复杂的语法结构。后者是嵌套要求(nested requirement),明确要求里面的表达式为true。对于简单布尔检查,嵌套要求更直接。

5. 常见误解或错误 (Common Misunderstandings/Mistakes)

  1. 混淆 &&||:最常见的错误是该用 &&(与)的时候用了 ||(或),或者反过来。务必想清楚你是要求 所有 条件都满足,还是 任一 条件满足即可。
  2. 忘记包含 <concepts> 头文件:使用标准概念(如 std::integral)或定义自己的概念都需要包含这个头文件。
  3. requires 子句语法错误requires 关键字后面直接跟约束表达式,或者跟一个 requires 表达式。例如 requires requires ConceptA<T> 是错误的,应该是 requires ConceptA<T>
  4. sizeof 的误解sizeof 返回的是类型占用的字节数,这个值在不同平台和编译器下可能不同。依赖 sizeof 的概念(如 TinyType)可能不具备完全的可移植性,除非你明确知道目标平台的类型大小。
  5. 过度复杂的内联概念:如前所述,虽然语法允许,但在 requires 子句中写一长串 requires { ... }; requires ...; 会严重降低代码可读性。

6. 编码练习 (Coding Exercise)

现在,请你来完成一个小练习。我们有一个概念 HasToString,用于检查一个类型是否有 .toString() 成员函数。请你修改下面的函数模板 processData,使其同时满足以下两个条件:

  1. 类型 T 必须是有符号整数(可以使用标准概念 std::signed_integral)。
  2. 类型 T 必须满足 HasToString 概念。

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
#include <iostream>
#include <concepts>
#include <string>
#include <vector> // 只是为了让 HasToString 有个例子

// 假设这个概念检查类型是否有 toString() 成员函数
template <typename T>
concept HasToString = requires(T obj) {
{ obj.toString() } -> std::convertible_to<std::string>;
};

// 一个满足 HasToString 的示例类型 (仅用于演示)
struct MyData {
int value;
std::string toString() const {
return "MyData{" + std::to_string(value) + "}";
}
};

// 一个不满足 HasToString 的示例类型
struct SimpleData {
int value;
};


// 请修改下面的 requires 子句
template <typename T>
requires /* 在这里填入正确的组合概念 */
void processData(const T& data) {
std::cout << "Processing data: " << data.toString() << " (Value is signed integral)" << std::endl;
}


int main() {
// 编译成功的例子 (需要你正确填写 requires 子句)
// MyInt a = -10; // 假设 MyInt 是一个同时满足 signed_integral 和 HasToString 的类型
// processData(a);

// 编译失败的例子
// int b = 5; // 满足 signed_integral 但不满足 HasToString
// unsigned int c = 5; // 不满足 signed_integral
// SimpleData d{1}; // 不满足 signed_integral 也不满足 HasToString
// MyData e{2}; // 满足 HasToString 但不满足 signed_integral (因为 MyData 不是整数类型)

std::cout << "请在代码中填写 requires 子句并尝试编译/运行合适的例子。" << std::endl;

return 0;
}

请你在 /* 在这里填入正确的组合概念 */ 这部分填入正确的代码,使得 processData 函数能够正确地约束模板参数 T。完成后请把你的答案告诉我。

c++
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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#include <concepts>

#include <iostream>

#include <string>

#include <vector>  // 只是为了让 HasToString 有个例子



// 假设这个概念检查类型是否有 toString() 成员函数

template <typename T>

concept HasToString = requires(T obj) {

    { obj.toString() } -> std::convertible_to<std::string>;

};



// 一个满足 HasToString 的示例类型 (仅用于演示)

struct MyData {

    int value;

    std::string toString() const {

        return "MyData{" + std::to_string(value) + "}";

    }

};



// 一个不满足 HasToString 的示例类型

struct SimpleData {

    int value;

};



// 请修改下面的 requires 子句

template <typename T>

    requires std::signed_integral<T> && HasToString<T>

void processData(const T& data) {

    std::cout << "Processing data: " << data.toString()

              << " (Value is signed integral)" << std::endl;

}



int main() {

    // 编译成功的例子 (需要你正确填写 requires 子句)

    // MyInt a = -10; // 假设 MyInt 是一个同时满足 signed_integral 和

    // HasToString 的类型 processData(a);



    // 编译失败的例子

    // int b = 5;         // 满足 signed_integral 但不满足 HasToString

    // unsigned int c = 5; // 不满足 signed_integral

    // SimpleData d{1};  // 不满足 signed_integral 也不满足 HasToString

    // MyData e{2};      // 满足 HasToString 但不满足 signed_integral (因为

    // MyData 不是整数类型)



    std::cout << "请在代码中填写 requires 子句并尝试编译/运行合适的例子。"

              << std::endl;



    return 0;

}

7. 评价你的解答、正确答案和解释

评价:

你的解答非常棒!完全正确!

评级: 5/5 ⭐⭐⭐⭐⭐ (满分!)

正确答案:

你给出的答案正是标准答案:

C++

Code
1
2
3
4
5
template <typename T>
requires std::signed_integral<T> && HasToString<T>
void processData(const T& data) {
// ... 函数体 ...
}

解释:

练习要求类型 T 必须同时 满足两个条件:

  1. 是有符号整数 (std::signed_integral<T>)
  2. 满足 HasToString<T> 概念

因为要求是 “同时满足”,所以我们需要使用 逻辑与 (AND) 运算符 && 来组合这两个概念。

  • std::signed_integral<T> 确保了类型 T 是像 int, short, long, long long 这样的有符号整数类型,但不包括 unsigned intchar (char 的符号性是实现定义的)。
  • HasToString<T> 确保了类型 T 有一个名为 toString 的成员函数,并且该函数的返回值可以转换成 std::string
  • && 将这两个要求连接起来,只有当一个类型 既是 有符号整数 拥有 toString 方法时,它才能被用于 processData 函数。

如果使用 || (逻辑或),那么只需要满足其中一个条件即可,这不符合题意。如果只写其中一个概念,则会漏掉另一个约束条件。

关于示例代码的一个说明:

你可能注意到 main 函数里的成功示例 MyInt a = -10; 被注释掉了。这是因为标准的 C++ 整数类型(如 int, long)本身并没有 toString() 成员函数。而我们定义的 MyData 类型有 toString(),但它不是一个整数类型 (integral)。因此,要找到一个同时满足 std::signed_integral 和我们定义的 HasToString 的类型,通常需要我们自己自定义一个类型(比如创建一个像 MyInt 的类,它包装了一个整数并提供了 toString 方法)。这是概念组合威力的体现——我们可以定义非常具体和定制化的类型要求!

8. 扩展问题与探索建议 (Extended Questions & Suggestions)

你已经掌握了组合概念的基础,非常棒!如果你想继续深入,可以尝试探索以下问题:

  1. 探索更多标准概念: <concepts> 头文件里还有很多有用的预定义概念。尝试查找并理解以下概念的作用:

    • std::totally_ordered<T>:要求类型 T 支持全序比较(<, >, <=, >=)。
    • std::invocable<F, Args...>:要求类型 F (通常是函数或函数对象) 可以用参数 Args... 来调用。
    • std::derived_from<Derived, Base>:要求类型 Derived 是从类型 Base 公有继承而来的。
    • std::same_as<T, U>:要求类型 TU 是同一种类型。
    • 思考一下,这些概念可以如何组合来表达更复杂的约束?
  2. 定义更复杂的概念: 尝试自己定义一个概念,它不仅检查某个成员函数是否存在(语法要求),还检查该函数的返回值是否满足另一个概念(结合嵌套要求或 requires 表达式)。例如,定义一个 HasIntegralSize 概念,要求类型有一个 size() 成员函数,并且 size() 的返回值必须是一个整数类型 (std::integral)。

  3. 实现 MyInt 类型: 尝试动手创建一个简单的 MyInt 类,让它包装一个 int 值,并实现 toString() 方法。然后用你的 MyInt 类型来成功调用 processData 函数。

  4. 不同的概念语法: C++20 提供了几种不同的语法来应用概念约束,除了我们使用的 requires 子句,还有:

    • 拖尾 requires 子句 (Trailing requires clause): template <typename T> T func(T p) requires Concept<T> { ... }
    • 约束模板参数 (Constrained template parameter): template <MyConcept T> T func(T p) { ... } 或者 template <std::integral T> T func(T p) { ... } (直接用概念名代替 typenameclass)
    • 研究一下这些不同语法的优缺点和适用场景。

继续努力,C++ 的世界还有很多有趣的东西等待你去发现!


评论