Formatting string with C Format Specifiers leaks memory
I’m working on an app, which displays a stopwatch on one of the views. To display stopwatch in a consistent way, values of minutes, seconds and milliseconds need proper amount of leading zeros. For example, values like 1 minute, 2 seconds, 3 milliseconds
should be displayed as "01:02.003"
. There are two common ways of formatting numbers in Swift. However, one of them has led me to unexpected memory leak in my app.
Two ways of formatting numbers
First one is using one of the String initializers which accept format string with C Format Specifiers and values as CVarArg
. Below is a trivial example that adds leading zeros to the value.
String(format: "%03d", 6) // "006"
String(format: "%03d", 666) // "666"
String(format: "%03d", 6666) // "6666"
Second way of formatting numbers is using NumberFormatter from Foundation. First you need to create and configure NumberFormatter instance and then use it to create String.
let nf = NumberFormatter()
nf.numberStyle = .none
nf.minimumIntegerDigits = 3
nf.string(for: 6)! // "006"
nf.string(for: 666)! // "666"
nf.string(for: 6666)! // "6666"
As you can see in the example above, it is much more complicated. You need to deal with creating and configuring formatter and optionals.
Choosing the right tool for the job
Using C format specifiers is on one hand limited in functionality but also quick and easy. NumberFormatter is more powerful but needs much more boilerplate.
Because my use case is simple and possible with C format specifiers, I decided to use this solution. Additional advantage of using this solution is possibility to define multiple specifiers in format string and then pass multiple arguments. Returning to the stopwatch example it will look like that.
String(format: "%02d:%02d.%03d", 1, 2, 3) // "01:02.003"
It's nice and concise!
Surprising memory leak
When testing my app, I noticed that memory is leaking. It was surprising, because using SwiftUI, state management with reducer and functional programming patterns, gives not much room for creating reference cycle.
After profiling app with Instruments it became clear that displaying formatted time value was causing the leak. More precisely, it was String(format:_:)
initializer! Memory leak was included right in Swift type. Even more surprising is that I couldn't find any information about that online.
After more testing I have found out that this initializer leaks only when there are more than 2 arguments!
String(format: "%02d", 1) // doesn't leak
String(format: "%02d:%02d", 1, 2) // doesn't leak
String(format: "%02d:%02d.%03d", 1, 2, 3) // leaks memory
Solutions
There are two main solutions to prevent memory leak.
Using less then 3 arguments for C format specifiers
String(format: "%02d:%02d.%03d", 1, 2, 3) // "01:02.003" - leaks memory.
String(format: "%02d:", 1)
+ String(format: "%02d.", 2)
+ String(format: "%03d", 3) // "01:02.003" doesn't leak.
Using NumberFormatter()
let twoDigits: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .none
formatter.minimumIntegerDigits = 2
return formatter
}()
let threeDigits: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .none
formatter.minimumIntegerDigits = 3
return formatter
}()
twoDigits.string(for: 1)! + ":"
+ twoDigits.string(for: 2)! + "."
+ threeDigits.string(for: 3)! // "01:02.003"
Which way is better?
Using String(format:_:)
, even when separated for multiple statements, it's much less boilerplate than NumberFormatter
. However, there are more concerns about it.
First one is performance. Using NumberFormatter
is faster (when reusing formatters). For loop with 10 000 iterations, using NumberFormatter takes 0.03 s vs. 0.05 s for C format specifiers. As you can see difference is not much, but, depending on application, it can matter.
Formatters are expensive to create and configure. To achieve best performance they need to be reused as much as possible. Otherwise using C format specifiers will be much faster.
Second concern is safety. According to open bug report for Swift, it's not safe to use initializers which takes C format specifiers. Reason for this is that compiler doesn't check that there are the same number of variadic arguments as there are format specifiers in the string. It's silently using zero as a value for missing arguments.
Code samples
You can find sample iOS app project here. It's simple SwiftUI with a few views using different ways to format integers with leading zeros. Additionally there are small suite of performance tests for different formatting.
If you can see any mistakes in my code or article, please don't hesitate to contact me.
Knowledge sources
To research this topic I used sources listed below.
- Flight School Guide to Swift Numbers - great book about numbers in Swift. Must have in you Dev Library.
- How to specify fractional digits for formatted number string in Swift
- How expensive is DateFormatter
- Bug report on Swift bugs radar
- C printf function reference
- String Format Specifiers on Apple Documentation archive.