Skip to content

Latest commit

 

History

History
367 lines (216 loc) · 22.4 KB

chapter07.md

File metadata and controls

367 lines (216 loc) · 22.4 KB

第七章 函数——C++的编程模块

本周内容包括:

  • 函数基本知识;
  • 函数原型;
  • 按值传递函数参数;
  • 设计处理数据的函数;
  • 使用 const 指针参数;
  • 设计处理文本字符串的函数;
  • 设计处理结构的函数;
  • 设计处理 string 对象的函数;
  • 调用自身的函数(递归);
  • 指向函数的指数。

7.1 函数基本知识

自定义函数三要素:

  • 函数定义;
  • 函数原型;
  • 调用函数。

7.1.1 定义函数

函数有两类:没有返回值的函数和有返回值的函数。

函数定义通用格式:

void functionName(parameterList) {
    statement(s);
    return;
}

C++ 对于返回值的类型有一定的限制:不能是数组,但可以是其他任何类型——整数、浮点数、指针,甚至可以是结构体和对象。

C++ 函数返回值的原理是什么? 首先,函数将返回值复制到指定的CPU寄存器或内存单元中将值返回;随后,调用程序将查看该内存单元。返回函数和调用函数必须就该内存单元中存储的数据的类型达成一致。函数原型将返回值类型告知调用程序,而函数定义命令被调用函数应返回什么类型的数据。

image-20210804133448677

7.1.2 函数原型和函数调用

  1. 为什么需要函数原型

原型描述了函数到编译器的接口,也就是说,它将函数返回值的类型(如果有的话)以及参数的类型和数量告诉编译器。举个例子:

double volume = cube(side);

首先,原型告诉编译器,cube() 有一个 double 参数。如果程序没有 提供这样的参数,原型将让编译器能够捕获这种错误。其次,cube() 函数完成计算后,将把返回值放置在指定的位置——可能是 CPU 寄存器, 也可能是内存中。然后调用函数(这里为 main())将从这个位置取得返回值。由于原型指出了 cube() 的类型为 double,因此编译器知道应检索多少个字节以及如何解释它们。如果没有这些信息,编译器将只能进行猜测,而编译器是不会这样做的。

为何编译器需要原型,它就不能在文件中进一步查找,以了解函数是如何定义的吗?这种方法的一个问题是效率不高。编译器在搜索文件的剩余部分时必须停止对 main() 的编译。一个更严重的问题是,函数甚至可能并不在文件中。C++ 允许将一个程序放 在多个文件中,单独编译这些文件,然后再将它们组合起来。在这种情况下,编译器在编译 main() 时,可能无权访问函数代码。如果函数位于库中,情况也将如此。避免使用函数原型的唯一方法是,在首次使用函数之前定义它,但这并不总是可行的。

  1. 原型的语法

函数原型是一条语句,因此必须以分号结束。获得原型最简单的方 法是,复制函数定义中的函数头,并添加分号。 函数原型不要求提供变量名,有类型列表就足够了。通常,在原型的参数列表中,可以包括变量名,也可以不包括。原 型中的变量名相当于占位符,因此不必与函数定义中的变量名相同。

  1. 原型的功能

它们可以极大地降低程序出错的几率。具体来说,原型 确保以下几点:

  • 编译器正确处理函数返回值;
  • 编译器检查使用的参数数目是否正确;
  • 编译器检查使用的参数类型是否正确,如果不正确,则转换为正确的类型。

image-20210804144730761

仅当有意义时,原型化才会导致类型转换。例如,原型不会将整数转换为结构或指针。

在编译阶段进行的原型化被称为静态类型检查(static type checking)。

7.2 函数参数和按值传递

C++通常按值传递参数,这意味着将 数值参数传递给函数,而后者将其赋给一个新的变量。

double volume = cube(side);

image-20210804145401521

side 是一个变量,被调用时,该函数将创建一个新的名为x的double变量,并将其初 始化为5。这样,cube( )执行的操作将不会影响main( )中的数据,因为 cube( )使用的是side的副本,而不是原来的数据。

在函数中声明的变量(包括参数)是该函数私有的。在函数被调用 时,计算机将为这些变量分配内存;在函数结束时,计算机将释放这些 变量使用的内存。这样的变量被称为局部变量,因为它们被限制在函数中。前面提到过,这样做有助于确保数据的完整性。这还意 味着,如果在main( )中声明了一个名为x的变量,同时在另一个函数中 也声明了一个名为x的变量,则它们将是两个完全不同的、毫无关系的变量。

image-20210804145526933

7.2.1 多个参数

函数可以有多个参数。在调用函数时,只需使用逗号将这些参数分开即可。

image-20210804150422862

