Skip to content

Latest commit

 

History

History
415 lines (255 loc) · 19.8 KB

Think-C_03.md

File metadata and controls

415 lines (255 loc) · 19.8 KB

#第三章 函数

##3.1 浮点

上一章我们遇到了一些处理非整数的问题。我们的方法是用百分比代替分数,但是通常的解决方案是用浮点数,它比整数更便于表示分数。在 C 语言中,有两种浮点数类型,floatdouble 。本书只用 double

你可以新建一个浮点型变量并赋值,语法和其他类型的一样。例如:

double pi;
pi = 3.14159;

在声明变量的同时为其赋值也是合法的:

int x = 1;
char first_char = "a";
double pi = 3.14159;

事实上,这个语法很常用。声明和赋值的结合体有时也叫做初始化(initialization)。

虽然浮点数很有用,但它们常会引起混淆,因为整数和浮点数之间有一部分是重合的。例如 1 ,它是一个整数还是浮点数?或者二者皆是?

简而言之,C 语言用整数 1 区别于浮点数 1.0 ,尽管它们看起来是同一个数。但它们是不同的类型,拼写也不一样,不能在不同的类型之间赋值。例如,下面的语句是非法的:

int x = 1.1;

因为左边的变量是 int ,右边的值是 double。但是这条规则很容易被忘记,主要是因为 C 语言有时会自动转换类型。例如:

double y = 1 / 3;

严格的说这不合法,但是 C 语言允许自动从 int 转换为 double 。这对程序员是一个便利,但是容易导致一些问题;例如:

double y = 1 / 3;

你可能期望 y 得到的值是 0.333333,这是一个合法的浮点数,但是实际得到的值是 0.0 。原因是,表达式右边是两个整数的除法,所以 C 语言只做整除,结果就是一个整数值 0 。然后再转换为浮点数 0.0 。

解决这个问题一种方法是将右边变为一个浮点数表达式:

double y = 1.0 / 3.0;

这样设置后,y 就等于 0.333333,正是你想要的。

我们已经看到的所有操作 —— 加法,减法,乘法,除法 —— 都可以操作浮点数,尽管底层机制是完全不同的。事实上,大部分处理器有一个专门的硬件结构负责处理浮点数操作。

##3.2 常数

我们在前面的章节将 3.14159 赋给了一个浮点数变量。关于变量有一个重点要记住,它们可以在程序中变换它们的值,这就是“变量”的意思。例如,我们可以先为变量 pi 赋值 3.14159 ,之后又可以为它赋其它的值:

double pi = 3.14159;
...
pi = 10.999;  /* probably a logical error in your program */

第二个值可能不是你新建 pi 时指定的那个值。π 的值是一个常数,任何时候都不会改变。在 pi 中随意的存放其他值可能会导致一些难以查找的 Bug 。

C 语言允许用关键字 const 将一个存储位置设为静态。这个关键字必须和常量的类型一起使用。这个位置在初始化时会赋值,但是在程序运行期间就无法更改了。

const double PI = 3.14159;
printf("Pi: %f\n", PI);
...
PI = 10.999;  /* wrong, error caught by the compiler */

PI 一旦被初始化就再也不可能更改了,除此以外,我们可以像变量使用它。

为了将常量区别于变量,我们将在它们的名字中使用大写字母。

##3.3 从 double 转换到 int

如我所说,必要时 C 语言会自动将 int 转换为 double ,因为这样的转换不会丢失任何信息。但是,从 double 转换为 int 就要舍去一些信息。C 语言不会自动提示这个操作,这就要考验你,作为一个程序员,能否注意到小数部分的丢失。

将浮点值转换为整数值的最简单方法是用 类型转换(typecast) 。通过这个方式可以将一个值从一种类型转换为另一种。

类型转换的语法是在表达式前放一对圆括号,在圆括号中指定目标类型,(Type)。例如:

const double PI = 3.14159;
int x = (int) PI;

