第6章 函数

本文最后更新于:2023年8月25日 凌晨

第6章 函数

1. 函数的基础

一个典型的函数定义包括以下部分:返回类型函数名形参列表 以及函数体。本章的内容也是围绕这几个点展开的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//val的阶乘 val*(val-1)*(val-2)...*1
int fact(int val)
{
int ret = 1;
while (val > 1)
ret *= val--;
return ret;
}

//调用函数
int main()
{
int j = fact(5);
cout << "5! is " << j << endl;
return 0;
}

1.1 局部对象

在c++语言中,名字有作用域,对象有生命周期

  • 名字的作用域是程序文本的一部分,名字在其中可见
  • 对象的生命周期是程序执行过程中该对象存在的一段时间

局部变量

形参和函数内部定义的变量统称局部变量

  • 局部变量只在函数内部起作用
  • 外部全局变量和局部变量同名,局部变量会覆盖全局,这里是名称的覆盖,不是值的覆盖

局部静态变量

使用static 关键字定义静态变量。在程序的执行路径第一次经过变量定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
size_t count_calls()
{
static size_t ctr = 0; // 调用结束后,这个值仍 然有效
return ++ctr;
}

int main()
{
for (size_t i = 0; i != 10; ++i)
{
cout << count_calls() << endl;
}
return 0;
}
// 输出从1到10(包括10在内)的数字。

静态变量存在于程序的整个生命周期,第一次调用count_calls, 定义并初始化ctr,之后再调用函数不会再执行初始化,ctr相当于一个全局的变量

1.2 函数声明

和变量名一样,函数也必须在使用之前声明,函数可以声明多次,但是只能定义一次。函数的定义不是必须的,比如声明一个函数,我们从没有调用它,那么它可以不用定义。函数的声明是没有函数体的,声明可以不写形参名,只声明类型,但在定义时如果在函数用到形参,则需要写上变量名。

1
2
3
4
int func(int, int);		//声明可以不写变量名

int func(int a, int b) //函数体中使用了形参,需要写 变量名
return a+b;

2. 参数传递

形参初始化的机制和变量初始化一样(本节内容可结合第2章的内容看)

在c++中传参的方式主要有两种:引用传递值传递

2.1 传值参数

普通类型形参

当初始化一个非引用类型的变量时,初始值被拷贝给变量,在函数体内改变的是实参的副本,不会对实参有影响

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//val的阶乘 val*(val-1)*(val-2)...*1
int fact(int val)
{
int ret = 1;
while (val > 1)
ret *= val--;
return ret;
}

//调用函数
int main()
{
int j = 5;
cout << "5! is " << fact(j) << endl;
cout << "5! is " << j << endl; //j的值没有变化
return 0;
}

指针形参

指针形参也是值传递的一种方式,传入的指针是实参的副本(一个拷贝出来的指针),同样在函数体中改变指针的值(指向的地址)不会影响实参的值。

1
2
3
4
5
void reset(int *p)
{
*ip = 0; //改变指针ip所指对象的值
ip = 0; //改变ip所指向的地址,但是只改变局部变量,实参未被改变
}

值传递的两种方式都是通过拷贝实参进行传值的,如果传入的实参比较大,拷贝会影响程序的性能。

建议使用下面将要介绍的引用传参的方式

2.2 传引用参数

相比于值传递,引用传递是直接将对象传入函数,没有拷贝带来的性能损失,所以在函数体中改变通过引用传入的形参的值,会改变实参

1
2
3
4
5
6
7
8
9
10
11
12
13
//这个函数,调用之后会实参的值会变成0
void reset(int &i)
{
i = 0;
}

int main()
{
int j = 42;
reset(j);
cout << "j = " << j << endl; //j的值是0
return 0;
}

建议使用引用传参, 对于不需要改变引用形参的值,可以将其声明为常量引用

引用形参的一种用法

我们知道函数只能返回一个值,然而有时函数需要同时返回多个值,引用形参为我们一次返回多个结果提供了有效的途径。就是我们把需要返回的一个或多个需要返回的值声明为引用形参,在函数体把值写入到引用形参中。

2.3 const形参和实参

这里涉及到顶层const和底层const的概念。

  • 用实参初始化形参时会忽略顶层const,也就是说对于一个含有顶层const的形参,可以给它传递常量和非常量对象
  • 我们可以使用非常量初始化一个底层const对象,但是反过来不行

