C陷阱与缺陷:语法陷阱

简介: C陷阱与缺陷:语法陷阱


博客大纲


语法陷阱

引入

要理解一个 C 程序,仅仅理解组成该程序的符号是不够的。程序员还必须理解这些符号是如何组合成声明、表达式、语句和程序的。虽然这些组合方式的定义都很完备,几乎无懈可击,但有时这些定义与人们的直觉相悖,或者容易引起混淆。本章将讨论一些用法和意义与我们想当然的认识不一致的语法结构。


理解函数声明

原文:

有一次,一位程序员与我交谈一个问题。他当时正在编写一个独立运行于某种微处理器上的 C 程序。当计算机启动时,硬件将调用首地址为 0位置的子例程,为了模拟开机启动时的情形,我们必须设计出一个 C 语句,以显式调用该子例程。经过一段时间的思考,我们最后得到的语句如下:
(*(void(*)())0)();
像这样的表达式恐怕会令每个 C 程序员都“不寒而栗”。不过,他们大可不必对此望而生畏,因为构造这类表达式其实只有一条简单的规则: 按照使用的方式来声明。

作者在此抛出了一个问题,即 ((void(*)())0)0);这个语句是如何运行的?

在此,我先向大家讲述几个预备知识,我们再回过头来看这个语句。

它们分别是:表达式,声明,函数名本质。

表达式

什么是表达式

有人说:“一个语句就是一个表达式。”;有人说:“一个括号内的判断条件就是一个表达式”;等等…

其实大家并不会去纠结到底什么是表达式,但是心里多多少少会对其有一定的认知,接下来我就为大家仔细讲解一些表达式的知识。

表达式的定义为:

由一系列运算符与操作数组成的序列

这个概括未免有点笼统,也就是因为其太笼统了,所以很多意想不到的式子也能被称为表达式,我先抛出我对表达式的比较具体的定义:

1.由常量,变量,函数组成,用C语言语法规则,用运算符链接起来的式子称为表达式

2.一个表示式可以没有运算符,但不能没有操作数。

第一句话好理解,第二句话我用例子来说明:

我们在定义一个变量时,可以不赋值,只声明其类型int a;,比如这个语句中,a本身就是一个表达式,在a这个表达式中,没有出现任何操作符,仅仅由一个变量组成;再比如while(i),在这个语句中,i这个变量是while循环的判断条件,i本身也是一个表达式。

此外,函数调用语句也是一个表达式,printf("Hello world")就是一个表达式。

表达式的目的

对于表达式的目的,相信大家也是众说纷纭,有太多表达式可以做的事情了,光是C语言的库函数就数不过来了,一个函数一个功能,那表达式的目的不就有成千上万种?

其实不是的,在C语言中,表达式只有三种作用,我先将其列举出来,再做详解:

1.计算数值

2.指明作用对象

3.产生副作用

计算数值:

这个很好理解,比如while(a - b )这个语句,a - b就是一个可以产生数值的表达式,大部分表达式可以返回一个数值,比如调用函数后的返回值,或是一个普通的计算表达式。

但是也有表达式不产生返回值,比如void类型的函数。

指明对象:

指明对象的过程,就是告诉编译器一个数据应该存在哪一个内存,或者从哪一个内存取用。如c = a + b,我们刚刚说过,一个变量本身也是表达式,所以此处的a,b,c本身都是一个表达式。a与b表达式的作用就是指明a与b对于的内存,让编译器去取用,而c表达式的作用就是指明c的内存,来存储表达式a + b的返回值。

产生副作用:

什么是副作用呢?

其实除了以上两条以外,都是副作用。

比如:c = a + b这个语句,它除了计算出一个值,还有什么作用?答案是对变量c赋值。其实我们在使用这样的一个语句的时候,多半就是为了用c来接收a+b的值,但是在定义中,这确实是一个副作用。

再比如:printf("Hello world")这个语句,它的主要目的是什么?

大部分人会回答输出一个字符串,其实并不是,它的主要作用是得到返回值,printf函数的返回值是字符串中字符的个数,所以此处printf的目的是返回11这个值。而输出hello world只是它的副作用。

我们刚刚简单给大家讲了什么是表达式,我们了解到,表达式的主要目的就是为了求值,那么一个表达式的返回值应该是什么类型呢?这就涉及到了声明:

声明

原文:

任何C变量都由两部分组成:类型以及一组类似表达式的声明符(declarator),声明符从表面上看与表达式有些类似,对它求值应返回一个声明中给定类型的结果。