(int) 会返回一个整数,所以 x 得到的值就是 3 。转换为整数时都是向下舍去,即使小数部分是 0.99999999。

对于 C 语言中的每种类型,都可以用一个相应的运算符(将类型放在圆括号中)可以把它的参数转换为合适的类型。

##3.4 数学函数

在数学方面,你可能见过类似 sin 和 log 的函数,并且已经学会用 sin(π/2) 和 log(1/x) 求表达式的值。首先,括号里面的表达式叫做函数的 参数(argument) 。例如,π/2 近似 1.571 ,1/x 是 0.1 (如果 x 正好是 10)。

然后再通过查表或各种计算,求函数本身的值。sin(1.571) 等于 1, log(0.1) 等于 -1(假设底数是10)。

这个过程可以通过嵌套来求跟多复制的表达式,例如 log(1/sin(π/2)) 。先对最里面的函数参数求值,然后逐步向外求值。

C 语言提供了一套内建函数,包含了大部分你想要的数学操作。这些数学函数的调用语法和它们的数学用法类似:

double log = log(17.0);
double angle = 1.5;
double height = sin(angle);

第一个例子是用 log 函数求 17 的对数,底数是 e 。还有一个函数叫做 log10 ,即求底数为 10 的对数。

第二个例子求变量 angle 的正弦。C 语言中 sin 和其他三角函数(cos,tan)中的值表示弧度。要将角度转换为弧度,需要先除以 360 再乘以 2π 。

如果你不知道 π 的前 15 位,可以用 acos 函数计算。-1 的反余弦是 π,因为 π 的余弦是 -1 。

const double PI = acos(-1.0);
double degrees = 90;
double angle = degrees * 2 * PI / 360.0;

在使用任何数学函数之前,必须包含 math 头文件。头文件包含了编译器所需的,在程序之外定义的函数的信息。例如,在 “Hello,world!” 程序中,我们用 include 语句包含了 stdio.h 头文件:

#include <stdio.h>

stdio.h 包含了 C 语言中的输入输出(I/O)函数的信息。

与之类似,math 头文件包含了数学函数的相关信息。可以在程序的开始处跟着 stdio.h 包含它:

#include <math.h>

##3.5 组合

和数学函数一样,C 语言的函数都是可以组合的,这意味着你可以把一个表达式当做另一个表达式的一部分。例如,可以将任何表达式当做一个函数的参数:

double x = cos(angle + PI/2);

这条语句将 PI 的值除以 2 ,然后将结果与 angle 的值相加。它们的和再作为 cos 函数的参数。

还可以将一个函数的结果当做另一个函数的参数:

double x = exp(log(10.0));

这条语句先求以 e 为底的 10 的对数,然后将结果作为 exp 的参数。最终将结果赋给 x ;希望你明白它的意思。

##3.6 添加新函数

目前为止我们只是使用 C 语句内置的函数,但是也可以添加新的函数。事实上,我们已经看到了一个函数的定义:main 。这个 main 函数是很特殊的,因为它标示了程序执行的起始位置,但是 main 函数的定义语法和其他函数是一样的:

void NAME( LIST OF PARAMETERS)
{
	STATEMENTS
}

可以为函数起任何名字,但是不能用 main 和其他 C 语言关键字。参数列表用于传递一些信息,必须在调用时提供。

main 函数没有任何参数,因为定义时在圆括号里包含了 void 关键字。我们先写两个不带参数的函数,语法类似这样:

void PrintNewLine(void)
{
	printf("\n");
}

这个函数名为 PrintNewLine;只有一条语句,输出一个换行符。注意,我们的函数名是以大写字符开头的。函数名中的每个单词都以大写字符开头。这个惯例将贯穿本书。

我们可以在 main 函数中调用这个新函数,语法和调用 C 语言内置函数类似:

int main(void)
{
	printf("First Line.\n");
	PrintNewLine();
	printf("Second line.\n");
}