将变量的初始化规则应用到参数传递

1
2
3
4
5
6
7
8
9
10
//函数原型: int reset(int &i); 和 int reset(int *ip);
int i = 0;
const int ci = i;
string::size_type ctr = 0;
reset(&i); //调用形参类型为 int* 的 reset
reset(&ci); //错误,int *ip = ci;
reset(i); //调用形参类型为 int& 的 reset
reset(ci); //错误, int *ip = ci;
reset(42); //错误 int &i = 42;
reset(ctr); //错误, 类型不匹配

尽量使用常量引用

  • 当我们把一个不需要改变的形参定义成非常量的话,会给人误导
  • 定义成常量引用的形参,调用者可以传递常量和非常量实参

2.4 数组形参

  1. 不允许拷贝数组,所以数组不能以值传递的方式传入
  2. 为函数传递一个数组时,实际上传递的是指向数组首元素的指针。
1
2
3
4
//传入的都是 const int* 
void print(const int*);
void print(const int[]);
void print(const int[10]); //这里的维度是希望传入含有10个元素的数组的指针,实际不一定

以上三个函数的形参虽然表现形式不一样,但是他们是等价的,都是const int* 类型形参

管理指针形参

和其他使用数组的代码一样,以数组作为形参的函数也必须确保使用数组时不越界,下面介绍三种常用的管理指针形参的技术: - 使用标记指定数组长度(如C风格字符串以空字符\0结束) - 使用标准库规范(传递数组首元素和尾后元素的指针) - 显示的传递一个表示数组大小的形参

数组引用形参

c++允许将变量定义成数组的引用,同样形参也可以是数组的引用

1
2
3
4
5
6
//形参是数组的引用,维度是类型的一部分
void print(int (&arr)[10])
{
for (auto elem : arr)
cout << elem << endl;
}

上面函数可传入 int arr[10] 类型,形参中维度是类型的一部分

&arr 两端的括号不能少,下面两个函数定义不等价

f(int &arr[10]) //错误,形参是引用类型的数组,不存在这种类型

f(int (&arr)[]) //正确,arr是具有10个整数类型数组的引用

传递多维数组

1
void print(int (*martrix)[10], int rowSize) { /*...*/}

上述语句将matrix声明成指向含有10个整数的数组的指针, *matrix 两端的括号不可少

1
2
int *matrix[10];		//10个整型指针组成的数组, 这是个数组变量
int (*matrix)[10]; //指向含有10个整型的数组的指针, 这是个指针变量

2.5 main: 命令行选项

main 函数是可以带参数的,我们在命令输入的命令就是传递到main函数中,假设main函数位于可执行文件 prog 之内,我们可以向程序传递下面的选项:

1
$ prog -d -o ofile data0

这些命令可以通过两个形参传递给main函数

1
2
3
//main函数带形参的两种形式,这两种形式是等价的
int main(int argc, char *argv[]) { ... }
int main(int argc, char **argv) { ... }

argc 表示命令行参数的个数,包括可执行程序本身的文件名,argv 存放命令行参数

prog -d -o ofile data0 命令, argc是5, argv内容为:

argv[0] = "prog ";

argv[1] = "-d";

argv[2] = "-o";

argv[3] = "ofile";

argv[4] = "data0";

当我们需要使用命令行输入的参数时,从argv[1] 开始读取,argv[0] 保存的是程序名

2.6 含有可变形参的函数

省略符形参

省略符形参是C语言的标准,在C++中也是适用的,它的形式有以下两种

1
2
void foo(parm_list, ...);
void foo(...);

省略符形参只能出现在形参列表的最后一个位置

第一种形式指定了 foo 函数的部分形参的类型,这些形参和正常的形参一样。省略符形参所对应的传入的实参无须类型检查。在第一种形式中,形参声明后的逗号是可选的。

initializer_list 形参

C++11 initializer_list 是一种标准库类型,改类型定义在同名的头文件中,它提供的操作如表

image-20211228182857202

使用示例:

1
2
3
4
5
6
void error_msg( initializer_list<string> il)
{
for (auto beg = il.begin(); beg != il.end(); ++beg)
cout << *beg << " ";
cout << endl;
}

initializer_list 是一个泛型类,使用时需指定类型,所以传递的多个参数必须是同一种类型

除了initializer_list 之外,函数也可以有其他的形参