也就是说,声明的作用是规范一个表达式的所求的值是什么类型的。此处的声明类型极为广泛,包括函数声明,变量声明。

变量的声明:

对单个变量的声明:比如int a,它由两部分组成,int是类型,a是声明符(理解为表达式即可)。此语句的意思就是:对于a这个表达式,求值后得到的返回值必须为int类型。

对指针的声明:比如char *p,这个语句有两个理解方式,大部分的现代的理解方式为,变量p,其类型为char*。

而从声明的角度来说,它的理解应该是:一个表达式*p,其求值后返回值为char类型。这也就说明p是一个指向char类型变量的指针。

当然,这两种理解方式没有谁好谁坏,只是角度不同,前者从变量p本身理解,而后者从声明来理解。

函数的声明:

对有了前面对声明的解释,对函数的声明也就不难理解了:

float func()这样一个声明语句,表达的意思就是:对于函数表达式func(),其求值后返回值类型为float类型。

以上的两中声明类型也是可以混合着来的,作者给出了两个语句对比分析:

float *g()float (*h)()

接下来我带大家辨析一下:

首先,造成它们的区别的最大前提就是:操作符*的优先级是小于()的

对于*g()这个表达式,g先和()结合,那么g的本质就是函数g()再和*结合成*(g())。从声明的角度解释:*(g())的返回值必须是float类型。那么float *g()的意思就是:g()函数是一个返回值为float*的函数。

对于(*h)()这个表达式,h由于被圆括号改变了结合顺序,h先和*结合,那么h的本质就是指针,再与()结合,说明这是一个函数指针。float (*h)()是一个指向返回值为float的函数的指针。

函数名本质

我们尝试监视一个函数名:

可以看到,函数名的值是一个地址,而&add也是一个地址,而且两者地址相同。所以函数名本质上是一个指针

我们平常调用函数的时候,函数名()这样的表达式,函数名是该函数的指针,那我们是不是也可以取一个函数指针出来,然后在指针后面加上小括号来调用函数呢?我们试一试:

此处我们发现,p得到了add函数的地址后,居然就可以直接用p()来代替add()了,这就更进一步说明了:add函数名本身是对应函数的指针。

但是我们对比一下p()(*p)(),发现它们居然都能执行这个函数调用,作者在此特殊声明:*ANSIC标准允许将(*p)()简写为p()

所以说,p()其实是一个简写,而我们平常在调用函数的时候,其实也是直接用函数名来调用,也就是以简写的形式调用。

对复杂代码分析

代码1

有了这些预备知识,我们接下来就可以分析表达式(*(void(*)())0)();了:

作者说过,这个代码的目的是让一个函数在0地址处被调用,也就是说我们需要一个0地址处的指针。

我们在表达式中只看到了一个数字0,没错,这就是0地址,但0单独出现,只表示一个整型常量,所以在此,我们要将这个int类型的0,强制转化为一个函数类型:

这个需要调用的函数是什么样的作者在文中并没有明说,先假设这个函数的函数名为func。

我们可以通过(void(*)())这个强制转化,推断出来此函数为void func()。既然我们要将0转化为此函数的指针,那就要得到这个函数的指针。我们将此函数写为完全形式void (*func)(),想要得到此函数的指针类型,只需要将这个函数的函数名去掉即可。所以此函数的指针类型就是void(*)(),将函数类型带个小括号,放在0前面,0就被强制转化为了对应的函数指针。

得到0处的函数指针后:

如果一个函数指针为p,调用这个函数就是(*p)(参数)或者p(参数)。我们的函数是void func(),其没有参数,此处作者采用的是没有简化的方法,所以调用形式就是(*p)(),然后将p指针改为我们之前得到的强制转化后的0指针就是(*(void(*)())0)();

所以此语句的作用就是:在0地址处调用void func()函数。

代码2

在解决这个问题之后,作者抛出了第二串代码void (*signal(int, void(*)(int)))(int),接下来我们对其分析:

请问这个语句是一个什么过程?

我们可以很明显看出,内部是有函数参数的,但是上述的三个int,后面都没有数据,这说明这不是一个函数调用过程,而是一个函数的声明过程。

signal其实是一个C语言的库函数:

而上述代码就是对signal函数的声明,不过在声明函数时,是可以去掉参数名的。所以作者给出的声明是上图C的声明的去参数版本。

我先简单介绍signal函数,使用signal函数,需要给signal传入两个参数,一个int类型,一个函数指针类型。当signal被调用后,就会返回这个传入函数的指针。

