[Advanced Rust] 2.7. API Design Principles of Flexibility Pt.3 - Borrowed vs Owned, Cow Type, and Fallible and Blocking Destruc…
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, Cow can borrow the existing data and avoid extra allocation. If the data needs to be modified, Cow will clone the data to gain ownership and then modify it. Example: use std::borrow::Cow; fn process_data(data: Cow) { 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_data in the comments In main, input1 does 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 input2 contains "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 drop method 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, Drop needs to ensure that all data has been written to disk to prevent data loss Network connections: when a TcpStream or UdpSocket is dropped, Drop needs to close the connection properly to prevent resource leaks Database connections: when a database connection object goes out of scope, Drop needs 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, but Drop cannot await The problem is that Rust's Drop trait cannot await, because drop() is not async: trait Drop { fn drop(&mut self); } drop() cannot await, 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 when main() is ending or after other async tasks finish, the task spawned inside drop() 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 a Future; 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 Future is created inside drop(), but it will not run by itself; an executor must drive it If no executor is available, the Future will 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) 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 Drop cannot return Result and also cannot be async (since drop() cannot await), it cannot handle async cleanup or errors Therefore, we can provide an explicit close() or shutdown() method that users call manually to ensure resources are released correctly and to support Result or async error 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 { // 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 { // 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 close is an explicit destructor: it closes the file and returns any error, takes self as its parameter, and returns a Result In main, we explicitly call the destructor and use match for pattern matching A Small Note Explicit destructors need to be highlighted in the documentation.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to