Advanced string interpolation in Swift 5.0

Original author: Paul Hudson
  • Transfer


String interpolation was in Swift from earlier versions, but in Swift 5.0 this functionality was expanded, became faster and much more powerful.

In this article, we will go over the new possibilities of string interpolation and consider how this can be applied in our own code. You can also download the sources for this article here.

The basics


We use basic string interpolation like this:

let age = 38
print("You are \(age)")

We take this for granted, but at one time it was a significant relief compared to what we had to deal with before:

[NSString stringWithFormat:@"%ld", (long)unreadCount];

There is also a significant performance gain, as the alternative was:

let all = s1 + s2 + s3 + s4

Yes, the end result would be the same, but Swift would have to add s1 to s2 to get s5, add s5 to s3 to get s6, and add s6 to s4 to get s7, before assigning all.

String interpolation practically did not change with Swift 1.0, the only significant change came with Swift 2.1, where we got the opportunity to use string literals in interpolation :

print("Hi, \(user ?? "Anonymous")")

As you know, Swift is developing largely thanks to community suggestions. Ideas are discussed, developed, and either accepted or rejected.

So, five years later, Swift's development got to line interpolation. Swift 5.0 introduces new super features that give us the ability to control the string interpolation process.

To try, consider the following scenario. If we set a new integer variable like this:

let age = 38

it’s quite obvious that we can use string interpolation as follows:

print("Hi, I'm \(age).")

But what if we want to format the result in a different way?

Using the new string interpolation system in in Swift 5.0, we can write extension String.StringInterpolation to add our own interpolation method:

extension String.StringInterpolation {
    mutating func appendInterpolation(_ value: Int) {
        let formatter = NumberFormatter()
        formatter.numberStyle = .spellOut
        if let result = formatter.string(from: value as NSNumber) {
            appendLiteral(result)
        }
    }
}

Now the code will output the whole variable as text: “Hi, I'm thirty-eight.”

We can use a similar technique to fix the date formatting, since the default date type in the form of a string is not very attractive:

print("Today's date is \(Date()).")

You will see that Swift displays the current date in the form of something like: “2019-02-21 23:30:21 +0000”. We can make it more beautiful using our own date formatting:

mutating func appendInterpolation(_ value: Date) {
    let formatter = DateFormatter()
    formatter.dateStyle = .full
    let dateString = formatter.string(from: value)
    appendLiteral(dateString)
}

Now the result looks much better, something like: “February 21, 2019 23:30:21”.

Note: to avoid possible confusion when working together in a team, you probably should not override the default Swift methods. Therefore, give the parameters the names of your choice to avoid confusion:

