[Pitch/Discussion] Introduce `@unsafe(always)` to require explicit unsafe in all language modes

Introduction

With the recent introduction of opt-in strict memory safety checking (SE-0458), Swift gained the @unsafe attribute. When strict safety mode is enabled, any interaction with unsafe types or functions requires an explicit unsafe effect at the call site:

extension Array<Int> {
  func sum() -> Int {
    withUnsafeBufferPointer { buffer in
      unsafe sumIntBuffer(buffer.baseAddress, buffer.count, 0)
    }
  }
}

Motivation

Historically, in non-strict modes, Swift has relied on a naming convention where "unsafe" is included as a substring in identifier names to warn developers of potential risks. A recent pitch highlighted this by proposing to rename certain symbols to reflect their underlying unsafety.

However, relying on naming conventions presents two major issues:

  1. API Churn: Renaming a symbol inherently breaks backward compatibility. Furthermore, if the underlying source of unsafety is eventually resolved (such as an LLVM fix in the linked pitch), removing "unsafe" from the name causes yet another breaking change.
  2. C++ Interoperability: In C++ interop, certain functions are inherently dangerous to use because Swift lacks the lifetime information necessary to keep the backing storage of returned views alive. Previously, we attempted to communicate this risk by automatically renaming these functions to include "Unsafe". This heuristic is often confusing, obfuscates the original API, and severely hinders discoverability.

Now that Swift has dedicated syntax to communicate unsafety, we have better tools at our disposal than renaming symbols.

Proposed Solution

I propose addressing both of these problems by introducing a stronger, unconditional variant of @unsafe, spelled as @unsafe(always).

Functions or types annotated with @unsafe(always) would mandate the unsafe keyword at their use site across all language modes, not just when strict memory safety is enabled.

This approach entirely avoids the churn of renaming symbols. Once the underlying safety issue is mitigated, the @unsafe(always) annotation can simply be removed without breaking backward compatibility.

Example: Improving C++ Interop

To illustrate the impact, let us look at how C++ interoperability would improve. Previously, due to the renaming heuristics designed to enforce Swift's naming conventions, a user would need to know that renaming had occurred. The exact spelling of the imported function was undiscoverable:

// C++
class MyIntVector { 
  int &getRawRef() { return data; }
};
// Swift caller (Before):
let _ = myVec.getRawRef() // Error: no such function
let _ = myVec.__getRawRefUnsafe() // Compiles, but is highly unintuitive

Under this proposal, no confusing renaming would occur. Instead, the inherent unsafety must be explicitly acknowledged via the unsafe effect:

// Swift caller (After):
let _ = myVec.getRawRef() // Error: function requires an explicit 'unsafe' acknowledgment
let _ = unsafe myVec.getRawRef() // Compiles

This change would make C++ interop significantly more intuitive for newcomers, and regular Swift APIs could similarly benefit in scenarios like the one linked above.

Conclusion

Because this proposal introduces the unsafe keyword into regular Swift (even when strict memory safety is disabled), the intention is to use @unsafe(always) sparingly. It should be reserved for scenarios where there is a compelling need to highlight significant API footguns.

What do you think? Is this a viable approach for the language?

I implemented a prototype of this change, feel free to experiment with it:

11 Likes

How would this interact syntactically with swift-evolution/proposals/0458-strict-memory-safety.md at main · swiftlang/swift-evolution · GitHub? I think it's desirable to be able to define "unsafe to implement" protocols, that are seen as unsafe in all language modes. The future direction mentions @unsafe(conforms) as a syntax for that which clashes with this suggestion, maybe something like @unsafe(always, to: conform) in that case is ok?

I think this would be nice, but isn't anything annotated with @unsafe already this way? I can't think of good guidance for when to choose an annotation. Undermining memory safety is one of "the biggest" footguns imo, you'll lose more than a foot... Afaik, @unsafe in swift isn't just for "may be surprising if you hold it wrong", or even "won't do what it says if you don't check invariants" which can be communicated with naming conventions and is still safe (this is a common misconception in Rust, it is totally safe to do unexpected things even if its unpleasant), but "will lead to memory unsafety if you hold it wrong, and I will not / cannot check invariants to determine if that will happen". What kinds of @unsafe functions are not "big footguns" to use wrong?

2 Likes

Oops, indeed. I was not aware of this proposal. But your idea of resolving the conflict looks good to me.

Indeed, the distinction here is a bit subtle. Let's look at two APIs to illustrate the problem.
Something like void *memcpy(void *dest, const void *src, size_t n); is error-prone. If the size does not match the buffer, there is a memory error. So it is exactly the shape you mentioned, if someone holds it wrong, there is a memory error.

On the other hand, something like std::vector<T>::begin() is a bit different. It's not just a memory safety issue if you hold it wrong, more like it is almost guaranteed that you are holding it wrong unless you have a very deep understanding if how the compiler works:

  let it = v.begin() // Would not compile today as begin is renamed to __beginUnsafe
  use(it)

The code above looks innocent but is incorrect since the Swift compiler does not know that it refers to storage owned by v. So the compiler can end the lifetime of v after the last use. To make this correct, one needs to use withExtendedLifetime or similar.

Based on this, the guidance would be if innocent/correct looking code is incorrect due to subtle details, it should be acknowledged with unsafe in both language modes.

I still feel like this is still just a description of memory safety. You mention memcpy as a safer example but using it safely, say with a stack allocated dest, has the same (potentially subtle) safety issues; you need to be sure the stack allocation lives long enough! If you take a C pointer into the stack buffer, you'll also need to ensure it isn't freed early. I don't see why C++ interop is uniquely unsafe, I'm capable of making the same mistake without it.

I personally think making strict memory safety the default would be extremely interesting! But probably not popular.

