Safe trait with an unsafe method

Is it reasonable for a safe trait to contain an unsafe method? Rust allows such a thing to exist, but what does it mean?

According to The Rust Book,

A trait is unsafe when at least one of its methods has some invariant that the compiler can’t verify.

According to the Rust Reference:

Traits items that begin with the unsafe keyword indicate that implementing the trait may be unsafe. It is safe to use a correctly implemented unsafe trait. The trait implementation must also begin with the unsafe keyword.

Sync and Send are examples of unsafe traits.

OK, we've established that the unsafe qualifier on a trait is only relevant to people implementing the trait, not downstream consumers of the trait's implementations.

So what is an implementor to make of a safe trait which contains an unsafe method? I can see two interpretations.

  • The idea that there can be a safe trait with an unsafe method is nonsense, because even if there are no invariants which must be upheld outside the context of the unsafe method, the trait still contains contains the method and thus also contains its invariants.
  • If any invariants are wholly encapsulated within the unsafe method, then the trait as a whole does not need to be marked unsafe. Even if the stated purpose of the unsafe qualifier on a function is to alert callers of the function that invoking it requires care, it also unavoidably creates an unsafe block around the function body; that unsafe block suffices as a warning to the implementor so the trait itself doesn't need to be marked unsafe.
1 Like

What about something like:

trait SafeTrait {
    // SAFETY: requires invariant A, but guarantees invariant B
    unsafe fn foo()
    where
        Self: UnsafeTrait
    {}
}

// SAFETY: if this trait is implemented then `<Self as SafeTrait>::foo`
// must guarantee invariant B given invariant A
unsafe UnsafeTrait {}
1 Like

unsafe on a trait means that the implementor of the trait has special obligations.
unsafe on a function means that the caller of the function has special obligations.

  • The idea that there can be a safe trait with an unsafe method is nonsense, because even if there are no invariants which must be upheld outside the context of the unsafe method, the trait still contains contains the method and thus also contains its invariants.

This isn't correct, because the trait could be implemented in a way which doesn't itself involve any unsafety. For example:

trait Memory {
    unsafe fn write(&mut self, addr: usize, value: u8);
}

struct Global;
impl Memory for Global {
    unsafe fn write(&mut self, addr: usize, value: u8) {
        // Extremely dangerous
        std::ptr::write(addr as *mut u8, value);
    }
}

impl Memory for &mut [u8] {
    unsafe fn write(&mut self, addr: usize, value: u8) {
        // Can only corrupt this slice — no UB
        self[addr] = value;
    }
}

Suppose the contract of this Memory::write function is that the caller is obligated to understand the address it's writing to. Then, it is perfectly safe (in the sense of not containing any unsafe operations or associated risks) to implement it on &mut [u8] — the function can contain unsafe operations but, if it doesn't, it has no special unsafe obligations.

There is a plan to stop making the bodies of unsafe functions automatically act as unsafe {} blocks. In this future version of the language, the situation here will be clearer: unsafe fn in a trait doesn't say anything at all about what an implementor of the trait is doing, only the caller.

The unsafety of the trait function means that any implementor of the trait is allowed to rely on the trait's function's documented obligations for callers of that function.

9 Likes

TL/DR:

  • unsafe fn adds preconditions that the caller must promise to meet when calling it, in addition to those checked by the compiler. A safe fn is simply one where that set of extra preconditions is empty.
  • unsafe trait adds postconditions that the implementation must promise to meet in its implementations, in addition to those checked by the compiler. A safe trait is simply one where that set of extra postconditions is empty.

You might also be interested in the discussion in the comments of RFC: Overconstraining and omitting `unsafe` in impls of `unsafe` trait methods by Centril · Pull Request #2316 · rust-lang/rfcs · GitHub

The reason an implementation can implement an unsafe fn as a safe fn is that if it doesn't actually depend on any of those preconditions, then for that particular implementation it would be ok for the caller to not check them.

9 Likes

Thanks — that's the crux! I understand now that two different forms are at least theoretically disconnected.

In practice, it seems to me as though that the presence of an unsafe function within a trait would nearly always justify adding the advisory unsafe label to the trait. In my view, all of the supposedly safe traits provided as examples in this thread seem as though they impose obligations on the trait implementer to uphold invariants.

Whether you're calling the unsafe function within other trait functions, implementing the unsafe function yourself, or even deliberately choosing to inherit the default implementation of the unsafe function, you basically have no choice but to contemplate the documented obligations imposed on callers of that unsafe function — because your implementation has to guarantee soundness so long as callers uphold those obligations.

It was clarifying to read someone arguing that labeling a function unsafe shouldn't automatically turn the function body into an unsafe block — in other words that explicit unsafe blocks should be required for actually unsafe code within unsafe functions. Although that would be ergonomically disastrous, the argument still illustrates that signalling to callers that they have special obligations and creating an unsafe code block are two different things.

1 Like

The key point imo is that "your implementation has to guarantee soundness so long as callers uphold those obligations" is a property of you calling unsafe code, not of the method being unsafe.

It is true that most of the time, it will end up being that a trait with an unsafe method will itself end up being unsafe. After all, if the method returns any sort of raw handle (be it pointer, index, or whatever), the caller needs to know something about its validity in order to be able to use it.

But if there's no further safety concerns for the output of a method, then the trait doesn't need to be unsafe, because there's no need to flow guarantees in that direction.

As an illustrative example, consider

trait Get {
    type Item;
    fn get(&self, ix: usize) -> Option<&Item>;
    unsafe fn get_unchecked(&self, ix: usize) -> &Item;
}

Now there are two reasonable implementations for slices:

// the simple, safe one:
impl<T> Get for &'_ [T] {
    type Item = T;
    fn get(&self, ix: usize) -> Option<&T> {
        self.get(ix) // the inherent one
    }
    fn get_unchecked(&self, ix: usize) -> &Item {
        self.get(ix).unwrap()
    }
}

// or the one that uses the precondition:
impl<T> Get for &'_ [T] {
    type Item = T;
    fn get(&self, ix: usize) -> Option<&T> {
        self.get(ix) // the inherent one
    }
    unsafe fn get_unchecked(&self, ix: usize) -> &Item {
        unsafe { self.get(ix).unwrap_unchecked() }
    }
}

The thing to consider here is the flow of promises:

  • Get::get_unchecked is unsafe fn, so the impl knows (on pain of UB) that the preconditions hold; that ix is a valid index.
  • trait Get is not unsafe trait, so any (generic) callers of its methods have the type guarantees of the return value, but just that, and must not ever cause UB for any potential (safety variant upholding) return value.

Any caller MUST (on pain of UB) fulfill any documented preconditions on the trait. The callee MUST NOT add any preconditions to the trait impl. The callee MAY use the trait documented preconditions to fulfill the preconditions of any unsafe functionality it itself calls. The caller MUST NOT rely on the correctness of the trait impl to ensure soundness of its own implementation. (The callee, however, must in fact be sound to call if the preconditions are held; if it is not, this is a soundness bug in the callee.)

I think the interesting point might be that soundness is a requirement of every Rust function, not just unsafe ones. It's just that it's trivial to prove the soundness of a function that doesn't call unsafe functionality -- all of the functions it calls are sound over all input, thus it is sound itself. Marking a function unsafe gives it no extra responsibilities; it just gives it more information (the documented safety preconditions) in order to satisfy the global requirement of soundness.


A somewhat more practical example might be SIMD and CPU capabilities. You could have a trait abstracting over doing some work, and it has multiple entry point methods. Depending on the (unsafe) method called, the implementation may unsafely assume that the CPU supports some level of functionality not necessarily guaranteed by just the CPU target for the program as a whole.

The abstracting trait shouldn't be unsafe; there's nothing more to it than FnOnce, really. However, the functions that assume CPU functionality do need to be unsafe, as they might execute undefined CPU instructions and cause undefined behavior if called on an unsupported CPU.

2 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.