C++Primer笔记——第六章:函数(下)
函数重载
名字相同,但是形参列表不同(注意,不包括返回值)
main函数不能重载
需要注意的是,有时候两个形参列表看起来不一样,实际上他们是相同的,要注意仔细辨别
Record lookup(const Account &acct);
Record lookup(const Account &); //省略了形参的名字typedef Phone Telon;
Record lookup(const Phone &);
Record lookup(const Telon &);
再次阐述一下顶层const与底层const
- 顶层 const (Top-level const):const 修饰的是对象本身,表示这个对象或变量的值是不可改变的。
- 底层 const (Low-level const):const 修饰的是指针或引用****所指向/引用的对象,
表示不能通过这个指针或引用来改变它所指向/引用的对象的值。它与指针/引用本身是否可以改变无关。
const int* p1;
// 解读:p1 是一个指针(*p1),它指向一个 const int。
// 含义:不能通过 p1 来修改它所指向的 int 值 (*p1 = 10; // 错误)
// 但是 p1 本身可以被修改,可以指向其他地方 (p1 = &another_int; // 正确)
// 这是底层 const。
int* const p2 = &some_int;
// 解读:p2 是一个 const 指针(* const p2),它指向一个 int。
// 含义:p2 本身的值(存储的地址)不能被修改 (p2 = &another_int; // 错误)
// 但是可以通过 p2 来修改它所指向的 int 值 (*p2 = 10; // 正确)
// 这是顶层 const。
引用 只有底层 const。用一旦绑定就不能再改变,
“不能再改变”的特性使得引用本身就类似于一个 const 指针
int x = 10;
const int& r = x; // r 是 x 的一个 const 别名
// r = 20; // 错误!不能通过 r 修改 x 的值
// x = 20; // 正确!可以直接修改 x
顶层 const 在参数传递或赋值时会被忽略,而底层不会。
所以如下的函数声明实际上是相同的
int f(const int i);
int f(int i);int g(int *);
int g(const int *);
而底层const在参数传递或赋值时不会被忽略
int f(int* );
int f(const int*);int g(int&);
int g(const int&);
在传入非常量对象时,按理说两种函数都可以,但是编译器会优先使用非常量对象的那一种
在重载函数时需要注意设计,是否一定需要重载?能否给出不同的命名?重载会不会丢失函数名的语义信息?
const_cast与重载:
const_cast<new_type>(expression)
new_type只能与old_type在const上不同。一般来讲,const_cast主要用于去除const属性。
但是,对于const变量的强制修改,可能会有问题:
当编译器看到 const int value = 10; 时,它可能会进行优化。它认为 value 永远是 10,可能会:
- 将 value 放入只读内存段 (read-only memory)。任何试图写入这块内存的行为都会导致操作系统层面的段错误(崩溃)。
- 在所有使用 value 的地方,直接用字面量 10 替换它,根本不从内存中读取。所以即使你通过指针“修改”了内存,所有用到 value 的代码仍然会使用 10。
// 我们可以传入非const的s1和s2,但是这样得到的仍旧是const的string引用
const string &shorterString(const string &s1,const string &s2) {return s1.size() < s2.size() ? s1 : s2;
}
//如果我们想要得到应该非const的string引用,可以这样
string &shorterString( string &s1, string &s2) {auto &r = shorterString(const_cast<const string&>(s1),const_cast<const string&>(s2));// r 肯定是引用s1或者s2,而s1和s2也是引用变量,所以返回r是没问题的,这不会造成空悬引用return const_cast<string&>(r);
}
上述的代码主要是为了实现在传入非const引用的时候也得到非const引用
//在调用的时候直接这样写似乎也行
const_cast<string&>(shorterString(s1,s2))
但是这实际上不是一个好的设计:
- const_cast的安全性完全取决于调用者
- 破坏了封装性,调用者需要知道 const_cast这个关键字
- 代码可读性差,别人可能会停下来思考这个转换是否安全
函数匹配/重载确定:将函数调用与重载函数中某个具体的函数相对应起立
结果:最佳匹配、无匹配、二义匹配
重载与作用域
核心原则其实就是内层隐藏外层作用
编译器先找内存再找外层,如果内层找到了,就不看外层了
string read();
void print(const string &);
void print(double);
void fooBar(int ival) {
bool read = false;
string s = read();//错误,read为bool,而非函数// 不推荐,通常来说,在局部作用域内声明函数不是一个好的选择void print(int);print("Value :");// 错误,函数外的两个print全都被隐藏,现在只能看见内部的void print(int)print(3.14);//错误print(1);//正确
}
默认实参
像python一样的规则,CPP也允许定义默认实参
int f(int a = 1, int b = 2,int c = 3,int d = 4) {return a + b + c + d;
}void g(int a=1, float b=2.0f,int c=3);
void g(int a=1, int b=2,int c=3);int main() {cout << f() <<endl;cout << f(2) <<endl;cout << f(3,4) <<endl;cout << f(,,,5) <<endl;//错误,只允许省略尾部的实参数//因为这一可以避免“数逗号”、修改函数签名会破坏大量调用、引入大量歧义和复杂性 等问题g(1);// 重载二义性return 0;}
C++ 本身不支持命名参数(尽管有一些库可以模拟)。
原因如下:
- C 语言的函数调用机制非常简单、高效且纯粹基于位置:将参数按顺序压入栈或放入寄存器。它没有任何命名参数的概念。
为了保持这种兼容性和简洁的底层模型,C++ 沿用了 C 的函数调用约定。引入命名参数将是对这个基础模型的重大颠覆,会破坏与大量现有 C 代码和库的无缝链接能力 - 设计哲学:零开销原则。命名参数会带来潜在的“开销”: 编译时开销:编译器需要做更复杂的工作来解析命名参数,并将其映射到正确的位置。 运行时开销:某些实现方式可能会导致运行时性能下降(尽管大多数设计会避免这一点)。 二进制大小开销:为了支持命名参数,可能需要在编译后的目标文件中存储参数名等元数据,这会增大二进制文件的大小。
- 与函数重载的灾难性冲突。正如上面的 g(1)调用的重载二义性
- 对编译模型的冲击void func(int count)和void func(int number)对于链接器来说,这两个声明是完全相同的。如果引入命名参数,参数名就必须成为函数签名的一部分。则需要一种全新的、更复杂的机制,彻底改变名字粉碎规则。
例如python中是支持的:
def create_window(width=800, height=600, color=0, style=1):print(f"width={width}, height={height}, color={color}, style={style}")# 我只想指定 style,其他用默认值
create_window(style=5)
# 输出: width=800, height=600, color=0, style=5
但是我们可以通过结构体来实现
#include <iostream>struct WindowOptions {int width = 800;int height = 600;int color = 0;int style = 1;
};void createWindow(const WindowOptions& options) {std::cout << "width=" << options.width<< ", height=" << options.height<< ", color=" << options.color<< ", style=" << options.style << std::endl;
}int main() {// 我只想指定 style,其他用默认值WindowOptions opts;opts.style = 5;createWindow(opts); // 调用非常清晰// C++20 designated initializers 让这变得更漂亮createWindow({.style = 5});
}
C++ 关于默认参数的核心规则
- 尾部规则: 在一个函数声明中,一旦某个形参被赋予了默认值,那么它右边的所有形参都必须有默认值。
- 聚合规则: 对于同一个函数的多个声明(通常一个在 .h 文件,一个在 .cpp 文件),编译器会将它们的默认参数聚合起来。你可以在后续的声明中为之前没有默认值的参数添加默认值。
- 唯一性规则: 你不能在后续的声明中重新定义一个已经有默认值的参数。
//可以通过这种方式来添加默认值:
int g(int a , int b, int c=10);
int g(int a, int int c);
//相当于int g(int a , int b=11, int c=10);
//但是这样就会报错
int g(int a , int b, int c=10);
int g(int a=11, int b, int c);
//因为这相当于 int g(int a=11 , int b, int c=10);
//这不符合尾部规则int g(int a, int b, int c=11);//不符合唯一性规则
下面讲一下实参的名称解析(编译时)与求值(运行时)的分离规则:
- 名称解析 (Name Resolution) - 在编译时
- 何时发生? 在编译器处理函数声明的那一刻。
- 在哪里发生? 在函数声明所在的作用域。
- 做什么? 编译器会查找这个名字,并永久地把它绑定到一个具体的实体(比如一个全局变量或一个全局函数)。这个绑定关系一旦确定,就不会再改变。
2.求值 (Evaluation) - 在运行时 - 何时发生? 在函数被实际调用的那一刻。
- 做什么? 程序会获取在“名称解析”阶段绑定好的那个实体的当前值。如果是变量,就读取它的当前值;如果是函数,就执行这个函数并获取其返回值。
int f() {return 10;
}
int val1 = 40;
int val2 = 20;
int g(int a = f(), int b = val1,int c = val2) {cout << a << " " << b << " " << c << endl;return a + b + c;
}int main() {val1 = 1; //修改了外层的val1,则会造成修改int val2 = 2;//隐藏了外侧的val2,但是实际使用的默认值还是外侧的val2g(); // 结果:10 1 20return 0;}
内联函数与constexpr函数
函数带来了泛用性以及良好的维护性,但是对于某些经常调用的函数,相较于等价的操作,
会产生函数调用的开销,则可以将其声明为内联函数(inline)
注意:内联说明只是向编译器发出一个请求,编译器可以不考虑这个请求
inline const string& getShorterString(const string &s1,const string &s2 ) {return s1.size() < s2.size() ? s1 : s2;
}
constexpr函数是指能用于常量表达式的函数。
需要满足如下的要求:
- 返回值类型为字面值
- 所有形参类型为字面值
- 函数体中有且只有一条return语句
在初始化时,编译器将constexpr函数替换为其返回的结果值。为了能够在编译过程中随时展开,
constexpr函数被隐式的指定为内联函数
constexpr int mal2(const int val) {return val*2;
}int main() {int arr1[mal2(20)];//使用常量表达式初始化数组,正确// 使用constexpr函数初始化constexpr int n = 10;int arr2[mal2(n)];//n为常量表达式,用来定义数组大小也是合法的return 0;
}
但是constexpr函数不一定返回常量表达式,这取决于采用何种实参调用他
constexpr int mal2(const int val) {return val*2;
}int main() {int user_input;cin >> user_input;// 编译器无法在编译时计算结果,实际上将 mal2 编译成一个普通的函数。cout << mal2(user_input) << endl;return 0;
}
这实际上是一种设计策略: 为了代码复用(DRY: Don’t Repeat Yourself)
否则,你就得为相同的功能写一个编译时常量函数,一个运行时普通函数
// 一个用于编译时计算的版本
constexpr int mal2(const int val) {return val*2;
}
// 一个用于运行时计算的版本int mal2(const int val) {return val*2;
}
constexpr 允许你只写一个函数,它既能满足编译时对常量的需求,又能作为普通函数在运行时使用。
它让编译器来决定在特定上下文中应该如何使用这个函数。
constexpr int mal2(const int val) {return val*2;
}int main() {int user_input;cin >> user_input;// 编译器无法在编译时计算结果,实际上将 mal2 编译成一个普通的函数。cout << mal2(user_input) << endl; // 运行时调用constexpr int n = mal2(100);//编译时替换int arr[n];//正确return 0;
}
为了解决这种“双重身份”可能带来的困惑,C++20 引入了一个新的关键字 consteval。
consteval 函数被称为立即函数 (immediate function)。它比 constexpr 更严格,它强制函数必须在编译时求值。
任何试图在运行时调用 consteval 函数的尝试都会导致编译错误。
consteval int get_scaled_value_strictly(int val) {return val * 2;
}int main() {constexpr int size1 = get_scaled_value_strictly(10); // 正确int n = 20;// int size2 = get_scaled_value_strictly(n); // 编译错误!n 不是常量表达式
}
在这里再次区分一下const和constexpr。
其实最主要的区别就在于const是运行时常量,而constexpr是编译时常量
#include <iostream>
#include <string>
#include <cstdlib> // 为了 atol 函数// 一个真正意义上的“运行时”函数
// 它的返回值完全取决于程序启动时用户输入了什么
// 编译器在编译代码时,对此一无所知
long get_value_from_args(int argc, char* argv[]) {if (argc > 1) {return std::atol(argv[1]); // 将第一个命令行参数(字符串)转为 long}return 10; // 如果没有参数,返回一个默认值
}int main(int argc, char* argv[]) {// === const 的情况:完全合法 ===// 1. 程序开始运行,操作系统将 argc 和 argv 传递给 main 函数。// 2. get_value_from_args 被调用,它的值在“运行时”被计算出来。// 3. 这个运行时的结果被用来初始化 CONFIG_VALUE。// 4. 从这一刻起,CONFIG_VALUE 的值在本次程序运行中被“锁定”,不能再修改。const long CONFIG_VALUE = get_value_from_args(argc, argv);std::cout << "Configuration value for this run is: " << CONFIG_VALUE << std::endl;// ... 程序后续可以使用这个 CONFIG_VALUE,并确信它不会被改变 ...// === constexpr 的情况:绝对非法 ===// 编译器会在这里直接报错!// 错误信息会是:“initializer of 'CONFIG_VALUE_CEXPR' is not a constant expression”// (CONFIG_VALUE_CEXPR 的初始化器不是一个常量表达式)// // 为什么?// 编译器在编译 main.cpp 时,根本不知道 argc 和 argv 会是什么。// 用户可能会运行 ./a.out 123,也可能运行 ./a.out 9999,或者不带参数。// 因为 get_value_from_args 的结果在编译时是完全未知的,// 所以它不是一个“常量表达式”,因此不能用来初始化一个 constexpr 变量。constexpr long CONFIG_VALUE_CEXPR = get_value_from_args(argc, argv); // 编译错误!return 0;
}
普通函数不允许多重定义,例如当链接 a.o, b.o, main.o 时,链接器会发现 func 有两个定义,从而报错。
// a.h
void func(); // 声明// a.cpp
#include "a.h"
void func() { /* ... a's definition ... */ }// b.cpp
#include "a.h"
void func() { /* ... b's different definition ... */ } // 错误!// main.cpp
#include "a.h"
int main() { func(); }
但是inline函数和constexpr函数允许有多重定义,但是这些定义必须完全相同,由此,一般将其定义也放在头文件中
我们将inline和constexpr函数的定义放在了h文件里面,但是在编译cpp文件的时候,由于其编译可见性,我们需要知晓其具体的实现(定义)
,所以我们不得不在每个cpp文件里面给出其具体的定义,
但是这在编译后又会产生多个定义,由此编译器放宽了对多重定义的限制,也就是说对于inline和constexpr函数允许有多重定义,
但是为了确保没有歧义,又规定这些定义必须完全相同。
在具体工程实践中,我们就采取折中的方法,特例性的违背一下将声明和实现分开的原则,
将inline和constexpr函数的定义和实现都写在了h文件里面。
调试帮助
assert:预处理宏
预处理名字由预处理器而非编译器管理,所以我们可以直接使用预处理名字assert而非std::assert
assert(expr)如果为0,则终止程序运行,否则什么也不做
NDEBUG预处理变量
assert的行为依赖于NDEBUG预处理变量的值,如果定义了NDEBUG,则assert失效。
默认情况下没有NDEBUG
函数匹配
在存在类型转换的情况下,确定该使用哪个重载函数并不简单
其过程如下:
- 确定候选函数:同名,且声明在调用点可见
- 选择可用函数:形参与实参数量相等,且形参与实参类型相同或者能够转换为对应的类型
- 寻找最佳匹配:实参类型与形参类型越接近,匹配得越好
例如
void f();
void f(int);
void f(int,int);
void f(double,double = 3.14);
调用f(5.6)的最佳匹配为f(double,double = 3.14)
f(42,2.56)的最佳匹配为不是f(double,double = 3.14),而是报错,因为:
C++ 中类型转换的匹配程度大致如下(从好到坏):
- 精确匹配 (Exact Match): 类型完全相同
- const转换:去除顶层const
- 提升 (Promotion): 安全的小整数类型向 int 的提升(如 char -> int),或 float -> double。
- 算数类型转换或指针转换: 比如 int -> double,或者 double -> int (即使有损精度)。注意,所有算数类型转换的级别都一样,
这也是为什么f(42,2.56)看起来使用f(double,double = 3.14)更好(从int到double比从double到int好),而实际上两者存在歧义 - 类类型转换。
在这个例子中,
对于第一个参数,f(int, int) 的匹配(精确匹配)优于 f(double, double)(标准转换)。
对于第二个参数,f(double, double) 的匹配(精确匹配)优于 f(int, int)(标准转换)。
则产生了歧义
(这也是一种设计上的权衡,如果我们再加规则,那cpp可能会变得臃肿不堪)
还需要考虑各种转换规则对选择重载函数的介入,如核心规则:整型提升 (Integral Promotion)规则:
当一些“小”的整型(如 char, signed char, unsigned char, short, unsigned short, bool 等)参与到一个表达式中时,
它们会自动地、优先地被转换(或称“提升”)为 int 类型(如果 int 足以容纳其所有可能的值,否则会提升为 unsigned int)。
为什么有这个规则?
这是一个历史悠久的性能优化,源于 C 语言。大多数 CPU 的算术逻辑单元 (ALU) 在处理其“原生”大小的整数(通常是 int)时效率最高。
为了简化编译器的实现并生成更快的代码,C/C++ 的设计者决定,与其为所有小整数类型之间的运算(char+short, bool+char 等)都生成专门的指令,
不如干脆先把它们都转换成 int,然后再进行运算。
这个提升动作的优先级非常高,它在重载决议的“匹配成本”计算之前就已经发生了。
void ff(int) {cout <<"int" << endl;
}
void ff(short) {cout <<"short" << endl;
}int main() {ff('a'); // result:intreturn 0;
}
如上代码’a’被提升为int,自然调用的是int类的函数
函数指针
函数指针指向某个函数,函数的类型仅由其形参和返回值决定,与函数名无关
bool lengthCompare(const string &a, const string &b) {return a.length() < b.length();
}int main() {// 函数指针// pfunc是名字,前面有个*表示他是一个指针,而后面有形参列表表示这是一个函数指针,前面有返回值类型bool (*pfunc)(const string &, const string &);pfunc = lengthCompare; //赋值pfunc = &lengthCompare; //等价,取地址符是可选的pfunc("hello", "world");(*pfunc)("hello", "world");//等价,解引用是可选的,但是要加括号*pfunc("hello", "world");//错误,函数调用操作符 () 的优先级高于解引用操作符 *return 0;
}
指向不同函数的指针之间不存在转换规则
就像数组本身不能作为形参,但是可以传递数组指针形参一样,函数本身虽然不能作为形参,但是函数指针可以作为形参
bool lengthCompare(const string &a, const string &b) {return a.length() < b.length();
}
bool compare(const string &a, const string &b,bool compareFunc(const string &a, const string &b)) {// 在作为形参时,编译器会自动进行转换// 实际上第三个参数等价于 bool (*compareFunc)(const string &a, const string &b)return compareFunc(a,b);
}int main() {cout << compare("hello", "world",lengthCompare) << endl;return 0;
}
也可以返回一个函数指针,但是需要注意:编译器并不会自动将函数返回类型,需要显示的声明为指针类型
using F = int(int*,int); //F是函数类型,不是函数指针
using FP = int(*)(int*,int);//F是函数指针FP f1(int); //正确
FP f2(int,F);//正确,参数会隐式的进行函数到函数指针的转换
F f2(int);//错误,返回值必须显示的声明为函数指针
个人觉得cpp这样设计的原因在于,如果运行返回值也为函数类型,那么上述代码的等价为
int f2(int)(int*,int); // 这是超级具有迷惑性的定义语句,很有可能认为是在进行f2(int)这个函数的调用,而非在声明一个函数指针
在声明复杂的返回值类型时,还是推荐使用尾指返回格式
auto f2(int) -> int()(int *,int)
特别注意的是,decltype(函数名)返回的是函数类型而非函数指针类型
bool lengthCompare(const string &a, const string &b) {return a.length() < b.length();
}
// *是必不可少的
decltype(lengthCompare) *compare(const string &a, const string &b) ;