All About Swift’s LosslessStringConvertible
Losslessly Convert Types to and from Strings
One of Swift’s lesser known, but powerful, protocol
s is LosslessStringConvertible
. A type that conforms to it can be converted to a String
, and vice versa, in a lossless, unambiguous way. Its most practical applications lie in data serialisation and parsing; think of (de)serialising custom data formats and parsing command line arguments. It was proposed in SE-0089 and was implemented in Swift 3.
I’ll cover its implementation, walk you through all conforming types from the Standard Library, show you how you can create your own custom, conforming type and end with a conclusion.
I’ll be using the Swift REPL so you can follow along.
Implementation
In order for a type to conform, it needs to implement two members; an initialiser and a property.
The first requirement is init?(_ description:)
:
public protocol LosslessStringConvertible: CustomStringConvertible {
init? (_ description: String)
}
It knows exactly how to create a value of a conforming type from a given String
. If description
can’t be converted to a value, then nil
is return
ed.
Some conforming types from the Standard Library implement this requirement slightly differently; some might not be failable and the initialisation parameter might have another name. But what they all have in common though is a single labelless, initialisation parameter of type String
.
The second requirement is description
, by way of inheriting the CustomStringConvertible
protocol
:
public protocol CustomStringConvertible {
var description: String { get }
}
description
must create a textual value-preserving representation of the original value. A type that already conforms to CustomStringConvertible
might not necessarily be suitable; since you can’t override its description
property, you’ll have to work within the confines it gives you.
Conforming Types From The Standard Library
As of Swift 4.1, the protocol
s from the Standard Library that inherit LosslessStringConvertible
are FixedWidthInteger
and StringProtocol
. The types that conform to the former are: Int
, Int8
, Int16
, Int32
, Int64
, UInt
, UInt8
, UInt16
, UInt32
and UInt64
. The types that conform to the latter are String
and Substring
. Other conforming types from the Standard Library are: Bool
, Character
, Double
, Float
, Float80
and Unicode.Scalar
.
Bool
Bool
implements the first requirement as init?(_ description:)
. If description
is any value other than "true"
or "false"
, then the result is nil
. Also note that it’s case-sensitive:
$ swift
Welcome to Apple Swift version 4.1 (swiftlang-902.0.34 clang-902.0.30). Type :help for assistance.
1> Bool("true")
$R0: Bool? = true
2> Bool("True")
$R1: Bool? = nil
The second requirement, description
, turns true
into "true"
and false
into "false"
:
3> true.description
$R2: String = "true"
Character
Character
implements the first requirement as init(_ s:)
. This is also the designated initialiser as there is no Character
literal. s
must be a String
containing a single extended grapheme cluster:
4> Character("🦄")
$R3: Character = {
_representation = smallUTF16 {
smallUTF16 = 3716470846
}
}
5> Character("ab")
Fatal error: Can't form a Character from a String containing more than one extended grapheme cluster
The second requirement, description
, return
s a single-character String
:
6> Character("🦄").description
$R4: String = "🦄"
Double, Float And Float80
Things become more interesting with Double
, Float
and Float80
. These all implement the first requirement as init?(_ text:)
. text
only accepts (hexa)decimal floating-point representations without any underscores and special representations for infinity and NaN.
The following examples will use Double
, but Float
and Float80
are identical.
Let’s check out which decimal floating-point representations are valid:
7> Double("42")
$R5: Double? = 42
8> Double("42.0")
$R6: Double? = 42
9> Double("42e0")
$R7: Double? = 42
10> Double("042")
$R8: Double? = 42
11> Double("4_2")
$R9: Double? = nil
Hexadecimal floating-point representations behave identical:
12> Double("0x2a")
$R10: Double? = 42
13> Double("0x2a.0")
$R11: Double? = 42
14> Double("0x2ap0")
$R12: Double? = 42
15> Double("0x02a")
$R13: Double? = 42
16> Double("0x2_a")
$R14: Double? = nil
In both cases, text
supports leading zeros, but not underscores. Binary and octal representations are the only numeric representations that are not accepted:
17> Double("0b101010")
$R15: Double? = nil
18> Double("0o52")
$R16: Double? = nil
Let’s check out the special representations for infinity and NaN:
19> Double("-inf")
$R17: Double? = -Inf
20> Double("Inf")
$R18: Double? = +Inf
21> Double("+INFINITY")
$R19: Double? = +Inf
22> Double("-nan")
$R20: Double? = NaN
23> Double("NaN")
$R21: Double? = NaN
24> Double("+NAN")
$R22: Double? = NaN
text
accepts "inf"
and "infinity"
for infinity and "nan"
for NaN. Both are case-insensitive and can be prefixed with -
or +
. NaN may also be suffixed with a payload in parentheses; it should consist of a sequence of decimal digits or the characters 0X
or 0x
followed by a sequence of hexadecimal digits:
25> Double("nan(42)")
$R23: Double? = NaN
26> Double("nan(0x2a)")
$R24: Double? = NaN
The second requirement, description
, return
s a decimal floating-point representation as a String
, regardless of the input:
27> 1.0.description
$R25: String = "1.0"
28> Float(1.0).description
$R26: String = "1.0"
29> Float80(1.0).description
$R27: String = "1.0"
Int And UInt
Int
, Int8
, Int16
, Int32
, Int64
, UInt
, UInt8
, UInt16
, UInt32
and UInt64
all implement the first requirement as init?(_ description:)
. description
only accepts decimal representations. These may be prefixed with a +
or -
, followed by one or more of 0-9
. description
must be representable in the specified type, e.g., for Int
, "9223372036854775807"
is the maximum representable integer; when description
is out of bounds nil
is return
ed:
30> Int("+42")
$R28: Int? = 42
31> Int("9223372036854775808")
$R29: Int? = nil
Note that any other representation is invalid and results in nil
:
32> Int("0_042")
$R30: Int? = nil
33> Int("0b101010")
$R31: Int? = nil
34> Int("0o52")
$R32: Int? = nil
35> Int("0x2a")
$R33: Int? = nil
36> Int("0.42e2")
$R34: Int? = nil
37> Int("0x2ap0")
$R35: Int? = nil
The second requirement, description
, return
s a decimal representation:
38> 42.description
$R36: String = "42"
String
String
is the most straight forward out of all types; init(_ characters:)
and description
both return
the argument:
39> String("forty-two")
$R37: String = "forty-two"
40> "forty-two".description
$R38: String = "forty-two"
Substring
Substring
is similar to String
; the first requirement is implemented as init(_ content:)
, where content
can be any String
:
41> Substring("forty-two")
$R39: Substring = {
_slice = {
_startIndex = {
_compoundOffset = 0
_cache = utf16
}
_endIndex = {
_compoundOffset = 36
_cache = utf16
}
_base = "forty-two"
}
}
The second requirement, description
, creates a new String
, removing the reference to the original String
:
42> Substring("forty-two").description
$R40: String = "forty-two"
Unicode.Scalar
Unicode.Scalar
implements the first requirement as init?(_ description:)
, where description
should be a String
containing a single character representing exactly one Unicode scalar value. It return
s nil
if description
is invalid:
43> Unicode.Scalar("🦄")
$R41: Unicode.Scalar? = U'🦄'
44> Unicode.Scalar("\u{1F984}")
$R42: Unicode.Scalar? = U'🦄'
45> Unicode.Scalar("ab")
$R43: Unicode.Scalar? = nil
The second requirement, description
, creates a single-character String
:
46> Unicode.Scalar("🦄")!.description
$R44: String = "🦄"
Creating A Custom, Conforming Type
Let’s create a custom type. A two-dimensional Point
type should give a good overview of what’s going on:
47> struct Point {
48. let x: Double
49. let y: Double
50. }
The next step is to turn the type into a textual value-preserving representation. We can use any, or a combination, of the following methods. First, we can use positioning to preserve values, e.g., given 1.0
for x
and 2.0
for y
, the simplest representation would look something like "1.0 2.0"
. Second, we can make it more readable by adding punctuation, e.g., "1.0, 2.0"
. Third, we can group the values by adding parentheses or other bracket delimiters, e.g., "(1.0 2.0)"
. Fourth, we can label the values, e.g., "x 1.0 y 2.0"
. This also enables us to put the values in any order, e.g., "y 2.0 x 1.0"
. Fifth, we can add type information, e.g., "Point 1.0 2.0"
.
For our purposes we’ll combine all of these methods into a representation that is identical to the type’s initialiser: "Point(x: 1.0, y: 2.0)"
.
Now we’re ready to make the type conform to LosslessStringConvertible
.
The first requirement, init?(_ description:)
, uses a regular expression to extract the x
and y
properties from description
. Then, we’ll call Double.init?(_ text:)
to convert these properties. If all goes well, we’ll call the designated initialiser init(x:y:)
, otherwise, nil
is return
ed:
51> extension Point: LosslessStringConvertible {
52. public init? (_ description: String) {
53. var regularExpression: NSRegularExpression
54.
55. do {
56. regularExpression = try NSRegularExpression(pattern: "^Point\\(x:\\s*(.+),\\s*y:\\s*(.+)\\)$", options: [])
57. } catch {
58. preconditionFailure("\(error)")
59. }
60.
61. guard let match = regularExpression.matches(in: description, options: [], range: NSRange(location: 0, length: description.count)).first, match.numberOfRanges == 3, let x = Double((description as NSString).substring(with: match.range(at: 1))), let y = Double((description as NSString).substring(with: match.range(at: 2))) else {
62. return nil
63. }
64.
65. self.init(x: x, y: y)
66. }
67.
The second requirement, description
, return
s the representation by using String
interpolation and calling the properties’ description
properties:
68. public var description: String {
69. return "Point(x: \(self.x.description), y: \(self.y.description))"
70. }
71. }
We can now see that our custom, conforming type is working as intended by creating a textual value-preserving representation, converting it to a Point
, converting it back into a representation and testing the original and converted representations for equality:
72> "Point(x: 1.0, y: 2.0)"
$R45: String = "Point(x: 1.0, y: 2.0)"
73> Point($R45)
$R46: Point? = {
x = 1
y = 2
}
74> $R46!.description
$R47: String = "Point(x: 1.0, y: 2.0)"
75> $R45 == $R47
$R48: Bool = true
Conclusion
Make sure to take extra precaution when converting String
s to conforming numeric types from the Standard Library; not all numeric literals are accepted and underscores are not supported.
Also, when conforming your own types to LosslessStringConvertible
, extra care needs to be put into documenting init?(_ description:)
, especially when description
is far removed from the intuitive representation of the value.
Finally, as an aside, it’s worth mentioning that the Foundation framework is full of non-conforming types that would be ideal candidates for LosslessStringConvertible
, such as Date
, Data
and URL
. Someday.