分类 Rust 下的文章

最近学习rust的时候,了解到rust的浮点数实现是和js是一样的, 也就导致了我们在js上遇到的精度问题, 在rust同样也能遇到.

首先我们来理清, rust的默认浮点类型是f64, 而js由于和其他语言不同, 无论是整数和浮点数都是number类型, 也是64位固定长度, 也就是标准的双精度浮点数,

双精度浮点数(double)是计算机使用的一种数据类型,使用 64 位(8字节) 来存储一个浮点数。 它可以表示十进制的15或16位有效数字,其可以表示的数字的绝对值范围大约是:-1.79E+308 ~ +1.79E+308 [1] 。

既然2种语言底层的标准都是一样的, 都是使用了IEEE 754标准中的double精度, 那我们就直接使用大家熟悉的js来做demo.

为什么使用double精度标准

对比单精度标准来说, 虽然double精度占用比单精度高(8byte > 4byte), 这也就间接意味着cpu在处理上, 单精度会有优势, 但是单精度的致命缺陷就是有效数少而且范围也会更小, 总的来说适用性略低, 而且在现代cpu来说, 处理速度上基本是可以忽略不计的.所以在rust中默认的浮点数类型就是f64, 如果有需要就选择f32(单精度)

double精度如何存储

直接从wiki上抄一张图下来

618px-IEEE_754_Double_Floating_Point_Format.svg.png

  • 符号位:1 位 (+, -)
  • 指数:11 位 (次方)
  • 有效位数精度:52 位

运算过程

我们通常会用十进制来表达浮点数, 但是我们rust/js底层都是用二进制实现浮点数类型的, 比如说我们写一句这样的代码:

var a = 0.1;

在我们程序员眼中它可能就是绝对等于0.1的, 但是在内部实现中, 它需要转换为二进制, 但是在二进制中就是无限精度类型, 也就变成了下面这样:

0.1 -> 0.0001 1001 1001 1001...(1100循环)

但是由于我们底层的标准, 有效位数的精度是52位, 我们在做浮点运算的时候, 多余的数字都会被截断, 所以在js从二进制转换为十进制之后, 就不是我们预想的答案了(在一定精度结果是对的)
, 同理在rust/js中我们也不要使用浮点数做比较, 因为是一个危险不受信赖的计算结果, 也希望精度问题能够引起大家重视, 因为有很多危险的事件是由转换精度触发的:

对于Ariane 4火箭的工作代码在Ariane 5中被重新使用,但是Ariane 5更高速的运算引擎在火箭航天计算机中的算法程序中触发了一个bug。该错误存在于将64位浮点数转换为16位带符号整数的程序中。更快的运算引擎导致了Ariane 5中的64位数据要比Ariane 4中更长,直接诱发了溢出条件,最终导致了航天计算机的崩溃。首先501航天飞机的备份计算机崩溃,然后0.05秒之后,主计算机也崩溃了。这些计算机崩溃直接导致了火箭的主要处理器使火箭的运算引擎过载,同时导致火箭在发射40秒后解体破碎。

顺带提一句, rust中对于整型有溢出处理, 在release环境下, 会按照补码循环溢出的规则去解决, 但是这仍然会造成结果不一致的错误.

如何解决

rust: 我不知道咋解决, 我才学rust
js: 大把的精度库, 最流行的方案就是底层使用string了, bignumber.js, 就可以避免浮点数陷阱;

基础类型

rust是一门静态编程语言, 所以我们有必要知道它的类型, rust的类型可以分为基本类型和复合类型(也可以称之为复杂类型), 基本类型指的就是原子化最小的类型, 它不能转换为其他类型;

rust不像ts, ts是js的超集, ts可以更好的推断类型, 但是rust拥有一个很聪明的编译器, 你可以无需指定类型, 让编译器去自动推断, 但是某些情况下编译器无法推断类型, 比如这样

let guess = "42".parse().expect("Not a number!");

鬼知道guess是什么类型, 所以编译器会报错, 你只需要显式的指定类型即可;

数值类型

rust创建数值非常简单

let a = 1;

整数

我们继续来探讨整数类型, 我们之前了解过的i32类型, 表示有符号的32位整数

i表示integer, 与之相反的是u, 代表无符号

类型统一定义为 “有无符号” + “位数(bit)”, 无符号表示就是正数, 有符号就是正负; 简单的使用准则需要我们记住, rust整形默认使用i32, 可以首选i32, 而且性能也是最好的;

我们在处理整型的时候, 如果设置的值超过了范围, 那么rust会区分模式, 做不同的决策, 比如在debug模式中, 程序会报错退出; 如果是release环境, 会被补码为范围内的最小值, 因此这样的结果是不符合预期的, 会被认为是错误的.

浮点数

浮点数默认类型是f64, 但是rust有一个浮点数的陷阱, 我们通常会使用十进制表达浮点数, 但是rust底层是使用二进制实现浮点数类型的, 比如说0.5在十进制上存在精确表达, 但是在二进制中不存在, 所以就会造成歧义, 所以想要表达完整的真实浮点数字, 就必须使用无限精度的浮点数才行.

还有一个注意的点就是, 我们不要对浮点进行比较, 这和js一样, 由于rust的浮点类型底层是二进制, 而js同样在浮点数运算时也是二进制, 都会存在不准确的问题; 在rust中对浮点数进行比较的时候, float声明了PartialEq, 注意它不是Eq; 在map数据结构中的k类型, rust是需要你传入声明过Eq 的类型, 很显然float不满足要求; 而且当我们使用浮点做比较的时候, 会造成编译器错误.

NaN

和js一样, 我们可以在rust中做一个防御性编程:

fn main() {
    let x = (-42.0_f32).sqrt();
    if x.is_nan() {
        println!("未定义的数学行为")
    }
}

序列

rust提供了一种简洁的方式生成连续的数值

// 生成1-5 (包括5)
1..=5
// 生成1-5 (不包括5)
1..5

字符, 布尔, 单元类型

这一部分比较简单,

char

fn main() {
    let c = 'z';
    let z = 'ℤ';
    let g = '国';
    let heart_eyed_cat = 'hhh';
}

不管是ascii, 还是unicloud都算作rust字符char; 另外字符是用’’包裹的, “”是表达为字符串

bool

rust中的布尔类型和其他语言一样, 存在true/false

单元类型

0长度的元组, 单元类型使用()来表达, 我们可以用单元类型占位, 它不占用任何内存, rust函数中的main还有print函数都会返回一个单元类型. 我们不能说main函数没有返回值, 因为没有返回值在rust中是有单独的定义的(发散函数: diverge function) , 顾名思义, 无法收敛的函数.

语句和表达式

在rust的函数体中是一系列语句组成, 最后是由表达式返回, 比如这样

fn add(x: i32, y: i32) -> i32 {
    let x = x + 2; // 语句
    let y = y + 3; // 语句
    x + y // 表达式
}

语句

 let x = x + 2; // 语句
 let y = y + 3; // 语句

由于let是语句, 我们不能将let赋值给其他值:

let b = (let a = 8);

这样编译器会报错…

表达式

表达式会进行运算/求值, 比如1+2, 会返回3, 我们在语句上面写了这段代码:

 let x = x + 2; 

这里的x + 2确实是一个表达式, 只要记住只要能返回值, 它就是表达式 而且表达式不能包含分号, 比如下面一段代码

let y = {
    let x = 3;
    x + 1
};

如果x + 1包含了分号, 那么此时这个语句块不会返回任何值了, 也就不算作表达式了, 所以我们在rust中编写函数的时候, 如果要返回值, 就要注意这里不需要加分号;

函数

rust的函数很简单, 下面是一个例子, 有关键字, 函数名称, 形参名称和类型, 函数返回值类型, 以及一个代码块, 就构成了一个最简单的函数:

fn add(i: i32, j: i32) -> i32 {
   i + j
 }
  1. 首先rust是强类型的语言, 我们在编写函数的时候, 参数的类型是必须的, 如果不提供参数的类型是会报错的.
  2. 函数的返回值就是代码块的最后一行的表达式返回值, 当然我们也可以使用return关键字提前返回也可以
  3. 在单元类型中, 我们提到了无返回值, 我们可以使用单元类型作为返回, 而一些表达式你不幸加了分号变成了语句, 那么此时也会返回一个单元类型.
  4. 在上文提到了diverge function, 我们可以使用!作为函数返回类型, 这一般会作为程序崩溃的函数, 代表永不返回:
fn end() -> ! {
  panic!("it is over...");
}

作为一个没有学过C/C++的开发者(比如说我),在初步学习Rust中需要了解一些JavaScript中不存在的东西,那么所有权就是Rust中的核心功能之一。我需要一篇文章记录这一次的学习,在真正内容开始之前我需要描述一些基础知识,在最后也会简单看看”引用和借用“,”slice类型“这些与之相关的Rust概念。因为是Rust初学者,请大家阅读本篇文章带着自己的思考,因为每个人的思考方式和理解都不一样,所以可能会导致某些错误...。

参考资料:

预热

程序员编写的所有程序都必须管理其使用计算机内存的方式,比如说JavaScript这一类的语言具有垃圾回收机制(GC)它可以不断寻找不再使用的内存,因此不需要开发者手动干预;在另外一些语言中,我们需要亲自分配和释放内存;但是在Rust中就利用了所有权的概念管理内存,编译器在编译阶段会根据规则进行检查,同样的也不需要程序员手动干预内存,这些有关内存的工作都交给了所有权和编译器。所以我们学习Rust中的所有权的时候,准确说应该是学习编译器是通过什么“规则”来进行检查的,这个规则对我们更重要。

堆栈

我们在写JavaScript的时候,通常业务开发我们不需要考虑堆栈,但是在Rust中我们需要考虑一个值是在堆上还是在栈上,这和所有权息息相关,所以我们先简单回顾一下堆和栈的基础知识。堆和栈都是代码运行时可使用的的内存,它们是不相同的。大家都知道栈是先进后出,像服务生手中一个一个托盘一样,最先放入的托盘一般都会最晚从上方取出;栈中所有数据都占用固定的大小,程序在编译时都会看值大小是否可能会变化,如果是则需要存储到堆中,因为堆中所有的数据都是凌乱的,需要分配器去在堆中区域开辟一块空间,标记为已使用并且返回指针,这个过程也叫做在堆上分配内存

数据直接入栈当然也比在堆上分配内存更快,入栈直接放到栈顶即可,入堆的话分配器不仅要查看内存,分配内存还要记录以便为下一次分配做准备;访问堆上的数据也比访问栈中的数据要慢(因为指针),而因为处理器缓存的原因,跳转越少就越快,当访问的数据彼此都是栈中那自然快,但是彼此数据一个在栈中一个在堆中那自然会稍慢一点。那么所有权将会帮助我们处理堆中的重复数据,无用的数据以及跟踪哪些代码在使用堆中数据,总的来说所有权的存在就是为了管理堆数据。

所有权规则

  • 每一个值都有一个被称为其所有者的变量
  • 值在任一时刻只有一个所有者
  • 当所有者(变量)离开作用域的时候就会被丢弃

感觉目前理解这几个规则还比较晦涩,但是我们可以随着笔记深入慢慢地理解

作用域

所有权规则中既然提到了作用域,那我们简单看看作用域,其实和JavaScript相差不大,我们非常容易理解

fn main(){ // a在这里无效
    let a = "hello seho" // a有效
} // 作用域结束了,a无效

看起来很简单不是么?和JS一样?

我们此时会发现hello seho这个值是通过字面量硬编码创建了一个字符串并且绑定到a中,这种情况下a将会在栈中,因为它有着固定的大小,那么当作用域结束之后,a将会直接出栈。

内存和分配

对于以上的hello seho这种值,在我们的实际业务中并不常见,我们大多数会存在一个大小不定的内容,而且会在程序的运行时增大或者减小,那么为了支持这种需求只能在堆中开辟出一块内存,意味着程序需要在运行时去创建这块的区域,而且当我们处理完内容,需要把内存还给内存分配器。

第一步很好说,我们调用String::from这种方法就可以在堆中创建一块区域,那么第二步怎么还呢?

在C中是需要手动的释放内存的,但是程序终究是人写的,有时候忘记还了,有时候过早还了,有时候重复还了,都会造成系统bug。在有GC的语言中,GC会记录并且清除不使用的内容从而释放内存,所以这也就是我为啥喜欢JS的原因了,真的太爽了....

我们把上面的代码改造一下

fn main(){ // a在这里无效
    let a = String::from(""hello seho"") // a有效
} // 作用域结束了,a无效

在作用域结束之后,rust会调用一个特殊函数drop从而释放内存,在stirng内部会实现这个drop函数...

移动

我们在JS中写过很多这样的代码

let a = 1;
let b = a;

很显然,它在栈中创建了2个变量, a和b,他们的值都是1

在rust中也一样,但是如果我使用String::from创建变量呢,在堆中和栈中会有不同么?

let a = String::from("hello seho");
let b = a;

很显然,a的值是hello seho,b“复制”了a的指针,它们都存在栈中,指针指向了堆中的hello seho。

诶,难道说,rust的表现和js一样?在我们前面提到了所有权的规则之一,当变量离开了作用域将会自动释放,但是此时a和b都指向同一堆中内容,此时不是造成了多次释放的问题?答案是肯定的,在我们之前就说到过,没有释放,过早释放,多次释放都会对程序造成影响,所以rust在针对我们上面这种代码的时候,作出了一个处理即当a被“复制”到了b身上,此时a不再生效,程序会在编译期间报错。

如此看来,就避免了多次释放的问题,而“复制”也不是真正的复制,而是移动

那么如果真的想克隆一个一摸一样的值,可以调用clone方法,而我们在上面写了一个整型的例子,为什么没有调用clone也可以被克隆,是因为它们本身就是栈中的数据,在栈中的拷贝是快速的,不需要通过移动这种机制来实现拷贝

到此为止,我们应该能理解所有权到底是什么了,我们在本篇笔记中学习了所有权的概念和规则,以及复习了堆栈基本知识,还有rust的内存分配。我们在本篇笔记中大量的使用了js这门语言作为参照对象,如果你不熟悉js的话,下次笔记我会将语言的比较去掉,这样应该会更加容易理解,有问题发评论区,吴彦祖和迪丽热巴发的评论我肯定都会回复