Re: [Pitch/Discussion] Introduce @unsafe(always) to require explicit unsafe in all language modes

This is a great pitch! The motivation here makes a lot of sense, especially for C++ interop. Mangling C++ method names like __getRawRefUnsafe() has always felt pretty clunky, so using the actual unsafe keyword instead of renaming APIs feels like a much cleaner approach.

A few quick thoughts on this:

  1. Avoiding API Churn: Replacing symbol renaming with an attribute is a huge win. If a C++ API gets proper lifetime annotations down the road, dropping the attribute won't break call sites—at worst, the unsafe keyword just becomes redundant, which is way easier to deal with than a breaking rename.

  2. Naming / Spelling: Is @unsafe(always) the clearest spelling, or would something like @unsafe(required) or @unsafe(unconditional) fit better? Since SE-0458 makes unsafety opt-in based on language mode, making sure compiler diagnostics clearly explain why unsafe is being forced here will be super important.

  3. Where to draw the line: Since we want to use this sparingly, it’d be worth setting clear guidelines on when to use @unsafe(always) vs standard @unsafe. Unannotated C++ interop views make total sense as a main driver, but for pure Swift code, should this be strictly limited to APIs that instantly lead to memory corruption or UB?

Overall, this feels like a really logical next step now that unsafe is an explicit language effect. Awesome job putting together the prototype PRs!

1 Like

Indeed, but following the convention that I propose, the blame would go to where the pointer is originated from, not to memcpy. Since the actual fix, using withExtendedLifetime would also go to where the pointer was acquired. So, if the Swift compiler cannot reason about the lifetimes of the pointer returning function, we would mark that function @unsafe(always), and the user would get the diagnostic at that call site. But not at all the use sites of the unsafe pointer.

Some of this comes from how the equivalent code spelled out in C++ is fine, but in Swift is UB since it has different object lifetime rules. Whereas, in pure Swift the APIs are designed in a way that it is unlikely one would run into the exact same problem. You can definitely make memory management errors in pure Swift but it is much harder and the code does not look that innocent.

I am sharing your opinion, I hope the strict language mode would become the default eventually. That being said, I am not sure whether we have enough real world experience and whether we worked out all the ergonomic issues to make that choice today. I am not 100% sure about the rest of the language, but on the interop side we definitely need to produce better diagnostics.

These are great suggestions! I am open to an alternative names.

This matches what I had in mind, APIs that require the user to do something extra, like calling with withExtendedLifetime to avoid mistakes (and every other call is basically instant UB) sounds like a good general guideline.

What about something like @unsafe(named: …), so you can have a better name in unsafe mode, while keeping the style in non-unsafe mode (putting unsafe in the name).

I am surprised to see that C++ interop has been attempting to incorporate this naming convention. It has never applied to C APIs, which are bridged without renaming and by default assumed to be unsafe.

It would seem that the solution to a substantial part of the motivating C++ interop example could be achieved simply by eliminating this attempt to adopt Swift convention which was never originally applied to non-Swift APIs.

If an API is almost guaranteed to be used incorrectly, isn't the actual solution to make the API unavailable in Swift until such time as it can bridged in a different way that can reasonably be used correctly? Why is it good to make such "almost guaranteed" footguns available but with extra steps and design a Swift language feature around that?

2 Likes

We still need to import the API so people can introduce safe overlays. We do have safe overlays for same of the C++ Standard Library types that produce a safe way to interact with the APIs, and want our users to be able to do the same for their own types.

Isn't that the problem that __attribute__((swift_private)) solves? Makes it available to the overlay with a __-prefixed name so that overlay can implement the safe version (perhaps with the name used by the original).

The __-prefixed name is still visible to anyone who imports the overlay module, but if someone wants to go as far as calling it directly when it doesn't show up in code-complete, then it's probably fine to assume they know what they're doing or let them face the consequences of their actions.

1 Like

That's helpful context: if it has to be imported for the narrow use case that safe overlays can be written against it in Swift, that the Swift name for an "almost guaranteed" footgun API is "undiscoverable" and "highly unintuitive," hard to find in code complete and not suggested by diagnostics, would be a feature here and not a bug. Surely we wouldn't want to have a feature dedicated to making the footgun itself easier to find and actively suggested in fix-its?

1 Like

Not quite. In case of C++ interop, we want to import more and more functions safely over time. Each time we rely on renaming, making a function safe (via an annotation or a new language feature) will break existing code. This is one of the motivations we want to move away from renaming.

This was the exact motivation why we did the renaming initially, years ago. But that was before the strict memory safe mode existed, so we had no better tools. Now, we have a path to get better tools where the unsafety is acknowledged at the call site and adding annotations to the C++ function to make it safe to call would not break the call sites and backwards compatibility.

We want people to be able to adopt language features, safety annotations without substantial costs. If our model relies on code churn and backward incompatibility every time you make your codebase safer, that makes it less likely for people to adopt these features. We do not want to make someone's attempt to improve safety punishing.

You're quite confident that "making it safe to call" in these cases could actually be a drop-in replacement but for the renaming issue? To me, this actually seems unlikely in the general case: it seems more likely that a Swift-native version of something that's totally, wildly unsafe to use when naively imported would require quite different idioms. For example, it might require a with... { } closure, advanced lifetime features we don't have yet, etc.

2 Likes

Our goal is to make it as close to a drop-in replacement as possible. It might not always be 100% possible, that is true. Also, this opens up other doors, if we later discover something is unsafe, we do not need to break all callers retroactively by renaming the symbol.

Swift is constantly in the process of adding new features like Ref. We plan to utilize these new features in interop. But even today we have scenarios where a C++ span returning function with sufficient lifetime annotations can get a Swift Span returning safe version that is a drop-in replacement for most callers.