上一篇,我们讲解了函数的基本知识以及参数传递的方法和实质,我们今天来聊聊返回值以及作用域、生命周期。

函数返回值

我们上一篇的例子中,函数的返回值都是 int 类型的。实际上,你可以拿多样的类型作为返回值。我们先从最简单也最特殊的一种开始——无返回值。

void

void,英文里面是的意思。返回值为空,也就是什么也不返回。如果你的函数只是执行一些操作,不需要把结果给到调用它的函数,那么选择这个类型是非常合理的。比如下面的函数,会直接输出一个 vector:

1
2
3
4
5
6
7
8
9
void printVector(vector<int> v){
for(const int &i:v){
cout<<i<<endl;
}
}
int main() {
vector<int> v={1,2,3,4};
printVector(v);
}

当然,你也可以提前返回,此时我们直接写 return; 即可:

1
2
3
4
5
6
7
8
9
10
11
12
void printVector(vector<int> v){
if(v.size()==0) return;
for(const int &i:v){
cout<<i<<endl;
}
}
int main() {
vector<int> v={1,2,3,4};
vector<int> v2={};
printVector(v);
printVector(v2);
}

完美!关于空返回值你已经完全学会了。

有返回值

刚才是没有返回值,现在我们来看看有返回值的情况。注意了,我们这里说的返回值,可以是一个对象,也可以是一个引用,但不能是一些奇怪的类型(数组或者函数本身)。

关于怎么迂回地返回数组,我们将在下方讨论。先来看看正常一点的情况,和参数一样,我们得知道——值到底是怎么返回的?

……这问题实际上一句话就能总结,和参数一模一样的传递方式,也就是将 return 后面的值,用于初始化 一个 返回到的点的 临时量

说人话:

  1. 在返回到的那个地方,创建一个临时量
  2. 把 return 后面的内容赋值给它

不过和传递参数不同,由于这是返回值,我们还要加一个问题:返回的(那个创建的临时量)是右值(临时量),还是左值(具有确定位置的对象)?

我们分开来讨论值和引用,仔细聊聊其中的原理。

首先是非引用类型的返回值,此时函数返回值是一个临时的右值

1
2
3
4
5
6
int doubleInt(int a){
return 2*a;
}
int main() {
cout<<doubleInt(1)<<endl;
}

奶奶都知道结果是 2,这个函数接受 a,然后返回其两倍。此时 cout 接收一个右值并输出。

注意,因为是非引用类型,也就是个具体对象,所以也包括指针。同时,const 也是适用的,这里就不啰嗦了,同样的说太多遍也没意思。

当结果是引用的时候,就不一样了。当你返回一个引用,那么就说明你的对象具有确定的内存位置(我们之前提到过,引用就像贴标签,本身并不是对象,只是给其它对象起了个别名),因此,此时返回的是左值。也就是说,你可以给返回值赋值:

1
2
3
4
5
6
7
8
9
int &getDouble(int &i){
i*=2;
return i;
}
int main(){
int m = 2;
getDouble(m) = 5;
cout<<m<<endl;
}

虽然上面的代码看起来像大脑发育不完全写出来的东西,不过它确实很好演示了返回值是左值的状况。

另外,得提醒一点:不要返回函数内部创建的对象(称为局部对象)的引用或指针。这是因为,函数调用完成后,内部的对象会被释放。这会导致引用或指针失效。具体的详细解释在下面。

返回数组

在 C++ 中,无法将数组作为返回值(数组没办法直接赋值给临时量),所以我们得选个其它方法返回。

在上一节中,我们强调了退化为指向首个元素的指针指向整个数组的指针的区别。而对于返回值来说,我们显然需要的是后者——只要解引用,就能直接取得数组对象。

先来看看我们一般是怎么创建指向数组的指针的,比较一下下面的代码:

1
2
3
int a[10];
int *p[10]; // 一个含有 10 个元素的数组,存储的元素是指向 int 的指针
int (*p)[10] = &a; // 指向含有 10 个 int 元素数组的指针

这是因为,创建变量时,[] 优先级更高,从变量开始往两侧读,优先级高的那一边,就是实际的数据类型。加上括号,就让 * 优先结合,因此结果是指针。

也就是说,第二行创建的是数组,第三行才是正确的指针。我们类比过来,当作为函数的返回值时,可以这么写:

1
2
3
int (*getA())[10]{
...
}

当然,引用也是支持的:

1
2
3
int (&getA())[10]{
...
}

作用域和生命周期

你可能已经注意到了,在函数中,有些时候变量可以用,有些则不能用或产生了意外的结果。要想搞清楚这个问题,我们必须了解两个概念:名字的作用域对象的生命周期

