如何在 C++ 中启用 Rust 所有权范式
How to enable Rust Ownership paradigm in C++
系统编程语言 Rust 使用 ownership 范式来确保在必须释放资源时在编译时以零成本在运行时运行。
在 C++ 中,我们通常使用智能指针来实现相同的目标,即隐藏管理资源分配的复杂性。不过有几点不同:
- 在 Rust 中总是只有一个所有者,而 C++
shared_ptr
很容易泄露所有权。
- 在 Rust 中,我们可以借用我们不拥有的引用,而 C++
unique_ptr
无法通过 weak_ptr
和 lock()
. 以安全的方式共享
shared_ptr
的引用计数开销很大。
我的问题是:我们如何在以下限制条件下模拟 C++ 中的所有权范例:
- 任何时候只有一位所有者
- 可以借用一个指针并临时使用它而不用担心资源超出范围(
observer_ptr
对此毫无用处)
- 尽可能多的编译时检查。
编辑: 根据目前的评论,我们可以得出结论:
编译器对此没有编译时支持(我希望有一些我不知道的 decltype
/模板魔法)。可能在其他地方使用静态分析(污点?)
没有引用计数就无法得到这个。
没有标准实现来区分 shared_ptr
具有拥有或借用语义
可以通过围绕 shared_ptr
和 weak_ptr
:
创建包装器类型来推出自己的
owned_ptr
:不可复制,移动语义,封装shared_ptr,访问borrowed_ptr
borrowed_ptr
:可复制,封装weak_ptr
,锁方法
locked_ptr
:不可复制,移动语义,封装 shared_ptr
免于锁定 weak_ptr
你根本无法通过编译时检查来做到这一点。 C++ 类型系统缺乏任何方法来推断对象何时超出范围、被移动或被销毁——更不用说将其转化为类型约束了。
您可以做的是使用 unique_ptr
的变体,它会记录在 运行 时间有多少“借用”处于活动状态。它不是 get()
return 原始指针,而是 return 一个智能指针,它在构造时递增此计数器并在销毁时递减它。如果 unique_ptr
在计数非零时被销毁,至少你知道有人在某处做错了什么。
但是,这不是万无一失的解决方案。无论您多么努力地阻止它,总会有办法获得指向底层对象的原始指针,然后游戏就结束了,因为该原始指针很容易比智能指针和 unique_ptr
更长寿。有时甚至需要获取原始指针,以便与需要原始指针的 API 进行交互。
此外,所有权与指针无关。 Box
/unique_ptr
允许您堆分配一个对象,但与将同一对象放在堆栈上(或另一个对象内部,或其他任何地方)相比,它不会改变所有权、生命周期等).为了在 C++ 中从这样的系统中获得相同的里程数,您必须为所有对象制作这样的“借用计数”包装器,而不仅仅是 unique_ptr
s。这很不切实际。
那么让我们重新审视一下编译时选项。 C++ 编译器帮不了我们,但也许 lints 可以?理论上,如果您实现类型系统的整个生命周期部分并为您使用的所有 API 添加注释(除了您自己的代码),那可能会起作用。
但它需要对整个程序中使用的所有函数进行注释。包括第三方库的私有助手功能。以及那些没有可用源代码的。对于那些实现太复杂以至于 linter 无法理解的人(根据 Rust 的经验,有时某些东西是安全的原因太微妙而无法在生命周期的静态模型中表达,并且必须稍微不同地编写以帮助编译器)。对于最后两个,linter 无法验证注解是否确实正确,因此您又回到了对程序员的信任。此外,一些 API(或者更确切地说,它们何时安全的条件)在 Rust 使用的生命周期系统中不能很好地表达。
换句话说,一个完整且实际有用的 linter 将是大量的原始研究,并具有相关的失败风险。
也许有一个中间立场,即以 20% 的成本获得 80% 的收益,但由于您想要硬性保证(老实说,我也喜欢),运气不好。 C++ 中现有的“良好实践”已经大大降低了风险,通过本质上思考(和记录)Rust 程序员的方式,只是没有编译器的帮助。考虑到 C++ 及其生态系统的状态,我不确定是否有很多改进。
tl;dr 只需使用 Rust ;-)
以下是人们尝试在 C++ 中模拟部分 Rust 所有权范式的一些示例,但收效甚微:
- Lifetime safety: Preventing common dangling. The most thorough and rigorous approach, involving several additions to the language to support the necessary annotations. If the effort is still alive (last commit was in 2019), getting this analysis added to a mainstream compiler is probably the most likely route to "borrow checked" C++. Discussed on IRLO.
- Borrowing Trouble: The Difficulties Of A C++ Borrow-Checker
- Is it possible to achieve Rust's ownership model with a generic C++ wrapper?
- C++Now 2017: Jonathan Müller “Emulating Rust's borrow checker in C++" (video) and associated code, about which the author says, "You're not actually supposed to use that, if you need such a feature, you should use Rust."
- Emulating the Rust borrow checker with C++ move-only types and part II(实际上更像是模仿
RefCell
而不是借用检查器本身)
我相信你可以通过强制执行一些严格的编码约定来获得 一些 Rust 的好处(毕竟这是你必须做的,因为没有办法使用 "template magic" 告诉编译器 不 编译 不 使用所述 "magic" 的代码。在我的脑海中,以下内容可能会让您...好吧...有点,但仅适用于单线程应用程序:
- 切勿直接使用
new
;相反,使用 make_unique
。这有助于确保堆分配的对象以类似 Rust 的方式"owned"。
- "Borrowing" 应始终通过函数调用的引用参数来表示。采用引用的函数应该 永远不会 创建任何类型的指向所引用对象的指针。 (在某些情况下可能需要使用原始指针作为参数而不是引用,但应适用相同的规则。)
- 请注意,这适用于堆栈上的对象或堆上的;函数不应该关心。
- 转让 所有权当然是通过 R 值引用 (
&&
) and/or R 值引用 unique_ptr
s.
不幸的是,我想不出任何方法来执行 Rust 的规则,即可变引用只能存在于 系统中的任何地方 当 没有 其他现存参考资料。
此外,对于任何类型的并行性,您都需要开始处理生命周期,而我能想到的允许跨线程生命周期管理(或使用共享内存的跨进程生命周期管理)的唯一方法是实现您自己的 "ptr-with-lifetime" 包装器。这可以使用 shared_ptr
来实现,因为在这里,引用计数实际上很重要;不过,这仍然是一些不必要的开销,因为引用计数块实际上有 两个 引用计数器(一个用于所有指向对象的 shared_ptr
s,另一个用于所有weak_ptr
s)。这也有点... 奇怪,因为在shared_ptr
场景中,每个人有shared_ptr
"equal" 所有权,而在 "borrowing with lifetime" 场景中,实际上只有一个 thread/process 应该 "own" 内存。
您可以使用 unique_ptr
的增强版本(强制唯一所有者)和 observer_ptr
的增强版本(为悬挂指针获得一个很好的运行时异常,即如果通过 unique_ptr
维护的原始对象超出范围)。 Trilinos package implements this enhanced observer_ptr
, they call it Ptr
. I have implemented the enhanced version of unique_ptr
here (I call it UniquePtr
): https://github.com/certik/trilinos/pull/1
最后,如果您希望对象被堆栈分配,但仍然能够传递安全引用,您需要使用 Viewable
class,请在此处查看我的初始实现:https://github.com/certik/trilinos/pull/2
这应该允许您像使用 Rust 一样使用 C++ 来处理指针,除了在 Rust 中会出现编译时错误,而在 C++ 中会出现运行时异常。另外,应该注意的是,您只会在调试模式下获得运行时异常。在 Release 模式下,classes 不做这些检查,所以它们和 Rust 一样快(基本上和原始指针一样快),但是它们可能会出现段错误。所以必须确保整个测试套件在调试模式下运行。
我认为可以通过引入跟踪所有权和借用的自定义包装器 类 来增加一定程度的 compile-time 自省和自定义清理。
下面的代码是一个假设的草图,而不是需要更多工具的生产解决方案,例如#def 在不消毒时取消检查。它使用一个非常简单的生命周期检查器来 'count' 借用整数中的错误,在这种情况下是在编译期间。 static_assert
s 是不可能的,因为整数不是 constexpr,但值在那里并且可以在 运行time 之前查询。我相信这回答了你的 3 个约束,不管它们是否是堆分配,所以我使用一个简单的 int 类型来演示这个想法,而不是一个智能指针。
尝试在下面的 main() 中取消注释用例(运行 在编译器资源管理器中使用 -O3 查看样板优化),您会看到警告计数器发生变化。
// Hypothetical Rust-like owner / borrow wrappers in C++
// This wraps types with data which is compiled away in release
// It is not possible to static_assert, so this uses static ints to count errors.
#include <utility>
// Statics to track errors. Ideally these would be static_asserts
// but they depen on Owner::has_been_moved which changes during compilation.
static int owner_already_moved = 0;
static int owner_use_after_move = 0;
static int owner_already_borrowed = 0;
// This method exists to ensure static errors are reported in compiler explorer
int get_fault_count() {
return owner_already_moved + owner_use_after_move + owner_already_borrowed;
}
// Storage for ownership of a type T.
// Equivalent to mut usage in Rust
// Disallows move by value, instead ownership must be explicitly moved.
template <typename T>
struct Owner {
Owner(T v) : value(v) {}
Owner(Owner<T>& ov) = delete;
Owner(Owner<T>&& ov) {
if (ov.has_been_moved) {
owner_already_moved++;
}
value = std::move(ov.value);
ov.has_been_moved = true;
}
T& operator*() {
if (has_been_moved) {
owner_use_after_move++;
}
return value;
}
T value;
bool has_been_moved{false};
};
// Safely borrow a value of type T
// Implicit constuction from Owner of same type to check borrow is safe
template <typename T>
struct Borrower {
Borrower(Owner<T>& v) : value(v.value) {
if (v.has_been_moved) {
owner_already_borrowed++;
}
}
const T& operator*() const {
return value;
}
T value;
};
// Example of function borrowing a value, can only read const ref
static void use(Borrower<int> v) {
(void)*v;
}
// Example of function taking ownership of value, can mutate via owner ref
static void use_mut(Owner<int> v) {
*v = 5;
}
int main() {
// Rather than just 'int', Owner<int> tracks the lifetime of the value
Owner<int> x{3};
// Borrowing value before mutating causes no problems
use(x);
// Mutating value passes ownership, has_been_moved set on original x
use_mut(std::move(x));
// Uncomment for owner_already_borrowed = 1
//use(x);
// Uncomment for owner_already_moved = 1
//use_mut(std::move(x));
// Uncomment for another owner_already_borrowed++
//Borrower<int> y = x;
// Uncomment for owner_use_after_move = 1;
//return *x;
}
使用静态计数器显然不可取,但是使用static_assert也是不行的,因为owner_already_moved就是non-const。这个想法是这些静态信息给出出现错误的提示,并且在最终的生产代码中它们可以被#defed 掉。
系统编程语言 Rust 使用 ownership 范式来确保在必须释放资源时在编译时以零成本在运行时运行。
在 C++ 中,我们通常使用智能指针来实现相同的目标,即隐藏管理资源分配的复杂性。不过有几点不同:
- 在 Rust 中总是只有一个所有者,而 C++
shared_ptr
很容易泄露所有权。 - 在 Rust 中,我们可以借用我们不拥有的引用,而 C++
unique_ptr
无法通过weak_ptr
和lock()
. 以安全的方式共享
shared_ptr
的引用计数开销很大。
我的问题是:我们如何在以下限制条件下模拟 C++ 中的所有权范例:
- 任何时候只有一位所有者
- 可以借用一个指针并临时使用它而不用担心资源超出范围(
observer_ptr
对此毫无用处) - 尽可能多的编译时检查。
编辑: 根据目前的评论,我们可以得出结论:
编译器对此没有编译时支持(我希望有一些我不知道的
decltype
/模板魔法)。可能在其他地方使用静态分析(污点?)没有引用计数就无法得到这个。
没有标准实现来区分
shared_ptr
具有拥有或借用语义可以通过围绕
创建包装器类型来推出自己的shared_ptr
和weak_ptr
:owned_ptr
:不可复制,移动语义,封装shared_ptr,访问borrowed_ptr
borrowed_ptr
:可复制,封装weak_ptr
,锁方法locked_ptr
:不可复制,移动语义,封装shared_ptr
免于锁定weak_ptr
你根本无法通过编译时检查来做到这一点。 C++ 类型系统缺乏任何方法来推断对象何时超出范围、被移动或被销毁——更不用说将其转化为类型约束了。
您可以做的是使用 unique_ptr
的变体,它会记录在 运行 时间有多少“借用”处于活动状态。它不是 get()
return 原始指针,而是 return 一个智能指针,它在构造时递增此计数器并在销毁时递减它。如果 unique_ptr
在计数非零时被销毁,至少你知道有人在某处做错了什么。
但是,这不是万无一失的解决方案。无论您多么努力地阻止它,总会有办法获得指向底层对象的原始指针,然后游戏就结束了,因为该原始指针很容易比智能指针和 unique_ptr
更长寿。有时甚至需要获取原始指针,以便与需要原始指针的 API 进行交互。
此外,所有权与指针无关。 Box
/unique_ptr
允许您堆分配一个对象,但与将同一对象放在堆栈上(或另一个对象内部,或其他任何地方)相比,它不会改变所有权、生命周期等).为了在 C++ 中从这样的系统中获得相同的里程数,您必须为所有对象制作这样的“借用计数”包装器,而不仅仅是 unique_ptr
s。这很不切实际。
那么让我们重新审视一下编译时选项。 C++ 编译器帮不了我们,但也许 lints 可以?理论上,如果您实现类型系统的整个生命周期部分并为您使用的所有 API 添加注释(除了您自己的代码),那可能会起作用。
但它需要对整个程序中使用的所有函数进行注释。包括第三方库的私有助手功能。以及那些没有可用源代码的。对于那些实现太复杂以至于 linter 无法理解的人(根据 Rust 的经验,有时某些东西是安全的原因太微妙而无法在生命周期的静态模型中表达,并且必须稍微不同地编写以帮助编译器)。对于最后两个,linter 无法验证注解是否确实正确,因此您又回到了对程序员的信任。此外,一些 API(或者更确切地说,它们何时安全的条件)在 Rust 使用的生命周期系统中不能很好地表达。
换句话说,一个完整且实际有用的 linter 将是大量的原始研究,并具有相关的失败风险。
也许有一个中间立场,即以 20% 的成本获得 80% 的收益,但由于您想要硬性保证(老实说,我也喜欢),运气不好。 C++ 中现有的“良好实践”已经大大降低了风险,通过本质上思考(和记录)Rust 程序员的方式,只是没有编译器的帮助。考虑到 C++ 及其生态系统的状态,我不确定是否有很多改进。
tl;dr 只需使用 Rust ;-)
以下是人们尝试在 C++ 中模拟部分 Rust 所有权范式的一些示例,但收效甚微:
- Lifetime safety: Preventing common dangling. The most thorough and rigorous approach, involving several additions to the language to support the necessary annotations. If the effort is still alive (last commit was in 2019), getting this analysis added to a mainstream compiler is probably the most likely route to "borrow checked" C++. Discussed on IRLO.
- Borrowing Trouble: The Difficulties Of A C++ Borrow-Checker
- Is it possible to achieve Rust's ownership model with a generic C++ wrapper?
- C++Now 2017: Jonathan Müller “Emulating Rust's borrow checker in C++" (video) and associated code, about which the author says, "You're not actually supposed to use that, if you need such a feature, you should use Rust."
- Emulating the Rust borrow checker with C++ move-only types and part II(实际上更像是模仿
RefCell
而不是借用检查器本身)
我相信你可以通过强制执行一些严格的编码约定来获得 一些 Rust 的好处(毕竟这是你必须做的,因为没有办法使用 "template magic" 告诉编译器 不 编译 不 使用所述 "magic" 的代码。在我的脑海中,以下内容可能会让您...好吧...有点,但仅适用于单线程应用程序:
- 切勿直接使用
new
;相反,使用make_unique
。这有助于确保堆分配的对象以类似 Rust 的方式"owned"。 - "Borrowing" 应始终通过函数调用的引用参数来表示。采用引用的函数应该 永远不会 创建任何类型的指向所引用对象的指针。 (在某些情况下可能需要使用原始指针作为参数而不是引用,但应适用相同的规则。)
- 请注意,这适用于堆栈上的对象或堆上的;函数不应该关心。
- 转让 所有权当然是通过 R 值引用 (
&&
) and/or R 值引用unique_ptr
s.
不幸的是,我想不出任何方法来执行 Rust 的规则,即可变引用只能存在于 系统中的任何地方 当 没有 其他现存参考资料。
此外,对于任何类型的并行性,您都需要开始处理生命周期,而我能想到的允许跨线程生命周期管理(或使用共享内存的跨进程生命周期管理)的唯一方法是实现您自己的 "ptr-with-lifetime" 包装器。这可以使用 shared_ptr
来实现,因为在这里,引用计数实际上很重要;不过,这仍然是一些不必要的开销,因为引用计数块实际上有 两个 引用计数器(一个用于所有指向对象的 shared_ptr
s,另一个用于所有weak_ptr
s)。这也有点... 奇怪,因为在shared_ptr
场景中,每个人有shared_ptr
"equal" 所有权,而在 "borrowing with lifetime" 场景中,实际上只有一个 thread/process 应该 "own" 内存。
您可以使用 unique_ptr
的增强版本(强制唯一所有者)和 observer_ptr
的增强版本(为悬挂指针获得一个很好的运行时异常,即如果通过 unique_ptr
维护的原始对象超出范围)。 Trilinos package implements this enhanced observer_ptr
, they call it Ptr
. I have implemented the enhanced version of unique_ptr
here (I call it UniquePtr
): https://github.com/certik/trilinos/pull/1
最后,如果您希望对象被堆栈分配,但仍然能够传递安全引用,您需要使用 Viewable
class,请在此处查看我的初始实现:https://github.com/certik/trilinos/pull/2
这应该允许您像使用 Rust 一样使用 C++ 来处理指针,除了在 Rust 中会出现编译时错误,而在 C++ 中会出现运行时异常。另外,应该注意的是,您只会在调试模式下获得运行时异常。在 Release 模式下,classes 不做这些检查,所以它们和 Rust 一样快(基本上和原始指针一样快),但是它们可能会出现段错误。所以必须确保整个测试套件在调试模式下运行。
我认为可以通过引入跟踪所有权和借用的自定义包装器 类 来增加一定程度的 compile-time 自省和自定义清理。
下面的代码是一个假设的草图,而不是需要更多工具的生产解决方案,例如#def 在不消毒时取消检查。它使用一个非常简单的生命周期检查器来 'count' 借用整数中的错误,在这种情况下是在编译期间。 static_assert
s 是不可能的,因为整数不是 constexpr,但值在那里并且可以在 运行time 之前查询。我相信这回答了你的 3 个约束,不管它们是否是堆分配,所以我使用一个简单的 int 类型来演示这个想法,而不是一个智能指针。
尝试在下面的 main() 中取消注释用例(运行 在编译器资源管理器中使用 -O3 查看样板优化),您会看到警告计数器发生变化。
// Hypothetical Rust-like owner / borrow wrappers in C++
// This wraps types with data which is compiled away in release
// It is not possible to static_assert, so this uses static ints to count errors.
#include <utility>
// Statics to track errors. Ideally these would be static_asserts
// but they depen on Owner::has_been_moved which changes during compilation.
static int owner_already_moved = 0;
static int owner_use_after_move = 0;
static int owner_already_borrowed = 0;
// This method exists to ensure static errors are reported in compiler explorer
int get_fault_count() {
return owner_already_moved + owner_use_after_move + owner_already_borrowed;
}
// Storage for ownership of a type T.
// Equivalent to mut usage in Rust
// Disallows move by value, instead ownership must be explicitly moved.
template <typename T>
struct Owner {
Owner(T v) : value(v) {}
Owner(Owner<T>& ov) = delete;
Owner(Owner<T>&& ov) {
if (ov.has_been_moved) {
owner_already_moved++;
}
value = std::move(ov.value);
ov.has_been_moved = true;
}
T& operator*() {
if (has_been_moved) {
owner_use_after_move++;
}
return value;
}
T value;
bool has_been_moved{false};
};
// Safely borrow a value of type T
// Implicit constuction from Owner of same type to check borrow is safe
template <typename T>
struct Borrower {
Borrower(Owner<T>& v) : value(v.value) {
if (v.has_been_moved) {
owner_already_borrowed++;
}
}
const T& operator*() const {
return value;
}
T value;
};
// Example of function borrowing a value, can only read const ref
static void use(Borrower<int> v) {
(void)*v;
}
// Example of function taking ownership of value, can mutate via owner ref
static void use_mut(Owner<int> v) {
*v = 5;
}
int main() {
// Rather than just 'int', Owner<int> tracks the lifetime of the value
Owner<int> x{3};
// Borrowing value before mutating causes no problems
use(x);
// Mutating value passes ownership, has_been_moved set on original x
use_mut(std::move(x));
// Uncomment for owner_already_borrowed = 1
//use(x);
// Uncomment for owner_already_moved = 1
//use_mut(std::move(x));
// Uncomment for another owner_already_borrowed++
//Borrower<int> y = x;
// Uncomment for owner_use_after_move = 1;
//return *x;
}
使用静态计数器显然不可取,但是使用static_assert也是不行的,因为owner_already_moved就是non-const。这个想法是这些静态信息给出出现错误的提示,并且在最终的生产代码中它们可以被#defed 掉。