Skip to content

Instantly share code, notes, and snippets.

@FrankHB
Created August 8, 2022 09:42
Show Gist options
  • Save FrankHB/c3eaf1fe4b61f6eb89a9329a9bfd110b to your computer and use it in GitHub Desktop.
Save FrankHB/c3eaf1fe4b61f6eb89a9329a9bfd110b to your computer and use it in GitHub Desktop.
关于所谓“弱类型”的误导问题

缘起

因为 Bilibili 莫名其妙吃回复,那就在这里存档。

原始内容见这里

如无另行指定,默认回复 @LanceMoe

预备 +TODO 改换文体,+FAQ

(不过虽然议题有相关性,仍然远远不够继续升级话题就是了。)

引用内容

(无关内容略。)

LanceMoe回复 @喜羊羊与刘备 :本来设计之初就是强类型的语言一般都不需要三等号,因为三等号本来就是给弱类型时候设计的双等号擦屁股用的[doge]

2022-07-16 18:44

LanceMoe回复 @星海如昼 :这个属于运算符重载,不是隐式转换 [doge]

2022-07-16 18:45

LanceMoe回复 @STEStellar :除0和对0取mod在类型安全的语言都是要抛异常的,建议你了解一下PL的基础知识:类型理论

2022-07-16 18:46

LanceMoe回复 @不氪金之后 :是b不能为0,除0和对0取mod都是应该抛异常的

2022-07-16 18:47

LanceMoe回复 @--诺安-- :没有说需要c和c++,况且c语言是弱类型,c++是强类型,c++和c早就是两门完全不同的语言了。做项目当然要选合适的语言和框架,现在正常来说,预判项目是大型需要多人合作的,新起项目都是首选强类型语言,比如前端原来用js现在基本上都是用ts(ts整体还算类型比较完备的),原来可能没得选必须用c起步的现在起步尽量用rust。现代语言基本上都有类型推论,所以也不需要显式声明类型,没有历史包袱的情况下写起来方便度和弱类型的东西也没什么太大区别了。

2022-07-16 18:54回复

幻の上帝回复 @LanceMoe :除0抛异常……C:?C++:?

2022-08-06 22:04回复

幻の上帝回复 @LanceMoe :如果你真正经学过PL,就不要用所谓强类型/弱类型这样的混沌说法。所谓弱类型也不是指类型检查强度低下,而是指根本上瞎指派类型+滥用多态。

2022-08-06 22:06回复

LanceMoe回复 @幻の上帝 :上来就喷?强弱类型虽然没绝对界限,但是几门语言放在一起比很容易判断。你只要看这个语言设计本意,是不是编写代码时需要“经常”做隐式cast、或者类型的decay是不是“通常”是隐式的就能轻松判断,我从来也没说什么“类型检查强度低下”这种话。比如c的malloc返回void*可以分配给任意type的pointer,数组传递会decay成指针,这就是弱类型。c++相当于c的差异点都是强类型的特征,如果这两门语言一比,那c++显然是强类型,但是跟rust这种新出的设计更先进的语言比,没有除0异常之类的点,那么跟rust比c++略弱,但是依旧是强类型,c++不搞除零异常只是为了最大程度兼容历史的包袱。有什么混沌的?难道有人认为js和lua这种是强类型?正经学过PL你告诉我什么叫瞎指派类型,我是没听说过。你的意思是隐式cast?更加通俗的说,弱类型简单来说就是非常容易让人在不注意的时候,因为类型问题犯错的语言。我回复也是因为这个层主在这边说是因为“动态语言”的0和"0"导致的,我只是想说动态语言也有强类型,静态语言也有弱类型的,跟动态静态无关。弱类型本来就是方便大家沟通产生的概念,不然你出去咋描述,把强类型的特征和弱类型的特征全部说一遍?

2022-08-07 13:14回复

幻の上帝回复 @LanceMoe :我不知道你是哪来的PL基础,总之少拿民科理论开腔误导。