3. 返回类型和return语句

return 语句终止当前正在执行的函数并控制返回到调用该函数的地方。return语句有两种形式

1
2
return;		//用于无返回值的函数
return expression; //用于有返回值的函数

3.1 无返回值函数

没有返回值的 return 语句只能用在返回类型是 void 的函数中。返回 void 的函数不要求非得有 return语句,这类函数的最后一句后面会隐式地执行 return,return可以放在函数内的其他位置,表示提前结束函数

1
2
3
4
5
6
7
8
9
void swap(int &v1, int &v2)
{
if(v1 == v2)
return;
int tmp = v2;
v2 = v1;
v1 = tmp;
//此处无须显示的return语句
}

返回值为 void 的函数体中不可以使用 return experssion; 返回语句

3.2 有返回值函数

只要函数的返回类型不是 void, 则该函数内的每条 return 语句必须返回一个值。return 语句返回值的类型必须与函数的返回类型相同,或者能隐式地转换成函数的返回类型 。

不要返回局部对象的引用或是指针

1
2
3
4
5
6
7
8
const string &manip()
{
string ret;
if (!ret.empty())
return ret; //错误:返回局部对象的引用!
else
return "Empty"; //错误: "Empty"是一个局部临时对象
}

局部对象的指针或是引用,在返回时就会被释放,用变量接收函数的返回值得到的是不存在的对象。

引用返回左值 ----> 能为返回类型是非常量引用的函数的结果赋值

列表初始化返回值 ----> 函数可以返回花括号包围的值的列表

递归 ----> 在函数中调用了自身。递归调用必须要有终止条件

3.3 返回数组指针

数组不能拷贝,所以函数不能返回数组。不过函数可以返回数组的指针或是引用。直接定义一个返回数组的指针或是引用的函数比较烦琐,先看下使用别名的方式。

1
2
3
typedef int arrT[10];	//arrT 是一个类型别名,他表示的类型是含有10个整数的数组
using arrT = int[10]; //和上面的等价
arrT* func(int i); //使用类型别名定义一个返回10个整数的数组的指针的函数

声明一个返回数组指针的函数

首先对下面的定义区分一下

1
2
3
int arr[10];		//arr是一个含有10个整数的数组
int *p1[10]; //p1是一个含有10个整型指针的数组
int (*p2)[10] = &arr; //p2是一个指针,它指向含有10个整数的数组

返回数组指针的函数的形式如下

1
Type (*function(parameter_lis)) [dimension]

dimension 是指数组的维度,比如和上面使用别名定义的函数的等价形式

1
int (*func(int i)) [10];

对于这个函数的定义,我们可以逐层的理解: func(int i) 表示一个名为 func,参数为 int i 的函数; * 表示的返回的是指针类型,int* [10] 表示是数组类型的指针

使用尾置返回类型

C++11 新标准提供了一种简便的方式定义这样的函数,就是使用 尾置返回类型 ,它的形式是这样的

1
auto func(int i) -> int(*)[10];

使用 decltype

还有一种情况,如果我们知道函数返回的指针将指向哪个数组,可以使用 decltype 关键做声明返回类型

1
2
3
4
5
6
7
int odd[] = {1, 3, 5, 7, 9};
int even[] = {0, 2, 4, 6, 8};
//返回一个指针,该指针指向含有5个整数的数组
decltype(odd) *arrPtr(int i) //decltype(odd) 后需要加 * 表示对应的指针类型
{
return (i % 2) ? &odd : &even; //返回一个指向数组的指针
}

4. 函数重载

如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为重载函数, 下面的几个参数列表不同的函数

1
2
3
4
5
6
7
8
void print(const char *cp);
void print(const int *beg, const int *end);
void print(const int ia[], size_t size);
//函数调用时,编译器会根据传入的参数类型调用不同的函数
int j[2] = {0, 1};
print("Hello world"); //调用 print(const char*)
print(j, end(j) - beging(j)); //调用第3个
print(begin(j), end(j)); //调用第2个

定义重载函数

重载函数唯一区分的指标就是形参列表的数量和类型,只有返回类型不同的函数不是重载函数

重载和const形参

顶层const是无法区分形参 ,所以顶层cosnt形参无法实现重载

1
2
3
4
5
int print(int);
int print(const int); //和上面声明等价,重复声明

int print(int*);
int print(int* const); //和上面声明等价,重复声明

