这次让我们来聊聊函数。当你的程序越写越长,不可能把所有代码都放在 main 里面,否则找个代码翻好几页是迟早的事情。函数允许我们一次只做一件事,把不同功能分离开来,放在一个或多个文件中。

函数

写一个函数,最重要的是三点:函数体(操作),函数参数(输入),函数返回值(输出)。我们先从大家都熟悉的 main 函数入手(是的,它也是个函数):

1
2
3
4
5
int main()
{
cout << "Hello world!" << endl;
return 0;
}

先看第一行:int 放在最开头,表示返回值是 int,然后紧接着的是函数名称 main,后面是一个括号表示参数(默认的 main 并没有参数,所以括号里面是空的)。

2、5 行有一堆大括号,它们就是我们所说的。函数的函数体通常是一个块,允许我们写多个由分号分隔的语句。

然后让我们进到块里面,return 0; 这一句,表示执行至此时,立即返回 0。因为这是主函数 main,所以我们返回 0,表示的是程序已正常运行完成并退出(返回 0 表示正常,这是一个操作系统的规定,先别问为什么)。

好了,你现在对函数应该有一个基本的了解了,我们可以开始编写自己的函数,然后调用了。

编写并调用函数

1
2
3
4
5
6
int addAB(int a,int b){
return a+b;
}
int main(){
cout<<addAB(1,2)<<endl; // 3
}

上面是一个非常简单的示例,演示了函数的基本使用方法。

我们刚才说了函数的三个重要组成部分,我们先来看看 1-3 行的函数本身:

  • 我们定义了一个 int 返回值的函数,名字叫 addAB
  • 函数有两个参数,都是 int 类型,名字叫 a 和 b
  • 我们在其中执行加法操作,并返回加法的结果

现在再来看看 main 函数,我们在其中直接写了 addAB(1,2)。这句语句的意思是,先中断 main,然后向 addAB 传入 1 和 2,分别作为 a 和 b 的值,再执行函数体内的语句,等待执行后返回,将返回的值,作为结果。

因此,输出是 3。

声明和定义

你或许注意到了,在上面的代码中,我们把 main 放在了 addAB 的下方。这个顺序是非常重要的,因为编译器会从头开始编译,如果你把 main 放在上面,那么会由于找不到 addAB 而报错。

现在,我们稍微改一改代码:

1
2
3
4
5
6
7
int addAB(int a,int b); // 声明
int main(){
cout<<addAB(1,2)<<endl;
}
int addAB(int a,int b){ // 定义
return a+b;
}

你先自己试试,然后猜一下:为什么这么写也可以通过编译?

这就要提到声明定义的区别了。

先顾名思义,声明,告诉你我在这里;定义,告诉你我具体是怎样的人。函数中也是一样:

声明,告诉编译器有叫做我这个名字的函数;定义,告诉编译器我这个函数具体做什么

也就是说,第一行的声明,只是告诉你有这么一个函数,返回值 int,名字叫 addAB;而函数体由分号替代,把定义的权力让出来,可以在后面再写函数体。

小提示:声明的参数列表是可以省略的,但是建议你写一下(并确保和定义一致),这样可以让阅读代码的程序员更好理解函数的功能。

把声明放在上面,main 就能够找到已经声明的那个函数了。只要在任意位置定义一下,即可被找到。