它使用cin>>ch,而不是cin.get(ch)ch = cin.get()来读取一个字符。这样做是有原因的。前面讲过,这两个cin.get() 函数读取所有的输入字符,包括空格和换行符,而 cin>> 跳过空格和换行符。当用户对程序提示作出响应时,必须在每行的最后按 Enter 键,以生成换行符。cin>>ch 方法可以轻松地跳过这些换行符,但当输入的下一个字符为数字时,cin.get() 将读取后面的换行符,虽然可以通过编程来避开这种麻烦,但比较简便的方法是像该程序那样使用 cin

7.3 函数和数

需要将数组名作为参数传递给它,为使函数通用,而不限于特定长度的数组,还需要传递数组长度。

int sum_arr(int arr[], int n);

方括号指出 arr 是一个数组,而方括号为空则表明,可以将任何长度的数组传递给该函数。但实际情况并非如此:arr实际上并不是数组,而是一个指针,好消息是,在编写函数的其余部分时,可以将 arr 看作是数组。

7.3.1 函数如何使用指针来处理数组

在大多数情况下,C++和C语言一样,也将数组名视为指针。C++将数组名解释为其第一个元素的地址:

cookies == &cookies[0];   // array name is the address of first element

首先,数组声明使用数组名来标记存储位置; 其次,对数组名使用sizeof将得到整个数组的长度(以字节为单位); 第三,正如第4章指出的,将地址运算符&用于数组名时,将返回整个数组的地址。

image-20210804152348446

当且仅当在函数头中或者函数原型中,int *arrint arr[] 的含义是相同的。它们都指 arr 是一个 int 指针。

对于数组,以下两个语句是恒等的:

arr[i] == *(arr+i);  // values in two notations
&arr[i] == arr + i;  // addresses in two notations

7.3.2 将数组作为参数意味着什么

传递常规变量时,函数将使用该变量的拷贝;但传递数组时,函数将使用原来的数组(传地址)。实际上,这种区别并不违反C++按值传递的方法,sum_arr() 函数仍传递了一个值,这个值被赋给 一个新变量,但这个值是一个地址,而不是数组的内容。

image-20210804154353723

将数组地址作为参数可以节省复制整个数组所需的时间和内存。如果数组很大,则使用拷贝的系统开销将非常大;程序不仅需要更多的计算机内存,还需要花费时间来复制大块的数据。但另一方面,使用原始数据增加了破坏数据的风险

sum_arr(cookies+4, 4)sum_arr(&cookies[4], 4) 是等效的。

image-20210804155245012

7.3.3 更多数组函数示

1.填充数组

2.显示数组及用const保护数组

创建显示数组内容的函数很简单。只需将数组名和填充的元素数目传递给函数,然后该函数使用循环来显示每个元素。然而,还有另一个问题——确保显示函数不修改原始数组。除非函数的目的就是修改传递给它的数据,否则应避免发生这种情况。使用普通参数时,这种保护将自动实现,这是由于C++按值传递数据,函数使用数据的副本。然而,接受数组名的函数将使用原始数据。为防止函数无意中修改数组的内容,可在声明形参时 使用关键字const

void show_array(const double arr[], int n);

show_array() 将数组视为只读数据。

3.修改数组

在这个例子中,对数组进行的第三项操作是将每个元素与同一个重新评估因子相乘。需要给函数传递3个参数:因子、数组和元素数目。 该函数不需要返回值,因此其代码如下:

void revalue(double r, double arr[], int n) {
    for (int i=0; i < n; ++i) {
        arr[i] *= r;
    }
}

由于这个函数将修改数组的值,因此在声明 arr 时,不能使用 const

4.将上述代码组合起来

前面已经讨论了与该示例相关的重要编程细节,因此这里回顾一下 整个过程。我们首先考虑的是通过数据类型和设计适当的函数来处理数据,然后将这些函数组合成一个程序。有时也称为自下而上的程序设计 (bottom-up programming),因为设计过程从组件到整体进行。这种方 法非常适合于OOP——它首先强调的是数据表示和操纵。而传统的过程性编程倾向于从上而下的程序设计(top-down programming),首先指定模块化设计方案,然后再研究细节。这两种方法都很有用,最终的产品都是模块化程序。

6.数组处理函数的常用编写方式

假设要编写一个处理double数组的函数。如果该函数要修改数组, 其原型可能类似于下面这样:

void f_modfiy(double arr[], int n);

如果函数不修改数组,其原型可能类似于下面这样:

void _f_no_change(const double arr[], int n);

7.3.4 使用数组区间的函数

对于处理数组的C++函数,必须将数组中的数据种类、数组的起始位置和数组中元素数量提交给它;传统的C/C++方法是,将指向数组起始处的指针作为一个参数,将数组长度作为第二个参数(指针指出数组的位置和数据类型),这样便给函数提供找到所有数据所需的信息。

