0%

Effective_Moderen_C

类型推导

条款1: 理解模板推导

image-20221011183456507

T 被推导成 int ,ParamType 被推导成 const int& 。

而当我们有代码

image-20221011183623641

T 的类型不仅和 expr 的类型独立, 而且还和 ParamType 的形式独立。 下面是三个例子:

  • ParamType 是一个指针或者是一个引用类型, 但并不是一个通用的引用类型 。
  • ParamType 是一个通用的引用 。
  • ParamType 既不是指针也不是引用 。
1
2
3
template<typename T>
void f(ParamType param);
f(expr); // 从expr推导出T和ParamType的类型

ParamType 是个非通用的引用或者是一个指针。

当ParamType是一个引用类型或是一个指针,但是并非是一个通用的引用类型,则类型推导过程如下 :

1、如果expr的类型是个引用,则忽略引用部分。

2、然后利用expr的类型和ParamType对比判断T的类型。

例如,我们有

1
2
3
4
5
6
template<typename T>
void f(T&param);//param是个引用类型

int x=27;//x是一个int
const int cx=x; //cx是一个const int
const int &rx=x; //rx是一个const int 的引用

则param和T在不同的调用下面的类型推导如下:

1
2
3
4
5
f(x); 		// T是int, param的类型时int&
f(cx); // T是const int,
// param的类型是const int&
f(rx); // T是const int
// param的类型时const int&

这里的引用进行忽略。

如果我们把 f 的参数类型从 T& 变成 const T& , 情况就会发生变化,由于 param 的声明是 const 引用的, cx 和 rx 的 const 特性会被保留, 这样的话 T 的 const 特性就没有必要了。

1
2
3
4
5
6
7
8
template<typename T>
void f(const T& param); // param现在是const的引用
int x = 27; // 和之前一样
const int cx = x; // 和之前一样
const int& rx = x; // 和之前一样
f(x); // T是int, param的类型是const int&
f(cx); // T是int, param的类型是const int&
f(rx); // T是int, param的类型是const int&

如果param是一个指针(或者指向const的指针)而不是引用,情况也类似

1
2
3
4
5
6
7
template<typename T>
void f(T* param); // param是一个指针
int x = 27; // 和之前一样
const int *px = &x; // px是一个指向const int x的指针
f(&x); // T是int, param的类型是int*
f(px); // T是const int
// param的类型时const int

ParamType 是个通用的引用( Universal Reference)

  1. 如果 expr 是一个左值, T 和 ParamType 都会被推导成左值引用。 这有些不同寻常。 第一, 这是模板类型 T 被推导成一个引用的唯一情况。 第二, 尽管 ParamType 利用右值引用的语法来进行推导, 但是他最终推导出来的类型是左值引用。
  2. 如果 expr 是一个右值, 那么就执行“普通”的法则( 第一种情况)
1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
void f(T&& param); // param现在是一个通用的引用
int x = 27; // 和之前一样
const int cx = x; // 和之前一样
const int& rx = x; // 和之前一样
f(x); // x是左值, 所以T是int&
// param的类型也是int&
f(cx); // cx是左值, 所以T是const int&
// param的类型也是const int&
f(rx); // rx是左值, 所以T是const int&
// param的类型也是const int&
f(27); // 27是右值, 所以T是int
// 所以param的类型是int&&

ParamType 既不是指针也不是引用

当 ParamType 既不是指针也不是引用, 我们把它处理成pass-by-value:

1
2
template<typename T>
void f(T param); // param现在是pass-by-value

这就意味着 param 就是完全传给他的参数的一份拷贝——一个完全新的对象。 基于这个事实可以从 expr 给出推导的法则:

1
2
3
4
5
6
int x = 27; // 和之前一样
const int cx = x; // 和之前一样
const int& rx = x; // 和之前一样
f(x); // T和param的类型都是int
f(cx); // T和param的类型也都是int
f(rx); // T和param的类型还都是int

注意尽管 cx 和 rx 都是 const 类型, param 却不是 const 的。 这是有道理的。 param 是一个和 cx 和 rx 独立的对象——一个 cx 和 rx 的拷贝。

数组参数

