diff --git a/docs/auto.md b/docs/auto.md index e246132..31f4b89 100644 --- a/docs/auto.md +++ b/docs/auto.md @@ -200,18 +200,70 @@ auto const &getConstRef() { // std::string const & ### 真正的万能 `decltype(auto)` +返回类型声明为 `decltype(auto)` 的效果等价于把返回类型替换为 `decltype((返回表达式))`: + +```cpp +int i; + +decltype(auto) func() { + return i; +} +// 等价于: +decltype((i)) func() { + return i; +} +// 等价于: +int &func() { + return i; +} +``` + +> {{ icon.warn }} 注意 `decltype(i)` 是 `int` 而 `decltype((i))` 是 `int &`。这是因为 `decltype` 实际上有两个版本!当 `decltype` 中的内容只是单独的一个标识符(变量名)时,会得到变量定义时的类型;而当 `decltype` 中的内容不是单纯的变量名,而是一个复杂的表达式时,就会进入 `decltype` 的第二个版本:表达式版,会求表达式的类型,例如当变量为 `int` 时,表达式 `(i)` 的类型是左值引用,`int &`,而变量本身 `i` 的类型则是 `int`。此处加上 `()` 就是为了让 `decltype` 被迫进入“表达式”的那个版本,`decltype(auto)` 遵循的也是“表达式”这个版本的结果。 + +```cpp +int i; + +decltype(auto) func() { + return i; +} +// 等价于: +decltype((i + 1)) func() { + return i + 1; +} +// 等价于: +int func() { + return i + 1; +} +``` + +```cpp +int i; + +decltype(auto) func() { + return std::move(i); +} +// 等价于: +decltype((std::move(i))) func() { + return std::move(i); +} +// 等价于: +int &&func() { + return std::move(i); +} +``` + 以上介绍的这些引用推导规则,其实也适用于局部变量的 `auto`,例如: ```cpp -auto i = 0; // int i = 0 -auto &ref = i; // int &ref = i -auto const &cref = i; // int const &cref = i -auto &&rvref = move(i); // int &&rvref = move(i) +auto i = 0; // int i = 0 +auto &ref = i; // int &ref = i +auto const &cref = i; // int const &cref = i +auto &&rvref = std::move(i); // int &&rvref = move(i) -decltype(auto) j = i; // int j = i -decltype(auto) k = ref; // int &k = ref +decltype(auto) j = i; // int j = i +decltype(auto) k = ref; // int &k = ref decltype(auto) l = cref; // int const &l = cref -decltype(auto) m = move(rvref); // int &&m = rvref +decltype(auto) m = std::move(rvref); // int &&m = rvref ``` ## 范围 for 循环中的 `auto &` diff --git a/docs/design_concept.md b/docs/design_concept.md index e98c3d8..8da8d59 100644 --- a/docs/design_concept.md +++ b/docs/design_concept.md @@ -4,6 +4,8 @@ 如果一个东西叫起来像一只鸭,走起路来像一只鸭,那么不妨认为他就是一只鸭。 +## 为什么需要多态 + 我们有三种类型的狗:拉布拉多犬,藏獒,张心欣。 > {{ icon.fun }} 请勿侮辱拉布拉多和藏獒! @@ -66,7 +68,96 @@ void feeder(Xinxin dog) { } ``` -方案1:模板函数 +这种写法的缺陷在两个方面: + +1. 当需要添加一个新类型的狗狗 `Shiba` 时,需要再复制粘贴定义一个 `feeder(Shiba)` 的重载。 +2. 当需要修改 `feeder` 的操作内容时,需要把三个重载都同样地修改一遍。 + +### 模板函数 + +可以把 `feeder` 定义为模板函数,这样他的参数可以为任意类型。 + +只要传入的模板参数类型具有 `intro` 和 `bark` 这两个成员函数,编译就不会出错。 + +```cpp +template +void feeder(Dog dog) { + dog.intro(); + dog.bark(); + dog.bark(); +} +``` + +1. 当添加了一个新类型的狗狗 `Shiba` 时,什么都不用做,只要 `Shiba` 定义了 `intro` 和 `bark` 成员函数,就可以直接传入 `feeder`,无需做任何适配工作。 +2. 当我们需要修改 `feeder` 的操作内容时,只需修改这一个模板函数的内容就行了。 + +可见,模板函数是重载函数的便民版,可用于当所有的重载函数内部代码完全一致的情况。 + +### “特殊照顾” + +但是,如果有些特殊类型的重载需要特殊照顾,导致内部代码不一样,这种传统的模板函数就不适用了。 + +例如,我们现在让拉布拉多和藏獒保持原样,但张心欣因为智力原因,没有了 `intro` 的功能。 + +```cpp +struct Labrador { + void intro() { + puts("我能帮你捡回棍棍!"); + } + + void bark() { + puts("汪汪!"); + } +}; + +struct Mastiff { + void intro() { + puts("我能保卫国王荣耀!"); + } + + void bark() { + puts("汪汪!"); + } +}; + +struct Xinxin { + // 没有 intro() 成员函数 + + void bark() { + puts("从未贡献任何核心功能!"); + } +}; +``` + +对于张心欣这种智力特殊丧失 `intro` 功能的狗狗,需要特殊照顾,在 `feeder` 中需要对 `Xinxin` 做特别判断,如果判断到狗狗类型是 `Xinxin`,就需要跳过对 `intro` 的调用,这该怎么做呢? + +传统的暴力重载函数的方法中,很简单,只需要拉布拉多和藏獒的重载版本保持不变,只对张心欣这一个 `feeder(Xinxin)` 重载里的代码做特殊修改,删掉 `intro` 调用即可。 + +```cpp +void feeder(Labrador dog) { + dog.intro(); + dog.bark(); + dog.bark(); +} + +void feeder(Mastiff dog) { + dog.intro(); + dog.bark(); + dog.bark(); +} + +void feeder(Xinxin dog) { + // dog.intro(); + dog.bark(); + dog.bark(); +} +``` + +模板函数要如何实现这种“特殊照顾”呢? + +#### 模板函数与普通重载函数并列 + +一种方法是额外定义一个普通的重载函数 `feeder(Xinxin)`,与模板函数并列。 ```cpp template @@ -75,8 +166,488 @@ void feeder(Dog dog) { dog.bark(); dog.bark(); } + +void feeder(Xinxin dog) { + // dog.intro(); + dog.bark(); + dog.bark(); +} +``` + +当调用 `feeder` 时,得益于 C++ 的重载机制,会优先匹配非模板的普通重载函数,如果匹配不成功,才会落入通用的模板函数作为备选方案。 + +```cpp +Xinxin xinxin; +feeder(xinxin); // 会优先匹配到 feeder(Xinxin) 这个普通函数 +Labrador labrador; +feeder(labrador); // 会匹配到 feeder(Labrador) 这个模板函数 +``` + +该方案依然存在缺陷: + +1. 这里模板函数和普通函数中,最后都有两次 `bark` 调用,出现了代码重复。 +2. 如果我们想要添加一个新类型的狗狗 `Yuanming`,他也没有 `intro`,难道又要为他单独定义一个重载么? + +#### 模板函数内做 if 特殊判断 + +针对缺点 1,我们想到,能不能不用分离两个函数,而是在函数内部,动态判断模板参数 `Dog` 类型是否为 `Xinxin`,如果不是 `Xinxin` 才去调用 `dog.intro()`。 + +```cpp +template +void feeder(Dog dog) { + if (Dog != Xinxin) { + dog.intro(); + } + dog.bark(); + dog.bark(); +} +``` + +上面这种写法 `Dog != Xinxin` 仅为示意,实际上是编译不通过的。 + +因为只有值表达式才能用运算符 `!=` 比较,类型表达式不能用 `!=` 比较。 + +要两个类型是否相等,需要用到 `` 头文件中的 `is_same_v`。 + +`is_same_v` 相当于类型版本的 `X == Y`。 + +这里因为我们要判断的是不等,`Dog != Xinxin`,所以用 `!is_same_v` 即可。 + +```cpp +#include + +template +void feeder(Dog dog) { + if (!std::is_same_v) { + dog.intro(); + } + dog.bark(); + dog.bark(); +} +``` + +试试看,你会发现编译错误: + +```cpp +Labrador labrador; +feeder(labrador); // 编译通过 +Xinxin xinxin; +feeder(xinxin); // 编译报错:“Xinxin 没有成员 intro” +``` + +为什么?我们不是判断了 `if (Dog 不为 Xinxin)` 才会调用 `dog.intro()` 吗?我们现在传入的是一个 `Xinxin` 类型的狗狗,为什么还是会执行到 `dog.intro()` 导致编译器找不到这个成员函数而报错呢? + +原来,“执行到”和“编译到”是两个概念。 + +`if` 只是避免了运行时的“执行到”,但编译期还是会“编译到”的。 + +例如以下代码会出错: + +```cpp +if (0) { + "string" = 0; +} +``` + +虽然 `if` 的判断条件始终为 `false`,“运行时”永远不会执行到里面的代码,但是由于编译器编译时,每个他看到的代码都要生成相应的 IR 中间码,即使最终可能被优化掉,也要为其生成 IR。 + +所以虽然 `if (0)` 会让运行时永远无法执行到或者可能被“中后端”优化掉而不会产生汇编码,但编译器的“前端”仍需完成该分支体内代码的翻译工作,而 `"string" = 0` 是非法的,根本无法生成出 IR 中间码,导致编译出错终止。 + +### C++17 编译期分支 `if constexpr` + +为了避免在“编译期”就触及 `xinxin.intro()` 这个无法通过编译的代码,我们需要使在编译期就完成分支,而不是拖到运行时或优化时。 + +C++17 引入的 `if constexpr` 就是一个编译期版本的 `if` 分支,他要求判断的表达式必须是编译期可以确定的,并且能保证分支一定在编译期完成,保证不会在运行时生成的汇编中产生任何额外的分支指令,无论是否开启优化。 + +如果 `if constexpr` 的分支条件不满足,则分支内的代码根本不会进行编译,即使含有本不能通过编译的代码也不会报错了。 + +```cpp +#include + +template +void feeder(Dog dog) { + if constexpr (!std::is_same_v) { // 编译期决定要不要编译下面的代码 + dog.intro(); + } + dog.bark(); + dog.bark(); +} +``` + +如果 Dog 是 Xinxin,则 `dog.intro()` 这条语句从编译期前端开始就不会经过编译,无论是否开启优化都会被抹除。因此即使找不到 `intro` 这个成员,也绝对不会报错了。 + +> {{ icon.fun }} 抹除的就和张心欣抹除小彭老师贡献一样干净! + +#### 依然无法自动适配所有新增类型 + +缺点 2 依然存在:如果我们想要添加一个新类型的狗狗 `Yuanming`,他也没有 `intro`,那就得在 if 判断中添加一个 `is_same_v` 判断,每多一个没有 `intro` 的狗狗就得加一遍,没完没了。 + +```cpp +struct Xinxin { + // 没有 intro() 成员函数 + + void bark() { + puts("从未贡献任何核心功能!"); + } +}; + +struct Yuanming { + // 没有 intro() 成员函数 + + void bark() { + puts("Taichi is your hobby, but yuanming's work"); + } +}; + +template +void feeder(Dog dog) { + if constexpr (!std::is_same_v + && !std::is_same_v) { // 搁着叠罗汉呢? + dog.intro(); + } + dog.bark(); + dog.bark(); +} +``` + +## C++20 concepts + +### `requires` 检查表达式合法性 + +与其用 `is_same_v` 一个个罗列出“没有 `intro`”的类型一一判断,不如直接检测 `dog` 有没有 `intro` 这个成员。 + +C++20 引入的 `requires` 关键字,可以帮你检测一个表达式是否“合法”,也就是能不能编译通过,如果能编译通过,会返回 `true`。 + +过去,如果一个表达式非法(例如找不到成员函数),我们就只能眼巴巴让编译器出错终止编译…… + +```cpp +struct Xinxin { + void bark() { + puts("从未贡献任何核心功能!"); + } +}; + +Xinxin xinxin; +xinxin.intro(); // 编译出错 +xinxin.bark(); // 编译通过 +``` + +现在,我们可以把“编译是否通过”安全地作为一个 `bool` 值返回回来,供我们后续判断处理,而不必粗暴地终止整个编译。 + +> {{ icon.fun }} 过去:一坨史害了一锅粥。现在:每粒米都放在一个隔离的“安全沙盒”里独立检验,检验结果通过 `bool` 返回,告诉小彭老师要不要吃这粒米。 + +用法就是:`requires { 要检验的表达式; }` + +所以,我们可以用 `requires { 要检验的变量.要检验的成员函数(参数...); }` 来判断某个变量类型是否有特定名字的成员函数,因为如果没有,那么表达式编译会失败,`requires` 就会返回 `false`。所以只要这个 `requires` 返回了 `true`,就可以说明该类型含有此名称的成员变量或成员函数了。对于成员函数还需要注意指定正确类型的参数,否则也无法通过编译。利用此方法还可以检测成员函数是否支持特定参数类型的重载等。 + +```cpp +struct Xinxin { + void bark() { + puts("从未贡献任何核心功能!"); + } +}; + +Xinxin xinxin; +bool has_intro = requires { xinxin.intro(); }; // false +bool has_bark = requires { xinxin.bark(); }; // true +``` + +`requires` 判断的结果是编译期常量(`constexpr bool`),可以作为 `if constexpr` 的条件使用。 + +结合 `if constexpr` 可以根据一个类型有没有某个成员(通过检测访问这个成员是否可以编译通过)来决定要不要调用这个成员。 + +```cpp +if constexpr (requires { dog.intro(); }) { + dog.intro(); +} +``` + +```cpp +template +void feeder(Dog dog) { + if constexpr (requires { dog.intro(); }) { // 如果支持 .intro() 成员函数 + dog.intro(); // 则调用他 + } + dog.bark(); + dog.bark(); +} ``` -此处把 `Dog` 定义为模板函数 +`if constexpr` 还可以带有 `else`,甚至 `else if constexpr`。 + +```cpp +template +void feeder(Dog dog) { + if constexpr (requires { dog.intro(); }) { // 如果支持 .intro() 成员函数 + dog.intro(); // 则调用他 + } else if constexpr (requires { dog.intro(1); }) { // 如果支持 .intro(int) 这种带一个 int 参数的重载 + dog.intro(1); // 则尝试调用这种带有 int 参数的重载 + } else { + puts("此狗狗似乎不支持自我介绍呢"); // 否则打印警告信息 + } + dog.bark(); + dog.bark(); +} +``` + +### `requires` 应用案例:迭代器 + +众所周知,迭代器分为很多类型,例如: + +随机迭代器支持 `+=` 操作,可以向前步进任意整数格,也可以 `-=` 向后退步。 + +而前向迭代器只能 `++` 向前移动一格,如果需要向前移动 n 格,就需要重复执行 `++` n 次。 + +还有一种双向迭代器,他既可以 `++` 向前移动一格,也可以 `--` 向后退步一格,但是不支持任意整数步长的 `+=` 和 `-=`,需要用循环来模拟。 + +比如 `vector` 的迭代器就属于随机迭代器,因为 `vector` 是连续内存的容器,他是一个线性的数组,其迭代器实际上就是一个指向元素的指针,迭代器的步进实际上就是指针在 `+=`,当然支持前进(加上)任意整数 n 格了。 + +我们现在想要实现一个通用的迭代器“步进”函数 `advance`: + +1. 对于随机迭代器他会直接调用 `+=` 前进 n 步,不用循环一格格 `++` 的低效。 +2. 对于前向迭代器他会循环调用 `++` n 次,如果 n 为负数则报错。 +3. 对于双向迭代器他会循环调用 `++` 或 `--` n 次,取决于 n 是否为正数。 + +伪代码如下: + +```cpp +template +void advance(It &it, int n) { + if (随机迭代器) { + it += n; + + } else if (双向迭代器) { + if (n > 0) { + for (int i = 0; i < -n; ++i) { + --it; + } + } else { + for (int i = 0; i < n; ++i) { + ++it; + } + } + + } else { // 前向迭代器 + if (n < 0) throw "前向迭代器不能步进一个负数"; + for (int i = 0; i < n; ++i) { + ++it; + } + } +} +``` + +如何用 `requires` 和 `if constexpr` 实现这个效果? + +```cpp +template +void advance(It &it, int n) { + if constexpr (requires { it += n; }) { + it += n; + + } else if constexpr (requires { ++it; --it; }) { + if (n > 0) { + for (int i = 0; i < -n; ++i) { + --it; + } + } else { + for (int i = 0; i < n; ++i) { + ++it; + } + } + + } else { + if (n < 0) throw "前向迭代器不能步进一个负数"; + for (int i = 0; i < n; ++i) { + ++it; + } + } +} +``` + +是不是很简单呢?只需要注意到随机迭代器需要支持 `+=`,那么我们就通过 `requires` 判断 `it` 是否支持 `+=`,支持了就说明应该是一个随机迭代器。否则如果是双向迭代器就应该支持 `++` 和 `--`,那就采用双向迭代器的方案。否则就只可能是前向迭代器,当 `n < 0` 时需要报错因为他不支持 `++`。 + +这里我们用了 `requires { ++it; --it; }` 这种带有多条语句的写法。没错,`requires` 支持一次性判断多条语句是否合法,只要其中一条非法就会返回 `false`,必须全部满足了才能返回 `true`。 + +> {{ icon.tip }} 所以 `requires { ++it; --it; }` 实际上等价于 `requires { ++it; } && requires { --it; }` + +### `requires` 自带干粮 + +有时,我们的 `requires` 是在一个函数体内,已经有变量 `dog` 的情况下,这时只需写 `requires { dog.intro(); }` 即可判断 `dog` 是否支持 `dog.intro()` 成员函数。 + +```cpp +Dog dog; +bool has_intro = requires { dog.intro(); }; +``` + +但有时,我们需要直接判断 `Dog` 类型是否含有成员函数 `intro`,避免在函数中创建 `Dog dog` 变量。 + +一种粗暴的方法是,直接用 `Dog()` 就地构造出一个 `Dog` 类型的对象来,然后访问这个临时对象的 `intro()`。 + +```cpp +bool has_intro = requires { Dog().intro(); }; +``` + +但是,这会要求 `Dog` 支持默认构造函数 `Dog()`,如果 `Dog` 不支持默认构造,比如需要两个参数 `Dog(1, 2)` 这样才能构造出来,那么 `Dog().intro()` 编译就会出错,即使 `Dog` 有 `.intro()` 成员函数也会出错,因为前面的 `Dog()` 就编译不过,导致明明有 `.intro()` 却返回了 `false`。 + +所以 `requires` 提供了一种方便的语法糖,你可以在 `requires` 和 `{` 之间加入 `(...)`,其中用类似于函数参数定义的写法,写你需要用到的变量的定义,在 `{...}` 中可以使用这些变量,变量的类型就是你在 `(...)` 中定义的类型。 + +```cpp +bool has_intro = requires (Dog dog) { dog.intro(); }; +``` + +和需要在函数体内定义一个 `Dog dog` 变量再判断相比,`requires (Dog dog)` 这种写法仅仅只是构造出一个“编译期”象征性创建的“虚假”变量,仅供判断使用,并不会在栈上产生任何实际的空间占用,不增加任何运行时成本。 + +`()` 中也可以有多个变量的定义,用逗号分隔: + +```cpp +if constexpr (requires (It it, int n) { + it += n; +}) { + // 检测到随机迭代器时要执行的分支 +} +``` + +### 预定义好 `concept` 更方便 + +推荐把常用到的条件预先定义成 `constexpr bool` 变量模板,这样以后不用每次都重写所有需要判断的表达式了。 + +```cpp +template +conetexpr bool random_access_iterator = requires (It it, int n) { + it += n; + it -= n; + ++it; + --it; +}; +``` + +`conetexpr bool` 表示这是一个编译期就能确定值的变量,不会占用任何运行时空间。只要 `It` 这个模板参数确定,`bool` 的值就是编译期唯一确定的。 + +更好的写法是用 `concept` 作为 `constexpr bool` 的简写,看起来更加“专业”“高B格”。 + +> {{ icon.tip }} `concept` 并不只是个缩写,他还附赠了一些额外的好处,稍后介绍。但总的来说 `concept` 完全可以当作普通 `bool` 使用。 + +预先定义好 `concept`,以后使用就不用“烧脑”思考需要支持哪些成员函数了,直接报 `concept` 的名字就行。 + +这也是“概念 (concept)”得名的由来:如果一个类型支持“鸭叫”,那么他就符合“鸭子”这个概念。 + +```cpp +template +concept random_access_iterator = requires (It it, int n) { + it += n; + it -= n; + ++it; + --it; +}; + +template +concept bidirectional_iterator = requires (It it) { + ++it; + --it; +}; + +template +concept forward_iterator = requires (It it) { + ++it; +}; + +template +void advance(It &it, int n) { + if constexpr (random_access_iterator) { + it += n; + + } else if constexpr (bidirectional_iterator) { + if (n > 0) { + for (int i = 0; i < -n; ++i) { + --it; + } + } else { + for (int i = 0; i < n; ++i) { + ++it; + } + } + + } else if constexpr (forward_iterator) { + if (n < 0) throw "前向迭代器不能步进一个负数"; + for (int i = 0; i < n; ++i) { + ++it; + } + + } else { + throw "It 不是任何一种合法的迭代器"; + } +} +``` + +针对满足不同的概念的参数类型采取不同的操作方式,这就是 C++20 概念。 + +### 标准库定义好的 `` + +我们自己定义的概念,难免有一些疏漏。例如前向迭代器实际上不仅要求支持 `++it` 还要支持 `it++` 这种后置的自增运算符,还要求支持拷贝构造函数等。 + +所以,对于这种标准库就已经有的概念,推荐使用标准库头文件 `` 中定义好的概念来用,而不必一个个自己手动定义。 + +```cpp +#include // 定义了 concept 如 std::random_access_iterator + +if constexpr (std::random_access_iterator) { + ... // 如果满足随机迭代器概念 +} +``` + +一些 C++11 `` 中就有的一些判断用的 `constexpr bool` 变量模板,在 C++20 `` 中也“转正”为了 `concept`: + +```cpp +#include + +if constexpr (std::is_integral_v) { // C++17 + // T 是整数类型时 +} + +#include + +if constexpr (std::integral) { // C++20 + // T 是整数类型时 +} +``` + +其实际效果是相同的,只是名字更简洁,并且类型由 `constexpr bool` 变成了 `concept`。 + +> {{ icon.tip }} 憋担心,如果你的编译器不支持 C++20,用 `std::is_integral_v` 实际上也和 `std::integral` 概念是一样的。 + +> {{ icon.fun }} `` 基本上就是去掉了 `is_` 和 `_v`。 + +这里我们罗列一部分常见的 `concept` 和老 `constexpr bool` 对应列表: + +| `` | `` | +|--------------|-----------------| +| `std::same_as` | `std::is_same_v` | +| `std::derived_from` | `std::is_base_of_v` | +| `std::convertible_to` | `std::is_convertible_v` | +| `std::integral` | `std::is_integral_v` | +| `std::floating_point` | `std::is_floating_point_v` | +| `std::signed_integral` | `std::is_signed_v` | +| `std::unsigned_integral` | `std::is_unsigned_v` | +| `std::move_constructible` | `std::is_move_constructible_v` | +| `std::copy_constructible` | `std::is_copy_constructible_v` | +| `std::copy_assignable` | `std::is_copy_assignable_v` | +| `std::move_assignable` | `std::is_move_assignable_v` | +| `std::copyable` | `std::is_copy_constructible_v && std::is_copy_assignable_v` | +| `std::movable` | `std::is_move_constructible_v && std::is_move_assignable_v` | +| `std::constructible_from` | `std::is_constructible_v` | +| `std::assignable_from` | `std::is_assignable_v` | +| `std::default_initializable` | `std::is_default_constructible_v` | +| `std::destructible` | `std::is_destructible_v` | +| `std::semiregular` | `std::is_default_constructible_v && std::is_copy_constructible_v && std::is_copy_assignable_v` | +| `std::regular` | `std::semiregular && std::is_equality_comparable_v` | +| `std::equality_comparable` | `std::is_equality_comparable` | +| `std::totally_ordered` | `std::equality_comparable && std::is_less_comparable` | +| `std::input_or_output_iterator` | `std::is_base_of_v::iterator_category> \|\| std::is_base_of_v::iterator_category>` | +| `std::input_iterator` | `std::is_base_of_v::iterator_category>` | +| `std::output_iterator` | `std::is_base_of_v::iterator_category>` | +| `std::forward_iterator` | `std::is_base_of_v::iterator_category>` | +| `std::bidirectional_iterator` | `std::is_base_of_v::iterator_category>` | +| `std::random_access_iterator` | `std::is_base_of_v::iterator_category>` | +| `std::invocable` | `std::is_invocable_v` | -TODO +更多细节请自行前往 [cppreference](https://en.cppreference.com/w/cpp/concepts) 慢慢查阅。