还有另一种给函数提供所需信息的方法,即指定元素区间 (range),这可以通过传递两个指针来完成:一个指针标识数组的开头,另一个指针标识数组的尾部

image-20210804170600111

它将 pt 设置为指向要处理的第一个元素(begin指向的元素)的指针,并将*pt(元素的值)加入到 total 中。然后,循环通过递增操作来更新 pt,使之指向下一个元素。只要 pt 不等于 end,这一过程就将继续下去。当 pt 等于 end 时,它将指向区间中最后一个元素后面的一个位置,此时循环将结束。

7.3.5 指针和 const

const 用于指针有一些很微妙的地方,可以用两种不同的方式将 const 关键字用于指针。第一种方法是让指针指向一个常量对象,防止使用该指针来修改所指向的值,第二种方法是将指针本身声明为常量,这样可以防止改变指针指向的位置。

首先看一个指向常量的指针:

image-20210804171314723

这里要求 pt 指向的是一个 const int,所以,赋值之后我们是不能用 *pt 来修改 age 的值的。

image-20210804171334907

现在来看一个微妙的问题。pt的声明并不限定着它指向的值就必须得是一个常量,只是对pt来说,这个值是常量。例如,pt指向age,但age不是const。我们是可以直接通过 age 变量来修改 age 的值的,但不能使用 pt 指针来修改它。

image-20210804171429933

我觉得这类指针可以称为:只读指针。这个指针可以移动,但是它只能读出它所指向地址的内容,但是无法通过这个指针修改其中的数据。

image-20210804171526205

C++禁止第二种情况的原因很简单——如果将 g_moon 的地 址赋给 pm,那么就可以用 pm 来修改 g_moon 的值,这使得 g_moonconst 状态很荒谬,因此, C++禁止将 const 的地址赋给非 const 指针

假设有一个由 const 数据组成的数组则禁止将常量数组的地址赋给非常量指针将意味着不能将数组名作为参数传递给使用非常量形参的函数。

只要条件允许,则应将指针形参声明为指向 const 的指针。

尽可能使用 const: 将指针参数声明为指向常量数据的指针有两个优点:

  • 避免由于无意间修改数据而导致的编程错误;
  • 使用 const 使得函数能够处理 const 和非 const 实参,否则将只能接受非 const 数据。

const 只能防止修改 pt 指向的值(这里为39),而不能防止修改 pt 的值。也就是说,可以将一个新地址赋给 pt

第二种使用 const 的方式使得无法修改指针的值:

image-20210804172355209

这样的指针,我想将其称为:静止指针。指针本身定死在一个地址上了,但是地址里的内容是可以通过这个指针随便修改的。

image-20210804172525799

通常,将指针作为函数参数来传递时,可以使用指向const的指针来保护数据。

在该声明中使用 const 意味着 show_array() 不能修改传递给它的数组中的值。只要只有一层间接关系,就可以使用这种技术。例如,这里的数组元素是基本类型,但如果它们是指针或指向指针的指针,则不能使用 const

7.4 函数和二维数组

数组名被视为其地 址,因此,相应的形参是一个指针,就像一维数组一样。

Data 是一个数组名,该数组有3个元素。第一个元素本身是一个数组,由4个 int 值组成。因此 data 的类型是指向由4个 int 组成的数组的指针,因此正确的原型如下:

int sum(int (*ar2)[4], int size);

还有另外一种格式,这种格式与上述原型的含义完全相同,但可读性更强:

int sum(int ar2[][4], int size);

对于二维数据,必须对指针 ar2 执行两次解除引用,才能得到数据。最简单的方法是使用方括号两次:ar2[r][c]。然而,如果不考虑难看的话,也可以使用运算符*两次:

ar2[r][c] == *(*(ar2+r)+c);   // 等效

7.5 函数和C-风格字符串

C-风格字符串由一系列字符组成,以空值字符(\0)结尾。

7.5.1 将C-风格字符串作为参数的函数

image-20210804204604444

假设要将字符串作为参数传递给函数,则表示字符串的方式有三 种:

  • char 数组;
  • 用引号括起的字符串常量(也称字符串字面值);
  • 被设置为字符串的地址的char指针。

但上述3种选择的类型都是char指针(准确地说是char*),因此可 以将其作为字符串处理函数的参数。

image-20210804214230755

可以说是将字符串作为参数来传递,但实际传递的是字符串第一个字符的地址。这意味着字符串函数原型应将其表示字符串的形参声明为 char * 类型。

C-风格字符串与常规 char 数组之间的一个重要区别是,字符串有内置的结束字符。这意味着不必将字符串长度作为参数传递给函数,而函数可以使用循环依次检查字符串中的每个字符,直到遇到结尾的空值字符为止。

不以空值字符结尾的char数组只是数组,而不是字符串。

7.5.2 返回C-风格字符串的函数

