
5.3 在数字值上执行运算
我们可以对数字型的值执行基本的算术运算(arithmetic operator),这包括加法(+)、减法(-)、乘法(*)与除法(/)。这些运算都是二元运算,也就是说,需要有两个数字表达式参与。这四种运算既适用于整数,也适用于实数。两个实数相除结果仍为实数。两个整数相除结果仍为整数,这意味着,如果有小数部分,那么这一部分会遭到丢弃。另外还有一个运算符叫作模运算符(%,也称为求余运算符(remainder operator)),用来计算两个整数相除的余数。
比方说,12.0/5.0的结果是2.5(因为12.0与5.0都是实数,所以结果也用实数来表示),但12/5的结果则是2(因为12与5都是整数,所以结果也用整数来表示)。如果我们要计算12与5这两个整数相除的余数,那么可以使用求余运算符%。12 % 5的结果,是整数2。
许多编程语言都有求幂的运算符,但C语言没有。在C语言中,要想对某个表达式执行幂运算,可以利用C语言标准库所提供的pow(x, y)函数。这个函数的原型是double pow(double x, double y);。它会求出x的y次方(也就是x的y次幂),并把求值结果表示成double(双精度浮点数)。要想使用这个函数,你应该在自己的程序文件里包含<math.h>头文件,因为该函数的原型是在这份头文件里声明的。
我们现在新建一个叫作convertTemperature.c的文件,并且在里面编写这样两个有用的函数,也就是celsiusToFahrenheit()与fahrenheitToCelsius():

这两个函数都接受一个double型的参数,并把转换后的值作为double返回给调用方。
这些函数里面有几个地方需要注意。
首先,函数中的那两行代码本来是可以合起来写成一行的,例如第一个函数可以写成:

另一个函数也是这样:

许多程序员都会这么写,但这其实并没有节省多大的空间,而且你熟悉编程之后就会发现,这样写不仅没有好处,而且会给调试造成困难,让你在使用调试器(debugger)调试程序时耗费更多的时间(如何用调试器来调试程序,是一个比较高级的话题)。
另外,还有许多开发者会把这两个函数改用#define指令来实现,也就是把它们定义成宏符号(macro symbol,这又是一个比较高深的话题):

采用宏的形式来模拟函数可能是比较危险的,因为这种做法忽略了类型信息,导致我们所编写的这个展开式(例如((x*9.0/5.0)+32))未必适用于程序中的每一个x。在用预处理指令定义这种符号时必须特别小心,以免产生意外的结果。总之,无论是把两行语句合并成一行,还是改用预处理指令来实现,都有可能引发问题,因此,我们不应该仅为了少打几个字就采用这两种有问题的写法。
开发者总是喜欢多用——甚至滥用——预处理指令,这会给程序带来许多风险。如果你考虑到某种原因,确实很想使用这种指令,那么请参看24.4.2节。
第二个应该注意的地方在于,我们用群组运算符(grouping operator,也就是算式中的括号)来确保计算机总是能按照我们想要的顺序来求值,而不是依照各运算符之间的默认顺序求值。目前大家只需要记住:算式里面由(与)括起来的这一部分总是会优先求值。具体的细节放在本章后面再讲。
说完这两个函数之后,我们就可以把程序中的其他代码列出来了。请将这些代码也写在convertTemperature.c文件里面,并放在那两个函数的定义之前:

把所有内容都准备好之后,我们保存这份文件,然后编译并运行程序。你应该会看到类似下面这样的输出结果。