动态语言跟强弱类型自然无关,但是很遗憾你的理解错的离谱,虽然这种错误理解流行得像模像样,以至于我仍然不得不见一个就纠正一个以免继续流毒。

所谓强类型(strongly typed)就是在负责在typing environment给出type assignment的type formation rules里明确指派nominal types并且要求接口约束能确保类型保持compatible的设计,对立的就是所谓的无类型(untyped)或者单一类型(unityped),而本来就没什么弱类型,就跟本来就不存在什么面向过程一样。你杜撰的差别是在强行扭曲原意,架空原本需要描述的性质。类型检查强度低下就是分不清typing和typechecking导致的常见脑补出所谓强弱类型的例子,但你既然没这样想,那么就更没有什么借口使用什么弱类型。

C和C++都是典型的强类型语言,而且符合最早的Liskov那时候定义的强类型仅限在函数签名的狭义的情况。你提及的所谓C和C++的差异属于type compatible rules/polymorphism机制的不同,根本没什么模型能给出可比较的强弱性——比如说C++根本没像C定义compatible type,却又多了conversion function之类的自定义polymorphism,你何德何能能把这些具体的不同设计抵消比较出“强弱”?Rust差得更大,就更加没可比性了。

C和C++没除零异常是你典型想当然的遗漏。刻意不用异常也不是你所说的理由——要知道,除零异常的标准化是所有UB中相对容易的一类(最简单地,搞成信号)。真正的理由虽然还是接口语义上的决策,但明确和想要什么类型系统达到什么目的无关。用WG21的话,这是所谓的narrow contract并且不应当被widen,宁可故意允许UB,也不添加用户程序无法回避的检查开销。

幻の上帝回复 @LanceMoe :你所谓的“方便沟通”很大程度是伪需求。你确定正经的讨论中真有把所谓“弱类型”一网打尽的共性存在(除了刻意设计一门表现可以烂到什么程度的语言专门拿来黑的情况)?尽管把0和"0"混同的类似设计在不止一个语言中存在,但这基本就是没搞清楚在自己设计什么的无意识跟风。这样的一个现象本来就是artifact,堪称反模式。但另一方面,这种性质没流行到需要专门污染词汇表去命名的程度(非得硬说,那就是一种type confusion,但这一般是描述用户程序的)。

另一方面,没有混淆0和"0"的正儿八斤的动态语言多得是,甚至Lua的设计者都知道不好在Lua5.4给改了。

至于具体的转换特性本身,我已经说过了,就叫强制(coercion)。这是个比“强类型”还老资格得多的跨语言标准术语,也是Lua手册中的用词。(某些不上道的C用户学歪了导致没意识到“强制”是指隐式转换这样的常识……就不要怪我咯。)

不懂也没什么,但是还要出来乱说给正经科普增加工作量,就是你的错了。

注释 这里还有个关于 LanceMoe 对 void* 的理解偏差描述的回复被 Bilibili 吃了没显示。

LanceMoe回复 @幻の上帝 :挺有意思,先说强弱类型是民科说法,然后你花了这么大篇幅讲你认为的“强类型”是什么,然后又说“方便沟通”是伪需求,合着是在这说废话。[OK]别说什么除0不抛异常是为了零开销,要真是一切零开销,现在c++这套异常系统就别做了。除0定为ub本来就是历史包袱,在大部分主流处理器里都产生硬件中断,就算不抛异常也可以根据ieee754返回非数常量,没必要定为ub,事实就是改掉包袱远比新起一个复杂。然后也别说什么你要做“正经讨论”,真要做“科普”麻烦你写篇论文给个你的建议,你再造个概念来“方便沟通”,我支持,否则就别说什么“方便沟通”是伪需求。欢迎多做做你觉得是对的“科普”。我本来那层只是想提醒“弱类型”不代表是“动态语言”,无论怎么说这点不接受反驳,无论你怎么定义,这两个都不能划等号,所以不知道你在杠什么。还有我给你提个建议,个人觉得像你这种上来就给人扣“民科”帽子的,对“科普”是最大的阻力。“正经讨论”就别在这盖楼回复了。

