ABSTRACT
Swift Array
s are a standard data type in Swift, but, like all data types, they are also a type of struct. As such, Array
s can be extended.
The issue with extending Array
s, though, is that they are generic, and can contain a whole bunch of different data types, impacting the practical application of Array
extensions.
In this post, we’ll explore a way to constrain Array
extensions to apply only when the contained type is a certain class or struct.
BASIC STRING EXAMPLE
For example. You can join(separator:)
Array<String>
, but only an Array of String, so this generic Array extension wouldn’t work:
extension Array { var description: String { return self.joined(separator: ",") } }
You will get an error, stating that the operation is ambiguous (There’s no guarantee that a String join will work on an arbitrary data type).
However, you can cast the type, to make it work, but it’s messy:
extension Array { var description1: String { if let selfie = self as? [String] { return selfie.joined(separator: ",") } return "UN-STRING" } }
That will work. Test it like so:
let testArray0 = [["One", "Two", "Three"],["Four", "Five", "Six"],["Seven","eight","Nine"]] let testArray1 = ["One", "Two", "Three"] let description = testArray0.description1 // Contains "UN-STRING" let description0 = testArray1.description1 // Contains "One,Two,Three"
ADVANCED STRING EXAMPLE
In a more complex example. You can’t iterate a single string (Array<String>
), but you can iterate over an Array of String (Array<[String]>
). Let’s say that we want to create a simple CSV (Comma-Separated Values) String from an Array of an Array of String, using pretty much the same mechanism:
extension Array { var description: String { if let selfie = self as? [String] { return selfie.joined(separator: ",") } else if let selfie = self as? [[String]] { return selfie.reduce(into: [String]()) { (accumulator: inout [String], element) in let line = element.description accumulator.append(line) }.joined(separator: "\n") } return "HUH?" } }
That will work. It’s convoluted, but will do the trick. Test it like so (Same two test Arrays):
let csv0 = testArray0.description // Contains "One,Two,Three\nFour,Five,Six\nSeven,eight,Nine" let csv1 = testArray0[0].description // Contains "One,Two,Three" let csv2 = testArray1.description // Contains "One,Two,Three"
SO, WHAT’S THE PROBLEM? IT WORKS, DOESN’T IT?
Yeah…but it’s messy. In particular, the casts:
extension Array { var description: String { if let selfie = self as? [String] { return selfie.joined(separator: ",") } else if let selfie = self as? [[String]] { return selfie.reduce(into: [String]()) { (accumulator: inout [String], element) in let line = element.description accumulator.append(line) }.joined(separator: "\n") } return "HUH?" } }
Also, the very fact that we need to do the if..else is messy. Wouldn’t it be great if there were a way to avoid that?
ENTER THE DRAGON
Queue chicken noises…
Swift allows you to add a where
clause to constrain extensions, similar to generic constraints, which were discussed earlier.
You apply extension constraints by looking at the Element
typealias member, like so:
extension Array where Element == String {
This tells the compiler that this extension only counts if Element
is of type String
.
Similarly, you can do the same for Array
of String
:
extension Array where Element == [String] {
The nice thing about this, is that the Element
typealias will now be the focused type, so we are free to do this:
extension Array where Element == String { var csvLine: String { return self.joined(separator: ",") } } extension Array where Element == [String] { var csv: String { return self.reduce(into: [String]()) { (accumulator: inout [String], element) in let element2 = element.csvLine accumulator.append(element2) }.joined(separator: "\n") } }
That’s a bit neater, eh?
Test it like so:
let csv3 = testArray0.csv // Contains "One,Two,Three\nFour,Five,Six\nSeven,eight,Nine" let csv4 = testArray0[0].csvLine // Contains "One,Two,Three" let csv5 = testArray1.csvLine // Contains "One,Two,Three"
A MORE ROBUST EXAMPLE
Here’s one way that I’ve leveraged this capability. I like to play with MapKit, and there’s a number of places where I sometimes need to drive into the bush in order to get done what needs doing. I’ve figured a way to help make these tasks a bit easier, by modifying Array
to give me a few extra capabilities:
extension Array where Element == CLLocationCoordinate2D { /* ################################################################## */ /** - returns: An MKMapRect, containing all the points in the Array. Nil, if the Array has less than 2 points, or does not contain coordinates. */ var rect: MKMapRect! { if 1 < self.count { let result = self.reduce(into: (maxLong: -180, maxLat: -180, minLong: 180, minLat: 180)) { (result: inout (maxLong: Double, maxLat: Double, minLong: Double, minLat: Double), inLocationCoords) in result.maxLong = Swift.max(inLocationCoords.longitude, result.maxLong) result.maxLat = Swift.max(inLocationCoords.latitude, result.maxLat) result.minLong = Swift.min(inLocationCoords.longitude, result.minLong) result.minLat = Swift.min(inLocationCoords.latitude, result.minLat) } let topLeft = MKMapPoint(CLLocationCoordinate2D(latitude: result.maxLat, longitude: result.minLong)) let bottomRight = MKMapPoint(CLLocationCoordinate2D(latitude: result.minLat, longitude: result.maxLong)) let size = MKMapSize(width: abs(bottomRight.x - topLeft.x), height: abs(bottomRight.y - topLeft.y)) return MKMapRect(origin: topLeft, size: size) } return nil } /* ################################################################## */ /** - returns: The center coordinate of the group of coordinates. */ var center: CLLocationCoordinate2D! { let result = self.reduce(into: (maxLong: -180, maxLat: -180, minLong: 180, minLat: 180)) { (result: inout (maxLong: Double, maxLat: Double, minLong: Double, minLat: Double), inLocationCoords) in result.maxLong = Swift.max(inLocationCoords.longitude, result.maxLong) result.maxLat = Swift.max(inLocationCoords.latitude, result.maxLat) result.minLong = Swift.min(inLocationCoords.longitude, result.minLong) result.minLat = Swift.min(inLocationCoords.latitude, result.minLat) } return CLLocationCoordinate2D(latitude: (result.maxLat + result.minLat) / 2, longitude: (result.maxLong + result.minLong) / 2) } /* ################################################################## */ /** - returns: A coordinate span, encompassing all of the coordinates in the Array. Nil, if the span cannot be calculated for any reason. */ var span: MKCoordinateSpan! { if 1 < self.count { let result = self.reduce(into: (maxLong: -180, maxLat: -180, minLong: 180, minLat: 180)) { (result: inout (maxLong: Double, maxLat: Double, minLong: Double, minLat: Double), inLocationCoords) in result.maxLong = Swift.max(inLocationCoords.longitude, result.maxLong) result.maxLat = Swift.max(inLocationCoords.latitude, result.maxLat) result.minLong = Swift.min(inLocationCoords.longitude, result.minLong) result.minLat = Swift.min(inLocationCoords.latitude, result.minLat) } return MKCoordinateSpan(latitudeDelta: result.maxLat - result.minLat, longitudeDelta: result.maxLong - result.minLong) } return nil } /* ################################################################## */ /** - returns: A region, encompassing all of the elements in the Array. Nil, if the region cannot be calculated for any reason. */ var region: MKCoordinateRegion! { if let center = self.center, let span = self.span { return MKCoordinateRegion(center: center, span: span) } return nil } }
This adds a few computed properties to the Array
struct that will interpret the contents for me. Here’s how I might use these new properties. We’ll create a dataset of a few locations in the Washington DC area:
let washingtonMonument = CLLocationCoordinate2D(latitude: 38.9072, longitude: -77.0369) let whiteHouse = CLLocationCoordinate2D(latitude: 38.8977, longitude: -77.0365) let capitolBuilding = CLLocationCoordinate2D(latitude: 38.8899, longitude: -77.0091) let lincolnMemorial = CLLocationCoordinate2D(latitude: 38.8893, longitude: -77.0502) let jeffersonMemorial = CLLocationCoordinate2D(latitude: 38.8814, longitude: -77.0365) let airAndSpaceMuseum = CLLocationCoordinate2D(latitude: 38.8882, longitude: -77.0199) let ronaldReaganAirport = CLLocationCoordinate2D(latitude: 38.8512, longitude: -77.0402) let pentagon = CLLocationCoordinate2D(latitude: 38.8719, longitude: 77.0563) let arlingtonCemetery = CLLocationCoordinate2D(latitude: 38.8783, longitude: -77.0687) let airAndSpaceMuseumAnnex = CLLocationCoordinate2D(latitude: 38.9109, longitude: -77.4442) let arrayOfCoordinates = [washingtonMonument, whiteHouse, capitolBuilding, lincolnMemorial, jeffersonMemorial, airAndSpaceMuseum, ronaldReaganAirport, pentagon, arlingtonCemetery, airAndSpaceMuseumAnnex ]
So now, we can extract that nice extra information from the Array
:
let rectThatWillEncloseAllLocations = arrayOfCoordinates.rect // Remember that this is in map points (not coordinates), so the numbers will be big: // left: 76471147.05123556 // top: 102675876.5094804 // width: 324434.0747377872 // height: 57184.79726368189 let greatLocationForAHotel = arrayOfCoordinates.center // This will contain (38.88105, -77.22665000000001), which is a mean of all the data points in the Array. let aSpanThatIsBigEnoughForEverything = arrayOfCoordinates.span // This contains (0.05969999999999942, 0.4350999999999914), which is in long/lat coordinates. let aRegionThatFitsEverything = arrayOfCoordinates.region // This is a region, made up of the center and span. It will enclose all the points.
In the sample playground, I’ll add some code to show a map.
Initial Display |
Zoomed Into Washington DC |
One Last Trick
It would be nice to be able to directly assign an Array<CLLocationCoordinate2D>
to a CLLocationCoordinate2D
property. Since we already have the center
computed property, why don’t we use that?
In order to do that, we simply extend the CLLocationCoordinate2D
type, like so:
extension CLLocationCoordinate2D { init(_ inLocationArray: [CLLocationCoordinate2D]) { self.init(latitude: inLocationArray.center.latitude, longitude: inLocationArray.center.longitude) } }
That way, we can simply do this:
let anotherGreatLocationForAHotel = CLLocationCoordinate2D(arrayOfCoordinates) // In this trick, we simply assign the center (38.88105, -77.22665000000001) to the property.
DON’T FORGET PROTOCOLS
You can also use protocols as the variable in the constraints, like so:
extension Array where Element: Equatable {
mutating func removeObject(object: Element) {
if let index = self.firstIndex(of: object) {
self.remove(at: index)
}
}
}
Test it like so:
// These tests apply to the above protocol-based extension. var testArray = [1,2,3,4,5,6,7,8,9,0] testArray.removeObject(object: 6) let newArray = testArray // Contains [1,2,3,4,5,7,8,9,0] var testArray2 = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"] testArray2.removeObject(object: "6") let newArray2 = testArray2 // Contains ["1", "2", "3", "4", "5", "7", "8", "9", "0"] var testArray3 = [["1", "2", "3"], ["4", "5", "6"], ["7", "8", "9"]] testArray3.removeObject(object: ["4", "5", "6"]) let newArray3 = testArray3 // Contains [["1", "2", "3"], ["7", "8", "9"]] var testArray4 = [CLLocationCoordinate2D(latitude: 36, longitude: -77), CLLocationCoordinate2D(latitude: 26, longitude: -80), CLLocationCoordinate2D(latitude: -26, longitude: 33)] // This will not work. CLLocationCoordinate2D is not Equatable //testArray4.removeObject(object: CLLocationCoordinate2D(latitude: 26, longitude: -80))
In the above example, we filtered the Array
to only allow Equatable
elements, so we could implement a simple “delete me” method.
The “mutating
” is necessary, because Array
is a struct
, and, as such, requires that methods that modify its state be marked as mutating.
CONCLUSION
This capability adds the ability to integrate some custom tools into generic types. It can go a long ways towards making your code simpler and more easily understood.
It’s not a critical capability, but it’s one of those things that makes Swift a fun language to mess with.
SAMPLE PLAYGROUND
import PlaygroundSupport import UIKit import MapKit // This can be messy. It will work, but...ick: extension Array { // ERROR: This will not work // var description: String { // return self.joined(separator: ",") // } // Simple CSV line. var description1: String { if let selfie = self as? [String] { // Filter for a certain type of element. return selfie.joined(separator: ",") // Now, since it's a String, we can join the party. } return "UN-STRING" // This is not a joiner. } // Creates a CSV string from multiple contained Arrays. var description: String { if let selfie = self as? [String] { // Simple CSV. return selfie.joined(separator: ",") } else if let selfie = self as? [[String]] { // An Array of Arrays of String can be CSVed. return selfie.reduce(into: [String]()) { (accumulator: inout [String], element) in // Pile each line on top of the other. let line = element.description // This asks each line to render as CSV. accumulator.append(line) }.joined(separator: "\n") // Join the lines by linefeeds. } return "HUH?" // Can't CSV. } } // These are test datasets for the CSV functionality. let testArray0 = [["One", "Two", "Three"],["Four", "Five", "Six"],["Seven","eight","Nine"]] let testArray1 = ["One", "Two", "Three"] let description = testArray0.description1 // Contains "UN-STRING" let description0 = testArray1.description1 // Contains "One,Two,Three" let description1 = testArray0.description // Contains "One,Two,Three\nFour,Five,Six\nSeven,eight,Nine" let description2 = testArray1.description // Contains "One,Two,Three" let csv0 = testArray0.description // Contains "One,Two,Three\nFour,Five,Six\nSeven,eight,Nine" let csv1 = testArray0[0].description // Contains "One,Two,Three" let csv2 = testArray1.description // Contains "One,Two,Three" // Now, with type-constrained extensions, we can get the same functionality with drastically less code. extension Array where Element == String { var csvLine: String { // Since this is type-constrained, we don't need to take a selfie. return self.joined(separator: ",") } } extension Array where Element == [String] { var csv: String { return self.reduce(into: [String]()) { (accumulator: inout [String], element) in let element2 = element.csvLine accumulator.append(element2) }.joined(separator: "\n") } } let csv3 = testArray0.csv // Contains "One,Two,Three\nFour,Five,Six\nSeven,eight,Nine" let csv4 = testArray0[0].csvLine // Contains "One,Two,Three" let csv5 = testArray1.csvLine // Contains "One,Two,Three" // This demonstrates the capability using a protocol. We extend Array for elements of the Equatable protocol. extension Array where Element: Equatable { mutating func removeObject(object: Element) { // This allows us to remove an object by value. if let index = self.firstIndex(of: object) { self.remove(at: index) } } } // These tests apply to the above protocol-based extension. var testArray = [1,2,3,4,5,6,7,8,9,0] testArray.removeObject(object: 6) let newArray = testArray // Contains [1,2,3,4,5,7,8,9,0] var testArray2 = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"] testArray2.removeObject(object: "6") let newArray2 = testArray2 // Contains ["1", "2", "3", "4", "5", "7", "8", "9", "0"] var testArray3 = [["1", "2", "3"], ["4", "5", "6"], ["7", "8", "9"]] testArray3.removeObject(object: ["4", "5", "6"]) let newArray3 = testArray3 // Contains [["1", "2", "3"], ["7", "8", "9"]] var testArray4 = [CLLocationCoordinate2D(latitude: 36, longitude: -77), CLLocationCoordinate2D(latitude: 26, longitude: -80), CLLocationCoordinate2D(latitude: -26, longitude: 33)] // This will not work. CLLocationCoordinate2D is not Equatable //testArray4.removeObject(object: CLLocationCoordinate2D(latitude: 26, longitude: -80)) extension Array where Element == CLLocationCoordinate2D { /* ################################################################## */ /** - returns: An MKMapRect, containing all the points in the Array. Nil, if the Array has less than 2 points, or does not contain coordinates. */ var rect: MKMapRect! { if 1 < self.count { let result = self.reduce(into: (maxLong: -180, maxLat: -180, minLong: 180, minLat: 180)) { (result: inout (maxLong: Double, maxLat: Double, minLong: Double, minLat: Double), inLocationCoords) in result.maxLong = Swift.max(inLocationCoords.longitude, result.maxLong) result.maxLat = Swift.max(inLocationCoords.latitude, result.maxLat) result.minLong = Swift.min(inLocationCoords.longitude, result.minLong) result.minLat = Swift.min(inLocationCoords.latitude, result.minLat) } let topLeft = MKMapPoint(CLLocationCoordinate2D(latitude: result.maxLat, longitude: result.minLong)) let bottomRight = MKMapPoint(CLLocationCoordinate2D(latitude: result.minLat, longitude: result.maxLong)) let size = MKMapSize(width: abs(bottomRight.x - topLeft.x), height: abs(bottomRight.y - topLeft.y)) return MKMapRect(origin: topLeft, size: size) } return nil } /* ################################################################## */ /** - returns: The center coordinate of the group of coordinates. */ var center: CLLocationCoordinate2D! { let result = self.reduce(into: (maxLong: -180, maxLat: -180, minLong: 180, minLat: 180)) { (result: inout (maxLong: Double, maxLat: Double, minLong: Double, minLat: Double), inLocationCoords) in result.maxLong = Swift.max(inLocationCoords.longitude, result.maxLong) result.maxLat = Swift.max(inLocationCoords.latitude, result.maxLat) result.minLong = Swift.min(inLocationCoords.longitude, result.minLong) result.minLat = Swift.min(inLocationCoords.latitude, result.minLat) } return CLLocationCoordinate2D(latitude: (result.maxLat + result.minLat) / 2, longitude: (result.maxLong + result.minLong) / 2) } /* ################################################################## */ /** - returns: A coordinate span, encompassing all of the coordinates in the Array. Nil, if the span cannot be calculated for any reason. */ var span: MKCoordinateSpan! { if 1 < self.count { let result = self.reduce(into: (maxLong: -180, maxLat: -180, minLong: 180, minLat: 180)) { (result: inout (maxLong: Double, maxLat: Double, minLong: Double, minLat: Double), inLocationCoords) in result.maxLong = Swift.max(inLocationCoords.longitude, result.maxLong) result.maxLat = Swift.max(inLocationCoords.latitude, result.maxLat) result.minLong = Swift.min(inLocationCoords.longitude, result.minLong) result.minLat = Swift.min(inLocationCoords.latitude, result.minLat) } return MKCoordinateSpan(latitudeDelta: result.maxLat - result.minLat, longitudeDelta: result.maxLong - result.minLong) } return nil } /* ################################################################## */ /** - returns: A region, encompassing all of the elements in the Array. Nil, if the region cannot be calculated for any reason. */ var region: MKCoordinateRegion! { if let center = self.center, let span = self.span { return MKCoordinateRegion(center: center, span: span) } return nil } } extension CLLocationCoordinate2D { init(_ inLocationArray: [CLLocationCoordinate2D]) { self.init(latitude: inLocationArray.center.latitude, longitude: inLocationArray.center.longitude) } } // Example dataset: let washingtonMonument = CLLocationCoordinate2D(latitude: 38.9072, longitude: -77.0369) let whiteHouse = CLLocationCoordinate2D(latitude: 38.8977, longitude: -77.0365) let capitolBuilding = CLLocationCoordinate2D(latitude: 38.8899, longitude: -77.0091) let lincolnMemorial = CLLocationCoordinate2D(latitude: 38.8893, longitude: -77.0502) let jeffersonMemorial = CLLocationCoordinate2D(latitude: 38.8814, longitude: -77.0365) let airAndSpaceMuseum = CLLocationCoordinate2D(latitude: 38.8882, longitude: -77.0199) let ronaldReaganAirport = CLLocationCoordinate2D(latitude: 38.8512, longitude: -77.0402) let pentagon = CLLocationCoordinate2D(latitude: 38.8719, longitude: -77.0563) let arlingtonCemetery = CLLocationCoordinate2D(latitude: 38.8783, longitude: -77.0687) let airAndSpaceMuseumAnnex = CLLocationCoordinate2D(latitude: 38.9109, longitude: -77.4442) let arrayOfCoordinates = [washingtonMonument, whiteHouse, capitolBuilding, lincolnMemorial, jeffersonMemorial, airAndSpaceMuseum, ronaldReaganAirport, pentagon, arlingtonCemetery, airAndSpaceMuseumAnnex ] let rectThatWillEncloseAllLocations = arrayOfCoordinates.rect // Remember that this is in map points (not coordinates), so the numbers will be big: // left: 76471147.05123556 // top: 102675876.5094804 // width: 324434.0747377872 // height: 57184.79726368189 let greatLocationForAHotel = arrayOfCoordinates.center // This will contain (38.88105, -77.22665000000001), which is a mean of all the data points in the Array. let aSpanThatIsBigEnoughForEverything = arrayOfCoordinates.span // This contains (0.05969999999999942, 0.4350999999999914), which is in long/lat coordinates. let aRegionThatFitsEverything = arrayOfCoordinates.region // This is a region, made up of the center and span. It will enclose all the points. let anotherGreatLocationForAHotel = CLLocationCoordinate2D(arrayOfCoordinates) // In this trick, we simply assign the center (38.88105, -77.22665000000001) to the property. // This should display the map, centered, with markers for each location. class MapViewController: UIViewController { var mapView: MKMapView! override func viewDidLoad() { super.viewDidLoad() self.mapView = MKMapView() self.view.addSubview(self.mapView) } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() self.mapView.frame = self.view.bounds self.mapView.mapType = .standard self.mapView.removeAnnotations(self.mapView.annotations) arrayOfCoordinates.forEach { [unowned self] coordinate in let annotation = MKPointAnnotation() annotation.coordinate = coordinate self.mapView.addAnnotation(annotation) } self.mapView.setRegion(self.mapView.regionThatFits(arrayOfCoordinates.region), animated: true) } } let controller = MapViewController() PlaygroundPage.current.liveView = controller PlaygroundPage.current.needsIndefiniteExecution = true