<audio id="audio" title="09 | 函数：自己动手实现低配版 scanf 函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8e/44/8e516f98bcef5c51ccf344ddbc271b44.mp3"></audio>

你好，我是胡光，今天是大年初一，是咱们中国传统的重要节日，春节！能在这样的节日氛围里，还能坚持过来学习的，我必须要说一声“鼠”你最优秀！在这里我也祝福热爱学习的你，在新的一年，身体健康，阖家欢乐！

今天呢，我们的学习课程也将迎来里程碑式的一课。所谓里程碑，是因为在这一节之前，你写的程序，只是片段，只是思想的随意表达，而通过了本节的学习，你的程序结构将发生翻天覆地的变化，会变得规格严整，变得可以复用，变得易于找错。

前面的课程，我们主要就是在一些基本的程序结构中做学习，包括顺序结构，分支结构以及循环结构。今天这一节中，我们将要认识的函数，可以将功能封装成可以复用的模块，就像创造乐高积木一样，废话不多说，开始今天的学习吧。

## 今日任务

对程序的输入输出函数，你应该已经很熟悉了。今天我们仿照 scanf 函数，实现一个低配版的 my_scanf 函数。这个函数的功能，简单来说就是将一个字符串信息转换成整型数字，能够完成这个任务，你会更深刻的理解 scanf 函数，更深刻的理解参数设计。下面给你几个例子。

首先先来看第一个基础功能：

```
int n = 98;
my_scanf(&quot;12345&quot;, &amp;n);
printf(&quot;%d&quot;, n); // 输出 12345，而不是 98

```

上面这段代码中，我们利用 my_scanf 函数，将字符串信息转换成了整型数据，并且将结果存储到了 n 变量的内存空间中，调用 printf 函数打印 n 变量值的时候，输出的信息不是 n 变量原有的初值 98，而是 12345。对于这个基础的转换功能，要考虑兼容负数的情况。

只有这一个基础功能肯定是远远不够的，下面就让我们看另外一种情况：

```
int n = 98, m = 0;
my_scanf(&quot;123 45&quot;, &amp;n, &amp;m);
printf(&quot;n = %d m = %d&quot;, n, m); // 输出 n = 123 m = 45

```

上面这段代码中，首先我们定义了两个整型变量 n 和 m，然后给 n 初始化为 98，m 初始化为 0。之后给 my_scanf 函数传入的字符串信息中有一个空格，那么 my_scanf 函数会以空格作为分隔符，将第一个转换出来的数字 123 赋值给 n，第二个转换出来的数字 45 赋值给 m。

上面举例了 my_scanf 函数转换 1 个整型参数和 2 个整型参数情况，这些都是在函数的基本知识范围内的内容。经常有初学者学完函数相关的基本知识以后，就认为自己掌握了函数的全部知识，但事实绝非如此，而之所以初学者有这种“假想”，是因为他不知道如何找到和判定自己的知识盲区。

所以今天我们要讲的内容就是破除“假想”。这个任务就是要设计一个能够转换任意个整型参数的 my_scanf 函数，注意，这里的重点难点，可是在任意个参数上面。清楚了任务以后，下面就让我们进入今天的查缺补漏环节吧。

## 必知必会，查缺补漏

要完成今天的这个任务，首先你需要知道如何实现一个基本的函数，由于要支持转换任意多个整型参数，所以你还需要知道变参函数相关的知识。下面我们就逐项的来进行学习吧。

#### 1. 函数的基础知识

数学中的函数，大家都不陌生，一般的形式是 f(x) = y，x 是自变量，y 是函数值。程序中的函数，和数学中的函数基本一致，有自变量，我们称作“传入参数”，还有函数值，我们叫做返回值。

先让我们来看一下程序中的函数的基本组成部分：<br>
<img src="https://static001.geekbang.org/resource/image/31/76/312f867dfccc3ed8422a5612a11d2e76.jpg" alt="" title="图1：函数的基本组成部分">

如图1所示，一个程序函数从左到右，从上到下，大体可以分成四个部分：

- 第一个部分，是函数返回值的类型。
- 第二个部分，是函数名字，调用函数的时候，需要给出这个函数名，所以在设计函数名的时候，要尽量设计一个与函数功能有关的名字，例如上图中的函数，通过名字我们可知，这就是一个求平方的函数。
- 第三部分，是传入参数，就是数学函数中的自变量。
- 第四部分就是函数体，也就是要完成函数功能的逻辑代码，结果值是通过 return 语句进行返回的，而整个函数体的逻辑要包裹在一对大括号内部。