这个程序的输出是:

First line.

Second line.

注意两行之间的空白。如果想在两个之间放更多的空白,该怎么办呢?可以多次调用这个函数:

int main(void)
{
	printf("First Line.\n");
	PrintNewLine();
	PrintNewLine();
	PrintNewLine();
	printf("Second Line.\n");
}

或者再写一个新的函数,名叫 PrintThreeLines,打印三个换行符:

void PrintThreeLines(void)
{
	PrintNewLine();		PrintNewLine();		PrintNewLine();
}

int main(void)
{
	printf("First Line.\n");
	PrintThreeLines();
	printf("Second Line.\n");
}

你应该注意关于这个程序的一些事情:

  • 可以重复的调用同意函数。事实上,这是很常用的。
  • 可以在一个函数里调用另一个函数。这个程序中,main 调用了 PrintThreeLines,而 PrintThreeLines 调用了 PrintNewLine。
  • PrintThreeLines 函数中,我在同一行里写了三条语句,这是合法的(要记住,空白和空行通常不会改变程序的意思)。另一方面,通常应该让每条语句单独占用一行,这样会使你的程序更易读。有时为了节省空间,我会破坏这条规则。

目前为止,可能还没有体现出新建这些函数的价值。事实上,有很多原因,但是这个例子只显示了两个:

  1. 新建一个函数就是给你一个机会可以为一组语句起一个名字。函数可以简化程序,可以将复制的计算隐藏在一条命令之后,并通过英文单词描述代码的意思。PrintNewLine 和 printf("\n"),哪个的意思更清晰呢?

  2. 新建一个函数可以消除重复代码,从而使程序更短。例如,打印九个换行的简短方法是调用三次 PrintThreeLines。你该怎样打印 27 个换行呢?

##3.7 定义和调用

将上一节的代码片段集合到一起,整个程序如下:

#include <stdio.h>
#include <stdlib.h>

void PrintNewLine(void)
{
	printf("\n");
}

void PrintThreeLines(void)
{
	PrintNewLine(); PrintNewLine(); PrintNewLine();
}

int main(void)
{
	printf("First Line.\n");
	PrintThreeLines();
	printf("Second Line.\n");
	return EXIT_SUCCESS;
}

这个程序包含了三个函数定义:PrintNewLine,PrintThreeLines,main。

main 函数里有一条语句调用了 PrintThreeLines。类似的,PrintThreeLines 调用了 PrintNewLine 三次。注意,每个函数的定义都出现在调用位置之前。

在 C 语言中这是必须的。函数的定义必须出现在它第一次被调用的位置之前。你应该尝试修改这个程序,然后编译运行,看看会出现哪些错误信息。

##3.8 编写多功能函数

当你看到一份包含很多函数的 C 源代码时,可能想要从头都到尾,但是这会让你感到困惑,因为程序的 执行顺序 可不是这样。

程序的执行总是从 main 函数里的第一条语句开始,并不关心 main 函数的位置(通常在程序的底部)。然后依次执行每条语句,直到抵达函数调用的位置。函数调用就像程序执行过程中的一次绕道。当执行到函数调用的位置时,不会执行下一条语句,而是进入该函数的第一行,然后一次执行函数的所有语句,最后在返回到离开时的位置。

这听起来很简单,但是不要忘了一个函数还可以调用另一个函数。因此,当我们处在 main 函数时,可能会离开 main 去执行 PrintThreeLines 中的语句,而当我们执行 PrintThreeLines 时,又会中断三次去执行 PrintNewLine。

幸运的是,C 语言善于跟踪函数调用的位置,所以每次 PrintNewLine 执行完成后,程序都可以返回它离开 PrintThreeLines 时的位置,最终会返回到 main,所以程序可以完成。

这意味着什么呢?当你要阅读一个程序时,不要从头读到尾,而应该跟随程序的执行顺序阅读。

