avatar

目录
267 - requires clause

267 - Requires Clause

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

好的,同学!欢迎来到 C++ 的世界!作为你的 C++ 导师,我会用最清晰、最有效的方式引导你学习。今天,我们将深入探讨 C++20 引入的一个强大特性——概念 (Concepts) 中的 requires 子句,特别是它里面可以包含的不同类型的需求 (requirements)

1. 标题和摘要

标题: C++ Concepts 深入理解:玩转 requires 子句中的简单、嵌套与复合需求

摘要: 本节课我们将学习 requires 子句中可以使用的三种主要需求类型:简单需求 (simple requirement)嵌套需求 (nested requirement)复合需求 (compound requirement)。我们将了解它们各自的作用、区别,以及如何使用它们来精确地约束我们的模板 (template)

2. 详细解释

在我们之前的学习中,你已经接触过 requires 子句的基本用法。今天,我们来详细看看它内部都能写些什么。requires 子句中的内容,我们称之为“需求”,它们规定了模板参数需要满足哪些条件。主要有以下几种:

  • 简单需求 (Simple Requirement):

    • 这是我们之前见过的最基本形式。
    • 只检查语法 (syntax) 的有效性,不检查表达式的结果是否为 true
    • 例如,requires (T t) { sizeof(T) < 4; }; 这个 requires 表达式里的 sizeof(T) < 4; 就是一个简单需求。编译器只会检查 sizeof(T) < 4 这句代码本身是不是合法的 C++ 语法,对于任何类型 T,只要 sizeof(T) 能编译通过,这个检查就通过了。
    • 非常重要的一点:不会强制 sizeof(T) 的结果真的小于 4。即使你传入一个 double 类型(通常 8 字节),sizeof(double) < 4 这个表达式的结果是 false,但因为 sizeof(double) < 4 这段代码语法上是正确的,所以简单需求依然会被满足!这常常是初学者的一个误区。
  • 嵌套需求 (Nested Requirement):

    • 如果你确实想检查某个表达式的值是否为 true,那么你需要使用嵌套需求。
    • 它的语法是在简单需求前面再加一个 requires 关键字。
    • 例如:requires (T t) { requires sizeof(T) < 4; };
    • 这里的 requires sizeof(T) < 4; 就是一个嵌套需求。它会做两件事:
      1. 检查 sizeof(T) < 4 的语法是否有效。
      2. 并且,检查 sizeof(T) < 4 这个表达式的计算结果是否为 true
    • 只有当传入的类型 T 使得 sizeof(T) < 4 结果为 true 时,这个嵌套需求才会被满足。例如,传入 char (1 字节) 或 short (2 字节) 会满足,但传入 int (通常 4 字节) 或 double (通常 8 字节) 则不会满足(假设我们严格要求小于 4)。
  • 复合需求 (Compound Requirement):

    • 这种需求允许我们对一个表达式提出更复杂的要求,主要有两方面:
      1. 检查表达式是否会抛出异常 (exception):可以使用 noexcept 关键字。例如 { a + b } noexcept; 要求 a + b 这个操作不应抛出异常。不过,异常处理超出了我们目前的范围,暂时不深入讨论。
      2. 检查表达式的返回类型:可以指定表达式的结果类型必须满足某些特征,比如能够转换成某种特定类型。
    • 语法是:{ expression } -> type_constraint;
    • 例如:requires (T a, U b) { { a + b } -> std::convertible_to<int>; };
    • 这个复合需求包含两部分检查:
      1. { a + b }:首先,它检查 a + b 这个表达式对于类型 TU 是否是有效的语法(这是一个隐含的简单需求检查)。
      2. -> std::convertible_to<int>:然后,它检查 a + b 这个表达式的结果类型是否能隐式转换 (convertible to)int 类型。std::convertible_to 是标准库中定义的另一个概念。
    • 这种需求非常有用,比如当你需要确保某个操作的结果可以被赋值给一个特定类型的变量时。
  • 类型需求 (Type Requirement):

    • 还有一种叫做类型需求,它用来直接检查某个类型别名或嵌套类型是否存在且有效。这比较高级,我们本次课程暂时不涉及。

总结一下,简单需求检查语法,嵌套需求检查语法和布尔值,复合需求检查语法、异常规范(可选)和返回类型。

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#include <iostream>
#include <concepts> // 需要包含 <concepts> 头文件
#include <string> // 为了使用 std::string