或许有人会觉得没用,但是你想想,如果我把所有的函数声明写在一个头文件里面(对应的定义需要 #include 这个头文件),那么,只要在 main 前面引用这个头文件,就可以随意使用那些相关的函数,而不用去一个个引用源代码文件了。

函数的参数传递

现在我们来仔细聊聊参数问题。

我们刚才一直在说,把参数传入,但这到底是怎么传递的呢?要解决这个问题,我们得先了解形参实参的概念。

形参和实参

什么是形参、实参?形参,即形式上的参数;而实参,则是实际的、真正的参数。

在我们刚才的代码中:

1
2
3
4
5
6
int addAB(int a,int b){
return a+b;
}
int main(){
cout<<addAB(1,2)<<endl; // 3
}

1,2 两个数,是实参,而 a,b 则是形参。当调用这个函数时,对应的实参将会用于初始化对应的形参。在这时,无形化为有形,形参变量有了新的值(在这个例子中,是 1 2)。

也就是说,有以下等价代码:

1
2
a = 1;
b = 2;

引用传递和值传递

那么问题来了,如果参数不是普通的类型,而是引用指针呢?

其实我们根本不用过于纠结。这是因为,我们可以直接写出等价的初始化语句,来判断到底是如何传递的,来看看下面的例子:

1
2
3
4
5
6
7
8
9
int addAB(int &a, int &b) {
++a;
return a + b;
}
int main() {
int c = 1, d = 2;
cout << addAB(c, d) << endl; // 4
cout << c << ' ' << d << endl; // 2 2
}

当传入引用时,相当于有这样的等价初始化:

1
2
int &a = c;
int &b = d;

也就是说,a b 是 c d 的别名(引用),改了 a b 相当于改了 c d。

我们把这种传递引用的过程,叫做引用传递。而不传递引用,传递值的过程,叫做值传递。现在你可以想一想,如果传递一个指针,属于哪一种呢?

想都不用想,肯定是值传递啊。指针也是一个对象,存储了一个对象的地址。因此,相当于传递了地址这个值。

1
2
3
4
5
6
7
8
9
10
11
int addAB(int *a, int *b) {
++*a;
return *a + *b;
}
int main() {
int c = 1, d = 2;
int *e = &c;
int *f = &d;
cout << addAB(e, f) << endl;
cout << c << ' ' << d << endl;
} // 输出和上面的引用那一部分,完全一致

如果你还是不理解,不妨也写出等价初始化:

1
2
3
int *a = e;
int *b = f;
// 拿一个指针初始化另外一个

const 类型

我们在const 限定符与指针一节中,已经探讨了存在顶层和底层 const 时候,初始化指针和变量的规则。根据上面的讨论,你可以直接写出存在 const 时候的等价初始化,并利用规则,此处不再赘述。

小提示:当函数内部不应该改变形参的值,建议把参数定义为 const 类型。这样,无论传入的是否为常量,都可以防止意外修改。这在参数为引用时非常需要注意,下方解释了原因。

我唯一要强调的一点,是常量引用的初始化。你应该还记得,我们说过,当初始化常量引用的时候,初始化值可以不是变量,而可以是字面量(右值),允许违反引用的特性。

这一点会为函数产生很多好处:有时候,我们只是需要在代码里面控制一些参数的值,没必要使用变量;而有时候,我们想要避免多次拷贝值,还要避免意外的修改。

因此,常量引用为我们提供了一个完美的解决方案——

1
2
3
4
5
6
7
8
9
int addAB(const int &a, const int &b) {
return a + b;
}
int main() {
int c = 1, d = 2;
cout << addAB(c, d) << endl;
cout << addAB(c, 2) << endl;
cout << addAB(1, 2) << endl;
}

用变量?用常量?用临时量?全都没问题,还能节省拷贝开销,同时防止不小心修改。

小提示:等价初始化是任何时候都有效的。如果你没明白,自己写一下上面的三条调用语句的等价初始化,相信你很快就会明白了。

数组传递

我们已经提到过,数组特定情况下会自动退化为指针。也就是说,如果参数是一个数组,传入的实际是指针。

要补充强调的事情是,退化为指针意味着丢失了除了在内存中位置以外的信息。也就是说,我们如果把数组作为参数传递(它变成了指向首个元素的指针,已经不再是数组了),是没法知道它的长度的,也不能用 begin() 和 end()。

小提示:搞清楚指向第一个元素的指针指向数组的指针。前者是退化而来的,你没法再获取数组的信息;后者可以直接解引用获得数组这个对象本身,得到的解引用对象也就包含了它原来的所有属性。

正因如此,形参中数组的长度并不重要,可以省略不写:

1
2
3
4
5
6
7
8
9
10
11
int addA(const int a[]) {
int sum = 0;
for(int i = 0; i < 2; i++) {
sum += a[i];
}
return sum;
}
int main() {
int a[2] = {1, 2};
cout << addA(a) << endl;
}

提示:如果你传入的数组长度不确定,可以加一个参数表示长度。或者直接传入首元素和尾部元素后一个位置的指针。

另外一个容易困惑的点是下标运算符,为什么退化后还可以正常使用?实际上,下标运算符只是指针运算的一种等价形式而已。

1
2
a[1]=1;
*(a+1)=1;

a 是一个数组,上面两条语句是等价的!

main 函数参数

好好好,在深入了解完传参的机制之后,是时候回来看看 main 函数了。我们刚才提到它默认没有参数,但实际上,它可以加入参数,表示从命令行接受的参数。

1
2
3
4
5
6
7
8
int main(int argc, char* argv[]) {
if (argc >= 3) { // 防止参数不够
std::string s = argv[1];
s += argv[2];
cout << s << endl;
}
}

仔细看看上面的代码。argc 表示,传入的参数有几个,而 char *argv[] 表示 指向 传入参数的 C 风格字符串首个元素指针列表

绕晕了都。

先说说 C 风格字符串,以前,在没有 string 标准库的时候,程序员们都用数组来存储字符串。它是一个 char 数组,数组中每个元素都是字符串的一个字符。数组长度不知道,因此规定,从数组开头元素开始向后,当遇到 ‘\0’ 这个结束符的时候,表示上一个元素就是字符串的结尾,结束符后面没东西了。

不过新编写的程序应尽量避免使用 C 风格字符串,因为 string 更安全快捷,C 风格字符串只是一种兼容性的妥协。

现在来让 main 参数说人话:

  1. 传入多个参数,每个都变成了 C 风格字符串,
  2. 取得指向每个字符串(char 数组)的首个元素的指针,
  3. 把这堆指针放到另外一个数组里面。

因此,上面的代码的意思是,先取得第一个和第二个参数,然后把它们转换成 string 连起来。

要注意的是,参数从 argv[1] 开始。argv[0] 是程序的名字。同时,我们不能指望指针指向的字符串能被修改、延长,因为它们是 C 风格字符串。

可变参数长度的初始化列表

C++ 中还有一个很有意思的东西叫做 initializer_list<T>,允许我们传入不定长的参数,这些参数是同一个类型(写死在 <T> 中),但是数量不定。

看看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
int getSum(initializer_list<int> numbers) {
int sum = 0;
for(auto i = numbers.begin(); i != numbers.end(); i++) {
sum += *i;
}
return sum;
}
int main() {
cout << getSum({1, 2, 3, 4, 5, 6, 7, 8, 90, 0}) << endl;
return 0;
}

这段代码对整个参数列表求和。initializer_list 有 begin() 和 end(),允许我们获取指向首个参数和尾部参数下一个位置的迭代器。而传入参数时,传入的是一个列表,所以需要用大括号包裹。

好了,我们已经涉及了相当多的、深入的函数知识了,下一篇,我们将继续探索函数,了解函数的返回值,以及重载究竟是什么,依旧是最简单生动易懂的语言。