就是数组类型和指针类型是不一样的, 尽管它们通常看起来是可以替换的。 一个最基本的幻觉就是在很多的情况下, 一个数组会被退化成一个指向其第一个元素的指针。 这个退化的代码常常如此:

1
2
const char name[] = "J. P. Briggs"; // name的类型是const char[13]
const char * ptrToName = name; // 数组被退化成指针

在这里, const char 指针 ptrToName 使用 name 初始化, 实际的 name 的类型是 constchar[13] 。 这些类型( const char 和 const char[13] ) 是不一样的, 但是因为数组到指针的退化规则, 代码会被正常编译。

如果一个数组传递给一个安置传递的模板参数会如何?

1
2
3
4
template<typename T>
void f(T param); // 模板拥有一个按值传递的参数

f(name); // T和param的类型会被推到成什么呢?

因为数组参数声明会被当做指针参数, 传递给模板函数的按值传递的数组参数会被退化成指针类型。 这就意味着在模板 f 的调用中, 模板参数 T 被推导成 const char* :

1
f(name); // name是个数组, 但是T被推导成const char*

若我们使用引用则

1
2
template<typename T>
void f(T& param); // 引用参数的模板

然后传递一个数组给他则

1
f(name); // 传递数组给f

T最后推导出来的实际类型是数组。类型推导包括了数组的长度, 所以在这个例子里面, T 被推导成了 const char [13] , 函数 f 的参数( 数组的引用) 被推导成了 const char(&)[13]

声明数组的引用可以使的创造出一个推导出一个数组包含的元素长度的模板:

1
2
3
4
5
6
7
// 在编译的时候返回数组的长度( 数组参数没有名字,
// 因为只关心数组包含的元素的个数)
template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{
return N; // constexpr和noexcept在随后的条款中介绍
}

函数模板

数组并不是C++唯一可以退化成指针的东西。 函数类型可以被退化成函数指针 。

1
2
3
4
5
6
7
8
9
10
void someFunc(intdouble); 	// someFunc是一个函数
// 类型是void(int, double)
template<typename T>
void f1(T param); // 在f1中 参数直接按值传递
template<typename T>
void f2(T& param); // 在f2中 参数是按照引用传递
f1(someFunc); // param被推导成函数指针
// 类型是void(*)(int, double)
f2(someFunc); // param被推导成函数指针
// 类型时void(&)(int, double)
总结
在模板类型推导的时候,有引用特性的参数的引用特性会被忽略
在推导通用引用参数的时候, 左值会被特殊处理
在推导按值传递的参数时候, const 和/或 volatile 参数会被视为非 const 和 非 volatile
在模板类型推导的时候, 参数如果是数组或者函数名称, 他们会被退化成指针, 除非是用 在初始化引用类型

条款2: 理解auto 类型推导

auto类型推导与前面的模板推导基本相同,只存在一种情况不大相同。在一个用 auto 声明的变量上, 类型声明代替了 ParamType的作用, 所以也有三种情况:

  • 情况1: 类型声明是一个指针或者是一个引用, 但不是一个通用的引用
  • 情况2: 类型声明是一个通用引用
  • 情况3: 类型声明既不是一个指针也不是一个引用

我们已经看了情况1和情况3的例子:

1
2
3
auto x = 27; // 情况3( x既不是指针也不是引用)
const auto cx = x; // 情况3( cx二者都不是)
const auto& rx = x; // 情况1( rx是一个非通用的引用)

情况2则如下

1
2
3
4
5
6
auto&& uref1 = x; 		// x是int并且是左值
// 所以uref1的类型是int&
auto&& uref2 = cx; // cx是int并且是左值
// 所以uref2的类型是const int&
auto&& uref3 = 27; // 27是int并且是右值
// 所以uref3的类型是int&&

条款1讲解了在非引用类型声明里, 数组和函数名称如何退化成指针。 这在 auto 类型推导上面也是一样:

1
2
3
4
5
6
7
8
const char name[] = // name的类型是const char[13]
"R. N. Briggs";
auto arr1 = name; // arr1的类型是const char*
auto& arr2 = name; // arr2的类型是const char (&)[13]
void someFunc(int, double); // someFunc是一个函数, 类型是
// void (*)(int, double)
auto& func2 = someFunc; // func1的类型是
// void (&)(int, double)