正论

直球回复

我是觉得挺无聊的,你自己明摆着不搭边的外行,非得强行出头连续回复一堆人,推销你自己的观点,动机是什么,显摆你理解更深刻?“弱类型”不代表是“动态语言”没问题我没反对,但是你其它地方这槽点出现率得也太离谱了,还特意扛着PL基础知识的旗号…… exm ,PL 你说而是这样的?

我花篇幅是因为即便这种性质恶劣的误导(尤其是当事人九成九毫无自觉)平时一两句话能忍则忍,但是集中连续输出错误忍无可忍无需再忍,就逮到猛薅以确保源头上的效率最大限度打击这种谎话说多了就是真理的心态而已。这也是主流民科的态度,只不过典型的民科更加刻意。另一方面,稍微细究一点就发现PL界本来就没你所谓的这种需求,“弱类型”的提法过一遍发现不是以讹传讹就全是一知半解的外行口嗨的发明,你非得无中生有,别人不当你民科无理取闹还能咋地?

强类型到底什么东西,弱类型的说法到底多不靠谱,这种随便一搜二手文献都马上七七八八的东西,对知道什么“PL 的基础问题”的人来讲,真需要我再继续指出来源吗?还有你真能指望一个中括号都会替换歪的地方里贴啥靠谱的文献引用?

除零问题我看你是彻底的外行,不知道这里的开销到底有多大——不止是运行时开销,还有维护spec上消除硬件实现差异的问题。虽然我说过“最简单的”做法,这仍然工程上极端复杂,跟往 C++ 里加 Windows SEH 没太大(不)可行性上的差别。这种异常要整清楚就得把常见 CPU 的异步异常机制在 C/C++ 的层次上整明白,干掉所有不一致的部分,光这坨就比整个 C++ 异常都复杂了。而刻意拒绝包装内建操作符的错误条件为 C++ 这样而同步异常则是明确实现开销上的考虑。(当然你也可以硬说 C 没 C++ 那种异常算“历史包袱”所以连这种机会都没有……那你开心就好。题外话,我不反对缺乏控制作用是 PL 设计史上的包袱,但支持控制作用的实现方式就不应该钦定是“异常”。)关于 C 或 C++ 这方面设计决策不服的,建议直接提交 proposal 。

Issue 1

首先的问题是为何“弱类型”之类的话题自然被视为民科,这个最基本的一般理由上面已经提及不再解释。

可能需要补充的是如何断定“弱类型”这个说法有资格被视为民科内容、应当被视为民科内容以及属于伪需求的理由或推理过程。

其一在于 PL 界的见闻。不管是正式的论文还是还算正经的 PL 讨论(比如 LtU 上的)实际上都难以看到会提到这个说法,乃至可以认为相关人士已经对这种提法的离谱性和搞乱说话人含义的危害程度有充分认识而刻意回避了。

其二,更直接的理由是有不止一个独立来源给出反对“弱类型”的提法,基本上是这方面比较内行去深究过这些问题人的共识。

其中的技术理由各有不同,但共同点是同一“弱类型”这个概念来源可疑、定义模糊且不大有希望挽救,不如彻底放弃。

TODO 补充参考文献的内联展开片段。

当然作为已经流行的讹用术语,它的存在感已经无法让一般二手文献忽视了。但是,其中除了出处相对地靠谱以外,说法仍然不得细究——有很多判定标准上无法推敲的地方,例如“什么算弱”“如何衡量相对的强弱”。

如果要以“类型检查的强度”来建模而允许明确定义相对意义上的“弱类型”弱在哪,其实并不是不行。但至今没找到这种归纳强度并建立包含序理论意义上的模型的正式工作。(作为对比,抽象理论就有,所以谁比谁更能抽象在 PL 的一般意义上是可比的——虽然也没落实到具体 PL 上。)

