Copy 特性源于 Rust 的早期设计,作为可以通过简单的按位复制而不涉及资源管理问题的类型的标记。引入 Drop 是为了处理管理外部资源(如文件描述符或堆内存)类型的确定性资源清理。设计人员意识到隐式复制和唯一所有权之间的冲突,当按位复制会共享不可共享的资源句柄时,这种冲突变得明显。因此,编译器被设计为拒绝任何试图同时实现这两个特性的类型。
如果一个实现 Drop 的类型(例如,管理文件描述符)也为 Copy,将值分配给新变量将创建两个按位相同的副本。当两个副本都超出范围时,自定义的 Drop 实现会在相同的底层资源上执行两次。这会导致 双重释放 漏洞或 使用后释放,如果资源在第一次释放后失效但被第二次访问,内存安全就会受到损害。
Rust 编译器在特性系统中包括了一项一致性检查,明确禁止一个类型同时实现 Copy 和 Drop。这一约束迫使开发者对需要自定义销毁的类型使用 Clone(显式复制),允许实现正确地增加引用计数或执行深度复制。通过确保每个逻辑实体都有相应的唯一释放,类型系统保持零成本抽象而不牺牲安全保证。
考虑一个 DatabaseHandle 结构,包装指向外部 C 库中连接对象的原始指针。应用程序需要将句柄通过值传递给多个用于日志记录的闭包,然而每个句柄在释放时必须通过 FFI 调用关闭其唯一的连接。如果句柄为 Copy,隐式复制将创建多个句柄,声称对同一底层 C 资源的所有权,不可避免地导致双重关闭或在作用域退出时使用后释放。
一种方法是允许 Copy 并实现具有内部引用计数的 Drop,使用 Arc。这将为每个句柄增加同步开销,增加二进制大小和所有操作的运行时成本。它还会使 FFI 边界复杂化,其中原始指针必须从 Arc 中原子提取,如果释放逻辑本身回调到 Rust 代码,可能会引入潜在的死锁。
另一种方法是使用 Copy,但记录用户必须在值被释放之前手动调用 close 方法。这将内存安全的责任完全放在程序员身上,违反了 Rust 防止编译错误的核心原则。当开发者忘记调用关闭时,最终导致资源泄漏,或者在无意中复制句柄并尝试关闭两个副本时导致双重关闭。
选择的解决方案是移除 Copy 并手动实现 Clone 和 Drop。Clone 通过打开新的数据库连接执行深复制,确保每个实例拥有其独特的资源,防止底层 C 指针的别名。Drop 只关闭自己的连接,而编译器防止意外的按位复制,保持安全而不产生运行时开销。
类型系统现在在编译时防止意外复制,迫使开发人员显式调用 clone,使资源获取在源代码中可见。当句柄被传递给线程或闭包时,程序避免了双重释放错误,确定性销毁保证保持不变,无需原子操作或手动内存管理。
为什么我不能为包含 Vec 的结构派生 Copy?
一个 Vec 拥有堆分配的内存,并实现 Drop 以在向量超出范围时释放该内存。如果一个包含 Vec 的结构是 Copy,按位复制将创建两个结构,指向栈上的同一个堆缓冲区,但两者都会包含对堆的相同指针。当第一个结构被释放时,内存被释放;当第二个被释放时,它尝试再次释放同一内存,导致未定义行为。Rust 通过要求 Copy 类型的所有字段也必须是 Copy 来防止这种情况,递归确保不存在嵌套的 Drop 实现。
mem::forget 能否防止 Copy 和 Drop 的问题?
std::mem::forget 在不运行其析构函数的情况下消耗一个值,但它仅影响一个特定的拥有值,而不是它的所有副本。如果允许 Copy 和 Drop,忘记一个副本不会阻止其他按位副本在其超出范围时执行它们的 Drop 实现。那些剩余的释放仍然会尝试释放相同的底层资源,从而导致使用后释放或双重释放,无论被遗忘的实例如何。
我可以使用 ManuallyDrop 安全地实现 Copy 吗?
将字段包装在 ManuallyDrop 中可防止自动调用 Drop,这在技术上允许外部结构派生 Copy。然而,这将调用 ManuallyDrop::drop 的责任转移给用户,每创建一个副本都需要如此,这实际上制造了一个手动内存管理的场景。如果用户忘记释放即使是一个副本,资源将永久泄漏;Rust 禁止这种模式以拥有资源的类型,因为它破坏了确定性、自动清理的安全保证。