C 语言问题
1. 如何生成 “半全局变量”, 就是那种只能被部分源文件中的部分函数访问变量?
答:
这在C语言中办不到. 如果不能或不方便在一个源文件中放下所有的函数, 那么有三种的解决方案 :
(1) 为一个库或相关函数的包中所有函数的包中的所有函数和全局变量增加一个唯一的前缀, 并警
告包的用户不能定义和使用文档中列出的公有符号意外的任何带有相同前缀的其它符号. (换言之,
文档中没有提及的带有相同前缀的全局变量被约定为 “私有”)
(2) 使用以下划线开头的名称, 因为这样的名称普通代码不能使用. (下划线开头表示”私有”, 是一
种约束和建议)
(3) 通过连接器操作, 例如
- piyo.c
int love = 1313; - hoge.c int like_you(void) { extern int love; return love + 1; }
在链接 hoge.o 的时候, 也需要 piyo.o 确定最终”半全局变量”地址.
2. 如何判断哪些标识符可以使用, 那些被保留了 ?
答:
(1) 标识符的3个属性: 作用域, 命名空间和链接类型.
[] C 语言有4种作用域(标识符声明的有效区域): 函数, 文件, 块和原型. (第4种类型仅仅存在于函
数原型声明的参数列表中)
[] C 语言有4种命名空间: 行标(label, 即 goto 的目的地), 标签(tag, 结构, 联合和枚举名称), 结构
联合成员, 以及标准所谓的其它”普通标识符”(函数, 变量, 类型定义名称和枚举常量). 另一个名称集(
虽然标准并没有称其为”命名空间”)包括了预处理宏.这些宏在编译器开始考虑上述4种命名空间之前
就会被扩展.
[] 标准定义了3中”链接类型”: 外部链接, 内部链接, 无链接. 对我们来说, 外部链接就是指全部变量,
非静态变量和函数(在所有的源文件中有效); 内部链接就是指限于文件作用域内的静态函数和变量; 而
“无链接”则是指局部变量及类型定义(typedef)名称和枚举常量.
(2) ANSI/ISO C标准标识符标准建议规则:
规则1: 所有下划线大头, 后跟一个大写字母或另一个下划线的标识符永远保留(所有的作用域, 所
有的命名空间).
规则2: 所有以下划线打头的标识符作为文件作用域的普通标识符(函数, 变量, 类型定义和枚举常
量)保留(为编译器后续实现保留).
规则3: 被包含的标准头文件中的宏名称的所有用法保留.
规则4: 标准中所有具有外部链接属性的标识符(即函数名)永远保留用作外部链接标识符.
规则5: 在标准头文件中定义的类型定义和标签名称, 如果对应的头文件被包含, 则在(同一个命名
空间中的)文件作用域内保留.(事实上, 标准声称”所有作用于文件作用域的标识符”, 但规则4没有包含
标识符只剩下类型定义和标签名称了.)
3. char a{[3]} = “abc”; 是否合法 ?
答:
也许远古时期这样的表达式是合法的, 但现在(2018-08-14)是不合法的!
> error C2143: 语法错误: 缺少“;”(在“{”的前面) > error C2143: 语法错误: 缺少“;”(在“[”的前面) > error C2109: 下标要求数组或指针类型 > fatal error C1004: 发现意外的文件尾
但 char a[3] = “abc”; 是合法的. 最后的 ‘\0’ 没有填充进去.
4. 程序运行正确, 但退出却 “core dump”(核心转存)了, 怎么回事?
struct list { struct list * next; char * item; } /* Here is the main program */ main(argc, argv) { puts("Hello, 世界"); }
答:
!!! 不要和写出上面的格式的人说代码, 怕你沟通能力有问题 ~
也许以前是崩溃, 但现在并没有, 可以正常运行. 书中对于崩溃给出理由是, 一般而言, 返回结构的函数
编译器在实现时,会加入一个隐含的返回指针, 这样产生的 main 函数试图接受 3 个参数, 而实际上只有
两个传入(这里,由C的启动代码传入).
他说的很有挖掘价值, 但先进一点编译器兼容性很好. 它也为 main 函数在启动函数栈中构建了返回结
构的实体, 所以没有崩溃.
我们来看这样一段代码
1 #include <stdio.h> 2 3 struct list { 4 struct list * next; 5 6 double number; 7 char * item; 8 int piyo; 9 }; 10 11 struct list list_get(void) { 12 return (struct list){ NULL, 0, "Hello, 世界", 1 }; 13 } 14 15 /* Here is the main program */ 16 int main(int argc, char * argv[]) { 17 struct list node = list_get(); 18 puts(node.item); 19 20 return 0; 21 }
运行到 17 行调试看反汇编代码
struct list node = list_get(); 002444EE lea eax,[ebp-0FCh] 002444F4 push eax 002444F5 call _list_get (024137Ah)
上面 push eax 表示传入了隐式地址. 这里有个有意思的现象, 如果我们返回的结构体很小例如
struct list { struct list * next; char * item; };
x64 是 16 字节, 编译器直接通过两个寄存器搞定, 来帮我们优化代码. 也不会在调用局部栈中构
造一个对象, 传入地址.
在我们返回结构体时候, 编译器帮我们”隐含” 传入结构体指针(寄存器)参数. 目前不推荐这样的做
法, 因为存在浅拷贝性能浪费. 这也是 C++ 引入移动复制的原因. 但没有屌用, 因为这本身就应该
编译器去做.而不是让程序员和编译器双宿双飞, 可能下一代智能编译器会优化的更好. 标准应该
推荐采用下面做法. 从这细节也可以看出, C 系列程序员对操作系统有种天然亲和力, 这种亲和力
也是把双刃剑, 让他太过于着魔, 落叶缤纷, 而败北在杨柳树下 ~
// 显示声明 void list_get(struct list * const out) { out->next = NULL; out->item = "Hello, 世界"; } // 调用 struct list node; list_get(&node);
5. 可否用显式括号来强制执行我所需要的计算顺序并控制相关副作用? 就算括号不行, 操作符优先
级是否能够计算顺序呢?
答:
一般来说, 不行. 操作符优先级和显示括号对表达式的计算顺序只有部分影响. 在如下的代码中
f() + g() * h()
尽管我们知道乘法运算在加法之前, 但这并不能说明这3个函数哪个会被调用. 换言之, 操作符优先
级只是 “部分” 地决定了表达式的求值顺序. 这里的 “部分” 并不包括对操作数的求值.
括号告诉编译器那个操作数和那个操作数结合, 但并不要求编译器先对括号内的表达式求值 .
在上面表达式中再加括号
f() + ( g() * h() )
也无助于改变函数调用的顺序. 同样, 对 i++ * i++ 的表达式加括号也毫无帮助, 因为 ++ 比 * 的优
先级高:
(i++) * (i++) /* WRONG */
这个表达式有没有括号都是未定义的.
如果需要确保子表达式的计算顺序, 可能需要使用显式的临时变量和独立语句.
6. 我有些代码包含这样的表达式.
a ? b = c : d
有些编译器可以接受, 有些却不能. 为什么 ?
答:
在 C 语言原来的定义中, = 的优先级是低于 ? : 的, 因此早期的编译器倾向于这样解释这个表达式:
(a ? b) : (c : d)
然而, 因为这样没什么意义, 后来编译器都接受了这种表达式, 并用这样的方式解释(就像里面暗含
了一对括号);
a ? (b = c) : d
这里, = 号的左操作数只是 b, 而不是非法的 a ? b. 实际上 ANSI/ISO C 标准中指定的语法就要求这
样的解释. (标准中关于这个的语法不是基于优先级的, 且指出了在 ? 和 : 符号之间可以出现任何表
达式).
问题中这样的表达式可以毫无问题地被 ANSI 编译器接受. 如果需要在较老的编译器上编译, 总
可以增加一对内部括号.
历史总是那么有意思, 现在编译器都已经支持这个跳过优先级的而存在的表达式了 ~ 毕竟那是
标准.
7. 我有一个 char * 型指针碰巧指向一些 int 型变量, 我想跳过它们. 为什么 ((int *)p)++; 这样的代码
不行?
答:
在 C 语言中, 类型转换操作符并不意味者 “把这些二进制位看作另一种类型, 并作相应的处理”. 这是一个
转换操作符, 根据定义它只能生成一个右值(rvalue). 而右值即不能赋值, 也不能用 ++ 自增. (如果编译器
接受这样的表达式, 那要么是一个错误, 要么是有意做出非标准扩展.) 要达到你的目的可以用
p = (char *)((int *)p + 1);
或者, 因为 p 是 char * 型, 直接用
p += sizeof(int);
要想真正明白无误, 你得用
int * ip = (int *)p;
p = (char *)(ip + 1);
但是, 可能的话, 你还是应该一开始就选择适当的指针类型, 而不是一味地试图桃僵李代.
8. 我看到下面这样的代码:
char * p = malloc(strlen(s) + 1);
strcpy(p, s);
难道不应该是 malloc((strlen(s) + 1) * sizeof(char)) 吗?
答:
永远不必乘上 sizeof(char), 因为根据定义, sizeof(char) 严格为 1. 另一方面, 乘上 sizeof(char) 也没有
害处, 有时候还可以帮忙为表达式引入 size_t 类型.
而且就算 char 类型定义为 16 位, sizeof (char) 依然是1, 而 <limits.h> 中 CHAR_BIT 会被定义为 16. 届
时将不能声明 (或用 malloc 分配) 一个 8位的对象.
传统上, 一个字节不一定是8位, 它不过是一小段内存, 通常适于存储一个字符. C 标准遵循了这种用
法, 因此 malloc 和 sizeof 所使用的字节可以是 8 位以上(8位字节正式称为八位字节 octet, 标准不允许
低于 8 位).
为了不用扩展 char 类型就能操作多语言字符集, ANSI/ISO C 定义了 “宽”字符类型 wchar_t 以及对
应的宽字符常量和宽字符串字面量, 同时也提供了操作和转换宽字符串函数.
可能有些令人惊讶, 在C语言中字符字面量是 int 类型, 因此 sizeof (‘a’) 是 sizeof (int) 而不是 sizeof (char)
这是和 C++ 中不同地方, C++ ‘a’ 被当作 char 类型字符字面量.
9. 我很吃惊, ANSI 标准竟然有那么多未定义的东西. 标准的唯一任务不就是让这些东西标准化吗?
答:
某些构造随编译器和硬件的实现而变化吗这一直是C语言的一个特点. 这种有意的不严格可以让编译器
生成效率更高的代码, 而不必让所有程序为了不合理的情况承担额外的负担. 因此, 标准只是把现存的实
践整理成文.
编程语言标准可以看作是语言使用者和编译器实现者之间的协议. 协议的一部分是编译器实现者同
意提供, 用户可以使用的功能. 而其它部分则包括用户同意遵守和编译器实现者认为会被遵守的规则. 只
要双方都恪守自己的保证, 程序就可以正确运行. 如果任何一方违反它的诺言, 则结果肯定失败.
面对未定义行为的时候(包括范围内的实现定义行为和不确定行为), 编译器可能做任何实现, 其中也
包括你所期望的结果. 但是依赖这个实现却不明智.
Roger Miller 提供了看待这个问题另一个角度:
”有人告诉我打篮球的时候不能抱着球跑. 我拿个篮球, 抱着就跑, 一点问题都没有. 显然他并不懂篮球.“
10. 有什么好的方法来检查浮点数在 “足够接近” 情况下相等?
答:
浮点数的定义决定它的绝对精度会随着其量级而变化, 所以比较两个浮点数的最好方法就要利用一个浮
点数的量级相关的精确阈值. 不要用下面这样的代码:
double a, b; ... if (a == b) /* WRONG */
要用类似这样的方法(相对因子):
#include <math.h> #include <float.h> if (fabs(a - b) <= fabs(a) * DBL_EPSILON)
#define DBL_EPSILON 2.2204460492503131e-016 // smallest such that 1.0+DBL_EPSILON != 1.0
DBL_EPSILON 是 float.h 中一个特定极小的值来控制”接近度”.
if (fabs(a - b) < 0.001) /* POOR */
对于上面 0.001 这样的绝对模糊因子恐怕难以持续有. 随着被比较的数不断变化, 很有可能两个较小的, 本
不应该看作不相等的数正好相差小于 0.001, 而两个本应看作相等的两个大数相差大于 0.001. (显然, 模糊
因子修改为0.0005或者0.00001或者其它任何绝对数都无助于解决这个问题.)
Doug Gwyn 推荐用下面的 “相对差” 函数. 它返回两个实数的相对差值, 如果两个数完全相同, 则返回
0.0, 否则, 返回差值和较大数的比值:
#include <math.h> #include <float.h> #include <stdlib.h> inline double reldif(double a, double b) { double c = fabs(a), d = fabs(b); d = max(c, d); return d == 0 ? 0 : fabs(a-b)/d; }
典型的用法是:
if (reldif(a, b) < DBL_EPSILON) ...
11. 我有个接受 float 型的变参函数, 为什么 va_arg(arg, float) 却不行 ?
答:
“默认参数提升” 规则适用于可变参数中可变部分: 参数类型为 float的总是提升到 double, char 和 short
提升到 int (无符号 unsigned). 所以 va_arg(arg, float) 是错误用法. 应该使用 va_arg(arg, double). 同理,
要用 va_arg(arg, int) 来取得原来类型是 char, short 或 int 的参数. 基于同样的理由, 传给 va_start 的最
后一个”固定”参数类型不会被提升. (printf(char const* const fmt, …) 类比 fmt 参数一定不会被提升.)
12. 用什么方法计算整数中为1的位的个数最高效?
答:
许多像这样的位操作可以使用查找表格来提高效率和速度. 这段代码是以每次4位的方式计算数值中为1
的位个数的小函数:
int bitcnt(unsigned u) { static int bitc[] = {0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4}; int n = 0; while (u) { n += bitc[u & 0xFF]; u >>= 4; } return n; }
这个查表思路极快, 突破在于更大的表, 空间换时间. 还有一种微软面试题套路是
int count(unsigned u) { int n = 0; while (u) { ++n; u = (u-1) & u; } return n; }
二者对比一下, 最坏情况 32 个 1 第一个好, 最好情况 0 个 1 两个一样. 但对于 0xF000 情况前者是4次循环,
后者 1次. 但拍脑门还是前者好. 预计 32位无符号数出现 1 的期望是 16, 前者最坏执行 8 次, 后者一定要执
行16次.从数学期望上面而言前者占优势, 毕竟算法 1 后续还可以构建一字节表更迅速 ~ 权当一乐.
13. 什么是 “达夫设备” (Duff’s Device)
答:
这是个很棒的迂回循环展开法, 由 Tom Duff 在 Lucasfilm 时设计. 它的 “传统” 形态是用来复制多个字节
void copy(int * to, int from[], int count) { register n = (count + 7) / 8; /* count > 0 assumed */ switch(count % 8) { case 0: do { *to = *from++; case 7: *to = *from++; case 6: *to = *from++; case 5: *to = *from++; case 4: *to = *from++; case 3: *to = *from++; case 2: *to = *from++; case 1: *to = *from++; } while (--n > 0); } }
这里 count 个字节从 from 指向的数组复制到 to 指向的内存地址(这是个内存映射的输出寄存器, 这也是
为什么它没有被增加). 它把 switch 语句和复制 8 个字节的循环交织在一起, 从而解决了剩余字节的处理
问题(当 count 不是 8 的倍数时). 信不信由你, 像这样的 case 标志放在嵌套在 switch 语句内的模块中是
合法的. 当他向 C 的开发者和世界公布这个技巧时. Diff 注意到 C 的 switch 语法, 特别时”跌落”行为, 一
直是备受争论的, 而 “这段代码在争论中形成了某种论据, 但我不清楚是赞成还是反对”.
后记 – 引述
– 错误是难免, 欢迎指正交流提升 ~
13 年刚工作的时候在地铁上看完 <<C语言问题>>, 随后就扔掉了. 过去好久, 18 年有幸又买了一本
<<C语言问题>> 看完后被其中好多段子说的心痒难耐. 所以就记录一些很经典的讨论, 供大家一块开
怀. 了解那些尘封在历史长河中, 问题由来的真相 ~
——-: ( :–
酷 – https://y.qq.com/n/yqq/song/003K5qlb0r7BDb.html?ADTAG=baiduald&play=1
我们都会上岸,阳光万里,去哪里都是鲜花开放。