C的设计失误

日常使用中,可能不时觉得某些编程语言里面有一些不好用的地方。当然大多设计都同时有优缺点,我们要理解其中的权衡。 不过以现在的眼光来看,以前有些设计的缺点明显大于优点,这就不能完全推脱为权衡,而可以称之为设计失误了。当然两者的界限不太分明,见仁见智,这里就谈谈我的看法。

我准备总结并吐槽一下我平时使用过的一些编程语言的种种设计问题。当然不是为了批评,有些语言是在几十年前设计的,在当时缺乏足够的实践教训和前人经验(或者是缺乏经验导致的不重视设计细节)的情况下,这些问题都是情有可原的(不过某些所谓现代语言仍然继承了,甚至自创了一些设计失误就值得批判了)。总结这些设计失误,一是引起重视,帮助我们在使用中尽量避免一些问题,二是帮助我们理解一些现代语言为何采取了不同的设计。

这里,就先从大名鼎鼎的C开刀(设计并不全部由C首创,但是起码照搬了没改,同时也影响深远),有些在新标准里面有所改善。另外考虑到C的年龄,从缺乏特性挑刺有点欺负人,这点就尽量不谈。

变量默认可变

声明变量默认是可变的,声明不可变变量需要额外加上const进行修饰。

我们要知道

  • 内存及线程不安全的根源是共享和可变性的共存。而当确定一个对象不可变时,我们就可以放心地共享它,同时方便编译器进行更彻底的优化。
  • 默认不可变更有利于程序的安全性。默认不可变时,如果我们修改了不可变变量,就会被编译器警告,我们确认是否真的需要这个变量可变,然后进行修改;变量默认可变的情况下,我们经常将不需变化的变量声明为可变的,编译器并没有什么意见,如果误改了不应该变化的变量较难发现。
  • 实际使用中,需要变化的变量并不显著多于固定的变量,特别是我们鼓励多使用中间变量提高可读性的情况下,不需变化的变量就更多,同时编译器的优化也能保证更多的中间变量并不会增加存储空间和性能的消耗。

从以上原因来看,可变和不可变中,如果要选择一个作为默认,有什么理由选择可变呢?

另外有些语言里也有可变与不可变地位平等,没有哪个是所谓的默认,比如val声明不可变,var声明可变。当然,基于以上理由,个人还是更倾向默认不可变。

基础类型偏执

其实这里讲的问题严格上不是基础类型偏执,但是对这个问题我没有找到合适的专有名词。

基础类型偏执,是指某些人偏好并尽可能使用基础类型,而不是定义一个新类型进行抽象,从而影响可读性和可重用性;主要是针对程序员编程的时候,偏向于使用基础类型,而不是构造复合类型。我这里讲的主要针对于语言设计的时候把不同用途的东西归于一个类型。严格来讲这是两个不同的问题,但是其背后的思想是相通的,带来的危害几乎是相同的。所以这里就当它也是基础类型偏执。

Type-rich Programming

先讲讲基础类型偏执的反面,有一种叫Type-rich Programming的思想。Type-rich Programming大概就是把不同单位和用途的东西用不同的类型表示,充分利用语言自身的类型系统,在编译期进行更多检查,便于开发时就发现更多隐藏的错误。
比如下面这个比较极端的例子,就有效防止了年月日都是整数类型而把参数顺序写错。虽然例子不太靠谱,这也说明了Type-rich Programming的一种应用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Year(usize);
struct Month(usize);
struct Day(usize);
fn print_date(y: Year, m: Month, d: Day) {
let Year(y) = y;
let Month(m) = m;
let Day(d) = d;
println!("{}-{}-{}", y, m, d);
}
fn main() {
print_date(Year(2017), Month(9), Day(12));
}

我们无视上面这种可以把程序员累死的风格,下面讲点实际的东西。

回到C

