作者:Bei - Founding Principal Software Engineer @ Dozer
英文:Two things that Rust does better than C++ | Dozer | Start building real-time data apps in minutes
在 Dozer,尽管我们的许多团队成员都有扎实的C++背景,但我们把 Rust 作为我们的主要编程语言。这是因为 Rust 的语言构造结合了表达性、安全性和人体工程学,这些都是非常有吸引力的特点。
在本文中,我们将讨论两个 Rust 比 C++ 处理得更好语言特性,即其所有权模型和 trait 系统。这些特性与 C++ 的移动语义和虚函数相比具有优势,这也解释了 Rust 很受开发人员欢迎的原因。
Ownership vs Move Semantics
现象
考虑如下的Rust代码(playground):
struct Struct;
impl Drop for Struct {
fn drop(&mut self) {
println!("dropped");
}
}
fn main() {
let a: Struct = Struct;
let _b: Struct = a;
}
运行后输出为一行dropped,即drop函数只执行了一次。
行为最接近的C++代码如下(playground):
#include <iostream>
struct Struct {
Struct() = default;
Struct(const Struct &) = delete;
Struct(Struct &&) = default;
Struct &operator=(const Struct&) = delete;
~Struct() {
std::cout << "destructed" << std::endl;
}
};
int main() {
Struct a;
Struct b = std::move(a);
return 0;
}
运行后输出为两行destructed,即析构函数执行了两次。
分析
问题的根源在于,C++在语言层面上只提供了右值引用这样一种特殊的类型,移动语义是由用户按照约定实现的。在编译器看来,被移动后的对象仍然是完整的对象。这带来的绝不只是析构函数被多次执行的问题(尽管这个问题已经带来了额外的运行时开销),事实上,C++给类作者强加了两个负担:
- 析构函数必须正确地处理被移动后的对象。
- 在所有public接口内正确处理移动后的对象,或者把这个负担转移给类用户。
“1“ 是显然的,关于 “2”,由于 “在所有public接口内正确处理移动后的对象“ 通常会带来运行时开销,所以 ”不使用被移动后的对象“ 这一责任被强加在了几乎所有C++用户身上,而类作者通常只会提供一个接口用于查询对象是否已经被移动。
“2“ 的一个典型例子是std::unique_ptr,任何使用std::unique_ptr的用户都必须检查它是否为空。
C++的移动语义极大地降低了RAII的可用性。当用户拿到一个对象时,总需要考虑它管理的资源是否已经被移动。这加大了程序员的心智负担,同时也是bug的温床。
Trait object vs Virtual function
考虑如下的Rust代码(playground):
trait Trait {
fn f(&self);
}
struct Impl;
impl Trait for Impl {
fn f(&self) {
println!("f from Impl");
}
}
fn main() {
let a: Impl = Impl;
let b: &dyn Trait = &a;
b.f();
println!("Size of Impl is {}", std::mem::size_of::<Impl>());
}
运行后输出为f from Impl和Size of Impl is 0。Impl结构体的大小为0意味着是否使用运行时多态对结构体本身的内存布局没有影响。
行为最接近的C++代码如下(playground):
#include <iostream>
class Trait {
public:
virtual void f() const = 0;
};
class Impl: public Trait {
public:
void f() const override {
std::cout << "f from Impl" << std::endl;
}
};
int main() {
Impl a;
Trait &b = a;
b.f();
std::cout << "Size of Impl is " << sizeof(a) << std::endl;
return 0;
}
运行后输出为f from Impl和Size of Impl is 8。这是在64位系统上运行的结果,由于使用了运行时多态,每个Impl对象中都保存了一个大小为8字节的虚表指针。
相比Rust的trait object,C++的运行时多态并非零开销抽象。8个字节的额外存储开销在很多时候是不能接受的,而虚表指针将改变对象内存布局这一特点也大大限制了运行时多态的适用范围。
了解更多
- GitHub: https://github.com/getdozer/dozer
- Discord: https://discord.com/invite/3eWXBgJaEQ
- Twitter: https://twitter.com/GetDozer
- LinkedIn: https://www.linkedin.com/company/getdozer/