作者最近嘗試寫了一些Rust代碼,本文主要講述了對Rust的看法和Rust與C++的一些區別。 背景 S2在推進團隊代碼規範時,先後學習了盤古編程規範,CPP core guidelines,進而瞭解到clang-tidy,以及Google Chrome 在安全方面的探索。 C++是一個威力非常強大 ...
作者最近嘗試寫了一些Rust代碼,本文主要講述了對Rust的看法和Rust與C++的一些區別。
背景
C++代碼中的風險
-
Temporal safety: 簡單來說就是use after free -
Spatial safety: 簡單來說,就是out of bound訪問 -
Logic error -
DCHECK: 簡單來說,就是debug assert的條件在release版本中被觸發了
其他不多展開。
Rust初體驗
預設不可變
let x = 0;
x = 10; // error
let mut y = 0;
y = 10; //ok
fn foo(x: u32) {}
let x: i32 = 0;
foo(x); // error
簡化構造、複製與析構
class ABC
{
public:
virtual ~ABC();
ABC(const ABC&) = delete;
ABC(ABC&&) = delete;
ABC& operator=(const ABC&) = delete;
ABC& operator=(ABC&&) = delete;
};
明明是一件非常常規的東西,寫起來卻那麼的複雜。
fn main()
{
// String類似std::string,只支持顯式clone,不支持隱式copy
let s: String = "str".to_string();
foo(s); // s will move
// cannot use s anymore
let y = "str".to_string();
foo(y.clone());
// use y is okay here
}
fn foo(s: String) {}
// can only be passed by move
struct Abc1
{
elems: Vec<int>
}
// can use abc.clone() to explicit clone a new Abc
#[derive(Clone)]
struct Abc2
{
elems: Vec<int>
}
// implement custom destructor for Abc
impl Drop for Abc2 {
// ...
}
// foo(xyz) will copy, 不能再定義Drop/析構函數,因為copy和drop是互斥的
#[dervie(Clone, Copy)]
struct Xyz
{
elems: i32
}
顯式參數傳遞
let mut x = 10;
foo(x); // pass by move, x cannot be used after the call
foo(&x); // pass by immutable reference
foo(&mut x); // pass by mutable reference
-
errno -
std::exception -
std::error_code/std::error_condition -
std::expected
看,連標準庫自己都這樣。std::filesystem,所有介面都有至少兩個重載,一個拋異常,一直傳std::error_code。
enum MyError
{
NotFound,
DataCorrupt,
Forbidden,
Io(std::io::Error)
}
impl From<io::Error> for MyError {
fn from(e: io::Error) -> MyError {
MyError::Io(e)
}
}
pub type Result<T> = result::Result<T, Error>;
fn main() -> Result<()>
{
let x: i32 = foo()?;
let y: i32 = bar(x)?;
foo(); // result is not handled, compile error
// use x and y
}
fn foo() -> Result<i32>
{
if (rand() > 0) {
Ok(1)
} else {
Err(MyError::Forbidden)
}
}
錯誤處理一律通過Result<T, ErrorType>來完成,通過?,一鍵向上傳播錯誤(如同時支持自動從ErrorType1向ErrorType2轉換,前提是你實現了相關trait),沒有錯誤時,自動解包。當忘記處理處理Result時,編譯器會報錯。
內置格式化與lint
標準化的開發流程和包管理
cargo test
cargo bench
cargo規定了目錄風格
benches // benchmark代碼go here
src
tests // ut go here
Rust在安全性的改進
lifetime安全性
let s = vec![1,2,3]; // s owns the Vec
foo(s); // the ownership is passed to foo, s cannot be used anymore
let x = vec![1,2,3];
let a1 = &x[0];
let a2 = &x[0]; // a1/a2 are both immutable ref to x
x.resize(10, 0); // error: x is already borrowed by a1 and a2
println!("{a1} {a2}");
這種unique ownership + borrow check的機制,能夠有效的避免pointer/iterator invalidation bug以及aliasing所引發的性能問題。
let s: &String;
{
let x = String::new("abc");
s = &x;
}
println!("s is {}", s); // error, lifetime(s) > lifetime(x)
這個例子比較簡單,再看一些複雜的。
// not valid rust, for exposition only
struct ABC
{
x: &String,
}
fn foo(x: String)
{
let z = ABC { x: &x };
consume_string(x); // not compile, x is borrowed by z
drop(z); // call destructor explicitly
consume_string(x); // ok
// won't compile, bind a temp to z.x
let z = ABC { x: &String::new("abc") };
// use z
// Box::new == make_unique
// won't compile, the box object is destroyed soon
let z = ABC{ x: &*Box::new(String::new("abc") };
// use z
}
再看一個更加複雜的,涉及到多線程的。
void foo(ThreadPool* thread_pool)
{
Latch latch{2};
thread_pool->spawn([&latch] {
// ...
latch.wait(); // dangle pointer訪問
});
// forget latch.wait();
}
這是一個非常典型的lifetime錯誤,C++可能要到運行時才會發現問題,但是對於Rust,類似代碼的編譯是不通過的。因為latch是個棧變數,其lifetime非常短,而跨線程傳遞引用時,這個引用實際上會可能在任意時間被調用,其lifetime是整個進程生命周期,rust中為此lifetime起了一個專門的名字,叫'static。正如cpp core guidelines所說:CP.24: Think of a thread as a global container ,never save a pointer in a global container。
fn foo(thread_pool: &mut ThreadPool)
{
let latch = Arc::new(Latch::new(2));
let latch_copy = Arc::clone(&latch);
thread_pool.spawn(move || {
// the ownership of latch_copy is moved in
latch_copy.wait();
});
latch.wait();
}
再看一個具體一些例子,假設你在寫一個文件reader,每次返回一行。為了降低開銷,我們期望返回的這一行,直接引用parser內部所維護的buffer,從而避免copy。
FileLineReader reader(path);
std::string_view<char> line = reader.NextLine();
std::string_view<char> line2 = reader.NextLine();
// ops
std::cout << line;
let reader = FileReader::next(path);
let line = reader.next_line();
// won't compile, reader is borrowed to line, cannot mutate it now
let line2 = reader.next_line();
println!("{line}");
// &[u8] is std::span<byte>
fn foo() -> &[u8] {
let reader = FileReader::next(path);
let line = reader.next_line();
// won't compile, lifetime(line) > lifetime(reader)
return line;
}
總結來說,Rust定義了一套規則,按照此規則進行編碼,絕對不會有lifetime的問題。當Rust編譯器無法推導某個寫法的正確性時,它會強制你使用引用計數來解決問題。
邊界安全性
類型安全性
let i: i32;
if rand() < 10 {
i = 10;
}
println!("i is {}", i); // do not compile: i is not always initialized
Rust的多線程安全性
-
Send: 一個類型是Send,表明,此類型的對象的所有權,可以跨線程傳遞。當一個新類型的所有成員都是Send時,這個類型也是Send的。幾乎所有內置類型和標準庫類型都是Send的,Rc(類似local shared_ptr)除外,因為內部用的是普通int來計數。 -
Sync: 一個類型是Sync,表明,此類型允許多個線程共用(Rust中,共用一定意味著不可變引用,即通過其不可變引用進行併發訪問)。
Send/Sync是兩個標準庫的Trait,標準庫在定義它們時,為已有類型提供了對應實現或者禁止了對應實現。
-
lifetime機制要求:一個對象要跨線程傳遞時,必須使用Arc(Arc for atomic reference counted)來封裝(Rc不行,因為它被特別標註為了!Send,即不可跨線程傳遞) -
ownership+borrow機制要求:Rc/Arc包裝的對象,只允許解引用為不可變引用,而多線程訪問一個不可變對象,是天生保證安全的。 -
內部可變性用於解決共用寫問題:Rust預設情況下,共用一定意味著不可變,只有獨占,才允許變。如果同時需要共用和可變,需要使用額外的機制,Rust官方稱之為內部可變性,實際上叫共用可變性可能更容易理解,它是一種提供安全變更共用對象的機制。如果需要多線程去變更同一個共用對象,必須使用額外的同步原語(RefCell/Mutex/RwLock),來獲得內部/共用可變性,這些原語會保證只有一個寫者。RefCell是與Rc一起使用的,用於單線程環境下的共用訪問。RefCell被特別標註為了!Sync,意味著,如果它和Arc一起使用,Arc就不是Send了,從而Arc<RefCell<T>>無法跨線程。
看個例子:假設我實現了一個Counter對象,希望多個線程同時使用。為瞭解決所有權問題,需要使用Arc<Counter>,來傳遞此共用對象。但是,以下代碼是編譯不通過的。
struct Counter
{
counter: i32
}
fn main()
{
let counter = Arc::new(Counter{counter: 0});
let c = Arc::clone(&counter);
thread::spawn(move || {
c.counter += 1;
});
c.counter += 1;
}
因為,Arc會共用一個對象,為了保證borrow機制,訪問Arc內部對象時,都只能獲得不可變引用(borrow機制規定,要麼一個可變引用,要麼若幹個不可變引用)。Arc的這條規則防止了data race的出現。
fn main()
{
let counter = Arc::new(RefCell::new(Counter{counter: 0}));
let c = Arc::clone(&counter);
thread::spawn(move || {
c.get_mut().counter += 1;
});
c.get_mut().counter += 1;
}
為啥?因為RefCell不是Sync,即不允許多線程訪問。Arc只在內部類型為Sync時,才為Send。即,Arc<Cell<T>>不是Send,無法跨線程傳遞。
struct Counter
{
counter: i32
}
fn main()
{
let counter = Arc::new(Mutex::new(Counter{counter: 0}));
let c = Arc::clone(&counter);
thread::spawn(move || {
let mut x = c.lock().unwrap();
x.counter += 1;
});
}
Rust的性能
// for demo purpose
fn foo(tasks: Vec<Task>)
{
let latch = Arc::new(Latch::new(tasks.len() + 1));
for task in tasks {
let latch = Arc::clone(&latch);
thread_pool.submit(move || {
task.run();
latch.wait();
});
}
latch.wait();
}
這裡,latch必須用Arc(即shared_ptr)。
-
函數調用 -
指針別名
C++和Rust都可以通過inline來消除函數調用引起的開銷。但是C++面對指針別名時,基本上是無能為力的。C++對於指針別名的優化依賴strict aliasing rule,不過這個rule出了名的噁心,Linus也罵過幾次。Linux代碼中,會使用-fno-strict-aliasing來禁止這條規則的。
int foo(const int* x, int* y)
{
*y = *x + 1;
return *x;
}
rust版本
fn foo(x: &i32, y: &mut i32) -> i32
{
*y = *x + 1;
*x
}
對應的彙編如下:
# c++
__Z3fooPKiPi:
ldr w8, [x0]
add w8, w8, #1
str w8, [x1]
ldr w0, [x0]
ret
# rust
__ZN2rs3foo17h5a23c46033085ca0E:
ldr w0, [x0]
add w8, w0, #1
str w8, [x1]
ret
看出差別沒?
感想
-
C++的安全性演進是趨勢,但是未來很不明朗:C++在全世界有數十億行的存量代碼,期望C++在維持相容性的基礎上,提升記憶體安全性,這是一個幾乎不可能的任務。clang-format與clang-tidy,都提供了line-filter,來對特定的行進行檢查,避免出現改動一個老文件,需要把整個文件都重新format或者修改掉所有lint失敗的情況。大概也是基於此,Bjarne才會一直嘗試通過靜態分析+局部檢查來提升C++的記憶體安全性。 -
Rust有利於大團隊協作:Rust代碼只要能編譯通過,並且沒有使用unsafe特性,那麼是能夠保證絕對不會有記憶體安全性或者線程安全性問題的。這大大降低了編寫複雜代碼的心智負擔。然而,Rust的安全性是以犧牲語言表達力而獲得的,這對於團隊合作與代碼可讀性,可能是一件好事。至於其他,在沒有足夠的Rust實踐經驗前,我也無法作出更進一步的判斷。
Relax and enjoy coding!
參考資料:
1、Cpp core guidelines: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines
2、Chrome在安全方面的探索:https://docs.google.com/document/d/e/2PACX-1vRZr-HJcYmf2Y76DhewaiJOhRNpjGHCxliAQTBhFxzv1QTae9o8mhBmDl32CRIuaWZLt5kVeH9e9jXv/pub
3、NSA對C++的批評:https://www.theregister.com/2022/11/11/nsa_urges_orgs_to_use/
4、C++ Lifetime Profile: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#SS-lifetime
5、C++ Safety Profile: https://open-std.org/JTC1/SC22/WG21/docs/papers/2023/p2816r0.pdf?file=p2816r0.pdf
6、Herb CppCon2022 Slide: https://github.com/CppCon/CppCon2022/blob/main/Presentations/CppCon-2022-Sutter.pdf
7、Rust所有權模型理解:https://limpet.net/mbrubeck/2019/02/07/rust-a-unique-perspective.html
本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/A-CPP-programmers-initial-experience-with-Rust.html