下面我们就来看一下在程序中如何使用函数功能：

```
#include &lt;stdio.h&gt;
int square(int x) { // 定义函数 square
    return x * x;
}
int main() {
    int n;
    scanf(&quot;%d&quot;, &amp;n);
    printf(&quot;%d\n&quot;, square(n));
    return 0;
}

```

上述代码中，在主函数中，我们读入一个整型数字 n，然后输出 n 的平方值。这里在计算 n 的平方值的时候，程序中调用了上面定义的 square 函数，那么 printf 函数相当于输出的是 square 函数的返回值，根据 square 函数的实现，如果传入的值是 x，那么返回值就是 x * x ，即 x 的平方值。

这里需要你注意两个概念，我们将 n 传递给函数 square 的过程中，会涉及到 n 给 square 函数参数 x 赋值的过程。也就是说，主函数中的 n 变量和 square 函数参数 x 变量是两个相互独立的变量，其中 n 叫做“实参”，实际的参数，x 叫做“形参”，形式上的参数。

关于这个例子，我还要多说一句，还记得程序中的顺序结构吧，这是程序最基本的执行结构，也就是从左到右，从上到下的执行程序中的每一条语句。其实，函数和函数之间的关系，也可以理解为这种顺序执行的关系。

在这个例子中，我们在主函数中调用了 square 函数，也就意味着在这句话之前，程序中必须知道 square 函数的存在，因此 square 函数实现在了主函数之前。后面的文章中，你将会学到，其实 square 函数不用实现在主函数之前也可以，这就要涉及到“声明”与“定义”的区别了，这个我后面再和你详细解释。

#### 2. 普通变量的函数传递参数

了解了函数的基本知识以后，接下来让我们重点学习一下函数的参数传递过程，也就是上文中提到的“形参”和“实参”之间关系的问题。接下来的学习，我们都是围绕着一句话展开的，你先记住：**函数的参数传递过程，就是“实参”给“形参”赋值的过程，“实参”与“形参”之间互相独立，互不影响。**

下面先来看一下普通变量的传递过程，请看下面这段程序：

```
#include &lt;stdio.h&gt;
void add(int n, int m) {
    n += m;
    return ;
}
int main() {
    int n, m;
    scanf(&quot;%d%d&quot;, &amp;n, &amp;m);
    add(n, m);
    printf(&quot;%d\n&quot;, n);
    return 0;
}

```

这段程序中，首先读入两个变量 n 和 m 的值，然后将 n 和 m 传递给一个名叫 add 的函数，add 函数的相关参数也叫 n 和 m，然后在 add 函数内部，将 m 累加到了 n 上面，之后函数返回结束，没有返回值。add 函数执行完后，回到主函数中，输出 n 的值。我的问题是，此时，n 的值有没有变化？

如果你实际运行这个程序，你会发现，n 的值不会改变，这就是我想让你记住的那句话，函数的参数传递过程，就是“实参”给“形参”赋值的过程。

这个程序中，主函数中的变量 n 就是“实参”，add 函数中的参数 n 就是“形参”，虽然两者名字一样，可完全是两个互相独立的变量。

两者有各自的存储空间，“实参”就是把自己存储空间中的值，复制一份给了“形参”，所以，在函数内部，我们实际修改的是“形参”中所存储的值，对主函数中的变量 n 毫无影响。整个过程如下图所示：<br>
<img src="https://static001.geekbang.org/resource/image/2b/ac/2be15931cddfb830bd07012c8d7aefac.jpg" alt="" title="图2：实参、形参赋值示意图">

如图所示，add 函数内部做的所有操作，都是在黄色的变量存储区内做的，对主函数中的变量存储区毫无影响。

那么如果我们想要改变n最后输出的值，你知道这个程序怎么改动呢？这里，你需要注意往下学习什么是传入参数和传出参数。

#### 3. 数组的函数传参

看了普通变量的传参以后，下面来看一下数组作为参数时候的传参方式和特性，请看下面这段代码：

```
#include &lt;stdio.h&gt;
void add(int *p, int n) {
    for (int i = 1; i &lt; n; i++) {
        p[0] += p[i];
    }
    return ;
}
int main() {
    int arr[10] = {1, 2, 3};
    add(arr, 3);
    printf(&quot;%d&quot;, arr[0]);
    return 0;
}

```

