網站首頁 編程語言 正文
+=
運算符與 MIR 應用
本文
+=
運算符部分整理自 Why does += require manual dereference when AddAssign() does not? 后半部分,
MIR 部分是我自己補充的。只在 https://zjp-cn.github.io/rust-note/ 上更新,其他地方懶得同步更新。
+=
解語法糖
一個基礎,但很少會思考的問題,Rust 的 +=
運算符是什么代碼的語法糖?
a = a + b
不等價于 a += b
a = a + b
是 a += b
的語法糖嗎?這意味著任何 a += b
與任何 a = a + b
代碼等價。
如果以標準庫定義的 impls 為例子,你可能覺得兩種寫法都能編譯,而且結果一致。
但考慮以下自定義類型的實現:
use std::ops::{Add, AddAssign}; fn main() { let mut s = S; s += (); // ok s = s + (); // error: expected struct `S`, found `()` } struct S; impl Add<()> for S { type Output = (); fn add(self, _: ()) { } } impl AddAssign<()> for S { fn add_assign(&mut self, _: ()) { } }
代碼不通過,原因是顯然的,s + ()
的類型是 ()
,無法賦值給 s
—— a = a + b
不是 a += b
的語法糖。
從運算符的 trait 定義來看(以 +
vs +=
為例),它們沒有任何關系:
pub trait Add<Rhs = Self> { type Output; fn add(self, rhs: Rhs) -> Self::Output; } pub trait AddAssign<Rhs = Self> { fn add_assign(&mut self, rhs: Rhs); }
AddAssign::add_assign(&mut a, b)
與 a += b
+
和 +=
是典型的二元運算符和復合賦值運算符。根據各自的運算符 trait 定義,可以得到以下解語法糖:
-
a + b
實際調用Add::add(a, b)
-
a += b
實際調用AddAssign::add_assign(&mut a, b)
注意以下幾點:
- 若 a 和 b 擁有所有權時,其右側運算數 b 的所有權被獲取1,而對待左側運算數所有權的方式并不相同:
- a + b 獲取了 a 的所有權(無法再使用 a)
- a += b 獲取了 a 的獨占引用,而非所有權(a 必須是 mut 的,而且此后仍可以使用 a)
- 若 a 或 b 不擁有所有權時,則不存在對 a 或 b 所有權的轉移2:
- 當 implementor 為引用時,參數一并沒有發生所有權的移動
- 當泛型類型參數為引用時,參數二并沒有發生所有權的移動
- 調用的形式最好使用完全限定語法,而不是方法調用語法。這是因為方法調用表達式存在隱式的 自動引用/解引用,而基于類型的分析才更可靠。
- a + b 實際調用 <TypeOfA as Add<TypeOfB>>::add(a, b),優于 a.add(b)
- a += b 實際調用 <TypeOfA as AddAssign<TypeOfB>>::add_assign(&mut a, b),優于 (&mut a).add_assign(b)
本文的重點在于 a += b
,而不是 a + b
,所以對 a + b
的內容就此結束。
對于分析 a += b
,我遵循以下思考流程:
- 寫下兩側的類型,
- 如
Self += Rhs
實際調用的形式,如 <Self as AddAssign<Rhs>>::add_assign(&mut Self, Rhs)
<Self as AddAssign<Rhs>>::add_assign(&mut a, b)
- 完全限定語法的幾種等價形式:
-
Self: AddAssign<Rhs>
:這在分析 trait bounds 時常用 -
impl AddAssign<Rhs> for Self
:這在搜索具體實現時有用3
-
但像 +=
這樣的“復合賦值運算符”,一個鮮為人知的規則是關于兩側運算數的求值順序。
賦值表達式的求值順序
通過一個示例來感受求值順序為什么重要:
*{ print!("lhs "); &mut 0 } += { print!("rhs "); 0 }; *{ print!("lhs "); &mut String::from("a") } += { print!("rhs "); "b" };
這段代碼打印什么?
如果你能準確說出和解釋打印的內容,那么這小節內容可以跳過了。
如果你不知道答案,請往下看。
規則
Rust 中,大部分表達式是從左往右求值的,比如對于方法調用表達式 (method call expression) a.add(b)
,脫糖為Add::add(a, b)
,然后先計算左邊的 a
,再計算右邊的 b
。但賦值表達式不一定是從左到右求值。
Rust 具有兩種“賦值表達式”:
- 賦值表達式 (assignment expressions):將一個值移動進指定的地方,語法為
assignee operand = assigned value operand
。 - 復合賦值表達式 (compound assignment expressions):將運算/邏輯二元運算符與賦值表達式結合起來,語法為
-
assigned operand 操作符 modifying operand
,其中“操作符”為一個標記后跟一個=
(中間不含空格),比如+=
、|=
、<<=
。
兩側運算數的名稱非常不直觀,所以我使用左右兩側的表達方式來稱呼它們。實際上,它們以前被稱作“左值” (lvalue) 和“右值” (rvalue)。
對賦值表達式來說,先計算等號右側的值,再計算等號左側的值,即從右到左;對于解構賦值,其內部求值順序為從左到右。
# let (mut a, mut b); (a, b) = (3, 4); // 從右到左:先計算等號右側的 (3, 4),再賦值給等號左側的 (a, b) // 脫糖為 { let (_a, _b) = (3, 4); // 解構賦值過程中,從左到右 a = _a; // 先賦值給解構模式左邊的 a b = _b; // 再賦值給解構模式右邊的 b }
對于復合賦值表達式,若兩側的類型同時為 primitives,從右到左計算;否則從左到右計算。
回到本小節開頭的示例,現在可以仔細分析代碼了:
// 等號兩側的類型都為 `i32`,它是 primitive type,所以從右到左計算,打印 `rhs lhs ` *{ print!("lhs "); &mut 0 } += { print!("rhs "); 0 }; // 等號左右的類型為 `String` 和 `&str`,都不是 primitive type,所以從左到右計算,打印 `lhs rhs ` *{ print!("lhs "); &mut String::from("a") } += { print!("rhs "); "b" };
或許這些細節你會感到困惑:
問 | 答 |
---|---|
等號左側為什么要那樣寫? | 因為不允許直接寫 0 += ... |
為什么左側可以維持臨時的引用 &mut ? |
見 temporary-lifetime-extension |
為什么左側類型是 i32
|
0 的類型為 i32 ,這是 Rust 默認推斷的;&mut 0 類型為 &mut i32 ;*&mut 0 類型為 i32
|
為什么 i32 是 primitive type? |
見標準庫 primitive types |
什么是 primitive type? | 見標準庫 primitive types |
為什么 &str 不是 primitive type? |
見標準庫 primitive types,且見下面的例子:i32 是,&i32 不是,所以 str 是,&str 不是 |
總而言之,在 Rust 中,大部分表達式的求值順序是從左往右的,僅有少數地方是從右往左的,比如:
賦值表達式:先計算等號右側復合賦值表達式:僅在兩側運算數都為 primitive types 時才先計算右側運算數。為了鞏固這一條,請確保你完全理解下面的
代碼 和注釋。此外,你還可以看懂 rustc 的這個 測試代碼。
use std::num::Wrapping; macro_rules! add_assign { ($e1:expr, $e2:expr) => { *({print!("lhs "); &mut $e1}) += {print!("rhs "); $e2}; println!(""); } } fn main() { add_assign!(1, 2); // rhs lhs: both operands are primitives add_assign!(1, &2); // lhs rhs: Rhs &i32 is not a direct primitive add_assign!(String::new(), ""); // lhs rhs: neither operands are primitives add_assign!(Wrapping(1), Wrapping(2)); // lhs rhs: neither operands are primitives // So usually the execution order of `+=` is LTR (left-to-right) }
MIR
Rust 的 MIR 是 HIR 到 LLVM IR 的中間產物,對 Rust 眾多語法糖進行了脫糖,并且極大地精簡了 Rust
語法(但并非其語法子集),是觀察和分析 Rust 代碼的常用手段,尤其是在控制流圖和借用檢查方面。
獲取 MIR 的最簡便的方式是通過 playground 左上角下拉框,點擊 MIR 按鈕。
此外,你還可以使用 rustc src/main.rs -Z dump-mir=main
或 cargo rustc -- -Z dump-mir=main
獲得有關 main 函數完整的 MIR
- 查看
mir_dump/main.main.-------.renumber.0.mir
等文件 - 使用
cargo rustc -- -Z help
查看更多 mir 相關命令 - 相關 MIR 資料
Rust Blog: Introducing MIR 友好的官方入門解釋
rustc-dev-guide: MIR Debugging
rustc-dev-guide: The MIR (Mid-level IR)
對于上一節開頭的示例:
// 去除了無關和冗雜的 print!,將這段代碼復制到 play.rust-lang.org 查看 MIR *{ &mut 0 } += 0; *{ &mut String::from("a") } += "b";
關鍵的 MIR 輸出:
bb0: {
? ? _1 = const 0_i32;
? ? _3 = const 0_i32;
? ? _2 = &mut _3;
? ? _4 = CheckedAdd((*_2), _1);
? ? assert(!move (_4.1: bool), "attempt to compute `{} + {}`, which would overflow", (*_2), move _1) -> bb1;
}bb2: {
? ? _7 = &mut _8;
? ? _6 = &mut (*_7);
? ? _10 = const "b";
? ? _9 = _10;
? ? _5 = <String as AddAssign<&str>>::add_assign(move _6, move _9) -> [return: bb3, unwind: bb5];
}
這很容易解釋 +=
的語法脫糖和真正的執行順序:
-
_4 = CheckedAdd((*_2), _1)
這里的執行順序是從右到左(注意觀察編號),并且不是調用<i32 as AddAssign<i32>>::add_assign
,
而是直接調用 CheckedAdd
函數。
- 而
add_assign!(1, &2)
則對應_1 = <i32 as AddAssign<&i32>>::add_assign(move _2, move _13) -> bb5
,順序從左到右,調用了重載的
+=
trait 方法。
-
_5 = <String as AddAssign<&str>>::add_assign(move _6, move _9)
這里的順序是從左到右,調用的是重載的+=
trait 方法。
單一實現下的強轉
遵循前面我提到的流程,對于以下正常工作代碼,第一步,寫下左右兩側的類型,你會得到 S += &&&&&&()
,實際不存在這個實現,因為S
僅有 S: AddAssign<&()>
。這發生了什么?
struct S; impl std::ops::AddAssign<&()> for S { fn add_assign(&mut self, _: &()) {} } fn main() { let mut s = S; let rrrrrr = &&&&&&(); s += rrrrrr; }
通過 MIR,你會發現
-
<S as AddAssign<&()>>::add_assign(move _4, move _5)
表明從左到右執行,因為兩側運算數不是 primitive type - 傳給
add_assign
的第二個參數,其類型并不是變量rrrrrr
的類型&&&&&&()
,而是經過 5 次解引用之后的&()
類型
bb0: { _6 = const _; _4 = &mut _1; _7 = deref_copy (*_2); _8 = deref_copy (*_7); _9 = deref_copy (*_8); _10 = deref_copy (*_9); _11 = deref_copy (*_10); _5 = _11; _3 = <S as AddAssign<&()>>::add_assign(move _4, move _5) -> bb1; }
這里隱式的解引用是因為強轉,而函數參數是能夠發生 強轉的地方 之一。
并且,依據這段 MIR(注意看從上到下的執行過程),我們知道,對于已知的 add_assign
實現,執行順序先于強轉發生。
而當 S
的 AddAssign
實現是多個,強轉被阻止,你需要傳入準確的類型的值:
struct S; impl std::ops::AddAssign<()> for S { fn add_assign(&mut self, _: ()) {} } impl std::ops::AddAssign<&()> for S { fn add_assign(&mut self, _: &()) {} } fn main() { let mut s = S; let rrrrrr = &&&&&&(); s += rrrrrr; } // error[E0277]: cannot add-assign `&&&&&&()` to `S` // --> src/main.rs:12:7 // | // 12 | s += rrrrrr; // | ^^ no implementation for `S += &&&&&&()` // | // = help: the trait `AddAssign<&&&&&&()>` is not implemented for `S` // = help: the following other types implement trait `AddAssign<Rhs>`: // <S as AddAssign<&()>> // <S as AddAssign<()>>
兩階段借用的參與
以下代碼能夠運行:
- 由于兩側類型不是 primitive type,
add_assign
從左到右執行 - 但已經使用
&mut self
的情況下,為什么能夠同時執行帶&self
的方法?
struct S; impl std::ops::AddAssign<()> for S { fn add_assign(&mut self, _: ()) {} } impl S { fn no_op(&self) {} } fn main() { let mut s = S; s += s.no_op(); }
通常對于初學者, &mut
會有兩個更高級的主題:
- 重新借用 (reborrow)
- open 狀態的 Reference issue、RFC issue,在遷移到 Chalk 之前,不會正式描述 reborrow
- 它大概是說:我們看見的
&'a mut T
,實際被自動轉化成更短的&'b mut T
,從而看起來&mut T
一直可用。這也發生在 -
&T
上面,但通常我們對&mut T
的 reborrow 更敏感。 - 這一是個在 1.0 之前就有的概念
- UCG 可能會對 reborrow 做出說明
- 一個直覺上的理解
- 兩階段借用 (two-phase borrows)
- 它在 rustc dev guide 上的 正式介紹
- 它大概是說,某些情況下
&mut T
會劃分成兩個階段進行使用: - 在 reservation 階段:
&mut T
像是&T
那樣,以允許多個&T
同時存在 - 在 activated 階段:
&mut T
以完全獨占的方式使用 - 某些情況指以下三種情況之一(上述鏈接對具體例子都有分析):
- 調用 receiver 為
&mut self
的方法(包括方法調用時的自動引用):如vec.push(vec.len())
- 函數參數中的
&mut T
reborrow:如std::mem::replace(r, vec![r.len()])
- 重載的復合賦值運算符中隱式的
&mut T
:如本小節示例 - 代碼中,任何顯式的
&mut
和ref mut
都不是兩階段借用
MIR 可以幫助你看到兩階段借用。
bb0: { // reservation 階段 _3 = &mut _1; // 兩階段借用的第三種前提:重載的復合賦值運算符中隱式的 `&mut T` _5 = &_1; // `&mut T` 暫時被視為 `&T`,從而允許在此處使用 `&T` _4 = S::no_op(move _5) -> bb1; } bb1: { // activated 階段 _2 = <S as AddAssign<()>>::add_assign(move _3, move _4) -> bb2; }
實戰
例子源自 #72199 issue,@steffahn 做了很好的 解釋,這里從 MIR 角度進行補充。
Vec<i32>
的 v[i] += v[j]
fn main() { let mut v = Vec::from([0, 1]); // 為了讓 MIR 精簡,故意不使用 vec![0, 1] v[0] += v[1]; // 第一步:i32 += i32 } // 兩側為 primitive types, RTL: <i32 as Add<i32>>::add_assign(&mut v[0], v[1]) // 1. 計算 v[1]:對它脫糖 `<Vec<i32> as Index<usize>>::index(&v, 1)` 得到 `&i32`,然后解引用得到 `i32` // 2. 計算 &mut v[0]:對它脫糖 `<Vec<i32> as IndexMut<usize>>::index_mut(&mut v, 0)` 得到 `&mut i32` // 可以看到先使用了 `&v`,再使用了 `&mut v`,通過借用檢查 // 僅列出 MIR 中的重點 // let mut _1: std::vec::Vec<i32>; // bb1: { // _5 = &_1; // _4 = <Vec<i32> as Index<usize>>::index(move _5, const 1_usize) -> [return: bb2, unwind: bb6]; // } // bb2: { // _3 = (*_4); // _7 = &mut _1; // _6 = <Vec<i32> as IndexMut<usize>>::index_mut(move _7, const 0_usize) -> [return: bb3, unwind: bb6]; // } // bb3: { // _8 = CheckedAdd((*_6), _3); // assert(!move (_8.1: bool), "attempt to compute `{} + {}`, which would overflow", (*_6), move _3) -> [success: bb4, unwind: bb6]; // } // bb4: { // (*_6) = move (_8.0: i32); // drop(_1) -> bb5; // }
&mut [Custom]
的 v[i] += v[j]
#[derive(Clone, Copy)] struct MyNum(i32); impl std::ops::AddAssign for MyNum { fn add_assign(&mut self, rhs: MyNum) { *self = MyNum(self.0 + rhs.0) } } fn main() { let mut b = vec![MyNum(0), MyNum(1)]; let v = b.as_mut_slice(); v[0] += v[1]; // MyNum += MyNum } // LTR: <MyNum as Add<MyNum>>::add_assign(&mut v[0], v[1]) // 1. 計算 &mut v[0]:獲取和維持對第 0 元素的獨占引用,但只進入 reservation 階段,將 &mut 視為 &,從而繼續使用切片 // 2. 計算 v[1]:在 `&mut v[0]` 的第一階段,通過 `*_10` 和索引拷貝 MyNum // 3. 調用方法,`&mut v[0]` 進入 activated 階段 // 僅列出 MIR 中的重點 // let mut _1: std::vec::Vec<MyNum>; // bb2: { // _11 = &mut _1; // _10 = Vec::<MyNum>::as_mut_slice(move _11) -> [return: bb3, unwind: bb8]; // } // bb3: { // 索引前進行了邊界檢查 // _14 = const 0_usize; // _15 = Len((*_10)); // _16 = Lt(_14, _15); // assert(move _16, "index out of bounds: the length is {} but the index is {}", move _15, _14) -> [success: bb4, unwind: bb8]; // } // bb4: { // _13 = &mut (*_10)[_14]; // 獲取 &mut v[0],進入 reservation 階段 // _18 = const 1_usize; // 索引前進行了邊界檢查 // _19 = Len((*_10)); // _20 = Lt(_18, _19); // assert(move _20, "index out of bounds: the length is {} but the index is {}", move _19, _18) -> [success: bb5, unwind: bb8]; // } // bb5: { // _17 = (*_10)[_18]; // 計算 v[1] // _12 = <MyNum as AddAssign>::add_assign(move _13, move _17) -> [return: bb6, unwind: bb8]; // activated 階段 // }
Vec<Custom>
的 v[i] += v[j]
#[derive(Clone, Copy)] struct MyNum(i32); impl std::ops::AddAssign for MyNum { fn add_assign(&mut self, rhs: MyNum) { *self = MyNum(self.0 + rhs.0) } } fn main() { let mut b = vec![MyNum(0), MyNum(1)]; b[0] += b[1]; }
它無法編譯成功,但編譯器提示你怎么 解決(把右側的值賦給局部變量,然后使用該變量):
error[E0502]: cannot borrow `b` as immutable because it is also borrowed as mutable --> src/main.rs:12:13 | 12 | b[0] += b[1]; | --------^--- | | | | | immutable borrow occurs here | mutable borrow occurs here | mutable borrow later used here | help: try adding a local storing this... --> src/main.rs:12:13 | 12 | b[0] += b[1]; | ^^^^ help: ...and then using that local here --> src/main.rs:12:5 | 12 | b[0] += b[1]; | ^^^^^^^^^^^^
當你試著從 MIR 分析為什么這樣,你會發現 playground 因為編譯失敗而沒有 MIR 的結果,提示為Unable to locate file for Rust MIR output
。
此時,你仍可以在本地獲取一部分 MIR 結果,因為 MIR 其實經過許多次迭代,mir_dump
文件夾下保留了半成品:運行cargo rustc -- -Z dump-mir=main
,查看 mir_dump/simd.main.-------.renumber.0.mir
文件。
// 僅列出關鍵部分 bb4: { _13 = &mut _1; _12 = <Vec<MyNum> as IndexMut<usize>>::index_mut(move _13, const 0_usize) -> [return: bb5, unwind: bb9]; } bb5: { _11 = &mut (*_12); StorageDead(_13); StorageLive(_14); StorageLive(_15); StorageLive(_16); _16 = &_1; _15 = <Vec<MyNum> as Index<usize>>::index(move _16, const 1_usize) -> [return: bb6, unwind: bb9]; } bb6: { _14 = (*_15); StorageDead(_16); _10 = <MyNum as AddAssign>::add_assign(move _11, move _14) -> [return: bb7, unwind: bb9]; }
把它與上一小節在 &mut [MyNum]
的 MIR 進行對比,你會發現在 &mut Vec<MyNum>
上沒有發生兩階段借用:
- 觀察兩個 MIR 片段的
move _13
,第二個片段的&mut _1
借用已經在獲取索引時結束(未能到達add_assign
),而第一個在調用add_assign
時結束 - 所以
Vec<MyNum>
上的b[0] += b[1]
是通過兩個不同的&mut Vec<MyNum>
和&Vec<MyNum>
,分別得到&mut MyNum
和MyNum
兩個操作數
而 Rust 的借用檢查不允許在一個函數調用中對同一個值同時使用 &mut
和 &
,從而編譯報錯。
// b[0] += b[1] on &mut [MyNum] _10 = Vec::<MyNum>::as_mut_slice(move _11) // _10: &mut [MyNum] _13 = &mut (*_10)[_14]; // two-phase _17 = (*_10)[_18]; // reservation 階段 _12 = <MyNum as AddAssign>::add_assign(move _13, move _17) // activated 階段 // b[0] += b[1] on Vec<MyNum> _13 = &mut _1; // _1: Vec<MyNum> _12 = <Vec<MyNum> as IndexMut<usize>>::index_mut(move _13, const 0_usize) // _13: &mut Vec<MyNum>, _12: &mut MyNum _11 = &mut (*_12); // reborrow _16 = &_1; _15 = <Vec<MyNum> as Index<usize>>::index(move _16, const 1_usize) // _16: &Vec<MyNum>, _15: &mut MyNum _14 = (*_15); _10 = <MyNum as AddAssign>::add_assign(move _11, move _14)
- 總結
+=
是可重載的復合賦值運算符,Self += Rhs
脫糖為<Self as AddAssign<Rhs>>::add_assign(&mut Self, Rhs)
,但 - 對兩側為 primitive types 的運算數,先計算
Rhs
,再計算Self
,然后調用編譯器實現的相加函數 - 若至少有一側運算數不為 primitive types,則先計算
Self
,再計算Rhs
,然后調用重載后的實現(即<Self as AddAssign<Rhs>>::add_assign
) - 大多數表達式是從左到右執行的。從右到左是特殊情況,比如
- 賦值表達式中,先計算
=
右側的值,再計算左側 - 復合賦值表達式中,兩側為 primitive types 的運算數時,先計算復合賦值運算符右側,再計算左側
- MIR 是 Rust 編譯過程的重要一環,(無論在代碼編譯成功還是失敗的情況下)也可以成為輔助你分析的 Rust 代碼的工具
一個例子
另一個例子?
拓展閱讀:運用這套流程分析
==
操作符的具體的例子?
原文鏈接:https://blog.csdn.net/m0_37952030/article/details/128805020
相關推薦
- 2022-07-22 Maven項目編譯運行后target/classes目錄下沒有xml和properties文件
- 2023-01-17 C#?wpf利用附加屬性實現界面上定義裝飾器_C#教程
- 2022-10-18 Qt?TCP實現簡單通信功能_C 語言
- 2021-12-03 Android消息機制Handler深入理解_Android
- 2022-10-16 python中列表添加元素的幾種方式(+、append()、extend())_python
- 2022-06-22 Python實現npy/mat文件的保存與讀取_python
- 2022-08-06 Python?pandas庫中isnull函數使用方法_python
- 2022-04-18 html2canvas 不支持圖片的object-fit樣式
- 最近更新
-
- window11 系統安裝 yarn
- 超詳細win安裝深度學習環境2025年最新版(
- Linux 中運行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎操作-- 運算符,流程控制 Flo
- 1. Int 和Integer 的區別,Jav
- spring @retryable不生效的一種
- Spring Security之認證信息的處理
- Spring Security之認證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權
- redisson分布式鎖中waittime的設
- maven:解決release錯誤:Artif
- restTemplate使用總結
- Spring Security之安全異常處理
- MybatisPlus優雅實現加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務發現-Nac
- Spring Security之基于HttpR
- Redis 底層數據結構-簡單動態字符串(SD
- arthas操作spring被代理目標對象命令
- Spring中的單例模式應用詳解
- 聊聊消息隊列,發送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠程分支