[swift-evolution] [Draft] Fix ExpressibleByStringInterpolation

Brent Royal-Gordon brent at architechies.com
Sat Mar 11 07:34:37 CST 2017


> On Mar 10, 2017, at 11:17 PM, Jacob Bandes-Storch <jtbandes at gmail.com> wrote:
> 
> I'm confused by this example — was ExpressibleByFailableStringInterpolation's init() supposed to be failable here?

Ugh, yes, I'm sorry. That should have been:

	protocol ExpressibleByFailableStringInterpolation: ExpressibleByStringLiteral {
		associatedtype StringInterpolationType
		
		init?(stringInterpolation: StringInterpolationSegment...)
	}

	...
	
	extension Optional: ExpressibleByStringInterpolation where Wrapped: ExpressibleByFailableStringInterpolation {
		typealias StringLiteralType = Wrapped.StringLiteralType
		typealias StringInterpolationType = Wrapped.StringInterpolationType
		
		init(stringInterpolation segments: StringInterpolationSegment...) {
			self = Wrapped(stringInterpolation: segments)
		}
	}

> Just to throw out another idea, what about keeping the entirety of the string in one contiguous block and providing String.Indexes to the initializer?
> 
> protocol ExpressibleByStringInterpolation {
>     associatedtype Interpolation
>     init(_ string: String, with interpolations: (String.Index, Interpolation)...)
> }

I've thought about that too. It's a little bit limiting—you have no choice except to use `String` as your input type. Also, the obvious way to use these parameters:

	init(stringLiteral string: String, with interpolations: (String.Index, Interpolation)...) {
		var copy = string
		for (i, expr) in interpolations {
			let exprString = doSomething(with: expr)
			copy.insert(exprString, at: i)
		}
		self.string = copy
	}

Will probably be slow, since you're inserting into the middle instead of appending to the end. Obviously a clever programmer can avoid doing that, but why create the attractive nuisance in the first place?

> On the other hand, unless I've missed something, it seems like most of the suggestions so far are assuming that for any ExpressibleByStringInterpolation type, the interpolated values' types will be homogeneous. In the hypothetical printf-replacement case, you'd really want the value types to depend on the format specifiers, so that a Float couldn't be passed to %d without explicitly converting to an integer type.
> 
> Although I suppose that could simply be achieved with a typealias Interpolation = enum { case f(Float), d(Int), ... }


Yup. Given Swift's current feature set, the only way to design this is to have a single type which all interpolations funnel through. That type could be `Any`, of course, but that doesn't really help anybody.

If we had variadic generics, you could of course have a variadic initializer with heterogeneous types. And I've often given thought to a "multiple associated types" feature where a protocol could be conformed to multiple times by specifying more than one concrete type for specific associated types. But these are both exotic features. In their absence, an enum (or something like that) is probably the best choice.

* * *

I'm going to try to explore some of these other designs, but they all seem to assume the new formatting system I sketched out in "future directions", so I implemented that first:

	https://github.com/brentdax/swift/compare/new-interpolation...brentdax:new-interpolation-formatting

The switch to `\(describing: foo)` has more impact than I expected; just the code that's built by `utils/build-script`—not including tests—has over a hundred lines with changes like this:

-    expectationFailure("\(lhs) < \(rhs)", trace: ${trace})
+    expectationFailure("\(describing: lhs) < \(describing: rhs)", trace: ${trace})

On the other hand, I like what it does to other formatting (I've only applied this kind of change in a few places):

-    return "CollectionOfOne(\(String(reflecting: _element)))"
+    return "CollectionOfOne(\(reflecting: _element))"

And it *does* make you think about whether you want to use `describing:` or `reflecting:`:

-    expectEqual(expected, actual, "where the argument is: \(a)")
+    expectEqual(expected, actual, "where the argument is: \(describing: a)")

And, thanks to LosslessStringConvertible, it also does a pretty good job of calling out the difference between interpolations that will probably look good to a user and ones that will look a little funny:

-      return "watchOS(\(major).\(minor).[\(bugFixRange)], reason: \(reason))"
+      return "watchOS(\(major).\(minor).[\(describing: bugFixRange)], reason: \(reason))"

All in all, it's a bit of a mixed bag:

-          return "<\(type(of: x)): 0x\(String(asNumericValue(x), radix: 16, uppercase: false))>"
+          return "<\(describing: type(of: x)): 0x\(asNumericValue(x), radix: 16, uppercase: false)>"

We could probably improve this situation with a few targeted `String.init(_:)`s for things like type names, `Error` instances, and `FloatingPoint` types. (Actually, I think that `FloatingPoint` should probably conform to `LosslessStringConvertible`, but that's a different story.) Possibly `Array`s of `LosslessStringConvertible` types as well.

But ultimately, this might just be too source-breaking. If it is, we'll need to think about changing the design.

The simplest fix is to add a leading parameter label if there isn't one—that is, `\(foo)` becomes `.init(formatting: foo)`—but then you lose the ability to use full-width initializers which are already present and work well outside of interpolation. Perhaps we could hack overload checking so that, if a particular flag is set on a call, it will consider both methods with *and* without the first parameter label? But that's kind of bizarre, and probably above my pay grade to implement.

In any case, I really think this is in the right general direction, and with it done, I can start exploring some of the alternatives we've discussed here. I'm hoping to build several and run the string interpolation benchmark against them—we'll see how that goes.

-- 
Brent Royal-Gordon
Architechies



More information about the swift-evolution mailing list