生命周期是 Rust 和人类经验的一个迷人特征。这是一个技术博客,所以我们重点关注前者。诚然,我在利用 Rust 的生命周期安全地借用数据方面采用得很慢。在孟菲斯(我用 Rust 编写的 Python 解释器)的树形行走实现中,我几乎不利用生命周期(通过不断克隆),并且尽可能地反复躲避借用检查器(通过使用内部可变性,也不断)。
我的 Rustaceans 同胞们,我今天来这里是为了告诉你们这一切现在结束了。读懂我的嘴唇……不再有捷径。
好吧好吧,说实话吧。什么是捷径,什么是正确的方法,是一个优先事项和视角的问题。我们都犯过错误,我来这里是为了为我的错误承担责任。
在我第一次安装 rustc 六周后,我开始编写一个解释器,因为我没有冷静。抛开这些长篇大论和故作姿态,让我们开始今天的讲座,了解如何使用生命周期作为我们的生命线来改进我臃肿的解释器代码库。
Rust 生命周期是一种提供编译时保证的机制,确保任何引用都不会比它们所引用的对象的寿命长。它们使我们能够避免 C 和 C 的“悬空指针”问题。
这是假设您完全利用它们!当您想要避免与管理生命周期相关的复杂性时,克隆是一种方便的解决方法,但缺点是增加了内存使用量,并且每次复制数据时都会出现轻微的延迟。
使用生命周期还迫使你更惯用地思考 Rust 中的所有者和借用,这是我渴望做的。
我选择了我的第一个候选作为来自 Python 输入文件的标记。当我坐在 Amtrak 上时,我最初的实现严重依赖 ChatGPT 指导,使用了以下流程:
克隆令牌流的便利之处在于,在第 3 步之后可以自由删除词法分析器。通过更新我的架构,让词法分析器拥有令牌,而解析器只借用它们,现在需要保留词法分析器活得更久。 Rust 生命周期将为我们保证这一点:只要解析器存在并持有对借用令牌的引用,编译器就会保证拥有这些令牌的词法分析器仍然存在,从而确保有效的引用。
就像所有代码一样,这最终是一个比我预期更大的变化。让我们看看为什么!
在更新解析器以从词法分析器借用令牌之前,它看起来像这样。今天讨论的两个感兴趣的领域是 tokens 和 current_token。我们不知道 Vec
pub struct Parser { state: Container<State>, tokens: Vec<Token>, current_token: Token, position: usize, line_number: usize, delimiter_depth: usize, } impl Parser { pub fn new(tokens: Vec<Token>, state: Container<State>) -> Self { let current_token = tokens.first().cloned().unwrap_or(Token::Eof); Parser { state, tokens, current_token, position: 0, line_number: 1, delimiter_depth: 0, } } }
从 Lexer 借用代币后,它看起来相当相似,但现在我们看到了 LIFETIME!通过将标记连接到生命周期 'a,Rust 编译器将不允许标记的所有者(即我们的词法分析器)和标记本身被删除,而我们的解析器仍然引用它们。这感觉安全又别致!
static EOF: Token = Token::Eof; /// A recursive-descent parser which attempts to encode the full Python grammar. pub struct Parser<'a> { state: Container<State>, tokens: &'a [Token], current_token: &'a Token, position: usize, line_number: usize, delimiter_depth: usize, } impl<'a> Parser<'a> { pub fn new(tokens: &'a [Token], state: Container<State>) -> Self { let current_token = tokens.first().unwrap_or(&EOF); Parser { state, tokens, current_token, position: 0, line_number: 1, delimiter_depth: 0, } } }
您可能会注意到的另一个小差异是这一行:
static EOF: Token = Token::Eof;
这是一个小的优化,当我的解析器朝着“内存高效”的方向发展时,我就开始考虑这个优化。新模型允许我仅实例化一个令牌并重复引用 &EOF,而不是每次解析器需要检查它是否位于文本流末尾时都实例化一个新的 Token::Eof。
同样,这是一个小优化,但它说明了每条数据在内存中只存在一次并且每个消费者在需要时引用它的更大思维方式,Rust 既鼓励你这样做,又紧握着你的手路。
说到优化,我真的应该对前后的内存使用情况进行基准测试。既然我没有,我对此事无话可说。
正如我之前提到的,将 Lexer 和 Parser 的生命周期结合在一起对我的 Builder 模式产生了很大的影响。让我们看看它是什么样子的!
在我上面描述的流程中,还记得我如何提到一旦解析器创建了自己的标记副本就可以删除词法分析器吗?这无意中影响了我的 Builder 的设计,它的目的是成为支持编排 Lexer、Parser 和 Interpreter 交互的组件,无论您是从 Python 文本流还是 Python 文件的路径开始。
正如您在下面看到的,此设计还有一些其他不理想的方面:
fn downcast<T: InterpreterEntrypoint + 'static>(input: T) -> Interpreter { let any_ref: &dyn Any = &input as &dyn Any; any_ref.downcast_ref::<Interpreter>().unwrap().clone() } fn init(text: &str) -> (Parser, Interpreter) { let (parser, interpreter) = Builder::new().text(text).build(); (parser, downcast(interpreter)) } #[test] fn function_definition() { let input = r#" def add(x, y): return x + y a = add(2, 3) "#; let (mut parser, mut interpreter) = init(input); match interpreter.run(&mut parser) { Err(e) => panic!("Interpreter error: {:?}", e), Ok(_) => { assert_eq!( interpreter.state.read("a"), Some(ExprResult::Integer(5.store())) ); } } }
下面是新的 MemphisContext 接口。该机制在内部管理 Lexer 生命周期(使我们的引用保持足够长的存活时间,以使我们的解析器满意!)并且仅公开运行此测试所需的内容。
pub struct Parser { state: Container<State>, tokens: Vec<Token>, current_token: Token, position: usize, line_number: usize, delimiter_depth: usize, } impl Parser { pub fn new(tokens: Vec<Token>, state: Container<State>) -> Self { let current_token = tokens.first().cloned().unwrap_or(Token::Eof); Parser { state, tokens, current_token, position: 0, line_number: 1, delimiter_depth: 0, } } }
context.run_and_return_interpreter() 仍然有点笨重,并且涉及到我将来可能要解决的另一个设计问题:当您运行解释器时,您是否只想返回最终返回值或允许您访问任意值的东西从符号表?该方法选择后一种方法。我实际上认为有一个案例可以做到这两点,并且会不断调整我的 API 以允许这样做。
顺便说一句,这一变化提高了我评估任意一段 Python 代码的能力。如果您还记得我的 WebAssembly 传奇,我当时必须依靠我的交叉检查 TreewalkAdapter 来做到这一点。现在,我们的 Wasm 界面更加简洁。
static EOF: Token = Token::Eof; /// A recursive-descent parser which attempts to encode the full Python grammar. pub struct Parser<'a> { state: Container<State>, tokens: &'a [Token], current_token: &'a Token, position: usize, line_number: usize, delimiter_depth: usize, } impl<'a> Parser<'a> { pub fn new(tokens: &'a [Token], state: Container<State>) -> Self { let current_token = tokens.first().unwrap_or(&EOF); Parser { state, tokens, current_token, position: 0, line_number: 1, delimiter_depth: 0, } } }
接口 context.evaluate_oneshot() 返回表达式结果而不是完整的符号表。我想知道是否有更好的方法来确保任何“oneshot”方法只能在上下文中运行一次,从而确保没有消费者在有状态上下文中使用它们。我会继续努力!
孟菲斯首先是一次学习练习,所以这绝对值得!
除了在 Lexer 和 Parser 之间共享标记之外,我还创建了一个接口来评估 Python 代码,并且样板代码明显减少。虽然共享数据带来了额外的复杂性,但这些变化带来了明显的好处:减少内存使用,通过更严格的生命周期管理提高安全保证,以及更易于维护和扩展的简化 API。
我选择相信这是正确的方法,主要是为了维护我的自尊。最终,我的目标是编写清晰反映软件和计算机工程原理的代码。我们现在可以打开孟菲斯源,指向代币的单一所有者,晚上可以睡个好觉了!
如果您想将更多类似的帖子直接发送到您的收件箱,您可以在这里订阅!
除了指导软件工程师之外,我还写了我在自营职业和晚期诊断自闭症方面的经验。更少的代码和相同数量的笑话。
以上是提高工作口译员的记忆效率的详细内容。更多信息请关注PHP中文网其他相关文章!