跳转到内容

函数

到现在为止,我们还没学习过任何与函数相关的内容,但实际上我们已经经常在使用函数了,例如:

// 这里的 printf() 就是一个函数,C 风格的输出
printf("%d", d);
// 这里原 sizeof() 也是一个函数,求给定变量类型所占内存空间大小
cout << sizeof(int) << endl;
// 这里的 abs() 也是一个函数,求给定值的绝对值
int x = -8
cout << abs(x) << endl; // 输出 -8 的绝对值 8

这些都是 C++ 内置的函数,我们直接拿来使用就行(有可能需要将对应的头文件包括进来)。

函数是编程中一种非常重要的概念,它可以让我们将一段可重复使用的代码封装起来,用于执行特定的任务。

函数可以授受输入参数,并返回一个结果。通过使用函数,我们可以将复杂的程序分解成多个小的、可管理的部分,从而提高代码的可读性、可维护性和复用性。

就像上面我们上面看到的几个函数,我们并不需要知道这些函数具体是如何实现这些功能的,我们只需要知道如何去用就行了,函数就像一个魔法盒子,我们更关心它的输入和输出。

上面这个加法的魔法盒子 add(),它的魔法是:接收两个数字输入,经过魔法盒子的魔法处理后,输出这两个数字的和。

当然,作为一个编程学习者来说,我们不只是要学习使用现有的函数,而且要自己来编写函数,完成函数内部的魔法设计。

函数的定义

函数本质上是一段可以独立执行的代码块,它包括函数返回值、函数名、参数这几个部分,下面是定义一个函数的基本形式:

返回值类型 函数名(参数列表) {
函数体
}
  1. 函数名:也就是函数的名字,使用者将使用这个名称来调用函数。
  2. 参数列表:指函数的输入参数,可以有多个参数,也可以没有参考。
  3. 返回值类型:指函数返回结果的类型,如果不返回任何值,则返回类型为 void
  4. 函数体:也就是函数具体要完成的功能代码,包括在一对花括号 {} 中。

举个最简单的例子,我们要定义一个加法函数,计算两个整数的和,可以这样写:

int add(int a, int b) {
return a + b;
}

对应于上面关于函数的基本形式:

  • 函数返回值为 int
  • 函数名为 add
  • 函数接受两个整数类型的参数 ab
  • 函数体中只有一句,通过 return 返回了 ab 的和

现在我们有了自己定义的计算两个整数之和的函数 add(),就可以直接使用了,完整的定义与使用代码如下所示。

#include <iostream>
using namespace std;
// 函数的定义
int add(int a, int b) {
return a + b;
}
int main() {
int x, y, rlt;
cin >> x >> y;
// 函数的调用
rlt = add(x, y);
cout << rlt << endl;
return 0;
}

对于给定的 x = 5y = 3,这个程序的输出结果为:8。

这里有两点需要注意:

  1. 自定义的函数是放在 main() 主函数之外,与 main() 是同一个层级,初学者需要注意。
  2. xy 是我们实际传入的两个数,而函数内的 ab 是函数内求和所代表的两个加数。在调用函数时,x 的值会被赋给 ay 的值会被赋给 b,对应关系是由定义和调用的参数顺序来确定的。

函数返回值

在函数中,我们是通过 return 来返回函数的执行结果的,也是就函数的返回值。函数的返回值可以是任意数据类型,包括基本数据类型和其它类型(结构体、类、指针等)。

函数也可以没有返回值,如果函数没有返回值,其返回值类型是 void,举个例子:

#include <iostream>
using namespace std;
void say_hello(string name) {
cout << "Hello, " << name << endl;
}
int main() {
string name;
getline(cin, name);
say_hello(name);
return 0;
}

这个函数的返回值类型是 void,在函数体内也不需要使用 return 去返回执行结果。

再举个例子,假设我们要写一个函数,专门用来判断一个整数是否是偶数。可以这样写:

bool is_even(int n) {
if(n % 2 == 0) {
return true;
} else {
return false;
}
}

这里的函数名为 is_even,返回值类型是 bool ,代表布尔类型,只有两种状态 truefalse。有了这个函数,在其它需要用到偶数判断的地方直接调用它就行了。