mutating func appendInterpolation(format value: Int) {

Now we will call this method with a named parameter:

print("Hi, I'm \(format: age).")

Now it will be clear that we are using our own implementation of the method.

Interpolation with parameters


This change shows that we now have full control over how string interpolation occurs.

For example, we can rewrite the code to process Twitter messages:

mutating func appendInterpolation(twitter: String) {
    appendLiteral("@\(twitter)")
}

Now we can write like this:

print("You should follow me on Twitter: \(twitter: "twostraws").")

But why should we limit ourselves to one parameter? For our example of number formatting, it makes no sense to force users to use one conversion parameter (.spellOut) - so we will change the method by adding a second parameter:

mutating func appendInterpolation(format value: Int, using style: NumberFormatter.Style) {
    let formatter = NumberFormatter()
    formatter.numberStyle = style
    if let result = formatter.string(from: value as NSNumber) {
        appendLiteral(result)
    }
}

And use it like this:

print("Hi, I'm \(format: age, using: .spellOut).")

You can have as many parameters as you like, of any type. Example using @autoclosure for the default value:

extension String.StringInterpolation {
    mutating func appendInterpolation(_ values: [String], empty defaultValue: @autoclosure () -> String) {
        if values.count == 0 {
            appendLiteral(defaultValue())
        } else {
            appendLiteral(values.joined(separator: ", "))
        }
    }
}
let names = ["Malcolm", "Jayne", "Kaylee"]
print("Crew: \(names, empty: "No one").")

Using the @autoclosure attribute means that for the default value, we can use simple values ​​or call complex functions. In the method, they will become a closure.

Now, you may be thinking that we can rewrite the code without using the interpolation functionality, something like this:

extension Array where Element == String {
    func formatted(empty defaultValue: @autoclosure () -> String) -> String {
        if count == 0 {
            return defaultValue()
        } else {
            return self.joined(separator: ", ")
        }
    }
}
print("Crew: \(names.formatted(empty: "No one")).")

But now we have complicated the call - because we are obviously trying to format something, this is the point of interpolation. Remember the Swift rule - avoid unnecessary words.

Erica Sadun offered a really short and beautiful example of how to simplify the code:

extension String.StringInterpolation {
    mutating func appendInterpolation(if condition: @autoclosure () -> Bool, _ literal: StringLiteralType) {
        guard condition() else { return }
        appendLiteral(literal)
    }
}
let doesSwiftRock = true
print("Swift rocks: \(if: doesSwiftRock, "(*)")")
print("Swift rocks \(doesSwiftRock ? "(*)" : "")")

Adding string interpolation for custom types


We can use string interpolation for our own types:

struct Person {
    var type: String
    var action: String
}
extension String.StringInterpolation {
    mutating func appendInterpolation(_ person: Person) {
        appendLiteral("I'm a \(person.type) and I'm gonna \(person.action).")
    }
}
let hater = Person(type: "hater", action: "hate")
print("Status check: \(hater)")

String interpolation is useful because we do not touch on debugging information about an object. If we look at it in the debugger or display it, then we will see untouched data:

print(hater)

We can combine a custom type with several parameters:

extension String.StringInterpolation {
    mutating func appendInterpolation(_ person: Person, count: Int) {
        let action = String(repeating: "\(person.action) ", count: count)
        appendLiteral("\n\(person.type.capitalized)s gonna \(action)")
    }
}
let player = Person(type: "player", action: "play")
let heartBreaker = Person(type: "heart-breaker", action: "break")
let faker = Person(type: "faker", action: "fake")
print("Let's sing: \(player, count: 5) \(hater, count: 5) \(heartBreaker, count: 5) \(faker, count: 5)")

Of course, you can use all the features of Swift to create your own formatting. For example, we can write an implementation that accepts any Encodable and prints it in JSON:

mutating func appendInterpolation(debug value: T) {
    let encoder = JSONEncoder()
    encoder.outputFormatting = .prettyPrinted
    if let result = try? encoder.encode(value) {
        let str = String(decoding: result, as: UTF8.self)
        appendLiteral(str)
    }
}


If we make Person compliant with the Encodable protocol , then we can do this:

print("Here's some data: \(debug: faker)")

You can use features like a variable number of parameters, go even mark your implementation of interpolation as throwing . For example, our JSON formatting system does not respond in the event of an encoding error, but we can fix this to analyze the error in the future:

mutating func appendInterpolation(debug value: T) throws {
    let encoder = JSONEncoder()
    encoder.outputFormatting = .prettyPrinted
    let result = try encoder.encode(value)
    let str = String(decoding: result, as: UTF8.self)
    appendLiteral(str)
}
print(try "Status check: \(debug: hater)")

All that we have looked at so far are just modifications to the string interpolation methods.

Creating Custom Types Using Interpolation


As you saw, it was a question of how to format the data in your application in a really convenient way, but we can also create our own types using string interpolation.

To demonstrate this, we will create a new type that is initialized from string using string interpolation.

struct ColoredString: ExpressibleByStringInterpolation {
    // это вложенная структура - черновик для хранения атрибутов-строк из нескольких интерполяций 
    struct StringInterpolation: StringInterpolationProtocol {
        // здесь храним атрибут-строку по мере создания
        var output = NSMutableAttributedString()
        // несколько атрибутов по умолчанию 
        var baseAttributes: [NSAttributedString.Key: Any] = [.font: UIFont(name: "Georgia-Italic", size: 64) ?? .systemFont(ofSize: 64), .foregroundColor: UIColor.black]
        // этот инициалайзер необходим, также может быть использован в оптимизации производительности
        init(literalCapacity: Int, interpolationCount: Int) { }
        // вызывается, когда нам необходимо добавить текст
        mutating func appendLiteral(_ literal: String) {
            // выводится на печать, так что можно видеть процесс выполнения
            print("Appending \(literal)")
            // получаем базовое оформление
            let attributedString = NSAttributedString(string: literal, attributes: baseAttributes)
            // добавляем к черновой строке
            output.append(attributedString)
        }
        // вызывается, когда нам нужно добавить цветное сообщение к нашей строке
        mutating func appendInterpolation(message: String, color: UIColor) {
            // снова выводим на печать
            print("Appending \(message)")
            // берем копию базового атрибута и применяем цвет
            var coloredAttributes = baseAttributes
            coloredAttributes[.foregroundColor] = color
            // заворачиваем в новый атрибут-строку и добавляем в черновик
            let attributedString = NSAttributedString(string: message, attributes: coloredAttributes)
            output.append(attributedString)
        }
    }
    // окончательная строка с атрибутами, когда все интерполции выполнены
    let value: NSAttributedString
    // создаем экземпляр из строки литералов
    init(stringLiteral value: String) {
        self.value = NSAttributedString(string: value)
    }
    // создаем экземпляр из интреполяции
    init(stringInterpolation: StringInterpolation) {
        self.value = stringInterpolation.output
    }
}
let str: ColoredString = "\(message: "Red", color: .red), \(message: "White", color: .white), \(message: "Blue", color: .blue)"

In fact, there is one syntactic sugar under the hood. We could write the final part manually:

var interpolation = ColoredString.StringInterpolation(literalCapacity: 10, interpolationCount: 1)
interpolation.appendLiteral("Hello")
interpolation.appendInterpolation(message: "Hello", color: .red)
interpolation.appendLiteral("Hello")
let valentine = ColoredString(stringInterpolation: interpolation)

Conclusion


As you saw, custom string interpolation allows us to place formatting in one place, so that method calls become simpler and clearer. It also provides us with great flexibility to create the required types as naturally as possible.

Remember that this is only one of the possibilities - and not the only one. This means that sometimes we use interpolation, sometimes functions or something else. Like much in development, you always need to choose the best way to solve the problem.

Also popular now: