《Beginning C++20 From Novice to Professional》第八章 Defining Functions

函数是语言的一个基本组成,也是软件复用思想的直接体现,使得我们省去大量时间避免进行重复工作

这章讲的主要有以下方面:

Segmenting Your Programs 程序分段

目前为止我们的所有代码都在main函数里直接呈现,因为程序不长没必要分段,当出现需要重复使用的一段代码时我们自然的想法就是包装起来以便多次使用

main函数也会调用一些其他函数和库函数,此时不同的函数信息将保存在调用栈里,进阶学到调试技巧的时候debugger会让我们看到函数的运行情况

Functions in Classes

类的成员函数我们在vector和string的部分其实已经使用过了,用对象加点操作符的形式进行调用,不过这里我们不会说类的内容,12章会详细介绍

Characteristics of a Function

函数一般是一段简短的功能单一的代码,不要太多行逻辑也不要太复杂,否则其实违背了复用的原则,会给后续维护带来很多麻烦

Defining Functions 函数定义

一般包括函数头和函数体两大部分,我们可以类比main函数来看

函数头包括了返回值类型、函数名、参数列表

函数体包括了内部逻辑和返回值

The Function Body

我们先来看个例子

power是用来计算x^n的函数,可以看到返回值是double,指数部分是int

这里power函数是调用了7次

不过虽然我们可以自己写,<cmath>里已经提供了power的很多版本,这种情况下我们最好是使用库函数,库函数由委员会讨论和验证,一般都比自己写的版本要好,比如这个power针对不同类型就有很多重载版本

Return Values

如果返回类型不是void,那么我们应该写一个返回语句

main函数是例外,不写返回值会返回0

实际上一个函数只能返回一个返回值这点是不是限制有过很多讨论,之前看过Go语言的就知道Go里的函数返回值可以有多个,Python也是;当然C/C++是采用了这种形式,后面也会讲我们有很多种方法使函数“返回”多个值

Function Declarations

这里提到函数声明,我的理解:这个声明的存在是给代码灵活性作保证的,如果没有声明那所有函数都得写在main函数前面以供编译器获取定义,但不是所有人都会这么写代码

拿前面的power函数举例,我们可以试一下函数定义在main函数后面会是什么效果

编译直接报错说power未声明

这下我们必须在使用的地方之前给一个函数原型的声明:

Function Prototypes

这三种写法都是可以的,但是相比于后两种,第一种看到原型就知道这个函数的作用,显然是更直观更推荐的写法,由于原型只保证声明,对参数名称不敏感,因此第三种写法不加参数名字也是可以编译通过的,但不是很推荐

Passing Arguments to a Function

函数传参的过程是很有必要理解的,这涉及我们使用函数的正确与否,此外这里面也有一些坑在

传参大致分为两类,一类是传值,一类是传引用

(不用问为什么传指针在这里没讲到,因为传指针也是传值的一种)

Pass-by-Value 传值

我们以一张图来说明传值的过程,按顺序传入两个参数后,函数内部会生成两个参数的复制体,或者叫副本,之后函数的所有操作都基于这两个副本,也就是说实际上函数的逻辑都运行在与实际参数无关的“形式参数”上

当函数运行结束后,这两个副本也被销毁,空间被回收,而一切与实际参数毫无关系,我们只是用到了它俩的值

传值的时候函数并不会改变实际参数,这也保证了一定的安全性,所以传值是函数默认的传参方式

当然我们也有很多方法改变实参,并且还是通过传值的方式:

Passing a Pointer to a Function 传指针

传指针还是复制实参的做法,只不过指针比较特殊,我们复制了地址就相当于复制了实参,因为解引用一下就是实参,而且没有任何包装,可以随意修改实参

可以看到即使还是复制了实参(地址),但是最重要的信息已经被复制过去了,那就是实参的地址

所以后面对这个地址的改变就是对实参的改变,只不过这个复制的副本会随着函数的结束而销毁,不影响我们已经对想要修改的地址进行了不可逆的操作

Passing an Array to a Function

由于数组的名字本来就是个地址,那我们直接传数组名字和传指针其实是一样的效果,这也就是所谓的数组退化为指针的意思

所以进入到函数之后,这个array就只是一个单纯的地址,是数组首元素的地址,我们不能对这个地址进行像size之类的操作,因为他已经不再是数组;换句话说,传数组名字也就把数组的长度信息给丢掉了

所以这两种写法其实没什么区别,使用原生数组就是会有这个问题,我们总需要一个count变量去传入需要处理的数据个数,因为数组长度进入函数后就被丢掉了(这也是原生数组的问题之一,所以我们推荐vector)

那又会产生另一个问题,我如果在参数里把长度写死,那函数不就知道要处理多长的数组了吗,这个时候还需要count吗,比如这样写:

但是这样写的效果是:只有长度为10的数组能够传入函数(这里的意思是能够被正确处理)

我们可以试试换一个长度:

还是那句话,数组名一旦作为参数传入数组,那它的长度信息就被丢掉了,这里的average10函数虽然参数写死了是10,但是不影响其他长度的数组传入,因为它的参数就是一个地址而已;而且其他长度的数组也不会被正确处理,总和依旧是除以10

C++如此规定,我们就不可以在数组参数里写入长度,因为没有任何意义,编译器也不会做任何处理

const Pointer Parameters

这里又提到const指针的问题,这么写是为了防止函数里对数组元素产生任何修改,毕竟我们用的是指针,很有可能对原地址进行修改

不过又有一个问题,我们为什么不对count进行const限制呢?因为它本身就是传值的形参,加不加const对原本的参数都不会有影响因此没有必要


多维数组传值我就不说了,有了vector之后用的极少

Pass-by-Reference 传引用

引用就可以当别名看,这样我们传入的参数该是什么样就是什么样,函数内部的一切改动和调用都会反映到实参上,不会发生复制的情况(实际底层还是个指针)

References vs. Pointers

先举一个简单例子

指针和引用传参最明显的区别就是使用指针的时候我们需要明确传进去的是一个地址,需要用取地址操作符,然后在函数内部我们需要解引用才可以操作对象;如果使用引用,我们就不需要这些操作符,对象该怎么使用就怎么用(不过对于编译器来说这些最后的结果是一样的)

还有一个区别就是引用必须初始化,不可以为空,而指针可以为nullptr;指针的这个特性也使我们必须检查指针是否为空。这是一个区分使用两者需要考虑的因素

语法写起来简单的同时,引用也有缺点,单看函数调用方式我们并不知道这是传值还是传引用,可能会导致我们对实参进行改变,所以书中建议我们对参数以及所有的对象都进行可能的const修饰,以免一些意外发生

总结一下有四点可以帮助我们判断应该用哪种类型来传参:

Input vs. Output Parameters

#include <iostream>
#include <format>
#include <vector>
using namespace std;

using std::string;
using std::vector;

void find_words(vector<string>& words, const string& str, const string& separators);

void list_words(const vector<string>& words);

int main() {
    std::string text; // The string to be searched
    std::cout << "Enter some text terminated by *:\n";
    std::getline(std::cin, text, '*');
    const std::string separators{" ,;:.\"!?'\n"}; // Word delimiters
    std::vector<std::string> words;               // Words found
    find_words(words, text, separators);
    list_words(words);
}

void find_words(vector<string>& words, const string& text, const string& separators) {
    size_t start{text.find_first_not_of(separators)}; // First word start index
    while (start != string::npos)                     // Find the words
    {
        size_t end = text.find_first_of(separators, start + 1); // Find end of word
        if (end == string::npos)                                // Found a separator?
            end = text.length();                                // No, so set to end of text
        words.push_back(text.substr(start, end - start));       // Store the word
        start = text.find_first_not_of(separators, end + 1);    // Find 1st character of next word
    }
}

void list_words(const vector<string>& words) {
    std::cout << "Your string contains the following " << words.size() << " words:\n";
    size_t count{}; // Number of outputted words
    for (const auto& word: words) {
        std::cout << std::format("{:>15}", word);
        if (!(++count % 5))
            std::cout << std::endl;
    }
    std::cout << std::endl;
}