如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时的const是底层const:

1
2
3
4
5
int print(int&);		//函数作用于int的引用
int print(const int&); //重载函数,作用于常量引用

int print(int*); //作用于int类型的指向
int print(const int*);//重载函数,作用于指向常量的指针

在本章的第2节中讲函数的参数时,有提到形参是常量对象的函数既可以传入常量对象,也可以传入非常量对象,比如上面两组函数中的第二个,它们可以传入常量对象也可传入非常量对象。

在这里,因为它们都有非常量形参的重载函数,那么在传入非常量对象时编译器会优先选用非常量版本的函数。

4.1重载与作用域

不要在某个语句块(函数体)的内部声明和外部名字一样的变量和函数。(受到作用域限制,会隐藏外层变量/函数)

5. 特殊用途语言特性

5.1 默认实参

有些时候,我们调用函数时,某些形参的值总是被赋予同样的值,只是在少数情况下需要要不同的值。这时我们可以把这样的形参赋予一个默认的值

1
2
3
4
//一个创建窗口的函数,窗口的默认高80,宽180
string screen(string name, int h = 80, int w = 180);
screen("window1"); //不传入h和w,使用默认值
screen("window2", 100); //只传入h, w使用默认值

注意:

  • 默认形参必须定义在形参列表的最后
  • 实参是按位置解析的,比如需要改变 w 的值,那么h的值也必须传入
  • 局部变量不能作为默认实参

5.2 内联函数和constexpr函数

内联函数

为了避免函数调用的开销,将一些规模较小、流程直接的函数声明成内联函数,内联函数不会有调用的过程,而是直接在调用点把函数体内的语句嵌入进来。

1
2
3
4
5
6
7
8
9
10
11
12
13
inline const string &
shorterString(const string &s1, const string &s2)
{
return s1.size() <= s2.size() ? s1 : s2;
}

int main()
{
string s1 = "hello";
string s2 = "world";
cout << shorterString(s1, s2) << endl; //等价 cout<< s1.size() <= s2.size() ? s1 : s2 << endl
return 0;
}

constexpr函数

在第2章中有讲到,使用constexpr关键字定义常量表达式,并且可以使用函数的返回值初始化定义的常量。这里使用的函数就是 constexpr函数, 语法形式如下

1
2
constexpr int new_sz() { return 42; }
constexpr int foo = new_sz(); //用constexpr函数的返回值初始化一个constexpr变量

constexpr函数在编译阶段就已经计算出了返回值,对于constexpr函数的调用是直接用计算的值替代的。为了编译过程随时展开,constexpr函数被隐式地指定为内联函数。

constexpr函数需遵循:

  • 函数的返回类型及所有的形参类型都得是字面值类型
  • 函数体中必须有且只有一条 return 语句

5.3 调试帮助

assert预处理宏

assert是一种预处理宏,所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。assert宏使用一个表达作为它的条件:

1
assert(expr);

首先对 expr 求值,如果表达式为假(即0),assert输出信息并终止程序的执行;如果表达式为真(即非0),assert什么也不做。

assert宏定义在cassert头文件中,预处理名字由预处理器而非编译器管理,所以我们可以直接使用预处理名字而无需提供 using 声明。

NDEBUG 预处理变量

assert的行为依赖于一个名为 NDEBUG 的预处理变量的状态。如果定义了 NDEBUG,则assert什么也不在。默认状态下没有定义NDEBUG。定义NDEBUG既可以在程序中定义,如下

1
#define NDEBUG

也可以在编译时加上NDEBUG这个参数

1
$ CC -D NDEBUG main.c	#等价于 #define NDEBUG

除了assert之外,我们也可以使用NDEBUG编写自己的条件调试代码

1
2
3
4
5
6
7
void print(const int ia[], size_t size)
{
#ifndef NDEBUG
cerr << __func__ << ": array size is " << size << endl;
#endif
//...
}

上面代码中 __func__ 是编译器定义的变量,它是 const char 的一个静态数组,存放当前函数的名字,除了这个,还有其他变量

  • __FILE__ 存放文件名的字符串字面值
  • __LINE__ 存放当前行号的整型字面值
  • __TIME__ 存放文件编译时间的字符串字面值
  • __DATE__ 存放文件编译日期的字符串字面值

我们可以利用上面这些变量提供错误的详细信息

6. 函数匹配