C(特别早期的C)里面有一种倾向,只要底层实现相同,则可以归为一种类型,比如没有单独的bool类型,8位整数和字符是一种类型。类似的思想又带来了C接口上的一种惯例,当概念上需要多个类型时,使用一个基本类型,并把多种类型映射到它的不同值区间,比如0表示正常,负值表示错误;空指针表示错误,否则正常。虽然从功能上没什么损失,但是降低可读性,同时更容易造成人为的编码错误。

没有独立的bool类型

就拿没有单独的bool类型来说,C最开始没有专门的bool类型,任何类型都可以当作bool,二进制表示非零则认为是true,全零则认为是false,最常见的是使用整型作为bool使用。不小心就可能把一个有其他含义的数值当成bool使用。比如比较函数,使用小于0,等于0,大于0的整数分别表示小于,等于,大于。

要比较两个对象相同,可以这样

1
2
3
if (cmp(a, b) == 0) {
/* code */
}

但是一不小心,写漏了返回值的判断,变成

1
2
3
if (cmp(a, b)) {
/* code */
}

乍一看没啥特别不对的地方,编译器也没什么异议,直接把返回值当bool类型用了,并不知道它是别的含义,相信不少人写过这种bug。

如果有单独的bool类型,供条件语句使用,同时不允许整数到bool的隐式转化,这里编译器就能纠正你,cmp(a, b)返回的不是一个bool类型,所以不能作为if后面的条件使用。虽然C后来的标准中加入了bool类型,但是由于兼容性原因,整数仍旧可以被隐式转化为bool,仍不能防止误用;值得欣慰的是GCC 7.0会对此有警告,然而实际生产环境基本没有地方会用这么新的版本,一些历史代码使用整型表示bool也需要大量的修改。
当然这里的问题不只是bool,其实比较函数的返回值应该定义一个enum,也不该是整数。不过说起来C的enum也不够到位,只是对整型的简单封装。

字符和字符串

C里面一开始char就是一个8位整数,因为当时他们觉得ASCII字符和8位整数本质上就是一样的;另外字符串就是char的数组。

于是后来需要支持各种语言的各种奇妙字符时,就发现需要定义两字节的宽字符类型,而不能复用char。再后来,大家开始使用Unicode的时候,字符串都不能用字符数组来表示了,因为Unicode字符都未必等长,必须搞个专门的字符串类型了,而这其实一早就该抽象出来。

没有根据用途定义不同的类型,就造成场景变化后无法重用,这就是个典型例子,如果把字符和字符串这种用途上不同的就抽象为专门的类型,起码能够自然地在扩展时达到代码的重用(当然对C来说,就算复用代码,编译出的库也不能复用,并不能完全兼容)。

空指针

虽然很多人可能没有意识到,空指针也是基础类型偏执的一种。空指针的设计属于,把多种不同的类型,映射到同一类型的不同值区间。

这里其实是说,空指针和真正指向正确目标的指针,应该是两种不同的类型。因为类型应当是相同行为的值的一个集合,而这两种指针的行为并不相同。解引用一个正常指针能得到一个值,解引用一个空指针,在C标准上来说是未定义行为,在实际实现中一般是段错误。

空指针这么多年来带来了不计其数的bug和经济损失,连其发明者都称之为”billion dollar mistake”。

至于把空指针作为一种类型的设计到底是如何操作的,之后会在另一篇文章介绍。

副作用返回值共存的运算符

C中有++--这样的自增操作符,经常被人滥用,只为了写出简短的代码。

未定义的求值顺序

一旦我们在同一个表达式中使用了多个自增操作,像x++ + ++x,其求值顺序就属于未定义行为,在不同编译器实现中可以不同。

所谓未定义行为,就是从规范上不应该出现的行为,标准没有规定。碰到之后编译器干任何事情都是符合标准的(比如编译出人工智能程序接管你的电脑;再比如通过啥恶意代码把你的硬件烧掉一些啥的(当然符合C标准不代表合法))。
据说GCC的早期版本有个彩蛋,在遇到未定义行为时会尝试打开机器里的某些游戏(真是温柔)。

