PhantomData 3/4: Lifetimes for External Resources
The invariants of external resources often match what we can do with lifetime rules.
// use std::marker::PhantomData; /// Direct FFI to a database library in C. /// We got this API as is, we have no influence over it. mod ffi { pub type DatabaseHandle = u8; // maximum 255 databases open at the same time fn database_open(name: *const std::os::raw::c_char) -> DatabaseHandle { unimplemented!() } // ... etc. } struct DatabaseConnection(ffi::DatabaseHandle); struct Transaction<'a>(&'a mut DatabaseConnection); impl DatabaseConnection { fn new_transaction(&mut self) -> Transaction<'_> { Transaction(self) } } fn main() {}
-
Remember the transaction API from the Aliasing XOR Mutability example.
We held onto a mutable reference to the database connection within the transaction type to lock out the database while a transaction is active.
In this example, we want to implement a
TransactionAPI on top of an external, non-Rust API.We start by defining a
Transactiontype that holds onto&mut DatabaseConnection. -
Ask: What are the limits of this implementation? Assume the
u8is accurate implementation-wise and enough information for us to use the external API.Expect:
- Indirection takes up 7 bytes more than we need to on a 64-bit platform, as well as costing a pointer dereference at runtime.
-
Problem: We want the transaction to borrow the database connection that created it, but we don’t want the
Transactionobject to store a real reference. -
Ask: What happens when we remove the mutable reference in
Transactionwhile keeping the lifetime parameter?Expect: Unused lifetime parameter!
-
Like with the type tagging from the previous slides, we can bring in
PhantomDatato capture this unused lifetime parameter for us.The difference is that we will need to use the lifetime alongside another type, but that other type does not matter too much.
-
Demonstrate: change
Transactionto the following:#![allow(unused)] fn main() { pub struct Transaction<'a> { connection: DatabaseConnection, _phantom: PhantomData<&mut 'a ()>, } }Update the
DatabaseConnection::new_transaction()method:#![allow(unused)] fn main() { fn new_transaction<'a>(&'a mut self) -> Transaction<'a> { Transaction { connection: DatabaseConnection(self.0), _phantom: PhantomData } } }This gives an owned database connection that is tied to the
DatabaseConnectionthat created it, but with less runtime memory footprint that the store-a-reference version did.Because
PhantomDatais a zero-sized type (like()orstruct MyZeroSizedType;), the size ofTransactionis now the same asu8.The implementation that held onto a reference instead was as large as a
usize.
More to Explore
-
This way of encoding relationships between types and values is very powerful when combined with unsafe, as the ways one can manipulate lifetimes becomes almost arbitrary. This is also dangerous, but when combined with tools like external, mechanically-verified proofs we can safely encode cyclic/self-referential types while encoding lifetime & safety expectations in the relevant data types.
-
The GhostCell (2021) paper and its relevant implementation show this kind of work off. While the borrow checker is restrictive, there are still ways to use escape hatches and then show that the ways you used those escape hatches are consistent and safe.