免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Rust第三章通用编程概念:从变量不可变到控制流实战解析

Rust第三章通用编程概念:从变量不可变到控制流实战解析 如果把Rust的学习比作拿到一本新手册那第三章“通用编程概念”就是手册开头那几页看起来平平无奇、却能决定你后面能不能顺利读下去的正文。很多人是冲着系统编程和高性能并发来的翻到第三章发现讲的居然是变量、数据类型、函数、控制流这些“谁都会”的东西容易直接跳过去。但学完所有权部分再回头看第三章里提到的变量不可变、shadowing、表达式和语句的区别几乎每一个都是后续章节的暗桩。所以这篇学习记录我想认真拆一遍C3聊聊这一章到底在讲什么、有哪些值得反复琢磨的点以及按我的实操经验该怎么学最稳。这篇内容主要适合两类人一类是刚接触Rust、正在跟官方书或视频课学的初学者另一类是写过C、Java、Python想搞明白“Rust为什么这么设计”的转语言开发者。我自己属于第二类所以在记录里会刻意对比一些其他语言的习惯方便你把旧思维切换过来。1. 先把“通用编程概念”放在完整学习路线里看1.1 这一章到底在讲什么Rust官方书《The Rust Programming Language》一共二十多章第三章标题叫Common Programming Concepts翻译过来就是通用编程概念。它涵盖的内容并不神秘变量与可变性、数据类型、函数、注释、控制流。这些概念在C语言里分散在各种角落在Python里被语法糖包得很隐晦在JavaScript里又因为动态类型显得很随意。Rust的做法是把它们打散、重新设计再用一套严格的编译器规则兜底。这里要特别注意第三章不是说“我教你什么是变量”而是“在Rust里变量应该怎么用”。它的核心价值不在于教会你if和for怎么写而在于让你第一次感受到Rust语言的设计态度尽量把错误在编译阶段拦下来把代码里隐含的意图变成显式规则。比如变量默认不可变、函数参数必须标注类型、if表达式要求所有分支返回同一种类型这些都是其他语言里没有的约束。从学习顺序上看这一章是后续所有内容的骨架。第四章所有权、第五章借用与引用、第六章结构体全是建立在你已经理解“变量绑定”“作用域”“表达式”这些概念之上的。所以我的建议很明确第三章值得用比官方建议更长的时间来磨宁可在这里多花一周也不要带着模糊的概念冲进所有权。1.2 三个最值得反复琢磨的“反常识”点第一个反常识点是变量默认不可变。几乎所有主流语言里int x 5; x 6;都是天经地义的事Rust偏偏要求你主动写mut才允许修改。这个设计初看很别扭但它的收益是巨大的任何变量一旦被绑定你在读代码的时候就能立刻确认它不会在某个角落被悄悄改掉在多线程环境下不可变绑定还能避免一大类数据竞争问题。后面学到所有权和借用检查器时你会发现这种“默认约束”的思路贯穿了整个语言。第二个反常识点是语句和表达式的严格区分。Rust是一个偏向表达式expression-oriented的语言函数体最后一个不带分号的表达式就是返回值。这个特性在写函数式风格代码时特别舒服但如果你之前习惯在每行末尾随手加分号一定会踩坑。官方书里那个经典例子——let x 5;是语句x 1是表达式——单独看很简单真正写代码时却经常因为少一个分号或多个分号被编译器教育。第三个反常识点是Rust的静态类型系统非常严格但编译器又很智能。它既要求在函数签名里写清参数和返回值的类型又允许你在声明局部变量时直接写let x 42而不需要标注i32。这种“关键位置显式、局部位置推断”的平衡是类型系统设计里很成熟的做法既保证了大型项目的可读性又不会让日常编码变得啰嗦。2. 变量与绑定Rust给你的第一个观念冲击2.1 为什么变量默认不可变先看一段最基本的代码fn main() { let x 5; // x 6; // 这行会编译报错 println!(x 的值是 {}, x); }如果去掉注释编译器会立刻告诉你cannot assign twice to immutable variable。这个报错不是Rust在故意刁难你而是它想让你意识到“变量x被我绑定到了5那它就应该一直是5”。这种设计背后的逻辑是一个值如果没有变化你就可以在读代码时放心地相信它。我自己的感受是第一次用Rust写业务逻辑时满脑子都是“怎么连赋值都不行”。但坚持写了两周之后再看回Java代码反而开始觉得任何变量都是可变的很不安全。你读一个方法时根本不知道某个字段在哪个分支里被改过只能靠经验和IDE辅助。Rust把这个负担转移到了编译期你想改就必须显式写mut改完之后所有读到这个变量的人都清楚这里有变化发生时机的门槛。为什么默认不可变对并发这么重要举个简单例子如果两个线程同时持有一个变量的引用一个线程在修改另一个线程在读取数据竞争就跑不掉。Rust通过“默认不可变”先把一半情况排除掉然后在后面的所有权章节里再通过“唯一所有者和借用规则”解决另一半。可以说第三章的不可变约定就是为你理解并发安全做的铺垫。2.2 Shadowing和mut的区别Shadowing变量遮蔽是很多初学者绕不明白的点因为它看起来很像“重新赋值”但本质上完全不同fn main() { let x 5; let x x 1; // 第一次遮蔽x现在是6 let x x * 2; // 第二次遮蔽x现在是12 println!(x {}, x); }这段代码能跑而且输出12。它做的事情不是把同一个变量从5改成12而是创建了一个全新的变量绑定只不过复用了变量名x。旧的x在遮蔽发生之后就不再可用了至少在当前作用域里。Shadowing的好处是你可以用同一个名字延续数据处理的语义而不用费心起x1、x2这种变量名。很多新手会问那这和mut有什么区别区别非常大。mut是允许修改同一个绑定所指向的值值的类型不能变shadowing是创建新绑定新绑定的类型可以跟旧绑定完全不同。比如fn main() { let spaces ; let spaces_len spaces.len(); // 如果这样写类型不同也没关系 }但真正典型的shadowing用法长这样let spaces ; let spaces spaces.len(); // 前一个是字符串后一个是usize类型改变了如果换成mut编译器会报错因为spaces一开始是str不能直接被赋成usize。这个机制在需要做连续类型转换的场景下特别顺手比如先解析字符串、再转数字、再参与计算每一步都可以用shadowing保持变量名清晰。我在实际项目中常用的一个模式是从配置文件里读出一个字符串然后shadowing成数字再shadowing成带单位的结构体。全程不需要起raw_value、parsed_value之类的辅助名代码读起来像一条流水线。2.3 const常量和let绑定的边界第三章里还专门讲了constconst MAX_POINTS: u32 100_000;const和let有几个关键区别。第一const必须显式标注类型编译器不会帮你推断因为它是编译期常量类型信息必须完整才能做优化。第二const一旦定义就永远不可变也不允许mut修饰更不能用const来做运行时的复杂计算——它的值必须在编译期就能确定。第三命名规范使用全大写加下划线SCREAMING_SNAKE_CASE这不是硬性语法要求而是社区约定建议遵守。为什么已经有了不可变的let还要const因为let创建的绑定是在运行时分配到栈上的虽然不可变但它仍然是一个“运行时的值”而const是编译期就内联进代码里的常量没有任何运行时开销。像数学常量、配置阈值、协议魔数这类东西用const表达语义最准确。我踩过的一个小坑是在函数内部写了一个很大的const数组以为它会在栈上占空间其实编译器把它放到了只读数据段性能和内存都很理想。所以如果你在开发Rust项目时发现某个常量被到处使用优先考虑用const而不是let。3. 数据类型静态类型带来的“被迫思考”3.1 从整数类型讲起Rust的整数类型非常丰富官方文档里的表可以背一下长度有符号无符号8-biti8u816-biti16u1632-biti32u3264-biti64u64128-biti128u128架构相关isizeusize未标注时默认整数类型是i32。选择的理由很简单在绝大多数CPU上32位整数的运算速度不比更短类型慢又能覆盖绝大多数数值范围。usize和isize的长度取决于运行程序的机器架构在64位系统上是64位、在32位系统上是32位它的典型用途是数组索引和内存尺寸计算。这里有个很多教程没细讲的点为什么要有这么多整数类型因为底层的系统编程场景差异太大。像内存地址偏移量你需要usize来保证足够宽像网络协议里的字段可能就是精确的16位、32位选错长度就会导致数据错位。Rust把选择权交给你代价是你必须思考“这个数到底应该占几位”。数字字面量的写法也值得熟悉100_000加下划线增强可读性0xff表示十六进制0o77表示八进制0b1111_0000表示二进制bA表示字节字面量类型是u8。这些写法在写底层控制逻辑时非常常见。3.2 浮点、布尔、字符细节里的坑浮点类型只有两种f32和f64默认是f64。官方解释是f64在现代CPU上跟f32速度差不多但精度更高。需要注意浮点数永远不要用来做精确的金额计算这不是Rust的问题是所有二进制浮点数的通病如果要精确计算建议用整数或专门的定点数库。布尔类型就是bool只有true和false两个值。有一点跟其他语言差很多Rust的if条件必须是bool类型不会帮你做隐式转换。在JavaScript里你可以写if (1)在Rust里直接编译报错必须写成if x 1或if flag。这个限制初看烦人实则能避免大量因为类型强转导致的隐蔽bug。字符类型char是Rust里容易被误解的点。它代表的不是ASCII字符而是Unicode标量值在内存里占4个字节。这意味着中文、emoji、特殊符号都可以直接放进char里let c z; let z: char 中; let emoji ;请务必记住char用单引号字符串用双引号。这个跟C语言类似但Rust的字符串类型后面还有String、str之分第三章先不管它们只要知道“字符不等于一个字节”就够用了。3.3 tuple和array两种复合类型复合类型是把多个值组合成一个类型第三章只介绍最基本的两个元组tuple和数组array。元组的特点是定长可以包含不同类型的值声明后长度不能变。访问方式有两种解构和点号下标fn main() { let tup: (i32, f64, u8) (500, 6.4, 1); let (x, y, z) tup; // 解构 println!(第二个元素是 {}, tup.1); // 点号下标访问 }元组最适合用来返回多个值。比如你写一个函数想同时返回成功标志和结果数据就可以用元组打包。另外有一个特殊的元组叫单元类型()它表示“没有值”后面你会频繁见到它函数如果没有返回值实际上返回的就是()。数组的特点是定长且所有元素类型相同它和元组一样也是定长的。声明方式let a [1, 2, 3, 4, 5]; let first a[0];按照Rust官方书的习惯这里会顺便提一句如果你想用动态增长的数组标准库提供了Vec名字叫vec!宏创建。Vec就是很多语言里的动态数组堆内存分配可以用来增删元素但那是后续章节的内容第三章先用定长数组建立“栈上连续内存”的概念。数组访问越界时Rust会直接panic这一点在后面的实操里会专门讲。这里先记住一个结论Rust的数组越界不是未定义行为而是有明确报错的运行时崩溃这在系统级语言里反而是个优点。3.4 类型推导什么时候可以偷懒Rust的局部变量类型可以由编译器推断所以你可以写let guess: u32 42.parse().expect(error)这种带类型标注的代码也可以写let v 0b1111_0000;让编译器自己判段。推断也不是万能的。当调用parse()这类泛型方法时编译器没有任何上下文线索知道目标类型你就必须显式标注。同样函数签名里的参数和返回值类型也必须是显式的因为Rust坚持“接口清晰”优先于“调用方便”。这条规则对API使用者和维护者都很友好我在看别人的Rust代码时读函数签名就能把数据流向猜个大概不需要翻遍实现。4. 函数与注释代码的基本组织方式4.1 函数定义和参数类型Rust函数的定义格式如下fn add_one(x: u32) - u32 { x 1 }关键字fn、函数名snake_case、参数必须带类型、返回值用-声明。跟Python不同Rust不允许省略参数类型这既是编译期的需求也是文档的一部分——你想调用这个函数时直接看签名就知道该传什么不用去读函数体。Rust还允许你在定义函数之前调用它函数体内的顺序无所谓只要函数在同一作用域内。这跟C语言需要先声明再使用完全不同你不需要为了放置顺序维护头文件当然在跨模块时还有模块和可见性规则但第三章先不展开。4.2 语句和表达式的区别重点这一小节我认为是第三章最重要的内容没有之一。Rust里语句statement是指执行操作但不返回值的指令表达式expression是指会计算并产生值的代码。常见的语句有let x 5; // 声明绑定let x 5;不会返回任何值所以你不能写成let y (let x 5);这在C语言里都不合法在Rust里也一样。但表达式就自由多了字面量5、运算x 1、函数调用、宏调用甚至一个块{ ... }都可能是表达式。块表达式的值就是块里最后一个不带分号的表达式fn main() { let y { let x 3; x 1 // 注意没有分号 }; println!(y {}, y); // 输出4 }这里最容易犯的错就是在x 1后面补一个分号。一旦加了分号这个块就变成了语句不再返回任何值let y { ... };就会编译失败报错信息通常长这样expected integer, found ()。这个报错在初学阶段出现频率极高但每次看到它其实都是在提醒你“表达式和语句的区别”。理解这个特性的好处是你能用表达式写出非常紧凑的函数。比如fn max(a: i32, b: i32) - i32 { if a b { a } else { b } }注意函数体里没有return也没有分号直接以if表达式结尾这个if表达式的值就是函数返回值干净利落。4.3 函数返回值与空的元组函数通过箭头声明返回值类型函数体最后一个表达式就是返回值。如果不写箭头那就默认返回()也就是单元类型。为了提前返回你可以用return关键字比如错误处理里常见的模式fn divide(a: f64, b: f64) - f64 { if b 0.0 { return 0.0; } a / b }初学时容易犯的错是函数签名写了- i32函数体却以表达式加分号结尾编译器会报类型不匹配。这个时候你要做的是检查最后一个表达式有没有多余的分号。还有一个经验是如果函数没有返回值就别写- ()直接不写箭头更符合Rust风格如果你看到别人的代码里有- ()那多半是历史遗留可以清理。4.4 注释从//到文档注释Rust的普通注释跟大多数语言一样用//不支持块注释的嵌套。第三章的主要内容是普通注释但如果你有做库的想法建议提前知道文档注释/// 计算两个数的和 /// /// # 示例 /// /// let result add(1, 2); /// assert_eq!(result, 3); /// fn add(a: i32, b: i32) - i32 { a b }这种///注释可以被cargo doc工具自动生成HTML文档还能在注释里放可运行的代码示例形成一个可以自动测试的文档。如果你写的是开源库这个特性几乎等于白送一份会过测试的文档非常划算。还有//!是写在模块文件顶部的内部文档注释通常用来描述整个模块的用途。我的建议是保持注释“描述为什么而不是做什么”。Rust代码本身已经很可读你不需要写// x加1这种废话而是要说明“这里为什么要加1”比如是为了对齐、补偿时区或者处理边界条件。好的注释是给明天早晨的自己和下一位维护者看的。5. 控制流if和循环在Rust里的特殊地位5.1 if是表达式不是语句Rust的if条件不需要括号但条件必须是boollet number 6; if number % 4 0 { println!(能被4整除); } else if number % 3 0 { println!(能被3整除); } else { println!(其他情况); }这段代码大家都会写重点是if可以当作表达式来用let condition true; let value if condition { 5 } else { 6 }; println!(value {}, value); // 5因为if是表达式所有分支必须返回同一类型。如果你写if condition { 5 } else { six }编译器会报错因为这俩类型不一致。初看这是个限制但仔细想想它其实让你在做分支返回时就有了强制的类型一致性判断避免了很多动态语言里分支返回不同类型导致的运行时问题。5.2 循环三兄弟loop、while、forRust提供三种循环loop、while、for。loop是无条件无限循环风格上有点“我就是要在这里一直跑”的意味loop { println!(一直打印); }它独特的地方在于可以带返回值在循环里用break带上值退出let mut counter 0; let result loop { counter 1; if counter 10 { break counter * 2; } }; println!(result {}, result); // 20这个特性在需要“先循环计算再取出结果”的场景里很顺手相当于把循环当成一个表达式来用。while是条件循环语义和C语言一样let mut n 3; while n 0 { println!({}, n); n - 1; }while适合条件不来自集合的场景但如果你要遍历一个集合Rust的推荐做法是forlet a [10, 20, 30, 40, 50]; for element in a { println!(值是 {}, element); }为什么要用for而不是像很多C程序员那样用索引遍历因为用索引需要自己管理下标、判断边界一旦越界就panic而for直接迭代元素从根本上消除了这类错误。对于需要下标的情况Rust也有iter().enumerate()方法后面学到迭代器时会非常舒服。for配合Range范围的方式也很常见for number in (1..4).rev() { println!({}, number); // 输出3、2、1 }1..4是一个Range表示从1到3不包含41..4表示包含4。这个语法在循环里非常有用写倒计时、取子范围、控制重复次数都很方便。5.3 循环标签跳出多层循环循环标签是Rust做得比较贴心的一个设计。当你嵌套多个循环时默认break只跳出一层想跳出外层就得用标签outer: for i in 1..5 { for j in 1..5 { if i * j 6 { println!(i {}, j {}, i, j); break outer; } } }outer是以单引号开头的标签名写在循环前面。break outer表示跳出标签标记的那个循环。这个语法在写复杂状态机或二维搜索时很实用避免了用额外布尔变量控制外层的土办法。6. 常见坑与排查经验6.1 新手最容易踩的四个坑第一个坑是试图给不可变变量赋值。任何人都逃不掉。编译器报错后不要急着加mut先想想这个值到底应不应该变如果只是临时想换个新值用shadowing可能更合适。当然如果确实需要反复修改加mut也没问题。第二个坑是表达式末尾加分号导致返回()。这个报错通常是expected i32, found ()看到()就马上检查函数体最后一个表达式。我的习惯是在函数最后有意写成不加分号的表达式然后让cargo fmt统一处理风格省得手滑。第三个坑是整数溢出。Rust在debug模式下遇到整数溢出会直接panic在release模式下默认使用二进制的补码回绕wrapping逻辑。这个差异隐藏很深我见过有项目在debug模式下一切正常release上线后突然出现奇怪数值排查半天才发现是溢出。如果你真的需要回绕语义就用wrapping_add、wrapping_sub等方法至少意图明确。第四个坑是习惯性地用索引遍历数组。Rust里写for i in 0..a.len() { println!({}, a[i]) }虽然合法但不推荐因为没有必要。直接for item in a更安全、更快编译器还能做更好的优化。只有在确实需要下标参与逻辑时才用enumerate。6.2 编译器的报错其实很友好这是我对Rust印象最深的一点编译器不是甩一句“语法错误”就完事而是会告诉你问题在哪、为什么、怎么改。比如对不可变变量赋值它会提示error[E0384]: cannot assign twice to immutable variable help: consider making this binding mutable: mut x这种建议对初学者极其友好。另一个实用技巧是cargo check它只做类型检查不生成可执行文件比cargo build快很多开发时反复运行它来验证编译能不能通过是最高效的工作流。写完代码再跑cargo fmt统一格式Rust生态的开发体验在系统级语言里算很舒服的了。7. 学习实操怎么把这一章真正吃透7.1 建议的练习步骤理论不看三遍没感觉动手写一遍胜过刷十遍视频。我当时的练习路径是先用cargo new创建项目然后做三个经典练习第一个是温度转换华氏度与摄氏度互相转换。这个练习能覆盖函数定义、参数类型、let绑定、shadowing、返回值还能顺便巩固整数/浮点运算。第二个是斐波那契数列指定第n项返回数值。这个练习能逼你用loop或for循环并理解循环与返回值的关系。第三个是打印圣诞颂歌的歌词需要处理多段重复、数字对应的英文序数词。这个练习比较长能让你把if表达式、match、循环、字符串拼接都串起来。如果你是零基础节奏可以放慢每读完一个小节就敲一遍配套代码然后在代码里故意改错几次观察编译器的反应。不要怕报错报错本身就是Rust老师在上课。每解决一个报错你对类型和绑定规则的理解就深一层。7.2 为所有权章节做准备第三章学完之后别急着庆祝下一章汹涌而来的是Rust的“独门绝技”——所有权。如果你觉得第三章里的变量绑定、作用域、shadowing已经有点绕了那所有权只会更绕因为它把“谁拥有这个值”“什么时候释放内存”“借出来怎么还”这些在C语言里全靠自觉的事情全部放进了类型系统。但好消息是第三章建立的几个直觉能帮你平稳过渡你理解了默认不可变是为了安全你理解了绑定和值的区别你理解了作用域对变量的限制。有了这些基础所有权章节里所有权的转移、借用与引用、生命周期这些概念就不再是空中楼阁。我在学完第三章后给自己定的目标是不看源码能把每一个知识点讲给一个完全不认识Rust的人听。做到这一点之后再推开第四章的大门心里的底气会完全不同。最后再分享一个学习工具上的小习惯我平时在VS Code里装rust-analyzer插件写代码时可以看到变量类型、函数签名和编译诊断但我不建议过度依赖它做自动修复。Rust的学习前期亲手读报错、亲手改代码非常重要IDE一键修复会让你跳过很多本该建立的连接。等基础牢固了再让工具帮你提速那才是发挥IDE价值的正确时机。
返回列表