退一步讲,就算有这种研究,也不可能建立放之四海皆准的全序关系(而且不限于类型检查)。这在先前提到的 C 和 C++ 类型系统的差异的例子上就已经暗示过了。

然而这个方向更大的问题是把弱类型和类型检查混淆根本就是说不通。关于这些讹传的背景成因,更严肃的技术分析和观点概述很早就已经有了,不再赘述。

Issue 2

C 和 C++ 不标准化(整数)除零异常显然不是单纯的历史包袱。

因为部分历史原因以及现在仍然强调的“性能优先”的习惯,C 和 C++ 的 /% 内建操作通常直接实现为可被 ISA(指令集架构)支持的操作。

在遇到除零的错误条件时,ISA 操作通常规定会引起硬件实现支持的异常。这种异常通常是异步的。这基本不是历史包袱,而是这种方式允许利用的硬件实现意义上的空间分散性允许物理同时直接支持并行处理。其中的历史包袱只有具体异常到底是单独的硬件器件(比如协处理器)还是和其它中断或异常机制一同实现的问题,但这在 ISA 以上的软件原则上是不可见的。

当然,以 C 和 C++ 为代表的 ISA 以上的软件实现难以利用硬件的并行红利,那就是另一个问题了。不过这也不是历史包袱,而是这类语言设计的抽象机语义本身就和并行的体系结构模型不搭——要说历史包袱,那么所有 ALGOL-like 语言整个都是。

这个意义上,不以 ISA 异常标准化除零异常的应该容易理解。考虑到具体设计的细节,比如如何与其它异常交互之类的问题,ISA 的异常远比现有的几乎所有高级 PL 中的异常机制都复杂。除了先天异步+并行和大多数语言的语义模型不搭这个麻烦的问题(比如考虑如何通知事件完成或取消——注意这里的延迟限制比外部 I/O 严格得太多,实用上这里不能引入本机线程),还在于客观上无法确保不同的 ISA 一定会有相同的设计。对标准化 PL 来说评估兼容不同的可能实现,又要尽量不引入间接层次的性能开销,这很要命。

所以强调兼容性、连调用栈都直接架空的 C ,此路不通,对一般的异常充其量就是提供了一个信号表示不是无事发生,但除零这个问题没到 SIGILL 这种程度还是完全无法保证。继承 C 的 C++ 更加不待见信号上下文对程序的运行时操作的限制,更加没扩充的余地。而 C++ 的同步异常扩充为异步异常在工作量上也是异想天开——不说绝对意义上 C++ 同步异常的复杂性已经非常离谱了(比如考虑 ABI 兼容性)。

另一方面,不想要 UB 去实现替代手法本身的工作量实在很低——最简单地,加个 if 判断,返回错误码。但 C 这样的抽象捉急的设计中,if 加在哪也是有讲究的。加上早期连内联都不健全的年代(这确实是历史包袱,但是也不全是——现在也不可能保持 ABI 解决跨翻译单元内联兼容问题),标准化封装个带有 if 的“安全”操作实在没现实吸引力。

让 C++ 异常包装错误条件看上去更正常(因为除零异常逻辑上其实是可以同步的,所以可行),但是 C++ 异常机制的一些过度设计(比如过于具体的异常对象类型和过强的 unwind 保证之类)和现实(非历史)的包袱(比如异常运行时 ABI 兼容性)会让八成用户明确反对这里使用异常的必要性。甚至还有不少 C++ 用户至今以此反对整个 C++ 异常机制,那么自然更加无可救药。

这些背景下,保持内建操作整数除零 UB 自然是共识。(说实话,我很奇怪 C 和 C++ 用户会有这方面认知偏差。)

(至于浮点数除零 inf 那是成熟的硬件方案里早就支持了,不用白不用。但是浮点数这玩意儿细节远远更头疼,不过这完全是另外的问题了。)

