Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to me as the review manager. When contacting the review manager directly, please put "SE-0498" at the start of the subject line.
What goes into a review?
The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:
What is your evaluation of the proposal?
Is the problem being addressed significant enough to warrant a change to Swift?
Does this proposal fit well with the feel and direction of Swift?
If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
More information about the Swift evolution process is available at:
In general, I'm supportive of this. It's a useful API to have without having to rely on unofficial entry points. It doesn't do everything I want (access to the full demangle tree) but that's not something that has to be litigated as part of this proposal.
Some specific quibbles:
Errors vs. enum result
Can the API be throwing in the event of an error instead of returning an enumerated type? The example in the proposal of if demangle("$sSG", into: &demangledOutputSpan) == .success { ... } feels like somewhat unidiomatic Swift. In addition to the bespoke result type, the function name is an active verb phrase which usually implies side effects, while value-returning functions are often noun phrases, which makes the simpler String form let s = demangle("$sSG") rub up against the edges of our API design guidelines.
Making the API throwing would mean we could also propagate error information out from the String-based API (instead of demangle(String) -> String?, it would be demangle(String) throws -> String with a non-optional result), meaning that we can still offer the simpler version of the API with no loss of fidelity compared to the lower-level Span version.
My only concern there is whether an error-based API makes usage harder to handle the truncated case. It may not be as clean to express the control flow for "attempt to demangle; if we ran out of space, reallocate and try again" with that API shapeâmaybe recursively. In any case, I think it would improve the proposal to explore that API shape and, if ultimately rejected, document why it's not suitable in "alternatives considered".
Truncated value label
If we stick with the truncated(Int) API, whether via an enum case or an error, can we label the payload? At first glance, I would probably interpret the Int as "the length it was truncated to", rather than its meaning of "the actual amount of space required". Something like truncated(codeUnitsNeeded: Int) would be self-documenting and remove the confusion.
Different failure cases
Can we provide more detail in failure cases without complicating the API? There are at least two distinct scenarios that I think are worth handling:
The symbol looked like a Swift symbol but it was not a valid mangling. For example, "$sS5" has the expected prefix $s but is otherwise nonsense.
The symbol does not in any way look like a Swift symbol. This is what I would expect if I fed something like "_foo" into the API.
These are worth distinguishing because determining the answer to #2 should be very fast and if I'm using this API in a logging scenario, then I'm likely to change my behavior depending on which failure I see. If I ask "demangle this symbol" and it tells me it's not remotely a Swift symbol, then I may decide to pass it to the demangler for some other language I've used in my application.
If it instead tells me that it's a corrupt Swift symbol, then that means something differentâeither something bad has happened with my data collection, or perhaps the version of the demangler I'm using is older than the Swift toolchain that produced the binary/trace.
The thinking that got me here was the question of whether we should have an API that just answers the question "is this likely to be a Swift symbol at all", but I think treating that as a separate failure case would be cleaner than a second file-scoped API, especially because the natural follow-up to that question, if the answer is "yes", is probably to just call demangle on it anyway.
Not really; we're not proposing to official-ize any of the String -> T or T -> String APIs in this proposal.
It would be good to discuss them at some point to be honest, since they're another set of those underscored-but-many-folks-need-to-use-them-anyway functions, but I would not put them together with this demangling proposal as they're a bit more controversial perhaps. If we were to ever make them official, they'd probably end up in the same module as this function.
Iââve followed the previous discussion and read the proposal - we are currently users of one unsupported way to get at the runtime function, so also very supportive of this and very happy to see it go forward - so big +1 and thanks for picking this up again.
I think Tony had good feedback on the api surface, but not much to add there.
Iâm an outlier, but this would be incredibly useful to me for demangling names for display in Reveal. Thanks for carrying this through to review, @ktoso â itâs very much appreciated.
Swiftâs symbol mangling is a key reason that NativeScript (a library, much like PyObjC and Appleâs own JXA, that automatically binds the entire iOS/macOS SDK to JavaScript) has always been limited to frameworks based on C and Objective-C. As a result, weâve begun to be left behind as new Swift-only frameworks have started to emerge. This symbol mangling is a problem for all such FFI libraries, and requires some pretty complicated solutions to work around (see Dartâs swift2objc).
I may not have the expertise to add much to the discussion here, but am very interested in the possibility of runtime demangling to open up new runtime options for FFI libraries, so would like to express some support for the proposal.
Very much in favor of this being a cleanly exposed element in Swift directly. I've been working around it with either the C level pieces or shelling out to use swift demangle in a terminal, and very much prefer having it available within Swift directly.
I don't have the background to comment on the API structure for this with the UTF8Span type pieces.
I agree with all three of Tony's points, but I figured I'd talk a little more about the error case, since he left his write-up a bit open-ended.
Let's see how this plays out in practice.
Suppose you have a buffer you're going to use when demangling. You'll try to demangle into its current capacity, then if that fails, you'll grow the buffer. That way you only have to reallocate when the current symbol demangles to a longer string than any symbol before it.
// A buffer we will reuse, growing it when necessary.
var demanglingBuffer: [UTF8.CodeUnit] = []
(Whether Array is really a particularly good type for this is immaterial; you'd still have broadly similar code with a different backing store. Also, for convenience I'm going to use an Array API that was deferred out of SE-0485.)
A naĂŻve implementation of that with the API as currently proposed might look like this:
// Try to demangle with the existing capacity.
demanglingBuffer.removeAll(keepingCapacity: true)
let demanglingResult = demanglingBuffer.append(addingCapacity: demanglingBuffer.capacity) { output in
demangle(symbol, into: &output)
}
switch demanglingResult {
case .failed:
return nil
case .truncated(let capacityNeeded):
// Grow to needed capacity, then try to demangle again.
demanglingBuffer.removeAll(keepingCapacity: true)
if .success != demanglingBuffer.append(addingCapacity: capacityNeeded, initializingWith: { output in
demangle(symbol, into: &output)
}) {
return nil
}
fallthrough
case .success:
// ...do something with `demanglingBuffer`...
}
With a throwing-based design, that might look more like this:
// outer do/catch: handles other errors (note that this wouldn't be necessary
// if you were willing to just rethrow the DemanglingError as-is).
do {
// inner do/catch: handles truncation
do {
// Try to demangle with the existing capacity.
demanglingBuffer.removeAll(keepingCapacity: true)
try demanglingBuffer.append(addingCapacity: demanglingBuffer.capacity) { output in
try demangle(symbol, into: &output)
}
}
catch DemanglingError.truncated(let capacityNeeded) {
// Grow to needed capacity, then try to demangle again.
demanglingBuffer.removeAll(keepingCapacity: true)
try demanglingBuffer.append(addingCapacity: capacityNeeded) { output in
try demangle(symbol, into: &output)
}
}
}
catch {
return nil
}
// ...do something with `demanglingBuffer`..
Neither of these would win a prize for conciseness, but I think the second one is clearer:
The happy path is easy to distinguish from the error handling.
There is one (non-truncating) error path and one success path, without messing around with case ordering so you can use fallthrough.
The two uses of demangle(_:into:) are a lot more parallel.
The truncation handling is very self-containedâit's easy to mentally code-fold away that catch block when you're thinking about the rest of the function.
It's just more straightforward overall.
What if you use a loop to avoid repetition? Well, that does clean up the code for the proposed API:
RETRY: repeat {
demanglingBuffer.removeAll(keepingCapacity: true)
let demanglingResult = demanglingBuffer.append(addingCapacity: demanglingBuffer.capacity) { output in
demangle(symbol, into: &output)
}
switch demanglingResult {
case .failed:
return nil
case .success:
break RETRY
case .truncated(let capacityNeeded):
demanglingBuffer.reserveCapacity(capacityNeeded)
}
} while true
// ...do something with `demanglingBuffer`...
But I think the throwing alternative's code comes out even better:
repeat {
do {
demanglingBuffer.removeAll(keepingCapacity: true)
try demanglingBuffer.append(addingCapacity: demanglingBuffer.capacity) { output in
try demangle(symbol, into: &output)
}
break
}
catch DemanglingError.truncated(let capacityNeeded) {
demanglingBuffer.reserveCapacity(capacityNeeded)
}
catch {
return nil
}
} while true
// ...do something with `demanglingBuffer`...
(As written, both of these variants may get stuck in an infinite loop if the capacityNeeded value is ever incorrect; the changes to correct that are left as an exercise to the reader.)
In all, demangle(_:into:)'s result feels a lot like the sort of thing the Error Handling Rationale design document calls a "recoverable error": in normal, expected use, the API may fail in a number of different ways and callers may have to distinguish between them so they can be handled differently. Throwing is the tool we designed for that kind of problem, so it shouldn't be especially surprising that it works well here.
So, what do I think we ought to propose instead?
public func demangle(
_ mangledName: borrowing UTF8Span,
into output: inout OutputSpan<UTF8.CodeUnit>
) throws(DemanglingError)
public enum DemanglingError: Error {
case nonSwiftSymbol
case malformed // possibly with an offset?
case truncated(capacityNeeded: Int)
}
There's an argument to be made for having the String variant throw tooâyou can always use try? to get back to handling an Optionalâbut I'm less sure about that one.
Edit to add: This is a good use case for typed throws because the function is throwing to represent abnormal results generated by its own logic, not by underlying I/O operations or other OS services. We might add new cases to DemanglingError in the future (that's why it isn't @frozen), but we won't need to rethrow some other library's errors.
Thanks for spelling those examples out so clearly, Becca! It's definitely convinced me that making it a throwing API doesn't hurt the ergonomics (indeed, the opposite).
I think we should definitely make both versions throw now, since it's strictly more flexible. As you point out, try? lets the user decide "I don't care why it failed, just that it did", without taking power away from users who want the full error information but might just be doing a one-off demangling (or multiple demanglings without a convenient place to stash a buffer that persists across calls). Forcing those users to deal with Span APIs when they just want String-in/String-out feels unnecessarily prescriptiveâwe can use documentation to direct users to the most appropriate API for their use case.
A couple more thoughts, independent of the three that Tony called out:
If we decide to keep DemanglingResult, we should still figure out whether or not it should be @frozen. I don't believe the proposal discusses this point.
The proposal should specify what the contents of output will be when demangle(_:into:) fails. Is it guaranteed to be empty, or could it contain partially demangled content? Is the answer different for failed vs. truncated?
It would be helpful for the proposal to include the APIs' doc comments, since they would presumably clarify some of these details.
I assume this API will be unavailable in Embedded?
Alternatively, the demanding part of the runtime could be copied into Embedded appsâ version of the standard library and LTO would strip it if unused. Thus, Embedded apps could also benefit from debugging tools without a binary-size increase.
It's not really very helpful in enabling new FFI since not only do you need to have the name of a symbol but also do the right calling conventions etc. It also does not enable you to get an AST node to get the mangled identifier of it etc; this "only" demangles existing identifiers.
Do note that this return type exists only in the âlow-level APIâ, and Iâd argue to keep that because it allows us to print partial demangled string if we for some reason cannot or will not âretry with larger bufferâ.
The simple API currently is () â String? and we could consider making the simple one throw an error instead.
I think Iâm sympathetic to making the simple API be throws:
public func demangle(_ mangledName: String) throws(DemanglingError) -> String?
Where I think the behavior could be:
return nil if the name isnât a recognized Swift symbol at all
throw if the demangling failed
return "demangled" if demangled successfully
This would also address the following request:
Thatâs a good point, agreed. Maybe call it necessaryBufferCapacity:?
I would be okey with doing the typed throw with a not-frozen error type, like proposed here:
and we would still write the partial result if we threw a truncation.
The failed case would not write into the buffer at all.
The truncation case, should write the partial demangled result into the result buffer; you may not want to allocate the whole thing for whatever reason, and be ok with a cut off demangling.
The âsimple apiâ I updated above, there is no truncation case in that, since the runtime allocates the buffer.
When I see an API that both throws and also returns an Optional value, I personally want to interpret that as:
throw when the operation failed
return nil when the operation succeeded but produced nothing
(Things get even more complicated when the return type is a Collection, like String, because there's a third stateânon-nil but empty result!âbut we can ignore that one here because a successful demangling would never produce an empty string.)
In practice, everyone may not design APIs that way, but I think it's a fairly reasonable interpretation based on the features of their language and their intended uses.
The above principles don't hold for demangle. Both scenariosâ"it wasn't a Swift symbol at all" and "it looked like a Swift symbol but the demangler didn't understand it"âare failures to demangle. Since we would already be defining nonSwiftSymbol and malformed as error cases for the Span-based API, I think it would be more confusing if the String-based API chose to turn one of those error cases into a different return value. More importantly, the user should be able to swap out one form of the API for the other without having to change their control flow.
We could treat ânot a swift symbolâ as an error, thatâs fine and yeah not return a nil then.
public func demangle(_ mangledName: String) throws(DemanglingError) -> String
public func demangle(
_ mangledName: borrowing UTF8Span,
into output: inout OutputSpan<UTF8.CodeUnit>
) throws(DemanglingError)
These look agreeable to me; It is an improvement treating the ânot a swift symbolâ consistently between the two versions with a throw, thatâs true.
Thatâs overstating it somewhat though; as those apis are not as easily interchangeable â because handling truncation, as weâve seen above.
That's true, and I may have oversimplified. Truncation is sort of an "extra" case that's specific to the Span overload, but the argument I was making was that for the other two failure cases, it's an ergonomic win if the caller is expected to handle them in the same way regardless of which overload they use.