这段程序中，主函数定义了一个拥有10个整型元素的数组，然后数组的前三位分别初始化为1、2、3，之后将数组作为 add 函数的第一个参数，第二个参数是一个数字 3，add 函数的功能是将传入的数组中的前 n 个位置的值，累加到数组的第一个元素上。在 add 函数执行完后，在主函数中输出数组第一个元素的值。

对于这份代码的输出，你有什么预测么？在你做出预测之前，我提醒你注意一个地方，就是 add 函数中负责接收数组参数的第一个参数的类型，是一个指针类型，这里结合之前的知识就能理解了。数组名一般情况下代表了数组的首地址，将一个地址作为值传入函数，当然要用指针变量来进行接收了。

最后，你运行这段程序，会发现输出的结果是 6，意味着数组中的第一个元素的值发生了变化。再想想今天我们要记住的那句话：**函数的参数传递过程，就是“实参”给“形参”赋值的过程，“实参”与“形参”之间互相独立，互不影响。**

不是说互相独立么，怎么数组的第一个元素的值却改变了呢。没错，数组的第一个元素的值确实在函数内部被改变了，可这跟“实参”和“形参”的关系完全没有冲突。

请你注意，这里面我们的“实参”，实际上是数组的首地址，形参是存储这个首地址的函数参数中的那个指针变量。也就是说，在 add 函数内部，操作的地址空间，和主函数中的那个数组的空间是一个空间，这就是为什么传递数组时，相关元素的值在函数内部可以被改掉的一个原因，因为传递的是地址！<br>
<img src="https://static001.geekbang.org/resource/image/01/cb/018c3fdadbe20ef95fa19997650bbecb.jpg" alt="" title="图3：数组传参过程">

就如图3中所示，主函数中的数组 arr 将自己的首地址赋值给了指针变量 p，两者虽然互相独立，可只要不改变指针变量 p 中存储的地址，p[0] 和 arr[0] 实际上对应的就是同一片存储空间，所以修改 p[0] 的值，也相当于修改了 arr[0] 的值。

#### 4. 传入与传出参数

学习了函数的基本知识以后，最后让我们来看两个逻辑上的概念“传入参数”与“传出参数”。

请看下面这段程序：

```
#include &lt;stdio.h&gt;
void calc(int x, int *p) {
    *p = x * x;
    return ;
}
int main() {
    int n, m;
    scanf(&quot;%d&quot;, &amp;n);
    calc(n, &amp;m);
    printf(&quot;%d\n&quot;, m);
    return 0;
}

```

上面这段程序中，开始先定义了一个 calc 函数，calc 函数有两个参数，第一个是一个整型参数，第二个是一个整型地址，函数内部，将 x 的平方值存储到了 p 所指向的存储空间中。在主函数中调用了 calc 函数，分别传入 n 的值和 m 的地址，然后输出 m 的值，最后你会发现输出的 m 值，就是 n 的平方值。

在这里我们重点来讨论一下函数 calc 两个参数的本质作用。首先第一个参数 x，是外部传入的一个值，这个值在函数内部，要参与重要的运算过程，也就是说，这个值的感觉更像是从外部传入到内部，然后在函数内部发挥作用，这种类型的参数，我们就叫“传入参数”。

而再看 calc 函数的第二个参数，传入的是一个地址。在函数内部的作用，只是将计算得到的一些结果存储在这个地址所指向的空间中，而记录的这些结果，在函数内部是没有用的，是要等到函数执行完后，回到调用者之后，例如上面的主函数内部，才有用。这一个参数的作用，更像是把值从 calc 内部带出到主函数内部而设计的，这类参数，我们叫做“传出参数”。

就像上面代码中看到的，“传入参数”一般就是把值传进去就行，而“传出参数”由于要把值从函数中带出来，一般要传变量地址进去，这样，函数内部才能准确的把结果写入到相关地址所对应的内存中。

## 一起动手，搞事情

我们又到了每天的“一起动手，搞事情”的环节，今天呢，将给你留两个思考题。

#### 思考题（1）：数组和函数的思考

请思考如下两个概念的异同：

<li>
一个整型数组元素，例如：arr[100]
</li>
<li>
一个传入整型并且返回整型的函数，例如：func(100)
</li>

这是一个开放思考题，写出你的理解及思考过程即可。

#### 思考题（2）：如何确定存在知识的盲区

什么叫“存在知识的盲区”呢？就是当你面对一片黑暗的时候，你可以确定这里一定有知识，而不仅仅只是一片黑暗。就像今天我们学习了函数的相关知识，自然的，就会反问自己一句，这些就是函数知识的全部了么？我们如何来确定这个问题的答案呢？很简单，根据已知推未知。