所以signal(int, void(*)(int))这一部分,就是signal函数名以及它的两个参数。既然是声明过程,那就要声明一个返回值,signal函数的返回值就是传入的那个函数 void (*func)(int),那么这个函数的指针就是 void (*)(int)。这也就是signal的返回类型。

综上,signal的声明就应该是:

void (*)(int) signal(int, void(*)(int))

对吗?

其实不对,在声明的语法规定中,要求如果函数的返回类型为函数指针,返回类型后面的部分要放在*后面

我们的返回类型为void (*)(int) ,后面的一大段signal(int, void(*)(int)) 先用x代替。在经过声明的语法规范后,就应该这样:void (*x)(int) ,随后将x替换为signal(int, void(*)(int)) ,就可以得到void (*signal(int, void(*)(int)))(int)


运算符的优先级

作者在第二个小节提出了操作符的优先级来带的问题,并举出了几个案例,来说明对操作符优先级不熟悉会带来的问题,随后又总结了几个操作符的大概优先级,在此我将此小节内容整理为了一个表格,我将先介绍此表格,后分析作者给出的案例。

优先级 运算符 描述 类型 操作数 结合性
1 ++ - - 后置自增与自减 --- --- 从左到右
( ) 函数调用 与访问有关的操作符
[ ] 数组下标
. 结构体与联合体成员访问
-> 结构体与联合体成员通过指针访问
(type){list} 复合字面量 ---
2 ++ - - 前置自增与自减 --- 单目操作符 从右到左
+ - 一元加与减
! ~ 逻辑非与按位非
(type) 强制类型转化
* 间接(解引用)
& 取址
sizeof 取大小
3 * / % 乘法、除法及余数 算数操作符 双目操作符 从左到右
4 + - 加法及减法
5 << >> 按位左移及右移 移位操作符
6 < <= > >= 分别为 < , ≤ ,> ,≥ 的关系运算符 关系操作符
7 == != 分别为 = 与 ≠ 关系
8 & 按位与 位操作符
9 ^ 按位异或(排除或) 单目
10 | 按位或(包含或) 双目操作符
11 && 逻辑与 逻辑操作符
12 || 逻辑或
13 ? : 三元条件 --- 三目操作符 从右到左
14 = 简单赋值 赋值操作符 ---
+= -= 以和及差赋值
*= /= %= 以积、商及余数赋值
<<= >>= 以按位左移及右移赋值
&= ^= |= 以按位与、异或及或赋值
15 , 逗号 --- --- 从左到右

以上表格可以总结为(这也差不多是作者的总结):

1.与访问有关的操作符优先级非常高,甚至高于单目操作符

2.单目操作符 > 双目操作符 > 三目操作符,优先级呈递减趋势(除去按位异或)

3.在双目操作符中:算数 > 移位 > 关系 > 位 > 逻辑

4.所有与赋值有关的操作符,优先级都非常低,甚至低于三目操作符

这些条目都非常好记,也很好理解,基本记忆这几条,就能解决C语言中99%的操作符优先级带来的问题。

如果你认为记忆第三条略有些麻烦,在平常编程时积累也未必不行,但是抛开第三条,我希望大家脑海里可以有一个大致的轴:

访问 -> 单目-> 双目-> 三目-> 赋值

接下来我们带着这四条规则去看看作者提出的案例:

  1. if (flags & FLAG != 0)
    此处程序员目的是:判断flags与FLAG按位与后,是否为0。
    而根据操作符的优先级,关系操作符的优先级是大于位操作符的,所以会先判断!=,后面才按位与,故此代码有误。
  2. while(c = getc(in) != EOF)
    此处程序员的目的是:让c接收getc函数的返回值,并判断输入是否成功。
    但是根据第四条,所有类型的赋值操作符优先级都是最低档的,所以这个表达式会先判断!=,后赋值。
  3. *func()
    这已经是一个老生常谈的问题了,也就是解引用与函数调用的优先级关系,分析如下:
    ()是一个访问类型的操作符,优先级极高。而*是一个单目操作符,优先级低于访问类型的操作符,所以func会先和()结合成函数,在解引用函数返回值。
    故对上述代码的错误理解为:func作为一个函数名,本质是指针,先解引用fuc,再调用,函数
    正确理解:func函数的返回值是一个指针,func()先调用后,将返回值解引用。

switch语句

原文:

*C语言的switch 语句的控制流程能够依次通过并执行各个 case 部分,这一点是C语言的与众不同之处。考虑下面的例子,两段程序代码分别用 C语言和 Pascal语言编写:

C语言:

switch(color){

case 1:

printf("red");

break;

case 2:

printf("yellow");

break;

case 3:

printf("blue");

break;

Pascal语言:

case color of

1: write('red');

2: write('yellow');

3: write('blue');

end

上述两串代码的最大区别就是break语句,其实两个语言在这方面的大致逻辑差不多,都是根据输入的整型值,来进入某一个分支,并输出其结果。

但是对于C语言switch语句本身,我认为这样理解更贴切:根据输入的整型值,进入某个case,并执行此case开始往下所有case的所有语句

在switch语句没有break参与时,它会从当前case往下执行,直到整个switch结束,而非当前case结束。而break参与,就会在执行完某个case后跳出switch。

对于上述Pascal的示例中。Pascal并没有在每个分支后面都加上end语句,这是因为pascal语言的每个分支都会偷偷包含一个end语句,来终止上一分支进入下一分支。这也就是为什么Pascal的分支语句最后要加一个end,因为最后一个分支下面没有其它分支了,也就没有一个隐藏的end来跳出整个语句。所以要用一个end来终止。

但是C语言这样的特性也可以带来一些妙用,比如作者给出的一个加减法计算器:

switch(choose)//选择1执行减法,选择2执行加法
  case 1:
    num1 = -num1;
    //此处没有break
  case 2;
    sum = num1 + num2;
    break;

上述代码中,在case1中利用了一个取反操作,当用户输入2,执行的就是num1 + num2的加法操作。而当用户输入1,就会先进行一次取反,由于缺少一个break,会继续执行case2,继续加法。但是由于刚刚case1取反过了,结果其实是num2 - num1,就完成了减法操作。

所以C语言的这个特性,也是有利有弊的。


悬挂else

#include <stdio.h>
int main()
{
  int a = 0;
  int b = 2;
  if(a == 1)
    if(b == 2)
    printf("hehe\n");
  else
    printf("haha\n");
  return 0;
}

作者在最后,提出了悬挂else的问题,这个问题常常是因为代码书写不规范导致的。

我们先分析上述代码,请问输出结果是什么?

答案是啥都不输出。

这就是悬空else 的问题,可以记住这样一条规则,如果有多个if 和else ,else 总是跟最接近的if 匹配

上面的代码排版,让else 和第一个if 语句对齐,让我们以为else 是和第一个if匹配的,当if语句不成立的时候,自然想到的就是执行 else 子句,打印haha 。

但实际上else 是和第二个if进行匹配的,因为else距离第二个if比较近,这样后边的if…else 语句是嵌套在第一个if 语句中的,第一个if 语句就不成立,第二个if 就没机会执行了,最终啥都不打印。


相关文章
|
5月前
|
编译器 C语言
C语言编程陷阱:语法陷阱
c语言要求在函数调用时即使函数不带参数,也应该包括函数列表。 是挂else问题
30 0
|
7月前
|
存储 自然语言处理 编译器
C陷阱与缺陷
C陷阱与缺陷
31 0
C陷阱与缺陷
|
3月前
|
测试技术
常见测试陷阱
常见测试陷阱
|
3月前
|
自然语言处理 编译器 程序员
C陷阱与缺陷:词法陷阱
C陷阱与缺陷:词法陷阱
26 0
|
11月前
|
编译器 C语言
《C陷阱与缺陷》之“语义”陷阱——数组越界导致的程序死循环问题
《C陷阱与缺陷》之“语义”陷阱——数组越界导致的程序死循环问题
88 0
|
自然语言处理 编译器 程序员
【C陷阱与缺陷】----语法陷阱
由于一个程序错误可以从不同层面采用不同方式进行考察,而根据程序错误与考察程序的方式之间的相关性,可以将程序错误进行划分为各种陷阱与缺陷
68 0
|
自然语言处理 编译器 程序员
《C陷阱与缺陷》----词法“陷阱”
由于在C语言中赋值操作相对于比较出现更加频繁,所以将字符较少的符号=赋予更常用的含义—赋值操作。
68 0
|
存储 人工智能 自然语言处理
【C缺陷与陷阱】----语义“陷阱”
那获得该下标为0的元素的指针,如果给这个指针加1,就能得到指向该数组中下一个元素的指针。也就是指针+一个整数得到的还是指针,只不过指针的位置发生改变
80 0
|
自然语言处理 编译器 C语言
|
自然语言处理 算法 编译器