##3.9 参数和参数值

我们用过的某些内置函数是有 参数 的,就是为了让函数工作而为其提供的值。例如,如果想要得到一个数的正弦,就必须指定这个数是多少。因此,sin 用一个 double 值作为参数。

有些函数需要一个以上的参数,例如 pow,需要两个 double 数,分别作为底数和指数。

注意,我们遇到的所有这些情况中,不仅知道参数的格式,而且知道参数的类型。所以,当你定义一个函数时,参数列表要为每个参数指定类型。例如:

void PrintTwice(char phil)
{
	printf("%c%c\n",phil,phil);
}

这个函数只有一个参数,名叫 phil,char 类型。无论参数是什么(这里我们不关心它的内容),都会打印两次,然后跟一个换行符。我选择 phil 这个名字是建议你自己来决定参数的名称,通常你想要选一个比 phil 能表达更多信息的名称。

为了调用这个函数,我们要提供一个字符。例如:

int main(void)
{
	PrintTwice('a');
}

这个你提供的字符叫做 参数值(argument),我们会说:参数的值 传递(passed) 给了这个函数。这里的 'a' 作为参数的值传递给了 PrintTwice,然后被打印两次。

或者,如果我们有一个 char 型变量,也可以用它作为参数的值:

int main()
{
	char argument = 'b';
	PrintTwice(argument);
}

注意:作为参数的值的变量名(argument)和参数名(phil)没有任何关系。再次强调:

The name of the variable we pass as an agrument has nothing to do with the name of the parameter。

它们的名称可以一样,也可以不同,重点是要意识到它们不是一回事,只是碰巧它们的值相同(这里是字符 'b')。

你提供的参数值必须和函数的参数类型相同。这条规则很重要,但有时会让人困惑,因为 C 语言有时会自动转换类型。目前你只需学习基本的规则,后面我们会处理这些例外。

##3.10 参数和局部变量

参数和变量只存在于它们自己的函数中。在 main 的范围内,没有任何关于 phil 的东西。如果你试图使用它,编译器会报错。类似的,PrintTwice 内部也没有任何关于 argument 的东西。

这样的变量叫做局部变量。为了了解参数和局部变量,我们画了一个框图。类似于状态图,框图展示了每个变量的值,但是变量被包含在一个更大的方框内,这个框表示变量所属的函数。

例如,PrintTwice 的框图:

3-1

每当一个函数被调用时,就会新建一个函数的 实例(instance) 。函数的每个实例都会包含这个函数的参数和局部变量。图中将一个函数实例表示为一个方框,外部是函数名,内部是变量和参数。

本例中,main 有一个局部变量 argument,没有参数。PrintTwice 没有局部变量,有一个参数 phil 。

##3.11 带多个参数的函数

声明和调用带有多个参数的函数时常会引起一些错误。首先,牢记必须为每个参数声明类型。例如:

void PrintTime(int hour,int minute)
{
	printf("%i", hour);
	printf(":");
	printf("%i", minute);
}

可能会不小心写成 (int hour,minute),但是这种格式只有在声明变量时才合法,对于参数是无效的。

另一个常见的错误是在传递参数值时声明类型。下面是错误的示例!

int hour = 11;
int minute = 59;
PrintTime(int hour,int minute); /* WRONG! */

这种情况下,编译器可以通过寻找它们的声明来确定 hour 和 minute 的类型。当把它们作为参数值传递给函数时,无需声明类型,而且这是非法的。正确的语法是 PrintTime(hour,minute); 。

##3.12 带返回值的函数

你可能已经注意到了,目前我们用过的一些函数,例如数学函数,是有返回值的。其他一些函数,例如 PrintNewLine ,只是表现一种行为但没有返回值。这引起了一些问题:

  • 如果你调用一个函数但是没有使用它的返回值(例如,没有将返回值赋给一个变量,或没有将函数调用作为更大的表达式的一部分),会发生什么?
  • 如果在一个表达式中调用一个没有返回值的函数会发生什么?例如 PrintNewLine() + 7 。
  • 我们可以写带有返回值的函数吗?