正如你所见, auto 类型推导和模板类型推导工作很类似。

除了有一种情况是不一样的。 我们从如果你想声明一个用27初始化的 int 。

1
2
3
4
int x1 = 27;
int x2(27);
int x3 = { 27 };
int x4{ 27 };

综上四种语法, 都会生成一种结果: 一个拥有27数值的 int 。

但是若把int替换成auto,则会出现如下情况。

1
2
3
4
auto x1 = 27;
auto x2(27);
auto x3 = {27};
auto x4{ 27 };

头两个的确是一样的, 声明一个初始化值为27的 int 。 然而后面两个, 声明了一个类型std::intializer_list 的变量, 这个变量包含了一个单一的元素27!

1
2
3
4
5
auto x1 = 27; 	// 类型时int, 值是27
auto x2(27); // 同上
auto x3 = { 27 }; // 类型是std::intializer_list<int>
// 值是{ 27 }
auto x4{ 27 }; // 同上

对待花括号初始化的行为是 auto 唯一和模板类型推导不一样的地方。 当 auto 声明变量被使用一对花括号初始化, 推导的类型是 std::intializer_list 的一个实例。 但是如果相同的初始化递给相同的模板, 类型推导会失败, 代码不能编译。

1
2
3
4
5
auto x = { 11, 23, 9 }; 	// x的类型是
// std::initializer_list<int>
template<typename T> // 和x的声明等价的
void f(T param); // 模板
f({ 11, 23, 9 }); // 错误的! 没办法推导T的类型

但是, 如果你明确模板的 param 的类型是一个不知道 T 类型的 std::initializer_list

1
2
3
4
template<typename T>
void f(std::initializer_list<T> initList);
f({ 11, 23, 9 }); // T被推导成int, initList的
// 类型是std::initializer_list<int>

所以 auto 和模板类型推导的本质区别就是 auto 假设花括号初始化代表的是std::initializer_list, 但是模板类型推导却不是 。所以一个使用 auto 声明的返回值的函数, 返回一个花括号初始化就无法编译。

1
2
3
4
auto createInitList()
{
return { 1, 2, 3 }; // 编译错误: 不能推导出{ 1, 2, 3 }的类型
}

在C++14的lambda里面, 当 auto 用在参数类型声明的时候也是如此:

1
2
3
4
5
6
std::vector<int> v;

auto resetV =
[&v](const auto& newValue) { v = newValue; } // C++14

resetV({ 1, 2, 3 }); // 编译错误, 不能推导出{ 1, 2, 3 }的类型
总结
auto 类型推导通常和模板类型推导类似, 但是 auto 类型推导假定花括号初始化代表的 类型是 std::initializer_list , 但是模板类型推导却不是这样
auto 在函数返回值或者lambda参数里面执行模板的类型推导, 而不是通常意义 的 auto 类型推导

auto 关键字

条款3: 优先使用 auto 而非显式类型声明

由于 auto 使用类型推导( 参见条款2) , 它可以表示那些仅仅被编译器知晓的类型:

1
2
3
4
auto dereUPLess = // comparison func.
[](const std::unique_ptr<Widget>& p1, // for Widgets
const std::unique_ptr<Widget>& p2) // pointed to by
{ return *p1 < *p2}; // std::unique_ptrs

非常酷。 在 C++14 中, 模板( 原文为temperature) 被进一步丢弃, 因为使用 lambda 表达式的参数可以包含 auto :

1
2
3
4
auto derefLess = // C++14 comparison
[](const auto& p1, // function for
const auto& p2) // values pointed
{ return *p1 < *p2; };

auto 的优点除了可以避免未初始化的变量, 变量声明引起的歧义, 直接持有封装体的能力。还有一个就是可以避免“类型截断”问题。 下面有个例子, 你可能见过或者写过:

1
2
3
std::vector<int> v;
...
unsigned sz = v.size();

v.size() 定义的返回类型是 std::vector::size_type , 但是很少有开发者对此十分清楚。 std::vector::size_type 被指定为一个非符号的整数类型, 因此很多程序员认为 unsigned 类型是足够的, 然后写出了上面的代码。 这将导致一些有趣的后果。 比如说在32位 Windows 系统上, unsigned 和 std::vector::size_type 有同样的大小, 但是在64位的 Windows 上, unsigned 是32bit的, 而 std::vector::size_type 是64bit的。 这意味着上面的代码在32位 Windows 系统上工作良好, 但是在64位 Windows 系统上时有可能不正确, 当应用程序从32位移植到64位上时, 谁又想在这种问题上浪费时间呢? 使用 auto 可以保证你不必被上面的东西所困扰:

1
auto sz = v.size() // sz's type is std::vector<int>::size_type

仍然不太确定使用 auto 的高明之处? 看看下面的代码:

1
2
3
4
5
6
std::unordered_map<std::string, int> m;
...
for (const std::pair<std::string, int>& p : m)
{ .
.. // do something with p
}

这看上去完美合理。 但是有一个问题, 你看出来了吗? 意识到 std::unorder_map 的 key 部分是 const 类型的, 在哈希表中的 std::pair 的类型不是 std::pair , 而是 std::pair 。 这种无意的类型不匹配可以通过 auto 解决 。

1
2
3
4
for (const auto& p : m)
{
... // as before
}
总结
auto 变量一定要被初始化, 并且对由于类型不匹配引起的兼容和效率问题有免疫力, 可 以简单化代码重构, 一般会比显式的声明类型敲击更少的键盘
auto 类型的变量也受限于条款2和条款6中描述的陷阱

条款4:当auto推导出非预期类型时应当使用显式的类型初始化

条款5解释了使用 auto 关键字去声明变量, 这样就比直接显示声明类型提供了一系列的技术优势, 但是有时候 auto 的类型推导会和你想的南辕北辙。

举一个例子, 假设我有一个函数接受一个 Widget 返回一个 std::vector , 其中每个 bool 表征 Widget 是否接受一个特定的特性:

1
std::vector<bool> features(const Widget& w);

进一步的, 假设第五个bit表示 Widget 是否有高优先级。 我们可以这样写代码:

1
2
3
4
5
Widget w;

bool highPriority = features(w)[5]; // w是不是个高优先级的?

processWidget(w, highPriority); // 配合优先级处理w

这份代码没有任何问题。 它工作正常。 但是如果我们做一个看起来无伤大雅的修改,把 highPriority 的显式的类型换成 auto :

1
auto highPriority = features(w)[5]; // w是不是个高优先级的?

情况变了。 所有的代码还是可以编译, 但是他的行为变得不可预测:

1
processWidget(w, highPriority); // 未定义行为

正如注释中所提到的, 调用 processWidget 现在会导致未定义的行为。 但是为什么呢? 答案是非常的令人惊讶的。 在使用 auto 的代码中, highPriority 的类型已经不是 bool 了。 尽管 std::vector 从概念上说是 bool 的容器, 对 std::vector 的 operator[] 运算符并不一定是返回容器中的元素的引用( std::vector::operator[] 对所有的类型都返回引用, 就是除了 bool ) 。 事实上, 他返回的是一个 std::vector::reference 对象( 是一个在 std::vector 中内嵌的class) 。

条款5:优先使用 nullptr 而不是 0 或者 NULL

在 C++98 中, 这意味着重载指针和整数类型的函数的行为会令人吃惊。 传递 0 或者 NULL 作为参数给重载函数永远不会调用指针重载的那个函数:

1
2
3
4
5
6
void f(int); 	// 函数f的三个重载
void f(bool);
void f(void*);
f(0); // 调用 f(int), 而非f(void*)
f(NULL); // 可能无法编译, 但是调用f(int)
// 不可能调用 f(void*)

使用 nullptr 作为参数去调用重载函数 f 将会调用 f(void*) 重载体, 因为 nullptr 不能被视为整数类型的:

1
f(nullptr); //调用f(void*)重载体

另一方面, 你如果看到下面的代码:

1
2
3
4
auto result = findRecord( /* arguments */);
if(reuslt == nullptr){
...
}

明显就没有歧义了: result 一定是个指针类型。

总结
相较于 0 和 NULL , 优先使用 nullptr
避免整数类型和指针类型之间的重载

优先使用声明别名而不是 typedef

1
2
3
4
5
template<typname T> 							// MyAllocList<T>
using MyAllocList = std::list<T, MyAlloc<T>>; // 等同于
// std::list<T,
// MyAlloc<T>>
MyAllocList<Widget> lw; // 终端代码