下面是一个完整的例子,用这个函数来判断输入的整数是不是奇数,并通过文字表示出来。

#include <iostream>
using namespace std;
bool is_even(int n) {
if(n % 2 == 0) {
return true;
} else {
return false;
}
}
int main() {
int n;
cin >> n;
if (is_even(n)) cout << n << "是偶数" << endl;
else cout << n << "是奇数" << endl;
return 0;
}

输入

8

输出

8是偶数

在上面的 is_even() 函数定义中,由于 n % 2 == 0 本身就会返回 truefalse,因此函数的判断逻辑其实可以简化为下面这样:

bool is_even(int n) {
return n % 2 == 0;
}

这样是不是简单了很多?

另外,在 C++ 中,默认情况下函数只能返回一个值。如果需要返回多个值,可以通过后面我们会学到的结构体或类去实现。

函数名

函数名用于标识一个函数的名称,函数名应该能够清晰表达该函数需要做的事,这也是一种良好的编程实践。

除此之外,函数名还有一些需要注意的地方:

  1. 命名规则。在 C++ 中,函数名只能由字母、数字和下划线组成,并且不能以数字开头。C++ 中函数名是大小写敏感的。实际上与变量命名的规则是一致的。
  2. 函数名应该具有描述性,能够准确、清晰地表达函数的意图,避免使用过于抽象的名称。
  3. 函数名应该采用一致的命名风格。例如你可以采用像 AddUrl()这样的驼峰式命名法或像 add_url() 这样的下划线命名法来命名函数,在整个文件和代码库中保持一致就行。
  4. 函数名不能使用系统中保留的关键字。关于 C++ 中有哪些保留的关键字,可以查看链接:C++ 保留关键字

如果你去看编程竞赛的一些代码,经常会发现命名都没有遵循上述的规范,主要是因为竞赛通常是一次性的,也不存在后期维护,大家为了追求快速得出正确答案,往往会使用极简单的一些写法。但从初学编程的角度看,无论是为了自己排查错误,还是协作时别人阅读你的代码,遵循最佳实践,养成良好的命名习惯是非常有必要的,

作为参考,你可以通过 Google C++ 风格指南 去了解一下大公司所遵循的代码风格规范。

函数参数

当我们在定义函数时,如果需要从外部传入一些数据,以便在函数内使用这些数据去完成特定的任务,这些从外部要传入的数据就是通过定义参数去完成的。当然,函数也可以没有参数

仍然以上面的加法函数 add() 为例,当我们在调用函数时,将真实的数字 3 和 5 传递给函数,函数的参数 a 和 b 的值被分别设定为 3 和 5,然后在函数内利用这两个参数进行求和运算,如下图所示。

函数定义时写的参数被称为形式参数,简称形参,也就是这里的 ab ; 而在调用函数时真实传递进去的参数称为实际参数,简称实参,这里的 35 则是实参。

在 C++ 中,根据函数参数的传递方式不同,有 值传递(pass by value)引用传递(pass by reference) 两种传递方式。这两种方式在传递参数时有着不同的表现行为,需要特别注意。

值传递(Pass by Value)

值传递是指在函数调用时,是将实参变量的值复制到函数的形参中去,这就意味着函数接收的是原始数据的副本,而不是原始数据本身。也就是说:在函数中对形参所做的任何更改都不会影响到原始实参变量的值

例如:

#include <iostream>
using namespace std;
void modify_value(int x) {
x = 10; // 试图修改形参的值
}
int main() {
int a = 5;
modify_value(a);
cout << "a = " << a << endl; // 输出仍然是 5
return 0;
}

在上面的例子中,a 的值不会因为 modify_value 函数内部的操作而改变,因为 modify_value 函数修改的是 a 的副本,而不是 a 本身。

在 C++ 中,对于简单数据类型,如 intcharfloat,都是采用值传递的方式,值传递只是复制数据值 ,函数内部对形式参数的修改并不会影响到外部的实际参数。

引用传递(Pass by Reference)

