In previous posts, we started to explore the basics of building a Bluetooth app for iOS. Many of the basic data types used for transferring the data exist natively on iOS. Unfortunately, one that is needed for the workout app is missing: a 24-bit unsigned integer. This post will explore how to create a custom integer data type in Swift.
There’s not a lot of information available on building out numeric primitives, so perhaps this post will help fill that gap. We are in luck for a key part of the process – the major computer platforms we are working with all use little endian byte ordering. This is the same order that we expect to receive bytes in from Bluetooth devices. That means that a value such as 0x123456
would be serialized with the least significant byte first: 0x56
, 0x34
, 0x12
.
The Basic Implementation
A very basic implementation could be created that met our minimum requirements. We simply need a data type that can receive and properly handle the three bytes of data for deserialization. At its most basic level, the type justs need to ensure it stores three bytes of data. A naive implementation could be as simple as this:
1struct UInt24 {
2 let low: UInt16
3 let high: UInt8
4
5 init(_ low: UInt16, _ high: UInt8){
6 self.low = word
7 self.high = byte
8 }
9
10 var int: intValue {
11 return Int(low) + (Int(high)<<16)
12 }
13}
This basic implementation would decode three bytes of data as a word (2 bytes) and a single byte. The ordering is important – the variables are declared from least significant to most significant. This ensures they can be properly decoded from a byte stream. A future post will dive into decoding types, so for now we’ll leave it at that.
The intValue
property allows those values to be combined the two into a single 24-bit value. The value low
would contain the lower bytes, while high
would represent the most significant value. In our earlier example of 0x123456
, low
would receive 0x3456
and high
would receive 0x12
. The intValue
property would then combine them into 0x123456
by shifting high
16 bits to the left.
A Better Implementation
The basic implementation is a good start in terms of being functional, but it is not ideal. The implementation is not compatible with the other numeric data types in Swift. As a result, it limits how it can be used in the language. In fact, the implementation really only works for deserializing the value, then using intValue
to make it usable. Let’s change that.
This implementation will rely on existing functionality from UInt32. While we could implement all of the low-level features ourselves, relying on the existing functionality ensures compatibility and simplifies the explanations. For this implementation, we will assume that we can work exclusively with little endian platforms. This is a safe assumption for iOS, macOS, and watchOS.
First, we need the type to declare the type and the protocols that need to be implemented. A numeric data type involves a number of data types that form a hierarchy. I’ll break out the protocols as we go. The basic start:
1public struct UInt24: FixedWidthInteger,
2 UnsignedInteger,
3 CustomReflectable,
4 Codable {
5}
It may seem like a short list, but each of these extends multiple other protocols, so this has a surprising amount of supporting code. To make it easier, I’ll break out the implementation into sections.
The Values
First, we’ll start with some of the basic values that we’ll need for the rest of the type. This includes the bytes values themselves and some helper methods that make it easier to retrieve the values for other methods.
1 // The individual bytes that make up the UInt24. It's important
2 // for these to be in order, least significant to most significant
3 private let low: UInt8
4 private let med: UInt8
5 private let high: UInt8
6
7 /**
8 The number of bytes represented by the type
9 */
10 fileprivate static let byteWidth: Int = 3
11
12 /**
13 The maximum value for the type (16,777,215).
14 */
15 fileprivate static let maxUInt = UInt32(0xFFFFFF)
16
17 /**
18 Initializes the type with a zero value (default)
19 */
20 public init() {
21 low = 0
22 med = 0
23 high = 0
24 }
25
26 /**
27 Initializes the type using a UInt24
28 - Parameter value: The value to use for initialization
29 */
30 public init(_ value: UInt24) {
31 low = value.low
32 med = value.med
33 high = value.high
34 }
35
36 /**
37 Initializes the type from the individual bytes
38 - Parameters:
39 - low: The least-significant byte
40 - med: The middle byte
41 - high: The most-significant byte
42 */
43 fileprivate init(low: UInt8, med: UInt8, high: UInt8)
44 {
45 self.low = low
46 self.med = med
47 self.high = high
48 }
49
50 /**
51 Initializes the type from an array of bytes with the least significant bytes first
52 - Parameter bytes: The array of bytes to use for initialization
53 - Precondition: The array should have 3 bytes
54 */
55 public init(_ bytes: [UInt8]){
56 precondition(bytes.count == UInt24.byteWidth,
57 "Expected \(UInt24.byteWidth) elements but received \(bytes.count)")
58 self.init(low: bytes[0],
59 med: bytes[1],
60 high: bytes[2])
61 }
62
63 /// Gets the byte representation for the type
64 fileprivate var bytes : [UInt8] {
65 return [ low, med, high ]
66 }
67
68 /// Gets the value of the type as a UInt
69 public var uintValue: UInt {
70 let result = UInt(high) << 16 + UInt(med) << 8 + UInt(low)
71 return result;
72 }
73
74 /// Gets the value of the type as an Int
75 public var intValue: Int {
76 let result = Int(high) << 16 + Int(med) << 8 + Int(low)
77 return result;
78 }
ExpressibleByIntegerLiteral
This protocol is surprisingly simple, relying on an associated type that is used to also define the type of the integer literal. This implementation relies on another initializer that will be developed shortly. This protocol is extended by Numeric.
1 // Associated type that will be used for the integerLiteral initializer
2 // For this, we'll use an available native type
3 public typealias IntegerLiteralType = UInt
4
5 /**
6 Initializes the type from an integer literal
7 - Parameter value: The value to use for initialization
8 */
9 public init(integerLiteral value: UInt) {
10 self.init(value)
11 }
AdditiveArithmetic
Now we start with the actual base of a numeric type,
AdditiveArithmetic
. This protocol extends Equatable
. As a result, it provides support for basic addition, subtraction, and equality operations. The implementation of the protocol will primarily rely on our upcoming implementation of FixedWidthInteger
. This makes it easier to catch overflows that may occur. For equality, we’ll rely on reading the bytes in order and comparing them.
1 /// A constant value of zero
2 public static var zero : UInt24 = UInt24()
3
4 public static func - (lhs: UInt24, rhs: UInt24) -> UInt24 {
5 let result = lhs.subtractingReportingOverflow(rhs)
6 guard !result.overflow else {
7 fatalError("Overflow")
8 }
9 return result.partialValue
10 }
11
12 public static func + (lhs: UInt24, rhs: UInt24) -> UInt24 {
13 let result = lhs.addingReportingOverflow(rhs)
14 guard !result.overflow else {
15 fatalError("Overflow")
16 }
17 return result.partialValue
18 }
19
20 public static func -= (lhs: inout UInt24, rhs: UInt24) {
21 let result = lhs.subtractingReportingOverflow(rhs)
22 guard !result.overflow else {
23 fatalError("Overflow")
24 }
25 lhs = result.partialValue
26 }
27
28 public static func += (lhs: inout UInt24, rhs: UInt24) {
29 let result = lhs.addingReportingOverflow(rhs)
30 guard !result.overflow else {
31 fatalError("Overflow")
32 }
33 lhs = result.partialValue
34 }
35
36 public static func == (lhs: UInt24, rhs: UInt24) -> Bool {
37 return lhs.bytes == rhs.bytes
38 }
39
40 public static func != (lhs: UInt24, rhs: UInt24) -> Bool {
41 !(lhs == rhs)
42 }
Numeric
This protocol extends AdditiveArithmetic
and ExpressibleByIntegerLiteral
to add support for multiplication and magnitude (absolute value). Since this type won’t have a sign (always positive), the magnitude will always be the value itself. This protocol also adds an initializer for an exact value; it returns nil
if the type cannot represented as a UInt24. Similar to before, we’ll rely on the FixedWithInteger
implementation for multiplication and overflow handling.
1 // Associated type that will be used to return the magnitude
2 public typealias Magnitude = UInt24
3
4 /**
5 Creates a new instance from the given integer, if it can be represented exactly.
6 - Parameter source: A value to convert to UInt24
7 */
8 public init?<T>(exactly source: T) where T : BinaryInteger {
9 guard source >= 0 && source <= UInt24.maxUInt else {
10 return nil
11 }
12
13 self.init(source)
14 }
15
16 public var magnitude: UInt24 {
17 return self
18 }
19
20 public static func *= (lhs: inout UInt24, rhs: UInt24) {
21 let result = lhs.multipliedReportingOverflow(by: rhs)
22 guard !result.overflow else {
23 fatalError("Overflow")
24 }
25 lhs = result.partialValue
26 }
27
28 public static func * (lhs: UInt24, rhs: UInt24) -> UInt24 {
29 let result = lhs.multipliedReportingOverflow(by: rhs)
30 guard !result.overflow else {
31 fatalError("Overflow")
32 }
33 return result.partialValue
34 }
BinaryInteger
The
BinaryInteger
protocol is a basis for all integer types. It builds on Numeric, but adds Convertible and Hashable. This is the largest protocol to implement, containing support for division, remainder, and bit operations. Because these operations are intended to work across binary integer types, we can utilize UInt32
to support them, just being mindful that the limits of this type are lower.
The protocol also introduces a collection containing the words of the binary representation, from least significant to most significant. The type needs to conform to
RandomAccessCollection
. This type isn’t using the classical definition of a “word” (2 bytes). Instead, it assumes “machine words”: UInt32
on 32-bit platforms and UInt64
on 64-bit platforms. Since the UInt24
is smaller than either of these, it will only have one word. As a result, we can use the helper property that was created earlier, uintValue
to return the correct value.
1 /// Gets the words for the type
2 public var words: Words { UInt24.Words(self) }
3
4 public struct Words: RandomAccessCollection {
5 public typealias Element = UInt
6 public typealias Index = Int
7 public typealias SubSequence = Slice<Self>
8 public typealias Indices = Range<Int>
9
10 public var startIndex: Int { 0 }
11 public var endIndex: Int { 1 }
12 public var value: UInt24
13
14 /**
15 Initializes the collection with the value.
16 - Parameter value: The value to use
17 **/
18 public init(_ value: UInt24) {
19 self.value = value
20 }
21
22 public subscript(position: Int) -> UInt {
23 get {
24 precondition(position >= 0 && position < count)
25 return value.uintValue
26 }
27 }
28 }
At this point, we’ll rely on UInt32
to do the heavy lifting. The key to making that work is limiting the results to only values allowed by UInt24
. The operations could be handled directly on the bytes, but the overhead of using UInt32
is minimal. Implementing the operations by hand is left as an exercise for the reader (if you find value in working through that).
Some of the functionality is worth explaining a bit further. The truncatingIfNeeded
initializer ensures that only the lowest 24 bits of a BinaryInteger are considered when initializing a UInt24
. Everything else is ignored (truncated). For example, initializing with the 32-bit value 0x12345678
would result in a UInt24
with the value 0x345678
. Notice the first byte was truncated since it was beyond the 24-bit limit. It’s important to also know that negative numbers are bit-extended. As an example, -21 is represented as a UInt8
in binary as 11101011
(0xEB
). Larger types will add more leading 1’s. Extending this until we have 24 bits, it becomes 0xFFFFEB
(binary 11111111 11111111 11101011
).
The clamping functionality, by comparison, ensures that all values remain within the range of UInt24
. If the value is too large to be represented, the maximum value of UInt24
is used. Similarly, if the value is negative it is clamped to zero.
1 /**
2 Initializes the type from a BinaryInteger value, truncating if necessary
3 - Parameter value: The value to use for initialization
4 **/
5 public init<T>(_ value: T) where T : BinaryInteger {
6 precondition(value.signum() > -1, "Cannot assign negative value to unsigned type")
7 precondition(value <= UInt24.maxUInt, "Not enough bits to represent value")
8 self.init(truncatingIfNeeded: value)
9 }
10
11 /**
12 Initializes the type from a BinaryInteger value, truncating if necessary
13 - Parameter value: The value to use for initialization
14 **/
15 public init<T: BinaryInteger>(truncatingIfNeeded source: T) {
16 if let word = source.words.first {
17 // Can only handle a single word for UInt24, so we don't
18 // need to worry about the higher words. Remember these are
19 // returned with the lowest byte first.
20 let low = UInt8(word & 0xFF)
21 let med = UInt8((word >> 8) & 0xFF)
22 let high = UInt8((word >> 16) & 0xFF)
23 self.init(low: low, med: med, high: high)
24 }
25 else {
26 self = 0
27 }
28 }
29
30 /**
31 Initializes the type from a BinaryInteger value, with out of range values clamped to the nearest representable value
32 - Parameter value: The value to use for initialization
33 **/
34 public init<T>(clamping source: T) where T: BinaryInteger {
35 guard let value = Self(exactly: source) else {
36 self = source < .zero ? .zero : .max
37 return
38 }
39 self = value
40 }
41
42 public var trailingZeroBitCount: Int {
43 return self.uintValue.trailingZeroBitCount
44 }
45
46 public static func /= (lhs: inout UInt24, rhs: UInt24) {
47 lhs = lhs/rhs
48 }
49
50 public static func / (lhs: UInt24, rhs: UInt24) -> UInt24 {
51 let result = lhs.dividedReportingOverflow(by: rhs)
52 guard !result.overflow else {
53 fatalError("Overflow")
54 }
55 return result.partialValue
56 }
57
58 public static func % (lhs: UInt24, rhs: UInt24) -> UInt24 {
59 let result = lhs.remainderReportingOverflow(dividingBy: rhs)
60 guard !result.overflow else {
61 fatalError("Overflow")
62 }
63 return result.partialValue
64 }
65
66 public static func %= (lhs: inout UInt24, rhs: UInt24) {
67 lhs = lhs % rhs
68 }
69
70 public static func &= (lhs: inout UInt24, rhs: UInt24) {
71 lhs = lhs & rhs
72 }
73
74 public static func |= (lhs: inout UInt24, rhs: UInt24) {
75 lhs = lhs | rhs
76 }
77
78 public static func ^= (lhs: inout UInt24, rhs: UInt24) {
79 lhs = lhs ^ rhs
80 }
81
82 public static func == <Other>(lhs: UInt24, rhs: Other) -> Bool where Other : BinaryInteger {
83 return UInt32(lhs) == rhs
84 }
85
86 public static func != <Other>(lhs: UInt24, rhs: Other) -> Bool where Other : BinaryInteger {
87 !(lhs == rhs)
88 }
89
90 public static func < (lhs: UInt24, rhs: UInt24) -> Bool {
91 return UInt32(lhs) < UInt32(rhs)
92 }
93
94 public static func <= (lhs: UInt24, rhs: UInt24) -> Bool {
95 return UInt32(lhs) <= UInt32(rhs)
96 }
97
98 public static func >= (lhs: UInt24, rhs: UInt24) -> Bool {
99 return UInt32(lhs) >= UInt32(rhs)
100 }
101
102 public static func < <Other>(lhs: UInt24, rhs: Other) -> Bool where Other : BinaryInteger {
103 return UInt32(lhs) < UInt32(rhs)
104 }
105
106 public static func > (lhs: UInt24, rhs: UInt24) -> Bool {
107 return UInt32(lhs) > UInt32(rhs)
108 }
109
110 public static func > <Other>(lhs: UInt24, rhs: Other) -> Bool where Other : BinaryInteger {
111 return UInt32(lhs) > rhs
112 }
113
114 /**
115 Helper function to perform a byte-safe bitwise operation on two UInt24 values
116 - Parameter lhs: The left hand side of the operation
117 - Parameter rhs: The right hand side of the operation
118 - Parameter operation: A function to perform on each byte of the two values
119 - Returns: The result of the operation
120 **/
121 private static func bitOperations(lhs: UInt24, rhs: UInt24, using operation: (UInt8, UInt8) -> UInt8) -> UInt24 {
122 var lhsBytes = lhs.bytes
123 let rhsBytes = rhs.bytes
124 for i in 0..<lhsBytes.count {
125 lhsBytes[i] = operation(lhsBytes[i], rhsBytes[i])
126 }
127
128 return UInt24(lhsBytes)
129 }
130
131 public static func & (lhs: UInt24, rhs: UInt24) -> UInt24 {
132 return bitOperations(lhs: lhs, rhs: rhs, using: { (lb, rb) in lb & rb })
133 }
134
135 public static func | (lhs: UInt24, rhs: UInt24) -> UInt24 {
136 return bitOperations(lhs: lhs, rhs: rhs, using: { (lb, rb) in lb | rb })
137 }
138
139 public static func ^ (lhs: UInt24, rhs: UInt24) -> UInt24 {
140 return bitOperations(lhs: lhs, rhs: rhs, using: { (lb, rb) in lb ^ rb })
141 }
142
143 public static func >>(lhs: UInt24, rhs: UInt24) -> UInt24 {
144 guard rhs != 0 else { return lhs }
145 let shifted = UInt32(lhs) >> UInt32(rhs)
146 return UInt24(shifted & 0x00FFFFFF)
147 }
148
149 public static func >><Other>(lhs: UInt24, rhs: Other) -> UInt24 where Other : BinaryInteger {
150 guard rhs != 0 else { return lhs }
151 return lhs >> UInt24(rhs)
152 }
153
154 public static func << (lhs: UInt24, rhs: UInt24) -> UInt24 {
155 guard rhs != 0 else { return lhs }
156 let shifted = UInt32(lhs) << UInt32(rhs)
157 return UInt24(shifted & 0x00FFFFFF)
158 }
159
160 public static func <<<Other>(lhs: UInt24, rhs: Other) -> UInt24 where Other : BinaryInteger {
161 guard rhs != 0 else { return lhs }
162 return lhs << UInt24(rhs)
163 }
The last component of this protocol to implement is Hashable
. Thankfully, this one is simpler. It just needs the stored values to calculate the value:
1 public func hash(into hasher: inout Hasher) {
2 hasher.combine(bytes)
3 }
FixedWidthInteger
The
FixedWidthInteger
protocol adds the functionality for mathematics with overflow. This was the basis of many of our implementations above. Once again, we’ll take advantage of the existing implementation in UInt32
to save ourself from implementing the lower-level math. The functions will report an overflow in two cases: the operation returns an overflow indication or the results are outside the range of values UInt24
supports.
1 public init(_truncatingBits truncatingBits: UInt) {
2 self.init(truncatingIfNeeded: truncatingBits)
3 }
4
5 public static let min: UInt24 = 0
6 public static let max: UInt24 = UInt24(maxUInt)
7 public static let bitWidth: Int = 24
8
9 public func addingReportingOverflow(_ rhs: UInt24) -> (partialValue: UInt24, overflow: Bool) {
10 let result = UInt32(self).addingReportingOverflow(UInt32(rhs))
11 return UInt24.reportIfOverflow(result)
12 }
13
14 public func subtractingReportingOverflow(_ rhs: UInt24) -> (partialValue: UInt24, overflow: Bool) {
15 let result = UInt32(self).subtractingReportingOverflow(UInt32(rhs))
16 return UInt24.reportIfOverflow(result)
17 }
18
19 public func multipliedReportingOverflow(by rhs: UInt24) -> (partialValue: UInt24, overflow: Bool) {
20 let result = UInt32(self).multipliedReportingOverflow(by: UInt32(rhs))
21 return UInt24.reportIfOverflow(result)
22 }
23
24 public func dividedReportingOverflow(by rhs: UInt24) -> (partialValue: UInt24, overflow: Bool) {
25 let result = UInt32(self).dividedReportingOverflow(by: UInt32(rhs))
26 return UInt24.reportIfOverflow(result)
27 }
28
29 public func remainderReportingOverflow(dividingBy rhs: UInt24) -> (partialValue: UInt24, overflow: Bool) {
30 let result = UInt32(self).remainderReportingOverflow(dividingBy: UInt32(rhs))
31 return UInt24.reportIfOverflow(result)
32 }
33
34 public func dividingFullWidth(_ dividend: (high: UInt24, low: UInt24)) -> (quotient: UInt24, remainder: UInt24) {
35 let result = UInt32(self).dividingFullWidth((high: UInt32(dividend.high), low: UInt32(dividend.low)))
36 return (quotient: UInt24(result.quotient), remainder: UInt24(result.remainder))
37 }
38
39 private static func reportIfOverflow(_ result: (partialValue: UInt32, overflow: Bool)) -> (partialValue: UInt24, overflow: Bool) {
40 let overflow = result.overflow || result.partialValue > UInt24.max || result.partialValue < UInt24.min
41 let value = UInt24(truncatingIfNeeded: result.partialValue)
42 return (partialValue: value, overflow: overflow )
43 }
44
45 public var nonzeroBitCount: Int {
46 let result = high.nonzeroBitCount + med.nonzeroBitCount + low.nonzeroBitCount
47 return result
48 }
49
50 public var leadingZeroBitCount: Int {
51 let result = (high.leadingZeroBitCount < 8) ? high.leadingZeroBitCount
52 : (med.leadingZeroBitCount < 8) ? 8 + med.leadingZeroBitCount
53 : 16 + low.leadingZeroBitCount
54 return result
55 }
56
57 /// A representation of this integer with the byte order swapped.
58 public var byteSwapped: UInt24 {
59 return UInt24(low: self.high, med: self.med, high: self.low)
60 }
61
UnsignedInteger
This is the easiest protocol to implement. It’s actually covered by the work we’ve done above. As a result, there’s no additional code required. At this point, you have a fully functional numeric type that should be compatible with other Swift numerics. Not too painful, right?
There are still a few protocols we haven’t implemented. These help to improve the experience with using and debugging the data type.
Other protocols
First, we’ll add the function necessary to conform to CustomDebugStringConvertible
. This controls how the type is presented when a debug description is requested. The second function we’ll add conforms to CustomReflectable
and hides the three bytes we implemented to store the values. Both of these help with the debugging visualization experience.
1 // MARK: CustomDebugStringConvertible
2 /// Gets a diagnostic string containing the type's value
3 public var debugDescription: String {
4 return String(uintValue)
5 }
6
7 // MARK: CustomReflectable
8 /// Gets mirror for reflecting on the instance, hiding the implementation details
9 public var customMirror: Mirror {
10 return Mirror(self, children: EmptyCollection())
11 }
The last protocol we’ll add is Codable
. This provides the support for serialization, allowing the type to be encoded and decoded. This is a simple implementation that just encodes the value using the native Swift functionality. As I mentioned before, this will be covered in more depth in the future. For now, we’ll keep the code simple. It may seem odd to encode/decode as a UInt32
, but this avoids a problem with the way the encoders/decoders are implemented in Swift. Under the covers, they don’t know how to handle the custom type, even if it implements the various protocols. Instead, they expect to serialize and deserialize one of the built-in types. This is a bit of a hack, but it maximizes compatibility.
1 // MARK: Codable
2 /**
3 Initializes the type using the provided decoder
4 - Parameter decoder: the decoder to use for initializing the type
5 */
6 public init(from decoder: Decoder) throws {
7 let container = try decoder.singleValueContainer()
8 let value = try container.decode(UInt.self)
9 self = UInt24(value)
10 }
11
12 /**
13 Serializes the type using the provided encoder
14 - Parameter encoder: the encoder to use for serializing the type
15 */
16 public func encode(to encoder: Encoder) throws {
17 var container = encoder.singleValueContainer()
18 try container.encode(UInt(self))
19 }
Conclusion
This article has covered quite a lot (and provided a healthy amount of code). Hopefully it demystifies how custom numeric types work in Swift. At the end of the day, a simple type is really just conforming to some protocols. It’s a lot of typing, but it’s fairly simple code. Thankfully, the remaining code we’ll need is less complex. There’s more you could do to make this type more robust (such as adding in the Sendable
protocol and implementing localization), but this is a good starting point.
In future articles, we’ll put together the rest of the pieces needed to read Bluetooth values in a testable way. We’ll also cover how and why to create a Swift Package for this code, including having a GitHub repo that contains the project. See you there!