使用using 来进行别名声明。而若使用 typedef , 你不得不从草稿图开始去做一个蛋糕:

1
2
3
4
5
template<typename T> // MyAllocList<T>::type
struct MyAllocList { // 等同于
typedef std::list<T, MyAlloc<T>> type; // std::list<T,
}; // MyAlloc<T>>
MyAllocList<Widget>::type lw; // 终端代码

如果你想在一个模板中使用 typedef 来完成创建一个节点类型可以被模板参数指定的链接表的任务, 你必须在 typedef 名称之前使用 typename

1
2
3
4
5
6
template<typename T> // Widget<T> 包含
class Widget{ // 一个 MyAloocList<T>
private: // 作为一个数据成员
typename MyAllocList<T>::type list;
...
};

此处, MyAllocList::type 表示一个依赖于模板类型参数 T 的类型, 因此 MyAllocList::type 是一个依赖类型( dependent type ) , C++ 中许多令人喜爱的原则中的一个就是在依赖类型的名称之前必须冠以 typename 。

如果 MyAllocList 被定义为一个声明别名, 就不需要使用 typename

1
2
3
4
5
6
7
8
template<typname T>
using MyAllocList = std::list<T, MyAlloc<T>>; // 和以前一样
template<typename T>
class Widget {
private:
MyAllocList<T> list; // 没有typename
... // 没有::type
};

对你来说, MyAllocList ( 使用模板别名) 看上去依赖于模板参数 T , 正如 MyAllocList::type ( 使用内嵌的 typdef ) 一样, 但是你不是编译器。 当编译器处理 Widget 遇到 MyAllocList ( 使用模板别名) , 编译器知道 MyAllocList 是一个类型名称, 因为 MyAllocList 是一个模板别名: 它必须是一个类型。 MyAllocList 因此是一个非依赖类型( non-dependent type ) , 指定符 typename 是不需要和不允许的。

总结
typedef 不支持模板化, 但是别名声明支持
模板别名避免了 ::type 后缀, 在模板中, typedef 还经常要求使用 typename 前缀
C++14 为 C++11 中的类型特征转换提供了模板别名

优先使用作用域限制的eums而不是无作用域的 enum

一般而言, 在花括号里面声明的变量名会限制在括号外的可见性。 但是这对于 C++98 风格的 enums 中的枚举元素并不成立。 枚举元素和包含它的枚举类型同属一个作用域空间, 这意味着在这个作用域中不能再有同样名字的定义:

1
2
3
4
enum Color { black, white, red}; 	 // black, white, red 和
// Color 同属一个定义域
auto white = false; // 错误! 因为 white
// 在这个定义域已经被声明过

事实就是枚举元素泄露到包含它的枚举类型所在的作用域中, 对于这种类型的 enum 官方称作无作用域的( unscoped ) 。 在 C++11 中对应的使用作用域的enums( scoped enums ) 不会造成这种泄露:

1
2
3
4
5
6
7
8
9
10
enum class Color { black, white, red}; 	// black, white, red
// 作用域为 Color
auto white = false; // fine, 在这个作用域内
// 没有其他的 "white"

Color c = white; // 错误! 在这个定义域中
// 没有叫"white"的枚举元素
Color c = Color::white; // fine
auto c = Color::white; // 同样没有问题( 和条款5
// 的建议项吻合)

因为限制作用域的 enum 是通过”enum class”来声明的, 它们有时被称作枚举类( enum class ) 。

并且无作用域的enum会存在隐性类型转换。

1
2
3
4
5
6
7
8
9
enum Color { black, white, red }; 		// 无限制作用域的enum
std::vector<std::size_t> // 返回x的质因子的函数
primeFactors(std::size_t x);
Color c = red;
...
if (c < 14.5 ){ // 将Color和double类型比较!
auto factors = // 计算一个Color变量的质因子
primeFactors(c);
}

在 “enum” 后增加一个 “class” , 就可以将一个无作用域的 enum 转换为一个有作用域的 enum , 变成一个有作用域的 enum 之后, 事情就变得不一样了。 在有作用域的 enum 中不存在从枚举元素到其他类型的隐式转换:

1
2
3
4
5
6
7
8
9
enum class Color { black, white, red }; 		// 有作用域的enum
Color c = Color::red; // 和前面一样, 但是
... // 加上一个作用域限定符
if (c < 14.5){ // 出错! 不能将Color类型
// 和double类型比较
auto factors = // 出错! 不能将Color类型传递给
primeFactors(c); // 参数类型为std::size_t的函数
...
}

如果你就是想将 Color 类型转换为一个其他类型, 使用类型强制转换( cast ) 可以满足你这种变态的需求:

1
2
3
4
5
if(static_cast<double>(c) < 14.5) { // 怪异但是有效的代码
auto factors = // 感觉不可靠
primeFactors(static_cast<std::size_t(c)); // 但是可以编译
...
}

相较于无定义域的 enum , 有定义域的 enum 也许还有第三个优势, 因为有定义域的 enum 可以被提前声明的, 即可以不指定枚举元素而进行声明:

1
2
enum Color; // 出错!
enum class Color; // 没有问题
总结
C++98 风格的 enum 是没有作用域的 enum
有作用域的枚举体的枚举元素仅仅对枚举体内部可见。 只能通过类型转换( cast ) 转换 为其他类型
有作用域和没有作用域的 enum 都支持指定潜在类型。 有作用域的 enum 的默认潜在类型 是 int 。 没有作用域的 enum 没有默认的潜在类型。
有作用域的 enum 总是可以前置声明的。 没有作用域的 enum 只有当指定潜在类型时才可 以前置声明。

优化使用delete键字删除函数而不是private却又不实现的函数

使用delete关键字可让函数在重载里不会隐式调用已经被删除的函数,而若使用private可能会隐式的调用未实现的private函数。

当有一个函数,它在类的外边和内部都是是无法工作的, 当它工作时, 知道链接的时候可能又不工作了。 所以还是坚持使用删除函数吧。

总结
优先使用删除函数而不是私有而不定义的函数
任何函数都可以被声明为删除, 包括非成员函数和模板实现

使用override关键字声明覆盖的函数

若有父类有虚函数,派生类可以使用override关键字进行覆盖,而若要使用override关键字进行覆盖则需要遵守以下规则 。

  • 基类中的函数被声明为虚的。
  • 基类中和派生出的函数必须是完全一样的( 出了虚析构函数) 。
  • 基类中和派生出的函数的参数类型必须完全一样。
  • 基类中和派生出的函数的常量特性必须完全一样。
  • 基类中和派生出的函数的返回值类型和异常声明必须使兼容的。
  • 函数的引用修饰符必须完全一样。 成员函数的引用修饰符是很少被提及的 C++11 的特性, 所以你之前没有听说过也不要惊奇。这些修饰符使得将这些函数只能被左值或者右值使用成为可能。

成员函数不需要声明为虚就可以使用它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget{
public:
...
void doWork() &; // 只有当 *this 为左值时
// 这个版本的 doWorkd()
// 函数被调用
void doWork() &&; // 只有当 *this 为右值
// 这个版本的 doWork()
// 函数被调用
};
...
Widget makeWidget(); // 工厂函数, 返回右值
Widget w; // 正常的对象( 左值)
...
w.doWork(); // 为左值调用 Widget::doWork()
//( 即 Widget::doWork &)
makeWidget().doWork(); // 为右值调用 Widget::doWork()
//( 即 Widget::doWork &&)

如果一个虚函数在基类中有一个引用修饰符, 派生类中对应的那个也必须要有完全一样的引用修饰符。 如果不完全一样, 派生类中的声明的那个函数也会存在, 但是它不会覆盖基类中的任何东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};
class Derived: public Base {
public:
virtual void mf1();
virtual void mf2(unsigned int x);
virtual void mf3() &&;
void mf4() const;
};

以上代码因为未使用override,所以Derived 里面的mf1()与 Base里面的mf1()const 里面可能是重载关系。而若使用override关键字将会是真正的覆盖关系。

1
2
3
4
5
6
7
class Derived: public Base {
public:
virtual void mf1() override;
virtual void mf2(unsigned int x) override;
virtual void mf3() && override;
virtual void mf4() const override;
};

而这种方式是无法通过编译的,因为与Base的参数完全不同。