关于Zig(和铁锈)的各种思考

2020-10-20 17:53:07

我已经使用ZIG进行了大约4个月的附带项目,包括一个玩具文本编辑器和一种关系语言的解释器。我已经写了~10kloc。

这远远没有足够的时间来形成一个连贯的、知情的观点。因此,以下是一系列杂乱无章的想法和经历,没有特别的顺序:)。

这不是对zig的介绍-请查看优秀的语言文档或新的ziglearn.org。我会试着把注意力放在那些从阅读介绍性材料中看不出来的东西上。

显而易见的比较点是生锈。作为背景,我从2015年起就开始使用铁锈了。主要是在研究岗位上编写一次性代码,但也有大约14个月的时间在大约100kloc的商业数据库上工作。

ZIG比铁锈简单得多。我花了几天时间才感到熟练,而不是一个月或更长时间的生锈。

这种差异大部分与寿命无关。Ruust有模式、特征、动态、模块、声明性宏、过程性宏、派生、关联类型、注释、cfg、货物特性、Turbofish、autoderefence、deref强制等等。我在第一周就遇到了这些东西中的大多数。仅仅了解它们是如何工作的就是一项巨大的时间投入,更不用说学习何时使用它们以及它们如何影响可用的设计空间了。

我还没有将全部规则内在化到能够预测我脑海中的设计是否能成功编译的程度。我不记得在自动取消引用期间解析方法的顺序,不记得模块可见性是如何工作的,也不记得类型系统是如何确定一个Impl可能会重叠另一个还是孤立的。经常有这样的时刻,我知道我想让机器做什么,但却很难把它编码成特性和寿命。

ZIG设法通过单一机制提供许多相同的特性-常规ZIG代码的编译时执行。这会带来各种各样的利弊,但一个重大而重要的优点是,我已经知道如何编写常规代码,所以写下我想要发生的事情对我来说很容易。

Zig和rust之间的主要区别之一是,在编写泛型函数时,rust将证明该函数对于泛型参数的每个可能值都是类型安全的。Zig将证明该函数只对您实际调用函数时使用的每个参数是类型安全的。

一方面,这允许ZIG使用任意的编译时逻辑,其中Rust必须将自己限制在结构化系统(特征等)上,它可以形成关于这些系统的一般证明。这反过来又赋予了ZIG极大的表现力,同时也极大地简化了语言。

另一方面,我们可以对包含泛型的zig库进行类型检查。我们只能对这些库的特定用途进行类型检查。

//如果不存在奇数完全数,则此函数是类型安全的//comptime foo(comptime n:comptime_int,i:usize)usize{const j=if(comptime is_ODD_Perfect_Number(N))";https://en.wikipedia.org/wiki/Perfect_number#Odd_perfect_numbersfn!";Else 1;Return i+j;}。

这意味着ZIG也无法获得自动的、机器检查的类型约束文档,而Ruust将从中受益,并可能面临更多提供IDE支持的挑战。

这可能会使构建各种库变得更加困难,从而损害ZIG生态系统。但朱莉娅有一个类似的模式,而且在实践中效果很好。

以同样的方式实现专门化应该是相对简单的,这是一项多年来一直在进行的工作,对Julia的数学库中的许多优化都是至关重要的。

朱莉娅之所以选择动态类型,是因为要把各种数学运算的类型编码成一个通用的模式非常困难(如堡垒为此苦苦挣扎)。Zig的方法不需要通用模式,但仍然对个别情况进行类型检查,这可能是一个有趣的最佳点。

我使用了2020年CWE最危险的25个软件弱点来了解不同内存不安全原因的相对频率。

(我假设ZIG程序员使用的是发布安全模式,而不是命名不幸的快速发布模式,该模式禁用所有运行时安全检查。)。

这两种语言主要使用边界检查切片,并将指针算法降级为单独的类型(*T表示铁锈,[*]T表示zig)。

这两种语言都需要对空值进行显式注释(rust中的选项<;t>;,zig中的?t),并且要求代码要么处理空值情况,要么在rust,x中的NULL(x.unwire())上安全崩溃。之字形)。

取消引用/强制转换null c指针在两种语言中都是未定义的行为,但在zig中会在运行时进行检查。

铁锈在调试中捕获溢出,并在发布中包装。ZIG在DEBUG/RELEASE-SAFE中捕获溢出,并在RELEASE-FAST中保留未定义的行为。

这两种语言都允许显式请求环绕(铁锈中的x.wrapping_add(1),zig中的x+%1)。

只要所有不安全的代码都遵守别名和生存期规则,铁锈就可以完全保护您免受UAF的攻击。

Zig几乎没有什么保护作用。最近合并的GeneralPurposeAllocator避免重用内存区域(这可以防止释放的数据被覆盖)和重用页面(这意味着UAF最终将导致页面错误)。但这是以碎片化和较低的性能为代价的,而且它也不会为使用GPA作为后备分配器的孩子分配器提供保护。

只要安全,这两种语言都会在基元类型之间插入隐式强制转换,否则需要显式强制转换。

在RUST中,发送/同步特征标记可以安全地跨线程移动/共享的类型。在没有不安全代码的情况下,应该不可能导致数据竞争。

Zig没有类似的保护措施。在comptime zig中实现与发送/同步相同的逻辑是可能的,但如果没有跟踪所有权的能力,则规则将受到更多限制。

生锈可防止同时对同一存储区域进行多个可变引用。

这意味着在编译时防止例如迭代器无效,因为当迭代器持有对数据结构的引用时,借用检查器不允许改变数据结构。类似地,用于在保持对旧分配的引用的同时调整数据结构的大小。这两个例子都是Zig型UAF的简单来源。

目前,这两种语言都不能生成堆栈溢出的堆栈跟踪(铁锈、ZIG)。

将来,zig旨在静态检查程序的最大堆栈使用率,并强制递归代码显式分配堆上的空间,以便堆栈溢出产生可恢复的OutOfMemory错误,而不是崩溃。

这不是一个学术问题-我在现实世界中见过编译器(例如)中的递归树转换造成的崩溃,在没有递归的情况下编写相同的逻辑通常是很痛苦的。

这里定义了锈蚀中未定义的行为。值得注意的是,在不安全的锈蚀中违反别名规则可能会导致未定义的行为,但这些规则还没有很好地定义。到目前为止,这还没有给我带来任何问题,但它让我有点不安。

MIRI是一个用于锈蚀中级中间表示的解释器,它将检测许多(但不是所有)不安全锈蚀中的未定义行为的情况。它太慢了,不能用于整个物化测试套件,但对于单元测试不安全的模块很有用。

此处定义了ZIG中未定义的行为。鉴于核心语言仍在开发中,这个列表可能并不完整。

Zig渴望在调试模式下编译时为几乎所有未定义的行为插入运行时检查。到目前为止,所有简单的情况都得到了处理,这已经比c++语言有了很大的进步。

Zigs编译时的部分计算是由IR解释器完成的-这似乎有可能在将来也用作类似Miri的工具。

@import接受文件的路径,并将整个文件转换为结构。所以模块只是结构。反之亦然-如果您有一个很大的结构声明,您可以将其移动到文件中以减少缩进。

@import是编译时执行系统的一部分,因此可以在常规代码中指定特定于平台的模块和可配置功能等内容,而不是使用RUST的有限#[cfg(...)]。宏。

数组、结构、枚举和联合文字可以是匿名的-。{.Constant=1.0}是其自身类型的匿名联合,但可以隐式强制转换为具有常量:f64字段的任何联合,因为它们共享相同的结构。

在Ruust中,我的代码中到处都是use expr::*和Use expr::*,我会小心避免想要在相同函数中导入的不同枚举之间的名称冲突。在zig中,我只是在任何地方都使用匿名文字,不用担心。

在使用结构模拟关键字参数时,匿名文字也很有用。无需查找并导入正确的类型:

Fn do_Things(config:struct{max_thing:usize=1000,//默认风味:风味,})void{...}do_Things(.{.style=.strawberry});

Let Constant=if let expr::Constant(Constant)=expr{Constant}Else{Panic!()};

这是可行的,因为ZIG中的标记联合为标记本身创建了单独的类型,并且存在从联合到标记的隐式强制转换。

Zig型整数可以是任意大小(例如,U42表示42位无符号整数)。再加上结构上的压缩注释,这使得编写比特打包代码(例如)变得非常容易。

没有模式匹配,但是在编译时检查枚举和联合的开关是否详尽。这涵盖了我使用的模式匹配的大约90%。

更复杂的匹配(例如)必须由链接的IF语句处理。这通常比等效锈的可读性差,我错过了详尽的检查。

现有的本地语言工具(valgrind、prof等)对两种语言都有效,即模名损坏。

这两种语言都可以使用gdb和lldb,但体验一般。Rust在gdb中有内置的支持,它改进了漂亮的打印,并允许编写rust表达式,但仍然有很多漏洞和怪异之处。

铁锈对LLVM消毒剂(ASAN、libfuzzer等)的支持不稳定。这在ZIG中是一个悬而未决的问题。

Rust语言服务器可以使用。它在有效代码上工作得很好,但在编辑过程中很难处理无效状态。在大型代码库中,它也可能慢得令人痛苦-当使用Materialize时,它通常比仅仅运行货物检查慢3-5倍。

我还没有尝试过Zig语言服务器,但它似乎相当完整。

即将推出的增量式ZIG编译器也可以作为IDE后端使用。我一直没有密切关注这个设计,但似乎很明显,他们一直在关注锈蚀社区在过去五年左右的时间里学到了什么。

ZIG标准库为分配器提供了标准接口。Stdlib中分配内存的所有内容都以分配器作为参数。这使得使用自定义分配器变得很容易,而不必编写您自己的基本数据结构。虽然目前还没有太多之字形的生态系统,但未来的图书馆似乎很可能会遵循这一惯例。

Rust提供了一个隐式全局分配器。标准库和大多数其他库仅使用全局分配器。可以选择全局分配器,但不能在程序的不同部分使用不同的全局分配器。

虽然可以用铁锈来编写您自己的分配器,但是目前还没有一个分配器的标准接口,而且生态系统的大多数都只使用全局分配器。目前正在进行改善这种情况的工作。

我还发现,在RUST中从全局分配器切换到自定义分配器需要进行大量的重构,并且会产生大量的样板,因为它会引入新的生命周期,这些生命周期会传播到每个已分配的类型中。当用之字形编写编译器时,我倾向于分配所有内容。在Rust中,我最终使用全局分配器并克隆大量数据,因为这样更容易。

在RUST中,如果您将字符串或VEC存储在例如TYPED-ARENA中,则支持数据仍将由全局分配器分配和释放。它提供了竞技场分配器的终生优势,但没有性能优势。像umpalo这样的分配器目前需要重新实现像string和vec这样的stdlib类型。我们有计划来解决这个问题。

在此之前,通常很难获得这种专用分配器的性能优势,例如,在Materialize数据平面(这里)中有一条热门路径,如果我们只需增加Allocation String和VEC,它将会更便宜、更简单。

Rust全局分配器在分配失败时出现恐慌。ZIG分配器返回一个OutOfMemory错误,可以从中恢复。

OutOfMemory错误的缺点是它们引入了更多难以测试的错误路径。不管怎样,我大多选择惊慌失措,但肯定有一些情况下恢复是可行的和有用的,例如:

SQL数据库可能希望为每个客户端设置内存限制,并中止超过该限制的查询

Zig没有闭包的语法。您可以手动创建闭包,但它很繁琐。有一个追踪问题悬而未决,但看起来还没有达成共识。缺少闭包限制了某些类型的API,例如将差分数据流直接移植到ZIG将不太好用。

ZIG没有等同于智能指针的功能。我现在还不想要,但我预计最终会怀念拉斯特的RC。手动递增引用计数非常容易出错。

Zig的错误处理模型类似于Rust的错误处理模型,但它的错误是开放联合类型,而不是像Rust那样的常规联合类型。因此:

编译器可以准确推断每个函数可以返回的确切错误集。(在Rust中,每个库只有一个错误枚举是非常诱人的)。

如果处理可能错误的子集并传播其余错误,编译器将正确推断减少的错误集。

您可以使用comptime断言来检查推断的错误集是否完全符合您的预期-既不多也不少。

ZIG的错误还带有它们所经过的每个函数的痕迹,即使错误在此过程中发生了变化。这与创建错误时的堆栈跟踪不同-它通过错误处理代码跟踪错误路径。

要在铁锈中找到这条路,我首先必须弄清楚如何重现撞车,将其记录在rr中,然后从恐慌中向后走。

ZIG的错误还不能携带任何有关该错误的额外信息。这是一个有待改进的问题,但看起来还有一些剩余的设计问题需要解决。

在此期间,任何额外的信息都必须通过旁路(例如)传递。这很烦人,而且容易出错。

函数列表(comptime T:type)类型{return struct{value:T,next:?list(T),def len(self:*const list(T))usize{return 1+if(self.next)|next|next.len()Else 0;}};}。

您还可以在该函数中插入任意逻辑。下面是一个实现数组结构转换的函数:

Fn StructOfArray(comptime T:type)type{//反映有关类型T的信息const t_info=@typeInfo(T);//如果(t_info!=.Struct)@compileError(";StructOfArray仅适用于结构!";);//新建一组字段,其中每种类型都是一个数组var SOA_FIELS:[t_info.Struct.fields.len]std.builtin.TypeInfo.StructField=未定义;对于(t_info.Struct.field)|t_field,i|{var soa_field=t_field;soa_field.field_type=[]t_field.field_type;soa_field.default_value=null;soa_field[i]=soa_field;}//用这些数组字段var soa_info=t_info;soa_info.Struct.field=&;soa_field;const INTERNAL=@Type(SOA_INFO);//返回最终类型返回结构{Internal:Internal,const self=@this();/获取自身fn get(self:*const self,i:usize)T的第i';个元素{var t:t=unfined;//对于t的每个字段,从i';(t_info.Struct.field)|t_field|{@field(t,t_field.name)=@field(self.ner,t_field.name)[i];}return t;}的内联对应数组的第个元素//(由于关键字`inline`而在编译时展开)|t_field|{@field(t,t_field.name)=@field(self.iner,t_field.name)[i];}return t;}};}。

虽然冗长,但这完全是由我在使用ZIG的最初几天学到的工具构建的。我认为在Ruust中实现相同示例的唯一方法是使用自定义派生,但我还没有学会如何编写过程性宏。

在Rust中,可以使用#[Derate(Ord)]为类型创建Ord特征的默认实现。如果您不执行此操作,则由于孤立实施规则,您的库的用户无法自己执行此操作。如果您这样做,您的库的用户将为额外的编译时间付费,即使他们从未使用过它。

Pub FN Compare(a:anytype,b:@typeof(A))排序{const T=@typeof(A);if(std.meta.trait.hasFn(";Compare";)(T)){//如果T具有Compare自定义实现,则使用返回T.Compare(a,b);}Else{/否则,使用反射派生默认实现开关(@typeInfo(T)){...}}

此函数适用于任何类型,即使是来自其他包的类型,也可以逐个类型重写,并且仅为实际使用它的函数编译。只需做一点额外的工作,它也可以由没有创建原始类型的第三方库进行扩展。

Zig的编译是懒惰的。只有实际可访问的代码才需要进行类型检查。因此,如果您运行zig test--test-filter the_one_test_i_care_about_right_now,那么只需要对用于该测试的代码进行类型检查。这使得测试快速更改和增量重构代码变得容易得多。

我是在反复未能为Pinephone交叉编译Rust+GTK hello world而休息时读到这篇文章的,几个小时内就有了一个工作正常的zig版本。

Zig编译器包括它自己的标准库的源代码和各种平台的libc源代码,因此不需要像xargo这样的工具。

另外,我还可以直接在Pinephone上编译zig+gtk hello world,而编译rust+gtk hello world需要2.8 GB的内存,大约是zig版本的10倍,超过手机上可用的2 GB。

ZIG有一个实验性的构建系统,其中构建图由ZIG代码组装。这使得拥有各种不同的构建任务并在它们之间共享逻辑变得容易。每个任务都与带有一些注释的命令捆绑在一起,这些注释显示在zig build--help中,因此很容易看到可用的内容。

Ruust在Cargo.toml中有固定的声明格式。它提供了四个内置配置文件,并且还不允许创建更多配置文件。它通过查找一些预定义的目录来确定目标列表以及每个目标要使用的配置文件。

在具体化中,这是行不通的-需要四个以上的配置文件。例如,其中一个测试套件运行速度太慢,无法在没有优化的情况下运行,但它仍然需要打开溢出检查之类的功能。使用各种不同设置运行某些目标通常也很有用。我最终只编写了覆盖配置文件设置的shell脚本。

Zig还处理编译和链接c库等任务。在rust中,这需要一个build.rs文件,这是另一个需要学习的系统。

公平地说,ZIG还没有包管理器,所以它不必担心试图组合各种项目的构建选项。但我希望使用代码而不是配置文件来指定选项范围仍然是更简单的选择。NIX和Guix在这里是很好的现有技术-这两种技术都使得表达任意修改dep变得很容易。

.