第三个问题的答案都是 “yes,you can”,在后面的章节就会看到。另外两个问题我就不告诉你了,你可以自己去尝试一下。无论何时,如果有关于 C 语言的语法问题,最好的办法是去问编译器。

##3.13 词汇

constant(常量): 一个命名的存储位置,类似一个变量,一旦初始化就不能更改。

floating-point(浮点数): 一种变量(或值)的类型。可以包含小数。C 语言中有多种浮点数类型;本书使用的是 double。

initialization(初始化): 一种语句,声明一个新变量的同时为它赋值。

function(函数): 一个命名的语句的队列,可以执行一项有用的功能。函数可以带参数,也可以不带,可以有返回值,也可以没有。

parameter(参数): 由你提供的一组信息,以便调用一个函数。参数在某种意义上类似变量,它们包含值和类型。

argument(参数值): 调用函数时由你提供的一个值。这个值必须和相应的参数具有相同的类型。

call(调用): 导致一个程序被执行。

##3.14 练习

Exercise 3.1

这个练习的重点是通过一个带有多个函数的程序练习阅读代码,确保你理解程序执行的流程。

  • a. 下面程序的输出是什么?准确的指出空白和换行的位置。

    HINT: 先描述一下 Ping 和 Baffle 的作用。

      #include <stdio.h>
      #include <stdlib.h>
    
      void Pint() {
      	printf(".\n");
      }
    
      void Baffle() {
      	printf("wug");
      }
    
      void Zoop() {
      	Baffle();
      	printf("You wugga ");
      	Baffle();
      }
    
      int main(void) {
      	printf("No, I ");
      	Zoop();
      	printf("I ");
      	Baffle();
      	return EXIT_SUCCESS;
      }
    
  • b. 画一个框图,展示第一次调用 Ping 时的程序状态。

Exercise 3.2

这个练习的重点是确保你理解如何编写和调用带参数的函数。

  • a. 写一个名叫 Zool 的函数,带三个参数:一个 int 和两个 char 。
  • b. 写一行代码调用 Zool ,将数字 11,字符 a 和 z 最为参数传递给函数。

Exercise 3.3

这个练习的目的是将前面练习过代码封装到一个带参数的函数中。先从一个解决方案开始。

  • a. 写一个名叫 PrintDateAmerican 的函数,所带参数分别是 day,month 和 year ,然后用美国格式打印它们。
  • b. 在 main 函数中调用它,并传递合适的参数,测试函数的正确性。函数的输出应该类似(日期可能不同):3/29/2009
  • c. 调试完 PrintDateAmerican 之后,再写一个 PrintDateEuropean 函数,用欧洲格式打印日期。

Exercise 3.4

很多计算都可以简化为”乘法加法“操作来表达,就像 a*b+c 。有些处理器为了处理浮点数甚至提供专门的硬件来实现这个操作。

  • a. 新建一个程序 Multadd.c。
  • b. 写一个函数 Multadd ,带三个 double 型的参数,将它们安装先乘后加的顺序计算,并打印结果。
  • c. 写一个 main 函数测试 Multadd ,传递一些简单的参数,例如 1.0,2.0,3.0,打印的结果应该是 5.0 。
  • d. 在 main 函数中用 Multadd 计算下面表达式的值:

3-2

  • e. 写一个函数 Yikes ,带一个 double 型参数,调用 Multadd 计算并打印:

3-3

HINT:计算以 e 为底的指数可以用函数 double exp(double x); 。

最后,可以写一个函数调用你所写的这些函数。无论何时,写完一个函数后都应该及时的仔细测试。另外,你会发现,同时调试两个函数是非常困难的。

这个练习的目的之一就是练习模式匹配:将一个特定问题识别为一个普通问题的实例。