更糟的是,曾有人堂而皇之地把这种代码写进教科书,并一本正经地分析其求值顺序,流毒甚远。

返回值造成的另一个问题

赋值语句具有返回值,于是我们可以写x = y = 0。看起来比较方便,但是同时也可能出现

1
2
3
if (x = 0) {
/* code */
}

这样的误用,if的条件语句中判断相等的==少打成=,但语法仍然是合法的(这有两个问题,一是赋值根本不应该有返回值;另一个是前面提到的整型不应该隐式转化为bool类型用于分支判断)。

这一类问题的根源在于不必要地追求语法的短小(短得有限,而且分开两句写逻辑可能更清晰),同时具有副作用和返回值的运算符完全不必要,还容易出错。

x++完全可以使用x += 1x = x + 1代替,虽然后面两个式子同样具有着返回值,但是看起来更像单纯的赋值,更容易给人不去使用其返回值的心理暗示(当然最好能直接去掉其返回值,虽然为了向下兼容这是不可能的)。

所以,我一般在使用C的时候,使用+= 1-= 1代替自增自减,这样更能提醒自己不去使用其返回值。

简陋的宏变换

宏其实就是编译阶段对代码的变换。

C的宏的实现方式为字符串替换,作用于词法分析之前。过于简单粗暴,容易出错,功能也有限。
这样的宏不是清洁宏,宏里面使用的中间变量可能污染作用域,恰好外面也使用相同变量名的话,就可能导致奇怪的行为。
另外一般使用的时候,我们心理上容易把宏当成和语句或函数差不多的东西,但是实际上一个类似于#define foo } else {或者#define bar ); sth()的宏的实际行为能完全超出你的预料之外。
仔细一想我们发现,这个问题和SQL注入的原理简直一模一样。

如果我们了解宏的问题所在,使用时尽量谨慎,其危害还是可控的,毕竟代码是在自己控制之下的,该变换的在编译时都确定了。不像SQL需要在接受并拼接外部的输入后,再进行语句的分析。
但一个很大的问题就是,并不是很多人都能意识到这个问题,谨慎地使用宏。不少C程序员会滥用宏,还引以为傲,颇为得意。不少使用宏的地方都可以使用内联函数,全局常量替代,而且相信编译器,这几乎没有性能或内存的代价。
另外一部分问题是,有的时候我们想减少重复代码,发现不用宏无法实现。其主要原因是由于C没有泛型或函数重载,比如可作用于所有可比较类型的max函数,就需要通过代码变换来绕开函数的类型限制。

宏并不是什么必需的功能,当然宏也是个有用的语言特性,可以是对语言功能的有益补充,但是需要好好考虑宏的设计(当然这个问题很复杂),比如考虑是对抽象语法树进行变换而不是对字符串进行变换,宏变换只能影响到自己内部,不会对语法树的上层造成影响之类的。

分号空语句

允许单独的一个分号;作为空语句存在的语法。
于是有时没注意,可能出现这样的代码

1
2
3
4
while (true);
{
/* code */
}

while (true)后面的;被视为空语句,并被作为循环体,结果进入了空语句循环,真正要循环的代码被遗弃在一边。

大概有两种解决方案

  • 禁止使用分号作为空语句,真的需要空语句的地方,使用一对大括号{}完全可以达到要求,不易手滑写错也更加显眼
  • 要求if,for,while后面跟随的必须是语句块而不能是单个语句(循环和条件语句后面只有一句语句的时候可以省略大括号,本来就是一个编码隐患,要求if,for,while后面必须跟随不省略大括号的语句块这一点被写进了不少编程规范中,其实不如干脆在语言语法中要求)

switch默认fallthrough

switch语句中的每个case,结束的默认行为是fallthrough,继续运行下一个case,也是一个潜在的bug制造点。这一点很反直觉,而且绝大多数情况我们都需要返回。不少现代语言都已经改变其默认行为返回。