请大家注意观察,我们是如何采用特定的输入值来验证这两个函数的。首先,选用水的冰点所对应的摄氏温度作输入值,看看第一个函数能不能把它正确地换算成华氏温度。然后选用冰点对应的华氏温度作输入值,看看第二个函数能不能把它正确地换算成摄氏温度。接下来,用水的沸点所对应的摄氏与华氏温度作输入值,把这两个函数测试了一遍。最后,选一个简单的中间值(也就是50)作为输入值,再把这两个函数测一遍。
你可能会问,如果程序想使用double类型之外的值来调用这两个温度换算函数,那应该怎么办?在这种情况下,有些人可能会尝试创建多个版本的换算函数,让这些同名函数在参数与返回值的类型上有所区别,以适应各种用法。然而,这种方案在C语言中行不通,因为C语言不允许两个函数重名,就算它们在参数与返回值的类型上有所区别也不行。尝试编译convertTemperature NoNo.c程序,看看会得到什么错误。
C语言只通过函数的名称来区分函数,其他因素(例如参数的个数以及参数与返回值的类型等)都不足以区分两个函数。因此,如果你已经写了一个返回值为某类型,且带有两个参数的函数,那么即便你想写一个返回值为另一种类型,且不带任何参数的同名函数,C语言也不会允许你这样做。
有些人可能想把参数与返回值的类型嵌入函数名称中,以区分同一系列的各个函数,例如,参数类型为double且返回值类型为int的版本叫作fahrenheitDblToCelsiusInt(),参数类型为int且返回值类型为double的版本叫作fahrenheitIntToCelsiusDbl()。像这样给参数与返回值之间的每一种组合方式都声明并定义相应的函数版本是相当枯燥的。而且,这种写法也让函数变得特别难用。我们可能会打错函数的名称,也可能会在调用函数时传入类型错误的参数,这些都会让编译器报错,对于一款庞大或者复杂的程序来说,这会让我们花费许多时间去应对这些错误。那么,我们在C语言里面究竟应该怎样做才好呢?
别急!我们在下一节就会讲到这个话题,到时还会有一款完整的程序,给大家演示怎样使用各种类型的参数来调用这些函数。
执行数字运算时需要特别注意的问题
针对数字值执行运算时,必须考虑到输入值与输出值的范围。每一种数值类型都有它所能取到的最大值与最小值。这些值定义在C语言标准库的limits.h头文件里面,该文件所写的具体大小跟你当前使用的操作系统也有一定关系。
编写程序的时候,你必须确保代码对某个值所执行的算术运算,其结果不会超出该值所属的数据类型所能容许的取值范围,或者说,你必须检查输入值是否有效,以防止由于无效输入而产生的无效输出。有三种情况可能会让C程序在运行的过程中,因为遇到无效的输出值而崩溃,这三种情况是:运算结果为NaN(Not a Number)、运算发生下溢(underflow),以及运算发生上溢(overflow)。
NaN
NaN是一种表示运算结果的方式,用来指出某项运算的结果是未定义的(undefined),或者无法表示成数字。
考虑这样一个算式:y=1/x。如果x从数轴正向趋近原点,那么y的值会怎样变化?当x从右侧无限趋近于0的时候,y的值趋近于正无穷。如果x从数轴负向趋近原点,那么y的值又会怎样变化?当x从左侧无限趋近于0的时候,y的值会趋近于负无穷。从数学上来说,这个函数在x为0时具备不连续性(discontinuity),也就是说,这个函数在x为0时的取值是无法求出的。x从左、右两个方向分别趋近于0点时,y值的虽然都是无穷大,但正负号不同,一个是负无穷大,一个是正无穷大。因此,1/0这样的算式在数学上是没有定义或者未定义的,计算机用NaN表示这种算式的结果。
还有一些情况也会导致NaN,比方说,你要对实数做某种运算,而这种运算的结果必须用复数才能表示出来,例如对负值取平方根,或者计算负值的对数。此外,对于某些反三角函数来说,如果该函数在你传入的这个参数点上不连续,那么就会导致运算结果为NaN。
下溢NaN
下溢是指某项算术运算的结果太小,因而没办法表示成某种类型的值。
对于整数来说,下溢有两种情况,一种是针对无符号的(unsigned)整数而言的,也就是说,由于计算结果小于0,因此没办法表示成无符号的整数。另一种是针对带符号的(signed)整数而言的,也就是说,计算结果是个很“大”的负数,比这种整数所能表示的最“大”负数还“大”(这里的大是指去掉负号的那一部分,如果带上负号,那么从数学上来看应该叫作“小”,例如-2小于-1)。
对于实数来说,下溢指的是计算结果跟0特别接近(或者说,计算结果是个很小的小数),以致无法予以表示。比方说,把一个很小的数跟一个很大的数相除,或者把两个很小的数相乘,就有可能出现下溢。
上溢NaN
上溢是指某项运算的结果太大,比某种类型所能表示的最大值还大。
如果让两个特别大的数相加或相乘,或者用一个特别大的数除以一个特别小的数,那么就有可能发生上溢。
精度
用实数执行运算的时候,需要考虑参与运算的两个实数在数量级上的差距。如果其中一个特别大(这种数用科学计数法来表示的时候,指数部分是个很大的正整数),而另外一个特别小(这种数用科学计数法来表示的时候,指数部分是个很大的负整数),那么在这两个数之间执行运算时,就有可能让人看不出运算结果与其中一个数有何差异,或者会让运算结果变成NaN。比方说,如果把这个很大的数跟这个很小的数相加或相减,那么计算出来的结果可能跟计算之前的这个大数没有什么明显的区别。如果把这个很大的数与这个很小的数相乘或相除,那么计算结果可能会无法表示,因而变成NaN。把一个特别小的值加到一个特别大的值上是不会让这个值产生明显变化的,因此,这样运算会导致精度丢失,也就是说,会让计算结果不够精确或不够准确。
只有当两个值在数量级上比较接近时,运算结果才有可能处于合理的范围之内,从而能够准确地进行表示。
我们现在可以用多达64个二进制位来表示整数,并用多达128个二进制位来表示实数,因而能够表示出相当庞大的值,这些值已经超出了普通人所能想象的范围。但问题在于,我们所遇到的许多程序代码通常使用的还是那种范围比较有限的数据类型(而未必总是会选用范围最大的类型来表示整数及浮点数),因此,我们依然需要注意这样的程序代码能否给出准确的计算结果。