引用传递是指函数调用时,实际上是将实参变量的地址传递给函数的形参,函数接收的是原始数据的引用(即内存地址的引用),而不是数据的副本。这也意味着在函数中对形参的所有更改都会最影响到原始的实参变量

来看一个简单的引用传递例子,假如说我们想要写一个函数 new_swap() 用于交换两个变量的值,我们之前有学习到,交换两个变量的值可以借助一个中间变量来完成,是这样来实现的。

int a = 3, b = 5;
// 交换变量 a 和 b 的值
int temp = a;
a = b;
b = temp;

因此,初学者很容易写出下面这样的函数:

#include <iostream>
using namespace std;
// 交换两变量的函数定义
void new_swap(int a, int b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int m, n;
cin >> m >> n;
// 调用函数
new_swap(m, n);
cout << "m = " << m << endl;
cout << "n = " << n << endl;
return 0;
}

运行测试一下,输入

3 5

输出结果:

m = 3
n = 5

发现变量 mn 的值并没有交换,还是原来的 35。这是因为这里的参数传递是值传递,虽然我们在调用函数时传递了参数 mn ,实际上只是把 mn 这两个变量的值 35 传递过去了,在函数中对 ab 的交换仅仅在函数内部起作用,并不影响外部的原始变量 mn 的值。

如果想要影响到原始的变量,我们就需要使用引用传递。我们只需要将函数的参数稍作改动,将变量的地址传递过去,改后的代码如下:

void new_swap(int &a, int &b) {
int temp = a;
a = b;
b = temp;
}

这里我们使用 & 取地址符号将参数传递的方式更改为引用传递了,再运行看看结果,发现 mn 这两个变量的值确实是交换了。

对于复杂的数据类型,如数组、类对象等,都是按引用传递,由于采用引用传递的方式不会产生数据的复制,因此开销更小,更加高效。

实际上,在 C++ 中已有一个内置的函数 swap() 用于交换两个元素的值,而且内置的 swap() 函数是基于模板来实现的,因此更加灵活,支持不同类型的元素交换。

参数默认值

在 C++ 中,根据需要,我们可以为参数提供默认值,在调用函数时,如果没有传递参数,则使用该默认值;如果在调用函数时从外部传递了参数,则忽略默认参数。

下面是一个例子:

#include <iostream>
using namespace std;
void say_hello(string name = "AI") {
cout << "Hello, " << name << endl;
}
int main() {
string name;
getline(cin, name);
// 传递了参数,忽略默认值
say_hello(name);
// 没有传递参数,则使用参数默认值
say_hello();
return 0;
}

输入:

Jenny

输出:

Hello, Jenny
Hello, AI

函数的重载

函数重载(Function Overloading)是 C++ 中的一种多态性(polymorphism)表现形式,允许我们在同一个作用域内定义多个同名函数,但这些函数的参数列表(参数的类型、数量、顺序等)不同。

C++ 编译器会根据函数调用时传递的参数类型和个数来决定调用哪一个函数。

函数重载的主要目的是为了提高代码的可读性和可维护性,避免使用多个相似但不同名称的函数。

比如说前面我们写的加法函数 add(),只能求两个整数的和,如果我们还希望支持求两个浮点数的和,我们就可以像下面这样定义多个同名函数:

#include <iostream>
using namespace std;
// 两个整数求和
int add(int a, int b) {
return a + b;
}
// 两个浮点数求和
float add(float a, float b) {
return a + b;
}
// 两个双精度浮点数求和
double add(double a, double b) {
return a + b;
}
int main() {
int a = 3, b = 5;
float c = 2.3, d = 3.2;
double e = 1.24, f = 7.28;
// 函数的调用很简单,都用同样的名字
cout << add(a, b) << endl;
cout << add(c, d) << endl;
cout << add(e, f) << endl;
return 0;
}

输出:

8
5.5
8.52

在这个例子中,add 函数被重载了三次,它们的参数类型各不相同,我们在调用函数时没什么区别,都是使用的同一个函数名,编译器会根据参数的类型、数量、顺序来自动决定调用哪一个函数,非常方便。

如果没有函数重载,针对上面的浮点数相加,我们就需要另外写两个不同名的函数,如: add_two_float()add_two_double(),用来表示将两个浮点数相加。

