写点什么

Java 程序员眼中的 Rust 系列 — 1. 初见

作者:景间
  • 2024-07-15
    天津
  • 本文字数:4062 字

    阅读完需:约 13 分钟

Java程序员眼中的Rust系列 — 1.初见

0-前言

我从未想过自己要写一个 Rust 入门系列的文章,因为知道要把这么复杂的一门语言想办法教会给初学者是多么困难,我实在没那个毅力。然而在我日常的编码工作中(主要是使用 Java),总是会冒出一系列的念头:“这个 bug 如果换做用 Rust 实现,就不会出现”或者“这部分代码如果用 Rust 实现可读性会更好”,当然也存在“这种功能如果用 Rust 写我会吐血”的时刻。可以说掌握一定 Rust 经验后,返回头来,我对 Java 的理解更深刻了,获得了更多的灵感。最重要的是,这种对比很好玩。

我想从一个 Javaer 的角度出发,带大家看看最近几年很火的 Rust 是一门什么样的语言——实际上不止于 Rust,当今新生代的编程语言,具有愈来愈多的类似特性,这些特性在 Java 上看不到,但 Rust 有。熟悉了 Rust,以后去学其他语言也会很快。

Rust 的学习曲线陡峭是出了名的,如果费了很多精力学而不用则毫无意义。所以我的终极目标是“好玩”,“激发灵感”,而不是“掌握全貌”,我甚至不求读者要自己敲一遍代码。对于一些有难度又无法避开的问题,我尽量做到内容简洁易懂。

如果真的在学习过程中产生了兴趣,想要深入学习,我强烈推荐看一下这个免费教程《Rust 语言圣经》关于本书 - Rust 语言圣经(Rust Course)。


当然,我并不拒绝深入讨论 Rust 相关问题,欢迎留言。

1-依旧从 Hello, World 开始

为了不吓跑读者,我们暂时不去谈如何安装一个完整的运行环境。Rust 官方提供了一个网页可以执行简单的 Rust 代码——Rust Playground : Rust Playground。在不知道如何在本地跑一个 Rust 程序前,可以先把文中的代码贴到 Playground 中。

fn main(){    println!("hello, world");}
复制代码

点击左上角的 RUN 按钮,可以看到执行结果:

这就是最简单的一个 HelloWorld 程序,我们逐行来说明。

首先, fn main()即是定一个 main 函数,等价于 Java 的:public static void main(String[] args)。fn 是 Rust 中的关键字,用于定义函数或方法。main 函数不可以有入参。

我相信打印出"hello, world"这行你能大致上明白,不过等等 println!是什么?这是一个内置的函数么?结尾的叹号是什么鬼?

实际上这不是一个函数,而是 Rust 中的宏(macro)。Java 或 Golang 程序员可能对宏的概念比较陌生,但 C/C++程序员都应该很熟悉了。宏在 Rust 是一种元编程的工具,用来提高可读性,减少重复。

所以 println!其实是 Rust 内置的用于打印文字到标准输出的宏。为什么 Rust 要将打印输出这一编程语言中最常用的功能设计成宏而不是函数呢?看下面这个例子:

fn main() {    println!("my name is {}, number is {} ", "Tom", 5);}
复制代码

如你所想,打印出的结果是:"my name is Tom, number is 5"。println!宏支持以这种方式拼接字符串,像极了 Java 中一些日志框架进行输出的写法:

logger.info("my name is {}, number is {} ", "Tom", 5);
复制代码

方便且易读,新时代语言的标配。在 java 中,这种效果的实现依赖于“可变参数方法”这一语法。但 Rust 本身的语法并不支持可变参数,所以要靠宏来曲线救国。简单说,Rust 中的宏会在编译阶段被替换成另外一个面目全非的样子。对应到 println!宏,就是在编辑阶段产生一段代码,内容是把所有参数组成一个参数,然后调用某个底层标准输出的函数。再次强调,这个过程与运行时无关。

最后,Rust“绝大多数”语句都是要以分号结尾的,未来你会看到例外的情况。

2-声明一个变量

在 Rust 中,“声明”一个变量,使用 let 关键字:

let x = 1;
复制代码

我们并没有指定 x 的类型,但它能工作,是因为 Rust 支持自动类型推导。1 在此会被编译器认定为一个 i32 类型的字面量,当然也可以显示写明类型:

let x: i32 = 1;
复制代码

与 Java 不同,Rust 的世界,类型是“后置”的。

结合上一节,你可以在打印的参数中加入变量:

fn main() {    let name = "Tome";    let number = 5;    println!("my name is {}, number is {} ", name, number);}
复制代码

甚至可以这样写:

fn main() {    let name = "Tome";    let number = 5;    println!("my name is {name}, number is {number}");}
复制代码

哈哈,有 python 内味儿了。

实际上 let x = 1 这种语法并不等于 Java 中的变量声明,严格的定义应该称为“绑定”(binding)。了解一些 js,ts,scala 等语言的同学会比较熟悉,这是函数式语言中常见的概念。至于到底“声明”与“绑定”有什么区别,还是要等到未来有机会再说。

2-数值类型

正如 Java 一样,Rust 也针对不同长度的数值设置了对应的类型,区别是每种类型多一个无符号的版本:

// 整数类型let a: i8 = 1_i8; // Java: bytelet b: u8 = 1_u8; let c: i16 = 1_i16; // Java: shortlet d: u16 = 1_u16; let e: i32 = 1_i32; // Java: intlet f: u32 = 1_u32;let g: i64 = 1_i64; // Java: longlet h: u64 = 1_u64;let i: i128 = 1_i128;let j: u128 = 1_u128;let k: isize = 1_isize;let l: usize = 1_usize;
// 浮点类型let m: f32 = 1.0_f32; // Java: floatlet n: f64 = 1.0_f64; // Java: double
复制代码

容易看懂,i8 代表 8 位有符号,u8 代表 8 位无符号。isize 和 usize 占用大小和运行的平台相关,64 位环境占用 64 位。

在 Java 中,可以直接将一个表示范围更小的值赋值给范围更大的变量,反之则不行:

int a = 1;long b = a;long c = 1L;int d = ((int) c);
复制代码

但 Rust 就会更严格一些,即使是从小向大的转换,也需要通过 as 关键字:

let a: i32 = 1;let b: i64 = a as i64;let c: i64 = 1;let d: i32 = c as i32;
复制代码

3-编译告警

如果你把上一节的代码直接拿来运行,而没有添加其他内容,则编译时应该出现以下信息:

这里的意思是,b 和 d 两个变量并没有被使用。Rust 的编译器针会对未使用的变量,方法,导入等进行告警。近些年新兴的编程语言越来越重视检查“用不上的”内容,以提高可读性维护性。比如 golang,对于未使用的变量和导入都会在编译阶段报错。

Rust 更温和一些,仅仅是告警,但告警多了也很烦人。这个问题编译器给出的方案是,将 bd 定义改为_b,_d。其实你也可以直接写一个下划线:

告警一样会消失。这种用法接近 python 和 golang。

下划线在 Rust 中有各种特殊的作用,未来还能看到,不过都可以概括为一个意思:“I don't care”。

4-修改一个变量

直到现在,Rust 看起来还和 Java 没本质的区别,别忙,第一个让 Javaer 别扭的特性要来了:

fn main() {    let x = 1;    println!("{x}");    x = 2;    println!("{x}");}
复制代码

我要将 x 的值修改为 2,看起来很正常是不是?运行一下,编译报错了:

Compiling playground v0.0.1 (/playground)error[E0384]: cannot assign twice to immutable variable `x` --> src/main.rs:4:5  |2 |     let x = 1;  |         -  |         |  |         first assignment to `x`  |         help: consider making this binding mutable: `mut x`3 |     println!("{x}");4 |     x = 2;  |     ^^^^^ cannot assign twice to immutable variable

For more information about this error, try `rustc --explain E0384`.error: could not compile `playground` (bin "playground") due to 1 previous error
复制代码

顺带一提,Rust 的编译器是非常智能和体贴的,每种类型的错误都有一个编号,比如这里的 E0384,你可以在错误码页面https://doc.rust-lang.org/stable/error_codes/找到对应的解释。除了错误原因,很多时候编译器还会给你建议,看这句话:“help: consider making this binding mutable: `mut x`”,编译器建议我们把 x 变量前面加上 mut。试一试:

fn main() {    let mut x = 1;    println!("{x}");    x = 2;    println!("{x}");}
复制代码

可以了,为什么?

在 Rust 中,一个变量默认是不可修改其值的,如果要可变,必须加上 mut 关键字,包括我们以后会讲到的函数也是如此。这种对可变性做限制的理念,源自于纯函数式语言,又被诸多混合式的语言借鉴,我想 Rust 如此设计大抵有两个原因:

  1. 提高可读性,避免难排查的 bug

做过复杂业务系统的码农,一定有过类似这样的经历:你有一个实体对象,会作为一些操作的核心参数,在不同的方法之间传递,这些方法是不同的人在不同的时间“堆积”出来的,每个方法里的代码背后都代表着一个不可理解的用户需求,你不懂,也不想懂。在调用方法传入参数的那一刻,你只是根据方法签名的含义(甚至还有注释),想当然的认为,这个参数在执行周期内不会发生改变,然而你错了:

public class Main {    static class Foo{        // 忽略一些字段    }
static void function_modify_foo(Foo foo){ // 调用其他方法,最终会修改foo的内容 }
static void function_use_foo(Foo foo) { // 使用foo的内容 }
public static void main(String[] args){ final Foo foo = new Foo(); function_modify_foo(foo); // 你觉得foo不会改变 function_use_foo(foo); }}
复制代码

解决这类问题的办法有两类,要么你把所有涉及到代码读一遍,要么你一层一层 debug 调试。这还仅仅是一些应用层的业务代码,你都不敢想象在 C/C++开发的领域内,要如何查找问题根源。

Rust 中的默认不可变机制并不能杜绝数据修改,mut 关键字的作用更多的是让你清楚正在做什么,清楚某个变量或者参数是可能被修改的,一旦发生问题,也能在一定程度上缩小排查范围。

至于上面举例的这种典型问题,得益于 Rust 的所有权,引用,Clone 一系列机制,会被扼杀在摇篮中。

  1. 便于编译器优化

这部分内容略有超纲,不深入讲解了,有兴趣的同学可以自行查找资料。我只简单的讲解一下。

编译器的工作并不只是将程序员写的代码原封不动的翻译成目标代码,而是会进行“一定程度”的优化,可以说比起词法语法分析,这些优化才是一个编译器真正的重点工作。当编译知道一个变量或者参数,在其生命周期中不会改变时,可以做非常多的事情,比如内联,消除多余代码,直接分配到寄存器中,调整执行顺序,并行计算等等。

总结

这是系列第一篇,希望你看到 mut 关键字的时候还没有“心生厌恶”。下一篇将介绍函数,结构体,流程控制这些内容,让我们看一看哪些语法特点能让你眼前一亮。同样,我努力做去到侧重于给大家一个直观的感受,而规避复杂烧脑的部分,至少是暂时规避。

发布于: 刚刚阅读数: 4
用户头像

景间

关注

还未添加个人签名 2018-01-21 加入

还未添加个人简介

评论

发布
暂无评论
Java程序员眼中的Rust系列 — 1.初见_Java_景间_InfoQ写作社区