Swiftwater Logo

Swift Extensions -Part Three: Type-Constrained Array Extensions

This entry is part 10 of 21 in the series Swiftwater

ABSTRACT

Swift Arrays are a standard data type in Swift, but, like all data types, they are also a type of struct. As such, Arrays can be extended.

The issue with extending Arrays, 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