随想011:关于编程
- 1945 年时,刚开始有计算机,那时候使用二进制数编程
- 到了40年代末期,出现了汇编器,可以自动将汇编程序转换为二进制数序列
- 1951 年
Grace Hopper
发明了编译器 - 1957 年,
Fortran
,第一个高级语言,首次亮相 - 接下来就是层出不穷的新编程语言 -
COBOL
、C
、Pascal
、C++
、Java
等等1
经过了几十年的发展,今天的软件与过去的软件本质上仍然是一样的,程序都是由 顺序结构
、分支结构
和 循环结构
组成,再没有出现新的结构。
- 1958 年
John Mccarthy
发明了 LISP 语言,函数式编程
范式诞生 - 1966 年
Ole Johan Dahl
和Kriste Nygaard
的论文开创了面向对象编程
范式 - 1968 年
Edsger Wybe Dijkstra
论证了goto
语句的危害,结构化编程
范式诞生
经过了几十年的发展,今天的编程范式与过去完全一样,也是结构化编程
范式、 面向对象编程
范式和 函数式编程
范式,再没有出现新的编程范式。
到 1968 年,今天所有的程序结构和编程范式就已经全部出现,从那之后的半个多世纪以来,软件工程领域只是更新了更好的工具以及沉淀了一些构建程序的关键经验。
软件工程的最基本经验是:保持简洁。2
软件设计有两种方式:一种是设计得极为简洁,明显没有缺陷;另一种是设计得极为复杂,没有明显的缺陷。第一种方式要难得多。3
大多数软件禁不起磕碰,毛病很多,就是因为过于复杂,很难通盘考虑。如果不能够理解一个程序的逻辑,就不能确信其是否正确,在出错时,你将更加没有头绪:可能性太多。
Brian Kernighan
认为 “计算机编程的本质就是控制复杂度”。要编写复杂软件而又不至于一败涂地的唯一方法就是降低其整体复杂度——用清晰的接口把若干简单的模块组合成一个复杂软件。如此一来,多数问题只会局限于某个局部,那么就还有希望对局部进行改进而不至牵动全身。
要保持代码简洁,需要艰辛的劳动和严格的职业操守,遵循的原则是“童子军规范”:每次迁入都要比迁出好一些。
要保持架构简洁,需要理解“单一职责原则”、“开闭原则”、“接口隔离原则” 、和“依赖反转原则”。
要保持设计简洁,需要思维上的转变。能删除什么?能简化什么?能重新定义什么?
所有的这些,一言蔽之,就一个字:简
把复杂的事情变简单,简单的事情便可靠!
让我们举例说明一下,先看看下面代码片段。参数 time->daysSince1980
保存从 1980 年 1 月 1 日到当前的天数,根据这个天数,此函数计算当前是哪一年的第几天,函数 IsLeapYear
用于判断给定的年份是否 闰年
。
#define STARTING_YEAR 1980
static void SetYearAndDayOfYear(RtcTime * time)
{
int days = time->daysSince1980;
int year = STARTING_YEAR;
while (days > 365)
{
if (IsLeapYear(year))
{
if (days > 366)
{
days -= 366;
year += 1;
}
}
else
{
days -= 365;
year += 1;
}
}
time->dayOfYear = days;
time->year = year;
}
这段代码有错误吗?
浏览一遍之后,我接触到的绝大多数人都认为这段代码 没有明显的错误 。注意不是没有错误,而是没有明显的错误。原因在于这个函数不够简洁,充斥着大量的判断语句,不能一眼看出它的意图。
这个代码片段有个错误 ,在 2008 年 12 月 31 日这天,让无数 Zune
播放器变成了一块砖头。
Zune
是微软公司推出的一款便携式播放器,于 2006 年第一次发布,定位与苹果公司的 iPod 类似。Zune
销量最高时占到美国便携式播放器市场份额的 9%,远低于 iPod 的 63%。Zune
于 2011 年 10 月停产。
我们回过头来仔细分析一下这段代码。2008 年是闰年,2008 年 12 月 31 日这天,是 2008 年的第 366 天。我们将这些数字带入函数,当变量 year
为 2008 时,变量 days
值为 366,因此 while
循环条件为真(days > 365
), 第一个 if
语句判断出 2008 年为闰年,为真,然后继续判断 if(days > 366)
,这里结果为假,因为 days
变量值恰好为 366 , 不会 > 366
。所以程序返回到 while 判断,如此陷入死循环。
当这个问题暴露出来后,在网上引起了众多人的讨论,大家也很快的想出了修复代码,很明显,问题出在判断 if(days > 366)
处,它没有处理等于 366 的情况,修复代码比源代码只多了一个字符:
#define STARTING_YEAR 1980
static void SetYearAndDayOfYear(RtcTime * time)
{
int days = time->daysSince1980;
int year = STARTING_YEAR;
while (days > 365)
{
if (IsLeapYear(year))
{
if (days >= 366) // <--- 这里,补上对恰好为 366 的判断
{
days -= 366;
year += 1;
}
}
else
{
days -= 365;
year += 1;
}
}
time->dayOfYear = days;
time->year = year;
}
现在,我再次问相同的问题:这段代码有错误吗?
有错还是没错,你能确切的得出结论吗?
就我接触到的人来说,完全不能。因为代码仍然不简洁,我们仍不能一眼看出意图。
这个版本的修复代码最为流行4,但这段代码仍然有错误。如果今天是 2008 年 12 月 31,那么这段代码会得出今天是 2009 年的第 0 天(正确结果是 2008 年第 366 天),你应该已经发现错误了。我们再修复一版:
#define STARTING_YEAR 1980
static void SetYearAndDayOfYear(RtcTime * time)
{
int days = time->daysSince1980;
int year = STARTING_YEAR;
while (days > 365)
{
if (IsLeapYear(year))
{
if(days == 366)
{
break; // 处理瑞年的最后一天
}
else if (days > 366)
{
days -= 366;
year += 1;
}
}
else
{
days -= 365;
year += 1;
}
}
time->dayOfYear = days;
time->year = year;
}
现在,我第三次问相同的问题:这段代码有错误吗?
大概率仍不确定,心里仍然在犯嘀咕,答案仍然是那句 没有明显的错误。因为同样的原因,代码没有简洁到一眼看过去 明显没有错误 。
让我们看看这段代码,算上括号和换行,它也只有区区 20 来行,很小,但它就是称不上简洁。过多的判断、重复、不清晰,充满了坏代码的味道,它不但隐藏了错误,还阻碍人们修正错误。
让我们来重构它。现实环境中,重构前要有测试来确保重构不影响代码行为,但由于测试这个话题不是这篇文章关注的,这里我刻意忽略测试。
判断是否瑞年的函数 IsLeapYear
没有问题,我们继续使用。在此基础上,我们提取出一个“给定年份有多少天”的函数:
static int GetDaysInYear(int year)
{
if (isLeapYear(year))
return 366;
else
return 365;
}
这个函数很好理解,我们一眼就能确定它 明显没有错误。之后就一切简化了,错综复杂的 if
语句不见了,程序立即变得容易理解,也更容易一眼就能看出有没有错误。
static void FirstSetYearAndDayOfYear(RtcTime * self)
{
int days = self->daysSince1980;
int year = STARTING_YEAR;
int daysInYear = GetDaysInYear(year);
while (days > daysInYear)
{
year++;
days -= daysInYear;
daysInYear = GetDaysInYear(year);
}
self->dayOfYear = days;
self->year = year;
}
关键在于思维的转变。原来的代码没有意识到 365
和 366
有着统一的概念,即一年的天数。它反而将年份和一年的天数耦合在一起,而瑞年和平年的天数又不一样。这就导致了复杂的判断,复杂就容易出错。反观重构后的代码,只关注一年的天数,瑞年和平年被封装起来了,实现简洁明了,简洁才容易稳定。
这是为什么代码要简洁的原因。
如果让你编写这个程序,你的程序是和重构前的类似还是和重构后的类似?我敢打包票,大多数人写出的代码与重构前类似。
写更简洁的代码,选择更简洁的方案,做更简洁的决策,这往往需要大量的经验和繁重的智力劳动。简洁不等于简单,更不等于简陋。这是为什么往复杂做容易,往简洁做难的原因。
软件工程的第二个基本经验是:程序应该与设备无关。换句话说,你要关注 依赖
,学会 解耦
。
为什么要这样做?
为了让程序具有更长的生命周期。
硬件的更新换代是显而易见的,手机、电脑、电视等甚至每年都推出新的一代。在嵌入式领域,也常常更换 CPU,比如疫情期间的芯片短缺,很多公司都被迫换芯,我们在架构嵌入式代码时要时刻记住这一点。如果软件和硬件有隐含的依赖关系,那么只要硬件变化,就会让软件难以迁移。
让程序与设备无关的关键,在于理解一条设计原则:依赖倒置
。依赖倒置原则的定义是:
- 高层模块不应依赖于低层模块,二者应依赖于抽象
- 抽象不应依赖于细节,细节应依赖于抽象
如果是第一次接触依赖倒置原则,看它的定义,你可能会云里雾里。没关系,让我们看一个例子。
计算机有各种存储设备:硬盘、移动优盘、网络硬盘、CD-ROM等等,相应的也有很多文件系统,比如 ext4、exFAT、NFS、ISO9660。根据存储设备的不同,文件系统在读取和写入数据的细节方面存在巨大的差别。这引出一个重要的问题,操作系统(linux)是如何跨设备复制的,比如从 CD 拷贝文件到本地硬盘?
我们说过,不同的文件系统在读取和写入数据的细节方面存在巨大差别,linux 操作系统在复制文件时,会直接访问文件系统,需要了解文件系统的细节吗?就像下图所示的这样:
如果是这样,就违反了依赖倒置原则,因为高层次的 Linux 内核,直接依赖了低层次的文件系统,内核与文件系统耦合到了一起!文件系统的任何变化,都可能迫使内核更改,比如新增一个文件系统,则必须在内核层增加相应的代码。
Linux 的设计者显然不会这样设计,他们使用了依赖倒置原则:在 Linux 内核与文件系统之间抽象出一个 虚拟文件系统
,虚拟文件系统是一套 API 接口,充当内核与与文件系统的中间人。如下图所示:
注意看图中的箭头。现在高层次的 Linux 内核不再直接依赖低层次的文件系统,而是依赖一个抽象层:虚拟文件系统。底层次的文件系统同样依赖这个虚拟文件系统,这就是依赖倒置。
每当有程序需要 I/O 操作时,它就向虚拟文件系统发送一个请求。虚拟文件系统定位合适的文件系统,通知设备驱动程序执行 I/O 与之进行通信。通过这种方式,用户和程序都不必知道硬件和文件系统的任何细节,这就实现了解耦。在每个文件操作的一端,虚拟文件系统以用户语言与用户沟通;在另一端,虚拟文件系统以具体设备文件系统的语言与各种设备文件系统沟通。最终,用户程序能够与任何文件系统交互,而且不必与文件系统直接沟通。
现在考虑另一个问题。当开发新的文件系统时,如何使它适合于 Linux 呢?答案在概念上讲非常简单。所有新设备的开发人员只需教新文件系统说“虚拟文件系统”语言(实现虚拟文件系统规定的 API 函数),就能够使这个新文件系统加入到 Linux 世界中来,并且无缝地集成到其中。
下面是另一个完美之处:无论何时学习 Linux —— 30 年前还是30分钟前,Linux 文件系统都有相同的外观和相同的运转方式。此外,新的设备和更好的文件系统多年以来一直在不停的开发,它们都平滑简易地集成到 Linux 中来。这就是为什么在学生带着彩旗抗议战争的年代开发的操作系统,在学生拿着智能手机抗议战争的时代仍然能够很好地运转的原因。
那么未来又会如何呢?我们不知道未来会开发出什么稀奇古怪的新设备,毕竟对于技术,没有人可以保证什么。但是,我可以保证的是,无论出现什么新技术,它都可以与 Linux 协调工作5。
读后有收获,资助博主养娃 - 千金难买知识,但可以买好多奶粉 (〃‘▽’〃)