那么C为什么一开始采用如此奇怪的实现方式呢?我估计主要是多种可能取值属于同一种情况的时候

1
2
3
4
5
6
7
8
switch (cond) {
case 1:
case 2:
/* code */
break;
case 3:
/* more */
}

这里由于case 1往下走了,才能和case 2共用一段代码。如果要改为默认返回的话,就需要额外声明case 1需要fallthrough。这是我对C为什么这样设计的猜测,不过个人挺奇怪为什么这么解决,我觉得有个更好的解决方案,就是支持一个case包含多个值的语法,比如

1
2
3
4
5
6
switch (cond) {
case 1, 2:
/* code */
case 3:
/* more */
}

这样就比较自然。

当然语言为了向下兼容,不能乱改,另一个比较实际的解决方案是GCC 7.0对每个case结尾没有break会有警告,除非我们在case结尾的位置使用__attribute__((fallthrough))属性标注或类似与/* Fall Through */的注释才能消除警告。说起来GCC的新版本不断增加新的警告,所以我见过有些编译选项里写了-Wall -Werror的项目升级编译器版本就直接各种编译失败了。

八进制整数字面量

以0开头的整数字面量被识别为八进制,想必也坑了一些人。按直觉和数学知识来看,数字前面的前导0对值应该没有影响。我觉得不如仿照十六进制的形式,使用类似于0o开头作为八进制的语法,在很多新语言里面都是这样的用法。

不过C里面为了兼容没法改,这点只能停留在吐槽了吧。

数字类型名

C中的各种整数类型,除了和八位ASCII码字符类型共用的char之外,都是由零至多个修饰词shortlong和类型名int组成的(使用修饰词时可省略int)。
另外还有利用long在32位和64位机器上长度不同,用来表示和指针长度相同的整型,这样一个非常不直观的潜规则。

其实这些整数类型,除了位数不同(先不考虑正负),没有其他区别。这样的表示法要多记一层对应关系(再考虑32位和64为有不同的对应关系),增加了记忆理解负担,也不方便更大位数的扩展。

增加记忆理解负担好理解,为什么说不方便更大数位的扩展呢?看看下面这个蛋疼且无用的推理和那张对应表吧,不想看随便略过。
假设我们以后有了128位的计算机(我好像已经看到一些硬件开始支持128位整数了),我们还坚持使用这种类型命名方式,考虑一下整数类型和长度的关系吧。
char作为一个ASCII字符,长度8位应该不变吧。现有的32位和64位机器上的类型长度也不能改。还有我们是不是要考虑long和指针等长的惯例,如果推翻这一点,有些兼容性代码就不太容易写出,那假设这一条也成立吧,那么128位上的long就得是128位了。
然后从过渡时期开始,64位机器就会支持128位整数吧,那现在long long已经是64位了,128位我们要给个long long long吧,long long longlong long在128位机器上也要有意义,介于long都已经128位了,它们也只能都是128位吧。于是128位机器的long,long long,long long long全成了128位整数。
那还剩下16,32,64位的整数我们要在128位机器上表示,但是我们的类型只剩short,int了。那我们要么在char和short中间插入一个short short表示16位;要么推翻long和指针等长的潜规则,让128位机器上的long成为64位。
总之在这之前我们应该早就抛弃这种类型命名了。

3264128
char888
short short161616
short161632(16)
int323264(32)
long3264128(64)
long long6464128
long long long/128128

C语言后来的标准也意识到这个问题,引入新的类型别名int8_t,int16_t,int32_t,int64_t,intptr_t等,使用相同的前缀后缀,直接在类姓名中写明长度,而intptr_t顾名思义就是和指针等长的。很多现代语言也采取了这种类型命名方式。这样的类型名能直观地看出每种类型的长度,便于记忆,而且统一,具有扩展性。