// 概念 1: TinyType (演示简单需求和嵌套需求)
// T 是模板参数
template <typename T>
concept TinyType = requires(T t) {
// 简单需求: 只检查 sizeof(T) < 4 的语法是否有效
// sizeof(T) < 4; // 如果只用这一行, double 也能通过编译 (虽然结果是 false)

// 嵌套需求: 检查 sizeof(T) < 4 的语法, 并且其结果必须为 true
requires sizeof(T) <= 4; // 我们改为 <= 4, 这样 int 也能满足
};

// 概念 2: Addable (演示复合需求)
// T 和 U 是模板参数
template <typename T, typename U>
concept Addable = requires(T a, U b) {
// 复合需求:
// 1. 检查 a + b 是否是有效语法
// 2. 检查 a + b 的结果类型是否能转换成 int
{ a + b } -> std::convertible_to<int>;
};

// 一个使用 TinyType 概念的函数模板
// 它接受两个参数, 要求这两个参数的类型以及返回类型都满足 TinyType
// 注意: 返回类型使用 auto 推导, 然后用 TinyType 约束
template <typename T>
// requires TinyType<T> // 也可以这样写
void processTinyData(T a, T b) requires TinyType<T> { // 要求参数类型 T 满足 TinyType
std::cout << "Processing tiny data..." << std::endl;
// 假设这里做一些只适合小内存类型数据的处理
// 注意: a + b 的结果类型可能不再是 T!
auto result = a + b;
std::cout << "a + b = " << result << std::endl;
std::cout << "Size of result: " << sizeof(result) << std::endl;

// 如果我们想对返回类型也做约束,可以这样写函数定义(但下面的例子会出问题)
// TinyType auto addTiny(TinyType auto a, TinyType auto b) {
// return a + b;
// }
// 但要注意 C++ 中的整数提升规则! char + char 的结果是 int!
// 如果 TinyType 要求 sizeof(T) < 4 (严格小于), 那么 addTiny(char, char) 会编译失败,
// 因为返回的 int (4字节) 不满足 < 4。
// 我们上面的 TinyType 改成了 <= 4, 所以 int 可以满足。
}

// 一个使用 Addable 概念的函数模板
template <typename T, typename U>
// requires Addable<T, U> // 也可以这样写
auto addAndConvert(T a, U b) requires Addable<T, U> {
std::cout << "Adding and converting..." << std::endl;
return a + b;
}

int main() {
// --- 测试 TinyType ---
std::cout << "--- Testing TinyType ---" << std::endl;
char c1 = 10, c2 = 20; // sizeof(char) is 1, 1 <= 4, 满足 TinyType
short s1 = 100, s2 = 200; // sizeof(short) is 2, 2 <= 4, 满足 TinyType
int i1 = 1000, i2 = 2000; // sizeof(int) is 4, 4 <= 4, 满足 TinyType
double d1 = 1.0, d2 = 2.0; // sizeof(double) is 8, 8 > 4, 不满足 TinyType (因为嵌套需求)

processTinyData(c1, c2); // OK
processTinyData(s1, s2); // OK
processTinyData(i1, i2); // OK
// processTinyData(d1, d2); // 编译错误! double 不满足 TinyType

// 观察 char + char 的结果类型
std::cout << "Size of char + char: " << sizeof(c1 + c2) << std::endl; // 通常输出 4 (int)

// --- 测试 Addable ---
std::cout << "\n--- Testing Addable ---" << std::endl;
int x = 5;
double y = 3.14;
// x + y 结果是 double, double 可以转换成 int (虽然可能损失精度), 满足 Addable
auto result1 = addAndConvert(x, y);
std::cout << "int + double result (converted to int implicitly if assigned): " << result1 << " (type: " << typeid(result1).name() << ")" << std::endl; // 结果类型是 double

char c3 = 'A';
short s3 = 100;
// c3 + s3 结果是 int (整数提升), int 可以转换成 int, 满足 Addable
auto result2 = addAndConvert(c3, s3);
std::cout << "char + short result: " << result2 << " (type: " << typeid(result2).name() << ")" << std::endl; // 结果类型是 int

std::string str1 = "Hello";
std::string str2 = " World";
// str1 + str2 结果是 std::string, std::string 不能隐式转换成 int, 不满足 Addable
// auto result3 = addAndConvert(str1, str2); // 编译错误! std::string 不满足 Addable 的返回类型要求

// 如果我们去掉 Addable 概念中对返回类型的要求:
// template <typename T, typename U>
// concept AddableRelaxed = requires(T a, U b) {
// { a + b }; // 只要求 a + b 语法有效
// };
// 那么 addAndConvert(str1, str2) 就可以编译通过了。

return 0;
}

