原文
你有没有看过五年前
的代码,不得不研究它,才能弄清楚它在做什么?越久远,情况就越糟糕.可怜的我,仍在维护40
多年前编写
的代码.
本文演示了许多简单
方法,来使你的代码自证明
且更易于理解和维护
.
请允许我介绍我在1987
年写的该小宝石:
#include
#define O1O printf
#define OlO putchar
#define O10 exit
#define Ol0 strlen
#define QLQ fopen
#define OlQ fgetc
#define O1Q abs
#define QO0 for
typedef char lOL;
lOL*QI[] = {"Use:\012\011dump file\012","Unable to open file '\x25s'\012",
"\012"," ",""};
main(I,Il)
lOL*Il[];
{ FILE *L;
unsigned lO;
int Q,OL[' '^'0'],llO = EOF,
O=1,l=0,lll=O+O+O+l,OQ=056;
lOL*llL="%2x ";
(I != 1<<1&&(O1O(QI[0]),O10(1011-1010))),
((L = QLQ(Il[O],"r"))==0&&(O1O(QI[O],Il[O]),O10(O)));
lO = I-(O<<l<<O);
while (L-l,1)
{ QO0(Q = 0L;((Q &~(0x10-O))== l);
OL[Q++] = OlQ(L));
if (OL[0]==llO) break;
O1O("\0454x: ",lO);
if (I == (1<<1))
{ QO0(Q=Ol0(QI[O<<O<<1]);Q<Ol0(QI[0]);
Q++)O1O((OL[Q]!=llO)?llL:QI[lll],OL[Q]);/*"O10(QI[1O])*/
O1O(QI[lll]);{}
}
QO0 (Q=0L;Q<1<<1<<1<<1<<1;Q+=Q<0100)
{ (OL[Q]!=llO)? /* 0010 10lOQ 000LQL */
((D(OL[Q])==0&&(*(OL+O1Q(Q-l))=OQ)),
OlO(OL[Q])):
OlO(1<<(1<<1<<1)<<1);
}
O1O(QI[01^10^9]);
lO+=Q+0+l;}
}
D(l) { return l>=' '&&l<='\~';
}
是的,这就是当时编写C代码
的方式.我甚至为此获奖
!
现在就射我:
#define BEGIN {
#define END }
这是1980
年代常见的C做法.它属于"不要让你的新语言
像你以前
语言"的分类.此问题以多种
形式出现.我仍喜欢用Fortran
风格命名旧时代的变量
.
转向D
之前,我发现在C之上,使用C宏
发明一个个人自定义语言
是令人憎恶
的.删除它并用普通的C代码
替换它
能大大改进清晰度.
了解什么是布尔值
,以下内容
都是相同
的:
false|true
0|1
no|yes
off|on
0 volts|5 volts
如下无疑
使代码更糟:
enum { No, Yes };
只需使用假和真
.就完了.顺便,
enum { Yes, No };
只是个自动的"不雇用
"决定,因为if (Yes)
会彻底
搞混所有人.如果已这样做了,请在有人诅咒
你的整个祖先
前运行并修复
它.
D的语法旨在避免
某些类型的编码恐怖
.
C++
有重载操作符
的正则式
我甚至不会链接它.可通过勤奋
的搜索找到.作用是重载操作符
来使普通的C++
代码是个正则式
!它违反了代码
不应假装是另一个
语言的原则.
如果存在编码
错误,编译器
会告诉你错误消息
.D
通过只允许重载
算术操作符完成,这禁止重载一元*
等.
在D中滥用重载操作符
更难,但仍有可能.但幸好,让它更难九分
阻止了它.
许多人要求给D
添加宏.我们抵制
这一点,因为宏不可避免地导致,人们在D上发明自定义,未记录
的语言分层.使其他人
很难利用此代码
.
在我不太谦虚的观点中,宏
是Lisp
从未在主流
中流行的原因.没有Lisper
可读懂其他人的Lisp
代码.
没有人知道会找到什么
符号.添加了ADL
,以便可在左侧
操作数上重载操作符
.而D
只有个简单的语法
来重载左或右
操作数.
没人知道SFINAE
是否在特定
表达中发挥作用.
这是指结构
是值
类型或引用
类型,或两者
的某种嵌合体
间的混淆
.在D中,结构
是值类型
,类
是引用类型
.公平地说,有人仍构建D嵌合体
类型,但应该加钱.
为何要这样做?使用钻石
继承时,事情
非常糟糕.怜悯下个人,避免诱惑.D
仅对接口
有多继承
,已证明这是绰绰有余
的.
代码应从左到右,从上到下
排列.与这篇文章
的阅读方式一样.
f() + g() //`0010 10l0Q 000LQL`
//哪个先执行
幸好,D
保证从左到右
的排序(C
不保证).但是呢:
g(f(e(d(c(b(a))),3)));
由内而外
执行!快速,3
调用哪个函数?传递给D的通用函数调用
语法以救援
:
a.b.c.d(3).e.f.g;
这是等效
的,但执行显然是从左到右
的.这是个极端
示例,还是常态?
import std.stdio;
import std.array;
import std.algorithm;
void main() {
stdin.byLine(KeepTerminator.yes).
map!(a => a.idup).
array.
sort.
copy(stdout.lockingTextWriter());
}
此代码逐行从stdin
读取,并把行放入
数组中,排序
数组,然后把排序结果写入stdout
.它不太符合"愚蠢简单
"的标准,但它非常接近.
所有这些都从左到右,从上到下
都有很好的流动.
该示例也很好地进入了下个
观察结果.
Shaw
:你很了解计算机
,不是吗?
斯波克先生:我对他们了如指掌
.
version (X)
doX();
doY();
if (Z)
doZ();
比以下更难
理解:
doX();
doY();
doZ();
条件式
有什么变化?把它们移动到doX()
和doZ()
的内部.
我知道你在想什么."但是沃尔特
,你没有消除
条件式,只是移动
了它们!"完全正确,但这些条件式正确
的属于函数
,而不是将这些函数
括起来.
它们是函数
提供的概念封装
的一部分,因此调用者
是干净的.
if (!noWay)
不可避免地当作:
if (noWay)
教训是尽量避免
在标识
中使用否定
.
if (way)
不是更好吗?
我自己的代码几乎是反面教材.一些标识:
tf.isnothrow
IsTypeNoreturn
Noaccesscheck
Ignoresymbolvisibility
Include.notComputed
not nothrow
D版本条件
非常简单,这里:
version ( Identifier )
一般由编译器或命令行
预定义标识.只允许使用标识
,禁止否定
,AND,OR
或XOR
.(统称为版本代数).用户经常对该限制
感到恼火.
可以搞版本代数
:
version (A) { } else {
//!A
}
version (A) version (B) {
//`A&&B`
}
version (A) version = AorB;
version (B) version = AorB;
version (AorB) {
//`A||B`
}
等等.它故意笨拙
.为什么D会这样做?它鼓励积极
方式思考
版本.假设项目有个Windows
和OSX
版本:
version (Windows) {
...
}
else version (OSX) {
...
}
else
static assert(0, "不支持");
是否比如下更好:
...
version (!Windows){
...
}
我在C语言
中看到了很多该风格
.使得很难添加新操作
系统的支持.毕竟,"不是窗口"
操作系统到底是什么
,这确实缩小了区间
!
前一个
片段更容易.
更进一步:
if (A && B && C && D)
if (A || B || C || D)
很容易懂.而遇见:
if (A && (!B || C))
呸.我一直在此结构
上犯错误.不仅很难看到!
而且仍很难满足自己
是正确的.
幸好,德摩根
定理有时可派上用场:
(!A && !B) => !(A || B)
(!A || !B) => !(A && B)
它摆脱了一个否定
.重复应用
一般可转换其为更容易理解的方程,同时同样正确.
轶事:在设计数字逻辑
电路时,NAND
栅极比AND
栅极效率更高,因为它少了一个晶体管.(AND
表示(A&&B)
,NAND
表示!(A&&B))
.
但人们在制作无错
的NAND
逻辑方面很臭.当我在1980
年代设计ABEL
语言时,来编程
可编程逻辑器件,ABEL
会接受正逻辑输入
.
它使用德摩根
定理自动转换
它为有效的负逻辑
.电子设计师
喜欢它.
总结本节,这里有一个来自Ubuntuunistd.h
的可耻片段:
#if defined __USE_BSD || (defined __USE_XOPEN && !defined __USE_UNIX98)
转换
颠覆了类型系统
的保护.有时必须要有它们(如,要实现malloc
,需要转换结果
),但很多
时候它们只是为了纠正
草率误用类型
.
因此,在D中,使用cast
关键字来完成转换
,而不是用特殊语法
,这样,很容易搜索它们.偶尔搜索cast
,并检查是否可重新设计
,来消除转换
,并让类型
系统为你工作
而不是反对你,是值得的.
拉取请求:删除一些dyncast
调用
char* xyzzy(char* p)
1,p是否修改
了指向内容?
2,是否返回p
?
3,xyzzy
是否释放p
4,xyzzy
是否如在全局中保存p
?
5,xyzzy
抛p
?
在函数文档
中很少
注明这些关键信息.更差,文档
经常出错!需要的是编译器
强制的自记录代码
.D
有涵盖
此内容的属性
:
const char* xyzzy(return scope const char* p)
1,p不会修改
指向内容
2,返回p
3,未释放
p
4,xyzzy
不会丢弃P
的副本
5,p
不会在异常中抛
现在不需要编写
文档,编译器
为你检查准确性
.是的,叫"属性汤
"是有充分理由
的,且要花时间
来适应
,但它仍比糟糕
文档要好,且添加属性
是可选
的.
函数声明
中存在的函数输入和输出
是"前门
".不在函数声明
中的输入和输出都是"侧门
".侧门包括全局变量,环境变量,从操作系统取信息,读取/写入文件,抛异常
等内容.
很少在文档中说明侧门
.调用
函数时,必须仔细阅读
实现,来辨别
侧门是什么.
自证明
代码应努力通过前门
运行所有内容.这不仅帮助
理解,而且还可实现,如简单
的单元测试
.
实现需要分配内存
的算法的函数
面临的一个持续问题
是,应用哪种分配
内存方案.一般,给调用者
强加可重用
的分配内存
函数的方法.那是落后的.
对由函数分配和释放
的内存,方法是函数
决定如何做.对函数返回
的已分配对象
,调用者应通过传递
指定分配方案
参数来确定
分配方案.
此参数一般带"接收器
"来发送输出
.
旧
方式(摘自DMD
源码):
import dmd.errors;
void gendocfile(Module m) {
...
if (!success)
error("扩展极限");
}
error()
是发送错误消息
的函数
.这是传统代码
中的典型公式
.从侧门
发出错误消息
.gendocfile()
管不了如何处理
错误消息,且文档
一般会省略
生成错误消息
的事实.
更差
,发射错误消息
,就很难单元测试
函数.
最好按参数传递"sink"
抽象接口,并发送
错误消息到接收器
:
import dmd.errorsink;
void gendocfile(Module m, ErrorSink eSink) {
...
if (!success)
eSink.error("扩展极限");
}
现在,调用者
可完全控制
错误消息,且隐式
记录.单元测试
器可提供特殊实现接口
,来适应测试
.
这是真实世界的PR
的此改进:doc.d
:使用errorSink
.
传递文件名
给函数
以读取和处理它们的典型代码
:
void gendocfile(Module m, const(char)*[] docfiles) {
OutBuffer mbuf;
foreach (file; ddocfiles) {
auto buffer = readFile(file.toDString());
mbuf.write(buffer.data);
}
...
}
很难单元测试该类代码
,因为给单元测试器
添加文件I/O
非常笨拙,因此没有编写单元测试
.文件I/O
,一般与函数无关.只需要操作的数据
.
修复
是按数组
传递文件内容.
void gendocfile(Module m, const char[] ddoctext) {
...
}
PR
:从doc.d
中移出读取ddoc
文件,这里.
处理
数据并把结果写入
文件的典型函数
:
void gendocfile(Module m) {
OutBuffer buf;
... 填充缓冲 ...
writeFile(m.loc, m.docfile.toString(), buf[ ]);
}
现在,知道应该是调用者
写入文件:
void gendocfile(Module m, ref OutBuffer outbuf) {
... 填充缓冲 ...
}
和公关:
doc.d
:把写入文件
移动到调用者
,这里
下面是从环境
中取输入
的函数
:
void gendocfile(Module m) {
char* p = getenv("DDOCFILE");
if (p)
global.params.ddoc.files.shift(p);
}
调用者
读取环境,然后通过前门
传递信息的PR
:
把DDOCFILE
从doc.d
移动到main.d
,这里
最近在研究文本处理
模块.需要识别
标识串的开头
.因为Unicode
很复杂,它导入
了处理Unicode
的(相当重要
的)模块.
但,我只要求确定标识的开头
;不需要进一步了解Unicode
.
终于想到,调用者只需按参数
传递函数指针
给不需要Unicode
知识的文本处理器
.
import dmd.doc;
bool expand(...) {
if (isIDStart(p))
...
}
变成:
alias fp_t = bool function(const(char)* p);
bool expand(..., fp_t isIDStart) {
if (isIDStart(p))
...
}
注意导入
是如何消失的,改进
了函数的封装
和可理解性
.函数指针
也可是对应用
更方便者的模板参数
.函数注解
越多,就越容易理解.
PR
:删除dmacro.d
对doc.d
的依赖,这里
1,更改
程序状态
在函数名
中提供线索,如doAction()
.
2,提出
问题
同样,应该在名字有线索
.如isSomething(),hasCharacteristic(),getInfo()
等.考虑使函数为纯
,以确保无副作用
.
尽量不要创建既提出问题又修改状态
的函数
.
格式化源码
的程序很好.但我不使用它们.哎呀!原因
如下:
final switch (of)
{
case elf: lib = LibElf_factory(); break;
case macho: lib = LibMach_factory(); break;
case coff: lib = LibMSCoff_factory(); break;
case omf: lib = LibOMF_factory(); break;
}
表明,大脑
非常擅长识别模式
.通过排列
,可创建模式
.偏离该模式
行为都可能是漏洞
.
我用模式
检测到了很多漏洞
,而格式化源码
程序在检查
模式方面做得不好.
ref
代替*
引用
是受限
指针.禁止
使用算术,也禁止ref
参数逃逸
函数.这不仅通知用户
,还通知编译器
,确保函数
是带引用
的好孩子
.
用引用的混杂(mangleToBuffer())
1,按期望
使用语言功能,不要发明
自己的语言
2,避免否定
3,从左到右,从上到下
4,函数,通过前门
完成所有操作
5,不要把引擎与环境
搞混
6,降低圈复杂度
7,拆分提出问题
与更改状态
的函数.
建议很容易遵循.少许
重构就可实现
.引用的PR
表明使代码
自证明是很简单的!