Ken Muse

Creating an Int24 for iOS


This is a post in the series Building a Workout App for watchOS in Swift. The posts in this series include:

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!