[Pitch] Default values for string interpolations

I think this could probably be handled in individual projects the way people normally provide commonly used strings, rather than trying to provide a specific API for this use case.

Agreed, that's the prior art I was using for this naming – I'll make sure to add a reference to that API to the proposal.

Thanks for sharing this exploration! I'm looking for a more specific solution, as pitched, but it definitely seems like there are other ways of enriching string interpolations.

I thought some more about this question, and I actually don't think there's a correct way to provide this more widely. StringInterpolationProtocol defines a single requirement for appending, the one for literal segments: appendLiteral(Self.StringLiteralType). What kinds of types are allowed in the actual interpolations is dictated by the specific func appendInterpolation overloads that a type implements. Since the proposed addition would add an overload that takes an optional version of any type T, adding it straight to the protocol would automatically increase the number of types that were allowed in every string interpolatable type.

As such, it's only okay to add this addition to interpolation types where an unconstrained parameter is always accepted, like String.

1 Like

To answer @Agent’s point above, I believe this covers Logger (the Swift equivalent of os_log) because both Logger.Message and OSLogInterpolation have typealias StringLiteralType = String.

My wording was unclear there - this proposal will not cover those other types, since it doesn’t work to add a new protocol-based method that can’t call through to a method defined by the protocol. My sample implementation isn’t helping - it shouldn’t call appendLiteral, and should instead call appendInterpolation with the correct value. I’ll update the pitch text in a bit.

It would be rather unfortunate for such useful functionality to be available to print() but not to Logger.

2 Likes

Given the design of StringInterpolationProtocol, it is not possible for us to add this to all interpolation types. Nor should we, because that would contradict intentional aspects of its design. String interpolation is intentionally designed to give types the ability to opt out of interpolation behaviors that might be inappropriate for their problem domains; the cost of this flexibility is that we can’t give them new interpolation features for free.

(And it’s worth noting that OSLogMessage in particular has chosen not to implement several default interpolation behaviors, like the ability to interpolate types that can only be stringified using reflection, because they were judged inappropriate for its mission of high-performance logging. This flexibility was an intentional part of the design and it’s intentionally used by clients.)

The maintainers of custom interpolation types can, of course, choose to implement appendInterpolation(_:default:) methods that follow the same conventions as what’s being pitched here, and I think we would strongly encourage them to do so if it was appropriate for their types. Whether Apple framework types like LocalizedStringKey (and possibly OSLogMessage; I’m not sure who owns it and I’m not going to start asking around on a weekend) choose to do so is outside the scope of Swift Evolution.

7 Likes

-1 on this.

For the age: Int? example here, why not just map it to String? and then give a String fallback value.

let age: Int? = nil
print("Your age: \(age, default: "missing")") // -1
print("Your age: \(age?.description ?? "missing")") // +1

I think this adds the complexity of the language and does not provide enough benefit.

1 Like

Nice. Works with OSLog as well.

What complexity? Most users won't even find it given interpolation's poor autocomplete, and its use should be obvious to those who do.

But this proposal specifically addresses types that don't have a convenient description (not that you should really rely on that value for anything important) and would have to reach for String(describing:) instead.

4 Likes

i’ve always had trouble reconciling that with the official documentation provided for the CustomStringConvertible.description requirement:

Calling this property directly is discouraged. Instead, convert an instance of any type to a string by using the String(describing:) initializer. This initializer works with any type, and uses the custom description property for types that conform to CustomStringConvertible

5 Likes

Yeah, I'd forgotten about that.

I don't recall ever seeing an explanation for that 'discouragement'. I use description all the time - often in cases like logging optionals - and it's not apparent to me that there's any downsides.

Not only is String(describing:) more verbose and more awkward, but it has the sharp 'feature' the documentation notes - it works with almost anything. Sometimes that's useful, sometimes it's a hazard. Usually when I'm logging something I know what it is and its description means something to me. If it didn't, it'd be a bit optimistic to log it.

Perhaps that documentation is simply obsolete?

Thanks for pointing out this. I have never paid attention to this part before.

+1 for this. This method does not have any side effect normally. (eg. Compared something as UIView.viewWillAppear in UIKit or close on some resource related API which we should not call directly.)

2 Likes

Also, there's an intuition to use description directly for anyone with Objective-C experience, since that's canonically how you access the method there. And as far as I know these are conceptually identical methods - Swift merely put a formal protocol on description, but Objective-C invented it.

For what it’s worth, calling -description directly in Objective-C code is arguably a code smell. At least twice, Foundation value types have changed the format of their -description, breaking code that assumed they could round trip it through -initWithString: as a cheap serialization mechanism. And po has invoked -debugDescription since GDB was the default debugger on Apple platforms.

1 Like

The documentation is the way it is because the CustomStringConvertible protocol is intended to be just a customization point for something that’s possible for all types: conversion to a string via String(describing:). There’s no semantic difference between a type that implements that protocol vs one that doesn’t, the author of the type that does implement it just decided they could do better than the default string conversion.

In practice, you can’t cause a problem by using description, so the documentation wording is probably a little overcooked. But that’s the intent.

4 Likes

I think that's just misuse of those APIs. They never made promises for that sort of symmetry and they were never intended to provide it. Most uses of -description were correct & valid, I believe. Which is to say, mostly for logging.