函数无法返回一 个字符串,但可以返回字符串的地址,这样做的效率更高。这种设计(让函数返回一个指针,该指针指向 new 分配的内存)的缺点是,程序员必须记住使用 delete

7.6 函数和结构体

结构体变量的行为更接近于基本的单值变量。也就是说,与数组不同,结构体将其数据组合成单个实体或数据对象,该实体被视为一个整体。前面讲过,可以将一个结构体赋给另外一个结构体。同样,也可以按值传递结构体,就像普通变量那样。在这种情况下,函数将使用原始结构体的副本。另外,函数也可以返回结构体。与数组名就是数组第一个元素的地址不同的是,结构体名只是结构体的名称,要获得结构的地址,必须使用地址运算符 &

使用结构编程时,最直接的方式是像处理基本类型那样来处理结构体;也就是说,将结构体作为参数传递,并在需要时将结构体用作返回值使用。

7.6.1 传递和返回结构体

当结构体比较小时,按值传递结构体最合理。

7.6.2 另一个处理结构的函数示例

7.6.3 传递结构体的地址

假设要传递结构的地址而不是整个结构以节省时间和空间,则需要 重新编写前面的函数,使用指向结构的指针。首先来看一看如何重新编写 show_polar() 函数。需要修改三个地方:

  • 调用函数时,将结构的地址(&pplace)而不是结构本身(pplace) 传递给它;
  • 将形参声明为指向 polar 的指针,即polar *类型。由于函数不应该修改结构,因此使用了 const 修饰符;
  • 由于形参是指针而不是结构,因此应间接成员运算符(->),而不是成员运算符(句点)。

7.7 函数和 string 对象

可以将对象作为完整的实体进行传递,如果需要多个字符串,可以声明一个string 对象数组,而不是二维 char 数组。

7.8 函数与 array 对象

7.9 递归

C++函数有一种有趣的特点——可 以调用自己,但不允许main()调用自己,这种功能称为递归。

我不喜欢递归,太耗资源,可读性还差。

7.9.1 包含一个递归调用的递归

7.9.2 包含多个递归调用的递归

7.10 函数指针

与数据项相似,函数也有地址。函数的地址是存储其机器语言代码 的内存的开始地址。对程序而言很有用,可以编写以另一个函数的地址作为参数的函数。

这样第一个函数就可以找到第二个函数,并运行它。与直接调用另一个函数相比,这种方法很笨拙,但它允许在不同的情况下传递不同函数的地址,这意味着可以在不同的时间使用不同的函数。

7.10.1 函数指针的基础知识

1.获取函数的地址 获取函数的地址很简单:只要使用函数名(后面不跟参数)即可。要将函数作为参数进行传递,必须传递函数名,要区分传递的是函数的地址还是函数的返回值。

2.声明函数指针

声明指向函数的指针时,必须指定指针指向的函数类型。意味着声明应指定函数的返回类型以及函数的特征标(参数列表)。也就是说,声明应像函数原型那样指出有关函数的信息:

image-20210805084458826

3.使用指针来调用函数

image-20210805084920592

为何pf和(*pf等价呢?一种学派认为,由于pf是函数指针,而 *pf 是函数,因此应将(*pf)()用作函数调用。另一种学派认为,由于函数名是指向该函数的指 针,指向函数的指针的行为应与函数名相似,因此应将pf( )用作函数调用使用。C++进行了折 衷——这2种方式都是正确的,或者至少是允许的,虽然它们在逻辑上是互相冲突的。在认为 这种折衷粗糙之前,应该想到,容忍逻辑上无法自圆其说的观点正是人类思维活动的特点。

image-20210805085227934

可能看起来比较深奥,但指向函数指针数组的指针并不少见。实际上,类的虚方法实现通常都采用了这种技术(参见第13章)。

7.10.4 使用typedef进行简化

这里采用的方法是,将别名当做标识符进行声明,并在开头使用关键字 typedef。 使用typedef可减少输入量,让您编写代码时不容易犯错,并让程序 更容易理解。

7.11 总结

函数是C++的编程模块。要使用函数,必须提供定义和原型,并调 用该函数。函数定义是实现函数功能的代码;函数原型描述了函数的接 口:传递给函数的值的数目和种类以及函数的返回类型。函数调用使得 程序将参数传递给函数,并执行函数的代码。

C++提供了3种表示C-风格字符串的方法:字符数组、字符串常量 和字符串指针。它们的类型都是char*char指针),因此被作为char*类型参数传递给函数。C++使用空值字符 \0 来结束字符串,因此字符 串函数检测空值字符来确定字符串的结尾。

C++处理结构体的方式与基本类型完全相同,这意味着可以按值传递结构体,并将其用作函数返回类型。然而,如果结构体非常大,则传递结构体指针的效率将更高,同时函数能够使用原始数据。