原则上,更重要的问题在于 UB 是被期望的。与之相对,滥用检查避免包括 UB 之类的“错误”的所谓防御性编程不受待见,因为它本质上不信任任何接口的用户,以至于用户即便完全清晰无误地理解了需求,也无法干预次等的实现——除非把实现拆了自己改了。但是既然我都得自己实现了,那么要你何用?

WG21 的讨论中,这种预期的无错误检查的接口和存在“防御性”检查的接口被分别称为 narrow contracts 和 wide contracts 。一般地,只提供 wide contracts 是有害的,因为检查的复杂性基本可以忽略,总是存在一个更自由的 narrow contract 的版本的接口,而同时满足 narrow 和 wide 的需求——封装底层的 narrow contract 的接口并添加检查就得到了后者。而反过来只要不是知道实现细节,一般是做不到的。

一个根本原因是,C++ 这样的语言不足以精确建模像度量性能这样的性质(顶天能在文档里约定一个复杂度要求,连检查都做不到),而相同接口的劣等实现的却又实实在在地影响程序的行为(甚至可能作为攻击向量),这就是一种抽象泄漏。

这种不对称性揭示了一些更深刻的普遍规律:如果想要隔离接口及其实现,又不想有实现开销的惩罚,那么 widening contracts considered harmful 。同理,一般意义上依赖这种问题操作的所谓防御性编程是会自动破产的:任何无条件地设定前置条件而拒绝让它的直接用户配置是否启用,是一种无能的反模式。

更正常一点的做法是,把接口的检查集中到(不)信任边界上。例如,不能信任最终用户的输入,就集中检查最终用户直接决定的输入,但是不要再嵌套调用中重复检查——这不仅多少在持续损害最终用户的体验,还可能会掩盖遗漏检查的根因,造成更大的问题。再者,还有不少严重的灾难性错误(比如硬件失败)是根本无法检查的,对系统状态一致性的维护天然地就依赖其它的手段。

是时候对鼓吹无能和剥夺用户自由的愚蠢编程臆想作斗争并纠正这些真正的历史包袱了。

@yangbowen
Copy link

yangbowen commented Jul 26, 2023

真正实际需要的,是一种代替 ABI 的抽象 HIR 规范。

Contract 啊,一直是在做了、在做了……然后跳票。不过就算这样,也应该比提案支持异步异常的模型进度快。

因为组合程序是各个层次上都存在的问题(源代码还是二进制程序都能有组合问题),本就不存在非得在某个固定的层次上解决某些固定问题的套路

我认为这些问题实际上是密切相关的。
现有大部分 组合程序 方案(包括.o上的链接或者动态链接——把 native ISA 看作一种 IR 的话)所使用的 IR 都是基于“语句”的。这些 IR 围绕着描述“做哪些运算步骤”而不是“运算值之间满足哪些不变量约束”。后者恰恰是优化、查错等需要的信息,而 contract 也恰恰是描述“约束”的语法。
因此我认为你说的这个 HIR 规范必须具备的一个特性是“高效地描述约束,而不是运算步骤”。然后语言层面的 contract 、优化器需要的“证明程序满足何种性质”都能统一到这个上面。


翻译单元原则上也不需要跟 ABI 有关系,实际甚至都不需要一定生成二进制代码(比如整个翻译单元都是空的或者都是内联掉的定义),非得默认把翻译单元映射到 .o 而不是在工具链的实现按需优化流程,也是实现细节

Clang-LLVM 的 LTO 选项会自动编译到 LLVM bitcode .bc 而不是 .o ,算是一个值得一提的例子吧。

@FrankHB
Copy link
Author

FrankHB commented Aug 15, 2023

真正实际需要的,是一种代替 ABI 的抽象 HIR 规范。

Contract 啊,一直是在做了、在做了……然后跳票。不过就算这样,也应该比提案支持异步异常的模型进度快。

