2310d用d写自证明代码

原文

用D写自证明的代码

你有没有看过五年前的代码,不得不研究它,才能弄清楚它在做什么?越久远,情况就越糟糕.可怜的我,仍在维护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封锁了恐怖

D的语法旨在避免某些类型的编码恐怖.

C++有重载操作符的正则式

我甚至不会链接它.可通过勤奋的搜索找到.作用是重载操作符来使普通的C++代码是个正则式!它违反了代码不应假装是另一个语言的原则.

如果存在编码错误,编译器会告诉你错误消息.D通过只允许重载算术操作符完成,这禁止重载一元*等.
在D中滥用重载操作符更难,但仍有可能.但幸好,让它更难九分阻止了它.

使用宏元编程

许多人要求给D添加宏.我们抵制这一点,因为宏不可避免地导致,人们在D上发明自定义,未记录的语言分层.使其他人很难利用此代码.

在我不太谦虚的观点中,Lisp从未在主流中流行的原因.没有Lisper可读懂其他人的Lisp代码.

C++依赖参数查找

没有人知道会找到什么符号.添加了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)

不是更好吗?

DMD源码耻辱堂

我自己的代码几乎是反面教材.一些标识:

tf.isnothrow
IsTypeNoreturn
Noaccesscheck
Ignoresymbolvisibility
Include.notComputed
not nothrow

否定和版本

D版本条件非常简单,这里:

version ( Identifier )

一般由编译器或命令行预定义标识.只允许使用标识,禁止否定,AND,ORXOR.(统称为版本代数).用户经常对该限制感到恼火.
可以搞版本代数:

version (A) { } else {
    //!A
}
version (A) version (B) {
     //`A&&B`
}
version (A) version = AorB;
version (B) version = AorB;
version (AorB) {
     //`A||B`
}

等等.它故意笨拙.为什么D会这样做?它鼓励积极方式思考版本.假设项目有个WindowsOSX版本:

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,xyzzyp?

函数文档很少注明这些关键信息.更差,文档经常出错!需要的是编译器强制的自记录代码.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:
DDOCFILEdoc.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.ddoc.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表明使代码自证明是很简单的!

你可能感兴趣的:(dlang,d,d)