像find_words这个函数的第一个参数,我们需要对其进行修改,一般也可以看作函数输出;第二个参数不修改,一般叫输入参数

我们尽量让参数都承担单一职责,要么输入要么输出,比较好理解

还有一个需要注意的地方就是const形参可以接收任意参数,但是非const形参不可以接收const参数,这是为了防止对const对象可能进行的修改

我们可以试一试把形参改为非const,看能不能传入分隔符这个const string

提示报错,说是将非const引用绑定到const

In short, a T value can be passed to both T& and const T& references, whereas a const T value can be passed only to a const T& reference. And this is only logical.

最后,作为输入参数的实参一般都适用const ref,除了一些特别小的数值类型复制成本不大,使用const引用语义也很明确

Passing Arrays by Reference

这里还是要回到上面的话题,既然数组传入函数是一个地址,虽然是传值但是已经可以修改数组内部的值,而且也不会有复制成本,因为只复制了一个地址,那为什么我们还要引用一个数组呢?

之前也讲过,数组名传值的缺点就是会丢掉第一个维度的长度,一旦传入数组名,这个长度信息就丢掉了,而且我们也没法获取,只能额外传一个count类参数来控制

那么引用数组名是什么效果呢?

当我们引用数组并且指定大小时,是可以写死长度的,不符合参数列表中大小的数组是不能传入函数的

但是引用数组名的写法套了一层括号,看起来有点复杂;如果我们把括号去掉会是什么效果

不允许我们有一个指针指向一个const引用,因为C++禁止数组存储引用这种类型,因此不可以这么写

更重要的是,此时数组名不再退化为指针,我们完全可以把数组名当成数组来使用了,size、range-for等等特性也都可以正常使用(这保证了引用的一致性,对于不同类型引用都是别名)

而且我们已经介绍过确定大小的数组,标准库帮我们实现了array<type,size>类模板

double average10(const std::array<double,10>& values)

这个时候我们用引用就很自然了,回到了const引用的合理性,不用复制且直接使用的方便又回来了~

第九章我们还会见到一个类型叫span,应该比数组传参更方便

References and Implicit Conversions

在值传递的时候,发生隐式的类型转换是无处不在的,这保证了类型的灵活性,比如一个double类型参数的函数,我们使用的时候传入整型也可以

但是在引用传参的时候,这种隐式转换却不是随时随地都自然发生的

参数不是const的时候使用引用且传入不匹配的类型,将会报错,但是const引用却可以发生隐式类型转换

所以print_it()这个函数怎么能够输出一个int呢?

So, how can this function be reading from an alias for a double if there is no such double defined anywhere in the program? The answer is that the compiler, before it calls print_it(), implicitly creates a temporary double value somewhere in memory, assigns it the converted
int value, and then passes a reference to this temporary memory location to print_it().

也就是说当我们使用const引用绑定一个不匹配的类型的时候,函数内部会创建一个临时对象,这个例子中就是一个临时的double,然后把传入的int转换为double赋值给临时对象,然后对这个临时对象进行绑定;这一系列过程之后我们的参数it绑定到了临时对象,然后输出了这个临时double的值

这种引用的隐式转换只适用于const引用参数~(因为按照上面的机制,非const这样做会导致精度损失)

Default Argument Values 

其实之前字符串那一章的字符串很多成员函数都是有默认参数的,比如find、substr这些,默认参数是应该提供的,很多时候一个函数重要的参数应该只有一两个,当我们需要其他输出的时候再传入参数应该是很直觉的做法

举个例子:

如果我不想指定输出发生了什么错误,那就使用默认方案就好了,什么参数也不传

当然这个函数对字符串的处理更好的做法应该是const引用

由于引用不可以绑定不同类型,所以只能是const引用来绑定字面量(之前讲过可以发生隐式转换),如果是普通引用是不能通过编译的

但是用string引用一个字面量还是有复制成本,因为你要创建一个临时string才可能调用string使用字面量初始化的那个版本的构造函数(涉及这一点我们第九章会讲到string参数的问题)