因为组合程序是各个层次上都存在的问题(源代码还是二进制程序都能有组合问题),本就不存在非得在某个固定的层次上解决某些固定问题的套路

我认为这些问题实际上是密切相关的。 现有大部分 组合程序 方案(包括.o上的链接或者动态链接——把 native ISA 看作一种 IR 的话)所使用的 IR 都是基于“语句”的。这些 IR 围绕着描述“做哪些运算步骤”而不是“运算值之间满足哪些不变量约束”。后者恰恰是优化、查错等需要的信息,而 contract 也恰恰是描述“约束”的语法。 因此我认为你说的这个 HIR 规范必须具备的一个特性是“高效地描述约束,而不是运算步骤”。然后语言层面的 contract 、优化器需要的“证明程序满足何种性质”都能统一到这个上面。

是相关的,但不同的人关注的方面不同。

硬件厂商自动会关心“语句”,因为那是硬件自身 IR(比如 microcode 和某些 RTL 支持的局部编码构造)基于实现可行性和复杂性的自然选择。对软件厂商,他们中一部分会关心的是 toolchain 这样 infrustructure 支持的可实现性,大多数非理论计算机科学出身的会往硬件方向(后端编码)如何省事这方向去靠。而目标用户和理论研究者的则不一定有这样的思想包袱,因为理论上语句表达的顺序构造不是什么基本的东西,即便广泛存在也只是实现上的简化,并且不需要是一种形态(比如 ISA 的程序计数器,语言运行时的顶层 trampoline ,应用软件框架中的顶层事件循环,其实都是一种东西——“默认”控制)。

尽管不同层次的 IR 都可以描述“做哪些运算步骤”,每引入一层明确不便被下游应用复用的要求都会导致似是而非的冗余,所以在抽象能力上,能描述清楚所有功能需求(不论效率)的 HIR 根本上是必须的。但为了包含最大的涉众(而不至于发现不够用整个推翻重来),这种构造又不能是传统意义上的静态的(对硬件和软件开发者,什么叫“静态”都不能有统一的定义——manufacture-time/translation time/runtime 不可能全部预设)。所以即便这种抽象可以是 contract ,也比现在 C++ 意义上的 contract 外延大得多,至少要能描述用户定义的 phase ,这不可能是一个具体 IR 设计中就能简单原生支持的特性。即便排除这个显式定义 phase 的需求,Racket 的 contract 也更接近这里的目的,注意这里的 idiomatic usage 大到已经能代替整个(静态)类型检查了。所以能满足这里的需求的 contract system 是个历史上前所未有地大的设计,至少能涵盖 gradual typing 对应的 typecheck 。从工程可实现性上,我不觉得这是能直接做到一套 spec 里就可能完工的东西。

翻译单元原则上也不需要跟 ABI 有关系,实际甚至都不需要一定生成二进制代码(比如整个翻译单元都是空的或者都是内联掉的定义),非得默认把翻译单元映射到 .o 而不是在工具链的实现按需优化流程,也是实现细节

Clang-LLVM 的 LTO 选项会自动编译到 LLVM bitcode .bc 而不是 .o ,算是一个值得一提的例子吧。

这是正常的思路,不过需要指出 .o 在 image format 层次上就跟 .bc 并列:a.out、PE/COFF+、ELF、Mach-O ……除了可移植性的现实差别,都是和 LLVM bitcode 能并列的。(考虑到 LLVM 自身就是个 target ,技术上加上 target ISA 这个维度在另一方面也可以是并列的。)由于 LTO 的 opt-in 特色,引入不同 image format 的时机又有更大的变数。于是无论哪方面看,这些都不是基本的东西了,需要在需求上做从用户侧更明确地的拆分才好理清其中具体特性的必要性(所以对应用来说自然就有很多是实现细节)——这也要求引入外部 HIR 才能提供的 high-level 抽象描述能力。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment