Full title: [Advanced Rust] 2.7. API Design Principles of Flexibility Pt.3 - Borrowed vs Owned, Cow Type, and Fallible and Blocking Destructors with Solutions
2.7.1. Borrowed vs. Owned
For almost every function, trait, and type in Rust, we need to decide:
- Should it own the data?
- Or should it hold a reference to the data?
If your code needs ownership of the data, then it must store owned data. When your code owns the data, you must require the caller to provide owned data rather than a reference or a clone. That lets the caller control allocation and clearly see the cost of using the interface.
If the code does not need to own the data, then it should operate on references to the data. But there are exceptions: for “small types” such as i32, bool, and f64, the cost of storing and copying them directly is basically the same as storing them by reference.
Most of these small types implement Copy, but not every Copy type can be called a “small type.” For example, [u8; 114514] implements Copy, but because it has so many elements, storing and copying it is too expensive, so passing by reference is recommended.
Cow Type
Sometimes we cannot tell whether code owns the data, because it depends on runtime conditions. The Cow type is a perfect fit for this situation.
Cow allows you to hold either a reference or an owned value when needed. If an owned value is required while only a reference is available, Cow uses the ToOwned trait to create an owned value behind the scenes, usually by cloning. In general, we use Cow in return types to express functions that may sometimes allocate memory.
In other words:
- If the data does not need to be modified,
Cowcan borrow the existing data and avoid extra allocation. - If the data needs to be modified,
Cowwill clone the data to gain ownership and then modify it.
Example:
use std::borrow::Cow;
fn process_data(data: Cow<str>) {
if data.contains("invalid") {
// If it contains "invalid", it needs to be modified, and modification requires ownership first
let owned_data = data.into_owned();
// ...some modification operations
println!("{}", owned_data); // final output
} else {
// No modification here, so we only need to read it
println!("Data: {}", data);
}
}
fn main() {
let input1 = "Hello, world!";
process_data(Cow::Borrowed(input1));
let input2 = "This is invalid data".to_string();
process_data(Cow::Owned(input2));
}
- I wrote the logic of
process_datain the comments - In
main,input1does not contain"invalid"and requires no modification, so it only needs to be read. Therefore, there is no need to pass an owned value; passing a reference (Cow::Borrowed(input1)) is enough -
input2contains"invalid"and needs to be modified, so an owned value (Cow::Owned(input2)) should be passed
When Should You Consider Taking Ownership of Data?
Sometimes reference lifetimes make an interface especially complicated and hard to use. If users encounter compilation problems while using it, that is a sign that we need to own the data, even if it is not strictly necessary.
If you decide to do that, the first thing to consider is converting easy-to-clone or performance-insensitive data into owned values, instead of mechanically heap-allocating large chunks of data content. That can avoid performance problems and improve usability.
2.7.2. Fallible and Blocking Destructors
Destructors, that is, the Drop trait, are special methods that are called automatically when an object reaches the end of its lifetime, and are used to release resources.
Destructors are generally not allowed to fail and are expected to be non-blocking, but there are exceptions:
- Releasing resources may require closing a network connection or writing to a log file, and these operations may fail
- The
dropmethod may need to perform blocking work, such as waiting for a thread to finish or waiting for an async task to complete
Problems with I/O Operations and Destructors
In I/O-related types such as files and network connections, resource management is very important, and the Drop mechanism (destructors) can ensure that cleanup operations are performed correctly when the object is dropped, avoiding resource leaks.
More specifically:
-
File operations: when a file object is dropped,
Dropneeds to ensure that all data has been written to disk to prevent data loss -
Network connections: when a
TcpStreamorUdpSocketis dropped,Dropneeds to close the connection properly to prevent resource leaks -
Database connections: when a database connection object goes out of scope,
Dropneeds to disconnect and free server-side resources
The problem is that in Rust's Drop mechanism, if an error occurs while performing cleanup, there is no direct way to return a Result for the caller to handle. The only thing you can do is trigger panic! and crash the program.
Problems with Async Code and Destructors
Async code has a similar problem. In Rust's async programming (async/await), we often want to perform cleanup operations in Drop, such as:
- Closing a database connection
- Flushing and closing a file
- Closing a WebSocket or TCP connection
- Releasing a lock or resource
However, async code may be running while other tasks are still pending, for example:
- A network I/O operation has not finished
- Other async tasks are still waiting for a signal
- The current task needs
await, butDropcannotawait
The problem is that Rust's Drop trait cannot await, because drop() is not async:
trait Drop {
fn drop(&mut self);
}
-
drop()cannotawait, which means it cannot perform async cleanup tasks such as closing a database connection asynchronously - But async cleanup usually needs
await, for example:
async fn close_connection() {
// Simulate closing a database connection
println!("Closing database connection...");
}
This code cannot be called directly from Drop, because Drop cannot await.
A common approach is to start another async executor inside drop() to run the cleanup code, for example:
impl Drop for MyAsyncResource {
fn drop(&mut self) {
tokio::spawn(async {
self.close().await;
});
}
}
- This allows you to run async tasks inside
drop() - But there is a problem: if
drop()happens whenmain()is ending or after other async tasks finish, the task spawned insidedrop()may not finish before the program exits
For These Two Problems
For these two problems, there is no perfect solution. The best we can do is use Drop to clean up as much as possible. If cleanup produces an error, at least we tried, and we can only ignore the error and continue.
If an executor is still available, we can try to create a Future to perform cleanup, but if the Future will never be allowed to run, there is nothing we can do.
A Small Extension: About Future
In Rust's async model, a Future represents a value that will be produced by an asynchronous computation:
async fn cleanup() {
println!("Cleaning up...");
}
- This
cleanup()function returns aFuture; it does not run immediately and instead must be polled by an executor
struct MyResource;
impl Drop for MyResource {
fn drop(&mut self) {
let fut = async {
println!("Cleaning up...");
};
// A `Future` is created here, but nobody executes it!
}
}
- A
Futureis created insidedrop(), but it will not run by itself; an executor must drive it - If no executor is available, the
Futurewill never run, and the cleanup task cannot complete
The Solution — Explicit Destructors
Now that we have covered Future, let us return to solving these two problems.
If users do not want to leave behind “dangling threads,” we can provide an explicit destructor. Such a destructor is usually a method that takes ownership of self and exposes any errors (using Result<T, E>) or asynchrony (using async fn), both of which are related to destruction.
“Dangling threads” refers to the following:
- Resources such as threads, database connections, or file handles are not cleaned up properly, so they are still occupied when the process exits
- For example, some background tasks may not terminate normally and may continue running, leak resources, or prevent the process from exiting
“Explicit destructor” means:
- Because Rust's
Dropcannot returnResult<T, E>and also cannot beasync(sincedrop()cannotawait), it cannot handle async cleanup or errors- Therefore, we can provide an explicit
close()orshutdown()method that users call manually to ensure resources are released correctly and to supportResultorasyncerror handling
Example:
use std::os::fd::{FromRawFd, IntoRawFd};
use std::fs::{File as StdFile, OpenOptions, metadata};
use std::io::Error;
/// A type that represents a file handle
struct File {
/// File name
name: String,
/// File descriptor
fd: i32,
}
impl File {
/// A constructor that opens a file and returns a `File` instance
fn open(name: &str) -> Result<File, Error> {
// Open the file with read and write permissions
let file: StdFile = OpenOptions::new()
.read(true)
.write(true)
.open(name)?;
// Take ownership of the file descriptor (do not use as_raw_fd:
// dropping StdFile would close the fd while we still hold a copy)
let fd: i32 = file.into_raw_fd();
// Return a `File` instance
Ok(File {
name: name.to_string(),
fd,
})
}
/// An explicit destructor that closes the file and returns any error
fn close(self) -> Result<(), Error> {
// Convert the fd back into a `File` using `FromRawFd`
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(self.fd)
};
// Flush file data to disk
file.sync_all()?;
// Truncate the file to 0 bytes
file.set_len(0)?;
// Flush the file again
file.sync_all()?;
// Drop the file instance, which will close the file automatically
drop(file);
// Return success
Ok(())
}
}
fn main() {
// Create a file named "test.txt" and write some content into it
std::fs::write("test.txt", "Hello, world!").unwrap();
// Open the file and obtain a `File` instance
let file: File = File::open("test.txt").unwrap();
// Print the file name and fd
println!("File name: {}, fd: {}", file.name, file.fd);
// Close the file and handle any error
match file.close() {
Ok(()) => println!("File closed successfully"),
Err(e) => println!("Error closing file: {}", e),
}
// Check the file size after closing
let metadata = metadata("test.txt").unwrap();
println!("File size: {} bytes", metadata.len());
}
- I wrote the important details in the code comments
-
closeis an explicit destructor: it closes the file and returns any error, takesselfas its parameter, and returns aResult - In
main, we explicitly call the destructor and usematchfor pattern matching
A Small Note
Explicit destructors need to be highlighted in the documentation.
Top comments (0)