全局变量与局部变量

在编程中,变量是用来存储数据的基本单元。根据变量的作用域(Scope),变量可以分为全局变量(Global Variables)和局部变量(Local Variables)。全局变量和局部变量的定义、使用场景以及它们的优缺点不同,了解它们的区别对于编写高效、可维护的代码非常重要。

1. 局部变量

局部变量是在函数、代码块或类的方法中定义的变量,其作用范围仅限于该函数或代码块。它在代码执行到声明位置时创建,函数或代码块执行完毕后销毁。

我们之前定义的大部分变量都是在主函数 main() 内,它们都是局部变量。

局部变量有以下特点:

  • 作用域:局部变量的作用域仅限于声明它的函数、代码块或类的方法内,离开这个范围后,变量不再存在。
  • 生命周期:局部变量在进入它所在的函数或代码块时创建,在函数或代码块结束时销毁,不能在外部访问。
  • 内存分配:局部变量的内存分配通常在(Stack)上,存储空间有限,但访问速度快。

2. 全局变量

全局变量是在函数之外定义的变量,其作用域为整个程序。它可以在程序中的任何地方(任何函数、代码块内)被访问或修改。

全局变量有以下特点:

  • 作用域:全局变量的作用域是整个程序,在声明之后,它可以在任何函数中被访问。
  • 生命周期:全局变量在程序开始时创建,直到程序结束时才销毁,因此它的生命周期与程序的生命周期相同。
  • 内存分配:全局变量通常存储在静态存储区(Static Storage Area),这部分内存在程序运行时一直存在。

3. 示例

大家看看下面这段代码,找一找,哪些是局部变量,哪些是全局变量?

#include <bits/stdc++.h>
using namespace std;
int n;
void divide(int n) {
for (int i = 2; i <= n / i; i++) {
if (n % i == 0) {
int s = 0;
while (n % i == 0) {
n /= i;
s++;
}
cout << i << " " << s << endl;
}
}
if (n > 1) cout << n << " " << 1 << endl;
}
int main() {
cin >> n;
while (n--) {
int x;
cin >> x;
divide(x);
}
return 0;
}

3. 如何选择

局部变量的作用范围较小,生命周期短,适用于临时数据存储,减少命名冲突和内存占用。全局变量可以在整个程序中共享数据,生命周期长,但容易引发命名冲突和难以维护的问题。

对于参加编程竞赛的学生来说,由于代码基本上是一次性的,基本上不存在多人协作和维护,如果不知道如何选,可以直接使用全局变量。

其一,局部变量存储空间有限,如果需要存储的数据量很大(如开一个很大的数组),一旦超过栈的存储空间限制,就会产生堆栈溢出错误。

其二,C++ 对于局部变量和全局变量的初始值处理是不同的。局部变量如果没有显式初始化,默认不会被赋值,这意味着,局部变量在定义后,如果没有赋初始值,它的内容是不可预测的,可能是随机的内存数据。而全局变量如果没有显式赋值,编译器会根据变量类型自动初始化默认值。

  • 对于整型变量,初始值为 0
  • 对于浮点型变量,初始值为 0.0
  • 对于指针类型,初始值为 nullptr
  • 对于布尔类型,初始值为 false

需要注意的是,在实际工程开发中,上面的建议并不适用。合理选择局部变量和全局变量可以提升代码的可维护性和效率。通常应尽量减少全局变量的使用,除非有明确的需求。

练习:求绝对值函数

输入一个整数 x,请你编写一个函数,输出 x 的绝对值。

输入描述

共一行,包含一个整数 xx,其中 100x100-100 \le x \le 100

输出描述

共一行,包含 xx 的绝对值。

输入样例

-8

输出样例

8

代码实现

#include <iostream>
using namespace std;
int another_abs(int x) {
if (x > 0) return x;
else return -x;
}
int main() {
int n;
cin >> n;
cout << another_abs(n) << endl;
return 0;
}

这里只是为了练习,我们自己去实现的如何求绝对的函数,实际上,C++ 中 <cmath> 头文件中已提供了用于求绝对值的函数 abs(),在实际应用中,建议直接使用内置的函数。

练习:累加求和函数

将我们前面学习过的累加求和的练习用函数封装起来。

#include <iostream>
using namespace std;
// 累加求和函数
int acc_sum(int n) {
int rlt = 0;
for (int i = 1; i <= n; i++) {
rlt = rlt + i;
}
return rlt;
}
int main() {
int n;
cin >> n;
cout << acc_sum(n) << endl;
return 0;
}

在主函数中,我们只需要调用刚刚写的累加求和函数 acc_sum() 就可以了,整个程序的逻辑就更加清楚了,这也是用函数封装逻辑带来的好处之一。

练习:质数判断1

题目描述

质数是指除了 1 和本身之外没有其他约数的数,如 7 和 11 都是质数,而 6 不是质数,因为 6 除了约数 1 和 6 之外还有约数 2 和 3 。

输入一个正整数,判断它是否为质数,如是质数则输出Y,不是质数则输出 N

输入描述

仅有一行包含一个正整数 nn,其中 0n1000 \le n \le 100

输出描述

根据题目要求输出 Y 或 N

输入输出样例

输入1

7

输出1

Y

输入2

8

输出2

N

题目分析

我们在循环章节已经讲过如何通过循环去判断一个数是否是质数,不知大家还记得不,当时我们为了实现如何保证 1 ~ nn 中除开 1 和本身之外的其它数都不能整除 nn,我们用了两种方法:

  1. 一种是使用 break,配合使用标志位。
  2. 一种是利用了 return 0 的特殊性质。

如果有了函数,其实可以变得更简洁一些。

参考代码

#include <bits/stdc++.h>
using namespace std;
// 定义判断质数的函数
bool is_prime(int x) {
if (x < 2) return false;
for (int i = 2; i < x; i++) {
if (x % i == 0) return false;
}
return true;
}
int main() {
int n;
cin >> n;
// 调用自定义的函数
if (is_prime(n)) cout << "Y";
else cout << "N";
return 0;
}

这里需要注意的是,我们在循环章节求质数时并没有考虑 n < 2 的情况,因为当时的题目指明是对于大于 1 的数来判断,而在本题中, 0n1000 \le n \le 100,需要考虑到 0 和 1 都不是质数的边界情况。

当然,作为判断一个数是否是质数的函数,也需要考虑到这样的边界情况,这样的函数也会更加健壮。

练习:质数判断2

题目描述

输入 n 个整数,判断这些整数是否是质数,如果是质数则显示为 1,否则的话显示为 0

输入描述

输入为两行。 第一行为一个整数 nn 。第二行为要判断的 nn 个整数。

输出描述

只有一行,依次输出要判断的 nn 个整数判断的结果,质数则为 1,否则为 0,中间用空格隔开。

输入输出样例

输入1

5
4 9 11 15 17

输出1

0 0 1 0 1

题目分析

这个题目是要判断多个数是否为质数,正好,我们在上个练习中刚刚写了一个判断一个整数是否是质数的函数: is_prime(),我们可以直接用这个函数就行了,差别仅仅在于我们需要多次调用这个函数来做判断。

代码实现

#include <iostream>
using namespace std;
const int N = 100;
int a[N];
// 与上一个练习的函数完全一致
bool is_prime(int x) {
if (x < 2) return false;
for (int i = 2; i < x; i++) {
if (x % i == 0) return false;
}
return true;
}
int main() {
int n;
cin >> n;
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = 0; i < n; i++) {
if (is_prime(a[i])) cout << 1 << " ";
else cout << 0 << " ";
}
return 0;
}

注:对于质数的判断,这里的代码只是其中最简单的一种实现,效率并不高,后续我们会有专门的主题来讨论质数求解问题的优化。

总结

总的来说,函数是 C++ 中非常重要的组成部分,合理使用函数,可以使代码更加模块化、易于理解和维护

接下来我们还会学习到 C++ 当中内置的许多函数,我们在编程时可以直接使用,可以极大的提高效率,另外,后面要重点学习算法内容,无论是递归算法还是搜索算法,也都依赖于函数。