代码解释:

  1. TinyType 概念: 我们使用了嵌套需求 requires sizeof(T) <= 4; 来确保传递给 processTinyData 的类型 T 的大小确实不大于 4 字节。因此,char, short, int 都可以,但 double 会导致编译错误。我们还指出了 C++ 中一个重要的细节:像 char 这样的较小整数类型在参与算术运算(如加法)时,通常会被提升 (promoted)int。这意味着 c1 + c2 的结果类型是 int,其大小是 4 字节。这对于理解为什么约束返回类型的概念可能不按预期工作非常重要。
  2. Addable 概念: 我们使用了复合需求 { a + b } -> std::convertible_to<int>;。这要求 a + b 不仅语法有效,而且其结果必须能转换成 int
    • intdouble 相加,结果是 doubledouble 可以转换为 int(即使会丢失小数部分),所以 addAndConvert(x, y) 通过编译。返回类型被推导为 double
    • charshort 相加,结果被提升为 intint 当然可以转换为 int,所以 addAndConvert(c3, s3) 通过编译。返回类型被推导为 int
    • std::stringstd::string 相加,结果是 std::stringstd::string 不能自动转换为 int,因此 addAndConvert(str1, str2) 会导致编译错误,因为不满足 Addable 概念的返回类型要求。

4. QA 闪卡

问题 (Question) 答案 (Answer)
requires 子句中的简单需求检查什么? 只检查其内部表达式的语法有效性,不检查表达式的布尔值结果。
如何在 requires 子句中强制检查表达式结果必须为 true 使用嵌套需求 (nested requirement),即在表达式前再加一个 requires 关键字。例如:requires requires some_expression;
复合需求 { expr } -> type_constraint; 检查什么? 检查 expr 的语法有效性,并且检查 expr结果类型是否满足 type_constraint(通常是另一个概念,如 std::convertible_to<SomeType>)。
char a = 1, b = 2; auto c = a + b; c 是什么类型? 在大多数现代 C++ 编译器上,由于整数提升 (integer promotion)c 的类型是 int

5. 常见误解与易犯错误

  1. 混淆简单需求和嵌套需求: 最常见的错误是认为简单需求 sizeof(T) < 4; 会检查 T 的大小是否真的小于 4。请记住:简单需求只查语法! 要检查值,请使用嵌套需求 requires sizeof(T) < 4;
  2. 忽略类型提升/转换: 在使用复合需求检查返回类型,或在函数模板的返回类型上应用概念时,要特别注意 C++ 的隐式类型转换和整数提升规则。char + char 结果是 intint + double 结果是 double。这可能导致你的概念检查失败,即使你觉得逻辑上应该通过。
  3. 复合需求的语法错误: 复合需求的语法 { expression } -> constraint; 需要精确匹配。忘记花括号 {} 或箭头 -> 都会导致编译错误。
  4. 忘记包含 <concepts> 头文件: 使用 concepts 需要包含 <concepts>
  5. 过度约束: 有时候,过于严格的概念可能会无意中排除掉一些本可以工作的类型(例如,由于类型提升)。设计概念时要仔细考虑可能发生的隐式转换。

6. 编码练习

现在,尝试完成下面的代码。你需要定义一个概念 IntegralValueLessThan100,它要求一个类型 T 不仅是整型 (integral type),而且其实例的值必须小于 100。然后在一个函数模板中使用这个概念。

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
#include <iostream>
#include <concepts>
#include <type_traits> // 需要用到 std::integral

