*** Updated for Swift 4.1 ***
In this tip, we will see how the Numeric protocol can be used as a constraint on type parameters of generic types to ensure that certain type parameters can only be used with numeric types, without having to specify the specific numeric type until we create an instance. We will also look at how protocol composition can be used to provide additional related functionality as required.
Contents
Generic types and type constraints
Generic types in Swift are classes, structs and enums that can work with any type. A generic type specifies one or more type parameters, which provide placeholder names for types to be specified when the generic type is instantiated. These placeholder names can be used within the definition of the generic type, and any of its extensions, wherever the name of a type would be expected, e.g., the type of a parameter or return value of a method, the type of a property, etc.
It is desirable in many situations to enforce certain constraints on the types that can be used with a generic type. A type constraint requires that a type parameter must inherit from a certain class or conform to a certain protocol. A type constraint may include more than one protocol using protocol composition, which we will cover a bit later on.
The Numeric protocol as a type constraint
By using the Numeric protocol as a constraint on type parameters, we can ensure that those type parameters can only be used with numeric types. However, we don’t need to specify the actual type to be used until we create an instance.
Let’s consider a scenario where we want to define a value type to represent the stock level of items in an inventory control application. We could have items whose quantities are measured in discrete units but we may also have items whose quantities may be in fractional amounts. We want to ensure that quantities are always numeric, but we want to be flexible about which numeric type to use for each item.
We start by defining a generic struct with a stored property for the available quantity of the stock item, which is set in the initializer. The stored property can be of any type that conforms to the Numeric protocol.
struct StockLevel<T: Numeric> {
init(_ available: T) {
self.available = available
}
var available: T
}
This allows us to create instances of StockLevel with quantities that are integers as well as fractional numbers.
var intStockLevel = StockLevel(5)
var doubleStockLevel = StockLevel(2.5)
If we try to create a StockLevel instance with a non-numeric argument, we get a compiler error.
let invalidStockLevel = StockLevel("level")
// Compiler error: Argument type 'String' does not conform to expected type 'Numeric'
It is noteworthy that instances created from the same generic type may actually be instances of different types, depending on which type(s) are used in place of the type parameter(s). The value assigned to the variable intStockLevel in the example above is of type StockLevel<Int>. Similarly, the value assigned to the variable doubleStockLevel is of type StockLevel<Double>.
We can use the type(of:) function to verify that the variables intStockLevel and doubleStockLevel have been inferred to be of the correct types.
print(type(of: intStockLevel)) // StockLevel<Int>
print(type(of: doubleStockLevel)) // StockLevel<Double>
If we try to assign the value of the variable doubleStockLevel to the variable intStockLevel, the compiler stops us dead in our tracks.
intStockLevel = doubleStockLevel
// Compiler error: Cannot assign value of type 'StockLevel<Double>' to type 'StockLevel<Int>'
This gets the Swift type system working for us to ensure that we don’t mix types and values in an inappropriate way, reducing the need for manual validation and preventing an entire class of errors.
Comparing for Equality
Attribute-based equality is one of the defining characteristics of values. Accordingly, all values types we define should conform to the Equatable protocol. When value types are composed from other value types, as most custom value types are, the Equatable conformance of the constituent types is used to implement Equatable conformance for the type being composed.
A Swift struct can automatically synthesize conformance to Equatable if all of its members are Equatable. This is done by declaring Equatable conformance without implementing any of its requirements. This conformance must be part of the original type definition and not in an extension. Since the Numeric protocol inherits from Equatable, all we need to do for our StockLevel type is declare Equatable conformance.
struct StockLevel<T: Numeric>: Equatable { ... }
Here is a quick check to make sure this works.
let level = StockLevel(3)
let equalToLevel = StockLevel(3)
let notEqualToLevel = StockLevel(5)
print(level == equalToLevel) // true
print(level == notEqualToLevel) // false
Performing basic arithmetic
One of the common uses of numeric types is to perform basic arithmetic. The Numeric protocol declares binary operators for addition, subtraction and multiplication (+, –, *) and also their mutating counterparts (+=, -=, *=).
We can use this to define the following method in our StockLevel type to receive new stock.
mutating func receive(_ quantity: T) {
available += quantity
}
Here is the new method in action.
var stockLevel = StockLevel(2)
stockLevel.receive(4)
print(stockLevel.available) // 6
It is noteworthy that the Numeric protocol does not specify a binary division operator. This appears to be a conscious decision by the designers of the standard library. It fits well with the notion of closure of operations.
A given set is said to be closed under an operation if that operation, when performed on any members of the set, will result in another member of the same set. This is an important property of many mathematical operations. Numeric protocols in the standard library in general specify arithmetic operators in terms of Self. Here is how the non-mutating binary addition, subtraction and multiplication operators are specified in the Numeric protocol.
static func +(Self, Self) -> Self
static func -(Self, Self) -> Self
static func *(Self, Self) -> Self
This means that the types conforming to Numeric are expected to be closed under these operations, i.e., both of the parameters as well as the return value should be of the same concrete type. This is true for addition, subtraction and multiplication but not for division, as dividing an integer by another integer may yield a non-integer value. If this value is returned as is, the operation will not be closed under the set of integers. Forcing an integer return value, on the other hand, may result in loss of information.
A high-level protocol like Numeric should not concern itself with these kinds of details. They are handled in protocols dealing with more specific sets of numbers.
If the division operation is required, depending on the context and the type of behaviour desired, one of the following protocols can be used:
- The BinaryInteger protocol, which is the basis for all the integer types provided by the standard library. It specifies integer division, which returns just the integer quotient and discards the remainder. The remainder, if required, is returned by the % operator.
- The FloatingPoint protocol, which is the basis of types used to represent floating-point numbers, or the BinaryFloatingPoint protocol, which extends the FloatingPoint protocol with operations specific to floating-point binary types, as defined by the IEEE 754 specification.
Using protocol composition
Another operation often used with numeric types is comparing for inequality. The Numeric protocol, however, does not support inequality operators. To make our generic type support inequality comparisons, we turn to protocol composition.
Protocol composition combines multiple protocols into a single requirement. Although a protocol composition does not define a new protocol type, it has the same effect as defining a temporary protocol which combines the requirements of all the protocols included in the composition. This encourages use of the Interface Segregation Principle, so protocols can be defined more narrowly but requirements from various protocols can be combined on the fly as and when required.
We modify the definition of our StockLevel type, replacing the Numeric protocol in the type constraint with a composition of the Numeric and Comparable protocols.
struct StockLevel<T: Numeric & Comparable>: Equatable { ... }
We can use the additional functionality provided by the Comparable protocol to add to our StockLevel type a method to requisition stock. Since a reasonable business rule would be that a request to requisition more stock than is available should fail, we define the following error type with the single case to reflect a situation where sufficient quantity is not available.
enum StockLevelError: Error {
case insufficientQuantityAvailable
}
We then add the following method to the StockLevel type to requisition stock. If the quantity requisitioned exceeds the available quantity, the error defined above is thrown.
mutating func requisition(_ quantity: T) throws {
guard quantity <= available else {
throw StockLevelError.insufficientQuantityAvailable
}
available -= quantity
}
Here is some code to test our handiwork.
func requisitionTester(stockLevel: inout StockLevel<Int>, quantity: Int) {
do {
try stockLevel.requisition(quantity)
print("\(quantity) unit(s) successfully issued. \(stockLevel.available) unit(s) available")
} catch {
print("Amount requisitioned exceeds available quantity")
}
}
var level = StockLevel(5)
requisitionTester(stockLevel: &level, quantity: 3) // 3 unit(s) successfully issued. 2 unit(s) available
requisitionTester(stockLevel: &level, quantity: 3) // Amount requisitioned exceeds available quantity
Adding functionality to sequences and collections
Another use of the Numeric protocol is to extend the functionality of the sequence and collection types included in the standard library with methods that only apply when the elements of the sequence or collection are numbers. Here is an example of a method defined through an extension on the Sequence protocol to calculate the sum of all elements of a given sequence provided the elements are numeric.
extension Sequence where Element: Numeric {
func sum() -> Element {
return reduce(0, +)
}
}
print([1, 2, 3, 4].sum()) // 10
print((1...4).sum()) // 10
As shown, this can be used to calculate the sum of a numeric array, range, etc.
Conclusion
The Numeric protocol is a very useful addition to the standard library as it allows programmers to use the power of generics while constraining certain types to be numeric and certain functionality to be available only for numeric types, without having to specify a particular numeric type until an instance is created. This leverages the type safety enforced by Swift, reduces the need for manual validation and makes code simpler, clearer and less prone to bugs.
Thank you for reading! I always appreciate constructive comments and feedback. Please feel free to leave a comment in the comments section of this post or start a discussion on Twitter.
rezwits says
Awesome, thanks! Way further than I needed, for now at least ;). But Swift seems to keep going and going…