我们假设现在学习的内容，已经是函数知识的全部了，也就是说，只要是函数，我们就能用我们现有知识对其加以解释。

那么，在之前，我们已知的函数中，有两个很基础，也很重要的函数，一个是 scanf 函数，一个是 printf 函数。

随便来看一个，例如来看 scanf 函数，当我问你，scanf 函数，传入几个参数的时候，你会发现是若干个。第一个参数是一个字符串，往后的参数，是根据字符串中格式占位符的数量而定的。在不要求你实现 scanf 函数功能的情况下，你能将 scanf 函数包含参数定义的形式写出来么？直到这里，我们就发现了一个存在知识的盲区。

所以，没有知识的盲区，只是盲区，发现有价值盲区的能力，也是我们要锻炼的重要能力。既然发现了这个知识盲区，给你留个小作业，自学“可变参函数”相关的知识吧。

## 实现 my_scanf 函数

准备完了对于函数的基础知识以后，再回到今天一开始提到的任务。首先来分析一下只转换一个整型参数的 my_scanf 函数应该如何进行实现。

第一步，我们先来看参数设计，第一个参数，应该是一个字符串类型的“传入参数”，代表要转换成整型信息的字符串信息。第二个参数，应该是一个指针类型的“传出参数”，指向存储转换结果的内存区域。

具体功能实现，请看下面这段代码：

```
#include &lt;stdio.h&gt;
void my_scanf(char *str, int *ret) {
    int num = 0, flag = 0;
    if (str[0] == '-') str += 1, flag = 1;
    for (int i = 0; str[i]; i++) {
        num = num * 10 + (str[i] - '0');
    }
    if (flag == 1) num = -num;
    *ret = num;
    return ;
}
int main() {
    char str[1000];
    int n = 65;
    scanf(&quot;%s&quot;, str);
    my_scanf(str, &amp;n);
    printf(&quot;n = %d\n&quot;, n);
    return 0;
}

```

这段代码中，实现了 my_scanf 函数。在看 my_scanf 函数具体逻辑之前，先来看一下主函数里面都写了些什么。

主函数的头两行定义了两个变量，一个是字符数组 str，另外是一个整型变量 n，然后读入一个字符串，将其保存在字符数组中。再之后，使用 my_scanf 函数将字符数组中的字符串信息，转换成为整型信息存储在 n 中，最后，使用 printf 函数输出 n 的值，加以确认。

看完了主函数以后，再来看一下 my_scanf 函数的具体实现。my_scanf 函数第一行定义了两个变量，一个用于存放转换结果的 num 变量，另一个 flag 变量用来标记正负数的，0代表正数，1代表负数。

第2行判断字符串中的第一位是不是字符 ‘-’，如果是字符 ‘-’，就将 flag 标记为1，并且把 str 字符指针所指的位置，向后跳动一位，因为 ‘-’ 后面就是要转换的第一个数字字符了。之后遍历字符串剩余的每一位，每次将当前字符所代表的数字，放到 num 数字的末尾。

其中 str[i] - ‘0’，就是将相关的数字字符，转换成对应的数字。之前我们说了，任何一个信息在底层存储的时候，都是二进制信息表示，也就是说，都可以转换为一个十进制数字，字符信息也不例外。其中字符 ‘0’ 所对应的底层数字是48，字符 ‘1’ 是 49，字符 ‘2’ 是 50，依次类推。所以当我们用 ‘2’ - ‘0’ 的时候，相当于50 - 48，得到的结果就是数字 2。

最后把 num 中的值拷贝到 ret 所指向的存储区中，也就是主函数中的 n 变量的内存区中。至此我们就完成了一个整型参数的 my_scanf 函数的实现。接下来，运用“可变参函数”的相关知识，改写这个程序，去独立完成最终形态的程序吧。

## 课程小结

今天讲的内容呢，是里程碑式的一课，到目前为止，你已经学会了将程序模块化的最基本技术：函数。也是从这一课开始，后面我将越来越多的起到引导你的作用，逐渐帮你撤掉学习中对我的依赖，如果后续学习中遇到什么问题，咱们随时在留言区中讨论。

最后呢，我来给你总结一下今天课程的重点，只希望你记住三点：

1. 函数的作用，是做功能封装，以便在程序其他地方复用相关功能。
1. C 语言中的函数的传参过程，是实参给形参赋值的过程，改变形参的值，不会影响实参。
1. 在函数参数设计中，一定要分清楚，传入参数和传出参数在功能上的差别。

好了，今天就到这里了，我是胡光，我们下次见。
