关于 C 语言的面试问题

由来

这是我在作为学生组织的面试官时,对熟练掌握 C/C++ 的面试者提出几个的问题,以及对于这几个问题我自己的答案。

语法规则和特定技巧很大程度上只是记忆的问题,而在几个事实上知晓与否并不能断定面试者的高下。C 语言是一门十分接近底层实现的语言,许多的设计决定与实现直接相关,如果想要合适地运用,也要求程序员对于 C 的实现有透彻的理解。因此,这几个问题主要考察的是面试者在 C 原理与设计层面上的理解。

问题

  1. 为什么以下的语句不会导致非法内存访问?

    1
    2
    3
    4
    5
    struct {
    int i;
    float f;
    } *s = NULL;
    printf("%p", &(*s).f);
  2. C++ 中的类实现了继承。请简单阐述如何在 C 中实现 struct 的继承。

  3. 在 C 中 void * 类型可以自动转换成其他指针类型,但在 C++ 中需要显式转换。请谈一谈 C 和 C++ 为什么采取了这两种设计。

  4. (请简述 C 语言中变量自动初始化的规则。)

    在 C 语言中,全局变量和静态局部变量可以自动初始化为全零,但局部变量不会进行自动初始化。请谈一谈 C 为什么采取了这样的设计。

我的答案

  1. C 语言中类型只存在于编译前,struct只是对偏移量计算等操作的简化而已,因此,计算 &(*s).f,不过是计算s + sizeof(s.i) 而已,并不会像其他语言那样导致空指针异常。

  2. 因为 C 中的 struct 不过是对偏移量计算的简化,可以通过在子类的头部直接放置一个父类成员,之后进行类型转换,就能实现数据成员的继承,示例如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    typedef struct {
    int i;
    } base_t;
    typedef struct {
    base_t base;
    float f;
    } derived_t
    derived_t *derived = malloc(sizeof(derived_t));
    base_t *base = (base_t *) derived;

    为了实现函数的继承和多态,则需要函数指针,this指针参数,构造器等实现,此处不再赘述。

  3. 在 C 中,void *指向的是未知类型的内存区域,无论按照什么类型理解,都是合法的;并且 malloc 这样的常用调用返回类型只能是void *,允许自动转换可以大大减少麻烦。

    在 C++ 中,类型可能是类,而为了实现面向对象编程的特性,在类中保存了一些元信息,因此将一块未知的内存区域作为一个类来理解,这个操作不一定合法;为了保持类和非类指针在语法上的统一,选择了禁止 void * 的自动转换。

  4. 由于很多全局变量的初始值都需要是零,而在可执行文件中存储这么多零并没有意义,因此 C 语言规定全局变量初值为零,由此可以在生成的可执行文件中只记录全局变量所需空间的大小,而省略为零的值。至于静态局部变量,它的生命周期要求与全局变量相同,只是在编译时赋予了不同作用域的限制而已。为了实现这个特性,在大多数操作系统中,全局变量和静态局部变量被存放在一个全局的数据存储位置(BSS 段),在程序开始执行前由系统进行分配和清零。

    局部变量与函数调用相关,在系统调用栈上动态分配,自动初始化将会在运行时不断带来额外负担,因此 C 语言将初始化交给程序员处理,不进行自动初始化。

结语

理解了 C 的实现与设计之后,使用 C 就得心应手了--你了解语言这样设计的原因和目的,也知道自己的一行代码会被翻译成怎样的指令,由此,豁然开朗。

当然,C 有适合的任务,也有不适合的任务,任何语言都是如此,所以复杂性上的欠缺并不影响我对 C 语言的评价。

大道至简,C 的许多设计与 Unix 哲学若合一契。也是因此我对 C/C++ 这种说法不太喜欢。