Multiple Default Parameter Values

当一个函数中有多个默认参数时,这些参数必须紧靠且位于参数列表靠右的位置

像这个函数有5个参数,默认参数只能从右往左指定,也就是说当count没有默认值的时候,data不允许有默认值,不可以跳过也不可以改变顺序(应该是编译的时候参数列表是从右往左处理的)

调用的时候也是一样的,不可以跳过前面的参数,从左到右的顺序需要我们遵守

Arguments to main()

说了很久函数的特性,我们应该来看看目前所有代码都涉及的main函数

不指定它的参数也行,说明我们用不到可执行文件执行时的参数;其实main函数可以获取程序运行时的参数选项,argc表示参数数量,后面char指针的数组则存储参数(以字符的形式)

Clion中可以在项目设置处修改程序运行参数

这里我给了两个数和一个字符串,并按照原样输出

Returning Values from a Function

返回基本类型的时候没什么好讲的,但是指针、引用倒是有些坑需要我们注意

Returning a Pointer

返回指针的时候我们要么返回空指针nullptr,要么返回一个调用处作用域存在的指针,不是这两种情况的话要么是UB要么会内存泄漏

我们考虑一种情况:

当我们这么写比较两个数大小并返回大数地址的函数时,我们没有考虑到函数的值传递参数会在函数结束后销毁这一点,导致调用这个函数的代码获取到了无效地址,这个返回值没有意义即使形式合法

这样写会好一点

永远不要返回一个自动生存期并且内存分配在栈上的局部变量的地址,这个变量也不要返回

Returning a Reference

指针虽然灵活,但是我们需要在使用前判断是否为空是否有效,而引用正好能解决指针可能为空的问题,但是引用函数内部新建的自动临时对象还是不可以被返回

假设我们写了一个返回较大字符串的函数,返回值是一个非const引用,那么我们其实是在允许通过函数返回值作左值的方式来修改对象,比如main函数第三行我们修改了较大的字符串

实际上这也是唯一的,把函数返回值放在=左边的方式;因为如果是const引用那不就不允许修改了么

对于字符串来说,非const引用不能绑定字面量,所以这个函数的参数也不能是C串,返回值也是;但是用了const引用作参数的话,那返回值就不能是非const,因为非const也绑定不了const对象,这两点使我们设计字符串的函数时有了更多的考量

Returning vs. Output Parameters

前面说过返回值只有一个的限制,引用和指针这种间接类型使得我们可以通过输出参数来返回多个值,也就是让函数有多个输出结果

像上面这两种写法就是典型的输出参数和返回多个值的接口设计区别

书上说现代C++是推荐后面的写法的,因为这样的函数可读性会更好,函数原型和调用会更易懂

不过包含数组的时候,使用输出参数是更好的选择

Return Type Deduction

就像初始化变量时使用的auto一样,我们也可以让编译器推导返回值类型

但是这么短的类型其实没必要用auto,像迭代器和复杂类型用auto是更好的

还有一种情景是函数模板中用auto比较方便,这一点第十章会讲到

出现多处return时只要类型一样编译器也是可以推导的


不过返回值是引用的时候我们要小心一点使用这个推导特性,因为auto引用需要明确&引用符号

如果这么写那返回类型会被推导为string而非string&,这是由auto这个关键字决定的

必须写成auto&,auto推导的永远是值类型

To have the compiler deduce a reference type, you can use auto& or const auto&.

const这个属性也不会被推导出来,需要const必须手写出来

Static Variables

假设我们想统计一个函数运行了多少次,我们可能的方案是在函数外作用域定义一个全局变量来统计,但是这个值可能被其它代码使用或更改

更好的方式是在函数内部定义一个static变量,这样这个变量会被维护到程序结束(见之前的第三章讲过变量生存期)《Beginning C++20 From Novice to Professional》第三章Working with Fundamental Data Types-****博客

Function Overloading 函数重载