当重载函数的参数数量一样,只是类型不同的情况下,函数匹配变得有点困难了。

1
2
3
4
5
void f();
void f(int);
void f(int , int);
void f(double, double = 3.14);
f(5.6); //调用 void f(double, double)

6.1 实参类型转换

为了确定最佳匹配,编译器将实参类型到形参类型的转换划分成几个等级,具体如下所示

  1. 精确匹配,包括以下情况:
    • 实参类型和形参类型相同
    • 实参从数组类型或函数类型转换成对应的指针类型(函数指针下小节会讲)
    • 向实参添加顶层const或者从实参中删除顶层const
  2. 通过const转换实现的匹配
  3. 通过类型提升实现的匹配
  4. 通过算术类型转换实现的匹配
  5. 通过类类型转换实现的匹配

7. 函数指针

函数指针是指针,它指向的是函数。函数的类型由它的返回类型和形参类型共同决定,与函数名无关。例如:

1
2
3
4
5
bool lengthCompare(const string &, const string &);
//上面函数的类型是
bool(const string&, const string&)
//声明一个对应的函数指针
bool (*pf)(const string&, const string&); //指针pf是函数指针,未初始化

*pf两端的括号不能少

1
2
//没有括号的话是定义一个名为pf的函数,返回值是 bool*
bool *pf(const string&, const string&);

使用函数指针

函数名和数组名一样,直接使用名字(不用取地址符)会自动地转换为指针,例如

1
2
pf = lengthCompare;		//pf 指向名为lengthCompare的函数
pf = &lengthCompare; //和上面的等价,取地址符是可选的

此外,我们还能直接使用指向函数的指针调用该函数,无须提前解引用指针:

1
2
3
bool b1 = pf("hello", "wolrd");      //调用lengthCompare函数
bool b1 = (*pf)("hello", "wolrd"); //等价的调用
bool b3 = lengthCompare("hello", "wolrd"); //另一个等价的调用

函数指针也可以赋予 nullptr , 函数指针赋值要和定义的类型一致才可以赋值。

函数指针形参

和数组类似,虽然不能定义函数类型的形参,但是形参可以是指向函数的指针。

1
2
3
4
5
6
//第三个参数是函数类型,它会自动转换成函数的指针
void useBigger(const string &s1, const string &s2, bool pf(const string&, const string&))
//等价声明
void useBigger(const string &s1, const string &s2, bool (*pf)(const string&, const string&))
//函数调用
useBigger(s1, s2, lengthCompare); //函数名自动转换为函数指针

使用别名简化写法

1
2
3
4
5
6
7
8
9
10
//Func和Func2是函数类型
typedef bool Func(const string&, const string&);
typedef decltype(lengthCompare) Func2; //等价类型
//FuncP 和FuncP2是指向函数的指针
typedef bool(*FuncP)(const string&, const string&);
typedef decltype(lengthCompare) *FuncP2; //等价的类型

//使用上面的别名声明带函数指针形参的函数
void useBigger(const string&, const string&, Func);
void useBigger(const string&, const string&, FuncP2);

返回指向函数的指针

和数组类似,我们不能返回函数,但是可以返回函数指针

1
2
3
4
5
6
7
8
9
10
11
//定义别名,简化写法
using F = int(int*, int); //F是函数类型,不是指针
using PF = int(*)(int*, int); //PF是函数指针类型

//声明返回函数指针的函数
PF f1(int); //正确, PF是指针函数,f1返回指向函数的指针
F f1(int); //错误,F是函数类型,不能返回函数
F *f1(int); //正确,显示地指定返回类型是指向函数的指针

//原始的不使用别名声明方式
int (*f1(int))(int*, int);

原始的声明是从里向外读, (*f1(int)) 表示 f1 是一个函数,参数是int ,返回的是指针 * , 指针指向的是函数类型 int(int*, int)

在前面我们声明返回数组指针的函数使用过返回类型后置,同样这里也适用

1
auto f1(int) -> int(*)(int*, int);

使用 decltype 自动检测类型

1
2
string::size_type sumLength(const string&, const string&);
decltype(sumLength) *getFcn(const string&);

decltype作用于函数时返回的是函数类型而非指针类型,所以需要显示地加上 * 声明为指针。


第6章 函数
https://kingw413.github.io/2023/08/25/Ch6-函数/
作者
Whd
发布于
2023年8月25日
许可协议