DEV Community

Cover image for [Advanced Rust] 2.7. API Design Principles of Flexibility Pt.3 - Borrowed vs Owned, Cow Type, and Fallible and Blocking Destruc…
SomeB1oody
SomeB1oody

Posted on

[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<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));
}
Enter fullscreen mode Exit fullscreen mode
  • 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);
}
Enter fullscreen mode Exit fullscreen mode
  • 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...");
}
Enter fullscreen mode Exit fullscreen mode

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;
        });
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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...");
}
Enter fullscreen mode Exit fullscreen mode
  • 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!
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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<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 Drop cannot return Result<T, E> 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<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());
}
Enter fullscreen mode Exit fullscreen mode
  • 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.

Top comments (0)