偶然间读到一篇饶有趣味的文章,点击左下角阅读原文可直达英文原文[1]这里是一道独特而美丽的分隔线,接下来,让我们一同领略Rust语言所蕴含的艺术魅力吧。不久前,有人在网上询问Rust中的这种语法:*pointer_of_some_kind=blah;他们想知道编译器如何理解这段
偶然间读到一篇饶有趣味的文章,点击左下角阅读原文可直达英文原文[1]
这里是一道独特而美丽的分隔线,接下来,让我们一同领略 Rust 语言所蕴含的艺术魅力吧。
不久前,有人在网上询问Rust中的这种语法:
*pointer_of_some_kind = blah;
他们想知道编译器如何理解这段代码,特别是当指针不是引用,而是智能指针时。我给他们写了一封很长的回复,但我想将其扩展并改编成一篇博客文章,以防更广泛的读者可能会感兴趣。
我并不从事Rust编译器的开发工作,实际上也从未参与过,但我所了解的是语言语义。如果你是一个语言爱好者,除了了解Rust的值类别之外,这篇文章可能对你来说不是特别有趣。但如果你没有花太多时间研究编程语言的细节,我希望这可以让你对那个世界有一个很好的窥探。
编程语言本身也是语言,在某种程度上与人类语言类似。不管怎样,大多数情况下是这样。关键是,要理解像这样的Rust代码:
*pointer_of_some_kind = blah;
我们可以运用类似的方法,就像理解这样的 “英语代码” 一样:
You can't judge a book by its cover.
哦,在阅读接下来的部分时,你可能也会问自己:“为什么要有这么多步骤?” 简短的回答是一个经典的答案:将 “这是什么意思?” 这个大问题分解成较小的步骤,每个步骤就会更容易。一次性完成所有事情比分成多个较小的步骤要困难得多。我将介绍编译器的经典工作方式,当你开始接触更现代的方法时,会有很多变化,而且通常这些步骤会混合在一起,或者顺序不同,还有各种各样的其他情况。处理错误本身就是一个巨大的话题!把这当作一个起点,而不是终点。
让我们开始吧。
我们要做的第一件事是尝试弄清楚这些词是否是有效的单词。在计算机语言中,这个过程被称为 “词法分析”,不过你也会听到 “标记化” 这个术语来描述它。换句话说,在这个阶段我们对任何意义都不感兴趣,我们只是想弄清楚我们在讨论什么。
所以,如果我们看这个英语句子:
You can't judge a book by its cover.
为了对它进行标记化,我们遵循一个两步过程:首先,我们 “扫描” 它以生成一系列 “词素”。我们通过遵循一些规则来做到这一点。这里我不会给你列举英语的规则示例,因为这篇文章已经够长了。但你最终可能会得到这样的结果:
You
can't
judge
a
book
by
its
cover
.
注意在can't
中我们有'
,但cover
和.
是分开的。这些就是我们要遵循的规则:'
是因为这是一个缩写,而.
实际上不是cover
的一部分,它是独立的。
然后我们进行第二步,评估每个字符字符串,将它们转换为 “标记”。在你的编译器中,标记可能是某种数据类型。例如,我们可以在Rust中这样做:
enum Token {
Word(String),
Punctuation(String),
}
所以我们的标记生成器的输出可能是一个类似这样的数组:
[
Word("You"),
Word("can't"),
Word("judge"),
Word("a"),
Word("book"),
Word("by"),
Word("its"),
Word("cover"),
Punctuation("."),
]
此时,我们知道我们有了一些还算连贯的东西,但我们仍然不确定它是否有效。进入下一步!
有趣的是,在这个领域,人类语言语言学和编译器的含义略有不同。在人类语言中,解析通常与我们的下一步,即语义分析结合在一起。但在编译器中,我们(大多数时候)试图将语法分析和语义分析分开。
同样,我将极大地简化英语的情况。我们将使用以下规则来定义什么是句子:
显然,这只是英语的一个小子集,但你能明白这个意思。语法分析的目标是将我们的标记序列转换为一个更丰富的数据结构,以便于处理。换句话说,我们已经弄清楚我们的句子是由有效的字符序列组成的,但它们是否符合我们语言的语法规则呢?注意,我们也不需要存储所有内容;例如,也许我们的英语数据结构是这样的:
struct Sentence {
subject: String,
words: Vec<String>,
}
句号在哪里呢?我们怎么知道subject
必须大写呢?这就是语法分析的工作。因为每个句子都以句号结尾,所以我们不需要在数据结构中跟踪它:分析确保这是真的,然后我们就不用再做了。同样,如果我们不想,我们也不需要将主语存储为大写字符串:我们确定输入是大写的,但我们可以根据需要进行转换。所以我们语法分析后的结果可能是这样的:
Sentence {
subject: "you",
words: ["can't", "judge", "a", "book", "by", "its", "cover"],
}
通常,对于计算机语言来说,树状结构效果很好,所以在这个阶段结束时,你会看到一个 “抽象语法树”,或 “AST”。但严格来说,这不是必需的,任何对你有意义的数据结构都可以。
现在我们有了一个更丰富的数据结构,我们几乎完成了。现在我们必须深入研究含义。
想象一下,我们的句子不是 “You can’t judge a book by its cover.”,而是这个:
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
这是一段著名的文本,但没有意义。这些词都是拉丁词,感觉它可能是一个句子,但实际上是无意义的。我们可以将其解析为一个Sentence
:
Sentence {
subject: "Lorem",
words: ["ipsum", "dolor", "sit", "amet", /* 等等 */ ],
}
但它是无效的。我们如何确定这一点呢?
在英语的上下文中,“Lorem” 不是一个有效的英语单词。所以如果我们检查主语是否是一个有效的单词,我们就会成功拒绝这个句子。在计算机语言中,我们会做类似的类型检查:5 + "hello"
可能在词法和语法分析中都没问题,但当我们试图弄清楚它的意思时,我们会发现这是无意义的。除非你的语言允许你将数字和字符串相加!
经过语义分析,我们确定我们的程序是正确的,也就是 “格式良好”。在编译器中,我们接着会生成机器代码或字节码来表示我们的程序。但这些内容虽然非常有趣,但不是我们这里要讨论的:还记得我们最初的目标吗?是理解这个:
*pointer_of_some_kind = blah;
这就是语义。所以现在我们有了一些背景知识,让我们来谈谈如何理解这段代码。
为了理解这段代码,我们首先需要了解它是如何进行词法分析和语法分析的。换句话说,我们语言的语法是什么。我们的语言将如何对代码进行词法分析、标记化,然后解析。在这种情况下,是Rust语言。Rust的语法庞大而复杂,所以我们今天只讨论其中的一部分。我们将专注于语句和表达式。
你可能之前听说过Rust是一种 “基于表达式的语言”。嗯,这就是人们的意思。你看,在程序中你说的大多数内容通常是这两种东西之一。表达式是产生某种值的东西,而语句用于对表达式的求值进行排序。这有点抽象,所以让我们具体一点。
Rust有几种类型的语句:首先,有 “声明语句” 和 “表达式语句”,并且它们各自也有子类型。\
声明语句有两种:项声明和let
语句。项声明是像mod
、struct
或fn
这样的东西:它们声明某些东西的存在。let
语句可能是Rust中最著名的语句形式,它们看起来像这样:
OuterAttribute* let PatternNoTopAlt ( : Type )? (= Expression † ( else BlockExpression) ? ) ? ;
这…… 有点拗口。我们还没有讨论过*
或?
,而且我们现在也不想涉及Rust中一些更奇特的部分。所以我们首先通过一个更简单的语法来讨论这个:
let Variable = Expression;
这就是我们在Rust中创建新变量的方式:我们写let
,然后是一个名称,一个=
,最后是某个表达式。计算该表达式的结果成为变量的值。
这里省略了很多内容:名称不只是一个名称,它是一个模式,这非常酷。Rust现在有let else
,这也很酷。我们这里忽略了类型。但通过这个简单的版本,你可以掌握基本要点。
表达式语句要简单得多:
ExpressionWithoutBlock ; | ExpressionWithBlock ;?
这里的|
表示 “或”,所以我们可以是一个单独的表达式后面跟着一个;
,或者是一个块(用{}
表示,它可以选择(?
表示它可以存在或不存在)后面跟着一个;
)。
所以,要像编译器一样思考,你可以开始弄清楚如何组合这些规则。例如:
let x = {
5 + 6
};
这里,我们有一个let
语句,但=
右边的表达式是一个ExpressionWithBlock
。给你一个小测验:;
是let
表达式的一部分,还是右边表达式的一部分呢?
答案是,它是let
的一部分。let
表达式必须有一个;
,但块不需要,所以:
let x = ExpressionWithBlock;
如果我们在块后面加上分号,我们仍然需要let
的分号,这样我们就会得到};;
。编译器会接受,但会发出警告。
回到我们最初的代码:
*pointer_of_some_kind = blah;
我们没有let
,这也不是一个项声明:这是一个表达式语句。我们有一个ExpressionWithoutBlock
,后面跟着一个;
。所以现在我们必须讨论表达式。
Rust中有很多种表达式类型。《Rust参考手册》的8.2节有19个子部分。哇!在这种情况下,这段代码是一个操作表达式,更具体地说,是一个赋值表达式:
Expression = Expression
很简单!所以=
左边的表达式是*pointer_of_some_kind
,右边是blah
。很简单!
但这两个表达式在某种程度上,正是我写这篇文章的全部原因。我们终于讲到这里了!你看,参考手册对赋值表达式是这样说的:\ 赋值表达式将一个值移动到指定的位置。
这些是什么呢?
C语言和旧版本的C++ 将这两个东西称为 “左值(lvalue)” 和 “右值(rvalue)”,分别对应=
的左边和右边。更新的C++ 标准有更多的类别。Rust取了个折衷:它像C语言一样只有两个类别,但它们更清晰地映射到C++ 的两个类别。Rust把左值,即左边,称为 “位置(place)”,把右值,即右边,称为 “值(value)”。这里有两个更精确的定义,来自《不安全代码指南》:
这两者都有表达式形式,所以位置表达式在求值时产生一个位置,值表达式产生一个值。这就是=
的工作方式:我们左边有一个位置表达式,右边有一个值表达式,然后我们将那个值放入那个位置。很简单!
再看一次我们试图弄清楚的代码:
*pointer_of_some_kind = blah;
所以*
,解引用运算符,获取指针,并求值为它所指向的位置:它的地址。而blah
给我们提供了要放入那里的值。
结束了吗?还没有!
Rust有一个trait
(特性)Deref
,它允许我们重载*
运算符。为了让事情更简单,让我们看这个例子:
use std::ops::{Deref, DerefMut};
struct DerefMutExample<T> {
value: T
}
impl<T> Deref for DerefMutExample<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl<T> DerefMut for DerefMutExample<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.value
}
}
fn main() {
let mut x = DerefMutExample { value: 'a' };
*x = 'b';
assert_eq!('b', x.value);
}
你可以在这里[2]运行它。
我们还没有讨论这里相关的表达式类型:路径表达式。\ 解析为局部或静态变量的路径表达式是位置表达式,其他路径是值表达式。
我们之前讨论过let
:在上面的let mut x = DerefMutExample { value: 'a' };
中,x
是一个路径表达式,因为它解析为我们的新变量,这意味着它是一个位置表达式。DerefMutExample { value: 'a' }
是一个值表达式,因为它没有解析为变量。
让我们来看看*x = 'b';
,还记得我们的赋值表达式吗:
expression = expression;
以及它的作用:它将一个值移动到一个位置。
为了理解*
是如何工作的,我们只需要再添加一个东西:解引用表达式。它由解引用运算符*
生成,看起来像这样:
*expression
它的语义非常直接:
&T
、&mut T
、*const T
或*mut T
,那么这个表达式求值为被指向的值的位置,并赋予它相同的可变性。*std::ops::Deref::deref(&x)
,如果它是可变的,则为*std::ops::DerefMut::deref_mut(&mut x)
。就是这样。现在我们有足够的知识来完全理解*x = 'b'
:
'b'
是一个值表达式。*x
不是指针类型,所以我们将其扩展为*std::ops::DerefMut::deref_mut(&mut x)
,然后再试一次。std::ops::DerefMut::deref_mut(&mut x)
在这种情况下返回类型&mut char
,它指向self.value
的位置(我简称它为<that place>
),当前存储在那里的值是'a'
。现在我们有*&mut <that place>
。&mut T
,所以*&mut <that place>
指的是<that place>
。<that place> = 'b'
,所以我们将'b'
移动到那个位置。哇!就是这样。
像编译器一样思考很有趣!一旦你掌握了语法的概念,并习惯了进行替换,你就可以弄清楚各种有趣的事情。在这个特定的例子中,有时人们会想,如果Deref
的全部目标是显示某个东西应该指向哪里,为什么它返回的是一个引用…… 如果你不知道解引用表达式会扩展为包含*
的东西,这会很令人困惑!但现在你知道了。希望你在这个过程中也学到了一些关于值、位置以及编译器如何思考代码的有趣知识。
[1]
英文原文: https\://steveklabnik.com/writing/thinking-like-a-compiler-places-and-values-in-rust/\
[2]
在这里: https\://play.rust-lang.org/?version=stable\&mode=debug\&edition=2021\&gist=0c009c7d8d507c2d977c424d77928661\
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!