很少给文章起这样招摇的名字让各位看官见笑了。不是标题党而是个人在学习 Rust 生命周期的时候有过一个“豁然开朗”的时刻一下子想通了最关键的问题于是决定把思考过程整理记录下来相信应该也能启发到大家。这篇文章有一个很宝贵的地方就是找到了几个不错的“抓手”在原本陡峭的学习曲线上给读者提供了几个难得的“借力点”让 Rust 生命周期里那些原本晦涩难懂的部分变得相对容易了一些特别是“为什么会有生命周期标注这个东西”这是 Rust 生命周期里最深层的一个核心问题把这个问题想明白其他的问题都会迎刃而解本文就是以这个问题为核心进行写作的。友情提示阅读本文需要具备 Rust 所有权的相关知识可以参考此前的《深入理解 Rust 所有权》一文。1. 初识生命周期看这样一段代码{letr;{letx5;rx;}println!(r: {},r);}x 是在局部作用域里定义的离开第 6 行时它已不再可用。而 r 则是在更外面的作用域里定义的至第 7 行时依然有效但它所引用的 x 已经失效此时 r 就是一个“悬垂引用”。Rust 是能够检查出代码中的这个问题的它的借用检查器borrow checker能通过比较作用域来确保借用是否有效。r 和 x 的作用域其实也是它们各自的生命周期可以通过注释“标记”出来{letr;// ----------- a{// |letx5;// --- b |rx;// | |}// - |println!(r: {},r);// |}// ---------我们用a和b把 r 和 x 的生命周期标记出来离开a标记的范围r 会自动失效离开b标记的范围x 会自动销毁。除了使用注释我们可以使用一种更具“象征意义”的方式去标注生命周期虽然它不是 Rust 认可的语法但却可以很好地诠释 Rust 编译器对生命周期的处理方式在后面还能帮助我们平滑地过渡到对生命周期标记的理解上。请看下面的伪代码// 注意: a: {...} 和 b: {...} 不是有效的语法这里只是为了说明生命周期的概念a:{letr;b:{letx5;rx;}println!(r: {},r);}上面伪代码用a和b标记了两个{}语句块也就等同于划定了两个生命周期范围显然r的生命周期就是ax的生命周期就是b。尽管这不是 Rust 的语法但是你可以想象编译器通过对代码的静态分析完全能推导出像a和b这样的东西而实际上它也正是这么做的我们只是用形象的方式把它表示了出来。Rust 编译器分析’a 和 b这样的东西要干什么呢它要进行借用检查借用检查主要针对的是引用类型因为引用一不留神就会变成一个“危险”的东西。上面示例中的问题就是在分析了 r 和 x 的生命周期后发现的r 是 x 的引用但的它的生命周期却比 x 还要长x 在离开第 7 行时就不可用了而 r 还在第 9 行被继续使用上述代码在 Rust 中是编译不过的借用检查会在第 9 行报错“x does not live long enough”。2. 生命周期进阶简单认识生命之后现在要准备开始爬坡了。这一节将会介绍几个生命周期的特点或者说是规则因为在后文的分析中将会使用到这些规则。之所以先把它们抽离出来集中介绍一下是因为后面的分析过程至关重要如果遇到时再介绍大家就得在短时间内密集消化这些知识这会让学习难度陡增同时也容易模糊主线逻辑。但单独介绍也有一些挑战因为没有上下文理解起来会有一定的困难这其实也是生命周期这部分内容很难介绍的一个原因。不过也不用过于担心你可以把本节内容作为一个“预习”如果一时消化不了在后面的分析过程中遇到时我们还会再次提及到时可以前后联系着理解效果可能会更好一些。在介绍具体规则前为了简化行文我们约定几个技术名词。在编写代码时一个值的产生往往是在其他值的基础之上计算得出的我们把这个计算出的值称为“输出值”如果输出的是一个引用就称作“输出引用”把参与计算的值称为“输入值”如果输入的是引用就称作“输入引用”同样的如果是以函数调用的方式取得的计算结果那我们就把传入的引用类型参数称为“参数引用”把引用类型的返回值称为“返回引用”。规则 1一个引用的生命周期不能超过它引用的值的生命周期这是最基本的一条规则引用之所以能存在完全是因为它所指向的值还存在当一个值消亡时它的所有引用都会被编译器判定为失效所以引用的生命周期必定小于它指向的值的生命周期。这条规则还有别一个“特别版本”就是当函数试图返回一个指向函数内的局部变量的引用时编译器会报错因为函数内的局部变量在函数结束时就失效了如果允许把它的引用返回给调用方那调用方拿到它的时候就是一个已失效的引用了。因此函数不能返回对局部变量的引用。这里额外解释一下在 Rust 里虽然值本身也有自己的作用域和存活范围但“生命周期lifetime”这个术语通常用于描述引用的有效范围。值的有效性是由所有权系统决定的所有者离开作用域时值会被销毁而引用的有效性则是基于它的生命周期进行借用检查确定的。规则 2生命周期是一个编译期概念编译器检查生命周期依赖时只分析静态代码分析不了动态的执行过程几乎所有初次接触生命周期这个概念的人也包括我自己都会下意识地把“生命周期”理解成一个运行期的概念。这会让我们不自觉地把代码的执行逻辑代入到生命周期的判断过程中这是错误的。例如在下面的示例中第 7 行 else 分支中的s2.as_str()永远不会被执行也就是说从运行时的角度看 result 从来没有依赖过s2.as_str()但编译器依然会报 “s2 does not live long enough” 错误fnmain(){lets1String::from(hello!);letresult;{lets2String::from(hello world!);// s2.as_str() 从未使用过resultiftrue{s1.as_str()}else{s2.as_str()};}println!({},result);}我们要始终保持清醒的认识在确定引用的生命周期时编译器只分析静态的代码不会分析动态的执行过程。生命周期不是运行时确定的是在编译期仅靠分析代码就定下来的。顺便说一句判断所有权的转移也是如此。规则 3引用传播生命周期依赖值截断生命周期依赖看下面的示例fnmain(){lets1String::from(hello!);letr1s1.as_str();letresult;{lets2String::from(hello world!);letr2s2.as_str();// 输入引用 输出值resultifr1.len()r2.len(){s1}else{s2};}println!({},result);}在第 8 行计算 result 时计算过程依赖到了两个输入引用 r1 和 r2但是返回的是 s1 或 s2它们都是值。尽管离开第 5 - 10 行的作用域后r2 已不再可用但这一行代码是不会报错的因为值一但被计算出来就再也不会依赖那些输入引用了编译器只会在计算值的那个时刻检查所有的输入引用是否有效而值的存活期始终只取决它是否离开了作用域与那些输入引用的生命周期没有任何瓜葛所以编译器将不再根据它们去推导结果值的生命周期这完全是本末倒置了这就是我们想表达的值截断了生命周期依赖。但如果输出的是一个引用这里要特别注意这里说的“输出一个引用”只能是选取一个输入的引用把它再次输出出去不是先生成一个中间值再返回这个中间值的引用如果是那样中间值就会“截断”引用生命周期的传播这就变成上一种情形了性质就完全变了。下面的例子只是将上一个例子中第 8 行返回的 s1 和 s2 改成了 r1 和 r2代码就报错了lets1String::from(hello!);letr1s1.as_str();letresult;{lets2String::from(hello world!);letr2s2.as_str();// 输入引用 输出引用resultifr1.len()r2.len(){r1}else{r2};}println!({},result);由于输出的 result 成了一个引用它不再像上一个版本中的值那样“是一个独立的存在”它必须接受借用检查它的生命周期将严格受到 r1 和 r2 生命周期的约束这就是我们说的借用传播生命周期依赖。既然大家都是引用都不真正拥有值那就必须层层地推导它们之间的生命周期依赖关系确保不会出现失效的引用。规则 4一个输出引用如果依赖到两个或两个以上的输入引用则它的生命周期是所有输入引用生命周期的交集初次接触这个规则你可能会觉得很有道理如果一个输出引用依赖到两个或两个以上的输入引用那就意味着如果要 100% 确保输出引用是有效的那只能是在所有输入引用都有效的时候也就是取它们生命周期的“交集”当其中任何一个输入引用失效时输出引用会被编译器立即判定为失效。但如果多思考一下你可能会觉得这个规则哪里怪怪的却又说不上来。觉得“怪”的原因可能来自这样几个方面首先你可能想象的是只要在计算时确保所有输入引用都可用就可以了在完成计算后似乎不再需要什么依赖了哪来的生命周期交集之说呢错你在这里掉进了第 3 条规则中提到的惯性思维陷阱里。只有当计算的结果是一个值的时候才不会“再有什么依赖”这里我们讨论是的引用在这个场景里输出引用不会是一个中间值的引用它只能是其中的某一个输入引用也就是把其中一个输入引用当作结果返回这一点我们在第 3 条规矩中解释过因为一旦出现了中间值就变成了值什么时候离开作用域的问题了与引用的生命周期就没有任何关系了。如果你一开始就是这样想的那一开始就错了之后是怎样想的就没有再讨论的意义了。再者既然输出引用只会是输入引用中的其中一个那按正常逻辑推理输出引用的生命周期是不是就是其中一个输入引用的生命周期呢如果你觉得对那就又掉到第 2 条规则提到的惯性思维的陷阱里去了生命周期不是动态概念是静态的代码分析既然在代码上表现为输出引用依赖到了所有的输入引用那编辑器只会认定是全部依赖不会去给你跑逻辑分支确定返回的是哪一个输入引用它就是很直白地把输出引用的生命周期判定为受所有输入引用生命周期的共同约束。这个规则既能保证“在计算时所有输入引用都是可用的”又可以保证在计算后输出引用能安全地活到它能存活的最晚期限。3. 函数调用生命周期的“劫”通过前两节的介绍相信你已经对生命周期有了基本的了解很可能你现在感觉还不错因为目前为止我们还没有遇到特别困难的内容。那现在它们要来了。不知道你有没有注意到一个细节在前面的讲解中我们使用的所有示例都是“一勺烩”式的内联代码没有函数调用那是因为如果引入了函数调用我们将遇到一个大麻烦这一节我们就来打开这个“潘多拉魔盒”。考虑下面比较两个字符串切片谁更长的函数它无法编译通过会报这样一个错误missing lifetime specifier编译器直白地告诉了开发者这个函数缺少生命周期标注。现在这个阶段我们还不需要知道什么是生命周期标注因为我们还没有触及这个问题的“根源”当我们真正理解了这个问题的根源时也就理解了什么是生命周期标注。fnlongest(x:str,y:str)-str{ifx.len()y.len(){x}else{y}}接下来请忽略这个错误继续看一段调用longest()函数的代码调用代码比longest()函数更重要它是揭开谜团的关键。以下代码试图通过调用longest()函数找出两个字符串中较长的一个然后返回它的切片fnmain(){lets1String::from(hello!);letresult;{lets2String::from(hello world!);// 因为 s2 比 s1 要长result 是 s2 的切片resultlongest(s1.as_str(),s2.as_str());}// 执行到此处时result作为 s2 的切片已经是一个悬垂引用了因为 s2 已经销毁了println!({},result);}因为longest()函数已经报错了上面的调用代码暂时跑不起来我们假设编译器允许longest()编译通过然后看一下调用它会带来什么后果上图标记的绿、黄、红三个颜色的方框分别对应s1、result、s2三个变量的作用域你可以很直观地看出它们之间的关系s1results2。在执行到第 9 行时返回的引用result是一个指向s2的 slice但是当执行到第 12 行时s2因离开了作用域已被清理此时的result还在指向着已经失效的s2它变成了一个悬垂引用。这就是如果编译器允许longest()编译通过会导致的后果。很明显是编译器“出手”规避了这个风险但是其实编译器根本没走到发生悬垂引用事故的这一行它在longest()函数声明的地方就报错了。这很奇怪编译器不用看 main 函数里的逻辑就直接断定longest()有问题那它错在哪了呢这是关于生命周期标注最 tricky 的地方报错是在被调用的函数上但要理解错误的原因还是得回到调用它的地方把函数代码展开谜底才会浮现让我们对比着看一下如果不调用函数而是把函数中的代码粘贴到调用处用 inline 方式实现同样的逻辑会怎样fnmain(){lets1String::from(hello!);letresult;{lets2String::from(hello world!);resultifs1.len()s2.len(){s1.as_str()}else{s2.as_str()};// 此处报错}println!({},result);}转成非函数调用的版本后编译器能从上下文判断出 result 的生命周期会同时依赖 s1 和 s2 的生命周期也就是它的生命周期是 s1 和 s2 生命周期的交集参考上节规则 4当 12 行 else 分支试图返回 s2 的切片时编译器报错了它明确地告知开发者s2 的生命周期不够长result 引用它之后会出现悬垂引用问题。但是如果换成函数调用的形式result 与 s1 和 s2 之间的生命周期依赖“信息”就没有了因为一但进入到函数中就是一个新的上下文函数能从调用方获得的“信息”只有传给它的参数没有“这些参数和返回值之间的生命周期关系说明”“result 的生命周期需要同时依赖 s1 和 s2 的生命周期”这个信息传不到函数里面去也从函数里面传不出来。在丢失了这层信息的情况下编译器到了函数里面就判断不出返回引用和输入引用之间是怎样的依赖关系同样在函数内部形成的返回引用和输入引用之间的依赖关系也不能带回给调用方。这就是本节标题的出处函数调用成了引用类型生命周期的“劫”因为调用函数时引用类型的参数和返回值之间的生命周期关系都会丢失回答一开始的问题longest() 函数错在哪里呢它错在它试图返回一个引用类型同时它还有两个引用类型的参数作为输入它的函数签名就是它的“原罪”与外部调用环境无关。因为 Rust 认为如果一个函数的输出和输入牵涉到引用类型时函数有责任说明返回的引用跟输入的参数引用之间的生命周期依赖关系这个关系不说清楚编译器没法进行编译检查所以“板子”打在了 longest() 函数身上。怎么办呢Rust 的设计者得想办法把这个“窟窿”堵上如果信息丢了那就补上强制 longest() 函数修改它的函数签名把返回值和参数之间的生命周期关系给“描述”出来不就行了吗这就是“生命周期标注”。4. 生命周期标注帮助函数“渡劫”好了其实我们已经度过了本文最困难的地方既不是生命周期标注的概念也不是如何进行生命周期标注而是为什么会有生命周期标注这个东西一切皆因此而起只要理解了这个源头你会发现所有关于生命周期标注的概念和做法都是理所当然的不会让你再有任何困惑。回到longest()让我们来修正它的错误吧。我们需要修改longest()函数的签名把它的两个参数 x 和 y 以及它返回的引用三者之间的生命周期关系描述出来应该是下面的样子fnlongesta(x:astr,y:astr)-astr{ifx.len()y.len(){x}else{y}}首先你不应该对a这种符号感到陌生因为在文章开头介绍生命周期概念的时候我们已经用它表示过生命周期这里正式解释一下a这种符号就是生命周期标注它的含义并不是说“引用真的带着一个叫a的东西”而是说我们先用a这个符号表示一个生命周期具体是什么样范围并不重要重要的是看“关系”看返回引用和参数引用是标注了同一种符号还是不同的符号如果是像上面那样返回引用和 x、y 标记的是同一个符号就表示它们要共同遵循同一个生命周期约束。记住生命周期约束是有方向的总是输入约束着输出或者说输出依赖着输入所以更进一步的解释是返回引用的生命周期要同时接受 x 和 y 的生命周期约束这里 x、y 使用了相同的符号标注是要表达“同时”这个语义也就是取它们生命周期的交集。如果返回引用仅依赖其中一个引用参数则另一个引用参数不应被标记。这就是标注要表达的东西。Rust 生命周期标注的语法形式在表达约束关系上真得算不上贴切所以理解起来需要多绕一下但语义是能表达出来的。最后重新解读一遍标注版的longest()函数签名的含义我声明我的返回引用的生命周期和我的两个引用类型参数 x 和 y 的生命周期是绑定在一起的也就是说我的返回引用能存活多久取决于 x 和 y 同时存活多久如果它们中的任何一个失效我的返回的引用也将自动失效。编译器请根据我的声明函数签名检查引用参数和返回引用在整个调用的上下文函数内函数外中有没有悬垂引用的情况发生。其中最后一句“编译器请根据我的声明函数签名检查引用参数和返回引用在整个调用的上下文函数内函数外中有没有悬垂引用的情况发生”它所对应的恰恰就是前面我们调用非标注版longest()函数时面临过的问题“result 的生命周期要同时依赖 s1 和 s2 的生命周期”这个信息即传不到函数里面去也从函数里面传不出来而通过在函数签名上标注它们之间的生命周期关系在整个调用上下文函数内函数外中三者的生命周期关系能一直保持着不会“断片”这就是标注的作用。5. 谁来主导生命周期的标注longest()函数和调用它的main()函数帮助我们理解了生命周期标注的作用但是它们设计得太“严丝合缝”了longest()声明的生命周期约束就像是为main()函数量身定制的一样而main()函数 又“十分应景”地设计了一个悬垂引用陷阱让longest()的生命周期标注刷了一波存在感。但这个示例掩盖了一个重要的问题是谁或是什么规则决定了longest()返回引用的生命周期必须同时受两个引用参数的约束也就是最终的标注样式如果只看longest()和main()函数读者可能会认为一定是同时参考了 result、r1、r2 三个引用在longest()和main()函数中的依赖关系才推导出了longest()的标注形式。但其实不是这样的不管有没有main()函数或是别的什么函数longest()函数在设计之初就确定了fn longesta(x: a str, y: a str) - a str这样的函数签名。在 Rust 中当我们要确定一个函数的标注形式时是不会考虑未来调用它的函数有什么逻辑的我们只会基于函数自身的意图来确定参数引用和返回引用之间的约束关系。你可能会对此感到意外或许你也能举出一些场景来证明由调用方或两方一起来主导制定生命周期的约束关系更加合理事情可以这样做但标注只能写在函数签名上而不是调用方的调用代码里Rust 的机制是让函数自己声明它对返回引用和参数引用之间生命周期约束的“期望”让调用方去遵守而不是基于某种场景或上下文反推函数的标注应该怎样写。当然在这种机制下很有可能发生的情况是在你设计函数之初并没有想清楚返回引用的生命周期应该受哪些参数的制约你可能需要一些调用场景来协助你做出判断或是在编译器报错后重新思考你的标注是否合理然后重构你的设计但决定应该怎样标注的权力或者说义务始终是函数自已。6. 什么情况下需要进行标注尽管在该标注时你没有标注的话编译器会给你送上missing lifetime specifier的“温馨提示”但如果不想总被打脸你还是得清楚地知道什么情况下必须要对你的函数进行标注。既然生命周期标注的作用是当编译器判断不出返回引用的生命周期应该受哪个或哪些引用参数约束的时候让开发人员告诉它那用这个逻辑反推就能判断出在什么情况下需要进行标注了1. 如果返回值不是引用类型不需要标注fnlongest1(x:str,y:str)-String{....}2. 如果返回的是引用类型且只有一个引用类型的参数编译器可以自动推断出返回引用会受唯一引用类型参数的约束也不需要标注fnlongest2(x:str,y:String)-str{....}3. 如果返回的是引用类型且有两个或两个以上的引用类型的参数编译器无法自动推断出返回引用要受哪个或哪些参数引用的约束就必须要标注fnlongest3(x:str,y:str)-str{....}