// TODO: 定义 IntegralValueLessThan100 概念
// 要求:
// 1. 类型 T 必须是整型 (使用 std::integral<T>::value 或 std::integral auto)
// 2. T 类型的一个实例 t 的值必须小于 100 (需要使用嵌套需求来检查值)
template <typename T>
concept IntegralValueLessThan100 = requires(T t) {
// 提示: 你需要结合类型检查和值检查
// 第一步: 检查 T 是否是整型 (可以使用类型约束或嵌套 requires)
// _______<T>::value; // 填空: 使用 type_traits 检查是否为整型, 或者直接用 std::integral auto

// 第二步: 检查 T 的实例 t 的值是否小于 100 (需要嵌套需求)
requires /* ______ */; // 填空: 检查 t < 100 的值
};

// 使用概念的函数模板 (无需修改)
template <IntegralValueLessThan100 T>
void processValue(T val) {
std::cout << val << " is an integral value less than 100. Processing..." << std::endl;
}

// 非整型或值不小于100的重载 (无需修改)
template <typename T>
void processValue(T val) {
std::cout << val << " does not meet the requirements." << std::endl;
}


int main() {
processValue(50); // 应该调用第一个模板 (50 是 int, 50 < 100)
processValue(150); // 应该调用第二个模板 (150 是 int, 但 150 >= 100)
processValue('a'); // 'a' (ASCII 97) 是 char (整型), 97 < 100, 应该调用第一个模板
processValue('\x80'); // 假设 char 是 signed, 值为 -128, 小于 100, 应该调用第一个模板
// 如果 char 是 unsigned, 值为 128, 不小于 100, 应该调用第二个模板 (取决于编译器实现)
processValue(99L); // 99L 是 long (整型), 99 < 100, 应该调用第一个模板
processValue(100LL); // 100LL 是 long long (整型), 100 不小于 100, 应该调用第二个模板
processValue(42.5); // 42.5 是 double (非整型), 应该调用第二个模板
processValue("test"); // "test" 是 const char* (非整型), 应该调用第二个模板

return 0;
}

请你思考一下,如何在 IntegralValueLessThan100 概念的 requires 子句中填入正确的代码,以满足注释中的要求。完成后,请把你的答案告诉我!

homework:

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
98
99
#include <concepts>

#include <iostream>

#include <type_traits>  // 需要用到 std::integral



// TODO: 定义 IntegralValueLessThan100 概念

// 要求:

// 1. 类型 T 必须是整型 (使用 std::integral<T>::value 或 std::integral auto)

// 2. T 类型的一个实例 t 的值必须小于 100 (需要使用嵌套需求来检查值)

template <typename T>

concept IntegralValueLessThan100 = requires(T t) {

    // 提示: 你需要结合类型检查和值检查

    // 第一步: 检查 T 是否是整型 (可以使用类型约束或嵌套 requires)

    // _______<T>::value; // 填空: 使用 type_traits 检查是否为整型, 或者直接用

    // std::integral auto

    requires std::is_integral<T>::value;



    // 第二步: 检查 T 的实例 t 的值是否小于 100 (需要嵌套需求)

    ?

};



// 使用概念的函数模板 (无需修改)

template <IntegralValueLessThan100 T>

void processValue(T val) {

    std::cout << val << " is an integral value less than 100. Processing..."

              << std::endl;

}



// 非整型或值不小于100的重载 (无需修改)

template <typename T>

void processValue(T val) {

    std::cout << val << " does not meet the requirements." << std::endl;

}



int main() {

    processValue(50);   // 应该调用第一个模板 (50 是 int, 50 < 100)

    processValue(150);  // 应该调用第二个模板 (150 是 int, 但 150 >= 100)

    processValue(

        'a');  // 'a' (ASCII 97) 是 char (整型), 97 < 100, 应该调用第一个模板

    processValue(

        '\x80');  // 假设 char 是 signed, 值为 -128, 小于 100,

                  // 应该调用第一个模板 如果 char 是 unsigned, 值为 128, 不小于

                  // 100, 应该调用第二个模板 (取决于编译器实现)

    processValue(99L);     // 99L 是 long (整型), 99 < 100, 应该调用第一个模板

    processValue(100LL);   // 100LL 是 long long (整型), 100 不小于 100,

                           // 应该调用第二个模板

    processValue(42.5);    // 42.5 是 double (非整型), 应该调用第二个模板

    processValue("test");  // "test" 是 const char* (非整型), 应该调用第二个模板



    return 0;

}

