Unsafe(impl) for improved granularity in unsafe trait

When defining a trait, one can mark it as unsafe to indicate that "implementors of this trait must uphold certain invariants":

unsafe trait Trait {
    // Unsafe to implement, safe to call.
    fn foo(&self);
}

However, it's not always the case that all methods one has to implement are unsafe. For example, bar could be perfectly safe to implement while foo isn't. Alas, the unsafe trait taints the entire implementation:

unsafe trait Trait {
    // Unsafe to implement, safe to call.
    fn foo(&self);

    // This is actually safe to implement, but it's still inside `unsafe trait` :-(
    fn bar(&self);
}

One option is to split the trait into two:

unsafe trait UnsafeTrait {
    fn foo(&self);
}

trait SafeTrait {
    fn bar(&self);
}

trait Trait: UnsafeTrait + SafeTrait {}
impl<T: UnsafeTrait + SafeTrait> Trait for T {}

I propose extending unsafe similar to how the visibility modifier pub can be controlled further, such as pub(crate). The idea here is introducing unsafe(impl):

trait Trait {
    unsafe(impl) fn foo(&self);
    fn bar(&self);
}

When users want to call Trait::foo, they do not need to be in an unsafe context, but implementors do need to specify unsafe fn:

impl Trait for i32 {
    unsafe(impl) fn foo(&self) {}
    fn bar(&self) {}
}

fn qux<T: Trait>(t: T) {
    t.foo(); // <- safe to call
}

No new keywords are needed, because impl fits the semantics perfectly.


Note: I do not actually think this would be a worthwhile addition. In my opinion, the "tainting" isn't so bad (at least for relatively small traits), and one can always go the split route (although implementing it becomes a bit more annoying). However, I just came across this scenario, and I wanted to share my thoughts on "what if".

What do you all think of this idea?

I'd expect specialization improves the split two trait approach, but I've no idea if all cases fit under specialization or how often min specialization suffices.

I agree that trait splitting is usually the better approach if the difference is meaningful.

Also, if the implementer just writes unsafe fn rather than unsafe(impl) fn, this would further confuse the split between "unsafe to implement" (has postconditions) and "unsafe to call" (has prerequisites). You can have a safe trait with an unsafe method. People are still discussing what exactly this means, but generally it's a method with extra non-typeststem prerequisites, but no guaranteed postconditions. The safe implementer can do anything made valid by the trait's defined preconditions for the method.

Of course, just writing unsafe(impl) at the definition site sidesteps that issue, and maybe even makes the split clearer...,

3 Likes

unsafe unsafe(impl) fn looks weird, at least to me (in case of unsafe-to-impl-unsafe-to-call method).

In that case, since you're already signaling there's something unsafe going on, I guess you would drop the unsafe(impl) part. A method which is unsafe to call will still have the unsafe in unsafe fn when implementing it, thus signaling there's something going on.

unsafe(impl) is about tightening how far the unsafe applies (an analogy is how pub is "global" similar to unsafe, and pub(crate) is more local like unsafe(impl)).

How about using the annotation syntax?

#[unsafe(impl)]
#[unsafe(call)]
#[unsafe(impl, call)]