【Rust语法】结构体与枚举

发布时间:2026/8/30 23:54:23
【Rust语法】结构体与枚举 结构体定义与实例化我们从基本类型和所有权系统一路走来已经掌握了数字、字符、布尔值、数组和切片这些内建的数据容器。但在真实程序中数据往往以更复杂的形态出现一个用户有名字、年龄和邮箱一本书有标题、作者和页数。把这些彼此关联的字段捆绑成一个语义完整的整体是组织代码的第一步。结构体struct正是 Rust 提供的第一个自定义命名数据类型。定义结构体结构体通过struct关键字引入后跟结构体名称和一组具名字段。字段的语法是字段名: 类型多个字段之间用逗号分隔structUser{username:String,email:String,sign_in_count:u64,active:bool,}这条定义声明了一个名为User的类型它包含四个字段每个字段都有明确的类型。这里的String保存在堆上其所有权归属于结构体实例u64和bool直接内联存储。结构体没有在堆上额外分配空间——它的内存布局就是所有字段的叠加不考虑对齐填充时。结构体名称遵循大驼峰命名法UpperCamelCase字段名遵循蛇形命名法snake_case。这不是编译器强制要求而是 Rust 社区的通用约定遵循它能显著提升代码的可读性。定义结构体只是声明了类型的存在它是模板而非数据。要真正使用它需要创建实例。实例化与字段初始化简写创建结构体实例的语法是写出结构体名称后跟一个花括号块块内以字段名: 值的形式为每个字段提供初值letuser1User{email:String::from(aliceexample.com),username:String::from(alice),active:true,sign_in_count:1,};字段的赋值顺序没有要求这一点与部分语言不同。编译器会在编译期检查每个字段是否都已被赋值——少写一个字段、拼错字段名都会产生编译错误。这种严格的静态检查正是 Rust 追求安全性的体现类型系统在数据进入运行期之前就完成了完整性验证。一个常见的模式是局部变量恰好与字段同名。在从函数参数构造结构体时这种重复尤为刺眼fncreate_user(username:String,email:String)-User{User{username:username,email:email,active:true,sign_in_count:1,}}username: username这样的写法虽然语义清晰却在视觉上制造了噪音——字段名和变量名各写一遍纯粹是文字的重复。Rust 为此提供了字段初始化简写field init shorthand当字段名与变量名完全一致时可以省略冒号和变量名只写一次字段名fncreate_user(username:String,email:String)-User{User{username,// 等价于 username: usernameemail,// 等价于 email: emailactive:true,sign_in_count:1,}}这条语法规则没有引入任何新的语义——它只是让你少敲几次键盘。但它的价值在于消除了表达上的冗余当你看到username时你看到的既是一个字段也是一个同名的局部变量编译器帮你完成了这层对应关系的接线。在构造包含五六个字段的结构体时这种简写能显著减少代码的视觉密度。简写规则只适用于具名字段结构体。元组结构体本身没有字段名构造时只能通过位置传值不存在简写的可能性。结构体更新语法另一种常见的构造场景是基于一个已有的实例修改其中部分字段其余字段保持不变。假设我们有了一个已注册的用户user1现在要创建一个同用户名、但邮箱不同且登录状态重置的新用户letuser2User{email:String::from(bobexample.com),active:true,..user1};这里的..user1就是结构体更新语法struct update syntax。它类似于其他语言中的展开或拷贝剩余字段但有一个关键区别需要留意..执行的是移动或拷贝语义具体取决于字段类型——activebool和sign_in_countu64实现了Copy会被按位复制而usernameString是移动而非复制其所有权从user1转移给了user2user1随后不能再访问username字段。这意味着一个微妙的后果如果user1的所有字段都实现了Copy比如一个全部由整数构成的坐标结构体那么更新后user1依然完全可用。但只要有一个字段是移动语义如Stringuser1就会部分失效。更新语法让基于已有值派生新值的表达变得极其紧凑在..之前列出的字段是差异..之后的值是基线。这种差异 基线的构造模式和 Git 的分支思想有异曲同工之妙——差异显式写出来其余从基线上自动继承。字段访问与所有权访问结构体实例的字段使用点号.语法println!({} has signed in {} times,user1.username,user1.sign_in_count);user1.username直接取出该字段的值。如果字段类型实现了Display则可以像这里的String和u64一样直接参与格式化输出。当实例被声明为可变时可以通过赋值表达式修改字段letmutuser3User{email:String::from(carolexample.com),username:String::from(carol),active:false,sign_in_count:0,};user3.activetrue;// 合法user3 是可变的user3.sign_in_count1;// 使用复合赋值运算符注意字段级别的可变性不独立存在——let user声明的是绑定不可变还是一般可变这个属性决定了其所有字段的读写权限。想要修改某个字段必须将整个实例声明为mut。这个设计保持了 Rust 在可变性上的简单一致可变性是属于绑定的属性而非数据结构的属性。字段访问还遵循所有权规则访问一个拥有所有权的字段如String时会移动该字段此后原实例不能再通过点号访问这个字段。访问实现了Copy的字段如u64、bool则是复制原实例继续可用。这是所有权系统在结构体内部的第一次实战应用——结构体作为一个整体拥有其所有字段的所有权而字段间的获取规则与独立变量完全一致。至此我们已经能用struct定义类型、创建实例、读写字段。一种复合数据的基本单元已经成形。但结构体的真正威力在于它不仅承载数据还能承载行为——这正是下一节impl块中方法method与关联函数associated function将要解决的事。元组结构体与单元结构体结构体并不是只有具名字段这一种形态。前一小节中User的每个字段都有明确的名称——username、email在访问时可以自解释其含义。但有些场景下字段的名称并无必要一个二维坐标(x, y)一个 RGB 颜色(r, g, b)字段的语义已经被位置和类型充分表达。Rust 为此提供了结构体的第二种形态元组结构体tuple struct。元组结构体元组结构体在名字上就揭示了它的本质——它像一个元组但披着结构体的外衣。定义时只需要声明字段类型不需要字段名// 二维坐标两个 i32 字段语义由位置决定structPoint(i32,i32);// RGB 颜色三个 u8 字段structColor(u8,u8,u8);fnmain(){letoriginPoint(0,0);letredColor(255,0,0);}实例化元组结构体时传入的实参按位置依次对应字段类型。访问其中的字段使用的是点号加索引。这里与具名字段结构体的关键区别在于索引从0开始不是从1开始这是元组访问语法tuple.0的自然延续——元组结构体本质上就是一个有名字的元组fnmain(){letmutpointPoint(3,5);println!(x {}, y {},point.0,point.1);// 输出: x 3, y 5// 字段可变性规则与具名字段结构体一致实例可变字段即可变point.010;println!(新的 x {},point.0);// 输出: 新的 x 10}这里有一个容易混淆的点值得明确指出Point和Color虽然内部字段类型完全相同但它们是不兼容的两种类型。你不能把一个Point赋值给一个声明为Color的变量即使两者底层都是(i32, i32)或(u8, u8, u8)。这是元组结构体区别于裸元组的核心价值——它为你提供了一层类型层面的防护。考虑下面的对比// 裸元组类型上没有约束编译器不区分含义letred:(u8,u8,u8)(255,0,0);letsize:(u8,u8,u8)(800,600,0);// 编译器完全不介意// size 和 red 可以互相赋值语义完全混淆// 元组结构体类型系统替你区分语义letred2:ColorColor(255,0,0);letsize2:SizeSize(800,600);// 如果还有 Size 类型// red2 和 size2 之间直接赋值会编译报错——这正是我们想要的在第一小节中我们提到具名字段结构体的最大优势是字段名自带文档。而元组结构体的取舍在于用位置的简洁换取字段名的自解释性。因此它最适合那些字段数量少通常 2~3 个、语义高度依赖位置、且你需要和裸元组区分开的场景。元组结构体的另一个特性是它可以被解构destructure这一点你在第 9 节学习模式匹配时会深入体验。这里先看一眼解构的基本形态为你留一个印象fnmain(){letPoint(x,y)Point(3,5);println!(({}, {}),x,y);// 输出: (3, 5)}单元结构体单元结构体是结构体中最轻量的一种形态定义时甚至没有任何字段structEmpty;注意这里的写法——struct Empty;以分号结尾没有花括号。这就是单元结构体unit struct。它之所以叫「单元」是因为它和 Rust 中的单元类型()有相似之处都是不携带任何数据、仅表达「一个类型存在」这一事实。单元结构体的价值不在于存储数据而在于定义类型本身。在所有权和借用那一节你已经体会到Rust 的类型系统是编译期行为的核心依据。单元结构体最常见的用途就是创建不需要数据、但需要一个独立类型来参与的上下文。比如一个后端的回调接口某些状态下你只需要「这个类型存在」这一事实而不需要它存储任何数据// 定义一个任务处理器目前不需要任何状态structTaskHandler;implTaskHandler{fnrun(self){println!(执行任务处理逻辑);}}fnmain(){lethandlerTaskHandler;// 实例化不需要任何参数handler.run();// 调用方法}实例化单元结构体不需要使用()元组语法直接写类型名就是一个值。它是零大小的类型Rust 会保证每个单元结构体实例占用的内存为 0 字节。此外单元结构体常被用作「类型标记」type marker嵌入泛型设计中这一点虽然超出本小节的范畴泛型 impl 将在后续展开但你可以记住这个方向当需要在类型层面区分两个运行时行为完全相同的逻辑时单元结构体是零成本的标记手段。三种结构体形态的适用场景至此三种结构体形态已经齐备。它们之间的选择标准并不复杂用一张对比表可以归纳清楚形态定义语法字段访问适用场景具名字段结构体struct S { name: T }s.name字段多、字段名有自解释价值元组结构体struct S(T1, T2)s.0,s.1字段少、语义靠位置承载单元结构体struct S;无字段只需要类型本身不携带数据一个值得记住的实践准则是当你犹豫要不要用元组结构体时先问自己「这里的字段名真的没有价值吗」。大多数时候具名字段结构体的可读性优势是值得多敲几个字符去换取的元组结构体应该用在那些结构足够固定、语义足够清晰的少量场景中。而单元结构体则只在你需要类型标记或零状态上下文时才登场。从具名字段结构体到元组结构体再到单元结构体我们已经完整掌握了如何用struct定义组织数据的骨架。但到目前为止这些结构体都只是一个被动的数据容器——我们访问其字段、读取其值却还没有让它们「行动起来」。下一节我们将学习如何通过impl块为结构体赋予方法让数据不仅被存储更被行为所驱动。结构体方法与关联函数前两节构建了结构体的三种形态——具名字段、元组字段和空字段——但我们还只停留在数据容器的层面。一个User结构体虽然能装下用户名和邮箱却无法回答这个用户能否登录或邮箱是否合法这类行为问题。Rust 通过impl块将行为与类型绑定它允许我们把与类型紧密相关的函数组织在一起让类型不只是被动的数据而是能够响应操作的行为主体。impl块将函数绑定到类型implimplementation块的语法非常直白在impl关键字后跟上类型名然后用一对花括号包住若干函数。先前提到的User结构体我们可以立即为它添加第一个方法structUser{username:String,email:String,sign_in_count:u64,active:bool,}implUser{fnis_active(self)-bool{self.active}}这里is_active接收一个self参数——它会在运行时返回结构体实例的active字段值。关键在于impl块内的函数天然与类型绑定不需要额外修饰一旦离开impl块is_active就不存在于普通作用域中必须通过具体实例调用。impl块允许定义多个函数但它们共享同一个语义上下文。你可以将方法按逻辑分组——一个impl块放认证相关的方法另一个放资料展示相关的方法——这是 Rust 允许的行为它给予了组织代码的自由。方法self的三种形态方法的第一参数是self它决定了调用时所有权如何转移。Rust 提供三种形态每种都有明确的语义含义形态签名语义调用后实例状态所有权方法fn into_name(self)消费实例夺取其字段实例被移动不可再用可变借用fn update(mut self)修改字段但不释放实例仍然存在且可变不可变借用fn is_active(self)只读访问实例不受影响实际使用中self最为常见——实现只读查询。mut self用于修改字段。self则用于需要从实例中提取数据的场景例如把User转换为仅含用户名的轻量结构implUser{// 获取用户名所有权消费掉 User 实例fninto_username(self)-String{self.username}}调用user.into_username()后原user不能再被使用——所有权已转移。这与第 5 节所有权转移的语义完全一致方法同样遵循移动规则。选择哪种self形态实际上是所有权设计的一部分需要谨慎权衡。调用语法十分统一实例.方法名(参数)。Rust 自动引用或解引用以匹配签名——你不需要手动写(user).is_active()这样的代码直接user.is_active()即可编译器自动完成借用。这也是 Rust 中方法调用语法糖的核心机制让代码保持简洁的同时不牺牲安全。关联函数不依赖实例的构造器impl块中的函数并非都接收self。如果函数签名中没有self它就变成了关联函数associated function——通过类型::函数名调用。最常见的用途是构造函数implUser{fnnew(username:String,email:String)-Self{User{username,email,sign_in_count:1,active:true,}}}此处的Self是impl块中类型的别名——写Self与写User完全等价但Self更简洁。User::new(...)返回一个全新的实例。关联函数不依赖具体实例存在因此无法通过.调用只能用::路径语法。new只是一个约定俗成的名字Rust 语言本身不强制要求任何构造函数名。你可以定义User::from_email(email)或User::default()只要它是无self的函数即可。不过社区习惯将最常用的构造入口命名为new目的是降低阅读者的认知负担。关联函数与方法的边界清晰有关self的称为方法无self的称为关联函数。不过Rust 中的关联函数可以返回Self并通常用于构造但也可以返回其他类型如验证布尔值。它的本质只是将逻辑挂到类型上是否构造取决于你的设计。组合与应用有了方法和关联函数一个结构体就能完整地表达数据 行为的封装。再回到Point元组结构体的例子看看如何为它补上计算距离的方法structPoint(f64,f64);implPoint{// 关联函数构造新点fnnew(x:f64,y:f64)-Self{Point(x,y)}// 方法计算到原点的距离只读fndistance_from_origin(self)-f64{(self.0*self.0self.1*self.1).sqrt()}// 方法平移点可变借用fntranslate(mutself,dx:f64,dy:f64){self.0dx;self.1dy;}}// 调用实例letmutpPoint::new(3.0,4.0);println!(距离: {},p.distance_from_origin());// 5.0p.translate(1.0,-1.0);println!(平移后: ({}, {}),p.0,p.1);// (4.0, 3.0)注意println!中我们通过p.0直接访问元组字段这符合第 2 节中学到的点号加索引的语法。而方法内部通过self.0访问同一字段。这清晰地展示了方法体与调用方使用相同字段访问规则一致性让代码更易推断。设计建议优先为结构体提供关联函数new作为唯一的构造入口这能统一实例化的方式避免散落的字段赋值。同时只读查询一律用self只有需要修改内部状态才用mut self而self则留给实例生命周期终结的场景。现在我们已经掌握了结构体定义、实例化和方法绑定下一步的核心问题是当我们拿到一个结构体实例如何根据其不同形态或状态执行不同逻辑这正是下一节——模式匹配与解构——要解决的问题。枚举定义与变体前三节我们构建了结构体的完整图景具名字段让数据有了可读性元组字段让紧凑的坐标和颜色有了简洁表达impl块则赋予了类型行为。但结构体回答的始终是一个值同时拥有哪些属性这个问题而真实世界还有另一类常见需求——一个值在多种形态之间选择一种。一个网络消息可能是请求也可能是响应一个形状可能是圆形也可能是矩形一张牌的花色只有四种可能。Rust 用枚举enum来建模这一场景它允许我们定义一个类型并明确列出这个类型所有可能的值。枚举变体类型的有限集合枚举通过enum关键字声明花括号内列出所有可能的变体variant。变体之间用逗号分隔enumIpAddrKind{V4,V6,}IpAddrKind类型的值只能是V4或V6二者之一。这就把可能性的集合显式地写进了类型系统——编译器会替我们保证任何IpAddrKind类型的变量都不可能落到这两个变体之外。这种穷尽性exhaustiveness是枚举最核心的承诺我们会在模式匹配一节看到它带来的全部力量。变体的命名遵循与结构体相同的大驼峰命名法这与结构体的风格保持一致让代码在视觉上就能快速区分类型与值。定义枚举时每个变体之间用逗号分隔最后一个变体后的逗号可以省略但保留它是一种常见风格便于后续追加新变体时减少 diff 噪声。变体携带数据从标签到容器上面IpAddrKind的两个变体是不带数据的——它们只是纯粹的标签。但枚举的真正威力在于每个变体可以携带不同类型和数量的数据。这使得枚举从有限集合升级为能区分形态、且每种形态自带载荷的复合类型。enumIpAddr{V4(String),// V4 变体携带一个 StringV6(String),// V6 变体携带一个 String}这里V4不再是一个孤立的标签而是携带了一个String类型的 IP 地址。当我们创建一个V4变体的值时这个值同时包含了它是 V4这个身份信息以及具体的地址数据lethomeIpAddr::V4(String::from(127.0.0.1));letloopbackIpAddr::V6(String::from(::1));更进一步的不同变体可以携带不同种类的数据。一个变体可以携带元组另一个可以携带结构体甚至可以携带另一个枚举enumMessage{Quit,// 不带数据Move{x:i32,y:i32},// 携带匿名结构体Write(String),// 携带一个 StringChangeColor(u8,u8,u8),// 携带三个 u8}Move变体携带了一个匿名的结构体——它有字段名和类型但不需要单独定义一个结构体类型。这种变体内嵌数据形态的能力让枚举可以描述层次复杂的数据结构。仔细想想Message枚举实际上把四种不同的数据形态统一到了一个类型之下这正是枚举相比结构体的核心优势结构体描述同时拥有的关系枚举描述择一而为的关系。构造值类型名加变体名枚举值的构造语法需要同时指定类型名和变体名中间用双冒号::连接。这与元组结构体的构造方式类似但多了一层变体的限定// 带数据的变体变体名后跟小括号填入对应数据letmsg_writeMessage::Write(String::from(hello));// 具名变体类似结构体初始化语法用字段名赋值letmsg_moveMessage::Move{x:10,y:30};// 不带数据的变体没有小括号没有花括号letmsg_quitMessage::Quit;三种变体的构造语法各不相同这直接反映了它们携带数据的方式元组样式的变体用位置参数结构体样式的变体用字段名无数据的变体则什么都不带。一旦构造完成访问变体内的数据需要依赖模式匹配——这正是下一节的主题。在我们正式引入match之前枚举值中的具体数据尚无法通过点号直接取出这与结构体字段的直接访问形成了鲜明对比也是枚举类型在早期学习中需要适应的一个差异点。值得注意的是枚举的定义语法本身也内建了类型定义。也就是说enum IpAddr { V4(String), V6(String) }一次性定义了一个新类型IpAddr同时定义了它的两个构造入口IpAddr::V4和IpAddr::V6。从语义上看这两个入口就像两个关联函数它们的返回值类型都是IpAddr。如果我们要为枚举添加方法同样可以使用impl块implIpAddr{fnis_loopback(self)-bool{matchself{// 这里使用了 match 表达式具体语法将在后续章节详细讲解IpAddr::V4(addr)addr.starts_with(127.),IpAddr::V6(addr)addr::1,}}}上面的match表达式尚不在本节展开范围内但你可以直观地看到impl块对枚举和结构体一视同仁方法通过self借用实例调用语法同样是instance.method()。枚举与结构体在定义和使用上的对称性让这两种自定义类型的组合几乎可以建模任何业务领域的数据结构。回顾本节枚举通过变体定义了一个类型的有限可能集合每个变体可以携带不同类型的数据让枚举从简单的标签升级为携带载荷的形态容器构造枚举值时使用类型名::变体名的语法三种变体形态对应三种构造写法。加上impl块对方法的支持枚举已经具备了与结构体同等的表达能力——而它独有的形态穷尽特性将把模式匹配这一语言特性推向舞台中央。结构体的内存布局我们已经熟练地定义和构造结构体了但一个更底层的问题值得追问一个结构体实例占据多少字节字段偏移是否稳定这直接影响缓存局部性、FFI、序列化和VecUser的密度。回答之前必须先区分可观测结果与语言保证。默认布局能测量不等于有契约默认结构体采用repr(Rust)。Rust Reference 的布局保证要求字段满足对齐且互不重叠但不保证字段按源码声明顺序排列也不保证不同编译器版本、目标平台或优化设置下偏移保持不变。编译器可以重排字段。因此不能根据源码手算偏移更不能把默认布局直接暴露给 C、磁盘格式或网络协议。下面这个类型通常占 8 字节但应以目标平台上的实际测量为准structPoint{x:i32,// 4 字节对齐 4y:i32,// 4 字节对齐 4}可以用标准库测量大小和对齐usestd::mem::{align_of,offset_of,size_of};#[repr(C)]structMixed{a:u8,b:i64,c:u8,}fnmain(){println!(size{}, align{},size_of::Mixed(),align_of::Mixed());println!(offsets: a{}, b{}, c{},offset_of!(Mixed,a),offset_of!(Mixed,b),offset_of!(Mixed,c));}这里显式使用#[repr(C)]后字段按声明顺序布局并遵循目标平台 C ABI 的对齐规则在常见 64 位平台上a、b、c的偏移通常为 0、8、16总大小通常为 24。若改成下列顺序常见结果是 16 字节#[repr(C)]structMixed{b:i64,a:u8,c:u8,}这说明字段顺序在要求顺序稳定的表示法中会影响填充但不能反推出默认repr(Rust)的字段偏移。#[repr(C)]的用途首先是建立 ABI 契约不是无脑优化内存#[repr(packed)]会降低对齐并可能产生未对齐访问不能为了省空间随意使用。如果布局影响性能应在目标平台上同时测量size_of、缓存未命中和端到端吞吐而不是只凭字段大小推断速度。若布局要跨语言或持久化还必须明确整数宽度、字节序、版本与兼容策略。枚举布局与 niche 优化枚举在概念上需要同时表示“当前变体”和“该变体的载荷”但其物理布局不能简单概括为最大载荷加一个固定整数标签。默认布局由编译器选择可能使用独立判别值也可能把某个载荷类型的无效位模式niche编码成其他变体。考虑下面的枚举enumShape{Circle(f64),// 8 字节Rectangle{w:u32,h:u32},// 8 字节Nothing,// 0 字节}这个枚举在某个具体目标上的大小可以用size_of::Shape()观察但不要把观察结果当成稳定 ABI。编译器如何放置判别值、载荷和填充属于默认表示的实现细节。经典例子是OptionT安全引用不能为 null编译器可以用空指针位模式表示None因此标准库保证OptionT与T具有相同大小和对齐。类似优化还可能出现在NonZeroUsize等类型上但不能把这一经验任意推广到所有自定义枚举。了解枚举的内存布局有助于你理解为什么某些枚举比其他枚举更高效但它通常不应成为你日常编码中优先考虑的因素。先写清晰的代码再在性能瓶颈出现时关注布局优化——这是 Rust 社区普遍认可的做法。小结repr(Rust)只承诺足以保证类型安全的布局不承诺字段顺序与跨版本 ABIrepr(C)才用于与 C 布局规则对齐。枚举可能采用判别值也可能利用 niche。size_of、align_of和offset_of!适合验证目标平台上的事实但只有语言与 Reference 明确保证的部分才能作为公共接口契约。至此我们既掌握了结构体与枚举的值语义也建立了布局边界默认布局可以测量却不能擅自当作协议。下一篇将把重点从“如何构造这些类型”推进到“如何用模式安全地拆解它们”并进一步讨论部分移动与借用。设计检查不变量、可见性与表示法公开字段会让外部代码直接依赖表示细节需要维护不变量时应保持字段私有并通过构造器返回ResultSelf, E。结构体更新语法会逐字段移动非Copy字段因此旧实例可能发生部分移动。枚举比“布尔值 若干可选字段”更能表达互斥状态因为每个变体只携带该状态合法的数据。只有 FFI、二进制协议或经过测量的性能热点才应选择repr(C)、整数repr、transparent或packed并为布局假设编写静态断言和跨平台测试。