Aside from %@ in format strings, I can't think of any other common way to get the description of an object in Objective-C. I don't recall any analog to String(describing:) - in the sense that, while I'm sure there's a bunch of ways to mimic that with NSString and other APIs, there was never any convention of doing it that way. You want the description, you call description. No unnecessary ceremony.

That makes sense, but brings up my earlier point: usually you don't want some random string, that's probably just a type name and a pointer if you're lucky. Writing x.description lets the type-checker ensure x actually has a meaningful description (well, for a loose definition of 'meaningful' when you're dealing with some types that you don't control).

String(describing:) definitely has its uses, of course, such as when you're dealing lazily with type erasure or pretending Swift is Python for a bit, but it ironically feels very "old school" in the Objective-C sense, and out of place with Swift's idioms. IMO. :man_shrugging:

i agree completely. moreover, i personally find the laxness of \(x) in default string interpolations a huge footgun and i can’t count how many times i’ve accidentally generated invalid strings because a faraway refactor caused something to start interpolating through String.init(describing:).

for a hilarious and embarassing example from today, i will note that the GitHub URLs on swiftinit (example) are currently pointing to:

https://github.comGitHubOrigin(id: 170177511, pushed: BSONABI.BSON.Millisecond(value: 1705920590000), owner: "apple", name: "swift-log", homepage: Optional("https://apple.github.io/swift-log/"), about: Optional("A Logging API for Swift"), watchers: 57, size: 739, archived: false, disabled: false, fork: false)/tree/e97a6fcb1ab07462881ac165fdbb37f067e205d5

when they should be pointing to

https://github.com/apple/swift-log/tree/e97a6fcb1ab07462881ac165fdbb37f067e205d5

they have likely been broken for weeks now, and will be for a few more hours until i get a chance to push an update to the server tonight.

how did this happen?

a faraway type lost a CustomStringConvertible conformance in favor of a named https:String { get } property:

- extension GitHubOrigin:CustomStringConvertible
+ extension GitHubOrigin
{
    @inlinable public
-   var description:String
+   var https:String
    {
        "https://github.com/\(self.owner)/\(self.name)"
    }
}

but this did not raise any compiler errors that would have reminded me to do:

import GitHubOrigin

... 

- url = "\(origin)/tree/\(commit)"
+ url = "\(origin.https)/tree/\(commit)"

i really really hate that string reflection uses the same interpolation syntax as string conversion.

2 Likes

String(describing:) is often not what you want if you interpolate an arbitrary type.
For example if you want to conform your struct to CustomStringConvertible you actually want to use String(reflecting:) for most values or otherwise get very hard to read or ambiguous output:

struct KeyValueBoxWithDefaultDescription<Value> {
    var id: String
    var value: Value
}

struct KeyValueBoxWithInterpolatedCustomDescription<Value>: CustomStringConvertible {
    var id: String
    var value: Value
    
    var description: String {
        "(\(id), \(value))"
    }
}

struct KeyValueBoxWithEscapedCustomDescription<Value>: CustomStringConvertible {
    var id: String
    var value: Value
    
    var description: String {
        "(\(String(reflecting: id)), \(String(reflecting: value)))"
    }
}

let id = "Key)"
let value = "\nValue"

let defaultDescription = KeyValueBoxWithDefaultDescription(id: id, value: value)
print("---------------------\(type(of: defaultDescription))--------------------")
print(defaultDescription)

let interpolatedCustomDescription = KeyValueBoxWithInterpolatedCustomDescription(id: id, value: value)
print("---------------------\(type(of: interpolatedCustomDescription))--------------------")
print(interpolatedCustomDescription)

let escapedCustomDescription = KeyValueBoxWithEscapedCustomDescription(id: id, value: value)
print("---------------------\(type(of: escapedCustomDescription))--------------------")
print(escapedCustomDescription)

Output:

---------------------KeyValueBoxWithDefaultDescription<String>--------------------
KeyValueBoxWithDefaultDescription<String>(id: "Key)", value: "\nValue")
---------------------KeyValueBoxWithInterpolatedCustomDescription<String>--------------------
(Key), 
Value)
---------------------KeyValueBoxWithEscapedCustomDescription<String>--------------------
("Key)", "\nValue")

String(describing:) is used by default but hurts the readability of the output quite a bit. This is very common mistake I have seen and I have made way to often in the past. I have barely seen anyone actually using String(reflecting:) for their custom descriptions and I only started using it because @lorentey made me aware of it.

This indirectly and directly affects logging as well as some logging systems require that a single log message doesn't contain new lines. Even if the interpolation of the log message properly uses String(reflecting:) for each value, if any of the logged values doesn't properly implement CustomStringConvertible/CustomDebugStringConvertible the output can contain unescaped values.

This proposal embraces the use of String(describing:) further and makes the switch to String(reflecting:) even more inconvenient than it already is.

I would like to see this convenience to cover String(reflecting:) as well. One option would be to add two more overloads that use String(reflecting:) instead of String(describing:) i.e.:

extension String.StringInterpolation {
    mutating func appendInterpolation<T>(
        reflecting value: T
    ) 
    mutating func appendInterpolation<T>(
        reflecting value: T?, 
        default: @autoclosure () -> String
    ) 
}
3 Likes