2

Swift 4.2

I have multiple functions that replace an object or struct in an array if it exists, and if it does not exist, it adds it.

func updateFruit(_ fruit: Fruit)
{
    if let idx = fruitArray.firstIndex(where: { $0.id == fruit.id })
    {
        fruitArray[idx] = fruit
    }
    else
    {
        fruitArray.append(fruit)
    }
}

Obviously I could make this into extension on Array:

extension Array
{
    mutating func replaceOrAppend(_ item: Element, whereFirstIndex predicate: (Element) -> Bool)
    {
        if let idx = self.firstIndex(where: predicate)
        {
            self[idx] = item
        }
        else
        {
            append(item)
        }
    }
}

However, is there a simpler, easier way of expressing this? Preferably using a closure or build-in function.

NOTE: current implementation does not allow using a set.

Dávid Pásztor
  • 51,403
  • 9
  • 85
  • 116
Freek Sanders
  • 1,187
  • 10
  • 26
  • 3
    Your code *is* simple and easy :) – Martin R Mar 20 '19 at 15:21
  • You can do this with a `Set`. Remove the matching item, if any. Then append the new item. – rmaddy Mar 20 '19 at 15:27
  • 1
    FYI - Since you are actually looking for more of a code review for working code, you may wish to look at the [Code Review](https://codereview.stackexchange.com) site. If you do end up posting your question there, remove this one. – rmaddy Mar 20 '19 at 15:28
  • 2
    You could make your life "easier" making your `Fruit` conform to `Equatable`. Doing so you don't need a predicate. If you would like to use a `Set` just make it conform to `Hashable` as well. – Leo Dabus Mar 20 '19 at 15:32
  • 1
    @LeoDabus I like that solution. – Freek Sanders Mar 20 '19 at 16:30
  • @FreekSanders related https://stackoverflow.com/questions/46519004/can-somebody-give-a-snippet-of-append-if-not-exists-method-in-swift-array/46519116#46519116 – Leo Dabus Mar 20 '19 at 16:33
  • 1
    When you say you have "multiple functions," what do the other functions look like? I suspect you're making this code generic along the wrong axis. Do they all have something like `$0.id == newthing.id`, or do they have other predicates? Are all the predicates `$0. == newthing.` (even if not just `id`)? Or are these predicates much more complex? Generic code must always start from how it is called or you'll go down the wrong road. – Rob Napier Mar 20 '19 at 20:05
  • 1
    @RobNapier the last example is the case, all the predicates are of the type `$0. == newthing.`. But they're all working on different arrays. – Freek Sanders Mar 25 '19 at 10:13

3 Answers3

4

Given your use case, in which you're always checking $0.<prop> == newthing.<prop>, you can lift this a little more by adding:

mutating func replaceOrAppend<Value>(_ item: Element, 
                                     firstMatchingKeyPath keyPath: KeyPath<Element, Value>)
    where Value: Equatable
{
    let itemValue = item[keyPath: keyPath]
    replaceOrAppend(item, whereFirstIndex: { $0[keyPath: keyPath] == itemValue })
}

You can then use it like:

struct Student {
    let id: Int
    let name: String
}

let alice0 = Student(id: 0, name: "alice")
let alice1 = Student(id: 1, name: "alice")
let bob = Student(id: 0, name: "bob")

var array = [alice0]

array.replaceOrAppend(alice1, firstMatchingKeyPath: \.name) // [alice1]
array.replaceOrAppend(bob, firstMatchingKeyPath: \.name)    // [alice1, bob]

And of course if you do this a lot, you can keep lifting and lifting.

protocol Identifiable {
    var id: Int { get }
}

extension Student: Identifiable {}

extension Array where Element: Identifiable {
    mutating func replaceOrAppendFirstMatchingID(_ item: Element)
    {
        replaceOrAppend(item, firstMatchingKeyPath: \.id)
    }
}

array.replaceOrAppendFirstMatchingID(alice0) // [alice1, alice0]
Rob Napier
  • 286,113
  • 34
  • 456
  • 610
1

I can suggest to create protocol Replacable with replaceValue that will represent identifier which we can use to enumerate thru objects.

protocol Replacable {
    var replaceValue: Int { get }
}

now we can create extension to Array, but now we can drop predicate from example code like this

extension Array where Element: Replacable {
    mutating func replaceOrAppend(_ item: Element) {
        if let idx = self.firstIndex(where: { $0.replaceValue == item.replaceValue }) {
            self[idx] = item
        }
        else {
            append(item)
        }
    }
}

Since Set is not ordered collection, we can simply remove object if set contains it and insert new value

extension Set where Element: Replacable {
    mutating func replaceOrAppend(_ item: Element) {
        if let existItem = self.first(where: { $0.replaceValue == item.replaceValue }) {
            self.remove(existItem)
        }
        self.insert(item)
    }
}
Taras Chernyshenko
  • 2,729
  • 14
  • 27
  • 1
    Although the solution for an array might work, I prefer the one where we conform to equatable, as it is simpler – Freek Sanders Mar 20 '19 at 16:31
  • You will need to override `==` method of `Equatable` by checking only if object `id`s is equal. What would be not correct for all situation. For example, you have two object with same `id`s but with different `name`s. Should they be equal? That's why I prefer too have special designed protocol – Taras Chernyshenko Mar 20 '19 at 18:42
  • I agree that using Equatable would be incorrect here, but this protocol is extremely single-purpose (particularly for such a generic name as "Replaceable"). The original code in the question is much better IMO. If an extension were desired, `where Element == Fruit` would be the appropriate restriction until you could find enough other use cases in the same program to extract a meaningful protocol. – Rob Napier Mar 20 '19 at 19:26
  • @TarasChernyshenko after reconsidering Equatable, I agree it's not the best solution. – Freek Sanders Mar 25 '19 at 17:45
1

Assuming your Types are Equatable, this is a generic extension:

extension RangeReplaceableCollection where Element: Equatable {

    mutating func addOrReplace(_ element: Element) {
        if let index = self.firstIndex(of: element) {
            self.replaceSubrange(index...index, with: [element])
        }
        else {
            self.append(element)
        }
    }
}

Though, keep in mind my (and your) function will only replace one of matching items.

Full Working playground test:

Playgrounds Test

GetSwifty
  • 7,568
  • 1
  • 29
  • 46
  • Your example implementation of `TestType.==` is not a valid conformance to Equatable. Equatable requires "To maintain substitutability, the == operator should take into account all visible aspects of an Equatable type." Your == only considers `first`. – Rob Napier Mar 20 '19 at 19:23
  • @RobNapier It's a test implementation to validate the functionality of `addOrReplace`...it's not meant to be "correct" – GetSwifty Mar 20 '19 at 19:38
  • But if it were more correct, it wouldn't be useful. If `element` is fully substitutable for the element found in the collection, why would you replace it? They're indistinguishable from each other. – Rob Napier Mar 20 '19 at 19:44
  • There could be various reasons. E.g. it's a new instance of a class with identical contents but a new pointer. – GetSwifty Mar 20 '19 at 19:54
  • `replaceSubrange(index.. – Leo Dabus Mar 20 '19 at 20:02
  • 1
    If that were significant, it would violate Equatable. "Equality implies substitutability—any two instances that compare equally can be used interchangeably in any code that depends on their values." The problem you're describing only comes up with mutable reference types. This is why mutable reference types are extremely difficult to make Equatable using anything but `===`. – Rob Napier Mar 20 '19 at 20:02
  • @LeoDabus I usually avoid inclusive ranges, but here it definitely makes sense! Added it in. – GetSwifty Mar 20 '19 at 20:46
  • Your edit doesn’t make sense. Should be `index...index` – Leo Dabus Mar 20 '19 at 20:55
  • 2
    @RobNapier If you want to be dogmatic about unenforced rules, go for it. Tool-shedding about encapsulation aside, I'll just say APIs like this are public so we can use them dynamically in a way that makes sense when esoteric ideals meet real world usage. – GetSwifty Mar 20 '19 at 20:59
  • @LeoDabus Undo is a cruel and fickle beast – GetSwifty Mar 20 '19 at 21:00
  • Incorrectly implementing `-isEqual` is a long-time source of real-world ObjC bugs. I've traced these bugs down myself, and they're quite difficult to figure out in production code. Like many things in Swift, Equatable has precise, carefully documented semantics due to those lessons learned from ObjC. There are lots of ways to implement these features well and conveniently without causing Equatable-related bugs. – Rob Napier Mar 20 '19 at 21:30
  • So...badly programed programs are bad? `==` is one of the easiest things to Unit test. But regardless, generalized critiques aren't really worth arguing about without specific examples, and esoteric critiques of _clearly_ non-production testing code are just silly. – GetSwifty Mar 20 '19 at 21:53
  • @PeejWeej though I used this solution at first, I tend to agree with Rob Napier. The [Apple docs](https://developer.apple.com/documentation/swift/equatable) state that *Equality implies substitutability—any two instances that compare equally can be used interchangeably in any code that depends on their values.* That is not the case for the structs I'm using, thus it would be incorrect to say they're equatable based on the one characteristic I'm using in this function. – Freek Sanders Mar 25 '19 at 17:42