有的时候我们针对不同类型有相同的操作,就像之前讲过的<cmath>中的power函数,对不同整型和浮点型都有定义,但是这个系列的函数都使用了一个名字;这种技术叫做函数重载

编译器靠函数名和参数列表来区分函数,因此相同的函数名要区分版本的话必须拥有不同的参数列表

Two functions with the same name are different if at least one of the
following is true:
• The functions have different numbers of parameters.
• At least one pair of corresponding parameters is of different types.

要么参数数量不同,要么至少一对参数类型不同;这两者是编译重载函数的必要条件

有这么四个版本的重载函数,最后一个注释里的编译无法通过,因为和第四个版本函数signature没有区别

但是这样还是显得很复杂,四个函数实现了一个功能但是仅仅是针对不同类型就要分开写,有没有什么办法能让这个函数更通用,让我们省去一点写冗余代码的时间呢?第十章会讲到函数模板,就是为了解决这个问题的

Overloading and Pointer Parameters

注意数组会“退化成”指针,所以

1和3两个版本的参数其实是一种类型,不可以这样重载

Overloading and Reference Parameters

引用是最复杂的,涉及重载参数的时候;编译器不能区分值类型和普通引用类型,因此不允许重载像下面这样的函数

看larger函数的第二次调用,即使我们把参数转换成了long类型,重载版本还是选择了double类型,这是什么导致的呢?

The arguments are not a_int and b_int but temporary locations that contain the same values after conversion to type long.

这里的解释是:参数不是两个int,而是我们进行类型转换后的临时long变量,并且普通引用不能引用一个临时变量,一个临时的地址不能用来初始化非const引用

也就是说无论我们转不转换类型,对于一个int来说,传入long&参数都会生成临时对象,因此普通引用不接收这样的参数,不过我们可以使用const引用试试:

这样的话我们就可以传入不完全符合参数列表类型的参数了,因为我们不会修改参数,所以我们允许这样的引用绑定

Overloading and const Parameters

A const parameter is only distinguished from a non-const parameter for references and pointers.

说完引用参数,再来看看联系紧密的const参数;这里说到,只有指针和引用的const会作区分,普通类型用不用const对参数列表没有影响,也就是说下面的两个版本不构成重载:

 

因为传值总会复制一遍参数,所以加不加const实际上没有作用;不过函数实现里用const是一种“态度”~,保证函数体内不修改参数

Overloading with const Pointer Parameters

前面说过,对于指针来说,有无const是区分函数签名的依据,因为它们对于参数的处理不同

原型是好理解的,那么调用的时候会使用哪个版本呢?

为什么这里会采用const版本呢,因为我们的参数是两个const long变量,如果传入了非const引用版本,那么相当于允许对a,b两个参数进行修改,而参数列表这一点和函数外的两个num的const属性矛盾,因此不允许非const绑定或指向const对象

同时,这两个版本也可以构成重载,因为虽然都是指针const,但是对于参数可否修改还是不一样的处理(指针const可以参看第二章 变量和基本类型-****博客这篇《Primer》的文章)

Overloading and Reference-to-const Parameters

引用与指针类似,看上面一小节就可以了

Overloading and Default Argument Values

这里要强调的是默认参数有时会产生重载决议的二义性,导致无法区分哪个版本的函数应该被调用

举了个例子:如果这两个函数都有默认参数,那么show_error()将无法通过编译

Recursion 递归

函数体内调用自己的行为叫做递归调用,来看一个简单例子:

计算n的阶乘,我们不仅可以使用循环,还可以通过递归的方式进行编写,看起来简洁一些,但是函数递归有个最大的问题就是函数调用栈成本比较大,尤其是递归次数比较多的时候

当然这个例子本身也有可改进的空间,n如果是0或者负数时的情况没有考虑

这样判断一下n的范围再进行计算比较好(一般函数都要进行边界处理,时刻谨记这一点!)


至于书上说的一些递归算法的例子,学习算法的时候可以了解,这里不再赘述

上一篇:CMake:静态库链接其他动态库或静态库(九)


下一篇:使用Axios从前端上传文件并且下载后端返回的文件