名字的作用域

我们一直在给对象起名字。但是,名字一般只在程序的一部分中具有意义。让名字具有意义的地方,就叫做作用域(起作用的地方)。

在 C++ 中,名字的作用域,一般是一对花括号,也就是(或者整个程序)。一旦在作用域中声明(不是定义)一个名字,那么它就在该声明语句作用域末尾有效。

我之所以强调名字,是因为这个声明可以是变量,也可以是函数,或者任何有名字的东西。C++ 都是通过名字来查找对应内容的。

这么说起来有点抽象,来看具体例子。我们把上次讲声明和定义的例子改一改:

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

刚才提到,整个程序首先是一个作用域,这叫做全局作用域。观察没有包裹在花括号中的声明,可见 addAB 和 main 都属于全局作用域。

这也就解释了我们上一节的内容:只要头文件里面声明,源代码中定义,那么就属于全局作用域,一旦 #include 头文件,就能直接使用。

而再看中的内容。首先是 c,它在一个块中,这叫做块作用域。我们说过,名字的有效区从声明到作用域末尾,因此,c 仅限在 main 定义之后的区域中使用。

我们之前早已遇到过块的嵌套,比如 whilefor 循环。它们也是一个块,因此也是独立的块作用域,所以在其中声明的名字无法在作用域外被找到。

但有个比较棘手的问题就是,如果内层作用域声明了与外层作用域相同的名字,会发生什么?(是的,C++ 允许你这么做)

我们很容易得出,外部作用域内、内部作用域外的地方,只可能查找到外部作用域的名字。

但内部作用域内呢?比如下面的代码:

1
2
3
4
5
int a = 1;
int main() {
int a = 3;
cout << a << endl;
}

我们刚才说了,名字的有效区域是它的声明到作用域末尾。外部的全局作用域中的 a 有效区域是第一行到末尾,内部的 a 有效区域是第三行到第五行的花括号。因此,第四行两个 a 都有效。

很简单:内层隐藏外层。查找对应名字优先查找内层有效区域,再查找外层有效区域。

但有一点需要注意,不能在同一个定义域内,声明一个名字两次:

1
2
3
4
5
int main() {
int a = 3;
cout << a << endl;
int a = 5; // Error
}

对象的生命周期

刚才讲解的是名字本身,我们现在来看看名字对应的对象(既然是对象,那么不包含函数。只有对象有生命周期)。

对于一个有名字的对象,我们将创建对象的过程,叫做定义。有创建,就有销毁——对象存活的全过程,叫做对象的生命周期

当我们在函数体外部(类似于全局作用域区域)中定义对象时,这些对象将从程序启动开始存在,直到程序结束才被销毁。也就是说,这些对象的创建将在 main 函数前执行。

小提示:分清楚作用域和生命周期的区别。对象被创建了,不等于一定能够根据名字访问它。后者是由作用域决定的。比如,我们在 main 函数后面定义一个变量,这个对象确实在启动时创建了,但是 main 函数内部并不是名字的有效区域。因此,我们无法通过名字找到它。

1
2
3
4
int main() {
cout << a << endl; // Error
}
int a = 1;

而当我们在函数体内部定义对象时,对象将在它所在的内层的末尾被销毁。

1
2
3
4
5
6
int main() {
int a =1;
if(a==1){
int b=2;
}
}

a 在离开 main 时销毁,b 在 if 块末尾销毁。

既然作用域已经决定了我们能用哪些名字,为什么还要强调生命周期呢?

还记得吗?返回值那里我们强调了:

不要返回函数内部创建的对象(称为局部对象)的引用或指针。

这和生命周期(而并非是和名字有关的作用域)有关系。我们知道,函数体内的对象在块结束时销毁。当你返回了,那么局部对象一定已经被销毁了,此时你的引用或指针就无效了。

静态对象

你或许早有预感:我们是不是要来点特例了?

没错,静态对象就是这样的特例,它们在函数体内定义,在变量类型前加上 static,但生命周期是从定义之后,到程序结束。比如,下面的代码会输出之前已经调用这个函数的次数:

1
2
3
4
5
6
7
8
9
10
void printCnt(){
static int cnt =0;
cout<<cnt<<endl;
++cnt;
}
int main() {
for(int i=1;i<=100;i++){
printCnt();
}
}

当然,虽然生命周期改变了,但是对应名字的有效区域还是一样的:从声明开始,到作用域末尾。

我们已经对函数有了相当程度的认知,了解了返回值和作用域,以及对象生命周期。下一篇,我们将看看什么是函数重载,它让我们能够轻松编写函数。