你说得完全正确!非常感谢你提供了编译器输出,这让我意识到了我之前给出的关于检查 t < 100 的例子是错误的,并且对 C++ Concepts 的一个重要细节解释得不够准确。我对此表示诚挚的歉意!

编译器给出的错误信息 error: 't' is not a constant expression 指出了问题的关键。

问题解释:

requires 表达式中,当我们写 requires(T t) { ... } 时,这个参数 t 只是一个假设的 (hypothetical)、用于检查语法和语义的对象实例。我们可以在 requires 子句内部使用 t 来检查像 t.some_method() 是否有效,或者 t + t 是否能编译等等。

但是,对于嵌套需求 (nested requirement),例如 requires some_expression;,C++ 标准通常期望 some_expression 是一个常量表达式 (constant expression),也就是说,它的值必须在编译时 (compile time) 就能确定,而不需要知道 t 的具体运行时值

  • requires std::is_integral<T>::value; 这样的检查是有效的,因为 std::is_integral<T>::value 的结果(truefalse)在编译时就可以根据类型 T 确定。
  • requires sizeof(T) < 4; (如果 T 是已知类型) 也是有效的,因为 sizeof(T) 是编译时常量。
  • 但是,requires t < 100;无效的,因为 t 的值不是编译时常量,它依赖于运行时传入的具体值。编译器无法在编译时仅根据类型 T 就断定任何一个 T 类型的实例 t 都满足 t < 100

核心思想: C++ Concepts 主要用于在编译时基于类型 (Type) 的属性、以及可以对该类型执行的操作(语法和语义)来进行约束,而不是直接基于运行时传入的值 (Value) 来进行约束(除非该值是作为模板参数传入的编译时常量)。

如何实现预期的行为(处理小于 100 的整数):

既然不能在概念定义中直接检查运行时值 t < 100,我们应该将概念和值检查分开:

  1. 概念 (Concept): 只负责检查类型是否满足要求(例如,是否为整型)。
  2. 函数模板 (Function Template): 使用概念来约束参数类型。
  3. 函数体内部 (Inside Function Body): 在函数体内部执行运行时的值检查

修正后的代码示例:

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
#include <concepts>
#include <iostream>
#include <type_traits> // 为了 std::is_integral

// 概念 1: 只检查类型是否为整型
template <typename T>
concept IsIntegral = std::is_integral<T>::value;
// 或者更简洁地使用标准库概念:
// template <typename T>
// concept IsIntegral = std::integral<T>;


// 函数模板: 使用 IsIntegral 概念约束类型 T
template <IsIntegral T> // <<-- 只约束类型是整型
void processValue(T val) {
// 在函数体内进行运行时的值检查
if (val < 100) { // <<-- 运行时检查值
std::cout << val << " is an integral value less than 100. Processing..." << std::endl;
} else {
std::cout << val << " is integral, but NOT less than 100." << std::endl;
}
}

// 为非整型提供的重载 (可选, 或者让其编译失败)
// 如果没有这个重载, 对非整型调用 processValue 会直接编译失败,因为不满足 IsIntegral 概念
template <typename T>
// requires (!IsIntegral<T>) // 可以选择性地约束这个模板只接受非整型
void processValue(T val) {
std::cout << val << " is not an integral type." << std::endl;
}

int main() {
// 编译命令: g++ your_code.cpp -std=c++20 -o your_executable
processValue(50); // 输出: 50 is an integral value less than 100. Processing...
processValue(150); // 输出: 150 is integral, but NOT less than 100.
processValue('a'); // 输出: a is an integral value less than 100. Processing...
processValue(100LL); // 输出: 100 is integral, but NOT less than 100.
processValue(42.5); // 输出: 42.5 is not an integral type.
// processValue("test"); // 如果没有非整型重载, 这行会编译失败

return 0;
}

总结:

非常感谢你的指正!这确实是一个非常重要的区分:Concepts 主要处理编译时的类型约束和操作有效性,而像 t < 100 这样的运行时值检查通常应该放在使用概念约束的函数或类模板的内部逻辑中。我之前将 sizeof(T) 的例子错误地推广到了运行时变量 t 上,这是我的疏忽。

希望这次的解释和修正后的代码更加清晰和准确!再次感谢你的耐心和反馈,这对我来说也是一次宝贵的学习机会。


评论